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
@@ -238,6 +238,7 @@ cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo doc --no-deps
|
||||
cargo test --all-features
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay — the standing fuzz gate
|
||||
cargo check --target wasm32-unknown-unknown # the default crate stays wasm-clean
|
||||
cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
||||
cargo publish --dry-run --allow-dirty # before a release
|
||||
@@ -248,6 +249,13 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
|
||||
`cargo test --all-features` exercises the `local` backend (PTY mode is
|
||||
`#[cfg(unix)]`; pipe mode is cross-platform).
|
||||
|
||||
The fuzz corpus replay (`docs/plans/fuzzing.md`) runs every committed
|
||||
seed through the same invariant functions the cargo-fuzz targets run —
|
||||
on stable, no nightly needed. Run detached campaigns
|
||||
(`fuzz/run-detached.sh <target>` — never foreground fuzzing) before a
|
||||
release and after touching `src/wire.rs`, `src/negotiation.rs`,
|
||||
`src/control.rs`, the adapter, or the session pump.
|
||||
|
||||
## Architecture Context
|
||||
|
||||
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All
|
||||
@@ -312,11 +320,9 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
|
||||
`BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`,
|
||||
`IdentityProvider`, `AccessControl`, `OwnershipProvider`,
|
||||
`OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`,
|
||||
`StreamError`) come from `alkcall::core`. alkcall is v0.4.x —
|
||||
breaking changes are expected at this major-zero stage; this is the
|
||||
first real consumer, so we find and fix issues upstream rather than
|
||||
working around them. Pin `alkcall = "0.6.0"` (the
|
||||
establishment-plan payload — ADR-049 amendment 2, review 007 R-01;
|
||||
adopted by alktty ADR-010 §2A — allocation runs in the channels
|
||||
establisher, the `TtyHandle` crosses via the plan) and bump
|
||||
deliberately.
|
||||
`StreamError`) come from `alkcall::core`. alkcall is v0.8.x —
|
||||
breaking changes are expected at this major-zero stage; this is the
|
||||
first real consumer, so we find and fix issues upstream rather than
|
||||
working around them. Pin `alkcall = "0.8.1"` (0.8.1 fixes the
|
||||
duplicate adopt/open channel-state destruction found by alkcall's
|
||||
fuzz campaign) and bump deliberately.
|
||||
Generated
+2
-2
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8695d47cee5b5a5fe68471f04a505bc257a7e7ed9e64d827de33ff83adf4db12"
|
||||
checksum = "bf6af1e4af1460480849adf959c972d65deeafc36f4101064ced1ce6d1adfd5b"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
||||
+6
-2
@@ -9,7 +9,11 @@ repository = "https://git.alk.dev/alkdev/alktty"
|
||||
readme = "README.md"
|
||||
keywords = ["tty", "terminal", "pty", "channels", "alkcall"]
|
||||
categories = ["network-programming", "asynchronous"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md", "fuzz/"]
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[lib]
|
||||
name = "alktty"
|
||||
@@ -25,7 +29,7 @@ default = []
|
||||
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = "0.8.0"
|
||||
alkcall = "0.8.1"
|
||||
# Minimal, wasm-clean tokio features. `local` adds `process` +
|
||||
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
|
||||
# pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
target
|
||||
corpus/*
|
||||
!corpus/chunk_frame
|
||||
!corpus/negotiation_frame
|
||||
!corpus/control_json
|
||||
artifacts
|
||||
coverage
|
||||
Generated
+1312
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "alktty-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
alktty-fuzz-shared = { path = "shared" }
|
||||
|
||||
[dependencies.alktty]
|
||||
path = ".."
|
||||
|
||||
[[bin]]
|
||||
name = "chunk_frame"
|
||||
path = "fuzz_targets/chunk_frame.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "negotiation_frame"
|
||||
path = "fuzz_targets/negotiation_frame.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "control_json"
|
||||
path = "fuzz_targets/control_json.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,60 @@
|
||||
# alktty fuzzing
|
||||
|
||||
cargo-fuzz targets for the wire-facing parse surfaces. The design and
|
||||
operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
|
||||
`docs/research/fuzzing.md`); this README is the operational cheat-sheet.
|
||||
|
||||
## Layout
|
||||
|
||||
- `fuzz_targets/` — nightly-only `fuzz_target!` binaries (thin wrappers).
|
||||
- `shared/` — stable-toolchain library holding the invariant logic; the
|
||||
corpus replay tests run here on plain `cargo test`.
|
||||
- `corpus/<target>/` — committed seeds (regenerate with
|
||||
`python3 fuzz/gen_fuzz_seeds.py`).
|
||||
- `artifacts/` — gitignored crash/oom/timeout artifacts + campaign logs.
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Drives |
|
||||
|---|---|
|
||||
| `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) |
|
||||
| `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam |
|
||||
| `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` |
|
||||
|
||||
## Running a campaign — always detached
|
||||
|
||||
Agent sessions must never run fuzzing in the foreground (an OOM in a
|
||||
target can take down the session host; see docs/plans/fuzzing.md §3).
|
||||
Use the detached runner:
|
||||
|
||||
```bash
|
||||
fuzz/run-detached.sh chunk_frame
|
||||
# poll:
|
||||
tail -n 50 fuzz/artifacts/chunk_frame-*.log
|
||||
ls fuzz/artifacts/chunk_frame/
|
||||
pgrep -f "cargo fuzz run chunk_frame"
|
||||
```
|
||||
|
||||
`FUZZ_RUNTIME_SECS=1800 fuzz/run-detached.sh negotiation_frame` for a
|
||||
longer campaign. The runner pins `-fork=1 -rss_limit_mb=2048
|
||||
-malloc_limit_mb=2048 -timeout=25` and detaches via `setsid` + `nohup`.
|
||||
|
||||
## Corpus replay (the standing fuzz gate)
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml
|
||||
```
|
||||
|
||||
Replays every committed seed through the same invariant functions the
|
||||
fuzzer runs, on stable, no nightly needed. Part of the release
|
||||
verification checklist (AGENTS.md).
|
||||
|
||||
## Toolchain notes
|
||||
|
||||
`rust-toolchain.toml` pins nightly for this subtree only (llvm-tools
|
||||
required by cargo-fuzz); the crate itself stays on stable at MSRV 1.88.
|
||||
`cargo fuzz build` must be run with the fuzz dir as CWD or trust the
|
||||
toolchain file (rustup resolves per directory).
|
||||
|
||||
Run campaigns before releases and after touching `src/wire.rs`,
|
||||
`src/negotiation.rs`, `src/control.rs`, the adapter, or the session pump.
|
||||
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 1809 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user