Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b32f84bd2 | ||
|
|
a16dee7385 | ||
|
|
a7d3e3afc3 | ||
|
|
b78b9a2618 | ||
|
|
4e8a2cfddd | ||
|
|
e023d3275e | ||
|
|
0ae53d2d27 | ||
|
|
27478f936b | ||
|
|
e6491e31c7 | ||
|
|
84c4b48407 | ||
|
|
5971c0a0d6 | ||
|
|
779e3ea908 | ||
|
|
40055170a0 | ||
|
|
911f186629 | ||
|
|
a63f4ed2c9 | ||
|
|
e854947c86 | ||
|
|
660ccf3e76 |
No files matched your search
@@ -1,3 +1,12 @@
|
||||
target/
|
||||
node_modules/
|
||||
.worktrees/
|
||||
|
||||
fuzz/target/
|
||||
fuzz/artifacts/
|
||||
fuzz/coverage/
|
||||
# Grown corpora: the hash-named files the campaigns drop into
|
||||
# fuzz/corpus/<target>/ are gitignored (the quinn/h2 policy); the
|
||||
# committed seeds are the seed-* files, kept via the per-dir
|
||||
# .gitignore un-ignores.
|
||||
fuzz/corpus/*/[0-9a-f][0-9a-f]*
|
||||
@@ -176,12 +176,22 @@ cargo test # full suite
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo doc --no-deps # if docs changed
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay (the fuzz gate)
|
||||
cargo publish --dry-run --allow-dirty # before a release
|
||||
```
|
||||
|
||||
If feature flags are added, also run `cargo test --all-features` and
|
||||
`cargo clippy --all-features --all-targets -- -D warnings`.
|
||||
|
||||
The corpus replay is the standing fuzz gate (the alkcall
|
||||
`docs/research/fuzzing.md` §7.9 posture): it replays every committed
|
||||
seed through the same invariant functions the fuzz targets run, on
|
||||
stable, without nightly. Campaigns (nightly, cargo-fuzz) run manually
|
||||
via `fuzz/run-detached.sh` — never as a foreground child of an agent
|
||||
session — after touching the SSE parser, the WS byte adapter, or the
|
||||
import adapters, and before releases. See `docs/plans/fuzzing.md` and
|
||||
`fuzz/README.md`.
|
||||
|
||||
## Architecture Context
|
||||
|
||||
- `docs/architecture/` — the authoritative spec. Read it before
|
||||
|
||||
+108
-1
@@ -4,7 +4,114 @@ 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/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] — 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Fuzzing infrastructure adopted** (the sibling-crate pattern:
|
||||
alkcall, alktty, alktunnels, alksocks). The `fuzz/` subtree holds
|
||||
five nightly targets (`sse_parser`, `ws_chunk_framing`,
|
||||
`spec_import`, `ws_session_opseq`, `retry_throttle`), a stable
|
||||
toolchain shared crate replaying every committed seed (80 + 42 + 16
|
||||
committed) through the identical invariant functions as plain
|
||||
`cargo test` — the standing fuzz gate,
|
||||
`cargo test --manifest-path fuzz/shared/Cargo.toml` — plus a
|
||||
detached campaign runner and a deterministic seed generator. `fuzz/`
|
||||
is excluded from publish; nightly stays confined to the subtree
|
||||
(MSRV 1.88 unchanged).
|
||||
- **New public API for the tested parse surfaces.** The incremental
|
||||
SSE parser widens `pub(crate)` → `pub` (`SseParser`, `SseEvent`,
|
||||
`SseParseError`, `SSE_EVENT_BUFFER_CAP`, `sse_event_envelope`) and
|
||||
the WS write pump's chunk walk is extracted as public API
|
||||
(`WriteChunkWalk`, `Walk`) — both direct-drive test targets. Five
|
||||
`fuzzing` re-export hubs (`adapters::forward::fuzzing`,
|
||||
`adapters::openapi_spec::fuzzing`, `adapters::input_validation::fuzzing`
|
||||
with the opaque `FuzzCompiledValidator`, `adapters::from_mcp::fuzzing`
|
||||
(\`mcp\`), `client::fuzzing` + `websocket::byte_adapter::fuzzing`
|
||||
with the mock-socket pump drive) gather the harness access surface
|
||||
at one named path each; the underlying crate-internal items keep
|
||||
their visibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **SSE dribble drain was quadratic in the dribbled bytes (FWD-20).**
|
||||
`SseParser::feed` re-walked the whole reassembly buffer per feed, so
|
||||
a subscription drip-feeding one giant unterminated line paid O(n²)
|
||||
(64 KiB ≈ 12.5 s CPU per connection; 1 MiB ran to minutes) with no
|
||||
per-stream total-CPU bound. Line assembly now walks only the
|
||||
arriving chunk — the reassembly buffer holds just the current line —
|
||||
measured linear (1 MiB dribble: 21 ms). Regression test:
|
||||
`sse_parser_dribble_feeds_do_not_rescan_the_buffer`.
|
||||
- **`WriteChunkWalk` failure arms discarded the already-drained pieces
|
||||
(FWD-21).** One feed that drained complete chunks and then hit an
|
||||
oversize header (or the pending cap) returned
|
||||
`Walk::Oversize`/`PendingOverCap` empty, dropping bytes the mux had
|
||||
already counted as written (the walk's consumption-accounting
|
||||
contract was violated; production could not reach the shape, but
|
||||
`WriteChunkWalk` is public API and the silent drop broke it). The
|
||||
failure variants now carry the pre-failure pieces (`Vec<Vec<u8>>`)
|
||||
and the write pump emits them before failing the stream (1011
|
||||
close). Regression seeds cover the drain-then-fail shape in both
|
||||
chunk harnesses.
|
||||
- **FWD-22 (harness-assert fix):** the fuzz harness's SSE re-frame
|
||||
round-trip probe is now total over anything JSON-encodable
|
||||
(`frame_event` emits a guard CR as the line terminator; the
|
||||
terminal-CR skip is dropped). No crate change.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`alkcall` dependency consumed at 0.8.1** (patch pickup): the
|
||||
duplicate adopt/open collision fix found by alkcall's
|
||||
`manager_routing` fuzz campaign — a duplicate adopt/open no longer
|
||||
replaces the live channel's state. This crate reaches the fixed
|
||||
paths via the shared `ChannelManager`; no alkcall API surface used
|
||||
here changed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`alkcall` dependency bumped to 0.8.0** (review 008's remediation
|
||||
wave: the graduation upstream asks + the in-tree channel relay). No
|
||||
code change in this crate — alkcall 0.8.0 is additive on the
|
||||
call-plane JSON and the registry seams, and none of its new surfaces
|
||||
are constructed here: this crate builds no `Establishment` reply
|
||||
fields (the WS ferry passes `Option<OpenEstablisher>` through
|
||||
unchanged; reply-field projection is an establisher-supplier
|
||||
feature), imports no marked specs through `from_call` (the
|
||||
`from_wss` operation filter excludes the protocol-session ops; any
|
||||
domain op the remote serves imports as a forwarding bundle exactly
|
||||
as before), and consumes neither `ChannelRelay` nor
|
||||
`HubLegImports`/`HubLegTemplate` (hub-leg assembly remains the
|
||||
consumer's concern at the assembly layer). One ride-through visible
|
||||
on this crate's discovery surface: for a deployment's
|
||||
`with_ws_openable_alpns` open-op specs whose names are not standard
|
||||
`channels/<seg>/(sub|pub)` shapes, the served `services/list` /
|
||||
`services/schema` output now carries an explicit
|
||||
`channel_open_alpn` string alongside the boolean marker (alkcall
|
||||
0.8.0's U-1, ADR-047 amendment 3) — so hubs relaying through a
|
||||
`from_call` import of those ops reconstruct the marker instead of
|
||||
seeing plain forwarding stubs. Standard-shape open-op names stay
|
||||
byte-stable. Minor bump per the dep-wave convention (0.3.0 rode
|
||||
alkcall 0.6.0; 0.4.0 rode 0.7.0).
|
||||
|
||||
## [0.4.1] — 2026-09-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **`rust-version` floor raised 1.85 → 1.88** (patch bump, no API change).
|
||||
The 1.85 claim was already false at the dependency level: the resolved
|
||||
lockfile pulls `icu_*` 2.x (MSRV 1.86 via `jsonschema` → `idna`) and —
|
||||
since alkcall 0.7.1 — alkcall itself declares 1.88, so 1.85 toolchains
|
||||
cannot build the tree regardless of what Cargo.toml declared. Raising
|
||||
the floor to 1.88 aligns with the ecosystem MSRV decision (noq QUIC
|
||||
path; recorded for alktls in OQ-TLS-08) and breaks no downstream that
|
||||
could build the crate before. Verified: 1.88 toolchain `cargo test
|
||||
--locked` + clippy `--all-targets -D warnings` clean; one 1.88 clippy
|
||||
lint fixed (`uninlined_format_args` in a `forward.rs` test).
|
||||
|
||||
- **`alkcall` dependency consumed at 0.7.1** (lockfile bump; the
|
||||
`version = "0.7"` requirement already covered it). No code change
|
||||
forced — alkcall 0.7.1's changes are its own MSRV-floor raise plus the
|
||||
cf-006/cf-007 feature work already consumed in the 0.7.0 wave.
|
||||
|
||||
## [0.4.0] — 2026-09-07
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.7.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5162a10144df65b03b887e30f77eb30d956ca1ca460a3f81b409128cbcae3199"
|
||||
checksum = "bf6af1e4af1460480849adf959c972d65deeafc36f4101064ced1ce6d1adfd5b"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkhttp"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"arc-swap",
|
||||
|
||||
+8
-4
@@ -1,19 +1,23 @@
|
||||
[package]
|
||||
name = "alkhttp"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "HTTP interface for the alk stack: serves HTTP/1.1 + HTTP/2 on standard ALPNs (with WebSocket upgrade carrying the channels protocol) and hosts the HTTP-backed call-protocol adapters"
|
||||
readme = "README.md"
|
||||
repository = "https://git.alk.dev/alkdev/alkhttp"
|
||||
keywords = ["http", "websocket", "rpc", "openapi", "mcp"]
|
||||
categories = ["network-programming", "web-programming", "asynchronous"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/", "tasks/", "scripts/"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/", "tasks/", "scripts/", "fuzz/"]
|
||||
|
||||
[lib]
|
||||
name = "alkhttp"
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[features]
|
||||
default = ["server", "client", "h2", "http1"]
|
||||
# Producer side: the axum `Router` host (gateway routes, /healthz,
|
||||
@@ -42,7 +46,7 @@ h2 = ["server", "dep:hyper", "hyper-util/http2", "hyper/http2"]
|
||||
http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = { version = "0.7", features = ["gateway"] }
|
||||
alkcall = { version = "0.8.1", features = ["gateway"] }
|
||||
arc-swap = { version = "1", optional = true }
|
||||
axum = { version = "0.8", optional = true, features = ["ws"] }
|
||||
bytes = "1"
|
||||
|
||||
@@ -161,7 +161,7 @@ A lean single-side build takes `default-features = false` plus the side
|
||||
it needs:
|
||||
|
||||
```toml
|
||||
alkhttp = { version = "0.4", default-features = false, features = ["server"] }
|
||||
alkhttp = { version = "0.5.0", default-features = false, features = ["server"] }
|
||||
```
|
||||
|
||||
## Security posture
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
# Plan: alkhttp fuzzing
|
||||
|
||||
Adopted from alkcall's `docs/research/fuzzing.md` (the pattern is
|
||||
operational in four sibling crates: alkcall, alktty, alktunnels,
|
||||
alksocks — each with the same `fuzz/` layout, the detached runner, and
|
||||
the corpus-replay-as-plain-test gate). This document is the working
|
||||
plan for alkhttp; the sibling docs are the reference for rationale
|
||||
(alkcall's doc is the deep version: tool landscape, comparable-crate
|
||||
survey, CI tiers) — this plan stays focused on what alkhttp fuzzes and
|
||||
in what order.
|
||||
|
||||
**Status:** targets 1–3 implemented and verified (1–2 on 2026-09-29,
|
||||
wave 1; 3 on 2026-09-29, wave 2); target 4 implemented (2026-09-29,
|
||||
wave 3: `ws_session_opseq` + `retry_throttle`; campaign results in
|
||||
§5); the only crash found so far (FWD-21, the `WriteChunkWalk`
|
||||
failure-carry drop) was a crate contract fix, not a harness
|
||||
misassert.
|
||||
|
||||
Progress log:
|
||||
|
||||
- **2026-09-29 — targets 1–2 + infra landed (commit 5971c0a).** The
|
||||
`fuzz/` workspace, 80 committed seeds (52 + 28), the detached
|
||||
runner, the corpus-replay gate (AGENTS.md verification checklist
|
||||
gains the line), nightly pinned subtree, explicit root `[workspace]`
|
||||
exclusion. One crate finding on the way (below). `cargo fuzz build`
|
||||
clean; corpus replay 80/80 green; campaigns pending at commit time.
|
||||
- **2026-09-29 — FWD-20, the SSE dribble-drain quadratic (fixed in
|
||||
the same commit).** Found by driving the new `sse_parser` harness's
|
||||
split-feed invariants: `SseParser::feed` re-walked the whole
|
||||
reassembly buffer per feed — a subscription dribble-fed one giant
|
||||
unterminated line (the attacker can hold `data: <n MiB>` open
|
||||
forever without sending `\n`) paid O(n²) in the dribbled bytes:
|
||||
64 KiB dribble ≈ 12.5 s CPU, 1 MiB would run to minutes per
|
||||
connection, with no per-stream total-CPU bound (the alkcall
|
||||
research §6.2-3 shape, in this crate). The fix: line assembly walks
|
||||
only the arriving chunk — the reassembly buffer holds just the
|
||||
current line, so the scan never revisits drained bytes; measured
|
||||
1 MiB dribble now 21 ms (linear, 4× data = ~4× time). Regression
|
||||
test `sse_parser_dribble_feeds_do_not_rescan_the_buffer`. Also
|
||||
fixed by corpus replay pre-campaign: the harness's own re-frame
|
||||
round-trip was wrong for data ending in `\r` (un-re-encodable under
|
||||
WHATWG CR-stripping — the serde_json key-order class; the harness
|
||||
skips that documented-lossy shape).
|
||||
- **2026-09-29 — target 3 (`spec_import`) implemented (wave 2).**
|
||||
Harness + 42 seeds + the three `fuzzing` hubs; no crate findings in
|
||||
the initial corpus replay (11 shared tests green, main crate 457
|
||||
default / 590 all-features). Smoke campaign clean: 2.34M execs /
|
||||
10 min, zero crash/OOM/timeout, coverage still growing at budget
|
||||
end (see §5). One harness-assert fix on the way (the quoted-`""`
|
||||
YAML key noted above).
|
||||
- **2026-09-29 — target 4 (`ws_session_opseq` + `retry_throttle`)
|
||||
implemented (wave 3).** Two stateful harnesses + 16 seeds + the
|
||||
`byte_adapter::fuzzing` drive hub (generic pump bodies over a mock
|
||||
socket flavor) + the `client::fuzzing` throttle hub. One crate
|
||||
finding on the way: FWD-21 (above) — fixed same commit.
|
||||
- **2026-09-29 — FWD-21, the `WriteChunkWalk` failure-carry drop.**
|
||||
Found minutes into the first `ws_session_opseq` campaign: one feed
|
||||
that drains a complete chunk and then hits an oversize header (or
|
||||
the pending cap) in the same call returned
|
||||
`Walk::Oversize`/`PendingOverCap` and **discarded the already
|
||||
drained pieces** — the walk's own consumption-accounting contract
|
||||
("every parsed byte is either emitted or retained", wave-1
|
||||
invariant 6) was violated by the crate, not the harness. Production
|
||||
could not reach the shape (the mux's per-write invariant caps
|
||||
`pending` at one chunk + header), so impact was nil, but
|
||||
`WriteChunkWalk` is public API and the wave-1 `split_walk` equality
|
||||
check was silently skipped on exactly this path. Fix (user-approved
|
||||
semver-relevant API change): `Walk::Oversize(Vec<Vec<u8>>)` /
|
||||
`Walk::PendingOverCap(Vec<Vec<u8>>)` carry the pre-failure pieces;
|
||||
the write pump emits them before `fail_write_pump`, so a 1011
|
||||
close never swallows a legal chunk the mux already counted as
|
||||
written. Regression seeds cover the drain-then-fail shape in both
|
||||
chunk harnesses.
|
||||
- **2026-09-29 — FWD-22, the round-trip guard-CR (harness-assert
|
||||
fix; wave 4).** Found ~32 min into the first long `sse_parser`
|
||||
campaign: the re-frame round-trip probe's `frame_event` emitted
|
||||
`data: <line>` unguarded, so a JSON payload carrying an *interior*
|
||||
CR (multi-line data `"\r\n…"` — the payload line is `"\r"`) had
|
||||
its payload CR consumed by `strip_cr` at re-parse time and the
|
||||
round-trip lost it (wave-1 had skipped only the *terminal*-CR
|
||||
case). The parser was never wrong — the shape is representable on
|
||||
input (`data: x\r\r\n` → value `x\r`) and both drives agreed — the
|
||||
re-encode was weak: one CR in the wire form is ambiguous between
|
||||
"payload CR" and "line-terminator CR". Fix: `frame_event` emits
|
||||
`data: <line>\r` for every data line (the guard CR is the
|
||||
terminator, `strip_cr` removes exactly it, the payload CR
|
||||
survives), and the terminal-CR skip is dropped — the round trip is
|
||||
now total over anything JSON-encodable (JSON never encodes a raw
|
||||
CR, so the residual asymmetry stays the serde_json non-preserved
|
||||
key-order class). No crate API change. Regression seeds + unit
|
||||
cases cover the interior-CR JSON shape (`{"k": "a\\rb"}`, two-line
|
||||
`{…}\r\r\n{…}`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why alkhttp fuzzes (the if)
|
||||
|
||||
Same three converging arguments as every sibling crate, with one
|
||||
stronger premise:
|
||||
|
||||
1. **This is the outermost shell.** alkcall's peers are authenticated
|
||||
by construction; alkhttp's HTTP surface faces anonymous callers —
|
||||
gateway routes accept bytes on the wire before any identity is
|
||||
resolved, `/healthz` and the stealth decoy are fully public, and the
|
||||
`from_*` adapters parse responses from remote HTTP servers nobody
|
||||
in this repo controls. The quinn-CVE class (RUSTSEC-2026-0037: a
|
||||
panic on attacker wire bytes is a remote DoS) applies here at its
|
||||
most direct. A remote crash in this crate does not need a peer
|
||||
trust step to reach.
|
||||
2. **Real hand-rolled byte parsing on untrusted streams.** The HTTP
|
||||
framing itself is hyper/axum/reqwest (upstream-fuzzed, not our job),
|
||||
but the crate owns an incremental SSE frame parser, the
|
||||
WS↔byte-stream chunk parser on both directions, a YAML→JSON
|
||||
conversion pass with its own number/key/pointer semantics, and
|
||||
bounded-schema compilation over arbitrary input values. These are
|
||||
the exact shapes (incremental, splitting-sensitive,
|
||||
cap-check-ordered) where example tests miss off-by-one bugs. No
|
||||
`unsafe` exists (grep-verified), so the expected yield is the
|
||||
alkcall profile: panics, arithmetic, allocation, and state-growth
|
||||
bugs — not memory corruption.
|
||||
3. **The integration seam is untested from this side.** alkhttp sits
|
||||
downstream of the alkcall 0.8.1 fix (duplicate adopt/open
|
||||
destroying the live channel — found by alkcall's `manager_routing`
|
||||
campaign minutes into its first run). alkhttp's WS path hands raw
|
||||
bytes to alkcall's demux; `fuzz/shared` depending on alkcall means
|
||||
corpus replay exercises the alkcall surface from the integration
|
||||
crate, a direction alkcall's own harness does not cover.
|
||||
|
||||
Also the standing empirical argument: the 0.8.1 bug was a stateful
|
||||
interleaving invisible to example-based tests, and it cost a patch
|
||||
release across every sibling. The infrastructure is already proven and
|
||||
cheap to replicate; the marginal cost here is only the target logic.
|
||||
|
||||
## 2. Infrastructure (identical to the siblings)
|
||||
|
||||
Layout (copy of alksocks/alktty/alktunnels/alkcall):
|
||||
|
||||
```
|
||||
fuzz/
|
||||
├── Cargo.toml alkhttp-fuzz (nightly-only bins; own [workspace])
|
||||
├── rust-toolchain.toml pins nightly + llvm-tools for this subtree only
|
||||
├── fuzz_targets/ thin fuzz_target! wrappers (3 lines each)
|
||||
├── shared/ alkhttp-fuzz-shared — STABLE-toolchain library:
|
||||
│ invariant logic + corpus-replay tests
|
||||
├── corpus/<target>/ committed seeds (generated by gen_fuzz_seeds.py)
|
||||
├── artifacts/ gitignored crash/oom/timeout artifacts + logs
|
||||
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
|
||||
├── json.dict JSON token dictionary (spec_import)
|
||||
├── run-detached.sh detached campaign runner (copied from the siblings)
|
||||
└── README.md operational cheat-sheet
|
||||
```
|
||||
|
||||
Load-bearing details (all four siblings hit these):
|
||||
|
||||
- **Invariant logic lives in `fuzz/shared/`**, not the target binaries.
|
||||
The stable-toolchain shared crate replays every committed seed
|
||||
through the identical invariant functions as plain `cargo test` —
|
||||
the standing fuzz gate (alkcall research §7.9 tier-3 deliverable,
|
||||
this repo has no hosted CI by policy) — and the `fuzz_target!`
|
||||
binaries are thin wrappers.
|
||||
- **Root `Cargo.toml` needs an explicit `[workspace]` table**
|
||||
(`members = ["."]`, `exclude = ["fuzz"]`); without it auto-discovery
|
||||
pulls `fuzz/shared/` into the main workspace and the stable
|
||||
toolchain builds nightly-consumed dev-deps. alkcall hit this trap.
|
||||
- **`fuzz/rust-toolchain.toml` pins nightly** so `cargo fuzz build`
|
||||
works from any CWD; nightly stays confined to `fuzz/`, MSRV 1.88
|
||||
untouched. `fuzz/` joins the publish `exclude` list.
|
||||
- **`.gitignore` additions**: `fuzz/artifacts/`, grown-corpus dirs
|
||||
(committed seeds stay).
|
||||
- **Detached runner (non-negotiable operating rule).** Campaigns never
|
||||
run as a foreground child of an agent session (an OOM in a target
|
||||
can take down the session host's process tree). Runner pins
|
||||
`-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25` and
|
||||
detaches via `setsid` + `nohup` + log redirect; the agent polls the
|
||||
log and artifact directory, never waits. Copied from alksocks
|
||||
(§`fuzz/run-detached.sh`).
|
||||
|
||||
**Verification-gate change:** `cargo test --manifest-path
|
||||
fuzz/shared/Cargo.toml` (corpus replay) joins AGENTS.md's verification
|
||||
checklist, as the siblings did.
|
||||
|
||||
## 3. Target inventory (all 4)
|
||||
|
||||
| # | Target | Drives | Input style | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `sse_parser` | `SseParser::feed` + `sse_event_envelope` mapping (`src/adapters/forward.rs`) | chunk-split sequence × eof flag | implemented 2026-09-29 |
|
||||
| 2 | `ws_chunk_framing` | the WS↔byte-adapter chunk parsers (`WriteChunkWalk`, the write pump's extracted chunk walk) (`src/websocket/byte_adapter.rs`) | byte sequences + message-split boundaries | implemented 2026-09-29 |
|
||||
| 3 | `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, `CompiledInputSchema`/`harden_closed_by_default`/`validate`, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`) | attacker-shaped OpenAPI/JSON/YAML docs + schemas | implemented 2026-09-29 |
|
||||
| 4 | `ws_session_opseq` / `retry_throttle` | stateful op sequences over the WS byte-adapter pumps (write/read pump interleavings, close timing, oversize frames); `RetryAfterMiddleware` throttle-map growth | `#[derive(Arbitrary)]` op enums | implemented 2026-09-29 |
|
||||
|
||||
### Target 1 — `sse_parser` (the chunk_header-class target)
|
||||
|
||||
The crate's most direct untrusted-byte parser: any subscription over
|
||||
`from_openapi`/`from_jsonschema` pipes remote HTTP bytes through
|
||||
`SseParser::feed`.
|
||||
|
||||
- **Drives:** incremental `feed` with the fuzzer choosing the chunk
|
||||
split boundaries; also the full-input single feed, and the
|
||||
`sse_event_envelope` mapping (FWD-17: JSON payload surfaces as
|
||||
itself, non-JSON as the `{"data","event"}` wrapper, empty →
|
||||
`Null`).
|
||||
- **Invariants:**
|
||||
- no-panic on any byte sequence, any split, any eof timing;
|
||||
- splitting transparency: whole-feed and every-slice-split feed
|
||||
produce identical event streams (the alksocks `datagram_codec`
|
||||
pattern);
|
||||
- the buffer-cap check runs *before* the buffer grows
|
||||
(FWD-14): the reassembly buffer can never exceed
|
||||
`SSE_EVENT_BUFFER_CAP`; overflow surfaces as `BufferOverflow`,
|
||||
never a panic or unbounded growth;
|
||||
- dispatch discipline: an event is dispatched only on a blank line
|
||||
(or EOF for a pending event with data); `data:` lines join with
|
||||
`\n`; last `event:` before the blank line wins; `id:`/`retry:`/
|
||||
comment lines (`:` prefix) never dispatch or alter state;
|
||||
- shape partition: `BufferOverflow` only when a fed chunk would push
|
||||
the reassembly buffer over the cap; a cap-sized-and-under input
|
||||
can never trigger it — never allocate beyond the cap;
|
||||
- UTF-8 discipline: a multi-byte character split at a chunk boundary
|
||||
is not corrupted (decode happens once a complete line/EOF bounds
|
||||
the window); invalid raw UTF-8 lines are skipped silently (WHATWG
|
||||
tolerance), never panic;
|
||||
- envelope mapping (FWD-17): every `feed`-produced event maps to a
|
||||
success `ResponseEnvelope`; the JSON-ness branch agrees with
|
||||
`serde_json::from_str` on the joined data; the JSON branch never
|
||||
carries the `event` binding.
|
||||
- round-trip-ish probe: re-serializing a parsed event as
|
||||
`data:`/`event:` lines re-parses to an equal event for
|
||||
JSON-encodable payloads (catches field-name and joining drift;
|
||||
structural equality only).
|
||||
- **Exposure:** `SseParser`/`SseEvent`/`sse_event_envelope` are
|
||||
`pub(crate)`. Shared/invariant access via quinn/rustls-style
|
||||
exposure if the pure functions can't be reached — alkcall's
|
||||
campaign never needed `#[cfg(fuzzing)]` after making parse helpers
|
||||
`pub(crate)` + re-export; the first choice here is the same: a
|
||||
minimal `#[cfg(fuzzing)] pub mod fuzzing` re-export in
|
||||
`src/adapters/forward.rs` (nightly-fuzz-only builds see it; stable
|
||||
builds don't).
|
||||
|
||||
### Target 2 — `ws_chunk_framing`
|
||||
|
||||
The WS↔byte adapter parses 8-byte chunk headers on the write
|
||||
(outbound byte→WS-message) side and validates against
|
||||
`MAX_CHUNK_LEN`; the inbound side reassembles WS messages into the
|
||||
byte stream that alkcall's demux parses. The hand-rolled pieces are
|
||||
the chunk-walk loop and the pending accumulator.
|
||||
|
||||
- **Drives:** the write-pump parse loop driven directly with the
|
||||
same logic `run_write_pump` executes on each `WriteMsg::Bytes`
|
||||
(header scan → `MAX_CHUNK_LEN` gate → drain → 1 MiB message-piece
|
||||
split → pending cap), and the read-pump inbound path's
|
||||
message→header reassembly, driven as a byte-sequence target with
|
||||
the fuzzer controlling both the byte stream and the message split
|
||||
boundaries.
|
||||
- **Invariants:**
|
||||
- no-panic on any byte sequence at any message-split boundary;
|
||||
- a header claiming `> MAX_CHUNK_LEN` fails the stream loudly
|
||||
(WS-04/HY-09) at exactly the point the pump reaches the header —
|
||||
never a silent wait and never an allocate-before-reject;
|
||||
- the pending accumulator never exceeds `PENDING_BUFFER_CAP` =
|
||||
`MAX_CHUNK_LEN + 8` (WS-14): bound asserted after every op; a
|
||||
single write above the cap is rejected in `poll_write` before
|
||||
queueing, so the cap is invariantly satisfied;
|
||||
- message-splitting transparency: a chunk split across N
|
||||
WS-message feeds parses exactly like the whole-buffer feed
|
||||
(receiver's boundary is the header, not the message —
|
||||
WS-06); pieces split at any offset ≤ `WS_MESSAGE_CAP`
|
||||
reassemble losslessly;
|
||||
- consumption accounting: every parsed byte is either emitted as a
|
||||
chunk piece or retained in the pending buffer — nothing lost or
|
||||
duplicated across the walk;
|
||||
- the over-cap write rejection (`poll_write` > `PENDING_BUFFER_CAP`
|
||||
→ `InvalidData` before queueing) is exact: no partial state, no
|
||||
silent truncation.
|
||||
- **Exposure:** the pure parse helpers are extracted/testable; the
|
||||
pump functions are `pub(crate)` async — drives via a
|
||||
`#[cfg(fuzzing)]` re-export and a current-thread runtime with a
|
||||
mock sink (the alkcall `envelope_frame` Cursor pattern).
|
||||
|
||||
### Target 3 — `spec_import` (implemented 2026-09-29)
|
||||
|
||||
- **Drives:** raw bytes (and `json.dict`) → `serde_json`/`yaml_serde`
|
||||
→ `OpenAPISpec::from_str` (JSON-first, YAML-fallback, ADR-051) and
|
||||
the typed helpers: `yaml_to_json_value` boundary, number/key
|
||||
conversion; plus `CompiledInputSchema::compile` +
|
||||
`harden_closed_by_default` + `validate` over attacker-shaped
|
||||
schemas and input values; plus `FromMCP::build_spec` over arbitrary
|
||||
`Tool`-shaped JSON.
|
||||
- **Invariants:** no-panic; parse failures are always clean
|
||||
`AdapterError::SchemaParse` naming a pointer (never a panic/silent
|
||||
null); the YAML normalization rejects loudly what
|
||||
`serde_json::Number` cannot represent (non-finite floats,
|
||||
non-string keys); `$ref` resolution errors on cycles instead of
|
||||
unbounded recursion; the compiled validator rejects/accepts
|
||||
consistently w.r.t. the schema; closed-by-default hardening pins
|
||||
`additionalProperties: false`; registry-compile shape parity with
|
||||
alkcall's `spec_parse` invariants (bounded compile, always
|
||||
`Result`).
|
||||
- **Implemented shape:** the harness drives one attacker string
|
||||
through all four lanes — the three spec constructors (str/json,
|
||||
yaml under `openapi`), the direct YAML→JSON normalization boundary
|
||||
(merge keys applied; a value the boundary accepted can't contain
|
||||
non-finite numbers — serde_json-invariant, asserted recursively),
|
||||
the compile/harden/validate lane against the whole doc (hardening
|
||||
partition: injected iff absent and the input is an object;
|
||||
validate determinism probe with an unknown-key input; deterministic
|
||||
two-call agreement), the MCP tool-shape lane (the doc and a
|
||||
`{name, inputSchema}` wrapper through `serde_json`→`Tool`→
|
||||
`build_spec`; success pins the namespace prefix), and the `$ref`
|
||||
lane (`resolve_refs_recursive` over the raw doc + declared
|
||||
component schemas when the doc parses). Exposure via
|
||||
`fuzzing` hubs alongside the wave-1 one:
|
||||
`openapi_spec::fuzzing`, `input_validation::fuzzing`
|
||||
(an opaque `FuzzCompiledValidator` newtype so the `pub(crate)`
|
||||
validator never leaks), `from_mcp::fuzzing` (`mcp` feature).
|
||||
The shared crate gates `openapi`/`mcp` (both on by default, riding
|
||||
the main crate's default build); corpus replay passes both with and
|
||||
without.
|
||||
- **Note:** yaml_serde (ADR-051: YAML 1.2 + merge keys) is the
|
||||
deepest third-party parse we own — the campaign should also encode
|
||||
the already-documented boundaries (`apply_merge` error path,
|
||||
duplicate-key rejection with line/column) as corpus expectations.
|
||||
42 committed seeds cover the menu below.
|
||||
|
||||
### Target 4 — stateful wave (implemented 2026-09-29)
|
||||
|
||||
- **`ws_session_opseq`:** `#[derive(Arbitrary)]` op sequences over
|
||||
the WS byte adapter driven end-to-end (like alktty's
|
||||
`session_opseq` / alkcall's `manager_routing`): write-side ops
|
||||
(well-formed chunks, oversize headers, the pending shape),
|
||||
read-side ops (binary deliveries, dribble, text, inbound read
|
||||
error, peer close), a lane-healthy requested close, shutdown vs
|
||||
close ordering, backpressure scaled 1..=8 read slots. Asserts:
|
||||
lossless inbound forwarding (forwarded == delivered exactly after
|
||||
the teardown joins, prefix mid-sequence), the WS-01 walk's
|
||||
completed-chunk count vs the declared frame count, the loud-gate
|
||||
close mapping (1002/1011/verbatim-requested + a per-cause reason,
|
||||
WS-15), lossless write framing (outbound concat == the declared
|
||||
chunk stream incl. the FWD-21 failure-carry), lane-failure
|
||||
isolation (the alkcall 0.8.1 class), and pump termination within
|
||||
the harness bound. Exposure: `byte_adapter::fuzzing`
|
||||
(`FuzzMockSocket` flavor, `FuzzChannels`/`FuzzWriteSide` channel
|
||||
bundle, pump spawn funnels, `FuzzInboundChunkProgress`).
|
||||
- **`retry_throttle`:** bounded-map growth of the inlined
|
||||
`RetryAfterMiddleware`: capacity bound (exact, distinct-URL
|
||||
observable partition), expired-first + Farthest-future eviction
|
||||
policy, ceiling clamp (deadline never grows past the ceiling of
|
||||
the record moment), refresh contraction (a higher `Retry-After`
|
||||
never extends the first-seen deadline; the missing-header refresh
|
||||
is a documented no-op), `Retry-After` parse partition
|
||||
(non-negative seconds ∪ future HTTP dates, post-clamp), and the
|
||||
jitter bounds (≤ 25% of a short wait, capped at 2 s). Op sequences
|
||||
of records/refreshes/queries/rebuilds with seconds, huge-seconds,
|
||||
HTTP-date, past-date, garbage, and no-header shapes. Exposure:
|
||||
`client::fuzzing` (constructor, synthetic throttled response,
|
||||
record/deadline/parse/jitter funnels — the middleware map never
|
||||
leaks).
|
||||
- Sequenced after 1–3 stabilize, per the sibling pattern.
|
||||
|
||||
## 4. Corpus policy
|
||||
|
||||
Committed hand-made seeds per target, generated by
|
||||
`fuzz/gen_fuzz_seeds.py` (deterministic, in-tree, quiche pattern);
|
||||
grown corpora and artifacts gitignored. Seed menus:
|
||||
|
||||
- `sse_parser`: a minimal valid event; every framing edge (`data:`
|
||||
multi-line joins, `event:` override, comment, `id:`, `retry:`,
|
||||
BOM, CRLF/LF, field with no colon, `data` with no space);
|
||||
truncation at every structural prefix; split at a UTF-8 multibyte
|
||||
boundary; cap-boundary event (`SSE_EVENT_BUFFER_CAP - 1`,
|
||||
exactly at cap, one over); an over-cap trailing line; EOF with
|
||||
pending data / pending no-data; empty stream; pure whitespace.
|
||||
- `ws_chunk_framing`: a minimal valid chunk (`[id:u32 BE][len:u32
|
||||
BE][payload]`); `len = 0`, `len = MAX_CHUNK_LEN`,
|
||||
`len = MAX_CHUNK_LEN + 1`, `len = u32::MAX`; truncation at every
|
||||
prefix length (1–8 bytes); chunk split across a message boundary
|
||||
at every offset; channel-id 0 (the control channel); a header
|
||||
claiming big length whose payload never arrives (the pump-wait
|
||||
shape); the >cap single write (`poll_write` rejection).
|
||||
- `spec_import` (when built): a minimal valid OpenAPI doc (JSON);
|
||||
the same doc as YAML; missing `info`/`paths`; a deep-nesting
|
||||
probe; duplicate-key YAML; `<<: *anchor` merge; `.inf`/`.nan`
|
||||
scalars; non-string map keys; a `$ref` cycle; an unreachable
|
||||
`$ref`; `additionalProperties` absent (hardening probe) and
|
||||
present both ways.
|
||||
- Stateful seeds (target 4): the semantic fixtures the campaign must
|
||||
always replay — a write/echo flow, the oversize loud-gate after a
|
||||
clean chunk, the text-rejection + surviving write lane, an inbound
|
||||
read error, a peer close mid-flight + shutdown drain, a requested
|
||||
close, the throttle-map fill/evict cycle at capacity, the
|
||||
missing-header refresh no-op, hostile-seconds clamping, refresh
|
||||
contraction, and a mid-sequence rebuild (16 total; the
|
||||
`ws_session_opseq`/`retry_throttle` blocks in
|
||||
`fuzz/gen_fuzz_seeds.py`, hand-encoded against the `arbitrary`
|
||||
1.4.x derive layout with ceiling-division tags and per-element
|
||||
keep-going bytes, pinned by seed-decode tests).
|
||||
|
||||
## 5. Campaign + gate policy
|
||||
|
||||
Identical to the siblings (alkcall §7.9 posture; no hosted CI in this
|
||||
repo):
|
||||
|
||||
- **Corpus replay is the standing fuzz gate:** `cargo test
|
||||
--manifest-path fuzz/shared/Cargo.toml` — in AGENTS.md's
|
||||
verification checklist.
|
||||
- **Campaigns run detached** via `fuzz/run-detached.sh` (never a
|
||||
foreground child of an agent session); budget 10 min per target for
|
||||
a smoke campaign, 30+ min before a release or after touching the
|
||||
SSE parser, the WS adapter, or the import adapters.
|
||||
- **Grown corpora stay gitignored**: the hash-named files a campaign
|
||||
drops into `fuzz/corpus/<target>/` after run-detached runs are
|
||||
ignored via a hash-pattern (the committed `seed-*` files stay);
|
||||
merge worthy entries into seeds only deliberately.
|
||||
|
||||
Wave-1 smoke campaign results (2026-09-29, 10 min per target, both
|
||||
exited 0, artifact dirs empty — no crash/hang/OOM/leak):
|
||||
|
||||
- `sse_parser` — 18.9k execs at ~30/s (each exec replays the
|
||||
whole-feed + 3-byte-split + cap-probe + envelope-mapping +
|
||||
round-trip chain, so per-exec work is heavy), coverage 1100 edges /
|
||||
9510 features / 807 in-memory corpus entries, still discovering when
|
||||
the budget ended — longer campaigns keep paying.
|
||||
- `ws_chunk_framing` — 4.8M execs at ~9k/s, coverage saturated at 245
|
||||
edges / 1071 features / 136 corpus entries.
|
||||
- **Seeds regenerate deterministically:** `python3
|
||||
fuzz/gen_fuzz_seeds.py`.
|
||||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||||
`fuzz/`.
|
||||
|
||||
Wave-2 (`spec_import`) smoke campaign (2026-09-29, 10 min, exited 0,
|
||||
artifact dirs empty):
|
||||
|
||||
- `spec_import` — 2.34M execs at ~4k/s (each exec replays the three
|
||||
spec constructors + the YAML boundary + compile/harden/validate +
|
||||
the MCP tool lane + `$ref` resolution, so per-exec work is heavy),
|
||||
coverage still growing at budget end: 14.1k edges / 36.9k features
|
||||
/ 3046 in-memory corpus entries. One early abort in the *first*
|
||||
launch, a harness assert too strong for the documented boundary
|
||||
(an explicitly-quoted `""` YAML mapping key is legal — JSON
|
||||
has the same key; the nullish-key rejection covers the implicit
|
||||
null key, not a quoted empty string), fixed before the counted run;
|
||||
no crate finding.
|
||||
- **Seeds regenerate deterministically:** `python3
|
||||
fuzz/gen_fuzz_seeds.py`.
|
||||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||||
`fuzz/`.
|
||||
|
||||
Wave-3 stateful smoke campaigns (2026-09-29, 10 min per target, both
|
||||
exited 0, artifact dirs empty — no crash/hang/OOM/leak; the first
|
||||
`ws_session_opseq` launch aborted at 17 s on FWD-21, fixed before
|
||||
the counted run):
|
||||
|
||||
- `ws_session_opseq` — 206k execs at ~340/s (each exec interleaves up
|
||||
to 64 two-lane ops with yield quiesce points per exec, so per-exec
|
||||
work is heavy), coverage 2251 edges / 5831 features / 232 in-memory
|
||||
corpus entries — coverage still growing at budget end.
|
||||
- `retry_throttle` — 897k execs at ~1.4k/s, coverage saturated at
|
||||
1154 edges / 6925 features / 324 corpus entries.
|
||||
- **Seeds regenerate deterministically:** `python3
|
||||
fuzz/gen_fuzz_seeds.py`.
|
||||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||||
`fuzz/`.
|
||||
|
||||
Wave-4 release-budget campaigns (2026-09-29, 45 min per target,
|
||||
launched concurrently, five of five):
|
||||
|
||||
- `ws_chunk_framing` — exited 0: 13.7M execs at ~5k/s, coverage
|
||||
saturated (297 edges / 1216 features / 139 corpus).
|
||||
- `spec_import` — exited 0: 6.85M execs at ~3.2k/s, coverage growing
|
||||
to budget end (14.9k edges / 43.4k features / 3946 corpus).
|
||||
- `ws_session_opseq` — exited 0: 715k execs at ~253/s, coverage
|
||||
growing to budget end (2251 edges / 6337 features / 260 corpus).
|
||||
- `retry_throttle` — exited 0: 3.3M execs at ~1.3k/s, coverage
|
||||
saturated (1154 edges / 7071 features / 352 corpus).
|
||||
- `sse_parser` — aborted at 1913 s on FWD-22 (above; the
|
||||
interior-CR round-trip loss, a harness fix); before the abort it
|
||||
had 57k execs at ~35/s (1269 edges / 5584 features / 509 corpus),
|
||||
still growing. 45-min rerun post-fix exited 0: 96k execs at ~33/s,
|
||||
coverage still growing at budget end (1378 edges / 6655 features /
|
||||
734 corpus) — zero findings.
|
||||
- A stale crash artifact from the wave-2 morning session (quoted-`""`
|
||||
YAML key, predating that fix) was still present in
|
||||
`fuzz/artifacts/spec_import/` at campaign start — not produced by
|
||||
wave 4 (the wave-4 `spec_import` log has zero findings).
|
||||
- **Seeds regenerate deterministically:** `python3
|
||||
fuzz/gen_fuzz_seeds.py`.
|
||||
- Toolchain notes live in `fuzz/README.md`; nightly stays confined to
|
||||
`fuzz/`.
|
||||
|
||||
1. ✅ **Done (2026-09-29, commit 5971c0a)** — infra (workspace
|
||||
exclude, toolchain pin, runner, seed generator, README) + targets
|
||||
1–2 + corpora + corpus replay + AGENTS.md gate + smoke campaigns
|
||||
(clean, see §5). Includes the FWD-20 SSE dribble quadratic fix.
|
||||
2. ✅ **Done (2026-09-29, commit e6491e3)** — target 3
|
||||
(`spec_import`): harness + invariants
|
||||
(`fuzz/shared/src/spec_import.rs`), 42 seeds, `json.dict` extended
|
||||
with the OpenAPI-token set, fuzzing hubs in the three driven
|
||||
modules. Smoke campaign clean (see §5).
|
||||
3. ✅ **Done (2026-09-29, wave 4)** — long (45 min) release-budget
|
||||
campaigns, all five targets, all exited 0 (see §5; the one
|
||||
finding, FWD-22, was a harness-assert fix, fixed + re-run clean in
|
||||
the same wave). Corpus merges remain deliberately deferred (grown
|
||||
corpora stay gitignored; nothing campaign-dropped has been curated
|
||||
into seeds).
|
||||
4. ✅ **Done (2026-09-29, wave 3)** — target 4 stateful
|
||||
(`ws_session_opseq` + `retry_throttle`): two harnesses + 16 seeds
|
||||
+ the `byte_adapter::fuzzing` / `client::fuzzing` hubs + FWD-21
|
||||
(the `WriteChunkWalk` failure-carry drop — a crate contract fix;
|
||||
user-approved semver-relevant API change). Smoke campaigns clean
|
||||
(see §5).
|
||||
|
||||
Status updates land in §3's table and §6's list as work completes.
|
||||
Generated
+2806
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "alkhttp-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
alkhttp-fuzz-shared = { path = "shared" }
|
||||
|
||||
[dependencies.alkhttp]
|
||||
path = ".."
|
||||
|
||||
[[bin]]
|
||||
name = "sse_parser"
|
||||
path = "fuzz_targets/sse_parser.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "ws_chunk_framing"
|
||||
path = "fuzz_targets/ws_chunk_framing.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "spec_import"
|
||||
path = "fuzz_targets/spec_import.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "ws_session_opseq"
|
||||
path = "fuzz_targets/ws_session_opseq.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "retry_throttle"
|
||||
path = "fuzz_targets/retry_throttle.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,65 @@
|
||||
# alkhttp fuzzing
|
||||
|
||||
cargo-fuzz targets for the HTTP surface's hand-rolled parse paths. 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 |
|
||||
|---|---|
|
||||
| `sse_parser` | `SseParser::feed` + the FWD-17 envelope mapping (`src/adapters/forward.rs`) — the SSE frame parser every subscription forwards through |
|
||||
| `ws_chunk_framing` | `WriteChunkWalk` (the write pump's chunk walk) (`src/websocket/byte_adapter.rs`) |
|
||||
| `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, the YAML normalization boundary, `CompiledInputSchema` compile/harden/validate, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `input_validation.rs`, `from_mcp/mod.rs`) |
|
||||
| `ws_session_opseq` | stateful op sequences over the WS byte adapter's generic pumps (`run_read_pump`/`run_write_pump` over the mock socket flavor) — interleavings, close timing, loud-gate mapping, backpressure |
|
||||
| `retry_throttle` | the inlined `RetryAfterMiddleware`'s throttle map: capacity bound, eviction policy, ceiling clamp, refresh contraction, `Retry-After` parse partition |
|
||||
|
||||
## 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 §2).
|
||||
Use the detached runner:
|
||||
|
||||
```bash
|
||||
fuzz/run-detached.sh sse_parser
|
||||
# poll:
|
||||
tail -n 50 fuzz/artifacts/sse_parser-*.log
|
||||
ls fuzz/artifacts/sse_parser/
|
||||
pgrep -f "cargo fuzz run sse_parser"
|
||||
```
|
||||
|
||||
`FUZZ_RUNTIME_SECS=1800 fuzz/run-detached.sh sse_parser` 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/adapters/forward.rs`
|
||||
(the SSE parser), `src/websocket/byte_adapter.rs` (the WS chunk walk),
|
||||
or the import adapters (`src/adapters/openapi_spec.rs`,
|
||||
`input_validation.rs`, `from_mcp/`).
|
||||
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 @@
|
||||
{"openapi":"3.0.0","info":{"title":"t","version":"1.0.0"},"paths":{"/x":{"get":{"operationId":"x_get","responses":{}}}}}
|
||||
@@ -0,0 +1,9 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: t
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/x:
|
||||
get:
|
||||
operationId: x_get
|
||||
responses: {}
|
||||
@@ -0,0 +1 @@
|
||||
{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/items/{id}":{"get":{"operationId":"get_item","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Widget"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Widget"}}}}}}}},"components":{"schemas":{"Widget":{"type":"object","properties":{"id":{"type":"string"},"n":{"minimum":1}}}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"paths":{}}
|
||||
@@ -0,0 +1 @@
|
||||
{"info":{"title":"t"}}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
42
|
||||
@@ -0,0 +1 @@
|
||||
"a string"
|
||||
@@ -0,0 +1 @@
|
||||
{
|
||||
@@ -0,0 +1 @@
|
||||
}{
|
||||
@@ -0,0 +1 @@
|
||||
not a document
|
||||
Whitespace-only changes.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"openapi":3.0}
|
||||
@@ -0,0 +1,2 @@
|
||||
a: 1
|
||||
a: 2
|
||||
@@ -0,0 +1,5 @@
|
||||
base: &b
|
||||
x: 1
|
||||
child:
|
||||
<<: *b
|
||||
y: 2
|
||||
@@ -0,0 +1 @@
|
||||
maximum: .inf
|
||||
@@ -0,0 +1 @@
|
||||
minimum: -.inf
|
||||
@@ -0,0 +1 @@
|
||||
value: .nan
|
||||
@@ -0,0 +1 @@
|
||||
~: v
|
||||
@@ -0,0 +1,2 @@
|
||||
? [1, 2]
|
||||
: v
|
||||
@@ -0,0 +1,2 @@
|
||||
responses:
|
||||
200: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
a: yes
|
||||
b: no
|
||||
@@ -0,0 +1 @@
|
||||
a: !!unknown b
|
||||
@@ -0,0 +1 @@
|
||||
a: !!str 200
|
||||
@@ -0,0 +1,5 @@
|
||||
paths:
|
||||
/x:
|
||||
get:
|
||||
responses:
|
||||
200: {}
|
||||
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"A":{"$ref":"#/components/schemas/A"}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"A":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/B"}}},"B":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/A"}}}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"A":{"$ref":"#/components/schemas/Missing"}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"A":{"$ref":"https://other/file.json"}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"S0":{"type":"object"},"S1":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S0"},"b":{"$ref":"#/components/schemas/S0"}}},"S2":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S1"},"b":{"$ref":"#/components/schemas/S1"}}}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"required": "n"}
|
||||
@@ -0,0 +1 @@
|
||||
"not an object"
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","properties":{"q":{"type":"string"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","properties":{"q":{"type":"string"}},"additionalProperties":true}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","properties":{"level":{"enum":["low","high"]},"n":{"minimum":1},"s":{"maxLength":3}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"ok_tool","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"ns/ok_tool","inputSchema":{"type":"object"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"has space","inputSchema":{"type":"object"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"","inputSchema":{"type":"object"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"ok_tool"}
|
||||
@@ -0,0 +1,2 @@
|
||||
data: hello
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data: {"k": 1}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
event: named
|
||||
data: x
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data: 42
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
event: ping
|
||||
data: heartbeat
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
data: a
|
||||
data: b
|
||||
data: c
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data: x
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
data: x␍␍
|
||||
@@ -0,0 +1,2 @@
|
||||
data: bom-first
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
:comment only
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:c
|
||||
data: after-comment
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
event: e1
|
||||
event: e2
|
||||
data: last-wins
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
id: 7
|
||||
retry: 100
|
||||
data: ignored-fields
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data:only
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
event
|
||||
|
||||
data: after-bare-event
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
random text line
|
||||
another
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
data: v
|
||||
@@ -0,0 +1 @@
|
||||
data: no-eof-dispatch
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
data: héllo
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data: ÿþý
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
event: é
|
||||
data: utf8-name
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
e
|
||||
@@ -0,0 +1 @@
|
||||
ev
|
||||
@@ -0,0 +1 @@
|
||||
eve
|
||||
@@ -0,0 +1 @@
|
||||
even
|
||||
@@ -0,0 +1 @@
|
||||
event
|
||||
@@ -0,0 +1 @@
|
||||
event:
|
||||
@@ -0,0 +1 @@
|
||||
event:
|
||||
@@ -0,0 +1 @@
|
||||
event: e
|
||||
@@ -0,0 +1 @@
|
||||
event: e
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
d
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
da
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
dat
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
data
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
data:
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user