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:
glm-5.3-flash committed 2026-09-28 08:07:30 +00:00
1 parent e95459c425
commit 49f70d9efb
1809 files changed
+9380 -12

No files matched your search

+296
View File
@@ -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.