mirror of
https://github.com/ruvnet/RuView
synced 2026-08-05 19:41:44 +00:00
4a083999e5
* fix(ruview-swarm): fail-closed on NaN/Inf at swarm-comm trust boundary (ADR-148)
Beyond-SOTA security review of the ADR-148 drone swarm control plane found
four IEEE-754 NaN/Inf fail-open / DoS bugs on data crossing the untrusted
swarm-comm boundary (receive_peer_state / receive_peer_detection accept full
DroneState/CsiDetection whose f64/f32 fields deserialize with no finite-check).
- HIGH: failsafe::tick collision-avoidance + battery checks fail-open on NaN
(NaN < threshold == false silently disabled collision avoidance / kept a
NaN-battery drone Nominal). Now fails closed to EmergencyDiverge / RTH.
- MED: geofence::check NaN-altitude bypass returned Safe through the
point-in-polygon path. Now leading non-finite-coordinate guard -> HardBreach.
- MED/DoS: antijamming FhssRadio panicked with "% 0" on an empty deserialized
channels_mhz. Now len==0 early-returns (benign 0.0 sentinel).
- LOW: multiview::fuse propagated a NaN victim_position into the fused
"confirmed victim" location. Now requires finite confidence + position.
Each fix pinned by a fails-on-old / passes-on-new test (MEASURED: old code
returned Nominal/Safe or panicked). cargo test -p ruview-swarm
--no-default-features: 117 -> 123 passed, 0 failed. Workspace green; Python
deterministic proof unchanged (f8e76f21...46f7a, off the signal path).
Documented-not-fixed (ADR slot 176): Raft AppendEntries lacks Log-Matching
consistency check (topology/raft.rs); MavlinkSigner::verify uses non-constant
-time tag compare + no replay-window rejection (already doc-flagged).
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr): ADR-176 — ruview-swarm NaN-fail-open safety review
Records the 4 MEASURED fail-open safety bugs fixed in f671000d7 (collision
avoidance, battery RTH, geofence, anti-jamming %0 panic — all NaN/Inf
defeating a safety comparison at the swarm-comm trust boundary) + 6 pins,
5 clean-with-evidence dimensions, and the 2 genuine issues deferred to a
focused follow-up (Raft AppendEntries log-matching; MAVLink signer
constant-time + replay window).
Co-Authored-By: claude-flow <ruv@ruv.net>
185 lines
5.9 KiB
Rust
185 lines
5.9 KiB
Rust
//! Geofence: polygon boundary with hard/soft margins.
|
|
|
|
use crate::types::Position3D;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Polygon geofence with altitude bounds.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Geofence {
|
|
/// Polygon vertices (x, y) in local NED metres.
|
|
pub boundary: Vec<(f64, f64)>,
|
|
pub min_altitude_m: f64,
|
|
pub max_altitude_m: f64,
|
|
/// Hard margin: triggers RTH immediately.
|
|
pub hard_margin_m: f64,
|
|
/// Soft margin: triggers warning + speed reduction.
|
|
pub soft_margin_m: f64,
|
|
}
|
|
|
|
/// Result of a geofence check.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum GeofenceResult {
|
|
Safe,
|
|
SoftWarning { distance_to_boundary_m: f64 },
|
|
HardBreach,
|
|
}
|
|
|
|
impl Geofence {
|
|
/// Check a position against this geofence.
|
|
pub fn check(&self, pos: &Position3D) -> GeofenceResult {
|
|
// Fail CLOSED on a non-finite position. A NaN/Inf component (from a
|
|
// corrupt GPS/EKF estimate or a forged position) makes every subsequent
|
|
// comparison false: `NaN < min || NaN > max` is `false`, so the altitude
|
|
// breach is skipped, and a NaN altitude with otherwise-valid x/y would
|
|
// return `Safe` — a silent geofence bypass on a flight-safety boundary.
|
|
// Treat any non-finite coordinate as a hard breach.
|
|
if !pos.x.is_finite() || !pos.y.is_finite() || !pos.z.is_finite() {
|
|
return GeofenceResult::HardBreach;
|
|
}
|
|
|
|
let altitude_m = -pos.z; // NED: negative z = altitude above ground
|
|
|
|
// Altitude check
|
|
if altitude_m < self.min_altitude_m || altitude_m > self.max_altitude_m {
|
|
return GeofenceResult::HardBreach;
|
|
}
|
|
|
|
let inside = self.point_in_polygon(pos.x, pos.y);
|
|
let dist = self.distance_to_boundary(pos.x, pos.y);
|
|
|
|
if !inside {
|
|
return GeofenceResult::HardBreach;
|
|
}
|
|
|
|
if dist <= self.hard_margin_m {
|
|
GeofenceResult::HardBreach
|
|
} else if dist <= self.soft_margin_m {
|
|
GeofenceResult::SoftWarning { distance_to_boundary_m: dist }
|
|
} else {
|
|
GeofenceResult::Safe
|
|
}
|
|
}
|
|
|
|
/// Ray-casting algorithm: even number of crossings = outside.
|
|
fn point_in_polygon(&self, x: f64, y: f64) -> bool {
|
|
let n = self.boundary.len();
|
|
if n < 3 {
|
|
return false;
|
|
}
|
|
let mut inside = false;
|
|
let mut j = n - 1;
|
|
for i in 0..n {
|
|
let (xi, yi) = self.boundary[i];
|
|
let (xj, yj) = self.boundary[j];
|
|
if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
|
|
inside = !inside;
|
|
}
|
|
j = i;
|
|
}
|
|
inside
|
|
}
|
|
|
|
/// Minimum distance from (x, y) to any boundary edge.
|
|
fn distance_to_boundary(&self, x: f64, y: f64) -> f64 {
|
|
let n = self.boundary.len();
|
|
if n == 0 {
|
|
return f64::INFINITY;
|
|
}
|
|
let mut min_dist = f64::INFINITY;
|
|
let mut j = n - 1;
|
|
for i in 0..n {
|
|
let (ax, ay) = self.boundary[j];
|
|
let (bx, by) = self.boundary[i];
|
|
let dist = point_to_segment_dist(x, y, ax, ay, bx, by);
|
|
if dist < min_dist {
|
|
min_dist = dist;
|
|
}
|
|
j = i;
|
|
}
|
|
min_dist
|
|
}
|
|
}
|
|
|
|
fn point_to_segment_dist(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
|
|
let dx = bx - ax;
|
|
let dy = by - ay;
|
|
let len_sq = dx * dx + dy * dy;
|
|
if len_sq < 1e-12 {
|
|
return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt();
|
|
}
|
|
let t = ((px - ax) * dx + (py - ay) * dy) / len_sq;
|
|
let t = t.clamp(0.0, 1.0);
|
|
let cx = ax + t * dx;
|
|
let cy = ay + t * dy;
|
|
((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn square_fence() -> Geofence {
|
|
Geofence {
|
|
boundary: vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)],
|
|
min_altitude_m: 0.0,
|
|
max_altitude_m: 120.0,
|
|
hard_margin_m: 10.0,
|
|
soft_margin_m: 25.0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_centre_is_safe() {
|
|
let f = square_fence();
|
|
let pos = Position3D { x: 50.0, y: 50.0, z: -30.0 };
|
|
assert_eq!(f.check(&pos), GeofenceResult::Safe);
|
|
}
|
|
|
|
#[test]
|
|
fn test_outside_is_hard_breach() {
|
|
let f = square_fence();
|
|
let pos = Position3D { x: 150.0, y: 50.0, z: -30.0 };
|
|
assert_eq!(f.check(&pos), GeofenceResult::HardBreach);
|
|
}
|
|
|
|
#[test]
|
|
fn test_near_edge_is_soft_warning() {
|
|
let f = square_fence();
|
|
// 15m from boundary → beyond hard (10m) but within soft (25m)
|
|
let pos = Position3D { x: 15.0, y: 50.0, z: -30.0 };
|
|
assert!(matches!(f.check(&pos), GeofenceResult::SoftWarning { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_altitude_breach() {
|
|
let f = square_fence();
|
|
let pos = Position3D { x: 50.0, y: 50.0, z: -200.0 }; // 200m altitude
|
|
assert_eq!(f.check(&pos), GeofenceResult::HardBreach);
|
|
}
|
|
|
|
/// Security: a NaN altitude with an otherwise in-bounds x/y must fail closed
|
|
/// to HardBreach. Fails on old code where `NaN < min || NaN > max` is `false`,
|
|
/// the altitude check is skipped, and the point-in-polygon path returns Safe —
|
|
/// a silent geofence bypass.
|
|
#[test]
|
|
fn test_nan_altitude_fails_closed() {
|
|
let f = square_fence();
|
|
let pos = Position3D { x: 50.0, y: 50.0, z: f64::NAN };
|
|
assert_eq!(f.check(&pos), GeofenceResult::HardBreach);
|
|
}
|
|
|
|
/// Security: NaN/Inf horizontal coordinates must also fail closed.
|
|
#[test]
|
|
fn test_nonfinite_horizontal_fails_closed() {
|
|
let f = square_fence();
|
|
assert_eq!(
|
|
f.check(&Position3D { x: f64::NAN, y: 50.0, z: -30.0 }),
|
|
GeofenceResult::HardBreach
|
|
);
|
|
assert_eq!(
|
|
f.check(&Position3D { x: 50.0, y: f64::INFINITY, z: -30.0 }),
|
|
GeofenceResult::HardBreach
|
|
);
|
|
}
|
|
}
|