diff --git a/.github/actions/resolve-targets/action.yml b/.github/actions/resolve-targets/action.yml new file mode 100644 index 00000000..cf9f57e4 --- /dev/null +++ b/.github/actions/resolve-targets/action.yml @@ -0,0 +1,28 @@ +name: Resolve fuzz target matrix +description: > + Parse fuzz/Cargo.toml (the single source of truth) and emit every + `[[bin]] name = "fuzz_*"` target as a compact JSON array, ready to feed a + `strategy.matrix.target`. The repository must already be checked out. + +outputs: + targets: + description: JSON array of fuzz target names, in Cargo.toml order. + value: ${{ steps.list.outputs.targets }} + +runs: + using: composite + steps: + - id: list + shell: bash + run: | + # Same source of truth enforced by scripts/check_target_inventory.py. + targets=$(grep -oE 'name = "fuzz_[a-z0-9_]+"' fuzz/Cargo.toml \ + | sed -E 's/.*"(fuzz_[a-z0-9_]+)"/\1/' \ + | awk '!seen[$0]++' \ + | jq -R -s -c 'split("\n") | map(select(length > 0))') + if [ "$targets" = "[]" ] || [ -z "$targets" ]; then + echo "ERROR: no fuzz_* [[bin]] targets found in fuzz/Cargo.toml" >&2 + exit 1 + fi + echo "targets=$targets" >> "$GITHUB_OUTPUT" + echo "Resolved targets: $targets" diff --git a/.github/actions/setup-afl/action.yml b/.github/actions/setup-afl/action.yml new file mode 100644 index 00000000..65ef59d7 --- /dev/null +++ b/.github/actions/setup-afl/action.yml @@ -0,0 +1,35 @@ +name: Set up AFL++ toolchain +description: > + Build and install AFL++ from source, then install the Rust stable toolchain + and cargo-afl. The repository must already be checked out before this runs. + +inputs: + afl-version: + description: AFL++ git tag to build from source. + required: false + default: v4.40c + +runs: + using: composite + steps: + - name: Install AFL++ ${{ inputs.afl-version }} from source + shell: bash + 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 ${{ inputs.afl-version }} \ + 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 + shell: bash + run: cargo install cargo-afl --locked diff --git a/.github/actions/setup-libfuzzer/action.yml b/.github/actions/setup-libfuzzer/action.yml new file mode 100644 index 00000000..3a22d865 --- /dev/null +++ b/.github/actions/setup-libfuzzer/action.yml @@ -0,0 +1,17 @@ +name: Set up libFuzzer toolchain +description: > + Install the Rust nightly toolchain (with llvm-tools-preview, required by + cargo-fuzz and llvm-cov) and cargo-fuzz itself. The repository and + logos-execution-zone must already be checked out before this runs. + +runs: + using: composite + steps: + - name: Install Rust nightly + llvm-tools-preview + uses: dtolnay/rust-toolchain@nightly + with: + components: llvm-tools-preview + + - name: Install cargo-fuzz + shell: bash + run: cargo install cargo-fuzz diff --git a/.github/workflows/corpus-update.yml b/.github/workflows/corpus-update.yml new file mode 100644 index 00000000..31a334d5 --- /dev/null +++ b/.github/workflows/corpus-update.yml @@ -0,0 +1,443 @@ +name: Corpus update + +# Fully-automated weekly corpus maintenance. +# +# Every Sunday, for each fuzz target (libFuzzer + AFL++ lanes, in parallel): +# Phase 1 — GROW: fuzz for 30 min starting from the checked-in corpus, +# keeping every new input it discovers. +# Phase 2 — MINIMISE: re-minimise that target's *entire* corpus +# (cmin / afl-cmin) so dominated inputs are dropped and +# the tree never balloons. +# +# Corpus minimisation is per-target by construction (each target has its own +# corpus dir + its own instrumented binary), so running Phase 2 right after +# Phase 1 inside the same job is equivalent to a separate global minimise pass +# — without shipping the whole corpus between jobs. +# +# Every per-target result is uploaded as an artifact; a single `commit` job +# aggregates them into ONE pull request. Matrix jobs never push, so they never +# race on the branch. The PR is opened with a classic PAT (secret +# CORPUS_BOT_TOKEN). + +on: + schedule: + - cron: "0 3 * * 0" # Sundays, 03:00 UTC + workflow_dispatch: + inputs: + duration: + description: "Seconds to fuzz per target in the grow phase" + required: false + default: "1800" + minimize_only: + description: "Skip fuzzing; only minimise the existing corpus" + type: boolean + default: false + +env: + RISC0_DEV_MODE: "1" + CARGO_TERM_COLOR: always + +permissions: + contents: read + +jobs: + # ── Resolve the target matrix + run parameters ──────────────────────────────── + config: + name: Resolve matrix & config + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.targets.outputs.targets }} + duration: ${{ steps.cfg.outputs.duration }} + minimize_only: ${{ steps.cfg.outputs.minimize_only }} + steps: + - uses: actions/checkout@v4 + - id: targets + uses: ./.github/actions/resolve-targets + - id: cfg + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + DUR="${{ inputs.duration }}" + MIN="${{ inputs.minimize_only }}" + else + DUR="1800" # scheduled weekly grow: 30 minutes per target + MIN="false" + fi + [ -n "$DUR" ] || DUR="1800" + [ -n "$MIN" ] || MIN="false" + echo "duration=$DUR" >> "$GITHUB_OUTPUT" + echo "minimize_only=$MIN" >> "$GITHUB_OUTPUT" + echo "duration=${DUR}s minimize_only=${MIN}" + + # ── libFuzzer lane: grow 30 min, then minimise ──────────────────────────────── + libfuzz: + name: "libFuzzer — ${{ matrix.target }}" + needs: config + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: ${{ fromJSON(needs.config.outputs.targets) }} + steps: + - uses: actions/checkout@v4 + - 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 }} + - uses: ./.github/actions/setup-libfuzzer + + - name: Build fuzz target + run: cargo fuzz build ${{ matrix.target }} + + - name: "Phase 1 — grow (fuzz ${{ needs.config.outputs.duration }}s)" + if: needs.config.outputs.minimize_only != 'true' + run: | + T="${{ matrix.target }}" + mkdir -p "corpus/libfuzz/$T" + before=$(ls "corpus/libfuzz/$T" | wc -l) + cargo fuzz run "$T" "corpus/libfuzz/$T" -- \ + -max_total_time=${{ needs.config.outputs.duration }} -jobs=2 -workers=2 + echo "grew corpus/libfuzz/$T: $before → $(ls "corpus/libfuzz/$T" | wc -l) inputs" + + - name: "Phase 2 — minimise entire corpus (cmin)" + run: | + T="${{ matrix.target }}" + mkdir -p "corpus/libfuzz/$T" + before=$(ls "corpus/libfuzz/$T" | wc -l) + cargo fuzz cmin "$T" "corpus/libfuzz/$T" + echo "minimised corpus/libfuzz/$T: $before → $(ls "corpus/libfuzz/$T" | wc -l) inputs" + + - name: Upload corpus + uses: actions/upload-artifact@v4 + with: + name: libfuzz-corpus-${{ matrix.target }} + path: corpus/libfuzz/${{ matrix.target }}/ + if-no-files-found: ignore + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: libfuzz-crash-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }}/ + if-no-files-found: ignore + + # ── AFL++ lane: grow 30 min, then minimise ──────────────────────────────────── + afl: + name: "AFL++ — ${{ matrix.target }}" + needs: config + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: ${{ fromJSON(needs.config.outputs.targets) }} + steps: + - uses: actions/checkout@v4 + - 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 }} + - uses: ./.github/actions/setup-afl + + - name: Build AFL++ target + run: | + cargo afl build \ + --manifest-path fuzz/Cargo.toml \ + --no-default-features \ + --features fuzzer-afl \ + --release \ + --bin ${{ matrix.target }} + + - name: Prepare seed corpus + if: needs.config.outputs.minimize_only != 'true' + run: | + T="${{ matrix.target }}" + SEEDS="afl-seeds/$T" + mkdir -p "$SEEDS" + for src in "corpus/libfuzz/$T" "corpus/afl/$T"; do + [ -d "$src" ] || continue + for f in "$src"/*; do [ -f "$f" ] && cp -n "$f" "$SEEDS/" 2>/dev/null || true; done + done + [ -n "$(ls -A "$SEEDS")" ] || echo -n "seed" > "$SEEDS/default_seed" + echo "Seed inputs: $(ls "$SEEDS" | wc -l)" + + - name: "Phase 1 — grow (AFL++ ${{ needs.config.outputs.duration }}s)" + if: needs.config.outputs.minimize_only != 'true' + env: + AFL_SKIP_CPUFREQ: "1" + AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: "1" + run: | + T="${{ matrix.target }}" + mkdir -p "afl-output/$T" + set +e + timeout ${{ needs.config.outputs.duration }} \ + afl-fuzz -i "afl-seeds/$T" -o "afl-output/$T" -- "fuzz/target/release/$T" + rc=$? + set -e + # 124 = SIGALRM from timeout (expected end); 0 = clean exit; else real failure + [ $rc -eq 0 ] || [ $rc -eq 124 ] || exit $rc + + - name: Sync new queue entries into corpus/afl + if: needs.config.outputs.minimize_only != 'true' + run: | + T="${{ matrix.target }}" + DEST="corpus/afl/$T" + mkdir -p "$DEST" + added=0 + for instance_dir in "afl-output/$T"/*/; do + QUEUE="${instance_dir}queue" + [ -d "$QUEUE" ] || continue + for f in "$QUEUE"/id:*; do + [ -f "$f" ] || continue + HASH=$(sha1sum "$f" | cut -d' ' -f1) + if [ ! -f "$DEST/$HASH" ]; then + cp "$f" "$DEST/$HASH" + added=$((added + 1)) + fi + done + done + echo "grew corpus/afl/$T → $(ls "$DEST" | wc -l) inputs (+$added new)" + + - name: "Phase 2 — minimise entire corpus (afl-cmin)" + env: + AFL_SKIP_CPUFREQ: "1" + AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: "1" + run: | + T="${{ matrix.target }}" + SRC="corpus/afl/$T" + if [ ! -d "$SRC" ] || [ -z "$(ls -A "$SRC" 2>/dev/null)" ]; then + echo "corpus/afl/$T is empty — nothing to minimise." + exit 0 + fi + before=$(ls "$SRC" | wc -l) + # afl-cmin can fail on pathological corpora; fall back to leaving SRC as-is. + if afl-cmin -i "$SRC" -o "afl-cmin/$T" -- "fuzz/target/release/$T"; then + rm -rf "$SRC" + mkdir -p "$SRC" + cp "afl-cmin/$T"/* "$SRC"/ 2>/dev/null || true + else + echo "afl-cmin failed — keeping corpus/afl/$T unchanged." + fi + echo "minimised corpus/afl/$T: $before → $(ls "$SRC" | wc -l) inputs" + + - name: Upload corpus + uses: actions/upload-artifact@v4 + with: + name: afl-corpus-${{ matrix.target }} + path: corpus/afl/${{ matrix.target }}/ + if-no-files-found: ignore + + - name: Package AFL findings on failure + if: failure() + run: | + T="${{ matrix.target }}" + # AFL filenames contain colons (forbidden by upload-artifact) — tar them. + tar -czf "afl-findings-$T.tar.gz" -C afl-output "$T" 2>/dev/null \ + || tar -czf "afl-findings-$T.tar.gz" -T /dev/null + - name: Upload AFL findings on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: afl-crash-${{ matrix.target }} + path: afl-findings-${{ matrix.target }}.tar.gz + if-no-files-found: ignore + + # ── Aggregate every per-target corpus into ONE pull request ─────────────────── + commit: + name: Open corpus update PR + needs: [config, libfuzz, afl] + # Run as long as config succeeded; individual matrix failures (fail-fast:false) + # must not block the PR for the targets that did succeed. + if: ${{ !cancelled() && needs.config.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - name: Download corpus artifacts + uses: actions/download-artifact@v4 + with: + path: corpus-artifacts + pattern: "*-corpus-*" # libfuzz-corpus-* and afl-corpus-* only + merge-multiple: false + continue-on-error: true + + - name: Apply corpus deltas to the working tree + run: | + shopt -s nullglob + applied=0 + # Replace per-target dirs only for targets that produced an artifact, so a + # crashed/skipped target never has its checked-in corpus deleted. Replacing + # (rm + repopulate) lets cmin-driven deletions show up in the PR diff. + for d in corpus-artifacts/libfuzz-corpus-*; do + t="${d##*/libfuzz-corpus-}" + rm -rf "corpus/libfuzz/$t"; mkdir -p "corpus/libfuzz/$t" + cp "$d"/* "corpus/libfuzz/$t/" 2>/dev/null || true + applied=$((applied + 1)) + done + for d in corpus-artifacts/afl-corpus-*; do + t="${d##*/afl-corpus-}" + rm -rf "corpus/afl/$t"; mkdir -p "corpus/afl/$t" + cp "$d"/* "corpus/afl/$t/" 2>/dev/null || true + applied=$((applied + 1)) + done + echo "Applied corpus for $applied target lane(s)." + echo "Changed files: $(git status --porcelain corpus | wc -l)" + + - name: Summarise corpus changes for the PR body + id: summary + env: + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + RUN_ID: "${{ github.run_id }}" + DURATION: "${{ needs.config.outputs.duration }}" + run: | + set -euo pipefail + + BODY="$RUNNER_TEMP/pr-body.md" + UNUSUAL="$RUNNER_TEMP/unusual.txt" + OUTSIDE="$RUNNER_TEMP/outside.txt" + : > "$UNUSUAL"; : > "$OUTSIDE" + + # Scan the working tree (not just corpus/) so anything touched outside + # corpus/ is surfaced for the reviewer. Exclude corpus-artifacts. + mapfile -t changes < <(git status --porcelain --untracked-files=all -- ':(exclude)corpus-artifacts') + + added=0; deleted=0; modified=0; other=0 + declare -A tgt_add tgt_del tgt_mod + for line in "${changes[@]}"; do + x="${line:0:2}" + path="${line:3}" + # "old -> new" for renames; keep the destination path. + case "$path" in *" -> "*) path="${path##* -> }";; esac + # git C-quotes odd names — drop the surrounding quotes for display. + path="${path%\"}"; path="${path#\"}" + + case "$x" in + "??"|"A "|"AM") added=$((added+1)); cls=add ;; + " D"|"D ") deleted=$((deleted+1)); cls=del ;; + " M"|"M "|"MM") modified=$((modified+1)); cls=mod ;; + *) other=$((other+1)); cls=other ;; + esac + + case "$path" in + corpus/*) ;; + *) printf '%s %s\n' "$x" "$path" >> "$OUTSIDE" ;; + esac + + base="${path##*/}" + if [[ "$path" == corpus/* ]] && ! [[ "$base" =~ ^[0-9a-f]{40}$ ]]; then + printf '%s %s\n' "$x" "$path" >> "$UNUSUAL" + fi + + if [[ "$path" =~ ^corpus/(libfuzz|afl)/([^/]+)/ ]]; then + key="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + case "$cls" in + add) tgt_add[$key]=$(( ${tgt_add[$key]:-0} + 1 )) ;; + del) tgt_del[$key]=$(( ${tgt_del[$key]:-0} + 1 )) ;; + mod) tgt_mod[$key]=$(( ${tgt_mod[$key]:-0} + 1 )) ;; + esac + fi + done + total=${#changes[@]} + + { + echo "Automated weekly corpus update produced by" + echo "\`.github/workflows/corpus-update.yml\` (run [#${RUN_ID}](${RUN_URL}))." + echo + echo "Per target, in parallel: **Phase 1** fuzzed ${DURATION}s (libFuzzer + AFL++)," + echo "**Phase 2** re-minimised the entire corpus (\`cmin\` / \`afl-cmin\`)." + echo + echo "## Change statistics" + echo + echo "| Metric | Count |" + echo "| --- | ---: |" + echo "| Files changed | ${total} |" + echo "| Added | ${added} |" + echo "| Deleted | ${deleted} |" + echo "| Modified | ${modified} |" + [ "$other" -gt 0 ] && echo "| Other status | ${other} |" + echo + } > "$BODY" + + if [ "${#tgt_add[@]}" -gt 0 ] || [ "${#tgt_del[@]}" -gt 0 ] || [ "${#tgt_mod[@]}" -gt 0 ]; then + { + echo "### Per target" + echo + echo "| Corpus | Added | Deleted | Modified |" + echo "| --- | ---: | ---: | ---: |" + printf '%s\n' "${!tgt_add[@]}" "${!tgt_del[@]}" "${!tgt_mod[@]}" \ + | sort -u | while read -r key; do + [ -n "$key" ] || continue + echo "| \`$key\` | ${tgt_add[$key]:-0} | ${tgt_del[$key]:-0} | ${tgt_mod[$key]:-0} |" + done + echo + } >> "$BODY" + fi + + # ── Reviewer flags ──────────────────────────────────────────────── + emit_list() { # title, file, intro + local title="$1" file="$2" intro="$3" n cap=50 + n=$(wc -l < "$file" | tr -d ' ') + { + echo "### ⚠️ $title ($n)" + echo + echo "$intro" + echo + echo '```' + head -n "$cap" "$file" + [ "$n" -gt "$cap" ] && echo "... and $((n - cap)) more" + echo '```' + echo + } >> "$BODY" + } + + flagged=0 + if [ -s "$OUTSIDE" ]; then + flagged=1 + emit_list "Files changed outside \`corpus/\`" "$OUTSIDE" \ + "A corpus update should only touch \`corpus/\` — review these carefully." + fi + if [ -s "$UNUSUAL" ]; then + flagged=1 + emit_list "Corpus files with unusual names" "$UNUSUAL" \ + "Corpus inputs are normally named by their 40-char SHA-1. These are not:" + fi + if [ "$flagged" -eq 0 ]; then + echo "✅ All changes are under \`corpus/\` and named by SHA-1 as expected." >> "$BODY" + echo >> "$BODY" + fi + + { + echo "---" + echo "Per-target corpora that crashed or were skipped are left untouched." + echo "Review the diff, confirm CI is green, and merge." + } >> "$BODY" + + echo "body_path=$BODY" >> "$GITHUB_OUTPUT" + echo "::group::Generated PR body"; cat "$BODY"; echo "::endgroup::" + + - name: Generate unique branch suffix + id: suffix + run: echo "value=$(LC_ALL=C tr -dc 'a-z' > "$GITHUB_OUTPUT" + + - name: Create or update pull request + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.CORPUS_BOT_TOKEN }} + base: main + branch: automation/corpus-update-${{ steps.suffix.outputs.value }} + delete-branch: true + add-paths: | + corpus/libfuzz/** + corpus/afl/** + commit-message: "chore: weekly corpus update (grow + minimise)" + title: "chore: automated weekly corpus update" + labels: | + automation + corpus + body-path: ${{ steps.summary.outputs.body_path }} diff --git a/.github/workflows/fuzz-afl.yml b/.github/workflows/fuzz-afl.yml index dde2a9e7..d9188052 100644 --- a/.github/workflows/fuzz-afl.yml +++ b/.github/workflows/fuzz-afl.yml @@ -80,25 +80,8 @@ jobs: 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: Set up AFL++ toolchain + uses: ./.github/actions/setup-afl - name: Build fuzz target run: | diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index b6b096cd..7a1046b0 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -55,11 +55,6 @@ jobs: - name: Checkout logos-execution-zone uses: ./.github/actions/checkout-lez - - name: Install Rust nightly (required by cargo-fuzz) - uses: dtolnay/rust-toolchain@nightly - with: - components: llvm-tools-preview - - name: Cache cargo registry uses: actions/cache@v4 with: @@ -74,8 +69,7 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install cargo-fuzz - run: cargo install cargo-fuzz + - uses: ./.github/actions/setup-libfuzzer - name: Build fuzz target run: cargo fuzz build ${{ matrix.target }} @@ -242,14 +236,11 @@ jobs: - uses: actions/checkout@v4 - name: Checkout logos-execution-zone uses: ./.github/actions/checkout-lez - - uses: dtolnay/rust-toolchain@nightly - with: - components: llvm-tools-preview - name: Install logos-blockchain-circuits uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits with: github-token: ${{ secrets.GITHUB_TOKEN }} - - run: cargo install cargo-fuzz + - uses: ./.github/actions/setup-libfuzzer - name: Reproduce corpus run: | mkdir -p corpus/libfuzz/${{ matrix.target }} @@ -282,14 +273,11 @@ jobs: - uses: actions/checkout@v4 - name: Checkout logos-execution-zone uses: ./.github/actions/checkout-lez - - uses: dtolnay/rust-toolchain@nightly - with: - components: llvm-tools-preview - name: Install logos-blockchain-circuits uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits with: github-token: ${{ secrets.GITHUB_TOKEN }} - - run: cargo install cargo-fuzz + - uses: ./.github/actions/setup-libfuzzer - name: Measure throughput (30 s per target) run: | for target in \ diff --git a/corpus/libfuzz/fuzz_multi_block_state_sequence/287c1358afcc2c83b86a03bd1f06e33811037527 b/corpus/libfuzz/fuzz_multi_block_state_sequence/287c1358afcc2c83b86a03bd1f06e33811037527 new file mode 100644 index 00000000..2d997a25 Binary files /dev/null and b/corpus/libfuzz/fuzz_multi_block_state_sequence/287c1358afcc2c83b86a03bd1f06e33811037527 differ diff --git a/corpus/libfuzz/fuzz_multi_block_state_sequence/957d6b0a369114d7315e4a379495636beabe3e5b b/corpus/libfuzz/fuzz_multi_block_state_sequence/957d6b0a369114d7315e4a379495636beabe3e5b new file mode 100644 index 00000000..3e46c47c Binary files /dev/null and b/corpus/libfuzz/fuzz_multi_block_state_sequence/957d6b0a369114d7315e4a379495636beabe3e5b differ diff --git a/corpus/libfuzz/fuzz_multi_block_state_sequence/d2d347de29031b8ed90521f782aa96ec8bf3e83c b/corpus/libfuzz/fuzz_multi_block_state_sequence/d2d347de29031b8ed90521f782aa96ec8bf3e83c new file mode 100644 index 00000000..7f2dee95 Binary files /dev/null and b/corpus/libfuzz/fuzz_multi_block_state_sequence/d2d347de29031b8ed90521f782aa96ec8bf3e83c differ diff --git a/fuzz_props/src/generators.rs b/fuzz_props/src/generators.rs index ca79628c..f1b6949d 100644 --- a/fuzz_props/src/generators.rs +++ b/fuzz_props/src/generators.rs @@ -59,19 +59,54 @@ pub struct FuzzAccount { /// 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. +/// +/// # Reserved-ID and duplicate exclusion +/// +/// The cap above is only sound if every generated balance survives genesis construction +/// unchanged. Two failure modes break that: +/// +/// * **Reserved system accounts.** [`nssa::V03State::new_with_genesis_accounts`] inserts +/// the faucet account (`balance = u128::MAX`) and bridge account *after* the supplied +/// genesis accounts, overwriting any generated account whose ID collides. A fuzzer that +/// lands on the faucet ID would make a caller read back `u128::MAX` instead of the capped +/// balance it generated, overflowing the conservation sum — a harness false positive, not +/// a protocol bug. +/// * **Duplicate IDs.** Genesis stores accounts in a `HashMap` keyed by ID, so duplicate +/// IDs collapse to a single (last-write-wins) account, while a caller's per-ID balance sum +/// double-counts that account's balance. +/// +/// Both are excluded here: generated IDs equal to a reserved system account, or already +/// seen in this state, are skipped. The result therefore contains only distinct, +/// non-reserved IDs whose generated balances match what genesis stores — so `0..=8` +/// accounts are returned (an empty state is a valid degenerate case). pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result> { + let reserved = [ + nssa::system_faucet_account_id(), + nssa::system_bridge_account_id(), + ]; let n = ((u8::arbitrary(u)? as usize) % 8) + 1; // 1..=8 - std::iter::repeat_with(|| { - Ok(FuzzAccount { - account_id: ArbAccountId::arbitrary(u)?.0, - // 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, - }) - }) - .take(n) - .collect() + + let mut seen = std::collections::HashSet::with_capacity(n); + let mut accounts = Vec::with_capacity(n); + for _ in 0..n { + let account_id = ArbAccountId::arbitrary(u)?.0; + // 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. + let balance = u128::arbitrary(u)? / 8; + let private_key = ArbPrivateKey::arbitrary(u)?.0; + + // Skip IDs that genesis would overwrite (reserved system accounts) or that would + // collapse on insertion (duplicates); see the doc comment above. + if reserved.contains(&account_id) || !seen.insert(account_id) { + continue; + } + accounts.push(FuzzAccount { + account_id, + balance, + private_key, + }); + } + Ok(accounts) } /// Reduce raw fuzzer draws into a *biased-valid* `(nonce, amount)` pair. diff --git a/fuzz_props/src/privacy.rs b/fuzz_props/src/privacy.rs index 180700d7..5ae61a4c 100644 --- a/fuzz_props/src/privacy.rs +++ b/fuzz_props/src/privacy.rs @@ -230,7 +230,7 @@ pub fn arb_privacy_preserving_tx( } let n_extra = (u8::arbitrary(u)? as usize) % 4; for _ in 0..n_extra { - let id = if bool::arbitrary(u)? { + let id = if !accounts.is_empty() && bool::arbitrary(u)? { // a known fuzz account — its post-state change is observable in the snapshot accounts[(u8::arbitrary(u)? as usize) % accounts.len()].account_id } else { diff --git a/fuzz_props/src/tests/generators.rs b/fuzz_props/src/tests/generators.rs index ec5584b5..b3f2cfce 100644 --- a/fuzz_props/src/tests/generators.rs +++ b/fuzz_props/src/tests/generators.rs @@ -49,22 +49,37 @@ fn signer_ids_contains_the_signing_account() { ); } +/// A buffer whose bytes are all distinct within any 80-byte window (the per-account +/// stride: 32 id + 16 balance + 32 key), so each generated account gets a distinct ID +/// and the dedup pass in `arbitrary_fuzz_state` does not collapse the count. Using +/// `buf[i] = i` works because two account-ID windows starting at offsets `a` and `b` +/// (both `< 256`) are equal only when `a ≡ b (mod 256)`, which never holds for the +/// `1 + j*80` offsets of the first eight accounts. +fn distinct_byte_buffer(len: usize) -> Vec { + (0_u8..=255).cycle().take(len).collect() +} + #[test] -fn fuzz_state_never_empty() { - let buf = vec![0_u8; 1000]; +fn fuzz_state_never_empty_for_distinct_ids() { + // Selector byte 0 -> (0 % 8) + 1 = 1 account; distinct bytes keep it from being + // deduped away. (An all-duplicate or all-reserved draw may legitimately return + // 0 accounts now — see `fuzz_state_dedups_account_ids` — so non-emptiness is only + // asserted for an input that yields distinct, non-reserved IDs.) + let buf = distinct_byte_buffer(1000); let mut u = Unstructured::new(&buf); let accounts = arbitrary_fuzz_state(&mut u).expect("should succeed"); assert!( !accounts.is_empty(), - "arbitrary_fuzz_state must return at least 1 account (n = 1..=8); \ + "arbitrary_fuzz_state must return at least 1 account for distinct-ID input; \ returned 0 \u{2014} mutation: `+ 1` replaced by `* 1` or `Ok(vec![])`" ); } #[test] fn fuzz_state_count_uses_modulo_not_div_or_add() { - // fill_buffer reads from the front; the first byte is the n-selector. - let mut buf = vec![0_u8; 1000]; + // fill_buffer reads from the front; the first byte is the n-selector. Distinct + // bytes give every account a unique ID so the count is not masked by dedup. + let mut buf = distinct_byte_buffer(1000); buf[0] = 8; // selector byte: 8 % 8 = 0, +1 -> n=1 | 8 / 8 = 1, +1 -> n=2 | 8 + 8 = 16, +1 -> n=17 let mut u = Unstructured::new(&buf); let accounts = arbitrary_fuzz_state(&mut u).expect("should succeed"); @@ -76,6 +91,56 @@ fn fuzz_state_count_uses_modulo_not_div_or_add() { ); } +#[test] +fn fuzz_state_excludes_reserved_system_ids() { + // Genesis overwrites the faucet (balance = u128::MAX) and bridge accounts after + // inserting the supplied genesis accounts; a generated account colliding with one + // would read back a balance the cap never produced, overflowing conservation sums. + // The generator must therefore never emit a reserved system ID. + let reserved = [ + nssa::system_faucet_account_id(), + nssa::system_bridge_account_id(), + ]; + let buf = distinct_byte_buffer(10_000); + let mut u = Unstructured::new(&buf); + let accounts = arbitrary_fuzz_state(&mut u).expect("should succeed"); + for acc in &accounts { + assert!( + !reserved.contains(&acc.account_id), + "arbitrary_fuzz_state emitted reserved system account ID {:?} \u{2014} \ + genesis would overwrite it and break the balance-conservation invariant", + acc.account_id + ); + } +} + +#[test] +fn fuzz_state_dedups_account_ids() { + // All-identical bytes make every drawn account ID identical; genesis stores + // accounts in a HashMap (last-write-wins), so duplicate IDs would let a per-ID + // balance sum double-count one account. The generator must collapse them to one. + let buf = vec![0xAB_u8; 10_000]; + let mut u = Unstructured::new(&buf); + let accounts = arbitrary_fuzz_state(&mut u).expect("should succeed"); + assert!( + accounts.len() <= 1, + "arbitrary_fuzz_state must dedup identical account IDs; got {} accounts", + accounts.len() + ); + + // Independent confirmation on a distinct-ID draw: no ID appears twice. + let distinct_buf = distinct_byte_buffer(10_000); + let mut distinct_u = Unstructured::new(&distinct_buf); + let distinct_accounts = arbitrary_fuzz_state(&mut distinct_u).expect("should succeed"); + let unique: std::collections::HashSet<_> = + distinct_accounts.iter().map(|a| a.account_id).collect(); + assert_eq!( + unique.len(), + distinct_accounts.len(), + "arbitrary_fuzz_state returned duplicate account IDs" + ); +} + /// Verifies that each account's balance is <= `u128::MAX / 8`. #[test] fn fuzz_state_balances_bounded_by_max_div_8() { diff --git a/fuzz_props/src/tests/privacy.rs b/fuzz_props/src/tests/privacy.rs index ad4a0d08..847c4392 100644 --- a/fuzz_props/src/tests/privacy.rs +++ b/fuzz_props/src/tests/privacy.rs @@ -257,6 +257,41 @@ fn arb_validity_window_bounds_use_modulo_8() { /// Two flavours of check run here: per-iteration upper bounds that must hold for /// *every* generated transaction, and end-of-run reachability checks that confirm /// the interesting shapes actually occur across the sampled inputs. +/// Which branches of the line-233 `if !accounts.is_empty() && bool::arbitrary(u)?` were +/// observable across a transaction's non-signer "extra" public-account ids. +#[derive(Default)] +struct ExtraKinds { + /// At least one extra (non-signer id) was appended at all. + any: bool, + /// An extra equal to a *known* fuzz-account id (the `&&`-true branch). + known: bool, + /// An extra that is a *random* id (the `else` branch). + random: bool, +} + +/// Classify a message's extras. A signer's public-account id is key-derived and independent +/// of `FuzzAccount.account_id`, so any non-signer id present in `public_account_ids` was +/// appended by the line-233 `if`; a *known* id can only come from its `&&`-true branch. +fn classify_extras( + public_account_ids: &[AccountId], + signer_ids: &[AccountId], + known_ids: &std::collections::HashSet, +) -> ExtraKinds { + let mut kinds = ExtraKinds::default(); + for id in public_account_ids { + if signer_ids.contains(id) { + continue; + } + kinds.any = true; + if known_ids.contains(id) { + kinds.known = true; + } else { + kinds.random = true; + } + } + kinds +} + #[test] fn arb_privacy_preserving_tx_generator_invariants() { let accounts: Vec = (1..=6_u8) @@ -270,6 +305,9 @@ fn arb_privacy_preserving_tx_generator_invariants() { accounts.iter().map(|a| (a.account_id, a.balance)).collect(); let state = V03State::new_with_genesis_accounts(&genesis, vec![], 0); + let known_ids: std::collections::HashSet = + accounts.iter().map(|a| a.account_id).collect(); + let mut rng = Rng::new(); let mut buf = vec![0_u8; 8192]; @@ -277,6 +315,8 @@ fn arb_privacy_preserving_tx_generator_invariants() { let mut max_signers = 0_usize; let mut saw_signer = false; let mut saw_extra = false; + let mut saw_known_extra = false; + let mut saw_random_extra = false; let mut max_commitments = 0_usize; let mut max_nullifiers = 0_usize; let mut saw_empty_comm_nonempty_null = false; @@ -344,14 +384,12 @@ fn arb_privacy_preserving_tx_generator_invariants() { msg.encrypted_private_post_states.len() ); - // An id that is not a signer can only be present because an extra was appended. - if msg - .public_account_ids - .iter() - .any(|id| !signer_ids.contains(id)) - { - saw_extra = true; - } + // Classify the non-signer "extras" by which branch of the line-233 `if` produced + // them — a *known* fuzz-account id, a *random* id, or both. + let extras = classify_extras(&msg.public_account_ids, &signer_ids, &known_ids); + saw_extra |= extras.any; + saw_known_extra |= extras.known; + saw_random_extra |= extras.random; max_commitments = max_commitments.max(msg.new_commitments.len()); max_nullifiers = max_nullifiers.max(msg.new_nullifiers.len()); @@ -393,6 +431,19 @@ fn arb_privacy_preserving_tx_generator_invariants() { saw_extra, "the generator never appended an extra public account id" ); + // Both branches `if !accounts.is_empty() && bool::arbitrary(u)?` must be + // reachable. The known-account branch must fire (else `delete !` — which short-circuits to + // the random branch when accounts are present — would be indistinguishable) + assert!( + saw_known_extra, + "the generator never appended a *known* fuzz-account id as an extra" + ); + // …and the random branch must fire (else `&&`→`||` — which short-circuits to the known + // branch when accounts are present — would be indistinguishable). + assert!( + saw_random_extra, + "the generator never appended a *random* id as an extra" + ); // Multiple distinct commitments must be reachable (the dedup must keep, not drop). assert!( max_commitments >= 2,