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.
This commit is contained in:
glm-5.3-flash committed 2026-09-27 23:52:45 +00:00
1 parent a1f257757b
commit 77f7006e2e
3 files changed
+97 -33

No files matched your search

+41 -16
View File
@@ -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
+49 -13
View File
@@ -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<u32, bool>,
/// (channel_id, bytes-read counter, drainer task).
drainers: Vec<(u32, Arc<AtomicU64>, 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;
}
+7 -4
View File
@@ -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"