fix: targets matrix reuse

This commit is contained in:
Roman 2026-07-02 15:04:31 +02:00
parent 259ed9471f
commit 325c37a291
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
8 changed files with 105 additions and 278 deletions

View File

@ -21,37 +21,13 @@ jobs:
outputs:
targets: ${{ steps.list.outputs.targets }}
steps:
- name: Build target list
- name: Checkout repository
uses: actions/checkout@v4
# Derive the matrix straight from fuzz/Cargo.toml (the single source of truth),
# so new [[bin]] targets are picked up with no manual list to maintain here.
- name: Resolve fuzz target matrix
id: list
run: |
# Canonical, human-readable list (one target per line) → compact JSON array.
targets=$(jq -R -s -c 'split("\n") | map(select(length > 0))' <<'EOF'
fuzz_apply_state_diff_split_path
fuzz_block_verification
fuzz_encoding_roundtrip
fuzz_multi_block_state_sequence
fuzz_program_deployment_lifecycle
fuzz_replay_prevention
fuzz_sequencer_vs_replayer
fuzz_signature_verification
fuzz_state_diff_computation
fuzz_state_serialization
fuzz_state_transition
fuzz_stateless_verification
fuzz_transaction_decoding
fuzz_validate_execute_consistency
fuzz_witness_set_verification
fuzz_merkle_tree
fuzz_transaction_properties
fuzz_privacy_preserving_witness
fuzz_encoding_privacy_preserving
fuzz_nullifier_set_roundtrip
fuzz_privacy_preserving_state_transition
fuzz_transaction_ordering_independence
EOF
)
echo "targets=$targets" >> "$GITHUB_OUTPUT"
echo "Resolved ${targets}"
uses: ./.github/actions/resolve-targets
# ────────────────────────────────────────────────────────────────────────────
# afl-smoke — 60-second per targets

View File

@ -20,36 +20,26 @@ jobs:
- uses: actions/checkout@v4
- run: python3 scripts/check_target_inventory.py
# ── Resolve the fuzz target matrix from fuzz/Cargo.toml (single source of truth) ─
resolve:
name: Resolve target matrix
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.list.outputs.targets }}
steps:
- uses: actions/checkout@v4
- id: list
uses: ./.github/actions/resolve-targets
# ── Smoke fuzz: 60 s per target ─────────────────────────────────────────────
smoke-fuzz:
name: Smoke fuzz (${{ matrix.target }})
runs-on: ubuntu-latest
needs: resolve
strategy:
fail-fast: false
matrix:
target:
- 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
- fuzz_privacy_preserving_state_transition
- fuzz_transaction_ordering_independence
target: ${{ fromJSON(needs.resolve.outputs.targets) }}
steps:
- uses: actions/checkout@v4
@ -203,32 +193,11 @@ jobs:
regression:
name: Corpus regression (${{ matrix.target }})
runs-on: ubuntu-latest
needs: resolve
strategy:
fail-fast: false
matrix:
target:
- 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
- fuzz_privacy_preserving_state_transition
- fuzz_transaction_ordering_independence
target: ${{ fromJSON(needs.resolve.outputs.targets) }}
steps:
- uses: actions/checkout@v4
- name: Checkout logos-execution-zone
@ -257,6 +226,7 @@ jobs:
perf-baseline:
name: Performance baseline
runs-on: ubuntu-latest
needs: resolve
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
@ -265,28 +235,7 @@ jobs:
- uses: ./.github/actions/setup-libfuzzer
- name: Measure throughput (30 s per target)
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 \
fuzz_privacy_preserving_state_transition; do
for target in $(jq -r '.[]' <<< '${{ needs.resolve.outputs.targets }}'); 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

View File

@ -139,23 +139,18 @@ jobs:
- 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.
# Resolve the target list from fuzz/Cargo.toml (the single source of truth)
# so this workflow needs no hand-maintained list; scripts/mutants-corpus-test.sh
# parses the same file, so the two stay in sync automatically.
- name: Resolve fuzz target matrix
id: list
uses: ./.github/actions/resolve-targets
# Build every fuzz target once before the mutation loop so that each mutant
# only needs to rebuild the mutated crate, not the fuzz harness.
- 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 \
fuzz_privacy_preserving_state_transition; do
for target in $(jq -r '.[]' <<< '${{ steps.list.outputs.targets }}'); do
cargo fuzz build "${target}"
done

View File

