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. 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_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.