1use std::collections::HashMap;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum BlacklistReason {
37 ManualBlock,
39 SpamDetected,
41 ProtocolViolation,
43 MaliciousBehavior,
45 RateLimitExceeded,
47 InvalidMessages,
49 ReputationThreshold,
51}
52
53#[derive(Debug, Clone)]
55pub struct BlacklistEntry {
56 pub peer_id: String,
58 pub reason: BlacklistReason,
60 pub blocked_at: u64,
62 pub expires_at: Option<u64>,
64 pub strike_count: u32,
66 pub notes: String,
68}
69
70#[derive(Debug, Clone)]
72pub struct BlacklistConfig {
73 pub max_entries: usize,
75 pub default_expiry_ms: Option<u64>,
78 pub auto_blacklist_strikes: u32,
80 pub permanent_after_strikes: u32,
82 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), auto_blacklist_strikes: 5,
92 permanent_after_strikes: 10,
93 strike_window_ms: 60_000, }
95 }
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct BlacklistStats {
101 pub total_blocked: u64,
103 pub total_unblocked: u64,
105 pub auto_blocked: u64,
107 pub permanent_blocks: u64,
109 pub expired_removed: u64,
111}
112
113pub struct PeerBlacklist {
118 config: BlacklistConfig,
119 entries: HashMap<String, BlacklistEntry>,
120 strike_log: HashMap<String, Vec<u64>>,
122 stats: BlacklistStats,
123}
124
125impl PeerBlacklist {
126 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 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 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 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, 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 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 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 pub fn is_blocked(&self, peer_id: &str) -> bool {
265 self.entries.contains_key(peer_id)
266 }
267
268 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 pub fn get_entry(&self, peer_id: &str) -> Option<&BlacklistEntry> {
284 self.entries.get(peer_id)
285 }
286
287 pub fn blocked_count(&self) -> usize {
289 self.entries.len()
290 }
291
292 pub fn all_entries(&self) -> Vec<&BlacklistEntry> {
294 self.entries.values().collect()
295 }
296
297 pub fn record_strike(&mut self, peer_id: &str, now: u64) -> u32 {
308 let log = self.strike_log.entry(peer_id.to_string()).or_default();
310 log.push(now);
311
312 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; if window_count >= self.config.auto_blacklist_strikes && !self.entries.contains_key(peer_id)
321 {
322 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 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 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 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 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 if !was_permanent && new_expiry.is_none() {
410 self.stats.permanent_blocks += 1;
411 }
412 true
413 }
414 }
415 }
416
417 pub fn stats(&self) -> &BlacklistStats {
423 &self.stats
424 }
425
426 fn resolve_expiry(&self, explicit: Option<u64>, strike_count: u32) -> Option<u64> {
433 if strike_count >= self.config.permanent_after_strikes {
434 return None; }
436 explicit
440 }
441}
442
443#[cfg(test)]
448mod tests {
449 use super::*;
450
451 const NOW: u64 = 1_700_000_000_000; fn default_config() -> BlacklistConfig {
454 BlacklistConfig {
455 max_entries: 1_000,
456 default_expiry_ms: Some(60_000), auto_blacklist_strikes: 3,
458 permanent_after_strikes: 6,
459 strike_window_ms: 30_000, }
461 }
462
463 fn make_bl() -> PeerBlacklist {
464 PeerBlacklist::new(default_config())
465 }
466
467 #[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 #[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 #[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 #[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 bl.record_strike("p1", NOW);
581 assert_eq!(bl.strikes_in_window("p1", NOW + 40_000), 0);
583 }
584
585 #[test]
588 fn test_auto_blacklist_triggers_at_threshold() {
589 let mut bl = make_bl(); bl.record_strike("p1", NOW);
591 bl.record_strike("p1", NOW + 1_000);
592 assert!(!bl.is_blocked("p1")); bl.record_strike("p1", NOW + 2_000); 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 #[test]
619 fn test_permanent_block_after_strike_threshold() {
620 let mut bl = make_bl(); 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(); 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 assert!(
637 entry.expires_at.is_some(),
638 "should not be permanent at only 3 strikes"
639 );
640 }
641
642 #[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); let removed = bl.purge_expired(NOW + 1_500);
663 assert_eq!(removed, 1); 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 #[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 #[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 assert_eq!(bl.stats().permanent_blocks, 1);
761 }
762
763 #[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 #[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 #[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 #[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); assert_eq!(bl.stats().total_blocked, 1);
834 assert_eq!(bl.blocked_count(), 1);
835 }
836
837 #[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 #[test]
852 fn test_blacklist_reason_equality() {
853 assert_eq!(BlacklistReason::ManualBlock, BlacklistReason::ManualBlock);
854 assert_ne!(BlacklistReason::ManualBlock, BlacklistReason::SpamDetected);
855 }
856}