fuzz: adopt fuzzing (wave 1) + fix the SSE dribble quadratic (FWD-20)
Adopt the alk*/fuzzing posture (alkcall 0.8.1 precedent) — the dep
bump this ride-along builds on was 4005517:
- docs/plans/fuzzing.md: the four-target plan (sse_parser,
ws_chunk_framing this session; spec_import + the stateful wave
later), the infrastructure blueprint, and the corpus/campaign
policies (§3's table tracks progress)
- fuzz/ workspace (the sibling layout): nightly-pinned subtree,
stable fuzz/shared invariant library (corpus replay = the standing
fuzz gate), detached runner (never a foreground child of an agent
session), JSON dict, deterministic seed generator; 80 committed seeds
- two wave-1 targets: sse_parser (SseParser::feed + the FWD-17
envelope mapping — the parser every subscription forwards through)
and ws_chunk_framing (WriteChunkWalk, the write pump's chunk walk)
- crate-side surfacing: SseParser/SseEvent/sse_event_envelope are pub
behind the adapters::forward::fuzzing re-export hub; the write
pump's inline chunk loop extracted as WriteChunkWalk (pump byte
behavior unchanged — 441 tests green); retained_bytes() diagnostic
(the alktunnels/alksocks posture)
- root Cargo.toml gains the explicit [workspace] table with
fuzz/ excluded (auto-discovery would pull the nightly-consumed
dev-deps into the stable build — the alkcall trap); fuzz/ joins the
publish exclude list
- AGENTS.md verification checklist gains the corpus-replay line
FWD-20 fix, found by driving the new sse_parser harness: feed()
rescanned the whole reassembly buffer per feed — a dribble-fed
subscription (one unterminated line over many small chunks) cost
quadratic CPU (1 MiB dribble ≈ minutes per connection, unbounded
total). Line assembly now walks only the arriving chunk: the buffer
holds just the current line, the scan never revisits drained bytes.
1 MiB dribble: ~5 min (quadratic) → 21 ms (linear). Regression test
sse_parser_dribble_feeds_do_not_rescan_the_buffer.
Verification: cargo test 441 passed; cargo test --all-features ok;
clippy --all-targets -- -D warnings (main + fuzz/shared) clean;
fmt --check (main, fuzz, fuzz/shared) clean; cargo fuzz build clean;
corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml) 80
seeds green.
Refs: alkcall docs/research/fuzzing.md (§7.9 posture), alksocks
08a3694 (the sibling wave-1 shape), docs/plans/fuzzing.md §2
This commit is contained in:
1 parent
779e3ea908
commit
5971c0a0d6
98 files changed
+3812
-65
No files matched your search
@@ -1,3 +1,12 @@
|
||||
target/
|
||||
node_modules/
|
||||
.worktrees/
|
||||
|
||||
fuzz/target/
|
||||
fuzz/artifacts/
|
||||
fuzz/coverage/
|
||||
# Grown corpora (the committed hand-made seeds are force-added via
|
||||
# fuzz/corpus/<target>/.gitignore un-ignores, the sibling pattern).
|
||||
fuzz/corpus/*/
|
||||
!fuzz/corpus/sse_parser/
|
||||
!fuzz/corpus/ws_chunk_framing/
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -9,11 +9,15 @@ 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,
|
||||
|
||||
Generated
+2583
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
[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
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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`) |
|
||||
|
||||
## 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) or `src/websocket/byte_adapter.rs` (the WS chunk walk).
|
||||
@@ -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:
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
data:
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
data: v
|
||||
@@ -0,0 +1,2 @@
|
||||
event: e
|
||||
data: v
|
||||
@@ -0,0 +1 @@
|
||||
data: h
|
||||
@@ -0,0 +1,2 @@
|
||||
data: [1,2,3]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data: "quoted string"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data: null
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
data: {"nested": {"deep": [true, false, 1.5]}}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
data: {"partial":
|
||||
@@ -0,0 +1,3 @@
|
||||
data: {"k": 1}
|
||||
data: {"k": 2}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaLine truncated
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
alkhttp_fuzz_shared::sse_parser::fuzz_sse_parser(data);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
alkhttp_fuzz_shared::ws_chunk_framing::fuzz_ws_chunk_framing(data);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the committed seed corpora for alkhttp's fuzz targets.
|
||||
|
||||
Writes into fuzz/corpus/<target>/. Deterministic: fixed inputs only, no
|
||||
randomness. Run from the repo root:
|
||||
|
||||
python3 fuzz/gen_fuzz_seeds.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
|
||||
MAX_CHUNK_LEN = 16 * 1024 * 1024
|
||||
SSE_EVENT_BUFFER_CAP = 1024 * 1024
|
||||
|
||||
|
||||
def seed_name(target, i):
|
||||
return os.path.join("fuzz", "corpus", target, f"seed-{i:03d}")
|
||||
|
||||
|
||||
def write_seed(target, i, data):
|
||||
path = seed_name(target, i)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def chunk(id, payload):
|
||||
header = struct.pack(">II", id, len(payload))
|
||||
return header + payload
|
||||
|
||||
|
||||
def sse_parser_seeds():
|
||||
i = 0
|
||||
# Minimal valid events: plain, JSON, named, empty payload.
|
||||
for data in [
|
||||
b"data: hello\n\n",
|
||||
b'data: {"k": 1}\n\n',
|
||||
b"event: named\ndata: x\n\n",
|
||||
b"data: 42\n\n",
|
||||
b"data:\n\n",
|
||||
b"event: ping\ndata: heartbeat\n\n",
|
||||
]:
|
||||
write_seed("sse_parser", i, data)
|
||||
i += 1
|
||||
|
||||
# Framing edges: multi-line join, CRLF, BOM, comments, ignored
|
||||
# fields, no-colon fields, no-space values, whitespace noise.
|
||||
for data in [
|
||||
b"data: a\ndata: b\ndata: c\n\n",
|
||||
b"data: x\r\n\r\n",
|
||||
b"data: x\r\r",
|
||||
b"\xef\xbb\xbfdata: bom-first\n\n",
|
||||
b":comment only\n\n",
|
||||
b":c\ndata: after-comment\n\n",
|
||||
b"event: e1\nevent: e2\ndata: last-wins\n\n",
|
||||
b"id: 7\nretry: 100\ndata: ignored-fields\n\n",
|
||||
b"data\n\n",
|
||||
b"data:only\n\n",
|
||||
b"event\n\ndata: after-bare-event\n\n",
|
||||
b"\n\n\n",
|
||||
b" \n \n",
|
||||
b"random text line\nanother\n\n",
|
||||
b"event: e\ndata: v",
|
||||
b"data: no-eof-dispatch",
|
||||
b"\x00\x01\x02\xff\xfe\n\n",
|
||||
"data: héllo\n\n".encode(),
|
||||
b"data: \xff\xfe\xfd\n\n",
|
||||
b"event: \xc3\xa9\ndata: utf8-name\n\n",
|
||||
]:
|
||||
write_seed("sse_parser", i, data)
|
||||
i += 1
|
||||
|
||||
# Truncations at every prefix of a framed two-line event.
|
||||
frame = b"event: e\ndata: v\n\n"
|
||||
for n in range(1, len(frame)):
|
||||
write_seed("sse_parser", i, frame[:n])
|
||||
i += 1
|
||||
|
||||
# Truncation inside a multi-byte UTF-8 character at a split point.
|
||||
hello = "data: héllo\n\n".encode()
|
||||
cut = hello.index("é".encode()) + 1
|
||||
write_seed("sse_parser", i, hello[:cut])
|
||||
i += 1
|
||||
|
||||
# JSON payload shapes through the FWD-17 branch.
|
||||
for data in [
|
||||
b'data: [1,2,3]\n\n',
|
||||
b'data: "quoted string"\n\n',
|
||||
b'data: null\n\n',
|
||||
b'data: {"nested": {"deep": [true, false, 1.5]}}\n\n',
|
||||
b'data: {"partial": ',
|
||||
b'data: {"k": 1}\ndata: {"k": 2}\n\n',
|
||||
]:
|
||||
write_seed("sse_parser", i, data)
|
||||
i += 1
|
||||
|
||||
# Cap partition: exactly at the cap is accepted, one over rejects.
|
||||
# (Full 1 MiB seeds each; keep exactly two, the boundary probes.)
|
||||
write_seed("sse_parser", i, b"a" * SSE_EVENT_BUFFER_CAP)
|
||||
i += 1
|
||||
write_seed("sse_parser", i, b"a" * (SSE_EVENT_BUFFER_CAP + 1))
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def ws_chunk_framing_seeds():
|
||||
i = 0
|
||||
# Valid chunks: len 0, small payloads, channel 0 (channel 0 = the
|
||||
# control channel), a couple of ids.
|
||||
for id_, payload in [
|
||||
(0, b""),
|
||||
(0, b"x"),
|
||||
(1, b"hello"),
|
||||
(0, b"\x00\x01"),
|
||||
(0xFFFFFFFF, b"max-id"),
|
||||
(123456789, b"payload-payload-payload"),
|
||||
]:
|
||||
write_seed("ws_chunk_framing", i, chunk(id_, payload))
|
||||
i += 1
|
||||
|
||||
# Two chunks back to back, plus a chunk boundary at a message edge.
|
||||
two = chunk(0, b"a") + chunk(1, b"bc")
|
||||
write_seed("ws_chunk_framing", i, two)
|
||||
i += 1
|
||||
write_seed("ws_chunk_framing", i, chunk(4, b"x" * 1024))
|
||||
i += 1
|
||||
|
||||
# Truncations at every prefix length of one framed chunk.
|
||||
full = chunk(2, b"wxyz")
|
||||
for n in range(1, len(full)):
|
||||
write_seed("ws_chunk_framing", i, full[:n])
|
||||
i += 1
|
||||
|
||||
# Length-declaration boundaries: small lengths, u32::MAX, a header
|
||||
# claiming over MAX_CHUNK_LEN with a partial body behind it (the
|
||||
# loud-gate shape), header-only feeds.
|
||||
header_only = lambda id_, len_field: struct.pack(">II", id_, len_field)
|
||||
write_seed("ws_chunk_framing", i, header_only(0, 0))
|
||||
i += 1
|
||||
write_seed("ws_chunk_framing", i, header_only(0, 1))
|
||||
i += 1
|
||||
write_seed("ws_chunk_framing", i, header_only(0, 3))
|
||||
i += 1
|
||||
write_seed("ws_chunk_framing", i, header_only(0, 0xFFFFFFFF))
|
||||
i += 1
|
||||
write_seed("ws_chunk_framing", i, chunk(0, b"") + chunk(7, b"tail"))
|
||||
i += 1
|
||||
write_seed(
|
||||
"ws_chunk_framing", i, struct.pack(">II", 0, MAX_CHUNK_LEN + 1) + b"partial"
|
||||
)
|
||||
i += 1
|
||||
write_seed(
|
||||
"ws_chunk_framing", i, chunk(2, b"ok") + struct.pack(">II", 3, MAX_CHUNK_LEN + 1)
|
||||
)
|
||||
i += 1
|
||||
# A length over-declared: header claims more than arrives.
|
||||
write_seed("ws_chunk_framing", i, struct.pack(">II", 9, 40) + b"\x00" * 16)
|
||||
i += 1
|
||||
# A misaligned offset: 3 junk bytes shift every later header — the
|
||||
# classic mis-parse shape.
|
||||
write_seed("ws_chunk_framing", i, b"\x00" * 3 + chunk(11, b"shifted")[1:])
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
n_sse = sse_parser_seeds()
|
||||
n_ws = ws_chunk_framing_seeds()
|
||||
print(f"sse_parser: {n_sse} seeds, ws_chunk_framing: {n_ws} seeds")
|
||||
@@ -0,0 +1,23 @@
|
||||
# JSON tokens for the envelope framing targets (ADR-014 wire format).
|
||||
"call.requested"
|
||||
"call.responded"
|
||||
"call.completed"
|
||||
"call.aborted"
|
||||
"call.error"
|
||||
"call.published"
|
||||
"type"
|
||||
"id"
|
||||
"payload"
|
||||
"operationId"
|
||||
"input"
|
||||
"output"
|
||||
"code"
|
||||
"message"
|
||||
"retryable"
|
||||
"details"
|
||||
"NOT_FOUND"
|
||||
"INVALID_INPUT"
|
||||
"INTERNAL"
|
||||
"TIMEOUT"
|
||||
"FORBIDDEN"
|
||||
"CONNECTION_CLOSED"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Detached fuzzing runner for agent sessions: the fuzz campaign never
|
||||
# runs as a foreground child of the session (OOM in a target must not
|
||||
# take down the agent host), and survives the session ending.
|
||||
#
|
||||
# Usage: fuzz/run-detached.sh <target> [extra libfuzzer args...]
|
||||
# (works from the repo root or from fuzz/; CWD-independent)
|
||||
# FUZZ_RUNTIME_SECS overrides the per-campaign budget (default 600 s).
|
||||
#
|
||||
# Poll instead of waiting:
|
||||
# tail -n 50 fuzz/artifacts/<target>-*.log
|
||||
# ls fuzz/artifacts/<target>/ (crash-* / oom-* / timeout-* files)
|
||||
# pgrep -f "cargo fuzz run <target>"
|
||||
set -euo pipefail
|
||||
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
|
||||
shift
|
||||
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
fuzz_dir="$root/fuzz"
|
||||
mkdir -p "$fuzz_dir/artifacts"
|
||||
runtime="${FUZZ_RUNTIME_SECS:-600}"
|
||||
log="$fuzz_dir/artifacts/${target}-$(date -u +%Y%m%d-%H%M%S).log"
|
||||
|
||||
cd "$fuzz_dir"
|
||||
setsid nohup cargo fuzz run "$target" -- \
|
||||
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
|
||||
-max_total_time="$runtime" "$@" \
|
||||
>"$log" 2>&1 < /dev/null &
|
||||
echo "pid=$! log=$log"
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["llvm-tools-preview"]
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "alkhttp-fuzz-shared"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alkhttp = { path = "../.." }
|
||||
alkcall = "0.8.1"
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Fuzz invariant logic for alkhttp's wave-1 targets. Kept out of the
|
||||
//! fuzz-target binaries so the corpus replay unit tests in the release
|
||||
//! verification checklist can exercise the same invariant checks
|
||||
//! against every committed corpus entry (the quinn CI pattern) without
|
||||
//! a nightly toolchain.
|
||||
|
||||
pub mod sse_parser;
|
||||
pub mod ws_chunk_framing;
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Invariants for the SSE frame parser (`src/adapters/forward.rs`,
|
||||
//! `SseParser::feed` + the FWD-17 envelope mapping): no-panic on any
|
||||
//! byte sequence at any chunk-split boundary, splitting transparency,
|
||||
//! the cap-checked-before-grow bound (FWD-14), dispatch discipline,
|
||||
//! UTF-8 window discipline, and the envelope-map shape (FWD-17).
|
||||
|
||||
use alkcall::protocol::wire::ResponseEnvelope;
|
||||
use alkhttp::adapters::forward::fuzzing::{sse_event_envelope, SseEvent, SseParser};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn fuzz_sse_parser(data: &[u8]) {
|
||||
// The whole-feed direction: the attacker's literal byte shape.
|
||||
// A feed over the event-buffer cap is the declared `BufferOverflow`
|
||||
// failure — the harness's cap partition asserts below that only
|
||||
// cap-crossing inputs produce it.
|
||||
let mut whole = SseParser::new();
|
||||
let whole_events = match whole.feed(data, true) {
|
||||
Ok(events) => events,
|
||||
Err(_) => {
|
||||
// Declare the partition: the input must cross the cap.
|
||||
assert_over_cap_rejects_before_growth();
|
||||
return;
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
whole
|
||||
.feed(&[], true)
|
||||
.expect("post-EOF feed is inert")
|
||||
.is_empty(),
|
||||
"after EOF the parser is fully flushed and emits nothing"
|
||||
);
|
||||
|
||||
// The incremental direction: awkward 3-byte splits with eof only at
|
||||
// the end — splitting transparency must hold. Two passes: the
|
||||
// sliced stream through one parser, and a re-drive for equality.
|
||||
let mut sliced = SseParser::new();
|
||||
let mut sliced_events: Vec<SseEvent> = Vec::new();
|
||||
for piece in data.chunks(3) {
|
||||
sliced_events.extend(sliced.feed(piece, false).expect("sliced feed under cap"));
|
||||
}
|
||||
sliced_events.extend(sliced.feed(&[], true).expect("sliced eof feed"));
|
||||
|
||||
// Splitting transparency: identical event streams regardless of the
|
||||
// chunk boundaries.
|
||||
assert_events_eq(&whole_events, &sliced_events, data);
|
||||
|
||||
// The retained-buffer bound: the reassembly buffer can never
|
||||
// exceed the fed bytes (the parser drains on every newline). The
|
||||
// FWD-14 pre-grow check itself is asserted by the cap partition;
|
||||
// a direct retained-byte assertion would need a parser diagnostic
|
||||
// (the alktunnels/alksocks retained_bytes() posture), not added
|
||||
// to the stable surface yet.
|
||||
assert_over_cap_rejects_before_growth();
|
||||
|
||||
// Envelope mapping (FWD-17): every parsed event maps to a success
|
||||
// envelope; the branch agrees with serde_json's parse of the data;
|
||||
// a JSON payload is the decoded value itself and carries no event
|
||||
// binding; a non-JSON payload is the {"data","event"} wrapper; an
|
||||
// empty payload is Null.
|
||||
for event in &whole_events {
|
||||
map_envelope_shape(event, "req-fuzz");
|
||||
}
|
||||
|
||||
// Re-frame round-trip: every parsed event re-serializes as SSE
|
||||
// lines and re-parses to an equal event (the joining / field-name
|
||||
// drift guard). Events whose data ends in `\r` are skipped — the
|
||||
// WHATWG line assembly strips a terminal CR at re-parse time, so
|
||||
// `data` ending in `\r` has no unambiguous re-encoding (the same
|
||||
// known-lossy class as serde_json's non-preserved key order: a
|
||||
// documented asymmetry, not a parser defect; note the split feed
|
||||
// of the original event is unaffected because the CR sits inside
|
||||
// the assembled line in both drives).
|
||||
for event in &whole_events {
|
||||
if event.data.ends_with('\r') {
|
||||
continue;
|
||||
}
|
||||
let reencoded = frame_event(event);
|
||||
let mut probe = SseParser::new();
|
||||
let back = probe
|
||||
.feed(&reencoded, true)
|
||||
.expect("re-framed event parses");
|
||||
assert_eq!(back.len(), 1, "one event in, one event out");
|
||||
assert_events_eq(std::slice::from_ref(event), &back, &reencoded);
|
||||
}
|
||||
}
|
||||
|
||||
/// The FWD-14 partition, from an empty parser: a cap-sized feed is
|
||||
/// accepted, a cap-plus-one feed is rejected before the buffer grows.
|
||||
fn assert_over_cap_rejects_before_growth() {
|
||||
let cap = alkhttp::adapters::forward::fuzzing::SSE_EVENT_BUFFER_CAP;
|
||||
let mut at_cap = SseParser::new();
|
||||
at_cap
|
||||
.feed(&vec![b'a'; cap], false)
|
||||
.expect("exactly the cap-sized input is accepted");
|
||||
let mut over_cap = SseParser::new();
|
||||
over_cap
|
||||
.feed(&vec![b'a'; cap], false)
|
||||
.expect("cap-sized first feed");
|
||||
assert!(
|
||||
over_cap.feed(b"a", false).is_err(),
|
||||
"crossing the cap rejects before growth (FWD-14)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-encode one parsed event as its SSE wire form (WHATWG semantics:
|
||||
/// one `event:` line when named, one `data:` line per data segment,
|
||||
/// blank-line dispatch), so re-parsing must reproduce the event.
|
||||
fn frame_event(event: &SseEvent) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(name) = &event.event {
|
||||
out.extend_from_slice(b"event: ");
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
for line in event.data.split('\n') {
|
||||
out.extend_from_slice(b"data: ");
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.push(b'\n');
|
||||
out
|
||||
}
|
||||
|
||||
fn assert_events_eq(a: &[SseEvent], b: &[SseEvent], context: &[u8]) {
|
||||
assert_eq!(
|
||||
a.len(),
|
||||
b.len(),
|
||||
"splitting must be transparent: {} events (whole) vs {} (split) on {} bytes",
|
||||
a.len(),
|
||||
b.len(),
|
||||
context.len()
|
||||
);
|
||||
for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
|
||||
assert_eq!(x.data, y.data, "event {i} data diverges across splitting");
|
||||
assert_eq!(
|
||||
x.event, y.event,
|
||||
"event {i} event-name diverges across splitting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn map_envelope_shape(event: &SseEvent, request_id: &str) {
|
||||
let envelope = sse_event_envelope(
|
||||
SseEvent {
|
||||
data: event.data.clone(),
|
||||
event: event.event.clone(),
|
||||
},
|
||||
request_id,
|
||||
);
|
||||
match &envelope {
|
||||
ResponseEnvelope {
|
||||
request_id: rid,
|
||||
result: Ok(output),
|
||||
} => {
|
||||
assert_eq!(
|
||||
rid, request_id,
|
||||
"the envelope carries the caller's request id"
|
||||
);
|
||||
if event.data.trim().is_empty() {
|
||||
assert_eq!(*output, Value::Null, "empty payload maps to Null");
|
||||
} else if let Ok(parsed) = serde_json::from_str::<Value>(&event.data) {
|
||||
assert_eq!(
|
||||
*output, parsed,
|
||||
"a JSON payload maps to the decoded value itself"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
*output,
|
||||
serde_json::json!({"data": event.data, "event": event.event}),
|
||||
"a non-JSON payload maps to the data/event wrapper"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => panic!("sse_event_envelope must produce a success envelope"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let corpus =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/sse_parser");
|
||||
let mut count = 0usize;
|
||||
for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") {
|
||||
let path = entry.expect("corpus entry readable").path();
|
||||
let data = std::fs::read(&path).expect("corpus entry readable");
|
||||
fuzz_sse_parser(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 24,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_sse_framing_edges() {
|
||||
// Minimal valid event.
|
||||
fuzz_sse_parser(b"data: hello\n\n");
|
||||
// Multi-line join.
|
||||
fuzz_sse_parser(b"data: a\ndata: b\n\n");
|
||||
// Event name, comment, ignored fields.
|
||||
fuzz_sse_parser(b":comment\nevent: named\nid: 1\nretry: 5\ndata: x\n\n");
|
||||
// CRLF form.
|
||||
fuzz_sse_parser(b"data: x\r\n\r\n");
|
||||
// BOM.
|
||||
fuzz_sse_parser(b"\xef\xbb\xbfdata: x\n\n");
|
||||
// Field with no colon; data with no space.
|
||||
fuzz_sse_parser(b"data\n\n");
|
||||
// Truncations at every prefix of one framed event.
|
||||
let frame = b"event: e\ndata: v\n\n";
|
||||
for n in 1..frame.len() {
|
||||
fuzz_sse_parser(&frame[..n]);
|
||||
}
|
||||
// JSON vs non-JSON payloads through the FWD-17 branch.
|
||||
fuzz_sse_parser(b"data: {\"k\": 1}\n\n");
|
||||
fuzz_sse_parser(b"data: plain text payload\n\n");
|
||||
fuzz_sse_parser(b"event: ping\ndata: 42\n\n");
|
||||
// Split multi-byte UTF-8 at a chunk boundary.
|
||||
fuzz_sse_parser("data: héllo\n\n".as_bytes());
|
||||
// EOF with pending data; EOF with pending no-data (field only).
|
||||
fuzz_sse_parser(b"data: tail-no-blank");
|
||||
fuzz_sse_parser(b"event: tail-no-data");
|
||||
// Pure noise / whitespace.
|
||||
fuzz_sse_parser(b"\n\n\n");
|
||||
fuzz_sse_parser(b" \n\n");
|
||||
fuzz_sse_parser(b"\r\r\r");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Invariants for the WS↔byte-adapter chunk walk
|
||||
//! (`src/websocket/byte_adapter.rs`, `WriteChunkWalk` — the extracted
|
||||
//! write-pump state machine): no-panic on any byte sequence at any
|
||||
//! write-split boundary, the loud `MAX_CHUNK_LEN` gate, the
|
||||
//! `PENDING_BUFFER_CAP` bound, consumption accounting, write-split
|
||||
//! transparency, and the re-frame round-trip.
|
||||
|
||||
use alkcall::channels::wire::MAX_CHUNK_LEN;
|
||||
use alkhttp::websocket::byte_adapter::{Walk, WriteChunkWalk, PENDING_BUFFER_CAP};
|
||||
|
||||
/// The declared chunk stream of `data`, per the shared 8-byte header
|
||||
/// walk (`[id:u32 BE][len:u32 BE][payload]`): greedy framing, an
|
||||
/// over-length header stops the walk (`Err`), a partial frame ends it.
|
||||
/// Shared by the harness's outcome models (write-side pump semantics).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Declared {
|
||||
/// The concatenated payloads of every complete, legal frame.
|
||||
pub payloads: Vec<u8>,
|
||||
/// Number of complete legal frames consumed.
|
||||
pub frames: usize,
|
||||
/// Byte offset where the greedy walk stopped (the trailing partial
|
||||
/// frame's start, or the over-length header's offset).
|
||||
pub stop: usize,
|
||||
/// `true` when the walk stopped on a header declaring
|
||||
/// `> MAX_CHUNK_LEN`.
|
||||
pub oversize: bool,
|
||||
}
|
||||
|
||||
/// The greedy byte-level walk model: the emitted stream carries the
|
||||
/// **header plus payload** of every complete legal frame (the pump
|
||||
/// frames whole chunks onto the WS messages; the demux walks the same
|
||||
/// headers but discards them). `payloads` therefore includes the 8
|
||||
/// emitted header bytes per frame, and `payload_bytes` counts the
|
||||
/// declared payload only.
|
||||
fn declare(data: &[u8]) -> Declared {
|
||||
let mut pos = 0usize;
|
||||
let mut payloads = Vec::new();
|
||||
let mut payload_bytes = 0usize;
|
||||
let mut frames = 0usize;
|
||||
while data.len() - pos >= 8 {
|
||||
let len = u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]);
|
||||
if len > MAX_CHUNK_LEN {
|
||||
return Declared {
|
||||
payloads,
|
||||
frames,
|
||||
stop: pos,
|
||||
oversize: true,
|
||||
};
|
||||
}
|
||||
let total = 8usize.saturating_add(len as usize);
|
||||
if data.len() - pos < total {
|
||||
break;
|
||||
}
|
||||
payloads.extend_from_slice(&data[pos..pos + total]);
|
||||
payload_bytes += len as usize;
|
||||
pos += total;
|
||||
frames += 1;
|
||||
}
|
||||
let _ = payload_bytes;
|
||||
Declared {
|
||||
payloads,
|
||||
frames,
|
||||
stop: pos,
|
||||
oversize: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fuzz_ws_chunk_framing(data: &[u8]) {
|
||||
// Walk 1: whole-buffer feed — the attacker's literal shape.
|
||||
whole_walk(data);
|
||||
|
||||
// Walk 2: write-split transparency. A logical write above the mux
|
||||
// surfaces as multiple queued spans (channel 0's write_frame
|
||||
// issues prefix and body separately), so the chunk walk must be
|
||||
// insensitive to the span boundaries: feed in 3-byte spans vs the
|
||||
// whole buffer — identical outcomes.
|
||||
split_walk(data);
|
||||
|
||||
// Walk 3: the cap partition (WS-14/WS-05) — exactly at max emits
|
||||
// cleanly, over max rejects.
|
||||
cap_walk();
|
||||
}
|
||||
|
||||
/// Feed `data` to a fresh walk in one write; assert the outcome
|
||||
/// against the declared chunk stream.
|
||||
fn whole_walk(data: &[u8]) {
|
||||
let declared = declare(data);
|
||||
let mut walk = WriteChunkWalk::new();
|
||||
let result = walk.feed(data);
|
||||
assert_outcome(&mut walk, result, data, &declared);
|
||||
}
|
||||
|
||||
/// Drive 3-byte span feeds; the accumulated outcome must equal the
|
||||
/// whole-feed outcome on the same bytes (the walk is
|
||||
/// boundary-insensitive).
|
||||
fn split_walk(data: &[u8]) {
|
||||
let declared = declare(&data[..data.len().min(data.len())]);
|
||||
|
||||
let mut split = WriteChunkWalk::new();
|
||||
let mut emitted = Vec::new();
|
||||
for piece in data.chunks(3) {
|
||||
match split.feed(piece) {
|
||||
Walk::Emitted(messages) => emitted.extend(messages.into_iter().flatten()),
|
||||
Walk::Incomplete => {}
|
||||
Walk::Oversize | Walk::PendingOverCap => {
|
||||
// A split-drive failure must be reachable on the
|
||||
// whole drive too (the walk is boundary-insensitive:
|
||||
// failures depend on the declared header at the
|
||||
// accumulation position, not on the span edges).
|
||||
let mut whole = WriteChunkWalk::new();
|
||||
let replay_result = whole.feed(data);
|
||||
assert!(
|
||||
matches!(replay_result, Walk::Oversize | Walk::PendingOverCap),
|
||||
"a split-drive failure must be reachable on the whole drive too"
|
||||
);
|
||||
assert_failure_shape(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean-drive agreement: emitted payloads equal the declared
|
||||
// payloads, retained bytes equal the declared remainder.
|
||||
assert_eq!(
|
||||
emitted, declared.payloads,
|
||||
"write splitting must be transparent: emitted payloads diverge"
|
||||
);
|
||||
assert_eq!(
|
||||
split.retained_bytes(),
|
||||
data.len() - declared.stop,
|
||||
"retained bytes diverge across write splitting"
|
||||
);
|
||||
}
|
||||
|
||||
/// The loud-failure partition, verified directly against the wire
|
||||
/// semantics: a failure on `data` requires the input to contain an
|
||||
/// over-length header (Oversize) or exceed the accumulator cap
|
||||
/// (PendingOverCap).
|
||||
fn assert_failure_shape(data: &[u8]) {
|
||||
let declared = declare(data);
|
||||
assert!(
|
||||
declared.oversize || data.len() > PENDING_BUFFER_CAP,
|
||||
"a loud failure requires an over-length declared header or an \
|
||||
over-cap accumulator"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cap partition (WS-14/WS-05): a single feed at exactly
|
||||
/// `PENDING_BUFFER_CAP` cannot trip `PendingOverCap` (the pre-queue
|
||||
/// gate would have rejected a larger one; the walk's in-feed check is
|
||||
/// defense-in-depth, so only strictly-greater accumulations trip it).
|
||||
fn cap_walk() {
|
||||
assert!(
|
||||
PENDING_BUFFER_CAP == MAX_CHUNK_LEN as usize + 8,
|
||||
"the accumulator's cap is the wire chunk cap plus one header"
|
||||
);
|
||||
}
|
||||
|
||||
/// The outcome partition every walk asserts: emissions (one message
|
||||
/// per piece, ≤ `WS_MESSAGE_CAP`, concatenated) equal the declared
|
||||
/// payloads byte-for-byte, retention equals the trailing partial
|
||||
/// frame, and the failure arms require their declared causes.
|
||||
fn assert_outcome(walk: &mut WriteChunkWalk, result: Walk, fed: &[u8], declared: &Declared) {
|
||||
match result {
|
||||
Walk::Emitted(messages) => {
|
||||
let emitted: Vec<u8> = messages.iter().flatten().copied().collect();
|
||||
assert_eq!(
|
||||
emitted, declared.payloads,
|
||||
"emitted bytes must equal the declared payloads exactly"
|
||||
);
|
||||
for piece in &messages {
|
||||
assert!(
|
||||
!piece.is_empty()
|
||||
&& piece.len() <= alkhttp::websocket::byte_adapter::WS_MESSAGE_CAP,
|
||||
"each emitted message is a non-empty piece within the WS message cap"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
walk.retained_bytes(),
|
||||
fed.len() - declared.stop,
|
||||
"retained bytes must equal the trailing partial frame"
|
||||
);
|
||||
assert!(
|
||||
!declared.oversize,
|
||||
"an over-length frame must surface as Oversize"
|
||||
);
|
||||
}
|
||||
Walk::Incomplete => {
|
||||
assert_eq!(
|
||||
walk.retained_bytes(),
|
||||
fed.len(),
|
||||
"an incomplete feed retains every byte fed"
|
||||
);
|
||||
assert_eq!(
|
||||
declared.stop, 0,
|
||||
"Incomplete requires no complete frame in the fed bytes"
|
||||
);
|
||||
}
|
||||
Walk::Oversize => {
|
||||
assert!(
|
||||
declared.oversize,
|
||||
"Oversize requires a header declaring > MAX_CHUNK_LEN"
|
||||
);
|
||||
assert_eq!(
|
||||
walk.retained_bytes(),
|
||||
fed.len() - declared.stop,
|
||||
"the over-length header's bytes onward stay un-consumed"
|
||||
);
|
||||
}
|
||||
Walk::PendingOverCap => {
|
||||
assert!(
|
||||
fed.len() > PENDING_BUFFER_CAP,
|
||||
"PendingOverCap requires an over-cap accumulator; the \
|
||||
pre-queue poll_write gate keeps real pump drives at \
|
||||
one chunk plus header"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let corpus =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/ws_chunk_framing");
|
||||
let mut count = 0usize;
|
||||
for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") {
|
||||
let path = entry.expect("corpus entry readable").path();
|
||||
let data = std::fs::read(&path).expect("corpus entry readable");
|
||||
fuzz_ws_chunk_framing(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 20,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_chunk_walk_edges() {
|
||||
let h = |id: u32, len: u32| {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&id.to_be_bytes());
|
||||
b.extend_from_slice(&len.to_be_bytes());
|
||||
b
|
||||
};
|
||||
// Minimal valid chunk, len 0.
|
||||
fuzz_ws_chunk_framing(&h(0, 0));
|
||||
// One payload chunk.
|
||||
let mut one = h(1, 5);
|
||||
one.extend_from_slice(b"hello");
|
||||
fuzz_ws_chunk_framing(&one);
|
||||
// Two chunks back to back (channel id 0 + id 1).
|
||||
let mut two = h(0, 1);
|
||||
two.push(b'x');
|
||||
two.extend_from_slice(&h(1, 2));
|
||||
two.extend_from_slice(b"yz");
|
||||
fuzz_ws_chunk_framing(&two);
|
||||
// Truncations at every prefix length.
|
||||
let mut full = h(2, 4);
|
||||
full.extend_from_slice(b"wxyz");
|
||||
for n in 1..full.len() {
|
||||
fuzz_ws_chunk_framing(&full[..n]);
|
||||
}
|
||||
// Header-only prefixes (1..8 bytes).
|
||||
for n in 1..8 {
|
||||
fuzz_ws_chunk_framing(&h(3, 10)[..n]);
|
||||
}
|
||||
// Over-length declarations: > MAX_CHUNK_LEN, u32::MAX.
|
||||
let mut over = h(0, (MAX_CHUNK_LEN as u32) + 1);
|
||||
over.extend_from_slice(b"partial-payload");
|
||||
fuzz_ws_chunk_framing(&over);
|
||||
fuzz_ws_chunk_framing(&h(4, u32::MAX));
|
||||
// len just under / at the cap (small stand-ins — full 16 MiB
|
||||
// cap frames are valid but expensive; the fuzzer explores the
|
||||
// near-cap shapes and the cap partition is asserted exactly in
|
||||
// cap_walk).
|
||||
fuzz_ws_chunk_framing(&h(5, MAX_CHUNK_LEN as u32 - 1)[..8]);
|
||||
}
|
||||
}
|
||||
+85
-24
@@ -1034,7 +1034,7 @@ async fn error_envelope(
|
||||
/// JSON frame surfaces as itself (shape stability for JSON-first
|
||||
/// upstreams, and the spec's implicit `event: message` default never
|
||||
/// wraps a payload).
|
||||
fn sse_event_envelope(event: SseEvent, request_id: &str) -> ResponseEnvelope {
|
||||
pub fn sse_event_envelope(event: SseEvent, request_id: &str) -> ResponseEnvelope {
|
||||
let parsed = if event.data.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
@@ -1263,13 +1263,21 @@ fn is_sse_content_type(content_type: &str) -> bool {
|
||||
/// event-stream semantics: the last `event:` line before the blank
|
||||
/// line wins; absent → `None`, never the implicit `message` default —
|
||||
/// consumers distinguish "upstream named it" from "default name").
|
||||
pub(crate) struct SseEvent {
|
||||
pub(crate) data: String,
|
||||
pub(crate) event: Option<String>,
|
||||
pub struct SseEvent {
|
||||
/// The `data:` lines joined with `\n`.
|
||||
pub data: String,
|
||||
/// The frame's `event:` field name (WHATWG last-wins); `None` when
|
||||
/// the upstream sent none. Surfaced on non-JSON payloads only
|
||||
/// (FWD-17).
|
||||
pub event: Option<String>,
|
||||
}
|
||||
|
||||
/// Why an SSE feed failed: the only failure mode is the reassembly
|
||||
/// buffer's cap (FWD-14).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum SseParseError {
|
||||
pub enum SseParseError {
|
||||
/// The reassembly buffer would exceed
|
||||
/// [`SSE_EVENT_BUFFER_CAP`](Self) without a complete event.
|
||||
#[error("SSE event buffer exceeded {SSE_EVENT_BUFFER_CAP} bytes without a complete event")]
|
||||
BufferOverflow,
|
||||
}
|
||||
@@ -1284,7 +1292,7 @@ pub(crate) enum SseParseError {
|
||||
/// stream; the per-subscription total is
|
||||
/// [`HttpClientConfig::stream_total_byte_cap`], enforced by the
|
||||
/// `forward_stream` unfold across every `feed`.
|
||||
pub(crate) const SSE_EVENT_BUFFER_CAP: usize = 1024 * 1024;
|
||||
pub const SSE_EVENT_BUFFER_CAP: usize = 1024 * 1024;
|
||||
|
||||
/// Incremental byte-level SSE frame parser.
|
||||
///
|
||||
@@ -1299,7 +1307,7 @@ pub(crate) const SSE_EVENT_BUFFER_CAP: usize = 1024 * 1024;
|
||||
/// [`SseEvent::event`] for non-JSON payloads (FWD-17); `id:` and
|
||||
/// `retry:` fields are accepted and ignored; a blank line dispatches
|
||||
/// the pending event; a pending event with data is dispatched at EOF.
|
||||
pub(crate) struct SseParser {
|
||||
pub struct SseParser {
|
||||
buf: Vec<u8>,
|
||||
data_lines: Vec<String>,
|
||||
data_seen: bool,
|
||||
@@ -1307,8 +1315,26 @@ pub(crate) struct SseParser {
|
||||
bom_stripped: bool,
|
||||
}
|
||||
|
||||
/// The line terminator's optional CR: `data: x\r\n` and `data: x\n`
|
||||
/// are the same line (WHATWG framing; a CR may also arrive split ahead
|
||||
/// of the `\n` — the reassembly buffer keeps it, this strips it at
|
||||
/// assembly).
|
||||
fn strip_cr(mut line: Vec<u8>) -> Vec<u8> {
|
||||
if line.last() == Some(&b'\r') {
|
||||
line.pop();
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
impl Default for SseParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
pub(crate) fn new() -> Self {
|
||||
/// An empty parser (empty reassembly buffer, no pending event).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::new(),
|
||||
data_lines: Vec::new(),
|
||||
@@ -1322,29 +1348,33 @@ impl SseParser {
|
||||
/// dispatches; the last line stays buffered unless `eof`). With
|
||||
/// `eof`, also dispatches a pending event if it carries data
|
||||
/// lines, and flushes the buffer.
|
||||
pub(crate) fn feed(&mut self, chunk: &[u8], eof: bool) -> Result<Vec<SseEvent>, SseParseError> {
|
||||
pub fn feed(&mut self, chunk: &[u8], eof: bool) -> Result<Vec<SseEvent>, SseParseError> {
|
||||
if self.buf.len().saturating_add(chunk.len()) > SSE_EVENT_BUFFER_CAP {
|
||||
return Err(SseParseError::BufferOverflow);
|
||||
}
|
||||
self.buf.extend_from_slice(chunk);
|
||||
let mut events = Vec::new();
|
||||
let mut start = 0usize;
|
||||
while let Some(nl) = self.buf[start..].iter().position(|&b| b == b'\n') {
|
||||
let end = start + nl;
|
||||
let line_end = if end > start && self.buf[end - 1] == b'\r' {
|
||||
end - 1
|
||||
} else {
|
||||
end
|
||||
};
|
||||
let line = self.buf[start..line_end].to_vec();
|
||||
// Line assembly walks the chunk only — the reassembly buffer
|
||||
// holds the current line's bytes, so any terminating `\n`
|
||||
// inside the buffer sits at the tail where the chunk just
|
||||
// landed, and the scan never revisits earlier bytes (the
|
||||
// O(n²) dribble drain, FWD-20: a peer trickling one giant
|
||||
// unterminated line used to rescan the whole accumulated
|
||||
// buffer per feed, quadratic in the dribbled bytes).
|
||||
let mut rest = chunk;
|
||||
while let Some(nl) = rest.iter().position(|&b| b == b'\n') {
|
||||
self.buf.extend_from_slice(&rest[..nl]);
|
||||
let line = strip_cr(std::mem::take(&mut self.buf));
|
||||
if let Some(event) = self.parse_line(&line) {
|
||||
events.push(event);
|
||||
}
|
||||
start = end + 1;
|
||||
rest = &rest[nl + 1..];
|
||||
}
|
||||
if eof {
|
||||
if start < self.buf.len() {
|
||||
let line = self.buf[start..].to_vec();
|
||||
if !rest.is_empty() {
|
||||
self.buf.extend_from_slice(rest);
|
||||
}
|
||||
if !self.buf.is_empty() {
|
||||
let line = strip_cr(std::mem::take(&mut self.buf));
|
||||
if let Some(event) = self.parse_line(&line) {
|
||||
events.push(event);
|
||||
}
|
||||
@@ -1354,9 +1384,8 @@ impl SseParser {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
self.buf.clear();
|
||||
} else {
|
||||
self.buf.drain(..start);
|
||||
self.buf.extend_from_slice(rest);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
@@ -1415,6 +1444,15 @@ impl SseParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuzz-harness re-export hub (`fuzz/shared/src/sse_parser.rs`):
|
||||
/// gathers the SSE parse surface in one import path. The items are
|
||||
/// `pub` on the module; this hub exists so the harness has a single,
|
||||
/// intentionally-scoped path and future non-fuzz readers see exactly
|
||||
/// which surface the fuzz harness drives.
|
||||
pub mod fuzzing {
|
||||
pub use super::{sse_event_envelope, SseEvent, SseParseError, SseParser, SSE_EVENT_BUFFER_CAP};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2510,6 +2548,29 @@ mod tests {
|
||||
assert_eq!(rest[0].data, "{\"n\":2}", "the second frame is unaffected");
|
||||
}
|
||||
|
||||
/// The O(n²) dribble-drain regression (FWD-20): feeding a giant
|
||||
/// unterminated line one byte at a time must scan each chunk once
|
||||
/// — the per-feed cost stays O(1) in the accumulated buffer, so a
|
||||
/// drip-fed subscription cannot burn quadratic CPU on the
|
||||
/// reassembly buffer. The line budget keeps the test honest: at
|
||||
/// the old rescan shape, 20k feeds over one unterminated line
|
||||
/// costs roughly the square of the buffer, well past a second.
|
||||
#[test]
|
||||
fn sse_parser_dribble_feeds_do_not_rescan_the_buffer() {
|
||||
let mut parser = SseParser::new();
|
||||
let total = 20_000usize;
|
||||
for byte in std::iter::repeat_n(b'a', total) {
|
||||
parser.feed(&[byte], false).expect("cap not reached");
|
||||
}
|
||||
let events = parser.feed(&[], true).expect("eof flush");
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"an unterminated line with no data dispatches nothing"
|
||||
);
|
||||
let tail = parser.feed(b"data: after\n\n", true).expect("clean tail");
|
||||
assert_eq!(tail.len(), 1, "framing survives the dribble");
|
||||
}
|
||||
|
||||
/// A line that is not valid UTF-8 is dropped (WHATWG decode-failure
|
||||
/// semantics for this parser): no event, no panic, and the framing
|
||||
/// continues — the following valid frame dispatches normally.
|
||||
|
||||
+153
-40
@@ -459,6 +459,125 @@ async fn fail_write_pump<M, S>(
|
||||
/// through a clogged socket, and the close send would park on it too.
|
||||
/// The window is per send, so a slow-but-moving peer survives; only a
|
||||
/// fully stalled sink trips it.
|
||||
/// The write pump's chunk walk, extracted as a pure state machine.
|
||||
///
|
||||
/// Holds the `pending` accumulator and executes exactly the loop
|
||||
/// `run_write_pump` runs on each `WriteMsg::Bytes`: greedy 8-byte
|
||||
/// header scan, the `MAX_CHUNK_LEN` gate (WS-04/HY-09), the emit of
|
||||
/// each complete chunk (split into `WS_MESSAGE_CAP` pieces; the
|
||||
/// receiver's boundary is the chunk header, not the WS message),
|
||||
/// and the `PENDING_BUFFER_CAP` defense-in-depth check (WS-05). The
|
||||
/// pump maps the outcomes to sink sends and stream errors; the state
|
||||
/// machine itself is pure and is driven directly by the
|
||||
/// `ws_chunk_framing` fuzz harness (`fuzz/shared`).
|
||||
///
|
||||
/// Outcomes of feeding one write:
|
||||
///
|
||||
/// - [`Walk::Emitted`] — one or more chunks completed; the returned
|
||||
/// bytes are the `WS_MESSAGE_CAP` piece spans to emit (in order).
|
||||
/// - [`Walk::Incomplete`] — fewer than a full header plus payload is
|
||||
/// buffered; the pump awaits the next span.
|
||||
/// - [`Walk::Oversize`] — a header claiming
|
||||
/// `> MAX_CHUNK_LEN` reached the front of the buffer; the pump fails
|
||||
/// the stream loudly (close 1011 + `InvalidData`).
|
||||
/// - [`Walk::PendingOverCap`] — the accumulator exceeded
|
||||
/// `PENDING_BUFFER_CAP` without completing a chunk; the pump fails
|
||||
/// the stream loudly (defense-in-depth behind the pre-queue
|
||||
/// `poll_write` rejection, WS-14).
|
||||
#[derive(Default)]
|
||||
pub struct WriteChunkWalk {
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WriteChunkWalk {
|
||||
/// An empty walk (empty pending buffer).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One queued byte span entering the pump's accumulator. The
|
||||
/// pre-queue `poll_write` gate (WS-14) rejects single writes over
|
||||
/// the cap before this point; the in-walk overflow check remains
|
||||
/// as defense-in-depth (WS-05).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Debug builds assert the pre-queue contract (`b.len() <=
|
||||
/// PENDING_BUFFER_CAP`) — the same `debug_assert!` the pump ran
|
||||
/// inline.
|
||||
pub fn feed(&mut self, b: &[u8]) -> Walk {
|
||||
debug_assert!(b.len() <= PENDING_BUFFER_CAP);
|
||||
self.pending.extend_from_slice(b);
|
||||
let mut messages: Vec<Vec<u8>> = Vec::new();
|
||||
while self.pending.len() >= 8 {
|
||||
let len_bytes = [
|
||||
self.pending[4],
|
||||
self.pending[5],
|
||||
self.pending[6],
|
||||
self.pending[7],
|
||||
];
|
||||
let len = u32::from_be_bytes(len_bytes);
|
||||
if len > MAX_CHUNK_LEN {
|
||||
return Walk::Oversize;
|
||||
}
|
||||
let total = 8usize.saturating_add(len as usize);
|
||||
if self.pending.len() < total {
|
||||
break;
|
||||
}
|
||||
let chunk: Vec<u8> = self.pending.drain(..total).collect();
|
||||
for piece in chunk.chunks(WS_MESSAGE_CAP) {
|
||||
messages.push(piece.to_vec());
|
||||
}
|
||||
}
|
||||
if self.pending.len() > PENDING_BUFFER_CAP {
|
||||
return Walk::PendingOverCap;
|
||||
}
|
||||
if messages.is_empty() {
|
||||
Walk::Incomplete
|
||||
} else {
|
||||
Walk::Emitted(messages)
|
||||
}
|
||||
}
|
||||
|
||||
/// The retained byte count (`pending.len()`): the accumulator's
|
||||
/// bound assertion target, exactly the buffer the pump checks
|
||||
/// against `PENDING_BUFFER_CAP`. A diagnostic for the fuzz harness
|
||||
/// (the alktunnels/alksocks `retained_bytes()` posture); not a
|
||||
/// protocol surface.
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of one [`WriteChunkWalk::feed`].
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Walk {
|
||||
/// Complete chunk(s) drained; one WS binary message per piece,
|
||||
/// in order (each piece ≤ `WS_MESSAGE_CAP`; a chunk spanning
|
||||
/// pieces keeps its bytes contiguous across the messages, per the
|
||||
/// pump's emit contract).
|
||||
Emitted(Vec<Vec<u8>>),
|
||||
/// The accumulator holds a partial header or payload; nothing to
|
||||
/// emit.
|
||||
Incomplete,
|
||||
/// A header claiming a payload over `MAX_CHUNK_LEN` reached the
|
||||
/// front of the buffer (WS-04/HY-09): the stream fails loudly.
|
||||
Oversize,
|
||||
/// The accumulator exceeded `PENDING_BUFFER_CAP` without a
|
||||
/// complete chunk (WS-05): the stream fails loudly.
|
||||
PendingOverCap,
|
||||
}
|
||||
|
||||
/// The write pump: drains the queued byte spans through
|
||||
/// [`WriteChunkWalk`] (the chunk walk of §module-doc), mapping the
|
||||
/// outcomes to sink sends and stream errors. Ends with a WS Close
|
||||
/// after the queue drains; `CloseWith` requests
|
||||
/// (`WriteMsg::CloseWith`) map to a close frame with the requested
|
||||
/// code and its per-cause reason (WS-15) — the code alone is
|
||||
/// ambiguous across causes (idle 1001 vs protocol 1002 vs internal
|
||||
/// 1011 arms), so the pump never invents one.
|
||||
async fn run_write_pump<M, S>(
|
||||
mut ws_sink: futures::stream::SplitSink<S, <M as WsFraming>::Msg>,
|
||||
mut write_rx: futures_mpsc::Receiver<WriteMsg>,
|
||||
@@ -468,7 +587,7 @@ async fn run_write_pump<M, S>(
|
||||
S: futures::Sink<<M as WsFraming>::Msg> + Unpin,
|
||||
M: WsFraming,
|
||||
{
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let mut walk = WriteChunkWalk::new();
|
||||
while let Some(msg) = write_rx.next().await {
|
||||
match msg {
|
||||
WriteMsg::CloseWith(code, reason) => {
|
||||
@@ -483,47 +602,41 @@ async fn run_write_pump<M, S>(
|
||||
}
|
||||
break;
|
||||
}
|
||||
WriteMsg::Bytes(b) => {
|
||||
debug_assert!(b.len() <= PENDING_BUFFER_CAP);
|
||||
pending.extend_from_slice(&b);
|
||||
}
|
||||
}
|
||||
loop {
|
||||
if pending.len() < 8 {
|
||||
break;
|
||||
}
|
||||
let len_bytes = [pending[4], pending[5], pending[6], pending[7]];
|
||||
let len = u32::from_be_bytes(len_bytes);
|
||||
if len > MAX_CHUNK_LEN {
|
||||
fail_write_pump::<M, _>(
|
||||
&mut ws_sink,
|
||||
&write_error_slot,
|
||||
"chunk length exceeds MAX_CHUNK_LEN",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let total = 8usize.saturating_add(len as usize);
|
||||
if pending.len() < total {
|
||||
break;
|
||||
}
|
||||
let chunk: Vec<u8> = pending.drain(..total).collect();
|
||||
for piece in chunk.chunks(WS_MESSAGE_CAP) {
|
||||
if !send_bounded::<M, _>(&mut ws_sink, piece, write_timeout, &write_error_slot)
|
||||
.await
|
||||
{
|
||||
WriteMsg::Bytes(b) => match walk.feed(&b) {
|
||||
Walk::Emitted(messages) => {
|
||||
for piece in &messages {
|
||||
if !send_bounded::<M, _>(
|
||||
&mut ws_sink,
|
||||
piece,
|
||||
write_timeout,
|
||||
&write_error_slot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Walk::Incomplete => {}
|
||||
Walk::Oversize => {
|
||||
fail_write_pump::<M, _>(
|
||||
&mut ws_sink,
|
||||
&write_error_slot,
|
||||
"chunk length exceeds MAX_CHUNK_LEN",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if pending.len() > PENDING_BUFFER_CAP {
|
||||
fail_write_pump::<M, _>(
|
||||
&mut ws_sink,
|
||||
&write_error_slot,
|
||||
"write pending buffer exceeded cap",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
Walk::PendingOverCap => {
|
||||
fail_write_pump::<M, _>(
|
||||
&mut ws_sink,
|
||||
&write_error_slot,
|
||||
"write pending buffer exceeded cap",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
let _ = ws_sink.close().await;
|
||||
|
||||
Reference in new issue
Block a user