bacnet_network/router_table.rs
1//! Router table — maps BACnet network numbers to transport ports.
2//!
3//! Per ASHRAE 135-2020 Clause 6.4, a BACnet router maintains a routing table
4//! that records which directly-connected or learned networks can be reached
5//! via which port.
6
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10use bacnet_types::MacAddr;
11
12// Align with the existing 30s reject-busy deadline: at most one accepted
13// reject-driven change per (ingress port, network) per window without fresh
14// learning. A legitimate unreachable-after-busy signal may wait up to 30s.
15const HOLD_DOWN: Duration = Duration::from_secs(30);
16
17// Repeat-based local hardening, aligned with the existing flap window.
18const CORROBORATION_WINDOW: Duration = Duration::from_secs(60);
19
20/// Count-only outcomes for routing claims handled by one router table.
21///
22/// All totals saturate at `u64::MAX`, never affect routing decisions, and expose
23/// no peer identities. Learning totals cover inspected I-Am-Router and
24/// Initialize-Routing-Table-Ack entries, not manual table edits, management
25/// Initialize-Routing-Table writes, or other message types. Existing cap-triggered early stops remain: their unexamined suffixes
26/// are not classified or counted. Flap warnings are counted at their emission.
27/// Pending replacements are not successful learning until corroborated. Disconnect
28/// totals count each well-formed ignored removal request, even for absent/direct
29/// routes; malformed messages are not counted.
30/// Duplicate cross-port entries within one message supply no extra vote or count.
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32pub struct RoutingClaimSnapshot {
33 /// Rejects that changed a learned route's effective state or removed it.
34 pub reject_applied: u64,
35 /// Rejects ignored as no-ops or during hold-down (counted once each).
36 pub reject_dampened: u64,
37 /// No-op rejects, including missing routes and directly-connected immunity.
38 /// Busy duplicates do not extend the existing busy deadline.
39 pub reject_dampened_same_state: u64,
40 /// State-changing rejects ignored within 30s of this key's last applied one.
41 pub reject_dampened_hold_down: u64,
42 /// Inspected learning claims that inserted or refreshed a learned route.
43 pub learned_ok: u64,
44 /// Inspected entries that triggered the existing route-cap early stop.
45 pub learned_cap_ignored: u64,
46 /// Flap warnings emitted at the existing port-change threshold.
47 pub flap_warned: u64,
48 /// Cross-port claims held pending, including new challengers and expiry restarts.
49 pub pending_started: u64,
50 /// Replacements applied on a second same-network/port claim within 60s inclusive.
51 pub corroborated_applied: u64,
52 /// Pending slots found older than 60s and discarded on the next learning claim.
53 /// Non-expiry cleanup (removal, aging, manual edits or refresh) does not count.
54 pub pending_expired: u64,
55 /// Disconnect-Connection-To-Network removal requests ignored (PTP unsupported).
56 pub disconnect_removal_ignored: u64,
57}
58
59#[derive(Debug, Clone)]
60struct PendingReplacement {
61 port_index: usize,
62 first_seen: Instant,
63}
64
65/// Reachability status of a route entry.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ReachabilityStatus {
68 /// Route is available for traffic.
69 Reachable,
70 /// Route is temporarily unreachable due to congestion (Router-Busy).
71 Busy,
72 /// Route has permanently failed.
73 Unreachable,
74}
75
76/// A route entry in the router table.
77///
78/// Freshness is split (RB-06): `last_seen` is control-plane confirmation
79/// (learning claims and management applies only), while `last_used` is
80/// data-plane forwarding use. Expiry keys off `last_seen` only so sustained
81/// traffic cannot manufacture fresh route evidence. Busy/Available marks
82/// touch neither timestamp.
83#[derive(Debug, Clone)]
84pub struct RouteEntry {
85 /// Index of the port this network is reachable through.
86 pub port_index: usize,
87 /// Whether this is a directly-connected network (vs learned via another router).
88 pub directly_connected: bool,
89 /// MAC address of the next-hop router (empty for directly-connected networks).
90 pub next_hop_mac: MacAddr,
91 /// When this learned route was last confirmed. `None` for direct routes.
92 pub last_seen: Option<Instant>,
93 /// When this route was last used for forwarding. `None` until first use
94 /// or after a port change installs an unused path. Never extends expiry.
95 pub last_used: Option<Instant>,
96 /// Recorded reachability state of this route.
97 pub reachability: ReachabilityStatus,
98 /// Deadline after which a `Busy` status auto-clears (spec 6.6.3.6).
99 pub busy_until: Option<Instant>,
100 /// Number of times this route changed ports within the flap detection window.
101 pub flap_count: u8,
102 /// When the route last changed ports.
103 pub last_port_change: Option<Instant>,
104}
105
106/// Convergence mode for learned cross-port moves (RB-06).
107///
108/// Standard (default) applies the 135-2020 6.6.3.2 last-wins rule immediately:
109/// the router takes every fresh advertisement as a topology change and adopts
110/// it on arrival. Hardened is an explicit opt-in local restriction that
111/// holds cross-port moves pending for a second same-(network, port) claim
112/// within 60s; it does not authenticate the claim and can delay legitimate
113/// convergence (alternation resets the slot, single-shot advertisers wait for
114/// the 60s reaper plus a fresh claim).
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum ConvergenceMode {
117 /// Immediate single-claim convergence (spec default).
118 #[default]
119 Standard,
120 /// K=2/60s corroboration gate (explicit opt-in hardening).
121 Hardened,
122}
123
124/// BACnet routing table.
125///
126/// Maps network numbers to the port through which they can be reached.
127/// Standard convergence ([`ConvergenceMode::Standard`]) is the default;
128/// hardened corroboration is an explicit opt-in via [`Self::new_hardened`]
129/// or [`Self::set_convergence`].
130#[derive(Debug, Clone)]
131pub struct RouterTable {
132 /// Network number → route entry.
133 routes: HashMap<u16, RouteEntry>,
134 // Network → (ingress port → last APPLIED reject). Grouping by network makes
135 // re-arming on learning/removal local. Only live learned routes retain
136 // records, at most one per router ingress port; absent/direct spam allocates
137 // nothing. Neither peer MACs nor advertised source addresses are keys.
138 reject_transitions: HashMap<u16, HashMap<usize, Instant>>,
139 // One challenger per live learned network, never keyed by peer identity.
140 // Thus pending entries <= live learned routes. Every route replacement,
141 // removal and mutable-entry handoff clears its slot; expiry is checked
142 // lazily on the next claim for that network plus the periodic reaper
143 // (`expire_pending_at`, driven by the router aging task).
144 pending_replacements: HashMap<u16, PendingReplacement>,
145 claim_counters: RoutingClaimSnapshot,
146 convergence: ConvergenceMode,
147}
148
149impl RouterTable {
150 /// Create an empty routing table with standard single-claim convergence.
151 pub fn new() -> Self {
152 Self {
153 routes: HashMap::new(),
154 reject_transitions: HashMap::new(),
155 pending_replacements: HashMap::new(),
156 claim_counters: RoutingClaimSnapshot::default(),
157 convergence: ConvergenceMode::Standard,
158 }
159 }
160
161 /// Create an empty routing table with the hardened K=2/60s gate enabled.
162 ///
163 /// Explicit opt-in only: cross-port learned moves wait for a second
164 /// same-(network, port) claim within 60s, with flap warnings retained.
165 /// Alternating challengers reset the slot and single-shot advertisers
166 /// need the reaper plus a fresh claim to converge.
167 pub fn new_hardened() -> Self {
168 let mut table = Self::new();
169 table.convergence = ConvergenceMode::Hardened;
170 table
171 }
172
173 /// Select the convergence mode. Switching to standard clears no routes
174 /// but leaves any hardened pending slots to lazy/reaper expiry; switching
175 /// to hardened keeps installed routes and applies the gate to later claims.
176 pub fn set_convergence(&mut self, mode: ConvergenceMode) {
177 self.convergence = mode;
178 }
179
180 /// Current convergence mode.
181 pub fn convergence(&self) -> ConvergenceMode {
182 self.convergence
183 }
184
185 /// Number of pending hardened challengers (bounded by live learned routes).
186 pub fn pending_count(&self) -> usize {
187 self.pending_replacements.len()
188 }
189
190 /// Read a consistent, non-resetting copy of this table's routing-claim totals.
191 ///
192 /// For a live router, call `router.table().lock().await.claim_snapshot()`.
193 /// The snapshot owns only counts and remains readable after table drop;
194 /// cloning the table copies, rather than shares, its counters and records.
195 pub fn claim_snapshot(&self) -> RoutingClaimSnapshot {
196 self.claim_counters
197 }
198
199 pub(crate) fn record_learned(&mut self) {
200 self.claim_counters.learned_ok = self.claim_counters.learned_ok.saturating_add(1);
201 }
202
203 pub(crate) fn record_learning_cap(&mut self) {
204 self.claim_counters.learned_cap_ignored =
205 self.claim_counters.learned_cap_ignored.saturating_add(1);
206 }
207
208 pub(crate) fn record_disconnect_removal_ignored(&mut self) {
209 self.claim_counters.disconnect_removal_ignored = self
210 .claim_counters
211 .disconnect_removal_ignored
212 .saturating_add(1);
213 }
214
215 /// Apply an I-Am-Router / Init-ACK learning claim.
216 ///
217 /// Standard mode applies immediately (135-2020 6.6.3.2 last-wins) with
218 /// flap warnings retained. Hardened mode gates cross-port learned moves
219 /// only: they need two claims for the same (network, new port) within
220 /// 60s inclusive. The caller holds the table lock across this synchronous
221 /// gate and update. Absent learning and current-port refreshes are
222 /// immediate in both modes. `now` makes the inclusive 60s boundary
223 /// testable without sleeping. Standard clears any stale challenger for
224 /// the network without counting it; hardened counts starts/applies/expiry.
225 pub(crate) fn apply_learning_claim(
226 &mut self,
227 network: u16,
228 port_index: usize,
229 next_hop_mac: MacAddr,
230 now: Instant,
231 ) -> bool {
232 if self.convergence == ConvergenceMode::Standard {
233 self.pending_replacements.remove(&network);
234 return self.add_learned_with_flap_detection_at(network, port_index, next_hop_mac, now);
235 }
236 let pending = self
237 .pending_replacements
238 .remove(&network)
239 .filter(|pending| {
240 if now.duration_since(pending.first_seen) > CORROBORATION_WINDOW {
241 self.claim_counters.pending_expired =
242 self.claim_counters.pending_expired.saturating_add(1);
243 false
244 } else {
245 true
246 }
247 });
248 if self
249 .routes
250 .get(&network)
251 .is_some_and(|entry| !entry.directly_connected && entry.port_index != port_index)
252 {
253 if pending.is_some_and(|pending| pending.port_index == port_index) {
254 // K=2: the second claim supplies the next hop, even if its MAC
255 // differs. Neither source identity nor message type is a key.
256 self.claim_counters.corroborated_applied =
257 self.claim_counters.corroborated_applied.saturating_add(1);
258 } else {
259 self.pending_replacements.insert(
260 network,
261 PendingReplacement {
262 port_index,
263 first_seen: now,
264 },
265 );
266 self.claim_counters.pending_started =
267 self.claim_counters.pending_started.saturating_add(1);
268 debug_assert!(self.pending_replacements.keys().all(|net| self
269 .routes
270 .get(net)
271 .is_some_and(|entry| !entry.directly_connected)));
272 return false; // Keep all old route state, including forwarding and age.
273 }
274 }
275 self.add_learned_with_flap_detection_at(network, port_index, next_hop_mac, now)
276 }
277
278 /// Discard hardened pending slots older than 60s at `now`.
279 ///
280 /// Periodic reaper for idle routes: lazy expiry alone cannot clear a slot
281 /// with no further claims. Counts one `pending_expired` per discarded
282 /// slot; other cleanup (removal, aging, manual edits, refresh) does not
283 /// count. Returns the discarded network numbers in ascending order.
284 pub fn expire_pending_at(&mut self, now: Instant) -> Vec<u16> {
285 let mut expired: Vec<u16> = self
286 .pending_replacements
287 .iter()
288 .filter(|(_, pending)| now.duration_since(pending.first_seen) > CORROBORATION_WINDOW)
289 .map(|(net, _)| *net)
290 .collect();
291 expired.sort_unstable();
292 for net in &expired {
293 self.pending_replacements.remove(net);
294 self.claim_counters.pending_expired =
295 self.claim_counters.pending_expired.saturating_add(1);
296 }
297 expired
298 }
299
300 /// Apply only the reject-driven table transition, never its relay.
301 /// The caller holds the table lock across this synchronous gate and update.
302 /// `now` is supplied so deadline boundaries can be tested without sleeping.
303 pub(crate) fn apply_reject(
304 &mut self,
305 network: u16,
306 ingress_port: usize,
307 reason: u8,
308 now: Instant,
309 ) {
310 let changes_state = self.routes.get(&network).is_some_and(|entry| {
311 if entry.directly_connected {
312 return false;
313 }
314 // Compare effective state, not the stored Busy marker: a busy
315 // deadline may have elapsed before the next aging sweep.
316 match reason {
317 1 => entry.reachability != ReachabilityStatus::Unreachable,
318 2 => {
319 // A Busy (flow-control) claim never lifts permanent
320 // Unreachable: the route is already beyond reachable, so
321 // there is no change to apply, dampen, or record — only
322 // Available or fresh learning exits Unreachable. An
323 // unexpired Busy is likewise already in effect.
324 entry.reachability != ReachabilityStatus::Unreachable
325 && (entry.reachability != ReachabilityStatus::Busy
326 || entry.busy_until.is_some_and(|deadline| now >= deadline))
327 }
328 _ => true,
329 }
330 });
331 let held_down = self
332 .reject_transitions
333 .get(&network)
334 .and_then(|ports| ports.get(&ingress_port))
335 .is_some_and(|last| now.duration_since(*last) < HOLD_DOWN);
336 if !changes_state || held_down {
337 let counters = &mut self.claim_counters;
338 counters.reject_dampened = counters.reject_dampened.saturating_add(1);
339 if !changes_state {
340 counters.reject_dampened_same_state =
341 counters.reject_dampened_same_state.saturating_add(1);
342 } else {
343 counters.reject_dampened_hold_down =
344 counters.reject_dampened_hold_down.saturating_add(1);
345 }
346 return; // Ignored claims never slide the window or busy deadline.
347 }
348
349 match reason {
350 1 => self.mark_unreachable(network),
351 2 => self.mark_busy(network, now + Duration::from_secs(30)),
352 _ => {
353 self.remove(network); // Removal also discards every port's record.
354 }
355 }
356 if self.routes.contains_key(&network) {
357 self.reject_transitions
358 .entry(network)
359 .or_default()
360 .insert(ingress_port, now);
361 }
362 self.claim_counters.reject_applied = self.claim_counters.reject_applied.saturating_add(1);
363 }
364
365 /// Add a directly-connected network on the given port.
366 /// Network 0 and 0xFFFF are reserved and will be silently ignored.
367 pub fn add_direct(&mut self, network: u16, port_index: usize) {
368 if network == 0 || network == 0xFFFF {
369 return;
370 }
371 self.reject_transitions.remove(&network);
372 self.pending_replacements.remove(&network);
373 self.routes.insert(
374 network,
375 RouteEntry {
376 port_index,
377 directly_connected: true,
378 next_hop_mac: MacAddr::new(),
379 last_seen: None,
380 last_used: None,
381 reachability: ReachabilityStatus::Reachable,
382 busy_until: None,
383 flap_count: 0,
384 last_port_change: None,
385 },
386 );
387 }
388
389 /// Add a learned route (network reachable via a next-hop router on the given port).
390 /// Network 0 and 0xFFFF are reserved and will be silently ignored.
391 /// Does not overwrite direct routes. Control-plane confirmation: sets
392 /// `last_seen`; preserves `last_used` on a same-port refresh, resets it
393 /// on a new or moved path.
394 pub fn add_learned(&mut self, network: u16, port_index: usize, next_hop_mac: MacAddr) {
395 self.add_learned_at(network, port_index, next_hop_mac, Instant::now());
396 }
397
398 /// Injectable-time variant of [`Self::add_learned`] for deterministic tests.
399 pub fn add_learned_at(
400 &mut self,
401 network: u16,
402 port_index: usize,
403 next_hop_mac: MacAddr,
404 now: Instant,
405 ) {
406 if network == 0 || network == 0xFFFF {
407 return;
408 }
409 let preserved_used = self.routes.get(&network).and_then(|existing| {
410 if existing.directly_connected {
411 None
412 } else if existing.port_index == port_index {
413 existing.last_used
414 } else {
415 None
416 }
417 });
418 if self
419 .routes
420 .get(&network)
421 .is_some_and(|existing| existing.directly_connected)
422 {
423 return; // never overwrite direct routes
424 }
425 self.reject_transitions.remove(&network);
426 self.pending_replacements.remove(&network);
427 self.routes.insert(
428 network,
429 RouteEntry {
430 port_index,
431 directly_connected: false,
432 next_hop_mac,
433 last_seen: Some(now),
434 last_used: preserved_used,
435 reachability: ReachabilityStatus::Reachable,
436 busy_until: None,
437 flap_count: 0,
438 last_port_change: None,
439 },
440 );
441 }
442
443 /// Wire Port ID for a dispatch port index (135-2020 6.4.7, Fig. 6-11).
444 ///
445 /// A zero Port ID instructs the receiver to delete every row it holds for
446 /// that DNET, so 0 is a removal trigger, never a real port. The stable
447 /// mapping is `port_index + 1`; `None` when the index cannot fit the
448 /// 1-octet field (unreachable for a bounded port vector, handled by
449 /// skipping the entry on encode).
450 pub fn wire_port_id(port_index: usize) -> Option<u8> {
451 u8::try_from(port_index.checked_add(1)?).ok()
452 }
453
454 /// Dispatch port index for a wire Port ID, given the local port count.
455 ///
456 /// Inverse of [`Self::wire_port_id`]: Port ID 0 (the purge trigger) and
457 /// IDs past the local port count map to `None`; the caller applies purge
458 /// versus unknown-port skip semantics.
459 pub fn port_index_for_wire_id(port_id: u8, port_count: usize) -> Option<usize> {
460 usize::from(port_id)
461 .checked_sub(1)
462 .filter(|index| *index < port_count)
463 }
464
465 /// Network numbers in ascending order for deterministic query replies.
466 pub fn sorted_networks(&self) -> Vec<u16> {
467 let mut networks = self.networks();
468 networks.sort_unstable();
469 networks
470 }
471
472 /// Apply one Initialize-Routing-Table update entry (135-2020 6.6.3.8).
473 ///
474 /// Each entry is an upsert keyed by DNET (see also 6.4.7): the supplied
475 /// route takes the place of whatever row the table holds for that
476 /// network, and a network the table lacks gets a fresh row. Management
477 /// writes install learned routes and never direct ones — direct
478 /// attachments stay locally configured until RB-09 authorization — so an
479 /// existing direct entry is left untouched and reports `false`. Reserved
480 /// networks are skipped. Replacement clears pending/reject records exactly
481 /// like fresh learning.
482 pub(crate) fn apply_management_update(
483 &mut self,
484 network: u16,
485 port_index: usize,
486 next_hop_mac: MacAddr,
487 ) -> bool {
488 if network == 0 || network == 0xFFFF {
489 return false;
490 }
491 if self
492 .routes
493 .get(&network)
494 .is_some_and(|entry| entry.directly_connected)
495 {
496 return false;
497 }
498 self.add_learned(network, port_index, next_hop_mac);
499 true
500 }
501
502 /// Apply one Initialize-Routing-Table purge entry (Port ID 0, 6.4.7).
503 ///
504 /// Port ID 0 asks for every row naming that DNET to be deleted; here that
505 /// is scoped to learned entries only, for the same direct-safety
506 /// reason as [`Self::apply_management_update`]. Returns `true` only when
507 /// a learned entry was actually removed.
508 pub(crate) fn apply_management_removal(&mut self, network: u16) -> bool {
509 if network == 0 || network == 0xFFFF {
510 return false;
511 }
512 if self
513 .routes
514 .get(&network)
515 .is_some_and(|entry| entry.directly_connected)
516 {
517 return false;
518 }
519 self.remove(network).is_some()
520 }
521
522 /// Add a learned route immediately (manual table update, without corroboration).
523 /// Detects rapid port changes for operator visibility but never suppresses updates.
524 ///
525 /// Returns `true` if the route was inserted/updated.
526 pub fn add_learned_with_flap_detection(
527 &mut self,
528 network: u16,
529 port_index: usize,
530 next_hop_mac: MacAddr,
531 ) -> bool {
532 self.add_learned_with_flap_detection_at(network, port_index, next_hop_mac, Instant::now())
533 }
534
535 /// Injectable-time variant of [`Self::add_learned_with_flap_detection`].
536 ///
537 /// A port change installs `last_seen = now` with `last_used = None` (the
538 /// new path has not forwarded yet); a same-port refresh preserves
539 /// `last_used` via [`Self::add_learned_at`].
540 pub fn add_learned_with_flap_detection_at(
541 &mut self,
542 network: u16,
543 port_index: usize,
544 next_hop_mac: MacAddr,
545 now: Instant,
546 ) -> bool {
547 if network == 0 || network == 0xFFFF {
548 return false;
549 }
550 if let Some(existing) = self.routes.get(&network) {
551 if existing.directly_connected {
552 return false;
553 }
554 if existing.port_index != port_index {
555 let flap_count = match existing.last_port_change {
556 Some(changed) if now.duration_since(changed) < Duration::from_secs(60) => {
557 existing.flap_count.saturating_add(1)
558 }
559 _ => 1,
560 };
561 if flap_count >= 3 {
562 self.claim_counters.flap_warned =
563 self.claim_counters.flap_warned.saturating_add(1);
564 tracing::warn!(
565 network,
566 old_port = existing.port_index,
567 new_port = port_index,
568 flap_count,
569 "Route flapping detected — network changed ports {} times in 60s",
570 flap_count
571 );
572 }
573 self.reject_transitions.remove(&network);
574 self.pending_replacements.remove(&network);
575 self.routes.insert(
576 network,
577 RouteEntry {
578 port_index,
579 directly_connected: false,
580 next_hop_mac,
581 last_seen: Some(now),
582 last_used: None,
583 reachability: ReachabilityStatus::Reachable,
584 busy_until: None,
585 flap_count,
586 last_port_change: Some(now),
587 },
588 );
589 return true;
590 }
591 }
592 self.add_learned_at(network, port_index, next_hop_mac, now);
593 true
594 }
595
596 /// Mark a network as busy with a deadline for auto-clear (spec 6.6.3.6).
597 /// Directly-connected paths are never overridden by Busy: they are served
598 /// locally, not via the announcing peer. A permanently Unreachable entry
599 /// is likewise left untouched: a temporary congestion claim must never
600 /// resurrect a failed route via the 30s auto-clear — only Available or
601 /// fresh learning lifts Unreachable. Touches neither `last_seen` nor
602 /// `last_used` (RB-06 freshness split).
603 pub fn mark_busy(&mut self, network: u16, deadline: Instant) {
604 if let Some(entry) = self.routes.get_mut(&network) {
605 if !entry.directly_connected && entry.reachability != ReachabilityStatus::Unreachable {
606 entry.reachability = ReachabilityStatus::Busy;
607 entry.busy_until = Some(deadline);
608 }
609 }
610 }
611
612 /// Mark a network as available, clearing any busy state (spec 6.6.3.7).
613 /// Directly-connected paths are never touched: Busy/Available only covers
614 /// routes served via the announcing peer. Touches neither `last_seen` nor
615 /// `last_used` (RB-06 freshness split).
616 pub fn mark_available(&mut self, network: u16) {
617 if let Some(entry) = self.routes.get_mut(&network) {
618 if !entry.directly_connected {
619 entry.reachability = ReachabilityStatus::Reachable;
620 entry.busy_until = None;
621 }
622 }
623 }
624
625 /// Whether `network` is served via the announcing peer on this ingress
626 /// path: a learned (never directly-connected) route whose egress port and
627 /// next-hop MAC match the immediate link peer — not the routed SNET/SADR.
628 /// RB-04 locks this predicate to 135-2020 Clauses 6.6.3.6/6.6.3.7: a Busy
629 /// that omits its network list asks that traffic be held back for every
630 /// network the router normally serves, and an Available without a list
631 /// lifts that hold for all of them. Explicit lists intersect with the
632 /// same set: listed nets outside the announcing path are not marked.
633 pub fn is_served_via_peer(&self, network: u16, port_index: usize, next_hop: &MacAddr) -> bool {
634 self.routes.get(&network).is_some_and(|entry| {
635 !entry.directly_connected
636 && entry.port_index == port_index
637 && entry.next_hop_mac == *next_hop
638 })
639 }
640
641 /// All networks served via the announcing peer: the omitted-list scope
642 /// for Router-Busy/Router-Available-To-Network (Clauses 6.6.3.6/6.6.3.7).
643 pub fn routes_served_via_peer(&self, port_index: usize, next_hop: &MacAddr) -> Vec<u16> {
644 self.routes
645 .iter()
646 .filter(|(_, entry)| {
647 !entry.directly_connected
648 && entry.port_index == port_index
649 && entry.next_hop_mac == *next_hop
650 })
651 .map(|(net, _)| *net)
652 .collect()
653 }
654
655 /// Mark a network as permanently unreachable (spec 6.6.3.5, reject reason 1).
656 /// Keeps the entry in the table (unlike `remove`).
657 pub fn mark_unreachable(&mut self, network: u16) {
658 if let Some(entry) = self.routes.get_mut(&network) {
659 if !entry.directly_connected {
660 entry.reachability = ReachabilityStatus::Unreachable;
661 entry.busy_until = None;
662 }
663 }
664 }
665
666 /// Clear busy state for entries whose `busy_until` deadline has elapsed.
667 pub fn clear_expired_busy(&mut self) {
668 let now = Instant::now();
669 for entry in self.routes.values_mut() {
670 if let Some(deadline) = entry.busy_until {
671 if now >= deadline {
672 entry.reachability = ReachabilityStatus::Reachable;
673 entry.busy_until = None;
674 }
675 }
676 }
677 }
678
679 /// Get effective reachability, checking busy_until inline for immediate accuracy.
680 /// This avoids up to 90s worst-case from the 60s aging sweep granularity.
681 pub fn effective_reachability(&self, network: u16) -> Option<ReachabilityStatus> {
682 self.routes.get(&network).map(|entry| {
683 if entry.reachability == ReachabilityStatus::Busy {
684 if let Some(deadline) = entry.busy_until {
685 if Instant::now() >= deadline {
686 return ReachabilityStatus::Reachable;
687 }
688 }
689 }
690 entry.reachability
691 })
692 }
693
694 /// Look up the route for a network number.
695 pub fn lookup(&self, network: u16) -> Option<&RouteEntry> {
696 self.routes.get(&network)
697 }
698
699 /// Lookup a mutable route entry by network number.
700 /// Clears any pending challenger since the caller can replace the route state.
701 pub fn lookup_mut(&mut self, network: u16) -> Option<&mut RouteEntry> {
702 self.pending_replacements.remove(&network);
703 self.routes.get_mut(&network)
704 }
705
706 /// Remove a route.
707 pub fn remove(&mut self, network: u16) -> Option<RouteEntry> {
708 self.reject_transitions.remove(&network);
709 self.pending_replacements.remove(&network);
710 self.routes.remove(&network)
711 }
712
713 /// List all known network numbers.
714 pub fn networks(&self) -> Vec<u16> {
715 self.routes.keys().copied().collect()
716 }
717
718 /// List networks reachable via ports OTHER than `exclude_port`.
719 pub fn networks_not_on_port(&self, exclude_port: usize) -> Vec<u16> {
720 self.routes
721 .iter()
722 .filter(|(_, entry)| entry.port_index != exclude_port)
723 .map(|(net, _)| *net)
724 .collect()
725 }
726
727 /// List networks reachable on a given port.
728 pub fn networks_on_port(&self, port_index: usize) -> Vec<u16> {
729 self.routes
730 .iter()
731 .filter(|(_, entry)| entry.port_index == port_index)
732 .map(|(net, _)| *net)
733 .collect()
734 }
735
736 /// Number of routes.
737 pub fn len(&self) -> usize {
738 self.routes.len()
739 }
740
741 /// Whether the table is empty.
742 pub fn is_empty(&self) -> bool {
743 self.routes.is_empty()
744 }
745
746 /// Record data-plane forwarding use for a learned route.
747 ///
748 /// Sets `last_used` only; `last_seen` (control-plane confirmation) is
749 /// untouched so traffic cannot extend expiry. Direct routes are unaffected
750 /// since they never expire.
751 pub fn touch(&mut self, network: u16) {
752 self.touch_used_at(network, Instant::now());
753 }
754
755 /// Injectable-time variant of [`Self::touch`] for deterministic tests.
756 pub fn touch_used_at(&mut self, network: u16, now: Instant) {
757 if let Some(entry) = self.routes.get_mut(&network) {
758 if !entry.directly_connected {
759 entry.last_used = Some(now);
760 }
761 }
762 }
763
764 /// Remove learned routes not confirmed within `max_age`.
765 ///
766 /// Keys off `last_seen` only; `last_used` never extends lifetime.
767 /// Direct routes (`last_seen == None`) never expire.
768 /// Returns the network numbers that were purged.
769 pub fn purge_stale(&mut self, max_age: Duration) -> Vec<u16> {
770 self.purge_stale_at(Instant::now(), max_age)
771 }
772
773 /// Injectable-time variant of [`Self::purge_stale`] for deterministic tests.
774 pub fn purge_stale_at(&mut self, now: Instant, max_age: Duration) -> Vec<u16> {
775 let stale: Vec<u16> = self
776 .routes
777 .iter()
778 .filter(|(_, entry)| {
779 if let Some(seen) = entry.last_seen {
780 !entry.directly_connected && now.duration_since(seen) > max_age
781 } else {
782 false
783 }
784 })
785 .map(|(net, _)| *net)
786 .collect();
787 for net in &stale {
788 self.remove(*net);
789 }
790 stale
791 }
792}
793
794impl Default for RouterTable {
795 fn default() -> Self {
796 Self::new()
797 }
798}
799
800#[cfg(test)]
801#[path = "router_table_reject_tests.rs"]
802mod reject_tests;
803
804#[cfg(test)]
805#[path = "router_table_corroboration_tests.rs"]
806mod corroboration_tests;
807
808#[cfg(test)]
809#[path = "router_table_tests.rs"]
810mod tests;