mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
8ce3bd090b
Deep review of PR #1437 (ADR-273..282 unified RF spatial world model) plus hardware-in-the-loop testing against a live ESP32-C6 CSI node turned up several real defects, fixed here: - pretrain.rs: sample_mask panicked (usize::clamp(1, 0)) on any single-token window, reachable from a valid RfTensor via a perfectly normal tokenizer output. eval() now skips empty masks instead of averaging in NaN. - math.rs: resample_complex(x, 1) with x.len() > 1 divided by zero (m - 1 == 0), silently poisoning the output with NaN. Now returns the mean. - gaussian/map.rs: merge_overlapping had no entity-kind guard (unlike insert()), so an unlabeled Room-linked Gaussian and an unlabeled PersonClass-linked Gaussian within each other's merge gate would be silently conflated. Added the same same_kind check insert() uses. Also hardened decay()'s tau_eff against a post-construction decay_tau_s of 0 (NaN instead of merely-fast decay). - adapters.rs: WifiCsiAdapter used the frequency band's fixed per-band constant (e.g. 2437 MHz) instead of the frame's real channel, misreporting center_freq_hz for every channel except the one that happens to match the constant. Confirmed against a live ESP32-C6 node on channel 4: pre-fix would report 2437000000 Hz, post-fix correctly reports 2427000000 Hz, matching the hardware parser's independently-computed frequency exactly. Added examples/esp32_live_hardware_test.rs, a hardware-in-the-loop test that bridges real ADR-018 UDP captures through the adapter (also confirms no panic on real 256-subcarrier HE-SU frames, well beyond CANONICAL_BINS=56). - control.rs: admit_task didn't validate requested_resolution_m, maximum_latency_ms, or modalities, so a task with 0/NaN resolution, 0ms latency, or zero modalities passed admission. Added boundary checks. - control.rs + security_boundaries.rs: validate_representation's only test coverage (unit test and proptest) hardcoded SensingPurpose::Presence, leaving the other three purpose-ceiling branches (Activity/Localization at P3, Vitals/PoseTracking at P4, IdentityRecognition at P5 — the higher-risk representations) completely unverified. Added coverage for all branches in both. Also fixed pre-existing issues surfaced while validating the above: - wifi-densepose-core: 7 clippy warnings (cast_possible_truncation/ wrap, single_match_else, suboptimal_flops) in the canonical encode/decode path, now using try_from/from_le_bytes/mul_add. - wifi-densepose-hardware: a test missing #[cfg(unix)] that used std::os::unix::fs::PermissionsExt unconditionally, breaking Windows builds of ruview-auth's test suite; a manual Default impl clippy flagged as derivable; two tests using field-reassignment instead of struct-update syntax after ::default(). - wifi-densepose-sensing-server: auth_wiring.rs's free_port() / child-process bind race (documented as "mildly racy" by design) now retries up to 3x specifically on an AddrInUse-shaped failure, preserving the original fail-loud behavior for genuine wiring regressions. All touched crates re-verified: ruview-unified 99 tests (was 98), wifi-densepose-core 37+40, wifi-densepose-hardware 483+1(ignored), ruview-auth builds and tests on Windows, sensing-server auth_wiring 7/7. ruview-unified remains clippy-clean under -D warnings; the pre-existing dependency warnings that -D warnings surfaced are fixed too. Co-Authored-By: claude-flow <ruv@ruv.net>
296 lines
12 KiB
Rust
296 lines
12 KiB
Rust
//! Security property tests (ADR-273 pre-merge item 12): the crate's
|
||
//! system boundaries must never panic on hostile input and must stay
|
||
//! fail-closed under arbitrary authorization states.
|
||
//!
|
||
//! Strategy: proptest drives the validated constructors and policy gates
|
||
//! with arbitrary values (including NaN/±inf smuggled through
|
||
//! `f64::from_bits`) and asserts the *contract*, not specific values:
|
||
//! every input either yields a valid object or a typed error — never a
|
||
//! panic, and never a permissive default.
|
||
|
||
use proptest::prelude::*;
|
||
|
||
use ruview_unified::adapters::{ble_cs_range, BleCsFrame};
|
||
use ruview_unified::control::{
|
||
admit_task, validate_representation, CoherentSensorGroup, MemberSyncState, PrivacyClass,
|
||
SensingTask, SpatialZone, TaskSufficientRepresentation,
|
||
};
|
||
use ruview_unified::gaussian::primitive::{Provenance, RfGaussian};
|
||
use ruview_unified::policy::{
|
||
BoundedEvent, EventValue, PolicyEngine, PrivacyZone, SensingPurpose,
|
||
};
|
||
use ruview_unified::tensor::{CalibrationMeta, LinkGeometry, RfModality, RfTensor};
|
||
|
||
/// Arbitrary f64 including NaN, ±inf, subnormals — the values an attacker
|
||
/// or a broken driver would deliver.
|
||
fn any_f64() -> impl Strategy<Value = f64> {
|
||
any::<u64>().prop_map(f64::from_bits)
|
||
}
|
||
|
||
fn any_purpose() -> impl Strategy<Value = SensingPurpose> {
|
||
prop_oneof![
|
||
Just(SensingPurpose::Presence),
|
||
Just(SensingPurpose::Activity),
|
||
Just(SensingPurpose::Vitals),
|
||
Just(SensingPurpose::Localization),
|
||
Just(SensingPurpose::PoseTracking),
|
||
Just(SensingPurpose::IdentityRecognition),
|
||
Just(SensingPurpose::ChannelDiagnostics),
|
||
]
|
||
}
|
||
|
||
proptest! {
|
||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||
|
||
/// RfTensor::new never panics; invalid numeric fields are typed errors.
|
||
#[test]
|
||
fn rf_tensor_constructor_never_panics(
|
||
re in any_f64(),
|
||
im in any_f64(),
|
||
freq in any_f64(),
|
||
bw in any_f64(),
|
||
age in any_f64(),
|
||
clock in any_f64(),
|
||
unc in any_f64(),
|
||
tx in prop::array::uniform3(any_f64()),
|
||
) {
|
||
let data = ndarray::Array3::from_elem((1, 4, 2), num_complex::Complex64::new(re, im));
|
||
let links = vec![LinkGeometry { tx_pos: tx, rx_pos: [1.0, 0.0, 1.0] }];
|
||
let result = RfTensor::new(
|
||
RfModality::WifiCsi, freq, bw, data, links, age, 0, "prop".into(),
|
||
clock, unc, CalibrationMeta::default(),
|
||
);
|
||
// Contract: Ok only when every validated field is actually valid.
|
||
if let Ok(t) = result {
|
||
prop_assert!(t.center_freq_hz.is_finite() && t.center_freq_hz > 0.0);
|
||
prop_assert!((0.0..=1.0).contains(&t.clock_quality));
|
||
prop_assert!((0.0..=1.0).contains(&t.uncertainty));
|
||
prop_assert!(t.sample_age_s.is_finite() && t.sample_age_s >= 0.0);
|
||
prop_assert!(t.data.iter().all(|z| z.re.is_finite() && z.im.is_finite()));
|
||
}
|
||
}
|
||
|
||
/// RfGaussian::new never panics; accepted Gaussians have a normalized
|
||
/// quaternion and in-range trust fields.
|
||
#[test]
|
||
fn rf_gaussian_constructor_never_panics(
|
||
pos in prop::array::uniform3(any_f64()),
|
||
scale in prop::array::uniform3(any_f64()),
|
||
quat in prop::array::uniform4(any_f64()),
|
||
occ in any_f64(),
|
||
conf in any_f64(),
|
||
tau in any_f64(),
|
||
) {
|
||
let result = RfGaussian::new(
|
||
pos, scale, quat, occ, conf, 0, tau,
|
||
Provenance { device_id: "prop".into(), model_version: 1, synthetic: true },
|
||
);
|
||
if let Ok(g) = result {
|
||
let qn: f64 = g.orientation.iter().map(|q| q * q).sum::<f64>().sqrt();
|
||
prop_assert!((qn - 1.0).abs() < 1e-9, "quaternion must be normalized");
|
||
prop_assert!(g.scale.iter().all(|s| *s > 0.0));
|
||
prop_assert!(g.occupancy >= 0.0);
|
||
prop_assert!((0.0..=1.0).contains(&g.confidence));
|
||
// Density at the centre of a valid Gaussian is exactly 1.
|
||
prop_assert!((g.density_at(g.position) - 1.0).abs() < 1e-9);
|
||
}
|
||
}
|
||
|
||
/// BoundedEvent::new never panics; exported accountability fields are
|
||
/// always in range.
|
||
#[test]
|
||
fn bounded_event_constructor_never_panics(
|
||
uncertainty in any_f64(),
|
||
model_version in any::<u32>(),
|
||
value in any_f64(),
|
||
) {
|
||
let result = BoundedEvent::new(
|
||
SensingPurpose::Presence,
|
||
EventValue::RespirationBpm(value),
|
||
uncertainty,
|
||
Provenance { device_id: "prop".into(), model_version: 1, synthetic: false },
|
||
model_version,
|
||
0,
|
||
"zone",
|
||
);
|
||
if let Ok(e) = result {
|
||
prop_assert!((0.0..=1.0).contains(&e.uncertainty));
|
||
prop_assert!(e.model_version != 0, "unassigned model version must never export");
|
||
}
|
||
}
|
||
|
||
/// ble_cs_range never panics on arbitrary phases/frequencies/RTT, and
|
||
/// any Ok evidence has a finite, non-negative distance.
|
||
#[test]
|
||
fn ble_cs_range_never_panics(
|
||
phases in prop::collection::vec(any_f64(), 0..24),
|
||
f0 in any_f64(),
|
||
df in any_f64(),
|
||
rtt in prop::option::of(any_f64()),
|
||
) {
|
||
let n = phases.len();
|
||
let frame = BleCsFrame {
|
||
frequency_steps_hz: (0..n).map(|k| f0 + df * k as f64).collect(),
|
||
phase_samples_rad: phases,
|
||
round_trip_time_ns: rtt,
|
||
};
|
||
if let Ok(ev) = ble_cs_range(&frame) {
|
||
// Non-finite inputs are rejected at the boundary (the unwrap
|
||
// loop would otherwise spin forever on +inf — the DoS this
|
||
// suite originally caught), so Ok evidence is fully finite.
|
||
prop_assert!(ev.phase_distance_m.is_finite() && ev.phase_distance_m >= 0.0);
|
||
prop_assert!((0.0..=1.0).contains(&ev.confidence));
|
||
if let Some(d) = ev.rtt_distance_m {
|
||
prop_assert!(d.is_finite());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The policy engine is fail-closed for every purpose against every
|
||
/// zone configuration that does not explicitly grant it.
|
||
#[test]
|
||
fn policy_engine_is_fail_closed_under_arbitrary_grants(
|
||
purpose in any_purpose(),
|
||
granted in prop::collection::btree_set(any_purpose(), 0..7),
|
||
identity_enabled in any::<bool>(),
|
||
query_unknown_zone in any::<bool>(),
|
||
) {
|
||
let mut engine = PolicyEngine::new();
|
||
engine.upsert_zone(PrivacyZone {
|
||
id: "z".into(),
|
||
allowed_purposes: granted.clone(),
|
||
retention_s: 60,
|
||
identity_explicitly_enabled: identity_enabled,
|
||
});
|
||
let zone_id = if query_unknown_zone { "nope" } else { "z" };
|
||
let verdict = engine.authorize(zone_id, purpose);
|
||
if query_unknown_zone {
|
||
prop_assert!(verdict.is_err(), "unknown zone must always deny");
|
||
} else if !granted.contains(&purpose) {
|
||
prop_assert!(verdict.is_err(), "ungranted purpose must deny");
|
||
} else if purpose == SensingPurpose::IdentityRecognition && !identity_enabled {
|
||
prop_assert!(verdict.is_err(), "identity single-gate must deny");
|
||
} else {
|
||
prop_assert!(verdict.is_ok());
|
||
}
|
||
}
|
||
|
||
/// Task admission can never approve raw export, whatever else is true.
|
||
#[test]
|
||
fn raw_export_is_unreachable(
|
||
purpose in any_purpose(),
|
||
raw in any::<bool>(),
|
||
confidence in any_f64(),
|
||
) {
|
||
let mut engine = PolicyEngine::new();
|
||
engine.upsert_zone(PrivacyZone {
|
||
id: "z".into(),
|
||
allowed_purposes: [purpose].into_iter().collect(),
|
||
retention_s: 60,
|
||
identity_explicitly_enabled: true,
|
||
});
|
||
let task = SensingTask {
|
||
task_id: 1,
|
||
purpose,
|
||
target_area: SpatialZone { id: "z".into(), min_m: [0.0; 3], max_m: [1.0; 3] },
|
||
modalities: vec![RfModality::WifiCsi],
|
||
requested_resolution_m: 0.5,
|
||
maximum_latency_ms: 100,
|
||
minimum_confidence: confidence,
|
||
raw_retention_seconds: 60,
|
||
result_retention_seconds: 60,
|
||
authorized_consumers: vec![],
|
||
consent_reference: Some("consent-1".into()),
|
||
raw_export_allowed: raw,
|
||
};
|
||
let verdict = admit_task(&engine, &task);
|
||
if raw {
|
||
prop_assert!(verdict.is_err(), "raw export must be structurally unreachable");
|
||
}
|
||
}
|
||
|
||
/// Coherent fusion denies whenever any reported error is non-finite or
|
||
/// out of bounds — NaN cannot sneak past the gate.
|
||
#[test]
|
||
fn coherent_fusion_rejects_non_finite_sync_state(
|
||
time_err in any_f64(),
|
||
phase_err in any_f64(),
|
||
hash in any::<u64>(),
|
||
) {
|
||
let group = CoherentSensorGroup {
|
||
group_id: "g".into(),
|
||
members: vec!["m".into()],
|
||
maximum_time_error_ns: 50.0,
|
||
maximum_phase_error_rad: 0.2,
|
||
baseline_geometry_hash: 7,
|
||
};
|
||
let verdict = group.can_fuse(&[MemberSyncState {
|
||
member_id: "m".into(),
|
||
time_error_ns: time_err,
|
||
phase_error_rad: phase_err,
|
||
geometry_hash: hash,
|
||
}]);
|
||
let in_bounds = time_err.is_finite()
|
||
&& time_err.abs() <= 50.0
|
||
&& phase_err.is_finite()
|
||
&& phase_err.abs() <= 0.2
|
||
&& hash == 7;
|
||
prop_assert_eq!(verdict.is_ok(), in_bounds, "NaN/inf must deny, bounds must bind");
|
||
}
|
||
|
||
/// Representation validation never approves an identity-retaining
|
||
/// occupancy representation, whatever the other fields say — checked
|
||
/// across *every* `SensingPurpose` branch (each has a distinct ceiling
|
||
/// and exclusion set in `validate_representation`; testing only
|
||
/// `Presence`, as this property previously did, leaves the other three
|
||
/// branches — covering P3–P5, the higher-risk representations —
|
||
/// completely unverified).
|
||
#[test]
|
||
fn occupancy_representations_must_exclude_identity(
|
||
purpose in any_purpose(),
|
||
class in prop_oneof![
|
||
Just(PrivacyClass::P0), Just(PrivacyClass::P1), Just(PrivacyClass::P2),
|
||
Just(PrivacyClass::P3), Just(PrivacyClass::P4), Just(PrivacyClass::P5)
|
||
],
|
||
excluded in prop::collection::vec("[a-z]{1,10}", 0..4),
|
||
bits in any_f64(),
|
||
) {
|
||
let rep = TaskSufficientRepresentation {
|
||
task_id: 1,
|
||
source_receipts: vec![1],
|
||
semantic_state: vec![0.5],
|
||
information_bound_bits: bits,
|
||
excluded_information: excluded.clone(),
|
||
privacy_class: class,
|
||
};
|
||
let verdict = validate_representation(&rep, purpose);
|
||
// Oracle mirrors the (ceiling, must_exclude) table in
|
||
// `control::validate_representation` so this property checks the
|
||
// *contract* per purpose group, not just the Presence case.
|
||
let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose {
|
||
SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => {
|
||
(PrivacyClass::P2, &["identity", "vitals"])
|
||
}
|
||
SensingPurpose::Activity | SensingPurpose::Localization => {
|
||
(PrivacyClass::P3, &["identity"])
|
||
}
|
||
SensingPurpose::Vitals | SensingPurpose::PoseTracking => {
|
||
(PrivacyClass::P4, &["identity"])
|
||
}
|
||
SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]),
|
||
};
|
||
let excludes_required =
|
||
must_exclude.iter().all(|c| excluded.iter().any(|e| e == c));
|
||
if verdict.is_ok() {
|
||
prop_assert!(
|
||
excludes_required,
|
||
"approved rep for {:?} must exclude {:?}", purpose, must_exclude
|
||
);
|
||
prop_assert!(
|
||
class <= ceiling,
|
||
"approved rep for {:?} must respect ceiling {:?}", purpose, ceiling
|
||
);
|
||
}
|
||
}
|
||
}
|