feat(fuzz): cargo-fuzz workspace, chunk_header + envelope_frame targets (fuzzing.md step 1)

- fuzz/ workspace (nightly-pinned via rust-toolchain.toml, excluded from
  the main workspace and the published package): chunk_header and
  envelope_frame targets per fuzzing.md \u00a77.1
- invariant logic in fuzz/shared (stable-toolchain crate): committed
  corpus replay as plain cargo test (quinn CI pattern, \u00a77.2 tier 3)
- envelope target adds FrameError shape-partition asserts, exact
  consumption accounting, structural write_frame round-trip, serde
  key-contract, and a trailing-byte probe (prefix counts body only)
- chunk_header target adds round-trip identity, TooLarge/short error
  shape, is_eof, 8-byte consumption, input-never-mutated
- committed seed corpora: 245 deterministic seeds via
  fuzz/gen_fuzz_seeds.py (truncations, len=0/MAX+1/u32::MAX, channel 0,
  invalid UTF-8, deep nesting); grown corpora + artifacts gitignored
- fuzz/run-detached.sh: \u00a77.6 detached runner (setsid+nohup+log, fork
  mode, rss/malloc limits) \u2014 campaigns never share fate with a session
- fuzz/json.dict; README; research doc \u00a77.7 records step-1 status

Verification: cargo fuzz build clean; stable side green (cargo test 682
passed, clippy -D warnings, fmt --check incl. fuzz/shared); corpus
replay 245 seeds green; detached 10-min campaigns on both targets
completed with zero crashes/OOMs/timeouts
This commit is contained in:
glm-5.3-flash committed 2026-09-27 21:14:35 +00:00
1 parent 7ae0315f19
commit 80a37e94ab
1454 files changed
+2026 -3

No files matched your search

