mirror of
https://github.com/logos-blockchain/lez-fuzzing.git
synced 2026-06-17 08:29:46 +00:00
Merge pull request #3 from logos-blockchain/chore-mutants
chore: Test quality diagnostics with cargo mutants
This commit is contained in:
commit
1558d21406
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
corpus/** linguist-generated=true
|
||||
22
.github/workflows/fuzz-afl.yml
vendored
22
.github/workflows/fuzz-afl.yml
vendored
@ -41,6 +41,11 @@ jobs:
|
||||
- fuzz_transaction_decoding
|
||||
- fuzz_validate_execute_consistency
|
||||
- fuzz_witness_set_verification
|
||||
- fuzz_merkle_tree
|
||||
- fuzz_transaction_properties
|
||||
- fuzz_privacy_preserving_witness
|
||||
- fuzz_encoding_privacy_preserving
|
||||
- fuzz_nullifier_set_roundtrip
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -239,7 +244,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# afl-coverage-aggregate — single HTML report merging all 15 targets
|
||||
# afl-coverage-aggregate — single HTML report merging all 20 targets
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
afl-coverage-aggregate:
|
||||
name: "AFL++ coverage — aggregated"
|
||||
@ -302,6 +307,11 @@ jobs:
|
||||
fuzz_transaction_decoding
|
||||
fuzz_validate_execute_consistency
|
||||
fuzz_witness_set_verification
|
||||
fuzz_merkle_tree
|
||||
fuzz_transaction_properties
|
||||
fuzz_privacy_preserving_witness
|
||||
fuzz_encoding_privacy_preserving
|
||||
fuzz_nullifier_set_roundtrip
|
||||
)
|
||||
for TARGET in "${TARGETS[@]}"; do
|
||||
cargo build \
|
||||
@ -330,6 +340,11 @@ jobs:
|
||||
fuzz_transaction_decoding
|
||||
fuzz_validate_execute_consistency
|
||||
fuzz_witness_set_verification
|
||||
fuzz_merkle_tree
|
||||
fuzz_transaction_properties
|
||||
fuzz_privacy_preserving_witness
|
||||
fuzz_encoding_privacy_preserving
|
||||
fuzz_nullifier_set_roundtrip
|
||||
)
|
||||
PROFRAW_DIR="coverage/afl/aggregated/profraw"
|
||||
mkdir -p "$PROFRAW_DIR"
|
||||
@ -409,6 +424,11 @@ jobs:
|
||||
fuzz_transaction_decoding
|
||||
fuzz_validate_execute_consistency
|
||||
fuzz_witness_set_verification
|
||||
fuzz_merkle_tree
|
||||
fuzz_transaction_properties
|
||||
fuzz_privacy_preserving_witness
|
||||
fuzz_encoding_privacy_preserving
|
||||
fuzz_nullifier_set_roundtrip
|
||||
)
|
||||
# llvm-cov show: first binary is a positional arg; the rest use --object
|
||||
first=1
|
||||
|
||||
17
.github/workflows/fuzz.yml
vendored
17
.github/workflows/fuzz.yml
vendored
@ -35,6 +35,11 @@ jobs:
|
||||
- fuzz_apply_state_diff_split_path
|
||||
- fuzz_multi_block_state_sequence
|
||||
- fuzz_sequencer_vs_replayer
|
||||
- fuzz_merkle_tree
|
||||
- fuzz_transaction_properties
|
||||
- fuzz_privacy_preserving_witness
|
||||
- fuzz_encoding_privacy_preserving
|
||||
- fuzz_nullifier_set_roundtrip
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@ -218,6 +223,11 @@ jobs:
|
||||
- fuzz_apply_state_diff_split_path
|
||||
- fuzz_multi_block_state_sequence
|
||||
- fuzz_sequencer_vs_replayer
|
||||
- fuzz_merkle_tree
|
||||
- fuzz_transaction_properties
|
||||
- fuzz_privacy_preserving_witness
|
||||
- fuzz_encoding_privacy_preserving
|
||||
- fuzz_nullifier_set_roundtrip
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout logos-execution-zone
|
||||
@ -285,7 +295,12 @@ jobs:
|
||||
fuzz_program_deployment_lifecycle \
|
||||
fuzz_apply_state_diff_split_path \
|
||||
fuzz_multi_block_state_sequence \
|
||||
fuzz_sequencer_vs_replayer; do
|
||||
fuzz_sequencer_vs_replayer \
|
||||
fuzz_merkle_tree \
|
||||
fuzz_transaction_properties \
|
||||
fuzz_privacy_preserving_witness \
|
||||
fuzz_encoding_privacy_preserving \
|
||||
fuzz_nullifier_set_roundtrip; do
|
||||
echo "=== $target ===" | tee -a perf_baseline.txt
|
||||
cargo fuzz run "$target" -- -max_total_time=30 2>&1 \
|
||||
| grep -E "exec/s|execs_per_sec" | tail -1 | tee -a perf_baseline.txt
|
||||
|
||||
236
.github/workflows/mutants.yml
vendored
Normal file
236
.github/workflows/mutants.yml
vendored
Normal file
@ -0,0 +1,236 @@
|
||||
name: Mutation Testing
|
||||
|
||||
# ── When to run ───────────────────────────────────────────────────────────────
|
||||
# Plane A (fuzz_props invariants) runs on every PR that touches harness code.
|
||||
# Plane B (LEZ protocol vs corpus) is slow (minutes per mutant × many mutants)
|
||||
# so it only runs on a weekly schedule or on manual dispatch.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "fuzz_props/**"
|
||||
- "fuzz/fuzz_targets/**"
|
||||
- ".github/workflows/mutants.yml"
|
||||
schedule:
|
||||
- cron: "0 4 * * 1" # 04:00 UTC every Monday
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
RISC0_DEV_MODE: "1"
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# ── Plane A: mutate fuzz_props (invariant harness) ────────────────────────
|
||||
# Oracle: cargo test -p fuzz_props --release
|
||||
# Fast (~30–120 s total). Blocks PRs if any invariant-check logic is
|
||||
# under-tested.
|
||||
mutants-harness:
|
||||
name: Mutants — fuzz_props invariants
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout logos-execution-zone
|
||||
uses: ./.github/actions/checkout-lez
|
||||
|
||||
- name: Install stable Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install logos-blockchain-circuits
|
||||
uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: mutants-harness-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install cargo-mutants
|
||||
run: cargo install cargo-mutants --locked
|
||||
|
||||
# workspace.metadata.cargo-mutants in Cargo.toml sets:
|
||||
# additional_cargo_args = ["--release"]
|
||||
# exclude_globs = ["fuzz/fuzz_targets/**"]
|
||||
# timeout_multiplier = 3.0
|
||||
- name: Run mutation tests on fuzz_props
|
||||
run: |
|
||||
cargo mutants \
|
||||
--package fuzz_props \
|
||||
--in-place \
|
||||
--output mutants-harness.out
|
||||
|
||||
- name: Upload mutants report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutants-harness-report
|
||||
path: mutants-harness.out/
|
||||
|
||||
- name: Write GitHub Step Summary
|
||||
if: always()
|
||||
run: |
|
||||
MISSED=$(wc -l < mutants-harness.out/missed.txt 2>/dev/null | tr -d ' ' || echo 0)
|
||||
CAUGHT=$(wc -l < mutants-harness.out/caught.txt 2>/dev/null | tr -d ' ' || echo 0)
|
||||
{
|
||||
echo "## Mutation Testing — \`fuzz_props\` invariants"
|
||||
echo ""
|
||||
echo "| Result | Count |"
|
||||
echo "|--------|-------|"
|
||||
echo "| ✅ Caught | ${CAUGHT} |"
|
||||
echo "| ❌ Survived | ${MISSED} |"
|
||||
echo ""
|
||||
if [ "${MISSED}" -gt 0 ]; then
|
||||
echo "### Surviving mutants (invariant-checker gaps)"
|
||||
echo '```'
|
||||
cat mutants-harness.out/missed.txt 2>/dev/null || true
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "> Each surviving mutant represents a mutation in the invariant-checking"
|
||||
echo "> code that \`cargo test -p fuzz_props\` did not detect."
|
||||
echo "> Add a property-test that specifically exercises that code path."
|
||||
else
|
||||
echo "> All mutants caught — invariant-checking logic is fully covered."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Fail if any mutations survived
|
||||
run: |
|
||||
if [ -s mutants-harness.out/missed.txt ]; then
|
||||
echo "ERROR: surviving mutants found in fuzz_props — see artifact and Step Summary"
|
||||
cat mutants-harness.out/missed.txt
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Plane B: mutate LEZ protocol code, oracle = corpus regression ─────────
|
||||
# Each mutant: rebuild nssa/common + replay all 20 fuzz corpora (-runs=0).
|
||||
# Surviving mutants = protocol bugs the committed corpus has never caught.
|
||||
# Runs on schedule (weekly Monday) or manual workflow_dispatch only.
|
||||
mutants-protocol:
|
||||
name: Mutants — LEZ protocol vs corpus
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout logos-execution-zone
|
||||
uses: ./.github/actions/checkout-lez
|
||||
|
||||
# cargo-fuzz requires nightly.
|
||||
- name: Install Rust nightly toolchain
|
||||
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 }}
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: mutants-protocol-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install cargo-fuzz and cargo-mutants
|
||||
run: |
|
||||
cargo install cargo-fuzz --locked
|
||||
cargo install cargo-mutants --locked
|
||||
|
||||
- name: Make corpus-regression wrapper executable
|
||||
run: chmod +x scripts/mutants-corpus-test.sh
|
||||
|
||||
# Build all 20 fuzz targets once before the mutation loop so that each
|
||||
# mutant only needs to rebuild the mutated crate, not the fuzz harness.
|
||||
# Keep this list in sync with scripts/mutants-corpus-test.sh.
|
||||
- name: Pre-build fuzz targets
|
||||
run: |
|
||||
for target in \
|
||||
fuzz_transaction_decoding fuzz_stateless_verification \
|
||||
fuzz_state_transition fuzz_block_verification \
|
||||
fuzz_encoding_roundtrip fuzz_signature_verification \
|
||||
fuzz_replay_prevention fuzz_state_diff_computation \
|
||||
fuzz_validate_execute_consistency fuzz_state_serialization \
|
||||
fuzz_witness_set_verification fuzz_program_deployment_lifecycle \
|
||||
fuzz_apply_state_diff_split_path fuzz_multi_block_state_sequence \
|
||||
fuzz_sequencer_vs_replayer fuzz_merkle_tree \
|
||||
fuzz_transaction_properties fuzz_privacy_preserving_witness \
|
||||
fuzz_encoding_privacy_preserving fuzz_nullifier_set_roundtrip; do
|
||||
cargo fuzz build "${target}"
|
||||
done
|
||||
|
||||
# cargo-mutants >=24 dropped --test-command; intercept "cargo test" with a
|
||||
# fake cargo wrapper that runs the corpus oracle instead. cargo-mutants is
|
||||
# called as a direct binary (not through `cargo`) so the CARGO env var we
|
||||
# set is respected rather than being overridden by cargo's process launch.
|
||||
- name: Run mutation tests against LEZ (nssa + common)
|
||||
run: |
|
||||
REAL_CARGO="$(command -v cargo)"
|
||||
FAKE_CARGO="$(mktemp /tmp/fake-cargo-XXXXXX)"
|
||||
# Intercept the test *execution* phase only; forward the build phase
|
||||
# (cargo test --no-run) to the real cargo so mutants are compiled.
|
||||
# cargo-mutants uses:
|
||||
# Build phase: cargo test --no-run --verbose --package=...
|
||||
# Test phase: cargo test --verbose --package=...
|
||||
printf '#!/bin/bash\n_has_no_run=false\nfor _a in "$@"; do [ "$_a" = "--no-run" ] && _has_no_run=true && break; done\nif [ "${1:-}" = "test" ] && [ "$_has_no_run" = "false" ]; then\n FUZZ_REPO="%s" exec "%s"\nelse\n exec "%s" "$@"\nfi\n' \
|
||||
"${{ github.workspace }}" \
|
||||
"${{ github.workspace }}/scripts/mutants-corpus-test.sh" \
|
||||
"$REAL_CARGO" > "$FAKE_CARGO"
|
||||
chmod +x "$FAKE_CARGO"
|
||||
# cargo install places cargo-mutants next to cargo in the same bin dir.
|
||||
MUTANTS_BIN="$(command -v cargo-mutants 2>/dev/null || echo "$(dirname "$REAL_CARGO")/cargo-mutants")"
|
||||
cd "${{ github.workspace }}/logos-execution-zone"
|
||||
# cargo-mutants is a Cargo plugin; when invoked directly (not via
|
||||
# `cargo mutants`) we must supply "mutants" as argv[1] ourselves.
|
||||
CARGO="$FAKE_CARGO" \
|
||||
"$MUTANTS_BIN" mutants \
|
||||
--package nssa \
|
||||
--package common \
|
||||
--in-place \
|
||||
--output "${{ github.workspace }}/mutants-protocol.out" \
|
||||
--timeout-multiplier 5.0
|
||||
rm -f "$FAKE_CARGO"
|
||||
|
||||
- name: Upload mutants report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutants-protocol-report
|
||||
path: mutants-protocol.out/
|
||||
|
||||
- name: Write GitHub Step Summary
|
||||
if: always()
|
||||
run: |
|
||||
MISSED=$(wc -l < mutants-protocol.out/missed.txt 2>/dev/null | tr -d ' ' || echo 0)
|
||||
CAUGHT=$(wc -l < mutants-protocol.out/caught.txt 2>/dev/null | tr -d ' ' || echo 0)
|
||||
{
|
||||
echo "## Mutation Testing — LEZ protocol vs committed corpus"
|
||||
echo ""
|
||||
echo "| Result | Count |"
|
||||
echo "|--------|-------|"
|
||||
echo "| ✅ Caught by corpus | ${CAUGHT} |"
|
||||
echo "| ❌ Survived (corpus gap) | ${MISSED} |"
|
||||
echo ""
|
||||
if [ "${MISSED}" -gt 0 ]; then
|
||||
echo "### Surviving mutants (corpus gaps — protocol bugs not yet reached)"
|
||||
echo '```'
|
||||
cat mutants-protocol.out/missed.txt 2>/dev/null || true
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "> For each surviving mutant:"
|
||||
echo "> 1. Run \`cargo fuzz run <target>\` targeting the mutated function."
|
||||
echo "> 2. Save the crashing input to \`corpus/libfuzz/<target>/\`."
|
||||
echo "> 3. Commit the corpus entry — the next run will show \`CAUGHT\`."
|
||||
else
|
||||
echo "> All mutants caught — committed corpus covers all tested mutation points."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -27,6 +27,13 @@ fuzz/coverage/
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# ── cargo-mutants outputs ─────────────────────────────────────────────────────
|
||||
# Local mutation-testing reports (caught.txt, missed.txt, etc.)
|
||||
# Created by `just mutants-harness` and `just mutants-protocol`.
|
||||
mutants.out/
|
||||
mutants-harness.out*
|
||||
mutants-protocol.out*
|
||||
|
||||
# ── Misc ──────────────────────────────────────────────────────────────────────
|
||||
# Performance baseline output from `just perf-baseline` or CI
|
||||
perf_baseline.txt
|
||||
|
||||
1606
Cargo.lock
generated
1606
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
28
Cargo.toml
@ -52,11 +52,13 @@ unsafe_code = "deny"
|
||||
[workspace.dependencies]
|
||||
|
||||
# ── LEZ crates — expects logos-execution-zone/ to be cloned at ../logos-execution-zone ──
|
||||
nssa = { path = "../logos-execution-zone/nssa" }
|
||||
nssa_core = { path = "../logos-execution-zone/nssa/core" }
|
||||
common = { path = "../logos-execution-zone/common" }
|
||||
key_protocol = { path = "../logos-execution-zone/key_protocol" }
|
||||
testnet_initial_state = { path = "../logos-execution-zone/testnet_initial_state" }
|
||||
# LEZ reorganised its directory layout; the package= key keeps the old dependency
|
||||
# alias so that fuzz_props source code (use nssa::...) compiles unchanged.
|
||||
nssa = { path = "../logos-execution-zone/lee/state_machine", package = "lee" }
|
||||
nssa_core = { path = "../logos-execution-zone/lee/state_machine/core", package = "lee_core" }
|
||||
common = { path = "../logos-execution-zone/lez/common" }
|
||||
key_protocol = { path = "../logos-execution-zone/lee/key_protocol" }
|
||||
testnet_initial_state = { path = "../logos-execution-zone/lez/testnet_initial_state" }
|
||||
token_core = { path = "../logos-execution-zone/programs/token/core" }
|
||||
test_program_methods = { path = "../logos-execution-zone/test_program_methods" }
|
||||
|
||||
@ -84,3 +86,19 @@ hmac-sha512 = "1.1.7"
|
||||
itertools = "0.14.0"
|
||||
risc0-build = "3.0.5"
|
||||
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git" }
|
||||
|
||||
# ── cargo-mutants configuration (Plane A: mutate fuzz_props invariants) ───────
|
||||
# Run with --release to match CI timing.
|
||||
# fuzz/fuzz_targets/** entry-points use fuzz_entry!() macros that are not
|
||||
# reachable via `cargo test`; mutations there produce false survivors.
|
||||
[workspace.metadata.cargo-mutants]
|
||||
additional_cargo_args = ["--release"]
|
||||
exclude_globs = ["fuzz/fuzz_targets/**"]
|
||||
# RISC0 release builds are slower than typical crates; give each mutant extra time.
|
||||
timeout_multiplier = 3.0
|
||||
# The workspace uses path dependencies outside its own directory
|
||||
# (../logos-execution-zone/*). cargo-mutants normally copies the workspace to a
|
||||
# temp directory, but the copy does not include the sibling LEZ directory, so the
|
||||
# build fails immediately. --in-place mutates the original source files in-place
|
||||
# and avoids the copy, letting cargo resolve ../logos-execution-zone as usual.
|
||||
in_place = true
|
||||
|
||||
317
Justfile
317
Justfile
@ -39,11 +39,65 @@ fuzz TIME="30":
|
||||
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)
|
||||
fuzz-regression:
|
||||
# Run ALL fuzz targets with a bounded job pool for TIME seconds each (default: 30).
|
||||
# At most JOBS targets run simultaneously (default: 4); as soon as one finishes
|
||||
# the next queued target is launched, so the pool stays full until all targets
|
||||
# have been processed.
|
||||
# Targets are discovered automatically from fuzz/Cargo.toml — no edit needed
|
||||
# here when a new [[bin]] entry is added.
|
||||
#
|
||||
# Usage: just fuzz-parallel # all targets, 30 s each, 4 in parallel
|
||||
# just fuzz-parallel 120 # all targets, 120 s each, 4 in parallel
|
||||
# just fuzz-parallel 120 8 # all targets, 120 s each, 8 in parallel
|
||||
fuzz-parallel TIME="30" JOBS="4":
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
for target in $(cargo fuzz list 2>/dev/null); do
|
||||
TARGETS=($(cargo fuzz list 2>/dev/null))
|
||||
total=${#TARGETS[@]}
|
||||
echo "Targets: $total | max parallel: {{JOBS}} | time per target: {{TIME}}s"
|
||||
echo ""
|
||||
PIDS=()
|
||||
failed=0
|
||||
|
||||
for target in "${TARGETS[@]}"; do
|
||||
# Wait for a free slot (block until running jobs < JOBS)
|
||||
while [ "$(jobs -rp | wc -l | tr -d ' ')" -ge "{{JOBS}}" ]; do
|
||||
# bash 4.3+: wait -n returns when any single child exits
|
||||
wait -n 2>/dev/null || sleep 0.5
|
||||
done
|
||||
echo "=== launching $target ({{TIME}}s) ==="
|
||||
mkdir -p "corpus/libfuzz/$target"
|
||||
cargo fuzz run "$target" "corpus/libfuzz/$target" -- -max_total_time={{TIME}} &
|
||||
PIDS+=($!)
|
||||
done
|
||||
|
||||
# Drain the remaining running targets
|
||||
for pid in "${PIDS[@]}"; do
|
||||
wait "$pid" || { echo "WARNING: PID $pid exited with non-zero status"; failed=$((failed + 1)); }
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ "$failed" -eq 0 ]; then
|
||||
echo "✓ All $total target(s) finished successfully."
|
||||
else
|
||||
echo "WARNING: $failed of $total target(s) reported errors."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Re-run the saved corpus for one or ALL targets (regression mode, no new mutations).
|
||||
# When TARGET is omitted every registered target is replayed in sequence.
|
||||
# Usage: just fuzz-regression # all targets
|
||||
# just fuzz-regression fuzz_state_transition # single target
|
||||
fuzz-regression TARGET="":
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
TARGET="{{TARGET}}"
|
||||
if [ -z "$TARGET" ]; then
|
||||
TARGETS=($(cargo fuzz list 2>/dev/null))
|
||||
else
|
||||
TARGETS=("$TARGET")
|
||||
fi
|
||||
for target in "${TARGETS[@]}"; do
|
||||
echo "=== regression $target ==="
|
||||
mkdir -p "corpus/libfuzz/$target"
|
||||
cargo fuzz run "$target" "corpus/libfuzz/$target" -- -runs=0
|
||||
@ -295,45 +349,96 @@ fuzz-afl TARGET="" TIME="30":
|
||||
echo " Format for a report: just afl-fmt <crash-file>"
|
||||
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.
|
||||
# Run AFL++ over all targets, up to JOBS targets at a time (one instance each),
|
||||
# for TIME seconds per target. As soon as one target finishes the next queued
|
||||
# target is launched, so the pool stays full until every target is done.
|
||||
# Requires that afl-fuzz is on PATH.
|
||||
# On macOS this needs the System V shared-memory limits raised and the crash
|
||||
# reporter disabled (both require admin). If the current user can use sudo the
|
||||
# recipe does it automatically; otherwise it asks an administrator to run
|
||||
# `sudo afl-system-config` once (sets the shm limits and disables the reporter —
|
||||
# both persist until reboot) and 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":
|
||||
# Usage: just fuzz-afl-parallel # all targets, 30 s each, 4 at a time
|
||||
# just fuzz-afl-parallel 600 # all targets, 600 s each, 4 at a time
|
||||
# just fuzz-afl-parallel 600 8 # all targets, 600 s each, 8 at a time
|
||||
fuzz-afl-parallel TIME="30" JOBS="4":
|
||||
#!/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 ───────────
|
||||
|
||||
# ── Collect targets to run ────────────────────────────────────────────────
|
||||
TARGETS=($(cargo fuzz list 2>/dev/null))
|
||||
|
||||
# ── macOS: shared-memory limits + crash reporter (both need admin) ────────
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
SL=/System/Library; PL=com.apple.ReportCrash
|
||||
# Can this user run privileged commands without prompting for a password?
|
||||
if sudo -n true 2>/dev/null; then CAN_SUDO=1; else CAN_SUDO=0; fi
|
||||
|
||||
# The default kern.sysv.shmmax (4 MB) is too small for AFL++'s shm
|
||||
# segments, so shmget() fails with EINVAL under parallel fuzzing.
|
||||
NEED_SHMMAX=524288000
|
||||
CUR_SHMMAX=$(sysctl -n kern.sysv.shmmax 2>/dev/null || echo 0)
|
||||
if [ "$CUR_SHMMAX" -lt "$NEED_SHMMAX" ]; then
|
||||
if [ "$CAN_SUDO" = 1 ]; then
|
||||
echo "macOS: raising shared-memory limits for parallel AFL++ instances…"
|
||||
sudo sysctl kern.sysv.shmmax=524288000 kern.sysv.shmmin=1 \
|
||||
kern.sysv.shmseg=48 kern.sysv.shmall=131072000 \
|
||||
kern.sysv.shmmni=1024 >/dev/null || true
|
||||
else
|
||||
echo "ERROR: System V shared-memory limits are too low for parallel AFL++"
|
||||
echo " (kern.sysv.shmmax=$CUR_SHMMAX, need >= $NEED_SHMMAX) and this"
|
||||
echo " user cannot use sudo."
|
||||
echo ""
|
||||
echo " Ask an administrator to run this once (persists until reboot):"
|
||||
echo ""
|
||||
echo " sudo afl-system-config"
|
||||
echo ""
|
||||
echo " It raises the shm limits and disables the crash reporter."
|
||||
echo " Then re-run: just fuzz-afl-parallel {{TIME}} {{JOBS}}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Disable the crash reporter for the duration of the run. The per-user
|
||||
# agent needs no privileges; the system daemon does, so only attempt it
|
||||
# when we can sudo (an admin's `afl-system-config` covers it otherwise).
|
||||
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
|
||||
launchctl unload -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true
|
||||
if [ "$CAN_SUDO" = 1 ]; then
|
||||
sudo launchctl unload -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true
|
||||
fi
|
||||
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
|
||||
launchctl load -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true
|
||||
[ "'"$CAN_SUDO"'" = 1 ] && 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" &
|
||||
|
||||
# ── Run targets, up to JOBS at a time ─────────────────────────────────────
|
||||
_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
|
||||
}
|
||||
echo "Targets: ${#TARGETS[@]} | max parallel: {{JOBS}} | time per target: {{TIME}}s"
|
||||
for t in "${TARGETS[@]}"; do
|
||||
# Wait for a free slot (block until running jobs < JOBS)
|
||||
while [ "$(jobs -rp | wc -l | tr -d ' ')" -ge "{{JOBS}}" ]; do
|
||||
wait -n 2>/dev/null || sleep 0.5
|
||||
done
|
||||
echo "=== launching afl++ $t ({{TIME}}s) ==="
|
||||
_run_one "$t" &
|
||||
done
|
||||
sleep {{TIME}}
|
||||
kill $(jobs -p) 2>/dev/null || true
|
||||
# Drain the remaining running targets
|
||||
wait 2>/dev/null || true
|
||||
just afl-corpus-sync
|
||||
|
||||
@ -643,12 +748,158 @@ coverage-all ENGINE="all":
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Mutation testing ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Prerequisites (install once):
|
||||
# cargo install cargo-mutants
|
||||
#
|
||||
# Two planes — run them independently:
|
||||
#
|
||||
# Plane A (fast, ~1-5 min): mutates fuzz_props invariant logic.
|
||||
# Oracle: cargo test -p fuzz_props --release
|
||||
# Run on every PR that touches fuzz_props/ or fuzz/fuzz_targets/.
|
||||
#
|
||||
# Plane B (slow, ~hours): mutates LEZ protocol code (lee, common).
|
||||
# Oracle: all 15 fuzz targets replayed against their committed corpus.
|
||||
# Run weekly or manually to find corpus gaps.
|
||||
|
||||
# Plane A — mutation testing of fuzz_props invariant implementations.
|
||||
#
|
||||
# Mutates every function in fuzz_props and checks whether `cargo test -p fuzz_props
|
||||
# --release` catches the mutation. Surviving mutants identify invariant-checker
|
||||
# logic that the property tests do not fully exercise.
|
||||
#
|
||||
# Workspace metadata in Cargo.toml configures --release, exclude_globs, and
|
||||
# timeout_multiplier automatically.
|
||||
#
|
||||
# --in-place is mandatory: fuzz_props depends on LEZ crates via relative path
|
||||
# (../logos-execution-zone/...) — without it cargo-mutants copies the workspace
|
||||
# to /tmp and the copy cannot resolve those relative paths.
|
||||
#
|
||||
# Output: mutants-harness.out/ (human-readable report also printed to stdout)
|
||||
mutants-harness:
|
||||
cargo mutants --package fuzz_props --in-place --output mutants-harness.out
|
||||
|
||||
# Plane B — mutation testing of the LEZ protocol code against the committed corpus.
|
||||
#
|
||||
# Mutates lee and common in the logos-execution-zone sibling workspace and uses
|
||||
# scripts/mutants-corpus-test.sh as the oracle. The oracle replays all 15
|
||||
# committed libFuzzer corpora (cargo fuzz run -runs=0) against each mutant.
|
||||
#
|
||||
# A mutant that SURVIVES means there is no corpus input that triggers the
|
||||
# relevant protocol invariant at that mutation point — a corpus gap worth
|
||||
# investigating with a longer fuzz run.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - logos-execution-zone cloned at ../logos-execution-zone
|
||||
# - cargo-fuzz installed (cargo install cargo-fuzz)
|
||||
# - cargo-mutants installed (cargo install cargo-mutants --locked)
|
||||
#
|
||||
# PACKAGES selects which LEZ crates to mutate (space-separated).
|
||||
# Default covers the two highest-value protocol crates.
|
||||
#
|
||||
# Output report: mutants-protocol.out/ in the repository root.
|
||||
mutants-protocol PACKAGES="lee common":
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
REPO_DIR="$(pwd)"
|
||||
|
||||
if [ ! -d "${REPO_DIR}/../logos-execution-zone" ]; then
|
||||
echo "ERROR: logos-execution-zone not found at ../logos-execution-zone"
|
||||
exit 1
|
||||
fi
|
||||
LEZ_DIR="$(cd "${REPO_DIR}/../logos-execution-zone" && pwd)"
|
||||
|
||||
# Build --package flags (one per crate name)
|
||||
PKG_FLAGS=()
|
||||
for pkg in {{PACKAGES}}; do
|
||||
PKG_FLAGS+=(--package "$pkg")
|
||||
done
|
||||
|
||||
echo "=== Plane B: mutating [{{PACKAGES}}] in logos-execution-zone ==="
|
||||
echo " Oracle: scripts/mutants-corpus-test.sh (corpus regression, -runs=0)"
|
||||
echo " Report: ${REPO_DIR}/mutants-protocol.out/"
|
||||
echo ""
|
||||
|
||||
# cargo-mutants must be run from inside the target workspace.
|
||||
# FUZZ_REPO tells the oracle script where to find the corpus and fuzz/ dir.
|
||||
# --output puts the report in our repo root so it's easy to browse/commit.
|
||||
# --in-place is required because LEZ depends on path crates outside its own
|
||||
# directory (e.g. the Rust standard toolchain); without it cargo-mutants copies
|
||||
# the workspace to a temp dir where those relative paths would not resolve.
|
||||
#
|
||||
# cargo-mutants >=24 dropped --test-command and only supports --test-tool cargo|nextest.
|
||||
# Work around: create a fake `cargo` wrapper that intercepts `cargo test` and
|
||||
# runs the corpus oracle instead; every other sub-command is delegated to the
|
||||
# real cargo. We call the cargo-mutants binary directly so that cargo's own
|
||||
# process launch doesn't override the CARGO env var back to the real binary.
|
||||
REAL_CARGO="$(command -v cargo)"
|
||||
FAKE_CARGO=$(mktemp /tmp/fake-cargo-XXXXXX)
|
||||
FAKE_CARGO_LOG=$(mktemp /tmp/fake-cargo-log-XXXXXX.txt)
|
||||
trap 'rm -f "$FAKE_CARGO" "$FAKE_CARGO_LOG"' EXIT
|
||||
# The fake cargo intercepts the test *execution* phase only.
|
||||
# cargo-mutants drives two kinds of "cargo test" invocations:
|
||||
# Build phase: cargo test --no-run --verbose --package=... (compile only)
|
||||
# Test phase: cargo test --verbose --package=... (run tests)
|
||||
# The oracle must only replace the test execution phase; the build phase
|
||||
# must be forwarded to the real cargo so mutants are actually compiled.
|
||||
printf '#!/bin/bash\necho "FAKE_CARGO: $*" >> "%s"\n_has_no_run=false\nfor _a in "$@"; do [ "$_a" = "--no-run" ] && _has_no_run=true && break; done\nif [ "${1:-}" = "test" ] && [ "$_has_no_run" = "false" ]; then\n FUZZ_REPO="%s" exec "%s"\nelse\n exec "%s" "$@"\nfi\n' \
|
||||
"$FAKE_CARGO_LOG" \
|
||||
"$REPO_DIR" \
|
||||
"${REPO_DIR}/scripts/mutants-corpus-test.sh" \
|
||||
"$REAL_CARGO" > "$FAKE_CARGO"
|
||||
chmod +x "$FAKE_CARGO"
|
||||
|
||||
# Locate the cargo-mutants binary (installed by `cargo install cargo-mutants`).
|
||||
MUTANTS_BIN="$(command -v cargo-mutants 2>/dev/null || true)"
|
||||
if [ -z "$MUTANTS_BIN" ]; then
|
||||
MUTANTS_BIN="$(dirname "$REAL_CARGO")/cargo-mutants"
|
||||
fi
|
||||
if [ ! -x "$MUTANTS_BIN" ]; then
|
||||
echo "ERROR: cargo-mutants not found. Install with: cargo install cargo-mutants --locked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# cargo-mutants is a Cargo plugin. When invoked via `cargo mutants`, Cargo
|
||||
# automatically prepends "mutants" as argv[1]. When we invoke the binary
|
||||
# directly (to keep our CARGO env override alive), we must supply it ourselves.
|
||||
cd "$LEZ_DIR"
|
||||
CARGO="$FAKE_CARGO" \
|
||||
"$MUTANTS_BIN" mutants \
|
||||
"${PKG_FLAGS[@]}" \
|
||||
--in-place \
|
||||
--output "${REPO_DIR}/mutants-protocol.out" \
|
||||
--timeout-multiplier 5.0 \
|
||||
|| { echo "--- fake-cargo invocations ---"; cat "$FAKE_CARGO_LOG"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "=== Mutation report summary ==="
|
||||
MISSED_FILE="${REPO_DIR}/mutants-protocol.out/missed.txt"
|
||||
CAUGHT_FILE="${REPO_DIR}/mutants-protocol.out/caught.txt"
|
||||
MISSED=$(wc -l < "$MISSED_FILE" 2>/dev/null | tr -d ' ' || echo 0)
|
||||
CAUGHT=$(wc -l < "$CAUGHT_FILE" 2>/dev/null | tr -d ' ' || echo 0)
|
||||
echo "Caught: ${CAUGHT} | Survived: ${MISSED}"
|
||||
echo ""
|
||||
if [ "${MISSED}" -gt 0 ]; then
|
||||
echo "Surviving mutants (corpus gaps):"
|
||||
cat "$MISSED_FILE" || true
|
||||
echo ""
|
||||
echo "For each surviving mutant: run 'just fuzz <target>' targeting the"
|
||||
echo "mutated function, add the crashing input to corpus/libfuzz/<target>/,"
|
||||
echo "then re-run 'just mutants-protocol' to confirm it is now CAUGHT."
|
||||
else
|
||||
echo "All mutants caught — corpus covers all tested mutation points."
|
||||
fi
|
||||
|
||||
# ── Housekeeping ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Remove all Cargo build artefacts (workspace + fuzz sub-crate)
|
||||
# Remove all Cargo build artefacts (workspace + fuzz sub-crate + logos-execution-zone)
|
||||
# Each command is prefixed with `-` so that a missing sibling workspace (LEZ not cloned)
|
||||
# does not abort the recipe — cargo clean still removes whatever targets are present.
|
||||
clean:
|
||||
cargo clean
|
||||
cargo clean --manifest-path fuzz/Cargo.toml
|
||||
-cargo clean
|
||||
-cargo clean --manifest-path fuzz/Cargo.toml
|
||||
-cargo clean --manifest-path ../logos-execution-zone/Cargo.toml
|
||||
|
||||
# Remove libFuzzer crash/timeout artifacts for all targets (corpus is kept)
|
||||
clean-artifacts:
|
||||
|
||||
171
README.md
171
README.md
@ -1,11 +1,20 @@
|
||||
# Lez-fuzzing
|
||||
<div align="center">
|
||||
|
||||
Coverage-guided fuzzing and adversarial testing infrastructure for the
|
||||
**Logos Execution Zone (LEZ)** protocol.
|
||||
# <img src="logos.avif" alt="" height="32" valign="middle"> Lez-fuzzing
|
||||
|
||||
**Coverage-guided fuzzing & adversarial testing infrastructure for the
|
||||
[Logos Execution Zone (LEZ)](https://github.com/) protocol.**
|
||||
|
||||
[](rust-toolchain.toml)
|
||||
[](#-fuzz-targets)
|
||||
[](.github/workflows/mutants.yml)
|
||||
[](LICENSE-MIT)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Repository Layout
|
||||
## 📂 Repository Layout
|
||||
|
||||
```
|
||||
lez-fuzzing/
|
||||
@ -22,34 +31,25 @@ lez-fuzzing/
|
||||
│ └── generators.rs # Arbitrary / proptest strategies
|
||||
├── fuzz/ # cargo-fuzz crate (own [workspace] sentinel)
|
||||
│ ├── Cargo.toml
|
||||
│ ├── fuzz_targets/
|
||||
│ │ ├── _template.rs # Template for just new-target
|
||||
│ │ ├── fuzz_transaction_decoding.rs
|
||||
│ │ ├── fuzz_stateless_verification.rs
|
||||
│ │ ├── fuzz_state_transition.rs
|
||||
│ │ ├── fuzz_block_verification.rs
|
||||
│ │ ├── fuzz_encoding_roundtrip.rs
|
||||
│ │ ├── fuzz_signature_verification.rs
|
||||
│ │ ├── fuzz_replay_prevention.rs
|
||||
│ │ ├── fuzz_state_diff_computation.rs
|
||||
│ │ ├── fuzz_validate_execute_consistency.rs
|
||||
│ │ ├── fuzz_state_serialization.rs
|
||||
│ │ ├── fuzz_witness_set_verification.rs
|
||||
│ │ ├── fuzz_program_deployment_lifecycle.rs
|
||||
│ │ ├── fuzz_apply_state_diff_split_path.rs
|
||||
│ │ └── fuzz_multi_block_state_sequence.rs
|
||||
│ ├── fuzz_targets/ # 20 targets total — see table below
|
||||
│ │ ├── _template.rs # Template for `just new-target`
|
||||
│ │ └── fuzz_*.rs
|
||||
│ └── corpus/ # Curated seed inputs (one dir per target)
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── fuzz.yml # CI: smoke-fuzz · regression · proptest · perf
|
||||
│ ├── fuzz.yml # CI: smoke-fuzz · regression · proptest · perf (libFuzzer)
|
||||
│ ├── fuzz-afl.yml # CI: AFL++ lane
|
||||
│ ├── mutants.yml # CI: mutation testing (cargo-mutants)
|
||||
│ └── lint.yml # CI: fmt + clippy
|
||||
├── scripts/
|
||||
│ └── add_fuzz_target.py # Automates new-target scaffolding (called by just new-target)
|
||||
└── docs/
|
||||
└── fuzzing.md # Full developer guide
|
||||
├── fuzzing.md # Full developer guide
|
||||
└── mutants-not-fuzzable.md # Policy + mutant→test mapping
|
||||
```
|
||||
|
||||
The LEZ codebase is consumed as a **sibling directory** — clone
|
||||
`logos-execution-zone` next to this repository:
|
||||
`logos-execution-zone` next to this repository so the `../` path deps resolve:
|
||||
|
||||
```
|
||||
parent/
|
||||
@ -59,25 +59,18 @@ parent/
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
### 1. Prerequisites
|
||||
|
||||
```bash
|
||||
rustup install nightly
|
||||
rustup component add llvm-tools-preview --toolchain nightly
|
||||
cargo install cargo-fuzz
|
||||
# Optional but recommended:
|
||||
cargo install just
|
||||
cargo install just # optional but recommended
|
||||
```
|
||||
|
||||
> **Why nightly?** `cargo-fuzz` passes `-Zsanitizer=address` and
|
||||
> `-Zinstrument-coverage` (unstable flags) to `rustc`, and depends on the
|
||||
> `llvm-tools-preview` nightly component for coverage reporting. The
|
||||
> `rust-toolchain.toml` pins the whole repository to nightly so you never
|
||||
> need an explicit `+nightly` flag.
|
||||
|
||||
### Setup
|
||||
### 2. Setup
|
||||
|
||||
```bash
|
||||
# Clone both repositories side by side
|
||||
@ -86,7 +79,7 @@ git clone <LEZ_FUZZING_REPO_URL> lez-fuzzing
|
||||
cd lez-fuzzing
|
||||
```
|
||||
|
||||
### Run the fuzz targets
|
||||
### 3. Run the fuzz targets
|
||||
|
||||
```bash
|
||||
# All targets for 30 s each (RISC0_DEV_MODE=1 is set automatically)
|
||||
@ -105,35 +98,44 @@ just fuzz-regression
|
||||
just fuzz-props
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **ZK-proof cost:** `RISC0_DEV_MODE=1` is exported at the top of the
|
||||
> `Justfile` and must be set in every fuzz run to stub out ZK proof
|
||||
> generation. Without it each execution takes seconds instead of
|
||||
> microseconds.
|
||||
> generation. Without it, each execution takes **seconds** instead of
|
||||
> **microseconds**.
|
||||
|
||||
---
|
||||
|
||||
## Fuzz Targets
|
||||
## 🎯 Fuzz Targets
|
||||
|
||||
| Target | Protocol layer | Entry point |
|
||||
|--------|---------------|-------------|
|
||||
| `fuzz_transaction_decoding` | Borsh decoding of all tx/block types (`NSSATransaction`, `Block`, `HashableBlockData`) with roundtrip re-encoding | `fuzz/fuzz_targets/fuzz_transaction_decoding.rs` |
|
||||
| `fuzz_stateless_verification` | `transaction_stateless_check()` no-panic + idempotency | `fuzz/fuzz_targets/fuzz_stateless_verification.rs` |
|
||||
| `fuzz_state_transition` | `V03State` transition: StateIsolationOnFailure + BalanceConservation + ReplayRejection invariants across up to 8 txs with fuzz-driven state | `fuzz/fuzz_targets/fuzz_state_transition.rs` |
|
||||
| `fuzz_block_verification` | Block hash integrity: HashRoundTrip · HashPreimage completeness (block_id/prev_hash/timestamp) · TxOrderCommitment | `fuzz/fuzz_targets/fuzz_block_verification.rs` |
|
||||
| `fuzz_encoding_roundtrip` | Borsh encode→decode→encode round-trip identity + canonical encoding for `PublicTransaction` and `ProgramDeploymentTransaction` | `fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs` |
|
||||
| `fuzz_signature_verification` | Signature correctness (sign→verify), no-panic on random bytes, cross-key soundness | `fuzz/fuzz_targets/fuzz_signature_verification.rs` |
|
||||
| `fuzz_replay_prevention` | Transaction nonce replay rejection with fuzz-driven initial state | `fuzz/fuzz_targets/fuzz_replay_prevention.rs` |
|
||||
| `fuzz_state_diff_computation` | `ValidatedStateDiff` forward containment + reverse completeness (bidirectional isolation check) | `fuzz/fuzz_targets/fuzz_state_diff_computation.rs` |
|
||||
| `fuzz_validate_execute_consistency` | `validate_on_state` / `execute_check_on_state` agreement + diff accuracy + BalanceConservation | `fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs` |
|
||||
| `fuzz_state_serialization` | `V03State` Borsh decode no-panic + StateSerializationRoundtrip idempotency + NullifierDeduplication (`NullifierSet` hand-written impl) | `fuzz/fuzz_targets/fuzz_state_serialization.rs` |
|
||||
| `fuzz_witness_set_verification` | `WitnessSet::is_valid_for` no-panic + CorrectVerification (sign→verify) + MessageIsolation (witness set for msg A rejected on msg B) | `fuzz/fuzz_targets/fuzz_witness_set_verification.rs` |
|
||||
| `fuzz_program_deployment_lifecycle` | `V03State::transition_from_program_deployment_transaction` no-panic + BalanceIsolation (deployment must not move tokens) + StateIsolationOnFailure | `fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs` |
|
||||
| `fuzz_apply_state_diff_split_path` | SplitPathEquivalence: `validate_on_state + apply_state_diff` == `execute_check_on_state` for all known accounts (balance, nonce, data, program_owner); NonceIncrementCorrectness | `fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs` |
|
||||
| `fuzz_multi_block_state_sequence` | LongRangeBalanceConservation across up to 16 blocks + FailedTxNonceStability (nonce must not change on rejection) + PerBlockReplayRejection | `fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs` |
|
||||
| # | Target | Protocol layer |
|
||||
|---|--------|----------------|
|
||||
| 1 | `fuzz_transaction_decoding` | Borsh decoding of all tx/block types (`LeeTransaction`, `Block`, `HashableBlockData`) with roundtrip re-encoding |
|
||||
| 2 | `fuzz_stateless_verification` | `transaction_stateless_check()` no-panic + idempotency |
|
||||
| 3 | `fuzz_state_transition` | `V03State` transition: StateIsolationOnFailure + BalanceConservation + ReplayRejection invariants across up to 8 txs with fuzz-driven state |
|
||||
| 4 | `fuzz_block_verification` | Block hash integrity: HashRoundTrip · HashPreimage completeness (block_id/prev_hash/timestamp) · TxOrderCommitment |
|
||||
| 5 | `fuzz_encoding_roundtrip` | Borsh encode→decode→encode round-trip identity + canonical encoding for `PublicTransaction` and `ProgramDeploymentTransaction` |
|
||||
| 6 | `fuzz_signature_verification` | Signature correctness (sign→verify), no-panic on random bytes, cross-key soundness |
|
||||
| 7 | `fuzz_replay_prevention` | Transaction nonce replay rejection with fuzz-driven initial state |
|
||||
| 8 | `fuzz_state_diff_computation` | `ValidatedStateDiff` forward containment + reverse completeness (bidirectional isolation check) |
|
||||
| 9 | `fuzz_validate_execute_consistency` | `validate_on_state` / `execute_check_on_state` agreement + diff accuracy + BalanceConservation |
|
||||
| 10 | `fuzz_state_serialization` | `V03State` Borsh decode no-panic + StateSerializationRoundtrip idempotency + NullifierDeduplication (`NullifierSet` hand-written impl) |
|
||||
| 11 | `fuzz_witness_set_verification` | `WitnessSet::is_valid_for` no-panic + CorrectVerification (sign→verify) + MessageIsolation (witness set for msg A rejected on msg B) |
|
||||
| 12 | `fuzz_program_deployment_lifecycle` | `V03State::transition_from_program_deployment_transaction` no-panic + BalanceIsolation (deployment must not move tokens) + StateIsolationOnFailure |
|
||||
| 13 | `fuzz_apply_state_diff_split_path` | SplitPathEquivalence: `validate_on_state + apply_state_diff` == `execute_check_on_state` for all known accounts (balance, nonce, data, program_owner); NonceIncrementCorrectness |
|
||||
| 14 | `fuzz_multi_block_state_sequence` | LongRangeBalanceConservation across up to 16 blocks + FailedTxNonceStability (nonce must not change on rejection) + PerBlockReplayRejection |
|
||||
| 15 | `fuzz_sequencer_vs_replayer` | Differential: sequencer path (`validate_on_state` → `apply_state_diff`) vs replayer path (`execute_check_on_state`) — SequencerReplayerEquivalence + ReplayerAcceptsAllSequencerTxs + ClockConsistency |
|
||||
| 16 | `fuzz_merkle_tree` | Commitment Merkle tree via the commitment set: ProofSome · ProofValid (leaf + auth path recomputes the root) · NonMembershipNone · IndicesSequential |
|
||||
| 17 | `fuzz_transaction_properties` | Transaction property invariants: HashDeterministic/HashNonDefault, SignerIds derived from witness keys & non-empty, AffectedAccountsContainSigners, PublicDiffNonEmptyOnSuccess |
|
||||
| 18 | `fuzz_privacy_preserving_witness` | `privacy_preserving_transaction::WitnessSet`: CorrectVerification (witness for msg A passes `signatures_are_valid_for(A)`) + MessageIsolation + SignerIdsMatchWitnessKeys |
|
||||
| 19 | `fuzz_encoding_privacy_preserving` | Privacy-preserving encoding: MessageEncodingRoundtrip + TxEncodingDeterministic/NonEmpty |
|
||||
| 20 | `fuzz_nullifier_set_roundtrip` | `NullifierSet` Borsh serialisation: NullifierSetRoundtrip (decode→encode identity for the hand-written impl) |
|
||||
|
||||
Each target lives at `fuzz/fuzz_targets/<name>.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Corpus Management
|
||||
## 🧬 Corpus Management
|
||||
|
||||
```bash
|
||||
# Minimise all corpora (removes dominated inputs, keeps coverage-equivalent set)
|
||||
@ -145,73 +147,70 @@ just corpus-cmin-target fuzz_state_transition
|
||||
|
||||
---
|
||||
|
||||
## Crash / Failure Workflow
|
||||
## 💥 Crash / Failure Workflow
|
||||
|
||||
```bash
|
||||
# Minimise a crash artifact
|
||||
# 1. Minimise a crash artifact
|
||||
just fuzz-tmin fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
|
||||
|
||||
# Print the bytes as a Rust literal (for a regression #[test])
|
||||
# 2. Print the bytes as a Rust literal (for a regression #[test])
|
||||
cargo fuzz fmt fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
|
||||
|
||||
# Promote the minimised input to the corpus so CI catches regressions
|
||||
# 3. Promote the minimised input to the corpus so CI catches regressions
|
||||
cp fuzz/artifacts/fuzz_state_transition/crash-abc123-minimised \
|
||||
fuzz/corpus/fuzz_state_transition/regression_001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Target
|
||||
## ➕ Adding a New Target
|
||||
|
||||
```bash
|
||||
# Scaffold everything automatically (corpus dir, .rs file, Cargo.toml entry, CI matrix entry)
|
||||
just new-target my_feature # creates fuzz_my_feature
|
||||
```
|
||||
|
||||
`just new-target` calls [`scripts/add_fuzz_target.py`](scripts/add_fuzz_target.py) which
|
||||
`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) and inserts the target
|
||||
into every strategy matrix in [`.github/workflows/fuzz.yml`](.github/workflows/fuzz.yml).
|
||||
|
||||
---
|
||||
|
||||
## Housekeeping
|
||||
## 🧹 Housekeeping
|
||||
|
||||
```bash
|
||||
just clean # Remove Cargo build artefacts (target/ and fuzz/target/)
|
||||
just clean-artifacts # Remove fuzz/artifacts/ (crash/timeout inputs)
|
||||
just clean-coverage # Remove fuzz/coverage/ (LLVM coverage reports)
|
||||
just clean-all # All of the above
|
||||
```
|
||||
| Command | Removes |
|
||||
|---------|---------|
|
||||
| `just clean` | Cargo build artefacts (`target/` and `fuzz/target/`) |
|
||||
| `just clean-artifacts` | `fuzz/artifacts/` (crash/timeout inputs) |
|
||||
| `just clean-coverage` | `fuzz/coverage/` (LLVM coverage reports) |
|
||||
| `just clean-all` | All of the above |
|
||||
|
||||
---
|
||||
|
||||
## CI
|
||||
## ⚙️ CI
|
||||
|
||||
GitHub Actions runs four jobs on every push/PR and nightly:
|
||||
GitHub Actions runs these workflows on every push/PR and nightly:
|
||||
|
||||
| Job | What it does |
|
||||
|-----|-------------|
|
||||
| `smoke-fuzz` (matrix, 9 targets) | Builds + runs each target for 60 s |
|
||||
| `regression` (matrix, 9 targets) | Replays the saved corpus (`-runs=0`) |
|
||||
| `proptest` | `cargo test -p fuzz_props --release` |
|
||||
| `perf-baseline` (nightly only) | Measures exec/sec per target, uploads `perf_baseline.txt` |
|
||||
|
||||
> **Note:** The CI matrix currently lists the original 9 targets. The 5 new targets
|
||||
> (`fuzz_state_serialization`, `fuzz_witness_set_verification`,
|
||||
> `fuzz_program_deployment_lifecycle`, `fuzz_apply_state_diff_split_path`,
|
||||
> `fuzz_multi_block_state_sequence`) need to be added to `.github/workflows/fuzz.yml`
|
||||
> — see [`docs/fuzzing.md`](docs/fuzzing.md) for the manual fallback instructions.
|
||||
| Workflow | What it does |
|
||||
|----------|--------------|
|
||||
| `fuzz.yml` — `smoke-fuzz` (matrix) | Builds + runs each libFuzzer target for 60 s |
|
||||
| `fuzz.yml` — `regression` (matrix) | Replays the saved corpus (`-runs=0`) |
|
||||
| `fuzz.yml` — `proptest` | `cargo test -p fuzz_props --release` |
|
||||
| `fuzz.yml` — `perf-baseline` (nightly only) | Measures exec/sec per target, uploads `perf_baseline.txt` |
|
||||
| `fuzz-afl.yml` | AFL++ lane over the same targets/corpus |
|
||||
| `mutants.yml` | Mutation testing (`cargo-mutants`) |
|
||||
| `lint.yml` | Formatting + Clippy |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
## 📖 Documentation
|
||||
|
||||
Full developer guide — how to add new targets, interpret crashes, update
|
||||
the LEZ sibling clone, and tune performance — is in
|
||||
The full developer guide — how to add new targets, interpret crashes, update
|
||||
the LEZ sibling clone, and tune performance — lives in
|
||||
[`docs/fuzzing.md`](docs/fuzzing.md).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
## 📜 License
|
||||
|
||||
Licensed under the [MIT License](LICENSE-MIT).
|
||||
|
||||
BIN
corpus/libfuzz/fuzz_witness_set_verification/regression_0004
generated
Normal file
BIN
corpus/libfuzz/fuzz_witness_set_verification/regression_0004
generated
Normal file
Binary file not shown.
BIN
corpus/libfuzz/fuzz_witness_set_verification/regression_0005
generated
Normal file
BIN
corpus/libfuzz/fuzz_witness_set_verification/regression_0005
generated
Normal file
Binary file not shown.
BIN
corpus/libfuzz/fuzz_witness_set_verification/regression_0006
generated
Normal file
BIN
corpus/libfuzz/fuzz_witness_set_verification/regression_0006
generated
Normal file
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
# Alternative Approaches vs. Current Implementation
|
||||
|
||||
## What the Current Project Does
|
||||
## 🧩 What the Current Project Does
|
||||
|
||||
The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing system** built on **cargo-fuzz / libFuzzer**, operating as a standalone companion to the Logos Execution Zone (LEZ) codebase. Its key design pillars:
|
||||
|
||||
@ -11,13 +11,13 @@ The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing
|
||||
| Rich generators | [`fuzz_props::generators`](fuzz_props/src/generators.rs) adds `proptest` strategies for pathological sequences, phantom-account attacks, overflow amounts, replay sequences |
|
||||
| Protocol invariants | [`fuzz_props::invariants`](fuzz_props/src/invariants.rs) expresses zero-mutation-on-rejection and replay-rejection as reusable `ProtocolInvariant` objects |
|
||||
| ZK-awareness | `RISC0_DEV_MODE=1` stubs out `risc0-zkvm` proofs, enabling ~5 000–200 000 exec/sec depending on target |
|
||||
| 15 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential |
|
||||
| CI integration | GitHub Actions smoke, regression, and performance-baseline jobs run on every PR |
|
||||
| 20 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential, Merkle-tree invariants, transaction properties, privacy-preserving witness/encoding, and nullifier-set round-trips. Input-independent invariant checks (genesis contents, getters, system-account guard) are kept as **LEZ unit tests**, not targets — see [`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md) |
|
||||
| CI integration | GitHub Actions libFuzzer (`fuzz.yml`), AFL++ (`fuzz-afl.yml`), and mutation-testing (`mutants.yml`) workflows run on every PR / nightly |
|
||||
| Pre-seeded corpus | Hundreds of minimised seed files in [`fuzz/corpus/`](fuzz/corpus/) ensure regressions are caught instantly |
|
||||
|
||||
---
|
||||
|
||||
## Alternative Approaches
|
||||
## 🔬 Alternative Approaches
|
||||
|
||||
### 1. AFL++ (American Fuzzy Lop++)
|
||||
|
||||
@ -32,7 +32,7 @@ The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing
|
||||
| CI ergonomics | Requires AFL++ binary in CI image | `cargo install cargo-fuzz` only |
|
||||
| Rust integration | `cargo-afl` | `cargo-fuzz` |
|
||||
|
||||
**Decision-maker view**: AFL++ and libFuzzer find *different* bugs because they use different mutation heuristics. Running both on the same corpus is the industry-standard "belt and suspenders" approach. [`docs/fuzzing.md`](docs/fuzzing.md:355) already lists `just fuzz-afl` as planned future work. **Incremental cost is low** — the same [`fuzz_props`](fuzz_props/src/lib.rs) crate and seed corpus work unchanged.
|
||||
**Decision-maker view**: ✅ **Implemented.** AFL++ and libFuzzer find *different* bugs because they use different mutation heuristics, and running both on the same corpus is the industry-standard "belt and suspenders" approach. AFL++ is now a live lane: `just fuzz-afl` / `just fuzz-afl-parallel` and the `.github/workflows/fuzz-afl.yml` nightly job, sharing the same [`fuzz_props`](fuzz_props/src/lib.rs) crate and seed corpus at **zero migration cost**.
|
||||
|
||||
---
|
||||
|
||||
@ -111,28 +111,42 @@ 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) 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.
|
||||
**Decision-maker view**: ✅ **Implemented.** `cargo-mutants` runs in two modes —
|
||||
`just mutants-harness` (mutates `fuzz_props`, oracle = `cargo test`, auditing the
|
||||
invariant assertions themselves) and `just mutants-protocol` (mutates the LEZ
|
||||
`lee`/`common` crates, oracle = a fuzz-corpus replay), with a `mutants.yml` CI job.
|
||||
The two oracles correspond to a deliberate **Plane A / Plane B** split — see
|
||||
[`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md), which catalogues
|
||||
the mutants each plane is and isn't expected to catch and why. (For reference, the
|
||||
`fuzz_props` registry still implements [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs),
|
||||
[`BalanceConservation`](fuzz_props/src/invariants.rs), and
|
||||
[`FailedTxNonceStability`](fuzz_props/src/invariants.rs) in `assert_invariants()`,
|
||||
with `ReplayRejection` and `NonceIncrementCorrectness` enforced via standalone
|
||||
helpers outside the registry.) This is a **complementary quality gate**, not a
|
||||
fuzzing replacement.
|
||||
|
||||
---
|
||||
|
||||
## Summary Comparison Matrix
|
||||
## 📊 Summary Comparison Matrix
|
||||
|
||||
| Approach | Bug-finding depth | CI cost | Impl. cost | Complements current? | Recommended action |
|
||||
|---|---|---|---|---|---|
|
||||
| **Current (cargo-fuzz/libFuzzer)** | High | Medium | ✅ Done | — | Maintain & expand |
|
||||
| AFL++ | High (different bugs) | Medium | Low | ✅ Yes | Add `just fuzz-afl` (already planned) |
|
||||
| AFL++ | High (different bugs) | Medium | ✅ Done | ✅ Yes | ✅ Implemented (`just fuzz-afl`, `fuzz-afl.yml`) |
|
||||
| Honggfuzz | High on Linux | Medium | Medium | ✅ Yes | Add for Linux CI only |
|
||||
| proptest-only | Low–medium | Low | ✅ Done | Already present | Keep as unit-test layer |
|
||||
| Differential (sequencer/replayer) | Very high (new bug class) | Medium | ✅ Done | ✅ Yes | ✅ Implemented (`fuzz_sequencer_vs_replayer`) |
|
||||
| Formal verification | Exhaustive (selected invariants) | Very high | Very high | ✅ Yes | Long-term supplement |
|
||||
| Mutation testing (`cargo-mutants`) | Measures assertion quality | High | Low | ✅ Yes | Pre-audit quality gate |
|
||||
| Mutation testing (`cargo-mutants`) | Measures assertion quality | High | ✅ Done | ✅ Yes | ✅ Implemented (`just mutants-harness` / `mutants-protocol`) |
|
||||
|
||||
---
|
||||
|
||||
## Decision-maker Recommendations
|
||||
## 🧭 Decision-maker Recommendations
|
||||
|
||||
**Highest-ROI next steps, in priority order:**
|
||||
**Remaining higher-ROI next steps, in priority order:**
|
||||
|
||||
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.
|
||||
1. **Honggfuzz on Linux CI only** — hardware-counter coverage finds different paths;
|
||||
gated to Linux since Apple Silicon has no HW counters.
|
||||
|
||||
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.
|
||||
2. **Formal verification of core invariants** (balance conservation, replay
|
||||
prevention) — a long-term supplement, not a replacement.
|
||||
127
docs/fuzzing.md
127
docs/fuzzing.md
@ -1,4 +1,10 @@
|
||||
# Fuzzing Guide
|
||||
<div align="center">
|
||||
|
||||
# 🔬 Fuzzing Guide
|
||||
|
||||
**The full developer guide to running, extending, and triaging the LEZ fuzzing infrastructure.**
|
||||
|
||||
</div>
|
||||
|
||||
This document covers how to run fuzz targets, add new targets, minimise failures,
|
||||
and convert findings into regression tests.
|
||||
@ -9,7 +15,7 @@ directory that must be cloned separately).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## 🏗️ Architecture
|
||||
|
||||
The fuzz workspace (`fuzz/`) is a single Cargo workspace that covers **both** fuzzing
|
||||
engines via Cargo features. No separate Cargo manifest is needed.
|
||||
@ -38,7 +44,7 @@ The `cfg` attributes in the macro expansion resolve against the **calling crate'
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
## 🧰 Prerequisites
|
||||
|
||||
```bash
|
||||
# libFuzzer lane
|
||||
@ -61,7 +67,7 @@ cargo install cargo-afl
|
||||
|
||||
---
|
||||
|
||||
## Repository Setup
|
||||
## 📁 Repository Setup
|
||||
|
||||
`lez-fuzzing` is a **standalone repository** — it does **not** use git submodules.
|
||||
It expects the LEZ codebase to be cloned at `../logos-execution-zone` relative to itself.
|
||||
@ -79,7 +85,7 @@ git clone <LEZ_FUZZING_REPO_URL> lez-fuzzing
|
||||
|
||||
---
|
||||
|
||||
## How to Run
|
||||
## ▶️ How to Run
|
||||
|
||||
All fuzz targets must be run with `RISC0_DEV_MODE=1` to disable expensive ZK
|
||||
proof generation. The `just` recipes handle this automatically.
|
||||
@ -99,11 +105,11 @@ just fuzz-regression
|
||||
|
||||
---
|
||||
|
||||
## Available Fuzz Targets
|
||||
## 🎯 Available Fuzz Targets
|
||||
|
||||
| Target | What it fuzzes | Entry point |
|
||||
|--------|---------------|-------------|
|
||||
| `fuzz_transaction_decoding` | Borsh decoding of `NSSATransaction`, `Block`, and `HashableBlockData`; roundtrip re-encoding of successfully decoded transactions | `fuzz/fuzz_targets/fuzz_transaction_decoding.rs` |
|
||||
| `fuzz_transaction_decoding` | Borsh decoding of `LeeTransaction`, `Block`, and `HashableBlockData`; roundtrip re-encoding of successfully decoded transactions | `fuzz/fuzz_targets/fuzz_transaction_decoding.rs` |
|
||||
| `fuzz_stateless_verification` | `transaction_stateless_check()` no-panic on arbitrary bytes; idempotency — a transaction that passes the check must pass it again | `fuzz/fuzz_targets/fuzz_stateless_verification.rs` |
|
||||
| `fuzz_state_transition` | `execute_check_on_state()` across up to 8 transactions with fuzz-driven initial state and monotonically-advancing block context; asserts **StateIsolationOnFailure** (balances unchanged on rejection), **BalanceConservation** (total balance unchanged on success), and **ReplayRejection** (nonce consumed on first acceptance) | `fuzz/fuzz_targets/fuzz_state_transition.rs` |
|
||||
| `fuzz_block_verification` | Three block-hash invariants: **HashRoundTrip** (`HashableBlockData::from(Block)` is lossless), **HashPreimage** (block_id, prev_block_hash, timestamp each individually affect the hash), **TxOrderCommitment** (reversing the transaction list changes the hash) | `fuzz/fuzz_targets/fuzz_block_verification.rs` |
|
||||
@ -118,10 +124,15 @@ just fuzz-regression
|
||||
| `fuzz_apply_state_diff_split_path` | **SplitPathEquivalence**: for every known account, `validate_on_state` + `apply_state_diff` must produce exactly the same balance, nonce, data, and program_owner as `execute_check_on_state`; **NonceIncrementCorrectness**: nonce after the split path equals nonce after the direct path for all signer accounts (catches bugs in the two-step `apply_state_diff` nonce-increment logic) | `fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs` |
|
||||
| `fuzz_multi_block_state_sequence` | **LongRangeBalanceConservation**: total genesis-account balance identical before and after all N (≤ 16) blocks; **FailedTxNonceStability**: every genesis-account nonce unchanged after a rejected transaction; **PerBlockReplayRejection**: every transaction accepted in block B is rejected in block B+1 (cumulative nonce-interaction coverage) | `fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs` |
|
||||
| `fuzz_sequencer_vs_replayer` | **SequencerReplayerEquivalence**: for every known account (genesis ∪ diff-declared), the sequencer path (`validate_on_state` → `apply_state_diff`) and the replayer path (`execute_check_on_state`) must produce identical balance, nonce, data, and program_owner after applying a full block of up to 8 transactions plus the mandatory clock invocation; **ReplayerAcceptsAllSequencerTxs**: every transaction accepted by `validate_on_state` must also be accepted by `execute_check_on_state`; **ClockConsistency**: the mandatory clock invocation must succeed on both paths and leave both states identical | `fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs` |
|
||||
| `fuzz_merkle_tree` | Commitment Merkle tree via the commitment set: **ProofSome**, **ProofValid** (leaf + auth path recomputes the root), **NonMembershipNone**, **IndicesSequential** | `fuzz/fuzz_targets/fuzz_merkle_tree.rs` |
|
||||
| `fuzz_transaction_properties` | Transaction property invariants: **HashDeterministic** / **HashNonDefault**, **SignerIds** derived from witness keys & non-empty, **AffectedAccountsContainSigners**, **PublicDiffNonEmptyOnSuccess** | `fuzz/fuzz_targets/fuzz_transaction_properties.rs` |
|
||||
| `fuzz_privacy_preserving_witness` | `privacy_preserving_transaction::WitnessSet`: **CorrectVerification** (witness for message A passes `signatures_are_valid_for(A)`), **MessageIsolation**, **SignerIdsMatchWitnessKeys** | `fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs` |
|
||||
| `fuzz_encoding_privacy_preserving` | Privacy-preserving encoding: **MessageEncodingRoundtrip**, **TxEncodingDeterministic** / **NonEmpty** | `fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs` |
|
||||
| `fuzz_nullifier_set_roundtrip` | `NullifierSet` Borsh serialisation: **NullifierSetRoundtrip** (decode→encode identity for the hand-written impl) | `fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs` |
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Fuzz Target
|
||||
## ➕ How to Add a New Fuzz Target
|
||||
|
||||
### Step 1 — Scaffold with `just new-target`
|
||||
|
||||
@ -159,6 +170,7 @@ which:
|
||||
- Inserts the target name into every strategy matrix and the perf-baseline shell
|
||||
loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml).
|
||||
|
||||
> [!TIP]
|
||||
> **Manual fallback:** if you create a target without `just new-target`, add the
|
||||
> entry yourself:
|
||||
>
|
||||
@ -197,12 +209,13 @@ cd fuzz && cargo afl build \
|
||||
|
||||
---
|
||||
|
||||
## AFL++ Parallel Fuzzing Lane
|
||||
## 🔀 AFL++ Parallel Fuzzing Lane
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install AFL++ natively on your machine.
|
||||
|
||||
> [!NOTE]
|
||||
> **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.
|
||||
@ -222,6 +235,7 @@ cd ..
|
||||
cargo install cargo-afl
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **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`)
|
||||
@ -245,6 +259,7 @@ cargo install cargo-afl
|
||||
> each restart. The `just fuzz-afl` and `just fuzz-afl-parallel` recipes **do not**
|
||||
> call this automatically because it requires `sudo`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **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`
|
||||
@ -361,24 +376,24 @@ 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 |
|
||||
| `afl-smoke` | nightly + `workflow_dispatch` | all 20 targets, 60 s each |
|
||||
| `afl-coverage-aggregate` | nightly, `needs: afl-smoke` | all 20 targets merged into one 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/<target>/` and opens a corpus PR
|
||||
4. Uploads crashes/hangs as a workflow artifact
|
||||
The smoke job (one matrix leg per target, on `ubuntu-latest`):
|
||||
1. Builds AFL++ from source, then builds the target with `cargo afl build --no-default-features --features fuzzer-afl`
|
||||
2. Runs `afl-fuzz` for 60 s (`timeout 60`)
|
||||
3. Reports edge-bitmap coverage to the job step summary
|
||||
4. Uploads the queue/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`
|
||||
The coverage-aggregate job:
|
||||
1. Downloads every smoke leg's findings
|
||||
2. Rebuilds all 20 targets with `RUSTFLAGS="-C instrument-coverage"`
|
||||
3. Runs all checked-in corpus + AFL queue inputs through each binary
|
||||
4. Merges every `.profraw` → one `.profdata` → a single combined HTML report via `llvm-cov show`
|
||||
|
||||
---
|
||||
|
||||
## Updating the LEZ Dependency
|
||||
## 🔄 Updating the LEZ Dependency
|
||||
|
||||
`lez-fuzzing` reads LEZ source directly from `../logos-execution-zone`. To pick up LEZ
|
||||
changes, simply update that repo:
|
||||
@ -401,7 +416,7 @@ just update-lez
|
||||
|
||||
---
|
||||
|
||||
## Minimising & Reproducing Failures
|
||||
## 🐛 Minimising & Reproducing Failures
|
||||
|
||||
When `cargo fuzz` finds a crash it writes an artifact to
|
||||
`fuzz/artifacts/fuzz_<target>/crash-<hash>`.
|
||||
@ -440,7 +455,7 @@ Open a PR. The `regression` CI job will permanently block re-introduction of thi
|
||||
|
||||
---
|
||||
|
||||
## Coverage Reports
|
||||
## 📊 Coverage Reports
|
||||
|
||||
### Step 1 — libFuzzer coverage (via `cargo fuzz coverage`)
|
||||
|
||||
@ -476,7 +491,7 @@ automates steps 2–5 and uploads the report as a workflow artifact.
|
||||
|
||||
---
|
||||
|
||||
## Invariant Framework
|
||||
## 🛡️ Invariant Framework
|
||||
|
||||
Shared invariants live in `fuzz_props/src/invariants.rs`. There are two layers:
|
||||
|
||||
@ -538,7 +553,7 @@ To add a new invariant:
|
||||
|
||||
---
|
||||
|
||||
## Input Generators
|
||||
## 🎲 Input Generators
|
||||
|
||||
The `fuzz_props` crate provides two layers of input generation:
|
||||
|
||||
@ -558,19 +573,19 @@ fuzz target parameters for zero-boilerplate structured fuzzing.
|
||||
| `ArbWitnessSet` | `WitnessSet` (0–3 `(Signature, PublicKey)` pairs; mixes valid and invalid) |
|
||||
| `ArbPublicTransaction` | `PublicTransaction` (composed from `ArbPubTxMessage` + `ArbWitnessSet`) |
|
||||
| `ArbProgramDeploymentTransaction` | `ProgramDeploymentTransaction` (arbitrary bytecode) |
|
||||
| `ArbHashableBlockData` | `HashableBlockData` (0–7 `ArbNSSATransaction` entries, random header fields) |
|
||||
| `ArbNSSATransaction` | `NSSATransaction` (`Public` or `ProgramDeployment` variant; `PrivacyPreserving` excluded) |
|
||||
| `ArbHashableBlockData` | `HashableBlockData` (0–7 `ArbLeeTransaction` entries, random header fields) |
|
||||
| `ArbLeeTransaction` | `LeeTransaction` (`Public` or `ProgramDeployment` variant; `PrivacyPreserving` excluded) |
|
||||
|
||||
### `fuzz_props::generators` (libFuzzer helpers + proptest strategies)
|
||||
|
||||
| Generator | Covers |
|
||||
|-----------|--------|
|
||||
| `arbitrary_fuzz_state()` | 1–8 fuzz-driven accounts with arbitrary IDs, balances, and private keys; used by `fuzz_state_transition`, `fuzz_replay_prevention`, `fuzz_validate_execute_consistency`, `fuzz_state_diff_computation` |
|
||||
| `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_fuzz_native_transfer()` | Correctly-signed native-transfer `LeeTransaction` referencing accounts from an `arbitrary_fuzz_state()` result; gives the fuzzer a path to successful state transitions |
|
||||
| `arbitrary_transaction()` | Structured `LeeTransaction` (`Public` or `ProgramDeployment`) from unstructured bytes via `ArbLeeTransaction` |
|
||||
| `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) |
|
||||
| `signer_account_ids()` | Extracts `AccountId`s of all signers from an `LeeTransaction`'s witness set; used to derive signer IDs before `apply_state_diff` consumes the diff |
|
||||
| `arb_native_transfer_tx()` | Valid native-transfer `LeeTransaction` 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) |
|
||||
| `arb_invalid_account_state_tx()` | Phantom accounts + overflow amounts — expected to be rejected (IS-3) |
|
||||
@ -579,7 +594,7 @@ fuzz target parameters for zero-boilerplate structured fuzzing.
|
||||
|
||||
---
|
||||
|
||||
## Performance Baseline
|
||||
## ⚡ Performance Baseline
|
||||
|
||||
Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`:
|
||||
|
||||
@ -600,7 +615,13 @@ Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`:
|
||||
| `fuzz_apply_state_diff_split_path` | ~5 000 exec/sec *(estimate)* |
|
||||
| `fuzz_multi_block_state_sequence` | ~1 000 exec/sec *(estimate)* |
|
||||
| `fuzz_sequencer_vs_replayer` | ~2 000 exec/sec *(estimate)* |
|
||||
| `fuzz_merkle_tree` | ~20 000 exec/sec *(estimate)* |
|
||||
| `fuzz_transaction_properties` | ~15 000 exec/sec *(estimate)* |
|
||||
| `fuzz_privacy_preserving_witness` | ~15 000 exec/sec *(estimate)* |
|
||||
| `fuzz_encoding_privacy_preserving` | ~50 000 exec/sec *(estimate)* |
|
||||
| `fuzz_nullifier_set_roundtrip` | ~100 000 exec/sec *(estimate)* |
|
||||
|
||||
> [!NOTE]
|
||||
> Throughput figures for the five new targets are rough estimates; run `just perf-baseline`
|
||||
> locally or check the `perf-baseline` CI artifact for up-to-date measurements.
|
||||
|
||||
@ -617,7 +638,7 @@ just fuzz-afl-parallel fuzz_state_transition $(nproc) 3600
|
||||
|
||||
---
|
||||
|
||||
## ZK-Proof Cost Warning
|
||||
## ⚠️ ZK-Proof Cost Warning
|
||||
|
||||
`PrivacyPreservingTransaction` uses `risc0-zkvm` (seconds per proof).
|
||||
All fuzz targets **must** set `RISC0_DEV_MODE=1` in the environment and the `just`
|
||||
@ -632,12 +653,44 @@ flag stubs out ZK proof generation and replaces it with a fast mock implementati
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations & Future Work
|
||||
## 🧬 Mutation testing — the two planes
|
||||
|
||||
Mutation testing here runs in two distinct planes, answering two different questions:
|
||||
|
||||
- **Plane A — "does a test catch this mutant?"** Run with a standard `cargo test`
|
||||
oracle against the `lee` crate's own unit tests.
|
||||
- **Plane B — "does the committed fuzz corpus catch this mutant?"** Run with
|
||||
`just mutants-protocol`, which swaps `cargo test` for a fuzz-corpus replay
|
||||
(`cargo fuzz run … -runs=0`) as the oracle.
|
||||
|
||||
A mutant surviving Plane B is **not automatically a corpus gap to fill.** Some
|
||||
mutations are only reachable by a fully-valid executing transaction or by a
|
||||
deliberately-misbehaving program — neither of which a fuzzer can synthesise from
|
||||
random bytes, and both of which are better pinned by deterministic unit tests in
|
||||
the `lee` crate. Encoding such scenarios as input-independent fuzz targets only
|
||||
duplicates those tests and slows every corpus replay.
|
||||
|
||||
The mutants that are **expected** to survive Plane B (and where each is actually
|
||||
covered) are catalogued in [`mutants-not-fuzzable.md`](mutants-not-fuzzable.md).
|
||||
Reconcile new `mutants-protocol` runs against that list: only a surviving mutant
|
||||
**not** on it warrants a new corpus input.
|
||||
|
||||
**No input-independent targets.** A fuzz target whose closure ignores its input
|
||||
(`|_data|`) is a deterministic unit test, not a fuzzer — it belongs in the LEZ
|
||||
crate that owns the code. Three such targets once existed
|
||||
(`fuzz_common_invariants`, `fuzz_genesis_invariants`,
|
||||
`fuzz_system_account_protection`); their invariants were ported to LEZ unit tests
|
||||
and the targets removed. The mutant→test mapping is recorded under "Group 2" in
|
||||
[`mutants-not-fuzzable.md`](mutants-not-fuzzable.md). When adding a target, drive it
|
||||
from `data`; if a check doesn't depend on the input, write it as a unit test in
|
||||
`logos-execution-zone` instead.
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Known Limitations & Future Work
|
||||
|
||||
| Item | Notes |
|
||||
|------|-------|
|
||||
| `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 |
|
||||
| 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 |
|
||||
|
||||
233
docs/mutants-not-fuzzable.md
Normal file
233
docs/mutants-not-fuzzable.md
Normal file
@ -0,0 +1,233 @@
|
||||
# Mutants Not Coverable by Fuzzing
|
||||
|
||||
This document catalogues the source mutations (from `just mutants-protocol`, the
|
||||
"Plane B" corpus-replay mutation run over the `lee` / `common` crates) that the
|
||||
**fuzzing corpus is not the right tool to catch**, together with where each one is
|
||||
actually covered.
|
||||
|
||||
It exists to keep a clean separation between two questions that the tooling can
|
||||
otherwise blur together:
|
||||
|
||||
- **"Does a test catch this mutant?"** — answered by the `lee` crate's own unit
|
||||
tests via `cargo test` (call this **Plane A**).
|
||||
- **"Does the committed fuzz corpus catch this mutant?"** — answered by
|
||||
`just mutants-protocol`, which replaces `cargo test` with a fuzz-corpus replay
|
||||
(`cargo fuzz run … -runs=0`) as the oracle (call this **Plane B**).
|
||||
|
||||
The mutants listed here are **expected Plane-B misses**. A future
|
||||
`mutants-protocol` run that reports them as surviving is *not* a regression — it
|
||||
is the documented, intended state.
|
||||
|
||||
This file is the complete registry, in **two groups**:
|
||||
|
||||
1. **Structurally unreachable by fuzzing** (Group 1) — mutants behind code that a
|
||||
fuzzer cannot reach from raw bytes (they need a valid executing transaction or a
|
||||
deliberately-misbehaving program). These were always unit-test territory.
|
||||
2. **Migrated input-independent targets** (Group 2) — mutants that *were* caught by
|
||||
input-independent fuzz targets (`fuzz_common_invariants`,
|
||||
`fuzz_genesis_invariants`, `fuzz_system_account_protection`). Because an
|
||||
input-independent target is a unit test in disguise, those targets were removed
|
||||
and their invariants ported to LEZ unit tests; the mutants therefore now survive
|
||||
Plane B by design.
|
||||
|
||||
Reconcile new `mutants-protocol` runs against this registry; only a surviving
|
||||
mutant on **neither** list warrants a new corpus input.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Why fuzzing is the wrong tool for these
|
||||
|
||||
Fuzzing earns its keep by exploring a large, *unknown* input space to find inputs
|
||||
a human wouldn't think of — malformed transactions, adversarial byte sequences,
|
||||
surprising state-transition orderings. The corpus-replay oracle then re-runs those
|
||||
discovered inputs cheaply as a regression net.
|
||||
|
||||
The mutations below live behind code that is only reachable by a **specific,
|
||||
valid, semantically rich object** that random bytes essentially never synthesise:
|
||||
|
||||
1. **A fully-valid, executing transaction.** Reaching the post-execution
|
||||
validation logic (authorization checks, claim checks, cycle limit) requires a
|
||||
transaction whose signature matches its signer, whose nonce matches the
|
||||
on-chain nonce, and whose program is deployed. A fuzzer mutating raw bytes
|
||||
almost always breaks one of these and is rejected at the stateless/nonce gate
|
||||
*before* any program runs — so the code never executes. Constructing such a
|
||||
transaction is a deterministic "this exact scenario must hold" property, which
|
||||
is the domain of **unit tests**, not input exploration.
|
||||
|
||||
2. **A deliberately-misbehaving program.** Some validator checks only fire when a
|
||||
program returns malformed output (claims an account it shouldn't, mutates a
|
||||
default account without claiming it, etc.). The only such programs are the
|
||||
test fixtures behind `V03State::with_test_programs()` (`program_owner_changer`,
|
||||
`extra_output_program`, …). They are **never deployed** in genesis or
|
||||
production, so they are unreachable through the public transaction API that the
|
||||
fuzzer drives — by construction, no fuzz input can exercise them.
|
||||
|
||||
In both cases the behaviour is pinned by deterministic unit tests in the `lee` /
|
||||
`common` crates. Encoding such scenarios as **input-independent** fuzz targets
|
||||
(targets that ignore their input and run a fixed battery) is an anti-pattern — it
|
||||
duplicates the unit-test role, adds heavyweight zkVM work to every corpus replay,
|
||||
and risks silent corpus rot, all to satisfy a metric (Plane B) better served by
|
||||
documenting the boundary. `lez-fuzzing` therefore keeps **no** input-independent
|
||||
targets: the public/privacy execution targets (which duplicated existing `lee`
|
||||
tests) and the three genesis/common/system targets (whose invariants were ported
|
||||
to new unit tests — see the companion doc) were all removed.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Catalogue (Group 1 — structurally unreachable by fuzzing)
|
||||
|
||||
The nine mutations reported as MISSED by the `mutants-protocol` run for which
|
||||
fuzzing is structurally the wrong tool, with their true coverage. Verified by
|
||||
applying each mutation to the `logos-execution-zone` working tree and running the
|
||||
cited tests (`RISC0_DEV_MODE=1 cargo test -p lee --lib`). (Group 2 — the migrated
|
||||
input-independent-target mutants — is summarised further down.)
|
||||
|
||||
| # | Location | Mutation | Category | Covered by |
|
||||
|---|----------|----------|----------|------------|
|
||||
| 1 | `lee/state_machine/src/program.rs:21:51` | `*` → `/` (cycle limit `32`) | Valid-tx unit test | transfer-execution tests |
|
||||
| 2 | `lee/state_machine/src/program.rs:21:51` | `*` → `+` (cycle limit `33 792`) | Valid-tx unit test | transfer-execution tests |
|
||||
| 3 | `lee/state_machine/src/program.rs:21:58` | `*` → `/` (cycle limit `32 768`) | Valid-tx unit test | transfer-execution tests |
|
||||
| 4 | `lee/state_machine/src/program.rs:21:58` | `*` → `+` (cycle limit `1 048 608`) | **Near-equivalent — genuine gap** | nothing (see below) |
|
||||
| 5 | `lee/state_machine/src/validated_state_diff.rs:155:21` | `\|\|` → `&&` | Valid-tx unit test | transfer-execution tests |
|
||||
| 6 | `lee/state_machine/src/validated_state_diff.rs:311:34` | `!=` → `==` | Misbehaving-program unit test | `public_changer_claimer_*` |
|
||||
| 7 | `lee/state_machine/src/validated_state_diff.rs:314:20` | `==` → `!=` | Misbehaving-program unit test | `public_changer_claimer_*` + validity-window tests |
|
||||
| 8 | `lee/state_machine/src/privacy_preserving_transaction/circuit.rs:88:32` | `>=` → `<` | Valid-PP-tx unit test | PP transition tests |
|
||||
| 9 | `lee/state_machine/src/state.rs:335:16` | delete `!` | Valid-PP-tx unit test | PP transition tests |
|
||||
|
||||
### Category A — Covered by `lee` unit tests, requires a valid *executing* transaction (1–3, 5, 8, 9)
|
||||
|
||||
These fire only after a fully-valid transaction reaches real program execution.
|
||||
A fuzzer's random bytes are rejected at the nonce/signature gate first, so the
|
||||
corpus never reaches them; the `lee` crate pins each with a deterministic test.
|
||||
|
||||
- **1–3 (public cycle limit, the catchable variants).**
|
||||
`MAX_NUM_CYCLES_PUBLIC_EXECUTION = 1024 * 1024 * 32` (= 33 554 432). A real
|
||||
`authenticated_transfer` execution consumes **between 33 792 and 1 048 608**
|
||||
RISC-V cycles, so any mutation lowering the limit below that range aborts
|
||||
execution with *"Session limit exceeded"*.
|
||||
Covered by `state::tests::transition_from_authenticated_transfer_program_invocation_*`
|
||||
(and the ~66 other public-execution tests that run a transfer). Verified: limit
|
||||
`33 792` → 66 tests fail.
|
||||
|
||||
- **5 (`||` → `&&` in `is_authorized`,
|
||||
`validated_state_diff.rs:155`).** With `&&`, the transaction signer is no longer
|
||||
treated as authorized, so a valid transfer fails with
|
||||
`InvalidAccountAuthorization`. Covered by the same transfer-execution tests.
|
||||
Verified: 3 of 7 `transition_from*` tests fail.
|
||||
|
||||
- **8 (`>=` → `<` in `execute_and_prove`,
|
||||
`circuit.rs:88`).** With `<`, the chained-call guard fires on the first
|
||||
iteration (`0 < MAX`) and proving aborts immediately with
|
||||
`MaxChainedCallsDepthExceeded`. Covered by
|
||||
`state::tests::transition_from_privacy_preserving_transaction_{shielded,private,deshielded}`.
|
||||
Verified: 3 PP tests fail.
|
||||
|
||||
- **9 (delete `!` in `check_nullifiers_are_valid`,
|
||||
`state.rs:335`).** Removing the `!` inverts the digest check so a *recognised*
|
||||
commitment-set digest is rejected, breaking every valid privacy-preserving
|
||||
transfer that spends a private input. Covered by the same PP transition tests.
|
||||
Verified: 3 PP tests fail.
|
||||
|
||||
### Category B — Covered by `lee` unit tests, requires a *misbehaving* program (6, 7)
|
||||
|
||||
These guard against a program returning malformed output (modifying or claiming a
|
||||
default account incorrectly). Only the test-only fixtures behind
|
||||
`V03State::with_test_programs()` misbehave this way; they are never deployed, so no
|
||||
fuzz input can reach this code. The `lee` crate exercises them directly.
|
||||
|
||||
- **6 (`!=` → `==`, `validated_state_diff.rs:311`)** — the
|
||||
"only inspect uninitialised accounts" filter. Verified: 1 test fails under the
|
||||
full `lee` suite.
|
||||
- **7 (`==` → `!=`, `validated_state_diff.rs:314`)** — the
|
||||
"skip unmodified accounts" guard. Verified: 16 tests fail, including
|
||||
`state::tests::public_changer_claimer_data_change_no_claim_fails` and
|
||||
`public_changer_claimer_no_data_change_no_claim_succeeds`.
|
||||
|
||||
> [!NOTE]
|
||||
> an earlier analysis guessed 6 and 7 were *equivalent mutants*. They are
|
||||
> not — they are caught by Plane A, just not reachable by Plane B. They appear
|
||||
> "equivalent" only if you restrict yourself to the deployed `authenticated_transfer`
|
||||
> program, which is exactly the restriction fuzzing operates under.
|
||||
|
||||
### Category C — The single genuine gap: near-equivalent weak mutant (4)
|
||||
|
||||
- **4 (`*` → `+` at `program.rs:21:58`, cycle limit `1 048 608`).**
|
||||
Catching this would require a *single* public program execution that consumes
|
||||
**more than 1 048 608 RISC-V cycles**. The `authenticated_transfer` instruction
|
||||
uses fewer than that (it is caught only by limits ≤ 33 792 — see category A), and
|
||||
no deployed program's single instruction reaches ~1M cycles. The difference
|
||||
between the mutated limit (1.05M) and the real limit (33.5M) is therefore
|
||||
**unobservable for any realistic workload**, making this a practically
|
||||
equivalent / weak mutant. Verified: survives the full `lee` suite (211/211 pass).
|
||||
|
||||
It is not worth chasing in either plane. If a future deployed program legitimately
|
||||
performs a >1M-cycle public execution, a normal execution test for that program
|
||||
would catch this mutation incidentally.
|
||||
|
||||
---
|
||||
|
||||
## 🔁 Group 2 — migrated input-independent targets
|
||||
|
||||
These mutants used to be caught by Plane B via input-independent fuzz targets.
|
||||
Those targets were removed and their invariants ported to LEZ unit tests, so the
|
||||
mutants now survive Plane B by design. They are **not** structurally unreachable
|
||||
like Group 1 — a fuzzer could "catch" them, but only by running a fixed scenario
|
||||
that ignores its input, which is a unit test, not fuzzing.
|
||||
|
||||
Each port below was verified to kill its mutant (apply the mutation → run the named
|
||||
test → observe a failure). Where a mutant had **no** prior unit-test coverage, the
|
||||
port *added* coverage rather than merely relocating it; those are marked **(new)**.
|
||||
|
||||
**From `fuzz_common_invariants`:**
|
||||
|
||||
| Mutant | New unit test |
|
||||
|---|---|
|
||||
| `HashType::as_ref` → `Vec::leak(Vec::new())` / `vec![0]` / `vec![1]` | `common::tests::as_ref_returns_exact_inner_bytes` (`common/src/lib.rs`) **(new)** |
|
||||
| `BasicAuth` `FromStr` delete `!` in `.filter(\|p\| !p.is_empty())` | `common::config::tests::parse_empty_password_is_none` (+ `parse_preserves_non_empty_password`) **(new)** |
|
||||
| `Program::elf` → empty / `vec![0]` / `vec![1]` | `program::tests::elf_returns_the_program_bytecode_constant` (was already caught incidentally) |
|
||||
| `Proof::into_inner` / `from_inner` → `vec![]` / `vec![0]` / `vec![1]` | `…::circuit::tests::proof_inner_roundtrip` **(new)** |
|
||||
| `Message::into_bytecode` → `vec![]` / `vec![0]` / `vec![1]` | `program_deployment_transaction::message::tests::bytecode_roundtrip` **(new)** |
|
||||
|
||||
**From `fuzz_genesis_invariants`** (all in `lee/state_machine/src/state.rs`):
|
||||
|
||||
| Mutant | New unit test |
|
||||
|---|---|
|
||||
| `system_faucet_account` → `Default` / delete `balance` / delete `program_owner` | `state::tests::genesis_system_accounts_have_expected_contents` **(new)** |
|
||||
| `system_bridge_account` → `Default` / delete `program_owner` | `genesis_system_accounts_have_expected_contents` **(new)** |
|
||||
| `commitment_set_digest` → `Default` | `state::tests::genesis_commitment_set_digest_differs_from_empty_state` **(new)** |
|
||||
| `add_pinata_token_program` delete `program_owner` / `data` | `state::tests::add_pinata_token_program_sets_non_default_owner_and_data` **(new)** |
|
||||
| `system_faucet_account_id` / `system_bridge_account_id` → `Default` | `genesis_system_accounts_have_expected_contents` + `system_account_ids_are_distinct_and_non_default` (was already caught) |
|
||||
|
||||
**From `fuzz_system_account_protection`:**
|
||||
|
||||
| Mutant | New unit test |
|
||||
|---|---|
|
||||
| `validate_doesnt_modify_account` `!=` → `==` (`common/src/transaction.rs`) | `common::transaction::tests::validate_on_state_rejects_modifying_a_system_account` **(new)** |
|
||||
| `public_diff` → `HashMap::new()` (`lee/.../validated_state_diff.rs`) | `validated_state_diff::tests::public_diff_reflects_a_successful_transfer` (+ the `validate_on_state_rejects…` test) **(new)** |
|
||||
| `system_*_account_id` non-default / distinct | `common::transaction::tests::system_account_ids_are_distinct_and_non_default` (was already caught) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Re-verifying
|
||||
|
||||
From `logos-execution-zone/` with the fuzzing repo checked out as a sibling:
|
||||
|
||||
```bash
|
||||
export RISC0_DEV_MODE=1
|
||||
|
||||
# Pick a mutation from a table above, apply it to the cited line, then run the
|
||||
# owning crate's tests (Plane A). A real failure ⇒ unit tests cover it.
|
||||
cargo test -p lee --lib # lee-owned mutants
|
||||
cargo test -p common # common-owned mutants (Group 2)
|
||||
git checkout -- <mutated-file> # always revert
|
||||
```
|
||||
|
||||
A mutation that makes `cargo test` fail is covered by Plane A and belongs in this
|
||||
registry; a mutation that the corpus replay (`just mutants-protocol`) catches
|
||||
belongs in the corpus instead. Across both groups, mutation #4 (the near-equivalent
|
||||
cycle-limit weak mutant) is the only one caught by **neither** plane.
|
||||
|
||||
> [!TIP]
|
||||
> when reverting, prefer reverse-editing only the mutated line rather than
|
||||
> `git checkout -- <file>` if you have uncommitted unit tests in the same file —
|
||||
> a whole-file checkout would discard them too.
|
||||
840
fuzz/Cargo.lock
generated
840
fuzz/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -44,11 +44,11 @@ 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" }
|
||||
nssa_core = { path = "../../logos-execution-zone/nssa/core" }
|
||||
common = { path = "../../logos-execution-zone/common" }
|
||||
nssa = { path = "../../logos-execution-zone/lee/state_machine", package = "lee" }
|
||||
nssa_core = { path = "../../logos-execution-zone/lee/state_machine/core", package = "lee_core" }
|
||||
common = { path = "../../logos-execution-zone/lez/common" }
|
||||
fuzz_props = { path = "../fuzz_props" }
|
||||
testnet_initial_state = { path = "../../logos-execution-zone/testnet_initial_state" }
|
||||
testnet_initial_state = { path = "../../logos-execution-zone/lez/testnet_initial_state" }
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
@ -119,3 +119,33 @@ name = "fuzz_sequencer_vs_replayer"
|
||||
path = "fuzz_targets/fuzz_sequencer_vs_replayer.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_merkle_tree"
|
||||
path = "fuzz_targets/fuzz_merkle_tree.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_transaction_properties"
|
||||
path = "fuzz_targets/fuzz_transaction_properties.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_privacy_preserving_witness"
|
||||
path = "fuzz_targets/fuzz_privacy_preserving_witness.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_encoding_privacy_preserving"
|
||||
path = "fuzz_targets/fuzz_encoding_privacy_preserving.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_nullifier_set_roundtrip"
|
||||
path = "fuzz_targets/fuzz_nullifier_set_roundtrip.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use fuzz_props::arbitrary_types::ArbNSSATransaction;
|
||||
use fuzz_props::arbitrary_types::ArbLeeTransaction;
|
||||
use fuzz_props::generators::{arbitrary_fuzz_state, signer_account_ids};
|
||||
use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness};
|
||||
use nssa::V03State;
|
||||
@ -52,7 +52,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
.collect();
|
||||
|
||||
// Generate and stateless-check a transaction.
|
||||
let tx_raw = match ArbNSSATransaction::arbitrary(&mut u) {
|
||||
let tx_raw = match ArbLeeTransaction::arbitrary(&mut u) {
|
||||
Ok(w) => w.0,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
@ -33,10 +33,9 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
let base = wrap.0;
|
||||
|
||||
let signing_key = PrivateKey::try_new(DUMMY_KEY_BYTES).expect("constant key is valid");
|
||||
let bedrock = [0u8; 32];
|
||||
|
||||
// Compute the canonical hash for the base input.
|
||||
let block = base.clone().into_pending_block(&signing_key, bedrock);
|
||||
let block = base.clone().into_pending_block(&signing_key);
|
||||
let hash_base = block.header.hash;
|
||||
|
||||
// ── INVARIANT 1: HashableBlockData::from(Block) is lossless ──────────────────
|
||||
@ -51,7 +50,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
{
|
||||
let roundtrip_hashable = HashableBlockData::from(block);
|
||||
let hash_roundtrip = roundtrip_hashable
|
||||
.into_pending_block(&signing_key, bedrock)
|
||||
.into_pending_block(&signing_key)
|
||||
.header
|
||||
.hash;
|
||||
assert_eq!(
|
||||
@ -67,7 +66,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
{
|
||||
let mut m = base.clone();
|
||||
m.block_id = m.block_id.wrapping_add(1);
|
||||
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
|
||||
let hash_m = m.into_pending_block(&signing_key).header.hash;
|
||||
assert_ne!(
|
||||
hash_base,
|
||||
hash_m,
|
||||
@ -81,7 +80,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
{
|
||||
let mut m = base.clone();
|
||||
m.prev_block_hash.0[0] ^= 0xFF;
|
||||
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
|
||||
let hash_m = m.into_pending_block(&signing_key).header.hash;
|
||||
assert_ne!(
|
||||
hash_base,
|
||||
hash_m,
|
||||
@ -95,7 +94,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
{
|
||||
let mut m = base.clone();
|
||||
m.timestamp = m.timestamp.wrapping_add(1);
|
||||
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
|
||||
let hash_m = m.into_pending_block(&signing_key).header.hash;
|
||||
assert_ne!(
|
||||
hash_base,
|
||||
hash_m,
|
||||
@ -121,7 +120,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
if first != last {
|
||||
let mut reordered = base.clone();
|
||||
reordered.transactions.reverse();
|
||||
let hash_reordered = reordered.into_pending_block(&signing_key, bedrock).header.hash;
|
||||
let hash_reordered = reordered.into_pending_block(&signing_key).header.hash;
|
||||
assert_ne!(
|
||||
hash_base,
|
||||
hash_reordered,
|
||||
|
||||
197
fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs
Normal file
197
fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs
Normal file
@ -0,0 +1,197 @@
|
||||
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
|
||||
//! Fuzz target: privacy-preserving encoding invariants.
|
||||
//!
|
||||
//! Tests that `to_bytes` / `from_bytes` round-trips work correctly for the
|
||||
//! privacy-preserving `Message` type, and that `try_from_circuit_output`
|
||||
//! validates ciphertext-to-key length matching.
|
||||
//!
|
||||
//! `PrivacyPreservingTransaction` is also tested for serialisation stability
|
||||
//! (non-empty, deterministic bytes) without requiring a real ZK receipt.
|
||||
|
||||
use nssa::{
|
||||
AccountId, PrivateKey, PublicKey,
|
||||
PrivacyPreservingTransaction,
|
||||
privacy_preserving_transaction::{
|
||||
Message as PPMessage,
|
||||
WitnessSet as PPWitnessSet,
|
||||
circuit::Proof,
|
||||
},
|
||||
};
|
||||
use nssa_core::{
|
||||
PrivacyPreservingCircuitOutput,
|
||||
account::Nonce,
|
||||
program::{BlockValidityWindow, TimestampValidityWindow},
|
||||
};
|
||||
|
||||
/// Build a minimal `Message` with no private state.
|
||||
fn minimal_message() -> PPMessage {
|
||||
let addr = AccountId::from(
|
||||
&PublicKey::new_from_private_key(
|
||||
&PrivateKey::try_new([1_u8; 32]).expect("known-good"),
|
||||
),
|
||||
);
|
||||
PPMessage {
|
||||
public_account_ids: vec![addr],
|
||||
nonces: vec![Nonce::from(0_u128)],
|
||||
public_post_states: vec![],
|
||||
encrypted_private_post_states: vec![],
|
||||
new_commitments: vec![],
|
||||
new_nullifiers: vec![],
|
||||
block_validity_window: BlockValidityWindow::new_unbounded(),
|
||||
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
// ── INVARIANT [MessageEncodingRoundtrip] ──────────────────────────────────
|
||||
// `Message::to_bytes()` followed by `Message::from_bytes()` must reproduce
|
||||
// the original message. Catches mutations that return `vec![]`, `vec![0]`,
|
||||
// or `vec![1]` — these break round-trip identity.
|
||||
{
|
||||
let msg = minimal_message();
|
||||
let encoded = msg.to_bytes();
|
||||
|
||||
// Non-empty: catches `→ vec![]`
|
||||
assert!(
|
||||
!encoded.is_empty(),
|
||||
"INVARIANT VIOLATION [MessageEncodingRoundtrip]: \
|
||||
Message::to_bytes must not return an empty vec",
|
||||
);
|
||||
|
||||
let decoded = PPMessage::from_bytes(&encoded)
|
||||
.expect("INVARIANT VIOLATION [MessageEncodingRoundtrip]: \
|
||||
from_bytes(to_bytes(msg)) must succeed");
|
||||
|
||||
let re_encoded = decoded.to_bytes();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
re_encoded,
|
||||
"INVARIANT VIOLATION [MessageEncodingRoundtrip]: \
|
||||
encode(decode(encode(msg))) != encode(msg)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── INVARIANT [TxEncodingNonEmpty] / [TxEncodingDeterministic] ────────────
|
||||
// `PrivacyPreservingTransaction::to_bytes()` must return a non-empty byte
|
||||
// slice and be deterministic. Catches mutations that return `vec![]` etc.
|
||||
{
|
||||
let key = PrivateKey::try_new([1_u8; 32]).expect("known-good");
|
||||
let msg = minimal_message();
|
||||
let proof = Proof::from_inner(vec![0xDE_u8, 0xAD, 0xBE, 0xEF]);
|
||||
let ws = PPWitnessSet::for_message(&msg, proof, &[&key]);
|
||||
let tx = PrivacyPreservingTransaction::new(msg, ws);
|
||||
|
||||
let bytes1 = tx.to_bytes();
|
||||
assert!(
|
||||
!bytes1.is_empty(),
|
||||
"INVARIANT VIOLATION [TxEncodingNonEmpty]: \
|
||||
PrivacyPreservingTransaction::to_bytes must not be empty",
|
||||
);
|
||||
|
||||
let bytes2 = tx.to_bytes();
|
||||
assert_eq!(
|
||||
bytes1,
|
||||
bytes2,
|
||||
"INVARIANT VIOLATION [TxEncodingDeterministic]: \
|
||||
to_bytes must be deterministic — called twice, got different results",
|
||||
);
|
||||
|
||||
// Verify round-trip for the full transaction:
|
||||
let decoded = PrivacyPreservingTransaction::from_bytes(&bytes1)
|
||||
.expect("INVARIANT VIOLATION: round-trip decode must succeed");
|
||||
assert_eq!(
|
||||
bytes1,
|
||||
decoded.to_bytes(),
|
||||
"INVARIANT VIOLATION [TxEncodingDeterministic]: \
|
||||
encode(decode(encode(tx))) != encode(tx)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── INVARIANT [CircuitOutputAccepted] ─────────────────────────────────────
|
||||
// `try_from_circuit_output` must succeed for a well-formed (empty) circuit
|
||||
// output, mapping the output fields onto the resulting `Message`.
|
||||
{
|
||||
let empty_output = PrivacyPreservingCircuitOutput {
|
||||
public_pre_states: vec![],
|
||||
public_post_states: vec![],
|
||||
new_commitments: vec![],
|
||||
new_nullifiers: vec![],
|
||||
encrypted_private_post_states: vec![],
|
||||
block_validity_window: BlockValidityWindow::new_unbounded(),
|
||||
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
|
||||
};
|
||||
|
||||
let result = PPMessage::try_from_circuit_output(
|
||||
vec![], // public_account_ids
|
||||
vec![], // nonces
|
||||
empty_output,
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"INVARIANT VIOLATION [CircuitOutputAccepted]: \
|
||||
try_from_circuit_output must accept a well-formed empty output, \
|
||||
got: {:?}",
|
||||
result.err(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Raw fuzz decode tests ─────────────────────────────────────────────────
|
||||
// Fuzz the Message decoder for no-panic and canonical round-trip.
|
||||
{
|
||||
// No-panic on arbitrary bytes:
|
||||
let _ = PPMessage::from_bytes(data);
|
||||
|
||||
// Canonical round-trip: if fuzz bytes decode, re-encoding must reproduce them.
|
||||
if let Ok(msg) = PPMessage::from_bytes(data) {
|
||||
let re_encoded = msg.to_bytes();
|
||||
assert_eq!(
|
||||
data,
|
||||
re_encoded.as_slice(),
|
||||
"INVARIANT VIOLATION: PP Message decoded from raw bytes but \
|
||||
re-encoding differs (non-canonical encoding accepted)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Varied-size message round-trips ──────────────────────────────────────
|
||||
// Verify round-trip for several multi-account messages.
|
||||
for n_accounts in [0, 1, 2, 3] {
|
||||
let mut account_ids = Vec::new();
|
||||
let mut nonces = Vec::new();
|
||||
for i in 0..n_accounts {
|
||||
let key_bytes = [i + 1_u8; 32];
|
||||
if let Ok(key) = PrivateKey::try_new(key_bytes) {
|
||||
let pk = PublicKey::new_from_private_key(&key);
|
||||
account_ids.push(AccountId::from(&pk));
|
||||
nonces.push(Nonce::from(i as u128));
|
||||
}
|
||||
}
|
||||
|
||||
let msg = PPMessage {
|
||||
public_account_ids: account_ids,
|
||||
nonces,
|
||||
public_post_states: vec![],
|
||||
encrypted_private_post_states: vec![],
|
||||
new_commitments: vec![],
|
||||
new_nullifiers: vec![],
|
||||
block_validity_window: BlockValidityWindow::new_unbounded(),
|
||||
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
|
||||
};
|
||||
|
||||
let encoded = msg.to_bytes();
|
||||
assert!(
|
||||
!encoded.is_empty(),
|
||||
"INVARIANT VIOLATION [MessageEncodingRoundtrip]: \
|
||||
Message::to_bytes must not be empty for a {n_accounts}-account message",
|
||||
);
|
||||
|
||||
let decoded = PPMessage::from_bytes(&encoded)
|
||||
.expect("round-trip must succeed for well-formed message");
|
||||
assert_eq!(
|
||||
encoded,
|
||||
decoded.to_bytes(),
|
||||
"INVARIANT VIOLATION [MessageEncodingRoundtrip]: \
|
||||
round-trip failed for {n_accounts}-account message",
|
||||
);
|
||||
}
|
||||
});
|
||||
136
fuzz/fuzz_targets/fuzz_merkle_tree.rs
Normal file
136
fuzz/fuzz_targets/fuzz_merkle_tree.rs
Normal file
@ -0,0 +1,136 @@
|
||||
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
|
||||
//! Fuzz target: Merkle-tree structural invariants, exercised through the
|
||||
//! **public** commitment-set API (no `pub mod merkle_tree` patch required).
|
||||
//!
|
||||
//! The commitment set in `V03State` is a thin wrapper around the internal
|
||||
//! `MerkleTree`:
|
||||
//!
|
||||
//! ```text
|
||||
//! V03State::commitment_set_digest() → MerkleTree::root() (→ root_index)
|
||||
//! V03State::get_proof_for_commitment(c) → (index, MerkleTree::get_authentication_path_for(index))
|
||||
//! CommitmentSet::extend(commitments) → MerkleTree::insert(value) per commitment
|
||||
//! compute_digest_for_path(c, proof) → canonical leaf→root recomputation
|
||||
//! ```
|
||||
//!
|
||||
//! Inserting commitments via `V03State::new_with_genesis_accounts` therefore
|
||||
//! drives `insert`, `root`/`root_index`, `get_authentication_path_for`, `depth`,
|
||||
//! `get_node`/`set_node`, and — once the count exceeds the genesis capacity (32)
|
||||
//! — `reallocate_to_double_capacity` and `prev_power_of_two`.
|
||||
//!
|
||||
//! Because the genesis commitment set has a fixed capacity of 32, a *small*
|
||||
//! number of commitments exercises the partial-fill regime (`depth <
|
||||
//! capacity_depth`, i.e. `root_index`'s else-branch), while a *large* number
|
||||
//! (> 31) forces one or more reallocations. A single target therefore covers
|
||||
//! both regimes — the committed corpus carries a small partial-fill seed
|
||||
//! (`seed_partial6`) and a large reallocation seed (`seed_realloc40`).
|
||||
//!
|
||||
//! # Input format
|
||||
//!
|
||||
//! Each 32-byte chunk of the fuzz input is reinterpreted as an `AccountId`, from
|
||||
//! which a distinct `Commitment` is derived (`Commitment::new`). Duplicate
|
||||
//! chunks are dropped so every inserted commitment is unique and lands at a
|
||||
//! distinct, sequential tree index. The number of distinct chunks selects the
|
||||
//! fill regime (partial-fill vs. reallocation).
|
||||
//!
|
||||
//! # Invariants
|
||||
//!
|
||||
//! 1. **ProofSome** — every inserted commitment has a membership proof.
|
||||
//! 2. **ProofValid** — `compute_digest_for_path(commitment, proof)` reproduces
|
||||
//! `commitment_set_digest()` for every inserted commitment. This is the core
|
||||
//! check: it independently recomputes the root from the leaf + authentication
|
||||
//! path and compares against the tree's reported root, catching arithmetic
|
||||
//! bugs in `root_index`, `insert`, and the path-walk.
|
||||
//! 3. **IndicesSequential** — the genesis dummy commitment occupies index 0, so
|
||||
//! `N` distinct user commitments must occupy exactly indices `1..=N`. Catches
|
||||
//! `insert -> 0` / `insert -> 1` return-value mutations.
|
||||
//! 4. **NonMembershipNone** — a commitment that was never inserted has no proof.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nssa::V03State;
|
||||
use nssa_core::{
|
||||
Commitment, Nullifier,
|
||||
account::{Account, AccountId},
|
||||
compute_digest_for_path,
|
||||
};
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
// Reinterpret each 32-byte chunk as an AccountId; derive one commitment each.
|
||||
// Dedup chunks so commitments are distinct and indices are clean.
|
||||
let mut seen: HashSet<[u8; 32]> = HashSet::new();
|
||||
let mut pairs: Vec<(Commitment, Nullifier)> = Vec::new();
|
||||
for chunk in data.chunks_exact(32) {
|
||||
let bytes: [u8; 32] = chunk.try_into().expect("chunks_exact(32) yields [u8;32]");
|
||||
if !seen.insert(bytes) {
|
||||
continue; // skip duplicate account ids
|
||||
}
|
||||
let commitment = Commitment::new(&AccountId::new(bytes), &Account::default());
|
||||
// A distinct nullifier per pair (content is irrelevant to the merkle tree).
|
||||
let nullifier = Nullifier::from_byte_array(bytes);
|
||||
pairs.push((commitment, nullifier));
|
||||
}
|
||||
|
||||
if pairs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the commitments so we can query their proofs after the state moves `pairs`.
|
||||
let commitments: Vec<Commitment> = pairs.iter().map(|(c, _)| c.clone()).collect();
|
||||
|
||||
// Genesis inserts DUMMY_COMMITMENT at index 0, then our commitments at 1..=N.
|
||||
let state = V03State::new_with_genesis_accounts(&[], pairs, 0);
|
||||
let digest = state.commitment_set_digest();
|
||||
|
||||
let mut indices: Vec<usize> = Vec::with_capacity(commitments.len());
|
||||
for commitment in &commitments {
|
||||
// ── INVARIANT [ProofSome] ─────────────────────────────────────────────
|
||||
let proof = state.get_proof_for_commitment(commitment).expect(
|
||||
"INVARIANT VIOLATION [ProofSome]: \
|
||||
get_proof_for_commitment returned None for an inserted commitment",
|
||||
);
|
||||
|
||||
// ── INVARIANT [ProofValid] ────────────────────────────────────────────
|
||||
// Recompute the root from the leaf + authentication path and compare to
|
||||
// the tree's reported digest. A bug in root_index / insert / the path
|
||||
// walk makes these disagree.
|
||||
assert_eq!(
|
||||
compute_digest_for_path(commitment, &proof),
|
||||
digest,
|
||||
"INVARIANT VIOLATION [ProofValid]: \
|
||||
membership proof for a commitment at index {} does not recompute to \
|
||||
commitment_set_digest()",
|
||||
proof.0,
|
||||
);
|
||||
|
||||
indices.push(proof.0);
|
||||
}
|
||||
|
||||
// ── INVARIANT [IndicesSequential] ─────────────────────────────────────────
|
||||
// The dummy commitment holds index 0; our N distinct commitments must hold
|
||||
// exactly indices 1..=N.
|
||||
indices.sort_unstable();
|
||||
for (k, &idx) in indices.iter().enumerate() {
|
||||
assert_eq!(
|
||||
idx,
|
||||
k + 1,
|
||||
"INVARIANT VIOLATION [IndicesSequential]: \
|
||||
inserted commitments must occupy sequential indices 1..=N (dummy at 0); \
|
||||
got index {idx} at sorted position {k}",
|
||||
);
|
||||
}
|
||||
|
||||
// ── INVARIANT [NonMembershipNone] ─────────────────────────────────────────
|
||||
// A commitment derived from an account id that was NOT inserted must have no
|
||||
// proof. Use an all-0xFF sentinel id and only assert when it is genuinely
|
||||
// absent from the inserted set.
|
||||
let sentinel_bytes = [0xFF_u8; 32];
|
||||
if !seen.contains(&sentinel_bytes) {
|
||||
let absent =
|
||||
Commitment::new(&AccountId::new(sentinel_bytes), &Account::default());
|
||||
assert!(
|
||||
state.get_proof_for_commitment(&absent).is_none(),
|
||||
"INVARIANT VIOLATION [NonMembershipNone]: \
|
||||
get_proof_for_commitment returned Some for a commitment never inserted",
|
||||
);
|
||||
}
|
||||
});
|
||||
75
fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs
Normal file
75
fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs
Normal file
@ -0,0 +1,75 @@
|
||||
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
|
||||
//! Fuzz target: `NullifierSet` Borsh serialisation.
|
||||
//!
|
||||
//! The `NullifierSet` has a hand-written `BorshDeserialize` (in
|
||||
//! `lee/state_machine/src/state.rs`) that rejects duplicate nullifiers via
|
||||
//! `if !set.insert(n)`. This target verifies that:
|
||||
//!
|
||||
//! 1. States containing distinct nullifiers survive a Borsh round-trip. The
|
||||
//! `delete-!` mutation at `state.rs:104` flips the dedup check so that
|
||||
//! `deserialize_reader` errors on the *first* (non-duplicate) element; a state
|
||||
//! with two distinct nullifiers then fails to deserialise, tripping Part 1.
|
||||
//! 2. Feeding arbitrary fuzz bytes to the `V03State` deserialiser never panics.
|
||||
//!
|
||||
//! # Corpus note
|
||||
//!
|
||||
//! A single `\x00` seed is sufficient — Part 1 uses fixed inputs and catches the
|
||||
//! `delete-!` mutation without fuzz-driven state.
|
||||
|
||||
use nssa::{Account, AccountId, V03State, system_faucet_account_id};
|
||||
use nssa_core::{Commitment, Nullifier};
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
// ── Part 1: State with nullifiers — Borsh round-trip ─────────────────────
|
||||
// Create a V03State that contains committed nullifiers via the
|
||||
// `initial_private_accounts` constructor argument.
|
||||
//
|
||||
// With state.rs:105 mutation (delete `!`):
|
||||
// - `BorshDeserialize for NullifierSet` returns `Err` on the FIRST element
|
||||
// - `borsh::from_slice::<V03State>(&bytes)` returns Err
|
||||
// - The assert_eq below fires → mutation CAUGHT
|
||||
{
|
||||
// Two deterministic nullifier values (use from_byte_array):
|
||||
let null1 = Nullifier::from_byte_array([0xAA_u8; 32]);
|
||||
let null2 = Nullifier::from_byte_array([0xBB_u8; 32]);
|
||||
// Commitment::new takes (&AccountId, &Account):
|
||||
let comm1 = Commitment::new(&AccountId::new([0x11_u8; 32]), &Account::default());
|
||||
let comm2 = Commitment::new(&AccountId::new([0x22_u8; 32]), &Account::default());
|
||||
|
||||
// Build a state that holds two nullifiers in its private state.
|
||||
let state = V03State::new_with_genesis_accounts(
|
||||
&[(system_faucet_account_id(), 0)],
|
||||
vec![(comm1, null1), (comm2, null2)],
|
||||
0,
|
||||
);
|
||||
|
||||
// Serialise the state:
|
||||
let bytes = borsh::to_vec(&state)
|
||||
.expect("BorshSerialize for V03State must not fail");
|
||||
assert!(!bytes.is_empty());
|
||||
|
||||
// Deserialise: with the mutation, this returns Err for any state with
|
||||
// nullifiers, triggering the assertion below.
|
||||
let state2 = borsh::from_slice::<V03State>(&bytes)
|
||||
.expect("INVARIANT VIOLATION [NullifierSetRoundtrip]: \
|
||||
borsh::from_slice of a state with nullifiers must succeed \
|
||||
(mutation delete-! in NullifierSet::deserialize_reader detected)");
|
||||
|
||||
// Re-encode and verify idempotence:
|
||||
let bytes2 = borsh::to_vec(&state2)
|
||||
.expect("second BorshSerialize must not fail");
|
||||
assert_eq!(
|
||||
bytes,
|
||||
bytes2,
|
||||
"INVARIANT VIOLATION [NullifierSetRoundtrip]: \
|
||||
encode(decode(encode(state))) != encode(state) — \
|
||||
NullifierSet round-trip is not idempotent",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Part 2: Fuzz-driven raw bytes ─────────────────────────────────────────
|
||||
// Feed raw fuzz bytes through V03State deserialiser — no panic allowed.
|
||||
{
|
||||
let _ = borsh::from_slice::<V03State>(data); // NoPanic: Ok or Err, no panic
|
||||
}
|
||||
});
|
||||
236
fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs
Normal file
236
fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs
Normal file
@ -0,0 +1,236 @@
|
||||
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
|
||||
//! Fuzz target: `privacy_preserving_transaction::WitnessSet` invariants.
|
||||
//!
|
||||
//! Mirrors `fuzz_witness_set_verification` but for the privacy-preserving
|
||||
//! witness set, which additionally holds a ZK `Proof` alongside the ECDSA
|
||||
//! signatures.
|
||||
//!
|
||||
//! # Invariants
|
||||
//!
|
||||
//! 1. **CorrectVerification** — a `WitnessSet` built for message A via
|
||||
//! `WitnessSet::for_message` must pass `signatures_are_valid_for(A)`.
|
||||
//!
|
||||
//! 2. **MessageIsolation** — the same `WitnessSet` must NOT pass
|
||||
//! `signatures_are_valid_for(B)` when B borsh-encodes differently from A.
|
||||
//!
|
||||
//! 3. **SignaturesAndPublicKeysNonEmpty** — after `for_message` with N keys,
|
||||
//! `signatures_and_public_keys()` must return N entries.
|
||||
//!
|
||||
//! 4. **SignerIdsMatchWitnessKeys** — `PrivacyPreservingTransaction::signer_account_ids`
|
||||
//! must equal `AccountId::from(pk)` for every key in the witness set.
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use fuzz_props::arbitrary_types::ArbPrivateKey;
|
||||
use nssa::{
|
||||
AccountId, PrivateKey, PublicKey,
|
||||
privacy_preserving_transaction::{
|
||||
Message as PPMessage,
|
||||
WitnessSet as PPWitnessSet,
|
||||
circuit::Proof,
|
||||
},
|
||||
PrivacyPreservingTransaction,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::Nonce,
|
||||
program::{BlockValidityWindow, TimestampValidityWindow},
|
||||
};
|
||||
|
||||
/// Build a minimal `Message` for testing — no commitments, no nullifiers,
|
||||
/// no encrypted states. Sufficient to test signature binding.
|
||||
fn minimal_message(account_ids: Vec<AccountId>, nonces: Vec<Nonce>) -> PPMessage {
|
||||
PPMessage {
|
||||
public_account_ids: account_ids,
|
||||
nonces,
|
||||
public_post_states: vec![],
|
||||
encrypted_private_post_states: vec![],
|
||||
new_commitments: vec![],
|
||||
new_nullifiers: vec![],
|
||||
block_validity_window: BlockValidityWindow::new_unbounded(),
|
||||
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a minimal (fake) `Proof` — bytes don't form a real ZK receipt but
|
||||
/// are valid for struct construction and serialisation.
|
||||
fn fake_proof() -> Proof {
|
||||
Proof::from_inner(vec![0xAB_u8; 32])
|
||||
}
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
let mut u = Unstructured::new(data);
|
||||
|
||||
// ── Fixed-key deterministic part ──────────────────────────────────────────
|
||||
// Always runs regardless of input length, ensuring the mutation is caught
|
||||
// even on an empty corpus.
|
||||
{
|
||||
let key1 = PrivateKey::try_new([1_u8; 32]).expect("known-good key");
|
||||
let key2 = PrivateKey::try_new([2_u8; 32]).expect("known-good key");
|
||||
let pub1 = PublicKey::new_from_private_key(&key1);
|
||||
let pub2 = PublicKey::new_from_private_key(&key2);
|
||||
let addr1 = AccountId::from(&pub1);
|
||||
let addr2 = AccountId::from(&pub2);
|
||||
|
||||
let msg = minimal_message(
|
||||
vec![addr1, addr2],
|
||||
vec![Nonce::from(0_u128), Nonce::from(1_u128)],
|
||||
);
|
||||
|
||||
let ws = PPWitnessSet::for_message(&msg, fake_proof(), &[&key1, &key2]);
|
||||
|
||||
// ── INVARIANT [SignaturesAndPublicKeysNonEmpty] ───────────────────────
|
||||
assert_eq!(
|
||||
ws.signatures_and_public_keys().len(),
|
||||
2,
|
||||
"INVARIANT VIOLATION [SignaturesAndPublicKeysNonEmpty]: \
|
||||
signatures_and_public_keys must return 2 entries for a 2-key witness set",
|
||||
);
|
||||
|
||||
// ── INVARIANT [CorrectVerification] ───────────────────────────────────
|
||||
assert!(
|
||||
ws.signatures_are_valid_for(&msg),
|
||||
"INVARIANT VIOLATION [CorrectVerification]: \
|
||||
WitnessSet::for_message produced a witness set that fails \
|
||||
signatures_are_valid_for on the same message",
|
||||
);
|
||||
|
||||
// ── INVARIANT [SignerIdsMatchWitnessKeys] ─────────────────────────────
|
||||
// signer_account_ids is pub(crate); derive from signatures_and_public_keys instead.
|
||||
let signers_from_ws: Vec<AccountId> = ws
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| AccountId::from(pk))
|
||||
.collect();
|
||||
assert_eq!(signers_from_ws.len(), 2);
|
||||
assert!(signers_from_ws.contains(&addr1));
|
||||
assert!(signers_from_ws.contains(&addr2));
|
||||
|
||||
// ── INVARIANT [SignerOnlyAccountInAffected] ───────────────────────────
|
||||
// `PrivacyPreservingTransaction::affected_public_account_ids` unions
|
||||
// `signer_account_ids()` with `message.public_account_ids`. To catch the
|
||||
// `signer_account_ids → vec![]` mutation, build a message whose
|
||||
// public_account_ids does NOT contain the signer, so the signer can only
|
||||
// reach `affected` via `signer_account_ids()`.
|
||||
let isolated_msg = minimal_message(
|
||||
vec![AccountId::new([0xB1_u8; 32]), AccountId::new([0xB2_u8; 32])],
|
||||
vec![Nonce::from(0_u128), Nonce::from(1_u128)],
|
||||
);
|
||||
// Sign with key1 — addr1 is (with overwhelming probability) not one of the
|
||||
// 0xB1/0xB2 placeholder accounts.
|
||||
if addr1 != AccountId::new([0xB1_u8; 32]) && addr1 != AccountId::new([0xB2_u8; 32]) {
|
||||
let isolated_ws = PPWitnessSet::for_message(&isolated_msg, fake_proof(), &[&key1]);
|
||||
let isolated_tx =
|
||||
PrivacyPreservingTransaction::new(isolated_msg, isolated_ws);
|
||||
let affected = isolated_tx.affected_public_account_ids();
|
||||
assert!(
|
||||
affected.contains(&addr1),
|
||||
"INVARIANT VIOLATION [SignerOnlyAccountInAffected]: \
|
||||
PP affected_public_account_ids must include the signer {:?} even when it \
|
||||
is absent from message.public_account_ids — signer_account_ids() must not \
|
||||
return an empty vec",
|
||||
addr1,
|
||||
);
|
||||
}
|
||||
|
||||
// ── INVARIANT [MessageIsolation] ──────────────────────────────────────
|
||||
// Build a different message (different nonces) — the witness set for msg
|
||||
// must NOT validate against msg_b.
|
||||
let msg_b = minimal_message(
|
||||
vec![addr1, addr2],
|
||||
vec![Nonce::from(999_u128), Nonce::from(1000_u128)],
|
||||
);
|
||||
let bytes_a = borsh::to_vec(&msg);
|
||||
let bytes_b = borsh::to_vec(&msg_b);
|
||||
if let (Ok(a), Ok(b)) = (bytes_a, bytes_b) {
|
||||
if a != b {
|
||||
assert!(
|
||||
!ws.signatures_are_valid_for(&msg_b),
|
||||
"INVARIANT VIOLATION [MessageIsolation]: \
|
||||
PP WitnessSet for msg accepted for a different msg_b — \
|
||||
possible signature-binding bypass",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Single-key variant:
|
||||
let ws_single = PPWitnessSet::for_message(&msg, fake_proof(), &[&key1]);
|
||||
assert_eq!(ws_single.signatures_and_public_keys().len(), 1);
|
||||
|
||||
let tx_single = PrivacyPreservingTransaction::new(msg.clone(), ws_single);
|
||||
// Use affected_public_account_ids (which calls signer_account_ids internally):
|
||||
let single_affected = tx_single.affected_public_account_ids();
|
||||
assert!(
|
||||
single_affected.contains(&addr1),
|
||||
"INVARIANT VIOLATION [SignerIdsMatchWitnessKeys]: 1-key tx must include addr1",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fuzz-driven part ──────────────────────────────────────────────────────
|
||||
// Generate 0–3 random private keys, build a WitnessSet, verify correct validation.
|
||||
{
|
||||
let n_keys = (u8::arbitrary(&mut u).unwrap_or(0) % 4) as usize;
|
||||
let mut keys = Vec::with_capacity(n_keys);
|
||||
let mut addrs = Vec::with_capacity(n_keys);
|
||||
let mut nonces = Vec::with_capacity(n_keys);
|
||||
|
||||
for i in 0..n_keys {
|
||||
match ArbPrivateKey::arbitrary(&mut u) {
|
||||
Ok(k) => {
|
||||
let pk = PublicKey::new_from_private_key(&k.0);
|
||||
addrs.push(AccountId::from(&pk));
|
||||
nonces.push(Nonce::from(i as u128));
|
||||
keys.push(k.0);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let msg = minimal_message(addrs.clone(), nonces);
|
||||
let key_refs: Vec<&PrivateKey> = keys.iter().collect();
|
||||
let ws = PPWitnessSet::for_message(&msg, fake_proof(), &key_refs);
|
||||
|
||||
// INVARIANT [SignaturesAndPublicKeysNonEmpty]
|
||||
assert_eq!(
|
||||
ws.signatures_and_public_keys().len(),
|
||||
keys.len(),
|
||||
"INVARIANT VIOLATION [SignaturesAndPublicKeysNonEmpty]: \
|
||||
signatures_and_public_keys count must match number of keys",
|
||||
);
|
||||
|
||||
// INVARIANT [CorrectVerification]
|
||||
assert!(
|
||||
ws.signatures_are_valid_for(&msg),
|
||||
"INVARIANT VIOLATION [CorrectVerification]: \
|
||||
PP WitnessSet::for_message produced witnesses that fail validation",
|
||||
);
|
||||
|
||||
// INVARIANT [SignerIdsMatchWitnessKeys]
|
||||
// signer_account_ids is pub(crate); verify via affected_public_account_ids
|
||||
// (which internally calls signer_account_ids) and via signatures_and_public_keys.
|
||||
let tx = PrivacyPreservingTransaction::new(msg, ws.clone());
|
||||
let signer_ids_from_ws: Vec<AccountId> = ws
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| AccountId::from(pk))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
signer_ids_from_ws.len(),
|
||||
addrs.len(),
|
||||
"INVARIANT VIOLATION [SignerIdsMatchWitnessKeys]: \
|
||||
witness set key count must match number of keys provided",
|
||||
);
|
||||
// affected_public_account_ids includes signer IDs:
|
||||
let affected2 = tx.affected_public_account_ids();
|
||||
for addr in &addrs {
|
||||
assert!(
|
||||
affected2.contains(addr),
|
||||
"INVARIANT VIOLATION [SignerIdsMatchWitnessKeys]: \
|
||||
affected_public_account_ids must contain {:?}",
|
||||
addr,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -38,7 +38,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use common::transaction::{NSSATransaction, clock_invocation};
|
||||
use common::transaction::{LeeTransaction, clock_invocation};
|
||||
use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction};
|
||||
use nssa::V03State;
|
||||
|
||||
@ -81,7 +81,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
|
||||
// Accepted transaction list — populated here, consumed by the replayer phase
|
||||
// so that both pipelines process exactly the same set of transactions.
|
||||
let mut accepted_txs: Vec<NSSATransaction> = Vec::new();
|
||||
let mut accepted_txs: Vec<LeeTransaction> = Vec::new();
|
||||
|
||||
let n_txs: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 8;
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
//! specific account shapes such as zero balance or `u128::MAX` — are reachable.
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use common::transaction::NSSATransaction;
|
||||
use common::transaction::LeeTransaction;
|
||||
use fuzz_props::arbitrary_types::ArbPublicTransaction;
|
||||
use fuzz_props::generators::arbitrary_fuzz_state;
|
||||
use nssa::{V03State, ValidatedStateDiff};
|
||||
@ -47,7 +47,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
// Collect the set of accounts the transaction declares it will touch.
|
||||
// `affected_public_account_ids()` returns owned data so `pub_tx` remains
|
||||
// available for both `from_public_transaction` (borrow) and the later move
|
||||
// into `NSSATransaction::Public`.
|
||||
// into `LeeTransaction::Public`.
|
||||
let affected = pub_tx.affected_public_account_ids();
|
||||
|
||||
match ValidatedStateDiff::from_public_transaction(&pub_tx, &state, 1, 0) {
|
||||
@ -77,7 +77,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
// we do not panic on a structurally malformed transaction.
|
||||
let mut exec_state = state.clone();
|
||||
// `pub_tx` is moved here; it is no longer borrowed after this point.
|
||||
let tx_for_exec = NSSATransaction::Public(pub_tx);
|
||||
let tx_for_exec = LeeTransaction::Public(pub_tx);
|
||||
if let Ok(checked_tx) = tx_for_exec.transaction_stateless_check() {
|
||||
if checked_tx.execute_check_on_state(&mut exec_state, 1, 0).is_ok() {
|
||||
for acc_id in &affected {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
|
||||
|
||||
use arbitrary::Unstructured;
|
||||
use common::transaction::NSSATransaction;
|
||||
use common::transaction::LeeTransaction;
|
||||
use fuzz_props::generators::arbitrary_transaction;
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
@ -22,7 +22,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
}
|
||||
|
||||
// Path B: raw decode first, then check — must never panic
|
||||
if let Ok(tx) = borsh::from_slice::<NSSATransaction>(data) {
|
||||
if let Ok(tx) = borsh::from_slice::<LeeTransaction>(data) {
|
||||
let _ = tx.transaction_stateless_check();
|
||||
}
|
||||
});
|
||||
|
||||
@ -2,19 +2,19 @@
|
||||
|
||||
use common::{
|
||||
block::{Block, HashableBlockData},
|
||||
transaction::NSSATransaction,
|
||||
transaction::LeeTransaction,
|
||||
};
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
// Attempt 1: decode as NSSATransaction and verify roundtrip
|
||||
if let Ok(tx) = borsh::from_slice::<NSSATransaction>(data) {
|
||||
// Attempt 1: decode as LeeTransaction and verify roundtrip
|
||||
if let Ok(tx) = borsh::from_slice::<LeeTransaction>(data) {
|
||||
let re_encoded = borsh::to_vec(&tx).expect("re-encode of valid tx must succeed");
|
||||
let tx2 = borsh::from_slice::<NSSATransaction>(&re_encoded)
|
||||
let tx2 = borsh::from_slice::<LeeTransaction>(&re_encoded)
|
||||
.expect("second decode of re-encoded tx must succeed");
|
||||
assert_eq!(
|
||||
re_encoded,
|
||||
borsh::to_vec(&tx2).unwrap(),
|
||||
"NSSATransaction roundtrip encoding divergence"
|
||||
"LeeTransaction roundtrip encoding divergence"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
271
fuzz/fuzz_targets/fuzz_transaction_properties.rs
Normal file
271
fuzz/fuzz_targets/fuzz_transaction_properties.rs
Normal file
@ -0,0 +1,271 @@
|
||||
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
|
||||
//! Fuzz target: transaction property invariants.
|
||||
//!
|
||||
//! Tests that key accessor methods on `LeeTransaction`, `PublicTransaction`, and
|
||||
//! `ValidatedStateDiff` return correct, non-stub values.
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use common::transaction::LeeTransaction;
|
||||
use fuzz_props::arbitrary_types::ArbPrivateKey;
|
||||
use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state};
|
||||
use nssa::{
|
||||
AccountId, PrivateKey, PublicKey, ValidatedStateDiff, V03State,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
PublicTransaction,
|
||||
program::Program,
|
||||
};
|
||||
use nssa_core::account::Nonce;
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
let mut u = Unstructured::new(data);
|
||||
|
||||
// ── Part 1: Known-good witness set / transaction using fixed keys ──────────
|
||||
// Uses deterministic keys so we always have at least one valid transaction.
|
||||
// This ensures hash, signer_account_ids, into_raw_parts, and affected_accounts
|
||||
// are always tested, even when the fuzzer input is insufficient for arb generators.
|
||||
{
|
||||
let key1 = PrivateKey::try_new([1_u8; 32]).expect("known-good key");
|
||||
let key2 = PrivateKey::try_new([2_u8; 32]).expect("known-good key");
|
||||
let pub1 = PublicKey::new_from_private_key(&key1);
|
||||
let pub2 = PublicKey::new_from_private_key(&key2);
|
||||
let addr1 = AccountId::from(&pub1);
|
||||
let addr2 = AccountId::from(&pub2);
|
||||
|
||||
let nonces = vec![Nonce::from(0_u128), Nonce::from(0_u128)];
|
||||
let message = Message::try_new(
|
||||
Program::authenticated_transfer_program().id(),
|
||||
vec![addr1, addr2],
|
||||
nonces,
|
||||
1337_u64,
|
||||
)
|
||||
.expect("known-good message");
|
||||
|
||||
let ws = WitnessSet::for_message(&message, &[&key1, &key2]);
|
||||
let pub_tx = PublicTransaction::new(message, ws);
|
||||
|
||||
// ── INVARIANT [SignerIdsNonEmpty] ─────────────────────────────────────
|
||||
// A transaction signed by 2 keys must expose 2 signer (key, sig) pairs.
|
||||
// `signer_account_ids` is pub(crate); we verify via the public witness_set API.
|
||||
let ws_pairs = pub_tx.witness_set().signatures_and_public_keys();
|
||||
assert_eq!(
|
||||
ws_pairs.len(),
|
||||
2,
|
||||
"INVARIANT VIOLATION [SignerIdsNonEmpty]: \
|
||||
witness_set signatures_and_public_keys must have 2 entries",
|
||||
);
|
||||
|
||||
// ── INVARIANT [IntoRawPartsCount] ─────────────────────────────────────
|
||||
// `into_raw_parts` must return the same number of pairs as the witness set.
|
||||
// Catches the mutation that returns `vec![]`.
|
||||
let ws2 = WitnessSet::for_message(pub_tx.message(), &[&key1, &key2]);
|
||||
let parts = ws2.into_raw_parts();
|
||||
assert_eq!(
|
||||
parts.len(),
|
||||
2,
|
||||
"INVARIANT VIOLATION [IntoRawPartsCount]: \
|
||||
WitnessSet::into_raw_parts must return 2 pairs for a 2-key witness set",
|
||||
);
|
||||
|
||||
// ── INVARIANT [AffectedAccountsContainSigners] ───────────────────────
|
||||
// `affected_public_account_ids` must include the signer accounts.
|
||||
// Catches the mutation that returns `vec![]` or `vec![Default::default()]`.
|
||||
let affected = pub_tx.affected_public_account_ids();
|
||||
assert!(
|
||||
!affected.is_empty(),
|
||||
"INVARIANT VIOLATION [AffectedAccountsContainSigners]: \
|
||||
affected_public_account_ids must be non-empty for a 2-signer tx",
|
||||
);
|
||||
assert!(
|
||||
affected.contains(&addr1),
|
||||
"INVARIANT VIOLATION [AffectedAccountsContainSigners]: \
|
||||
affected_public_account_ids must include addr1 (signer)",
|
||||
);
|
||||
assert!(
|
||||
affected.contains(&addr2),
|
||||
"INVARIANT VIOLATION [AffectedAccountsContainSigners]: \
|
||||
affected_public_account_ids must include addr2 (signer)",
|
||||
);
|
||||
|
||||
// ── INVARIANT [HashNonDefault] ────────────────────────────────────────
|
||||
// The transaction hash must not be the all-zero default.
|
||||
// Catches the mutation that returns `Default::default()`.
|
||||
let lee_tx = LeeTransaction::Public(pub_tx);
|
||||
let hash = lee_tx.hash();
|
||||
assert_ne!(
|
||||
hash.0,
|
||||
[0_u8; 32],
|
||||
"INVARIANT VIOLATION [HashNonDefault]: \
|
||||
LeeTransaction::hash must not return all-zero bytes",
|
||||
);
|
||||
|
||||
// Also verify it's deterministic (same tx → same hash):
|
||||
let hash2 = lee_tx.hash();
|
||||
assert_eq!(
|
||||
hash,
|
||||
hash2,
|
||||
"INVARIANT VIOLATION [HashDeterministic]: \
|
||||
LeeTransaction::hash must be deterministic",
|
||||
);
|
||||
|
||||
// LeeTransaction::affected_public_account_ids must also be non-empty:
|
||||
let lee_affected = lee_tx.affected_public_account_ids();
|
||||
assert!(
|
||||
lee_affected.contains(&addr1),
|
||||
"INVARIANT VIOLATION [AffectedAccountsContainSigners]: \
|
||||
LeeTransaction::affected_public_account_ids must include addr1",
|
||||
);
|
||||
}
|
||||
|
||||
// ── INVARIANT [SignerOnlyAccountInAffected] ───────────────────────────────
|
||||
// Build a transaction signed by a key whose AccountId is NOT among
|
||||
// `message.account_ids`. Then `affected_public_account_ids` can only contain
|
||||
// the signer's AccountId via `signer_account_ids()` — it is absent from the
|
||||
// message's account list. This directly catches the `signer_account_ids`
|
||||
// mutations (`→ vec![]` / `→ vec![Default::default()]`) on PublicTransaction,
|
||||
// which the earlier checks miss because there the signer also appears in
|
||||
// `message.account_ids`.
|
||||
{
|
||||
// Signer key — its AccountId must NOT appear in the message account list.
|
||||
let signer_key = PrivateKey::try_new([9_u8; 32]).expect("known-good key");
|
||||
let signer_pub = PublicKey::new_from_private_key(&signer_key);
|
||||
let signer_addr = AccountId::from(&signer_pub);
|
||||
|
||||
// Two unrelated account IDs for the message (deterministic, not derived
|
||||
// from the signer key).
|
||||
let other1 = AccountId::new([0xA1_u8; 32]);
|
||||
let other2 = AccountId::new([0xA2_u8; 32]);
|
||||
|
||||
// Guard: ensure the signer is genuinely not one of the message accounts.
|
||||
if signer_addr != other1 && signer_addr != other2 {
|
||||
let nonces = vec![Nonce::from(0_u128)];
|
||||
if let Ok(msg) = Message::try_new(
|
||||
Program::authenticated_transfer_program().id(),
|
||||
vec![other1, other2],
|
||||
nonces,
|
||||
7_u64,
|
||||
) {
|
||||
let ws = WitnessSet::for_message(&msg, &[&signer_key]);
|
||||
let pt = PublicTransaction::new(msg, ws);
|
||||
|
||||
let affected = pt.affected_public_account_ids();
|
||||
assert!(
|
||||
affected.contains(&signer_addr),
|
||||
"INVARIANT VIOLATION [SignerOnlyAccountInAffected]: \
|
||||
affected_public_account_ids must include the signer {:?} even when it \
|
||||
is absent from message.account_ids — signer_account_ids() must not \
|
||||
return an empty (or defaulted) vec",
|
||||
signer_addr,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Part 2: Fuzz-driven state + valid native transfer ─────────────────────
|
||||
// Generates a random state and a correctly-signed transfer. When the transfer
|
||||
// succeeds, verifies that `public_diff` is non-empty and contains the
|
||||
// expected account changes.
|
||||
{
|
||||
let fuzz_accs = match arbitrary_fuzz_state(&mut u) {
|
||||
Ok(accs) => accs,
|
||||
Err(_) => return,
|
||||
};
|
||||
let init_accs: Vec<(AccountId, u128)> = fuzz_accs
|
||||
.iter()
|
||||
.map(|a| (a.account_id, a.balance))
|
||||
.collect();
|
||||
let state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0);
|
||||
|
||||
let Ok(tx) = arb_fuzz_native_transfer(&mut u, &fuzz_accs) else {
|
||||
return;
|
||||
};
|
||||
let Ok(checked) = tx.transaction_stateless_check() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pub_tx = match &checked {
|
||||
LeeTransaction::Public(pt) => pt,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// For a public transaction with signers, affected_public_account_ids must
|
||||
// include all signer account IDs. Derive signers from the public witness API.
|
||||
let signers: Vec<AccountId> = pub_tx
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| AccountId::from(pk))
|
||||
.collect();
|
||||
let affected = pub_tx.affected_public_account_ids();
|
||||
for signer in &signers {
|
||||
assert!(
|
||||
affected.contains(signer),
|
||||
"INVARIANT VIOLATION [AffectedAccountsContainSigners]: \
|
||||
affected_public_account_ids must include signer {:?}",
|
||||
signer,
|
||||
);
|
||||
}
|
||||
|
||||
// When from_public_transaction succeeds, public_diff must be non-empty
|
||||
// (at least the signer nonces are updated).
|
||||
// Catches the mutation that returns `HashMap::new()`.
|
||||
if let Ok(diff) = ValidatedStateDiff::from_public_transaction(pub_tx, &state, 1, 0) {
|
||||
let public_diff = diff.public_diff();
|
||||
|
||||
// The diff must contain at least the signer accounts (nonce updates):
|
||||
for signer in &signers {
|
||||
// Signers appear in diff because their nonces are updated.
|
||||
// If public_diff() returns empty HashMap, this assert fires.
|
||||
assert!(
|
||||
public_diff.contains_key(signer),
|
||||
"INVARIANT VIOLATION [PublicDiffNonEmptyOnSuccess]: \
|
||||
public_diff must contain signer account {:?} after successful validation \
|
||||
(nonce must have been updated)",
|
||||
signer,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Part 3: Fuzz-driven arbitrary keys for additional coverage ─────────────
|
||||
{
|
||||
if let Ok(key_wrap) = ArbPrivateKey::arbitrary(&mut u) {
|
||||
let key = key_wrap.0;
|
||||
let pubkey = PublicKey::new_from_private_key(&key);
|
||||
let addr = AccountId::from(&pubkey);
|
||||
|
||||
let nonces = vec![Nonce::from(0_u128)];
|
||||
if let Ok(msg) = Message::try_new(
|
||||
Program::authenticated_transfer_program().id(),
|
||||
vec![addr],
|
||||
nonces,
|
||||
42_u64,
|
||||
) {
|
||||
let ws = WitnessSet::for_message(&msg, &[&key]);
|
||||
let pt = PublicTransaction::new(msg, ws);
|
||||
|
||||
// Single-signer checks via witness_set (signer_account_ids is pub(crate)):
|
||||
let ws_pairs2 = pt.witness_set().signatures_and_public_keys();
|
||||
assert_eq!(
|
||||
ws_pairs2.len(),
|
||||
1,
|
||||
"INVARIANT VIOLATION [SignerIdsNonEmpty]: 1-key witness set must have 1 pair",
|
||||
);
|
||||
let derived_addr = AccountId::from(&ws_pairs2[0].1);
|
||||
assert_eq!(
|
||||
derived_addr,
|
||||
addr,
|
||||
"INVARIANT VIOLATION [SignerIdsDerivedFromKeys]: \
|
||||
derived signer address must match expected addr",
|
||||
);
|
||||
|
||||
let ws2 = WitnessSet::for_message(pt.message(), &[&key]);
|
||||
let parts = ws2.into_raw_parts();
|
||||
assert_eq!(
|
||||
parts.len(),
|
||||
1,
|
||||
"INVARIANT VIOLATION [IntoRawPartsCount]: 1-signer witness set → 1 raw part",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -25,7 +25,7 @@
|
||||
//! reachable by the fuzzer.
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use fuzz_props::arbitrary_types::ArbNSSATransaction;
|
||||
use fuzz_props::arbitrary_types::ArbLeeTransaction;
|
||||
use fuzz_props::generators::{arbitrary_fuzz_state, signer_account_ids};
|
||||
use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness};
|
||||
use nssa::V03State;
|
||||
@ -47,7 +47,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
.collect();
|
||||
|
||||
// Generate the transaction from the remaining fuzz bytes.
|
||||
let tx = match ArbNSSATransaction::arbitrary(&mut u) {
|
||||
let tx = match ArbLeeTransaction::arbitrary(&mut u) {
|
||||
Ok(w) => w.0,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
@ -21,3 +21,4 @@ testnet_initial_state = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.4"
|
||||
nssa = { workspace = true, features = ["prove"] }
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! **No changes to `../logos-execution-zone` are required.**
|
||||
//!
|
||||
//! The Rust orphan rule forbids `impl Arbitrary for NSSATransaction` when both
|
||||
//! The Rust orphan rule forbids `impl Arbitrary for LeeTransaction` when both
|
||||
//! the trait and the type come from external crates. Using newtypes (`ArbXxx`)
|
||||
//! sidesteps the restriction entirely.
|
||||
//!
|
||||
@ -10,10 +10,10 @@
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! #![no_main]
|
||||
//! use fuzz_props::arbitrary_types::ArbNSSATransaction;
|
||||
//! use fuzz_props::arbitrary_types::ArbLeeTransaction;
|
||||
//! use libfuzzer_sys::fuzz_target;
|
||||
//!
|
||||
//! fuzz_target!(|wrapped: ArbNSSATransaction| {
|
||||
//! fuzz_target!(|wrapped: ArbLeeTransaction| {
|
||||
//! let tx = wrapped.0;
|
||||
//! let Ok(valid_tx) = tx.transaction_stateless_check() else { return; };
|
||||
//! // …
|
||||
@ -21,7 +21,7 @@
|
||||
//! ```
|
||||
|
||||
use arbitrary::{Arbitrary, Result as ArbResult, Unstructured};
|
||||
use common::{HashType, block::HashableBlockData, transaction::NSSATransaction};
|
||||
use common::{HashType, block::HashableBlockData, transaction::LeeTransaction};
|
||||
use nssa::{
|
||||
AccountId, PrivateKey, PublicKey, Signature,
|
||||
program_deployment_transaction::ProgramDeploymentTransaction,
|
||||
@ -210,24 +210,24 @@ impl<'a> Arbitrary<'a> for ArbProgramDeploymentTransaction {
|
||||
}
|
||||
}
|
||||
|
||||
// ── NSSATransaction ───────────────────────────────────────────────────────────
|
||||
// ── LeeTransaction ───────────────────────────────────────────────────────────
|
||||
// `PrivacyPreservingTransaction` is intentionally excluded: it embeds a risc0
|
||||
// ZK receipt that cannot be generated inside a hot fuzzing loop. This matches
|
||||
// the known limitation documented in `docs/fuzzing.md`.
|
||||
|
||||
/// Newtype wrapper providing [`Arbitrary`] for [`NSSATransaction`].
|
||||
/// Newtype wrapper providing [`Arbitrary`] for [`LeeTransaction`].
|
||||
///
|
||||
/// Generates `Public` and `ProgramDeployment` variants only.
|
||||
#[derive(Debug)]
|
||||
pub struct ArbNSSATransaction(pub NSSATransaction);
|
||||
pub struct ArbLeeTransaction(pub LeeTransaction);
|
||||
|
||||
impl<'a> Arbitrary<'a> for ArbNSSATransaction {
|
||||
impl<'a> Arbitrary<'a> for ArbLeeTransaction {
|
||||
fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult<Self> {
|
||||
match u8::arbitrary(u)? % 2 {
|
||||
0 => Ok(Self(NSSATransaction::Public(
|
||||
0 => Ok(Self(LeeTransaction::Public(
|
||||
ArbPublicTransaction::arbitrary(u)?.0,
|
||||
))),
|
||||
_ => Ok(Self(NSSATransaction::ProgramDeployment(
|
||||
_ => Ok(Self(LeeTransaction::ProgramDeployment(
|
||||
ArbProgramDeploymentTransaction::arbitrary(u)?.0,
|
||||
))),
|
||||
}
|
||||
@ -246,7 +246,7 @@ impl<'a> Arbitrary<'a> for ArbHashableBlockData {
|
||||
fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult<Self> {
|
||||
// 0–7 transactions per block
|
||||
let n = (u8::arbitrary(u)? as usize) % 8;
|
||||
let transactions = std::iter::repeat_with(|| ArbNSSATransaction::arbitrary(u).map(|t| t.0))
|
||||
let transactions = std::iter::repeat_with(|| ArbLeeTransaction::arbitrary(u).map(|t| t.0))
|
||||
.take(n)
|
||||
.collect::<ArbResult<Vec<_>>>()?;
|
||||
Ok(Self(HashableBlockData {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use common::{block::HashableBlockData, transaction::NSSATransaction};
|
||||
use common::{block::HashableBlockData, transaction::LeeTransaction};
|
||||
use nssa::{AccountId, PrivateKey};
|
||||
|
||||
use crate::arbitrary_types::{ArbAccountId, ArbNSSATransaction, ArbPrivateKey};
|
||||
use crate::arbitrary_types::{ArbAccountId, ArbLeeTransaction, ArbPrivateKey};
|
||||
use proptest::prelude::*;
|
||||
use testnet_initial_state::initial_pub_accounts_private_keys;
|
||||
|
||||
@ -12,22 +12,22 @@ use testnet_initial_state::initial_pub_accounts_private_keys;
|
||||
/// 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<nssa::AccountId> {
|
||||
use common::transaction::NSSATransaction;
|
||||
pub fn signer_account_ids(tx: &common::transaction::LeeTransaction) -> Vec<nssa::AccountId> {
|
||||
use common::transaction::LeeTransaction;
|
||||
match tx {
|
||||
NSSATransaction::Public(pt) => pt
|
||||
LeeTransaction::Public(pt) => pt
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| nssa::AccountId::from(pk))
|
||||
.collect(),
|
||||
NSSATransaction::PrivacyPreserving(pt) => pt
|
||||
LeeTransaction::PrivacyPreserving(pt) => pt
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| nssa::AccountId::from(pk))
|
||||
.collect(),
|
||||
NSSATransaction::ProgramDeployment(_) => vec![],
|
||||
LeeTransaction::ProgramDeployment(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result<Vec<F
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a native-transfer [`NSSATransaction`] between two accounts chosen
|
||||
/// Generate a native-transfer [`LeeTransaction`] between two accounts chosen
|
||||
/// from `accounts`.
|
||||
///
|
||||
/// Because every account in the slice has a known private key, the resulting
|
||||
@ -87,7 +87,7 @@ pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result<Vec<F
|
||||
pub fn arb_fuzz_native_transfer(
|
||||
u: &mut Unstructured<'_>,
|
||||
accounts: &[FuzzAccount],
|
||||
) -> arbitrary::Result<NSSATransaction> {
|
||||
) -> arbitrary::Result<LeeTransaction> {
|
||||
if accounts.is_empty() {
|
||||
return Err(arbitrary::Error::IncorrectFormat);
|
||||
}
|
||||
@ -112,9 +112,9 @@ pub fn arb_fuzz_native_transfer(
|
||||
|
||||
// ── Arbitrary (for libFuzzer targets) ────────────────────────────────────────
|
||||
|
||||
/// Generate a structurally plausible `NSSATransaction` from unstructured bytes.
|
||||
pub fn arbitrary_transaction(u: &mut Unstructured<'_>) -> arbitrary::Result<NSSATransaction> {
|
||||
ArbNSSATransaction::arbitrary(u).map(|w| w.0)
|
||||
/// Generate a structurally plausible `LeeTransaction` from unstructured bytes.
|
||||
pub fn arbitrary_transaction(u: &mut Unstructured<'_>) -> arbitrary::Result<LeeTransaction> {
|
||||
ArbLeeTransaction::arbitrary(u).map(|w| w.0)
|
||||
}
|
||||
|
||||
// ── proptest strategies ───────────────────────────────────────────────────────
|
||||
@ -128,7 +128,7 @@ prop_compose! {
|
||||
to_idx in 0..accounts.len(),
|
||||
nonce in 0_u128..1_000_u128,
|
||||
amount in 0_u128..10_000_u128,
|
||||
) -> NSSATransaction {
|
||||
) -> LeeTransaction {
|
||||
let (from_id, from_key) = &accounts[from_idx];
|
||||
let (to_id, _) = &accounts[to_idx];
|
||||
common::test_utils::create_transaction_native_token_transfer(
|
||||
@ -146,11 +146,11 @@ pub fn test_accounts() -> Vec<(AccountId, PrivateKey)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Strategy: raw bytes that are valid borsh encodings of `NSSATransaction`.
|
||||
/// Strategy: raw bytes that are valid borsh encodings of `LeeTransaction`.
|
||||
pub fn arb_borsh_transaction_bytes() -> impl Strategy<Value = Vec<u8>> {
|
||||
any::<Vec<u8>>().prop_map(|bytes| {
|
||||
// Either pass through raw bytes OR encode a known dummy transaction
|
||||
if borsh::from_slice::<NSSATransaction>(&bytes).is_ok() {
|
||||
if borsh::from_slice::<LeeTransaction>(&bytes).is_ok() {
|
||||
bytes
|
||||
} else {
|
||||
borsh::to_vec(&common::test_utils::produce_dummy_empty_transaction()).unwrap()
|
||||
@ -183,7 +183,7 @@ prop_compose! {
|
||||
phantom_id_bytes in proptest::array::uniform32(0_u8..),
|
||||
amount in (u128::MAX / 2)..u128::MAX, // overflow-inducing amount
|
||||
nonce in 0_u128..10_u128,
|
||||
) -> NSSATransaction {
|
||||
) -> LeeTransaction {
|
||||
let phantom_id = nssa::AccountId::new(phantom_id_bytes);
|
||||
// Attempt to sign with a key that has no matching on-chain account
|
||||
let signing_key = nssa::PrivateKey::try_new(phantom_id_bytes)
|
||||
@ -204,11 +204,11 @@ prop_compose! {
|
||||
/// attack candidates) and some are re-ordered permutations of a valid sequence.
|
||||
/// Used in proptest-level tests and as a seed generator for the state-transition
|
||||
/// fuzz target.
|
||||
pub fn arb_duplicate_tx_sequence() -> impl Strategy<Value = Vec<NSSATransaction>> {
|
||||
pub fn arb_duplicate_tx_sequence() -> impl Strategy<Value = Vec<LeeTransaction>> {
|
||||
let accounts = test_accounts();
|
||||
proptest::collection::vec(arb_native_transfer_tx(accounts), 1..5_usize).prop_flat_map(|txs| {
|
||||
// Build a sequence that: original | duplicates | reversed
|
||||
let duped: Vec<NSSATransaction> = txs
|
||||
let duped: Vec<LeeTransaction> = txs
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(txs.iter().cloned()) // append exact duplicates
|
||||
@ -225,7 +225,7 @@ pub fn arb_duplicate_tx_sequence() -> impl Strategy<Value = Vec<NSSATransaction>
|
||||
/// - self-transfers (sender == recipient),
|
||||
/// - max-nonce wrapping,
|
||||
/// - alternating valid / invalid transactions to test partial-batch isolation.
|
||||
pub fn arb_pathological_sequence() -> impl Strategy<Value = Vec<NSSATransaction>> {
|
||||
pub fn arb_pathological_sequence() -> impl Strategy<Value = Vec<LeeTransaction>> {
|
||||
let accounts = test_accounts();
|
||||
let n = accounts.len();
|
||||
proptest::collection::vec((0..n, 0..n, 0_u128..5_u128, any::<bool>()), 1..8_usize).prop_map(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use common::transaction::NSSATransaction;
|
||||
use common::transaction::LeeTransaction;
|
||||
use nssa::V03State;
|
||||
use nssa_core::account::Nonce;
|
||||
|
||||
@ -185,7 +185,7 @@ impl ProtocolInvariant for FailedTxNonceStability {
|
||||
/// # Enforcement
|
||||
///
|
||||
/// This invariant **cannot** be enforced through [`InvariantCtx`] because the replay
|
||||
/// check requires re-applying the `NSSATransaction` that `execute_check_on_state`
|
||||
/// check requires re-applying the `LeeTransaction` 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`.
|
||||
@ -235,7 +235,7 @@ pub struct NonceIncrementCorrectness;
|
||||
///
|
||||
/// # Why a standalone function?
|
||||
///
|
||||
/// `execute_check_on_state` consumes the `NSSATransaction` and returns it on `Ok`,
|
||||
/// `execute_check_on_state` consumes the `LeeTransaction` and returns it on `Ok`,
|
||||
/// so the transaction is not available as a shared reference inside [`InvariantCtx`].
|
||||
/// This function accepts ownership of the returned transaction and performs the
|
||||
/// replay in-place.
|
||||
@ -249,7 +249,7 @@ pub struct NonceIncrementCorrectness;
|
||||
/// }
|
||||
/// ```
|
||||
pub fn assert_replay_rejection(
|
||||
applied_tx: NSSATransaction,
|
||||
applied_tx: LeeTransaction,
|
||||
state: &mut V03State,
|
||||
next_block_id: u64,
|
||||
next_timestamp: u64,
|
||||
@ -270,7 +270,7 @@ pub fn assert_replay_rejection(
|
||||
/// passing the signer IDs derived from the transaction's witness set, the [`NonceSnapshot`]
|
||||
/// captured **before** execution, and the post-execution state.
|
||||
///
|
||||
/// For a `NSSATransaction::Public(tx)`, derive signer IDs as:
|
||||
/// For a `LeeTransaction::Public(tx)`, derive signer IDs as:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let signer_ids: Vec<nssa::AccountId> = tx
|
||||
@ -281,7 +281,7 @@ pub fn assert_replay_rejection(
|
||||
/// .collect();
|
||||
/// ```
|
||||
///
|
||||
/// For `NSSATransaction::ProgramDeployment`, there are no signers; pass an empty slice.
|
||||
/// For `LeeTransaction::ProgramDeployment`, there are no signers; pass an empty slice.
|
||||
///
|
||||
/// # Why a standalone function?
|
||||
///
|
||||
@ -377,7 +377,7 @@ pub fn assert_tx_execution_invariants<E>(
|
||||
state_after: &mut V03State,
|
||||
balances_before: BalanceSnapshot,
|
||||
nonces_before: NonceSnapshot,
|
||||
execution_result: Result<NSSATransaction, E>,
|
||||
execution_result: Result<LeeTransaction, E>,
|
||||
replay_context: (u64, u64),
|
||||
) {
|
||||
let execution_succeeded = execution_result.is_ok();
|
||||
@ -400,19 +400,19 @@ pub fn assert_tx_execution_invariants<E>(
|
||||
if let Ok(applied_tx) = execution_result {
|
||||
// Derive signer IDs from the witness set. ProgramDeployment has no signers.
|
||||
let signer_ids: Vec<nssa::AccountId> = match &applied_tx {
|
||||
NSSATransaction::Public(pt) => pt
|
||||
LeeTransaction::Public(pt) => pt
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| nssa::AccountId::from(pk))
|
||||
.collect(),
|
||||
NSSATransaction::PrivacyPreserving(pt) => pt
|
||||
LeeTransaction::PrivacyPreserving(pt) => pt
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| nssa::AccountId::from(pk))
|
||||
.collect(),
|
||||
NSSATransaction::ProgramDeployment(_) => vec![],
|
||||
LeeTransaction::ProgramDeployment(_) => vec![],
|
||||
};
|
||||
assert_nonce_increment_correctness(&signer_ids, &nonces_for_nonce_check, state_after);
|
||||
let (next_block_id, next_timestamp) = replay_context;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
mod arbitrary_types_test;
|
||||
mod generators_test;
|
||||
mod invariants;
|
||||
mod replay_proptest;
|
||||
mod seed_gen;
|
||||
|
||||
158
fuzz_props/src/tests/arbitrary_types_test.rs
Normal file
158
fuzz_props/src/tests/arbitrary_types_test.rs
Normal file
@ -0,0 +1,158 @@
|
||||
//! Tests that detect mutations in `arbitrary_types.rs`.
|
||||
//!
|
||||
//! # Design rationale
|
||||
//!
|
||||
//! `arbitrary::Unstructured::fill_buffer` reads bytes from the **front** of the buffer
|
||||
//! and pads with zeros when the buffer is exhausted — it never returns an error. As a
|
||||
//! result, the total number of items generated by `take(n)` always equals `n` regardless
|
||||
//! of buffer size. This makes count-based tests the most reliable mutation detectors.
|
||||
//!
|
||||
//! For types that expose their length through public APIs we check the count directly.
|
||||
//! For `ArbPubTxMessage`, whose inner [`nssa::public_transaction::Message`] is opaque,
|
||||
//! we use the borsh-serialised size of a wrapping [`LeeTransaction::Public`] as a proxy.
|
||||
|
||||
use crate::arbitrary_types::{
|
||||
ArbHashableBlockData, ArbLeeTransaction, ArbPubTxMessage, ArbPublicTransaction, ArbWitnessSet,
|
||||
};
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use common::transaction::LeeTransaction;
|
||||
|
||||
#[test]
|
||||
fn arb_lee_transaction_zero_byte_selects_public() {
|
||||
// fill_buffer reads from the front, so the first byte consumed = 0.
|
||||
let buf = vec![0_u8; 4096];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let arb = ArbLeeTransaction::arbitrary(&mut u).expect("should succeed");
|
||||
assert!(
|
||||
matches!(arb.0, LeeTransaction::Public(_)),
|
||||
"expected Public variant: with first byte=0 and `% 2`, arm 0 (Public) is selected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arb_lee_transaction_byte4_selects_public() {
|
||||
// Place 4 as the first byte (variant selector); rest are zeros.
|
||||
let mut buf = vec![0_u8; 4096];
|
||||
buf[0] = 4;
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let arb = ArbLeeTransaction::arbitrary(&mut u).expect("should succeed");
|
||||
assert!(
|
||||
matches!(arb.0, LeeTransaction::Public(_)),
|
||||
"expected Public variant: `4 % 2 = 0` \u{2192} arm 0; \
|
||||
mutant `4 / 2 = 2` or `4 + 2 = 6` maps to `_` \u{2192} ProgramDeployment"
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates from all-1 bytes: `1 % 2 = 1` -> `_` -> `ProgramDeployment`.
|
||||
#[test]
|
||||
fn arb_lee_transaction_one_byte_selects_program_deployment() {
|
||||
let buf = vec![1_u8; 4096];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let arb = ArbLeeTransaction::arbitrary(&mut u).expect("should succeed");
|
||||
assert!(
|
||||
matches!(arb.0, LeeTransaction::ProgramDeployment(_)),
|
||||
"expected ProgramDeployment variant with first byte=1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates `ArbHashableBlockData` from all-255 bytes and asserts `transactions.len() <= 7`.
|
||||
#[test]
|
||||
fn arb_hashable_block_data_tx_count_bounded() {
|
||||
let buf = vec![255_u8; 50_000];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let arb = ArbHashableBlockData::arbitrary(&mut u)
|
||||
.expect("ArbHashableBlockData::arbitrary should succeed with a large all-255 buffer");
|
||||
assert!(
|
||||
arb.0.transactions.len() <= 7,
|
||||
"expected at most 7 transactions (% 8), got {} \
|
||||
(mutation: % replaced by / or + on line 248 of arbitrary_types.rs)",
|
||||
arb.0.transactions.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates `ArbWitnessSet` from all-255 bytes and asserts pair count <= 3.
|
||||
#[test]
|
||||
fn arb_witness_set_pair_count_bounded() {
|
||||
let buf = vec![255_u8; 50_000];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let arb = ArbWitnessSet::arbitrary(&mut u)
|
||||
.expect("ArbWitnessSet::arbitrary should succeed with a large all-255 buffer");
|
||||
let pair_count = arb.0.signatures_and_public_keys().len();
|
||||
assert!(
|
||||
pair_count <= 3,
|
||||
"expected at most 3 witness pairs (% 4), got {pair_count} \
|
||||
(mutation: % replaced by / or + on line 173 of arbitrary_types.rs)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks the borsh-encoded size of a `LeeTransaction::Public` wrapping an
|
||||
/// `ArbPubTxMessage` generated from a buffer where the len-selector byte = 255.
|
||||
#[test]
|
||||
fn arb_pub_tx_message_account_count_bounded_via_borsh() {
|
||||
// Bytes 0-31: zeros for program_id ([u32; 8] via fill_buffer reads 32 bytes).
|
||||
// Byte 32: 255 — this is the len-selector byte. 255 % 8 = 7 (correct) vs 255 / 8 = 31 (mutant).
|
||||
// Bytes 33+: zeros so Vec<u32> (instruction_data) produces 0 elements (last byte = 0).
|
||||
let mut buf = vec![0_u8; 2000];
|
||||
buf[32] = 255;
|
||||
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let msg =
|
||||
ArbPubTxMessage::arbitrary(&mut u).expect("ArbPubTxMessage::arbitrary should succeed");
|
||||
|
||||
// Wrap in a real PublicTransaction to enable borsh serialisation.
|
||||
let mut u_witness = Unstructured::new(&[0_u8; 10]);
|
||||
let witness = ArbWitnessSet::arbitrary(&mut u_witness)
|
||||
.expect("ArbWitnessSet::arbitrary should succeed with zero bytes (n=0)");
|
||||
|
||||
let tx = LeeTransaction::Public(nssa::public_transaction::PublicTransaction::new(
|
||||
msg.0, witness.0,
|
||||
));
|
||||
let borsh_bytes = borsh::to_vec(&tx).expect("borsh serialization should succeed");
|
||||
|
||||
// With 7 accounts the message borsh-encodes to ~380 bytes; the whole transaction to ~400 bytes.
|
||||
// With 31 accounts the message encodes to ~1540 bytes.
|
||||
// Using 800 as a conservative threshold clearly separates the two cases.
|
||||
assert!(
|
||||
borsh_bytes.len() < 800,
|
||||
"borsh-encoded size {} bytes exceeds threshold: too many accounts in message \
|
||||
(% 8 may have been replaced with / 8 or + 8 on line 144 of arbitrary_types.rs)",
|
||||
borsh_bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Additional check: with an all-zero buffer the `ArbPubTxMessage` generates a
|
||||
/// message with 0 accounts (`0 % 8 = 0`). This verifies the zero case.
|
||||
#[test]
|
||||
fn arb_pub_tx_message_zero_accounts_with_zero_selector() {
|
||||
// All zeros: program_id = all 0, len selector byte = 0.
|
||||
// 0 % 8 = 0 (correct), 0 + 8 = 8 (+ mutant).
|
||||
let buf = vec![0_u8; 500];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let msg =
|
||||
ArbPubTxMessage::arbitrary(&mut u).expect("ArbPubTxMessage::arbitrary should succeed");
|
||||
|
||||
let mut u_witness = Unstructured::new(&[0_u8; 10]);
|
||||
let witness = ArbWitnessSet::arbitrary(&mut u_witness).expect("witness should succeed");
|
||||
|
||||
let tx = LeeTransaction::Public(nssa::public_transaction::PublicTransaction::new(
|
||||
msg.0, witness.0,
|
||||
));
|
||||
let borsh_bytes = borsh::to_vec(&tx).expect("borsh serialization should succeed");
|
||||
|
||||
// With 0 accounts borsh size is minimal (~50 bytes for empty message + envelope).
|
||||
// With 8 accounts (+ mutant) borsh size > 400 bytes.
|
||||
assert!(
|
||||
borsh_bytes.len() < 300,
|
||||
"borsh-encoded size {} bytes too large for zero-account message \
|
||||
(% 8 may have been replaced with + 8 on line 144 of arbitrary_types.rs)",
|
||||
borsh_bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that `ArbPublicTransaction::arbitrary` completes without error.
|
||||
#[test]
|
||||
fn arb_public_transaction_smoke() {
|
||||
let buf = vec![0_u8; 4096];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let _ = ArbPublicTransaction::arbitrary(&mut u).expect("should succeed");
|
||||
}
|
||||
135
fuzz_props/src/tests/generators_test.rs
Normal file
135
fuzz_props/src/tests/generators_test.rs
Normal file
@ -0,0 +1,135 @@
|
||||
//! Tests that detect mutations in `generators.rs`.
|
||||
|
||||
use arbitrary::Unstructured;
|
||||
use nssa::{AccountId, PrivateKey};
|
||||
|
||||
use crate::generators::{
|
||||
FuzzAccount, arb_fuzz_native_transfer, arbitrary_fuzz_state, signer_account_ids, test_accounts,
|
||||
};
|
||||
|
||||
/// Verifies that `signer_account_ids` returns a **non-empty** list for a properly signed
|
||||
/// public transaction.
|
||||
#[test]
|
||||
fn signer_ids_nonempty_for_signed_public_tx() {
|
||||
let accounts = test_accounts();
|
||||
let (from_id, from_key) = &accounts[0];
|
||||
let (to_id, _) = &accounts[1];
|
||||
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
*from_id, 0, // nonce 0 — genesis nonce for the account
|
||||
*to_id, 100, from_key,
|
||||
);
|
||||
|
||||
let ids = signer_account_ids(&tx);
|
||||
assert!(
|
||||
!ids.is_empty(),
|
||||
"signer_account_ids must return at least one ID for a signed public transaction \
|
||||
(mutation: function body replaced with vec![])"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that the returned signer ID matches the account that actually signed the
|
||||
/// transaction — not a default/zeroed account ID.
|
||||
#[test]
|
||||
fn signer_ids_contains_the_signing_account() {
|
||||
let accounts = test_accounts();
|
||||
let (from_id, from_key) = &accounts[0];
|
||||
let (to_id, _) = &accounts[1];
|
||||
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
*from_id, 0, *to_id, 100, from_key,
|
||||
);
|
||||
|
||||
let ids = signer_account_ids(&tx);
|
||||
assert!(
|
||||
ids.contains(from_id),
|
||||
"signer_account_ids must contain the account ID of the private key that signed \
|
||||
the transaction; got {ids:?} but expected it to contain {from_id:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_state_never_empty() {
|
||||
let buf = vec![0_u8; 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); \
|
||||
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];
|
||||
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");
|
||||
assert_eq!(
|
||||
accounts.len(),
|
||||
1,
|
||||
"with selector byte=8: (8 % 8) + 1 = 1 account; \
|
||||
mutation `% \u{2192} /` gives (8/8)+1=2; mutation `% \u{2192} +` gives (8+8)+1=17"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that each account's balance is <= `u128::MAX / 8`.
|
||||
#[test]
|
||||
fn fuzz_state_balances_bounded_by_max_div_8() {
|
||||
let buf = vec![255_u8; 10_000];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
// With correct division, this must NOT overflow (no panic).
|
||||
let accounts = arbitrary_fuzz_state(&mut u)
|
||||
.expect("should succeed \u{2014} no overflow with correct / 8 implementation");
|
||||
|
||||
let max_balance = u128::MAX / 8;
|
||||
for acc in &accounts {
|
||||
assert!(
|
||||
acc.balance <= max_balance,
|
||||
"account balance {} exceeds u128::MAX/8={} \u{2014} \
|
||||
mutation: `/ 8` replaced by `* 8` (overflow) or `% 8`",
|
||||
acc.balance,
|
||||
max_balance
|
||||
);
|
||||
}
|
||||
|
||||
// Ensures the `% 8` mutation is caught: with u128::MAX bytes, correct `/` gives a
|
||||
// large balance (u128::MAX/8 ~= 3.4e37), while `%` gives only 0-7.
|
||||
let has_large_balance = accounts.iter().any(|a| a.balance > 7);
|
||||
assert!(
|
||||
has_large_balance,
|
||||
"expected at least one account with balance > 7 \u{2014} \
|
||||
mutation: `/ 8` replaced by `% 8` (balance capped at 7)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_transfer_index_uses_modulo_not_div_add() {
|
||||
let accounts = vec![
|
||||
FuzzAccount {
|
||||
account_id: AccountId::new([1_u8; 32]),
|
||||
balance: 1_000_000,
|
||||
private_key: PrivateKey::try_new([1_u8; 32]).expect("scalar 1 is a valid private key"),
|
||||
},
|
||||
FuzzAccount {
|
||||
account_id: AccountId::new([2_u8; 32]),
|
||||
balance: 1_000_000,
|
||||
private_key: PrivateKey::try_new([2_u8; 32]).expect("scalar 2 is a valid private key"),
|
||||
},
|
||||
];
|
||||
|
||||
// All-0xFF bytes: the from_idx byte = 255, to_idx byte = 255.
|
||||
// 255 % 2 = 1 (in-bounds), 255 / 2 = 127 (out-of-bounds), 255 + 2 = 257 (out-of-bounds).
|
||||
let buf = vec![0xFF_u8; 500];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
|
||||
// With the mutated `/ 2` or `+ 2`, `accounts[127]` or `accounts[257]` panics.
|
||||
let result = arb_fuzz_native_transfer(&mut u, &accounts);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"arb_fuzz_native_transfer should succeed with valid modulo-bounded indices; \
|
||||
mutation: `% accounts.len()` replaced by `/ accounts.len()` or `+ accounts.len()`"
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
use crate::generators::test_accounts;
|
||||
use crate::invariants::{
|
||||
BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants,
|
||||
assert_nonce_increment_correctness,
|
||||
BalanceConservation, BalanceSnapshot, FailedTxNonceStability, InvariantCtx, NonceSnapshot,
|
||||
ProtocolInvariant, StateIsolationOnFailure, assert_invariants,
|
||||
assert_nonce_increment_correctness, assert_replay_rejection, assert_tx_execution_invariants,
|
||||
};
|
||||
use common::transaction::LeeTransaction;
|
||||
use nssa::V03State;
|
||||
use nssa_core::account::Nonce;
|
||||
|
||||
@ -117,3 +120,185 @@ fn failed_tx_nonce_stability_catches_nonce_mutation() {
|
||||
"expected panic for nonce mutation on failure"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that `BalanceSnapshot::total` returns the correct arithmetical sum.
|
||||
#[test]
|
||||
fn balance_snapshot_total_is_correct_sum() {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert(nssa::AccountId::new([1_u8; 32]), 100_u128);
|
||||
map.insert(nssa::AccountId::new([2_u8; 32]), 200_u128);
|
||||
map.insert(nssa::AccountId::new([3_u8; 32]), 700_u128);
|
||||
let snap = BalanceSnapshot(map);
|
||||
assert_eq!(
|
||||
snap.total(),
|
||||
1000,
|
||||
"BalanceSnapshot::total must sum all balances"
|
||||
);
|
||||
}
|
||||
|
||||
/// Ensures `total()` is non-zero when accounts have positive balances.
|
||||
///
|
||||
/// Together with `balance_snapshot_total_is_correct_sum`, this forms a pair that
|
||||
/// catches the `replace total with 0` mutation even when the expected sum is zero
|
||||
/// in other tests.
|
||||
#[test]
|
||||
fn balance_snapshot_total_nonzero_for_positive_balances() {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert(nssa::AccountId::new([42_u8; 32]), 1_u128);
|
||||
let snap = BalanceSnapshot(map);
|
||||
assert_ne!(
|
||||
snap.total(),
|
||||
0,
|
||||
"BalanceSnapshot::total must not return 0 when accounts have positive balances \
|
||||
(mutation: replaced with literal 0)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that `StateIsolationOnFailure::name` returns a non-empty, non-"xyzzy" string.
|
||||
#[test]
|
||||
fn state_isolation_name_is_nonempty_and_not_placeholder() {
|
||||
let inv = StateIsolationOnFailure;
|
||||
let name = inv.name();
|
||||
assert!(
|
||||
!name.is_empty(),
|
||||
"StateIsolationOnFailure::name must not be empty"
|
||||
);
|
||||
assert_ne!(
|
||||
name, "xyzzy",
|
||||
"StateIsolationOnFailure::name must not be 'xyzzy'"
|
||||
);
|
||||
assert_eq!(name, "StateIsolationOnFailure");
|
||||
}
|
||||
|
||||
/// Verifies that `BalanceConservation::name` returns a non-empty, non-"xyzzy" string.
|
||||
#[test]
|
||||
fn balance_conservation_name_is_nonempty_and_not_placeholder() {
|
||||
let inv = BalanceConservation;
|
||||
let name = inv.name();
|
||||
assert!(
|
||||
!name.is_empty(),
|
||||
"BalanceConservation::name must not be empty"
|
||||
);
|
||||
assert_ne!(
|
||||
name, "xyzzy",
|
||||
"BalanceConservation::name must not be 'xyzzy'"
|
||||
);
|
||||
assert_eq!(name, "BalanceConservation");
|
||||
}
|
||||
|
||||
/// Verifies that `FailedTxNonceStability::name` returns a non-empty, non-"xyzzy" string.
|
||||
#[test]
|
||||
fn failed_tx_nonce_stability_name_is_nonempty_and_not_placeholder() {
|
||||
let inv = FailedTxNonceStability;
|
||||
let name = inv.name();
|
||||
assert!(
|
||||
!name.is_empty(),
|
||||
"FailedTxNonceStability::name must not be empty"
|
||||
);
|
||||
assert_ne!(
|
||||
name, "xyzzy",
|
||||
"FailedTxNonceStability::name must not be 'xyzzy'"
|
||||
);
|
||||
assert_eq!(name, "FailedTxNonceStability");
|
||||
}
|
||||
|
||||
/// Verifies that `StateIsolationOnFailure::check` returns `Some` when execution failed and
|
||||
/// the balance in `state_after` differs from `balances_before`.
|
||||
#[test]
|
||||
fn state_isolation_check_detects_balance_change_on_failure() {
|
||||
let acc_id = nssa::AccountId::new([1_u8; 32]);
|
||||
// State has balance 100 for acc_id.
|
||||
let state = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0);
|
||||
|
||||
// balances_before claims balance was 50, but state_after (== state) has 100.
|
||||
let mut balances = std::collections::HashMap::new();
|
||||
balances.insert(acc_id, 50_u128);
|
||||
|
||||
let ctx = InvariantCtx {
|
||||
state_before: &state,
|
||||
state_after: &state,
|
||||
execution_succeeded: false, // failure → isolation invariant is active
|
||||
balances_before: BalanceSnapshot(balances),
|
||||
nonces_before: make_empty_nonce_snapshot(),
|
||||
};
|
||||
|
||||
let inv = StateIsolationOnFailure;
|
||||
let result = inv.check(&ctx);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"StateIsolationOnFailure::check must return Some violation when \
|
||||
state_after balance (100) differs from balances_before (50) on a failed tx \
|
||||
(mutations: replace with None; delete !; replace != with ==)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that `assert_replay_rejection` panics when the replayed transaction is
|
||||
/// accepted (i.e. NOT rejected — a genuine invariant violation).
|
||||
#[test]
|
||||
fn assert_replay_rejection_panics_when_replay_not_rejected() {
|
||||
let accounts = test_accounts();
|
||||
let (from_id, from_key) = &accounts[0];
|
||||
let (to_id, _) = &accounts[1];
|
||||
|
||||
let genesis: Vec<(nssa::AccountId, u128)> = accounts
|
||||
.iter()
|
||||
.map(|(id, _)| (*id, 10_000_000_u128))
|
||||
.collect();
|
||||
|
||||
let tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
*from_id, 0, *to_id, 100, from_key,
|
||||
);
|
||||
let validated = tx
|
||||
.transaction_stateless_check()
|
||||
.expect("test setup: transaction must pass stateless validation");
|
||||
let mut scratch_state = V03State::new_with_genesis_accounts(&genesis, vec![], 0);
|
||||
let applied_tx = validated
|
||||
.execute_check_on_state(&mut scratch_state, 1, 1)
|
||||
.expect("test setup: first execution must succeed (block_id=1, timestamp=1)");
|
||||
|
||||
// Replay `applied_tx` (nonce 0) against a FRESH state still at nonce 0.
|
||||
// The nonce matches → execute_check_on_state ACCEPTS the replay — a protocol
|
||||
// violation that assert_replay_rejection must detect and panic on.
|
||||
let mut fresh_state = V03State::new_with_genesis_accounts(&genesis, vec![], 0);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
assert_replay_rejection(applied_tx, &mut fresh_state, 1, 1);
|
||||
}));
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"assert_replay_rejection must panic when the replayed tx is accepted \
|
||||
(mutation: replace function body with () \u{2014} no-op skips the check)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that `assert_tx_execution_invariants` is NOT a no-op by providing a
|
||||
/// context that violates `StateIsolationOnFailure` and expecting a panic.
|
||||
#[test]
|
||||
fn assert_tx_execution_invariants_is_not_noop() {
|
||||
let acc_id = nssa::AccountId::new([5_u8; 32]);
|
||||
// Both state_before and state_after have the account at balance 100.
|
||||
let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0);
|
||||
let mut state_after = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0);
|
||||
|
||||
// Lie: claim balance was 50 before. State_after shows 100.
|
||||
// With execution_succeeded=false, StateIsolationOnFailure detects the discrepancy.
|
||||
let mut balances = std::collections::HashMap::new();
|
||||
balances.insert(acc_id, 50_u128);
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
assert_tx_execution_invariants(
|
||||
&state_before,
|
||||
&mut state_after,
|
||||
BalanceSnapshot(balances),
|
||||
make_empty_nonce_snapshot(),
|
||||
Err::<LeeTransaction, &str>("simulated failure"),
|
||||
(1, 1),
|
||||
);
|
||||
}));
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"assert_tx_execution_invariants must panic on a StateIsolationOnFailure violation \
|
||||
(mutation: replace entire function body with () \u{2014} no-op skips all invariant checks)"
|
||||
);
|
||||
}
|
||||
|
||||
BIN
logos.avif
Normal file
BIN
logos.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
65
scripts/mutants-corpus-test.sh
Executable file
65
scripts/mutants-corpus-test.sh
Executable file
@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Plane-B mutation-testing oracle.
|
||||
#
|
||||
# Called by `cargo mutants --test-command` from *inside* the logos-execution-zone
|
||||
# workspace directory after each source mutation. Replays the committed
|
||||
# libFuzzer corpus against every fuzz target (cargo fuzz run -runs=0).
|
||||
#
|
||||
# Exit behaviour (used by cargo-mutants to classify each mutant):
|
||||
# exit 0 → all corpus replays passed → mutant SURVIVED (corpus gap)
|
||||
# exit ≠0 → at least one replay panicked → mutant CAUGHT (corpus covers it)
|
||||
#
|
||||
# Environment variables:
|
||||
# FUZZ_REPO absolute path to the lez-fuzzing repository root.
|
||||
# Defaults to the directory one level above this script.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FUZZ_REPO="${FUZZ_REPO:-"$(cd "${SCRIPT_DIR}/.." && pwd)"}"
|
||||
|
||||
CORPUS_ROOT="${FUZZ_REPO}/corpus/libfuzz"
|
||||
FUZZ_DIR="${FUZZ_REPO}/fuzz"
|
||||
|
||||
targets=(
|
||||
fuzz_transaction_decoding
|
||||
fuzz_stateless_verification
|
||||
fuzz_state_transition
|
||||
fuzz_block_verification
|
||||
fuzz_encoding_roundtrip
|
||||
fuzz_signature_verification
|
||||
fuzz_replay_prevention
|
||||
fuzz_state_diff_computation
|
||||
fuzz_validate_execute_consistency
|
||||
fuzz_state_serialization
|
||||
fuzz_witness_set_verification
|
||||
fuzz_program_deployment_lifecycle
|
||||
fuzz_apply_state_diff_split_path
|
||||
fuzz_multi_block_state_sequence
|
||||
fuzz_sequencer_vs_replayer
|
||||
fuzz_merkle_tree
|
||||
fuzz_transaction_properties
|
||||
fuzz_privacy_preserving_witness
|
||||
fuzz_encoding_privacy_preserving
|
||||
fuzz_nullifier_set_roundtrip
|
||||
)
|
||||
|
||||
# cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.).
|
||||
# When this script is called by `cargo-mutants` the working directory is the
|
||||
# LEZ workspace (logos-execution-zone/), whose rust-toolchain.toml pins the
|
||||
# stable 1.x compiler. Change to the fuzzing repo so that rustup resolves
|
||||
# the nightly toolchain from lez-fuzzing/rust-toolchain.toml instead.
|
||||
cd "${FUZZ_REPO}"
|
||||
|
||||
for target in "${targets[@]}"; do
|
||||
corpus="${CORPUS_ROOT}/${target}"
|
||||
mkdir -p "${corpus}"
|
||||
|
||||
# -runs=0 → replay every file in the corpus directory exactly once, then exit.
|
||||
# A panic (invariant violation) causes cargo fuzz to exit non-zero, which
|
||||
# propagates through this script and causes cargo-mutants to mark the mutant
|
||||
# as CAUGHT.
|
||||
cargo fuzz run "${target}" \
|
||||
--fuzz-dir "${FUZZ_DIR}" \
|
||||
"${corpus}" \
|
||||
-- -runs=0
|
||||
done
|
||||
Loading…
x
Reference in New Issue
Block a user