From 719073df1c11a19c4df20d5f4f7a9ade242385d6 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Wed, 19 Aug 2026 23:33:27 +0700 Subject: [PATCH 1/2] test(proto): add decoder fuzzing Add isolated cargo-fuzz/libFuzzer infrastructure for irtt-proto, fuzzing decode_request, decode_open_reply, decode_echo_reply, Params::decode(_with_presence), and the varint codec against arbitrary and structurally-derived wire input. Each target's core invariant is that arbitrary bytes must never panic; a small clean-room seed corpus (generated from irtt-proto's own encoders) and a bounded nightly smoke CI workflow are included. The fuzz project is its own isolated cargo workspace and does not affect the root workspace, its lockfile, or any published crate's dependency graph. --- .github/workflows/fuzz.yml | 59 +++++ fuzz/.gitignore | 10 + fuzz/Cargo.lock | 246 ++++++++++++++++++ fuzz/Cargo.toml | 53 ++++ fuzz/README.md | 77 ++++++ fuzz/corpus/decode_echo_reply/hmac_variant | Bin 0 -> 100 bytes .../corpus/decode_echo_reply/midpoint_variant | Bin 0 -> 24 bytes fuzz/corpus/decode_echo_reply/ordinary | Bin 0 -> 100 bytes .../decode_echo_reply/recv_count_variant | Bin 0 -> 20 bytes .../decode_echo_reply/recv_window_variant | Bin 0 -> 24 bytes fuzz/corpus/decode_open_reply/hmac_reply | 1 + .../decode_open_reply/no_test_style_reply | Bin 0 -> 32 bytes fuzz/corpus/decode_open_reply/normal_reply | 1 + fuzz/corpus/decode_params/empty | 0 fuzz/corpus/decode_params/normal | 1 + fuzz/corpus/decode_params/repeated_known_tag | 1 + fuzz/corpus/decode_params/server_fill | 1 + fuzz/corpus/decode_params/unknown_tag | 1 + fuzz/corpus/decode_request/close | 1 + fuzz/corpus/decode_request/echo | Bin 0 -> 100 bytes fuzz/corpus/decode_request/hmac_echo | Bin 0 -> 100 bytes fuzz/corpus/decode_request/hmac_open | 1 + fuzz/corpus/decode_request/minimal_open | 1 + fuzz/corpus/decode_request/no_test_open | 1 + fuzz/fuzz_targets/decode_echo_reply.rs | 133 ++++++++++ fuzz/fuzz_targets/decode_open_reply.rs | 49 ++++ fuzz/fuzz_targets/decode_params.rs | 46 ++++ fuzz/fuzz_targets/decode_request.rs | 45 ++++ fuzz/fuzz_targets/decode_varint.rs | 38 +++ 29 files changed, 766 insertions(+) create mode 100644 .github/workflows/fuzz.yml create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/corpus/decode_echo_reply/hmac_variant create mode 100644 fuzz/corpus/decode_echo_reply/midpoint_variant create mode 100644 fuzz/corpus/decode_echo_reply/ordinary create mode 100644 fuzz/corpus/decode_echo_reply/recv_count_variant create mode 100644 fuzz/corpus/decode_echo_reply/recv_window_variant create mode 100644 fuzz/corpus/decode_open_reply/hmac_reply create mode 100644 fuzz/corpus/decode_open_reply/no_test_style_reply create mode 100644 fuzz/corpus/decode_open_reply/normal_reply create mode 100644 fuzz/corpus/decode_params/empty create mode 100644 fuzz/corpus/decode_params/normal create mode 100644 fuzz/corpus/decode_params/repeated_known_tag create mode 100644 fuzz/corpus/decode_params/server_fill create mode 100644 fuzz/corpus/decode_params/unknown_tag create mode 100644 fuzz/corpus/decode_request/close create mode 100644 fuzz/corpus/decode_request/echo create mode 100644 fuzz/corpus/decode_request/hmac_echo create mode 100644 fuzz/corpus/decode_request/hmac_open create mode 100644 fuzz/corpus/decode_request/minimal_open create mode 100644 fuzz/corpus/decode_request/no_test_open create mode 100644 fuzz/fuzz_targets/decode_echo_reply.rs create mode 100644 fuzz/fuzz_targets/decode_open_reply.rs create mode 100644 fuzz/fuzz_targets/decode_params.rs create mode 100644 fuzz/fuzz_targets/decode_request.rs create mode 100644 fuzz/fuzz_targets/decode_varint.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..139ec1a --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,59 @@ +name: Proto Fuzz Smoke + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`. + CARGO_FUZZ_VERSION: 0.13.1 + +jobs: + fuzz-smoke: + name: cargo fuzz smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install nightly Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + with: + toolchain: nightly + cache-key: fuzz + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}" + + - name: Build all fuzz targets + run: cargo +nightly fuzz build + working-directory: fuzz + + - name: Run bounded fuzz smoke for every target + # A single job building once and running each target sequentially + # avoids recompiling the fuzz workspace per target. Ten seconds per + # target is enough to catch a broken harness or an obvious + # regression; this is not meant to be exhaustive fuzzing. + run: | + set -euo pipefail + for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do + echo "::group::fuzz smoke: ${target}" + cargo +nightly fuzz run "${target}" -- \ + -max_total_time=10 \ + -timeout=5 + echo "::endgroup::" + done + working-directory: fuzz diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..ce0e380 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,10 @@ +target +artifacts +coverage + +# The committed seed corpus under corpus/ is small and curated by hand; a +# local `cargo fuzz run` grows each target's corpus directory far beyond +# that, so ignore anything not already tracked instead of the directory +# itself. +corpus/*/* +!corpus/*/ diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..8cbb037 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,246 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "irtt-proto" +version = "0.5.0" +dependencies = [ + "hmac", + "md-5", + "subtle", + "thiserror", +] + +[[package]] +name = "irtt-proto-fuzz" +version = "0.0.0" +dependencies = [ + "irtt-proto", + "libfuzzer-sys", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..56ee3fc --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "irtt-proto-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.irtt-proto] +path = "../crates/irtt-proto" + +# Use independent workspace for fuzzers +[workspace] +members = ["."] + +[[bin]] +name = "decode_request" +path = "fuzz_targets/decode_request.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_open_reply" +path = "fuzz_targets/decode_open_reply.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_echo_reply" +path = "fuzz_targets/decode_echo_reply.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_params" +path = "fuzz_targets/decode_params.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_varint" +path = "fuzz_targets/decode_varint.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..2692edf --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,77 @@ +# `irtt-proto` fuzzing + +Coverage-guided fuzzing of `irtt-proto`'s wire decoders via +[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer. + +This project is intentionally isolated from the normal stable/MSRV +workspace: it lives in its own `[workspace]` under `fuzz/`, is never a member +of the root workspace, requires nightly Rust, and is not built by +`cargo check`/`test`/`clippy`/`doc` at the workspace root. + +## Prerequisites + +- nightly Rust (`rustup toolchain install nightly`) +- `cargo-fuzz` (`cargo install cargo-fuzz --locked --version 0.13.1`) + +## Targets + +| Target | Exercises | +| ------------------- | ----------------------------------------------------------------- | +| `decode_request` | `decode_request`, `Params::decode` on a decoded Open, `verify_packet_hmac` | +| `decode_open_reply` | `decode_open_reply`, unauthenticated and HMAC-authenticated | +| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated | +| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip | +| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` | + +The invariant every target asserts is: **arbitrary bytes must never panic**. +Returning `Err` is normal and expected; a successful decode is also normal. + +Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so +libFuzzer spends its time exploring protocol structure rather than +allocating/copying megabytes — production IRTT datagrams are UDP-sized, well +under this bound. + +## Running + +```sh +cargo +nightly fuzz run decode_request +``` + +Bounded smoke run, e.g. 30 seconds: + +```sh +cargo +nightly fuzz run decode_request -- -max_total_time=30 +``` + +Build every target without running: + +```sh +cargo +nightly fuzz build +``` + +## Corpus + +Seed corpora live under `fuzz/corpus//`. They were generated once +from `irtt-proto`'s own public encoders (`encode_request`, `encode_open_reply`, +`encode_echo_reply`, `Params::encode`) by a throwaway local script, not copied +from any external source, and not derived from upstream `heistp/irtt`. Each +file is a small, representative wire packet (minimal Open, Close, Echo, HMAC +variants, ordinary/recv-count/recv-window/midpoint Echo replies, and a few +`Params` payloads including an unknown tag and `server_fill`). + +## Crash artifacts + +A failing case is written under `fuzz/artifacts//`. To replay it: + +```sh +cargo +nightly fuzz run decode_request fuzz/artifacts/decode_request/ +``` + +## Isolation notes + +- `fuzz/Cargo.toml` declares its own `[workspace]`, so it is never picked up + by the root `[workspace]` in `../Cargo.toml`. +- `libfuzzer-sys` and `arbitrary` are fuzz-only dependencies; they never + appear in any published crate's dependency graph. +- `fuzz/Cargo.lock` is committed for reproducible fuzzing builds, independent + of the root `Cargo.lock`. diff --git a/fuzz/corpus/decode_echo_reply/hmac_variant b/fuzz/corpus/decode_echo_reply/hmac_variant new file mode 100644 index 0000000000000000000000000000000000000000..55f67a2147f6a11cf921317638873f5236f27439 GIT binary patch literal 100 zcmWex9?jLd)#AAQ>oafGr2hE3s7SF=IH<6F^|omhObiSR%s|Wz#2~;3p%_5oAOMmB Q0Tu|wP>`RO4rQWH03byWh5!Hn literal 0 HcmV?d00001 diff --git a/fuzz/corpus/decode_echo_reply/midpoint_variant b/fuzz/corpus/decode_echo_reply/midpoint_variant new file mode 100644 index 0000000000000000000000000000000000000000..3afa5c543db3adbb1c00d416ddbd55efa99bf60f GIT binary patch literal 24 TcmWex9?ita00AJn8p;L$9PI&? literal 0 HcmV?d00001 diff --git a/fuzz/corpus/decode_echo_reply/ordinary b/fuzz/corpus/decode_echo_reply/ordinary new file mode 100644 index 0000000000000000000000000000000000000000..0bf3fa4e123254d7a2c34e8786ed67438cf0e33c GIT binary patch literal 100 zcmWex9?c{iRM@_H+q4QM1_lOZAZ7<*5MYE*3?Ok30Lg&>3xr}Q$j?iMGVxLX5Znf@ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/decode_echo_reply/recv_count_variant b/fuzz/corpus/decode_echo_reply/recv_count_variant new file mode 100644 index 0000000000000000000000000000000000000000..a5f2b9ab3fa05f163f037afbf1f0cc06d7ca1b54 GIT binary patch literal 20 ScmWex9?ita00AJH6Nmv6%>g6; literal 0 HcmV?d00001 diff --git a/fuzz/corpus/decode_echo_reply/recv_window_variant b/fuzz/corpus/decode_echo_reply/recv_window_variant new file mode 100644 index 0000000000000000000000000000000000000000..d81c790129a96f3e93b395bca6ed565da3098777 GIT binary patch literal 24 TcmWex9?ita00AIc3(5un8f5`R literal 0 HcmV?d00001 diff --git a/fuzz/corpus/decode_open_reply/hmac_reply b/fuzz/corpus/decode_open_reply/hmac_reply new file mode 100644 index 0000000..82d2341 --- /dev/null +++ b/fuzz/corpus/decode_open_reply/hmac_reply @@ -0,0 +1 @@ +§[ u9ü;dº%Íý¶Ä¢•oRq‡«¶–x€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/corpus/decode_open_reply/no_test_style_reply b/fuzz/corpus/decode_open_reply/no_test_style_reply new file mode 100644 index 0000000000000000000000000000000000000000..43df18427da19acd68bdc761796680256ed13977 GIT binary patch literal 32 icmWex9?i}G1&mBg4L_RJiZM5=xVDp>m5q&!oecnI3kJmi literal 0 HcmV?d00001 diff --git a/fuzz/corpus/decode_open_reply/normal_reply b/fuzz/corpus/decode_open_reply/normal_reply new file mode 100644 index 0000000..61378eb --- /dev/null +++ b/fuzz/corpus/decode_open_reply/normal_reply @@ -0,0 +1 @@ +§[Rq‡«¶–x€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/corpus/decode_params/empty b/fuzz/corpus/decode_params/empty new file mode 100644 index 0000000..e69de29 diff --git a/fuzz/corpus/decode_params/normal b/fuzz/corpus/decode_params/normal new file mode 100644 index 0000000..6d84b7f --- /dev/null +++ b/fuzz/corpus/decode_params/normal @@ -0,0 +1 @@ +€ø‚­€¨Ö¹ \ No newline at end of file diff --git a/fuzz/corpus/decode_params/repeated_known_tag b/fuzz/corpus/decode_params/repeated_known_tag new file mode 100644 index 0000000..024b4fa --- /dev/null +++ b/fuzz/corpus/decode_params/repeated_known_tag @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/corpus/decode_params/server_fill b/fuzz/corpus/decode_params/server_fill new file mode 100644 index 0000000..bfa93ac --- /dev/null +++ b/fuzz/corpus/decode_params/server_fill @@ -0,0 +1 @@ + pattern:abc \ No newline at end of file diff --git a/fuzz/corpus/decode_params/unknown_tag b/fuzz/corpus/decode_params/unknown_tag new file mode 100644 index 0000000..723f66f --- /dev/null +++ b/fuzz/corpus/decode_params/unknown_tag @@ -0,0 +1 @@ +cö \ No newline at end of file diff --git a/fuzz/corpus/decode_request/close b/fuzz/corpus/decode_request/close new file mode 100644 index 0000000..e71e93d --- /dev/null +++ b/fuzz/corpus/decode_request/close @@ -0,0 +1 @@ +§[ˆwfUD3" \ No newline at end of file diff --git a/fuzz/corpus/decode_request/echo b/fuzz/corpus/decode_request/echo new file mode 100644 index 0000000000000000000000000000000000000000..95304bc294bdbd44b0a284496327b10dce205160 GIT binary patch literal 100 ecmWex9?j5Eo)+q2tR%?JKpH40%1=rqLJI($xCNR3 literal 0 HcmV?d00001 diff --git a/fuzz/corpus/decode_request/hmac_echo b/fuzz/corpus/decode_request/hmac_echo new file mode 100644 index 0000000000000000000000000000000000000000..69df4307caa88c0e7c995e9f7f26a4204261976b GIT binary patch literal 100 vcmWex9?fyy^qAF>G_~(B* ReceivedStats { + match v % 4 { + 0 => ReceivedStats::None, + 1 => ReceivedStats::Count, + 2 => ReceivedStats::Window, + _ => ReceivedStats::Both, + } +} + +fn pick_stamp_at(v: u8) -> StampAt { + match v % 5 { + 0 => StampAt::None, + 1 => StampAt::Send, + 2 => StampAt::Receive, + 3 => StampAt::Both, + _ => StampAt::Midpoint, + } +} + +fn pick_clock(v: u8) -> Clock { + match v % 4 { + 0 => Clock::Unspecified, + 1 => Clock::Wall, + 2 => Clock::Monotonic, + _ => Clock::Both, + } +} + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + // Need enough control bytes to derive Params before any packet bytes. + const CONTROL_LEN: usize = 6; + if data.len() < CONTROL_LEN { + return; + } + let (control, rest) = data.split_at(CONTROL_LEN); + + let received_stats = pick_received_stats(control[0]); + let stamp_at = pick_stamp_at(control[1]); + let clock = pick_clock(control[2]); + let length_bucket = LENGTH_BUCKETS[control[3] as usize % LENGTH_BUCKETS.len()]; + let hmac_present = control[4] & 1 == 1; + let use_compat_len = control[5] & 1 == 1; + + let params = Params { + protocol_version: 1, + length: length_bucket, + received_stats, + stamp_at, + clock, + ..Params::default() + }; + + let Ok(normal_len) = echo_packet_len(hmac_present, ¶ms) else { + return; + }; + + let layout = PacketLayout::echo(hmac_present, ¶ms); + let is_single_clock_midpoint = + stamp_at == StampAt::Midpoint && matches!(clock, Clock::Wall | Clock::Monotonic); + let target_len = if use_compat_len && is_single_clock_midpoint { + (layout.header_len() + TIMESTAMP_SIZE).max(normal_len) + } else { + normal_len + }; + + let flags = FLAG_REPLY | if hmac_present { FLAG_HMAC } else { 0 }; + let mut packet = Vec::with_capacity(target_len); + packet.extend_from_slice(&irtt_proto::MAGIC); + packet.push(flags); + if hmac_present { + packet.extend_from_slice(&[0u8; 16]); + } + // Fill the remainder of the negotiated length from the fuzz-provided + // tail, truncating or zero-padding to hit the target length exactly so + // the decoder's field parsing gets real coverage rather than bailing out + // on a length mismatch every time. + let body_len = target_len.saturating_sub(packet.len()); + if rest.len() >= body_len { + packet.extend_from_slice(&rest[..body_len]); + } else { + packet.extend_from_slice(rest); + packet.resize(target_len, 0); + } + packet.resize(target_len, 0); + + if hmac_present { + if compute_hmac_in_place(FUZZ_HMAC_KEY, &mut packet, HMAC_OFFSET).is_err() { + return; + } + let reply = decode_echo_reply(&packet, ¶ms, Some(FUZZ_HMAC_KEY)); + if let Ok(reply) = reply { + assert!(reply.payload.len() <= packet.len()); + } + } else { + let reply = decode_echo_reply(&packet, ¶ms, None); + if let Ok(reply) = reply { + assert!(reply.payload.len() <= packet.len()); + } + } + + // Also exercise the raw fuzz bytes directly against the same negotiated + // Params, unmodified and un-lengthened, purely for the no-panic + // invariant on genuinely arbitrary bytes. + let _ = decode_echo_reply(data, ¶ms, None); +}); diff --git a/fuzz/fuzz_targets/decode_open_reply.rs b/fuzz/fuzz_targets/decode_open_reply.rs new file mode 100644 index 0000000..116903a --- /dev/null +++ b/fuzz/fuzz_targets/decode_open_reply.rs @@ -0,0 +1,49 @@ +#![no_main] + +use irtt_proto::{ + decode_open_reply, hmac::compute_hmac_in_place, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY, +}; +use libfuzzer_sys::fuzz_target; + +/// See `decode_request.rs` for the rationale behind this bound. +const MAX_INPUT_LEN: usize = 128 * 1024; + +/// Offset of the HMAC field, right after the 4-byte header. `irtt-proto` +/// keeps the real constant crate-private; this mirrors it the same way the +/// crate's own external integration tests do. +const HMAC_OFFSET: usize = 4; +const HMAC_SIZE: usize = 16; + +const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-open-reply-key"; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + // Unauthenticated decode path: no panic is the invariant, Ok/Err both + // acceptable. + let _ = decode_open_reply(data, None); + + // Authenticated decode path against arbitrary bytes. Most inputs will + // fail on HMAC presence/verification before reaching the post-HMAC + // parser, which is expected and fine on its own. + let _ = decode_open_reply(data, Some(FUZZ_HMAC_KEY)); + + // To also give the post-HMAC parser sustained coverage, derive a packet + // that *will* authenticate: when the input is structurally capable of + // carrying an HMAC field and its flags already indicate HMAC, patch in a + // freshly-computed HMAC over the untouched body via the crate's own + // helper (never reimplemented here) and decode that. + if data.len() >= HMAC_OFFSET + HMAC_SIZE { + let flags = data[3]; + let looks_like_open_reply = + flags & FLAG_OPEN != 0 && flags & FLAG_REPLY != 0 && flags & FLAG_HMAC != 0; + if looks_like_open_reply { + let mut signed = data.to_vec(); + if compute_hmac_in_place(FUZZ_HMAC_KEY, &mut signed, HMAC_OFFSET).is_ok() { + let _ = decode_open_reply(&signed, Some(FUZZ_HMAC_KEY)); + } + } + } +}); diff --git a/fuzz/fuzz_targets/decode_params.rs b/fuzz/fuzz_targets/decode_params.rs new file mode 100644 index 0000000..5bba854 --- /dev/null +++ b/fuzz/fuzz_targets/decode_params.rs @@ -0,0 +1,46 @@ +#![no_main] + +use irtt_proto::Params; +use libfuzzer_sys::fuzz_target; + +/// See `decode_request.rs` for the rationale behind this bound. +const MAX_INPUT_LEN: usize = 128 * 1024; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + let decoded = Params::decode(data); + let decoded_with_presence = Params::decode_with_presence(data); + + // Both entry points share one parser, so when both succeed they must + // agree on the decoded values. + match (&decoded, &decoded_with_presence) { + (Ok(params), Ok(with_presence)) => { + assert_eq!(params, &with_presence.params); + } + (Ok(_), Err(_)) | (Err(_), Ok(_)) => { + panic!("decode and decode_with_presence disagreed on success for {data:02x?}"); + } + (Err(_), Err(_)) => {} + } + + // No panic is the primary invariant for malformed input; unknown tags are + // tolerated and repeated known tags are last-value-wins by design, so we + // do not assert a particular failure mode here. + if let Ok(params) = decoded { + let encoded = params.encode(); + // Re-encoding and decoding again must preserve the semantic value. + // Presence information does not round-trip through `encode` for + // absent/default fields, so we compare `Params` values, not the + // original wire bytes. + if let Ok(reencoded_params) = Params::decode(&encoded) { + assert_eq!(params, reencoded_params); + } else { + panic!( + "re-encoding a successfully decoded Params produced bytes that failed to decode" + ); + } + } +}); diff --git a/fuzz/fuzz_targets/decode_request.rs b/fuzz/fuzz_targets/decode_request.rs new file mode 100644 index 0000000..244b968 --- /dev/null +++ b/fuzz/fuzz_targets/decode_request.rs @@ -0,0 +1,45 @@ +#![no_main] + +use irtt_proto::{decode_request, verify_packet_hmac, DecodedRequestKind, Params}; +use libfuzzer_sys::fuzz_target; + +/// Keeps individual cases bounded so libFuzzer explores protocol structure +/// instead of spending time allocating/copying megabytes. This is UDP wire +/// protocol code; production datagrams are well under this bound. +const MAX_INPUT_LEN: usize = 128 * 1024; + +/// Fixed key used only to exercise the HMAC verification path. Never treated +/// as a secret. +const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-request-key"; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + // The core invariant: arbitrary bytes must never panic. Ok/Err are both + // normal outcomes. + let decoded = match decode_request(data) { + Ok(decoded) => decoded, + Err(_) => return, + }; + + match decoded.kind { + DecodedRequestKind::Open { params, .. } => { + // Structural Open decoding deliberately permits malformed + // parameter bytes; both Ok and Err from the secondary parser are + // acceptable, and a structurally decoded Open is not required to + // contain valid Params. + let _ = Params::decode(params); + } + DecodedRequestKind::Close { .. } | DecodedRequestKind::Echo { .. } => { + // No token/session policy belongs here; that's above irtt-proto. + } + } + + if decoded.hmac_present { + // Most arbitrary HMAC fields will fail authentication; both outcomes + // are acceptable, we're only exercising the verifier for panics. + let _ = verify_packet_hmac(FUZZ_HMAC_KEY, data); + } +}); diff --git a/fuzz/fuzz_targets/decode_varint.rs b/fuzz/fuzz_targets/decode_varint.rs new file mode 100644 index 0000000..014422b --- /dev/null +++ b/fuzz/fuzz_targets/decode_varint.rs @@ -0,0 +1,38 @@ +#![no_main] + +use irtt_proto::varint::{decode_uvarint, decode_varint, encode_uvarint, encode_varint}; +use libfuzzer_sys::fuzz_target; + +/// See `decode_request.rs` for the rationale behind this bound. Varint +/// grammar is trivial, but the bound is kept for consistency with the other +/// targets. +const MAX_INPUT_LEN: usize = 128 * 1024; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + if let Ok((value, used)) = decode_uvarint(data) { + assert!(used > 0); + assert!(used <= data.len()); + assert!(used <= 10); + + let mut encoded = Vec::new(); + encode_uvarint(value, &mut encoded); + // Canonical re-encoding need not reproduce noncanonical fuzz input, + // but it must decode back to the same value and consume exactly its + // own bytes. + assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len()))); + } + + if let Ok((value, used)) = decode_varint(data) { + assert!(used > 0); + assert!(used <= data.len()); + assert!(used <= 10); + + let mut encoded = Vec::new(); + encode_varint(value, &mut encoded); + assert_eq!(decode_varint(&encoded), Ok((value, encoded.len()))); + } +}); -- 2.54.0 From 5ce5f4fcda2a83d7bd18a85c84e93894677e4cdb Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Wed, 19 Aug 2026 23:36:08 +0700 Subject: [PATCH 2/2] fix(fuzz-ci): build cargo-fuzz with stable, not the nightly override Installing cargo-fuzz under a nightly directory override made rustix's build script (a cargo-fuzz dependency) gate on `rustc_attrs`, which this runner's nightly rejects as reserved-for-rustc. Install nightly without overriding the directory default so `cargo install cargo-fuzz` still builds under stable; only `fuzz build`/`fuzz run` opt into `+nightly`. --- .github/workflows/fuzz.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 139ec1a..ac6adaa 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -29,12 +29,20 @@ jobs: with: persist-credentials: false - - name: Install nightly Rust toolchain + - name: Install stable Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: - toolchain: nightly cache-key: fuzz + - name: Install nightly Rust toolchain + # Installed alongside, but deliberately not made the directory + # default: cargo-fuzz itself is built with the default stable + # toolchain below, and only `fuzz build`/`fuzz run` are run with + # `+nightly`. Building cargo-fuzz's own dependency tree under a + # nightly override can trip build scripts (e.g. rustix) that gate + # unstable internal attributes on detecting a nightly compiler. + run: rustup toolchain install nightly --profile minimal --no-self-update + - name: Install cargo-fuzz run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}" -- 2.54.0