Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3855bb1c99 | ||
|
|
fb7cfe3851 | ||
|
|
e72e1fc4c7 | ||
|
|
49f70d9efb |
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.
|
||||
@@ -4,6 +4,90 @@ All notable changes to this crate are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.6.0] - 2026-09-28
|
||||
|
||||
The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing
|
||||
parse surfaces, with zero findings in the crate — every crash the
|
||||
campaigns produced was a harness-model gap, and every fix landed in the
|
||||
harness, not the crate. The `alkcall` requirement also updates to
|
||||
0.8.1. No API, wire-format, or behavior change; this release ships no
|
||||
source change in `src/` at all — the deltas are the fuzz workspace,
|
||||
the dependency bump, and this changelog. Minor bump per the 0.x
|
||||
ecosystem-coordination rule (alktty's public signatures reference
|
||||
alkcall types, so downstream crates must unify on alkcall 0.8.1).
|
||||
|
||||
### Added
|
||||
|
||||
- **cargo-fuzz workspace under `fuzz/`** (per
|
||||
`docs/plans/fuzzing.md`, adopted from the alkcall fuzzing playbook):
|
||||
four targets over the wire-facing surfaces, with the invariant logic
|
||||
in a stable-toolchain shared crate (`fuzz/shared/`) so the corpus
|
||||
replays on stable — the nightly pin is scoped to the subtree via
|
||||
`fuzz/rust-toolchain.toml`:
|
||||
- `chunk_frame` — the 5-byte chunk codec
|
||||
(`ChunkReader`/`ChunkWriter`), including the
|
||||
peek/disambiguation seam (`peek_stream_type` vs
|
||||
`read_chunk_after_peek` in both call orders must yield exactly one
|
||||
chunk).
|
||||
- `negotiation_frame` — the 4-byte BE length-prefixed negotiation
|
||||
framing + `NegotiateRequest` JSON parse + `error_response_bytes`,
|
||||
and the cross-codec invariant that an error-response frame's
|
||||
length-prefix high byte is `0x00` so a `ChunkReader` peek
|
||||
distinguishes it from a chunk (ADR-001 §5).
|
||||
- `control_json` — `ControlMessage` JSON parse + `signal_from_name`.
|
||||
- `session_opseq` — the stateful target: a `#[derive(Arbitrary)]`
|
||||
12-op sequence drives the real producer session pump
|
||||
(`drive_session_pre_negotiated`) over a duplex pair against a
|
||||
fuzz-local mock backend, asserting the wire-contract invariants
|
||||
(kill-on-`Drop` per ADR-005, exit-chunk-is-last per ADR-004,
|
||||
lossless per-stream FIFO + single stdout sentinel, stdin
|
||||
prefix-losslessness, control dispatch bounds, take-once
|
||||
allocation, teardown termination).
|
||||
- **Committed corpus: 340 hand-made seeds** across the four targets
|
||||
(deterministic generator `fuzz/gen_fuzz_seeds.py`; the
|
||||
`session_opseq` seeds are hand-encoded against the `arbitrary` 1.4.x
|
||||
derive layout with the encoding pinned by a decode test). Grown
|
||||
corpus entries are gitignored and excised after campaigns.
|
||||
- **The standing fuzz gate**: `cargo test --manifest-path
|
||||
fuzz/shared/Cargo.toml` replays every committed seed through the
|
||||
same invariant functions the targets run, on stable. Added to the
|
||||
verification checklist.
|
||||
- **Detached-runner rule**: campaigns run via `fuzz/run-detached.sh`
|
||||
(setsid/nohup, fork mode, bounded RSS) — never in the foreground of
|
||||
an agent session.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.1.**
|
||||
alkcall 0.8.1 is a patch release fixing the duplicate
|
||||
adopt/open channel-state destruction found by alkcall's own fuzz
|
||||
campaign; it changed no type alktty touches. Downstream crates must
|
||||
unify on alkcall 0.8.x per the caret requirement. Minor bump per
|
||||
the 0.x ecosystem-coordination rule.
|
||||
- **Root `Cargo.toml` gains a `[workspace]` table**
|
||||
(`members = ["."]`, `exclude = ["fuzz"]`) so the fuzz subtree's
|
||||
nightly dev-deps never join the root workspace (an MSRV footgun),
|
||||
and `fuzz/` joins the publish exclude. Verified: the packaged file
|
||||
list is unchanged except for the exclusion (43 files; the fuzz
|
||||
workspace, seeds, plans, reviews, and `.opencode/` all stay out of
|
||||
the crate).
|
||||
|
||||
### Verification
|
||||
|
||||
- 0 findings across all four targets: detached campaigns (10 min each
|
||||
on the three wire targets; 10 min on `session_opseq` at 42.3k execs,
|
||||
cov 3934 → 4075 and still growing at budget end) plus a 60s smoke
|
||||
run on target 4 — no crash/OOM/timeout/leak.
|
||||
- Corpus replay 9 tests green on stable (340 seeds); crate tests 113
|
||||
default / 156 all-features; clippy stable + wasm; fmt; doc; wasm
|
||||
check — all pass.
|
||||
- The three crashes the harness-model smoke runs surfaced were
|
||||
harness gaps, each fixed in the fuzz harness (the alkcall §7.7
|
||||
pattern): a failed client write can mean the session completed and
|
||||
dropped the server duplex half mid-write — the harness drains and
|
||||
requires the exit chunk on any write/shutdown error (a premature
|
||||
close is a finding). No adapter code changed.
|
||||
|
||||
## [0.5.0] - 2026-09-18
|
||||
|
||||
The alkcall 0.8.0 adoption: a dependency bump only — no source change
|
||||
@@ -321,6 +405,7 @@ alknet mono-repo.
|
||||
the client→backend pump is aborted at session end instead of
|
||||
lingering until the client disconnects.
|
||||
|
||||
[0.6.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.6.0
|
||||
[0.5.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.5.0
|
||||
[0.4.1]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.1
|
||||
[0.4.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.0
|
||||
|
||||
Generated
+3
-3
@@ -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",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alktty"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"async-trait",
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alktty"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -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,350 @@
|
||||
# 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` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
|
||||
|
||||
### 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
|
||||
│ └── session_opseq.rs thin wrapper (typed SessionSequence input)
|
||||
├── shared/ alktty-fuzz-shared — STABLE-toolchain library
|
||||
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.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`, `error_response_bytes`, `drive_session_pre_negotiated`,
|
||||
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes 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. The `session_opseq` harness is fuzz-local for the same
|
||||
reason `MockBackend`/`TestBackend` are `#[cfg(test)]`-only: a test
|
||||
backend in `src/` would either leak into the public API or force the
|
||||
whole mock apparatus behind `#[cfg(any(test, fuzzing))]`; the shared
|
||||
crate is the fuzz-side home for it (mirrors alkcall, whose `manager_routing`
|
||||
harness also lives in `fuzz/shared/`).
|
||||
|
||||
## 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.
|
||||
**Status: landed — see §8. All four targets are in scope and live.**
|
||||
|
||||
## 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.
|
||||
- **2026-09-28 (later still)** — **§7 step 6 landed: target 4
|
||||
`session_opseq`. All four targets are now live.**
|
||||
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
|
||||
(`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl.
|
||||
barrier probes, backend production/EOF, exit resolve/fail/drop,
|
||||
control messages, bounded reads, yields) + a fuzz-local
|
||||
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
|
||||
the harness driving `drive_session_pre_negotiated` over a
|
||||
`tokio::io::duplex` pair, with a delivery model mirroring §4's
|
||||
invariants. 12 committed seeds via the deterministic generator
|
||||
(hand-encoded against the `arbitrary` 1.4.x derive layout — variant
|
||||
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
|
||||
gating each `Vec` element; the encoding is pinned by a decode test
|
||||
asserting the exact ops each seed decodes to).
|
||||
- Invariants encoded: kill-on-Drop (ADR-005), exit-chunk-is-last
|
||||
(ADR-004), lossless per-stream FIFO + sentinel semantics, stdin
|
||||
prefix-losslessness (post-shutdown chunks never delivered), control
|
||||
dispatch bounds, take-once allocation, teardown termination.
|
||||
Harness bounds: 128 ops, 4 KiB/chunk, 32 KiB produce/write caps,
|
||||
unbounded backend stdin (control dispatch stays prompt).
|
||||
- **Harness-model correction before the campaign** (the alkcall §7.7
|
||||
pattern repeating, third time): a 60s smoke run crashed on
|
||||
`ClientWrite` after the session had completed — a failed client
|
||||
write can mean the *server half of the duplex dropped* because the
|
||||
session finished (exit resolved → pumps join → exit chunk →
|
||||
drainer exits → duplex halves drop) while the write was in flight.
|
||||
The harness now treats any write/shutdown error as "the session
|
||||
closed": it drains the read side and requires the exit chunk (a
|
||||
close without it is a premature close — a real bug). No adapter
|
||||
code changed; the finding was a harness-model gap.
|
||||
- Corpus replay green on stable (9 tests, incl. the seed-decode
|
||||
pin). Full checklist green: 113 + 156 all-features tests, clippy
|
||||
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
|
||||
check.
|
||||
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
|
||||
empty artifact directory — no crashes, hangs, OOMs, or leaks** (all
|
||||
33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
|
||||
job (the op-seq target is stateful — each input runs a full tokio
|
||||
session with bounded reads/yields, so exec/s is inherently tens, not
|
||||
millions; the value is interleaving exploration). Cov grew
|
||||
3934 → 4075 edges over the run, still finding coverage at budget
|
||||
end. Grown corpus excised per the §6 policy; 12 committed seeds
|
||||
unchanged; corpus replay green after.
|
||||
- §7 fuzzing plan complete: all four targets live, corpus replay is
|
||||
the standing gate, campaigns pre-release and on adapter/pump/wire
|
||||
changes.
|
||||
|
||||
## 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/*/
|
||||
corpus/*/*
|
||||
!corpus/*/seed-*
|
||||
artifacts
|
||||
coverage
|
||||
Generated
+1330
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
[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
|
||||
|
||||
[[bin]]
|
||||
name = "session_opseq"
|
||||
path = "fuzz_targets/session_opseq.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,73 @@
|
||||
# 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` |
|
||||
| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend — the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) |
|
||||
|
||||
## 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.
|
||||
|
||||
## session_opseq notes
|
||||
|
||||
The op-sequence target is inherently slower than the byte-format targets
|
||||
(each input drives a full tokio session with bounded reads and yields —
|
||||
tens of exec/s per worker, not millions). That is expected: the value is
|
||||
state-space exploration, not throughput. It uses `tokio::time::timeout`
|
||||
internally, so the nightly target needs the `--features time` tokio
|
||||
surface already present via the shared crate's deps — no extra flags.
|
||||
Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO
|
||||
semantics) are the ones unit tests assert individually; the fuzzer
|
||||
explores interleavings no example test can enumerate.
|
||||
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.
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user