From c8c699a90ad6ce0747a9d4de3253205a30970ac1 Mon Sep 17 00:00:00 2001 From: forkwright Date: Fri, 7 Aug 2026 16:56:06 +0000 Subject: [PATCH 1/2] fix(semaino): bound per-cell convergence storage to one hit per domain ConvergenceGrid stored every ingested DomainHit in an unbounded per-cell Vec, so an attacker streaming frames at one coordinate grew that cell's memory at the input rate with no ceiling. detect() then cloned every windowed hit across every cell on each call, so per- signal work scaled with total in-window hits rather than the relevant cell's contents. No consumer reads more than one hit per domain -- domain_count only needs distinctness -- so each cell now retains at most one hit per of the <=8 domain discriminants (DomainSlots), independent of input volume. A later hit for an already-present domain replaces the earlier one rather than accumulating. Refs #223 --- crates/semaino/src/convergence.rs | 177 +++++++++++++++++++++++++++--- 1 file changed, 161 insertions(+), 16 deletions(-) diff --git a/crates/semaino/src/convergence.rs b/crates/semaino/src/convergence.rs index fe99b86..2e602b1 100644 --- a/crates/semaino/src/convergence.rs +++ b/crates/semaino/src/convergence.rs @@ -85,16 +85,78 @@ pub struct Convergence { // ConvergenceGrid // --------------------------------------------------------------------------- +/// Number of domain-discriminant slots a cell can hold. +/// +/// WHY(#223): [`kind_discriminant`] returns 0-6 for the seven known +/// [`SignalKind`] variants, plus sentinel 255 for any future +/// `#[non_exhaustive]` variant. Reserving one slot per known discriminant +/// (indices 0-6) and one shared slot for the sentinel (index 7) makes +/// per-cell storage exactly as expressive as `domain_count` ever reads -- +/// distinctness of discriminant values -- while fixing its size at +/// compile time. +const DOMAIN_SLOTS: usize = 8; + +/// Map a domain discriminant to its slot index in [`DOMAIN_SLOTS`]. +const fn slot_index(discriminant: u8) -> usize { + match discriminant { + 255 => DOMAIN_SLOTS - 1, + d => d as usize, + } +} + +/// Bounded per-cell observation state: one retained hit per domain. +/// +/// WHY(#223): the grid previously stored every ingested [`DomainHit`] in an +/// unbounded per-cell `Vec`, so an attacker who streams frames at one +/// coordinate grows that cell's memory at the input rate with no ceiling. No +/// consumer of [`Convergence`] reads more than one hit per domain -- +/// `domain_count` only needs distinctness -- so retaining the single most +/// recent hit per discriminant carries the same detection semantics at fixed +/// size. +#[derive(Debug, Clone, Default)] +struct DomainSlots([Option; DOMAIN_SLOTS]); + +impl DomainSlots { + /// Record `hit`, replacing any earlier hit for the same domain. + fn record(&mut self, hit: DomainHit) { + self.0[slot_index(kind_discriminant(&hit.kind))] = Some(hit); + } + + /// Hits at or after `cutoff_ms`, at most one per domain. + fn within_window(&self, cutoff_ms: i64) -> impl Iterator { + self.0 + .iter() + .filter_map(Option::as_ref) + .filter(move |h| h.timestamp.as_unix_millis() >= cutoff_ms) + } + + /// Drop hits older than `cutoff_ms`. Returns `true` if any slot remains. + fn evict(&mut self, cutoff_ms: i64) -> bool { + let mut any_remaining = false; + for slot in &mut self.0 { + if slot + .as_ref() + .is_some_and(|h| h.timestamp.as_unix_millis() < cutoff_ms) + { + *slot = None; + } + any_remaining |= slot.is_some(); + } + any_remaining + } +} + /// Spatial grid tracking per-cell domain observations. /// -/// Cells are keyed by [`GridCell`]. Each cell holds a [`Vec`] -/// ordered by insertion time. Callers must periodically call [`evict`] to -/// prevent unbounded growth. +/// Cells are keyed by [`GridCell`]. Each cell holds a bounded [`DomainSlots`] +/// (at most one retained hit per domain, independent of input volume — see +/// [`DomainSlots`]). Callers must periodically call [`evict`] to reclaim +/// cells whose hits have all aged out. /// /// [`evict`]: ConvergenceGrid::evict pub struct ConvergenceGrid { /// Per-cell observations. - cells: HashMap>, + cells: HashMap, /// Grid quantization factor (default 10 000 ≈ 10 m resolution). resolution: u32, } @@ -118,7 +180,7 @@ impl ConvergenceGrid { return; }; let cell = quantize(&coords, self.resolution); - self.cells.entry(cell).or_default().push(DomainHit { + self.cells.entry(cell).or_default().record(DomainHit { kind: signal.kind.clone(), timestamp: signal.timestamp, }); @@ -130,6 +192,11 @@ impl ConvergenceGrid { /// Only hits whose `timestamp` is `>= cutoff` (where `cutoff = now - window`) /// are considered. The caller supplies `now` so the function is pure and /// testable without a real clock. + /// + /// WHY(#223): work here is bounded by `cells.len() * DOMAIN_SLOTS` + /// regardless of how many signals were ingested — no cell can hold more + /// than [`DOMAIN_SLOTS`] hits, so a flood at one coordinate no longer + /// makes this scan more expensive. #[must_use] pub fn detect( &self, @@ -146,12 +213,8 @@ impl ConvergenceGrid { let mut result = Vec::new(); - for (&cell, hits) in &self.cells { - // Filter to hits within the time window. - let window_hits: Vec<&DomainHit> = hits - .iter() - .filter(|h| h.timestamp.as_unix_millis() >= cutoff_ms) - .collect(); + for (&cell, slots) in &self.cells { + let window_hits: Vec<&DomainHit> = slots.within_window(cutoff_ms).collect(); // Count distinct domain discriminants. let distinct = domain_count(&window_hits); @@ -159,7 +222,7 @@ impl ConvergenceGrid { let center = cell_center(cell, self.resolution); result.push(Convergence { center, - hits: window_hits.iter().map(|h| (*h).clone()).collect(), + hits: window_hits.into_iter().cloned().collect(), domain_count: distinct, }); } @@ -173,10 +236,7 @@ impl ConvergenceGrid { /// Cells that become empty after eviction are removed from the grid. pub fn evict(&mut self, older_than: Timestamp) { let cutoff = older_than.as_unix_millis(); - self.cells.retain(|_, hits| { - hits.retain(|h| h.timestamp.as_unix_millis() >= cutoff); - !hits.is_empty() - }); + self.cells.retain(|_, slots| slots.evict(cutoff)); } } @@ -501,6 +561,91 @@ mod tests { // ── full domain set ─────────────────────────────────────────────────────── + // ── #223: per-cell storage is bounded under a same-domain flood ───────── + + #[test] + fn single_domain_flood_does_not_grow_cell_storage() { + let mut grid = ConvergenceGrid::new(10_000); + let loc = coords(51.5, -0.1); + + for _ in 0..10_000 { + grid.ingest(&signal_at(rf_kind(), loc)); + } + + let cell = quantize(&loc, 10_000); + let slots = grid + .cells + .get(&cell) + .expect("cell must exist after at least one ingest"); + let occupied = slots.0.iter().filter(|s| s.is_some()).count(); + assert_eq!( + occupied, 1, + "10,000 same-domain signals at one coordinate must retain exactly \ + one slot, not grow with input volume" + ); + } + + #[test] + fn multi_domain_flood_stays_within_domain_slot_bound() { + let mut grid = ConvergenceGrid::new(10_000); + let loc = coords(51.5, -0.1); + let kinds = [ + rf_kind(), + mesh_kind(), + network_kind(), + proximity_kind(), + gps_kind(), + env_kind(), + osint_kind(), + ]; + + for i in 0..10_000 { + grid.ingest(&signal_at(kinds[i % kinds.len()].clone(), loc)); + } + + let cell = quantize(&loc, 10_000); + let slots = grid.cells.get(&cell).expect("cell must exist"); + let occupied = slots.0.iter().filter(|s| s.is_some()).count(); + assert!( + occupied <= DOMAIN_SLOTS, + "occupied slots ({occupied}) must never exceed the fixed bound \ + ({DOMAIN_SLOTS}) regardless of input volume" + ); + assert_eq!( + occupied, + kinds.len(), + "all seven distinct domains from the flood should still be represented" + ); + } + + #[test] + fn a_later_hit_for_the_same_domain_replaces_the_earlier_one() { + // WHY(#223): the whole point of bounding storage to one slot per + // domain is that a later hit for a domain already present replaces + // the earlier one rather than accumulating -- verify the *content* + // that survives is the latest, not just the count. + let mut grid = ConvergenceGrid::new(10_000); + let loc = coords(51.5, -0.1); + + let old_ts = Timestamp::from_unix_millis(Timestamp::now().as_unix_millis() - 5_000) + .expect("valid timestamp"); + grid.ingest(&GeoSignal::new(rf_kind(), old_ts, Some(loc))); + + let now = Timestamp::now(); + grid.ingest(&GeoSignal::new(rf_kind(), now, Some(loc))); + + let cell = quantize(&loc, 10_000); + let slots = grid.cells.get(&cell).expect("cell must exist"); + let rf_slot = slots.0[slot_index(kind_discriminant(&rf_kind()))] + .as_ref() + .expect("rf slot must be occupied"); + assert_eq!( + rf_slot.timestamp.as_unix_millis(), + now.as_unix_millis(), + "the surviving hit must be the latest one, not the first" + ); + } + #[test] fn seven_distinct_domains_all_detected() { let mut grid = ConvergenceGrid::new(10_000); From 9e4f0258b706163b7eee45fddc00588c3a9b52a0 Mon Sep 17 00:00:00 2001 From: forkwright Date: Fri, 7 Aug 2026 17:13:44 +0000 Subject: [PATCH 2/2] fix(semaino): fix borrow order and indexing-slicing in DomainSlots record() indexed with an expression that borrowed the hit being moved into the same statement, which the borrow checker rejects when evaluation order resolves the value side first. Splitting the index into its own binding fixes it independent of that ordering. Also replaces every `[]` array index this bounded-storage type introduced with `.get()`/`.get_mut()` -- clippy::indexing_slicing is denied workspace-wide (a panic-shaped operator in tooling that has to stay up under an active adversary). Refs #223 --- crates/semaino/src/convergence.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/semaino/src/convergence.rs b/crates/semaino/src/convergence.rs index 2e602b1..e5b2aec 100644 --- a/crates/semaino/src/convergence.rs +++ b/crates/semaino/src/convergence.rs @@ -119,7 +119,14 @@ struct DomainSlots([Option; DOMAIN_SLOTS]); impl DomainSlots { /// Record `hit`, replacing any earlier hit for the same domain. fn record(&mut self, hit: DomainHit) { - self.0[slot_index(kind_discriminant(&hit.kind))] = Some(hit); + let idx = slot_index(kind_discriminant(&hit.kind)); + // WHY: idx is derived from slot_index, which only ever returns + // 0..DOMAIN_SLOTS -- get_mut cannot fail, but indexing_slicing is + // denied workspace-wide, so this avoids the panic-shaped operator + // regardless. + if let Some(slot) = self.0.get_mut(idx) { + *slot = Some(hit); + } } /// Hits at or after `cutoff_ms`, at most one per domain. @@ -599,8 +606,8 @@ mod tests { osint_kind(), ]; - for i in 0..10_000 { - grid.ingest(&signal_at(kinds[i % kinds.len()].clone(), loc)); + for kind in kinds.iter().cycle().take(10_000) { + grid.ingest(&signal_at(kind.clone(), loc)); } let cell = quantize(&loc, 10_000); @@ -636,8 +643,11 @@ mod tests { let cell = quantize(&loc, 10_000); let slots = grid.cells.get(&cell).expect("cell must exist"); - let rf_slot = slots.0[slot_index(kind_discriminant(&rf_kind()))] - .as_ref() + let rf_idx = slot_index(kind_discriminant(&rf_kind())); + let rf_slot = slots + .0 + .get(rf_idx) + .and_then(Option::as_ref) .expect("rf slot must be occupied"); assert_eq!( rf_slot.timestamp.as_unix_millis(),