Skip to main content

ipfrs_network/
routing_auditor.rs

1//! Routing Table Auditor
2//!
3//! Audits the DHT routing table for health issues including stale entries,
4//! bucket imbalances, and unreachable peers.
5
6/// Severity level of an audit finding.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
8pub enum AuditSeverity {
9    /// Informational finding; no action required.
10    Info = 0,
11    /// Warning finding; should be investigated.
12    Warning = 1,
13    /// Error finding; requires immediate attention.
14    Error = 2,
15}
16
17/// A single finding produced by a routing-table audit run.
18#[derive(Debug, Clone)]
19pub struct AuditFinding {
20    /// How serious this finding is.
21    pub severity: AuditSeverity,
22    /// Machine-readable category such as `"stale_entry"` or `"empty_buckets"`.
23    pub category: String,
24    /// Human-readable description of the finding.
25    pub description: String,
26    /// Peer IDs (or bucket identifiers) involved in this finding.
27    pub affected_peers: Vec<String>,
28    /// Unix timestamp (seconds) at which the finding was detected.
29    pub detected_at_secs: u64,
30}
31
32/// Default k-bucket capacity as defined by the Kademlia paper.
33pub const DEFAULT_MAX_CAPACITY: usize = 20;
34
35/// Information about a single Kademlia k-bucket.
36#[derive(Debug, Clone)]
37pub struct BucketInfo {
38    /// Kademlia bucket index (0–255).
39    pub bucket_index: usize,
40    /// Number of peers currently tracked in this bucket.
41    pub peer_count: usize,
42    /// Maximum number of peers this bucket can hold.
43    pub max_capacity: usize,
44    /// Unix timestamp (seconds) of the last time this bucket was refreshed.
45    pub last_refreshed_secs: u64,
46}
47
48impl BucketInfo {
49    /// Returns `true` when the bucket has reached its maximum capacity.
50    #[must_use]
51    pub fn is_full(&self) -> bool {
52        self.peer_count >= self.max_capacity
53    }
54
55    /// Returns `true` when the bucket contains no peers.
56    #[must_use]
57    pub fn is_empty(&self) -> bool {
58        self.peer_count == 0
59    }
60
61    /// Returns the ratio of current peers to maximum capacity (0.0 – 1.0+).
62    #[must_use]
63    pub fn fill_ratio(&self) -> f64 {
64        if self.max_capacity == 0 {
65            return 0.0;
66        }
67        self.peer_count as f64 / self.max_capacity as f64
68    }
69}
70
71/// Configuration for the [`RoutingTableAuditor`].
72#[derive(Debug, Clone)]
73pub struct AuditorConfig {
74    /// An entry is considered stale if it has not been refreshed within this
75    /// many seconds (default: 3600 = 1 hour).
76    pub stale_threshold_secs: u64,
77    /// Emit a warning when the number of empty buckets exceeds this value
78    /// (default: 10).
79    pub max_empty_buckets: usize,
80    /// Emit an error when the total number of tracked peers falls below this
81    /// value (default: 3).
82    pub min_total_peers: usize,
83}
84
85impl Default for AuditorConfig {
86    fn default() -> Self {
87        Self {
88            stale_threshold_secs: 3600,
89            max_empty_buckets: 10,
90            min_total_peers: 3,
91        }
92    }
93}
94
95/// Audits a DHT routing table represented as a collection of [`BucketInfo`]
96/// entries and produces [`AuditFinding`] items describing any health issues.
97#[derive(Debug, Clone)]
98pub struct RoutingTableAuditor {
99    /// Up to 256 Kademlia k-buckets tracked by this auditor.
100    pub buckets: Vec<BucketInfo>,
101    /// Configuration driving audit thresholds.
102    pub config: AuditorConfig,
103}
104
105impl RoutingTableAuditor {
106    /// Creates a new auditor with the given configuration and an empty bucket
107    /// list.
108    #[must_use]
109    pub fn new(config: AuditorConfig) -> Self {
110        Self {
111            buckets: Vec::new(),
112            config,
113        }
114    }
115
116    /// Appends a bucket to the auditor's bucket list.
117    pub fn add_bucket(&mut self, bucket: BucketInfo) {
118        self.buckets.push(bucket);
119    }
120
121    /// Updates an existing bucket identified by `bucket_index`.  If no bucket
122    /// with that index exists a new one is inserted with
123    /// [`DEFAULT_MAX_CAPACITY`].
124    pub fn update_bucket(&mut self, bucket_index: usize, peer_count: usize, now_secs: u64) {
125        if let Some(b) = self
126            .buckets
127            .iter_mut()
128            .find(|b| b.bucket_index == bucket_index)
129        {
130            b.peer_count = peer_count;
131            b.last_refreshed_secs = now_secs;
132        } else {
133            self.buckets.push(BucketInfo {
134                bucket_index,
135                peer_count,
136                max_capacity: DEFAULT_MAX_CAPACITY,
137                last_refreshed_secs: now_secs,
138            });
139        }
140    }
141
142    /// Runs the full audit at time `now_secs` and returns all findings sorted
143    /// by severity descending (most severe first).
144    ///
145    /// Checks performed:
146    /// 1. **insufficient_peers** (Error) – total peer count < `min_total_peers`.
147    /// 2. **empty_buckets** (Warning) – count of empty buckets > `max_empty_buckets`.
148    /// 3. **stale_bucket** (Warning) – each bucket whose `last_refreshed_secs`
149    ///    is older than `stale_threshold_secs`.
150    /// 4. **full_bucket** (Info) – each bucket at maximum capacity.
151    #[must_use]
152    pub fn audit(&self, now_secs: u64) -> Vec<AuditFinding> {
153        let mut findings: Vec<AuditFinding> = Vec::new();
154
155        // 1. Insufficient peers
156        let total = self.total_peers();
157        if total < self.config.min_total_peers {
158            findings.push(AuditFinding {
159                severity: AuditSeverity::Error,
160                category: "insufficient_peers".to_string(),
161                description: format!(
162                    "Routing table has only {} peer(s); minimum required is {}.",
163                    total, self.config.min_total_peers
164                ),
165                affected_peers: Vec::new(),
166                detected_at_secs: now_secs,
167            });
168        }
169
170        // 2. Too many empty buckets
171        let empty_count = self.empty_bucket_count();
172        if empty_count > self.config.max_empty_buckets {
173            findings.push(AuditFinding {
174                severity: AuditSeverity::Warning,
175                category: "empty_buckets".to_string(),
176                description: format!(
177                    "{} bucket(s) are empty; threshold is {}.",
178                    empty_count, self.config.max_empty_buckets
179                ),
180                affected_peers: Vec::new(),
181                detected_at_secs: now_secs,
182            });
183        }
184
185        // 3. Stale buckets
186        for bucket in &self.buckets {
187            let age = now_secs.saturating_sub(bucket.last_refreshed_secs);
188            if age > self.config.stale_threshold_secs {
189                findings.push(AuditFinding {
190                    severity: AuditSeverity::Warning,
191                    category: "stale_bucket".to_string(),
192                    description: format!(
193                        "Bucket {} has not been refreshed for {} second(s) (threshold: {}).",
194                        bucket.bucket_index, age, self.config.stale_threshold_secs
195                    ),
196                    affected_peers: vec![format!("bucket-{}", bucket.bucket_index)],
197                    detected_at_secs: now_secs,
198                });
199            }
200        }
201
202        // 4. Full buckets
203        for bucket in &self.buckets {
204            if bucket.is_full() {
205                findings.push(AuditFinding {
206                    severity: AuditSeverity::Info,
207                    category: "full_bucket".to_string(),
208                    description: format!(
209                        "Bucket {} is at full capacity ({}/{}).",
210                        bucket.bucket_index, bucket.peer_count, bucket.max_capacity
211                    ),
212                    affected_peers: vec![format!("bucket-{}", bucket.bucket_index)],
213                    detected_at_secs: now_secs,
214                });
215            }
216        }
217
218        // Sort by severity descending (Error > Warning > Info)
219        findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
220
221        findings
222    }
223
224    /// Returns the total number of peers across all tracked buckets.
225    #[must_use]
226    pub fn total_peers(&self) -> usize {
227        self.buckets.iter().map(|b| b.peer_count).sum()
228    }
229
230    /// Returns the number of buckets that currently contain no peers.
231    #[must_use]
232    pub fn empty_bucket_count(&self) -> usize {
233        self.buckets.iter().filter(|b| b.is_empty()).count()
234    }
235
236    /// Returns the number of buckets that have not been refreshed within the
237    /// configured `stale_threshold_secs`.
238    #[must_use]
239    pub fn stale_bucket_count(&self, now_secs: u64) -> usize {
240        self.buckets
241            .iter()
242            .filter(|b| {
243                now_secs.saturating_sub(b.last_refreshed_secs) > self.config.stale_threshold_secs
244            })
245            .count()
246    }
247}
248
249// ─────────────────────────────────────────────────────────────────────────────
250// Tests
251// ─────────────────────────────────────────────────────────────────────────────
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn make_bucket(index: usize, peer_count: usize, refreshed: u64) -> BucketInfo {
258        BucketInfo {
259            bucket_index: index,
260            peer_count,
261            max_capacity: DEFAULT_MAX_CAPACITY,
262            last_refreshed_secs: refreshed,
263        }
264    }
265
266    fn default_auditor() -> RoutingTableAuditor {
267        RoutingTableAuditor::new(AuditorConfig::default())
268    }
269
270    // ── add_bucket ───────────────────────────────────────────────────────────
271
272    #[test]
273    fn test_add_bucket_increases_count() {
274        let mut auditor = default_auditor();
275        assert_eq!(auditor.buckets.len(), 0);
276        auditor.add_bucket(make_bucket(0, 5, 1000));
277        assert_eq!(auditor.buckets.len(), 1);
278        auditor.add_bucket(make_bucket(1, 3, 1000));
279        assert_eq!(auditor.buckets.len(), 2);
280    }
281
282    #[test]
283    fn test_add_bucket_stores_correct_data() {
284        let mut auditor = default_auditor();
285        auditor.add_bucket(make_bucket(42, 7, 999));
286        let b = &auditor.buckets[0];
287        assert_eq!(b.bucket_index, 42);
288        assert_eq!(b.peer_count, 7);
289        assert_eq!(b.last_refreshed_secs, 999);
290    }
291
292    // ── update_bucket ────────────────────────────────────────────────────────
293
294    #[test]
295    fn test_update_bucket_updates_existing() {
296        let mut auditor = default_auditor();
297        auditor.add_bucket(make_bucket(5, 2, 500));
298        auditor.update_bucket(5, 10, 1000);
299        assert_eq!(auditor.buckets.len(), 1, "no new bucket should be inserted");
300        assert_eq!(auditor.buckets[0].peer_count, 10);
301        assert_eq!(auditor.buckets[0].last_refreshed_secs, 1000);
302    }
303
304    #[test]
305    fn test_update_bucket_inserts_when_missing() {
306        let mut auditor = default_auditor();
307        auditor.update_bucket(7, 4, 2000);
308        assert_eq!(auditor.buckets.len(), 1);
309        assert_eq!(auditor.buckets[0].bucket_index, 7);
310        assert_eq!(auditor.buckets[0].peer_count, 4);
311        assert_eq!(auditor.buckets[0].max_capacity, DEFAULT_MAX_CAPACITY);
312    }
313
314    #[test]
315    fn test_update_bucket_upsert_multiple() {
316        let mut auditor = default_auditor();
317        auditor.update_bucket(1, 3, 100);
318        auditor.update_bucket(2, 5, 200);
319        auditor.update_bucket(1, 8, 300); // update existing
320        assert_eq!(auditor.buckets.len(), 2);
321        let b1 = auditor
322            .buckets
323            .iter()
324            .find(|b| b.bucket_index == 1)
325            .expect("test: bucket with index 1 should exist after upsert");
326        assert_eq!(b1.peer_count, 8);
327        assert_eq!(b1.last_refreshed_secs, 300);
328    }
329
330    // ── total_peers ──────────────────────────────────────────────────────────
331
332    #[test]
333    fn test_total_peers_empty_table() {
334        let auditor = default_auditor();
335        assert_eq!(auditor.total_peers(), 0);
336    }
337
338    #[test]
339    fn test_total_peers_sum() {
340        let mut auditor = default_auditor();
341        auditor.add_bucket(make_bucket(0, 5, 1000));
342        auditor.add_bucket(make_bucket(1, 3, 1000));
343        auditor.add_bucket(make_bucket(2, 0, 1000));
344        assert_eq!(auditor.total_peers(), 8);
345    }
346
347    // ── empty_bucket_count ───────────────────────────────────────────────────
348
349    #[test]
350    fn test_empty_bucket_count() {
351        let mut auditor = default_auditor();
352        auditor.add_bucket(make_bucket(0, 0, 1000));
353        auditor.add_bucket(make_bucket(1, 5, 1000));
354        auditor.add_bucket(make_bucket(2, 0, 1000));
355        assert_eq!(auditor.empty_bucket_count(), 2);
356    }
357
358    // ── stale_bucket_count ───────────────────────────────────────────────────
359
360    #[test]
361    fn test_stale_bucket_count() {
362        let mut auditor = RoutingTableAuditor::new(AuditorConfig {
363            stale_threshold_secs: 600,
364            ..AuditorConfig::default()
365        });
366        let now = 10_000u64;
367        // refreshed 500 s ago → not stale
368        auditor.add_bucket(make_bucket(0, 2, now - 500));
369        // refreshed 601 s ago → stale
370        auditor.add_bucket(make_bucket(1, 2, now - 601));
371        // refreshed 700 s ago → stale
372        auditor.add_bucket(make_bucket(2, 2, now - 700));
373        assert_eq!(auditor.stale_bucket_count(now), 2);
374    }
375
376    // ── fill_ratio, is_full, is_empty ────────────────────────────────────────
377
378    #[test]
379    fn test_fill_ratio_zero_peers() {
380        let b = make_bucket(0, 0, 0);
381        assert!((b.fill_ratio() - 0.0).abs() < f64::EPSILON);
382    }
383
384    #[test]
385    fn test_fill_ratio_half_full() {
386        let b = make_bucket(0, 10, 0); // max_capacity = 20
387        assert!((b.fill_ratio() - 0.5).abs() < f64::EPSILON);
388    }
389
390    #[test]
391    fn test_fill_ratio_full() {
392        let b = make_bucket(0, 20, 0);
393        assert!((b.fill_ratio() - 1.0).abs() < f64::EPSILON);
394    }
395
396    #[test]
397    fn test_is_full_and_is_empty() {
398        let empty = make_bucket(0, 0, 0);
399        let full = make_bucket(1, 20, 0);
400        let partial = make_bucket(2, 10, 0);
401
402        assert!(empty.is_empty());
403        assert!(!empty.is_full());
404
405        assert!(full.is_full());
406        assert!(!full.is_empty());
407
408        assert!(!partial.is_empty());
409        assert!(!partial.is_full());
410    }
411
412    // ── audit: insufficient_peers → Error ───────────────────────────────────
413
414    #[test]
415    fn test_audit_insufficient_peers_error() {
416        let mut auditor = RoutingTableAuditor::new(AuditorConfig {
417            min_total_peers: 5,
418            max_empty_buckets: 100, // suppress empty-bucket warning
419            stale_threshold_secs: 9999,
420        });
421        auditor.add_bucket(make_bucket(0, 2, 1000));
422        let findings = auditor.audit(2000);
423        let errors: Vec<_> = findings
424            .iter()
425            .filter(|f| f.severity == AuditSeverity::Error && f.category == "insufficient_peers")
426            .collect();
427        assert_eq!(errors.len(), 1, "expected one insufficient_peers error");
428    }
429
430    // ── audit: many empty buckets → Warning ──────────────────────────────────
431
432    #[test]
433    fn test_audit_many_empty_buckets_warning() {
434        let mut auditor = RoutingTableAuditor::new(AuditorConfig {
435            max_empty_buckets: 2,
436            min_total_peers: 0, // suppress peer error
437            stale_threshold_secs: 9999,
438        });
439        // Add 5 peers so insufficient_peers won't fire, but 3 empty buckets
440        auditor.add_bucket(make_bucket(0, 5, 1000));
441        auditor.add_bucket(make_bucket(1, 0, 1000));
442        auditor.add_bucket(make_bucket(2, 0, 1000));
443        auditor.add_bucket(make_bucket(3, 0, 1000));
444        let findings = auditor.audit(2000);
445        let warnings: Vec<_> = findings
446            .iter()
447            .filter(|f| f.severity == AuditSeverity::Warning && f.category == "empty_buckets")
448            .collect();
449        assert_eq!(warnings.len(), 1, "expected one empty_buckets warning");
450    }
451
452    // ── audit: stale bucket → Warning ───────────────────────────────────────
453
454    #[test]
455    fn test_audit_stale_bucket_warning() {
456        let now = 10_000u64;
457        let mut auditor = RoutingTableAuditor::new(AuditorConfig {
458            stale_threshold_secs: 500,
459            min_total_peers: 0,
460            max_empty_buckets: 100,
461        });
462        auditor.add_bucket(make_bucket(0, 3, now - 600)); // stale
463        auditor.add_bucket(make_bucket(1, 3, now - 100)); // fresh
464        let findings = auditor.audit(now);
465        let stale_warnings: Vec<_> = findings
466            .iter()
467            .filter(|f| f.category == "stale_bucket")
468            .collect();
469        assert_eq!(stale_warnings.len(), 1);
470        assert_eq!(stale_warnings[0].affected_peers, vec!["bucket-0"]);
471    }
472
473    // ── audit: full bucket → Info ────────────────────────────────────────────
474
475    #[test]
476    fn test_audit_full_bucket_info() {
477        let now = 5000u64;
478        let mut auditor = RoutingTableAuditor::new(AuditorConfig {
479            min_total_peers: 0,
480            max_empty_buckets: 100,
481            stale_threshold_secs: 9999,
482        });
483        auditor.add_bucket(make_bucket(3, DEFAULT_MAX_CAPACITY, now));
484        let findings = auditor.audit(now);
485        let info: Vec<_> = findings
486            .iter()
487            .filter(|f| f.severity == AuditSeverity::Info && f.category == "full_bucket")
488            .collect();
489        assert_eq!(info.len(), 1);
490        assert_eq!(info[0].affected_peers, vec!["bucket-3"]);
491    }
492
493    // ── audit: sort by severity descending ───────────────────────────────────
494
495    #[test]
496    fn test_audit_sorted_by_severity_descending() {
497        let now = 20_000u64;
498        let mut auditor = RoutingTableAuditor::new(AuditorConfig {
499            min_total_peers: 10,       // will trigger Error (total = 20 + full)
500            max_empty_buckets: 0,      // will trigger Warning (1 empty bucket)
501            stale_threshold_secs: 500, // will trigger Warning for old bucket
502        });
503        // 1 empty bucket → empty_buckets warning
504        auditor.add_bucket(make_bucket(0, 0, now));
505        // 1 stale bucket
506        auditor.add_bucket(make_bucket(1, 2, now - 600));
507        // 1 full bucket → full_bucket info
508        auditor.add_bucket(make_bucket(2, DEFAULT_MAX_CAPACITY, now));
509        // Enough peers so insufficient_peers fires: total = 0+2+20 = 22 >= 10, won't fire
510        // Let's make total < 10 → use few peers
511        let mut auditor2 = RoutingTableAuditor::new(AuditorConfig {
512            min_total_peers: 50,
513            max_empty_buckets: 0,
514            stale_threshold_secs: 500,
515        });
516        auditor2.add_bucket(make_bucket(0, 0, now)); // empty → warning
517        auditor2.add_bucket(make_bucket(1, 2, now - 600)); // stale → warning
518        auditor2.add_bucket(make_bucket(2, DEFAULT_MAX_CAPACITY, now)); // full → info
519                                                                        // total = 22 < 50 → error
520
521        let findings = auditor2.audit(now);
522        assert!(!findings.is_empty());
523
524        // Verify that no finding has a severity higher than the previous one
525        for window in findings.windows(2) {
526            assert!(
527                window[0].severity >= window[1].severity,
528                "findings not sorted descending: {:?} < {:?}",
529                window[0].severity,
530                window[1].severity
531            );
532        }
533
534        // First finding must be Error
535        assert_eq!(findings[0].severity, AuditSeverity::Error);
536        // Last finding must be Info
537        assert_eq!(
538            findings.last().map(|f| f.severity),
539            Some(AuditSeverity::Info)
540        );
541    }
542
543    // ── AuditSeverity ordering ────────────────────────────────────────────────
544
545    #[test]
546    fn test_audit_severity_ordering() {
547        assert!(AuditSeverity::Error > AuditSeverity::Warning);
548        assert!(AuditSeverity::Warning > AuditSeverity::Info);
549        assert_eq!(AuditSeverity::Info, AuditSeverity::Info);
550    }
551
552    // ── fill_ratio with zero capacity ────────────────────────────────────────
553
554    #[test]
555    fn test_fill_ratio_zero_capacity() {
556        let b = BucketInfo {
557            bucket_index: 0,
558            peer_count: 5,
559            max_capacity: 0,
560            last_refreshed_secs: 0,
561        };
562        assert!((b.fill_ratio() - 0.0).abs() < f64::EPSILON);
563    }
564
565    // ── no stale buckets when all fresh ──────────────────────────────────────
566
567    #[test]
568    fn test_no_stale_buckets_all_fresh() {
569        let now = 5000u64;
570        let mut auditor = RoutingTableAuditor::new(AuditorConfig {
571            stale_threshold_secs: 3600,
572            ..AuditorConfig::default()
573        });
574        auditor.add_bucket(make_bucket(0, 3, now - 100));
575        auditor.add_bucket(make_bucket(1, 5, now - 200));
576        assert_eq!(auditor.stale_bucket_count(now), 0);
577    }
578}