mirror of
https://github.com/ruvnet/RuView
synced 2026-07-26 18:01:48 +00:00
cfd0ad76cf
* test(core,cli): pin DoS-resistance of CSI deserialisers (ADR-127 security review)
Beyond-SOTA security review of wifi-densepose-core + wifi-densepose-cli.
Load-bearing-question verdict: the NaN-state-poisoning bug class does NOT
originate in core — core exposes no stateful accumulator (no Welford,
von-Mises, IIR, voxel grid, running mean); each downstream crate rolls its
own, so each fix is correctly local. Both crates confirmed clean on every
reviewed dimension (panic-on-adversarial-input, NaN handling, unbounded
memory, path traversal, secrets) — no production code changed.
Adds 4 regression pins locking in two existing-but-untested DoS guards:
- core: from_canonical_bytes shape guard (Vec::with_capacity bound) — proven
to fail with `capacity overflow` when the saturating-mul guard is removed.
- core: canonical decoder never panics on arbitrary/truncated bytes.
- cli: parse_csi_packet rejects an oversized n_antennas*n_subcarriers claim
before Array2 allocation (33 MB claim in a 2 KB datagram -> None).
- cli: parse_csi_packet never panics on arbitrary UDP bytes.
core: 35 -> 37 lib tests; cli: 24 -> 26 tests; 0 failed. Python proof
unchanged (f8e76f21…46f7a — off the signal path).
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr): ADR-172 — wifi-densepose-cli + core CSI-deserialiser security review
Records the clean-with-evidence verdict + 4 DoS-resistance regression pins
(test-only, committed in a1051607d). Documents the load-bearing finding:
the NaN-state-poisoning bug class does NOT originate in a shared core
primitive (core exposes no stateful accumulator — MEASURED via grep), so
the 3 prior downstream-local fixes are complete. Gives the wifi-densepose-cli
review its own ADR slot (core portion cross-refs ADR-127 §9).
Co-Authored-By: claude-flow <ruv@ruv.net>
545 lines
20 KiB
Rust
545 lines
20 KiB
Rust
//! `wifi-densepose calibrate` — empty-room baseline calibration subcommand.
|
||
//!
|
||
//! Reads CSI frames from a UDP socket (ESP32 0xC511_0001 wire format), feeds
|
||
//! them through [`wifi_densepose_signal::CalibrationRecorder`], prints a
|
||
//! real-time deviation banner (ADR-135 §risk 1), and serialises the finished
|
||
//! [`wifi_densepose_signal::BaselineCalibration`] to disk in the compact
|
||
//! little-endian binary format defined in ADR-135 §2.4.
|
||
//!
|
||
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
|
||
//!
|
||
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
|
||
//!
|
||
//! Offset Size Field
|
||
//! ────── ──── ─────────────────────────────────────────────────────────────
|
||
//! 0 4 Magic: 0xC511_0001 (LE u32)
|
||
//! 4 1 node_id (u8)
|
||
//! 5 1 n_antennas (u8)
|
||
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
|
||
//! 8 4 freq_mhz (LE u32)
|
||
//! 12 4 sequence (LE u32)
|
||
//! 16 1 rssi (i8)
|
||
//! 17 1 noise_floor (i8)
|
||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
|
||
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
|
||
//!
|
||
//! This parser mirrors `parse_esp32_frame` in
|
||
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
|
||
|
||
use anyhow::{bail, Result};
|
||
use clap::Args;
|
||
use ndarray::Array2;
|
||
use num_complex::Complex64;
|
||
use std::time::{Duration, Instant};
|
||
use tokio::net::UdpSocket;
|
||
use wifi_densepose_core::types::{
|
||
AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand, Timestamp,
|
||
};
|
||
use wifi_densepose_signal::{
|
||
BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationRecorder,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Arguments
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Arguments for the `calibrate` subcommand.
|
||
#[derive(Args, Debug, Clone)]
|
||
pub struct CalibrateArgs {
|
||
/// UDP port to listen on for CSI frames from the ESP32.
|
||
/// Must match the target-port written into NVS by provision.py (default 5005).
|
||
#[arg(long, default_value_t = 5005)]
|
||
pub udp_port: u16,
|
||
|
||
/// Bind address for the UDP socket.
|
||
/// Default 0.0.0.0 receives from any device on the LAN.
|
||
#[arg(long, default_value = "0.0.0.0")]
|
||
pub bind: String,
|
||
|
||
/// Calibration duration in seconds.
|
||
/// ADR-135 default is 30 s at 20 Hz = 600 frames.
|
||
/// Minimum 10; values above 300 emit a warning.
|
||
#[arg(long, default_value_t = 30)]
|
||
pub duration_s: u32,
|
||
|
||
/// Output path for the binary baseline file (ADR-135 §2.4 format).
|
||
#[arg(long, default_value = "./baseline.bin")]
|
||
pub output: String,
|
||
|
||
/// PHY tier matching the ESP32 configuration.
|
||
/// Valid: ht20 / ht40 / he20 / he40.
|
||
#[arg(long, default_value = "ht20")]
|
||
pub tier: String,
|
||
|
||
/// Print a deviation banner to stderr every N frames during capture.
|
||
/// 0 disables banners. Default 20 = once per second at 20 Hz.
|
||
#[arg(long, default_value_t = 20)]
|
||
pub banner_every: u32,
|
||
|
||
/// Abort if the per-frame amplitude z-score median exceeds this value
|
||
/// for 20 consecutive banner intervals. 0.0 disables the abort guard.
|
||
#[arg(long, default_value_t = 2.0)]
|
||
pub abort_z_threshold: f32,
|
||
|
||
/// Override the ADR-135 minimum frame count for the tier. 0 = use the
|
||
/// tier default (600 for HT20 at 20 Hz = 30 s). Useful for debugging or
|
||
/// low-traffic environments where the firmware emits CSI far below 20 Hz.
|
||
/// Production deployments should leave this at 0.
|
||
#[arg(long, default_value_t = 0)]
|
||
pub min_frames: u32,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Maximum UDP receive buffer. HT20 CSI frame is well under 1 500 bytes.
|
||
const RECV_BUF: usize = 2048;
|
||
|
||
/// Number of banner intervals in the high-z abort sliding window.
|
||
const ABORT_WINDOW_INTERVALS: u32 = 20;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Execute the `calibrate` subcommand (async).
|
||
pub async fn execute(args: CalibrateArgs) -> Result<()> {
|
||
validate_args(&args)?;
|
||
|
||
let mut config = tier_config(&args.tier);
|
||
if args.min_frames > 0 {
|
||
config.min_frames = args.min_frames;
|
||
eprintln!(
|
||
"[calibrate] WARN: --min-frames={} overrides ADR-135 tier default ({} for {}). \
|
||
This relaxes the phase-concentration guarantee; do not use in production.",
|
||
args.min_frames, tier_config(&args.tier).min_frames, args.tier
|
||
);
|
||
}
|
||
let target_frames = config.min_frames as usize;
|
||
|
||
let addr = format!("{}:{}", args.bind, args.udp_port);
|
||
let socket = UdpSocket::bind(&addr).await
|
||
.map_err(|e| anyhow::anyhow!("cannot bind UDP socket on {addr}: {e}"))?;
|
||
|
||
eprintln!("[calibrate] listening on udp://{addr}");
|
||
eprintln!(
|
||
"[calibrate] capturing {} frames (~{} s, tier={}) — ensure room is empty",
|
||
target_frames, args.duration_s, args.tier
|
||
);
|
||
|
||
let mut recorder = CalibrationRecorder::new(config);
|
||
let mut buf = vec![0u8; RECV_BUF];
|
||
let mut high_z_count: u32 = 0;
|
||
let deadline = Instant::now() + Duration::from_secs(args.duration_s as u64);
|
||
|
||
loop {
|
||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||
if remaining.is_zero() {
|
||
break;
|
||
}
|
||
|
||
let timeout = remaining.min(Duration::from_millis(500));
|
||
let recv = tokio::time::timeout(timeout, socket.recv(&mut buf)).await;
|
||
|
||
let n = match recv {
|
||
Ok(Ok(n)) => n,
|
||
Ok(Err(e)) => { eprintln!("[calibrate] recv error: {e}"); continue; }
|
||
Err(_) => continue, // timeout — recheck deadline
|
||
};
|
||
|
||
let Some(csi_frame) = parse_csi_packet(&buf[..n], &args.tier) else {
|
||
continue;
|
||
};
|
||
|
||
let score: CalibrationDeviationScore = match recorder.record(&csi_frame) {
|
||
Ok(s) => s,
|
||
Err(e) => { eprintln!("[calibrate] WARN frame skipped: {e}"); continue; }
|
||
};
|
||
|
||
let frames = recorder.frames_recorded() as usize;
|
||
|
||
if args.banner_every > 0 && (frames as u32) % args.banner_every == 0 {
|
||
print_banner(frames, target_frames, &score);
|
||
|
||
if args.abort_z_threshold > 0.0 && score.amplitude_z_median > args.abort_z_threshold {
|
||
high_z_count += 1;
|
||
if high_z_count >= ABORT_WINDOW_INTERVALS {
|
||
bail!(
|
||
"aborted: amplitude_z_median={:.2} exceeded threshold={:.2} for {} \
|
||
consecutive banner intervals — ensure the room is empty and retry",
|
||
score.amplitude_z_median, args.abort_z_threshold, high_z_count
|
||
);
|
||
}
|
||
} else {
|
||
high_z_count = 0;
|
||
}
|
||
}
|
||
|
||
if frames >= target_frames {
|
||
break;
|
||
}
|
||
}
|
||
|
||
finalise_and_save(recorder, &args.output)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Banner printer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn print_banner(frames: usize, target: usize, score: &CalibrationDeviationScore) {
|
||
let motion_str = if score.motion_flagged {
|
||
"YES \u{2190} operator should be still"
|
||
} else {
|
||
"no"
|
||
};
|
||
eprintln!(
|
||
"[calibrate] {}/{} frames | z_med={:.2} z_max={:.2} | motion: {}",
|
||
frames, target, score.amplitude_z_median, score.amplitude_z_max, motion_str
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Finalise + persist
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()> {
|
||
let frames = recorder.frames_recorded();
|
||
eprintln!("[calibrate] finalising baseline from {frames} frames…");
|
||
|
||
let baseline: BaselineCalibration = recorder
|
||
.finalize()
|
||
.map_err(|e| anyhow::anyhow!("calibration failed: {e}"))?;
|
||
|
||
let bytes = baseline.to_bytes();
|
||
std::fs::write(output, &bytes)
|
||
.map_err(|e| anyhow::anyhow!("cannot write {output}: {e}"))?;
|
||
|
||
eprintln!(
|
||
"[calibrate] baseline saved to {output} ({} bytes)",
|
||
bytes.len()
|
||
);
|
||
eprintln!(
|
||
"[calibrate] summary: frames={} tier={:?} subcarriers={}",
|
||
baseline.frame_count,
|
||
baseline.tier,
|
||
baseline.subcarriers.len(),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tier helper
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub(crate) fn tier_config(tier: &str) -> CalibrationConfig {
|
||
match tier.to_ascii_lowercase().as_str() {
|
||
"ht40" => CalibrationConfig::ht40(),
|
||
"he20" => CalibrationConfig::he20(),
|
||
"he40" => CalibrationConfig::he40(),
|
||
_ => CalibrationConfig::ht20(), // ht20 or unknown → safe default
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Local UDP packet parser (option b)
|
||
//
|
||
// Mirrors parse_esp32_frame in wifi-densepose-sensing-server/src/csi.rs.
|
||
// Magic 0xC511_0001, 20-byte header, IQ bytes follow.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Parse a single UDP datagram and return a `CsiFrame` ready for
|
||
/// `CalibrationRecorder::record()`. Returns `None` on any parse failure.
|
||
pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||
if buf.len() < 20 {
|
||
return None;
|
||
}
|
||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||
if magic != 0xC511_0001 {
|
||
return None;
|
||
}
|
||
|
||
let node_id = buf[4];
|
||
let n_antennas = buf[5] as usize;
|
||
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
|
||
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
|
||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
|
||
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
|
||
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||
let rssi = buf[16] as i8;
|
||
let noise_floor = buf[17] as i8;
|
||
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
|
||
|
||
let n_pairs = n_antennas * n_subcarriers;
|
||
let iq_start = 20usize;
|
||
if buf.len() < iq_start + n_pairs * 2 {
|
||
return None;
|
||
}
|
||
|
||
// Build an ndarray Array2<Complex64> shaped [n_antennas, n_subcarriers].
|
||
let mut data = Array2::<Complex64>::zeros((n_antennas.max(1), n_subcarriers.max(1)));
|
||
for s in 0..n_antennas {
|
||
for k in 0..n_subcarriers {
|
||
let idx = s * n_subcarriers + k;
|
||
let i_val = buf[iq_start + idx * 2] as i8 as f64;
|
||
let q_val = buf[iq_start + idx * 2 + 1] as i8 as f64;
|
||
data[[s, k]] = Complex64::new(i_val, q_val);
|
||
}
|
||
}
|
||
|
||
let band = if freq_mhz >= 5000 {
|
||
FrequencyBand::Band5GHz
|
||
} else {
|
||
FrequencyBand::Band2_4GHz
|
||
};
|
||
let bw = tier_to_bw_mhz(tier);
|
||
|
||
let mut meta = CsiMetadata::new(
|
||
DeviceId::new(format!("esp32-node{}", node_id)),
|
||
band,
|
||
freq_mhz_to_channel(freq_mhz),
|
||
);
|
||
meta.bandwidth_mhz = bw;
|
||
meta.rssi_dbm = rssi;
|
||
meta.noise_floor_dbm = noise_floor;
|
||
meta.antenna_config = AntennaConfig {
|
||
tx_antennas: 1,
|
||
rx_antennas: n_antennas as u8,
|
||
spacing_mm: None,
|
||
};
|
||
meta.timestamp = Timestamp::now();
|
||
|
||
Some(CsiFrame::new(meta, data))
|
||
}
|
||
|
||
/// Map a tier string to a bandwidth in MHz.
|
||
fn tier_to_bw_mhz(tier: &str) -> u16 {
|
||
match tier.to_ascii_lowercase().as_str() {
|
||
"ht40" | "he40" => 40,
|
||
_ => 20,
|
||
}
|
||
}
|
||
|
||
/// Rough 802.11 channel from centre frequency.
|
||
fn freq_mhz_to_channel(freq_mhz: u16) -> u8 {
|
||
// 2.4 GHz: ch = (freq - 2407) / 5
|
||
if freq_mhz < 3000 {
|
||
((freq_mhz.saturating_sub(2407)) / 5) as u8
|
||
} else {
|
||
// 5 GHz: ch = (freq - 5000) / 5
|
||
((freq_mhz.saturating_sub(5000)) / 5) as u8
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Input validation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn validate_args(args: &CalibrateArgs) -> Result<()> {
|
||
if args.duration_s < 10 {
|
||
bail!(
|
||
"--duration-s must be at least 10 s (got {}). \
|
||
Fewer frames produce unreliable phase-concentration estimates (ADR-135 §2.3).",
|
||
args.duration_s
|
||
);
|
||
}
|
||
if args.duration_s > 300 {
|
||
eprintln!(
|
||
"[calibrate] WARN: --duration-s={} exceeds 300 s; this is unusual.",
|
||
args.duration_s
|
||
);
|
||
}
|
||
let valid = ["ht20", "ht40", "he20", "he40"];
|
||
if !valid.contains(&args.tier.to_ascii_lowercase().as_str()) {
|
||
bail!(
|
||
"--tier must be one of {:?} (got {:?})",
|
||
valid, args.tier
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Unit tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_validate_args_min_duration() {
|
||
let mut args = default_args();
|
||
args.duration_s = 5;
|
||
assert!(validate_args(&args).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_args_ok() {
|
||
let args = default_args();
|
||
assert!(validate_args(&args).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_args_bad_tier() {
|
||
let mut args = default_args();
|
||
args.tier = "ht80".into();
|
||
assert!(validate_args(&args).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_tier_config_ht20() {
|
||
let cfg = tier_config("ht20");
|
||
assert_eq!(cfg.num_active, 52);
|
||
}
|
||
|
||
#[test]
|
||
fn test_tier_config_ht40() {
|
||
let cfg = tier_config("ht40");
|
||
assert_eq!(cfg.num_active, 114);
|
||
}
|
||
|
||
#[test]
|
||
fn test_tier_config_he20() {
|
||
let cfg = tier_config("he20");
|
||
// Issue #1009 §1b: HE20 baseline records all 256 delivered bins
|
||
// (no tone map in the recorder), not the 242 active tones.
|
||
assert_eq!(cfg.num_active, 256);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_csi_packet_bad_magic() {
|
||
let buf = vec![0u8; 32];
|
||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_csi_packet_too_short() {
|
||
let buf = vec![0u8; 10];
|
||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||
}
|
||
|
||
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
|
||
fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
|
||
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
|
||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||
buf[4] = 12; // node_id
|
||
buf[5] = 1; // n_antennas
|
||
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
|
||
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
|
||
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
|
||
buf[16] = (-40i8) as u8; // rssi
|
||
buf[17] = (-87i8) as u8; // noise floor
|
||
buf[18] = ppdu;
|
||
buf[19] = 0x10; // time-sync valid
|
||
for k in 0..n_subcarriers as usize {
|
||
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
|
||
buf[20 + k * 2 + 1] = (k % 50) as u8;
|
||
}
|
||
buf
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_csi_packet_valid() {
|
||
let buf = build_frame(2, 0);
|
||
let frame = parse_csi_packet(&buf, "ht20");
|
||
assert!(frame.is_some());
|
||
let f = frame.unwrap();
|
||
assert_eq!(f.num_spatial_streams(), 1);
|
||
assert_eq!(f.num_subcarriers(), 2);
|
||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||
assert_eq!(f.metadata.noise_floor_dbm, -87);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_csi_packet_he_su_256_bins() {
|
||
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
|
||
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
|
||
let buf = build_frame(256, 1);
|
||
assert_eq!(buf.len(), 532); // matches the live wire size
|
||
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
|
||
assert_eq!(f.num_subcarriers(), 256);
|
||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
|
||
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
|
||
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
|
||
assert!(he.record(&f).is_ok());
|
||
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
|
||
assert!(ht.record(&f).is_err());
|
||
}
|
||
|
||
/// Security pin (review 2026-06, ADR-127): the UDP parser is the CLI's
|
||
/// widest attack surface — `calibrate` / `enroll` / `room-watch` bind it to
|
||
/// 0.0.0.0 by default, so any host on the LAN can send arbitrary bytes. A
|
||
/// header that *claims* a huge `n_antennas * n_subcarriers` must be rejected
|
||
/// by the length check BEFORE the `Array2::zeros` allocation, so a single
|
||
/// small datagram can never trigger a multi-MB allocation (unbounded-memory
|
||
/// DoS). The largest possible claim (255 × 65535 pairs ≈ 33 MB of IQ) inside
|
||
/// a RECV_BUF-sized (2048-byte) datagram parses to `None`, never OOMs.
|
||
#[test]
|
||
fn test_parse_csi_packet_oversized_claim_is_rejected_not_allocated() {
|
||
let mut buf = vec![0u8; RECV_BUF];
|
||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||
buf[4] = 1; // node_id
|
||
buf[5] = 255; // n_antennas (max)
|
||
buf[6..8].copy_from_slice(&65535u16.to_le_bytes()); // n_subcarriers (max)
|
||
buf[8..12].copy_from_slice(&2432u32.to_le_bytes());
|
||
// n_pairs = 255 * 65535 = 16_711_425 → needs ~33 MB of IQ bytes that a
|
||
// 2048-byte datagram cannot carry → length check fails → None.
|
||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||
}
|
||
|
||
/// Security pin (review 2026-06): the parser must never panic on ANY byte
|
||
/// string — truncated headers, lying length fields, odd sizes. IQ-loop
|
||
/// indexing is guarded by the length check; this sweeps a spread of
|
||
/// adversarial inputs to lock in panic-on-adversarial-input = 0.
|
||
#[test]
|
||
fn test_parse_csi_packet_never_panics_on_arbitrary_bytes() {
|
||
let mut st = 0x1234_5678u64;
|
||
let mut next = move || {
|
||
st = st
|
||
.wrapping_mul(6_364_136_223_846_793_005)
|
||
.wrapping_add(1_442_695_040_888_963_407);
|
||
(st >> 33) as u8
|
||
};
|
||
for len in 0..600usize {
|
||
let buf: Vec<u8> = (0..len).map(|_| next()).collect();
|
||
for tier in ["ht20", "he20", "garbage"] {
|
||
let _ = parse_csi_packet(&buf, tier);
|
||
}
|
||
}
|
||
// Valid magic, lying n_subcarriers, no payload → None (not a panic).
|
||
let mut buf = vec![0u8; 20];
|
||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||
buf[5] = 3;
|
||
buf[6..8].copy_from_slice(&500u16.to_le_bytes());
|
||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_freq_to_channel_24ghz() {
|
||
assert_eq!(freq_mhz_to_channel(2437), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn test_freq_to_channel_5ghz() {
|
||
assert_eq!(freq_mhz_to_channel(5180), 36);
|
||
}
|
||
|
||
fn default_args() -> CalibrateArgs {
|
||
CalibrateArgs {
|
||
udp_port: 5005,
|
||
bind: "0.0.0.0".into(),
|
||
duration_s: 30,
|
||
output: "./baseline.bin".into(),
|
||
tier: "ht20".into(),
|
||
banner_every: 20,
|
||
abort_z_threshold: 2.0,
|
||
min_frames: 0,
|
||
}
|
||
}
|
||
}
|