diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fc9552..82db0c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/.github/workflows/fuzz-nightly.yml b/.github/workflows/fuzz-nightly.yml new file mode 100644 index 0000000..9b78617 --- /dev/null +++ b/.github/workflows/fuzz-nightly.yml @@ -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 diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index df0d744..40cb9ca 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -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 diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..896fce1 --- /dev/null +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..7fd2e68 --- /dev/null +++ b/fuzz/README.md @@ -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//` 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//` +before the run. diff --git a/fuzz/fuzz_targets/fuzz_aadl_parse.rs b/fuzz/fuzz_targets/fuzz_aadl_parse.rs new file mode 100644 index 0000000..4640663 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_aadl_parse.rs @@ -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(); +}); diff --git a/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs b/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs new file mode 100644 index 0000000..814385a --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs @@ -0,0 +1,84 @@ +#![no_main] +//! Fuzz target: deterministic AADL → instance model → codegen roundtrip. +//! +//! Per issue #138, this target does *not* derive the `SystemInstance` from +//! arbitrary bytes (the HIR construction surface is too deep and most +//! inputs would be rejected upstream). Instead it feeds a known-good AADL +//! fixture through `SystemInstance::instantiate` and varies the +//! `CodegenConfig` flags from the fuzzer input. The contract: `generate()` +//! must not panic on any reachable configuration combination. +//! +//! Traceability: REQ-CODEGEN-001, REQ-CODEGEN-WIT, REQ-CODEGEN-RUST. + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +use spar_codegen::{CodegenConfig, OutputFormat, VerifyMode, generate}; +use spar_hir_def::instance::SystemInstance; +use spar_hir_def::name::Name; +use spar_hir_def::resolver::GlobalScope; + +/// Seed AADL model — same file the golden codegen tests use. Compiled in +/// so the fuzz binary is hermetic. +const SEED_AADL: &str = include_str!("../../test-data/codegen/building_control.aadl"); + +#[derive(Arbitrary, Debug)] +struct Knobs { + format_pick: u8, + verify_pick: u8, + rivet: bool, + dry_run: bool, +} + +fn build_seed_instance() -> SystemInstance { + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new( + &db, + "fuzz.aadl".to_string(), + SEED_AADL.to_string(), + ); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + SystemInstance::instantiate( + &scope, + &Name::new("BuildingControl"), + &Name::new("BuildingSystem"), + &Name::new("impl"), + ) +} + +fuzz_target!(|knobs: Knobs| { + let inst = build_seed_instance(); + + let format = match knobs.format_pick % 3 { + 0 => OutputFormat::Rust, + 1 => OutputFormat::Wit, + _ => OutputFormat::Both, + }; + + let verify = match knobs.verify_pick % 5 { + 0 => None, + 1 => Some(VerifyMode::All), + 2 => Some(VerifyMode::Build), + 3 => Some(VerifyMode::Test), + _ => Some(VerifyMode::Proof), + }; + + let config = CodegenConfig { + root_name: "fuzz_root".to_string(), + output_dir: "out".to_string(), + format, + verify, + rivet: knobs.rivet, + dry_run: knobs.dry_run, + }; + + // Contract: no panic on any config combination, even though inputs are + // identical for the instance model. + let out = generate(&inst, &config); + // Touch every file path + content length so a latent panic in formatting + // code would fire here rather than being dead-code-eliminated. + for f in &out.files { + std::hint::black_box((f.path.len(), f.content.len())); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_scheduler_solver.rs b/fuzz/fuzz_targets/fuzz_scheduler_solver.rs new file mode 100644 index 0000000..320fdf4 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_scheduler_solver.rs @@ -0,0 +1,123 @@ +#![no_main] +//! Fuzz target: adversarial task sets for the MILP scheduler/allocator. +//! +//! We derive a small `TaskSet` via `arbitrary::Arbitrary` (≤8 tasks, ≤4 +//! processors, small integers for period/wcet/priority), build a +//! `ModelConstraints` the same way the unit tests do (dummy arena idx), and +//! call `solve_milp`. The contract is: the call must return `Ok` or `Err` — +//! it must never panic, and the outer libfuzzer `-timeout` backstop catches +//! hangs. +//! +//! Traceability: REQ-SOLVER-001, REQ-SOLVER-003, REQ-SOLVER-005. + +use arbitrary::Arbitrary; +use la_arena::Arena; +use libfuzzer_sys::fuzz_target; + +use spar_hir_def::instance::{ComponentInstance, ComponentInstanceIdx}; +use spar_hir_def::item_tree::ComponentCategory; +use spar_hir_def::name::Name; +use spar_solver::constraints::{ModelConstraints, ProcessorConstraint, ThreadConstraint}; +use spar_solver::milp::solve_milp; + +/// Bounded task description. Small integer ranges keep the MILP search space +/// tractable inside a 10-second libfuzzer slice. +#[derive(Arbitrary, Debug)] +struct Task { + /// Period in picoseconds — clamped to [0, 65535] via `u16` at the wire. + period: u16, + /// WCET in picoseconds — clamped to [0, 65535] via `u16` at the wire. + wcet: u16, + /// Optional deadline; if None, defaults to period. + deadline: Option, + /// Optional priority (not read by solve_milp but exercises the struct path). + priority: Option, + /// Optional existing binding: if Some(n), bind to processor index n % len. + bind_to: Option, +} + +/// Bounded processor description. +#[derive(Arbitrary, Debug)] +struct Processor { + memory_bytes: Option, +} + +#[derive(Arbitrary, Debug)] +struct TaskSet { + tasks: Vec, + processors: Vec, +} + +/// Mint a throwaway `ComponentInstanceIdx` via a local arena. The solver +/// reads `name`, `period_ps`, `wcet_ps`, `deadline_ps`, `current_binding`, +/// `priority` — but never dereferences `idx` — so a dummy index is safe. +fn dummy_idx() -> ComponentInstanceIdx { + let mut arena: Arena = Arena::new(); + arena.alloc(ComponentInstance { + name: Name::new("dummy"), + category: ComponentCategory::Thread, + type_name: Name::new("Dummy"), + impl_name: None, + package: Name::new("Test"), + parent: None, + children: Vec::new(), + features: Vec::new(), + connections: Vec::new(), + flows: Vec::new(), + modes: Vec::new(), + mode_transitions: Vec::new(), + array_index: None, + in_modes: Vec::new(), + }) +} + +fuzz_target!(|input: TaskSet| { + // Cap sizes. The Arbitrary Vecs are already short in practice but we + // enforce explicit bounds so the time budget per iteration stays small. + let n_tasks = input.tasks.len().min(8); + let n_procs = input.processors.len().min(4); + + let processors: Vec = (0..n_procs) + .map(|j| ProcessorConstraint { + idx: dummy_idx(), + name: format!("cpu{j}"), + memory_bytes: input.processors[j].memory_bytes.map(u64::from), + }) + .collect(); + + let threads: Vec = (0..n_tasks) + .map(|i| { + let t = &input.tasks[i]; + let period_ps = u64::from(t.period); + let wcet_ps = u64::from(t.wcet); + let deadline_ps = t.deadline.map(u64::from).unwrap_or(period_ps); + let current_binding = t.bind_to.and_then(|b| { + if processors.is_empty() { + None + } else { + let j = (b as usize) % processors.len(); + Some(processors[j].name.clone()) + } + }); + ThreadConstraint { + idx: dummy_idx(), + name: format!("t{i}"), + period_ps, + wcet_ps, + deadline_ps, + current_binding, + priority: t.priority.map(u64::from), + } + }) + .collect(); + + let constraints = ModelConstraints { + threads, + processors, + warnings: Vec::new(), + }; + + // The only invariant we check here is: no panic. Both Ok and Err are + // acceptable outcomes — infeasible task sets are legitimate inputs. + let _ = solve_milp(&constraints); +});