Files
ruvnet--RuView/v2/crates/wifi-densepose-ruvector/src/mat/triangulation.rs
T
ruv a2daa2e443 fix(ruvector): crafted-input DoS — no panic on out-of-range indices (ADR-156 §2.2)
Security fix: two functions on a fusion/localisation path that can carry
network-sourced multistatic frames panicked on crafted input (remote DoS).

- triangulation::solve_triangulation indexed ap_positions[0] (empty table) and
  ap_positions[i]/[j] (crafted out-of-range AP index in a TDoA tuple). Now uses
  .first()? / .get(i)? / .get(j)? — returns None, never panics.
- heartbeat::band_power computed n_freq_bins-1 (usize underflow on a zero-bin
  spectrogram) and did not clamp low_bin. Now guards n_freq_bins==0 and clamps
  both bounds into [0,last]; returns 0.0 for empty/inverted ranges.

Tests (each panics on old code, verified by revert):
triangulation_out_of_range_index_returns_none_no_panic,
triangulation_empty_ap_positions_returns_none_no_panic,
heartbeat_band_power_zero_bins_no_panic,
heartbeat_band_power_out_of_range_bounds_no_panic.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 20:23:12 -04:00

183 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TDoA multi-AP survivor localisation (ruvector-solver).
//!
//! [`solve_triangulation`] solves the linearised TDoA least-squares system
//! using a Neumann series sparse solver to estimate a survivor's 2-D position
//! from Time Difference of Arrival measurements across multiple access points.
use ruvector_solver::neumann::NeumannSolver;
use ruvector_solver::types::CsrMatrix;
/// Solve multi-AP TDoA survivor localisation.
///
/// # Arguments
///
/// - `tdoa_measurements`: `(ap_i_idx, ap_j_idx, tdoa_seconds)` tuples. Each
/// measurement is the TDoA between AP `ap_i` and AP `ap_j`.
/// - `ap_positions`: `(x_m, y_m)` per AP in metres, indexed by AP index.
///
/// # Returns
///
/// Estimated `(x, y)` position in metres, or `None` if fewer than 3 TDoA
/// measurements are provided, `ap_positions` is empty, any measurement
/// references an out-of-range AP index, or the solver fails to converge.
///
/// # Robustness (ADR-156 §finding 2)
///
/// Inputs may originate from network-sourced multistatic frames, so crafted
/// AP indices must NOT panic. Any TDoA tuple whose `i`/`j` is out of range for
/// `ap_positions` (or an empty `ap_positions`) returns `None` instead of an
/// out-of-bounds index panic (a DoS vector).
///
/// # Algorithm
///
/// Linearises the TDoA hyperbolic equations around AP index 0 as the reference
/// and solves the resulting 2-D least-squares system with Tikhonov
/// regularisation (`λ = 0.01`) via the Neumann series solver.
pub fn solve_triangulation(
tdoa_measurements: &[(usize, usize, f32)],
ap_positions: &[(f32, f32)],
) -> Option<(f32, f32)> {
if tdoa_measurements.len() < 3 {
return None;
}
const C: f32 = 3e8_f32; // speed of light, m/s
// Guard: empty AP table cannot anchor a reference (ADR-156 §finding 2).
let &(x_ref, y_ref) = ap_positions.first()?;
let mut col0 = Vec::new();
let mut col1 = Vec::new();
let mut b = Vec::new();
for &(i, j, tdoa) in tdoa_measurements {
// Guard against crafted out-of-range indices (no index panic / DoS).
let &(xi, yi) = ap_positions.get(i)?;
let &(xj, yj) = ap_positions.get(j)?;
col0.push(xi - xj);
col1.push(yi - yj);
b.push(
C * tdoa / 2.0 + ((xi * xi - xj * xj) + (yi * yi - yj * yj)) / 2.0
- x_ref * (xi - xj)
- y_ref * (yi - yj),
);
}
let lambda = 0.01_f32;
let a00 = lambda + col0.iter().map(|v| v * v).sum::<f32>();
let a01: f32 = col0.iter().zip(&col1).map(|(a, b)| a * b).sum();
let a11 = lambda + col1.iter().map(|v| v * v).sum::<f32>();
let ata = CsrMatrix::<f32>::from_coo(
2,
2,
vec![(0, 0, a00), (0, 1, a01), (1, 0, a01), (1, 1, a11)],
);
let atb = vec![
col0.iter().zip(&b).map(|(a, b)| a * b).sum::<f32>(),
col1.iter().zip(&b).map(|(a, b)| a * b).sum::<f32>(),
];
NeumannSolver::new(1e-5, 500)
.solve(&ata, &atb)
.ok()
.map(|r| (r.solution[0], r.solution[1]))
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify that `solve_triangulation` returns `Some` for a well-specified
/// problem with 4 TDoA measurements and produces a position within 5 m of
/// the ground truth.
///
/// APs are on a 1 m scale to keep matrix entries near-unity (the Neumann
/// series solver converges when the spectral radius of `I A` < 1, which
/// requires the matrix diagonal entries to be near 1).
#[test]
fn triangulation_small_scale_layout() {
// APs on a 1 m grid: (0,0), (1,0), (1,1), (0,1)
let ap_positions = vec![(0.0_f32, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
let c = 3e8_f32;
// Survivor off-centre: (0.35, 0.25)
let survivor = (0.35_f32, 0.25_f32);
let dist = |ap: (f32, f32)| -> f32 {
((survivor.0 - ap.0).powi(2) + (survivor.1 - ap.1).powi(2)).sqrt()
};
let tdoa =
|i: usize, j: usize| -> f32 { (dist(ap_positions[i]) - dist(ap_positions[j])) / c };
let measurements = vec![
(1, 0, tdoa(1, 0)),
(2, 0, tdoa(2, 0)),
(3, 0, tdoa(3, 0)),
(2, 1, tdoa(2, 1)),
];
// The result may be None if the Neumann series does not converge for
// this matrix scale (the solver has a finite iteration budget).
// What we verify is: if Some, the estimate is within 5 m of ground truth.
// The none path is also acceptable (tested separately).
match solve_triangulation(&measurements, &ap_positions) {
Some((est_x, est_y)) => {
let error = ((est_x - survivor.0).powi(2) + (est_y - survivor.1).powi(2)).sqrt();
assert!(
error < 5.0,
"estimated position ({est_x:.2}, {est_y:.2}) is more than 5 m from ground truth"
);
}
None => {
// Solver did not converge — acceptable given Neumann series limits.
// Verify the None case is handled gracefully (no panic).
}
}
}
#[test]
fn triangulation_too_few_measurements_returns_none() {
let ap_positions = vec![(0.0_f32, 0.0), (10.0, 0.0), (10.0, 10.0)];
let result = solve_triangulation(&[(0, 1, 1e-9), (1, 2, 1e-9)], &ap_positions);
assert!(
result.is_none(),
"fewer than 3 measurements must return None"
);
}
/// ADR-156 §finding 2 (security / DoS): crafted out-of-range AP indices in
/// TDoA measurements must NOT panic — they return `None`. Before the fix the
/// `ap_positions[i]` / `ap_positions[j]` indexing panicked on these inputs,
/// a remote-triggerable denial-of-service on a fusion path that can carry
/// network-sourced multistatic frames.
#[test]
fn triangulation_out_of_range_index_returns_none_no_panic() {
let ap_positions = vec![(0.0_f32, 0.0), (1.0, 0.0), (1.0, 1.0)];
// AP index 99 does not exist (3 APs ⇒ valid indices 0..=2).
let crafted = vec![(0, 99, 1e-9_f32), (1, 0, 1e-9), (2, 0, 1e-9)];
let result = solve_triangulation(&crafted, &ap_positions);
assert!(
result.is_none(),
"crafted out-of-range AP index must return None, not panic"
);
// Reference index out of range (i = 5).
let crafted2 = vec![(5, 0, 1e-9_f32), (1, 0, 1e-9), (2, 0, 1e-9)];
assert!(solve_triangulation(&crafted2, &ap_positions).is_none());
}
/// ADR-156 §finding 2: an empty AP table must return `None`, not panic on
/// `ap_positions[0]`.
#[test]
fn triangulation_empty_ap_positions_returns_none_no_panic() {
let empty: Vec<(f32, f32)> = Vec::new();
let measurements = vec![(0, 1, 1e-9_f32), (1, 2, 1e-9), (2, 0, 1e-9)];
assert!(
solve_triangulation(&measurements, &empty).is_none(),
"empty AP table must return None, not panic"
);
}
}