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