Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/fuzz-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Fuzz smoke — short cargo-fuzz run (~60s / target) on every PR + push to main.
#
# Catches new panics / unencodable instructions / silent op drops in the
# WASM → IR → ARM lowering pipeline. Long-budget runs (1h / target with
# corpus persistence) are out of scope for #82; this is the smoke gate.
#
# Target list mirrors fuzz/Cargo.toml `[[bin]]` entries. Add a new
# fuzz_target there → add the binary name to `matrix.target` here.
#
# Refs: issue #82, issue #93 (silent-drop class).

name: Fuzz Smoke

on:
push:
branches: [main]
paths:
- 'crates/**'
- 'fuzz/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/fuzz-smoke.yml'
pull_request:
branches: [main]
paths:
- 'crates/**'
- 'fuzz/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/fuzz-smoke.yml'

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
fuzz:
name: "${{ matrix.target }} (60s)"
# Match ci.yml: rust-cpu self-hosted pool, with ubuntu-latest fallback if
# the pool is unavailable (cargo-fuzz needs Linux for libfuzzer-sys ASan).
runs-on: [self-hosted, linux, x64, rust-cpu]
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
# Each entry: `target` + `gating`. Gating harnesses pass clean today
# and block PR merge on regression. Exploration harnesses keep finding
# new bugs at a rate faster than we close them in one cycle; they
# report (crash artifacts uploaded) but don't block — `continue-on-
# error` is taken from the `gating` flag. Promote an exploration
# harness to gating once its bug-list stabilises.
include:
- target: wasm_ops_lower_or_error
gating: true
- target: wasm_to_ir_roundtrip_op_coverage
gating: true
- target: i64_lowering_doesnt_clobber_params
gating: false # finds real bugs faster than we fix them; tracked as follow-up issues
- target: encoder_no_panic
gating: false # encoder-level corner cases; not silicon-blocking
# When gating == false, treat job failure as a non-blocking warning. The
# matrix value is plain JSON (bool), not untrusted user input, so this is
# safe to interpolate.
continue-on-error: ${{ matrix.gating == false }}
steps:
- uses: actions/checkout@v4

- name: Install nightly Rust
uses: dtolnay/rust-toolchain@nightly

- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
fuzz/target
key: fuzz-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'fuzz/Cargo.toml') }}
restore-keys: fuzz-${{ runner.os }}-

- name: Install cargo-fuzz
uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz

- name: Run fuzz target for 60s
env:
TARGET: ${{ matrix.target }}
# Force the GNU target — cargo-fuzz defaults to musl on Linux,
# whose statically-linked libc is incompatible with ASan
# (libfuzzer-sys turns ASan on by default). The GNU target has
# a dynamic libc and works correctly.
run: |
mkdir -p fuzz/artifacts fuzz/corpus
cargo +nightly fuzz run "${TARGET}" \
--target x86_64-unknown-linux-gnu \
-- -max_total_time=60 -print_final_stats=1

- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crash-${{ matrix.target }}
path: |
fuzz/artifacts/${{ matrix.target }}/
fuzz/corpus/${{ matrix.target }}/
retention-days: 30
if-no-files-found: ignore
5 changes: 5 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target
corpus
artifacts
coverage
Cargo.lock
66 changes: 66 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
[package]
name = "synth-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
rust-version = "1.88"

# Exclude this crate from the main workspace so that the libfuzzer-sys
# build-script (which depends on a C++ runtime + sanitizers) does not
# pull in nightly-only features when the main workspace is built with
# stable rustc.
[workspace]
members = []

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }

# Compiler crates under test. Paths are relative to /fuzz.
synth-core = { path = "../crates/synth-core" }
synth-synthesis = { path = "../crates/synth-synthesis" }
synth-backend = { path = "../crates/synth-backend" }

# Shared fuzz utilities (FuzzOp -> WasmOp generator, etc.)
[lib]
path = "src/lib.rs"

# ---------------------------------------------------------------------------
# Fuzz targets
# ---------------------------------------------------------------------------
# Each [[bin]] below is a libfuzzer entry point. To add a new harness:
# 1. Drop a new file under fuzz_targets/<name>.rs.
# 2. Add a matching [[bin]] entry here.
# 3. Add the target name to .github/workflows/fuzz-smoke.yml `matrix.target`.
# ---------------------------------------------------------------------------

[[bin]]
name = "wasm_ops_lower_or_error"
path = "fuzz_targets/wasm_ops_lower_or_error.rs"
test = false
doc = false
bench = false

[[bin]]
name = "wasm_to_ir_roundtrip_op_coverage"
path = "fuzz_targets/wasm_to_ir_roundtrip_op_coverage.rs"
test = false
doc = false
bench = false

[[bin]]
name = "i64_lowering_doesnt_clobber_params"
path = "fuzz_targets/i64_lowering_doesnt_clobber_params.rs"
test = false
doc = false
bench = false

[[bin]]
name = "encoder_no_panic"
path = "fuzz_targets/encoder_no_panic.rs"
test = false
doc = false
bench = false
129 changes: 129 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# synth fuzz harnesses

