Files
ruvnet--RuView/v2/crates/ruview-swarm/src/failsafe/mod.rs
T
rUv 4a083999e5 security(ruview-swarm): fail-closed on NaN/Inf at the swarm-comm trust boundary + ADR-176 (#1096)
* 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>
2026-06-15 09:55:40 -04:00

192 lines
6.3 KiB
Rust

//! Fail-safe state machine: link loss, low battery, collision avoidance.
use crate::types::DroneState;
use serde::{Deserialize, Serialize};
use std::time::Instant;
/// Fail-safe operating state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FailSafeState {
Nominal,
AutonomousHold,
LowBatteryWarn,
ReturnToHome,
EmergencyLand,
EmergencyDiverge,
ControlledDescent,
}
/// State machine driving fail-safe transitions.
pub struct FailSafeMachine {
state: FailSafeState,
link_loss_start: Option<Instant>,
pub link_loss_hold_secs: f64,
pub link_loss_rth_secs: f64,
pub battery_warn_pct: f32,
pub battery_rth_pct: f32,
pub collision_dist_m: f64,
}
impl FailSafeMachine {
pub fn new() -> Self {
Self {
state: FailSafeState::Nominal,
link_loss_start: None,
link_loss_hold_secs: 3.0,
link_loss_rth_secs: 30.0,
battery_warn_pct: 20.0,
battery_rth_pct: 15.0,
collision_dist_m: 1.5,
}
}
/// Drive one tick. Returns the current state after evaluation.
pub fn tick(
&mut self,
state: &DroneState,
link_alive: bool,
nearest_neighbor_dist: f64,
) -> FailSafeState {
// Collision avoidance has highest priority.
//
// Fail CLOSED on a non-finite neighbour distance. `nearest_neighbor_dist`
// is derived from peer positions (see
// `SwarmOrchestrator::nearest_peer_distance`), which arrive over the
// untrusted swarm comm layer as `DroneState` values whose f64 position
// fields can deserialize to NaN/Inf. A naive `NaN < collision_dist_m`
// evaluates to `false`, silently DISABLING collision avoidance — the
// worst possible failure for a physical drone. Treat a non-finite
// distance as "too close" so the swarm diverges rather than trusting a
// poisoned reading.
if !nearest_neighbor_dist.is_finite() || nearest_neighbor_dist < self.collision_dist_m {
self.state = FailSafeState::EmergencyDiverge;
return self.state.clone();
}
// Link loss handling
if !link_alive {
let start = self.link_loss_start.get_or_insert_with(Instant::now);
let elapsed = start.elapsed().as_secs_f64();
if elapsed > self.link_loss_rth_secs {
self.state = FailSafeState::ReturnToHome;
} else if elapsed > self.link_loss_hold_secs {
self.state = FailSafeState::AutonomousHold;
}
return self.state.clone();
} else {
// Link restored
self.link_loss_start = None;
if self.state == FailSafeState::AutonomousHold {
self.state = FailSafeState::Nominal;
}
}
// Battery checks. A non-finite battery reading (NaN/Inf from a corrupt or
// forged telemetry/peer message) must fail CLOSED: `NaN <= threshold` is
// `false`, which would otherwise let a drone with an unknown battery
// level keep flying nominally. Treat a non-finite reading as critical.
if !state.battery_pct.is_finite() || state.battery_pct <= self.battery_rth_pct {
self.state = FailSafeState::ReturnToHome;
} else if state.battery_pct <= self.battery_warn_pct {
self.state = FailSafeState::LowBatteryWarn;
} else if self.state == FailSafeState::LowBatteryWarn {
// Recovered from low battery (charged on the fly / wrong reading)
self.state = FailSafeState::Nominal;
}
self.state.clone()
}
pub fn current(&self) -> &FailSafeState {
&self.state
}
pub fn force_land(&mut self) {
self.state = FailSafeState::EmergencyLand;
}
}
impl Default for FailSafeMachine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::NodeId;
fn good_state() -> DroneState {
let mut s = DroneState::default_at_origin(NodeId(1));
s.battery_pct = 80.0;
s.link_quality = 1.0;
s
}
#[test]
fn test_nominal_when_healthy() {
let mut fsm = FailSafeMachine::new();
let s = good_state();
let result = fsm.tick(&s, true, 10.0);
assert_eq!(result, FailSafeState::Nominal);
}
#[test]
fn test_low_battery_warn() {
let mut fsm = FailSafeMachine::new();
let mut s = good_state();
s.battery_pct = 18.0;
let result = fsm.tick(&s, true, 10.0);
assert_eq!(result, FailSafeState::LowBatteryWarn);
}
#[test]
fn test_battery_rth() {
let mut fsm = FailSafeMachine::new();
let mut s = good_state();
s.battery_pct = 10.0;
let result = fsm.tick(&s, true, 10.0);
assert_eq!(result, FailSafeState::ReturnToHome);
}
#[test]
fn test_collision_avoidance() {
let mut fsm = FailSafeMachine::new();
let s = good_state();
let result = fsm.tick(&s, true, 0.5); // too close
assert_eq!(result, FailSafeState::EmergencyDiverge);
}
/// Security: a NaN neighbour distance (poisoned peer position over the swarm
/// comm layer) must NOT silently disable collision avoidance. Fails on old
/// code where `NaN < collision_dist_m` is `false` and the state stays Nominal.
#[test]
fn test_nan_neighbor_distance_fails_closed_to_diverge() {
let mut fsm = FailSafeMachine::new();
let s = good_state();
let result = fsm.tick(&s, true, f64::NAN);
assert_eq!(
result,
FailSafeState::EmergencyDiverge,
"non-finite neighbour distance must fail closed to EmergencyDiverge"
);
}
/// Security: a NaN battery reading must fail closed to ReturnToHome rather
/// than being treated as a healthy battery. Fails on old code where
/// `NaN <= battery_rth_pct` is `false` and the drone stays Nominal.
#[test]
fn test_nan_battery_fails_closed_to_rth() {
let mut fsm = FailSafeMachine::new();
let mut s = good_state();
s.battery_pct = f32::NAN;
let result = fsm.tick(&s, true, 10.0);
assert_eq!(
result,
FailSafeState::ReturnToHome,
"non-finite battery must fail closed to ReturnToHome"
);
}
}