@ -168,13 +168,19 @@ cp fuzz/artifacts/fuzz_state_transition/crash-abc123-minimised \
## Adding a New Target
```bash
# Scaffold everything automatically (corpus dir, .rs file, Cargo.toml entry, CI matrix entry)
# Scaffold everything automatically (corpus dir, .rs file, Cargo.toml 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
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).
appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](fuzz/Cargo.toml) — the **single source of
truth**. Every workflow and script derives its target list from that file at runtime (the CI
matrices and build loops via the [`resolve-targets`](.github/actions/resolve-targets)
composite action, and [`scripts/mutants-corpus-test.sh`](scripts/mutants-corpus-test.sh) via
an inline parse), so **no CI edits are needed**. The only manual step is a prose row in the
target tables of `README.md` and [`docs/fuzzing.md`](docs/fuzzing.md);
[`scripts/check_target_inventory.py`](scripts/check_target_inventory.py) (run in CI) fails the
build if either table drifts from `fuzz/Cargo.toml`.
---

View File

@ -148,8 +148,7 @@ This single command does four things automatically:
|---|---|
| Creates the corpus directory | `fuzz/corpus/fuzz_my_feature/` |
| Writes a typed fuzz target template | `fuzz/fuzz_targets/fuzz_my_feature.rs` |
| Appends `[[bin]]` entry to `fuzz/Cargo.toml` | Covers **both** the libFuzzer and AFL++ lanes |
| Inserts target into every CI matrix + perf loop | `.github/workflows/fuzz.yml` |
| Appends `[[bin]]` entry to `fuzz/Cargo.toml` | Covers **both** the libFuzzer and AFL++ lanes — and every workflow/script, which derive their target lists from this file |
The generated template uses `fuzz_props::fuzz_entry!` and works with both engines
without modification.
@ -165,16 +164,26 @@ structured input, or the proptest generators from
### Step 3 — Automated registration (cargo-fuzz + CI)
`just new-target` calls [`scripts/add_fuzz_target.py`](../scripts/add_fuzz_target.py)
which:
- Appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](../fuzz/Cargo.toml).
This **single entry** covers both the libFuzzer lane (`cargo fuzz build`) and
the AFL++ lane (`cargo afl build --no-default-features --features fuzzer-afl`).
- Inserts the target name into every strategy matrix and the perf-baseline shell
loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml).
which appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](../fuzz/Cargo.toml). This
**single entry** covers both the libFuzzer lane (`cargo fuzz build`) and the AFL++
lane (`cargo afl build --no-default-features --features fuzzer-afl`) — and it is the
**single source of truth** every workflow and script reads at runtime:
- The CI matrices and build loops (`fuzz.yml`, `fuzz-afl.yml`, `corpus-update.yml`,
`mutants.yml`) resolve their target list through the
[`resolve-targets`](../.github/actions/resolve-targets) composite action, which
parses `fuzz/Cargo.toml`.
- [`scripts/mutants-corpus-test.sh`](../scripts/mutants-corpus-test.sh) parses the
same file inline.
So a new `[[bin]]` needs **no workflow edits** — the only hand-authored places are the
prose target tables in this file and `README.md`, which
[`scripts/check_target_inventory.py`](../scripts/check_target_inventory.py) (run in CI)
checks against `fuzz/Cargo.toml`.
> [!TIP]
> **Manual fallback:** if you create a target without `just new-target`, add the
> entry yourself:
> entry yourself — that alone wires it into every lane:
>
> ```toml
> [[bin]]
@ -207,7 +216,8 @@ cd fuzz && cargo afl build \
| `fuzz/corpus/fuzz_<name>/` | Create | ✅ `just new-target` |
| `fuzz/Cargo.toml` | Add `[[bin]]` (covers both lanes) | ✅ `just new-target` |
| `Justfile` | Nothing — auto-discovers | ✅ automatic |
| `.github/workflows/fuzz.yml` | Add to 3 matrix lists | ✅ `just new-target` |
| `.github/workflows/*.yml`, `scripts/mutants-corpus-test.sh` | Nothing — target lists derive from `fuzz/Cargo.toml` | ✅ automatic |
| `README.md`, `docs/fuzzing.md` | Add a prose row to the target table | ⚠️ manual (CI-gated by `check_target_inventory.py`) |
---

View File

