Exited 0 at 2765 s: 96k execs at ~33/s, coverage 1378 edges / 6655 features / 734 corpus, still growing at budget end, zero findings. Wave 4 is fully done: five of five targets exited 0.
511 lines
28 KiB
Markdown
511 lines
28 KiB
Markdown
# Plan: alkhttp fuzzing
|
||
|
||
Adopted from alkcall's `docs/research/fuzzing.md` (the pattern is
|
||
operational in four sibling crates: alkcall, alktty, alktunnels,
|
||
alksocks — each with the same `fuzz/` layout, the detached runner, and
|
||
the corpus-replay-as-plain-test gate). This document is the working
|
||
plan for alkhttp; the sibling docs are the reference for rationale
|
||
(alkcall's doc is the deep version: tool landscape, comparable-crate
|
||
survey, CI tiers) — this plan stays focused on what alkhttp fuzzes and
|
||
in what order.
|
||
|
||
**Status:** targets 1–3 implemented and verified (1–2 on 2026-09-29,
|
||
wave 1; 3 on 2026-09-29, wave 2); target 4 implemented (2026-09-29,
|
||
wave 3: `ws_session_opseq` + `retry_throttle`; campaign results in
|
||
§5); the only crash found so far (FWD-21, the `WriteChunkWalk`
|
||
failure-carry drop) was a crate contract fix, not a harness
|
||
misassert.
|
||
|
||
Progress log:
|
||
|
||
- **2026-09-29 — targets 1–2 + infra landed (commit 5971c0a).** The
|
||
`fuzz/` workspace, 80 committed seeds (52 + 28), the detached
|
||
runner, the corpus-replay gate (AGENTS.md verification checklist
|
||
gains the line), nightly pinned subtree, explicit root `[workspace]`
|
||
exclusion. One crate finding on the way (below). `cargo fuzz build`
|
||
clean; corpus replay 80/80 green; campaigns pending at commit time.
|
||
- **2026-09-29 — FWD-20, the SSE dribble-drain quadratic (fixed in
|
||
the same commit).** Found by driving the new `sse_parser` harness's
|
||
split-feed invariants: `SseParser::feed` re-walked the whole
|
||
reassembly buffer per feed — a subscription dribble-fed one giant
|
||
unterminated line (the attacker can hold `data: <n MiB>` open
|
||
forever without sending `\n`) paid O(n²) in the dribbled bytes:
|
||
64 KiB dribble ≈ 12.5 s CPU, 1 MiB would run to minutes per
|
||
connection, with no per-stream total-CPU bound (the alkcall
|
||
research §6.2-3 shape, in this crate). The fix: line assembly walks
|
||
only the arriving chunk — the reassembly buffer holds just the
|
||
current line, so the scan never revisits drained bytes; measured
|
||
1 MiB dribble now 21 ms (linear, 4× data = ~4× time). Regression
|
||
test `sse_parser_dribble_feeds_do_not_rescan_the_buffer`. Also
|
||
fixed by corpus replay pre-campaign: the harness's own re-frame
|
||
round-trip was wrong for data ending in `\r` (un-re-encodable under
|
||
WHATWG CR-stripping — the serde_json key-order class; the harness
|
||
skips that documented-lossy shape).
|
||
- **2026-09-29 — target 3 (`spec_import`) implemented (wave 2).**
|
||
Harness + 42 seeds + the three `fuzzing` hubs; no crate findings in
|
||
the initial corpus replay (11 shared tests green, main crate 457
|
||
default / 590 all-features). Smoke campaign clean: 2.34M execs /
|
||
10 min, zero crash/OOM/timeout, coverage still growing at budget
|
||
end (see §5). One harness-assert fix on the way (the quoted-`""`
|
||
YAML key noted above).
|
||
- **2026-09-29 — target 4 (`ws_session_opseq` + `retry_throttle`)
|
||
implemented (wave 3).** Two stateful harnesses + 16 seeds + the
|
||
`byte_adapter::fuzzing` drive hub (generic pump bodies over a mock
|
||
socket flavor) + the `client::fuzzing` throttle hub. One crate
|
||
finding on the way: FWD-21 (above) — fixed same commit.
|
||
- **2026-09-29 — FWD-21, the `WriteChunkWalk` failure-carry drop.**
|
||
Found minutes into the first `ws_session_opseq` campaign: one feed
|
||
that drains a complete chunk and then hits an oversize header (or
|
||
the pending cap) in the same call returned
|
||
`Walk::Oversize`/`PendingOverCap` and **discarded the already
|
||
drained pieces** — the walk's own consumption-accounting contract
|
||
("every parsed byte is either emitted or retained", wave-1
|
||
invariant 6) was violated by the crate, not the harness. Production
|
||
could not reach the shape (the mux's per-write invariant caps
|
||
`pending` at one chunk + header), so impact was nil, but
|
||
`WriteChunkWalk` is public API and the wave-1 `split_walk` equality
|
||
check was silently skipped on exactly this path. Fix (user-approved
|
||
semver-relevant API change): `Walk::Oversize(Vec<Vec<u8>>)` /
|
||
`Walk::PendingOverCap(Vec<Vec<u8>>)` carry the pre-failure pieces;
|
||
the write pump emits them before `fail_write_pump`, so a 1011
|
||
close never swallows a legal chunk the mux already counted as
|
||
written. Regression seeds cover the drain-then-fail shape in both
|
||
chunk harnesses.
|
||
- **2026-09-29 — FWD-22, the round-trip guard-CR (harness-assert
|
||
fix; wave 4).** Found ~32 min into the first long `sse_parser`
|
||
campaign: the re-frame round-trip probe's `frame_event` emitted
|
||
`data: <line>` unguarded, so a JSON payload carrying an *interior*
|
||
CR (multi-line data `"\r\n…"` — the payload line is `"\r"`) had
|
||
its payload CR consumed by `strip_cr` at re-parse time and the
|
||
round-trip lost it (wave-1 had skipped only the *terminal*-CR
|
||
case). The parser was never wrong — the shape is representable on
|
||
input (`data: x\r\r\n` → value `x\r`) and both drives agreed — the
|
||
re-encode was weak: one CR in the wire form is ambiguous between
|
||
"payload CR" and "line-terminator CR". Fix: `frame_event` emits
|
||
`data: <line>\r` for every data line (the guard CR is the
|
||
terminator, `strip_cr` removes exactly it, the payload CR
|
||
survives), and the terminal-CR skip is dropped — the round trip is
|
||
now total over anything JSON-encodable (JSON never encodes a raw
|
||
CR, so the residual asymmetry stays the serde_json non-preserved
|
||
key-order class). No crate API change. Regression seeds + unit
|
||
cases cover the interior-CR JSON shape (`{"k": "a\\rb"}`, two-line
|
||
`{…}\r\r\n{…}`).
|
||
|
||
---
|
||
|
||
## 1. Why alkhttp fuzzes (the if)
|
||
|
||
Same three converging arguments as every sibling crate, with one
|
||
stronger premise:
|
||
|
||
1. **This is the outermost shell.** alkcall's peers are authenticated
|
||
by construction; alkhttp's HTTP surface faces anonymous callers —
|
||
gateway routes accept bytes on the wire before any identity is
|
||
resolved, `/healthz` and the stealth decoy are fully public, and the
|
||
`from_*` adapters parse responses from remote HTTP servers nobody
|
||
in this repo controls. The quinn-CVE class (RUSTSEC-2026-0037: a
|
||
panic on attacker wire bytes is a remote DoS) applies here at its
|
||
most direct. A remote crash in this crate does not need a peer
|
||
trust step to reach.
|
||
2. **Real hand-rolled byte parsing on untrusted streams.** The HTTP
|
||
framing itself is hyper/axum/reqwest (upstream-fuzzed, not our job),
|
||
but the crate owns an incremental SSE frame parser, the
|
||
WS↔byte-stream chunk parser on both directions, a YAML→JSON
|
||
conversion pass with its own number/key/pointer semantics, and
|
||
bounded-schema compilation over arbitrary input values. These are
|
||
the exact shapes (incremental, splitting-sensitive,
|
||
cap-check-ordered) where example tests miss off-by-one bugs. No
|
||
`unsafe` exists (grep-verified), so the expected yield is the
|
||
alkcall profile: panics, arithmetic, allocation, and state-growth
|
||
bugs — not memory corruption.
|
||
3. **The integration seam is untested from this side.** alkhttp sits
|
||
downstream of the alkcall 0.8.1 fix (duplicate adopt/open
|
||
destroying the live channel — found by alkcall's `manager_routing`
|
||
campaign minutes into its first run). alkhttp's WS path hands raw
|
||
bytes to alkcall's demux; `fuzz/shared` depending on alkcall means
|
||
corpus replay exercises the alkcall surface from the integration
|
||
crate, a direction alkcall's own harness does not cover.
|
||
|
||
Also the standing empirical argument: the 0.8.1 bug was a stateful
|
||
interleaving invisible to example-based tests, and it cost a patch
|
||
release across every sibling. The infrastructure is already proven and
|
||
cheap to replicate; the marginal cost here is only the target logic.
|
||
|
||
## 2. Infrastructure (identical to the siblings)
|
||
|
||
Layout (copy of alksocks/alktty/alktunnels/alkcall):
|
||
|
||
```
|
||
fuzz/
|
||
├── Cargo.toml alkhttp-fuzz (nightly-only bins; own [workspace])
|
||
├── rust-toolchain.toml pins nightly + llvm-tools for this subtree only
|
||
├── fuzz_targets/ thin fuzz_target! wrappers (3 lines each)
|
||
├── shared/ alkhttp-fuzz-shared — STABLE-toolchain library:
|
||
│ invariant logic + corpus-replay tests
|
||
├── corpus/<target>/ committed seeds (generated by gen_fuzz_seeds.py)
|
||
├── artifacts/ gitignored crash/oom/timeout artifacts + logs
|
||
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
|
||
├── json.dict JSON token dictionary (spec_import)
|
||
├── run-detached.sh detached campaign runner (copied from the siblings)
|
||
└── README.md operational cheat-sheet
|
||
```
|
||
|
||
Load-bearing details (all four siblings hit these):
|
||
|
||
- **Invariant logic lives in `fuzz/shared/`**, not the target binaries.
|
||
The stable-toolchain shared crate replays every committed seed
|
||
through the identical invariant functions as plain `cargo test` —
|
||
the standing fuzz gate (alkcall research §7.9 tier-3 deliverable,
|
||
this repo has no hosted CI by policy) — and the `fuzz_target!`
|
||
binaries are thin wrappers.
|
||
- **Root `Cargo.toml` needs an explicit `[workspace]` table**
|
||
(`members = ["."]`, `exclude = ["fuzz"]`); without it auto-discovery
|
||
pulls `fuzz/shared/` into the main workspace and the stable
|
||
toolchain builds nightly-consumed dev-deps. alkcall hit this trap.
|
||
- **`fuzz/rust-toolchain.toml` pins nightly** so `cargo fuzz build`
|
||
works from any CWD; nightly stays confined to `fuzz/`, MSRV 1.88
|
||
untouched. `fuzz/` joins the publish `exclude` list.
|
||
- **`.gitignore` additions**: `fuzz/artifacts/`, grown-corpus dirs
|
||
(committed seeds stay).
|
||
- **Detached runner (non-negotiable operating rule).** Campaigns never
|
||
run as a foreground child of an agent session (an OOM in a target
|
||
can take down the session host's process tree). Runner pins
|
||
`-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25` and
|
||
detaches via `setsid` + `nohup` + log redirect; the agent polls the
|
||
log and artifact directory, never waits. Copied from alksocks
|
||
(§`fuzz/run-detached.sh`).
|
||
|
||
**Verification-gate change:** `cargo test --manifest-path
|
||
fuzz/shared/Cargo.toml` (corpus replay) joins AGENTS.md's verification
|
||
checklist, as the siblings did.
|
||
|
||
## 3. Target inventory (all 4)
|
||
|
||
| # | Target | Drives | Input style | Status |
|
||
|---|---|---|---|---|
|
||
| 1 | `sse_parser` | `SseParser::feed` + `sse_event_envelope` mapping (`src/adapters/forward.rs`) | chunk-split sequence × eof flag | implemented 2026-09-29 |
|
||
| 2 | `ws_chunk_framing` | the WS↔byte-adapter chunk parsers (`WriteChunkWalk`, the write pump's extracted chunk walk) (`src/websocket/byte_adapter.rs`) | byte sequences + message-split boundaries | implemented 2026-09-29 |
|
||
| 3 | `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, `CompiledInputSchema`/`harden_closed_by_default`/`validate`, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`) | attacker-shaped OpenAPI/JSON/YAML docs + schemas | implemented 2026-09-29 |
|
||
| 4 | `ws_session_opseq` / `retry_throttle` | stateful op sequences over the WS byte-adapter pumps (write/read pump interleavings, close timing, oversize frames); `RetryAfterMiddleware` throttle-map growth | `#[derive(Arbitrary)]` op enums | implemented 2026-09-29 |
|
||
|
||
### Target 1 — `sse_parser` (the chunk_header-class target)
|
||
|
||
The crate's most direct untrusted-byte parser: any subscription over
|
||
`from_openapi`/`from_jsonschema` pipes remote HTTP bytes through
|
||
`SseParser::feed`.
|
||
|
||
- **Drives:** incremental `feed` with the fuzzer choosing the chunk
|
||
split boundaries; also the full-input single feed, and the
|
||
`sse_event_envelope` mapping (FWD-17: JSON payload surfaces as
|
||
itself, non-JSON as the `{"data","event"}` wrapper, empty →
|
||
`Null`).
|
||
- **Invariants:**
|
||
- no-panic on any byte sequence, any split, any eof timing;
|
||
- splitting transparency: whole-feed and every-slice-split feed
|
||
produce identical event streams (the alksocks `datagram_codec`
|
||
pattern);
|
||
- the buffer-cap check runs *before* the buffer grows
|
||
(FWD-14): the reassembly buffer can never exceed
|
||
`SSE_EVENT_BUFFER_CAP`; overflow surfaces as `BufferOverflow`,
|
||
never a panic or unbounded growth;
|
||
- dispatch discipline: an event is dispatched only on a blank line
|
||
(or EOF for a pending event with data); `data:` lines join with
|
||
`\n`; last `event:` before the blank line wins; `id:`/`retry:`/
|
||
comment lines (`:` prefix) never dispatch or alter state;
|
||
- shape partition: `BufferOverflow` only when a fed chunk would push
|
||
the reassembly buffer over the cap; a cap-sized-and-under input
|
||
can never trigger it — never allocate beyond the cap;
|
||
- UTF-8 discipline: a multi-byte character split at a chunk boundary
|
||
is not corrupted (decode happens once a complete line/EOF bounds
|
||
the window); invalid raw UTF-8 lines are skipped silently (WHATWG
|
||
tolerance), never panic;
|
||
- envelope mapping (FWD-17): every `feed`-produced event maps to a
|
||
success `ResponseEnvelope`; the JSON-ness branch agrees with
|
||
`serde_json::from_str` on the joined data; the JSON branch never
|
||
carries the `event` binding.
|
||
- round-trip-ish probe: re-serializing a parsed event as
|
||
`data:`/`event:` lines re-parses to an equal event for
|
||
JSON-encodable payloads (catches field-name and joining drift;
|
||
structural equality only).
|
||
- **Exposure:** `SseParser`/`SseEvent`/`sse_event_envelope` are
|
||
`pub(crate)`. Shared/invariant access via quinn/rustls-style
|
||
exposure if the pure functions can't be reached — alkcall's
|
||
campaign never needed `#[cfg(fuzzing)]` after making parse helpers
|
||
`pub(crate)` + re-export; the first choice here is the same: a
|
||
minimal `#[cfg(fuzzing)] pub mod fuzzing` re-export in
|
||
`src/adapters/forward.rs` (nightly-fuzz-only builds see it; stable
|
||
builds don't).
|
||
|
||
### Target 2 — `ws_chunk_framing`
|
||
|
||
The WS↔byte adapter parses 8-byte chunk headers on the write
|
||
(outbound byte→WS-message) side and validates against
|
||
`MAX_CHUNK_LEN`; the inbound side reassembles WS messages into the
|
||
byte stream that alkcall's demux parses. The hand-rolled pieces are
|
||
the chunk-walk loop and the pending accumulator.
|
||
|
||
- **Drives:** the write-pump parse loop driven directly with the
|
||
same logic `run_write_pump` executes on each `WriteMsg::Bytes`
|
||
(header scan → `MAX_CHUNK_LEN` gate → drain → 1 MiB message-piece
|
||
split → pending cap), and the read-pump inbound path's
|
||
message→header reassembly, driven as a byte-sequence target with
|
||
the fuzzer controlling both the byte stream and the message split
|
||
boundaries.
|
||
- **Invariants:**
|
||
- no-panic on any byte sequence at any message-split boundary;
|
||
- a header claiming `> MAX_CHUNK_LEN` fails the stream loudly
|
||
(WS-04/HY-09) at exactly the point the pump reaches the header —
|
||
never a silent wait and never an allocate-before-reject;
|
||
- the pending accumulator never exceeds `PENDING_BUFFER_CAP` =
|
||
`MAX_CHUNK_LEN + 8` (WS-14): bound asserted after every op; a
|
||
single write above the cap is rejected in `poll_write` before
|
||
queueing, so the cap is invariantly satisfied;
|
||
- message-splitting transparency: a chunk split across N
|
||
WS-message feeds parses exactly like the whole-buffer feed
|
||
(receiver's boundary is the header, not the message —
|
||
WS-06); pieces split at any offset ≤ `WS_MESSAGE_CAP`
|
||
reassemble losslessly;
|
||
- consumption accounting: every parsed byte is either emitted as a
|
||
chunk piece or retained in the pending buffer — nothing lost or
|
||
duplicated across the walk;
|
||
- the over-cap write rejection (`poll_write` > `PENDING_BUFFER_CAP`
|
||
→ `InvalidData` before queueing) is exact: no partial state, no
|
||
silent truncation.
|
||
- **Exposure:** the pure parse helpers are extracted/testable; the
|
||
pump functions are `pub(crate)` async — drives via a
|
||
`#[cfg(fuzzing)]` re-export and a current-thread runtime with a
|
||
mock sink (the alkcall `envelope_frame` Cursor pattern).
|
||
|
||
### Target 3 — `spec_import` (implemented 2026-09-29)
|
||
|
||
- **Drives:** raw bytes (and `json.dict`) → `serde_json`/`yaml_serde`
|
||
→ `OpenAPISpec::from_str` (JSON-first, YAML-fallback, ADR-051) and
|
||
the typed helpers: `yaml_to_json_value` boundary, number/key
|
||
conversion; plus `CompiledInputSchema::compile` +
|
||
`harden_closed_by_default` + `validate` over attacker-shaped
|
||
schemas and input values; plus `FromMCP::build_spec` over arbitrary
|
||
`Tool`-shaped JSON.
|
||
- **Invariants:** no-panic; parse failures are always clean
|
||
`AdapterError::SchemaParse` naming a pointer (never a panic/silent
|
||
null); the YAML normalization rejects loudly what
|
||
`serde_json::Number` cannot represent (non-finite floats,
|
||
non-string keys); `$ref` resolution errors on cycles instead of
|
||
unbounded recursion; the compiled validator rejects/accepts
|
||
consistently w.r.t. the schema; closed-by-default hardening pins
|
||
`additionalProperties: false`; registry-compile shape parity with
|
||
alkcall's `spec_parse` invariants (bounded compile, always
|
||
`Result`).
|
||
- **Implemented shape:** the harness drives one attacker string
|
||
through all four lanes — the three spec constructors (str/json,
|
||
yaml under `openapi`), the direct YAML→JSON normalization boundary
|
||
(merge keys applied; a value the boundary accepted can't contain
|
||
non-finite numbers — serde_json-invariant, asserted recursively),
|
||
the compile/harden/validate lane against the whole doc (hardening
|
||
partition: injected iff absent and the input is an object;
|
||
validate determinism probe with an unknown-key input; deterministic
|
||
two-call agreement), the MCP tool-shape lane (the doc and a
|
||
`{name, inputSchema}` wrapper through `serde_json`→`Tool`→
|
||
`build_spec`; success pins the namespace prefix), and the `$ref`
|
||
lane (`resolve_refs_recursive` over the raw doc + declared
|
||
component schemas when the doc parses). Exposure via
|
||
`fuzzing` hubs alongside the wave-1 one:
|
||
`openapi_spec::fuzzing`, `input_validation::fuzzing`
|
||
(an opaque `FuzzCompiledValidator` newtype so the `pub(crate)`
|
||
validator never leaks), `from_mcp::fuzzing` (`mcp` feature).
|
||
The shared crate gates `openapi`/`mcp` (both on by default, riding
|
||
the main crate's default build); corpus replay passes both with and
|
||
without.
|
||
- **Note:** yaml_serde (ADR-051: YAML 1.2 + merge keys) is the
|
||
deepest third-party parse we own — the campaign should also encode
|
||
the already-documented boundaries (`apply_merge` error path,
|
||
duplicate-key rejection with line/column) as corpus expectations.
|
||
42 committed seeds cover the menu below.
|
||
|
||
### Target 4 — stateful wave (implemented 2026-09-29)
|
||
|
||
- **`ws_session_opseq`:** `#[derive(Arbitrary)]` op sequences over
|
||
the WS byte adapter driven end-to-end (like alktty's
|
||
`session_opseq` / alkcall's `manager_routing`): write-side ops
|
||
(well-formed chunks, oversize headers, the pending shape),
|
||
read-side ops (binary deliveries, dribble, text, inbound read
|
||
error, peer close), a lane-healthy requested close, shutdown vs
|
||
close ordering, backpressure scaled 1..=8 read slots. Asserts:
|
||
lossless inbound forwarding (forwarded == delivered exactly after
|
||
the teardown joins, prefix mid-sequence), the WS-01 walk's
|
||
completed-chunk count vs the declared frame count, the loud-gate
|
||
close mapping (1002/1011/verbatim-requested + a per-cause reason,
|
||
WS-15), lossless write framing (outbound concat == the declared
|
||
chunk stream incl. the FWD-21 failure-carry), lane-failure
|
||
isolation (the alkcall 0.8.1 class), and pump termination within
|
||
the harness bound. Exposure: `byte_adapter::fuzzing`
|
||
(`FuzzMockSocket` flavor, `FuzzChannels`/`FuzzWriteSide` channel
|
||
bundle, pump spawn funnels, `FuzzInboundChunkProgress`).
|
||
- **`retry_throttle`:** bounded-map growth of the inlined
|
||
`RetryAfterMiddleware`: capacity bound (exact, distinct-URL
|
||
observable partition), expired-first + Farthest-future eviction
|
||
policy, ceiling clamp (deadline never grows past the ceiling of
|
||
the record moment), refresh contraction (a higher `Retry-After`
|
||
never extends the first-seen deadline; the missing-header refresh
|
||
is a documented no-op), `Retry-After` parse partition
|
||
(non-negative seconds ∪ future HTTP dates, post-clamp), and the
|
||
jitter bounds (≤ 25% of a short wait, capped at 2 s). Op sequences
|
||
of records/refreshes/queries/rebuilds with seconds, huge-seconds,
|
||
HTTP-date, past-date, garbage, and no-header shapes. Exposure:
|
||
`client::fuzzing` (constructor, synthetic throttled response,
|
||
record/deadline/parse/jitter funnels — the middleware map never
|
||
leaks).
|
||
- Sequenced after 1–3 stabilize, per the sibling pattern.
|
||
|
||
## 4. Corpus policy
|
||
|
||
Committed hand-made seeds per target, generated by
|
||
`fuzz/gen_fuzz_seeds.py` (deterministic, in-tree, quiche pattern);
|
||
grown corpora and artifacts gitignored. Seed menus:
|
||
|
||
- `sse_parser`: a minimal valid event; every framing edge (`data:`
|
||
multi-line joins, `event:` override, comment, `id:`, `retry:`,
|
||
BOM, CRLF/LF, field with no colon, `data` with no space);
|
||
truncation at every structural prefix; split at a UTF-8 multibyte
|
||
boundary; cap-boundary event (`SSE_EVENT_BUFFER_CAP - 1`,
|
||
exactly at cap, one over); an over-cap trailing line; EOF with
|
||
pending data / pending no-data; empty stream; pure whitespace.
|
||
- `ws_chunk_framing`: a minimal valid chunk (`[id:u32 BE][len:u32
|
||
BE][payload]`); `len = 0`, `len = MAX_CHUNK_LEN`,
|
||
`len = MAX_CHUNK_LEN + 1`, `len = u32::MAX`; truncation at every
|
||
prefix length (1–8 bytes); chunk split across a message boundary
|
||
at every offset; channel-id 0 (the control channel); a header
|
||
claiming big length whose payload never arrives (the pump-wait
|
||
shape); the >cap single write (`poll_write` rejection).
|
||
- `spec_import` (when built): a minimal valid OpenAPI doc (JSON);
|
||
the same doc as YAML; missing `info`/`paths`; a deep-nesting
|
||
probe; duplicate-key YAML; `<<: *anchor` merge; `.inf`/`.nan`
|
||
scalars; non-string map keys; a `$ref` cycle; an unreachable
|
||
`$ref`; `additionalProperties` absent (hardening probe) and
|
||
present both ways.
|
||
- Stateful seeds (target 4): the semantic fixtures the campaign must
|
||
always replay — a write/echo flow, the oversize loud-gate after a
|
||
clean chunk, the text-rejection + surviving write lane, an inbound
|
||
read error, a peer close mid-flight + shutdown drain, a requested
|
||
close, the throttle-map fill/evict cycle at capacity, the
|
||
missing-header refresh no-op, hostile-seconds clamping, refresh
|
||
contraction, and a mid-sequence rebuild (16 total; the
|
||
`ws_session_opseq`/`retry_throttle` blocks in
|
||
`fuzz/gen_fuzz_seeds.py`, hand-encoded against the `arbitrary`
|
||
1.4.x derive layout with ceiling-division tags and per-element
|
||
keep-going bytes, pinned by seed-decode tests).
|
||
|
||
## 5. Campaign + gate policy
|
||
|
||
Identical to the siblings (alkcall §7.9 posture; no hosted CI in this
|
||
repo):
|
||
|
||
- **Corpus replay is the standing fuzz gate:** `cargo test
|
||
--manifest-path fuzz/shared/Cargo.toml` — in AGENTS.md's
|
||
verification checklist.
|
||
- **Campaigns run detached** via `fuzz/run-detached.sh` (never a
|
||
foreground child of an agent session); budget 10 min per target for
|
||
a smoke campaign, 30+ min before a release or after touching the
|
||
SSE parser, the WS adapter, or the import adapters.
|
||
- **Grown corpora stay gitignored**: the hash-named files a campaign
|
||
drops into `fuzz/corpus/<target>/` after run-detached runs are
|
||
ignored via a hash-pattern (the committed `seed-*` files stay);
|
||
merge worthy entries into seeds only deliberately.
|
||
|
||
Wave-1 smoke campaign results (2026-09-29, 10 min per target, both
|
||
exited 0, artifact dirs empty — no crash/hang/OOM/leak):
|
||
|
||
- `sse_parser` — 18.9k execs at ~30/s (each exec replays the
|
||
whole-feed + 3-byte-split + cap-probe + envelope-mapping +
|
||
round-trip chain, so per-exec work is heavy), coverage 1100 edges /
|
||
9510 features / 807 in-memory corpus entries, still discovering when
|
||
the budget ended — longer campaigns keep paying.
|
||
- `ws_chunk_framing` — 4.8M execs at ~9k/s, coverage saturated at 245
|
||
edges / 1071 features / 136 corpus entries.
|
||
- **Seeds regenerate deterministically:** `python3
|
||
fuzz/gen_fuzz_seeds.py`.
|
||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||
`fuzz/`.
|
||
|
||
Wave-2 (`spec_import`) smoke campaign (2026-09-29, 10 min, exited 0,
|
||
artifact dirs empty):
|
||
|
||
- `spec_import` — 2.34M execs at ~4k/s (each exec replays the three
|
||
spec constructors + the YAML boundary + compile/harden/validate +
|
||
the MCP tool lane + `$ref` resolution, so per-exec work is heavy),
|
||
coverage still growing at budget end: 14.1k edges / 36.9k features
|
||
/ 3046 in-memory corpus entries. One early abort in the *first*
|
||
launch, a harness assert too strong for the documented boundary
|
||
(an explicitly-quoted `""` YAML mapping key is legal — JSON
|
||
has the same key; the nullish-key rejection covers the implicit
|
||
null key, not a quoted empty string), fixed before the counted run;
|
||
no crate finding.
|
||
- **Seeds regenerate deterministically:** `python3
|
||
fuzz/gen_fuzz_seeds.py`.
|
||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||
`fuzz/`.
|
||
|
||
Wave-3 stateful smoke campaigns (2026-09-29, 10 min per target, both
|
||
exited 0, artifact dirs empty — no crash/hang/OOM/leak; the first
|
||
`ws_session_opseq` launch aborted at 17 s on FWD-21, fixed before
|
||
the counted run):
|
||
|
||
- `ws_session_opseq` — 206k execs at ~340/s (each exec interleaves up
|
||
to 64 two-lane ops with yield quiesce points per exec, so per-exec
|
||
work is heavy), coverage 2251 edges / 5831 features / 232 in-memory
|
||
corpus entries — coverage still growing at budget end.
|
||
- `retry_throttle` — 897k execs at ~1.4k/s, coverage saturated at
|
||
1154 edges / 6925 features / 324 corpus entries.
|
||
- **Seeds regenerate deterministically:** `python3
|
||
fuzz/gen_fuzz_seeds.py`.
|
||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||
`fuzz/`.
|
||
|
||
Wave-4 release-budget campaigns (2026-09-29, 45 min per target,
|
||
launched concurrently, five of five):
|
||
|
||
- `ws_chunk_framing` — exited 0: 13.7M execs at ~5k/s, coverage
|
||
saturated (297 edges / 1216 features / 139 corpus).
|
||
- `spec_import` — exited 0: 6.85M execs at ~3.2k/s, coverage growing
|
||
to budget end (14.9k edges / 43.4k features / 3946 corpus).
|
||
- `ws_session_opseq` — exited 0: 715k execs at ~253/s, coverage
|
||
growing to budget end (2251 edges / 6337 features / 260 corpus).
|
||
- `retry_throttle` — exited 0: 3.3M execs at ~1.3k/s, coverage
|
||
saturated (1154 edges / 7071 features / 352 corpus).
|
||
- `sse_parser` — aborted at 1913 s on FWD-22 (above; the
|
||
interior-CR round-trip loss, a harness fix); before the abort it
|
||
had 57k execs at ~35/s (1269 edges / 5584 features / 509 corpus),
|
||
still growing. 45-min rerun post-fix exited 0: 96k execs at ~33/s,
|
||
coverage still growing at budget end (1378 edges / 6655 features /
|
||
734 corpus) — zero findings.
|
||
- A stale crash artifact from the wave-2 morning session (quoted-`""`
|
||
YAML key, predating that fix) was still present in
|
||
`fuzz/artifacts/spec_import/` at campaign start — not produced by
|
||
wave 4 (the wave-4 `spec_import` log has zero findings).
|
||
- **Seeds regenerate deterministically:** `python3
|
||
fuzz/gen_fuzz_seeds.py`.
|
||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||
`fuzz/`.
|
||
|
||
1. ✅ **Done (2026-09-29, commit 5971c0a)** — infra (workspace
|
||
exclude, toolchain pin, runner, seed generator, README) + targets
|
||
1–2 + corpora + corpus replay + AGENTS.md gate + smoke campaigns
|
||
(clean, see §5). Includes the FWD-20 SSE dribble quadratic fix.
|
||
2. ✅ **Done (2026-09-29, commit e6491e3)** — target 3
|
||
(`spec_import`): harness + invariants
|
||
(`fuzz/shared/src/spec_import.rs`), 42 seeds, `json.dict` extended
|
||
with the OpenAPI-token set, fuzzing hubs in the three driven
|
||
modules. Smoke campaign clean (see §5).
|
||
3. ✅ **Done (2026-09-29, wave 4)** — long (45 min) release-budget
|
||
campaigns, all five targets, all exited 0 (see §5; the one
|
||
finding, FWD-22, was a harness-assert fix, fixed + re-run clean in
|
||
the same wave). Corpus merges remain deliberately deferred (grown
|
||
corpora stay gitignored; nothing campaign-dropped has been curated
|
||
into seeds).
|
||
4. ✅ **Done (2026-09-29, wave 3)** — target 4 stateful
|
||
(`ws_session_opseq` + `retry_throttle`): two harnesses + 16 seeds
|
||
+ the `byte_adapter::fuzzing` / `client::fuzzing` hubs + FWD-21
|
||
(the `WriteChunkWalk` failure-carry drop — a crate contract fix;
|
||
user-approved semver-relevant API change). Smoke campaigns clean
|
||
(see §5).
|
||
|
||
Status updates land in §3's table and §6's list as work completes. |