From 77f7006e2e74abdb54f1ec9ede2670517718ffae Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 27 Sep 2026 23:52:45 +0000 Subject: [PATCH] docs(research): fuzzing steps 1-3 complete; 10-min campaigns clean on all five targets \u00a77.8 campaign results: manager_routing 1.06M execs (exact-counter and parked-bytes invariants held across every adversarial sequence), envelope_semantic 2.44M execs (coverage saturated, all event kinds round-trip), spec_parse 10.8M execs (registry compiled every attacker-shaped schema or rejected cleanly). All exited 0, zero artifacts. Also records the two harness-model defects the fuzzer caught in the interim (per-receiver EOF-sentinel latch; odd/even Adopt range per ADR-047 \u00a75). Doc-only + harness fixes; no crate changes. --- docs/research/fuzzing.md | 57 +++++++++++++++++++-------- fuzz/shared/src/manager_routing.rs | 62 +++++++++++++++++++++++------- fuzz/shared/src/spec_parse.rs | 11 ++++-- 3 files changed, 97 insertions(+), 33 deletions(-) diff --git a/docs/research/fuzzing.md b/docs/research/fuzzing.md index dbf244a..84ab09d 100644 --- a/docs/research/fuzzing.md +++ b/docs/research/fuzzing.md @@ -1,8 +1,9 @@ # Fuzzing alkcall: Research and Recommendation -**Status:** step 1 of §7.4 adopted (`fuzz/` workspace, targets 1–2, committed -seeds, detached runner; campaigns run — §7.7); step 3 targets 3–5 adopted, -one real finding fixed — see §7.8 +**Status:** steps 1–3 of §7.4 adopted — `fuzz/` workspace, five targets, +committed seeds, detached runner, two-tier campaigns run; one real +finding (duplicate adopt/open destroying the live channel) fixed with +regression tests. CI deferred until a platform exists (§7.8) **Date:** 2026-09-27 **Research inputs:** web survey of the 2025–2026 Rust fuzzing ecosystem, survey of fuzzing practice in comparable Rust protocol crates (rustls, quinn, quiche, @@ -379,10 +380,10 @@ comparisons), and a `-dict` of JSON tokens for the envelope targets. found; triage §6.2 candidates with targeted corpus entries. **Done for targets 1–2 — see §7.7.** 3. Add targets 3–5, the smoke CI job, and `.gitignore` entries. - **Targets 3–5 done — see §7.8. CI deferred (no CI platform exists in - this repo yet; the stable-side corpus replay `cargo test - --manifest-path fuzz/shared/Cargo.toml` is the drop-in smoke gate - when a platform is chosen).** + **Targets 3–5 done, campaigns clean — see §7.8. CI deferred (no CI + platform exists in this repo yet; the stable-side corpus replay + `cargo test --manifest-path fuzz/shared/Cargo.toml` is the drop-in + smoke gate when a platform is chosen).** 4. Scheduled campaign tier; then OSS-Fuzz application. ### 7.5 Relationship to existing tests @@ -697,16 +698,40 @@ not channel-map entries — adopted channels carry no ledger entry, so counters survive `clear_all`; and drainer-byte reconciliation needs per-id supersession when a fresh stream replaces a drained one. -### Campaign results (smoke tier, 20 s per target) +### Campaign results (10-min detached runs per target, §7.6 runner) -All three targets ran clean (`-fork=1 -rss_limit_mb=2048 --malloc_limit_mb=2048`): `manager_routing` 79k runs, `envelope_semantic` -141k runs, `spec_parse` 517k runs — zero crashes beyond the found-and- -fixed channel bug above. The stable side stayed green throughout: -684 tests (682 + the two regression tests), clippy `-D warnings`, fmt -(main + fuzz workspace + shared). Detached 10-min campaigns on the new -targets follow this commit; §6.2-1 (parked-bytes bound) is now encoded -as an exact counter model and the §7.4 step-3 stateful coverage exists. +The smoke-tier runs above surfaced two harness-model defects worth +recording (the fuzzer as a harness-checker), fixed before the long +runs: the op-sequence driver's EOF-sentinel latch is **per-receiver, +not per-channel-id** — a fresh adopt installs a fresh reassembled +stream with no latched EOF, while a sentinel inside a *drained +early-arrival queue* latches the new receiver too +(`drain_early_arrivals` delivers it); and `Adopt` ops must respect +ADR-047 §5's odd/even split (an Accept-side manager adopts the peer's +odd ids; its own `Open` allocates even — a cross-range adopt/open +collision is a protocol violation, not a manager bug). + +**Final 10-minute detached campaigns, all three targets (post-fixes, +post the `insert`-replace fix): every run exited 0 with an empty +artifacts directory — no crashes, hangs, OOMs, or leaks.** + +- `manager_routing` — 1.06M execs (~1.9k exec/s; each exec replays up + to 256 manager ops on a live runtime), 33 fork jobs clean, + `oom/timeout/crash: 0/0/0` throughout. Final coverage 2499 edges / + 11332 features / 461 in-memory corpus entries. The exact-counter and + parked-bytes invariants held across every adversarial sequence. +- `envelope_semantic` — 2.44M execs (~4.4k exec/s), 33 fork jobs + clean, coverage saturated at 1669 edges (constructor × payload shape + space is finite), corpus 765. All six event kinds round-tripped + structurally; `CallError` parse-back never failed. +- `spec_parse` — 10.8M execs (~19k exec/s), 33 fork jobs clean, + coverage 978 edges / corpus 1267. The registry compiled every + attacker-shaped schema it was handed (or rejected cleanly); the + `spec_to_json_pub` → `from_json` round-trip never diverged. + +§6.2-1 (parked-bytes bound) is now encoded as an exact counter model +and held; §6.2-3 confirmed bounded at the parser level (§7.7). The +stateful coverage §7.4 step 3 called for exists and is clean. ## 8. Answering the "if / how" directly diff --git a/fuzz/shared/src/manager_routing.rs b/fuzz/shared/src/manager_routing.rs index 37670bf..6235703 100644 --- a/fuzz/shared/src/manager_routing.rs +++ b/fuzz/shared/src/manager_routing.rs @@ -28,10 +28,28 @@ pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; #[derive(Debug, arbitrary::Arbitrary, Clone)] pub enum ManagerOp { - Route { channel_id: u32, len: u16, byte: u8 }, - Open { alpn_idx: u8, opener_idx: u8 }, - Adopt { channel_id: u32 }, - Teardown { channel_id: u32 }, + Route { + channel_id: u32, + len: u16, + byte: u8, + }, + Open { + alpn_idx: u8, + opener_idx: u8, + }, + /// Adopt a channel whose id the REMOTE side allocated. The + /// harness manager is `ChannelSide::Accept`, so the peer is the + /// Connect side and its allocated ids are odd (ADR-047 §5) — the + /// op masks the fuzzer's arbitrary id into the legal range. + Adopt { + channel_id: u32, + }, + /// Teardown an arbitrary channel id (any live id — teardown is + /// local, not range-constrained). + Teardown { + channel_id: u32, + }, + /// Transport EOF — clear everything. ClearAll, } @@ -98,7 +116,11 @@ struct ManagerHarness { /// Byte totals for drainers that ended mid-sequence (sender drop /// on adopt-over-adopt) — reconciled into the post-sequence total. retired_drainer_bytes: u64, - /// Channels whose stream latched EOF (sentinel routed while known). + /// Whether the CURRENT receiver for each id latched an EOF + /// sentinel — per-receiver state, not per-id: a fresh adopt + /// installs a fresh reassembled stream with no latched EOF, while + /// a sentinel inside a drained early-arrival queue latches the + /// NEW receiver too (`drain_early_arrivals` delivers it into it). sentinel_seen: HashMap, /// (channel_id, bytes-read counter, drainer task). drainers: Vec<(u32, Arc, tokio::task::JoinHandle<()>)>, @@ -169,22 +191,31 @@ impl ManagerHarness { } } ManagerOp::Adopt { channel_id } => { + // The harness manager is the Accept side; the peer + // (Connect side) allocates odd ids, so only odd ids + // arrive for adoption (ADR-047 §5 odd/even split). + let channel_id = (*channel_id | 1).max(1); match self .manager - .adopt_channel(*channel_id, "alk/tty", None) + .adopt_channel(channel_id, "alk/tty", None) .await { Ok((_send, recv)) => { - // Adoption drains the parked queue FIFO into - // the receiver — readable until a sentinel. - if let Some(queue) = self.model.parked.remove(channel_id) { + // Fresh receiver (no latched EOF), then + // supersede the id's prior drainer (retire its + // model, reset its sentinel latch), THEN fold + // the drained parked queue into the new + // receiver's model — `drain_early_arrivals` + // delivers it into the new receiver, sentinel + // included. + self.spawn_drainer(channel_id, recv); + if let Some(queue) = self.model.parked.remove(&channel_id) { let drained_readable = ParkModel::readable_bytes_until_sentinel(&queue); - *self.readable.entry(*channel_id).or_insert(0) += drained_readable; + *self.readable.entry(channel_id).or_insert(0) += drained_readable; if queue.contains(&0) { - self.sentinel_seen.insert(*channel_id, true); + self.sentinel_seen.insert(channel_id, true); } } - self.spawn_drainer(*channel_id, recv); } Err(ManagerError::ChannelExists(_)) => {} Err(ManagerError::TooManyChannels { .. }) => {} @@ -233,7 +264,12 @@ impl ManagerHarness { // A fresh drainer for an id supersedes any earlier one: the // earlier stream's sender was dropped (teardown) or routed // past an EOF sentinel, so its readable-byte model is retired - // into the reconciled total and the id's model restarts. + // into the reconciled total and the id's model restarts. The + // id's sentinel latch also resets — the new receiver is a + // fresh reassembled stream (any sentinel in the drained + // early-arrival queue is re-applied by the adopt arm AFTER + // this, since `drain_early_arrivals` delivers it into the new + // receiver too). if let Some(bytes) = self.readable.remove(&channel_id) { self.retired_drainer_bytes += bytes; } diff --git a/fuzz/shared/src/spec_parse.rs b/fuzz/shared/src/spec_parse.rs index 5f06c20..2599b23 100644 --- a/fuzz/shared/src/spec_parse.rs +++ b/fuzz/shared/src/spec_parse.rs @@ -68,11 +68,14 @@ pub fn fuzz_spec_parse(data: &[u8]) { // Layer 3: the rebuilt spec serializes back to the wire shape. // (Structural equality with the input is NOT asserted — the wire // shape is a projection of the spec, and rebuild normalizes; the - // round-trip invariant is that `spec_to_json_pub` output re-parses - // to the same spec.) + // round-trip invariant is that the full op/register payload built + // from `spec_to_json_pub` output re-parses to the same spec.) let wire = alkcall::registry::discovery::spec_to_json_pub(&request.spec); - let reparsed = OpRegisterRequest::from_json(&wire) - .expect("spec_to_json_pub output must re-parse through op/register"); + let reparsed = OpRegisterRequest::from_json(&serde_json::json!({ + "spec": wire, + "replace": request.replace, + })) + .expect("spec_to_json_pub output must re-parse through op/register"); assert_eq!( reparsed.spec, request.spec, "spec_to_json_pub → from_json round-trips the rebuilt spec"