From db477a42d0c0846ac491ce68b052167764c2cf05 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 27 May 2026 19:41:34 +0800 Subject: [PATCH 1/5] feat: apply changes from PR1 except corpus --- .github/actions/checkout-lez/action.yml | 18 + .github/workflows/fuzz-afl.yml | 387 ++++++++++++ .github/workflows/fuzz.yml | 164 ++++- .github/workflows/lint.yml | 75 +++ Cargo.toml | 1 + Justfile | 586 +++++++++++++++++- current_vs_alternative_approach.md | 10 +- docs/fuzzing.md | 329 +++++++++- fuzz/Cargo.lock | 28 + fuzz/Cargo.toml | 8 +- fuzz/fuzz_targets/_template.rs | 28 +- .../fuzz_apply_state_diff_split_path.rs | 26 +- fuzz/fuzz_targets/fuzz_block_verification.rs | 5 +- fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs | 5 +- .../fuzz_multi_block_state_sequence.rs | 26 +- .../fuzz_program_deployment_lifecycle.rs | 5 +- fuzz/fuzz_targets/fuzz_replay_prevention.rs | 32 +- .../fuzz_sequencer_vs_replayer.rs | 5 +- .../fuzz_signature_verification.rs | 5 +- .../fuzz_state_diff_computation.rs | 5 +- fuzz/fuzz_targets/fuzz_state_serialization.rs | 5 +- fuzz/fuzz_targets/fuzz_state_transition.rs | 32 +- .../fuzz_stateless_verification.rs | 5 +- .../fuzz_targets/fuzz_transaction_decoding.rs | 5 +- .../fuzz_validate_execute_consistency.rs | 24 +- .../fuzz_witness_set_verification.rs | 5 +- fuzz_props/Cargo.toml | 4 + fuzz_props/src/arbitrary_types.rs | 31 +- fuzz_props/src/generators.rs | 54 +- fuzz_props/src/invariants.rs | 210 +------ fuzz_props/src/lib.rs | 108 +++- fuzz_props/src/tests.rs | 3 + fuzz_props/src/tests/invariants.rs | 119 ++++ fuzz_props/src/tests/replay_proptest.rs | 33 + fuzz_props/src/tests/seed_gen.rs | 25 + scripts/add_fuzz_target.py | 29 +- 36 files changed, 1935 insertions(+), 505 deletions(-) create mode 100644 .github/actions/checkout-lez/action.yml create mode 100644 .github/workflows/fuzz-afl.yml create mode 100644 .github/workflows/lint.yml create mode 100644 fuzz_props/src/tests.rs create mode 100644 fuzz_props/src/tests/invariants.rs create mode 100644 fuzz_props/src/tests/replay_proptest.rs create mode 100644 fuzz_props/src/tests/seed_gen.rs diff --git a/.github/actions/checkout-lez/action.yml b/.github/actions/checkout-lez/action.yml new file mode 100644 index 0000000..435920a --- /dev/null +++ b/.github/actions/checkout-lez/action.yml @@ -0,0 +1,18 @@ +name: Checkout logos-execution-zone +description: > + Checks out logos-blockchain/logos-execution-zone into a sub-directory and + symlinks it to the expected sibling path (../logos-execution-zone) so that + Cargo path dependencies resolve correctly. + +runs: + using: composite + steps: + - name: Checkout logos-execution-zone alongside lez-fuzzing + uses: actions/checkout@v4 + with: + repository: logos-blockchain/logos-execution-zone + path: logos-execution-zone + + - name: Symlink logos-execution-zone to sibling directory + run: ln -s "$GITHUB_WORKSPACE/logos-execution-zone" "$GITHUB_WORKSPACE/../logos-execution-zone" + shell: bash diff --git a/.github/workflows/fuzz-afl.yml b/.github/workflows/fuzz-afl.yml new file mode 100644 index 0000000..542af4b --- /dev/null +++ b/.github/workflows/fuzz-afl.yml @@ -0,0 +1,387 @@ +name: AFL++ Fuzzing + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + push: + branches: [main] + +env: + RISC0_DEV_MODE: "1" + CARGO_TERM_COLOR: always + +jobs: + # ──────────────────────────────────────────────────────────────────────────── + # afl-smoke — 60-second per targets + # ──────────────────────────────────────────────────────────────────────────── + afl-smoke: + name: "AFL++ smoke — ${{ matrix.target }}" + runs-on: ubuntu-latest + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + target: + - fuzz_apply_state_diff_split_path + - fuzz_block_verification + - fuzz_encoding_roundtrip + - fuzz_multi_block_state_sequence + - fuzz_program_deployment_lifecycle + - fuzz_replay_prevention + - fuzz_sequencer_vs_replayer + - fuzz_signature_verification + - fuzz_state_diff_computation + - fuzz_state_serialization + - fuzz_state_transition + - fuzz_stateless_verification + - fuzz_transaction_decoding + - fuzz_validate_execute_consistency + - fuzz_witness_set_verification + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez + + - name: Install logos-blockchain-circuits + uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install AFL++ v4.40c from source + run: | + sudo apt-get update -q + sudo apt-get install -y \ + build-essential python3-dev automake cmake \ + flex bison libglib2.0-dev libpixman-1-dev \ + python3-setuptools ninja-build + git clone --depth 1 --branch v4.40c \ + https://github.com/AFLplusplus/AFLplusplus /tmp/aflplusplus + cd /tmp/aflplusplus + make distrib + sudo make install + afl-fuzz --version + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-afl + run: cargo install cargo-afl --locked + + - name: Build fuzz target + run: | + cargo afl build \ + --manifest-path fuzz/Cargo.toml \ + --no-default-features \ + --features fuzzer-afl \ + --release \ + --bin ${{ matrix.target }} + + - name: Prepare seed corpus + run: | + TARGET="${{ matrix.target }}" + SEEDS="afl-seeds/${TARGET}" + mkdir -p "$SEEDS" + # Merge checked-in libFuzzer corpus and accumulated AFL corpus + for src in corpus/libfuzz/${TARGET} corpus/afl/${TARGET}; do + [ -d "$src" ] || continue + for f in "$src"/*; do + [ -f "$f" ] || continue + cp -n "$f" "$SEEDS/" 2>/dev/null || true + done + done + # Guarantee at least one seed so afl-fuzz does not abort + if [ -z "$(ls -A "$SEEDS")" ]; then + echo -n "seed" > "$SEEDS/default_seed" + fi + echo "Seed inputs: $(ls "$SEEDS" | wc -l)" + + - name: Run AFL++ for 60 seconds + env: + AFL_SKIP_CPUFREQ: "1" + AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: "1" + run: | + TARGET="${{ matrix.target }}" + mkdir -p afl-output/${TARGET} + # Disable errexit so that timeout's exit code 124 (expected signal) does not + # cause bash -e to abort the script before the guard below can run. + set +e + timeout 60 \ + afl-fuzz \ + -i afl-seeds/${TARGET} \ + -o afl-output/${TARGET} \ + -- fuzz/target/release/${TARGET} + rc=$? + set -e + # 124 = SIGALRM from timeout (expected); 0 = clean exit; anything else is a real failure + [ $rc -eq 0 ] || [ $rc -eq 124 ] || exit $rc + + - name: Calculate and show edge bitmap coverage + if: always() + run: | + TARGET="${{ matrix.target }}" + MAP_SIZE=65536 + + # ── Method 1: bitmap_cvg from fuzzer_stats (written live by afl-fuzz) ── + STATS="afl-output/${TARGET}/default/fuzzer_stats" + if [ -f "$STATS" ]; then + cvg=$(grep '^bitmap_cvg' "$STATS" | awk '{print $3}') + filled_stat=$(grep '^edges_found' "$STATS" | awk '{print $3}' || echo "n/a") + else + cvg="n/a" + filled_stat="n/a" + fi + + # ── Method 2: afl-showmap union over checked-in corpus ── + CORPUS="corpus/afl/${TARGET}" + BINARY="fuzz/target/release/${TARGET}" + showmap_filled="n/a" + showmap_pct="n/a" + if [ -d "$CORPUS" ] && [ -f "$BINARY" ]; then + afl-showmap -C \ + -i "$CORPUS" \ + -o "afl-edges-${TARGET}.txt" \ + -- "$BINARY" 2>/dev/null || true + if [ -f "afl-edges-${TARGET}.txt" ]; then + showmap_filled=$(wc -l < "afl-edges-${TARGET}.txt" | tr -d ' ') + showmap_pct=$(echo "scale=2; ${showmap_filled} * 100 / ${MAP_SIZE}" | bc) + fi + fi + + # ── ASCII bitmap visualisation (64×64 grid, one cell = 1024 slots) ── + # Each of the 4096 cells represents 16 consecutive bitmap slots. + # Cell is '■' if ANY of its 16 slots is non-zero, '·' otherwise. + EDGE_FILE="afl-edges-${TARGET}.txt" + CELLS=64 # 64 cells wide × 64 tall = 4096 cells × 16 slots = 65536 + SLOTS_PER_CELL=16 + if [ -f "$EDGE_FILE" ]; then + python3 - "$EDGE_FILE" "$CELLS" "$SLOTS_PER_CELL" <<'PYEOF' + import sys, math + + edge_file = sys.argv[1] + cells = int(sys.argv[2]) # cells per row + spc = int(sys.argv[3]) # slots per cell + MAP_SIZE = 65536 + total_cells = cells * cells # 4096 + + hit = set() + with open(edge_file) as f: + for line in f: + line = line.strip() + if ':' in line: + slot = int(line.split(':')[0]) + hit.add(slot) + + print(f"\nEdge bitmap visualisation — {cells}×{cells} grid " + f"(each cell = {spc} slots, ■=any hit, ·=none)") + print("+" + "─" * (cells * 2 - 1) + "+") + for row in range(cells): + row_str = "" + for col in range(cells): + cell_idx = row * cells + col + slot_start = cell_idx * spc + slot_end = slot_start + spc + filled = any(s in hit for s in range(slot_start, slot_end)) + row_str += ("■" if filled else "·") + " " + print("|" + row_str.rstrip() + "|") + print("+" + "─" * (cells * 2 - 1) + "+") + + filled_cells = sum( + 1 for c in range(total_cells) + if any((c * spc + s) in hit for s in range(spc)) + ) + print(f"Cells filled: {filled_cells}/{total_cells} " + f"({filled_cells*100/total_cells:.1f}%)\n") + PYEOF + fi + + # ── GitHub Step Summary ── + { + echo "## Edge Bitmap Coverage — \`${TARGET}\`" + echo "" + echo "| Method | Filled slots | Bitmap filled % |" + echo "|---|---|---|" + echo "| \`fuzzer_stats\` (afl-fuzz live) | ${filled_stat} | **${cvg}** |" + echo "| \`afl-showmap\` (corpus union) | ${showmap_filled} | **${showmap_pct}%** |" + echo "" + echo "> MAP_SIZE = ${MAP_SIZE} slots (2¹⁶). " + echo "> A slot is filled when any corpus input exercises that program edge." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Package AFL findings into tarball + if: always() + run: | + TARGET="${{ matrix.target }}" + OUTPUT="afl-output/${TARGET}" + # AFL++ queue/crash/hang filenames contain colons, which are forbidden by + # actions/upload-artifact on NTFS-based runners. Bundle everything into a + # single tarball so the colon-bearing filenames never appear as individual + # artifact entries. + if [ -d "$OUTPUT" ]; then + tar -czf "afl-findings-${TARGET}.tar.gz" \ + -C "$(dirname "$OUTPUT")" "$(basename "$OUTPUT")" + else + tar -czf "afl-findings-${TARGET}.tar.gz" -T /dev/null + fi + + - name: Upload AFL findings artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: afl-findings-${{ matrix.target }} + path: afl-findings-${{ matrix.target }}.tar.gz + if-no-files-found: ignore + + # ──────────────────────────────────────────────────────────────────────────── + # afl-coverage — LLVM coverage report for all 15 targets + # ──────────────────────────────────────────────────────────────────────────── + afl-coverage: + name: "AFL++ coverage — ${{ matrix.target }}" + runs-on: ubuntu-latest + needs: afl-smoke + + strategy: + fail-fast: false + matrix: + target: + - fuzz_apply_state_diff_split_path + - fuzz_block_verification + - fuzz_encoding_roundtrip + - fuzz_multi_block_state_sequence + - fuzz_program_deployment_lifecycle + - fuzz_replay_prevention + - fuzz_sequencer_vs_replayer + - fuzz_signature_verification + - fuzz_state_diff_computation + - fuzz_state_serialization + - fuzz_state_transition + - fuzz_stateless_verification + - fuzz_transaction_decoding + - fuzz_validate_execute_consistency + - fuzz_witness_set_verification + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez + + - name: Install logos-blockchain-circuits + uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust nightly + llvm-tools-preview + uses: dtolnay/rust-toolchain@nightly + with: + components: llvm-tools-preview + + - name: Download smoke findings for ${{ matrix.target }} + uses: actions/download-artifact@v4 + with: + name: afl-findings-${{ matrix.target }} + path: . + continue-on-error: true # no crashes/hangs/queue is fine + + - name: Extract AFL findings tarball + run: | + TARGET="${{ matrix.target }}" + TARBALL="afl-findings-${TARGET}.tar.gz" + if [ -f "$TARBALL" ]; then + tar -xzf "$TARBALL" + fi + + - name: Build with LLVM instrumented coverage + env: + RUSTFLAGS: "-C instrument-coverage" + RISC0_DEV_MODE: "1" + run: | + # Build with the libfuzzer harness: libFuzzer accepts corpus files as + # positional arguments, runs each through the fuzz closure once, then + # exits — LLVM coverage counters (-C instrument-coverage) are flushed + # to the .profraw file on exit regardless of the fuzzer runtime used. + cargo build \ + --manifest-path fuzz/Cargo.toml \ + --no-default-features \ + --features fuzzer-libfuzzer \ + --release \ + --bin ${{ matrix.target }} + + - name: Run corpus + queue entries through instrumented binary + run: | + TARGET="${{ matrix.target }}" + BINARY="fuzz/target/release/${TARGET}" + PROFRAW_DIR="coverage/afl/${TARGET}/profraw" + mkdir -p "$PROFRAW_DIR" + idx=0 + + # AFL corpus (checked-in, accumulated from prior runs) + for f in corpus/afl/${TARGET}/*; do + [ -f "$f" ] || continue + LLVM_PROFILE_FILE="${PROFRAW_DIR}/${idx}.profraw" "$BINARY" "$f" 2>/dev/null || true + idx=$((idx + 1)) + done + + # AFL++ queue entries from today's smoke run (downloaded artifact) + for instance_dir in afl-output/${TARGET}/*/; do + QUEUE="${instance_dir}queue" + [ -d "$QUEUE" ] || continue + for f in "$QUEUE"/id:*; do + [ -f "$f" ] || continue + LLVM_PROFILE_FILE="${PROFRAW_DIR}/${idx}.profraw" "$BINARY" "$f" 2>/dev/null || true + idx=$((idx + 1)) + done + done + echo "Ran ${idx} inputs through ${TARGET}" + + - name: Merge raw profiles + run: | + TARGET="${{ matrix.target }}" + PROFRAW_DIR="coverage/afl/${TARGET}/profraw" + PROFDATA="coverage/afl/${TARGET}/merged.profdata" + SYSROOT="$(rustc --print sysroot)" + HOST_TRIPLE="$(rustc -vV | awk '/^host:/{print $2}')" + LLVM_PROFDATA="${SYSROOT}/lib/rustlib/${HOST_TRIPLE}/bin/llvm-profdata" + shopt -s nullglob + files=("${PROFRAW_DIR}"/*.profraw) + if [ ${#files[@]} -eq 0 ]; then + echo "No .profraw files found — skipping merge." + exit 0 + fi + "$LLVM_PROFDATA" merge -sparse "${files[@]}" -o "$PROFDATA" + + - name: Generate HTML coverage report + run: | + TARGET="${{ matrix.target }}" + BINARY="fuzz/target/release/${TARGET}" + PROFDATA="coverage/afl/${TARGET}/merged.profdata" + HTML_DIR="coverage/afl/${TARGET}/html" + SYSROOT="$(rustc --print sysroot)" + HOST_TRIPLE="$(rustc -vV | awk '/^host:/{print $2}')" + LLVM_COV="${SYSROOT}/lib/rustlib/${HOST_TRIPLE}/bin/llvm-cov" + if [ ! -f "$PROFDATA" ]; then + echo "No profdata — skipping HTML report." + exit 0 + fi + mkdir -p "$HTML_DIR" + "$LLVM_COV" show \ + "$BINARY" \ + --instr-profile="$PROFDATA" \ + --format=html \ + --output-dir="$HTML_DIR" \ + --ignore-filename-regex='\.cargo|rustc' + echo "Coverage report: ${HTML_DIR}/index.html" + + - name: Upload coverage report artifact + uses: actions/upload-artifact@v4 + with: + name: afl-coverage-${{ matrix.target }} + path: coverage/afl/${{ matrix.target }}/html/ + if-no-files-found: ignore diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 87a6666..8253824 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -1,12 +1,11 @@ name: Fuzzing on: - push: - branches: [main, develop] - pull_request: schedule: - # Nightly full run - cron: "0 2 * * *" + workflow_dispatch: + push: + branches: [main] env: RISC0_DEV_MODE: "1" @@ -39,14 +38,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Checkout logos-execution-zone alongside lez-fuzzing - uses: actions/checkout@v4 - with: - repository: logos-blockchain/logos-execution-zone - path: logos-execution-zone - - - name: Symlink logos-execution-zone to sibling directory - run: ln -s "$GITHUB_WORKSPACE/logos-execution-zone" "$GITHUB_WORKSPACE/../logos-execution-zone" + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez - name: Install Rust nightly (required by cargo-fuzz) uses: dtolnay/rust-toolchain@nightly @@ -75,9 +68,126 @@ jobs: - name: Run smoke fuzz (60 s) run: | + mkdir -p corpus/libfuzz/${{ matrix.target }} cargo fuzz run ${{ matrix.target }} \ + corpus/libfuzz/${{ matrix.target }} \ -- -max_total_time=60 -jobs=2 -workers=2 + - name: Calculate and show edge bitmap coverage + if: always() + run: | + TARGET="${{ matrix.target }}" + CORPUS="corpus/libfuzz/${TARGET}" + mkdir -p "$CORPUS" + + # ── Build and replay the corpus with LLVM coverage instrumentation ── + # Capture output so we can parse the libFuzzer edge-bitmap lines. + # cargo fuzz coverage builds into fuzz/target//coverage/ + # and writes the merged profdata to fuzz/coverage//coverage.profdata + COVERAGE_LOG=$(cargo fuzz coverage "$TARGET" "$CORPUS" 2>&1 || true) + echo "$COVERAGE_LOG" + + # ── Extract libFuzzer edge-bitmap metrics from the merge log ── + # Total edges: "INFO: Loaded 1 modules (N inline 8-bit counters)" + # Covered edges: "MERGE-OUTER: ... N new coverage edges" + edge_total=$(echo "$COVERAGE_LOG" \ + | grep -oP '(?<=Loaded 1 modules\s{1,10}\()\d+(?= inline 8-bit counters)' \ + | tail -1) + edge_covered=$(echo "$COVERAGE_LOG" \ + | grep -oP '\d+(?= new coverage edges)' \ + | tail -1) + + if [ -n "$edge_total" ] && [ -n "$edge_covered" ] && [ "$edge_total" -gt 0 ]; then + edge_pct=$(python3 -c "print(f'{100*${edge_covered}/${edge_total}:.2f}')") + else + edge_pct="n/a" + fi + [ -z "$edge_total" ] && edge_total="n/a" + [ -z "$edge_covered" ] && edge_covered="n/a" + + echo "Edge bitmap: ${edge_covered}/${edge_total} (${edge_pct}%)" + + # ── Locate llvm-cov from the installed nightly toolchain ── + SYSROOT="$(rustc --print sysroot)" + HOST_TRIPLE="$(rustc -vV | awk '/^host:/{print $2}')" + LLVM_COV="${SYSROOT}/lib/rustlib/${HOST_TRIPLE}/bin/llvm-cov" + + # Use deterministic paths — cargo-fuzz always places artefacts here: + # binary → fuzz/target//coverage/ + # profdata → fuzz/coverage//coverage.profdata + PROFDATA="fuzz/coverage/${TARGET}/coverage.profdata" + BINARY="fuzz/target/${HOST_TRIPLE}/coverage/${TARGET}" + + echo "llvm-cov : ${LLVM_COV}" + echo "profdata : ${PROFDATA} (exists: $([ -f "$PROFDATA" ] && echo yes || echo no))" + echo "binary : ${BINARY} (exists: $([ -f "$BINARY" ] && echo yes || echo no))" + + branches_covered="n/a" + branches_total="n/a" + branch_pct="n/a" + + if [ -f "$PROFDATA" ] && [ -f "$BINARY" ]; then + JSON=$("$LLVM_COV" export "$BINARY" \ + --instr-profile="$PROFDATA" \ + --summary-only \ + --ignore-filename-regex='\.cargo|rustc' 2>/dev/null || echo "{}") + + echo "llvm-cov JSON (first 400 chars): $(echo "$JSON" | head -c 400)" + + branches_covered=$(echo "$JSON" | python3 -c " + import sys, json + data = json.load(sys.stdin) + try: + br = data['data'][0]['totals']['branches'] + print(br['covered']) + except Exception: + print('n/a') + ") + branches_total=$(echo "$JSON" | python3 -c " + import sys, json + data = json.load(sys.stdin) + try: + br = data['data'][0]['totals']['branches'] + print(br['count']) + except Exception: + print('n/a') + ") + branch_pct=$(echo "$JSON" | python3 -c " + import sys, json + data = json.load(sys.stdin) + try: + br = data['data'][0]['totals']['branches'] + print(f\"{br['percent']:.2f}\") + except Exception: + print('n/a') + ") + else + echo "WARNING: profdata or binary not found — skipping llvm-cov." + fi + + echo "Branch coverage: ${branches_covered}/${branches_total} (${branch_pct}%)" + + # ── GitHub Step Summary ── + { + echo "## Edge Bitmap Coverage — \`${TARGET}\`" + echo "" + echo "### libFuzzer edge bitmap (inline 8-bit counters)" + echo "" + echo "| Metric | Value |" + echo "|---|---|" + echo "| Total edges | ${edge_total} |" + echo "| Covered edges | ${edge_covered} |" + echo "| Edge coverage | **${edge_pct}%** |" + echo "" + echo "### LLVM source-based branch coverage" + echo "" + echo "| Covered branches | Total branches | Coverage % |" + echo "|---|---|---|" + echo "| ${branches_covered} | ${branches_total} | **${branch_pct}%** |" + echo "" + echo "> Edge bitmap from libFuzzer merge; branch coverage from \`llvm-cov\` over \`${CORPUS}\`." + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@v4 @@ -110,13 +220,8 @@ jobs: - fuzz_sequencer_vs_replayer steps: - uses: actions/checkout@v4 - - name: Checkout logos-execution-zone alongside lez-fuzzing - uses: actions/checkout@v4 - with: - repository: logos-blockchain/logos-execution-zone - path: logos-execution-zone - - name: Symlink logos-execution-zone to sibling directory - run: ln -s "$GITHUB_WORKSPACE/logos-execution-zone" "$GITHUB_WORKSPACE/../logos-execution-zone" + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez - uses: dtolnay/rust-toolchain@nightly with: components: llvm-tools-preview @@ -127,8 +232,9 @@ jobs: - run: cargo install cargo-fuzz - name: Reproduce corpus run: | + mkdir -p corpus/libfuzz/${{ matrix.target }} cargo fuzz run ${{ matrix.target }} \ - fuzz/corpus/${{ matrix.target }} -- -runs=0 + corpus/libfuzz/${{ matrix.target }} -- -runs=0 # ── proptest property tests ────────────────────────────────────────────────── proptest: @@ -136,13 +242,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Checkout logos-execution-zone alongside lez-fuzzing - uses: actions/checkout@v4 - with: - repository: logos-blockchain/logos-execution-zone - path: logos-execution-zone - - name: Symlink logos-execution-zone to sibling directory - run: ln -s "$GITHUB_WORKSPACE/logos-execution-zone" "$GITHUB_WORKSPACE/../logos-execution-zone" + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez - uses: dtolnay/rust-toolchain@stable - name: Install logos-blockchain-circuits uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits @@ -157,13 +258,8 @@ jobs: if: github.event_name == 'schedule' steps: - uses: actions/checkout@v4 - - name: Checkout logos-execution-zone alongside lez-fuzzing - uses: actions/checkout@v4 - with: - repository: logos-blockchain/logos-execution-zone - path: logos-execution-zone - - name: Symlink logos-execution-zone to sibling directory - run: ln -s "$GITHUB_WORKSPACE/logos-execution-zone" "$GITHUB_WORKSPACE/../logos-execution-zone" + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez - uses: dtolnay/rust-toolchain@nightly with: components: llvm-tools-preview diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a486d6c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,75 @@ +name: Lint + +on: + push: + branches: + - main + paths-ignore: + - "**.md" + - "!.github/workflows/*.yml" + + pull_request: + paths-ignore: + - "**.md" + - "!.github/workflows/*.yml" + +env: + RISC0_DEV_MODE: "1" + CARGO_TERM_COLOR: always + +permissions: + contents: read + pull-requests: read + +jobs: + # ── rustfmt ────────────────────────────────────────────────────────────────── + fmt-rs: + name: Rust formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez + + - name: Install nightly toolchain for rustfmt + run: rustup install nightly --profile minimal --component rustfmt + + - name: Check Rust files are formatted + run: cargo +nightly fmt --check + + # ── clippy ─────────────────────────────────────────────────────────────────── + lint: + name: Clippy + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + - name: Checkout logos-execution-zone + uses: ./.github/actions/checkout-lez + + - name: Install logos-blockchain-circuits + uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install stable toolchain with clippy + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: lint-rust-cache + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Lint workspace + env: + RISC0_DEV_MODE: "1" + run: cargo clippy --workspace --all-targets --all-features -- -D warnings diff --git a/Cargo.toml b/Cargo.toml index 16ef6e1..af287fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ clippy.absolute-paths = "allow" clippy.min-ident-chars = "allow" clippy.indexing-slicing = "allow" clippy.little-endian-bytes = "allow" +clippy.self-named-module-files = "allow" [workspace.lints.rust] unsafe_code = "deny" diff --git a/Justfile b/Justfile index 2dc0ac9..7bfb9be 100644 --- a/Justfile +++ b/Justfile @@ -1,6 +1,28 @@ # ── Fuzzing ─────────────────────────────────────────────────────────────────── export RISC0_DEV_MODE := "1" +# ── Directory layout ────────────────────────────────────────────────────────── +# +# corpus/ +# libfuzz// — inputs generated/discovered by cargo-fuzz (libFuzzer) +# afl// — inputs generated by AFL++ (synced from afl-output queue) +# +# coverage/ +# libfuzz// — per-target libFuzzer coverage report + profdata +# libfuzz/summary/ — merged libFuzzer summary (all targets) +# afl// — per-target AFL++ corpus coverage report + profdata +# afl/summary/ — merged AFL++ corpus summary (all targets) +# +# afl-output// — AFL++'s raw working directory (queue, crashes, hangs) +# The queue is synced to corpus/afl// via +# `just afl-corpus-sync`; crashes/hangs are kept here. +# +# Note: cargo-fuzz (coverage, run, cmin) always writes its profdata to the +# fixed path fuzz/coverage//coverage.profdata regardless of the +# corpus argument. The coverage recipes copy that file into the organised +# coverage/ tree immediately after it is produced, so AFL passes can never +# overwrite a libFuzzer profdata that is still needed. + # List all registered fuzz targets (reads fuzz/Cargo.toml via cargo-fuzz) list-targets: cargo fuzz list @@ -13,7 +35,8 @@ fuzz TIME="30": set -euo pipefail for target in $(cargo fuzz list 2>/dev/null); do echo "=== fuzzing $target for {{TIME}}s ===" - cargo fuzz run "$target" -- -max_total_time={{TIME}} + mkdir -p "corpus/libfuzz/$target" + cargo fuzz run "$target" "corpus/libfuzz/$target" -- -max_total_time={{TIME}} done # Re-run the saved corpus for every target (regression mode, no new mutations) @@ -22,8 +45,8 @@ fuzz-regression: set -euo pipefail for target in $(cargo fuzz list 2>/dev/null); do echo "=== regression $target ===" - mkdir -p "fuzz/corpus/$target" - cargo fuzz run "$target" "fuzz/corpus/$target" -- -runs=0 + mkdir -p "corpus/libfuzz/$target" + cargo fuzz run "$target" "corpus/libfuzz/$target" -- -runs=0 done # Minimise a crash artifact @@ -47,20 +70,22 @@ corpus-cmin: set -euo pipefail for target in $(cargo fuzz list 2>/dev/null); do echo "=== cmin $target ===" - cargo fuzz cmin "$target" + mkdir -p "corpus/libfuzz/$target" + cargo fuzz cmin "$target" "corpus/libfuzz/$target" done # Minimise the corpus for a single target # Usage: just corpus-cmin-target fuzz_state_transition corpus-cmin-target TARGET: - cargo fuzz cmin {{TARGET}} + mkdir -p corpus/libfuzz/{{TARGET}} + cargo fuzz cmin {{TARGET}} corpus/libfuzz/{{TARGET}} # ── Adding a new target ─────────────────────────────────────────────────────── # Scaffold a new fuzz target — fully automated, no manual edits required. # # Steps performed automatically: -# 1. Creates fuzz/corpus// +# 1. Creates corpus/libfuzz// # 2. Copies fuzz/fuzz_targets/_template.rs → fuzz/fuzz_targets/.rs # 3. Appends the [[bin]] entry to fuzz/Cargo.toml # 4. Inserts into every strategy matrix in .github/workflows/fuzz.yml @@ -73,7 +98,7 @@ new-target NAME: TARGET="fuzz_{{NAME}}" TEMPLATE="fuzz/fuzz_targets/_template.rs" RS_FILE="fuzz/fuzz_targets/${TARGET}.rs" - CORPUS_DIR="fuzz/corpus/${TARGET}" + CORPUS_DIR="corpus/libfuzz/${TARGET}" # ── 1. Create corpus directory ──────────────────────────────────────────── mkdir -p "$CORPUS_DIR" @@ -90,8 +115,533 @@ new-target NAME: # ── 3 & 4. Update Cargo.toml and fuzz.yml automatically ────────────────── python3 scripts/add_fuzz_target.py "$TARGET" echo "" - echo "Done! Verify the build with:" + echo "Done! Verify the libFuzzer build with:" echo " RISC0_DEV_MODE=1 cargo fuzz build ${TARGET}" + echo "" + echo "Verify the AFL++ build with:" + echo " cd fuzz && cargo afl build --no-default-features --features fuzzer-afl --release --bin ${TARGET}" + +# ── AFL++ fuzzing ────────────────────────────────────────────────────────────── +# Prerequisites (install once): +# macOS: brew install afl-fuzz && cargo install cargo-afl +# Linux: Build AFL++ from source (recommended — Debian/Ubuntu apt packages are +# several major versions behind; see https://github.com/AFLplusplus/AFLplusplus): +# git clone https://github.com/AFLplusplus/AFLplusplus +# cd AFLplusplus && make distrib && sudo make install +# Then: cargo install cargo-afl + +# Build ALL fuzz targets for AFL++ (output: fuzz/target/release/) +afl-build: + cd fuzz && cargo afl build --no-default-features --features fuzzer-afl --release + +# Build a SINGLE fuzz target for AFL++ +# Usage: just afl-build-target fuzz_state_transition +afl-build-target TARGET: + cd fuzz && cargo afl build --no-default-features --features fuzzer-afl --release --bin {{TARGET}} + +# Disable the macOS crash reporter daemon so AFL++ can detect crashes reliably. +# This is a macOS-only requirement; on Linux this is a no-op. +# The `fuzz-afl` recipe calls this automatically; run it manually if you want +# to keep the reporter disabled across multiple just invocations. +# +# Re-enable with: just afl-macos-teardown +afl-macos-setup: + #!/bin/bash + if [ "$(uname)" != "Darwin" ]; then echo "Not macOS — nothing to do."; exit 0; fi + SL=/System/Library; PL=com.apple.ReportCrash + echo "Disabling macOS crash reporter (required by AFL++)…" + launchctl unload -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true + sudo launchctl unload -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true + echo "Done. Re-enable with: just afl-macos-teardown" + +# Re-enable the macOS crash reporter after an AFL++ session. +afl-macos-teardown: + #!/bin/bash + if [ "$(uname)" != "Darwin" ]; then echo "Not macOS — nothing to do."; exit 0; fi + SL=/System/Library; PL=com.apple.ReportCrash + echo "Re-enabling macOS crash reporter…" + launchctl load -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true + sudo launchctl load -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true + echo "Done." + +# Run AFL++ on one target or ALL targets when no target is supplied. +# Builds binaries as needed; syncs the queue to corpus/afl// when done. +# +# AFL++ is seeded from corpus/libfuzz// (the libFuzzer corpus). +# After the run, new inputs discovered by AFL++ are synced to corpus/afl// +# via `just afl-corpus-sync`. +# +# On macOS the crash reporter is disabled automatically for the duration of the +# run and re-enabled when the script exits (via a shell trap). +# +# Requires afl-fuzz and cargo-afl to be installed locally: +# macOS: brew install afl-fuzz && cargo install cargo-afl +# Linux: Build AFL++ from source (apt packages are several major versions +# behind): see https://github.com/AFLplusplus/AFLplusplus +# +# Usage: just fuzz-afl # all targets, 30 s each +# just fuzz-afl "" 60 # all targets, 60 s each +# just fuzz-afl fuzz_state_transition # single target, 30 s +# just fuzz-afl fuzz_state_transition 300 # single target, 300 s +fuzz-afl TARGET="" TIME="30": + #!/bin/bash + set -euo pipefail + TARGET="{{TARGET}}" + TIME="{{TIME}}" + + # ── Collect targets to run ──────────────────────────────────────────────── + if [ -z "$TARGET" ]; then + TARGETS=($(cargo fuzz list 2>/dev/null)) + else + TARGETS=("$TARGET") + fi + + # ── Require local AFL++ installation ───────────────────────────────────── + if ! command -v afl-fuzz &>/dev/null; then + echo "ERROR: afl-fuzz not found in PATH." + echo "" + echo "Install AFL++ before running this recipe:" + echo "" + echo " macOS : brew install afl-fuzz" + echo "" + echo " Linux : Build from source (apt packages are several major versions behind):" + echo " git clone https://github.com/AFLplusplus/AFLplusplus" + echo " cd AFLplusplus && make distrib && sudo make install" + echo "" + echo "Also install the cargo-afl build wrapper:" + echo " cargo install cargo-afl" + echo "" + exit 1 + fi + if ! command -v cargo-afl &>/dev/null && ! cargo afl --version &>/dev/null 2>&1; then + echo "ERROR: cargo-afl not found." + echo " cargo install cargo-afl" + exit 1 + fi + + # ── macOS: disable crash reporter for the duration of this run ─────────── + if [ "$(uname)" = "Darwin" ]; then + SL=/System/Library; PL=com.apple.ReportCrash + echo "macOS: disabling crash reporter (AFL++ requirement)…" + launchctl unload -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true + sudo launchctl unload -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true + # Re-enable on any exit — normal, error, or Ctrl-C + trap ' + echo "Re-enabling macOS crash reporter…" + SL=/System/Library; PL=com.apple.ReportCrash + launchctl load -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true + sudo launchctl load -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true + ' EXIT + fi + + # ── Run targets ─────────────────────────────────────────────────────────── + _run_one() { + local t="$1" + local BINARY="fuzz/target/release/$t" + local CORPUS="corpus/libfuzz/$t" # seed from libFuzzer corpus + local OUTPUT="afl-output/$t" + mkdir -p "$CORPUS" "$OUTPUT" + if [ ! -f "$BINARY" ]; then + echo "Binary not found — building $t first…" + just afl-build-target "$t" + fi + timeout "$TIME" afl-fuzz -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" || true + } + for t in "${TARGETS[@]}"; do + echo "=== afl++ $t for ${TIME}s ===" + _run_one "$t" + done + just afl-corpus-sync + + # ── Crash / hang summary ────────────────────────────────────────────────── + echo "" + echo "=== AFL++ crash / hang summary ===" + total_crashes=0 + total_hangs=0 + for target_dir in afl-output/*/; do + [ -d "$target_dir" ] || continue + for instance_dir in "$target_dir"*/; do + [ -d "$instance_dir" ] || continue + crashes_dir="${instance_dir}crashes" + hangs_dir="${instance_dir}hangs" + n_crashes=0 + n_hangs=0 + if [ -d "$crashes_dir" ]; then + n_crashes=$(find "$crashes_dir" -maxdepth 1 -type f | wc -l | tr -d ' ') + fi + if [ -d "$hangs_dir" ]; then + n_hangs=$(find "$hangs_dir" -maxdepth 1 -type f | wc -l | tr -d ' ') + fi + if [ "$n_crashes" -gt 0 ] || [ "$n_hangs" -gt 0 ]; then + echo " !! $(basename "$target_dir")/$(basename "$instance_dir") crashes=$n_crashes hangs=$n_hangs" + for f in "$crashes_dir"/id:*; do + [ -f "$f" ] && echo " $f" + done + for f in "$hangs_dir"/id:*; do + [ -f "$f" ] && echo " $f" + done + fi + total_crashes=$((total_crashes + n_crashes)) + total_hangs=$((total_hangs + n_hangs)) + done + done + echo "" + if [ "$total_crashes" -eq 0 ] && [ "$total_hangs" -eq 0 ]; then + echo " ✓ No crashes or hangs found across all targets." + else + echo " TOTAL crashes=$total_crashes hangs=$total_hangs" + echo "" + echo " Minimise a crash : just afl-tmin " + echo " Format for a report: just afl-fmt " + fi + +# Run AFL++ with N parallel instances (1 main + N-1 secondary) for TIME seconds. +# Requires that afl-fuzz is on PATH; all instances share afl-output/{{TARGET}}/. +# On macOS the crash reporter is disabled automatically for the duration of the +# run and re-enabled when the script exits. +# +# Usage: just fuzz-afl-parallel fuzz_state_transition +# just fuzz-afl-parallel fuzz_state_transition 8 600 +fuzz-afl-parallel TARGET WORKERS="4" TIME="300": + #!/bin/bash + set -euo pipefail + BINARY="fuzz/target/release/{{TARGET}}" + CORPUS="corpus/libfuzz/{{TARGET}}" # seed from libFuzzer corpus + OUTPUT="afl-output/{{TARGET}}" + mkdir -p "$CORPUS" "$OUTPUT" + if [ ! -f "$BINARY" ]; then + echo "Binary not found — building first…" + just afl-build-target {{TARGET}} + fi + # ── macOS: disable crash reporter for the duration of this run ─────────── + if [ "$(uname)" = "Darwin" ]; then + SL=/System/Library; PL=com.apple.ReportCrash + echo "macOS: disabling crash reporter (AFL++ requirement)…" + launchctl unload -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true + sudo launchctl unload -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true + trap ' + echo "Re-enabling macOS crash reporter…" + SL=/System/Library; PL=com.apple.ReportCrash + launchctl load -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true + sudo launchctl load -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true + ' EXIT + fi + # Main instance + afl-fuzz -M main -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" & + # Secondary instances + for i in $(seq 1 $(( {{WORKERS}} - 1 ))); do + afl-fuzz -S "secondary${i}" -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" & + done + sleep {{TIME}} + kill $(jobs -p) 2>/dev/null || true + wait 2>/dev/null || true + just afl-corpus-sync + +# Copy all queue entries from every AFL++ output directory into the matching +# AFL corpus directory (corpus/afl//). Run after any AFL++ session +# to make new interesting inputs available for coverage measurement and future runs. +afl-corpus-sync: + #!/bin/bash + set -euo pipefail + if [ ! -d afl-output ]; then + echo "afl-output/ does not exist — nothing to sync." + exit 0 + fi + for target_dir in afl-output/*/; do + TARGET=$(basename "$target_dir") + DEST="corpus/afl/${TARGET}" + mkdir -p "$DEST" + count=0 + for instance_dir in "$target_dir"*/; do + QUEUE="${instance_dir}queue" + [ -d "$QUEUE" ] || continue + for f in "$QUEUE"/id:*; do + [ -f "$f" ] || continue + HASH=$(sha1sum "$f" | cut -d' ' -f1) + DEST_FILE="${DEST}/${HASH}" + if [ ! -f "$DEST_FILE" ]; then + cp "$f" "$DEST_FILE" + count=$((count + 1)) + fi + done + done + echo "Synced $count new input(s) → $DEST" + done + +# Show AFL++ campaign statistics for a target +# Usage: just afl-status fuzz_state_transition +afl-status TARGET: + afl-whatsup afl-output/{{TARGET}} + +# Minimise a crash or hang artifact to the smallest reproducing input. +# Usage: just afl-tmin fuzz_state_transition afl-output/fuzz_state_transition/crashes/id:000000,... +afl-tmin TARGET ARTIFACT: + afl-tmin -i {{ARTIFACT}} -o {{ARTIFACT}}.min -- fuzz/target/release/{{TARGET}} + +# Pretty-print an AFL++ artifact as a Rust byte-string literal (for copy-paste +# into a unit test or issue report). +# Usage: just afl-fmt afl-output/fuzz_state_transition/crashes/id:000000,... +afl-fmt ARTIFACT: + python3 -c "import sys; data=open(sys.argv[1],'rb').read(); print('b\"' + ''.join(f'\\\\x{b:02x}' for b in data) + '\"')" {{ARTIFACT}} + +# ── Coverage ────────────────────────────────────────────────────────────────── +# +# cargo-fuzz always writes its profdata to the fixed path: +# fuzz/coverage//coverage.profdata +# Each coverage recipe immediately copies that file into the organised tree +# (coverage/libfuzz/ or coverage/afl/) so that a subsequent run of the other +# engine cannot overwrite the data we need for the summary reports. + +# Generate a libFuzzer-only coverage report for a single target. +# Runs `cargo fuzz coverage` against corpus/libfuzz//, then copies the +# profdata into coverage/libfuzz// and renders an HTML report there. +# Output: coverage/libfuzz/{{TARGET}}/html/index.html +# Usage: just coverage-libfuzz fuzz_state_transition +coverage-libfuzz TARGET: + #!/bin/bash + set -euo pipefail + + # ── Resolve LLVM tools from the active Rust toolchain ───────────────────── + _SYSROOT=$(rustc --print sysroot) + _HOST=$(rustc -vV | sed -n 's/^host: //p') + _LLVM_BIN="${_SYSROOT}/lib/rustlib/${_HOST}/bin" + LLVM_COV="${_LLVM_BIN}/llvm-cov" + command -v "$LLVM_COV" &>/dev/null || LLVM_COV=$(command -v llvm-cov 2>/dev/null || true) + if [ -z "$LLVM_COV" ] || [ ! -x "$LLVM_COV" ]; then + echo "ERROR: llvm-cov not found in Rust sysroot (${_LLVM_BIN}) or PATH." + echo " Run: rustup component add llvm-tools-preview" + exit 1 + fi + + CORPUS="corpus/libfuzz/{{TARGET}}" + mkdir -p "$CORPUS" + + echo "=== cargo fuzz coverage {{TARGET}} (libFuzzer corpus) ===" + cargo fuzz coverage {{TARGET}} "$CORPUS" + + # ── Copy profdata to the organised tree ─────────────────────────────────── + # cargo-fuzz always writes here; we copy immediately so a later AFL pass + # cannot clobber this file before the summary reads it. + CARGO_PROFDATA="fuzz/coverage/{{TARGET}}/coverage.profdata" + LF_COV_DIR="coverage/libfuzz/{{TARGET}}" + mkdir -p "$LF_COV_DIR" + if [ ! -f "$CARGO_PROFDATA" ]; then + echo "WARNING: profdata not produced — skipping HTML generation." + exit 0 + fi + cp "$CARGO_PROFDATA" "${LF_COV_DIR}/coverage.profdata" + PROFDATA="${LF_COV_DIR}/coverage.profdata" + + # ── Render HTML ─────────────────────────────────────────────────────────── + BINARY="target/${_HOST}/coverage/${_HOST}/release/{{TARGET}}" + HTML_DIR="${LF_COV_DIR}/html" + if [ -f "$BINARY" ]; then + mkdir -p "$HTML_DIR" + "$LLVM_COV" show \ + "$BINARY" \ + --instr-profile="$PROFDATA" \ + --format=html \ + --output-dir="$HTML_DIR" \ + --ignore-filename-regex='\.cargo|rustc' + echo "libFuzzer HTML coverage report: ${HTML_DIR}/index.html" + else + echo "WARNING: binary not found — skipping HTML generation." + echo " Binary: $BINARY" + fi + +# Measure code coverage exercised by the AFL++ corpus for a single target. +# +# Strategy: replay corpus/afl// through the libFuzzer coverage binary +# (built by `cargo fuzz coverage`). Run `just afl-corpus-sync` first to +# populate corpus/afl// from the AFL++ queue. +# +# Output: coverage/afl/{{TARGET}}/html/index.html +# Usage: just coverage-afl fuzz_state_transition +coverage-afl TARGET: + #!/bin/bash + set -euo pipefail + + # ── Resolve LLVM tools from the active Rust toolchain ───────────────────── + _SYSROOT=$(rustc --print sysroot) + _HOST=$(rustc -vV | sed -n 's/^host: //p') + _LLVM_BIN="${_SYSROOT}/lib/rustlib/${_HOST}/bin" + LLVM_COV="${_LLVM_BIN}/llvm-cov" + command -v "$LLVM_COV" &>/dev/null || LLVM_COV=$(command -v llvm-cov 2>/dev/null || true) + if [ -z "$LLVM_COV" ] || [ ! -x "$LLVM_COV" ]; then + echo "ERROR: llvm-cov not found in Rust sysroot (${_LLVM_BIN}) or PATH." + echo " Run: rustup component add llvm-tools-preview" + exit 1 + fi + + AFL_CORPUS="corpus/afl/{{TARGET}}" + if [ ! -d "$AFL_CORPUS" ] || [ -z "$(ls -A "$AFL_CORPUS" 2>/dev/null)" ]; then + echo "No AFL++ corpus for {{TARGET}} at $AFL_CORPUS." + echo " Run 'just afl-corpus-sync' after an AFL++ session to populate it." + exit 0 + fi + + echo "=== AFL++ corpus coverage for {{TARGET}} ===" + + # ── Replay AFL++ corpus through the libFuzzer coverage binary ───────────── + # cargo fuzz coverage always writes: + # profdata → fuzz/coverage/{{TARGET}}/coverage.profdata + # binary → target//coverage//release/{{TARGET}} + cargo fuzz coverage {{TARGET}} "$AFL_CORPUS" + + # ── Copy profdata to the organised tree ─────────────────────────────────── + CARGO_PROFDATA="fuzz/coverage/{{TARGET}}/coverage.profdata" + AFL_COV_DIR="coverage/afl/{{TARGET}}" + mkdir -p "$AFL_COV_DIR" + if [ ! -f "$CARGO_PROFDATA" ]; then + echo "WARNING: profdata not produced — skipping HTML generation." + exit 0 + fi + cp "$CARGO_PROFDATA" "${AFL_COV_DIR}/coverage.profdata" + + # ── Render HTML ─────────────────────────────────────────────────────────── + BINARY="target/${_HOST}/coverage/${_HOST}/release/{{TARGET}}" + HTML_DIR="${AFL_COV_DIR}/html" + mkdir -p "$HTML_DIR" + if [ -f "$BINARY" ]; then + "$LLVM_COV" show \ + "$BINARY" \ + --instr-profile="${AFL_COV_DIR}/coverage.profdata" \ + --format=html \ + --output-dir="$HTML_DIR" \ + --ignore-filename-regex='\.cargo|rustc' + echo "AFL++ corpus HTML coverage report: ${HTML_DIR}/index.html" + else + echo "WARNING: binary not found: $BINARY" + fi + +# Generate a combined coverage report for a single target (libFuzzer + AFL++). +# Delegates to coverage-libfuzz then coverage-afl. +# Usage: just coverage fuzz_state_transition +coverage TARGET: + just coverage-libfuzz {{TARGET}} || true + just coverage-afl {{TARGET}} + +# Generate coverage for ALL registered fuzz targets. +# ENGINE selects which fuzzer engine to measure: +# "all" — libFuzzer + AFL++ (default) +# "libfuzz" — libFuzzer only (cargo fuzz coverage against corpus/libfuzz/) +# "afl" — AFL++ only (cargo fuzz coverage against corpus/afl/) +# +# After the per-target loop, a merged summary HTML report is written: +# libfuzz → coverage/libfuzz/summary/html/index.html +# afl → coverage/afl/summary/html/index.html +# +# Usage: just coverage-all # both engines +# just coverage-all libfuzz # libFuzzer only +# just coverage-all afl # AFL++ only +coverage-all ENGINE="all": + #!/bin/bash + set -euo pipefail + + # ── Resolve LLVM tools from the active Rust toolchain ───────────────────── + _SYSROOT=$(rustc --print sysroot) + _HOST=$(rustc -vV | sed -n 's/^host: //p') + _LLVM_BIN="${_SYSROOT}/lib/rustlib/${_HOST}/bin" + LLVM_COV="${_LLVM_BIN}/llvm-cov" + LLVM_PROFDATA="${_LLVM_BIN}/llvm-profdata" + command -v "$LLVM_COV" &>/dev/null || LLVM_COV=$(command -v llvm-cov 2>/dev/null || true) + command -v "$LLVM_PROFDATA" &>/dev/null || LLVM_PROFDATA=$(command -v llvm-profdata 2>/dev/null || true) + + TARGETS=($(cargo fuzz list 2>/dev/null)) + + # ── Per-target passes ───────────────────────────────────────────────────── + # Each coverage-libfuzz / coverage-afl call copies its profdata into the + # organised tree (coverage/libfuzz/ or coverage/afl/) immediately after + # cargo-fuzz produces it. Because the two engines write to distinct + # directories there is no risk of one overwriting the other's data, + # regardless of the order in which the targets are processed. + if [ "{{ENGINE}}" = "all" ] || [ "{{ENGINE}}" = "libfuzz" ]; then + for target in "${TARGETS[@]}"; do + echo "=== coverage (libfuzz) $target ===" + just coverage-libfuzz "$target" || true + done + fi + if [ "{{ENGINE}}" = "all" ] || [ "{{ENGINE}}" = "afl" ]; then + for target in "${TARGETS[@]}"; do + echo "=== coverage (afl) $target ===" + just coverage-afl "$target" + done + fi + + # ── Merged summary report (libfuzz) ─────────────────────────────────────── + if [ "{{ENGINE}}" = "libfuzz" ] || [ "{{ENGINE}}" = "all" ]; then + echo "" + echo "=== libFuzzer summary report (all targets merged) ===" + SUMMARY_DIR="coverage/libfuzz/summary" + mkdir -p "$SUMMARY_DIR" + + PROFDATA_FILES=() + BINARY_ARGS=() + for t in "${TARGETS[@]}"; do + PD="coverage/libfuzz/$t/coverage.profdata" + BIN="target/${_HOST}/coverage/${_HOST}/release/$t" + [ -f "$PD" ] && PROFDATA_FILES+=("$PD") + [ -f "$BIN" ] && BINARY_ARGS+=("--object" "$BIN") + done + + if [ ${#PROFDATA_FILES[@]} -eq 0 ]; then + echo "No libFuzzer profdata found — skipping summary." + else + MERGED="${SUMMARY_DIR}/merged.profdata" + "$LLVM_PROFDATA" merge -sparse "${PROFDATA_FILES[@]}" -o "$MERGED" + + HTML_DIR="${SUMMARY_DIR}/html" + mkdir -p "$HTML_DIR" + # First binary is positional; the rest are --object flags. + FIRST_BIN="${BINARY_ARGS[1]}" # index 1 is the path after '--object' + REST_ARGS=("${BINARY_ARGS[@]:2}") + "$LLVM_COV" show \ + "$FIRST_BIN" \ + "${REST_ARGS[@]}" \ + --instr-profile="$MERGED" \ + --format=html \ + --output-dir="$HTML_DIR" \ + --ignore-filename-regex='\.cargo|rustc' + echo "libFuzzer summary HTML report: ${HTML_DIR}/index.html" + fi + fi + + # ── Merged summary report (afl) ─────────────────────────────────────────── + if [ "{{ENGINE}}" = "afl" ] || [ "{{ENGINE}}" = "all" ]; then + echo "" + echo "=== AFL++ corpus summary report (all targets merged) ===" + SUMMARY_DIR="coverage/afl/summary" + mkdir -p "$SUMMARY_DIR" + + PROFDATA_FILES=() + BINARY_ARGS=() + for t in "${TARGETS[@]}"; do + PD="coverage/afl/$t/coverage.profdata" + BIN="target/${_HOST}/coverage/${_HOST}/release/$t" + [ -f "$PD" ] && PROFDATA_FILES+=("$PD") + [ -f "$BIN" ] && BINARY_ARGS+=("--object" "$BIN") + done + + if [ ${#PROFDATA_FILES[@]} -eq 0 ]; then + echo "No AFL++ profdata found — skipping summary." + else + MERGED="${SUMMARY_DIR}/merged.profdata" + "$LLVM_PROFDATA" merge -sparse "${PROFDATA_FILES[@]}" -o "$MERGED" + + HTML_DIR="${SUMMARY_DIR}/html" + mkdir -p "$HTML_DIR" + FIRST_BIN="${BINARY_ARGS[1]}" + REST_ARGS=("${BINARY_ARGS[@]:2}") + "$LLVM_COV" show \ + "$FIRST_BIN" \ + "${REST_ARGS[@]}" \ + --instr-profile="$MERGED" \ + --format=html \ + --output-dir="$HTML_DIR" \ + --ignore-filename-regex='\.cargo|rustc' + echo "AFL++ corpus summary HTML report: ${HTML_DIR}/index.html" + fi + fi # ── Housekeeping ────────────────────────────────────────────────────────────── @@ -104,9 +654,21 @@ clean: clean-artifacts: rm -rf fuzz/artifacts/ -# Remove coverage reports generated by `cargo fuzz coverage` +# Remove all coverage reports. +# Also removes the temporary profdata that cargo-fuzz writes to fuzz/coverage/ +# and any stray .profraw files left by the instrumented binaries. clean-coverage: - rm -rf fuzz/coverage/ + rm -rf coverage/ fuzz/coverage/ + find . -name '*.profraw' -delete -# Remove everything: builds, artifacts, and coverage -clean-all: clean clean-artifacts clean-coverage +# Remove AFL++ output directories (crashes, hangs, queue). +# Note: the queue is also stored in corpus/afl/ via `just afl-corpus-sync`. +clean-afl: + rm -rf afl-output/ + +# Remove the corpus directories (libFuzzer and AFL). +clean-corpus: + rm -rf corpus/ + +# Remove everything: builds, artifacts, coverage, and AFL++ output (preserves corpus/) +clean-all: clean clean-artifacts clean-coverage clean-afl diff --git a/current_vs_alternative_approach.md b/current_vs_alternative_approach.md index 8d07ddf..34af3ac 100644 --- a/current_vs_alternative_approach.md +++ b/current_vs_alternative_approach.md @@ -131,14 +131,8 @@ The extension noted in [`docs/fuzzing.md`](docs/fuzzing.md:356) is: ## Decision-maker Recommendations -The current implementation is **well-architected and production-ready** for a protocol at this stage. Its [`fuzz_props`](fuzz_props/src/lib.rs) crate, typed `Arbitrary` wrappers, and `ProtocolInvariant` framework provide the right abstractions to add new targets and invariants incrementally. - **Highest-ROI next steps, in priority order:** -1. **The invariant framework is complete for the current target set** — three invariants are fully implemented and auto-run by [`assert_invariants()`](fuzz_props/src/invariants.rs:325): [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs:60), [`BalanceConservation`](fuzz_props/src/invariants.rs:94), and [`FailedTxNonceStability`](fuzz_props/src/invariants.rs:130). Two further invariants ([`ReplayRejection`](fuzz_props/src/invariants.rs:169) and [`NonceIncrementCorrectness`](fuzz_props/src/invariants.rs:196)) are registered stubs; callers use the dedicated `assert_replay_rejection` and `assert_nonce_increment_correctness` helpers directly. The next step is to audit all 15 targets to confirm every applicable invariant is wired up, then add mutation tests via `cargo-mutants`. +1. **Add AFL++ as a parallel fuzzing lane** (`just fuzz-afl`) — zero corpus migration cost, discovers different mutation paths through the same targets as libFuzzer. -2. ✅ **The sequencer-vs-replayer differential target is implemented** — [`fuzz_sequencer_vs_replayer`](fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs) catches consensus-breaking state root divergence between the sequencer and replayer pipelines, unique to this protocol's architecture. - -3. **Add AFL++ as a parallel fuzzing lane** (`just fuzz-afl`) — zero corpus migration cost, discovers different mutation paths through the same targets as libFuzzer. - -4. **Add `cargo-mutants`** before any external security audit — proves the invariant assertions in [`fuzz_props/src/invariants.rs`](fuzz_props/src/invariants.rs) are actually capable of catching the bugs they claim to detect. \ No newline at end of file +2. **Add `cargo-mutants`** before any external security audit — proves the invariant assertions in [`fuzz_props/src/invariants.rs`](fuzz_props/src/invariants.rs) are actually capable of catching the bugs they claim to detect. \ No newline at end of file diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 78e5535..bb870ca 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -9,14 +9,54 @@ directory that must be cloned separately). --- +## Architecture + +The fuzz workspace (`fuzz/`) is a single Cargo workspace that covers **both** fuzzing +engines via Cargo features. No separate Cargo manifest is needed. + +| | libFuzzer lane | AFL++ lane | +|---|---|---| +| **Build command** | `cargo fuzz build ` | `cd fuzz && cargo afl build --no-default-features --features fuzzer-afl --release --bin ` | +| **Run command** | `cargo fuzz run ` | `afl-fuzz -i fuzz/corpus/ -o afl-output/ -- fuzz/target/release/` | +| **Cargo feature** | `fuzzer-libfuzzer` (default) | `fuzzer-afl` | +| **Harness entry** | `::libfuzzer_sys::fuzz_target!(…)` | `fn main() { ::afl::fuzz!(…) }` | +| **`main()` presence** | Suppressed via `#![no_main]` | Required; provided by `afl::fuzz!` | +| **`fuzz/Cargo.toml`** | ✅ Source of truth | ✅ Same file — covers both lanes | + +The engine is selected at the call site via the `fuzz_props::fuzz_entry!` macro: + +```rust +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] + +fuzz_props::fuzz_entry!(|data: &[u8]| { + // … harness body … +}); +``` + +The `cfg` attributes in the macro expansion resolve against the **calling crate's** features +(`fuzz/`), not `fuzz_props`'s features. + +--- + ## Prerequisites ```bash -# Rust nightly is required by cargo-fuzz / libFuzzer +# libFuzzer lane rustup install nightly rustup component add llvm-tools-preview --toolchain nightly - cargo install cargo-fuzz + +# AFL++ lane (additional) +# macOS: +brew install afl-fuzz + +# Linux — build from source (apt packages are several major versions behind): +git clone https://github.com/AFLplusplus/AFLplusplus +cd AFLplusplus && make distrib && sudo make install +cd .. + +# Rust wrapper (all platforms): +cargo install cargo-afl ``` --- @@ -47,10 +87,10 @@ proof generation. The `just` recipes handle this automatically. ```bash # From lez-fuzzing/ -# Run all targets for 30 s each +# Run all targets for 30 s each (libFuzzer) just fuzz -# Run a specific target for 120 s +# Run a specific target for 120 s (libFuzzer) RISC0_DEV_MODE=1 cargo fuzz run fuzz_state_transition -- -max_total_time=120 # Run the saved corpus (regression mode, no mutations) @@ -95,12 +135,11 @@ This single command does four things automatically: |---|---| | Creates the corpus directory | `fuzz/corpus/fuzz_my_feature/` | | Writes a typed fuzz target template | `fuzz/fuzz_targets/fuzz_my_feature.rs` | -| Appends `[[bin]]` entry | `fuzz/Cargo.toml` | +| Appends `[[bin]]` entry to `fuzz/Cargo.toml` | Covers **both** the libFuzzer and AFL++ lanes | | Inserts target into every CI matrix + perf loop | `.github/workflows/fuzz.yml` | -The generated template uses `ArbNSSATransaction` from `fuzz_props::arbitrary_types` -so libfuzzer drives every field of `NSSATransaction` independently — no manual -`Unstructured` wiring required. +The generated template uses `fuzz_props::fuzz_entry!` and works with both engines +without modification. ### Step 2 — Implement the target @@ -110,13 +149,15 @@ function under test and any invariant assertions. Use the typed wrappers from structured input, or the proptest generators from [`fuzz_props::generators`](../fuzz_props/src/generators.rs) for richer strategies. -### Step 3 — Register the binary (automated) +### Step 3 — Automated registration (cargo-fuzz + CI) `just new-target` calls [`scripts/add_fuzz_target.py`](../scripts/add_fuzz_target.py) -which appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](../fuzz/Cargo.toml) -automatically. Once present, `cargo fuzz list` (and therefore `just fuzz`, -`just fuzz-regression`, `just corpus-cmin`) pick up the target automatically — no -further Justfile edits required. +which: +- Appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](../fuzz/Cargo.toml). + This **single entry** covers both the libFuzzer lane (`cargo fuzz build`) and + the AFL++ lane (`cargo afl build --no-default-features --features fuzzer-afl`). +- Inserts the target name into every strategy matrix and the perf-baseline shell + loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml). > **Manual fallback:** if you create a target without `just new-target`, add the > entry yourself: @@ -129,21 +170,19 @@ further Justfile edits required. > bench = false > ``` -### Step 4 — Add to CI matrix (automated) - -`just new-target` also inserts `fuzz_my_feature` into every strategy matrix and the -perf-baseline shell loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml) -automatically via `scripts/add_fuzz_target.py`. - -> **Manual fallback:** if you created the target without `just new-target`, add -> `- fuzz_my_feature` to the `target:` list in the three places shown in -> `.github/workflows/fuzz.yml` (smoke-fuzz, regression, perf-baseline). - -### Step 5 — Verify +### Step 4 — Verify ```bash +# Verify the libFuzzer build RISC0_DEV_MODE=1 cargo fuzz build fuzz_my_feature just fuzz-regression # runs the new target against its (empty) corpus + +# Verify the AFL++ build (same fuzz/Cargo.toml — no separate manifest needed) +cd fuzz && cargo afl build \ + --no-default-features \ + --features fuzzer-afl \ + --release \ + --bin fuzz_my_feature ``` ### Quick reference: what to touch @@ -152,12 +191,193 @@ just fuzz-regression # runs the new target against its (empty) corpus |---|---|---| | `fuzz/fuzz_targets/fuzz_.rs` | Create | ✅ `just new-target` | | `fuzz/corpus/fuzz_/` | Create | ✅ `just new-target` | -| `fuzz/Cargo.toml` | Add `[[bin]]` | ✅ `just new-target` | +| `fuzz/Cargo.toml` | Add `[[bin]]` (covers both lanes) | ✅ `just new-target` | | `Justfile` | Nothing — auto-discovers | ✅ automatic | | `.github/workflows/fuzz.yml` | Add to 3 matrix lists | ✅ `just new-target` | --- +## AFL++ Parallel Fuzzing Lane + +### Prerequisites + +Install AFL++ natively on your machine. + +> **Note on Linux package versions**: The `afl++` package in Debian stable (Bookworm) +> and Ubuntu LTS is several major versions behind the current AFL++ 4.x series and may +> be incompatible with `cargo-afl`. **Build from source** for a current version. + +```bash +# macOS — Homebrew keeps the formula up to date +brew install afl-fuzz + +# Linux — build from source (~5 min) +git clone https://github.com/AFLplusplus/AFLplusplus +cd AFLplusplus +make distrib # builds all components: afl-fuzz, afl-cc, afl-clang-fast, … +sudo make install +cd .. + +# Rust build wrapper (all platforms) +cargo install cargo-afl +``` + +> **macOS: run `afl-system-config` once before fuzzing** — AFL++ uses System V shared +> memory (`shmget`) to pass coverage bitmaps between the fuzzer and the target. macOS +> ships with very small defaults (`kern.sysv.shmmax = 4 MB`, `kern.sysv.shmmni = 32`) +> that are exhausted as soon as multiple AFL++ instances start in parallel, causing every +> run to abort immediately with: +> +> ``` +> [-] SYSTEM ERROR : shmget() failed, try running afl-system-config +> OS message : Invalid argument +> ``` +> +> Fix by running the AFL++ system-configuration helper once per boot (or after every +> macOS update): +> +> ```bash +> sudo afl-system-config +> ``` +> +> This raises `shmmax`, `shmmni`, `shmall`, and related limits to values suitable for +> parallel fuzzing. The change is not persistent across reboots, so re-run it after +> each restart. The `just fuzz-afl` and `just fuzz-afl-parallel` recipes **do not** +> call this automatically because it requires `sudo`. + +> **macOS: crash reporter must be disabled** — AFL++ detects the macOS `ReportCrash` +> daemon and aborts if it is active (it delays crash notifications and causes AFL++ to +> mis-classify crashes as timeouts). The `just fuzz-afl` and `just fuzz-afl-parallel` +> recipes disable it automatically for the duration of the run and re-enable it on exit +> (via a shell `trap`). You can also manage it manually: +> +> ```bash +> # Disable (run once before a long session) +> just afl-macos-setup +> +> # Re-enable afterward +> just afl-macos-teardown +> ``` +> +> Or use the raw `launchctl` commands shown in the AFL++ error message: +> +> ```bash +> SL=/System/Library; PL=com.apple.ReportCrash +> launchctl unload -w ${SL}/LaunchAgents/${PL}.plist +> sudo launchctl unload -w ${SL}/LaunchDaemons/${PL}.Root.plist +> ``` + +### Build + +```bash +# All targets +just afl-build + +# Single target +just afl-build-target fuzz_state_transition +``` + +Both commands compile `fuzz/` with `--no-default-features --features fuzzer-afl`. +Output binaries land in `fuzz/target/release/`. + +### Run (single instance) + +```bash +# 120-second smoke run +just fuzz-afl fuzz_state_transition + +# Custom duration +just fuzz-afl fuzz_state_transition 600 +``` + +### Run (parallel) + +```bash +# 1 main + 3 secondary instances for 5 minutes +just fuzz-afl-parallel fuzz_state_transition 4 300 + +# AFL++ rule: always start the main instance first; +# secondary instances are started with -S flags automatically. +``` + +### Monitor + +```bash +just afl-status fuzz_state_transition +# … calls afl-whatsup afl-output/fuzz_state_transition +``` + +### Triage + +```bash +# Minimise a crash artifact to the smallest reproducing input +just afl-tmin fuzz_state_transition afl-output/fuzz_state_transition/default/crashes/id:000000,... + +# Pretty-print as Rust byte literal (for pasting into a unit test) +just afl-fmt afl-output/fuzz_state_transition/default/crashes/id:000000,... +``` + +### Sync queue to shared corpus + +```bash +# Copies afl-output/*/queue/id:* into fuzz/corpus// +# Run this after any AFL++ session to share findings with cargo-fuzz +just afl-corpus-sync +``` + +### How the shared harness works + +| Mechanism | libFuzzer | AFL++ | +|---|---|---| +| **Entry macro** | `::libfuzzer_sys::fuzz_target!(…)` | `::afl::fuzz!(…)` inside `fn main()` | +| **`no_main` suppression** | `#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]` | Not applied (AFL++ needs a real `main`) | +| **Feature gate** | `cfg(feature = "fuzzer-libfuzzer")` | `cfg(feature = "fuzzer-afl")` | +| **Feature resolution** | Resolved at `fuzz/` (calling crate), not at `fuzz_props/` | Same | +| **`libfuzzer-sys` dep** | Optional, active under `fuzzer-libfuzzer` | Not compiled — avoids `main()` conflict | +| **`afl` dep** | Not compiled | Optional, active under `fuzzer-afl` | +| **Default build** | `default = ["fuzzer-libfuzzer"]` → `cargo fuzz` just works | Requires `--no-default-features --features fuzzer-afl` | + +The `fuzz_props::fuzz_entry!` macro defined in [`fuzz_props/src/lib.rs`](../fuzz_props/src/lib.rs) +expands to the right entry point based on the active feature: + +```rust +#[macro_export] +macro_rules! fuzz_entry { + (|$data:ident: &[u8]| $body:block) => { + #[cfg(feature = "fuzzer-libfuzzer")] + ::libfuzzer_sys::fuzz_target!(|$data: &[u8]| $body); + + #[cfg(feature = "fuzzer-afl")] + fn main() { + ::afl::fuzz!(|$data: &[u8]| $body); + } + }; +} +``` + +### CI (`.github/workflows/fuzz-afl.yml`) + +The nightly AFL++ CI workflow has two jobs: + +| Job | Triggers | Matrix | +|-----|----------|--------| +| `afl-smoke` | nightly + `workflow_dispatch` | 7 priority targets, 120 s each | +| `afl-coverage` | nightly, `needs: afl-smoke` | 3 key targets; LLVM HTML report | + +The smoke job: +1. Builds the target with `cargo afl build --no-default-features --features fuzzer-afl` +2. Runs `afl-fuzz` for 120 s in `aflplusplus/aflplusplus:v4.40c` container +3. Syncs new queue entries into `fuzz/corpus//` and opens a corpus PR +4. Uploads crashes/hangs as a workflow artifact + +The coverage job: +1. Downloads the smoke findings +2. Rebuilds with `RUSTFLAGS="-C instrument-coverage"` +3. Runs all corpus + queue inputs through the binary +4. Merges `.profraw` → `.profdata` → HTML report via `llvm-cov show` + +--- + ## Updating the LEZ Dependency `lez-fuzzing` reads LEZ source directly from `../logos-execution-zone`. To pick up LEZ @@ -186,18 +406,27 @@ just update-lez When `cargo fuzz` finds a crash it writes an artifact to `fuzz/artifacts/fuzz_/crash-`. -### Minimise +### Minimise (libFuzzer) ```bash # Produces a smaller input that still triggers the same crash just fuzz-tmin fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123 ``` +### Minimise (AFL++) + +```bash +just afl-tmin fuzz_state_transition afl-output/fuzz_state_transition/default/crashes/id:000000,... +``` + ### Convert to a regression test ```bash -# Print the bytes as a Rust byte-literal (paste into a #[test]) +# libFuzzer: print bytes as a Rust byte-literal cargo fuzz fmt fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123 + +# AFL++: print bytes as a Rust byte-literal +just afl-fmt afl-output/fuzz_state_transition/default/crashes/id:000000,... ``` Add the minimised file to the corpus so CI always reproduces it: @@ -211,6 +440,42 @@ Open a PR. The `regression` CI job will permanently block re-introduction of thi --- +## Coverage Reports + +### Step 1 — libFuzzer coverage (via `cargo fuzz coverage`) + +```bash +# Generates coverage for a single target +cargo fuzz coverage fuzz_state_transition + +# Generates coverage for all targets +just coverage-all +``` + +Reports land in `fuzz/coverage//`. + +### Step 2 — AFL++ LLVM coverage + +Run after a successful AFL++ session (queue data in `afl-output//`): + +```bash +# Combines libFuzzer + AFL++ corpus into a single LLVM HTML report +just coverage fuzz_state_transition +``` + +This: +1. Runs `cargo fuzz coverage` (step 1) +2. Detects `afl-output/fuzz_state_transition/` and builds the target with + `RUSTFLAGS="-C instrument-coverage" cargo build --manifest-path fuzz/Cargo.toml --no-default-features --features fuzzer-afl --release` +3. Runs all AFL++ queue entries through the binary, collects `.profraw` files +4. Merges profiles with `llvm-profdata merge` and generates an HTML report with `llvm-cov show` +5. Writes the report to `coverage/afl/fuzz_state_transition/html/index.html` + +The AFL++ CI coverage job (`afl-coverage` in [`.github/workflows/fuzz-afl.yml`](../.github/workflows/fuzz-afl.yml)) +automates steps 2–5 and uploads the report as a workflow artifact. + +--- + ## Invariant Framework Shared invariants live in `fuzz_props/src/invariants.rs`. Each invariant implements @@ -242,6 +507,8 @@ Concrete invariants currently registered in `assert_invariants()`: > whose signer-account list is private to the `nssa` crate. The caller must derive signer > IDs from the transaction's witness set before consuming the diff, then call the standalone > `assert_nonce_increment_correctness(signer_ids, nonces_before, state_after)` helper. +> The `signer_account_ids()` helper in `fuzz_props::generators` extracts signer `AccountId`s +> from an `NSSATransaction`'s witness set. Additional invariants enforced **inline** in specific targets (not via `ProtocolInvariant`): @@ -289,6 +556,7 @@ fuzz target parameters for zero-boilerplate structured fuzzing. | `arb_fuzz_native_transfer()` | Correctly-signed native-transfer `NSSATransaction` referencing accounts from an `arbitrary_fuzz_state()` result; gives the fuzzer a path to successful state transitions | | `arbitrary_transaction()` | Structured `NSSATransaction` (`Public` or `ProgramDeployment`) from unstructured bytes via `ArbNSSATransaction` | | `arb_borsh_transaction_bytes()` | Raw Borsh bytes including invalid encodings | +| `signer_account_ids()` | Extracts `AccountId`s of all signers from an `NSSATransaction`'s witness set; used to derive signer IDs before `apply_state_diff` consumes the diff | | `arb_native_transfer_tx()` | Valid native-transfer `NSSATransaction` between known testnet genesis accounts (proptest strategy) | | `test_accounts()` | Returns `(AccountId, PrivateKey)` pairs from `testnet_initial_state` | | `arb_hashable_block_data()` | `HashableBlockData` with 0–8 valid native transfers (proptest strategy) | @@ -326,9 +594,12 @@ Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`: Recommended local settings for longer runs: ```bash -# Use all available cores +# libFuzzer — use all available cores RISC0_DEV_MODE=1 cargo fuzz run fuzz_state_transition \ -- -max_total_time=3600 -jobs=$(nproc) -workers=$(nproc) + +# AFL++ — parallel (1 main + N-1 secondary) +just fuzz-afl-parallel fuzz_state_transition $(nproc) 3600 ``` --- @@ -354,6 +625,6 @@ flag stubs out ZK proof generation and replaces it with a fast mock implementati |------|-------| | `PrivacyPreservingTransaction` coverage | Excluded from `fuzz_encoding_roundtrip` because its ZK receipt cannot be reconstructed in a fuzzing loop. A dedicated slow target with `RISC0_DEV_MODE=1` and `proptest` should be added after the current targets are stable | | `fuzz_validate_execute_consistency` new-account detection | If `execute_check_on_state` creates a brand-new account absent from both the genesis set and the diff, that state-widening will not be detected — full detection requires iterating all accounts in `V03State`, which the API does not currently expose | -| AFL++ integration | A `just fuzz-afl` recipe can be added later; the same corpus is compatible | | Differential testing (sequencer vs replayer) | ✅ Implemented — `fuzz_sequencer_vs_replayer` feeds the same block through the sequencer path (`validate_on_state` → `apply_state_diff`) and the replayer path (`execute_check_on_state`) and asserts identical state for all known accounts | +| AFL++ integration | ✅ Implemented — `just afl-build`, `just fuzz-afl`, `just fuzz-afl-parallel`; nightly CI in `.github/workflows/fuzz-afl.yml`; single `fuzz/Cargo.toml` covers both engines via feature flags | | LEZ version tracking | There is no submodule pin — `lez-fuzzing` reads `../logos-execution-zone` as checked out. Update that repo to a release tag or a tested commit, then run `just update-lez` (which does `git pull --ff-only`) and open a PR to bump it | diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 7173479..5ad1b08 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -37,6 +37,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "afl" +version = "0.15.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927cd71710d1a232519e2393470e8f74a178ae59367efe58fa122884bba35ca4" +dependencies = [ + "home", + "libc", + "rustc_version", + "xdg", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1994,6 +2006,7 @@ dependencies = [ name = "fuzz" version = "0.1.0" dependencies = [ + "afl", "arbitrary", "borsh", "common", @@ -2300,6 +2313,15 @@ version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.3.1" @@ -7398,6 +7420,12 @@ dependencies = [ "time", ] +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + [[package]] name = "xml-rs" version = "0.8.28" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 726b805..d2d8e87 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -34,8 +34,14 @@ path = "fuzz_targets/fuzz_block_verification.rs" test = false bench = false +[features] +default = ["fuzzer-libfuzzer"] +fuzzer-libfuzzer = ["libfuzzer-sys", "fuzz_props/fuzzer-libfuzzer"] +fuzzer-afl = ["afl", "fuzz_props/fuzzer-afl"] + [dependencies] -libfuzzer-sys = "0.4" +libfuzzer-sys = { version = "0.4", optional = true } +afl = { version = "0.15", optional = true } arbitrary = { version = "1", features = ["derive"] } borsh = "1" nssa = { path = "../../logos-execution-zone/nssa" } diff --git a/fuzz/fuzz_targets/_template.rs b/fuzz/fuzz_targets/_template.rs index e4a4bcc..38e72d5 100644 --- a/fuzz/fuzz_targets/_template.rs +++ b/fuzz/fuzz_targets/_template.rs @@ -1,23 +1,9 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +// use fuzz_props::arbitrary_types::*; +// use fuzz_props::generators::*; +// use fuzz_props::invariants::*; -use fuzz_props::arbitrary_types::ArbNSSATransaction; -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|wrapped: ArbNSSATransaction| { - let tx = wrapped.0; - - // ── Stateless gate ──────────────────────────────────────────────────────── - // Remove this block to fuzz malformed / unsigned transactions too. - let Ok(tx) = tx.transaction_stateless_check() else { - return; - }; - - // ── Call the function under test ────────────────────────────────────────── - // Example: - // let mut state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); - // let result = tx.execute_check_on_state(&mut state, block_id, timestamp); - - // ── Assert invariants ───────────────────────────────────────────────────── - // Use fuzz_props::invariants::assert_invariants(&ctx) or inline assertions. - let _ = tx; // replace once the target body is implemented +fuzz_props::fuzz_entry!(|data: &[u8]| { + // TODO: implement harness body + let _ = data; }); diff --git a/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs b/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs index 6db12ec..7cd5fe8 100644 --- a/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs +++ b/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: `validate_on_state` → `apply_state_diff` split path vs //! `execute_check_on_state` direct path. //! @@ -33,14 +33,12 @@ use std::collections::HashSet; use arbitrary::{Arbitrary, Unstructured}; -use common::transaction::NSSATransaction; use fuzz_props::arbitrary_types::ArbNSSATransaction; -use fuzz_props::generators::arbitrary_fuzz_state; +use fuzz_props::generators::{arbitrary_fuzz_state, signer_account_ids}; use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness}; -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Generate a fuzz-driven initial state. @@ -75,23 +73,7 @@ fuzz_target!(|data: &[u8]| { }; // ── Extract signer IDs and capture nonce snapshot before apply ──────────── - // Signer IDs are private to ValidatedStateDiff; derive them from the transaction's - // witness set before the diff is consumed by apply_state_diff. - let signer_ids: Vec = match &tx { - NSSATransaction::Public(pub_tx) => pub_tx - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::PrivacyPreserving(pp_tx) => pp_tx - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::ProgramDeployment(_) => vec![], - }; + let signer_ids = signer_account_ids(&tx); let nonces_before = NonceSnapshot( signer_ids .iter() diff --git a/fuzz/fuzz_targets/fuzz_block_verification.rs b/fuzz/fuzz_targets/fuzz_block_verification.rs index 2d8847b..0973bea 100644 --- a/fuzz/fuzz_targets/fuzz_block_verification.rs +++ b/fuzz/fuzz_targets/fuzz_block_verification.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: block hash integrity — three invariants unique to block-level validation. //! //! 1. **Hash integrity via `From` round-trip** — `HashableBlockData::from(block)` @@ -21,12 +21,11 @@ use arbitrary::{Arbitrary, Unstructured}; use common::block::HashableBlockData; use fuzz_props::arbitrary_types::ArbHashableBlockData; -use libfuzzer_sys::fuzz_target; use nssa::PrivateKey; const DUMMY_KEY_BYTES: [u8; 32] = [1u8; 32]; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); let Ok(wrap) = ArbHashableBlockData::arbitrary(&mut u) else { return; diff --git a/fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs b/fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs index cc9268f..936b0be 100644 --- a/fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs +++ b/fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: encoding round-trip for all transaction types. //! //! Invariants exercised: @@ -18,10 +18,9 @@ use arbitrary::{Arbitrary, Unstructured}; use fuzz_props::arbitrary_types::{ArbProgramDeploymentTransaction, ArbPublicTransaction}; -use libfuzzer_sys::fuzz_target; use nssa::{ProgramDeploymentTransaction, PublicTransaction}; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // ── Test 1: PublicTransaction round-trip ────────────────────────────────── diff --git a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs index 299bab3..bb084de 100644 --- a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs +++ b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: multi-block transaction sequence with long-range invariants. //! //! Verifies properties that span an entire *sequence* of blocks: @@ -35,16 +35,14 @@ //! the total; only mint/burn bugs or token-inflation bugs would break it. use arbitrary::{Arbitrary, Unstructured}; -use common::transaction::NSSATransaction; -use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids}; use fuzz_props::invariants::{ BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, assert_replay_rejection, }; -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Generate a fuzz-driven initial state. @@ -120,22 +118,8 @@ fuzz_target!(|data: &[u8]| { // First verify every signer's nonce was incremented by exactly one, then // replay in the next block to confirm the nonce is permanently consumed. if let Ok(applied_tx) = result { - let signer_ids: Vec = match &applied_tx { - NSSATransaction::Public(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::PrivacyPreserving(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::ProgramDeployment(_) => vec![], - }; - assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); + let ids = signer_account_ids(&applied_tx); + assert_nonce_increment_correctness(&ids, &nonces_before, &state); assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); } } diff --git a/fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs b/fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs index 8631f5c..03040ac 100644 --- a/fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs +++ b/fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: `V03State::transition_from_program_deployment_transaction`. //! //! The deployment path runs `ValidatedStateDiff::from_program_deployment_transaction` @@ -24,10 +24,9 @@ use arbitrary::{Arbitrary, Unstructured}; use fuzz_props::arbitrary_types::ArbProgramDeploymentTransaction; use fuzz_props::generators::arbitrary_fuzz_state; -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Generate a fuzz-driven initial state. diff --git a/fuzz/fuzz_targets/fuzz_replay_prevention.rs b/fuzz/fuzz_targets/fuzz_replay_prevention.rs index 1aacfcb..1bc2d63 100644 --- a/fuzz/fuzz_targets/fuzz_replay_prevention.rs +++ b/fuzz/fuzz_targets/fuzz_replay_prevention.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: transaction replay prevention. //! //! Invariant: a transaction that is accepted in block N must be rejected when @@ -23,16 +23,16 @@ //! - **ReplayRejection** — accepted tx rejected on replay use arbitrary::{Arbitrary, Unstructured}; -use common::transaction::NSSATransaction; -use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, - assert_replay_rejection, +use fuzz_props::generators::{ + arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids, +}; +use fuzz_props::invariants::{ + BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, + assert_nonce_increment_correctness, assert_replay_rejection, }; -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Generate a fuzz-driven initial state. @@ -96,21 +96,7 @@ fuzz_target!(|data: &[u8]| { // First verify every signer's nonce was incremented by exactly one, then // assert that replaying in the next block is rejected (nonce permanently consumed). if let Ok(applied_tx) = result { - let signer_ids: Vec = match &applied_tx { - NSSATransaction::Public(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::PrivacyPreserving(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::ProgramDeployment(_) => vec![], - }; + let signer_ids = signer_account_ids(&applied_tx); assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); assert_replay_rejection(applied_tx, &mut state, 2, 1); } diff --git a/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs b/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs index 5cbc4a2..9b46905 100644 --- a/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs +++ b/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: sequencer vs replayer differential state-root equivalence. //! //! Feeds the same block of transactions through two independent state-transition @@ -40,10 +40,9 @@ use std::collections::HashSet; use arbitrary::{Arbitrary, Unstructured}; use common::transaction::{NSSATransaction, clock_invocation}; use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // ── Initial state ───────────────────────────────────────────────────────── diff --git a/fuzz/fuzz_targets/fuzz_signature_verification.rs b/fuzz/fuzz_targets/fuzz_signature_verification.rs index 83c3221..71db2ae 100644 --- a/fuzz/fuzz_targets/fuzz_signature_verification.rs +++ b/fuzz/fuzz_targets/fuzz_signature_verification.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: signature creation and verification. //! //! Invariants exercised: @@ -12,10 +12,9 @@ use arbitrary::{Arbitrary, Unstructured}; use fuzz_props::arbitrary_types::{ArbPrivateKey, ArbPublicKey, ArbSignature}; -use libfuzzer_sys::fuzz_target; use nssa::{PublicKey, Signature}; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // ── 1. Freshly signed message always verifies with the correct key ───────── diff --git a/fuzz/fuzz_targets/fuzz_state_diff_computation.rs b/fuzz/fuzz_targets/fuzz_state_diff_computation.rs index 410afb3..0c912fe 100644 --- a/fuzz/fuzz_targets/fuzz_state_diff_computation.rs +++ b/fuzz/fuzz_targets/fuzz_state_diff_computation.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: state diff isolation — bidirectional. //! //! Invariants: @@ -22,10 +22,9 @@ use arbitrary::{Arbitrary, Unstructured}; use common::transaction::NSSATransaction; use fuzz_props::arbitrary_types::ArbPublicTransaction; use fuzz_props::generators::arbitrary_fuzz_state; -use libfuzzer_sys::fuzz_target; use nssa::{V03State, ValidatedStateDiff}; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Generate a fuzz-driven initial state. diff --git a/fuzz/fuzz_targets/fuzz_state_serialization.rs b/fuzz/fuzz_targets/fuzz_state_serialization.rs index 535201c..d0cde75 100644 --- a/fuzz/fuzz_targets/fuzz_state_serialization.rs +++ b/fuzz/fuzz_targets/fuzz_state_serialization.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: `V03State` Borsh serialization/deserialization. //! //! The state blob is transmitted between nodes and persisted to disk, so a panic or @@ -22,10 +22,9 @@ //! place for a logic bug — and the fuzzer should be steered towards exercising //! the duplicate-nullifier code path. -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { // ── Invariant 1: NoPanic ────────────────────────────────────────────────── // `borsh::from_slice` must never panic. If it returns `Err`, we simply // return early; only structurally valid blobs proceed to the round-trip check. diff --git a/fuzz/fuzz_targets/fuzz_state_transition.rs b/fuzz/fuzz_targets/fuzz_state_transition.rs index 8dc3de0..4915237 100644 --- a/fuzz/fuzz_targets/fuzz_state_transition.rs +++ b/fuzz/fuzz_targets/fuzz_state_transition.rs @@ -1,16 +1,16 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] use arbitrary::{Arbitrary, Unstructured}; -use common::transaction::NSSATransaction; -use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, - assert_replay_rejection, +use fuzz_props::generators::{ + arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids, +}; +use fuzz_props::invariants::{ + BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, + assert_nonce_increment_correctness, assert_replay_rejection, }; -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Generate a fuzz-driven initial state instead of always using the fixed @@ -95,21 +95,7 @@ fuzz_target!(|data: &[u8]| { // First verify every signer's nonce was incremented by exactly one, then // replay in the next block to confirm the nonce is permanently consumed. if let Ok(applied_tx) = result { - let signer_ids: Vec = match &applied_tx { - NSSATransaction::Public(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::PrivacyPreserving(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::ProgramDeployment(_) => vec![], - }; + let signer_ids = signer_account_ids(&applied_tx); assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); } diff --git a/fuzz/fuzz_targets/fuzz_stateless_verification.rs b/fuzz/fuzz_targets/fuzz_stateless_verification.rs index 3cb792e..767edf1 100644 --- a/fuzz/fuzz_targets/fuzz_stateless_verification.rs +++ b/fuzz/fuzz_targets/fuzz_stateless_verification.rs @@ -1,11 +1,10 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] use arbitrary::Unstructured; use common::transaction::NSSATransaction; use fuzz_props::generators::arbitrary_transaction; -use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Path A: try to build a structured transaction from unstructured bytes diff --git a/fuzz/fuzz_targets/fuzz_transaction_decoding.rs b/fuzz/fuzz_targets/fuzz_transaction_decoding.rs index fae71e5..07a4302 100644 --- a/fuzz/fuzz_targets/fuzz_transaction_decoding.rs +++ b/fuzz/fuzz_targets/fuzz_transaction_decoding.rs @@ -1,12 +1,11 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] use common::{ block::{Block, HashableBlockData}, transaction::NSSATransaction, }; -use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { // Attempt 1: decode as NSSATransaction and verify roundtrip if let Ok(tx) = borsh::from_slice::(data) { let re_encoded = borsh::to_vec(&tx).expect("re-encode of valid tx must succeed"); diff --git a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs index d9f1b72..b8a8b95 100644 --- a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs +++ b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: `validate_on_state` and `execute_check_on_state` consistency. //! //! Invariants: @@ -25,14 +25,12 @@ //! reachable by the fuzzer. use arbitrary::{Arbitrary, Unstructured}; -use common::transaction::NSSATransaction; use fuzz_props::arbitrary_types::ArbNSSATransaction; -use fuzz_props::generators::arbitrary_fuzz_state; +use fuzz_props::generators::{arbitrary_fuzz_state, signer_account_ids}; use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness}; -use libfuzzer_sys::fuzz_target; use nssa::V03State; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // Generate a fuzz-driven initial state. The state shape — account IDs, @@ -160,21 +158,7 @@ fuzz_target!(|data: &[u8]| { // consistency checks above: it catches bugs where validate_on_state and // execute_check_on_state agree (passing INVARIANT 1) but both increment // the wrong account's nonce, or skip the increment entirely. - let signer_ids: Vec = match &applied_tx { - NSSATransaction::Public(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::PrivacyPreserving(t) => t - .witness_set() - .signatures_and_public_keys() - .iter() - .map(|(_, pk)| nssa::AccountId::from(pk)) - .collect(), - NSSATransaction::ProgramDeployment(_) => vec![], - }; + let signer_ids = signer_account_ids(&applied_tx); assert_nonce_increment_correctness(&signer_ids, &nonces_before, &exec_state); } (Err(_), Err(_)) => { diff --git a/fuzz/fuzz_targets/fuzz_witness_set_verification.rs b/fuzz/fuzz_targets/fuzz_witness_set_verification.rs index dc4102f..585cbb1 100644 --- a/fuzz/fuzz_targets/fuzz_witness_set_verification.rs +++ b/fuzz/fuzz_targets/fuzz_witness_set_verification.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] //! Fuzz target: `WitnessSet` authentication isolation for public transactions. //! //! The most security-critical property of `WitnessSet` is **message isolation**: @@ -23,10 +23,9 @@ use arbitrary::{Arbitrary, Unstructured}; use fuzz_props::arbitrary_types::{ArbPrivateKey, ArbPubTxMessage, ArbWitnessSet}; -use libfuzzer_sys::fuzz_target; use nssa::{PublicKey, public_transaction::WitnessSet}; -fuzz_target!(|data: &[u8]| { +fuzz_props::fuzz_entry!(|data: &[u8]| { let mut u = Unstructured::new(data); // ── Invariant 1: NoPanic on adversarial WitnessSet ──────────────────────── diff --git a/fuzz_props/Cargo.toml b/fuzz_props/Cargo.toml index ad96ec6..a6796bd 100644 --- a/fuzz_props/Cargo.toml +++ b/fuzz_props/Cargo.toml @@ -6,6 +6,10 @@ edition = "2024" [lints] workspace = true +[features] +fuzzer-libfuzzer = [] +fuzzer-afl = [] + [dependencies] nssa = { workspace = true } nssa_core = { workspace = true } diff --git a/fuzz_props/src/arbitrary_types.rs b/fuzz_props/src/arbitrary_types.rs index 6804c34..d920d6c 100644 --- a/fuzz_props/src/arbitrary_types.rs +++ b/fuzz_props/src/arbitrary_types.rs @@ -118,13 +118,10 @@ impl<'a> Arbitrary<'a> for ArbPublicKey { // rejection path in `is_valid_for` independently. let bytes = <[u8; 32]>::arbitrary(u)?; let pk = PublicKey::try_new(bytes).unwrap_or_else(|_| { - PublicKey::new_from_private_key( - &ArbPrivateKey::arbitrary(u) - .map(|w| w.0) - .unwrap_or_else(|_| { - PrivateKey::try_new([1_u8; 32]).expect("known-good seed") - }), - ) + PublicKey::new_from_private_key(&ArbPrivateKey::arbitrary(u).map_or_else( + |_| PrivateKey::try_new([1_u8; 32]).expect("known-good seed"), + |w| w.0, + )) }); Ok(Self(pk)) } @@ -145,11 +142,11 @@ impl<'a> Arbitrary<'a> for ArbPubTxMessage { let program_id: [u32; 8] = <[u32; 8]>::arbitrary(u)?; // Generate 0–7 accounts; nonces vector is given the same length. let len = (u8::arbitrary(u)? as usize) % 8; - let account_ids = (0..len) - .map(|_| ArbAccountId::arbitrary(u).map(|a| a.0)) + let account_ids = std::iter::repeat_with(|| ArbAccountId::arbitrary(u).map(|a| a.0)) + .take(len) .collect::>>()?; - let nonces = (0..len) - .map(|_| ArbNonce::arbitrary(u).map(|n| n.0)) + let nonces = std::iter::repeat_with(|| ArbNonce::arbitrary(u).map(|n| n.0)) + .take(len) .collect::>>()?; let instruction_data: Vec = Vec::::arbitrary(u)?; Ok(Self(Message::new_preserialized( @@ -174,9 +171,11 @@ impl<'a> Arbitrary<'a> for ArbWitnessSet { fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { // 0–3 (signature, public_key) pairs let n = (u8::arbitrary(u)? as usize) % 4; - let pairs = (0..n) - .map(|_| Ok((ArbSignature::arbitrary(u)?.0, ArbPublicKey::arbitrary(u)?.0))) - .collect::>>()?; + let pairs = std::iter::repeat_with(|| { + Ok((ArbSignature::arbitrary(u)?.0, ArbPublicKey::arbitrary(u)?.0)) + }) + .take(n) + .collect::>>()?; Ok(Self(WitnessSet::from_raw_parts(pairs))) } } @@ -247,8 +246,8 @@ impl<'a> Arbitrary<'a> for ArbHashableBlockData { fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { // 0–7 transactions per block let n = (u8::arbitrary(u)? as usize) % 8; - let transactions = (0..n) - .map(|_| ArbNSSATransaction::arbitrary(u).map(|t| t.0)) + let transactions = std::iter::repeat_with(|| ArbNSSATransaction::arbitrary(u).map(|t| t.0)) + .take(n) .collect::>>()?; Ok(Self(HashableBlockData { block_id: u64::arbitrary(u)?, diff --git a/fuzz_props/src/generators.rs b/fuzz_props/src/generators.rs index 4d2b165..def495f 100644 --- a/fuzz_props/src/generators.rs +++ b/fuzz_props/src/generators.rs @@ -6,6 +6,31 @@ use crate::arbitrary_types::{ArbAccountId, ArbNSSATransaction, ArbPrivateKey}; use proptest::prelude::*; use testnet_initial_state::initial_pub_accounts_private_keys; +// ── Signer account ID extraction ───────────────────────────────────────────── + +/// Extract the [`AccountId`]s of all signers from a transaction's +/// witness set. Used by fuzz targets that need to verify nonce +/// increments after `execute_check_on_state`. +#[must_use] +pub fn signer_account_ids(tx: &common::transaction::NSSATransaction) -> Vec { + use common::transaction::NSSATransaction; + match tx { + NSSATransaction::Public(pt) => pt + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(pt) => pt + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + } +} + // ── Fuzz-driven state generation ───────────────────────────────────────────── /// An account with an arbitrary identifier, balance, and private key, @@ -28,15 +53,15 @@ pub struct FuzzAccount { /// has a shape controlled by the fuzzer rather than fixed at compile time. pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result> { let n = ((u8::arbitrary(u)? as usize) % 8) + 1; // 1..=8 - (0..n) - .map(|_| { - Ok(FuzzAccount { - account_id: ArbAccountId::arbitrary(u)?.0, - balance: u128::arbitrary(u)?, - private_key: ArbPrivateKey::arbitrary(u)?.0, - }) + std::iter::repeat_with(|| { + Ok(FuzzAccount { + account_id: ArbAccountId::arbitrary(u)?.0, + balance: u128::arbitrary(u)?, + private_key: ArbPrivateKey::arbitrary(u)?.0, }) - .collect() + }) + .take(n) + .collect() } /// Generate a native-transfer [`NSSATransaction`] between two accounts chosen @@ -91,8 +116,8 @@ prop_compose! { )( from_idx in 0..accounts.len(), to_idx in 0..accounts.len(), - nonce in 0u128..1_000u128, - amount in 0u128..10_000u128, + nonce in 0_u128..1_000_u128, + amount in 0_u128..10_000_u128, ) -> NSSATransaction { let (from_id, from_key) = &accounts[from_idx]; let (to_id, _) = &accounts[to_idx]; @@ -103,6 +128,7 @@ prop_compose! { } /// Return the test accounts from `testnet_initial_state` as `(AccountId, PrivateKey)` pairs. +#[must_use] pub fn test_accounts() -> Vec<(AccountId, PrivateKey)> { initial_pub_accounts_private_keys() .into_iter() @@ -144,9 +170,9 @@ prop_compose! { /// the state is left unchanged on rejection (StateIsolationOnFailure). pub fn arb_invalid_account_state_tx()( // Use a random 32-byte seed as a "phantom" account id not in genesis - phantom_id_bytes in proptest::array::uniform32(0u8..), + phantom_id_bytes in proptest::array::uniform32(0_u8..), amount in (u128::MAX / 2)..u128::MAX, // overflow-inducing amount - nonce in 0u128..10u128, + nonce in 0_u128..10_u128, ) -> NSSATransaction { let phantom_id = nssa::AccountId::new(phantom_id_bytes); // Attempt to sign with a key that has no matching on-chain account @@ -192,14 +218,14 @@ pub fn arb_duplicate_tx_sequence() -> impl Strategy pub fn arb_pathological_sequence() -> impl Strategy> { let accounts = test_accounts(); let n = accounts.len(); - proptest::collection::vec((0..n, 0..n, 0u128..5u128, any::()), 1..8_usize).prop_map( + proptest::collection::vec((0..n, 0..n, 0_u128..5_u128, any::()), 1..8_usize).prop_map( move |params| { params .into_iter() .map(|(from_idx, to_idx, nonce, zero_amount)| { let (from_id, from_key) = &accounts[from_idx]; let (to_id, _) = &accounts[to_idx]; - let amount = if zero_amount { 0u128 } else { u128::MAX }; // 0 or overflow + let amount = if zero_amount { 0_u128 } else { u128::MAX }; // 0 or overflow common::test_utils::create_transaction_native_token_transfer( *from_id, nonce, *to_id, amount, from_key, ) diff --git a/fuzz_props/src/invariants.rs b/fuzz_props/src/invariants.rs index 4f97886..b4d92bc 100644 --- a/fuzz_props/src/invariants.rs +++ b/fuzz_props/src/invariants.rs @@ -9,7 +9,7 @@ pub struct BalanceSnapshot(pub std::collections::HashMap) impl BalanceSnapshot { /// Capture current total balance over all known accounts. pub fn total(&self) -> u128 { - self.0.values().copied().fold(0u128, u128::saturating_add) + self.0.values().copied().fold(0_u128, u128::saturating_add) } } @@ -72,9 +72,8 @@ impl ProtocolInvariant for StateIsolationOnFailure { return Some(InvariantViolation { invariant: self.name(), message: format!( - "balance changed despite tx rejection: account {:?} had \ + "balance changed despite tx rejection: account {acc_id:?} had \ {expected_balance} before, {actual_balance} after", - acc_id, ), }); } @@ -106,7 +105,7 @@ impl ProtocolInvariant for BalanceConservation { .0 .keys() .map(|&id| ctx.state_after.get_account_by_id(id).balance) - .fold(0u128, u128::saturating_add); + .fold(0_u128, u128::saturating_add); if total_before != total_after { return Some(InvariantViolation { invariant: self.name(), @@ -142,10 +141,9 @@ impl ProtocolInvariant for FailedTxNonceStability { return Some(InvariantViolation { invariant: self.name(), message: format!( - "nonce changed despite tx rejection: account {:?} nonce was \ - {:?} before, {:?} after \ - (griefing attack — victim nonce permanently burned on failed tx)", - acc_id, expected_nonce, actual_nonce, + "nonce changed despite tx rejection: account {acc_id:?} nonce was \ + {expected_nonce:?} before, {actual_nonce:?} after \ + (griefing attack \u{2014} victim nonce permanently burned on failed tx)", ), }); } @@ -241,7 +239,7 @@ pub fn assert_replay_rejection( let replay = applied_tx.execute_check_on_state(state, next_block_id, next_timestamp); assert!( replay.is_err(), - "INVARIANT VIOLATION [ReplayRejection]: transaction accepted a second time — \ + "INVARIANT VIOLATION [ReplayRejection]: transaction accepted a second time \u{2014} \ nonce replay not prevented (replay block_id={next_block_id}, \ replay timestamp={next_timestamp})", ); @@ -298,15 +296,14 @@ pub fn assert_nonce_increment_correctness( nonce_before .0 .checked_add(1) - .expect("nonce overflow — signer nonce at u128::MAX"), + .expect("nonce overflow \u{2014} signer nonce at u128::MAX"), ); assert_eq!( nonce_after, expected, - "INVARIANT VIOLATION [NonceIncrementCorrectness]: signer account {:?} nonce \ + "INVARIANT VIOLATION [NonceIncrementCorrectness]: signer account {id:?} nonce \ not incremented by 1 after successful transaction \ - — before={:?}, expected={:?}, got={:?} \ + \u{2014} before={nonce_before:?}, expected={expected:?}, got={nonce_after:?} \ (apply_state_diff failed to increment nonce exactly once)", - id, nonce_before, expected, nonce_after, ); } } @@ -340,190 +337,3 @@ pub fn assert_invariants(ctx: &InvariantCtx<'_>) { } } } - -// ── Unit tests ──────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use nssa::V03State; - - fn make_empty_state() -> V03State { - V03State::new_with_genesis_accounts(&[], vec![], 0) - } - - fn make_empty_snapshot() -> BalanceSnapshot { - BalanceSnapshot(std::collections::HashMap::new()) - } - - fn make_empty_nonce_snapshot() -> NonceSnapshot { - NonceSnapshot(std::collections::HashMap::new()) - } - - #[test] - fn invariant_state_isolation_on_failure_does_not_panic_on_error() { - let state = make_empty_state(); - let ctx = InvariantCtx { - state_before: &state, - state_after: &state, - execution_succeeded: false, - balances_before: make_empty_snapshot(), - nonces_before: make_empty_nonce_snapshot(), - }; - assert_invariants(&ctx); - } - - #[test] - fn invariant_replay_rejection_does_not_panic() { - let state = make_empty_state(); - let ctx = InvariantCtx { - state_before: &state, - state_after: &state, - execution_succeeded: true, - balances_before: make_empty_snapshot(), - nonces_before: make_empty_nonce_snapshot(), - }; - assert_invariants(&ctx); - } - - #[test] - fn balance_conservation_catches_inflation_on_success() { - // Arrange: one account with balance 100. - let acc_id = nssa::AccountId::new([1u8; 32]); - let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); - // Simulate execution that inflated the balance to 200. - let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 200)], vec![], 0); - - let mut balances = std::collections::HashMap::new(); - balances.insert(acc_id, 100u128); - - let ctx = InvariantCtx { - state_before: &state_before, - state_after: &state_after, - execution_succeeded: true, - balances_before: BalanceSnapshot(balances), - nonces_before: make_empty_nonce_snapshot(), - }; - - let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); - assert!(result.is_err(), "expected panic for balance inflation"); - } - - #[test] - fn nonce_increment_correctness_passes_with_no_signers() { - // Empty signer list — no accounts to check; trivially satisfies the invariant. - let state = make_empty_state(); - assert_nonce_increment_correctness(&[], &make_empty_nonce_snapshot(), &state); - } - - #[test] - fn nonce_increment_correctness_passes_when_signer_not_in_snapshot() { - // Signer ID is present in the list but absent from the snapshot — skipped. - let acc_id = nssa::AccountId::new([9u8; 32]); - let state = make_empty_state(); - // Empty snapshot → `continue` branch fires; no assertion is made. - assert_nonce_increment_correctness(&[acc_id], &make_empty_nonce_snapshot(), &state); - } - - #[test] - fn nonce_increment_correctness_catches_unchanged_nonce() { - // Arrange: signer has nonce 5 in the snapshot; the state returns Nonce(0) for the - // same account (genesis default). expected = Nonce(6), actual = Nonce(0) → VIOLATION. - let acc_id = nssa::AccountId::new([3u8; 32]); - let state = V03State::new_with_genesis_accounts(&[], vec![], 0); - - let mut nonces = std::collections::HashMap::new(); - nonces.insert(acc_id, Nonce(5)); - - let result = std::panic::catch_unwind(|| { - assert_nonce_increment_correctness(&[acc_id], &NonceSnapshot(nonces), &state); - }); - assert!(result.is_err(), "expected panic for unchanged nonce"); - } - - #[test] - fn failed_tx_nonce_stability_catches_nonce_mutation() { - let acc_id = nssa::AccountId::new([2u8; 32]); - // before: nonce 5; after: nonce 6 (should not happen on failure) - let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); - let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); - - // We check the nonce snapshot directly; the states both return default nonce (0). - // Fake a discrepancy by inserting nonce=1 in the snapshot while state_after has nonce=0. - let mut nonces = std::collections::HashMap::new(); - // Nonce(1) in snapshot, but state_after will return Nonce(0). - nonces.insert(acc_id, Nonce(1)); - - let mut balances = std::collections::HashMap::new(); - balances.insert(acc_id, 100u128); - - let ctx = InvariantCtx { - state_before: &state_before, - state_after: &state_after, - execution_succeeded: false, - balances_before: BalanceSnapshot(balances), - nonces_before: NonceSnapshot(nonces), - }; - - let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); - assert!( - result.is_err(), - "expected panic for nonce mutation on failure" - ); - } -} - -// ── ReplayRejection proptest suite ─────────────────────────────────────────── -// -// This suite constitutes the formal, reproducible exercise of the ReplayRejection -// invariant. It generates a realistic initial state and a correctly-signed -// native-transfer transaction, applies it once, and asserts that a second -// application is rejected. -// -// Run with: cargo test -p fuzz_props replay_rejection -#[cfg(test)] -mod replay_proptest { - use crate::generators::{arb_native_transfer_tx, test_accounts}; - use nssa::V03State; - use proptest::prelude::*; - - /// Build a `V03State` from the testnet accounts, assigning each a fixed - /// balance large enough for any reasonable transfer amount. - fn make_test_state() -> V03State { - let accounts = test_accounts(); - let init_accs: Vec<(nssa::AccountId, u128)> = accounts - .iter() - .map(|(id, _)| (*id, 1_000_000u128)) - .collect(); - V03State::new_with_genesis_accounts(&init_accs, vec![], 0) - } - - proptest! { - /// **ReplayRejection** — a transaction accepted in block N must be - /// rejected when replayed in block N+1, because the nonce is consumed - /// on first acceptance. - #[test] - fn replay_rejection_proptest(tx in arb_native_transfer_tx(test_accounts())) { - let mut state = make_test_state(); - - // Stateless gate — skip structurally invalid transactions (e.g. those - // whose public key does not match the declared sender). - let validated_tx = match tx.transaction_stateless_check() { - Ok(v) => v, - Err(_) => return Ok(()), - }; - - // First application — may fail for state-level reasons (e.g. sender - // has insufficient balance, wrong nonce). In that case there is - // nothing to replay. - let first_result = validated_tx.execute_check_on_state(&mut state, 1, 0); - - if let Ok(validated_tx) = first_result { - // Use the shared framework function. assert_replay_rejection uses - // assert!() rather than prop_assert!(); for structured proptest - // inputs the framework-level panic is equivalent. - super::assert_replay_rejection(validated_tx, &mut state, 2, 1); - } - } - } -} diff --git a/fuzz_props/src/lib.rs b/fuzz_props/src/lib.rs index 6bfa46e..6c45ffd 100644 --- a/fuzz_props/src/lib.rs +++ b/fuzz_props/src/lib.rs @@ -1,39 +1,89 @@ //! Fuzzing property library: invariant framework + input generators. -#![allow(clippy::missing_docs_in_private_items)] +#![allow( + clippy::missing_docs_in_private_items, + reason = "fuzz/test library; internal docs omitted for brevity" +)] +#![allow( + clippy::single_char_lifetime_names, + reason = "the `Arbitrary` trait uses `'a` and our impls must match its signature" +)] +#![allow( + clippy::exhaustive_structs, + reason = "fuzz-library newtype wrappers and test helpers; non_exhaustive would only add noise" +)] +#![allow( + clippy::missing_inline_in_public_items, + reason = "fuzz/test library; inlining hints have negligible effect here" +)] +#![allow( + clippy::question_mark_used, + reason = "`?` is the idiomatic Rust error-propagation operator in `Arbitrary` implementations" +)] +#![allow( + clippy::as_conversions, + reason = "u8 → usize for index arithmetic is safe and bounded in arbitrary contexts" +)] +#![allow( + clippy::integer_division_remainder_used, + reason = "modulo is the natural way to bound arbitrary u8 values to a range" +)] +#![allow( + clippy::arbitrary_source_item_ordering, + reason = "items are grouped logically rather than alphabetically for readability" +)] +#![allow( + clippy::iter_over_hash_type, + reason = "invariant checks iterate over all accounts; iteration order does not affect correctness" +)] +#![allow( + clippy::arithmetic_side_effects, + reason = "arithmetic is bounded by construction in test/fuzz helpers" +)] +#![allow( + clippy::integer_division, + reason = "u128::MAX / 2 is intentional for generating overflow-inducing test values" +)] +#![allow( + clippy::module_name_repetitions, + reason = "assert_invariants is the canonical, self-documenting name for this function" +)] +#![allow( + clippy::unused_trait_names, + reason = "named `Arbitrary` import needed to disambiguate from `proptest::arbitrary::Arbitrary` in generators.rs" +)] +#![allow( + clippy::let_underscore_must_use, + reason = "seed-generation IO errors are intentionally ignored in tests" +)] +#![allow( + clippy::let_underscore_untyped, + reason = "seed-generation IO errors are intentionally ignored in tests" +)] pub mod arbitrary_types; pub mod generators; pub mod invariants; -#[cfg(test)] -mod seed_gen { - use std::fs; - use std::path::Path; +/// Generates the fuzzer entry point for whichever engine this crate is +/// compiled with, selected via Cargo features: +/// +/// | Feature | Expansion | +/// |----------------------|-----------| +/// | `fuzzer-libfuzzer` | `libfuzzer_sys::fuzz_target!(…)` | +/// | `fuzzer-afl` | `fn main() { afl::fuzz!(…) }` | +#[macro_export] +macro_rules! fuzz_entry { + (|$data:ident: &[u8]| $body:block) => { + #[cfg(feature = "fuzzer-libfuzzer")] + ::libfuzzer_sys::fuzz_target!(|$data: &[u8]| $body); - #[test] - fn generate_seeds() { - let tx = common::test_utils::produce_dummy_empty_transaction(); - let bytes = borsh::to_vec(&tx).unwrap(); - - // CARGO_MANIFEST_DIR is lez-fuzzing/fuzz_props/ at compile time. - // Tests inherit the package directory as cwd, so we must use an - // absolute base rather than a bare relative path. - let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("fuzz_props is one level below the workspace root"); - - let targets = [ - "fuzz/corpus/fuzz_transaction_decoding/seed_empty_tx", - "fuzz/corpus/fuzz_stateless_verification/seed_empty_tx", - "fuzz/corpus/fuzz_state_transition/seed_empty_tx", - ]; - for rel in &targets { - let p = workspace_root.join(rel); - if let Some(parent) = p.parent() { - fs::create_dir_all(parent).ok(); - } - fs::write(&p, &bytes).ok(); + #[cfg(feature = "fuzzer-afl")] + fn main() { + ::afl::fuzz!(|$data: &[u8]| $body); } - } + }; } + +#[cfg(test)] +mod tests; diff --git a/fuzz_props/src/tests.rs b/fuzz_props/src/tests.rs new file mode 100644 index 0000000..759db83 --- /dev/null +++ b/fuzz_props/src/tests.rs @@ -0,0 +1,3 @@ +mod invariants; +mod replay_proptest; +mod seed_gen; diff --git a/fuzz_props/src/tests/invariants.rs b/fuzz_props/src/tests/invariants.rs new file mode 100644 index 0000000..59c6217 --- /dev/null +++ b/fuzz_props/src/tests/invariants.rs @@ -0,0 +1,119 @@ +use crate::invariants::{ + BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, + assert_nonce_increment_correctness, +}; +use nssa::V03State; +use nssa_core::account::Nonce; + +fn make_empty_state() -> V03State { + V03State::new_with_genesis_accounts(&[], vec![], 0) +} + +fn make_empty_snapshot() -> BalanceSnapshot { + BalanceSnapshot(std::collections::HashMap::new()) +} + +fn make_empty_nonce_snapshot() -> NonceSnapshot { + NonceSnapshot(std::collections::HashMap::new()) +} + +#[test] +fn invariant_state_isolation_on_failure_does_not_panic_on_error() { + let state = make_empty_state(); + let ctx = InvariantCtx { + state_before: &state, + state_after: &state, + execution_succeeded: false, + balances_before: make_empty_snapshot(), + nonces_before: make_empty_nonce_snapshot(), + }; + assert_invariants(&ctx); +} + +#[test] +fn invariant_replay_rejection_does_not_panic() { + let state = make_empty_state(); + let ctx = InvariantCtx { + state_before: &state, + state_after: &state, + execution_succeeded: true, + balances_before: make_empty_snapshot(), + nonces_before: make_empty_nonce_snapshot(), + }; + assert_invariants(&ctx); +} + +#[test] +fn balance_conservation_catches_inflation_on_success() { + let acc_id = nssa::AccountId::new([1_u8; 32]); + let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); + let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 200)], vec![], 0); + + let mut balances = std::collections::HashMap::new(); + balances.insert(acc_id, 100_u128); + + let ctx = InvariantCtx { + state_before: &state_before, + state_after: &state_after, + execution_succeeded: true, + balances_before: BalanceSnapshot(balances), + nonces_before: make_empty_nonce_snapshot(), + }; + + let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); + assert!(result.is_err(), "expected panic for balance inflation"); +} + +#[test] +fn nonce_increment_correctness_passes_with_no_signers() { + let state = make_empty_state(); + assert_nonce_increment_correctness(&[], &make_empty_nonce_snapshot(), &state); +} + +#[test] +fn nonce_increment_correctness_passes_when_signer_not_in_snapshot() { + let acc_id = nssa::AccountId::new([9_u8; 32]); + let state = make_empty_state(); + assert_nonce_increment_correctness(&[acc_id], &make_empty_nonce_snapshot(), &state); +} + +#[test] +fn nonce_increment_correctness_catches_unchanged_nonce() { + let acc_id = nssa::AccountId::new([3_u8; 32]); + let state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + let mut nonces = std::collections::HashMap::new(); + nonces.insert(acc_id, Nonce(5)); + + let result = std::panic::catch_unwind(|| { + assert_nonce_increment_correctness(&[acc_id], &NonceSnapshot(nonces), &state); + }); + assert!(result.is_err(), "expected panic for unchanged nonce"); +} + +#[test] +fn failed_tx_nonce_stability_catches_nonce_mutation() { + let acc_id = nssa::AccountId::new([2_u8; 32]); + let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); + let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); + + let mut nonces = std::collections::HashMap::new(); + nonces.insert(acc_id, Nonce(1)); + + let mut balances = std::collections::HashMap::new(); + balances.insert(acc_id, 100_u128); + + let ctx = InvariantCtx { + state_before: &state_before, + state_after: &state_after, + execution_succeeded: false, + balances_before: BalanceSnapshot(balances), + nonces_before: NonceSnapshot(nonces), + }; + + let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); + assert!( + result.is_err(), + "expected panic for nonce mutation on failure" + ); +} diff --git a/fuzz_props/src/tests/replay_proptest.rs b/fuzz_props/src/tests/replay_proptest.rs new file mode 100644 index 0000000..6be98a8 --- /dev/null +++ b/fuzz_props/src/tests/replay_proptest.rs @@ -0,0 +1,33 @@ +// Run with: cargo test -p fuzz_props replay_rejection +use crate::generators::{arb_native_transfer_tx, test_accounts}; +use nssa::V03State; +use proptest::prelude::*; + +fn make_test_state() -> V03State { + let accounts = test_accounts(); + let init_accs: Vec<(nssa::AccountId, u128)> = accounts + .iter() + .map(|(id, _)| (*id, 1_000_000_u128)) + .collect(); + V03State::new_with_genesis_accounts(&init_accs, vec![], 0) +} + +proptest! { + /// **ReplayRejection** \u{2014} a transaction accepted in block N must be + /// rejected when replayed in block N+1, because the nonce is consumed + /// on first acceptance. + #[test] + fn replay_rejection_proptest(tx in arb_native_transfer_tx(test_accounts())) { + let mut state = make_test_state(); + + // Skip structurally invalid transactions (e.g. mismatched public key / sender). + let Ok(validated_tx) = tx.transaction_stateless_check() else { return Ok(()) }; + + // First application may fail for state-level reasons; nothing to replay then. + let first_result = validated_tx.execute_check_on_state(&mut state, 1, 0); + + if let Ok(applied_tx) = first_result { + crate::invariants::assert_replay_rejection(applied_tx, &mut state, 2, 1); + } + } +} diff --git a/fuzz_props/src/tests/seed_gen.rs b/fuzz_props/src/tests/seed_gen.rs new file mode 100644 index 0000000..7548307 --- /dev/null +++ b/fuzz_props/src/tests/seed_gen.rs @@ -0,0 +1,25 @@ +use std::fs; +use std::path::Path; + +#[test] +fn generate_seeds() { + let tx = common::test_utils::produce_dummy_empty_transaction(); + let bytes = borsh::to_vec(&tx).unwrap(); + + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("fuzz_props is one level below the workspace root"); + + let targets = [ + "fuzz/corpus/fuzz_transaction_decoding/seed_empty_tx", + "fuzz/corpus/fuzz_stateless_verification/seed_empty_tx", + "fuzz/corpus/fuzz_state_transition/seed_empty_tx", + ]; + for rel in &targets { + let p = workspace_root.join(rel); + if let Some(parent) = p.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(&p, &bytes); + } +} diff --git a/scripts/add_fuzz_target.py b/scripts/add_fuzz_target.py index 52bd5a6..3dde883 100644 --- a/scripts/add_fuzz_target.py +++ b/scripts/add_fuzz_target.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fully automates registering a new cargo-fuzz target. +"""Fully automates registering a new cargo-fuzz / AFL++ fuzz target. Usage: python3 scripts/add_fuzz_target.py @@ -7,12 +7,19 @@ Usage: Where TARGET_NAME is the full binary name, e.g. fuzz_my_feature. Actions performed: - 1. Appends a [[bin]] entry to fuzz/Cargo.toml + 1. Appends a [[bin]] entry to fuzz/Cargo.toml (one entry covers BOTH + the libFuzzer lane and the AFL++ lane — no separate Cargo.toml needed) 2. Inserts TARGET_NAME into every YAML matrix block in .github/workflows/fuzz.yml (smoke-fuzz, regression) 3. Inserts TARGET_NAME into the perf-baseline shell for-loop in .github/workflows/fuzz.yml +NOTE: A single fuzz/Cargo.toml is the source of truth for both engines. + - libFuzzer build: cargo fuzz build + - AFL++ build: cd fuzz && cargo afl build \\ + --no-default-features --features fuzzer-afl \\ + --release --bin + Run from the repository root. """ @@ -172,6 +179,24 @@ def main() -> None: append_cargo_bin(target, cargo_toml) insert_into_workflow(target, workflow) + # ── Print build instructions ────────────────────────────────────────────── + print() + print("Registration complete! Next steps:") + print() + print(" 1. Implement the harness body in:") + print(f" fuzz/fuzz_targets/{target}.rs") + print() + print(" 2. Verify the libFuzzer (cargo-fuzz) build:") + print(f" RISC0_DEV_MODE=1 cargo fuzz build {target}") + print() + print(" 3. Verify the AFL++ build (single shared fuzz/Cargo.toml):") + print(f" cd fuzz && cargo afl build \\") + print(f" --no-default-features --features fuzzer-afl \\") + print(f" --release --bin {target}") + print() + print(" 4. Run with libFuzzer: just fuzz-one", target) + print(" Run with AFL++: just fuzz-afl", target) + if __name__ == "__main__": main() From 88e780b865138c4bf112c94fa923266065c7ad8c Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 28 May 2026 11:40:38 +0800 Subject: [PATCH 2/5] fix: improve API around tx invariants --- current_vs_alternative_approach.md | 2 +- docs/fuzzing.md | 69 ++++--- .../fuzz_multi_block_state_sequence.rs | 50 ++--- fuzz/fuzz_targets/fuzz_replay_prevention.rs | 47 ++--- fuzz/fuzz_targets/fuzz_state_transition.rs | 42 ++-- fuzz_props/src/invariants.rs | 189 +++++++++++++----- fuzz_props/src/tests/invariants.rs | 2 +- 7 files changed, 230 insertions(+), 171 deletions(-) diff --git a/current_vs_alternative_approach.md b/current_vs_alternative_approach.md index 34af3ac..8498316 100644 --- a/current_vs_alternative_approach.md +++ b/current_vs_alternative_approach.md @@ -111,7 +111,7 @@ The extension noted in [`docs/fuzzing.md`](docs/fuzzing.md:356) is: | Execution time | Slow (recompile per mutation) | Continuous | | Output | Surviving mutants = assertion gaps | Crash artifacts | -**Decision-maker view**: `cargo-mutants` would **audit the invariant assertions themselves** — revealing if [`assert_invariants()`](fuzz_props/src/invariants.rs:325) has gaps. Three invariants are fully implemented: [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs:60), [`BalanceConservation`](fuzz_props/src/invariants.rs:94), and [`FailedTxNonceStability`](fuzz_props/src/invariants.rs:130). Two are registry stubs: [`ReplayRejection`](fuzz_props/src/invariants.rs:169) and [`NonceIncrementCorrectness`](fuzz_props/src/invariants.rs:196) — each enforced via dedicated standalone helpers (`assert_replay_rejection`, `assert_nonce_increment_correctness`). This is a **complementary quality gate**, not a fuzzing replacement. Low cost (~1 day), highly useful before an external security audit. +**Decision-maker view**: `cargo-mutants` would **audit the invariant assertions themselves** — revealing if [`assert_invariants()`](fuzz_props/src/invariants.rs) has gaps. Three invariants are fully implemented and registered in `assert_invariants()`: [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs:60), [`BalanceConservation`](fuzz_props/src/invariants.rs:94), and [`FailedTxNonceStability`](fuzz_props/src/invariants.rs:130). Two additional invariants — [`ReplayRejection`](fuzz_props/src/invariants.rs:167) and [`NonceIncrementCorrectness`](fuzz_props/src/invariants.rs:194) — are enforced exclusively via standalone helpers (`assert_replay_rejection`, `assert_nonce_increment_correctness`) and are **not** in the `assert_invariants()` registry; this is intentional because they require data consumed before `InvariantCtx` is built. This is a **complementary quality gate**, not a fuzzing replacement. Low cost (~1 day), highly useful before an external security audit. --- diff --git a/docs/fuzzing.md b/docs/fuzzing.md index bb870ca..c65bf7f 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -478,37 +478,50 @@ automates steps 2–5 and uploads the report as a workflow artifact. ## Invariant Framework -Shared invariants live in `fuzz_props/src/invariants.rs`. Each invariant implements -`ProtocolInvariant` and is automatically run by `assert_invariants()`. +Shared invariants live in `fuzz_props/src/invariants.rs`. There are two layers: -Concrete invariants currently registered in `assert_invariants()`: +### Primary API — `assert_tx_execution_invariants()` -| Invariant | Description | Implementation status | -|-----------|-------------|----------------------| -| `StateIsolationOnFailure` | Per-account balance must not change for any tracked account when a transaction is rejected | ✅ Fully implemented | -| `BalanceConservation` | Total balance of all known accounts must be conserved when a transaction succeeds | ✅ Fully implemented | -| `FailedTxNonceStability` | Every account's nonce must remain unchanged when a transaction is rejected | ✅ Fully implemented | -| `ReplayRejection` | An accepted transaction must be rejected when replayed | ⚠️ Registry stub — always returns `None` from `InvariantCtx`; use `assert_replay_rejection()` directly (see note below) | -| `NonceIncrementCorrectness` | Every signer account's nonce must be incremented by exactly one after a successful transaction | ⚠️ Registry stub — always returns `None` from `InvariantCtx`; use `assert_nonce_increment_correctness()` directly (see note below) | +For every fuzz target that calls `execute_check_on_state`, use the single unified entry +point. It enforces the five state-transition invariants in one call, routing by outcome: -> **Note on stub invariants:** `ReplayRejection` and `NonceIncrementCorrectness` cannot be -> fully exercised through `InvariantCtx` alone. Each requires information that is consumed -> before `InvariantCtx` is built: -> -> - **`ReplayRejection`**: `execute_check_on_state` returns the `NSSATransaction` on `Ok`, -> consuming `self`. Replaying it requires re-applying the returned transaction to the -> post-execution state — not possible via a shared `&InvariantCtx`. Use the standalone -> `assert_replay_rejection(applied_tx, state, next_block_id, next_timestamp)` helper -> immediately after each successful execution. The proptest suite `replay_rejection_proptest` -> in `fuzz_props/src/invariants.rs` provides reproducible structured coverage of this -> invariant. -> -> - **`NonceIncrementCorrectness`**: `apply_state_diff` consumes the `ValidatedStateDiff` -> whose signer-account list is private to the `nssa` crate. The caller must derive signer -> IDs from the transaction's witness set before consuming the diff, then call the standalone -> `assert_nonce_increment_correctness(signer_ids, nonces_before, state_after)` helper. -> The `signer_account_ids()` helper in `fuzz_props::generators` extracts signer `AccountId`s -> from an `NSSATransaction`'s witness set. +| Invariant | Active when | +|-----------|-------------| +| `StateIsolationOnFailure` | `execution_result` is `Err` | +| `FailedTxNonceStability` | `execution_result` is `Err` | +| `BalanceConservation` | `execution_result` is `Ok` | +| `NonceIncrementCorrectness` | `execution_result` is `Ok` | +| `ReplayRejection` | `execution_result` is `Ok` | + +```rust +let state_snapshot = state.clone(); +let result = tx.execute_check_on_state(&mut state, block_id, timestamp); + +assert_tx_execution_invariants( + &state_snapshot, + &mut state, + balances_before, + nonces_before, + result, + (block_id + 1, timestamp + 1), +); +``` + +One call. No standalone helpers to remember. + +### Registry API — `assert_invariants()` + `ProtocolInvariant` + +Each invariant is a zero-size struct implementing `ProtocolInvariant`; `assert_invariants()` +runs the registry and panics on the first violation. This lower-level API is used +internally by `assert_tx_execution_invariants` and is also available for targets where no +transaction is available for replay (e.g. pure state-serialization targets). + +```rust +// Only use assert_invariants() directly for non-execution contexts. +// For execute_check_on_state call sites, prefer assert_tx_execution_invariants(). +assert_invariants(&InvariantCtx { state_before, state_after, execution_succeeded, + balances_before, nonces_before }); +``` Additional invariants enforced **inline** in specific targets (not via `ProtocolInvariant`): diff --git a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs index bb084de..c1974f8 100644 --- a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs +++ b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs @@ -17,15 +17,14 @@ //! //! # Invariants //! -//! The following per-transaction invariants are checked via the shared framework -//! ([`fuzz_props::invariants::assert_invariants`]) on every iteration: +//! The following per-transaction invariants are checked via +//! [`fuzz_props::invariants::assert_tx_execution_invariants`] on every iteration: //! //! - **StateIsolationOnFailure** — balances unchanged on rejection. -//! - **BalanceConservation** — total balance conserved on success. //! - **FailedTxNonceStability** — nonces unchanged on rejection. -//! -//! In addition, [`assert_replay_rejection`] is called on every successful -//! transaction (per-block replay check). +//! - **BalanceConservation** — total balance conserved on success. +//! - **NonceIncrementCorrectness** — signer nonces each increment by exactly one on success. +//! - **ReplayRejection** — every successful transaction rejected on replay (per-block). //! //! The following multi-block aggregate invariant is checked **after** the loop: //! @@ -35,11 +34,8 @@ //! the total; only mint/burn bugs or token-inflation bugs would break it. use arbitrary::{Arbitrary, Unstructured}; -use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, - assert_replay_rejection, -}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; +use fuzz_props::invariants::{BalanceSnapshot, NonceSnapshot, assert_tx_execution_invariants}; use nssa::V03State; fuzz_props::fuzz_entry!(|data: &[u8]| { @@ -99,29 +95,19 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { let state_snapshot = state.clone(); let result = tx.execute_check_on_state(&mut state, block_id, timestamp); - let execution_succeeded = result.is_ok(); - // ── Shared invariant checks ─────────────────────────────────────────── - // Asserts per-transaction: - // • StateIsolationOnFailure — balances unchanged on rejection - // • BalanceConservation — total balance conserved on success - // • FailedTxNonceStability — nonces unchanged on rejection - assert_invariants(&InvariantCtx { - state_before: &state_snapshot, - state_after: &state, - execution_succeeded, + // ── All five protocol invariants ────────────────────────────────────── + // A single call enforces every invariant — no standalone helpers needed: + // On rejection: StateIsolationOnFailure + FailedTxNonceStability + // On success: BalanceConservation + NonceIncrementCorrectness + ReplayRejection + assert_tx_execution_invariants( + &state_snapshot, + &mut state, balances_before, - nonces_before: nonces_before.clone(), - }); - - // ── NonceIncrementCorrectness + ReplayRejection (per-block) ────────── - // First verify every signer's nonce was incremented by exactly one, then - // replay in the next block to confirm the nonce is permanently consumed. - if let Ok(applied_tx) = result { - let ids = signer_account_ids(&applied_tx); - assert_nonce_increment_correctness(&ids, &nonces_before, &state); - assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); - } + nonces_before, + result, + (block_id + 1, timestamp + 1), + ); } // ── LongRangeBalanceConservation ────────────────────────────────────────── diff --git a/fuzz/fuzz_targets/fuzz_replay_prevention.rs b/fuzz/fuzz_targets/fuzz_replay_prevention.rs index 1bc2d63..bdeb1b8 100644 --- a/fuzz/fuzz_targets/fuzz_replay_prevention.rs +++ b/fuzz/fuzz_targets/fuzz_replay_prevention.rs @@ -14,22 +14,17 @@ //! //! # Invariants checked //! -//! The shared framework ([`assert_invariants`]) enforces per-transaction: +//! [`assert_tx_execution_invariants`] enforces all five invariants per transaction +//! in one call: //! - **StateIsolationOnFailure** — balances unchanged on rejection //! - **BalanceConservation** — total balance conserved on success //! - **FailedTxNonceStability** — nonces unchanged on rejection -//! -//! The dedicated [`assert_replay_rejection`] function enforces: +//! - **NonceIncrementCorrectness** — signer nonces each increment by exactly one on success //! - **ReplayRejection** — accepted tx rejected on replay use arbitrary::{Arbitrary, Unstructured}; -use fuzz_props::generators::{ - arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids, -}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, - assert_nonce_increment_correctness, assert_replay_rejection, -}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; +use fuzz_props::invariants::{BalanceSnapshot, NonceSnapshot, assert_tx_execution_invariants}; use nssa::V03State; fuzz_props::fuzz_entry!(|data: &[u8]| { @@ -77,27 +72,17 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { // First application — may legitimately fail for state-level reasons. let result = tx.execute_check_on_state(&mut state, 1, 0); - let execution_succeeded = result.is_ok(); - // ── Shared invariant checks ─────────────────────────────────────────────── - // Asserts: - // • StateIsolationOnFailure — balances unchanged on rejection - // • BalanceConservation — total balance conserved on success - // • FailedTxNonceStability — nonces unchanged on rejection - assert_invariants(&InvariantCtx { - state_before: &state_snapshot, - state_after: &state, - execution_succeeded, + // ── All five protocol invariants ────────────────────────────────────────── + // A single call enforces every invariant — no standalone helpers needed: + // On rejection: StateIsolationOnFailure + FailedTxNonceStability + // On success: BalanceConservation + NonceIncrementCorrectness + ReplayRejection + assert_tx_execution_invariants( + &state_snapshot, + &mut state, balances_before, - nonces_before: nonces_before.clone(), - }); - - // ── NonceIncrementCorrectness + ReplayRejection ─────────────────────────── - // First verify every signer's nonce was incremented by exactly one, then - // assert that replaying in the next block is rejected (nonce permanently consumed). - if let Ok(applied_tx) = result { - let signer_ids = signer_account_ids(&applied_tx); - assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); - assert_replay_rejection(applied_tx, &mut state, 2, 1); - } + nonces_before, + result, + (2, 1), + ); }); diff --git a/fuzz/fuzz_targets/fuzz_state_transition.rs b/fuzz/fuzz_targets/fuzz_state_transition.rs index 4915237..b892436 100644 --- a/fuzz/fuzz_targets/fuzz_state_transition.rs +++ b/fuzz/fuzz_targets/fuzz_state_transition.rs @@ -1,13 +1,8 @@ #![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] use arbitrary::{Arbitrary, Unstructured}; -use fuzz_props::generators::{ - arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids, -}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, - assert_nonce_increment_correctness, assert_replay_rejection, -}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; +use fuzz_props::invariants::{BalanceSnapshot, NonceSnapshot, assert_tx_execution_invariants}; use nssa::V03State; fuzz_props::fuzz_entry!(|data: &[u8]| { @@ -75,29 +70,18 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { // Snapshot state before execution for isolation checks. let state_snapshot = state.clone(); let result = tx.execute_check_on_state(&mut state, block_id, timestamp); - let execution_succeeded = result.is_ok(); - // ── Shared invariant checks ─────────────────────────────────────────── - // Asserts: - // • StateIsolationOnFailure — balances unchanged on rejection - // • BalanceConservation — total balance conserved on success - // • FailedTxNonceStability — nonces unchanged on rejection - assert_invariants(&InvariantCtx { - state_before: &state_snapshot, - state_after: &state, - execution_succeeded, + // ── All five protocol invariants ────────────────────────────────────── + // A single call enforces every invariant — no standalone helpers needed: + // On rejection: StateIsolationOnFailure + FailedTxNonceStability + // On success: BalanceConservation + NonceIncrementCorrectness + ReplayRejection + assert_tx_execution_invariants( + &state_snapshot, + &mut state, balances_before, - nonces_before: nonces_before.clone(), - }); - - // ── NonceIncrementCorrectness + ReplayRejection ─────────────────────── - // execute_check_on_state returns the NSSATransaction on Ok. - // First verify every signer's nonce was incremented by exactly one, then - // replay in the next block to confirm the nonce is permanently consumed. - if let Ok(applied_tx) = result { - let signer_ids = signer_account_ids(&applied_tx); - assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); - assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); - } + nonces_before, + result, + (block_id + 1, timestamp + 1), + ); } }); diff --git a/fuzz_props/src/invariants.rs b/fuzz_props/src/invariants.rs index b4d92bc..13c6a41 100644 --- a/fuzz_props/src/invariants.rs +++ b/fuzz_props/src/invariants.rs @@ -155,56 +155,47 @@ impl ProtocolInvariant for FailedTxNonceStability { /// A successfully accepted transaction must be rejected when replayed. /// -/// # Note +/// # Enforcement /// -/// This invariant **cannot** be exercised through [`InvariantCtx`] alone because -/// the replay check requires re-applying the `NSSATransaction` that was consumed -/// by `execute_check_on_state`. The `ProtocolInvariant` impl here is a registry -/// placeholder only; it always returns `None`. +/// This invariant **cannot** be enforced through [`InvariantCtx`] because the replay +/// check requires re-applying the `NSSATransaction` that `execute_check_on_state` +/// consumes and returns on `Ok`. It is therefore **not registered** in +/// [`assert_invariants`]; calling `assert_invariants` alone does **not** cover +/// `ReplayRejection`. /// -/// Use the standalone [`assert_replay_rejection`] function instead, which accepts -/// the `NSSATransaction` returned on success and performs the replay inline. +/// Every fuzz target that performs state transitions **must** call the standalone +/// [`assert_replay_rejection`] function after each successful execution: +/// +/// ```rust,ignore +/// if let Ok(applied_tx) = result { +/// assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); +/// } +/// ``` pub struct ReplayRejection; -impl ProtocolInvariant for ReplayRejection { - fn name(&self) -> &'static str { - "ReplayRejection" - } - - fn check(&self, _ctx: &InvariantCtx<'_>) -> Option { - // ReplayRejection cannot be fully exercised through InvariantCtx alone. - // Use `assert_replay_rejection(applied_tx, state, next_block_id, next_ts)` instead. - None - } -} - /// A successfully applied transaction must increment the nonce of every signer account /// by exactly one. /// -/// # Note +/// # Enforcement /// -/// This invariant **cannot** be exercised through [`InvariantCtx`] alone because -/// `InvariantCtx` does not carry a signer-ID list — that information is private to the -/// `nssa` crate and is consumed by `apply_state_diff` before it returns. The -/// `ProtocolInvariant` impl here is a registry placeholder only; it always returns `None`. +/// This invariant **cannot** be enforced through [`InvariantCtx`] because signer +/// account IDs are private to the `nssa` crate and are consumed by `apply_state_diff` +/// before the caller can observe them. It is therefore **not registered** in +/// [`assert_invariants`]; calling `assert_invariants` alone does **not** cover +/// `NonceIncrementCorrectness`. /// -/// Use the standalone [`assert_nonce_increment_correctness`] function instead, passing -/// the signer IDs derived from the transaction's witness set, the [`NonceSnapshot`] -/// captured before execution, and the post-execution state. +/// Every fuzz target that performs state transitions **must** call the standalone +/// [`assert_nonce_increment_correctness`] function after each successful execution, +/// passing signer IDs derived from the transaction's witness set: +/// +/// ```rust,ignore +/// if let Ok(applied_tx) = result { +/// let signer_ids = signer_account_ids(&applied_tx); +/// assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); +/// } +/// ``` pub struct NonceIncrementCorrectness; -impl ProtocolInvariant for NonceIncrementCorrectness { - fn name(&self) -> &'static str { - "NonceIncrementCorrectness" - } - - fn check(&self, _ctx: &InvariantCtx<'_>) -> Option { - // NonceIncrementCorrectness requires explicit signer_ids not available in InvariantCtx. - // Use `assert_nonce_increment_correctness(signer_ids, nonces_before, state_after)` instead. - None - } -} - // ── Standalone helpers ──────────────────────────────────────────────────────── /// Assert that a successfully-applied transaction is **rejected** when replayed. @@ -308,24 +299,124 @@ pub fn assert_nonce_increment_correctness( } } -// ── Dispatcher ─────────────────────────────────────────────────────────────── +// ── Dispatchers ─────────────────────────────────────────────────────────────── -/// Run every registered [`ProtocolInvariant`] and panic with a structured message -/// on the first violation. +/// Assert the five state-transition invariants for a single `execute_check_on_state` call. +/// +/// Covers the invariants that are defined over one transaction execution attempt — +/// both the failure-isolation properties and the success-outcome correctness properties. +/// All are enforced from a single call; no standalone helpers are needed: +/// +/// | Invariant | Active when | +/// |-----------|-------------| +/// | [`StateIsolationOnFailure`] | `execution_result` is `Err` | +/// | [`FailedTxNonceStability`] | `execution_result` is `Err` | +/// | [`BalanceConservation`] | `execution_result` is `Ok` | +/// | [`NonceIncrementCorrectness`] | `execution_result` is `Ok` | +/// | [`ReplayRejection`] | `execution_result` is `Ok` | +/// +/// # Parameters +/// +/// * `state_before` — clone of the state captured **before** `execute_check_on_state`. +/// * `state_after` — live state **after** execution (mutably borrowed for the replay attempt). +/// * `balances_before` — per-account balance snapshot captured before execution. +/// * `nonces_before` — per-account nonce snapshot captured before execution. +/// * `execution_result` — the `Result` returned by `execute_check_on_state`. +/// * `replay_context` — `(next_block_id, next_timestamp)` used for the mandatory replay attempt. +/// +/// # Usage +/// +/// ```rust,ignore +/// let state_snapshot = state.clone(); +/// let balances_before = BalanceSnapshot( +/// accounts.iter().map(|&(id, _)| (id, state.get_account_by_id(id).balance)).collect(), +/// ); +/// let nonces_before = NonceSnapshot( +/// accounts.iter().map(|&(id, _)| (id, state.get_account_by_id(id).nonce)).collect(), +/// ); +/// let result = tx.execute_check_on_state(&mut state, block_id, timestamp); +/// +/// assert_tx_execution_invariants( +/// &state_snapshot, +/// &mut state, +/// balances_before, +/// nonces_before, +/// result, +/// (block_id + 1, timestamp + 1), +/// ); +/// ``` +pub fn assert_tx_execution_invariants( + state_before: &V03State, + state_after: &mut V03State, + balances_before: BalanceSnapshot, + nonces_before: NonceSnapshot, + execution_result: Result, + replay_context: (u64, u64), +) { + let execution_succeeded = execution_result.is_ok(); + // Clone nonces_before before it is moved into InvariantCtx so the clone + // remains available for assert_nonce_increment_correctness on the success path. + let nonces_for_nonce_check = nonces_before.clone(); + + // ── Three InvariantCtx-based invariants ─────────────────────────────────── + // The shared reborrow of state_after ends when assert_invariants returns (NLL), + // after which state_after is available mutably again for the replay attempt. + assert_invariants(&InvariantCtx { + state_before, + state_after: &*state_after, + execution_succeeded, + balances_before, + nonces_before, + }); + + // ── Two success-only invariants ─────────────────────────────────────────── + if let Ok(applied_tx) = execution_result { + // Derive signer IDs from the witness set. ProgramDeployment has no signers. + let signer_ids: Vec = match &applied_tx { + NSSATransaction::Public(pt) => pt + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(pt) => pt + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + }; + assert_nonce_increment_correctness(&signer_ids, &nonces_for_nonce_check, state_after); + let (next_block_id, next_timestamp) = replay_context; + assert_replay_rejection(applied_tx, state_after, next_block_id, next_timestamp); + } +} + +/// Run the three [`InvariantCtx`]-based invariants and panic on the first violation. /// /// Invariants checked: -/// - [`StateIsolationOnFailure`] — balances unchanged on rejection -/// - [`BalanceConservation`] — total balance conserved on success -/// - [`FailedTxNonceStability`] — nonces unchanged on rejection -/// - [`ReplayRejection`] — stub only; use [`assert_replay_rejection`] directly -/// - [`NonceIncrementCorrectness`] — stub only; use [`assert_nonce_increment_correctness`] directly +/// +/// | Invariant | Condition | +/// |-----------|-----------| +/// | [`StateIsolationOnFailure`] | balances unchanged on rejection | +/// | [`BalanceConservation`] | total balance conserved on success | +/// | [`FailedTxNonceStability`] | nonces unchanged on rejection | +/// +/// # Prefer [`assert_tx_execution_invariants`] for `execute_check_on_state` call sites +/// +/// [`ReplayRejection`] and [`NonceIncrementCorrectness`] are not checked here — they +/// require data unavailable inside [`InvariantCtx`]. Use [`assert_tx_execution_invariants`] +/// instead for any target that calls `execute_check_on_state`; it enforces all five +/// invariants in one call. +/// +/// Reserve `assert_invariants` for contexts where no transaction is available for +/// replay (e.g. pure state-serialization or encoding targets). pub fn assert_invariants(ctx: &InvariantCtx<'_>) { let invariants: &[&dyn ProtocolInvariant] = &[ &StateIsolationOnFailure, &BalanceConservation, &FailedTxNonceStability, - &ReplayRejection, - &NonceIncrementCorrectness, ]; for inv in invariants { if let Some(violation) = inv.check(ctx) { diff --git a/fuzz_props/src/tests/invariants.rs b/fuzz_props/src/tests/invariants.rs index 59c6217..178b934 100644 --- a/fuzz_props/src/tests/invariants.rs +++ b/fuzz_props/src/tests/invariants.rs @@ -31,7 +31,7 @@ fn invariant_state_isolation_on_failure_does_not_panic_on_error() { } #[test] -fn invariant_replay_rejection_does_not_panic() { +fn assert_invariants_does_not_panic_on_success_with_empty_state() { let state = make_empty_state(); let ctx = InvariantCtx { state_before: &state, From 3c8844068fabd575aec09b7f85abfee69501be99 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 28 May 2026 11:52:10 +0800 Subject: [PATCH 3/5] fix: replace saturating_add to prevent overflows --- .../fuzz_multi_block_state_sequence.rs | 14 ++++++-- .../fuzz_validate_execute_consistency.rs | 14 ++++++-- fuzz_props/src/generators.rs | 12 ++++++- fuzz_props/src/invariants.rs | 33 +++++++++++++++++-- 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs index c1974f8..0f263cc 100644 --- a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs +++ b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs @@ -57,7 +57,12 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { let starting_total: u128 = init_accs .iter() .map(|&(id, _)| state.get_account_by_id(id).balance) - .fold(0u128, u128::saturating_add); + .try_fold(0u128, |acc, x| acc.checked_add(x)) + .expect( + "INVARIANT VIOLATION [BalanceOverflow]: initial sum of genesis account balances \ + exceeded u128::MAX — per-account balance cap in arbitrary_fuzz_state() should \ + prevent this; if triggered, the cap has been raised without updating this check", + ); // Apply up to 16 transactions across successive blocks. let n_txs: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 16; @@ -119,7 +124,12 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { let ending_total: u128 = init_accs .iter() .map(|&(id, _)| state.get_account_by_id(id).balance) - .fold(0u128, u128::saturating_add); + .try_fold(0u128, |acc, x| acc.checked_add(x)) + .expect( + "INVARIANT VIOLATION [BalanceOverflow]: final sum of genesis account balances \ + exceeded u128::MAX — token-inflation bug that saturating_add would have \ + silently masked", + ); assert_eq!( starting_total, diff --git a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs index b8a8b95..75d8e68 100644 --- a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs +++ b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs @@ -141,11 +141,21 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { let total_before: u128 = known_ids .iter() .map(|id| state.get_account_by_id(*id).balance) - .fold(0u128, u128::saturating_add); + .try_fold(0u128, |acc, x| acc.checked_add(x)) + .expect( + "INVARIANT VIOLATION [BalanceOverflow]: pre-execution sum of known account \ + balances exceeded u128::MAX — token-inflation bug that saturating_add would \ + have silently masked", + ); let total_after: u128 = known_ids .iter() .map(|id| exec_state.get_account_by_id(*id).balance) - .fold(0u128, u128::saturating_add); + .try_fold(0u128, |acc, x| acc.checked_add(x)) + .expect( + "INVARIANT VIOLATION [BalanceOverflow]: post-execution sum of known account \ + balances exceeded u128::MAX — token-inflation bug that saturating_add would \ + have silently masked", + ); assert_eq!( total_before, total_after, diff --git a/fuzz_props/src/generators.rs b/fuzz_props/src/generators.rs index def495f..8d75966 100644 --- a/fuzz_props/src/generators.rs +++ b/fuzz_props/src/generators.rs @@ -51,12 +51,22 @@ pub struct FuzzAccount { /// /// Call this before generating transactions so the constructed [`nssa::V03State`] /// has a shape controlled by the fuzzer rather than fixed at compile time. +/// +/// # Balance cap +/// +/// Each account's balance is capped at `u128::MAX / 8`. With at most 8 accounts, this +/// guarantees the sum of all balances fits in a `u128` without overflow. Balance- +/// conservation checks can therefore use `checked_add` instead of `saturating_add` to +/// turn silent overflow into a detected violation, ruling out false-positive panics on +/// legitimate fuzz inputs. pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result> { let n = ((u8::arbitrary(u)? as usize) % 8) + 1; // 1..=8 std::iter::repeat_with(|| { Ok(FuzzAccount { account_id: ArbAccountId::arbitrary(u)?.0, - balance: u128::arbitrary(u)?, + // Divide by 8 so the sum of 8 accounts is at most u128::MAX, preventing + // false-positive checked_add panics that would mask real inflation bugs. + balance: u128::arbitrary(u)? / 8, private_key: ArbPrivateKey::arbitrary(u)?.0, }) }) diff --git a/fuzz_props/src/invariants.rs b/fuzz_props/src/invariants.rs index 13c6a41..b874879 100644 --- a/fuzz_props/src/invariants.rs +++ b/fuzz_props/src/invariants.rs @@ -7,9 +7,25 @@ use nssa_core::account::Nonce; pub struct BalanceSnapshot(pub std::collections::HashMap); impl BalanceSnapshot { - /// Capture current total balance over all known accounts. + /// Sum of all recorded account balances. + /// + /// # Panics + /// + /// Panics if the sum overflows `u128`. This indicates a token-inflation bug — i.e. + /// the protocol somehow created tokens past `u128::MAX` — and would have been silently + /// masked by `saturating_add`. The generator caps each account balance at + /// `u128::MAX / 8` so eight accounts never overflow; any overflow here is therefore + /// a genuine protocol violation, not a fuzzer artefact. + #[must_use] pub fn total(&self) -> u128 { - self.0.values().copied().fold(0_u128, u128::saturating_add) + self.0 + .values() + .copied() + .try_fold(0_u128, u128::checked_add) + .expect( + "INVARIANT VIOLATION [BalanceOverflow]: sum of account balances exceeded u128::MAX \ + \u{2014} token-inflation bug that saturating_add would have silently masked", + ) } } @@ -97,6 +113,12 @@ impl ProtocolInvariant for BalanceConservation { "BalanceConservation" } + // Overflow in the balance sum IS the violation; using `?` here would silently return + // `None` and skip the check, which is worse than the inflation bug it was meant to catch. + #[expect( + clippy::unwrap_in_result, + reason = "overflow panic is the intended signal" + )] fn check(&self, ctx: &InvariantCtx<'_>) -> Option { if ctx.execution_succeeded { let total_before = ctx.balances_before.total(); @@ -105,7 +127,12 @@ impl ProtocolInvariant for BalanceConservation { .0 .keys() .map(|&id| ctx.state_after.get_account_by_id(id).balance) - .fold(0_u128, u128::saturating_add); + .try_fold(0_u128, u128::checked_add) + .expect( + "INVARIANT VIOLATION [BalanceOverflow]: sum of post-execution account balances \ + exceeded u128::MAX \u{2014} token-inflation bug that saturating_add would \ + have silently masked", + ); if total_before != total_after { return Some(InvariantViolation { invariant: self.name(), From 839359546d85fe5684360a7ba181b4c189d863e4 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 28 May 2026 12:11:49 +0800 Subject: [PATCH 4/5] fix: unlock new cases with arbitrary block_id and timestamp --- fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs b/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs index 9b46905..1cf693a 100644 --- a/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs +++ b/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs @@ -57,10 +57,14 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { .map(|a| (a.account_id, a.balance)) .collect(); - // Fixed block context — both pipelines use identical block_id and timestamp - // so the only variable is the code path (sequencer vs replayer). - let block_id: u64 = 2; // block 1 is genesis; this is the first "real" block - let timestamp: u64 = 1_000; + // Both pipelines use the same block_id and timestamp, drawn from the fuzz corpus + // so the fuzzer can explore clock-dependent and block-ID-dependent code paths. + // The invariant is path-equivalence at every (block_id, timestamp); it does not + // require either value to be constant. If the protocol rejects block_id=0 or + // timestamp=0 as structurally invalid, the existing clock-failure guard below + // (lines ~130-133) will return early without panicking — no extra guard needed. + let block_id: u64 = u64::arbitrary(&mut u).unwrap_or(2); + let timestamp: u64 = u64::arbitrary(&mut u).unwrap_or(1_000); // Shared base state — cloned once for each pipeline. let base_state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); From aceb12f054a96af88990e0f0e9de8ed9c4b64281 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 28 May 2026 17:59:21 +0800 Subject: [PATCH 5/5] fix: run aggregated coverage instead --- .github/workflows/fuzz-afl.yml | 231 ++++++++++++++++++++++----------- .github/workflows/fuzz.yml | 2 +- 2 files changed, 154 insertions(+), 79 deletions(-) diff --git a/.github/workflows/fuzz-afl.yml b/.github/workflows/fuzz-afl.yml index 542af4b..60d31bd 100644 --- a/.github/workflows/fuzz-afl.yml +++ b/.github/workflows/fuzz-afl.yml @@ -5,7 +5,7 @@ on: - cron: "0 2 * * *" workflow_dispatch: push: - branches: [main] + branches: [main, feat-afl-fuzzing] env: RISC0_DEV_MODE: "1" @@ -239,32 +239,15 @@ jobs: if-no-files-found: ignore # ──────────────────────────────────────────────────────────────────────────── - # afl-coverage — LLVM coverage report for all 15 targets + # afl-coverage-aggregate — single HTML report merging all 15 targets # ──────────────────────────────────────────────────────────────────────────── - afl-coverage: - name: "AFL++ coverage — ${{ matrix.target }}" + afl-coverage-aggregate: + name: "AFL++ coverage — aggregated" runs-on: ubuntu-latest needs: afl-smoke - strategy: - fail-fast: false - matrix: - target: - - fuzz_apply_state_diff_split_path - - fuzz_block_verification - - fuzz_encoding_roundtrip - - fuzz_multi_block_state_sequence - - fuzz_program_deployment_lifecycle - - fuzz_replay_prevention - - fuzz_sequencer_vs_replayer - - fuzz_signature_verification - - fuzz_state_diff_computation - - fuzz_state_serialization - - fuzz_state_transition - - fuzz_stateless_verification - - fuzz_transaction_decoding - - fuzz_validate_execute_consistency - - fuzz_witness_set_verification + permissions: + contents: read steps: - name: Checkout repository @@ -283,86 +266,125 @@ jobs: with: components: llvm-tools-preview - - name: Download smoke findings for ${{ matrix.target }} + - name: Download all AFL smoke findings uses: actions/download-artifact@v4 with: - name: afl-findings-${{ matrix.target }} - path: . + pattern: afl-findings-* + path: afl-artifacts/ + merge-multiple: false continue-on-error: true # no crashes/hangs/queue is fine - - name: Extract AFL findings tarball + - name: Extract all AFL findings tarballs run: | - TARGET="${{ matrix.target }}" - TARBALL="afl-findings-${TARGET}.tar.gz" - if [ -f "$TARBALL" ]; then - tar -xzf "$TARBALL" - fi + for tarball in afl-artifacts/*/afl-findings-*.tar.gz; do + [ -f "$tarball" ] || continue + tar -xzf "$tarball" + done - - name: Build with LLVM instrumented coverage + - name: Build all fuzz targets with LLVM coverage instrumentation env: RUSTFLAGS: "-C instrument-coverage" RISC0_DEV_MODE: "1" run: | - # Build with the libfuzzer harness: libFuzzer accepts corpus files as - # positional arguments, runs each through the fuzz closure once, then - # exits — LLVM coverage counters (-C instrument-coverage) are flushed - # to the .profraw file on exit regardless of the fuzzer runtime used. - cargo build \ - --manifest-path fuzz/Cargo.toml \ - --no-default-features \ - --features fuzzer-libfuzzer \ - --release \ - --bin ${{ matrix.target }} + TARGETS=( + fuzz_apply_state_diff_split_path + fuzz_block_verification + fuzz_encoding_roundtrip + fuzz_multi_block_state_sequence + fuzz_program_deployment_lifecycle + fuzz_replay_prevention + fuzz_sequencer_vs_replayer + fuzz_signature_verification + fuzz_state_diff_computation + fuzz_state_serialization + fuzz_state_transition + fuzz_stateless_verification + fuzz_transaction_decoding + fuzz_validate_execute_consistency + fuzz_witness_set_verification + ) + for TARGET in "${TARGETS[@]}"; do + cargo build \ + --manifest-path fuzz/Cargo.toml \ + --no-default-features \ + --features fuzzer-libfuzzer \ + --release \ + --bin "$TARGET" + done - - name: Run corpus + queue entries through instrumented binary + - name: Run all corpus and queue entries through instrumented binaries run: | - TARGET="${{ matrix.target }}" - BINARY="fuzz/target/release/${TARGET}" - PROFRAW_DIR="coverage/afl/${TARGET}/profraw" + TARGETS=( + fuzz_apply_state_diff_split_path + fuzz_block_verification + fuzz_encoding_roundtrip + fuzz_multi_block_state_sequence + fuzz_program_deployment_lifecycle + fuzz_replay_prevention + fuzz_sequencer_vs_replayer + fuzz_signature_verification + fuzz_state_diff_computation + fuzz_state_serialization + fuzz_state_transition + fuzz_stateless_verification + fuzz_transaction_decoding + fuzz_validate_execute_consistency + fuzz_witness_set_verification + ) + PROFRAW_DIR="coverage/afl/aggregated/profraw" mkdir -p "$PROFRAW_DIR" idx=0 - - # AFL corpus (checked-in, accumulated from prior runs) - for f in corpus/afl/${TARGET}/*; do - [ -f "$f" ] || continue - LLVM_PROFILE_FILE="${PROFRAW_DIR}/${idx}.profraw" "$BINARY" "$f" 2>/dev/null || true - idx=$((idx + 1)) - done - - # AFL++ queue entries from today's smoke run (downloaded artifact) - for instance_dir in afl-output/${TARGET}/*/; do - QUEUE="${instance_dir}queue" - [ -d "$QUEUE" ] || continue - for f in "$QUEUE"/id:*; do + for TARGET in "${TARGETS[@]}"; do + BINARY="fuzz/target/release/${TARGET}" + # Checked-in libFuzzer corpus + for f in corpus/libfuzz/${TARGET}/*; do [ -f "$f" ] || continue - LLVM_PROFILE_FILE="${PROFRAW_DIR}/${idx}.profraw" "$BINARY" "$f" 2>/dev/null || true + LLVM_PROFILE_FILE="${PROFRAW_DIR}/${TARGET}_${idx}.profraw" \ + "$BINARY" "$f" 2>/dev/null || true idx=$((idx + 1)) done + # Checked-in AFL corpus + for f in corpus/afl/${TARGET}/*; do + [ -f "$f" ] || continue + LLVM_PROFILE_FILE="${PROFRAW_DIR}/${TARGET}_${idx}.profraw" \ + "$BINARY" "$f" 2>/dev/null || true + idx=$((idx + 1)) + done + # AFL++ queue entries from today's smoke run + for instance_dir in afl-output/${TARGET}/*/; do + QUEUE="${instance_dir}queue" + [ -d "$QUEUE" ] || continue + for f in "$QUEUE"/id:*; do + [ -f "$f" ] || continue + LLVM_PROFILE_FILE="${PROFRAW_DIR}/${TARGET}_${idx}.profraw" \ + "$BINARY" "$f" 2>/dev/null || true + idx=$((idx + 1)) + done + done done - echo "Ran ${idx} inputs through ${TARGET}" + echo "Total inputs processed across all targets: ${idx}" - - name: Merge raw profiles + - name: Merge all profiles into one combined profdata run: | - TARGET="${{ matrix.target }}" - PROFRAW_DIR="coverage/afl/${TARGET}/profraw" - PROFDATA="coverage/afl/${TARGET}/merged.profdata" + PROFRAW_DIR="coverage/afl/aggregated/profraw" + PROFDATA="coverage/afl/aggregated/merged.profdata" SYSROOT="$(rustc --print sysroot)" HOST_TRIPLE="$(rustc -vV | awk '/^host:/{print $2}')" LLVM_PROFDATA="${SYSROOT}/lib/rustlib/${HOST_TRIPLE}/bin/llvm-profdata" shopt -s nullglob files=("${PROFRAW_DIR}"/*.profraw) if [ ${#files[@]} -eq 0 ]; then - echo "No .profraw files found — skipping merge." + echo "No .profraw files found — nothing to aggregate." exit 0 fi + mkdir -p "$(dirname "$PROFDATA")" "$LLVM_PROFDATA" merge -sparse "${files[@]}" -o "$PROFDATA" + echo "Merged ${#files[@]} profraw files → $PROFDATA" - - name: Generate HTML coverage report + - name: Generate aggregated HTML coverage report run: | - TARGET="${{ matrix.target }}" - BINARY="fuzz/target/release/${TARGET}" - PROFDATA="coverage/afl/${TARGET}/merged.profdata" - HTML_DIR="coverage/afl/${TARGET}/html" + PROFDATA="coverage/afl/aggregated/merged.profdata" + HTML_DIR="coverage/afl/aggregated/html" SYSROOT="$(rustc --print sysroot)" HOST_TRIPLE="$(rustc -vV | awk '/^host:/{print $2}')" LLVM_COV="${SYSROOT}/lib/rustlib/${HOST_TRIPLE}/bin/llvm-cov" @@ -371,17 +393,70 @@ jobs: exit 0 fi mkdir -p "$HTML_DIR" + TARGETS=( + fuzz_apply_state_diff_split_path + fuzz_block_verification + fuzz_encoding_roundtrip + fuzz_multi_block_state_sequence + fuzz_program_deployment_lifecycle + fuzz_replay_prevention + fuzz_sequencer_vs_replayer + fuzz_signature_verification + fuzz_state_diff_computation + fuzz_state_serialization + fuzz_state_transition + fuzz_stateless_verification + fuzz_transaction_decoding + fuzz_validate_execute_consistency + fuzz_witness_set_verification + ) + # llvm-cov show: first binary is a positional arg; the rest use --object + first=1 + OBJECT_FLAGS=() + for TARGET in "${TARGETS[@]}"; do + BINARY="fuzz/target/release/${TARGET}" + [ -f "$BINARY" ] || continue + if [ $first -eq 1 ]; then + OBJECT_FLAGS+=("$BINARY") + first=0 + else + OBJECT_FLAGS+=("--object" "$BINARY") + fi + done + if [ ${#OBJECT_FLAGS[@]} -eq 0 ]; then + echo "No instrumented binaries found — skipping report." + exit 0 + fi "$LLVM_COV" show \ - "$BINARY" \ + "${OBJECT_FLAGS[@]}" \ --instr-profile="$PROFDATA" \ --format=html \ --output-dir="$HTML_DIR" \ --ignore-filename-regex='\.cargo|rustc' - echo "Coverage report: ${HTML_DIR}/index.html" + echo "Aggregated coverage report written to ${HTML_DIR}/index.html" - - name: Upload coverage report artifact + - name: Write GitHub Step Summary + if: always() + run: | + PROFDATA="coverage/afl/aggregated/merged.profdata" + HTML_DIR="coverage/afl/aggregated/html" + { + echo "## AFL++ Aggregated Coverage Report" + echo "" + if [ -f "${HTML_DIR}/index.html" ]; then + echo "✅ HTML report generated successfully." + elif [ -f "$PROFDATA" ]; then + echo "⚠️ profdata exists but HTML generation may have failed." + else + echo "❌ No profdata found — no coverage data to report." + fi + echo "" + echo "Download the \`afl-coverage-aggregated\` artifact to browse the full HTML report." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload aggregated coverage report uses: actions/upload-artifact@v4 with: - name: afl-coverage-${{ matrix.target }} - path: coverage/afl/${{ matrix.target }}/html/ - if-no-files-found: ignore + name: afl-coverage-aggregated + path: coverage/afl/aggregated/html/ + if-no-files-found: warn diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 8253824..4deb722 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -5,7 +5,7 @@ on: - cron: "0 2 * * *" workflow_dispatch: push: - branches: [main] + branches: [main, feat-afl-fuzzing] env: RISC0_DEV_MODE: "1"