Skip to content

Fuzzing

Two complementary surfaces harden the untrusted-input paths. The Rust crates are driven by coverage-guided cargo-fuzz (a time-boxed nightly campaign); the pure Python parsers are driven by in-suite Hypothesis property tests that run on every commit (see Python parser property fuzzing).

Rust surfaces

The Rust crates under scpn-control-rs accept two kinds of input that a unit test does not fully cover: untrusted text/byte streams (configuration files, external solver output) and high-volume numeric arrays that cross the PyO3 FFI boundary. The scpn-control-rs/fuzz package drives those surfaces with cargo-fuzz (libFuzzer + nightly AddressSanitizer) so that malformed, adversarial, or extreme-but-finite input is exercised for real rather than assumed safe.

Fuzzing is not a per-commit blocker. A fast cargo fuzz build smoke check keeps the targets compiling; the time-boxed campaign runs on the nightly Fuzz nightly workflow (and on demand) and fails closed on any crash, timeout, leak, or out-of-memory artefact.

Targets and surfaces

Target Surface class Code under test
config_json parser ReactorConfig JSON deserialisation (control-types)
vmec_import parser import_vmec_like_text VMEC-like boundary text (control-core)
bout_stability parser parse_bout_stability BOUT++ output (control-core)
capacitor_bank safety-critical numeric adapter / FFI series-RLC discharge ledger (control-control)
kuramoto_kernel vector kernel / FFI Kuramoto-Sakaguchi phase step (control-math)

The capacitor_bank and kuramoto_kernel targets exercise the same Rust code reached through the PyO3 PyCapacitorBankModel and Kuramoto bindings, so the FFI numeric boundary is covered without driving a Python interpreter inside the fuzzer.

Each target asserts the invariant appropriate to its surface:

  • the parser targets require that malformed input returns an error and that any accepted value survives a serialise/export round-trip — never a panic;
  • capacitor_bank asserts that an admitted energy balance (energy_balance_passed) carries only finite, physically-ordered quantities, so a passed admission can never smuggle a NaN/Inf ledger or a negative ohmic dissipation;
  • kuramoto_kernel asserts the order-parameter magnitude stays finite and in [0, 1] and that wrap_phase lands in (-π, π] for finite phases.

Running a campaign

The campaign is driven by tools/run_fuzz_campaign.py, which copies the tracked seed corpus into the working corpus, records provenance, runs each target for a bounded wall-clock time, collects any reproducer artefacts, and fails closed:

# Build-only smoke check (fast):
python tools/run_fuzz_campaign.py --build-only

# Full campaign with evidence report (default 300 s per target):
python tools/run_fuzz_campaign.py \
  --max-total-time 300 \
  --json-out artifacts/fuzz/fuzz_campaign_report.json \
  --markdown-out artifacts/fuzz/fuzz_campaign_report.md

A single target can also be driven directly:

cd scpn-control-rs
cargo +nightly fuzz run capacitor_bank -- -max_total_time=60 -timeout=10

Evidence and triage

Every campaign emits a JSON evidence document (scpn-control.fuzz-campaign-evidence.v1) that binds the run to its inputs and environment: the nightly Rust toolchain, cargo-fuzz version, target triple, sanitiser configuration, per-target executed-unit counts and duration, the SHA-256 of every seed (with a per-target aggregate digest), and the list of any reproducer artefacts. The embedded triage verdict admits a campaign only when every requested target ran and no target crashed, timed out, leaked, or produced an artefact; a missing target is itself a fail-closed failure. The report carries production_claim_allowed: false. Triage requires a nonempty, unique selection of registered targets and exactly one corresponding record per target. Every record needs a positive nonboolean executed-unit count, positive finite duration, nonnegative integer rate/RSS, the correct surface label, zero exit and no reproducer. Empty selections, duplicate or extra records and missing execution statistics fail even when the native exit is zero.

The manifest binds the tracked seed files. Existing working-corpus names and additional evolved inputs are retained; their complete bytes are not bound by that manifest. Campaign version commands must succeed with nonempty output and exactly one nonempty Rust host line. Unavailable metadata refuses before the native build; build-only still requests just a build. These observations do not authenticate binary/source identity or the dated hosted toolchain pin. Report digests bind submitted JSON, and triage validates submitted records; these checks do not certify scientific correctness or production readiness.

CLI target selection and positive --max-total-time / --rss-limit-mb limits are checked before Cargo, including in build-only mode. Compilation is outside the libFuzzer time limit. Explicit report paths resolve from the caller's working directory, create missing parents and overwrite existing destinations. See the campaign API contract for record validation and the limits of the observations.

Reproducer artefacts (crash-*, leak-*, timeout-*, oom-*, slow-unit-*) are written under scpn-control-rs/fuzz/artifacts/<target>/ and uploaded by the nightly workflow. A confirmed reproducer is copied into scpn-control-rs/fuzz/seeds/<target>/ as a tracked regression seed so it runs on every subsequent campaign.

Seed corpus

Tracked seeds live in scpn-control-rs/fuzz/seeds/<target>/; the working corpus (fuzz/corpus/), build output (fuzz/target/), and artefacts (fuzz/artifacts/) are git-ignored. scpn-control-rs/fuzz/seeds/SHA256SUMS records the seed digests so a reviewer can verify the corpus without running the campaign.

Recorded findings

  • Capacitor-bank discharge denial-of-service (capacitor_bank target, timeout-be2a810f…). A denormal capacitance combined with a ~1e103 H/Ω inductance and resistance overflowed the assembled Van Loan gramian matrix norm to +inf. In the Rust matrix_exp scaling-and-squaring routine the squaring exponent log2(norm).ceil() as u32 then saturated +inf to u32::MAX, turning the squaring loop into ~4.3×10⁹ matrix multiplies — a multi-minute hang on a single input. Fixed by failing closed on a non-finite norm (no scaling, prompt non-finite result that callers reject) and clamping the squaring exponent; the Python path already failed closed through scipy.linalg.expm. Covered by Rust regression tests in h_infinity.rs and capacitor_bank.rs and by the tracked regression seed.

Python parser property fuzzing

Two pure-Python paths consume attacker-suppliable input with no Rust equivalent: the G-EQDSK reader (scpn_control.core.eqdsk.read_geqdsk, which is globbed over reference directories during shot validation) and the disruption feature-builder (scpn_control.control.disruption_predictor.build_disruption_feature_vector, which reduces a raw diagnostic signal array). tests/test_untrusted_parser_fuzz_properties.py drives both with Hypothesis so a large randomised input space — arbitrary bytes, near-valid documents with exponent-overflow and truncated numeric bodies, and finite/non-finite signal arrays — exercises the deep code paths on every run.

Each property asserts the surface's fail-closed contract:

  • read_geqdsk over arbitrary bytes and structured documents returns a shape-consistent GEqdsk or raises the typed GEqdskFormatError — never a bare UnicodeDecodeError, OverflowError, IndexError, or an unbounded allocation;
  • build_disruption_feature_vector over any signal/observable pair returns a finite feature vector of the contract length or raises ValueError — never a non-finite vector.

The deterministic rejection paths (empty file, oversized declared grid, non-integer or non-finite counts, non-UTF-8 bytes, truncated blocks) are pinned individually in tests/test_geqdsk_malicious_input.py; the property tests widen the net to the unknown ones.

A continuous, coverage-guided atheris (libFuzzer) target over the same Python parsers is a deferred infrastructure decision — it would add an atheris build dependency and a dedicated fuzz-CI job. The in-suite property tests need no new dependency and close the reachable threat model deterministically; the atheris target is a later robustness increment, not a prerequisite.