Target 4 from the fuzzing plan (the alkcall-bug-class hunt): a #[derive(Arbitrary)] op sequence drives the producer session pump (drive_session_pre_negotiated) over a duplex pair against a fuzz-local mock backend. - fuzz/shared/src/session_opseq.rs: op vocabulary (client writes incl. invalid-type/oversize-length barrier probes, backend production/EOF, exit resolve/fail/drop, control messages, bounded reads, yields), kill-guard exit future (ADR-005 observability), delivery model, and the invariants: kill-on-Drop (kill_fired == !exit_resolved), exit chunk is last with first-issued code -1 on wait-failure (ADR-004), lossless per-stream FIFO + single stdout sentinel, stdin prefix-losslessness (post-shutdown chunks never delivered), control dispatch bounds, take-once allocation, teardown termination. - Harness-model correction caught by a 60s smoke run before the campaign (the alkcall §7.7 pattern, third time): a failed client write can mean the session completed and dropped the server duplex half while the write was in flight - the harness now drains and requires the exit chunk on any write/shutdown error (premature close without it is a finding). No adapter code changed. - 12 committed seeds via the deterministic generator, hand-encoded against the arbitrary 1.4.x derive layout; the encoding is pinned by a seed-decode test. Existing chunk_frame/negotiation_frame/ control_json seed corpora are byte-identical to before. - fuzz/.gitignore: the per-target seed negations never matched (corpus/* excludes the parent dir; git cannot re-include beneath an excluded dir). Fixed with the !corpus/*/ + corpus/*/* + !corpus/*/seed-* recipe - new seed files were silently ignored until now. - Campaign: 10 min detached via run-detached.sh, exited 0, artifact dir empty - 42.3k execs across 33 fork jobs, 0 crash/oom/timeout, cov 3934 -> 4075 edges. Grown corpus excised per the corpus policy. Verification: corpus replay green on stable (9 tests), cargo test 113 + --all-features 156, clippy stable/wasm/fuzz-shared, fmt, doc, publish dry-run, wasm check - all pass.
350 lines
21 KiB
Markdown
350 lines
21 KiB
Markdown
# Fuzzing alktty: Plan
|
||
|
||
**Status:** adopted — implementation in progress (see §8 progress log)
|
||
**Date:** 2026-09-28
|
||
**Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the
|
||
alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached
|
||
runner, and no-CI policy below are adopted from it wholesale rather than
|
||
re-derived).
|
||
|
||
---
|
||
|
||
## 1. Decision: fuzz this crate
|
||
|
||
**Yes.** Alktty is the *named follow-up* in the alkcall research: §5
|
||
"Non-surfaces" explicitly defers the downstream parsers to "downstream
|
||
crates' fuzz targets" — the channels layer carries opaque `Bytes` and the
|
||
handler owns sub-stream framing (ADR-035). Alktty is that handler, and it
|
||
owns four parse surfaces fed by arbitrary peers on both transports (direct
|
||
ALPN and channels):
|
||
|
||
| # | Surface | Location | Shape |
|
||
|---|---|---|---|
|
||
| 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` |
|
||
| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199` — the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) |
|
||
| 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct |
|
||
| 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure |
|
||
|
||
Reasons this pays off here, beyond the generic alkcall argument:
|
||
|
||
1. **Semantic invariants no example test explores adversarially.** The
|
||
peek/disambiguation machinery (`peek_stream_type` vs
|
||
`read_chunk_after_peek` — both call orders must yield exactly one
|
||
chunk) and the cross-codec invariant that makes it sound: an
|
||
error-response frame's length-prefix high byte must be `0x00` so a
|
||
`ChunkReader` peek distinguishes it from a chunk whose first byte is a
|
||
server stream type ∈ {1, 2, 4} (`wire.rs:189-213`, ADR-052 §5). If
|
||
`MAX_CHUNK_LEN` ever changes or a stream type is added, that invariant
|
||
breaks subtly. A fuzz target spanning both codecs is the cheapest
|
||
enforcement that exists.
|
||
2. **The alkcall bug class has a plausible analog here.** The duplicate
|
||
adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no
|
||
unit test replays destroyed the live channel. Alktty's adapter has the
|
||
same shape — take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
|
||
session re-registration, handle replacement. A stateful op-sequence
|
||
target against the session pump + mock backend is where a real finding
|
||
would live.
|
||
3. **Cost is low and the playbook is proven.** Mirroring alkcall's
|
||
as-built layout is a port, not a design exercise (§5 below).
|
||
|
||
Zero parser-bug yield is a successful negative result — it converts "we
|
||
think the codecs are robust" into a demonstrated property (the quinn
|
||
lesson cuts both ways).
|
||
|
||
**Scope excludes `src/local/`:** PTY/pipe output is local-OS-shaped, not
|
||
wire-attacker-shaped. The wire-facing side is entirely the
|
||
shared/producer/consumer modules (`wire`, `negotiation`, `control`,
|
||
`backend`, `adapter`, `channels`, `session`).
|
||
|
||
**The wasm invariant is unaffected:** `fuzz/` is host-only (nightly
|
||
toolchain, `path` dep on the crate), excluded from publishing, and never
|
||
compiled for `wasm32-unknown-unknown`.
|
||
|
||
## 2. Toolchain decision (adopted from alkcall §4/§7.7)
|
||
|
||
- `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own
|
||
`[workspace]` table (nightly-only bins).
|
||
- **The invariant logic lives in `fuzz/shared/` — a stable-toolchain
|
||
library crate** — not inline in the target binaries. The `fuzz_target!`
|
||
binaries are thin wrappers. This makes corpus replay a plain
|
||
`cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it
|
||
replays every committed seed through the identical invariant functions
|
||
the fuzzer runs, and joins the release verification checklist.
|
||
- `fuzz/rust-toolchain.toml` pins `nightly` for the subtree only; the
|
||
crate stays stable at MSRV 1.88.
|
||
- Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern);
|
||
deterministic seed generator script committed next to them.
|
||
- **No hosted CI** — this repo has the same no-CI policy as alkcall
|
||
(§7.9 there): the gitea host serves git only; verification and
|
||
campaigns are manual. Corpus replay is the standing fuzz gate;
|
||
campaigns run via the detached runner before releases and after
|
||
touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the
|
||
adapter, or the session pump. Do not add workflow files or "when CI
|
||
exists" language.
|
||
- AFL/bolero/libafl/OSS-Fuzz: not adopted, same reasoning as alkcall §4.
|
||
|
||
## 3. Operational isolation: campaigns must be detached from the agent session
|
||
|
||
**This is mandatory, not a nice-to-have.** Agent sessions (opencode) run
|
||
commands through a bash tool that spawns processes as children of the
|
||
session's own process tree and cgroup. A fuzz campaign is exactly the
|
||
workload shape that can kill its own host:
|
||
|
||
- **OOM-killer shared fate.** The target's worst case *is* unbounded
|
||
allocation. libFuzzer's RSS guard is a *soft* poll — a fast single huge
|
||
allocation can beat it, and then the kernel OOM killer fires. It picks
|
||
the largest-RSS process in the cgroup, which can be the opencode server
|
||
hosting the session. Losing that server is recoverable (sessions are
|
||
recorded in a db) but hugely derailing and entirely avoidable. An OOM
|
||
in a fuzz target must cost the fuzzer, never the session.
|
||
- **Fork bombs / CPU saturation:** `-fork=N` workers and unbounded task
|
||
spawning in a buggy target can starve the session host.
|
||
- **Deadlock hangs:** a wedged target must not hang the session's bash
|
||
call.
|
||
|
||
Three layers, all standard:
|
||
|
||
1. **libFuzzer soft limits (always on):** `-rss_limit_mb=2048`
|
||
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class
|
||
artifact) before the kernel gets involved. Do **not** use `ulimit -v`
|
||
with ASAN (the sanitizer reserves terabytes of virtual address space;
|
||
the classic failure is instant `MmapAlloc` death) — the ASAN-safe
|
||
equivalent is `malloc_limit_mb`.
|
||
2. **Fork mode as blast-radius containment (default):** `-fork=1` runs
|
||
each input in a short-lived child; a crash/timeout/OOM/leak kills only
|
||
that child, the parent records the artifact and keeps going. Also
|
||
turns deadlocks into per-input timeouts instead of hung sessions.
|
||
3. **Process detachment (mandatory for agent-run campaigns):** never run
|
||
fuzzing as a foreground child of the session. `fuzz/run-detached.sh`
|
||
wraps `cargo fuzz run` in `setsid` + `nohup` + redirection to a log
|
||
file: removed from the session's controlling terminal and signal
|
||
relation, survives the session ending, and gives a pollable log
|
||
instead of a blocking call.
|
||
|
||
Polling protocol (never `wait` on the detached process):
|
||
|
||
```bash
|
||
tail -n 50 fuzz/artifacts/<target>-*.log # campaign progress
|
||
ls fuzz/artifacts/ # crash-* / oom-* / timeout-* artifacts
|
||
pgrep -f "cargo fuzz run <target>" # still running?
|
||
```
|
||
|
||
One session holds one detached campaign per target; log filenames carry
|
||
UTC timestamps. `FUZZ_RUNTIME_SECS` overrides the per-campaign budget
|
||
(default 600 s).
|
||
|
||
## 4. Targets
|
||
|
||
In priority order. Targets 1–3 are the initial scope; target 4 is second
|
||
wave, decided after campaigns on 1–3 come back clean (same sequencing as
|
||
alkcall §7.4).
|
||
|
||
| # | Target | Input style | Drives | Invariants |
|
||
|---|---|---|---|---|
|
||
| 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers |
|
||
| 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) |
|
||
| 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) |
|
||
| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
|
||
|
||
### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
|
||
|
||
The consumer disambiguates an error-response frame from a raw chunk by
|
||
peeking one byte (`wire.rs:189-213`): server stream types are {1, 2, 4};
|
||
a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is
|
||
neither. Encoded as fuzz assertions:
|
||
|
||
- For every `error_response_bytes` output and every negotiation frame of
|
||
length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader`
|
||
peek on `[frame bytes…]` never mistakes it for a chunk.
|
||
- **Known boundary collision, encoded deliberately:** a frame of length
|
||
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` —
|
||
first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths
|
||
admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an
|
||
inherent edge of the one-byte-peek design; producers only ever send
|
||
tiny error frames, so it is accepted — but the assertion pins the
|
||
boundary so any future `MAX_CHUNK_LEN` change that widens the
|
||
collision gets caught. (`validate_header`'s doc comment at
|
||
`wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the
|
||
claim holds only for `<` the cap. Worth a one-word fix in the doc
|
||
comment if we touch `wire.rs` for any other reason.)
|
||
|
||
Also encoded (mirroring alkcall §7.7's trailing-byte probe): a valid
|
||
negotiation frame with one extra JSON byte appended *and counted in the
|
||
length prefix* must fail with a `Json` error, so a future switch to a
|
||
tolerant streaming reader is caught.
|
||
|
||
## 5. Layout (mirrors alkcall §7.7 as-built)
|
||
|
||
```
|
||
fuzz/
|
||
├── Cargo.toml alktty-fuzz (nightly-only bins; own [workspace])
|
||
├── rust-toolchain.toml pins nightly for this subtree only
|
||
├── fuzz_targets/
|
||
│ ├── chunk_frame.rs thin fuzz_target! wrapper
|
||
│ ├── negotiation_frame.rs thin wrapper
|
||
│ ├── control_json.rs thin wrapper
|
||
│ └── session_opseq.rs thin wrapper (typed SessionSequence input)
|
||
├── shared/ alktty-fuzz-shared — STABLE-toolchain library
|
||
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs
|
||
├── corpus/<target>/ committed seeds
|
||
├── gen_fuzz_seeds.py deterministic seed generator
|
||
├── run-detached.sh §3 detached runner (CWD-independent)
|
||
└── README.md
|
||
```
|
||
|
||
Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
|
||
|
||
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without
|
||
it cargo auto-discovers `fuzz/shared/` into the main workspace and the
|
||
MSRV toolchain tries to build nightly-consumed dev-deps.
|
||
- `"fuzz/"` added to the publish `exclude` list.
|
||
|
||
No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`,
|
||
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
|
||
`ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`,
|
||
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub`
|
||
(alkcall needed none of its five either). The one private constant,
|
||
`MAX_CHUNK_LEN`, is wire-stable (ADR-001); the shared crate carries its
|
||
own copy, and the shape invariants assert the rejection boundary so a
|
||
drift is caught. The `session_opseq` harness is fuzz-local for the same
|
||
reason `MockBackend`/`TestBackend` are `#[cfg(test)]`-only: a test
|
||
backend in `src/` would either leak into the public API or force the
|
||
whole mock apparatus behind `#[cfg(any(test, fuzzing))]`; the shared
|
||
crate is the fuzz-side home for it (mirrors alkcall, whose `manager_routing`
|
||
harness also lives in `fuzz/shared/`).
|
||
|
||
## 6. Corpus policy
|
||
|
||
Commit hand-made seeds; gitignore grown corpora and artifacts
|
||
(`fuzz/.gitignore` per alkcall's). Seeds per target: valid frames/headers
|
||
for every stream type / every control variant, truncation at every prefix
|
||
length, `length = 0`, `length = MAX`, `length = MAX + 1`, `length =
|
||
u32::MAX`, invalid UTF-8 in JSON, deep-ish nesting, peek-interleave
|
||
fixtures, cross-codec error-frame fixtures. The generator script is
|
||
deterministic (no randomness) so seeds are reproducible.
|
||
|
||
## 7. Sequencing
|
||
|
||
1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on
|
||
alktty's own path — `TtySession::open_via_channels` and the channels
|
||
establisher run on the fixed `ChannelManager`). Full verification set
|
||
including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0"
|
||
line while touching it. **Status: see §8.**
|
||
2. Scaffold `fuzz/` + root workspace table + publish exclude.
|
||
3. Targets 1–3 invariant logic in `fuzz/shared/` + thin target bins +
|
||
seeds + detached runner + README.
|
||
4. Corpus replay green on stable (`cargo test --manifest-path
|
||
fuzz/shared/Cargo.toml`); add it to the AGENTS.md verification
|
||
checklist.
|
||
5. Detached campaigns (10 min per target via `run-detached.sh`), triage
|
||
anything found, record results in §8.
|
||
6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
|
||
**Status: landed — see §8. All four targets are in scope and live.**
|
||
|
||
## 8. Progress log (append-only across sessions)
|
||
|
||
- **2026-09-28** — Proposal discussed and adopted; this plan written.
|
||
Alkcall dependency bump + fuzz scaffold started.
|
||
- **2026-09-28 (later)** — **All of §7 steps 1–5 landed.**
|
||
- alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 +
|
||
all-features 156 tests, clippy stable/wasm, fmt, doc).
|
||
- AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus
|
||
replay added to the verification checklist; campaign guidance
|
||
added.
|
||
- `fuzz/` scaffolded exactly as §5: root `[workspace]` table + publish
|
||
exclude, `fuzz/rust-toolchain.toml` nightly pin, thin target bins,
|
||
invariant logic in `fuzz/shared/` (stable toolchain), 328 committed
|
||
seeds via `gen_fuzz_seeds.py`, `run-detached.sh`, README,
|
||
`.gitignore`.
|
||
- Corpus replay green on stable
|
||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`).
|
||
- Harness-model corrections the corpus replay caught *before* any
|
||
campaign (the alkcall §7.7 pattern repeating): (1) a valid first
|
||
chunk with trailing stream bytes is correct reader behavior — the
|
||
payload slice is `5..5+len`, not `5..`; (2) the negotiation framing
|
||
layer is length-prefix-only — `read_frame` returns raw bytes and the
|
||
adapter does the serde parse (`adapter.rs:411`), so
|
||
`NegotiationError::Json` is unreachable from this reader (unlike
|
||
alkcall's serde-backed envelope reader; the `Json` arm now panics as
|
||
"unreachable" and the trailing-byte probe asserts exact-body
|
||
consumption instead of a Json error); (3) zero-length negotiation
|
||
frames are admitted by this reader (the adapter's parse rejects
|
||
them) — the shape assertion was relaxed accordingly.
|
||
- **10-min detached campaigns, all three targets: every run exited 0
|
||
with an empty artifact directory — no crashes, hangs, OOMs, or
|
||
leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
|
||
jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within
|
||
seconds — the 5-byte-parser ceiling is fully enumerated);
|
||
`negotiation_frame` 3.69M execs (cov 3157, still finding coverage at
|
||
budget end — JSON structure exploration); `control_json` 8.6M execs
|
||
(cov 2035, still growing). Grown corpus entries were deleted per the
|
||
§6 policy; committed seeds unchanged.
|
||
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
|
||
session.
|
||
- **2026-09-28 (later still)** — **§7 step 6 landed: target 4
|
||
`session_opseq`. All four targets are now live.**
|
||
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
|
||
(`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl.
|
||
barrier probes, backend production/EOF, exit resolve/fail/drop,
|
||
control messages, bounded reads, yields) + a fuzz-local
|
||
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
|
||
the harness driving `drive_session_pre_negotiated` over a
|
||
`tokio::io::duplex` pair, with a delivery model mirroring §4's
|
||
invariants. 12 committed seeds via the deterministic generator
|
||
(hand-encoded against the `arbitrary` 1.4.x derive layout — variant
|
||
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
|
||
gating each `Vec` element; the encoding is pinned by a decode test
|
||
asserting the exact ops each seed decodes to).
|
||
- Invariants encoded: kill-on-Drop (ADR-005), exit-chunk-is-last
|
||
(ADR-004), lossless per-stream FIFO + sentinel semantics, stdin
|
||
prefix-losslessness (post-shutdown chunks never delivered), control
|
||
dispatch bounds, take-once allocation, teardown termination.
|
||
Harness bounds: 128 ops, 4 KiB/chunk, 32 KiB produce/write caps,
|
||
unbounded backend stdin (control dispatch stays prompt).
|
||
- **Harness-model correction before the campaign** (the alkcall §7.7
|
||
pattern repeating, third time): a 60s smoke run crashed on
|
||
`ClientWrite` after the session had completed — a failed client
|
||
write can mean the *server half of the duplex dropped* because the
|
||
session finished (exit resolved → pumps join → exit chunk →
|
||
drainer exits → duplex halves drop) while the write was in flight.
|
||
The harness now treats any write/shutdown error as "the session
|
||
closed": it drains the read side and requires the exit chunk (a
|
||
close without it is a premature close — a real bug). No adapter
|
||
code changed; the finding was a harness-model gap.
|
||
- Corpus replay green on stable (9 tests, incl. the seed-decode
|
||
pin). Full checklist green: 113 + 156 all-features tests, clippy
|
||
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
|
||
check.
|
||
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
|
||
empty artifact directory — no crashes, hangs, OOMs, or leaks** (all
|
||
33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
|
||
job (the op-seq target is stateful — each input runs a full tokio
|
||
session with bounded reads/yields, so exec/s is inherently tens, not
|
||
millions; the value is interleaving exploration). Cov grew
|
||
3934 → 4075 edges over the run, still finding coverage at budget
|
||
end. Grown corpus excised per the §6 policy; 12 committed seeds
|
||
unchanged; corpus replay green after.
|
||
- §7 fuzzing plan complete: all four targets live, corpus replay is
|
||
the standing gate, campaigns pre-release and on adapter/pump/wire
|
||
changes.
|
||
|
||
## 9. Relationship to existing tests
|
||
|
||
The existing suite covers valid-input round-trips and documented error
|
||
paths (`wire.rs` codec tests, `negotiation.rs` and `tests/negotiation.rs`
|
||
frame tests, `control.rs` message tests). Fuzzing adds: random malformed
|
||
input exploration (the quinn CVE class), stateful interleavings (target
|
||
4), and adversarial exploration of the peek/disambiguation seam that
|
||
example-based tests structurally cannot enumerate.
|
||
|
||
Verification additions once landed:
|
||
|
||
```bash
|
||
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate
|
||
```
|
||
|
||
…added to the AGENTS.md checklist alongside the existing commands. The
|
||
wasm checks (`cargo check --target wasm32-unknown-unknown`,
|
||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`) remain
|
||
untouched by fuzz work but must still pass after the root `Cargo.toml`
|
||
gains its `[workspace]` table. |