@ -1,25 +1,27 @@
#!/usr/bin/env python3
"""Fully automates registering a new cargo-fuzz / AFL++ fuzz target.
"""Register a new cargo-fuzz / AFL++ fuzz target.
Usage:
python3 scripts/add_fuzz_target.py <TARGET_NAME>
Where TARGET_NAME is the full binary name, e.g. fuzz_my_feature.
Actions performed:
1. Appends a [[bin]] entry to fuzz/Cargo.toml (one entry covers BOTH
the libFuzzer lane and the AFL++ lane no separate Cargo.toml needed)
2. Inserts TARGET_NAME into every YAML matrix block in
.github/workflows/fuzz.yml (smoke-fuzz, regression)
3. Inserts TARGET_NAME into the perf-baseline shell for-loop in
.github/workflows/fuzz.yml
A single `[[bin]]` entry in fuzz/Cargo.toml is the source of truth for BOTH
engines and for every workflow/script: the CI matrices and build loops derive
their target lists from fuzz/Cargo.toml at runtime (via the
`.github/actions/resolve-targets` composite action, or an inline parse in
`scripts/mutants-corpus-test.sh`). Appending the `[[bin]]` is therefore all this
script needs to do no workflow editing.
NOTE: A single fuzz/Cargo.toml is the source of truth for both engines.
- libFuzzer build: cargo fuzz build <TARGET>
- AFL++ build: cd fuzz && cargo afl build \\
--no-default-features --features fuzzer-afl \\
--release --bin <TARGET>
The only remaining manual step is the human-authored target tables in README.md
and docs/fuzzing.md, which carry a prose description per target that cannot be
auto-generated. `scripts/check_target_inventory.py` (run in CI) guards those.
Run from the repository root.
"""
@ -49,107 +51,6 @@ def append_cargo_bin(target: str, cargo_toml: Path) -> None:
print(f" [+] fuzz/Cargo.toml — added [[bin]] {target!r}")
def insert_into_yaml_matrices(target: str, content: str) -> tuple[str, int]:
"""Insert target into YAML strategy matrix blocks.
Matches blocks of the form::
target:
- fuzz_a
- fuzz_b
and appends `` - <target>`` after the last existing entry.
"""
pattern = re.compile(
r"( target:\n(?: - fuzz_\w+\n)+)",
re.MULTILINE,
)
def add_target(m: re.Match) -> str:
return m.group(0) + f" - {target}\n"
new_content, count = pattern.subn(add_target, content)
return new_content, count
def insert_into_shell_loop(target: str, content: str) -> tuple[str, int]:
"""Insert target into a 'for target in ... ; do' shell loop.
The last entry in the loop ends with ``; do``. We change it to end with
a backslash continuation and append the new entry with ``; do``.
Example before::
fuzz_block_verification; do
After::
fuzz_block_verification \\
fuzz_new_target; do
"""
# Match the last fuzz target in the for-loop: " fuzz_xxx; do"
# Indentation: 12 spaces (inside a run: | block).
pattern = re.compile(r"( fuzz_\w+)(; do)", re.MULTILINE)
# We only want to replace the *last* occurrence (the closing entry).
matches = list(pattern.finditer(content))
if not matches:
return content, 0
if len(matches) > 1:
print(
f" ERROR: found {len(matches)} shell loops matching the pattern; "
"cannot determine which one to update. "
"Please edit .github/workflows/fuzz.yml manually.",
file=sys.stderr,
)
sys.exit(1)
m = matches[-1]
replacement = f"{m.group(1)} \\\n {target}{m.group(2)}"
new_content = content[: m.start()] + replacement + content[m.end() :]
return new_content, 1
def insert_into_workflow(target: str, workflow: Path) -> None:
"""Update all target lists in the fuzz workflow file."""
content = workflow.read_text()
if target in content:
print(f" SKIP .github/workflows/fuzz.yml — {target!r} already present")
return
# 1. YAML matrix blocks (smoke-fuzz, regression)
content, yaml_count = insert_into_yaml_matrices(target, content)
if yaml_count:
print(
f" [+] .github/workflows/fuzz.yml — inserted {target!r} into "
f"{yaml_count} YAML matrix block(s)"
)
else:
print(
f" ERROR: no YAML matrix blocks matched in {workflow} — please edit manually",
file=sys.stderr,
)
sys.exit(1)
# 2. Shell for-loop (perf-baseline)
content, loop_count = insert_into_shell_loop(target, content)
if loop_count:
print(
f" [+] .github/workflows/fuzz.yml — inserted {target!r} into "
f"perf-baseline shell loop"
)
else:
print(
f" ERROR: perf-baseline shell loop not found in {workflow} — please edit manually",
file=sys.stderr,
)
sys.exit(1)
workflow.write_text(content)
def main() -> None:
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <TARGET_NAME>", file=sys.stderr)
@ -167,17 +68,11 @@ def main() -> None:
root = Path(__file__).parent.parent # repository root
cargo_toml = root / "fuzz" / "Cargo.toml"
workflow = root / ".github" / "workflows" / "fuzz.yml"
if not cargo_toml.exists():
print(f"ERROR: {cargo_toml} not found", file=sys.stderr)
sys.exit(1)
if not workflow.exists():
print(f"ERROR: {workflow} not found", file=sys.stderr)
sys.exit(1)
append_cargo_bin(target, cargo_toml)
insert_into_workflow(target, workflow)
# ── Print build instructions ──────────────────────────────────────────────
print()
@ -197,13 +92,11 @@ def main() -> None:
print(" 4. Run with libFuzzer: just fuzz-one", target)
print(" Run with AFL++: just fuzz-afl", target)
print()
print(" 5. This script only edits .github/workflows/fuzz.yml. Add the")
print(" target to the other enumeration sites too, then verify with:")
print(" 5. Every workflow and script derives its target list from")
print(" fuzz/Cargo.toml, so no CI edits are needed. Only add a prose row")
print(" to the two doc tables, then verify with:")
print(" python3 scripts/check_target_inventory.py")
print(" (the same check runs in CI and will fail the build on drift):")
print(" .github/workflows/fuzz-afl.yml")
print(" .github/workflows/mutants.yml")
print(" scripts/mutants-corpus-test.sh")
print(" README.md, docs/fuzzing.md")

View File

@ -1,11 +1,18 @@
#!/usr/bin/env python3
"""Fail if any fuzz target registered in fuzz/Cargo.toml is missing from a
workflow / script / doc that enumerates the target list.
human-authored doc that enumerates the target list.
`fuzz/Cargo.toml` is the single source of truth: every `[[bin]] name = "fuzz_*"`
must be mentioned by name in each of the consumer files below. This guards
against the drift that `scripts/add_fuzz_target.py` cannot prevent on its own
(it only edits `.github/workflows/fuzz.yml`).
must be mentioned by name in each consumer file below.
The CI workflows and shell scripts derive their target lists *directly* from
`fuzz/Cargo.toml` at runtime, so they cannot drift and are not checked here:
- `.github/workflows/{fuzz,fuzz-afl}.yml` and the `corpus-update.yml` matrices
use the `.github/actions/resolve-targets` composite action.
- `.github/workflows/mutants.yml` calls the same action for its build loop.
- `scripts/mutants-corpus-test.sh` parses `fuzz/Cargo.toml` inline.
Only the prose target tables in the docs below carry a hand-written description
per target and therefore need this drift gate.
Usage:
python3 scripts/check_target_inventory.py
@ -19,13 +26,10 @@ import re
import sys
from pathlib import Path
# Files that enumerate the full target list and must stay in sync with Cargo.toml.
# Human-authored docs whose prose target tables must stay in sync with Cargo.toml.
# (Workflows/scripts auto-derive their lists from Cargo.toml — see the module docstring.)
# Paths are relative to the repository root.
CONSUMERS = [
".github/workflows/fuzz.yml",
".github/workflows/fuzz-afl.yml",
".github/workflows/mutants.yml",
"scripts/mutants-corpus-test.sh",
"README.md",
"docs/fuzzing.md",
]

View File

@ -20,29 +20,23 @@ 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
fuzz_privacy_preserving_state_transition
# Derive the target list from fuzz/Cargo.toml (the single source of truth) — the same
# `[[bin]] name = "fuzz_*"` parse that .github/actions/resolve-targets uses. This keeps
# the script in sync with every workflow automatically, with no hand-maintained list.
# (A while-read loop rather than `mapfile`, so this also works under macOS' bash 3.2.)
CARGO_TOML="${FUZZ_DIR}/Cargo.toml"
targets=()
while IFS= read -r _target; do
[ -n "${_target}" ] && targets+=("${_target}")
done < <(
grep -oE 'name = "fuzz_[a-z0-9_]+"' "${CARGO_TOML}" \
| sed -E 's/.*"(fuzz_[a-z0-9_]+)"/\1/' \
| awk '!seen[$0]++'
)
if [ "${#targets[@]}" -eq 0 ]; then
echo "ERROR: no fuzz_* [[bin]] targets found in ${CARGO_TOML}" >&2
exit 1
fi
# cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.).
# When this script is called by `cargo-mutants` the working directory is the