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_bankasserts 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_kernelasserts the order-parameter magnitude stays finite and in[0, 1]and thatwrap_phaselands 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:
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 — fuzzing is a robustness gate,
not a performance or correctness certification.
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_banktarget,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 Rustmatrix_expscaling-and-squaring routine the squaring exponentlog2(norm).ceil() as u32then saturated+inftou32::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 throughscipy.linalg.expm. Covered by Rust regression tests inh_infinity.rsandcapacitor_bank.rsand 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_geqdskover arbitrary bytes and structured documents returns a shape-consistentGEqdskor raises the typedGEqdskFormatError— never a bareUnicodeDecodeError,OverflowError,IndexError, or an unbounded allocation;build_disruption_feature_vectorover any signal/observable pair returns a finite feature vector of the contract length or raisesValueError— 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.