Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions .github/workflows/fuzz-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@
workflow_dispatch:
inputs:
fuzz_seconds:
description: "Seconds per fuzz target"
description: "Seconds per fuzz target (time-based; ignored when fuzz_runs is set)"
required: false
default: "1800"
fuzz_runs:
description: "Iteration count per fuzz target (-runs=N). When set, overrides fuzz_seconds."
required: false
default: ""

env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
FUZZ_SECONDS: ${{ github.event.inputs.fuzz_seconds || '1800' }}
FUZZ_RUNS: ${{ github.event.inputs.fuzz_runs || '' }}

jobs:
fuzz:
Expand Down Expand Up @@ -94,10 +99,17 @@
working-directory: contracts
run: |
mkdir -p fuzz/artifacts/${{ matrix.target }}
if [ -n "${FUZZ_RUNS}" ]; then
LIMIT_FLAG="-runs=${FUZZ_RUNS}"
echo "Running ${FUZZ_RUNS} iterations (iteration-based)"
else
LIMIT_FLAG="-max_total_time=${FUZZ_SECONDS}"
echo "Running for ${FUZZ_SECONDS}s (time-based)"
fi
cargo +nightly fuzz run "${{ matrix.target }}" \
"fuzz/corpus/${{ matrix.target }}" \
-- \
-max_total_time="${FUZZ_SECONDS}" \
${LIMIT_FLAG} \
-artifact_prefix="fuzz/artifacts/${{ matrix.target }}/" \
-print_final_stats=1

Expand All @@ -110,3 +122,35 @@
contracts/fuzz/corpus/${{ matrix.target }}
contracts/fuzz/artifacts/${{ matrix.target }}
if-no-files-found: ignore

# Stable-toolchain regression replay: deterministically re-runs every crash
# artifact under contracts/subscription/tests/regressions/ plus a seeded
# in-process fuzz smoke. Runs on PRs without requiring nightly/libfuzzer.
regression-replay:
name: regression replay (stable)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
FUZZ_REGRESSION_ITERATIONS: "128"
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Cache cargo
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
contracts/target
key: ${{ runner.os }}-cargo-fuzz-regression-${{ hashFiles('contracts/**/Cargo.lock', 'contracts/**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-fuzz-

- name: Replay crash artifacts and seeded fuzz smoke
run: |
cargo test --manifest-path contracts/subscription/Cargo.toml \
--test fuzz_regressions -- --nocapture
57 changes: 55 additions & 2 deletions contracts/fuzz/FUZZING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,65 @@ When a crash is found:
4. **Add regression test**: Copy the minimized input to
`.github/corpus/<target>/` so the corpus cache prevents regression.

## Configurable Iterations

Fuzz runs can be driven by **time** or by **iteration count**:

```bash
# Time-based (default): run each target for N seconds
cargo fuzz run subscription_lifecycle -- -max_total_time=1800

# Iteration-based: run exactly N inputs
cargo fuzz run subscription_lifecycle -- -runs=100000
```

In CI (`.github/workflows/fuzz-test.yml`), the `workflow_dispatch` trigger
exposes both:

- `fuzz_seconds` — seconds per target (time-based; default `1800`).
- `fuzz_runs` — iteration count per target (`-runs=N`). When set, it
overrides `fuzz_seconds` so a run can be bounded by input count.

## Regression Replay (failures reproducible)

Crash artifacts found by `cargo-fuzz` are promoted into deterministic
`cargo test` cases via the **regression replay harness** at
`contracts/subscription/tests/fuzz_regressions.rs`. This runs on the
**stable** toolchain — no nightly/libfuzzer required.

### Triage → permanent regression guard

1. `contracts/fuzz/scripts/triage-crash.sh <target> <crash-file>` copies a
crash into `contracts/subscription/tests/regressions/<target>_<sha>.bin`.
2. `cargo test --test fuzz_regressions` replays every `.bin` in that
directory. A previously-crashing input that still crashes fails the
test, so a fixed bug can never silently regress.

### Seeded in-process fuzz smoke

The harness also runs a deterministic, seeded in-process fuzzer whose
iteration count is configurable via `FUZZ_REGRESSION_ITERATIONS`
(default `64`):

```bash
# default 64 iterations
cargo test --manifest-path contracts/subscription/Cargo.toml --test fuzz_regressions

# heavier smoke
FUZZ_REGRESSION_ITERATIONS=1024 cargo test --test fuzz_regressions -- --nocapture
```

This gives a fast, reproducible fuzz smoke that runs in CI on every PR
(see the `regression-replay` job).

## CI Pipeline

The workflow in `.github/workflows/fuzz-test.yml`:

- Runs all 4 targets **in parallel** via a build matrix
- Each target runs for **30 minutes** with AddressSanitizer
- Runs the coverage-guided targets **in parallel** via a build matrix
(nightly + AddressSanitizer), bounded by `fuzz_seconds` or `fuzz_runs`
- Runs a **stable-toolchain regression-replay** job that replays all
crash artifacts and the seeded fuzz smoke on every PR
- Crash artifacts are uploaded and kept for 14 days
- Seed corpus is cached between runs for coverage continuity
- Runs on every push/PR touching contracts and weekly on Monday
Expand Down
Loading
Loading