Skip to main content

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;