1use std::collections::HashMap;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BanKind {
34 Temporary,
36 Permanent,
38}
39
40#[derive(Debug, Clone)]
42pub struct BanEntry {
43 pub peer_id: String,
45 pub kind: BanKind,
47 pub reason: String,
49 pub banned_tick: u64,
51 pub expires_tick: Option<u64>,
53}
54
55#[derive(Debug, Clone)]
57pub struct BanConfig {
58 pub default_temp_ttl_ticks: u64,
60 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#[derive(Debug, Clone)]
75pub struct BanListStats {
76 pub active_bans: usize,
78 pub permanent_bans: usize,
80 pub temporary_bans: usize,
82 pub total_bans_issued: u64,
84 pub total_unbans: u64,
86}
87
88#[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 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 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 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 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 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 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 self.bans.remove(peer_id);
182 self.total_unbans += 1;
183 false
184 } else {
185 true
186 }
187 } else {
188 true
190 }
191 }
192 }
193 } else {
194 false
195 }
196 }
197
198 pub fn get_ban(&self, peer_id: &str) -> Option<&BanEntry> {
203 self.bans.get(peer_id)
204 }
205
206 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 pub fn banned_count(&self) -> usize {
225 self.bans.len()
226 }
227
228 pub fn list_banned(&self) -> Vec<&BanEntry> {
230 self.bans.values().collect()
231 }
232
233 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 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 #[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 list.tick_cleanup(); list.tick_cleanup(); assert!(list.is_banned("peer-1")); list.tick_cleanup(); 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)); }
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 #[test]
311 fn test_permanent_ban_persists() {
312 let mut list = default_list();
313 list.ban_permanent("peer-1", "protocol violation");
314 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 #[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 #[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(); 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 #[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 list.tick_cleanup(); list.tick_cleanup(); assert_eq!(list.banned_count(), 2); 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 #[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 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"); 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 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 #[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 #[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(); 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 #[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 #[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 #[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 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 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(); 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}