- Rewrote the remaining em dashes outside docs/reviews/ (preserved as historical record, matching the alktty precedent): tests/ (313), fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in fences), docs/research + sdd_process + plans (525 plus 25 in fences), docs/architecture/overview.md fence labels (13), README, CHANGELOG, AGENTS.md, fuzz/README.md (176), and the .opencode/agents prompts (110) - Rewrote fenced content too: ASCII tree and module-map label separators, Rust snippet comments, and template placeholders; wire data, JSON/BAST examples, and transcripts survive verbatim - Rewrote 6 assert/expect message literals in tests/ and 2 format strings in fuzz/shared/src; no test data changed - Added Project Convention 18: no em dashes in prose (doc comments, markdown including docs/ and tasks/, agent prompts, commit messages); fence content rule, en-dash clause rule, and the no-spaced-hyphen prohibition follow alktty's convention 15 Follows6b20924(src doc comments) andcd0afe0(docs/architecture). Verification: cargo test (141 pass); cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/
596 lines
32 KiB
Markdown
596 lines
32 KiB
Markdown
# 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
|
||
(`session_pumps`) implemented and campaigning (see §3.3/§7).
|
||
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 to 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),
|
||
and 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
|
||
│ └── session_pumps.rs
|
||
├── shared/ alktunnels-fuzz-shared, the 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, so
|
||
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. **Wave-2 resolution (§3.3):** the
|
||
session-shape backlog is now an asserted invariant: the harness's
|
||
per-op drain holds the pending queue to the routed-batch remainder
|
||
(≤ one chunk's worth of batched datagrams, drained by
|
||
`recv_datagram` pulls), so the amplification question is bounded
|
||
by construction + assertion rather than remediated; the
|
||
`datagram_backlog()` diagnostic exposes the live count.)
|
||
|
||
### 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.
|
||
|
||
### 3.3 `session_pumps` (wave 2)
|
||
|
||
Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence`, the
|
||
`manager_routing` pattern) over one live channels stack per exec:
|
||
MuxRunner → raw chunk stream → the real `run_demux_loop_for_client`
|
||
demux → `ChannelManager` per side (producer Connect / consumer
|
||
Accept over one duplex transport, the reverse POC's topology). Ops:
|
||
`Route` (producer→consumer framed datagrams / raw stream bytes via
|
||
`route_payload`), `AdoptDatagram` / `AdoptStream` (the
|
||
`TunnelSession` data-plane mirrors: codec reader + `pending`, and
|
||
`pump_bidi` against in-process peer halves), `Pump` (peer→channel;
|
||
`len == 0` drops the p2c source, the EOF path), `Teardown`,
|
||
`ClearAll`. Invariants:
|
||
|
||
- **no-panic / no-wedge on any op sequence** (the baseline; the
|
||
harness's 5 s reconcile bound turns a wedged pump into a finding,
|
||
and §5's `-timeout=25` fork containment is the outer net).
|
||
- **two-pump shutdown-on-completion (convention 8)**: after the p2c
|
||
source drops, the pump's channel-side shutdown must propagate and
|
||
the pump must complete once the teardown EOFs the c2p source,
|
||
pinned by the `reconcile` join with the 5 s bound.
|
||
- **lossless p2c copy**: the pump's `p2c` count equals exactly what
|
||
the harness pushed into the peer half (copy never truncates).
|
||
- **EOF-sentinel teardown semantics**: after a sentinel (teardown /
|
||
`clear_all`), the reassembled stream drains its queue before
|
||
reporting EOF, with every routed datagram consumable and nothing
|
||
lost;
|
||
the consumer map empties; the codec's `len == 0` sentinel layering
|
||
holds at the session layer (a routed empty datagram is data; the
|
||
channels EOF is a separate mechanism).
|
||
- **FIFO reconciliation**: every complete frame routed through the
|
||
real demux path arrives as exactly one datagram of the declared
|
||
length, in order (the model walks the framing as the codec does,
|
||
the wave-1 F-2 walk reused at the session layer).
|
||
- **pending-queue bound (the §1.3 finding-class assertion)**: the
|
||
session-shape `DatagramHalves.pending` backlog never exceeds the
|
||
unrouted remainder (i.e. the backlog holds only datagrams decoded
|
||
from routed chunks beyond the ones consumed; with the harness's
|
||
per-op drain the queue returns to zero after every op, so an
|
||
amplification factor > 1× chunk size would trip the assert. (The
|
||
wave-1 decoder-state bound re-asserted per op alongside.)
|
||
- **monotonicity**: the manager's early-arrival / dropped counters
|
||
survive `clear_all` (the alkcall §7.7 pattern), and the consumer
|
||
map empties on the transport-EOF path.
|
||
|
||
Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's
|
||
`MAX_DATAGRAM_LEN` stays the wire bound). Even-id masking (ADR-047
|
||
§5: the consumer is the Accept side; the producer allocates even).
|
||
|
||
## 4. Scope: two waves
|
||
|
||
**Wave 1 (complete):** the two targets above. Both drive already-
|
||
`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed (matching alkcall's
|
||
experience where none of five targets needed it).
|
||
|
||
**Wave 2 (this session):** `session_pumps`, the stateful op-sequence
|
||
target over the two-pump tunnel shape in the alkcall `manager_routing`
|
||
style (`#[derive(Arbitrary)]` op enum; live mux + demux runners; the
|
||
alkcall harness lessons applied: even-id masking, drained-state
|
||
asserts). Drives: the `TunnelSession` data-plane shapes (the datagram
|
||
codec plane + the `pump_bidi` stream plane) over a REAL channels
|
||
stack: the two-pump shutdown-on-completion contract (AGENTS.md
|
||
convention 8), the EOF-sentinel teardown semantics, and the §1.3
|
||
backlog bound as byte-accounting invariants (§3.3). The
|
||
`forwarded_routing` correlation target stays deferred until the
|
||
assembly-side correlation registry (ADR-008 §3, assembly
|
||
bookkeeping) grows into this crate.
|
||
|
||
Sequencing mirrors alkcall §7.4: init + wave-1 targets → 10–30 min
|
||
campaigns per target via the detached runner → fix anything found →
|
||
wave 2 (`session_pumps`) → further targets 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, since
|
||
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, and 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, so 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, which (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, since it
|
||
resolves its
|
||
own repo root):
|
||
|
||
```bash
|
||
#!/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, with 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, since `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, which is 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`,
|
||
so
|
||
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,
|
||
with the harness surviving, persisting the artifact, and the run
|
||
wrapping up. That artifact is the promoted seed-900.
|
||
- Grown corpora (3018 params entries) were **not** merged into the
|
||
committed seeds, since
|
||
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, since
|
||
the
|
||
wave-1 targets assert the decoder, not the session pump.
|
||
- **2026-09-28, wave-2 implemented: `session_pumps` (the stateful
|
||
two-pump/EOF-sentinel target, §3.3).** The harness mirrors
|
||
`TunnelSession`'s data-plane shapes over one REAL channels stack
|
||
per exec (MuxRunner → raw chunk stream → the real
|
||
`run_demux_loop_for_client` demux → `ChannelManager` per side over
|
||
one duplex transport; producer Connect / consumer Accept, the
|
||
reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/
|
||
Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic
|
||
addition to the crate: `TunnelSession::datagram_backlog()` (the
|
||
`DatagramHalves.pending` count the §3.3 bound asserts against, in
|
||
the `retained_bytes()` diagnostic style) + a suite test pinning
|
||
its rest states. Seeds: 8 committed (deterministic decode-scan
|
||
hex patterns, the `manager_routing` precedent, since
|
||
arbitrary ships
|
||
no encoder) + 2 in-source semantic fixtures (the §1.3 batched-
|
||
backlog probe; the W4 half-close shape). Two harness-model
|
||
findings caught by corpus replay pre-campaign:
|
||
- **the p2c-EOF pump-join wedge is the DESIGNED half-close
|
||
shape, not a wedge.** The first `reconcile` draft awaited the
|
||
pump after dropping only the p2c source, but the c2p pump's
|
||
source (the channel read half) EOFs only via teardown, so the
|
||
join legitimately blocked (the 5 s bound tripped). `pump_bidi`
|
||
completes when BOTH pumps finish; the half-close contract is
|
||
that the p2c completion shuts the channel side down (the
|
||
observable), and the pump completes once the c2p source EOFs
|
||
too. The harness now models exactly that: drop p2c source →
|
||
teardown the entry → join with the 5 s bound. (The alkcall §7.7
|
||
harness-checker pattern again: the fuzzer harness's first
|
||
campaign-found crash in wave 1 and this replay catch are both
|
||
harness-model defects, not crate bugs.)
|
||
- **the stream-plane c2p accounting is asynchronous**: the pump
|
||
writes channel→peer into the peer duplex while a far-end reader
|
||
task counts; the reconcile join (after the pump completes) is
|
||
when the count is final. First draft read it early. Fixed by
|
||
reading after the reader task's join.
|
||
- **2026-09-28, third harness finding (campaign-found, fork-mode
|
||
containment as designed): duplicate adopt is a LEGAL manager
|
||
outcome, not a finding.** The first campaign crashed at ~49 s on
|
||
`AdoptDatagram { channel_id: 0 }` after the id had already been
|
||
adopted. `ChannelExists` is the manager's documented
|
||
id-collision error, and my adopt arms treated every error as a
|
||
finding. Fix: both adopt arms model the documented error table
|
||
(`ChannelExists` / `TooManyChannels` are no-ops, exactly the
|
||
alkcall `manager_routing` harness's treatment). The crash input
|
||
is committed as seed-900 and replays clean; the campaign
|
||
relaunched clean.
|
||
- **2026-09-28, fourth harness finding (second campaign): the
|
||
harness modeled the producer→consumer path through the WRONG
|
||
primitive.** The second campaign crashed at ~5 s on `Route`:
|
||
raw-id addressing (`even_id` masked arbitrary ids; collisions +
|
||
an `UnknownChannel`-class state the model didn't track), `len ==
|
||
0` raw routes doubling as EOF sentinels mid-model, and
|
||
(the structural one) `route_payload` was used as the producer's
|
||
forward write. `route_payload` routes into the LOCAL manager's
|
||
channel entry (the demux direction); the producer pump handler
|
||
writes the channel's SEND half (the mux pump frames onto the
|
||
transport). Bytes never reached the consumer. Rewrite (the real
|
||
protocol shape, matching `manager_routing`'s op-vocabulary
|
||
lesson): producer `Open` (`open_channel` allocates the id, the
|
||
harness holds the send half, the pump handler's write side),
|
||
session-INDEX addressing (no masking, no collisions), `Adopt`
|
||
consumes the producer-allocated id (the early-arrival drain is
|
||
the real adopt-race path), `Route` writes the producer send half
|
||
(the real wire path: mux pump → transport → consumer demux →
|
||
reassembly), `Pull` (the `recv_datagram` pull pops the backlog +
|
||
the model), and a STATEFUL framing model (partial frames persist
|
||
across ops, with the wave-1 walk carried across ops). EOF sentinels
|
||
now arise only from teardown/sender-drop (the real mechanisms),
|
||
never from `len == 0` routes (F-2: a routed empty datagram is
|
||
DATA). All committed session_pumps seeds regenerated by the
|
||
rewrite (the old hex patterns decode against the new op enum);
|
||
the semantic fixtures re-pinned. Verification of the fix: the
|
||
campaign's 0-exec crash went away; batched frames now reconcile
|
||
FIFO through the real two-manager stack.
|
||
- **2026-09-28, fifth + sixth harness findings (third campaign's
|
||
first minutes): the two ClearAll modeling defects.** (5) A
|
||
double-adopt of a live session panicked the harness's own guard,
|
||
but a re-adopt of a LIVE id is the manager's documented
|
||
`ChannelExists` outcome (a legal no-op; alkcall 0.8.1's
|
||
insert-replace fix leaves the live entry intact). Modeled as a
|
||
no-op. (6) A `ClearAll` followed by `Route` on the same session
|
||
tripped the backlog reconciliation. The harness retired only the
|
||
sessions whose ids appeared in `clear_all`'s RETURN value, but
|
||
that value is the OPENER-LEDGER records (ADR-047 §7): adopted
|
||
channels carry no ledger entry, so the set is empty while the map
|
||
itself empties. The alkcall §7.7 lesson ("`drained.len() ==
|
||
open_count()` is not an invariant"), learned again at this
|
||
crate's layer: the map-empties assert is the ground truth; the
|
||
harness now retires EVERY session on `ClearAll`.
|
||
- **2026-09-28, wave-2 campaign run (10-min detached run, §6
|
||
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
|
||
-timeout=25 -max_len=4096 -use_value_profile=1`): exited 0.**
|
||
228,676 execs (~380 exec/s; each exec replays up to 128 ops on a
|
||
live two-manager stack with real mux/demux tasks), 32 fork jobs,
|
||
`oom/timeout/crash: 0/0/0` throughout. Coverage 3427 edges /
|
||
17,529 features / 1184 in-memory corpus entries and STILL
|
||
EXPANDING at budget end (the op-interleaving space is much larger
|
||
than the parser targets'), so longer campaigns keep paying. The
|
||
two-pump contract held across every adversarial sequence: no
|
||
wedged pump (the 5 s reconcile join), no lost datagram (the
|
||
FIFO/backlog reconciliation), no premature EOF (the sentinel
|
||
layering). The §1.3 backlog bound held everywhere: the
|
||
`DatagramHalves.pending` queue never exceeded the
|
||
routed-but-unpulled remainder.
|
||
- **NO crate bugs found by wave 2.** All six findings across waves
|
||
1–2 were harness-model defects (four this session), each
|
||
invisible to example-based review and caught by fuzzing/replay:
|
||
the alkcall §7.7 "fuzzer as a harness-checker" pattern,
|
||
re-confirmed. The stateful thesis is validated from the harness
|
||
side: the harness defects themselves (double-adopt semantics, the
|
||
clear_all ledger/map distinction, the route_payload direction)
|
||
are exactly the interleaving-shape mistakes the alkcall §7.8
|
||
campaigns caught in their own drivers.
|
||
- (progress appended as implementation proceeds; see git log)
|
||
|
||
## 8. Verification commands
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
Campaign flags per target: `datagram_codec -max_len=65536`;
|
||
`params_parse -dict=json.dict -use_value_profile=1`;
|
||
`session_pumps -max_len=4096 -use_value_profile=1`.
|
||
|
||
## 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) |