Files
alktunnels/docs/research/fuzzing.md
T
glm-5.3-flash 57285d23bd fuzz: adopt fuzzing (wave 1) + alkcall 0.8.1 bump
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.
2026-09-28 08:09:11 +00:00

22 KiB
Raw Blame History

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:

  1. alkcall's fuzz doc already assigned this to us. Its §5 "non-surfaces (deliberately)" note ends: the channels layer carries opaque Bytes and the handler owns sub-stream framing (ADR-035) — those downstream parsers are downstream crates' fuzz targets. Our wire.rs datagram codec IS that named downstream target. Fuzzing it completes alkcall's fuzz scope rather than duplicating it.

  2. 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.

  3. 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 M VecDeque entries (≈ 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 plain cargo test is the fuzz gate.
  • Root Cargo.toml needs the explicit [workspace] table (members = ["."], exclude = ["fuzz"]) and "fuzz/" on the publish exclude list — without it cargo auto-discovers fuzz/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! in feed is a structural invariant of the state machine — the target is how it stays true).
  • infallibility: feed never errors (contract — oversize is rejected at frame time; the decode direction is infallible by design, src/wire.rs docs).
  • 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 declared payload bytes are buffered (is_mid_datagram observable consistency); feed(&[]) never yields anything and never clears mid-datagram state.
  • round-trip: feed(frame_datagram(x)) == [x] for any x; the reverse direction write(feed-parse(x)) re-frames to the same frame for any well-framed input.
  • F-2 sentinel layering: len == 0 is 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_datagram errors iff payload > MAX_DATAGRAM_LEN, echoing the sizes.
  • pending-queue bound (the §1.3 finding-class assertion): total bytes retained across buf + current + pending after 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-side DatagramHalves.pending backlog between chunk-read and recv_datagram pulls 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, the positive_u32 pin).
  • clean failure only: parse failures are Err, never panics; parse_listen_reply returns TunnelError::MalformedReply on malformed shapes.
  • round-trip for valid params: from_json(to_json(p)) == p for constructed params of every substrate.
  • deny-unknown posture: unknown top-level fields reject; untagged-variant leniency (extra fields inside a SubstrateAddr variant) is the documented asymmetry — asserted, not "fixed".
  • the listen: 0 pin: zero rejects at serde level.
  • projection agreement: listen_reply_fields yields both-Some iff parse_listen_reply is Ok; it never panics and never refuses construction (the lenient view projects malformed to both-None).
  • establishment-reason surface: establishment_reason never panics on arbitrary ChannelOpenError shapes.

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=N workers; 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:

  1. libFuzzer soft limits (always on): -rss_limit_mb=2048 -malloc_limit_mb=2048 make 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. Never ulimit -v with ASAN (it reserves terabytes of virtual address space); the ASAN-safe hard-limit recipe is -fork=1.
  2. 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.
  3. 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 run in setsid + 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 — alkcall bumped 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's TunnelSession::adopt path — 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, stable fuzz/shared library, runner, dict, generator); datagram_codec + params_parse targets over already-pub APIs — no #[cfg(fuzzing)] exposure needed. One new public diagnostic: DatagramReader::retained_bytes() (the decoder-state byte count the §3.1 bound asserts against, in the is_mid_datagram diagnostic style). Seeds committed: 27 + 69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash input promoted to datagram_codec/seed-900 (see the finding below). Stable side: corpus replay green, shared crate clippy/fmt clean; main suite untouched and green; cargo fuzz build clean.
  • 2026-09-28 — first campaign finding: a HARNESS-model bug, not a crate bug. The datagram_codec campaign crashed in its first minute on input 00 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 2b declares a 43-byte datagram; 01 00 00 05 is 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 counted 00 00 byte pairs anywhere in the RAW stream, but a 00 00 pair 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 claimed 4 + len when 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_reason is a pass-through extractor, not a validated table. The params.rs doc names the pinned reason strings, but the upstream implementation (alkcall channels/client.rs establishment_reason) extracts details.reason AS-IS whenever the code is channel: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 on made_up_reason before any campaign ran. Invariant corrected to assert the extraction shape (Some iff code == channel:open_failed and reason is a string).
  • 2026-09-28 — second harness finding (same replay pass): is_mid_datagram means 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 asserted true for 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/0 throughout. 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/0 throughout. Coverage saturated at 221 edges (ft 1198) — the 2-byte-header state machine is fully enumerated, matching alkcall §7.7's chunk_header saturation 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_codec run (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 run returns before its last fork-mode children fully exit; a pkill -f "cargo fuzz run <target>" followed immediately by another pgrep of the same pattern can wedge a bash tool call on its own process match — prefer pgrep -af to LIST, kill by explicit pid from the runner's echoed pid=, 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-shape DatagramHalves.pending amplification 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)