test(proto): add decoder fuzzing #116

Merged
Lochnair merged 2 commits from test/proto-fuzzing into main 2026-08-19 17:31:41 +00:00
Lochnair commented 2026-08-19 16:34:04 +00:00 (Migrated from github.com)

Summary

  • add isolated cargo-fuzz/libFuzzer infrastructure for irtt-proto;
  • fuzz request/open-reply/echo-reply/Params/varint decoders;
  • add small clean-room seed corpora;
  • add bounded nightly fuzz smoke CI.

Coverage

  • decode_request feeds arbitrary bytes to decode_request; on a structural Open it also runs Params::decode on the encoded parameter bytes (both Ok/Err accepted, since structural Open decoding deliberately permits malformed params), and when hmac_present it runs verify_packet_hmac with a fixed fuzz key. Invariant: no panic.
  • decode_open_reply calls decode_open_reply(data, None) and decode_open_reply(data, Some(key)) on the raw bytes, then — when the input is structurally capable of carrying an HMAC field and its flags already indicate HMAC — recomputes the HMAC via the crate's own compute_hmac_in_place and decodes that derived packet too, so the post-HMAC parser gets sustained coverage rather than getting stuck on HmacPresenceMismatch/BadHmac every time.
  • decode_echo_reply derives a valid, bounded Params from a small control prefix of the fuzz input (received_stats/stamp_at/clock enum, a negotiated-length vocabulary around header boundaries up to 65507, and an HMAC toggle), builds a packet of the negotiated length from the remaining fuzz bytes (also exercising the upstream single-clock midpoint compatibility length), and asserts a successful decode's payload length never exceeds the packet length. The raw fuzz bytes are also decoded unmodified against the same Params for the plain no-panic invariant.
  • decode_params calls both Params::decode and Params::decode_with_presence (which share one parser) and asserts they agree; on success it re-encodes and re-decodes to assert semantic (not byte-for-byte) round-trip equality.
  • decode_varint calls decode_uvarint/decode_varint, asserts used bounds (>0, <= data.len(), <= 10), and asserts a canonical re-encode/decode round trip, without requiring noncanonical fuzz input to reproduce itself.

Isolation

  • fuzz/Cargo.toml declares its own [workspace], so fuzz/ is never a member of the root workspace in Cargo.toml.
  • libfuzzer-sys/arbitrary are fuzz-only dependencies that never enter any published crate's dependency graph; the root Cargo.lock is untouched (verified via git status/git diff — zero root-lockfile churn).
  • The normal stable/MSRV workspace is unaffected: cargo check/test/clippy/doc --workspace all pass unchanged, and fuzz/ requires nightly only for its own isolated workspace.
  • No protocol/production code changed; this PR is fuzzing infrastructure and seed corpora only.

Verification

  • cargo-fuzz 0.13.1, nightly toolchain (cargo 1.99.0-nightly).
  • Local smoke run per target, ~40s each, -timeout=5:
    • decode_request: 8,393,950 exec in 41s — no crashes
    • decode_open_reply: 4,676,635 exec in 41s — no crashes
    • decode_echo_reply: 3,325,893 exec in 41s — no crashes
    • decode_params: 9,887,763 exec in 41s — no crashes
    • decode_varint: 20,614,310 exec in 41s — no crashes
  • No crashes/panics found in any target.
  • Normal workspace: cargo fmt --all -- --check, cargo check/test/clippy --workspace --all-targets --all-features --locked, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked, and cargo check -p irtt-proto --locked all pass.
  • CI: .github/workflows/fuzz.yml builds all five targets once, then runs each for -max_total_time=10 -timeout=5 sequentially in one job, on pull_request/push to main and workflow_dispatch.
## Summary - add isolated cargo-fuzz/libFuzzer infrastructure for irtt-proto; - fuzz request/open-reply/echo-reply/Params/varint decoders; - add small clean-room seed corpora; - add bounded nightly fuzz smoke CI. ## Coverage - `decode_request` feeds arbitrary bytes to `decode_request`; on a structural `Open` it also runs `Params::decode` on the encoded parameter bytes (both Ok/Err accepted, since structural Open decoding deliberately permits malformed params), and when `hmac_present` it runs `verify_packet_hmac` with a fixed fuzz key. Invariant: no panic. - `decode_open_reply` calls `decode_open_reply(data, None)` and `decode_open_reply(data, Some(key))` on the raw bytes, then — when the input is structurally capable of carrying an HMAC field and its flags already indicate HMAC — recomputes the HMAC via the crate's own `compute_hmac_in_place` and decodes that derived packet too, so the post-HMAC parser gets sustained coverage rather than getting stuck on `HmacPresenceMismatch`/`BadHmac` every time. - `decode_echo_reply` derives a valid, bounded `Params` from a small control prefix of the fuzz input (received_stats/stamp_at/clock enum, a negotiated-length vocabulary around header boundaries up to 65507, and an HMAC toggle), builds a packet of the negotiated length from the remaining fuzz bytes (also exercising the upstream single-clock midpoint compatibility length), and asserts a successful decode's payload length never exceeds the packet length. The raw fuzz bytes are also decoded unmodified against the same Params for the plain no-panic invariant. - `decode_params` calls both `Params::decode` and `Params::decode_with_presence` (which share one parser) and asserts they agree; on success it re-encodes and re-decodes to assert semantic (not byte-for-byte) round-trip equality. - `decode_varint` calls `decode_uvarint`/`decode_varint`, asserts `used` bounds (`>0`, `<= data.len()`, `<= 10`), and asserts a canonical re-encode/decode round trip, without requiring noncanonical fuzz input to reproduce itself. ## Isolation - `fuzz/Cargo.toml` declares its own `[workspace]`, so `fuzz/` is never a member of the root workspace in `Cargo.toml`. - `libfuzzer-sys`/`arbitrary` are fuzz-only dependencies that never enter any published crate's dependency graph; the root `Cargo.lock` is untouched (verified via `git status`/`git diff` — zero root-lockfile churn). - The normal stable/MSRV workspace is unaffected: `cargo check/test/clippy/doc --workspace` all pass unchanged, and `fuzz/` requires nightly only for its own isolated workspace. - No protocol/production code changed; this PR is fuzzing infrastructure and seed corpora only. ## Verification - cargo-fuzz 0.13.1, nightly toolchain (`cargo 1.99.0-nightly`). - Local smoke run per target, ~40s each, `-timeout=5`: - `decode_request`: 8,393,950 exec in 41s — no crashes - `decode_open_reply`: 4,676,635 exec in 41s — no crashes - `decode_echo_reply`: 3,325,893 exec in 41s — no crashes - `decode_params`: 9,887,763 exec in 41s — no crashes - `decode_varint`: 20,614,310 exec in 41s — no crashes - No crashes/panics found in any target. - Normal workspace: `cargo fmt --all -- --check`, `cargo check/test/clippy --workspace --all-targets --all-features --locked`, `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked`, and `cargo check -p irtt-proto --locked` all pass. - CI: `.github/workflows/fuzz.yml` builds all five targets once, then runs each for `-max_total_time=10 -timeout=5` sequentially in one job, on `pull_request`/`push` to `main` and `workflow_dispatch`.
Sign in to join this conversation.