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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,32 @@ jobs:
name: mutants-report
path: mutants-out/

# ── Fuzz smoke (60s per target on PRs) ──────────────────────────────
fuzz-smoke:
name: Fuzz smoke (60s/target)
runs-on: ubuntu-latest
# Only run on PRs — pushes to main hit the nightly workflow instead.
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
workspaces: fuzz
- name: Install cargo-fuzz
uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
# cargo-fuzz defaults to x86_64-unknown-linux-musl, whose statically-
# linked libc is incompatible with ASan ("sanitizer is incompatible
# with statically linked libc"). Pin to the GNU triple explicitly.
- name: fuzz_aadl_parse (60s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu fuzz_aadl_parse -- -max_total_time=60 -timeout=10
- name: fuzz_scheduler_solver (60s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu fuzz_scheduler_solver -- -max_total_time=60 -timeout=5
- name: fuzz_codegen_roundtrip (60s)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu fuzz_codegen_roundtrip -- -max_total_time=60 -timeout=10

# ── Supply chain verification ───────────────────────────────────────
supply-chain:
name: Supply Chain (cargo-vet)
Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/fuzz-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Fuzz (nightly)

on:
schedule:
# Daily 03:00 UTC
- cron: "0 3 * * *"
workflow_dispatch:

permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
fuzz-long:
name: Fuzz ${{ matrix.target }} (1h)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target:
- fuzz_aadl_parse
- fuzz_scheduler_solver
- fuzz_codegen_roundtrip
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@nightly

- uses: Swatinem/rust-cache@v2
with:
workspaces: fuzz

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

# Restore previous corpus for this target (if one was uploaded).
- name: Restore corpus
uses: actions/cache@v4
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
restore-keys: |
fuzz-corpus-${{ matrix.target }}-

- name: Run fuzz target for 1h
env:
TARGET: ${{ matrix.target }}
run: cargo +nightly fuzz run "$TARGET" -- -max_total_time=3600 -timeout=10

- name: Upload corpus
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-corpus-${{ matrix.target }}
path: fuzz/corpus/${{ matrix.target }}
if-no-files-found: ignore
retention-days: 30

- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-artifacts-${{ matrix.target }}
path: fuzz/artifacts/${{ matrix.target }}
if-no-files-found: ignore
retention-days: 30
70 changes: 70 additions & 0 deletions artifacts/verification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1036,3 +1036,73 @@ artifacts:
target: REQ-TRACE-001
- type: satisfies
target: REQ-TRACE-002
# ── Fuzz Verification (issue #138) ──────────────────────────────────

- id: FUZZ-PARSER
type: feature
title: cargo-fuzz target — AADL parser robustness
description: >
libfuzzer-sys harness `fuzz_aadl_parse` feeds arbitrary byte sequences
(UTF-8 filtered) through `spar_syntax::parse`. Contract: no panic and
no hang (libfuzzer `-timeout=10` backstop) on any input, exercising
the parser's error-recovery paths with adversarial data.
fields:
method: automated-test
steps:
- run: cargo +nightly fuzz run fuzz_aadl_parse -- -max_total_time=60 -timeout=10
status: implemented
tags: [fuzz, parser, robustness]
links:
- type: verifies
target: REQ-PARSE-001
- type: verifies
target: REQ-PARSE-002
- type: verifies
target: REQ-PARSER-001

- id: FUZZ-SOLVER
type: feature
title: cargo-fuzz target — scheduler/solver robustness
description: >
libfuzzer-sys harness `fuzz_scheduler_solver` derives a bounded
`TaskSet` (≤8 tasks, ≤4 processors, u16-bounded timing fields) via
`arbitrary::Arbitrary` and calls `spar_solver::milp::solve_milp`.
Contract: return `Ok` or `Err`, never panic; `-timeout=5` catches
non-terminating MILP calls.
fields:
method: automated-test
steps:
- run: cargo +nightly fuzz run fuzz_scheduler_solver -- -max_total_time=60 -timeout=5
status: implemented
tags: [fuzz, solver, robustness]
links:
- type: verifies
target: REQ-SOLVER-001
- type: verifies
target: REQ-SOLVER-003
- type: verifies
target: REQ-SOLVER-005

- id: FUZZ-CODEGEN
type: feature
title: cargo-fuzz target — codegen roundtrip robustness
description: >
libfuzzer-sys harness `fuzz_codegen_roundtrip` feeds a deterministic
AADL fixture (`test-data/codegen/building_control.aadl`) through
`SystemInstance::instantiate` and varies the `CodegenConfig` knobs
(format, verify mode, rivet, dry_run) from arbitrary bytes. Contract:
`spar_codegen::generate` must not panic for any reachable
configuration combination.
fields:
method: automated-test
steps:
- run: cargo +nightly fuzz run fuzz_codegen_roundtrip -- -max_total_time=60 -timeout=10
status: implemented
tags: [fuzz, codegen, robustness]
links:
- type: verifies
target: REQ-CODEGEN-001
- type: verifies
target: REQ-CODEGEN-WIT
- type: verifies
target: REQ-CODEGEN-RUST
4 changes: 4 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
54 changes: 54 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
[package]
name = "spar-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
license = "MIT"

[package.metadata]
cargo-fuzz = true

# `cargo fuzz` expects this crate to live *outside* the workspace so its
# nightly-only `-Zsanitizer` flags don't poison other builds.
[workspace]

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

spar-parser = { path = "../crates/spar-parser" }
spar-syntax = { path = "../crates/spar-syntax" }
spar-solver = { path = "../crates/spar-solver" }
spar-codegen = { path = "../crates/spar-codegen" }
spar-hir-def = { path = "../crates/spar-hir-def" }
spar-base-db = { path = "../crates/spar-base-db" }
la-arena = "0.3"

# cargo-fuzz disables default features of workspace members, but spar-solver
# compiles in default configuration here (it depends on good_lp + HiGHS).

[profile.release]
debug = 1

# ── fuzz target binaries ─────────────────────────────────────────────

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

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

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

`cargo-fuzz` harnesses for the parser, MILP scheduler/allocator, and codegen
pipeline. Satisfies issue [#138](https://github.com/pulseengine/spar/issues/138).

## Targets

| target | surface | requirements traced |
|----------------------------|-------------------------------------|-----------------------------------|
| `fuzz_aadl_parse` | `spar_syntax::parse` | REQ-PARSE-001, REQ-PARSE-002, REQ-PARSER-001 |
| `fuzz_scheduler_solver` | `spar_solver::milp::solve_milp` | REQ-SOLVER-001, REQ-SOLVER-003, REQ-SOLVER-005 |
| `fuzz_codegen_roundtrip` | `spar_codegen::generate` | REQ-CODEGEN-001, REQ-CODEGEN-WIT, REQ-CODEGEN-RUST |

Each target asserts only **"no panic, no hang"** — `Err` returns from the
solver are legitimate (infeasible task sets), parse errors are legitimate
(malformed input), and varying codegen configs must all succeed on the
fixed fixture.

## Running locally

Requires nightly Rust and `cargo-fuzz`:

```sh
cargo install cargo-fuzz
```

From the repo root:

```sh
# Quick smoke (60 s per target, matches CI PR gate)
cargo +nightly fuzz run fuzz_aadl_parse -- -max_total_time=60
cargo +nightly fuzz run fuzz_scheduler_solver -- -max_total_time=60 -timeout=5
cargo +nightly fuzz run fuzz_codegen_roundtrip -- -max_total_time=60

# Extended (1 h per target, matches nightly cron job)
cargo +nightly fuzz run fuzz_aadl_parse -- -max_total_time=3600
```

Build-only (no execution, useful for CI caching):

```sh
cargo +nightly fuzz build
```

## Time budgets

| context | per-target wall time | notes |
|------------------------|----------------------|------------------------------------|
| local smoke / dev loop | 10-60 s | quick regression gate |
| CI `fuzz-smoke` (PR) | 60 s | `-max_total_time=60`, no corpus persist |
| CI `fuzz-nightly` | 3600 s (1 h) | cron daily 03:00 UTC, corpus uploaded as artifact |

The `fuzz_scheduler_solver` target passes `-timeout=5` so any single MILP
call that blocks for more than five seconds is reported as a hang — this
is the "non-termination" guard the issue body calls out.

## Corpus

Corpora live under `fuzz/corpus/<target>/` and are `.gitignore`d (libfuzzer
writes and mutates them at runtime). The nightly workflow uploads the
corpus directory as a build artifact so it can be reused across runs and
seeded into the criterion benchmark's worst-case input collection.

Seed inputs can be added by dropping files into `fuzz/corpus/<target>/`
before the run.
34 changes: 34 additions & 0 deletions fuzz/fuzz_targets/fuzz_aadl_parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#![no_main]
//! Fuzz target: feed arbitrary bytes to the AADL parser and assert it never
//! panics or hangs. Malformed input must be rejected cleanly via the error
//! recovery machinery — the parser has explicit recovery sets and this target
//! exercises those paths with adversarial input.
//!
//! Traceability: REQ-PARSE-001, REQ-PARSE-002, REQ-PARSER-001.

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
// The parser consumes `&str`, so reject non-UTF8 silently — libfuzzer
// will treat this as a trivial path, which is fine: the intent is
// "no panic, no hang on malformed input", not "every byte sequence
// reaches the parser".
let Ok(text) = std::str::from_utf8(data) else {
return;
};

// Bound input length so a single iteration can't stall the fuzzer
// on pathological O(n^k) grammar paths. `-timeout=5` on the libfuzzer
// side is the real backstop; this is a soft cap for throughput.
if text.len() > 64 * 1024 {
return;
}

// Call the lossless parser. `Parse` owns the green tree and error list;
// constructing a `SyntaxNode` and walking errors exercises the tree
// builder, which is the other half of the parser stack.
let parse = spar_syntax::parse(text);
let _ = parse.syntax_node();
let _ = parse.errors();
let _ = parse.ok();
});
Loading
Loading