Skip to main content

ipfrs_network/
peer_blacklist.rs

1//! Peer blacklist with expiry, reason tracking, and reputation-based auto-blacklisting.
2//!
3//! Provides a wall-clock-time-aware blacklist for peers, supporting:
4//! - Manual blocking with optional expiry
5//! - Strike-based automatic blacklisting with configurable thresholds
6//! - Permanent escalation after repeated offences
7//! - Fine-grained reason classification
8//! - Aggregate statistics
9//!
10//! # Example
11//!
12//! ```rust
13//! use ipfrs_network::peer_blacklist::{PeerBlacklist, BlacklistConfig, BlacklistReason};
14//!
15//! let config = BlacklistConfig::default();
16//! let mut bl = PeerBlacklist::new(config);
17//!
18//! // Manually block a peer for 60 seconds (in milliseconds)
19//! let now = 1_000_000u64;
20//! bl.block("peer-1", BlacklistReason::ManualBlock, "test block", Some(now + 60_000));
21//! assert!(bl.is_blocked_at("peer-1", now));
22//!
23//! // Record strikes to trigger auto-block
24//! let count = bl.record_strike("peer-2", now);
25//! assert!(count >= 1);
26//! ```
27
28use std::collections::HashMap;
29
30// ---------------------------------------------------------------------------
31// Public types
32// ---------------------------------------------------------------------------
33
34/// Reason why a peer was added to the blacklist.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum BlacklistReason {
37    /// Manually blocked by an operator.
38    ManualBlock,
39    /// Peer sent an excessive number of messages in a short window.
40    SpamDetected,
41    /// Peer violated the expected protocol semantics.
42    ProtocolViolation,
43    /// Peer performed actions classified as malicious.
44    MaliciousBehavior,
45    /// Peer repeatedly exceeded connection-level rate limits.
46    RateLimitExceeded,
47    /// Peer sent messages that failed validation.
48    InvalidMessages,
49    /// Peer's reputation score fell below the configured threshold.
50    ReputationThreshold,
51}
52
53/// A single entry in the blacklist.
54#[derive(Debug, Clone)]
55pub struct BlacklistEntry {
56    /// Peer identifier (libp2p PeerId string or any opaque identifier).
57    pub peer_id: String,
58    /// Reason the peer was blacklisted.
59    pub reason: BlacklistReason,
60    /// Unix-epoch milliseconds when the block was recorded.
61    pub blocked_at: u64,
62    /// Unix-epoch milliseconds when the block expires. `None` means permanent.
63    pub expires_at: Option<u64>,
64    /// Total number of strikes accumulated (across all windows) by this peer.
65    pub strike_count: u32,
66    /// Free-form operator notes.
67    pub notes: String,
68}
69
70/// Configuration for `PeerBlacklist`.
71#[derive(Debug, Clone)]
72pub struct BlacklistConfig {
73    /// Maximum number of entries retained. Oldest entries are evicted when exceeded.
74    pub max_entries: usize,
75    /// Default expiry in milliseconds for blocks that do not specify one.
76    /// `None` means blocks without an explicit expiry are permanent by default.
77    pub default_expiry_ms: Option<u64>,
78    /// Number of strikes (within `strike_window_ms`) that trigger an automatic block.
79    pub auto_blacklist_strikes: u32,
80    /// After this many total strikes, the block becomes permanent regardless of expiry.
81    pub permanent_after_strikes: u32,
82    /// Width of the rolling window (in milliseconds) used to count strikes.
83    pub strike_window_ms: u64,
84}
85
86impl Default for BlacklistConfig {
87    fn default() -> Self {
88        Self {
89            max_entries: 10_000,
90            default_expiry_ms: Some(3_600_000), // 1 hour
91            auto_blacklist_strikes: 5,
92            permanent_after_strikes: 10,
93            strike_window_ms: 60_000, // 1 minute
94        }
95    }
96}
97
98/// Aggregate statistics for the blacklist.
99#[derive(Debug, Clone, Default)]
100pub struct BlacklistStats {
101    /// Total peers ever blocked (including re-blocks).
102    pub total_blocked: u64,
103    /// Total peers ever unblocked (manual or via expiry purge).
104    pub total_unblocked: u64,
105    /// Peers automatically blocked via the strike threshold.
106    pub auto_blocked: u64,
107    /// Peers given a permanent block (either manual or strike escalation).
108    pub permanent_blocks: u64,
109    /// Entries removed by `purge_expired`.
110    pub expired_removed: u64,
111}
112
113/// Peer blacklist manager.
114///
115/// Uses wall-clock time (Unix-epoch milliseconds supplied by the caller) so
116/// that it is fully deterministic and testable without relying on system clocks.
117pub struct PeerBlacklist {
118    config: BlacklistConfig,
119    entries: HashMap<String, BlacklistEntry>,
120    /// Maps peer_id -> sorted list of timestamps at which strikes were recorded.
121    strike_log: HashMap<String, Vec<u64>>,
122    stats: BlacklistStats,
123}
124
125impl PeerBlacklist {
126    // -----------------------------------------------------------------------
127    // Construction
128    // -----------------------------------------------------------------------
129
130    /// Create a new `PeerBlacklist` with the given configuration.
131    pub fn new(config: BlacklistConfig) -> Self {
132        Self {
133            config,
134            entries: HashMap::new(),
135            strike_log: HashMap::new(),
136            stats: BlacklistStats::default(),
137        }
138    }
139
140    // -----------------------------------------------------------------------
141    // Core block / unblock
142    // -----------------------------------------------------------------------
143
144    /// Block a peer.
145    ///
146    /// Returns `false` if the peer is already blocked (the existing entry is
147    /// left unchanged); returns `true` on success.
148    ///
149    /// If `expires_at` is `None` the `default_expiry_ms` from `BlacklistConfig`
150    /// is applied relative to the current strike-count; if the peer's strike
151    /// count already meets `permanent_after_strikes`, the block is made permanent.
152    pub fn block(
153        &mut self,
154        peer_id: &str,
155        reason: BlacklistReason,
156        notes: &str,
157        expires_at: Option<u64>,
158    ) -> bool {
159        if self.entries.contains_key(peer_id) {
160            return false;
161        }
162
163        // Enforce max_entries: remove the first (arbitrary) entry when full.
164        if self.entries.len() >= self.config.max_entries {
165            if let Some(key) = self.entries.keys().next().cloned() {
166                self.entries.remove(&key);
167            }
168        }
169
170        let strike_count = self
171            .strike_log
172            .get(peer_id)
173            .map(|v| v.len() as u32)
174            .unwrap_or(0);
175
176        // Resolve effective expiry.
177        let effective_expires = self.resolve_expiry(expires_at, strike_count);
178
179        let is_permanent = effective_expires.is_none();
180
181        let entry = BlacklistEntry {
182            peer_id: peer_id.to_string(),
183            reason,
184            blocked_at: 0, // caller did not pass `now`; use sentinel value
185            expires_at: effective_expires,
186            strike_count,
187            notes: notes.to_string(),
188        };
189
190        self.entries.insert(peer_id.to_string(), entry);
191        self.stats.total_blocked += 1;
192        if is_permanent {
193            self.stats.permanent_blocks += 1;
194        }
195        true
196    }
197
198    /// Block a peer with an explicit `now` timestamp for `blocked_at`.
199    ///
200    /// Returns `false` if already blocked.
201    pub fn block_at(
202        &mut self,
203        peer_id: &str,
204        reason: BlacklistReason,
205        notes: &str,
206        expires_at: Option<u64>,
207        now: u64,
208    ) -> bool {
209        if self.entries.contains_key(peer_id) {
210            return false;
211        }
212
213        if self.entries.len() >= self.config.max_entries {
214            if let Some(key) = self.entries.keys().next().cloned() {
215                self.entries.remove(&key);
216            }
217        }
218
219        let strike_count = self
220            .strike_log
221            .get(peer_id)
222            .map(|v| v.len() as u32)
223            .unwrap_or(0);
224
225        let effective_expires = self.resolve_expiry(expires_at, strike_count);
226        let is_permanent = effective_expires.is_none();
227
228        let entry = BlacklistEntry {
229            peer_id: peer_id.to_string(),
230            reason,
231            blocked_at: now,
232            expires_at: effective_expires,
233            strike_count,
234            notes: notes.to_string(),
235        };
236
237        self.entries.insert(peer_id.to_string(), entry);
238        self.stats.total_blocked += 1;
239        if is_permanent {
240            self.stats.permanent_blocks += 1;
241        }
242        true
243    }
244
245    /// Unblock a peer regardless of expiry.
246    ///
247    /// Returns `false` if the peer was not in the blacklist.
248    pub fn unblock(&mut self, peer_id: &str) -> bool {
249        if self.entries.remove(peer_id).is_some() {
250            self.stats.total_unblocked += 1;
251            true
252        } else {
253            false
254        }
255    }
256
257    // -----------------------------------------------------------------------
258    // Lookup
259    // -----------------------------------------------------------------------
260
261    /// Check whether a peer is currently blocked, **without** checking expiry.
262    ///
263    /// Use `is_blocked_at` for expiry-aware checks.
264    pub fn is_blocked(&self, peer_id: &str) -> bool {
265        self.entries.contains_key(peer_id)
266    }
267
268    /// Check whether a peer is blocked at the given wall-clock time (ms).
269    ///
270    /// An entry whose `expires_at` is `Some(t)` and `t <= now` is considered
271    /// expired and treated as not blocked.
272    pub fn is_blocked_at(&self, peer_id: &str, now: u64) -> bool {
273        match self.entries.get(peer_id) {
274            None => false,
275            Some(entry) => match entry.expires_at {
276                None => true,
277                Some(exp) => exp > now,
278            },
279        }
280    }
281
282    /// Return a reference to the blacklist entry for a peer, if present.
283    pub fn get_entry(&self, peer_id: &str) -> Option<&BlacklistEntry> {
284        self.entries.get(peer_id)
285    }
286
287    /// Return the number of entries currently in the blacklist.
288    pub fn blocked_count(&self) -> usize {
289        self.entries.len()
290    }
291
292    /// Return references to all current blacklist entries.
293    pub fn all_entries(&self) -> Vec<&BlacklistEntry> {
294        self.entries.values().collect()
295    }
296
297    // -----------------------------------------------------------------------
298    // Strike system
299    // -----------------------------------------------------------------------
300
301    /// Record a strike against a peer at timestamp `now` (Unix-epoch ms).
302    ///
303    /// Returns the number of strikes within the current window
304    /// (`strike_window_ms`). When the count reaches `auto_blacklist_strikes`,
305    /// the peer is automatically blocked; if `permanent_after_strikes` is also
306    /// reached, the block is permanent.
307    pub fn record_strike(&mut self, peer_id: &str, now: u64) -> u32 {
308        // Append the new strike timestamp.
309        let log = self.strike_log.entry(peer_id.to_string()).or_default();
310        log.push(now);
311
312        // Trim strikes older than the window.
313        let cutoff = now.saturating_sub(self.config.strike_window_ms);
314        log.retain(|&t| t > cutoff);
315
316        let window_count = log.len() as u32;
317        let total_count = log.len() as u32; // we use window count for auto-block too
318
319        // Determine whether we should auto-block.
320        if window_count >= self.config.auto_blacklist_strikes && !self.entries.contains_key(peer_id)
321        {
322            // Determine permanence from cumulative strikes in the log.
323            // We count all recorded (potentially pruned) strikes via the entry's
324            // strike_count field after insertion.
325            let is_permanent = total_count >= self.config.permanent_after_strikes;
326            let expires_at = if is_permanent {
327                None
328            } else {
329                self.config.default_expiry_ms.map(|d| now.saturating_add(d))
330            };
331
332            if self.entries.len() >= self.config.max_entries {
333                if let Some(key) = self.entries.keys().next().cloned() {
334                    self.entries.remove(&key);
335                }
336            }
337
338            let entry = BlacklistEntry {
339                peer_id: peer_id.to_string(),
340                reason: BlacklistReason::ReputationThreshold,
341                blocked_at: now,
342                expires_at,
343                strike_count: total_count,
344                notes: format!("{} strikes in window", window_count),
345            };
346            self.entries.insert(peer_id.to_string(), entry);
347            self.stats.total_blocked += 1;
348            self.stats.auto_blocked += 1;
349            if is_permanent {
350                self.stats.permanent_blocks += 1;
351            }
352        } else if let Some(entry) = self.entries.get_mut(peer_id) {
353            // Peer is already blocked — update strike count and upgrade to
354            // permanent if the total now meets the threshold.
355            entry.strike_count = entry.strike_count.saturating_add(1);
356            if entry.expires_at.is_some()
357                && entry.strike_count >= self.config.permanent_after_strikes
358            {
359                entry.expires_at = None;
360                self.stats.permanent_blocks += 1;
361            }
362        }
363
364        window_count
365    }
366
367    /// Return the number of strikes recorded for `peer_id` within the rolling
368    /// window ending at `now`.
369    pub fn strikes_in_window(&self, peer_id: &str, now: u64) -> u32 {
370        let cutoff = now.saturating_sub(self.config.strike_window_ms);
371        self.strike_log
372            .get(peer_id)
373            .map(|v| v.iter().filter(|&&t| t > cutoff).count() as u32)
374            .unwrap_or(0)
375    }
376
377    // -----------------------------------------------------------------------
378    // Maintenance
379    // -----------------------------------------------------------------------
380
381    /// Remove all entries whose `expires_at` is `Some(t)` with `t <= now`.
382    ///
383    /// Returns the number of entries removed.
384    pub fn purge_expired(&mut self, now: u64) -> usize {
385        let before = self.entries.len();
386        self.entries.retain(|_, entry| match entry.expires_at {
387            None => true,
388            Some(exp) => exp > now,
389        });
390        let removed = before - self.entries.len();
391        self.stats.expired_removed += removed as u64;
392        self.stats.total_unblocked += removed as u64;
393        removed
394    }
395
396    /// Extend (or make permanent) the block for an existing entry.
397    ///
398    /// `new_expiry` is the absolute timestamp (ms) for the new expiry, or
399    /// `None` to make the block permanent.
400    ///
401    /// Returns `false` if the peer is not currently in the blacklist.
402    pub fn extend_block(&mut self, peer_id: &str, new_expiry: Option<u64>) -> bool {
403        match self.entries.get_mut(peer_id) {
404            None => false,
405            Some(entry) => {
406                let was_permanent = entry.expires_at.is_none();
407                entry.expires_at = new_expiry;
408                // Track new permanent block if this is an escalation.
409                if !was_permanent && new_expiry.is_none() {
410                    self.stats.permanent_blocks += 1;
411                }
412                true
413            }
414        }
415    }
416
417    // -----------------------------------------------------------------------
418    // Stats
419    // -----------------------------------------------------------------------
420
421    /// Return a reference to the current aggregate statistics.
422    pub fn stats(&self) -> &BlacklistStats {
423        &self.stats
424    }
425
426    // -----------------------------------------------------------------------
427    // Private helpers
428    // -----------------------------------------------------------------------
429
430    /// Resolve the effective expiry timestamp, applying the permanent-after-strikes
431    /// rule and the default expiry.
432    fn resolve_expiry(&self, explicit: Option<u64>, strike_count: u32) -> Option<u64> {
433        if strike_count >= self.config.permanent_after_strikes {
434            return None; // permanent
435        }
436        // default_expiry_ms is relative; we have no `now` in this path when
437        // called from the non-`_at` variant. Return `None` (permanent) as
438        // the safest default when we cannot compute an absolute timestamp.
439        explicit
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Tests
445// ---------------------------------------------------------------------------
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    const NOW: u64 = 1_700_000_000_000; // arbitrary fixed "now" in ms
452
453    fn default_config() -> BlacklistConfig {
454        BlacklistConfig {
455            max_entries: 1_000,
456            default_expiry_ms: Some(60_000), // 1 minute
457            auto_blacklist_strikes: 3,
458            permanent_after_strikes: 6,
459            strike_window_ms: 30_000, // 30 seconds
460        }
461    }
462
463    fn make_bl() -> PeerBlacklist {
464        PeerBlacklist::new(default_config())
465    }
466
467    // --- block / unblock ---
468
469    #[test]
470    fn test_block_returns_true_on_success() {
471        let mut bl = make_bl();
472        let result = bl.block_at("p1", BlacklistReason::ManualBlock, "test", None, NOW);
473        assert!(result);
474    }
475
476    #[test]
477    fn test_block_returns_false_when_already_blocked() {
478        let mut bl = make_bl();
479        bl.block_at("p1", BlacklistReason::ManualBlock, "first", None, NOW);
480        let result = bl.block_at("p1", BlacklistReason::SpamDetected, "second", None, NOW);
481        assert!(!result);
482    }
483
484    #[test]
485    fn test_unblock_returns_true_when_blocked() {
486        let mut bl = make_bl();
487        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
488        assert!(bl.unblock("p1"));
489    }
490
491    #[test]
492    fn test_unblock_returns_false_when_not_blocked() {
493        let mut bl = make_bl();
494        assert!(!bl.unblock("nonexistent"));
495    }
496
497    #[test]
498    fn test_unblock_removes_entry() {
499        let mut bl = make_bl();
500        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
501        bl.unblock("p1");
502        assert!(!bl.is_blocked("p1"));
503    }
504
505    // --- is_blocked ---
506
507    #[test]
508    fn test_is_blocked_true_when_present() {
509        let mut bl = make_bl();
510        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
511        assert!(bl.is_blocked("p1"));
512    }
513
514    #[test]
515    fn test_is_blocked_false_for_unknown_peer() {
516        let bl = make_bl();
517        assert!(!bl.is_blocked("unknown"));
518    }
519
520    // --- is_blocked_at with expiry ---
521
522    #[test]
523    fn test_is_blocked_at_returns_true_before_expiry() {
524        let mut bl = make_bl();
525        let expiry = NOW + 10_000;
526        bl.block_at("p1", BlacklistReason::ManualBlock, "", Some(expiry), NOW);
527        assert!(bl.is_blocked_at("p1", NOW));
528        assert!(bl.is_blocked_at("p1", NOW + 9_999));
529    }
530
531    #[test]
532    fn test_is_blocked_at_returns_false_at_expiry() {
533        let mut bl = make_bl();
534        let expiry = NOW + 10_000;
535        bl.block_at("p1", BlacklistReason::ManualBlock, "", Some(expiry), NOW);
536        assert!(!bl.is_blocked_at("p1", expiry));
537    }
538
539    #[test]
540    fn test_is_blocked_at_returns_false_after_expiry() {
541        let mut bl = make_bl();
542        let expiry = NOW + 10_000;
543        bl.block_at("p1", BlacklistReason::ManualBlock, "", Some(expiry), NOW);
544        assert!(!bl.is_blocked_at("p1", NOW + 20_000));
545    }
546
547    #[test]
548    fn test_is_blocked_at_permanent_entry_always_true() {
549        let mut bl = make_bl();
550        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
551        assert!(bl.is_blocked_at("p1", NOW + 1_000_000_000));
552    }
553
554    #[test]
555    fn test_is_blocked_at_false_for_unknown_peer() {
556        let bl = make_bl();
557        assert!(!bl.is_blocked_at("unknown", NOW));
558    }
559
560    // --- strike counting in window ---
561
562    #[test]
563    fn test_strikes_in_window_zero_initially() {
564        let bl = make_bl();
565        assert_eq!(bl.strikes_in_window("p1", NOW), 0);
566    }
567
568    #[test]
569    fn test_strikes_in_window_counts_recent() {
570        let mut bl = make_bl();
571        bl.record_strike("p1", NOW);
572        bl.record_strike("p1", NOW + 1_000);
573        assert_eq!(bl.strikes_in_window("p1", NOW + 2_000), 2);
574    }
575
576    #[test]
577    fn test_strikes_in_window_excludes_old() {
578        let mut bl = make_bl();
579        // Record a strike that is outside the 30-second window
580        bl.record_strike("p1", NOW);
581        // Query from 40 seconds later — the strike at NOW is outside the window
582        assert_eq!(bl.strikes_in_window("p1", NOW + 40_000), 0);
583    }
584
585    // --- auto-blacklist on threshold ---
586
587    #[test]
588    fn test_auto_blacklist_triggers_at_threshold() {
589        let mut bl = make_bl(); // auto_blacklist_strikes = 3
590        bl.record_strike("p1", NOW);
591        bl.record_strike("p1", NOW + 1_000);
592        assert!(!bl.is_blocked("p1")); // not yet at threshold
593        bl.record_strike("p1", NOW + 2_000); // 3rd strike -> auto-block
594        assert!(bl.is_blocked("p1"));
595    }
596
597    #[test]
598    fn test_auto_blacklist_increments_auto_blocked_stat() {
599        let mut bl = make_bl();
600        for i in 0..3 {
601            bl.record_strike("p1", NOW + i * 1_000);
602        }
603        assert_eq!(bl.stats().auto_blocked, 1);
604    }
605
606    #[test]
607    fn test_auto_blacklist_reason_is_reputation_threshold() {
608        let mut bl = make_bl();
609        for i in 0..3 {
610            bl.record_strike("p1", NOW + i * 1_000);
611        }
612        let entry = bl.get_entry("p1").expect("should be auto-blocked");
613        assert_eq!(entry.reason, BlacklistReason::ReputationThreshold);
614    }
615
616    // --- permanent after strikes ---
617
618    #[test]
619    fn test_permanent_block_after_strike_threshold() {
620        let mut bl = make_bl(); // permanent_after_strikes = 6
621        for i in 0..6 {
622            bl.record_strike("p2", NOW + i * 1_000);
623        }
624        let entry = bl.get_entry("p2").expect("should be blocked");
625        assert!(entry.expires_at.is_none(), "should be permanent");
626    }
627
628    #[test]
629    fn test_auto_block_not_permanent_below_threshold() {
630        let mut bl = make_bl(); // auto at 3, permanent at 6
631        for i in 0..3 {
632            bl.record_strike("p3", NOW + i * 1_000);
633        }
634        let entry = bl.get_entry("p3").expect("should be auto-blocked");
635        // 3 strikes < permanent_after_strikes (6), so should have an expiry
636        assert!(
637            entry.expires_at.is_some(),
638            "should not be permanent at only 3 strikes"
639        );
640    }
641
642    // --- purge_expired ---
643
644    #[test]
645    fn test_purge_expired_removes_expired_entries() {
646        let mut bl = make_bl();
647        bl.block_at(
648            "p1",
649            BlacklistReason::ManualBlock,
650            "",
651            Some(NOW + 1_000),
652            NOW,
653        );
654        bl.block_at(
655            "p2",
656            BlacklistReason::ManualBlock,
657            "",
658            Some(NOW + 2_000),
659            NOW,
660        );
661        bl.block_at("p3", BlacklistReason::ManualBlock, "", None, NOW); // permanent
662        let removed = bl.purge_expired(NOW + 1_500);
663        assert_eq!(removed, 1); // only p1 expired
664        assert!(!bl.is_blocked("p1"));
665        assert!(bl.is_blocked("p2"));
666        assert!(bl.is_blocked("p3"));
667    }
668
669    #[test]
670    fn test_purge_expired_returns_zero_when_none_expired() {
671        let mut bl = make_bl();
672        bl.block_at(
673            "p1",
674            BlacklistReason::ManualBlock,
675            "",
676            Some(NOW + 10_000),
677            NOW,
678        );
679        assert_eq!(bl.purge_expired(NOW), 0);
680    }
681
682    #[test]
683    fn test_purge_expired_updates_stats() {
684        let mut bl = make_bl();
685        bl.block_at(
686            "p1",
687            BlacklistReason::ManualBlock,
688            "",
689            Some(NOW + 1_000),
690            NOW,
691        );
692        bl.purge_expired(NOW + 2_000);
693        assert_eq!(bl.stats().expired_removed, 1);
694        assert_eq!(bl.stats().total_unblocked, 1);
695    }
696
697    // --- extend_block ---
698
699    #[test]
700    fn test_extend_block_updates_expiry() {
701        let mut bl = make_bl();
702        bl.block_at(
703            "p1",
704            BlacklistReason::ManualBlock,
705            "",
706            Some(NOW + 1_000),
707            NOW,
708        );
709        let result = bl.extend_block("p1", Some(NOW + 10_000));
710        assert!(result);
711        let entry = bl.get_entry("p1").expect("should exist");
712        assert_eq!(entry.expires_at, Some(NOW + 10_000));
713    }
714
715    #[test]
716    fn test_extend_block_to_permanent() {
717        let mut bl = make_bl();
718        bl.block_at(
719            "p1",
720            BlacklistReason::ManualBlock,
721            "",
722            Some(NOW + 1_000),
723            NOW,
724        );
725        bl.extend_block("p1", None);
726        let entry = bl.get_entry("p1").expect("should exist");
727        assert!(entry.expires_at.is_none());
728    }
729
730    #[test]
731    fn test_extend_block_returns_false_when_not_blocked() {
732        let mut bl = make_bl();
733        assert!(!bl.extend_block("unknown", None));
734    }
735
736    // --- stats ---
737
738    #[test]
739    fn test_stats_total_blocked() {
740        let mut bl = make_bl();
741        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
742        bl.block_at("p2", BlacklistReason::ManualBlock, "", None, NOW);
743        assert_eq!(bl.stats().total_blocked, 2);
744    }
745
746    #[test]
747    fn test_stats_total_unblocked() {
748        let mut bl = make_bl();
749        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
750        bl.unblock("p1");
751        assert_eq!(bl.stats().total_unblocked, 1);
752    }
753
754    #[test]
755    fn test_stats_permanent_blocks() {
756        let mut bl = make_bl();
757        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
758        // p1 has 0 strikes so resolve_expiry would return None (no default now)
759        // -> permanent block counted
760        assert_eq!(bl.stats().permanent_blocks, 1);
761    }
762
763    // --- empty blacklist ---
764
765    #[test]
766    fn test_empty_blacklist_blocked_count() {
767        let bl = make_bl();
768        assert_eq!(bl.blocked_count(), 0);
769    }
770
771    #[test]
772    fn test_empty_blacklist_all_entries() {
773        let bl = make_bl();
774        assert!(bl.all_entries().is_empty());
775    }
776
777    #[test]
778    fn test_empty_blacklist_stats_all_zero() {
779        let bl = make_bl();
780        let s = bl.stats();
781        assert_eq!(s.total_blocked, 0);
782        assert_eq!(s.total_unblocked, 0);
783        assert_eq!(s.auto_blocked, 0);
784        assert_eq!(s.permanent_blocks, 0);
785        assert_eq!(s.expired_removed, 0);
786    }
787
788    // --- all_entries ---
789
790    #[test]
791    fn test_all_entries_returns_correct_count() {
792        let mut bl = make_bl();
793        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
794        bl.block_at(
795            "p2",
796            BlacklistReason::SpamDetected,
797            "",
798            Some(NOW + 1_000),
799            NOW,
800        );
801        let entries = bl.all_entries();
802        assert_eq!(entries.len(), 2);
803    }
804
805    // --- get_entry ---
806
807    #[test]
808    fn test_get_entry_fields() {
809        let mut bl = make_bl();
810        let expiry = NOW + 5_000;
811        bl.block_at(
812            "p1",
813            BlacklistReason::ProtocolViolation,
814            "bad proto",
815            Some(expiry),
816            NOW,
817        );
818        let entry = bl.get_entry("p1").expect("should exist");
819        assert_eq!(entry.peer_id, "p1");
820        assert_eq!(entry.reason, BlacklistReason::ProtocolViolation);
821        assert_eq!(entry.notes, "bad proto");
822        assert_eq!(entry.blocked_at, NOW);
823        assert_eq!(entry.expires_at, Some(expiry));
824    }
825
826    // --- duplicate block ---
827
828    #[test]
829    fn test_duplicate_block_does_not_double_count_stats() {
830        let mut bl = make_bl();
831        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
832        bl.block_at("p1", BlacklistReason::SpamDetected, "", None, NOW); // returns false, ignored
833        assert_eq!(bl.stats().total_blocked, 1);
834        assert_eq!(bl.blocked_count(), 1);
835    }
836
837    // --- blocked_count after operations ---
838
839    #[test]
840    fn test_blocked_count_after_block_and_unblock() {
841        let mut bl = make_bl();
842        bl.block_at("p1", BlacklistReason::ManualBlock, "", None, NOW);
843        bl.block_at("p2", BlacklistReason::ManualBlock, "", None, NOW);
844        assert_eq!(bl.blocked_count(), 2);
845        bl.unblock("p1");
846        assert_eq!(bl.blocked_count(), 1);
847    }
848
849    // --- BlacklistReason equality ---
850
851    #[test]
852    fn test_blacklist_reason_equality() {
853        assert_eq!(BlacklistReason::ManualBlock, BlacklistReason::ManualBlock);
854        assert_ne!(BlacklistReason::ManualBlock, BlacklistReason::SpamDetected);
855    }
856}