mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +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>
192 lines
6.3 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|