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:
1 parent
7ae0315f19
commit
80a37e94ab
1454 files changed
+2026
-3
No files matched your search
+5
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
target
|
||||
corpus/*
|
||||
!corpus/chunk_header
|
||||
!corpus/envelope_frame
|
||||
artifacts
|
||||
coverage
|
||||
Generated
+1263
File diff suppressed because it is too large.
Load diff
@@ -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]
|
||||
@@ -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.
@@ -0,0 +1 @@
|
||||
��������
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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.
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.
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.
Loaded 100 of 1454 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user