Skip to main content

ipfrs_network/
ban_list.rs

1//! Peer ban list for temporary and permanent banning of misbehaving peers.
2//!
3//! Provides a tick-based ban management system that supports both temporary bans
4//! (which expire after a configurable TTL) and permanent bans (which persist until
5//! explicitly removed). The ban list enforces a maximum capacity and tracks
6//! statistics about ban/unban operations.
7//!
8//! # Example
9//!
10//! ```rust
11//! use ipfrs_network::ban_list::{PeerBanList, BanConfig};
12//!
13//! let config = BanConfig::default();
14//! let mut ban_list = PeerBanList::new(config);
15//!
16//! // Temporarily ban a peer with default TTL
17//! ban_list.ban_temporary("peer-1", "spamming", None);
18//! assert!(ban_list.is_banned("peer-1"));
19//!
20//! // Permanently ban a peer
21//! ban_list.ban_permanent("peer-2", "protocol violation");
22//! assert!(ban_list.is_banned("peer-2"));
23//!
24//! // Unban a peer
25//! assert!(ban_list.unban("peer-1"));
26//! assert!(!ban_list.is_banned("peer-1"));
27//! ```
28
29use std::collections::HashMap;
30
31/// The kind of ban applied to a peer.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BanKind {
34    /// Temporary ban that expires after a TTL measured in ticks.
35    Temporary,
36    /// Permanent ban that never expires unless explicitly removed.
37    Permanent,
38}
39
40/// An entry recording a ban against a specific peer.
41#[derive(Debug, Clone)]
42pub struct BanEntry {
43    /// The identifier of the banned peer.
44    pub peer_id: String,
45    /// Whether the ban is temporary or permanent.
46    pub kind: BanKind,
47    /// Human-readable reason for the ban.
48    pub reason: String,
49    /// The tick at which the ban was issued.
50    pub banned_tick: u64,
51    /// The tick at which a temporary ban expires. `None` for permanent bans.
52    pub expires_tick: Option<u64>,
53}
54
55/// Configuration for the peer ban list.
56#[derive(Debug, Clone)]
57pub struct BanConfig {
58    /// Default TTL in ticks for temporary bans when no explicit TTL is provided.
59    pub default_temp_ttl_ticks: u64,
60    /// Maximum number of simultaneous bans allowed.
61    pub max_bans: usize,
62}
63
64impl Default for BanConfig {
65    fn default() -> Self {
66        Self {
67            default_temp_ttl_ticks: 500,
68            max_bans: 10_000,
69        }
70    }
71}
72
73/// Aggregate statistics about the ban list.
74#[derive(Debug, Clone)]
75pub struct BanListStats {
76    /// Number of currently active bans.
77    pub active_bans: usize,
78    /// Number of currently active permanent bans.
79    pub permanent_bans: usize,
80    /// Number of currently active temporary bans.
81    pub temporary_bans: usize,
82    /// Total number of bans ever issued.
83    pub total_bans_issued: u64,
84    /// Total number of unbans (manual or via expiry cleanup).
85    pub total_unbans: u64,
86}
87
88/// Manages a list of banned peers with temporary and permanent ban support.
89///
90/// Bans are tracked using a logical tick counter rather than wall-clock time,
91/// allowing deterministic testing and integration with tick-based event loops.
92#[derive(Debug, Clone)]
93pub struct PeerBanList {
94    config: BanConfig,
95    bans: HashMap<String, BanEntry>,
96    current_tick: u64,
97    total_bans_issued: u64,
98    total_unbans: u64,
99}
100
101impl PeerBanList {
102    /// Create a new `PeerBanList` with the given configuration.
103    pub fn new(config: BanConfig) -> Self {
104        Self {
105            config,
106            bans: HashMap::new(),
107            current_tick: 0,
108            total_bans_issued: 0,
109            total_unbans: 0,
110        }
111    }
112
113    /// Ban a peer temporarily. Uses `default_temp_ttl_ticks` when `ttl_ticks` is `None`.
114    ///
115    /// If the ban list is at capacity, the ban is silently dropped.
116    /// If the peer is already banned, the entry is updated (re-banned).
117    pub fn ban_temporary(&mut self, peer_id: &str, reason: &str, ttl_ticks: Option<u64>) {
118        let ttl = ttl_ticks.unwrap_or(self.config.default_temp_ttl_ticks);
119        let expires = self.current_tick.saturating_add(ttl);
120
121        // If the peer is already banned, we update (re-ban) without checking max_bans again.
122        let is_update = self.bans.contains_key(peer_id);
123        if !is_update && self.bans.len() >= self.config.max_bans {
124            return;
125        }
126
127        let entry = BanEntry {
128            peer_id: peer_id.to_string(),
129            kind: BanKind::Temporary,
130            reason: reason.to_string(),
131            banned_tick: self.current_tick,
132            expires_tick: Some(expires),
133        };
134        self.bans.insert(peer_id.to_string(), entry);
135        self.total_bans_issued += 1;
136    }
137
138    /// Ban a peer permanently. The ban will not expire until explicitly removed via `unban`.
139    ///
140    /// If the ban list is at capacity, the ban is silently dropped.
141    /// If the peer is already banned, the entry is updated (re-banned).
142    pub fn ban_permanent(&mut self, peer_id: &str, reason: &str) {
143        let is_update = self.bans.contains_key(peer_id);
144        if !is_update && self.bans.len() >= self.config.max_bans {
145            return;
146        }
147
148        let entry = BanEntry {
149            peer_id: peer_id.to_string(),
150            kind: BanKind::Permanent,
151            reason: reason.to_string(),
152            banned_tick: self.current_tick,
153            expires_tick: None,
154        };
155        self.bans.insert(peer_id.to_string(), entry);
156        self.total_bans_issued += 1;
157    }
158
159    /// Remove a ban for the given peer. Returns `true` if the peer was banned.
160    pub fn unban(&mut self, peer_id: &str) -> bool {
161        if self.bans.remove(peer_id).is_some() {
162            self.total_unbans += 1;
163            true
164        } else {
165            false
166        }
167    }
168
169    /// Check whether a peer is currently banned, respecting temporary ban expiry.
170    ///
171    /// A temporary ban is considered expired if `current_tick >= expires_tick`.
172    /// Expired entries are lazily removed on access.
173    pub fn is_banned(&mut self, peer_id: &str) -> bool {
174        if let Some(entry) = self.bans.get(peer_id) {
175            match entry.kind {
176                BanKind::Permanent => true,
177                BanKind::Temporary => {
178                    if let Some(expires) = entry.expires_tick {
179                        if self.current_tick >= expires {
180                            // Expired: lazily remove
181                            self.bans.remove(peer_id);
182                            self.total_unbans += 1;
183                            false
184                        } else {
185                            true
186                        }
187                    } else {
188                        // Temporary with no expiry treated as expired (defensive)
189                        true
190                    }
191                }
192            }
193        } else {
194            false
195        }
196    }
197
198    /// Get the ban entry for a peer, if it exists and is still active.
199    ///
200    /// Note: This does not perform lazy expiry cleanup. Use `is_banned` for
201    /// an expiry-aware check, or call `tick_cleanup` periodically.
202    pub fn get_ban(&self, peer_id: &str) -> Option<&BanEntry> {
203        self.bans.get(peer_id)
204    }
205
206    /// Advance the current tick by one and remove all expired temporary bans.
207    pub fn tick_cleanup(&mut self) {
208        self.current_tick += 1;
209        let current = self.current_tick;
210        let before = self.bans.len();
211        self.bans.retain(|_, entry| {
212            if entry.kind == BanKind::Temporary {
213                if let Some(expires) = entry.expires_tick {
214                    return current < expires;
215                }
216            }
217            true
218        });
219        let removed = before - self.bans.len();
220        self.total_unbans += removed as u64;
221    }
222
223    /// Return the number of currently active bans (including not-yet-cleaned expired ones).
224    pub fn banned_count(&self) -> usize {
225        self.bans.len()
226    }
227
228    /// Return references to all current ban entries.
229    pub fn list_banned(&self) -> Vec<&BanEntry> {
230        self.bans.values().collect()
231    }
232
233    /// Return aggregate statistics about the ban list.
234    pub fn stats(&self) -> BanListStats {
235        let mut permanent = 0usize;
236        let mut temporary = 0usize;
237        for entry in self.bans.values() {
238            match entry.kind {
239                BanKind::Permanent => permanent += 1,
240                BanKind::Temporary => temporary += 1,
241            }
242        }
243        BanListStats {
244            active_bans: self.bans.len(),
245            permanent_bans: permanent,
246            temporary_bans: temporary,
247            total_bans_issued: self.total_bans_issued,
248            total_unbans: self.total_unbans,
249        }
250    }
251
252    /// Return the current logical tick.
253    pub fn current_tick(&self) -> u64 {
254        self.current_tick
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn default_list() -> PeerBanList {
263        PeerBanList::new(BanConfig::default())
264    }
265
266    // --- Basic temporary ban ---
267
268    #[test]
269    fn test_temp_ban_is_banned() {
270        let mut list = default_list();
271        list.ban_temporary("peer-1", "spam", None);
272        assert!(list.is_banned("peer-1"));
273    }
274
275    #[test]
276    fn test_temp_ban_expires_after_ttl() {
277        let mut list = default_list();
278        list.ban_temporary("peer-1", "spam", Some(3));
279        assert!(list.is_banned("peer-1"));
280        // Advance 3 ticks (tick goes 1, 2, 3)
281        list.tick_cleanup(); // tick=1
282        list.tick_cleanup(); // tick=2
283        assert!(list.is_banned("peer-1")); // expires_tick=3, current=2 => still banned
284        list.tick_cleanup(); // tick=3
285                             // Now current_tick == expires_tick == 3 => expired
286        assert!(!list.is_banned("peer-1"));
287    }
288
289    #[test]
290    fn test_temp_ban_with_default_ttl() {
291        let mut list = default_list();
292        list.ban_temporary("peer-1", "reason", None);
293        let entry = list.get_ban("peer-1");
294        assert!(entry.is_some());
295        let entry = entry.expect("already checked");
296        assert_eq!(entry.expires_tick, Some(500)); // default TTL
297    }
298
299    #[test]
300    fn test_temp_ban_with_custom_ttl() {
301        let mut list = default_list();
302        list.ban_temporary("peer-1", "reason", Some(100));
303        let entry = list.get_ban("peer-1").expect("should exist");
304        assert_eq!(entry.expires_tick, Some(100));
305        assert_eq!(entry.kind, BanKind::Temporary);
306    }
307
308    // --- Permanent ban ---
309
310    #[test]
311    fn test_permanent_ban_persists() {
312        let mut list = default_list();
313        list.ban_permanent("peer-1", "protocol violation");
314        // Advance many ticks
315        for _ in 0..1000 {
316            list.tick_cleanup();
317        }
318        assert!(list.is_banned("peer-1"));
319    }
320
321    #[test]
322    fn test_permanent_ban_has_no_expiry() {
323        let mut list = default_list();
324        list.ban_permanent("peer-1", "bad actor");
325        let entry = list.get_ban("peer-1").expect("should exist");
326        assert_eq!(entry.kind, BanKind::Permanent);
327        assert!(entry.expires_tick.is_none());
328    }
329
330    // --- Unban ---
331
332    #[test]
333    fn test_unban_removes_temp_ban() {
334        let mut list = default_list();
335        list.ban_temporary("peer-1", "spam", None);
336        assert!(list.unban("peer-1"));
337        assert!(!list.is_banned("peer-1"));
338    }
339
340    #[test]
341    fn test_unban_removes_permanent_ban() {
342        let mut list = default_list();
343        list.ban_permanent("peer-1", "bad");
344        assert!(list.unban("peer-1"));
345        assert!(!list.is_banned("peer-1"));
346    }
347
348    #[test]
349    fn test_unban_nonexistent_returns_false() {
350        let mut list = default_list();
351        assert!(!list.unban("peer-nonexistent"));
352    }
353
354    // --- is_banned with expiry ---
355
356    #[test]
357    fn test_is_banned_lazily_removes_expired() {
358        let mut list = default_list();
359        list.ban_temporary("peer-1", "spam", Some(1));
360        list.tick_cleanup(); // tick=1, expires_tick=1 => expired
361                             // is_banned should return false and lazily remove
362        assert!(!list.is_banned("peer-1"));
363        assert_eq!(list.banned_count(), 0);
364    }
365
366    #[test]
367    fn test_is_banned_false_for_unknown_peer() {
368        let mut list = default_list();
369        assert!(!list.is_banned("unknown"));
370    }
371
372    // --- tick_cleanup ---
373
374    #[test]
375    fn test_tick_cleanup_removes_expired_bans() {
376        let mut list = default_list();
377        list.ban_temporary("peer-1", "a", Some(2));
378        list.ban_temporary("peer-2", "b", Some(5));
379        list.ban_permanent("peer-3", "c");
380
381        // Advance to tick 2 => peer-1 expires
382        list.tick_cleanup(); // tick=1
383        list.tick_cleanup(); // tick=2
384        assert_eq!(list.banned_count(), 2); // peer-2, peer-3 remain
385        assert!(list.get_ban("peer-1").is_none());
386    }
387
388    #[test]
389    fn test_tick_cleanup_preserves_permanent_bans() {
390        let mut list = default_list();
391        list.ban_permanent("peer-1", "forever");
392        for _ in 0..100 {
393            list.tick_cleanup();
394        }
395        assert_eq!(list.banned_count(), 1);
396    }
397
398    #[test]
399    fn test_tick_cleanup_advances_tick() {
400        let mut list = default_list();
401        assert_eq!(list.current_tick(), 0);
402        list.tick_cleanup();
403        assert_eq!(list.current_tick(), 1);
404        list.tick_cleanup();
405        assert_eq!(list.current_tick(), 2);
406    }
407
408    // --- max_bans enforcement ---
409
410    #[test]
411    fn test_max_bans_enforcement_temp() {
412        let config = BanConfig {
413            default_temp_ttl_ticks: 500,
414            max_bans: 3,
415        };
416        let mut list = PeerBanList::new(config);
417        list.ban_temporary("p1", "a", None);
418        list.ban_temporary("p2", "b", None);
419        list.ban_temporary("p3", "c", None);
420        // This should be silently dropped
421        list.ban_temporary("p4", "d", None);
422        assert_eq!(list.banned_count(), 3);
423        assert!(!list.is_banned("p4"));
424    }
425
426    #[test]
427    fn test_max_bans_enforcement_permanent() {
428        let config = BanConfig {
429            default_temp_ttl_ticks: 500,
430            max_bans: 2,
431        };
432        let mut list = PeerBanList::new(config);
433        list.ban_permanent("p1", "a");
434        list.ban_permanent("p2", "b");
435        list.ban_permanent("p3", "c"); // dropped
436        assert_eq!(list.banned_count(), 2);
437        assert!(!list.is_banned("p3"));
438    }
439
440    #[test]
441    fn test_max_bans_allows_reban_existing() {
442        let config = BanConfig {
443            default_temp_ttl_ticks: 500,
444            max_bans: 2,
445        };
446        let mut list = PeerBanList::new(config);
447        list.ban_temporary("p1", "a", None);
448        list.ban_temporary("p2", "b", None);
449        // Re-banning an existing peer should work even at capacity
450        list.ban_temporary("p1", "updated reason", Some(100));
451        assert_eq!(list.banned_count(), 2);
452        let entry = list.get_ban("p1").expect("should exist");
453        assert_eq!(entry.reason, "updated reason");
454    }
455
456    // --- Re-banning updates entry ---
457
458    #[test]
459    fn test_reban_updates_entry_temp_to_permanent() {
460        let mut list = default_list();
461        list.ban_temporary("peer-1", "spam", Some(10));
462        list.ban_permanent("peer-1", "escalated");
463        let entry = list.get_ban("peer-1").expect("should exist");
464        assert_eq!(entry.kind, BanKind::Permanent);
465        assert_eq!(entry.reason, "escalated");
466    }
467
468    #[test]
469    fn test_reban_updates_entry_permanent_to_temp() {
470        let mut list = default_list();
471        list.ban_permanent("peer-1", "permanent");
472        list.ban_temporary("peer-1", "downgraded", Some(50));
473        let entry = list.get_ban("peer-1").expect("should exist");
474        assert_eq!(entry.kind, BanKind::Temporary);
475        assert_eq!(entry.reason, "downgraded");
476    }
477
478    #[test]
479    fn test_reban_increments_total_bans() {
480        let mut list = default_list();
481        list.ban_temporary("peer-1", "first", None);
482        list.ban_temporary("peer-1", "second", None);
483        let stats = list.stats();
484        assert_eq!(stats.total_bans_issued, 2);
485        assert_eq!(stats.active_bans, 1);
486    }
487
488    // --- Stats ---
489
490    #[test]
491    fn test_stats_accuracy() {
492        let mut list = default_list();
493        list.ban_temporary("p1", "a", None);
494        list.ban_temporary("p2", "b", None);
495        list.ban_permanent("p3", "c");
496        list.unban("p1");
497
498        let stats = list.stats();
499        assert_eq!(stats.active_bans, 2);
500        assert_eq!(stats.temporary_bans, 1);
501        assert_eq!(stats.permanent_bans, 1);
502        assert_eq!(stats.total_bans_issued, 3);
503        assert_eq!(stats.total_unbans, 1);
504    }
505
506    #[test]
507    fn test_stats_after_tick_cleanup() {
508        let mut list = default_list();
509        list.ban_temporary("p1", "a", Some(1));
510        list.ban_temporary("p2", "b", Some(3));
511        list.ban_permanent("p3", "c");
512        list.tick_cleanup(); // tick=1 => p1 expires
513        let stats = list.stats();
514        assert_eq!(stats.active_bans, 2);
515        assert_eq!(stats.temporary_bans, 1);
516        assert_eq!(stats.permanent_bans, 1);
517        assert_eq!(stats.total_unbans, 1);
518    }
519
520    #[test]
521    fn test_stats_empty_list() {
522        let list = default_list();
523        let stats = list.stats();
524        assert_eq!(stats.active_bans, 0);
525        assert_eq!(stats.permanent_bans, 0);
526        assert_eq!(stats.temporary_bans, 0);
527        assert_eq!(stats.total_bans_issued, 0);
528        assert_eq!(stats.total_unbans, 0);
529    }
530
531    // --- list_banned ---
532
533    #[test]
534    fn test_list_banned_returns_all_entries() {
535        let mut list = default_list();
536        list.ban_temporary("p1", "a", None);
537        list.ban_permanent("p2", "b");
538        let entries = list.list_banned();
539        assert_eq!(entries.len(), 2);
540    }
541
542    #[test]
543    fn test_list_banned_empty() {
544        let list = default_list();
545        let entries = list.list_banned();
546        assert!(entries.is_empty());
547    }
548
549    // --- banned_count ---
550
551    #[test]
552    fn test_banned_count() {
553        let mut list = default_list();
554        assert_eq!(list.banned_count(), 0);
555        list.ban_temporary("p1", "a", None);
556        assert_eq!(list.banned_count(), 1);
557        list.ban_permanent("p2", "b");
558        assert_eq!(list.banned_count(), 2);
559        list.unban("p1");
560        assert_eq!(list.banned_count(), 1);
561    }
562
563    // --- Edge cases ---
564
565    #[test]
566    fn test_ban_empty_peer_id() {
567        let mut list = default_list();
568        list.ban_temporary("", "empty id", None);
569        assert!(list.is_banned(""));
570    }
571
572    #[test]
573    fn test_ban_empty_reason() {
574        let mut list = default_list();
575        list.ban_temporary("p1", "", None);
576        let entry = list.get_ban("p1").expect("should exist");
577        assert_eq!(entry.reason, "");
578    }
579
580    #[test]
581    fn test_zero_ttl_expires_immediately_on_cleanup() {
582        let mut list = default_list();
583        list.ban_temporary("p1", "zero", Some(0));
584        // expires_tick = current_tick + 0 = 0, current_tick = 0 => expired
585        assert!(!list.is_banned("p1"));
586    }
587
588    #[test]
589    fn test_large_ttl_does_not_overflow() {
590        let mut list = default_list();
591        list.ban_temporary("p1", "big", Some(u64::MAX));
592        let entry = list.get_ban("p1").expect("should exist");
593        // saturating_add should cap at u64::MAX
594        assert_eq!(entry.expires_tick, Some(u64::MAX));
595    }
596
597    #[test]
598    fn test_multiple_bans_and_unbans_stats() {
599        let mut list = default_list();
600        for i in 0..10 {
601            list.ban_temporary(&format!("p{}", i), "test", Some(5));
602        }
603        for i in 0..5 {
604            list.unban(&format!("p{}", i));
605        }
606        let stats = list.stats();
607        assert_eq!(stats.total_bans_issued, 10);
608        assert_eq!(stats.total_unbans, 5);
609        assert_eq!(stats.active_bans, 5);
610    }
611
612    #[test]
613    fn test_tick_cleanup_counts_as_unban() {
614        let mut list = default_list();
615        list.ban_temporary("p1", "a", Some(1));
616        list.ban_temporary("p2", "b", Some(1));
617        list.tick_cleanup(); // both expire at tick=1
618        let stats = list.stats();
619        assert_eq!(stats.total_unbans, 2);
620        assert_eq!(stats.active_bans, 0);
621    }
622
623    #[test]
624    fn test_get_ban_returns_none_for_unknown() {
625        let list = default_list();
626        assert!(list.get_ban("unknown").is_none());
627    }
628
629    #[test]
630    fn test_default_config() {
631        let config = BanConfig::default();
632        assert_eq!(config.default_temp_ttl_ticks, 500);
633        assert_eq!(config.max_bans, 10_000);
634    }
635
636    #[test]
637    fn test_ban_kind_equality() {
638        assert_eq!(BanKind::Temporary, BanKind::Temporary);
639        assert_eq!(BanKind::Permanent, BanKind::Permanent);
640        assert_ne!(BanKind::Temporary, BanKind::Permanent);
641    }
642}