feat(fuzz): cargo-fuzz workspace, 3 wire-surface targets; bump alkcall 0.8.1
- Bump alkcall 0.8.0 -> 0.8.1 (duplicate adopt/open channel-state destruction fix from alkcall's fuzz campaign; AGENTS.md stale-pin fix). - Adopt fuzzing per docs/plans/fuzzing.md (mirrors alkcall's docs/research/fuzzing.md as-built layout): fuzz/ workspace with own [workspace] table, nightly pinned for the subtree only, invariant logic in a stable-toolchain shared crate. - Targets: chunk_frame (5-byte chunk codec), negotiation_frame (negotiation framing + NegotiateRequest + error_response_bytes + the cross-codec peek-disambiguation seam), control_json (ControlMessage JSON + signal_from_name). src/local/ out of scope (not wire-attacker-shaped). - 328 committed seeds (deterministic generator); grown corpora gitignored. Corpus replay on stable is the standing fuzz gate (cargo test --manifest-path fuzz/shared/Cargo.toml). - Detached-runner rule (fuzz/run-detached.sh): campaigns never run in the foreground of an agent session - OOM in a target must cost the fuzzer, never the session host. - Root Cargo.toml gained [workspace] members/exclude (MSRV vs fuzz nightly dev-deps footgun) and fuzz/ in the publish exclude. Verification: cargo test (113), cargo test --all-features (156), clippy stable + wasm32-unknown-unknown, fmt, doc, publish dry-run, corpus replay 328 seeds - all green. 10-min detached campaigns on all three targets: 0 crashes/hangs/OOMs/leaks (chunk_frame cov-saturated at 710 edges; negotiation_frame 3.69M execs cov 3157; control_json 8.6M execs cov 2035). Corpus replay caught three harness-model mismatches pre-campaign (payload-slice shape; the negotiation framing layer is length-prefix-only - Json unreachable from read_frame; zero-length frames admitted by the reader, rejected by the adapter's parse). Campaigns recorded in docs/plans/fuzzing.md section 8.
This commit is contained in:
1 parent
e95459c425
commit
49f70d9efb
1809 files changed
+9380
-12
No files matched your search
@@ -0,0 +1,296 @@
|
||||
# Fuzzing alktty: Plan
|
||||
|
||||
**Status:** adopted — implementation in progress (see §8 progress log)
|
||||
**Date:** 2026-09-28
|
||||
**Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the
|
||||
alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached
|
||||
runner, and no-CI policy below are adopted from it wholesale rather than
|
||||
re-derived).
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision: fuzz this crate
|
||||
|
||||
**Yes.** Alktty is the *named follow-up* in the alkcall research: §5
|
||||
"Non-surfaces" explicitly defers the downstream parsers to "downstream
|
||||
crates' fuzz targets" — the channels layer carries opaque `Bytes` and the
|
||||
handler owns sub-stream framing (ADR-035). Alktty is that handler, and it
|
||||
owns four parse surfaces fed by arbitrary peers on both transports (direct
|
||||
ALPN and channels):
|
||||
|
||||
| # | Surface | Location | Shape |
|
||||
|---|---|---|---|
|
||||
| 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` |
|
||||
| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199` — the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) |
|
||||
| 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct |
|
||||
| 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure |
|
||||
|
||||
Reasons this pays off here, beyond the generic alkcall argument:
|
||||
|
||||
1. **Semantic invariants no example test explores adversarially.** The
|
||||
peek/disambiguation machinery (`peek_stream_type` vs
|
||||
`read_chunk_after_peek` — both call orders must yield exactly one
|
||||
chunk) and the cross-codec invariant that makes it sound: an
|
||||
error-response frame's length-prefix high byte must be `0x00` so a
|
||||
`ChunkReader` peek distinguishes it from a chunk whose first byte is a
|
||||
server stream type ∈ {1, 2, 4} (`wire.rs:189-213`, ADR-052 §5). If
|
||||
`MAX_CHUNK_LEN` ever changes or a stream type is added, that invariant
|
||||
breaks subtly. A fuzz target spanning both codecs is the cheapest
|
||||
enforcement that exists.
|
||||
2. **The alkcall bug class has a plausible analog here.** The duplicate
|
||||
adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no
|
||||
unit test replays destroyed the live channel. Alktty's adapter has the
|
||||
same shape — take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
|
||||
session re-registration, handle replacement. A stateful op-sequence
|
||||
target against the session pump + mock backend is where a real finding
|
||||
would live.
|
||||
3. **Cost is low and the playbook is proven.** Mirroring alkcall's
|
||||
as-built layout is a port, not a design exercise (§5 below).
|
||||
|
||||
Zero parser-bug yield is a successful negative result — it converts "we
|
||||
think the codecs are robust" into a demonstrated property (the quinn
|
||||
lesson cuts both ways).
|
||||
|
||||
**Scope excludes `src/local/`:** PTY/pipe output is local-OS-shaped, not
|
||||
wire-attacker-shaped. The wire-facing side is entirely the
|
||||
shared/producer/consumer modules (`wire`, `negotiation`, `control`,
|
||||
`backend`, `adapter`, `channels`, `session`).
|
||||
|
||||
**The wasm invariant is unaffected:** `fuzz/` is host-only (nightly
|
||||
toolchain, `path` dep on the crate), excluded from publishing, and never
|
||||
compiled for `wasm32-unknown-unknown`.
|
||||
|
||||
## 2. Toolchain decision (adopted from alkcall §4/§7.7)
|
||||
|
||||
- `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own
|
||||
`[workspace]` table (nightly-only bins).
|
||||
- **The invariant logic lives in `fuzz/shared/` — a stable-toolchain
|
||||
library crate** — not inline in the target binaries. The `fuzz_target!`
|
||||
binaries are thin wrappers. This makes corpus replay a plain
|
||||
`cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it
|
||||
replays every committed seed through the identical invariant functions
|
||||
the fuzzer runs, and joins the release verification checklist.
|
||||
- `fuzz/rust-toolchain.toml` pins `nightly` for the subtree only; the
|
||||
crate stays stable at MSRV 1.88.
|
||||
- Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern);
|
||||
deterministic seed generator script committed next to them.
|
||||
- **No hosted CI** — this repo has the same no-CI policy as alkcall
|
||||
(§7.9 there): the gitea host serves git only; verification and
|
||||
campaigns are manual. Corpus replay is the standing fuzz gate;
|
||||
campaigns run via the detached runner before releases and after
|
||||
touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the
|
||||
adapter, or the session pump. Do not add workflow files or "when CI
|
||||
exists" language.
|
||||
- AFL/bolero/libafl/OSS-Fuzz: not adopted, same reasoning as alkcall §4.
|
||||
|
||||
## 3. Operational isolation: campaigns must be detached from the agent session
|
||||
|
||||
**This is mandatory, not a nice-to-have.** Agent sessions (opencode) run
|
||||
commands through a bash tool that spawns processes as children of the
|
||||
session's own process tree and cgroup. A fuzz campaign is exactly the
|
||||
workload shape that can kill its own host:
|
||||
|
||||
- **OOM-killer shared fate.** The target's worst case *is* unbounded
|
||||
allocation. libFuzzer's RSS guard is a *soft* poll — a fast single huge
|
||||
allocation can beat it, and then the kernel OOM killer fires. It picks
|
||||
the largest-RSS process in the cgroup, which can be the opencode server
|
||||
hosting the session. Losing that server is recoverable (sessions are
|
||||
recorded in a db) but hugely derailing and entirely avoidable. An OOM
|
||||
in a fuzz target must cost the fuzzer, never the session.
|
||||
- **Fork bombs / CPU saturation:** `-fork=N` workers and unbounded task
|
||||
spawning in a buggy target can starve the session host.
|
||||
- **Deadlock hangs:** a wedged target must not hang the session's bash
|
||||
call.
|
||||
|
||||
Three layers, all standard:
|
||||
|
||||
1. **libFuzzer soft limits (always on):** `-rss_limit_mb=2048`
|
||||
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class
|
||||
artifact) before the kernel gets involved. Do **not** use `ulimit -v`
|
||||
with ASAN (the sanitizer reserves terabytes of virtual address space;
|
||||
the classic failure is instant `MmapAlloc` death) — the ASAN-safe
|
||||
equivalent is `malloc_limit_mb`.
|
||||
2. **Fork mode as blast-radius containment (default):** `-fork=1` runs
|
||||
each input in a short-lived child; a crash/timeout/OOM/leak kills only
|
||||
that child, the parent records the artifact and keeps going. Also
|
||||
turns deadlocks into per-input timeouts instead of hung sessions.
|
||||
3. **Process detachment (mandatory for agent-run campaigns):** never run
|
||||
fuzzing as a foreground child of the session. `fuzz/run-detached.sh`
|
||||
wraps `cargo fuzz run` in `setsid` + `nohup` + redirection to a log
|
||||
file: removed from the session's controlling terminal and signal
|
||||
relation, survives the session ending, and gives a pollable log
|
||||
instead of a blocking call.
|
||||
|
||||
Polling protocol (never `wait` on the detached process):
|
||||
|
||||
```bash
|
||||
tail -n 50 fuzz/artifacts/<target>-*.log # campaign progress
|
||||
ls fuzz/artifacts/ # crash-* / oom-* / timeout-* artifacts
|
||||
pgrep -f "cargo fuzz run <target>" # still running?
|
||||
```
|
||||
|
||||
One session holds one detached campaign per target; log filenames carry
|
||||
UTC timestamps. `FUZZ_RUNTIME_SECS` overrides the per-campaign budget
|
||||
(default 600 s).
|
||||
|
||||
## 4. Targets
|
||||
|
||||
In priority order. Targets 1–3 are the initial scope; target 4 is second
|
||||
wave, decided after campaigns on 1–3 come back clean (same sequencing as
|
||||
alkcall §7.4).
|
||||
|
||||
| # | Target | Input style | Drives | Invariants |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers |
|
||||
| 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) |
|
||||
| 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) |
|
||||
| 4 | `session_opseq` (second wave) | `#[derive(Arbitrary)]` op sequence | the producer session pump + mock backend (`src/testing.rs`, `drive_session`) | no-panic; take-once slot semantics; duplicate/replayed session ops leave the live session intact — the alkcall-bug-class target |
|
||||
|
||||
### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
|
||||
|
||||
The consumer disambiguates an error-response frame from a raw chunk by
|
||||
peeking one byte (`wire.rs:189-213`): server stream types are {1, 2, 4};
|
||||
a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is
|
||||
neither. Encoded as fuzz assertions:
|
||||
|
||||
- For every `error_response_bytes` output and every negotiation frame of
|
||||
length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader`
|
||||
peek on `[frame bytes…]` never mistakes it for a chunk.
|
||||
- **Known boundary collision, encoded deliberately:** a frame of length
|
||||
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` —
|
||||
first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths
|
||||
admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an
|
||||
inherent edge of the one-byte-peek design; producers only ever send
|
||||
tiny error frames, so it is accepted — but the assertion pins the
|
||||
boundary so any future `MAX_CHUNK_LEN` change that widens the
|
||||
collision gets caught. (`validate_header`'s doc comment at
|
||||
`wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the
|
||||
claim holds only for `<` the cap. Worth a one-word fix in the doc
|
||||
comment if we touch `wire.rs` for any other reason.)
|
||||
|
||||
Also encoded (mirroring alkcall §7.7's trailing-byte probe): a valid
|
||||
negotiation frame with one extra JSON byte appended *and counted in the
|
||||
length prefix* must fail with a `Json` error, so a future switch to a
|
||||
tolerant streaming reader is caught.
|
||||
|
||||
## 5. Layout (mirrors alkcall §7.7 as-built)
|
||||
|
||||
```
|
||||
fuzz/
|
||||
├── Cargo.toml alktty-fuzz (nightly-only bins; own [workspace])
|
||||
├── rust-toolchain.toml pins nightly for this subtree only
|
||||
├── fuzz_targets/
|
||||
│ ├── chunk_frame.rs thin fuzz_target! wrapper
|
||||
│ ├── negotiation_frame.rs thin wrapper
|
||||
│ └── control_json.rs thin wrapper
|
||||
├── shared/ alktty-fuzz-shared — STABLE-toolchain library
|
||||
│ └── src/{chunk_frame,negotiation_frame,control_json}.rs
|
||||
├── corpus/<target>/ committed seeds
|
||||
├── gen_fuzz_seeds.py deterministic seed generator
|
||||
├── run-detached.sh §3 detached runner (CWD-independent)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
|
||||
|
||||
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without
|
||||
it cargo auto-discovers `fuzz/shared/` into the main workspace and the
|
||||
MSRV toolchain tries to build nightly-consumed dev-deps.
|
||||
- `"fuzz/"` added to the publish `exclude` list.
|
||||
|
||||
No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`,
|
||||
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
|
||||
`ControlMessage`, and `error_response_bytes` are all already `pub`
|
||||
(alkcall needed none of its five either). The one private constant,
|
||||
`MAX_CHUNK_LEN`, is wire-stable (ADR-001); the shared crate carries its
|
||||
own copy, and the shape invariants assert the rejection boundary so a
|
||||
drift is caught.
|
||||
|
||||
## 6. Corpus policy
|
||||
|
||||
Commit hand-made seeds; gitignore grown corpora and artifacts
|
||||
(`fuzz/.gitignore` per alkcall's). Seeds per target: valid frames/headers
|
||||
for every stream type / every control variant, truncation at every prefix
|
||||
length, `length = 0`, `length = MAX`, `length = MAX + 1`, `length =
|
||||
u32::MAX`, invalid UTF-8 in JSON, deep-ish nesting, peek-interleave
|
||||
fixtures, cross-codec error-frame fixtures. The generator script is
|
||||
deterministic (no randomness) so seeds are reproducible.
|
||||
|
||||
## 7. Sequencing
|
||||
|
||||
1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on
|
||||
alktty's own path — `TtySession::open_via_channels` and the channels
|
||||
establisher run on the fixed `ChannelManager`). Full verification set
|
||||
including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0"
|
||||
line while touching it. **Status: see §8.**
|
||||
2. Scaffold `fuzz/` + root workspace table + publish exclude.
|
||||
3. Targets 1–3 invariant logic in `fuzz/shared/` + thin target bins +
|
||||
seeds + detached runner + README.
|
||||
4. Corpus replay green on stable (`cargo test --manifest-path
|
||||
fuzz/shared/Cargo.toml`); add it to the AGENTS.md verification
|
||||
checklist.
|
||||
5. Detached campaigns (10 min per target via `run-detached.sh`), triage
|
||||
anything found, record results in §8.
|
||||
6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
|
||||
|
||||
## 8. Progress log (append-only across sessions)
|
||||
|
||||
- **2026-09-28** — Proposal discussed and adopted; this plan written.
|
||||
Alkcall dependency bump + fuzz scaffold started.
|
||||
- **2026-09-28 (later)** — **All of §7 steps 1–5 landed.**
|
||||
- alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 +
|
||||
all-features 156 tests, clippy stable/wasm, fmt, doc).
|
||||
- AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus
|
||||
replay added to the verification checklist; campaign guidance
|
||||
added.
|
||||
- `fuzz/` scaffolded exactly as §5: root `[workspace]` table + publish
|
||||
exclude, `fuzz/rust-toolchain.toml` nightly pin, thin target bins,
|
||||
invariant logic in `fuzz/shared/` (stable toolchain), 328 committed
|
||||
seeds via `gen_fuzz_seeds.py`, `run-detached.sh`, README,
|
||||
`.gitignore`.
|
||||
- Corpus replay green on stable
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`).
|
||||
- Harness-model corrections the corpus replay caught *before* any
|
||||
campaign (the alkcall §7.7 pattern repeating): (1) a valid first
|
||||
chunk with trailing stream bytes is correct reader behavior — the
|
||||
payload slice is `5..5+len`, not `5..`; (2) the negotiation framing
|
||||
layer is length-prefix-only — `read_frame` returns raw bytes and the
|
||||
adapter does the serde parse (`adapter.rs:411`), so
|
||||
`NegotiationError::Json` is unreachable from this reader (unlike
|
||||
alkcall's serde-backed envelope reader; the `Json` arm now panics as
|
||||
"unreachable" and the trailing-byte probe asserts exact-body
|
||||
consumption instead of a Json error); (3) zero-length negotiation
|
||||
frames are admitted by this reader (the adapter's parse rejects
|
||||
them) — the shape assertion was relaxed accordingly.
|
||||
- **10-min detached campaigns, all three targets: every run exited 0
|
||||
with an empty artifact directory — no crashes, hangs, OOMs, or
|
||||
leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
|
||||
jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within
|
||||
seconds — the 5-byte-parser ceiling is fully enumerated);
|
||||
`negotiation_frame` 3.69M execs (cov 3157, still finding coverage at
|
||||
budget end — JSON structure exploration); `control_json` 8.6M execs
|
||||
(cov 2035, still growing). Grown corpus entries were deleted per the
|
||||
§6 policy; committed seeds unchanged.
|
||||
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
|
||||
session.
|
||||
|
||||
## 9. Relationship to existing tests
|
||||
|
||||
The existing suite covers valid-input round-trips and documented error
|
||||
paths (`wire.rs` codec tests, `negotiation.rs` and `tests/negotiation.rs`
|
||||
frame tests, `control.rs` message tests). Fuzzing adds: random malformed
|
||||
input exploration (the quinn CVE class), stateful interleavings (target
|
||||
4), and adversarial exploration of the peek/disambiguation seam that
|
||||
example-based tests structurally cannot enumerate.
|
||||
|
||||
Verification additions once landed:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate
|
||||
```
|
||||
|
||||
…added to the AGENTS.md checklist alongside the existing commands. The
|
||||
wasm checks (`cargo check --target wasm32-unknown-unknown`,
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`) remain
|
||||
untouched by fuzz work but must still pass after the root `Cargo.toml`
|
||||
gains its `[workspace]` table.
|
||||
Reference in new issue
Block a user