Bump the substrate dependency to alkcall 0.8.1 — the bug-fix release
whose fix (duplicate adopt/open no longer destroying the live channel)
is directly relevant to this crate's reverse-path adopt flow.
Adopt fuzzing per docs/research/fuzzing.md, scoped smaller than
alkcall's (the envelope/chunk/demux/manager surfaces are upstream and
now fuzzed there; this crate fuzzes its own named downstream target —
the ADR-003 datagram codec — plus the params/reply parsers):
- fuzz/ workspace per the alkcall pattern: nightly-pinned subtree
(rust-toolchain.toml), thin fuzz_target! binaries, and a
stable-toolchain shared crate carrying the invariant logic so the
committed corpus replays as plain `cargo test` (the fuzz gate in
the release checklist — no hosted CI, alkcall §7.9 posture).
- Targets (both over already-pub APIs; no #[cfg(fuzzing)] exposure):
- datagram_codec — DatagramReader::feed + frame_datagram: no-panic,
infallible decode, size bound, F-2 sentinel layering (empty
datagram only from a length-0 header at a frame boundary),
3-byte-slice splitting transparency, re-frame round-trip,
oversize rejection at the bound, is_mid_datagram framing state,
and the decoder-state retained-bytes bound.
- params_parse — serde params/reply parsers + establishment_reason:
no-panic on raw bytes and bounded structured Values, clean typed
failure, round-trips, listen: 0 pin, loud/lenient projection-pair
agreement, pass-through reason extraction model.
- New public diagnostic: DatagramReader::retained_bytes() (decoder-
state byte count; the fuzz bound asserts against it).
- 96 committed seeds (gen_fuzz_seeds.py) + the campaign-found crash
input promoted as datagram_codec/seed-900; grown corpora gitignored.
- run-detached.sh: setsid+nohup runner so campaigns never share fate
with the agent session process tree (fuzzing.md §5).
Campaign findings this session were harness-model defects, not crate
bugs (both invisible to example-based review; the alkcall
"fuzzer as harness-checker" pattern confirmed):
- the F-2 empty-datagram invariant first counted [00 00] pairs in the
raw stream — a pair inside a payload region is payload, not a
header; corrected to walk the framing (crash artifact saved).
- establishment_reason is a pass-through extractor of details.reason,
not a validated table (harness first asserted a closed set).
- is_mid_datagram means a partial datagram, not buffered header bytes
(a 1-byte header fragment is buffered, not in flight).
Verification: cargo test 135 passed; clippy -D warnings and fmt clean
(main + fuzz/shared); corpus replay green; cargo fuzz build clean;
wasm32-unknown-unknown check clean. Wave-1 campaigns (10-min detached,
fork mode): params_parse 3.14M execs / datagram_codec 827k execs,
0 crashes/OOMs/timeouts; coverage saturated on the codec state space.
22 KiB
Fuzzing alktunnels: Proposal and Plan
Status: adopted — wave-1 complete (datagram_codec +
params_parse implemented, seeds committed, two 10-min campaigns
clean, two harness-model findings fixed — see §7); wave-2 (stateful
two-pump target) deferred, see §4. Campaigns run via the detached
runner (§6) — never as a foreground child of an agent session (§5).
Date: 2026-09-28
Upstream precedent: /workspace/@alkdev/alkcall/docs/research/fuzzing.md
(0.8.0 — 0.8.1 campaign; one real finding: duplicate adopt/open destroying
the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the
no-hosted-CI posture are inherited from that doc wholesale — this doc does
not re-litigate them; it scopes them to this crate.
1. Should this crate be fuzzed?
Yes — but scoped smaller than alkcall's. The decision follows from three lines, all pointing the same way:
-
alkcall's fuzz doc already assigned this to us. Its §5 "non-surfaces (deliberately)" note ends: the channels layer carries opaque
Bytesand the handler owns sub-stream framing (ADR-035) — those downstream parsers are downstream crates' fuzz targets. Ourwire.rsdatagram codec IS that named downstream target. Fuzzing it completes alkcall's fuzz scope rather than duplicating it. -
We own one hand-rolled parse surface of exactly the covered class.
DatagramReader::feed(src/wire.rs:100) is an incremental length-prefix state machine reassembling[len: u16 BE][payload]frames across arbitrary chunk boundaries — length arithmetic and boundary handling, the dominant rust-fuzz trophy-case classes. It is sync, pure, allocation-light, and infallible by contract (the decode direction cannot fail; oversize is rejected at frame time) — a fuzz target is how that contract is demonstrated rather than believed. -
The stateful DoS class applies to us directly. One pre-fuzz code-review candidate, the analog of alkcall §6.2-1 (the early-arrival key-space):
DatagramHalves.pending(src/consumer.rs:78) queues every datagram decoded from a chunk beyond the one returned to the caller. A single 16 MiB chunk of[00 00]pairs = ~8.4 M empty datagrams → ~8.4 MVecDequeentries (≈ 400+ MiB) from ONE peer chunk — a ~25–50× memory amplification riding a bounded channel buffer. Example-based tests structurally cannot find this; a byte-sequence target with a pending-bytes assertion will.
The honest caveat (mirroring alkcall §1): the highest-value alkcall
targets — envelope framing, chunk header, demux, manager — are upstream
and now fuzzed (0.8.1). Our params parsing is serde + registry-side
jsonschema (upstream's spec_parse target covers attacker-shaped
schemas compiling at registration). Expected yield here: datagram-codec
pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge
cases in params, and lifecycle invariants in the two-pump shape. That
is worthwhile, not five-targets-worth of new machinery on day one —
hence the two-wave scope in §4. If the first campaigns find nothing,
that is the successful-negative-result posture alkcall's §1 codified:
"we think the codec is robust" becomes a demonstrated property, and the
targets become regression protection for a wire-stable format
(ADR-003).
2. Toolchain (inherited, not re-decided)
Identical to alkcall §4: cargo-fuzz 0.13.2 + arbitrary 1.4.x,
nightly confined to the fuzz/ subtree via fuzz/rust-toolchain.toml,
the crate itself stays stable at MSRV 1.88. AFL optional/deferred;
bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz
not adopted. Sanitizer: --sanitizer=address with debug assertions
(cargo-fuzz defaults) — no MSAN (no unsafe in this crate).
Layout (the alkcall pattern, load-bearing):
fuzz/
├── Cargo.toml alktunnels-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml pins nightly for this subtree only
├── fuzz_targets/ thin fuzz_target! wrappers (3-line each)
│ ├── datagram_codec.rs
│ └── params_parse.rs
├── shared/ alktunnels-fuzz-shared — STABLE-toolchain library
│ └── src/… invariant logic + corpus replay (§3)
├── corpus/<target>/ committed hand-made seeds (§3)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
├── run-detached.sh §6 detached runner (CWD-independent)
└── README.md
- Invariant logic lives in
fuzz/shared/, not inline in the target binaries — the stable-toolchain corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml) runs the SAME invariant functions the fuzzer runs, on stable, in the release verification checklist. This is CI tier-3's deliverable (alkcall §7.2) minus automation; per alkcall §7.9 this repo has no hosted CI and will not get one (same gitea posture) — corpus replay as plaincargo testis the fuzz gate. - Root
Cargo.tomlneeds the explicit[workspace]table (members = ["."],exclude = ["fuzz"]) and"fuzz/"on the publish exclude list — without it cargo auto-discoversfuzz/shared/into the main workspace and the MSRV toolchain tries to build the nightly-consumed dev-deps (the exact failure alkcall hit). - Corpus policy: commit hand-made seeds, gitignore grown corpora
(quinn/h2 model). Seeds per target: valid frames, truncation at every
short prefix length,
len = 0/len = MAX/len = MAX+1, empty datagrams, batching (two frames per chunk), splitting (one frame per many chunks), and for params: every substrate value, unknown substrate, unknown/missing fields,listen: 0, malformed reply shapes, untagged cross-shape targets, deep/invalid JSON.
3. Invariants by target
3.1 datagram_codec (wave 1)
Input: raw &[u8] (and in the shared logic, chunk-slice parameter
arbitrary). Drives DatagramReader::feed + frame_datagram
(src/wire.rs). Invariants:
- no-panic on any byte sequence (the baseline; the one
unreachable!infeedis a structural invariant of the state machine — the target is how it stays true). - infallibility:
feednever errors (contract — oversize is rejected at frame time; the decode direction is infallible by design,src/wire.rsdocs). - datagram-size bound: every yielded datagram is
<= MAX_DATAGRAM_LEN(u16 frame field makes this structural — the target pins it against future refactors). - no partial yields: a datagram completes only when exactly
declaredpayload bytes are buffered (is_mid_datagramobservable consistency);feed(&[])never yields anything and never clears mid-datagram state. - round-trip:
feed(frame_datagram(x)) == [x]for any x; the reverse directionwrite(feed-parse(x))re-frames to the same frame for any well-framed input. - F-2 sentinel layering:
len == 0is a legal empty datagram, never EOF; the codec never emits zero-length datagrams for non-empty frames and never treats an empty datagram as stream end. - consumption accounting: re-framing all consumed bytes and feeding the byte-level remainder yields the same datagram stream (splitting a feed at any boundary is transparent).
- oversize frame rejection:
frame_datagramerrors iff payload >MAX_DATAGRAM_LEN, echoing the sizes. - pending-queue bound (the §1.3 finding-class assertion): total
bytes retained across
buf+current+pendingafter a feed is bounded by the fed chunk size plus the one in-flight frame — an adversarial peer chunk cannot force unbounded queue growth in the decoder state. (NOTE: this is the decoder-state bound; the consumer-sideDatagramHalves.pendingbacklog between chunk-read andrecv_datagrampulls is a session-shape question tracked in §4/§8 — the wave-2 stateful target, or a code fix if the bound is judged unacceptable. The fuzzer documents the amplification factor; the remediation decision is ours.)
3.2 params_parse (wave 1)
Input: raw bytes → serde_json (with alkcall's bounded
arbitrary_value approach — depth/width-capped Value generation) →
TunnelParams / TunnelDirectParams / TunnelForwardedParams /
SubstrateAddr deserialization + parse_listen_reply /
listen_reply_fields (src/params.rs). Invariants:
- no-panic on any JSON-shaped input (untagged-enum serde dispatch,
deny_unknown_fields, thepositive_u32pin). - clean failure only: parse failures are
Err, never panics;parse_listen_replyreturnsTunnelError::MalformedReplyon malformed shapes. - round-trip for valid params:
from_json(to_json(p)) == pfor constructed params of every substrate. - deny-unknown posture: unknown top-level fields reject;
untagged-variant leniency (extra fields inside a
SubstrateAddrvariant) is the documented asymmetry — asserted, not "fixed". - the
listen: 0pin: zero rejects at serde level. - projection agreement:
listen_reply_fieldsyields both-Someiffparse_listen_replyisOk; it never panics and never refuses construction (the lenient view projects malformed to both-None). - establishment-reason surface:
establishment_reasonnever panics on arbitraryChannelOpenErrorshapes.
4. Scope: two waves
Wave 1 (this session): the two targets above. Both drive already-
pub APIs — no #[cfg(fuzzing)] exposure needed (matching alkcall's
experience where none of five targets needed it).
Wave 2 (deferred): a stateful op-sequence target over the two-pump
tunnel shape in the alkcall manager_routing style (#[derive(Arbitrary)]
op enum; live mux runner; the alkcall harness lessons apply: per-
receiver EOF-sentinel latching, odd/even id ranges on adopt/open).
Drives: TunnelSession pump lifecycle + the producer open handlers —
the two-pump shutdown-on-completion contract (AGENTS.md convention 8)
and the EOF-sentinel teardown semantics, with byte-accounting
invariants across pump_bidi. Also: a forwarded_routing correlation
target if the assembly-side correlation registry (ADR-008 §3, assembly
bookkeeping) grows into this crate. Highest harness cost of anything
here — the wave-1 targets will be saturated long before this one pays
its setup cost, which is exactly why it is deferred, not dropped.
Sequencing mirrors alkcall §7.4: init + wave-1 targets → 10–30 min campaigns per target via the detached runner → fix anything found → wave 2 when justified.
5. Operational isolation: campaigns never share fate with the agent session
This is an environment constraint, not a nice-to-have (upstream doc §7.6, adopted verbatim as policy here). Agent sessions (opencode) run fuzzer builds/campaigns through a bash tool that spawns the fuzzer as a child of the session's own process tree and cgroup. A fuzz run is precisely the workload shape that kills its own host:
- OOM-killer shared fate: the target's worst case IS unbounded allocation. If libFuzzer's soft RSS guard loses the race with the kernel, the OOM killer fires — and it picks the largest-RSS process in the cgroup, which can be the opencode server hosting the session. That is recoverable (sessions are recorded in a DB) but hugely derailing — the session's live environment access dies mid-task. An OOM in a fuzz target must cost the fuzzer, never the session.
- Fork bombs / CPU saturation:
-fork=Nworkers; a runaway campaign starves the session host. - Wedged targets: a deadlock in an async harness would hang a foreground bash call forever.
Three layers of defense, cheapest first — all mandatory:
- libFuzzer soft limits (always on):
-rss_limit_mb=2048-malloc_limit_mb=2048make libFuzzer exit cleanly (artifact recorded) before the kernel gets involved. Note the RSS limit is soft (polls/proc/self/statm); a fast huge allocation can beat it — that is what layers 2–3 are for. Neverulimit -vwith ASAN (it reserves terabytes of virtual address space); the ASAN-safe hard-limit recipe is-fork=1. - Fork mode as blast-radius containment (default):
-fork=1(or a small number) runs each input in a short-lived child — a crash/timeout/OOM/leak kills only that child; the harness survives, records the artifact, keeps fuzzing. Also the deadlock answer: the per-input timeout kills the child, not the session's bash call. - Process detachment from the session tree (MANDATORY for agent-run
campaigns): never run fuzzing as a foreground child of the
session. The runner script wraps
cargo fuzz runinsetsid+nohup+ full redirection to a log file: (a) removes the fuzzer from the session's controlling terminal and signal relation, (b) survives the session ending, (c) gives the agent a pollable log + artifact directory instead of a blocking call. Combined with fork mode, an OOM-class finding costs one child process.
The runner (fuzz/run-detached.sh, CWD-independent — resolves its
own repo root):
#!/usr/bin/env bash
# Detached fuzzing runner for agent sessions: the campaign never runs
# as a foreground child of the session (an OOM in a target must cost
# the fuzzer, never the agent host), and survives the session ending.
set -euo pipefail
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
shift
runtime="${FUZZ_RUNTIME_SECS:-600}"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
log_dir="$root/fuzz/artifacts"
mkdir -p "$log_dir"
log="$log_dir/${target}-$(date -u +%Y%m%d-%H%M%S).log"
cd "$root"
setsid nohup cargo fuzz run "$target" -- \
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
-max_total_time="$runtime" "$@" \
>"$log" 2>&1 < /dev/null &
echo "pid=$! log=$log"
Agent polling protocol (never wait on the detached process from a
tool call — poll instead):
tail -n 50 fuzz/artifacts/<target>-*.log— campaign progress.ls fuzz/artifacts/—crash-*/oom-*/timeout-*/leak-*artifacts are the findings.pgrep -f "cargo fuzz run <target>"— still running?- One detached campaign per target at a time; log filenames carry the UTC timestamp.
Flags worth pinning beyond the runner defaults: -max_len=65536
on the codec target; -use_value_profile=1; a JSON token -dict for
the params target (alkcall's json.dict transfers as-is).
6. No hosted CI
Per alkcall §7.9 — the standing policy applies to this repo identically:
no hosted CI will exist; do not add Actions/Gitea-Actions workflow
files. Corpus replay is the fuzz gate as plain cargo test:
cargo test --manifest-path fuzz/shared/Cargo.toml replays every
committed seed through the invariant functions — add it to the release
verification checklist (AGENTS.md). Campaigns run manually via the
detached runner (§6) — e.g. before a release, or after touching
src/wire.rs, the consumer data planes, or src/params.rs.
7. Implementation log
- 2026-09-28 — proposal adopted (this doc). Wave-1 targets
datagram_codec+params_parse; wave-2 deferred (§4). - 2026-09-28 —
alkcallbumped 0.8.0 → 0.8.1. Bug-fix release, no API/wire change; the fix (duplicate adopt/open no longer destroying the live channel) is directly relevant to this crate'sTunnelSession::adoptpath — the changelog's "open-op response replayed / id re-announced" scenario is our reverse path. Verification: full suite (135 passed), clippy, fmt, wasm check (§8 commands). - 2026-09-28 — wave-1 implemented.
fuzz/workspace laid out per §2 (nightly-pinned subtree, stablefuzz/sharedlibrary, runner, dict, generator);datagram_codec+params_parsetargets over already-pubAPIs — no#[cfg(fuzzing)]exposure needed. One new public diagnostic:DatagramReader::retained_bytes()(the decoder-state byte count the §3.1 bound asserts against, in theis_mid_datagramdiagnostic style). Seeds committed: 27 + 69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash input promoted todatagram_codec/seed-900(see the finding below). Stable side: corpus replay green, shared crate clippy/fmt clean; main suite untouched and green;cargo fuzz buildclean. - 2026-09-28 — first campaign finding: a HARNESS-model bug, not a
crate bug. The
datagram_codeccampaign crashed in its first minute on input00 2b 01 00 00 05(fuzz/artifacts/datagram_codec/ crash-e9ba284e…, exit 77, fork-mode containment worked as designed — the harness parent survived and persisted the artifact). Analysis: the codec is CORRECT —00 2bdeclares a 43-byte datagram;01 00 00 05is its in-flight payload; nothing is yielded. The invariant that tripped ("an empty datagram must come only from a [00 00] length header") was mis-modeled: it counted00 00byte pairs anywhere in the RAW stream, but a00 00pair sitting INSIDE a frame's payload region is payload, not a length header — exactly the same class as alkcall §7.7's probe (the harness claimed4 + lenwhen the prefix counts only the body). Fix: the count now walks the framing as the decoder does (complete frames from the start; the walk stops where the decoder would be stuck mid-frame). The crash input is committed as seed-900 and replays clean; the campaign relaunched clean. - 2026-09-28 — second harness finding (caught by corpus replay,
pre-campaign):
establishment_reasonis a pass-through extractor, not a validated table. The params.rs doc names the pinned reason strings, but the upstream implementation (alkcallchannels/client.rsestablishment_reason) extractsdetails.reasonAS-IS whenever the code ischannel:open_failed— a made-up reason is surfaced as-is by design, and validation is the consumer's job. The harness first asserted a closed set of reason strings and corpus replay tripped it onmade_up_reasonbefore any campaign ran. Invariant corrected to assert the extraction shape (Some iff code ==channel:open_failedand reason is a string). - 2026-09-28 — second harness finding (same replay pass):
is_mid_datagrammeans a partial DATAGRAM, not buffered header bytes. A dangling 1-byte length prefix at input end is buffered header state (no frame in flight,is_mid_datagram == false); the harness first assertedtruefor that case. Corrected: mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes (a parsed-but-unfinished payload), not!= 0. - 2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner;
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25; codec with-max_len=65536, params with-dict=json.dict -use_value_profile=1). Both exited 0.params_parse— 3.14M execs (~5.3k exec/s), 33 fork jobs,oom/timeout/crash: 0/0/0throughout. Coverage 2049 edges / 15.6k features; in-memory corpus 69 committed seeds → 3018 entries — the serde/untagged-enum shape space was still expanding at budget end, so longer campaigns keep paying here.datagram_codec(post-harness-fix) — 827k execs, 33 fork jobs,oom/timeout/crash: 0/0/0throughout. Coverage saturated at 221 edges (ft 1198) — the 2-byte-header state machine is fully enumerated, matching alkcall §7.7'schunk_headersaturation profile. Exec/s reads low (~400) because each exec replays the input through a ~35-feed 3-byte-sliced sequence alongside the whole-input feed; the state space is small and exhausted early.- The pre-fix
datagram_codecrun (the §7 finding above) crashed at job 4 and exited 77; fork-mode containment behaved as designed — the harness survived, persisted the artifact, and the run wrapped up. That artifact is the promoted seed-900. - Grown corpora (3018 params entries) were not merged into the committed seeds — per the §2 policy, grown corpora stay gitignored.
- Operational note for future sessions:
cargo fuzz runreturns before its last fork-mode children fully exit; apkill -f "cargo fuzz run <target>"followed immediately by anotherpgrepof the same pattern can wedge a bash tool call on its own process match — preferpgrep -afto LIST, kill by explicit pid from the runner's echoedpid=, and never pattern-kill-and-recheck in one call.
- NO crate bugs found by wave 1. The two findings this session
were both harness-model defects (§7 above), each invisible to
example-based review and caught by fuzzing/replay — the alkcall §7.7
"fuzzer as a harness-checker" pattern, confirmed. The §1.3
decoder-state bound held everywhere:
retained_bytes()never exceeded fed + one frame on any adversarial input. The session-shapeDatagramHalves.pendingamplification remains a documented §1.3/§4 note (wave-2's motivation), not a campaign finding — the wave-1 targets assert the decoder, not the session pump. - (progress appended as implementation proceeds — see git log)
8. Verification commands
cargo test # main suite (stable)
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate)
cargo fuzz build # in fuzz/ (nightly, ASAN)
fuzz/run-detached.sh <target> # campaigns (never foreground)
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings
cargo fmt --manifest-path fuzz/shared/Cargo.toml --check
cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean
9. References
- Upstream campaign + operational model (authoritative for tooling,
runner, corpus policy, no-CI posture):
/workspace/@alkdev/alkcall/docs/research/fuzzing.md - alkcall 0.8.1 changelog — the finding our adopt path is exposed to
- This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008
(direct/forwarded ops),
docs/architecture/wire.md - External: RUSTSEC-2026-0037 (quinn-proto — the maintainers' fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust Fuzz Book (all linked from the upstream doc's §9)