fix(ruview-unified): panics, NaN corruption, entity-conflation, and wrong center-freq found in review

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>
This commit is contained in:
ruv
2026-07-26 17:08:27 -04:00
parent 42485495ed
commit 8ce3bd090b
15 changed files with 389 additions and 73 deletions
Generated
+1
View File
@@ -7688,6 +7688,7 @@ dependencies = [
"serde",
"thiserror 2.0.18",
"wifi-densepose-core",
"wifi-densepose-hardware",
]
[[package]]
+1
View File
@@ -588,6 +588,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_temp_file_is_never_world_readable_even_for_an_instant() {
// The test above checks the FINAL file. It passed while `save` wrote via
+5
View File
@@ -31,6 +31,11 @@ rand_chacha = { version = "0.3", default-features = false }
[dev-dependencies]
criterion = { workspace = true }
proptest = { workspace = true }
# Dev-only: bridges real ESP32 ADR-018 UDP captures (wifi-densepose-hardware's
# already-proven Esp32CsiParser) into wifi_densepose_core::CsiFrame for the
# `esp32_live_hardware_test` example. Does not affect the published dependency
# graph — the crate's real dependency stays thin-dependency (see above).
wifi-densepose-hardware = { workspace = true }
[[bench]]
name = "unified_bench"
@@ -0,0 +1,155 @@
//! Hardware-in-the-loop test: feeds REAL ADR-018 CSI frames from a live
//! ESP32 node through `WifiCsiAdapter`, not synthetic data.
//!
//! The PR that introduced this crate is explicit that every reported number
//! is generator-produced (L0) and real-data validation (P2) is future work.
//! This example closes part of that gap for the one adapter that matters
//! most: it binds the real ADR-018 UDP port, parses live packets with the
//! already-proven `wifi_densepose_hardware::Esp32CsiParser` (the same parser
//! `aggregator`/`sensing-server` use in production), converts each frame into
//! the exact `wifi_densepose_core::types::CsiFrame` the adapter expects, and
//! runs it through `AdapterRegistry::normalize`.
//!
//! Usage: `cargo run -p ruview-unified --example esp32_live_hardware_test --
//! --bind 0.0.0.0:5005 --frames 8`
//! (point a live ESP32 CSI node's UDP target at this host's IP:5005 first).
use std::net::UdpSocket;
use std::time::{SystemTime, UNIX_EPOCH};
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame as CoreCsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_hardware::{Esp32CsiParser, ParseError};
use ruview_unified::adapters::{AdapterRegistry, RawCapture};
use ruview_unified::tensor::LinkGeometry;
/// Inverse of `ruview_unified::adapters`'s (now-fixed) channel->frequency
/// map: recovers the 802.11 channel number from the real per-frame
/// `channel_freq_mhz` the hardware parser already computes correctly. Used
/// here only to populate `CsiMetadata::channel` for the adapter under test —
/// exercising the fix end-to-end against a real, independently-computed
/// frequency instead of a value this same test invented.
fn freq_mhz_to_band_and_channel(freq_mhz: u32) -> (FrequencyBand, u8) {
if freq_mhz == 2484 {
(FrequencyBand::Band2_4GHz, 14)
} else if (2412..=2472).contains(&freq_mhz) {
(FrequencyBand::Band2_4GHz, ((freq_mhz - 2407) / 5) as u8)
} else if (5000..6000).contains(&freq_mhz) {
(FrequencyBand::Band5GHz, ((freq_mhz - 5000) / 5) as u8)
} else {
(FrequencyBand::Band6GHz, ((freq_mhz.saturating_sub(5950)) / 5) as u8)
}
}
fn to_core_frame(hw: wifi_densepose_hardware::CsiFrame) -> CoreCsiFrame {
let (band, channel) = freq_mhz_to_band_and_channel(hw.metadata.channel_freq_mhz);
let mut meta = CsiMetadata::new(DeviceId::new(format!("esp32-node-{}", hw.metadata.node_id)), band, channel);
meta.bandwidth_mhz = match hw.metadata.bandwidth {
wifi_densepose_hardware::Bandwidth::Bw20 => 20,
wifi_densepose_hardware::Bandwidth::Bw40 => 40,
wifi_densepose_hardware::Bandwidth::Bw80 => 80,
wifi_densepose_hardware::Bandwidth::Bw160 => 160,
};
meta.antenna_config = AntennaConfig::new(1, hw.metadata.n_antennas.max(1));
meta.rssi_dbm = hw.metadata.rssi_dbm;
meta.noise_floor_dbm = hw.metadata.noise_floor_dbm;
meta.sequence_number = hw.metadata.sequence;
// Single spatial stream (n_antennas isn't broken out per-antenna in the
// ADR-018 wire format consumed here) x real subcarrier count.
let n_bins = hw.subcarriers.len().max(1);
let data = Array2::from_shape_fn((1, n_bins), |(_, b)| {
hw.subcarriers.get(b).map_or(Complex64::new(0.0, 0.0), |sc| {
Complex64::new(f64::from(sc.i), f64::from(sc.q))
})
});
CoreCsiFrame::new(meta, data)
}
fn main() {
let bind = std::env::args()
.collect::<Vec<_>>()
.windows(2)
.find(|w| w[0] == "--bind")
.map_or_else(|| "0.0.0.0:5005".to_string(), |w| w[1].clone());
let want_frames: usize = std::env::args()
.collect::<Vec<_>>()
.windows(2)
.find(|w| w[0] == "--frames")
.and_then(|w| w[1].parse().ok())
.unwrap_or(8);
let socket = UdpSocket::bind(&bind).expect("bind UDP socket");
socket.set_read_timeout(Some(std::time::Duration::from_secs(30))).unwrap();
eprintln!("Listening on {bind} for real ESP32 ADR-018 CSI frames (need {want_frames})...");
let mut buf = [0u8; 2048];
let mut core_frames: Vec<CoreCsiFrame> = Vec::new();
let mut real_channel_freq_hz: Option<f64> = None;
while core_frames.len() < want_frames {
let (n, _src) = socket.recv_from(&mut buf).expect("recv (timed out — is a live node targeting this host?)");
match Esp32CsiParser::parse_frame(&buf[..n]) {
Ok((hw_frame, _consumed)) => {
// Lock onto the first node/shape seen so all frames in the
// window share (n_links, n_bins), as the adapter requires.
if let Some(first) = core_frames.first() {
let n_bins = hw_frame.subcarriers.len();
if n_bins != first.num_subcarriers() {
eprintln!(" [skip: shape changed mid-window ({n_bins} vs {})]", first.num_subcarriers());
continue;
}
}
if real_channel_freq_hz.is_none() {
real_channel_freq_hz = Some(f64::from(hw_frame.metadata.channel_freq_mhz) * 1e6);
}
eprintln!(
" [captured frame {}: sc={} rssi={} node={}]",
core_frames.len() + 1,
hw_frame.subcarriers.len(),
hw_frame.metadata.rssi_dbm,
hw_frame.metadata.node_id,
);
core_frames.push(to_core_frame(hw_frame));
}
Err(ParseError::NonCsiPacket { .. }) => {}
Err(e) => eprintln!(" [parse error: {e}]"),
}
}
let raw = RawCapture::WifiCsi {
frames: core_frames,
links: vec![LinkGeometry { tx_pos: [0.0, 0.0, 1.0], rx_pos: [3.0, 0.0, 1.0] }],
age_s: 0.05,
clock_quality: 0.5, // free-running ESP32 crystal, not disciplined — honest, not 1.0
};
let registry = AdapterRegistry::with_reference_adapters();
let now_ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
match registry.normalize("esp32s3-csi", &raw) {
Ok(tensor) => {
let real_hz = real_channel_freq_hz.unwrap();
println!("=== RfTensor from REAL ESP32 hardware (not synthetic) ===");
println!("dims (links, bins, snapshots): {:?}", tensor.data.dim());
println!("adapter-computed center_freq_hz : {:.0}", tensor.center_freq_hz);
println!("real per-frame channel_freq_hz : {:.0} (from hardware parser)", real_hz);
println!(
"match within 1 MHz: {} (this is the ADR-018 channel-aware fix — the pre-fix \
code always reported the fixed-band constant, 2437000000 Hz for 2.4 GHz, \
regardless of the real channel)",
(tensor.center_freq_hz - real_hz).abs() < 1e6
);
println!("bandwidth_hz: {:.0}", tensor.bandwidth_hz);
println!("uncertainty (from real SNR): {:.3}", tensor.uncertainty);
println!("device_id: {}", tensor.device_id);
println!("timestamp_ns (now, for reference): {now_ns}");
println!("No panic on real hardware data, including subcarrier counts != CANONICAL_BINS=56.");
}
Err(e) => {
println!("ADAPTER REJECTED real hardware data: {e}");
std::process::exit(1);
}
}
}
+24 -2
View File
@@ -19,7 +19,7 @@ use std::collections::HashMap;
use ndarray::{Array3, Axis};
use num_complex::Complex64;
use wifi_densepose_core::types::CsiFrame;
use wifi_densepose_core::types::{CsiFrame, FrequencyBand};
use crate::math::{linear_slope, median, resample_complex};
use crate::tensor::{
@@ -378,6 +378,28 @@ impl WifiCsiAdapter {
}
}
/// IEEE 802.11 channel-number → center-frequency, in MHz.
///
/// `CsiMetadata::frequency_band` alone only identifies which ~100 MHz-wide
/// band a capture came from (a fixed per-band constant), not the actual
/// channel — using the band constant directly misreports every channel
/// except the one it happens to match (2.4 GHz channel 6, 5 GHz channel 36),
/// by up to tens of MHz on 2.4 GHz and hundreds of MHz on 5/6 GHz. Falls back
/// to the band constant only when the channel number is out of the known
/// range (e.g. `0`, meaning "unknown").
fn channel_center_freq_mhz(band: FrequencyBand, channel: u8) -> f64 {
match band {
FrequencyBand::Band2_4GHz => match channel {
1..=13 => 2407.0 + 5.0 * f64::from(channel),
14 => 2484.0,
_ => f64::from(band.center_frequency_mhz()),
},
FrequencyBand::Band5GHz if channel > 0 => 5000.0 + 5.0 * f64::from(channel),
FrequencyBand::Band6GHz if channel > 0 => 5950.0 + 5.0 * f64::from(channel),
FrequencyBand::Band5GHz | FrequencyBand::Band6GHz => f64::from(band.center_frequency_mhz()),
}
}
impl RfAdapter for WifiCsiAdapter {
fn modality(&self) -> RfModality {
RfModality::WifiCsi
@@ -419,7 +441,7 @@ impl RfAdapter for WifiCsiAdapter {
let snr_db =
frames.iter().map(|f| f.metadata.snr_db()).sum::<f64>() / frames.len() as f64;
let uncertainty = (1.0 - snr_db / 40.0).clamp(0.0, 1.0);
let center_freq_hz = f64::from(meta.frequency_band.center_frequency_mhz()) * 1e6;
let center_freq_hz = channel_center_freq_mhz(meta.frequency_band, meta.channel) * 1e6;
let ts = &meta.timestamp;
let timestamp_ns = u64::try_from(ts.seconds).unwrap_or(0) * 1_000_000_000 + u64::from(ts.nanos);
normalize_grid(
+51
View File
@@ -114,6 +114,15 @@ pub fn admit_task(engine: &PolicyEngine, task: &SensingTask) -> Result<()> {
if !(task.minimum_confidence.is_finite() && (0.0..=1.0).contains(&task.minimum_confidence)) {
return Err(UnifiedError::InvalidInput("minimum_confidence must be in [0,1]".into()));
}
if !(task.requested_resolution_m.is_finite() && task.requested_resolution_m > 0.0) {
return Err(UnifiedError::InvalidInput("requested_resolution_m must be finite and > 0".into()));
}
if task.maximum_latency_ms == 0 {
return Err(UnifiedError::InvalidInput("maximum_latency_ms must be > 0".into()));
}
if task.modalities.is_empty() {
return Err(UnifiedError::InvalidInput("a sensing task must declare at least one modality".into()));
}
if task.purpose == SensingPurpose::IdentityRecognition && task.consent_reference.is_none() {
return Err(UnifiedError::PolicyDenied(
"identity recognition tasks require a consent reference".into(),
@@ -663,4 +672,46 @@ mod tests {
orphan.source_receipts.clear();
assert!(validate_representation(&orphan, SensingPurpose::Presence).is_err());
}
/// Only `Presence` was ever exercised above; the other three
/// ceiling/exclusion-set branches (Activity/Localization at P3,
/// Vitals/PoseTracking at P4, IdentityRecognition at P5) had zero test
/// coverage — a bug in any of them would go undetected.
#[test]
fn task_sufficient_representation_covers_every_purpose_branch() {
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
task_id: 6,
source_receipts: vec![1],
semantic_state: vec![0.2],
information_bound_bits: 4.0,
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
privacy_class: class,
};
for purpose in [SensingPurpose::Activity, SensingPurpose::Localization] {
// P3 ceiling excluding identity: fine.
assert!(validate_representation(&rep(PrivacyClass::P3, &["identity"]), purpose).is_ok());
// Forgot to exclude identity: deny.
assert!(validate_representation(&rep(PrivacyClass::P3, &[]), purpose).is_err());
// Above the P3 ceiling: deny.
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_err());
}
for purpose in [SensingPurpose::Vitals, SensingPurpose::PoseTracking] {
// P4 ceiling excluding identity: fine.
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_ok());
// Forgot to exclude identity: deny.
assert!(validate_representation(&rep(PrivacyClass::P4, &[]), purpose).is_err());
// Above the P4 ceiling: deny.
assert!(validate_representation(&rep(PrivacyClass::P5, &["identity"]), purpose).is_err());
}
// IdentityRecognition: P5 ceiling, nothing required to be excluded.
assert!(validate_representation(&rep(PrivacyClass::P5, &[]), SensingPurpose::IdentityRecognition)
.is_ok());
// Still bounded — no class exceeds P5, so exercise the lineage guard instead.
let mut orphan = rep(PrivacyClass::P5, &[]);
orphan.source_receipts.clear();
assert!(validate_representation(&orphan, SensingPurpose::IdentityRecognition).is_err());
}
}
+18 -1
View File
@@ -185,7 +185,12 @@ impl GaussianMap {
for g in &mut self.gaussians {
let dt_s = (now_ns.saturating_sub(g.timestamp_ns)) as f64 / 1e9;
let lifetime_s = (g.timestamp_ns.saturating_sub(g.first_seen_ns)) as f64 / 1e9;
let tau_eff = g.decay_tau_s * (1.0 + (1.0 + lifetime_s / g.decay_tau_s).ln());
// `RfGaussian::new` validates `decay_tau_s > 0`, but the field is
// mutable after construction (`gaussians_mut`); re-clamp here so a
// stray zero/negative value can't turn this division into NaN
// instead of a merely-fast decay.
let tau = g.decay_tau_s.max(1e-6);
let tau_eff = tau * (1.0 + (1.0 + lifetime_s / tau).ln());
g.confidence *= (-dt_s / tau_eff).exp();
}
self.gaussians.retain(|g| g.confidence >= PRUNE_CONFIDENCE);
@@ -209,6 +214,18 @@ impl GaussianMap {
continue;
}
let (a, b) = (&self.gaussians[i], &self.gaussians[j]);
// Same entity-kind gate as `insert` (§3.2): never conflate
// Gaussians linked to different entity kinds (e.g. a Room
// structure and a PersonClass detection sitting within each
// other's merge gate near a doorway).
let same_kind = match (a.links.first(), b.links.first()) {
(Some(la), Some(lb)) => la.kind == lb.kind,
(None, None) => true,
_ => false,
};
if !same_kind {
continue;
}
let mutual = a.mahalanobis_sq(b.position) < MERGE_MAHALANOBIS_SQ
&& b.mahalanobis_sq(a.position) < MERGE_MAHALANOBIS_SQ;
if !mutual {
+6
View File
@@ -132,6 +132,12 @@ pub fn resample_complex(x: &[Complex64], m: usize) -> Vec<Complex64> {
if n == 1 {
return vec![x[0]; m];
}
if m == 1 {
// `(m - 1)` would divide by zero below; a single output point is the
// mean of the series rather than an arbitrary NaN-poisoned sample.
let sum: Complex64 = x.iter().copied().sum();
return vec![sum / n as f64];
}
let mut out = Vec::with_capacity(m);
for j in 0..m {
let pos = (j as f64) * ((n - 1) as f64) / ((m - 1) as f64);
+16 -6
View File
@@ -193,6 +193,12 @@ pub struct PretrainReport {
}
fn sample_mask(rng: &mut rand_chacha::ChaCha20Rng, n_tokens: usize, fraction: f64) -> Vec<usize> {
// A window with fewer than 2 tokens has nothing left to reconstruct from
// once one token is masked; return an empty mask rather than panicking
// (`clamp(1, n_tokens - 1)` is invalid once `n_tokens - 1 < 1`).
if n_tokens < 2 {
return Vec::new();
}
let n_mask = ((n_tokens as f64 * fraction).round() as usize).clamp(1, n_tokens - 1);
let mut idx: Vec<usize> = (0..n_tokens).collect();
idx.shuffle(rng);
@@ -216,12 +222,16 @@ pub fn pretrain(
.map(|w| sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction))
.collect();
let eval = |e: &RfEncoder| {
windows
.iter()
.zip(&eval_masks)
.map(|(w, m)| masked_loss(e, &w.tokens, m, WindowContext::from(w)))
.sum::<f64>()
/ windows.len() as f64
let mut total = 0.0;
let mut n = 0usize;
for (w, m) in windows.iter().zip(&eval_masks) {
if m.is_empty() {
continue;
}
total += masked_loss(e, &w.tokens, m, WindowContext::from(w));
n += 1;
}
if n == 0 { 0.0 } else { total / n as f64 }
};
let initial_loss = eval(enc);
@@ -239,9 +239,15 @@ proptest! {
}
/// Representation validation never approves an identity-retaining
/// occupancy representation, whatever the other fields say.
/// 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 P3P5, 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)
@@ -257,12 +263,33 @@ proptest! {
excluded_information: excluded.clone(),
privacy_class: class,
};
let verdict = validate_representation(&rep, SensingPurpose::Presence);
let excludes_required = excluded.iter().any(|e| e == "identity")
&& excluded.iter().any(|e| e == "vitals");
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 must exclude identity+vitals");
prop_assert!(class <= PrivacyClass::P2, "approved rep must respect the ceiling");
prop_assert!(
excludes_required,
"approved rep for {:?} must exclude {:?}", purpose, must_exclude
);
prop_assert!(
class <= ceiling,
"approved rep for {:?} must respect ceiling {:?}", purpose, ceiling
);
}
}
}
+19 -14
View File
@@ -65,6 +65,7 @@ impl ComplexSample {
/// This is a lossy *view*, never re-serialised as the witness form
/// (ADR-136 §3.3 risk mitigation — one encoder only).
#[must_use]
#[allow(clippy::cast_possible_truncation)] // f64 -> f32 is the documented lossy narrowing above
pub fn as_complex32(&self) -> num_complex::Complex32 {
num_complex::Complex32::new(self.0.re as f32, self.0.im as f32)
}
@@ -581,7 +582,9 @@ impl crate::traits::CanonicalFrame for CsiFrame {
b.extend_from_slice(&m.timestamp.seconds.to_le_bytes());
b.extend_from_slice(&m.timestamp.nanos.to_le_bytes());
let dev = m.device_id.as_str().as_bytes();
b.extend_from_slice(&(dev.len() as u32).to_le_bytes());
b.extend_from_slice(
&u32::try_from(dev.len()).expect("device_id length fits u32").to_le_bytes(),
);
b.extend_from_slice(dev);
b.push(match m.frequency_band {
FrequencyBand::Band2_4GHz => 0,
@@ -592,15 +595,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
b.extend_from_slice(&m.bandwidth_mhz.to_le_bytes());
b.push(m.antenna_config.tx_antennas);
b.push(m.antenna_config.rx_antennas);
match m.antenna_config.spacing_mm {
Some(s) => {
b.push(1);
b.extend_from_slice(&s.to_le_bytes());
}
None => {
b.push(0);
b.extend_from_slice(&[0u8; 4]);
}
if let Some(s) = m.antenna_config.spacing_mm {
b.push(1);
b.extend_from_slice(&s.to_le_bytes());
} else {
b.push(0);
b.extend_from_slice(&[0u8; 4]);
}
b.extend_from_slice(&m.rssi_dbm.to_le_bytes());
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
@@ -623,8 +623,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
b.extend_from_slice(&m.model_version.to_le_bytes());
// Shape, then complex payload stream-major.
b.extend_from_slice(&(self.data.nrows() as u32).to_le_bytes());
b.extend_from_slice(&(self.data.ncols() as u32).to_le_bytes());
b.extend_from_slice(
&u32::try_from(self.data.nrows()).expect("stream count fits u32").to_le_bytes(),
);
b.extend_from_slice(
&u32::try_from(self.data.ncols()).expect("subcarrier count fits u32").to_le_bytes(),
);
for sample in self.data_complex_samples() {
b.extend_from_slice(&sample.to_le_bytes());
}
@@ -714,7 +718,7 @@ impl<'a> Cursor<'a> {
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
Ok(self.take(1)?[0] as i8)
Ok(i8::from_le_bytes(self.take(1)?.try_into().unwrap()))
}
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
@@ -1462,7 +1466,7 @@ mod tests {
fn lcg(state: &mut u64) -> f64 {
*state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
// Map high bits into [-1e6, 1e6) for a wide exponent spread.
((*state >> 11) as f64 / (1u64 << 53) as f64) * 2.0e6 - 1.0e6
((*state >> 11) as f64 / (1u64 << 53) as f64).mul_add(2.0e6, -1.0e6)
}
/// AC1 — `ComplexSample` little-endian round-trip + endianness pin.
@@ -1671,6 +1675,7 @@ mod tests {
/// unwinding panic (panic-on-adversarial-input = 0). Sweep truncations and a
/// deterministic fuzz spread.
#[test]
#[allow(clippy::cast_possible_truncation)] // fuzz byte extraction: truncation IS the point
fn canonical_decode_never_panics_on_arbitrary_bytes() {
use ndarray::Array2;
let mut meta = CsiMetadata::new(DeviceId::new("node"), FrequencyBand::Band5GHz, 36);
@@ -146,7 +146,7 @@ impl PpduType {
/// bit 3 : LDPC (reserved — not yet populated by firmware)
/// bit 4 : 802.15.4 time-sync valid (C6 only)
/// bit 5-7 : reserved
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adr018Flags {
pub bw40: bool,
pub stbc: bool,
@@ -173,12 +173,6 @@ impl Adr018Flags {
}
}
impl Default for Adr018Flags {
fn default() -> Self {
Self { bw40: false, stbc: false, ldpc: false, ieee802154_sync_valid: false }
}
}
/// WiFi channel bandwidth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Bandwidth {
@@ -93,8 +93,10 @@ fn fsm_full_cycle_setup_measure_report_terminate() {
#[test]
fn responder_rejects_unsupported_bandwidth_and_initiator_resets() {
let mut cfg = SessionConfig::default();
cfg.capabilities = SensingCapabilities::esp32_opportunistic(); // max 40 MHz
let cfg = SessionConfig {
capabilities: SensingCapabilities::esp32_opportunistic(), // max 40 MHz
..SessionConfig::default()
};
let mut responder = SensingSession::new_responder(cfg);
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
@@ -204,8 +206,10 @@ fn capacity_and_policy_and_profile_rejections() {
));
// Incompatible profile
let mut cfg = SessionConfig::default();
cfg.profile = SpecProfile::VendorExtension("acme".into());
let cfg = SessionConfig {
profile: SpecProfile::VendorExtension("acme".into()),
..SessionConfig::default()
};
let mut responder = SensingSession::new_responder(cfg);
let actions = responder
.handle(SessionEvent::SetupRequestReceived(setup_request(6)))
@@ -268,8 +268,10 @@ fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() {
// Incompatible profile now surfaces as its own status (the old
// duplicated SBP chain folded it into RejectedUnsupportedParams).
let mut cfg = SessionConfig::default();
cfg.profile = SpecProfile::VendorExtension("acme".into());
let cfg = SessionConfig {
profile: SpecProfile::VendorExtension("acme".into()),
..SessionConfig::default()
};
let mut proxy = SensingSession::new_responder(cfg);
let actions = proxy
.handle(SessionEvent::SbpRequestReceived(sbp_request(41)))
@@ -66,48 +66,64 @@ impl Server {
/// test in the suite that observes real wiring disarmed itself exactly when
/// it mattered; a boot-time panic in the auth path would have shipped green.
fn start(env: &[(&str, &str)]) -> Self {
let (http, ws, udp) = (free_port(), free_port(), free_port());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
cmd.args([
"--http-port", &http.to_string(),
"--ws-port", &ws.to_string(),
"--udp-port", &udp.to_string(),
"--bind-addr", "127.0.0.1",
"--no-edge-registry",
"--source", "simulate",
])
// Inherit nothing auth-related from the developer's shell, or a local
// RUVIEW_* export would silently change what this test proves.
.env_remove("RUVIEW_API_TOKEN")
.env_remove("RUVIEW_OAUTH_ISSUER")
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
.stdout(Stdio::null())
// Captured, not discarded: if the server dies at boot, its stderr is the
// only thing that says why, and the panic below reproduces it.
.stderr(Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn sensing-server");
let http_port = http;
let ws_port = ws;
if !await_ready(http_port, ws_port) {
// `free_port()` releases the port before the child binds it, so under
// parallel `cargo test` execution another test's server can grab the
// same ephemeral port first (observed as `Os { code: 10048/98, kind:
// AddrInUse }` on the child's actual bind, distinct from a genuine
// auth-wiring break). Retry a bounded number of times on THAT specific
// signature only — any other boot failure (including a real wiring
// regression) still panics on the first attempt, preserving the
// fail-loud property described above.
const MAX_ATTEMPTS: u32 = 3;
for attempt in 1..=MAX_ATTEMPTS {
let (http, ws, udp) = (free_port(), free_port(), free_port());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
cmd.args([
"--http-port", &http.to_string(),
"--ws-port", &ws.to_string(),
"--udp-port", &udp.to_string(),
"--bind-addr", "127.0.0.1",
"--no-edge-registry",
"--source", "simulate",
])
// Inherit nothing auth-related from the developer's shell, or a local
// RUVIEW_* export would silently change what this test proves.
.env_remove("RUVIEW_API_TOKEN")
.env_remove("RUVIEW_OAUTH_ISSUER")
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
.stdout(Stdio::null())
// Captured, not discarded: if the server dies at boot, its stderr is the
// only thing that says why, and the panic below reproduces it.
.stderr(Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn sensing-server");
if await_ready(http, ws) {
return Server { child, http, ws };
}
let mut err = String::new();
if let Some(mut s) = child.stderr.take() {
let _ = s.read_to_string(&mut err);
}
let _ = child.kill();
let _ = child.wait();
let looks_like_port_collision =
err.contains("AddrInUse") || err.contains("Address already in use")
|| err.contains("code: 10048") || err.contains("code: 98");
if looks_like_port_collision && attempt < MAX_ATTEMPTS {
continue;
}
panic!(
"sensing-server did not become ready on :{http_port} (http) and :{ws_port} (ws) \
within 30s. This is a FAILURE, not a skip the wiring assertions below cannot \
run, and a boot-time break in the auth path is exactly what they exist to catch.\n\
"sensing-server did not become ready on :{http} (http) and :{ws} (ws) \
within 30s (attempt {attempt}/{MAX_ATTEMPTS}). This is a FAILURE, not a skip \
the wiring assertions below cannot run, and a boot-time break in the auth path \
is exactly what they exist to catch.\n\
--- server stderr ---\n{err}"
);
}
Server { child, http: http_port, ws: ws_port }
unreachable!("loop always returns or panics");
}
}
fn await_ready(http: u16, ws: u16) -> bool {