mirror of
https://github.com/ruvnet/RuView
synced 2026-06-16 11:23:19 +00:00
fix(nvsim): guard degenerate input — config-induced panic + NaN-state poisoning
Beyond-SOTA security review of the ADR-089 NV-diamond simulator (milestone #9, crate 2 of 4). Two real degenerate-input findings, each pinned fails-on-old: NVSIM-DT-01 (config panic/DoS, pipeline.rs): an external f_s_hz == 0 made dt == +Inf, dt_us saturated to u64::MAX, and `sample * dt_us` panicked with "attempt to multiply with overflow" at sample >= 2 (debug/WASM panic=abort; garbage t_us in release). Fix: sanitise dt (non-finite/non-positive -> 1 µs fallback), cap the u64 cast, and saturating_mul the timestamp. NVSIM-NAN-01 (NaN-state poisoning, digitiser.rs): a non-finite scene parameter (NaN dipole position / Inf moment / NaN loop radius) bypasses the near-field clamp (NaN < R_MIN_M is false) and yields a NaN field; at the ADC `NaN as i32` == 0 silently emitted b_pt=[0,0,0] with ADC_SATURATED CLEAR — indistinguishable from a legit zero-field reading. Fix at the funnel: adc_quantise treats any non-finite input as out-of-range -> clamps to code 0 AND raises the saturation flag, so the corruption is visible downstream. Determinism integrity, panic-free MagFrame deserialisation, and RNG seeding confirmed clean with evidence. The published cross-machine witness (cc8de9b0…93b4) is unchanged — guards only affect degenerate inputs. cargo test -p nvsim --no-default-features: 50 -> 53 passed, 0 failed. Workspace green; Python deterministic proof unchanged (f8e76f21…46f7a, nvsim off the signal proof path). Needs ADR slot 177. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -39,7 +39,20 @@ pub const DEFAULT_SAMPLE_RATE_HZ: f64 = 10_000.0;
|
||||
pub const DEFAULT_F_MOD_HZ: f64 = 1_000.0;
|
||||
|
||||
/// Quantise one input sample (T) to a signed ADC code. Returns `(code, saturated)`.
|
||||
///
|
||||
/// A **non-finite** input (`NaN` / `±Inf`) is treated as an out-of-range
|
||||
/// condition: it clamps to code `0` and raises the saturation flag. This is
|
||||
/// the funnel point that stops the NaN-state-poisoning class — a non-finite
|
||||
/// physical field (e.g. produced by a degenerate scene with a NaN dipole
|
||||
/// position) would otherwise coerce silently to code `0` *with the saturation
|
||||
/// flag clear*, yielding a frame indistinguishable from a legitimate
|
||||
/// zero-field reading. Flagging it preserves the "every frame is honest about
|
||||
/// its own validity" contract the proof bundle relies on.
|
||||
pub fn adc_quantise(b_in_t: f64) -> (i32, bool) {
|
||||
if !b_in_t.is_finite() {
|
||||
// Non-finite => not representable on the ±FS scale; mark saturated.
|
||||
return (0, true);
|
||||
}
|
||||
let code_f = (b_in_t / ADC_LSB_T).round();
|
||||
let max_code = (1_i32 << (ADC_BITS - 1)) - 1; // 32_767 for 16-bit signed
|
||||
let min_code = -max_code; // symmetric
|
||||
@@ -153,6 +166,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_quantise_flags_non_finite_as_saturated() {
|
||||
// Security pinning (NaN-state-poisoning guard): a non-finite field
|
||||
// value must clamp to code 0 AND raise the saturation flag, so the
|
||||
// pipeline can flag the frame rather than emitting it as a silent,
|
||||
// indistinguishable zero-field reading. Pre-fix this returned
|
||||
// (0, false) for NaN — a silent corruption.
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let (code, sat) = adc_quantise(bad);
|
||||
assert_eq!(code, 0, "non-finite input {bad} must clamp to code 0");
|
||||
assert!(sat, "non-finite input {bad} must raise the saturation flag");
|
||||
}
|
||||
// A finite in-range value is unaffected (no false positives).
|
||||
let (_, sat) = adc_quantise(1.0e-7);
|
||||
assert!(!sat, "a finite in-range value must NOT be flagged saturated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_saturates_above_full_scale() {
|
||||
let (code_pos, sat_pos) = adc_quantise(20.0e-6);
|
||||
|
||||
@@ -51,11 +51,28 @@ impl Pipeline {
|
||||
/// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
|
||||
/// in scene-major / sample-minor order.
|
||||
pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
|
||||
let dt = self
|
||||
// `dt` is derived from caller-supplied config — an external boundary
|
||||
// (e.g. the WASM `config_json`). A degenerate `f_s_hz == 0` makes
|
||||
// `1.0 / f_s_hz == +Inf`; a non-finite or non-positive `dt_s` is
|
||||
// equally hostile. Sanitise before any arithmetic that could panic.
|
||||
let raw_dt = self
|
||||
.config
|
||||
.dt_s
|
||||
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
|
||||
let dt_us = (dt * 1.0e6) as u64;
|
||||
// Fall back to a 1 µs step (the smallest physically meaningful
|
||||
// sample interval here) when `dt` is non-finite or non-positive, so
|
||||
// the run produces well-defined frames instead of garbage / a panic.
|
||||
let dt = if raw_dt.is_finite() && raw_dt > 0.0 {
|
||||
raw_dt
|
||||
} else {
|
||||
1.0e-6
|
||||
};
|
||||
// `dt` is now finite & positive, so `dt * 1e6` is finite. Cap the
|
||||
// `u64` cast defensively (a huge but finite `dt` could still exceed
|
||||
// `u64::MAX`) and use `saturating_mul` for the per-sample timestamp so
|
||||
// a pathological config can never trigger a multiply-with-overflow
|
||||
// panic (debug / WASM panic=abort) or wrap to a garbage timestamp.
|
||||
let dt_us = (dt * 1.0e6).min(u64::MAX as f64) as u64;
|
||||
let nv = NvSensor::new(self.config.sensor);
|
||||
|
||||
let mut out: Vec<MagFrame> =
|
||||
@@ -92,7 +109,7 @@ impl Pipeline {
|
||||
];
|
||||
|
||||
let mut frame = MagFrame::empty(sensor_idx as u16);
|
||||
frame.t_us = (sample as u64) * dt_us;
|
||||
frame.t_us = (sample as u64).saturating_mul(dt_us);
|
||||
frame.b_pt = b_pt;
|
||||
frame.sigma_pt = sigma_pt;
|
||||
frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
|
||||
@@ -205,6 +222,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_zero_sample_rate_does_not_panic() {
|
||||
// Security pinning (panic / DoS guard): an externally-supplied
|
||||
// `f_s_hz == 0` makes `1/f_s_hz == +Inf`; pre-fix that produced
|
||||
// `dt_us == u64::MAX`, and `sample * dt_us` panicked with
|
||||
// "attempt to multiply with overflow" (debug / WASM panic=abort) at
|
||||
// sample >= 2, or wrapped to a garbage timestamp in release. The
|
||||
// sanitised `dt` + `saturating_mul` must keep the run finite.
|
||||
let scene = fixture_scene();
|
||||
let cfg = PipelineConfig {
|
||||
digitiser: crate::digitiser::DigitiserConfig {
|
||||
f_s_hz: 0.0,
|
||||
f_mod_hz: 1000.0,
|
||||
},
|
||||
..PipelineConfig::default()
|
||||
};
|
||||
let frames = Pipeline::new(scene, cfg, 42).run(8);
|
||||
assert_eq!(frames.len(), 8);
|
||||
for f in &frames {
|
||||
// Timestamps are monotone-well-defined, not garbage.
|
||||
assert!(f.t_us < u64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_scene_input_flags_frame_instead_of_silently_zeroing() {
|
||||
// Security pinning (NaN-state-poisoning guard): a NaN dipole position
|
||||
// makes `r_norm` NaN, which bypasses the near-field clamp
|
||||
// (`NaN < R_MIN_M` is false) and yields a NaN field. Pre-fix the
|
||||
// digitiser silently coerced that NaN to code 0 with the saturation
|
||||
// flag CLEAR — a frame indistinguishable from a real zero-field
|
||||
// reading. Post-fix the frame must carry ADC_SATURATED so the
|
||||
// corruption is visible downstream.
|
||||
let mut scene = Scene::new();
|
||||
scene.add_dipole(DipoleSource::new([f64::NAN, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
|
||||
scene.add_sensor([0.0, 0.0, 0.0]);
|
||||
let cfg = PipelineConfig {
|
||||
sensor: NvSensorConfig {
|
||||
shot_noise_disabled: true,
|
||||
..NvSensorConfig::default()
|
||||
},
|
||||
..PipelineConfig::default()
|
||||
};
|
||||
let frames = Pipeline::new(scene, cfg, 0).run(4);
|
||||
for f in &frames {
|
||||
assert!(
|
||||
f.has_flag(flag::ADC_SATURATED),
|
||||
"non-finite field must raise ADC_SATURATED, not emit a silent zero frame"
|
||||
);
|
||||
// And the emitted value is a defined number, not NaN.
|
||||
for b in f.b_pt {
|
||||
assert!(b.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_saturation_flag_fires_above_full_scale() {
|
||||
// Place a dipole close enough to drive the field above ±10 µT FS.
|
||||
|
||||
Reference in New Issue
Block a user