From 6b4ed5a5fed8365fbdf646da08ba215fa5490853 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Tue, 22 Sep 2026 16:16:09 +0100 Subject: [PATCH] test(ci): add a scheduled fuzz smoke job Add a weekly (and manually dispatchable) workflow that discovers every fuzz target, runs each for a bounded time on a nightly toolchain, fails on crash artifacts, and uploads them. Add the fuzz/ cargo-fuzz crate with targets for SCVal, envelope, and event decoding (satisfying the fuzz-target dependency), plus docs/TESTING.md. Closes #94 --- .github/workflows/fuzz.yml | 85 ++++++++++++++++++++++++++++ CHANGELOG.md | 2 + README.md | 1 + docs/TESTING.md | 76 +++++++++++++++++++++++++ fuzz/.gitignore | 4 ++ fuzz/Cargo.toml | 35 ++++++++++++ fuzz/fuzz_targets/decode_envelope.rs | 9 +++ fuzz/fuzz_targets/decode_event.rs | 9 +++ fuzz/fuzz_targets/decode_scval.rs | 9 +++ 9 files changed, 230 insertions(+) create mode 100644 .github/workflows/fuzz.yml create mode 100644 docs/TESTING.md create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/decode_envelope.rs create mode 100644 fuzz/fuzz_targets/decode_event.rs create mode 100644 fuzz/fuzz_targets/decode_scval.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..a6cd259 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,85 @@ +name: Fuzz + +on: + schedule: + # Weekly, Monday 03:00 UTC. Bounded runs keep normal CI fast. + - cron: "0 3 * * 1" + workflow_dispatch: + inputs: + seconds: + description: Bounded seconds to fuzz each target + required: false + default: "60" + type: string + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: false + +jobs: + smoke: + name: Fuzz smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + with: + workspaces: fuzz + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + - name: Discover fuzz targets + id: targets + run: | + set -euo pipefail + if [ ! -d fuzz/fuzz_targets ]; then + echo "::error::fuzz/fuzz_targets not found" >&2 + exit 1 + fi + targets=$(find fuzz/fuzz_targets -maxdepth 1 -name '*.rs' -printf '%f\n' \ + | sed 's/\.rs$//' | sort) + if [ -z "$targets" ]; then + echo "::error::no fuzz targets found in fuzz/fuzz_targets" >&2 + exit 1 + fi + { + echo "targets<> "$GITHUB_OUTPUT" + echo "Discovered targets:" + echo "$targets" + - name: Run each target (bounded) + env: + TARGETS: ${{ steps.targets.outputs.targets }} + SECONDS_PER_TARGET: ${{ github.event.inputs.seconds || '60' }} + run: | + set -euo pipefail + status=0 + while IFS= read -r target; do + [ -z "$target" ] && continue + echo "::group::fuzz $target" + cargo +nightly fuzz run "$target" -- -max_total_time="$SECONDS_PER_TARGET" \ + || status=1 + echo "::endgroup::" + done <<< "$TARGETS" + exit "$status" + - name: Fail on crash artifacts + if: success() + run: | + set -euo pipefail + if [ -n "$(find fuzz/artifacts -type f 2>/dev/null)" ]; then + echo "::error::fuzz produced crash artifacts" >&2 + find fuzz/artifacts -type f + exit 1 + fi + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashes + path: fuzz/artifacts + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6767d..742b3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,5 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation: ecosystem research, architecture, getting started, CLI reference, project status, roadmap. - CI: format, clippy, and tests on Linux, Windows, and macOS. +- Fuzzing: `fuzz/` cargo-fuzz targets for XDR/SCVal decoding, exercised by a + weekly bounded scheduled workflow (see `docs/TESTING.md`). - Governance: README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, LICENSE, pull-request and issue templates. diff --git a/README.md b/README.md index d2e9968..19a63dc 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Exit codes: `0` success, `2` usage error, `3` runtime/decode error. - [Architecture](docs/ARCHITECTURE.md) - [Getting started](docs/GETTING_STARTED.md) - [CLI reference](docs/CLI.md) +- [Testing guide](docs/TESTING.md) - [Project status](docs/PROJECT_STATUS.md) - [Roadmap](docs/ROADMAP.md) - [Contributing through Drips Wave](docs/WAVE.md) diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..1a3b624 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,76 @@ +# Testing guide + +## The local gate + +Run the same checks as CI before opening a pull request: + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all +``` + +Unit and integration tests live next to the code (`#[cfg(test)]` modules) and in +`tests/`. Decoding changes must include malformed-input tests. + +## Fuzzing + +Fuzz targets live in the standalone `fuzz/` crate (it is not part of the main +workspace). Targets exercise the untrusted-input decoders: + +| Target | Entry point | +| ------ | ----------- | +| `decode_scval` | `devkit_xdr::inspect_scval_base64` | +| `decode_envelope` | `devkit_xdr::inspect_envelope_base64` | +| `decode_event` | `devkit_xdr::inspect_event_base64` | + +Fuzzing requires a nightly toolchain and `cargo-fuzz`: + +```bash +cargo install cargo-fuzz +cd fuzz +cargo +nightly fuzz run decode_scval -- -max_total_time=60 +``` + +Targets must not touch the filesystem and must not panic on arbitrary input: +every failure should be a structured `DevkitError`. + +### Adding a target + +1. Add `fuzz/fuzz_targets/.rs` using the `libfuzzer_sys::fuzz_target!` + macro. +2. Register a `[[bin]]` entry for it in `fuzz/Cargo.toml`. +3. The scheduled job discovers targets automatically; no workflow change needed. + +## Scheduled fuzz smoke job + +`.github/workflows/fuzz.yml` runs weekly (Mondays at 03:00 UTC) and on demand via +`workflow_dispatch`. It: + +1. Discovers every target under `fuzz/fuzz_targets/`. +2. Runs each target for a bounded time (`60` seconds by default, overridable + from the dispatch input). +3. Fails if any target crashes or leaves a crash artifact in `fuzz/artifacts`. +4. Uploads `fuzz/artifacts` as the `fuzz-crashes` artifact when a run fails. + +Runs are intentionally short so they never slow normal pull-request CI. + +### Pinning the nightly + +The workflow uses `dtolnay/rust-toolchain@nightly`. To pin a specific nightly, +change that reference to a dated toolchain, for example: + +```yaml +- uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-01-01 +``` + +## Reproducing a scheduled failure + +Download the `fuzz-crashes` artifact, then re-run the offending input: + +```bash +cd fuzz +cargo +nightly fuzz run decode_scval artifacts/decode_scval/ +``` diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..d543a03 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "devkit-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +devkit-xdr = { path = "../crates/devkit-xdr" } + +[[bin]] +name = "decode_scval" +path = "fuzz_targets/decode_scval.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_envelope" +path = "fuzz_targets/decode_envelope.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_event" +path = "fuzz_targets/decode_event.rs" +test = false +doc = false +bench = false + +[workspace] diff --git a/fuzz/fuzz_targets/decode_envelope.rs b/fuzz/fuzz_targets/decode_envelope.rs new file mode 100644 index 0000000..720da42 --- /dev/null +++ b/fuzz/fuzz_targets/decode_envelope.rs @@ -0,0 +1,9 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(input) = std::str::from_utf8(data) { + let _ = devkit_xdr::inspect_envelope_base64(input); + } +}); diff --git a/fuzz/fuzz_targets/decode_event.rs b/fuzz/fuzz_targets/decode_event.rs new file mode 100644 index 0000000..784e56f --- /dev/null +++ b/fuzz/fuzz_targets/decode_event.rs @@ -0,0 +1,9 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(input) = std::str::from_utf8(data) { + let _ = devkit_xdr::inspect_event_base64(input); + } +}); diff --git a/fuzz/fuzz_targets/decode_scval.rs b/fuzz/fuzz_targets/decode_scval.rs new file mode 100644 index 0000000..6f7481b --- /dev/null +++ b/fuzz/fuzz_targets/decode_scval.rs @@ -0,0 +1,9 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(input) = std::str::from_utf8(data) { + let _ = devkit_xdr::inspect_scval_base64(input); + } +});