+5 -1
View File
@@ -9,7 +9,11 @@ readme = "README.md"
repository = "https://git.alk.dev/alkdev/alkcall"
keywords = ["rpc", "json-rpc", "multiplexing", "wire-format", "alpn"]
categories = ["network-programming", "asynchronous", "encoding"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/sdd_process.md"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/sdd_process.md", "fuzz/"]
[workspace]
members = ["."]
exclude = ["fuzz"]
[lib]
name = "alkcall"
+122 -2
View File
@@ -1,6 +1,7 @@
# Fuzzing alkcall: Research and Recommendation
**Status:** recommendation (not yet adopted)
**Status:** step 1 of §7.4 adopted (`fuzz/` workspace, targets 1–2, committed
seeds, detached runner; campaigns run — §7.7)
**Date:** 2026-09-27
**Research inputs:** web survey of the 2025–2026 Rust fuzzing ecosystem, survey
of fuzzing practice in comparable Rust protocol crates (rustls, quinn, quiche,
@@ -371,7 +372,7 @@ comparisons), and a `-dict` of JSON tokens for the envelope targets.
1. `cargo fuzz init` + first two targets (`chunk_header`, `envelope_frame`)
— highest value, lowest setup (both are thin wrappers over existing
sync/`Cursor`-drivable APIs).
sync/`Cursor`-drivable APIs). **Done — see §7.7.**
2. Run a local 10–30 min campaign per target **via the detached runner
(§7.6 — never in the foreground of an agent session)**; fix anything
found; triage §6.2 candidates with targeted corpus entries.
@@ -492,6 +493,125 @@ by the runner, not the host.
---
## 7.7 Implementation status (step 1 of §7.4, 2026-09-27)
**Adopted.** The `fuzz/` workspace exists with targets 1 and 2 from
§7.1, committed seed corpora, the detached runner, and stable-side
corpus replay. Everything below is verified in-tree.
### Layout (as implemented)
```
fuzz/
├── Cargo.toml alkcall-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml pins nightly for this subtree only
├── fuzz_targets/
│ ├── chunk_header.rs thin fuzz_target! wrapper
│ └── envelope_frame.rs thin fuzz_target! wrapper
├── shared/ alkcall-fuzz-shared — STABLE-toolchain library
│ └── src/{chunk_header,envelope_frame}.rs invariant logic + corpus replay
├── corpus/{chunk_header,envelope_frame}/ committed seeds (245 total)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
├── run-detached.sh §7.6 detached runner (CWD-independent)
├── json.dict JSON token dictionary for envelope targets
└── README.md
```
Deviations from the §7.1 sketch, all load-bearing:
- **The invariant logic lives in `fuzz/shared/` (a separate
stable-toolchain crate), not inline in the target binaries.** This
makes §7.2's tier-3 fallback (corpus replay as plain `cargo test`)
real from day one: `cargo test --manifest-path fuzz/shared/Cargo.toml`
replays every committed seed through the identical invariant functions
the fuzzer runs, on stable, in normal CI. The `fuzz_target!` binaries
are three-line wrappers.
- **The root `Cargo.toml` gained a `[workspace]` table
(`members = ["."]`, `exclude = ["fuzz"]`)** and `"fuzz/"` joined the
publish `exclude` list. Without the explicit workspace table, cargo
auto-discovers `fuzz/shared/` into the main workspace and the MSRV
toolchain tries to build the nightly-consumed dev-deps.
- **`fuzz/rust-toolchain.toml` pins `nightly`** for the fuzz subtree so
`cargo fuzz build` works from any CWD (rustup resolves toolchains per
directory; the initial build attempt from the repo root picked the
stable toolchain and failed on `-Zsanitizer`). Nightly remains
confined to `fuzz/` — the crate itself stays stable at MSRV 1.88.
- **No `#[cfg(fuzzing)]` exposure was needed.** Both target APIs were
already `pub`: `parse_header`/`write_header` (`channels/wire.rs`) and
`FrameFramedReader`/`FrameFramedWriter` (`protocol/wire.rs`). The one
private item, `MAX_FRAME_SIZE` (`wire.rs:20`), is a wire-stable
constant (ADR-014), so the shared crate carries its own copy
(16 MiB/64 MiB are one-way-door values; a drift would be caught by the
shape invariants, which assert the rejection boundary).
- **Seeds are committed** (245 files), gitignored artifacts/corpus-grown
per the generated `fuzz/.gitignore`; the generator script
(`gen_fuzz_seeds.py`) is committed next to them.
### Invariants encoded (beyond the §7.1 table)
`chunk_header`:
- no-panic; round-trip identity (`write_header(parse_header(x)) ==
x[..8]`); `TooLarge` iff `length > MAX_CHUNK_LEN` with the parsed
`length` echoed in the error; `HeaderTooShort` iff `< 8` bytes with
exact `need`/`have`; `is_eof() == (length == 0)`; consumption
accounting (8 bytes); **input buffer never mutated**.
`envelope_frame`:
- no-panic; clean error classes with **shape assertions** on each
`FrameError` variant: `ConnectionClosed` only when the prefix is
truncated or the body is short (a complete body must never yield it);
`InvalidFrame` only for `len == 0` or `len > MAX_FRAME_SIZE`;
`Json` only when the full body was present; `Io` impossible on a
Cursor; Ok only when `len > 0`, `len <= MAX`, body complete.
- allocation bound: the reader never allocates for `len > MAX_FRAME_SIZE`
or `len == 0` (rejection precedes `vec![0u8; length]` — verified by the
`InvalidFrame`/Ok shape partition).
- exact consumption accounting (4 + len bytes read for a valid frame).
- structural round-trip: every decodable envelope re-encodes via
`write_frame` and re-decodes to an equal envelope (structural —
serde_json without `preserve_order` does not preserve key order).
- serde contract: the decoded envelope serializes with `type`/`id`/
`payload` keys intact (`type` survives the rename).
- **trailing-byte probe**: for any input ending in a complete JSON
object body, a copy with one extra byte appended *and counted in the
length prefix* must fail with a `Json` error (serde_json's
from_slice rejects trailing non-whitespace) — catching any future
switch to a streaming/tolerant reader that would silently accept
junk after the JSON document.
Note on the probe: the length prefix counts **only the body**, not the
4-byte prefix — the first probe draft claimed `4 + len + 1` and tripped
its own shape assert (`ConnectionClosed`). The fuzzer would have found
that instantly; corpus replay found it first.
### Verification at adoption
- `cargo fuzz build` clean (nightly-1.95.0-nightly, cargo-fuzz 0.13.2,
libfuzzer-sys 0.4.13).
- Stable side untouched and green: `cargo test` 682 passed / 0 failed;
`cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check`
clean (main crate, fuzz workspace, and fuzz/shared).
- Corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`):
245 committed seeds through both invariant sets, green.
### Campaign results (10-min detached runs per target, §7.6 runner)
**No crashes, no hangs, no leak/OOM findings.** Both campaigns ran to
their time budget via `run-detached.sh` (`-fork=1 -rss_limit_mb=2048
-malloc_limit_mb=2048 -timeout=25`; `-max_len=65536 -dict=json.dict`
for the envelope target). At this size the campaign is a smoke-tier
result, not saturation: `chunk_header` reached libFuzzer's coverage
ceiling for a pure 8-byte parser almost immediately (the log shows the
characteristic flat `cov: 51 ft: 54` saturation line at >300k exec/s —
the target-space is fully enumerated and the remainder of the budget is
stability confirmation). `envelope_frame` was still finding coverage
when the budget ended (1732→1885 edges over 10 min, ~11k exec/s,
`oom/timeout/crash: 0/0/0` on every fork job). The §6.2-3 pre-payload
allocation window remains bounded as designed; §6.2 items 1/2/4/5 are
stateful targets (§7.4 step 3) and were not exercised by these
parser-level campaigns. Artifacts directories after both campaigns:
empty (no `crash-*`/`oom-*`/`timeout-*` files).
## 8. Answering the "if / how" directly
- **If?** Yes — justified by position in the dependency graph, two stable
+6
View File
@@ -0,0 +1,6 @@
target
corpus/*
!corpus/chunk_header
!corpus/envelope_frame
artifacts
coverage
+1263
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "alkcall-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
alkcall-fuzz-shared = { path = "shared" }
[dependencies.alkcall]
path = ".."
[[bin]]
name = "chunk_header"
path = "fuzz_targets/chunk_header.rs"
test = false
doc = false
bench = false
[[bin]]
name = "envelope_frame"
path = "fuzz_targets/envelope_frame.rs"
test = false
doc = false
bench = false
[workspace]
+53
View File
@@ -0,0 +1,53 @@
# alkcall fuzzing
libFuzzer targets for alkcall's wire formats (see
`docs/research/fuzzing.md` for the full rationale and campaign plan).
## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
modules; quinn's CI pattern). The fuzz binaries are nightly-only;
the shared crate is stable-clean.
- `corpus/<target>/` — committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
and artifacts are gitignored.
- `run-detached.sh` — mandatory runner for agent sessions: wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host (research doc §7.6).
- `json.dict` — JSON token dictionary for the envelope targets.
## Targets
| Target | Drives | Invariants |
|---|---|---|
| `chunk_header` | `parse_header` / `write_header` (`channels/wire.rs`) | no-panic; round-trip identity; `TooLarge` iff `length > MAX_CHUNK_LEN`; consumption accounting (8 bytes); input never mutated |
| `envelope_frame` | `FrameFramedReader::new(Cursor).read_frame()` on a current-thread runtime | no-panic; never allocates > `MAX_FRAME_SIZE`; clean `FrameError` on truncation/oversize/zero-length; exact consumption; structural `write_frame` round-trip |
## Commands
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned
nightly toolchain — the detached runner handles that itself.
```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
cargo fuzz build
# agent sessions: detached campaign (never foreground; CWD-independent)
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh chunk_header
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh envelope_frame -max_len=65536 -dict=json.dict
# corpus replay through the invariants (stable toolchain, no nightly)
cargo test --manifest-path fuzz/shared/Cargo.toml
# coverage
cargo fuzz coverage <target>
```
The fuzz workspace is excluded from the main workspace and from the
published package (`exclude` in the root `Cargo.toml`); it pins its own
nightly toolchain via `rust-toolchain.toml` and does not affect the
crate's stable MSRV.
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.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
��������
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Loaded 100 of 1454 files, more files were not shown because too many files have changed in this diff. Show more