Cargo-fuzz harnesses for synth's WASM → IR → ARM lowering and the
ARM-Thumb-2 encoder. Tracking issue: [#82](https://github.com/pulseengine/synth/issues/82).

The harnesses target the failure surfaces where silent mis-compilations
have actually appeared in synth (#85, #86, #93). Each one corresponds
to one *class* of bug, not a specific instance — when the fuzzer finds
a crash, the failing input goes into the corpus and the fix can write
a minimal regression test from that input.

## Layout

```
fuzz/
├── Cargo.toml # Excluded from workspace; depends on libfuzzer-sys.
├── src/
│ ├── lib.rs # Re-exports.
│ └── common.rs # FuzzOp ↔ WasmOp mapping (Arbitrary-derived).
└── fuzz_targets/
├── wasm_ops_lower_or_error.rs # Harness 1
├── wasm_to_ir_roundtrip_op_coverage.rs # Harness 2 (issue #93 class)
├── i64_lowering_doesnt_clobber_params.rs # Harness 3 (AAPCS class)
└── encoder_no_panic.rs # Harness 4
```

## Running locally

Cargo-fuzz needs nightly:

```bash
rustup toolchain install nightly
cargo install cargo-fuzz
```

Then, from the repository root:

```bash
# Smoke run — same budget as CI.
cargo +nightly fuzz run wasm_ops_lower_or_error -- -max_total_time=60

# Longer local sweep — useful while developing a fix.
cargo +nightly fuzz run wasm_to_ir_roundtrip_op_coverage -- -max_total_time=300

# All four harnesses in series.
for t in wasm_ops_lower_or_error wasm_to_ir_roundtrip_op_coverage \
i64_lowering_doesnt_clobber_params encoder_no_panic; do
cargo +nightly fuzz run "$t" -- -max_total_time=60 || break
done
```

Crashes are written to `fuzz/artifacts/<target>/`. To re-run a saved
crash:

```bash
cargo +nightly fuzz run wasm_ops_lower_or_error fuzz/artifacts/wasm_ops_lower_or_error/crash-XXXX
```

On macOS you may also need `--target $(rustc --print host-tuple)` —
`cargo-fuzz` defaults to the musl target on Linux, which is not what
you want with ASan; on macOS the host triple is correct.

## Harness reference

### `wasm_ops_lower_or_error`

* **Class:** lowering panic / unencodable instruction
* **What it does:** drives an arbitrary `Vec<WasmOp>` through both
`OptimizerBridge::optimize_full` + `ir_to_arm` (the optimized path)
and `InstructionSelector::select_with_stack` (the non-optimized path),
then runs every emitted `ArmOp` through `ArmEncoder::encode`.
* **Pass criterion:** every step returns `Ok(_)` or `Err(_)`. A panic,
an integer overflow under `arithmetic_overflow=panic`, or any
unencodable instruction is a crash.
* **Caps:** input is rejected if `wasm_ops.len() > 256` to keep libfuzzer
cycles focused; this is not a soundness concession, it's a budget.

### `wasm_to_ir_roundtrip_op_coverage` (issue #93 class)

* **Class:** silent op drop in `wasm_to_ir`
* **What it does:** for each value-producing `FuzzOp`, builds a
minimal stack-correct preamble, runs `optimize_full` with **all
optimizations disabled**, and asserts the live IR length is
≥ input op count.
* **Pass criterion:** every value-producing wasm op contributes at least
one IR instruction. The post-filter inside `optimize_full` strips
`Opcode::Nop`, so an op silently mapped to `Nop` (the #93 fingerprint)
drops below the floor and the harness panics.
* **Note:** `I64ExtendI32S`, `I64ExtendI32U`, and `I32WrapI64` are
currently *skipped* until PR #97 lands. The skip block is documented
inline; remove it after merge.

### `i64_lowering_doesnt_clobber_params` (AAPCS class)

* **Class:** AAPCS param register clobber
* **What it does:** generates a sequence that mixes i64 ops with
`LocalGet(p)` reads of i32 params, lowers via
`select_with_stack`, then walks each emitted ARM instruction and
asserts no instruction writes to `r{p}` *before* the wasm
`LocalGet(p)` site.
* **Pass criterion:** for every param `p < num_params`, no ARM
instruction (excluding the prologue) emitted from a wasm op preceding
`LocalGet(p)` writes to `r{p}`.
* **Coverage:** the `writes()` helper enumerates every i64-pair op the
selector currently emits. Conservative — unlisted variants are
treated as no-write — so false negatives are possible but false
positives are not.

### `encoder_no_panic`

* **Class:** encoder panic on a syntactically-valid `ArmOp`
* **What it does:** generates randomly-parametrised but well-typed
`ArmOp` values across the most encoder-rich variants
(data-processing, load/store, immediate-shift, branch, sign-extend,
…) and runs each through every encoder mode (ARM32, Thumb-2,
Thumb-2+VFP single, Thumb-2+VFP double).
* **Pass criterion:** `ArmEncoder::encode` returns `Ok(_)` or `Err(_)`
on every input — never panics.

## CI integration

`.github/workflows/fuzz-smoke.yml` runs each target for 60 seconds on
every PR. The matrix mirrors the `[[bin]]` list in `fuzz/Cargo.toml`.
Long-budget runs (1 h / target) and corpus persistence are out of
scope for #82 and tracked separately on the issue.

If you add a new harness, update **both**:
1. `fuzz/Cargo.toml` `[[bin]]` block, and
2. `.github/workflows/fuzz-smoke.yml` `matrix.target`.
Loading
Loading