# v0.10.0 — Zero-Allocation `_into` Paths + `mod-alloc` Profile

**Release date:** 2026-05-23
**Status:** pre-1.0 milestone — the **last** pre-1.0 phase before
the RC cut. 0.11.0 is docs + release-candidate; 1.0 ships
immediately after the soak. The public API is allowed to evolve in
breaking ways through the `0.x` series; 1.0 freezes it.

---

## What this release is

The wrapping-overhead gap vs upstream RustCrypto, closed.

0.8.0's PERFORMANCE.md documented that the only material overhead
in `crypt-io` was the per-call `Vec::with_capacity` allocation in
the encrypt paths. The user asked for that gap to be closed
before 1.0 rather than slipping to post-1.0 — this is that work.

A new `_into` API family lets the caller supply the output buffer.
After a one-time grow, every subsequent call reuses the capacity
and runs with **zero steady-state allocations**, verified by
running the `Crypt::encrypt` vs `Crypt::encrypt_into` loop under
[`mod-alloc`](https://github.com/jamesgober/mod-alloc) (the
portfolio's dhat-compatible heap profiler).

The result on the reference machine: AEAD encrypt +38-47% at
1 MiB, stream encrypt +54-55% — cleanly clearing the 1 GiB/s
contract target for both algorithms that was marginal in 0.9.

---

## Highlights

### The new API

```rust
impl Crypt {
    pub fn encrypt_into(&self, key, plaintext, out: &mut Vec<u8>) -> Result<()>;
    pub fn encrypt_with_aad_into(&self, key, plaintext, aad, out: &mut Vec<u8>) -> Result<()>;
    pub fn decrypt_into(&self, key, ciphertext, out: &mut Vec<u8>) -> Result<()>;
    pub fn decrypt_with_aad_into(&self, key, ciphertext, aad, out: &mut Vec<u8>) -> Result<()>;
}

impl StreamEncryptor {
    pub fn update_into(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<()>;
    pub fn finalize_into(self, out: &mut Vec<u8>) -> Result<()>;
}

impl StreamDecryptor {
    pub fn update_into(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<()>;
    pub fn finalize_into(self, out: &mut Vec<u8>) -> Result<()>;
}
```

Same semantics as the existing `encrypt` / `decrypt` / `update` /
`finalize` — just appends into `out` instead of allocating a fresh
`Vec`. **Fully additive — no breaking changes to 0.9.0.**

### Measured wins (AMD Ryzen 9 9950X3D, WSL2 Ubuntu)

| Operation @ 1 MiB | 0.9.0 allocating | 0.10.0 `_into` | Δ |
|---|---:|---:|---:|
| `Crypt::encrypt` ChaCha20-Poly1305 | 1.05 GiB/s | **1.45 GiB/s** | **+38%** |
| `Crypt::encrypt` AES-256-GCM       | 1.08 GiB/s | **1.59 GiB/s** | **+47%** |
| `StreamEncryptor` ChaCha20-Poly1305 | 932 MiB/s | **1.40 GiB/s** | **+54%** |
| `StreamEncryptor` AES-256-GCM       | 999 MiB/s | **1.54 GiB/s** | **+55%** |

### Allocation count (`mod-alloc` profile, 10 000 iterations per case)

| Path | Total allocations | Per-call |
|---|---:|---:|
| `Crypt::encrypt` (any algorithm, any size) | 20 000 | 2 |
| `Crypt::encrypt_into` (any algorithm, any size) | **0** | **0 steady-state** |

Reproduce locally:

```bash
cargo run --release --example profile_alloc
```

### Auth-failure scrub

`decrypt_into` and `decrypt_chunk_into` **clear the caller's
output buffer** before returning `Error::AuthenticationFailed`.
The upstream `decrypt_in_place_detached` writes decrypted bytes
into the buffer *first* and then verifies the tag; on tag mismatch
the buffer holds unverified plaintext. The wrapper clears it
explicitly on the error path so callers can't accidentally read
partially-decrypted bytes. Verified by the
`decrypt_into_scrubs_on_auth_failure` test.

---

## Why this matters

Hot-path symmetric crypto is often called millions of times per
second — encrypting database rows, decrypting cache entries,
signing request batches. At those rates, allocating a fresh
`Vec<u8>` per call has three costs:

1. **CPU** — `malloc` + `extend_from_slice` + nonce-prepend run
   ahead of the actual AEAD work.
2. **Memory pressure** — every encrypt call hits the global
   allocator, which on a busy server is a contended lock.
3. **GC pressure (in mixed runtimes)** — the freed `Vec`s
   churn the allocator's free lists.

The `_into` paths let callers keep one `Vec<u8>` per task / per
connection / per worker and reuse it for every encrypt. The
allocator never sees the encrypt path again after warm-up.

Code change to take advantage:

```rust
// Before (still works):
let ciphertext = crypt.encrypt(&key, plaintext)?;

// After (zero-alloc steady state):
let mut ciphertext = Vec::new();  // construct once
loop {
    crypt.encrypt_into(&key, plaintext, &mut ciphertext)?;
    send(&ciphertext);
    // ciphertext.clear() not needed — `_into` does it
}
```

---

## What's NOT in 0.10.0

- **End-to-end zero-allocation streaming** would also need
  caller-supplied scratch buffers (currently one allocation per
  `update_into` call for the internal chunk-encrypt scratch).
  Doable post-1.0 as a `*_into_with_scratch` variant if anyone
  needs it; the gain on top of "caller-supplied output" is
  small (1 alloc per update vs many) and the API gets uglier.
- **A `to_vec` convenience** that goes the other way (allocating
  wrappers around the `_into` methods) — not needed, the
  existing `encrypt` / `decrypt` already serve that case.
- **dhat-format JSON export from `mod-alloc`** — exists in mod-
  alloc behind its `dhat-compat` feature; we currently print
  human-readable counts. JSON export can land alongside any
  future profile-driven optimization.

---

## Compatibility & build

- **Fully backwards compatible.** All 0.9.0 public API carries
  forward unchanged. The `_into` methods are purely additive.
- **No new runtime dependencies.** `mod-alloc` is added as a
  `dev-dependency` (only pulled in for `cargo run --example
  profile_alloc`).
- **MSRV unchanged:** Rust 1.85 (edition 2024).
- **Default features unchanged.**

---

## Installation

```toml
[dependencies]
crypt-io = "0.10"
```

---

## Verification

| Check | Result |
|---|---|
| `cargo fmt --all -- --check` (Windows + WSL Linux) | clean |
| `cargo clippy --all-targets --all-features -- -D warnings` (both) | clean |
| `cargo test --all-features` (both) | 126 unit + **12 new `_into`** + 1 smoke + 25 stream + 33 doctest — **all passing** |
| `cargo doc --no-deps --all-features` with `RUSTDOCFLAGS="-D warnings"` (both) | clean |
| `cargo run --release --example profile_alloc` | **0 steady-state allocations / 10 000 iterations** for `encrypt_into` |
| `cargo bench --bench aead -- encrypt_into` | matches PERFORMANCE.md numbers |
| `cargo bench --bench stream -- stream_encrypt_into` | matches PERFORMANCE.md numbers |
| MSRV (1.85) build | clean |
| `cargo-fuzz` smoke (carried forward from 0.9.0) | 8 targets clean |

---

## What's next

Phase 0.11.0: Final documentation pass + 1.0.0-rc.1 cut.
`docs/STABILITY-1.0.md`, `docs/ARCHITECTURE.md`,
`docs/SECURITY.md`, `docs/PLATFORM-NOTES.md`,
`docs/FILE_FORMAT.md`, every public item rustdoc'd with an
example, `docs/release/v1.0.0.md`, then the
1-CPU-hour-per-target `cargo-fuzz` runs the 0.9.0 release notes
flagged as the RC gate. One-week soak, address any rc.N
findings, then 1.0.0 ships.
