1#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
8pub enum AuditSeverity {
9 Info = 0,
11 Warning = 1,
13 Error = 2,
15}
16
17#[derive(Debug, Clone)]
19pub struct AuditFinding {
20 pub severity: AuditSeverity,
22 pub category: String,
24 pub description: String,
26 pub affected_peers: Vec<String>,
28 pub detected_at_secs: u64,
30}
31
32pub const DEFAULT_MAX_CAPACITY: usize = 20;
34
35#[derive(Debug, Clone)]
37pub struct BucketInfo {
38 pub bucket_index: usize,
40 pub peer_count: usize,
42 pub max_capacity: usize,
44 pub last_refreshed_secs: u64,
46}
47
48impl BucketInfo {
49 #[must_use]
51 pub fn is_full(&self) -> bool {
52 self.peer_count >= self.max_capacity
53 }
54
55 #[must_use]
57 pub fn is_empty(&self) -> bool {
58 self.peer_count == 0
59 }
60
61 #[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#[derive(Debug, Clone)]
73pub struct AuditorConfig {
74 pub stale_threshold_secs: u64,
77 pub max_empty_buckets: usize,
80 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#[derive(Debug, Clone)]
98pub struct RoutingTableAuditor {
99 pub buckets: Vec<BucketInfo>,
101 pub config: AuditorConfig,
103}
104
105impl RoutingTableAuditor {
106 #[must_use]
109 pub fn new(config: AuditorConfig) -> Self {
110 Self {
111 buckets: Vec::new(),
112 config,
113 }
114 }
115
116 pub fn add_bucket(&mut self, bucket: BucketInfo) {
118 self.buckets.push(bucket);
119 }
120
121 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 #[must_use]
152 pub fn audit(&self, now_secs: u64) -> Vec<AuditFinding> {
153 let mut findings: Vec<AuditFinding> = Vec::new();
154
155 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 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 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 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 findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
220
221 findings
222 }
223
224 #[must_use]
226 pub fn total_peers(&self) -> usize {
227 self.buckets.iter().map(|b| b.peer_count).sum()
228 }
229
230 #[must_use]
232 pub fn empty_bucket_count(&self) -> usize {
233 self.buckets.iter().filter(|b| b.is_empty()).count()
234 }
235
236 #[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#[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 #[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 #[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); 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 #[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 #[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 #[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 auditor.add_bucket(make_bucket(0, 2, now - 500));
369 auditor.add_bucket(make_bucket(1, 2, now - 601));
371 auditor.add_bucket(make_bucket(2, 2, now - 700));
373 assert_eq!(auditor.stale_bucket_count(now), 2);
374 }
375
376 #[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); 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 #[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, 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 #[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, stale_threshold_secs: 9999,
438 });
439 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 #[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)); auditor.add_bucket(make_bucket(1, 3, now - 100)); 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 #[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 #[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, max_empty_buckets: 0, stale_threshold_secs: 500, });
503 auditor.add_bucket(make_bucket(0, 0, now));
505 auditor.add_bucket(make_bucket(1, 2, now - 600));
507 auditor.add_bucket(make_bucket(2, DEFAULT_MAX_CAPACITY, now));
509 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)); auditor2.add_bucket(make_bucket(1, 2, now - 600)); auditor2.add_bucket(make_bucket(2, DEFAULT_MAX_CAPACITY, now)); let findings = auditor2.audit(now);
522 assert!(!findings.is_empty());
523
524 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 assert_eq!(findings[0].severity, AuditSeverity::Error);
536 assert_eq!(
538 findings.last().map(|f| f.severity),
539 Some(AuditSeverity::Info)
540 );
541 }
542
543 #[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 #[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 #[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}