From 08a36944646b9e835bba55845cdecafbf1f8360f Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 28 Sep 2026 11:18:08 +0000 Subject: [PATCH] fuzz: adopt fuzzing (wave 1) + alkcall 0.8.1 / alktunnels 0.2.1 bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the alk*/fuzzing posture (alkcall 0.8.1 + alktunnels 0.2.1 precedent) before the v0.1 publish — the cheapest moment this crate will ever have for it: - alkcall 0.8.1 + alktunnels 0.2.1: the duplicate-adopt/open fix is directly relevant to this crate's consumer open/adopt paths - fuzz/ workspace (the sibling layout): nightly-pinned subtree, stable fuzz/shared library (corpus replay = the fuzz gate), detached runner, JSON dict, deterministic seed generator; 129 committed seeds - three wave-1 targets over already-pub APIs: datagram_codec (the ASSOCIATE frame codec — the alkcall-fuzz-doc-assigned downstream target), rfc1928_codec (the forked RFC 1928 core's handshake state machines, both protocol directions), params_parse (the wire-stable open-op surface) - one diagnostic: DatagramReader::retained_bytes() (the decoder-state bound's assertion target, the alktunnels retained_bytes() posture) - docs/research/fuzzing.md: the scoped plan + implementation log; AGENTS.md gains the corpus-replay verification line - campaigns: 3 × 10-min detached runs exited 0 (datagram ~1.9M execs, rfc1928 ~2.0M, params ~3.1M; oom/timeout/crash 0/0/0 on all 33 fork jobs per target; coverage saturated on both codec targets). No crate bugs; three harness-model defects found + fixed (2 pre-campaign by corpus replay, 2 campaign-found, crash inputs promoted as seeds 900/ 901): the len==0 well-framed decode partition, the fieldless-struct serde-seq form, the BadAtyp re-frame arm, the short-domain count-walk gate - wave 2 (session_pumps stateful target) deferred to post-0.1 — the byte-level targets carry the attacker-reachable load for the publish Verification: cargo test (15 suites ok, default + all-features), clippy (all-targets + wasm + fuzz/shared), fmt, cargo doc, publish --dry-run, corpus replay 129 seeds green, cargo fuzz build clean. Refs: alkcall docs/research/fuzzing.md (§7.9 posture), alktunnels docs/research/fuzzing.md; docs/research/fuzzing.md (this crate's copy) --- AGENTS.md | 8 + Cargo.toml | 18 +- docs/research/fuzzing.md | 435 ++++++++++++++++++++++++++++ fuzz/.gitignore | 7 + fuzz/Cargo.toml | 38 +++ fuzz/README.md | 55 ++++ fuzz/corpus/datagram_codec/seed-000 | Bin 0 -> 9 bytes fuzz/corpus/datagram_codec/seed-001 | Bin 0 -> 10 bytes fuzz/corpus/datagram_codec/seed-002 | Bin 0 -> 14 bytes fuzz/corpus/datagram_codec/seed-003 | Bin 0 -> 309 bytes fuzz/corpus/datagram_codec/seed-004 | Bin 0 -> 21 bytes fuzz/corpus/datagram_codec/seed-005 | Bin 0 -> 22 bytes fuzz/corpus/datagram_codec/seed-006 | Bin 0 -> 26 bytes fuzz/corpus/datagram_codec/seed-007 | Bin 0 -> 321 bytes fuzz/corpus/datagram_codec/seed-008 | Bin 0 -> 17 bytes fuzz/corpus/datagram_codec/seed-009 | Bin 0 -> 18 bytes fuzz/corpus/datagram_codec/seed-010 | Bin 0 -> 22 bytes fuzz/corpus/datagram_codec/seed-011 | Bin 0 -> 317 bytes fuzz/corpus/datagram_codec/seed-012 | Bin 0 -> 1 bytes fuzz/corpus/datagram_codec/seed-013 | Bin 0 -> 2 bytes fuzz/corpus/datagram_codec/seed-014 | Bin 0 -> 3 bytes fuzz/corpus/datagram_codec/seed-015 | Bin 0 -> 4 bytes fuzz/corpus/datagram_codec/seed-016 | Bin 0 -> 5 bytes fuzz/corpus/datagram_codec/seed-017 | Bin 0 -> 6 bytes fuzz/corpus/datagram_codec/seed-018 | Bin 0 -> 7 bytes fuzz/corpus/datagram_codec/seed-019 | Bin 0 -> 8 bytes fuzz/corpus/datagram_codec/seed-020 | Bin 0 -> 9 bytes fuzz/corpus/datagram_codec/seed-021 | Bin 0 -> 10 bytes fuzz/corpus/datagram_codec/seed-022 | Bin 0 -> 11 bytes fuzz/corpus/datagram_codec/seed-023 | Bin 0 -> 12 bytes fuzz/corpus/datagram_codec/seed-024 | Bin 0 -> 13 bytes fuzz/corpus/datagram_codec/seed-025 | Bin 0 -> 14 bytes fuzz/corpus/datagram_codec/seed-026 | Bin 0 -> 15 bytes fuzz/corpus/datagram_codec/seed-027 | Bin 0 -> 2 bytes fuzz/corpus/datagram_codec/seed-028 | Bin 0 -> 9 bytes fuzz/corpus/datagram_codec/seed-029 | Bin 0 -> 302 bytes fuzz/corpus/datagram_codec/seed-030 | Bin 0 -> 42 bytes fuzz/corpus/datagram_codec/seed-031 | Bin 0 -> 9 bytes fuzz/corpus/datagram_codec/seed-032 | Bin 0 -> 9 bytes fuzz/corpus/datagram_codec/seed-033 | Bin 0 -> 281 bytes fuzz/corpus/datagram_codec/seed-034 | Bin 0 -> 53 bytes fuzz/corpus/datagram_codec/seed-035 | Bin 0 -> 1 bytes fuzz/corpus/datagram_codec/seed-036 | Bin 0 -> 2 bytes fuzz/corpus/datagram_codec/seed-037 | Bin 0 -> 3 bytes fuzz/corpus/datagram_codec/seed-038 | Bin 0 -> 5 bytes fuzz/corpus/datagram_codec/seed-039 | Bin 0 -> 7 bytes fuzz/corpus/datagram_codec/seed-040 | Bin 0 -> 11 bytes fuzz/corpus/datagram_codec/seed-041 | Bin 0 -> 47 bytes fuzz/corpus/datagram_codec/seed-042 | Bin 0 -> 17 bytes fuzz/corpus/datagram_codec/seed-043 | Bin 0 -> 424 bytes fuzz/corpus/datagram_codec/seed-044 | Bin 0 -> 411 bytes fuzz/corpus/params_parse/seed-065 | 1 + fuzz/corpus/params_parse/seed-066 | 1 + fuzz/corpus/params_parse/seed-067 | 1 + fuzz/corpus/params_parse/seed-068 | 1 + fuzz/corpus/params_parse/seed-069 | 1 + fuzz/corpus/params_parse/seed-070 | 1 + fuzz/corpus/params_parse/seed-071 | 1 + fuzz/corpus/params_parse/seed-072 | 1 + fuzz/corpus/params_parse/seed-073 | 1 + fuzz/corpus/params_parse/seed-074 | 1 + fuzz/corpus/params_parse/seed-075 | 1 + fuzz/corpus/params_parse/seed-076 | 1 + fuzz/corpus/params_parse/seed-077 | 1 + fuzz/corpus/params_parse/seed-078 | 1 + fuzz/corpus/params_parse/seed-079 | 1 + fuzz/corpus/params_parse/seed-080 | 1 + fuzz/corpus/params_parse/seed-081 | 1 + fuzz/corpus/params_parse/seed-082 | 1 + fuzz/corpus/params_parse/seed-083 | 1 + fuzz/corpus/rfc1928_codec/seed-000 | Bin 0 -> 3 bytes fuzz/corpus/rfc1928_codec/seed-001 | 1 + fuzz/corpus/rfc1928_codec/seed-002 | Bin 0 -> 4 bytes fuzz/corpus/rfc1928_codec/seed-003 | Bin 0 -> 7 bytes fuzz/corpus/rfc1928_codec/seed-004 | Bin 0 -> 7 bytes fuzz/corpus/rfc1928_codec/seed-005 | Bin 0 -> 20 bytes fuzz/corpus/rfc1928_codec/seed-006 | Bin 0 -> 15 bytes fuzz/corpus/rfc1928_codec/seed-007 | Bin 0 -> 23 bytes fuzz/corpus/rfc1928_codec/seed-008 | Bin 0 -> 27 bytes fuzz/corpus/rfc1928_codec/seed-009 | Bin 0 -> 15 bytes fuzz/corpus/rfc1928_codec/seed-010 | Bin 0 -> 15 bytes fuzz/corpus/rfc1928_codec/seed-011 | Bin 0 -> 3 bytes fuzz/corpus/rfc1928_codec/seed-012 | 1 + fuzz/corpus/rfc1928_codec/seed-013 | Bin 0 -> 3 bytes fuzz/corpus/rfc1928_codec/seed-014 | Bin 0 -> 9 bytes fuzz/corpus/rfc1928_codec/seed-015 | 1 + fuzz/corpus/rfc1928_codec/seed-016 | 1 + fuzz/corpus/rfc1928_codec/seed-017 | Bin 0 -> 3 bytes fuzz/corpus/rfc1928_codec/seed-018 | Bin 0 -> 4 bytes fuzz/corpus/rfc1928_codec/seed-019 | Bin 0 -> 5 bytes fuzz/corpus/rfc1928_codec/seed-020 | Bin 0 -> 6 bytes fuzz/corpus/rfc1928_codec/seed-021 | Bin 0 -> 7 bytes fuzz/corpus/rfc1928_codec/seed-022 | Bin 0 -> 8 bytes fuzz/corpus/rfc1928_codec/seed-023 | Bin 0 -> 9 bytes fuzz/corpus/rfc1928_codec/seed-024 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-025 | Bin 0 -> 11 bytes fuzz/corpus/rfc1928_codec/seed-026 | Bin 0 -> 12 bytes fuzz/corpus/rfc1928_codec/seed-027 | Bin 0 -> 13 bytes fuzz/corpus/rfc1928_codec/seed-028 | Bin 0 -> 14 bytes fuzz/corpus/rfc1928_codec/seed-029 | Bin 0 -> 9 bytes fuzz/corpus/rfc1928_codec/seed-030 | 1 + fuzz/corpus/rfc1928_codec/seed-031 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-032 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-033 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-034 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-035 | Bin 0 -> 17 bytes fuzz/corpus/rfc1928_codec/seed-036 | Bin 0 -> 25 bytes fuzz/corpus/rfc1928_codec/seed-037 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-038 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-039 | Bin 0 -> 1 bytes fuzz/corpus/rfc1928_codec/seed-040 | Bin 0 -> 2 bytes fuzz/corpus/rfc1928_codec/seed-041 | Bin 0 -> 3 bytes fuzz/corpus/rfc1928_codec/seed-042 | Bin 0 -> 4 bytes fuzz/corpus/rfc1928_codec/seed-043 | Bin 0 -> 5 bytes fuzz/corpus/rfc1928_codec/seed-044 | Bin 0 -> 6 bytes fuzz/corpus/rfc1928_codec/seed-045 | Bin 0 -> 7 bytes fuzz/corpus/rfc1928_codec/seed-046 | Bin 0 -> 8 bytes fuzz/corpus/rfc1928_codec/seed-047 | Bin 0 -> 9 bytes fuzz/corpus/rfc1928_codec/seed-048 | Bin 0 -> 10 bytes fuzz/corpus/rfc1928_codec/seed-049 | Bin 0 -> 11 bytes fuzz/corpus/rfc1928_codec/seed-050 | Bin 0 -> 12 bytes fuzz/corpus/rfc1928_codec/seed-051 | Bin 0 -> 13 bytes fuzz/corpus/rfc1928_codec/seed-052 | Bin 0 -> 14 bytes fuzz/corpus/rfc1928_codec/seed-053 | Bin 0 -> 15 bytes fuzz/corpus/rfc1928_codec/seed-054 | Bin 0 -> 16 bytes fuzz/corpus/rfc1928_codec/seed-055 | Bin 0 -> 17 bytes fuzz/corpus/rfc1928_codec/seed-056 | Bin 0 -> 18 bytes fuzz/corpus/rfc1928_codec/seed-057 | Bin 0 -> 19 bytes fuzz/corpus/rfc1928_codec/seed-058 | Bin 0 -> 20 bytes fuzz/corpus/rfc1928_codec/seed-059 | Bin 0 -> 21 bytes fuzz/corpus/rfc1928_codec/seed-060 | Bin 0 -> 22 bytes fuzz/corpus/rfc1928_codec/seed-061 | Bin 0 -> 23 bytes fuzz/corpus/rfc1928_codec/seed-062 | Bin 0 -> 259 bytes fuzz/corpus/rfc1928_codec/seed-063 | Bin 0 -> 7 bytes fuzz/corpus/rfc1928_codec/seed-064 | 1 + fuzz/fuzz_targets/datagram_codec.rs | 7 + fuzz/fuzz_targets/params_parse.rs | 7 + fuzz/fuzz_targets/rfc1928_codec.rs | 7 + fuzz/gen_fuzz_seeds.py | 256 ++++++++++++++++ fuzz/json.dict | 23 ++ fuzz/run-detached.sh | 29 ++ fuzz/rust-toolchain.toml | 3 + fuzz/shared/Cargo.toml | 16 + fuzz/shared/src/arbitrary_value.rs | 108 +++++++ fuzz/shared/src/datagram_codec.rs | 343 ++++++++++++++++++++++ fuzz/shared/src/lib.rs | 10 + fuzz/shared/src/params_parse.rs | 165 +++++++++++ fuzz/shared/src/rfc1928_codec.rs | 258 +++++++++++++++++ src/wire.rs | 9 + 149 files changed, 1822 insertions(+), 5 deletions(-) create mode 100644 docs/research/fuzzing.md create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/corpus/datagram_codec/seed-000 create mode 100644 fuzz/corpus/datagram_codec/seed-001 create mode 100644 fuzz/corpus/datagram_codec/seed-002 create mode 100644 fuzz/corpus/datagram_codec/seed-003 create mode 100644 fuzz/corpus/datagram_codec/seed-004 create mode 100644 fuzz/corpus/datagram_codec/seed-005 create mode 100644 fuzz/corpus/datagram_codec/seed-006 create mode 100644 fuzz/corpus/datagram_codec/seed-007 create mode 100644 fuzz/corpus/datagram_codec/seed-008 create mode 100644 fuzz/corpus/datagram_codec/seed-009 create mode 100644 fuzz/corpus/datagram_codec/seed-010 create mode 100644 fuzz/corpus/datagram_codec/seed-011 create mode 100644 fuzz/corpus/datagram_codec/seed-012 create mode 100644 fuzz/corpus/datagram_codec/seed-013 create mode 100644 fuzz/corpus/datagram_codec/seed-014 create mode 100644 fuzz/corpus/datagram_codec/seed-015 create mode 100644 fuzz/corpus/datagram_codec/seed-016 create mode 100644 fuzz/corpus/datagram_codec/seed-017 create mode 100644 fuzz/corpus/datagram_codec/seed-018 create mode 100644 fuzz/corpus/datagram_codec/seed-019 create mode 100644 fuzz/corpus/datagram_codec/seed-020 create mode 100644 fuzz/corpus/datagram_codec/seed-021 create mode 100644 fuzz/corpus/datagram_codec/seed-022 create mode 100644 fuzz/corpus/datagram_codec/seed-023 create mode 100644 fuzz/corpus/datagram_codec/seed-024 create mode 100644 fuzz/corpus/datagram_codec/seed-025 create mode 100644 fuzz/corpus/datagram_codec/seed-026 create mode 100644 fuzz/corpus/datagram_codec/seed-027 create mode 100644 fuzz/corpus/datagram_codec/seed-028 create mode 100644 fuzz/corpus/datagram_codec/seed-029 create mode 100644 fuzz/corpus/datagram_codec/seed-030 create mode 100644 fuzz/corpus/datagram_codec/seed-031 create mode 100644 fuzz/corpus/datagram_codec/seed-032 create mode 100644 fuzz/corpus/datagram_codec/seed-033 create mode 100644 fuzz/corpus/datagram_codec/seed-034 create mode 100644 fuzz/corpus/datagram_codec/seed-035 create mode 100644 fuzz/corpus/datagram_codec/seed-036 create mode 100644 fuzz/corpus/datagram_codec/seed-037 create mode 100644 fuzz/corpus/datagram_codec/seed-038 create mode 100644 fuzz/corpus/datagram_codec/seed-039 create mode 100644 fuzz/corpus/datagram_codec/seed-040 create mode 100644 fuzz/corpus/datagram_codec/seed-041 create mode 100644 fuzz/corpus/datagram_codec/seed-042 create mode 100644 fuzz/corpus/datagram_codec/seed-043 create mode 100644 fuzz/corpus/datagram_codec/seed-044 create mode 100644 fuzz/corpus/params_parse/seed-065 create mode 100644 fuzz/corpus/params_parse/seed-066 create mode 100644 fuzz/corpus/params_parse/seed-067 create mode 100644 fuzz/corpus/params_parse/seed-068 create mode 100644 fuzz/corpus/params_parse/seed-069 create mode 100644 fuzz/corpus/params_parse/seed-070 create mode 100644 fuzz/corpus/params_parse/seed-071 create mode 100644 fuzz/corpus/params_parse/seed-072 create mode 100644 fuzz/corpus/params_parse/seed-073 create mode 100644 fuzz/corpus/params_parse/seed-074 create mode 100644 fuzz/corpus/params_parse/seed-075 create mode 100644 fuzz/corpus/params_parse/seed-076 create mode 100644 fuzz/corpus/params_parse/seed-077 create mode 100644 fuzz/corpus/params_parse/seed-078 create mode 100644 fuzz/corpus/params_parse/seed-079 create mode 100644 fuzz/corpus/params_parse/seed-080 create mode 100644 fuzz/corpus/params_parse/seed-081 create mode 100644 fuzz/corpus/params_parse/seed-082 create mode 100644 fuzz/corpus/params_parse/seed-083 create mode 100644 fuzz/corpus/rfc1928_codec/seed-000 create mode 100644 fuzz/corpus/rfc1928_codec/seed-001 create mode 100644 fuzz/corpus/rfc1928_codec/seed-002 create mode 100644 fuzz/corpus/rfc1928_codec/seed-003 create mode 100644 fuzz/corpus/rfc1928_codec/seed-004 create mode 100644 fuzz/corpus/rfc1928_codec/seed-005 create mode 100644 fuzz/corpus/rfc1928_codec/seed-006 create mode 100644 fuzz/corpus/rfc1928_codec/seed-007 create mode 100644 fuzz/corpus/rfc1928_codec/seed-008 create mode 100644 fuzz/corpus/rfc1928_codec/seed-009 create mode 100644 fuzz/corpus/rfc1928_codec/seed-010 create mode 100644 fuzz/corpus/rfc1928_codec/seed-011 create mode 100644 fuzz/corpus/rfc1928_codec/seed-012 create mode 100644 fuzz/corpus/rfc1928_codec/seed-013 create mode 100644 fuzz/corpus/rfc1928_codec/seed-014 create mode 100644 fuzz/corpus/rfc1928_codec/seed-015 create mode 100644 fuzz/corpus/rfc1928_codec/seed-016 create mode 100644 fuzz/corpus/rfc1928_codec/seed-017 create mode 100644 fuzz/corpus/rfc1928_codec/seed-018 create mode 100644 fuzz/corpus/rfc1928_codec/seed-019 create mode 100644 fuzz/corpus/rfc1928_codec/seed-020 create mode 100644 fuzz/corpus/rfc1928_codec/seed-021 create mode 100644 fuzz/corpus/rfc1928_codec/seed-022 create mode 100644 fuzz/corpus/rfc1928_codec/seed-023 create mode 100644 fuzz/corpus/rfc1928_codec/seed-024 create mode 100644 fuzz/corpus/rfc1928_codec/seed-025 create mode 100644 fuzz/corpus/rfc1928_codec/seed-026 create mode 100644 fuzz/corpus/rfc1928_codec/seed-027 create mode 100644 fuzz/corpus/rfc1928_codec/seed-028 create mode 100644 fuzz/corpus/rfc1928_codec/seed-029 create mode 100644 fuzz/corpus/rfc1928_codec/seed-030 create mode 100644 fuzz/corpus/rfc1928_codec/seed-031 create mode 100644 fuzz/corpus/rfc1928_codec/seed-032 create mode 100644 fuzz/corpus/rfc1928_codec/seed-033 create mode 100644 fuzz/corpus/rfc1928_codec/seed-034 create mode 100644 fuzz/corpus/rfc1928_codec/seed-035 create mode 100644 fuzz/corpus/rfc1928_codec/seed-036 create mode 100644 fuzz/corpus/rfc1928_codec/seed-037 create mode 100644 fuzz/corpus/rfc1928_codec/seed-038 create mode 100644 fuzz/corpus/rfc1928_codec/seed-039 create mode 100644 fuzz/corpus/rfc1928_codec/seed-040 create mode 100644 fuzz/corpus/rfc1928_codec/seed-041 create mode 100644 fuzz/corpus/rfc1928_codec/seed-042 create mode 100644 fuzz/corpus/rfc1928_codec/seed-043 create mode 100644 fuzz/corpus/rfc1928_codec/seed-044 create mode 100644 fuzz/corpus/rfc1928_codec/seed-045 create mode 100644 fuzz/corpus/rfc1928_codec/seed-046 create mode 100644 fuzz/corpus/rfc1928_codec/seed-047 create mode 100644 fuzz/corpus/rfc1928_codec/seed-048 create mode 100644 fuzz/corpus/rfc1928_codec/seed-049 create mode 100644 fuzz/corpus/rfc1928_codec/seed-050 create mode 100644 fuzz/corpus/rfc1928_codec/seed-051 create mode 100644 fuzz/corpus/rfc1928_codec/seed-052 create mode 100644 fuzz/corpus/rfc1928_codec/seed-053 create mode 100644 fuzz/corpus/rfc1928_codec/seed-054 create mode 100644 fuzz/corpus/rfc1928_codec/seed-055 create mode 100644 fuzz/corpus/rfc1928_codec/seed-056 create mode 100644 fuzz/corpus/rfc1928_codec/seed-057 create mode 100644 fuzz/corpus/rfc1928_codec/seed-058 create mode 100644 fuzz/corpus/rfc1928_codec/seed-059 create mode 100644 fuzz/corpus/rfc1928_codec/seed-060 create mode 100644 fuzz/corpus/rfc1928_codec/seed-061 create mode 100644 fuzz/corpus/rfc1928_codec/seed-062 create mode 100644 fuzz/corpus/rfc1928_codec/seed-063 create mode 100644 fuzz/corpus/rfc1928_codec/seed-064 create mode 100644 fuzz/fuzz_targets/datagram_codec.rs create mode 100644 fuzz/fuzz_targets/params_parse.rs create mode 100644 fuzz/fuzz_targets/rfc1928_codec.rs create mode 100644 fuzz/gen_fuzz_seeds.py create mode 100644 fuzz/json.dict create mode 100755 fuzz/run-detached.sh create mode 100644 fuzz/rust-toolchain.toml create mode 100644 fuzz/shared/Cargo.toml create mode 100644 fuzz/shared/src/arbitrary_value.rs create mode 100644 fuzz/shared/src/datagram_codec.rs create mode 100644 fuzz/shared/src/lib.rs create mode 100644 fuzz/shared/src/params_parse.rs create mode 100644 fuzz/shared/src/rfc1928_codec.rs diff --git a/AGENTS.md b/AGENTS.md index fb96abf..b05a1b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,11 +273,19 @@ cargo clippy --all-targets -- -D warnings cargo fmt --check cargo doc --no-deps # if docs changed cargo test --all-features # if features are added +cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (the fuzz gate) cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean cargo clippy --target wasm32-unknown-unknown -- -D warnings cargo publish --dry-run --allow-dirty # before a release ``` +The corpus replay is the fuzz gate (the alkcall §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` when +`src/wire.rs`, `src/rfc1928/`, or `src/params.rs` change — never as a +foreground child of an agent session (docs/research/fuzzing.md §5). + The wasm check is the structural guard for the "default crate is wasm-clean" invariant (same as alktty/alktunnels) — run it whenever a non-backend module changes. It is a hard gate: the RFC 1928 core is diff --git a/Cargo.toml b/Cargo.toml index b6b2626..1c66aaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,11 @@ repository = "https://git.alk.dev/alkdev/alksocks" readme = "README.md" keywords = ["socks5", "proxy", "rfc1928", "channels", "alkcall"] categories = ["network-programming", "asynchronous"] -exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md"] +exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md", "fuzz/"] + +[workspace] +members = ["."] +exclude = ["fuzz"] [lib] name = "alksocks" @@ -24,8 +28,10 @@ composed = ["dep:alktunnels"] [dependencies] # The substrate (convention 11): pinned, bump deliberately, never -# transitively. First consumption of the 0.8.0 surface. -alkcall = "0.8.0" +# transitively. 0.8.1: the duplicate-adopt/open fix (the alkcall 0.8.1 +# fuzz finding — the consumer sessions' open/adopt paths are exposed to +# the replayed-open-op scenario its changelog names). +alkcall = "0.8.1" tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] } # The fork's declared logging seam (rfc1928.md: upstream `log` calls # were dropped or re-pointed here); no source file references it yet — @@ -40,7 +46,9 @@ futures = { version = "0.3", default-features = false, features = ["std"] } # The sibling crate (the composed egress's direct-op vehicle — ADR-008/ # ADR-009): rides `composed` alone. Pinned; bump deliberately # (convention 11's deliberate-bump rule applies to siblings too). -alktunnels = { version = "0.2.0", optional = true } +# 0.2.1: the fuzzing graduation (its wave-1/wave-2 targets + the +# alkcall 0.8.1 bump), bug-fix release, no API/wire change. +alktunnels = { version = "0.2.1", optional = true } [dev-dependencies] tokio = { version = "1", features = ["full", "test-util", "macros"] } @@ -48,4 +56,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # The composed suite's topology (the alktunnels direct-op producer) — # dev-only: the test binary gates itself on the `composed` feature # (`#![cfg(feature = "composed")]`), the dev-dep keeps it compiling. -alktunnels = "0.2.0" +alktunnels = "0.2.1" diff --git a/docs/research/fuzzing.md b/docs/research/fuzzing.md new file mode 100644 index 0000000..653193e --- /dev/null +++ b/docs/research/fuzzing.md @@ -0,0 +1,435 @@ +# Fuzzing alksocks: Research and Plan + +**Status:** adopted — wave-1 targets (`datagram_codec`, `rfc1928_codec`, +`params_parse`) implemented, seeds committed, campaigns run clean (§7). +Wave 2 (`session_pumps`) deferred to post-0.1 (§4). Campaigns run via the +detached runner (§6) — **never as a foreground child of an agent +session** (§5). +**Date:** 2026-09-28 +**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` +(one real finding: the duplicate adopt/open destroying the live channel — +fixed in 0.8.1) and +`/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` (wave-1/wave-2 +adopted, six harness-model findings, no crate bugs). Tooling, corpus +policy, runner, and the no-hosted-CI posture are inherited from those +docs wholesale — this doc does not re-litigate them; it scopes them to +this crate. + +--- + +## 1. Should this crate be fuzzed? + +**Yes — and alkcall's fuzz doc already assigned this to us.** Three +lines, all pointing the same way: + +1. **alkcall's fuzz doc §5 named this crate's surfaces as downstream + targets.** Its "non-surfaces (deliberately)" note ends: *the channels + layer carries opaque `Bytes` and the handler owns sub-stream framing + (ADR-035) — those downstream parsers are downstream crates' fuzz + targets.* Our `src/wire.rs` datagram codec **is** that named + downstream target (shaped on alktunnels' codec, which alktunnels + fuzzed as its wave-1 first target — the same assignment, landed + here). Fuzzing it completes the upstream scope rather than + duplicating it. + +2. **We own an entire RFC handshake state machine fed by arbitrary + peers — on both sides.** The targeted fork (`src/rfc1928/`, ADR-013) + is fully owned code with project conventions applying unconditionally + (convention 17). Its parse surfaces — `read_address` (the address + codec), `negotiate_auth` (method negotiation), `read_command` (the + command encoding), `read_username_password` (RFC 1929), the client's + `read_request_reply` — are fed bytes produced by **arbitrary peers** + in both directions: the producer drives the server typestate against + consumer bytes, and the consumer sessions + front doors drive the + client path against producer bytes. An RFC state machine is exactly + the shape the quinn-proto CVE class (RUSTSEC-2026-0037 — remote DoS + via `unwrap()` in parsing, the upstream doc §1's strongest + datapoint) lives in: hand-rolled state transitions, length arithmetic, + and boundary handling. One fork-carried correction is already of this + class — upstream's `ReplyError::from_u8` ended in `unreachable!()` + for unassigned reply codes; the fork made it a typed catch-all (the + review's F-1) precisely because the value comes off the wire. A fuzz + target is how that posture is *demonstrated* across the whole codec, + not believed spotwise. + +3. **The differential suite's blind spot is exactly fuzzing's + strength.** `tests/rfc1928-differential.rs` pins our fork's behavior + against the reference checkout on *valid* RFC edge cases — curated + inputs a human thought of. What no example-based suite provides: + random *malformed* input exploration (the panic-on-attacker-bytes + class), stateful interleavings, and resource-bound assertions under + adversarial sequences. The fork is about to be published (0.1) as + wire-stable; after that, targets are regression protection for a + frozen surface — cheapest now. + +**The honest caveat** (mirroring both upstream docs): the crate has no +`unsafe` (checked at the crate root), the codec's decode direction is +infallible by contract (oversize rejected at frame time; the decoder +buffers, bounded by the u16 declared length), the RFC 1929 length +fields are one byte each (the attacker-controlled byte cannot ask for +more than 255), and the fork's error vocabulary is typed end-to-end +(the `unreachable!()`/`unwrap` sweep already ran in review 001). +Expected yield is therefore not memory-safety bugs; it is (a) the +demonstration that the infallibility/no-panic contracts hold on +adversarial input, (b) the decoder-state bound assertion (alktunnels +§1.3's finding class — a peer chunk amplifying into unbounded state), +and (c) regression protection for the published wire surface. If the +campaigns find nothing, that is the successful-negative-result posture +alkcall's §1 codified. + +## 2. Toolchain (inherited, not re-decided) + +Identical to both upstream docs: **cargo-fuzz 0.13.2 + `arbitrary` +1.4.x**, nightly confined to the `fuzz/` subtree via +`fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88. +AFL optional/deferred; bolero deferred (the async-friction argument); +libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address` +with debug assertions (cargo-fuzz defaults) — no MSAN (no `unsafe` in +this crate). + +**Layout (the upstream pattern, load-bearing):** + +``` +fuzz/ +├── Cargo.toml alksocks-fuzz (nightly-only bins; own [workspace]) +├── rust-toolchain.toml pins nightly for this subtree only +├── fuzz_targets/ thin fuzz_target! wrappers (3-line each) +│ ├── datagram_codec.rs +│ ├── rfc1928_codec.rs +│ └── params_parse.rs +├── shared/ alksocks-fuzz-shared — STABLE-toolchain library +│ └── src/… invariant logic + corpus replay (§3) +├── corpus// committed hand-made seeds (§3) +├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern) +├── run-detached.sh §6 detached runner (CWD-independent) +└── README.md +``` + +- **Invariant logic lives in `fuzz/shared/`**, not inline in the target + binaries — the stable-toolchain corpus replay + (`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME + invariant functions the fuzzer runs, on stable, in the release + verification checklist. This is alkcall §7.2 tier-3's deliverable + minus automation; per alkcall §7.9 (adopted here identically) this + repo has no hosted CI and will not get one — corpus replay as plain + `cargo test` is the fuzz gate. +- **Root `Cargo.toml` needs the explicit `[workspace]` table** + (`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the + publish exclude list — without it cargo auto-discovers `fuzz/shared/` + into the main workspace and the MSRV toolchain tries to build the + nightly-consumed dev-deps (the exact failure alkcall hit). +- **Corpus policy:** commit hand-made seeds, gitignore grown corpora + (quinn/h2 model). Seeds per target listed in §3. + +## 3. Invariants by target + +### 3.1 `datagram_codec` (the ASSOCIATE frame codec, `src/wire.rs`) + +Input: raw `&[u8]`. Drives `DatagramReader::feed` + `frame` / +`frame_inner` / `addr_encoding_len`. This is the alktunnels wave-1 +`datagram_codec` target's shape, scoped to this crate's codec (the +frame carries `addr + data` inner bytes and decodes to +`(TargetAddr, payload)`, not opaque datagrams — the invariants follow +the inner structure). Invariants: + +- **no-panic on any byte sequence** (the baseline). +- **infallible reassembly**: `feed` never errors at the reassembly + layer — every frame completes as `Ok` or as one of the four + *decode-garbage* `WireError` variants (`EmptyFrame` / `BadAtyp` / + `ShortAddress` / `DomainNotUtf8`); `Oversize`/`Address` are + unreachable from `feed` (they are frame-time errors — a shape + assertion pins the partition). +- **empty-payload F-2 layering**: a decoded `(addr, empty)` pair comes + only from a real `[len][addr]` frame whose payload region is empty — + never invented by the codec; a `len == 0` frame (zero inner bytes) + always surfaces as `EmptyFrame`, never as an empty datagram. +- **bad-ATYP loudness**: every `BadAtyp` error's byte is the actual + first inner byte of its frame, and it is not one of the three + RFC ATYPs (the post-reply control-byte semantic — any non-ATYP + control byte must fail loud, never guess). +- **consumption accounting / splitting transparency**: feeding in + 3-byte slices yields exactly the datagram sequence the whole-input + feed yields (both directions' framing is transparent to splitting). +- **round-trip**: every decoded datagram re-frames via `frame` (or + `frame_inner` over its inner bytes) and re-decodes to itself; + well-framed input re-frames byte-for-byte. +- **joint bound**: `frame` rejects iff + `addr_encoding_len(addr) + payload.len() > MAX_INNER_LEN`, echoing + `(total, max)` exactly — including the max-domain case where a legal + RFC payload (65507 to a 255-char domain = 65766) must reject (the + joint-bound reason the codec exists); `frame_inner` rejects iff + `inner.len() > MAX_INNER_LEN`. The `checked_add` path (payload + overflow wrapping the u16) rejects, never wraps. +- **decoder-state bound**: `buf` + in-flight `current` stay bounded by + the fed chunk size plus one frame (header + `MAX_INNER_LEN`) on any + adversarial input — the alktunnels §1.3 finding-class assertion + (state must never grow with input). The crate's `DatagramReader` + exposes this via `is_mid_frame()` + a new `retained_bytes()` + diagnostic (the alktunnels `retained_bytes()` posture). + +Seeds: valid frames (all three ATYPs), truncation at every prefix +length, `len = 0` / `len = MAX` / `len = MAX+1`, empty payloads, +batched frames, split frames, garbage ATYP, non-UTF-8 domains, the +65766 joint-bound shape, deep batch/split mixes. + +### 3.2 `rfc1928_codec` (the forked RFC 1928 core, `src/rfc1928/`) + +Input: raw `&[u8]`. Drives the fork's byte-level parsers over tokio +duplex streams (the in-tree test shape — mock bytes, no sockets): + +- **`read_address`** (the address codec, both directions consume it): + no-panic on any ATYP/bytes; every failure is a typed `AddrError` + (`IncorrectAddressType` for unknown ATYP — the F-1 class, already + fixed; never a panic); round-trip for every well-formed address + (`to_be_bytes` → parse → equal). +- **the server handshake** (`negotiate_auth` → `read_command` over a + duplex, `accept_no_auth` + `read_command`): no-panic on any byte + sequence; every malformed shape is a typed `SocksServerError` + (version mismatch, unacceptable method, unknown command, bad addr); + a well-formed negotiation + CONNECT request parses to the exact + command + target. +- **the client reply path** (`read_request_reply`): no-panic on any + server byte sequence; unassigned reply codes map to the + `GeneralFailure` catch-all (the F-1 pin — never `unreachable!()`); + version mismatches are typed errors. +- **`parse_udp_request`** (the RFC UDP header codec, front doors only + but still wire-reachable): no-panic; garbage RSV / unknown ATYP / + truncation are typed `UdpHeaderError`s; round-trip with + `new_udp_header`. +- **auth sub-handshakes** (`read_username_password`, RFC 1929): + no-panic; the u8 length fields bound allocations structurally + (≤ 255 bytes — asserted); empty username/password are typed errors; + round-trip accept/reject status bytes. +- **`ReplyError::from_u8`** full-domain table: every `u8` maps, no + panic, unassigned → catch-all (the direct probe of the F-1 class). + +This is the wave-1 target of highest value: it is the entire RFC 1928 +byte surface, fed attacker bytes from both protocol directions, and the +fork is owned code whose behavior the differential suite pins only on +valid inputs. + +Seeds: valid handshakes (no-auth / password), version mismatches, +truncations at every prefix length of each message shape, unknown +methods/commands/ATYPs, empty and 255-byte domains, non-UTF-8 domains, +unassigned reply codes, RSV garbage. + +### 3.3 `params_parse` (the open-op params, `src/params.rs`) + +Input: raw bytes → `serde_json` (alkcall's bounded `arbitrary_value` +generator) → `Socks5Params` deserialization + `socks5_open_spec()` + +`SocksEstablishmentReason::parse` / `establishment_reason`. Invariants: + +- **no-panic on any JSON-shaped input** (`deny_unknown_fields` + dispatch, the registry-spec builder). +- **the trivial-params pin**: `{}` parses, `Socks5Params::as_json()` == + `{}`, and unknown fields always reject (the loud-rejection posture — + the serde pin and the schema's `additionalProperties: false` agree). +- **the spec is a constant**: `socks5_open_spec()`'s wire-visible + fields (op id, ALPN, scopes, schemas) are identical on every + execution — the open-op surface is wire-stable (ADR-001); the + reason-enum table equals the typed parse table (the schema/typed-view + drift guard, mirrored from the in-suite test). +- **the reason surface is a pass-through extraction, not a validated + table**: `establishment_reason` yields `Some(details.reason)` iff the + code is `channel:open_failed` and the reason is a string — any string + is surfaced as-is (the alktunnels harness-model finding #2, adopted + here as the modeled contract); `SocksEstablishmentReason::parse` + never panics and is total. + +### 3.4 What is deliberately NOT a target (yet) + +- **The producer pump (`serve_socks5_over`) / consumer sessions as + stateful op-sequence targets** — wave 2, deferred (§4). The + handshake-level target (§3.2) covers the byte surface; the + session-level interleavings (associate stage select-shape, BIND quiet + wait, the two-pump CONNECT plane) are stateful shapes over a live + channels stack — the alktunnels wave-2 pattern, worth its own session + post-0.1. +- **The doors (`door.rs`, `local/`, `composed/`)** — feature-gated + assembly; their RFC-codec boundary (`parse_udp_request` / + `new_udp_header`) is covered by the §3.2 target. The rest is I/O + plumbing over the fuzzed cores, not parse surfaces. +- **`front_door` RFC 1929 negotiation** — covered by §3.2's auth + sub-handshake invariants (the door drives the same forked parsers). + +## 4. Scope: two waves + +**Wave 1 (this session):** the three targets above. All drive +already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching +both upstream docs' experience across all eight targets to date). One +diagnostic addition to the crate: `DatagramReader::retained_bytes()` +(the decoder-state byte count §3.1's bound asserts against, in +alktunnels' `retained_bytes()` style). + +**Wave 2 (deferred — post-0.1):** `session_pumps`, the stateful +op-sequence target over the producer pump + consumer sessions in the +alkcall `manager_routing` / alktunnels `session_pumps` style (live +channels stack; ops over CONNECT/ASSOCIATE/BIND interleavings; the +two-pump shutdown-on-completion contract, the egress readiness +contract, and the datagram-stage exit matrix as invariants). Deferred +because wave 1 covers the wire byte surface completely, the session +harness is a large build, and the crate is one commit from its first +publish — the alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption, +wave 2 the same session) was possible there because its session shape +was the crate's whole surface; here the byte-level targets carry the +attacker-reachable load and the stateful session target is a separate +planning unit. + +Sequencing mirrors the upstream docs: init + wave-1 targets → 10–30 +min campaigns per target via the detached runner → fix anything found → +wave 2 when scheduled. + +## 5. Operational isolation: campaigns never share fate with the agent session + +**Adopted verbatim from the upstream docs (alkcall §7.6, alktunnels +§5)** — an environment constraint, not a nice-to-have. Agent sessions +run fuzzer builds/campaigns through a bash tool that spawns the fuzzer +as a child of the session's own process tree and cgroup; an OOM in a +fuzz target must cost the fuzzer, never the session. Three layers, all +mandatory: (1) libFuzzer soft limits (`-rss_limit_mb=2048` +`-malloc_limit_mb=2048`) — never `ulimit -v` under ASAN; (2) fork mode +(`-fork=1`) as blast-radius containment (crash/timeout/OOM kills only +the short-lived child; the harness survives and persists the artifact; +also the deadlock answer); (3) process detachment — the runner script +wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection to a +log file; poll, never `wait`. + +Also inherited: the operational note that `cargo fuzz run` returns +before its last fork-mode children fully exit — prefer `pgrep -af` to +LIST, kill by explicit pid from the runner's echoed `pid=`, and never +pattern-kill-and-recheck in one call. + +## 6. No hosted CI + +Per alkcall §7.9 / alktunnels §6 — the standing policy applies to this +repo identically: no hosted CI will exist; do not add Actions/Gitea +Actions workflow files. **Corpus replay is the fuzz gate as plain +`cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml` +replays every committed seed through the invariant functions — add it +to the release verification checklist (AGENTS.md). Campaigns run +manually via the detached runner (§6) — e.g. before a release, or after +touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`. + +## 7. Implementation log + +- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets + `datagram_codec` + `rfc1928_codec` + `params_parse`; wave 2 deferred + (§4). +- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1, + `alktunnels` 0.2.0 → 0.2.1.** Bug-fix releases, no API/wire change; + alkcall's fix (the duplicate adopt/open destroying the live channel) + is directly relevant to this crate's consumer sessions — every + session's teardown reaps via the manager, and the + open-op-response-replayed scenario alkcall's changelog names is the + consumer open path. Verification: full suite (default + all-features) + green, clippy, fmt, wasm check (§8 commands). +- **2026-09-28 — wave-1 implemented.** The `fuzz/` workspace laid out + per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner, + dict, generator); the three targets over already-`pub` APIs — no + `#[cfg(fuzzing)]` exposure needed (the upstream docs' experience, + now across eleven targets). One diagnostic addition to the crate: + `DatagramReader::retained_bytes()` (§3.1's bound target). Seeds + committed: 45 + 65 + 19 generated (fuzz/gen_fuzz_seeds.py) + 2 + campaign-found crash inputs promoted (`datagram_codec/seed-900`, + `seed-901` — the §7 harness findings below). +- **2026-09-28 — two harness-model defects caught by corpus replay + pre-campaign** (the alkcall §7.7 fuzzer-as-harness-checker pattern): + 1. **A `len == 0` frame is well-framed but decodes to + `Err(EmptyFrame)`** — the re-frame round-trip first expected every + well-framed frame to decode `Ok`. The frame count still holds over + every frame; the byte-for-byte re-frame holds over the decodable + ones; `EmptyFrame` is pinned as the only zero-inner-byte shape. + The same arm widened for `ShortAddress`/`DomainNotUtf8`/ + `BadAtyp`: a well-framed frame can carry any addr-garbage shape — + the decode refuses the addr, and the raw frame is the only + re-frame possible. + 2. **A fieldless `deny_unknown_fields` struct deserializes from ANY + empty JSON composite** (the serde seq form `[]` included) — not + only `{}`. Not wire-reachable (the registry's `type: object` + input schema is the gate that keeps non-object forms from ever + reaching the type), but the harness must model the serde reality: + the trivial-params invariant is now "an empty composite parses, + everything non-empty rejects, the canonical round-trip is `{}`." +- **2026-09-28 — first campaign finding (fork-mode containment as + designed): a third harness-model defect.** The `datagram_codec` + campaign crashed at ~35 s on input `0005 0301 1203 03bb 00` + (`BadAtyp(0)` shape) — the re-frame walk's `Err` arm (fixed in the + replay pass above) still omitted `BadAtyp`. Fixed; the crash input is + committed as `seed-900`; the campaign relaunched clean. +- **2026-09-28 — second campaign finding (also harness, fork-mode + containment): the count walk's `declared >= 7` gate excluded short + domains.** Input `0005 0301 1203 03bb 00` — a domain frame with a + 1-byte domain (`addr_len = 5`) is a legal empty-payload frame, but + the walk's `declared >= 7` gate (meant to skip addr-less frames) + rejected it; the per-ATYP `addr_len` computation already does that + job. The gate lowered to `>= 4` (the minimum ATYP + length-byte + prefix the walk reads); the crash input is committed as `seed-901`; + the campaign relaunched clean. +- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 + runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 + -timeout=25`; codec `-max_len=65536`, rfc1928 + `-max_len=4096 -use_value_profile=1`, params + `-dict=json.dict -use_value_profile=1). All three exited 0 with + empty artifact directories — no crashes, hangs, OOMs, or leaks.** + - `datagram_codec` (post-harness-fixes) — ~1.9M execs (~3k exec/s), + 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage + saturated at 481 edges (ft 2222, corp 209) — the 2-byte-header + state machine is fully enumerated (the alktunnels wave-1 + saturation profile). + - `rfc1928_codec` — ~2.0M execs (~3.6k exec/s), 33 fork jobs, + `oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 1429 + edges (ft 7987, corp 704) — the handshake state machines' + byte-level shape space is fully enumerated; both protocol + directions held across every adversarial sequence. + - `params_parse` — ~3.1M execs (~5.5k exec/s), 33 fork jobs, + `oom/timeout/crash: 0/0/0` throughout. Coverage 1512 edges (ft + 12970, in-memory corpus 2981 from the 19 committed seeds) — the + serde/untagged shape space was still expanding at budget end; the + spec-constant and pass-through-reason invariants held everywhere. + - The decoder-state bound (§3.1) held everywhere: + `retained_bytes()` never exceeded fed + one frame on any + adversarial input. Grown corpora were **not** merged into the + committed seeds (the §2 policy). +- **NO crate bugs found by wave 1.** All three findings this session + were harness-model defects, each invisible to example-based review + and caught by fuzzing/replay — the upstream "fuzzer as a + harness-checker" pattern, confirmed a third time (alkcall §7.7, + alktunnels §7, here §7). The codec's contracts (infallible + reassembly, the F-2 layering, the joint bound, the retained-bytes + bound) and the RFC core's no-panic posture held across every + adversarial input the campaigns generated. +- (progress appended as implementation proceeds — see git log) + +## 8. Verification commands + +```bash +cargo test # main suite (stable) +cargo test --all-features # the feature-gated suite +cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate) +cargo fuzz build # in fuzz/ (nightly, ASAN) +fuzz/run-detached.sh # campaigns (never foreground) +cargo clippy --all-targets -- -D warnings +cargo fmt --check +cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings +cargo fmt --manifest-path fuzz/shared/Cargo.toml --check +cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean +``` + +Campaign flags per target: `datagram_codec -max_len=65536`; +`rfc1928_codec -max_len=4096 -use_value_profile=1`; +`params_parse -dict=json.dict -use_value_profile=1`. + +## 9. References + +- Upstream campaigns + operational model (authoritative for tooling, + runner, corpus policy, no-CI posture): + `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`, + `/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` +- alkcall 0.8.1 changelog — the finding our consumer open/adopt paths + are exposed to +- This crate: ADR-001 (op/params), ADR-003 (the frame codec), ADR-013 + (the targeted fork), `docs/architecture/wire.md`, + `docs/architecture/rfc1928.md` +- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers' + fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust + Fuzz Book (all linked from the upstream doc's §9) \ No newline at end of file diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..5bcaf91 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,7 @@ +target +corpus/* +!corpus/datagram_codec +!corpus/rfc1928_codec +!corpus/params_parse +artifacts +coverage \ No newline at end of file diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..e55ac91 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "alksocks-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +alksocks-fuzz-shared = { path = "shared" } + +[dependencies.alksocks] +path = ".." + +[[bin]] +name = "datagram_codec" +path = "fuzz_targets/datagram_codec.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "rfc1928_codec" +path = "fuzz_targets/rfc1928_codec.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "params_parse" +path = "fuzz_targets/params_parse.rs" +test = false +doc = false +bench = false + +[workspace] \ No newline at end of file diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..dece507 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,55 @@ +# alksocks fuzzing + +libFuzzer targets for alksocks' wire surfaces (see +`docs/research/fuzzing.md` for the full rationale and campaign plan). + +## Layout + +- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers. +- `shared/` — the invariant logic, as a plain library so normal + `cargo test` (stable toolchain) can replay the committed corpora + through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]` + modules; quinn's CI pattern). The fuzz binaries are nightly-only; + the shared crate is stable-clean. +- `corpus//` — committed hand-made seeds (regenerate with + `python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora + and artifacts are gitignored. +- `run-detached.sh` — mandatory runner for agent sessions: wraps + `cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM + in a target can never take down the agent host (research doc §5). +- `json.dict` — JSON token dictionary for the params target. + +## Targets + +| Target | Drives | Invariants | +|---|---|---| +| `datagram_codec` | `DatagramReader::feed` / `frame` / `frame_inner` (`src/wire.rs`) | no-panic; infallible reassembly with the decode-error partition; empty-payload F-2 layering; bad-ATYP loudness; splitting transparency; round-trips; the joint bound (incl. the 65766 shape); the decoder-state retained-bytes bound | +| `rfc1928_codec` | the forked RFC 1928 core (`src/rfc1928/`): `read_address`, the server typestate handshake, the RFC 1929 sub-handshake, the RFC UDP header codec, `ReplyError::from_u8` | no-panic on any byte sequence, both protocol directions; typed failures only (the F-1 class); round-trips; the full-domain reply table | +| `params_parse` | `Socks5Params` / `socks5_open_spec()` / the reason surface (`src/params.rs`) | no-panic; the trivial-params pin; the spec is a constant (the wire-stable ADR-001 surface + the schema/typed-view drift guard); the pass-through reason-extraction posture | + +## Commands + +`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/` +(or deeper) as the working directory so rustup selects the pinned +nightly toolchain — the detached runner handles that itself. + +```sh +# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/) +cargo fuzz build + +# agent sessions: detached campaign (never foreground; CWD-independent) +FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh datagram_codec -max_len=65536 +FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh rfc1928_codec -max_len=4096 -use_value_profile=1 +FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh params_parse -dict=json.dict -use_value_profile=1 + +# corpus replay through the invariants (stable toolchain, no nightly) +cargo test --manifest-path fuzz/shared/Cargo.toml + +# coverage +cargo fuzz coverage +``` + +The fuzz workspace is excluded from the main workspace and from the +published package (`exclude` in the root `Cargo.toml`); it pins its own +nightly toolchain via `rust-toolchain.toml` and does not affect the +crate's stable MSRV. \ No newline at end of file diff --git a/fuzz/corpus/datagram_codec/seed-000 b/fuzz/corpus/datagram_codec/seed-000 new file mode 100644 index 0000000000000000000000000000000000000000..666a7b1ff39e5675c3e39185bcc944c3d8360417 GIT binary patch literal 9 QcmZQzXFR~b#K>R@00jsE0RR91 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-001 b/fuzz/corpus/datagram_codec/seed-001 new file mode 100644 index 0000000000000000000000000000000000000000..2f2bbb86f41e25fbc5c9fe0253f8ea179d733876 GIT binary patch literal 10 RcmZSJU_8LU#K>S;0RRSy0eS!c literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-002 b/fuzz/corpus/datagram_codec/seed-002 new file mode 100644 index 0000000000000000000000000000000000000000..ba68c3d1cb47bc71b1a6e1c8a726866f30121cd8 GIT binary patch literal 14 VcmZSJVLZUV#K>Tpk(!f}4*(E#0~!DT literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-003 b/fuzz/corpus/datagram_codec/seed-003 new file mode 100644 index 0000000000000000000000000000000000000000..bd0ff864fee352dd4cd349dbfd2f6db2046435bc GIT binary patch literal 309 VcmZQHW<0>a#K>TpI0}dk0RTVqa@qg@ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-004 b/fuzz/corpus/datagram_codec/seed-004 new file mode 100644 index 0000000000000000000000000000000000000000..a0b8b70eef25cad04777708581f33ea72c99a137 GIT binary patch literal 21 TcmZP&W>H||-NAqWj0~m#5B>o( literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-005 b/fuzz/corpus/datagram_codec/seed-005 new file mode 100644 index 0000000000000000000000000000000000000000..b417b8ab6fe9db314406bbf78e456ad77deb43b2 GIT binary patch literal 22 UcmZP&VNqb@-NAqWj0~m~01>|dtpET3 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-006 b/fuzz/corpus/datagram_codec/seed-006 new file mode 100644 index 0000000000000000000000000000000000000000..b3288692a36790a4ac335cc67c1a18fe2c2e571a GIT binary patch literal 26 YcmZROU{PS?-NAqWj0~n3sX00M034|!=0Pq0N literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-016 b/fuzz/corpus/datagram_codec/seed-016 new file mode 100644 index 0000000000000000000000000000000000000000..5b7cb85b5c7873b869cde3aee36b30fc2339bfaf GIT binary patch literal 5 McmZSJV?4kB00F=N&;S4c literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-017 b/fuzz/corpus/datagram_codec/seed-017 new file mode 100644 index 0000000000000000000000000000000000000000..029977e0f6f0e8279ffb07f28aab63c3bba8b3a8 GIT binary patch literal 6 NcmZSJV?4mX1ONh(0MY;e literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-018 b/fuzz/corpus/datagram_codec/seed-018 new file mode 100644 index 0000000000000000000000000000000000000000..18bd4e01c99dcd2707b18acdf94a98f34bd5c521 GIT binary patch literal 7 OcmZSJV?4mX#0UTbWdPFv literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-019 b/fuzz/corpus/datagram_codec/seed-019 new file mode 100644 index 0000000000000000000000000000000000000000..82247f8ad22734d6368ea458cba73d9a12bc6576 GIT binary patch literal 8 PcmZSJV?4mX#K-^u1ULZG literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-020 b/fuzz/corpus/datagram_codec/seed-020 new file mode 100644 index 0000000000000000000000000000000000000000..2801aee0b2956795024f4e98b86679917596ed38 GIT binary patch literal 9 QcmZSJV?4mX#K>R@00lq+2mk;8 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-021 b/fuzz/corpus/datagram_codec/seed-021 new file mode 100644 index 0000000000000000000000000000000000000000..decf00183efd424c556ec239a101ca4cd2fb33a3 GIT binary patch literal 10 RcmZSJV?4mX#K>S;000KK0eAoa literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-022 b/fuzz/corpus/datagram_codec/seed-022 new file mode 100644 index 0000000000000000000000000000000000000000..e91cc57537f16c6f52a5d9f8bcff8428f1a51bde GIT binary patch literal 11 ScmZSJV?4mX#K>S;kO%+>kpbBN literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-023 b/fuzz/corpus/datagram_codec/seed-023 new file mode 100644 index 0000000000000000000000000000000000000000..dce4683edefcf2cb6b050d8658870afa553ff7e2 GIT binary patch literal 12 TcmZSJV?4mX#K>S;kXQ)-3gZG& literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-024 b/fuzz/corpus/datagram_codec/seed-024 new file mode 100644 index 0000000000000000000000000000000000000000..06974b1b0d37a7e9626517ecba0393ad1c014c4b GIT binary patch literal 13 UcmZSJV?4mX#K>S;kXV@m01cr6z5oCK literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-025 b/fuzz/corpus/datagram_codec/seed-025 new file mode 100644 index 0000000000000000000000000000000000000000..86638381903279a215002e5a332d1dd918f001a3 GIT binary patch literal 14 VcmZSJV?4mX#K>S;kXV_M4*(F(11$gm literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-026 b/fuzz/corpus/datagram_codec/seed-026 new file mode 100644 index 0000000000000000000000000000000000000000..0488345cba4fb8fc77b13e79ce93a2bd3625ba7c GIT binary patch literal 15 WcmZSJV?4mX#K>S;kXV_Mp9laHTmy~( literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-027 b/fuzz/corpus/datagram_codec/seed-027 new file mode 100644 index 0000000000000000000000000000000000000000..09f370e38f498a462e1ca0faa724559b6630c04f GIT binary patch literal 2 JcmZQz0000200961 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-028 b/fuzz/corpus/datagram_codec/seed-028 new file mode 100644 index 0000000000000000000000000000000000000000..a870b31a49b0b36faf8b80c56be363c1495076f9 GIT binary patch literal 9 QcmezWpYZ?#6C;Bu02RLi_y7O^ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-029 b/fuzz/corpus/datagram_codec/seed-029 new file mode 100644 index 0000000000000000000000000000000000000000..4f4a9810667bf4e00755aa1b2bc0978c943e6e88 GIT binary patch literal 302 OcmezWpJ5ab9RdJZ@d5t; literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-030 b/fuzz/corpus/datagram_codec/seed-030 new file mode 100644 index 0000000000000000000000000000000000000000..69457e5446b883c01faca96efdb8e217173a89bb GIT binary patch literal 42 McmezWpMgjK08-Zh{{R30 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-031 b/fuzz/corpus/datagram_codec/seed-031 new file mode 100644 index 0000000000000000000000000000000000000000..9dc06b8a7734d5222e131d6df4568fd625402a30 GIT binary patch literal 9 QcmZQzt7l|lW?={b00b%k-~a#s literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-032 b/fuzz/corpus/datagram_codec/seed-032 new file mode 100644 index 0000000000000000000000000000000000000000..817071a4412a6d650de46119bf1702adf8c92d7c GIT binary patch literal 9 QcmZQzXJ-EY?=M3D01To7SO5S3 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-033 b/fuzz/corpus/datagram_codec/seed-033 new file mode 100644 index 0000000000000000000000000000000000000000..f70f748c5f90c4290882b86bfbb93fe34fb1131f GIT binary patch literal 281 TcmezWpZR~{$Oa6i*!&LwJg{qN literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-034 b/fuzz/corpus/datagram_codec/seed-034 new file mode 100644 index 0000000000000000000000000000000000000000..6fda77ea682968996e5b57bbbea386c2acd5bbf6 GIT binary patch literal 53 tcmZSJWIVvY#K>TplFlH?qQJoh;2}S?_ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-035 b/fuzz/corpus/datagram_codec/seed-035 new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-036 b/fuzz/corpus/datagram_codec/seed-036 new file mode 100644 index 0000000000000000000000000000000000000000..d0c35998e85ede3d974bad27a2e37af7de7aef18 GIT binary patch literal 2 JcmZSJ1ONaF015yA literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-037 b/fuzz/corpus/datagram_codec/seed-037 new file mode 100644 index 0000000000000000000000000000000000000000..d75fe8e492d8991e6bd4a1176cd8abd161f8154e GIT binary patch literal 3 KcmZSJWCQ>J761zX literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-038 b/fuzz/corpus/datagram_codec/seed-038 new file mode 100644 index 0000000000000000000000000000000000000000..642e3c071547793921a1ac307f05babdf03cac7c GIT binary patch literal 5 McmZSJWIVtC00FE3%K!iX literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-039 b/fuzz/corpus/datagram_codec/seed-039 new file mode 100644 index 0000000000000000000000000000000000000000..66ba9fc46f66c541ce8d54ab9ce45d83d5cf640c GIT binary patch literal 7 OcmZSJWIVvY#0UTbM*z+M literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-040 b/fuzz/corpus/datagram_codec/seed-040 new file mode 100644 index 0000000000000000000000000000000000000000..ac0d4009dbbc94613b298e5a02e3bd433c6dbdf5 GIT binary patch literal 11 ScmZSJWIVvY#K>Tpk`4d}O##jT literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-041 b/fuzz/corpus/datagram_codec/seed-041 new file mode 100644 index 0000000000000000000000000000000000000000..7a9cf33b0d05f5e0f603912ab132ffe5efb741b5 GIT binary patch literal 47 rcmZQzXFR~b#K>UEAk3n`$h(690YHNM%-pFJiMa(isd~xzxeTTNbgc&8 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-042 b/fuzz/corpus/datagram_codec/seed-042 new file mode 100644 index 0000000000000000000000000000000000000000..c41fad962a601907eb4b685b50628b0190c6a851 GIT binary patch literal 17 ScmZSJVm!dW#K>UE00jUIKmh~* literal 0 HcmV?d00001 diff --git a/fuzz/corpus/datagram_codec/seed-043 b/fuzz/corpus/datagram_codec/seed-043 new file mode 100644 index 0000000000000000000000000000000000000000..03f1696a0632f0cce4b414238db821e6116de96e GIT binary patch literal 424 ycmZSJWIVvY#K>TplFlH?qQJrPR0WaOb}U7mQhXMFrjTg005e3 BNJjtw literal 0 HcmV?d00001 diff --git a/fuzz/corpus/params_parse/seed-065 b/fuzz/corpus/params_parse/seed-065 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/fuzz/corpus/params_parse/seed-065 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-066 b/fuzz/corpus/params_parse/seed-066 new file mode 100644 index 0000000..09ac78b --- /dev/null +++ b/fuzz/corpus/params_parse/seed-066 @@ -0,0 +1 @@ +{"resource": "sneaky"} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-067 b/fuzz/corpus/params_parse/seed-067 new file mode 100644 index 0000000..dcc1312 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-067 @@ -0,0 +1 @@ +{"substrate": "tcp"} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-068 b/fuzz/corpus/params_parse/seed-068 new file mode 100644 index 0000000..6602723 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-068 @@ -0,0 +1 @@ +{"code": "channel:open_failed", "details": {"reason": "dial_failed"}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-069 b/fuzz/corpus/params_parse/seed-069 new file mode 100644 index 0000000..c57038e --- /dev/null +++ b/fuzz/corpus/params_parse/seed-069 @@ -0,0 +1 @@ +{"code": "channel:open_failed", "details": {"reason": "made_up_reason"}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-070 b/fuzz/corpus/params_parse/seed-070 new file mode 100644 index 0000000..fd1b9d1 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-070 @@ -0,0 +1 @@ +{"code": "FORBIDDEN", "details": {"reason": "no"}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-071 b/fuzz/corpus/params_parse/seed-071 new file mode 100644 index 0000000..bd71998 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-071 @@ -0,0 +1 @@ +{"reason": {"enum": ["dial_failed", 1]}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-072 b/fuzz/corpus/params_parse/seed-072 new file mode 100644 index 0000000..46b134b --- /dev/null +++ b/fuzz/corpus/params_parse/seed-072 @@ -0,0 +1 @@ +ÿþ \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-073 b/fuzz/corpus/params_parse/seed-073 new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-073 @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-074 b/fuzz/corpus/params_parse/seed-074 new file mode 100644 index 0000000..494ce57 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-074 @@ -0,0 +1 @@ +{"nested": {"deep": {"deeper": [1, {"a": null}]}}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-075 b/fuzz/corpus/params_parse/seed-075 new file mode 100644 index 0000000..b2148e2 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-075 @@ -0,0 +1 @@ +{"channel_open": {"alpn": "alk/tunnel"}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-076 b/fuzz/corpus/params_parse/seed-076 new file mode 100644 index 0000000..b95bd2a --- /dev/null +++ b/fuzz/corpus/params_parse/seed-076 @@ -0,0 +1 @@ +{"access_control": {"required_scopes": ["socks5:open"]}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-077 b/fuzz/corpus/params_parse/seed-077 new file mode 100644 index 0000000..b0bdab0 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-077 @@ -0,0 +1 @@ +{"details": {"reason": 1}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-078 b/fuzz/corpus/params_parse/seed-078 new file mode 100644 index 0000000..9fb8ab4 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-078 @@ -0,0 +1 @@ +{"details": {"reason": {"enum": "dial_failed"}}} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-079 b/fuzz/corpus/params_parse/seed-079 new file mode 100644 index 0000000..25ee833 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-079 @@ -0,0 +1 @@ +{"code": {"channel:open_failed": true}, "details": null} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-080 b/fuzz/corpus/params_parse/seed-080 new file mode 100644 index 0000000..66e1eb3 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-080 @@ -0,0 +1 @@ +{"type": "object", "additionalProperties": false} \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-081 b/fuzz/corpus/params_parse/seed-081 new file mode 100644 index 0000000..029aa91 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-081 @@ -0,0 +1 @@ +"{}" \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-082 b/fuzz/corpus/params_parse/seed-082 new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/fuzz/corpus/params_parse/seed-082 @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/fuzz/corpus/params_parse/seed-083 b/fuzz/corpus/params_parse/seed-083 new file mode 100644 index 0000000..80fa8ec --- /dev/null +++ b/fuzz/corpus/params_parse/seed-083 @@ -0,0 +1 @@ +{"a": {"a": {"a": {"a": {"a": {"a": {"a": {"a": {"a": {"a": {"a": {"a": 1}}}}}}}}}}}} \ No newline at end of file diff --git a/fuzz/corpus/rfc1928_codec/seed-000 b/fuzz/corpus/rfc1928_codec/seed-000 new file mode 100644 index 0000000000000000000000000000000000000000..b9f2b6d46e35fa6ffdb4272101ffdedee380dcc0 GIT binary patch literal 3 KcmZQ&WB>pF6aWVR literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-001 b/fuzz/corpus/rfc1928_codec/seed-001 new file mode 100644 index 0000000..fab084a --- /dev/null +++ b/fuzz/corpus/rfc1928_codec/seed-001 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/corpus/rfc1928_codec/seed-002 b/fuzz/corpus/rfc1928_codec/seed-002 new file mode 100644 index 0000000000000000000000000000000000000000..672ae645ce55e49ba1de123e09ccc62b10680527 GIT binary patch literal 4 LcmZQ&VqgLQ03ZMg literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-003 b/fuzz/corpus/rfc1928_codec/seed-003 new file mode 100644 index 0000000000000000000000000000000000000000..d1ecee30fefe5cc4a6f51fcb4d17a1407628b205 GIT binary patch literal 7 OcmZQ&WME}rU;qFBL;wr` literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-004 b/fuzz/corpus/rfc1928_codec/seed-004 new file mode 100644 index 0000000000000000000000000000000000000000..b5ee2e477d590089af4d45a8e96160b6ba8349ca GIT binary patch literal 7 McmZQ&WMBnC002$^5dZ)H literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-005 b/fuzz/corpus/rfc1928_codec/seed-005 new file mode 100644 index 0000000000000000000000000000000000000000..b684f039b9906620d070029b9cf6bc2302474e85 GIT binary patch literal 20 bcmZQ&WME}rWnxau$xKdVD^5)=N-Y5Z9#90J literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-006 b/fuzz/corpus/rfc1928_codec/seed-006 new file mode 100644 index 0000000000000000000000000000000000000000..7099a2059e85045df9c62f91ca8ea2f4aedec130 GIT binary patch literal 15 WcmZQ&WME}rWn^GHz`(@F5C8xL!2u=! literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-007 b/fuzz/corpus/rfc1928_codec/seed-007 new file mode 100644 index 0000000000000000000000000000000000000000..e41721b67871adbd6f147ebbe2880ac886abc1d2 GIT binary patch literal 23 ecmZQ&WME}rWn^IHPOV7HEyzjLOU}<_+zkLGIt4WV literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-008 b/fuzz/corpus/rfc1928_codec/seed-008 new file mode 100644 index 0000000000000000000000000000000000000000..3b7f8d8fbcde3163a8c8dc292188a26e0d46e336 GIT binary patch literal 27 UcmZQ&WME}rWoBSuKm&}s0Rrj()Bpeg literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-009 b/fuzz/corpus/rfc1928_codec/seed-009 new file mode 100644 index 0000000000000000000000000000000000000000..b1da7481021ba547d60c1b1ca7ab5b480e270c38 GIT binary patch literal 15 WcmZQ&WME}rWny4v&Pz^a2mk;EIsu&k literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-010 b/fuzz/corpus/rfc1928_codec/seed-010 new file mode 100644 index 0000000000000000000000000000000000000000..19c6003adb3ffe53c5b4062586c85ec00673761a GIT binary patch literal 15 WcmZQ&WME}rt!H37z`(@F5C8xQF9DpF5da1N literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-012 b/fuzz/corpus/rfc1928_codec/seed-012 new file mode 100644 index 0000000..843ee0a --- /dev/null +++ b/fuzz/corpus/rfc1928_codec/seed-012 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/corpus/rfc1928_codec/seed-013 b/fuzz/corpus/rfc1928_codec/seed-013 new file mode 100644 index 0000000000000000000000000000000000000000..73e3502b3364658a6b6e0bb7c9abcd61834a9a9e GIT binary patch literal 3 KcmZQ&U;zLC761wW literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-014 b/fuzz/corpus/rfc1928_codec/seed-014 new file mode 100644 index 0000000000000000000000000000000000000000..04787a39df788ac434262cac0b74fb29df021ae8 GIT binary patch literal 9 QcmZQ$U}R)sW?={b006!KVgLXD literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-015 b/fuzz/corpus/rfc1928_codec/seed-015 new file mode 100644 index 0000000..b0b2b1c --- /dev/null +++ b/fuzz/corpus/rfc1928_codec/seed-015 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/corpus/rfc1928_codec/seed-016 b/fuzz/corpus/rfc1928_codec/seed-016 new file mode 100644 index 0000000..f635acf --- /dev/null +++ b/fuzz/corpus/rfc1928_codec/seed-016 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/corpus/rfc1928_codec/seed-017 b/fuzz/corpus/rfc1928_codec/seed-017 new file mode 100644 index 0000000000000000000000000000000000000000..b9f2b6d46e35fa6ffdb4272101ffdedee380dcc0 GIT binary patch literal 3 KcmZQ&WB>pF6aWVR literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-018 b/fuzz/corpus/rfc1928_codec/seed-018 new file mode 100644 index 0000000000000000000000000000000000000000..02c7c9b22bc4173610e948aff442a654ed888701 GIT binary patch literal 4 LcmZQ&WMBmV03ZMi literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-019 b/fuzz/corpus/rfc1928_codec/seed-019 new file mode 100644 index 0000000000000000000000000000000000000000..c90a9782e69f0ed250a6849f65c0f3ebc739b45a GIT binary patch literal 5 McmZQ&WME|g001li3;+NC literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-020 b/fuzz/corpus/rfc1928_codec/seed-020 new file mode 100644 index 0000000000000000000000000000000000000000..a73c944717b2731914cf0ca6f3472e404711851c GIT binary patch literal 6 NcmZQ&WME}r1poj&01*HH literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-021 b/fuzz/corpus/rfc1928_codec/seed-021 new file mode 100644 index 0000000000000000000000000000000000000000..a6d154605c6498c9f364f002a8286dbb811576f2 GIT binary patch literal 7 OcmZQ&WME}rWdr~KPXH1C literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-022 b/fuzz/corpus/rfc1928_codec/seed-022 new file mode 100644 index 0000000000000000000000000000000000000000..223b9f8fa3bcb4ebfc3867142edcbe743c477b20 GIT binary patch literal 8 PcmZQ&WME}rWn=&V0AT01*HH literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-033 b/fuzz/corpus/rfc1928_codec/seed-033 new file mode 100644 index 0000000000000000000000000000000000000000..ccf91b6b3fec6088050bfe2b647db9b34304c351 GIT binary patch literal 10 NcmZRrXJBLi0{{fN0EPen literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-034 b/fuzz/corpus/rfc1928_codec/seed-034 new file mode 100644 index 0000000000000000000000000000000000000000..fd18c5221781536b39cf3ced3ec1e304fa6b5f69 GIT binary patch literal 10 NcmZSm&%nq41^@{<0R{j7 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-035 b/fuzz/corpus/rfc1928_codec/seed-035 new file mode 100644 index 0000000000000000000000000000000000000000..90d4702d264342fe2032fd9a42677ca756c55bdd GIT binary patch literal 17 YcmZQzU|>AJz{JR4T98tw_u*$Vt^p&d+5qEl8})$xloH08n-ZhX4Qo literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-037 b/fuzz/corpus/rfc1928_codec/seed-037 new file mode 100644 index 0000000000000000000000000000000000000000..e6a513e07e751606542aad1bf70295f6a83b3e5d GIT binary patch literal 10 RcmZQzWMDkNz{JR43IGJ!0Q&#{ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-038 b/fuzz/corpus/rfc1928_codec/seed-038 new file mode 100644 index 0000000000000000000000000000000000000000..41fccf2e859f5d3ea6e42ce3f275581aefb6fb5c GIT binary patch literal 10 RcmZQzU|?coVrF4r0000$01W^D literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-039 b/fuzz/corpus/rfc1928_codec/seed-039 new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-040 b/fuzz/corpus/rfc1928_codec/seed-040 new file mode 100644 index 0000000000000000000000000000000000000000..09f370e38f498a462e1ca0faa724559b6630c04f GIT binary patch literal 2 JcmZQz0000200961 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-041 b/fuzz/corpus/rfc1928_codec/seed-041 new file mode 100644 index 0000000000000000000000000000000000000000..4227ca4e8736af63036e7457e2db376ddf7e5795 GIT binary patch literal 3 KcmZQzU;qFB0{{U4 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-042 b/fuzz/corpus/rfc1928_codec/seed-042 new file mode 100644 index 0000000000000000000000000000000000000000..fde1ac19d2b083530bcab4cb4fd2dcaa285234ab GIT binary patch literal 4 LcmZQzU|1pokw0CWHV literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-045 b/fuzz/corpus/rfc1928_codec/seed-045 new file mode 100644 index 0000000000000000000000000000000000000000..308b9145c2c83030fcedb236ca2140df9ada8562 GIT binary patch literal 7 OcmZQzU|{A>tpET4b^z=E literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-046 b/fuzz/corpus/rfc1928_codec/seed-046 new file mode 100644 index 0000000000000000000000000000000000000000..cba74bd422a61a2ec9bac89fdb209afc83a13ebe GIT binary patch literal 8 PcmZQzU|{A>tw;m_0>c4K literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-047 b/fuzz/corpus/rfc1928_codec/seed-047 new file mode 100644 index 0000000000000000000000000000000000000000..fed80faca3051683ce44b3d18ae62e917e040190 GIT binary patch literal 9 QcmZQzU|{A>tw_uT00eyjx&QzG literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-048 b/fuzz/corpus/rfc1928_codec/seed-048 new file mode 100644 index 0000000000000000000000000000000000000000..39a9e7bcf6195f00232a902c39cfcb38870763fd GIT binary patch literal 10 RcmZQzU|{A>tw_u*000K30xAFi literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-049 b/fuzz/corpus/rfc1928_codec/seed-049 new file mode 100644 index 0000000000000000000000000000000000000000..a11770094bddcf6d5d653db8f9d0de13d21e7659 GIT binary patch literal 11 ScmZQzU|{A>tw_u*$N>NeJpz^h literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-050 b/fuzz/corpus/rfc1928_codec/seed-050 new file mode 100644 index 0000000000000000000000000000000000000000..2980f6e78eaab740b620b98210d3191118a2728a GIT binary patch literal 12 TcmZQzU|{A>tw_u*$VmkN3^)S& literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-051 b/fuzz/corpus/rfc1928_codec/seed-051 new file mode 100644 index 0000000000000000000000000000000000000000..e68a0f2b3e5f38f54d90c49c51f50d271325d7e4 GIT binary patch literal 13 UcmZQzU|{A>tw_u*$Vt@$01sgUDF6Tf literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-052 b/fuzz/corpus/rfc1928_codec/seed-052 new file mode 100644 index 0000000000000000000000000000000000000000..f4fe000efa595c204528d505c2a39dac1322d046 GIT binary patch literal 14 VcmZQzU|{A>tw_u*$Vt^p1^^Q61B?Iw literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-053 b/fuzz/corpus/rfc1928_codec/seed-053 new file mode 100644 index 0000000000000000000000000000000000000000..3e5b3aa67987c5d37cec70eb98ccc4ebcd9e31d1 GIT binary patch literal 15 WcmZQzU|{A>tw_u*$Vt^p&IbS%=mYxz literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-054 b/fuzz/corpus/rfc1928_codec/seed-054 new file mode 100644 index 0000000000000000000000000000000000000000..8797764df90c5ec3f8331f21cacc6041d54db884 GIT binary patch literal 16 XcmZQzU|{A>tw_u*$Vt^p&d&t^8&Cvj literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-055 b/fuzz/corpus/rfc1928_codec/seed-055 new file mode 100644 index 0000000000000000000000000000000000000000..2d441c326677188704f006163fb28177c89efd54 GIT binary patch literal 17 YcmZQzU|{A>tw_u*$Vt^p&d+5403WynXaE2J literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-056 b/fuzz/corpus/rfc1928_codec/seed-056 new file mode 100644 index 0000000000000000000000000000000000000000..52e1e1b7e4ea9d139d2e39c7d6e7a55630dc041c GIT binary patch literal 18 ZcmZQzU|{A>tw_u*$Vt^p&d+5q1pp*f1f2i? literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-057 b/fuzz/corpus/rfc1928_codec/seed-057 new file mode 100644 index 0000000000000000000000000000000000000000..468360c0a20c38224e5845222c635b0f436df6f1 GIT binary patch literal 19 acmZQzU|{A>tw_u*$Vt^p&d+5qEdT&1Vg(HV literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-058 b/fuzz/corpus/rfc1928_codec/seed-058 new file mode 100644 index 0000000000000000000000000000000000000000..f01e4bd3272071a37763d6093923840f72de99ea GIT binary patch literal 20 bcmZQzU|{A>tw_u*$Vt^p&d+5qEl30aF3<&T literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-059 b/fuzz/corpus/rfc1928_codec/seed-059 new file mode 100644 index 0000000000000000000000000000000000000000..614be53a5f509f9e87cc0a5c3eea6a1c580583b3 GIT binary patch literal 21 ccmZQzU|{A>tw_u*$Vt^p&d+5qEl8{c05rD+=Kufz literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-060 b/fuzz/corpus/rfc1928_codec/seed-060 new file mode 100644 index 0000000000000000000000000000000000000000..346e649e9a3b4c21ff2872e86eb79dbcbadc41bb GIT binary patch literal 22 dcmZQzU|{A>tw_u*$Vt^p&d+5qEl8})0RTG+22%h4 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-061 b/fuzz/corpus/rfc1928_codec/seed-061 new file mode 100644 index 0000000000000000000000000000000000000000..4575f7ac8282950c2d79710589b84697b8e34d63 GIT binary patch literal 23 ecmZQzU|{A>tw_u*$Vt^p&d+5qEl8})$p-*I%m%^$ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-062 b/fuzz/corpus/rfc1928_codec/seed-062 new file mode 100644 index 0000000000000000000000000000000000000000..1888714550a314f08d9a5624ad46a575d7dcbf6f GIT binary patch literal 259 OcmZSnpE$AsLjV9724V65 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-063 b/fuzz/corpus/rfc1928_codec/seed-063 new file mode 100644 index 0000000000000000000000000000000000000000..84c5cf6918408dc246949d1fbf38517467c4e0b6 GIT binary patch literal 7 OcmZQ({{QbULjV8_Xai9I literal 0 HcmV?d00001 diff --git a/fuzz/corpus/rfc1928_codec/seed-064 b/fuzz/corpus/rfc1928_codec/seed-064 new file mode 100644 index 0000000..a1a73ba --- /dev/null +++ b/fuzz/corpus/rfc1928_codec/seed-064 @@ -0,0 +1 @@ +Èshort \ No newline at end of file diff --git a/fuzz/fuzz_targets/datagram_codec.rs b/fuzz/fuzz_targets/datagram_codec.rs new file mode 100644 index 0000000..96706a4 --- /dev/null +++ b/fuzz/fuzz_targets/datagram_codec.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + alksocks_fuzz_shared::datagram_codec::fuzz_datagram_codec(data); +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/params_parse.rs b/fuzz/fuzz_targets/params_parse.rs new file mode 100644 index 0000000..9d6b294 --- /dev/null +++ b/fuzz/fuzz_targets/params_parse.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + alksocks_fuzz_shared::params_parse::fuzz_params_parse(data); +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/rfc1928_codec.rs b/fuzz/fuzz_targets/rfc1928_codec.rs new file mode 100644 index 0000000..dd23222 --- /dev/null +++ b/fuzz/fuzz_targets/rfc1928_codec.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + alksocks_fuzz_shared::rfc1928_codec::fuzz_rfc1928_codec(data); +}); \ No newline at end of file diff --git a/fuzz/gen_fuzz_seeds.py b/fuzz/gen_fuzz_seeds.py new file mode 100644 index 0000000..cd0d61b --- /dev/null +++ b/fuzz/gen_fuzz_seeds.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Regenerate the committed seed corpora for alksocks' fuzz targets. + +Writes into fuzz/corpus//. Deterministic: fixed inputs only, no +randomness. Run from the repo root: + + python3 fuzz/gen_fuzz_seeds.py +""" + +import os +import struct + +ATYP_V4 = 0x01 +ATYP_DOMAIN = 0x03 +ATYP_V6 = 0x04 +MAX_INNER_LEN = 65535 + + +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 v4_addr(port=53): + return bytes([ATYP_V4, 192, 0, 2, 1]) + struct.pack(">H", port) + + +def v6_addr(port=53): + return bytes([ATYP_V6] + [0x20, 0x01, 0x0D, 0xB8] + [0] * 11 + [1]) + struct.pack(">H", port) + + +def domain_addr(name, port=53): + return bytes([ATYP_DOMAIN, len(name)]) + name.encode() + struct.pack(">H", port) + + +def datagram_codec_seeds(): + i = 0 + addrs = [v4_addr(), v6_addr(), domain_addr("example.com")] + + # Valid frames: every ATYP, empty + non-empty payloads. + for addr in addrs: + for payload in [b"", b"x", b"hello", b"a" * 300]: + inner = addr + payload + write_seed("datagram_codec", i, struct.pack(">H", len(inner)) + inner) + i += 1 + + # Truncations at every prefix length of one frame. + frame = struct.pack(">H", len(addrs[0] + b"payload")) + addrs[0] + b"payload" + for n in range(1, len(frame)): + write_seed("datagram_codec", i, frame[:n]) + i += 1 + + # Boundary shapes: len = 0, len = MAX+1 (u32-ish over-declare via + # u16 is impossible; 0xFFFF over-declared), exact MAX. + write_seed("datagram_codec", i, b"\x00\x00") # zero inner bytes + i += 1 + write_seed("datagram_codec", i, struct.pack(">H", MAX_INNER_LEN) + v4_addr()) + i += 1 + write_seed("datagram_codec", i, b"\xff\xff" + b"\x00" * 300) + i += 1 + # over-declared length header followed by a full-frame body (the + # declared > actual truncation shape) + write_seed("datagram_codec", i, struct.pack(">H", MAX_INNER_LEN) + b"\x00" * 40) + i += 1 + + # Garbage ATYP (the loud-fail class) + non-UTF-8 domain. + write_seed("datagram_codec", i, struct.pack(">H", 6) + bytes([0x7F, 1, 2, 3, 4, 0, 80])) + i += 1 + write_seed( + "datagram_codec", + i, + struct.pack(">H", 7) + bytes([ATYP_DOMAIN, 3, 0xFF, 0xFE, 0xFD, 0, 80]), + ) + i += 1 + + # The joint-bound max-domain shape: a legal RFC payload that would + # overflow the u16 (65507 to a 255-char domain = 65766). The frame + # itself can never carry that length field — encode the closest + # wire-legal shape (0xFFFF declared with the max-domain body) for + # the decode walk; the encode-side 65766 rejection is pinned in + # the shared invariants. + domain255 = domain_addr("a" * 255) + write_seed("datagram_codec", i, struct.pack(">H", MAX_INNER_LEN) + domain255 + b"a" * 20) + i += 1 + + # Batched frames in one chunk; split frames across boundaries. + batched = b"" + for addr in addrs: + inner = addr + b"dg" + batched += struct.pack(">H", len(inner)) + inner + write_seed("datagram_codec", i, batched) + i += 1 + for n in [1, 2, 3, 5, 7, 11]: + write_seed("datagram_codec", i, batched[:n]) + i += 1 + + # Empty datagrams batched; `00 00` byte pairs inside payload + # regions (the framing-walk probe). + empties = b"".join(struct.pack(">H", len(addr)) + addr for addr in addrs) + write_seed("datagram_codec", i, empties) + i += 1 + write_seed( + "datagram_codec", + i, + struct.pack(">H", 10) + v4_addr() + b"\x00\x00\x00\x00\x00\x00\x00\x00", + ) + i += 1 + + # Deep batch/split mix. + big = batched * 8 + write_seed("datagram_codec", i, big) + i += 1 + write_seed("datagram_codec", i, big[13:]) + i += 1 + + assert i >= 16 + + +def rfc1928_codec_seeds(): + i = 0 + + def greeting(methods): + return bytes([0x05, len(methods)] + methods) + + # Valid handshakes: no-auth and password, then CONNECT requests. + write_seed("rfc1928_codec", i, greeting([0x00])) + i += 1 + write_seed("rfc1928_codec", i, greeting([0x02])) + i += 1 + write_seed("rfc1928_codec", i, greeting([0x00, 0x02])) + i += 1 + for body in [ + bytes([0x00, 0x00]), + bytes([0x05, 0x00]), + bytes([0x05, 0x02, 0x03]) + b"alice" + bytes([0x06]) + b"secret", + bytes([0x05, 0x01, 0x00, 0x01, 192, 0, 2, 1, 0, 80]), + bytes([0x05, 0x01, 0x00, 0x03, 11]) + b"example.com" + bytes([0x01, 0xBB]), + bytes([0x05, 0x03, 0x00, 0x04]) + bytes(16) + bytes([0x01, 0xBB]), + bytes([0x05, 0x02, 0x00, 0x03, 3]) + b"ncc" + bytes([0, 80]), + bytes([0x05, 0x7F, 0x00, 0x01, 192, 0, 2, 1, 0, 80]), + ]: + write_seed("rfc1928_codec", i, greeting([0x00]) + bytes([0x05, 0x00]) + body) + i += 1 + + # Version mismatches and unknown methods/commands/ATYPs. + write_seed("rfc1928_codec", i, bytes([0x04, 0x01, 0x00])) + i += 1 + write_seed("rfc1928_codec", i, bytes([0x05, 0x01, 0x09])) + i += 1 + write_seed("rfc1928_codec", i, bytes([0x05, 0x00, 0x04])) + i += 1 + write_seed("rfc1928_codec", i, bytes([0x06, 0x00, 0x01, 1, 2, 3, 4, 0, 80])) + i += 1 + + # Truncations at every prefix length of the no-auth + CONNECT flow. + flow = greeting([0x00]) + bytes([0x05, 0x00]) + bytes( + [0x05, 0x01, 0x00, 0x01, 192, 0, 2, 1, 0, 80] + ) + for n in range(1, len(flow)): + write_seed("rfc1928_codec", i, flow[:n]) + i += 1 + + # Password-auth sub-handshake shapes: empty user/pass pins, 255-byte + # fields, reject status. + write_seed("rfc1928_codec", i, greeting([0x02]) + bytes([0x05, 0x02, 0x01, 0x00, 0x01, 0x78])) + i += 1 + write_seed( + "rfc1928_codec", + i, + greeting([0x02]) + bytes([0x05, 0x02, 0xFF]) + b"u" * 255 + bytes([0xFF]) + b"p" * 255, + ) + i += 1 + + # Unassigned reply codes (the F-1 class). + for code in [0x09, 0x0A, 0x7F, 0xFF]: + write_seed( + "rfc1928_codec", + i, + bytes([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + ) + i += 1 + + # RFC UDP header shapes: valid v4/domain, garbage RSV, unknown ATYP, + # truncations, FRAG carried. + write_seed("rfc1928_codec", i, bytes([0, 0, 0]) + v4_addr(53) + b"payload") + i += 1 + write_seed("rfc1928_codec", i, bytes([0, 0, 0]) + domain_addr("example.com") + b"payload") + i += 1 + write_seed("rfc1928_codec", i, bytes([0, 0x01, 0]) + v4_addr()) + i += 1 + write_seed("rfc1928_codec", i, bytes([0, 0, 0, 0x02, 1, 2, 3, 4, 0, 0])) + i += 1 + udp = bytes([0, 0, 0]) + domain_addr("example.com") + b"payload" + for n in range(1, min(len(udp), 24)): + write_seed("rfc1928_codec", i, udp[:n]) + i += 1 + + # Address codec probes: 255-byte domain, non-UTF-8 domain, dangling + # length fields. + write_seed("rfc1928_codec", i, bytes([ATYP_DOMAIN, 255]) + b"a" * 255 + bytes([0, 80])) + i += 1 + write_seed("rfc1928_codec", i, bytes([ATYP_DOMAIN, 3, 0xFF, 0xFE, 0xFD, 0, 80])) + i += 1 + write_seed("rfc1928_codec", i, bytes([ATYP_DOMAIN, 200]) + b"short") + i += 1 + + # The spec/params JSON shapes for the params_parse corpus. + params_seeds = [ + b"{}", + b'{"resource": "sneaky"}', + b'{"substrate": "tcp"}', + b'{"code": "channel:open_failed", "details": {"reason": "dial_failed"}}', + b'{"code": "channel:open_failed", "details": {"reason": "made_up_reason"}}', + b'{"code": "FORBIDDEN", "details": {"reason": "no"}}', + b'{"reason": {"enum": ["dial_failed", 1]}}', + b"\xff\xfe", + b"[]", + b'{"nested": {"deep": {"deeper": [1, {"a": null}]}}}', + ] + for data in params_seeds: + write_seed("params_parse", i, data) + i += 1 + + # Structured-probe extras: type confusions and near-miss shapes the + # bounded generator explores (committed so corpus replay covers the + # spec-constant + reason-extraction invariants directly). + for data in [ + b'{"channel_open": {"alpn": "alk/tunnel"}}', + b'{"access_control": {"required_scopes": ["socks5:open"]}}', + b'{"details": {"reason": 1}}', + b'{"details": {"reason": {"enum": "dial_failed"}}}', + b'{"code": {"channel:open_failed": true}, "details": null}', + b'{"type": "object", "additionalProperties": false}', + b'"{}"', + b"0", + b'{"a": ' * 12 + b"1" + b"}" * 12, + ]: + write_seed("params_parse", i, data) + i += 1 + + assert i >= 16 + + +def main(): + datagram_codec_seeds() + rfc1928_codec_seeds() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fuzz/json.dict b/fuzz/json.dict new file mode 100644 index 0000000..afccc1c --- /dev/null +++ b/fuzz/json.dict @@ -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" \ No newline at end of file diff --git a/fuzz/run-detached.sh b/fuzz/run-detached.sh new file mode 100755 index 0000000..f3f9b07 --- /dev/null +++ b/fuzz/run-detached.sh @@ -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 [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/-*.log +# ls fuzz/artifacts// (crash-* / oom-* / timeout-* files) +# pgrep -f "cargo fuzz run " +set -euo pipefail +target="${1:?usage: run-detached.sh [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" \ No newline at end of file diff --git a/fuzz/rust-toolchain.toml b/fuzz/rust-toolchain.toml new file mode 100644 index 0000000..691b3cb --- /dev/null +++ b/fuzz/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly" +components = ["llvm-tools-preview"] \ No newline at end of file diff --git a/fuzz/shared/Cargo.toml b/fuzz/shared/Cargo.toml new file mode 100644 index 0000000..2dafecc --- /dev/null +++ b/fuzz/shared/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "alksocks-fuzz-shared" +version = "0.0.0" +publish = false +edition = "2021" + +[dependencies] +alksocks = { path = "../.." } +alkcall = "0.8.1" +serde_json = "1" +bytes = "1" +tokio = { version = "1", features = ["rt", "io-util", "macros"], default-features = false } + +[dependencies.arbitrary] +version = "1" +features = ["derive"] \ No newline at end of file diff --git a/fuzz/shared/src/arbitrary_value.rs b/fuzz/shared/src/arbitrary_value.rs new file mode 100644 index 0000000..74632a4 --- /dev/null +++ b/fuzz/shared/src/arbitrary_value.rs @@ -0,0 +1,108 @@ +//! `Arbitrary` support for `serde_json::Value` — a bounded-depth +//! recursive generator shared by the semantic targets. Depth and size +//! bounds keep exec/s high (serde_json's own 128-depth recursion limit +//! is the decode-side guard; this generator explores structure). + +use arbitrary::{Arbitrary, Unstructured}; +use serde_json::{Map, Number, Value}; + +#[derive(Debug, arbitrary::Arbitrary, Clone)] +#[allow(dead_code)] +enum JsonShape { + Null, + Bool(bool), + Number { + mantissa: u32, + negative: bool, + }, + String { + len: u8, + byte: u8, + }, + Array { + len: u8, + items: Vec, + }, + Object { + len: u8, + keys: Vec<(u8, u8)>, + values: Vec, + }, +} + +/// Generate a `Value` with bounded depth: arrays/objects nest at most +/// 6 levels, each holding at most 6 items. The `u8`-bounded shapes +/// keep the generator cheap; the semantic target's value space is +/// structure, not size. +impl<'a> Arbitrary<'a> for JsonValue { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let shape = JsonShape::arbitrary(u)?; + Ok(JsonValue(shape_to_value(shape, 6))) + } +} + +fn shape_to_value(shape: JsonShape, depth: u32) -> Value { + if depth == 0 { + return Value::Null; + } + match shape { + JsonShape::Null => Value::Null, + JsonShape::Bool(b) => Value::Bool(b), + JsonShape::Number { mantissa, negative } => { + let n = i64::from(mantissa % 1_000_000); + Number::from(if negative { -n } else { n }).into() + } + JsonShape::String { len, byte } => { + // Repeat a printable-ish char; embedded control bytes and + // invalid UTF-8 come from the fuzzer's byte mutations of + // the raw frame in `envelope_frame`, not here. + let n = (len % 64) as usize; + std::iter::repeat_n((0x41u8 + (byte % 26)) as char, n) + .collect::() + .into() + } + JsonShape::Array { len, items } => { + let items: Vec = items + .into_iter() + .take(6) + .map(|s| shape_to_value(s, depth - 1)) + .collect(); + let _ = len; + Value::Array(items) + } + JsonShape::Object { keys, values, .. } => { + let mut map = Map::new(); + for (i, (k_len, k_byte)) in keys.into_iter().take(6).enumerate() { + let key: String = std::iter::repeat_n( + (0x61u8 + (k_byte % 26)) as char, + (k_len % 16) as usize + 1, + ) + .collect(); + let value = values + .get(i) + .map(|s| shape_to_value(s.clone(), depth - 1)) + .unwrap_or(Value::Null); + map.insert(key, value); + } + Value::Object(map) + } + } +} + +/// Newtype so `impl Arbitrary` applies (serde_json re-exports `Value` +/// as `serde_json::Value`; a foreign-type impl is not allowed). +#[derive(Debug, Clone, PartialEq)] +pub struct JsonValue(pub Value); + +impl std::ops::Deref for JsonValue { + type Target = Value; + fn deref(&self) -> &Value { + &self.0 + } +} + +impl From for Value { + fn from(value: JsonValue) -> Value { + value.0 + } +} diff --git a/fuzz/shared/src/datagram_codec.rs b/fuzz/shared/src/datagram_codec.rs new file mode 100644 index 0000000..23236a4 --- /dev/null +++ b/fuzz/shared/src/datagram_codec.rs @@ -0,0 +1,343 @@ +//! Invariants for the ASSOCIATE datagram frame codec (ADR-003, +//! `src/wire.rs`): no-panic, infallible reassembly with a pinned +//! decode-error partition, the empty-payload F-2 layering, bad-ATYP +//! loudness, splitting transparency, round-trips, the joint bound +//! (including the 65766 max-domain shape), and the decoder-state +//! retained-bytes bound. + +use alksocks::rfc1928::consts; +use alksocks::rfc1928::util::target_addr::TargetAddr; +use alksocks::wire::{ + addr_encoding_len, frame, frame_inner, DatagramReader, WireError, MAX_INNER_LEN, +}; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; + +/// The decoder-state bound after feeding: leftover buffer + in-flight +/// accumulation stay bounded by the fed chunk size plus one frame (the +/// header + a full u16 frame). State must never grow with input. +fn assert_retained_bound(reader: &DatagramReader, fed: usize) { + let retained = reader.retained_bytes(); + let max = fed + MAX_INNER_LEN + 2; + assert!( + retained <= max, + "decoder retained {retained} bytes after feeding {fed}: \ + the state must stay bounded by fed + one frame" + ); +} + +fn probe_addr() -> TargetAddr { + TargetAddr::Ip(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(192, 0, 2, 1), + 53, + ))) +} + +pub fn fuzz_datagram_codec(data: &[u8]) { + // The infallible reassembly direction: any chunk sequence feeds + // cleanly. Drive the reader with the whole input and with awkward + // 3-byte slices — splitting transparency must hold for both. + let mut whole = DatagramReader::new(); + let whole_dgs = whole.feed(data); + assert_retained_bound(&whole, data.len()); + + let mut sliced = DatagramReader::new(); + let mut sliced_dgs: Vec> = Vec::new(); + let mut last_fed = 0usize; + for piece in data.chunks(3) { + sliced_dgs.extend(sliced.feed(piece)); + last_fed = piece.len(); + } + assert_retained_bound(&sliced, last_fed); + + assert_eq!( + result_shapes(&whole_dgs), + result_shapes(&sliced_dgs), + "chunk splitting must be transparent to the datagram stream" + ); + + // The decode-error partition: only the four decode-garbage + // variants surface from `feed`; the frame-time variants + // (`Oversize`/`Address`) are unreachable there. + for res in &whole_dgs { + if let Err(err) = res { + assert!( + matches!( + err, + WireError::EmptyFrame + | WireError::BadAtyp(_) + | WireError::ShortAddress + | WireError::DomainNotUtf8(_) + ), + "feed must surface only decode-garbage errors, got {err:?}" + ); + } + } + + // Empty-payload F-2 layering + bad-ATYP loudness, frame-walked. + let empty_payload_frames = count_empty_payload_frames(data); + let empty_yielded = whole_dgs + .iter() + .filter(|r| matches!(r, Ok((_, p)) if p.is_empty())) + .count(); + assert_eq!( + empty_yielded, empty_payload_frames, + "an empty payload must come only from a real frame's empty payload region" + ); + for res in &whole_dgs { + if let Err(WireError::BadAtyp(atyp)) = res { + assert!( + *atyp != consts::SOCKS5_ADDR_TYPE_IPV4 + && *atyp != consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME + && *atyp != consts::SOCKS5_ADDR_TYPE_IPV6, + "BadAtyp must carry a non-RFC ATYP byte, got {atyp:#x}" + ); + } + } + + // Round-trip: every decoded datagram re-frames and re-decodes to + // itself (both frame entry points agree). + for res in &whole_dgs { + let Ok((addr, payload)) = res else { + continue; + }; + let framed = frame(addr, payload).expect("decoded datagram fits the joint bound"); + let mut probe = DatagramReader::new(); + let back = probe.feed(&framed); + assert_eq!(back.len(), 1); + let (back_addr, back_payload) = back.into_iter().next().unwrap().unwrap(); + assert_eq!(back_addr, *addr); + assert_eq!(&back_payload[..], &payload[..]); + assert_retained_bound(&probe, framed.len()); + + let mut inner = addr.to_be_bytes().expect("decoded addr encodes"); + inner.extend_from_slice(payload); + let framed_inner = frame_inner(&inner).expect("inner fits u16"); + assert_eq!(&framed_inner[..], &framed[..], "the two frame paths agree"); + } + + // Re-frame round-trip: for input that is a concatenation of + // complete frames, parsing then re-framing reproduces the frames + // byte-for-byte — except a `len == 0` frame, which is well-framed + // but decodes to `Err(EmptyFrame)` (protocol garbage; it has no + // addr to re-frame). The frame count holds over every frame; the + // byte-for-byte re-frame holds over the decodable ones. + let (frames, well_framed_len) = split_frames(data); + if well_framed_len == data.len() && !frames.is_empty() { + assert_eq!( + whole_dgs.len(), + frames.len(), + "frame count preserved for well-framed input" + ); + for (res, frame_bytes) in whole_dgs.iter().zip(frames.iter()) { + match res { + Ok((addr, payload)) => { + let reframed = frame(addr, payload).expect("decoded datagram reframes"); + assert_eq!(&reframed[..], &frame_bytes[..]); + } + Err(WireError::EmptyFrame) => { + // The frame-walk must agree this is a `len == 0` + // frame — the only zero-inner-byte shape. + let declared = u16::from_be_bytes([frame_bytes[0], frame_bytes[1]]) as usize; + assert_eq!(declared, 0); + assert_eq!(frame_bytes.len(), 2); + } + Err(WireError::ShortAddress) + | Err(WireError::DomainNotUtf8(_)) + | Err(WireError::BadAtyp(_)) => { + // A well-framed frame can decode to the address + // garbage shapes: the declared length carried a + // too-short, non-UTF-8, or unknown-ATYP addr + // region. The decode refused the addr — the only + // re-frame possible is the raw frame itself, which + // the count/prefix walks above already pin. + } + other => panic!( + "well-framed input must decode or be a decode-garbage shape, got {other:?}" + ), + } + } + } + + // Frame direction: the joint bound exactly at the edge, with the + // sizes echoed — including the 65766 max-domain shape (a legal RFC + // payload that would overflow the u16 length field). + let max_payload = MAX_INNER_LEN - addr_encoding_len(&probe_addr()); + let framed = frame(&probe_addr(), &vec![0u8; max_payload]).expect("exact limit is legal"); + assert_eq!(framed.len(), 2 + MAX_INNER_LEN); + let err = frame(&probe_addr(), &vec![0u8; max_payload + 1]).unwrap_err(); + match err { + WireError::Oversize(total, max) => { + assert_eq!(total, MAX_INNER_LEN + 1); + assert_eq!(max, MAX_INNER_LEN); + } + other => panic!("expected Oversize at the joint bound, got {other:?}"), + } + let err = frame_inner(&vec![0u8; MAX_INNER_LEN + 1]).unwrap_err(); + match err { + WireError::Oversize(total, max) => { + assert_eq!(total, MAX_INNER_LEN + 1); + assert_eq!(max, MAX_INNER_LEN); + } + other => panic!("expected Oversize from frame_inner, got {other:?}"), + } + let domain = TargetAddr::Domain("a".repeat(255), 53); + let err = frame(&domain, &vec![0u8; 65507]).unwrap_err(); + match err { + WireError::Oversize(total, max) => { + assert_eq!( + total, 65766, + "the max-domain 65507 payload is the joint bound's reason" + ); + assert_eq!(max, MAX_INNER_LEN); + } + other => panic!("expected Oversize(65766, ..), got {other:?}"), + } + + // Empty payload is a legal datagram for every ATYP (the F-2 + // invariant, at payload level). + let v6 = TargetAddr::Ip(SocketAddr::V6(SocketAddrV6::new( + Ipv6Addr::LOCALHOST, + 53, + 0, + 0, + ))); + for addr in [probe_addr(), v6, domain.clone()] { + let framed = frame(&addr, &[]).expect("empty payload is a legal datagram"); + let mut probe = DatagramReader::new(); + let dgs = probe.feed(&framed); + assert_eq!(dgs.len(), 1); + let (back_addr, back_payload) = dgs.into_iter().next().unwrap().unwrap(); + assert_eq!(back_addr, addr); + assert!(back_payload.is_empty()); + } + + // Mid-frame observability: after the final feed, the reader is + // mid-frame exactly when the input ends inside a frame (a partial + // frame beyond the 2-byte header; a dangling 1-byte header + // fragment is NOT mid-frame — the header is not yet consumed). + let expected_mid = ends_inside_frame(data, well_framed_len); + assert_eq!( + whole.is_mid_frame(), + expected_mid, + "is_mid_frame must track the framing state" + ); +} + +fn result_shapes(dgs: &[Result<(TargetAddr, bytes::Bytes), WireError>]) -> Vec { + dgs.iter() + .map(|r| match r { + Ok((addr, payload)) => format!("ok:{addr}:{}", digest(payload)), + Err(WireError::EmptyFrame) => "err:empty".to_owned(), + Err(WireError::BadAtyp(a)) => format!("err:badatyp:{a:#x}"), + Err(WireError::ShortAddress) => "err:short".to_owned(), + Err(WireError::DomainNotUtf8(_)) => "err:utf8".to_owned(), + other => panic!("unexpected error shape {other:?}"), + }) + .collect() +} + +/// A cheap digest stand-in: length + first/last bytes — enough to pin +/// equality across the split feeds. +fn digest(payload: &[u8]) -> u64 { + let mut h = payload.len() as u64; + if let Some(f) = payload.first() { + h |= u64::from(*f) << 8; + } + if let Some(l) = payload.last() { + h |= u64::from(*l) << 16; + } + h +} + +/// Walk the framing exactly as the decoder does: complete frames from +/// the start; the walk stops at the first frame that overruns the +/// input. Returns the frames and the byte length they occupy — the +/// remainder (if any) is a partial/trailing region. +fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) { + let mut frames = Vec::new(); + let mut pos = 0usize; + while pos + 2 <= data.len() { + let declared = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + let end = pos + 2 + declared; + if end > data.len() { + return (frames, pos); + } + frames.push(&data[pos..end]); + pos = end; + } + (frames, pos) +} + +/// Count complete, well-formed-address frames whose payload region is +/// empty — the only source an empty-payload datagram may come from +/// (the F-2 walk, alktunnels' count_empty_frames shape adapted to the +/// `(addr, payload)` inner structure: a frame's empty payload needs +/// `declared == addr_len`, not `declared == 0`). Domain-form frames +/// must also carry a valid-UTF-8 domain: the codec validates UTF-8 +/// before the payload split — a non-UTF-8 domain with an empty payload +/// region surfaces as `DomainNotUtf8`, never as an empty datagram. +fn count_empty_payload_frames(data: &[u8]) -> usize { + let mut count = 0usize; + let mut pos = 0usize; + while pos + 2 <= data.len() { + let declared = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + let end = pos + 2 + declared; + if end > data.len() { + break; + } + if declared >= 4 { + let atyp = data[pos + 2]; + let addr_len = match atyp { + consts::SOCKS5_ADDR_TYPE_IPV4 => 7, + consts::SOCKS5_ADDR_TYPE_IPV6 => 19, + consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME => { + if pos + 4 > data.len() { + break; + } + 2 + data[pos + 3] as usize + 2 + } + _ => 0, + }; + let utf8_ok = atyp != consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME + || data + .get(pos + 4..pos + 4 + data[pos + 3] as usize) + .is_some_and(|d| std::str::from_utf8(d).is_ok()); + if addr_len > 0 && declared == addr_len && utf8_ok { + count += 1; + } + } + pos = end; + } + count +} + +fn ends_inside_frame(data: &[u8], well_framed_len: usize) -> bool { + // Mid-frame iff the well-framed prefix leaves a *parsed but + // incomplete* frame: a remainder ≥ 2 bytes means the length header + // was consumed and the frame is in flight (a remainder of 1 byte + // is a buffered header fragment — not yet a frame in flight). + let remainder = data.len() - well_framed_len; + remainder >= 2 +} + +#[cfg(test)] +mod corpus_replay { + use super::*; + use std::path::PathBuf; + + #[test] + fn committed_corpus_replays_through_the_invariants() { + let corpus = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/datagram_codec"); + 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_datagram_codec(&data); + count += 1; + } + assert!( + count >= 16, + "committed seed corpus is present, found {count}" + ); + } +} diff --git a/fuzz/shared/src/lib.rs b/fuzz/shared/src/lib.rs new file mode 100644 index 0000000..8295400 --- /dev/null +++ b/fuzz/shared/src/lib.rs @@ -0,0 +1,10 @@ +//! Invariant logic shared by alksocks' fuzz 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 arbitrary_value; +pub mod datagram_codec; +pub mod params_parse; +pub mod rfc1928_codec; diff --git a/fuzz/shared/src/params_parse.rs b/fuzz/shared/src/params_parse.rs new file mode 100644 index 0000000..7c2d024 --- /dev/null +++ b/fuzz/shared/src/params_parse.rs @@ -0,0 +1,165 @@ +//! Invariants for the open-op params (`src/params.rs`): no-panic on +//! any JSON-shaped input, the trivial-params pin (`{}` valid, unknown +//! fields reject loudly), the wire-stability of the open-op spec, and +//! the pass-through reason-extraction posture. + +use alkcall::channels::client::ChannelOpenError; +use alkcall::protocol::wire::CallError; +use alksocks::error::SocksEstablishmentReason; +use alksocks::params::{ + establishment_reason, socks5_open_spec, Socks5Params, OP_SOCKS5_OPEN, SOCKS5_ALPN, + SOCKS5_BIND_SCOPE, SOCKS5_OPEN_SCOPE, +}; +use arbitrary::Arbitrary; +use serde_json::Value; + +use crate::arbitrary_value::JsonValue; + +pub fn fuzz_params_parse(data: &[u8]) { + // Raw-bytes path: the attacker's actual input shape is bytes, and + // serde_json's parse must never panic on any of them. + if let Ok(value) = serde_json::from_slice::(data) { + parse_shapes(&value); + } + + // Structured path: bounded-depth Value generation explores param + // shapes far beyond what byte-level mutation reaches cheaply. + let mut u = arbitrary::Unstructured::new(data); + if let Ok(json) = JsonValue::arbitrary(&mut u) { + parse_shapes(&json); + } +} + +fn parse_shapes(value: &Value) { + // The trivial params: the canonical form is `{}` — it parses and + // round-trips to the canonical form. Unknown fields reject loudly + // (the serde pin and the schema's additionalProperties agree — + // ADR-001). NOTE the serde reality the harness must model: a + // fieldless struct deserializes from ANY empty JSON composite + // (the seq form `[]` included) — the wire's `type: object` input + // schema (the registry's gate) is what keeps the non-object forms + // from ever reaching the type; the serde pin is the second gate. + let parses = serde_json::from_value::(value.clone()).is_ok(); + if parses { + let params = serde_json::from_value::(value.clone()).unwrap(); + let back = serde_json::to_value(params).expect("params serialize"); + assert_eq!(back, serde_json::json!({})); + assert_eq!(back, Socks5Params::as_json()); + assert!( + value.as_object().is_some_and(|o| o.is_empty()) + || matches!(value, Value::Array(a) if a.is_empty()), + "a parse success must be an empty composite (object or seq form)" + ); + } else { + assert_ne!( + value, + &serde_json::json!({}), + "the empty object must parse — anything else must reject" + ); + } + + // The open-op spec is a constant: the wire-visible surface is + // identical on every execution (the wire-stable ADR-001 surface), + // and the schema's reason-enum table equals the typed parse table + // (the schema/typed-view drift guard). + let spec = socks5_open_spec(); + assert_eq!(spec.name, OP_SOCKS5_OPEN); + assert_eq!( + spec.channel_open + .as_ref() + .expect("channel-open marker") + .alpn, + SOCKS5_ALPN + ); + assert_eq!( + spec.access_control.required_scopes, + vec![SOCKS5_OPEN_SCOPE.to_string()] + ); + assert_eq!( + spec.input_schema, + serde_json::json!({"type": "object", "additionalProperties": false}) + ); + let def = spec + .error_schemas + .iter() + .find(|e| e.code == "channel:open_failed") + .expect("channel:open_failed declared"); + let reasons: Vec<&str> = def.schema["properties"]["reason"]["enum"] + .as_array() + .expect("reason enum") + .iter() + .filter_map(Value::as_str) + .collect(); + for reason in &reasons { + assert_eq!( + SocksEstablishmentReason::parse(reason).map(|r| r.as_str()), + Some(*reason), + "declared reason {reason} is a typed ADR-049 §3 code" + ); + } + // The bind scope is a distinct constant, never implied (ADR-006). + assert_ne!(SOCKS5_OPEN_SCOPE, SOCKS5_BIND_SCOPE); + + // The reason surface is a pass-through extraction, not a validated + // table: Some(details.reason) iff the code is channel:open_failed + // and the reason is a string — any string surfaced as-is (the + // alktunnels harness-model finding #2, the modeled contract). + let code = value + .get("code") + .and_then(Value::as_str) + .unwrap_or("channel:open_failed"); + let message = value + .get("message") + .and_then(Value::as_str) + .unwrap_or("establishment failed"); + let details = value.get("details").cloned().unwrap_or(Value::Null); + let error = ChannelOpenError::CallFailed { + error: CallError::new(code, message, false).with_details(details), + }; + let expected = if code == "channel:open_failed" { + value + .get("details") + .and_then(|d| d.get("reason")) + .and_then(Value::as_str) + } else { + None + }; + assert_eq!( + establishment_reason(&error), + expected, + "establishment_reason is a pass-through extraction of details.reason" + ); + // The typed parse view is total: never panics, and round-trips its + // own vocabulary. + for reason in [ + "dial_failed", + "unknown_resource", + "resource_shortage", + "handler_error", + "timeout", + ] { + assert!(SocksEstablishmentReason::parse(reason).is_some()); + } +} + +#[cfg(test)] +mod corpus_replay { + use super::*; + use std::path::PathBuf; + + #[test] + fn committed_corpus_replays_through_the_invariants() { + let corpus = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/params_parse"); + 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_params_parse(&data); + count += 1; + } + assert!( + count >= 16, + "committed seed corpus is present, found {count}" + ); + } +} diff --git a/fuzz/shared/src/rfc1928_codec.rs b/fuzz/shared/src/rfc1928_codec.rs new file mode 100644 index 0000000..74b4b4b --- /dev/null +++ b/fuzz/shared/src/rfc1928_codec.rs @@ -0,0 +1,258 @@ +//! Invariants for the forked RFC 1928 core (`src/rfc1928/`, ADR-013 — +//! fully owned code): no-panic on any byte sequence across the whole +//! handshake state machine, every malformed shape is a typed error +//! (the F-1 class — wire bytes must never reach `unreachable!()`), +//! round-trips for well-formed messages, and the full-domain +//! reply-code table. Both protocol directions are covered: the server +//! typestate (the producer drives it against consumer bytes) and the +//! client reply path (the consumer sessions + front doors drive it +//! against producer bytes). + +use alksocks::rfc1928::consts; +use alksocks::rfc1928::server::states; +use alksocks::rfc1928::server::{Socks5ServerProtocol, SocksServerError}; +use alksocks::rfc1928::util::stream::ConnectError; +use alksocks::rfc1928::util::target_addr::{read_address, AddrError, TargetAddr, ToTargetAddr}; +use alksocks::rfc1928::{ + new_udp_header, parse_udp_request, ReplyError, Socks5Command, UdpHeaderError, +}; +use std::net::Ipv4Addr; +use tokio::io::AsyncWriteExt; + +pub fn fuzz_rfc1928_codec(data: &[u8]) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime builds"); + rt.block_on(async { + fuzz_read_address(data).await; + fuzz_server_handshake(data).await; + fuzz_password_auth(data).await; + fuzz_udp_header(data).await; + }); + fuzz_reply_table(data); + fuzz_connect_error_table(data); + fuzz_target_conversions(data); +} + +/// The address codec, fed arbitrary (atyp, bytes) pairs from both +/// protocol directions: no-panic, typed failures only, round-trip for +/// every well-formed address. +async fn fuzz_read_address(data: &[u8]) { + let raw_atyp = data.first().copied().unwrap_or(0); + for &atyp in [raw_atyp, 0x01, 0x03, 0x04, 0x00, 0xff].iter() { + // The body is the bytes after the atyp byte when the input's + // own first byte is the atyp under test, else the raw input + // (the same shape as the in-tree `read_target_addr` helper). + let body: &[u8] = if atyp == raw_atyp { + data.get(1..).unwrap_or(&[]) + } else { + &data[..data.len().min(260)] + }; + let (mut client, mut server) = tokio::io::duplex(1024); + client + .write_all(body) + .await + .expect("duplex write cannot fail"); + drop(client); + match read_address(&mut server, atyp).await { + Ok(addr) => { + // Round-trip: a parsed address re-encodes and re-parses + // to itself. + let encoded = addr.to_be_bytes().expect("parsed addr encodes"); + let (mut client, mut server) = tokio::io::duplex(1024); + client + .write_all(&encoded[1..]) + .await + .expect("duplex write cannot fail"); + drop(client); + let reparsed = read_address(&mut server, encoded[0]).await; + match (&reparsed, &Ok::(addr.clone())) { + (Ok(got), Ok(want)) => assert_eq!(got, want), + (Err(e), Ok(_)) => panic!("round-trip re-parse failed: {e:?}"), + _ => panic!("round-trip re-parse diverged"), + } + if let TargetAddr::Domain(domain, _) = &addr { + assert!( + domain.len() <= 255, + "the u8 length field bounds domains at 255" + ); + } + } + Err(err) => { + // Typed failure — fine; the ATYP gate maps unknown + // types to IncorrectAddressType (the F-1 class). + if let AddrError::IncorrectAddressType = err { + assert!( + atyp != consts::SOCKS5_ADDR_TYPE_IPV4 + && atyp != consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME + && atyp != consts::SOCKS5_ADDR_TYPE_IPV6, + "IncorrectAddressType only for non-RFC ATYPs" + ); + } + } + } + } +} + +/// The server handshake state machine (negotiation → command read), +/// fed arbitrary bytes. No panic is the finding; every malformed shape +/// must be a typed error. +async fn fuzz_server_handshake(data: &[u8]) { + // accept_no_auth + read_command over a duplex, the in-tree test + // shape: feed the bytes, close, let the state machine consume. + let (client, server) = tokio::io::duplex(4096); + let handle: tokio::task::JoinHandle> = + tokio::spawn(async move { + let proto = + Socks5ServerProtocol::<_, states::Authenticated>::accept_no_auth(server).await?; + let (_proto, cmd, target) = proto.read_command().await?; + Ok::<_, SocksServerError>((cmd, target)) + }); + let mut client = client; + let _ = client.write_all(data).await; + drop(client); + match handle.await { + Ok(Ok((cmd, target))) => { + // A successful parse must be a real command and a real + // target: the command from the RFC table, the target + // well-formed. + assert!(matches!( + cmd, + Socks5Command::TCPConnect | Socks5Command::TCPBind | Socks5Command::UDPAssociate + )); + if let TargetAddr::Domain(domain, _) = &target { + assert!(domain.len() <= 255); + } + } + Ok(Err(_)) => {} + Err(join_err) if join_err.is_panic() => { + panic!("the RFC handshake state machine panicked on {data:?}: {join_err}"); + } + Err(_) => {} + } +} + +/// The RFC 1929 sub-handshake (read_username_password under +/// accept_password_auth), fed arbitrary bytes: u8 length fields bound +/// allocations structurally (≤ 255 bytes each), outcomes typed only. +async fn fuzz_password_auth(data: &[u8]) { + let (client, server) = tokio::io::duplex(4096); + let handle: tokio::task::JoinHandle> = + tokio::spawn(async move { + let (proto, _accepted) = + Socks5ServerProtocol::<_, states::Authenticated>::accept_password_auth( + server, + |_user, _pass| true, + ) + .await?; + let (_proto, cmd, _target) = proto.read_command().await?; + Ok::<_, SocksServerError>(cmd) + }); + let mut client = client; + let _ = client.write_all(data).await; + drop(client); + match handle.await { + Ok(Ok(_)) | Ok(Err(_)) => {} + Err(join_err) if join_err.is_panic() => { + panic!("the RFC 1929 sub-handshake panicked on {data:?}: {join_err}"); + } + Err(_) => {} + } +} + +/// The RFC UDP header codec (front doors): no-panic; garbage RSV / +/// unknown ATYP / truncation are typed; well-formed input round-trips +/// its header portion with `new_udp_header`. +async fn fuzz_udp_header(data: &[u8]) { + match parse_udp_request(data).await { + Ok((frag, target, payload)) => { + if let TargetAddr::Domain(domain, _) = &target { + assert!(domain.len() <= 255); + } + let header = new_udp_header(target).expect("parsed addr re-encodes"); + // The re-encoded header must be exactly the RSV/FRAG/addr + // prefix the parse consumed (3 + addr bytes). + assert!( + data.len() >= 3 + header.len() - 3, + "a parsed header implies the input carried it" + ); + assert_eq!(payload, &data[3 + header.len() - 3..]); + let _ = frag; + } + Err(err) => match err { + UdpHeaderError::GarbageInReserved + | UdpHeaderError::AddrError(_) + | UdpHeaderError::ReadingError(_) => {} + other => panic!("unexpected UdpHeaderError shape {other:?}"), + }, + } +} + +/// `ReplyError::from_u8` over the full u8 domain: total, no panic, +/// unassigned codes map to the GeneralFailure catch-all (the F-1 +/// pin — the direct probe of the class the fork already fixed). +fn fuzz_reply_table(data: &[u8]) { + for &code in data { + let reply = ReplyError::from_u8(code); + match code { + 0x00..=0x08 => assert_eq!(reply.as_u8(), code), + _ => assert_eq!(reply.as_u8(), consts::SOCKS5_REPLY_GENERAL_FAILURE), + } + } + for code in 0x00u8..=0x08 { + assert_eq!(ReplyError::from_u8(code).as_u8(), code); + } +} + +/// `ConnectError::to_reply_error` is total over its variants (the +/// mapping a future kernel-net port reuses; the probe re-pins it so a +/// future variant addition landing unmapped surfaces). +fn fuzz_connect_error_table(data: &[u8]) { + let kinds = [ + std::io::ErrorKind::TimedOut, + std::io::ErrorKind::ConnectionRefused, + std::io::ErrorKind::ConnectionAborted, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::NotConnected, + std::io::ErrorKind::Other, + ]; + for kind in kinds { + let err = ConnectError::Other(std::io::Error::new(kind, "probe")); + let _ = err.to_reply_error(); + } + let _ = data; +} + +/// `ToTargetAddr` conversions over string-ish inputs never panic (the +/// producer's dial seam and the front doors construct targets from +/// remote-influenced strings). +fn fuzz_target_conversions(data: &[u8]) { + if let Ok(s) = std::str::from_utf8(&data[..data.len().min(512)]) { + let _ = (s, 80u16).to_target_addr(); + let _ = s.parse::(); + let _ = s.parse::(); + } +} + +#[cfg(test)] +mod corpus_replay { + use super::*; + use std::path::PathBuf; + + #[test] + fn committed_corpus_replays_through_the_invariants() { + let corpus = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/rfc1928_codec"); + 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_rfc1928_codec(&data); + count += 1; + } + assert!( + count >= 16, + "committed seed corpus is present, found {count}" + ); + } +} diff --git a/src/wire.rs b/src/wire.rs index 580eb62..16d84d5 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -260,6 +260,15 @@ impl DatagramReader { pub fn is_mid_frame(&self) -> bool { self.current.is_some() } + + /// The decoder's retained byte count: the buffered remainder plus + /// any in-flight frame's accumulated payload. The fuzz harness's + /// bound assertion (`fuzz/shared`'s `datagram_codec` target) + /// asserts against this diagnostic (the alktunnels + /// `retained_bytes()` posture); it is not a protocol surface. + pub fn retained_bytes(&self) -> usize { + self.buf.len() + self.current.as_ref().map_or(0, |(_, acc)| acc.len()) + } } /// Decode one frame's inner bytes (`addr + data`, the length prefix