1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
//! IP and port filtering using sorted interval maps.
//!
//! Provides [`IpFilter`] for blocking peer connections by IP address range,
//! and [`PortFilter`] for blocking by port range. Supports eMule `.dat` and
//! P2P plaintext blocklist file formats.
use std::collections::BTreeMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::rate_limiter::is_local_network;
// ── Helper traits ────────────────────────────────────────────────────
/// Types that have a minimum and maximum value.
trait Bounded {
fn min_value() -> Self;
#[allow(dead_code)]
fn max_value() -> Self;
}
/// Types that can produce a successor (saturating).
trait Successor {
fn successor(self) -> Self;
}
impl Bounded for Ipv4Addr {
fn min_value() -> Self {
Self::UNSPECIFIED
}
fn max_value() -> Self {
Self::BROADCAST
}
}
impl Successor for Ipv4Addr {
fn successor(self) -> Self {
let n: u32 = self.into();
Self::from(n.saturating_add(1))
}
}
impl Bounded for Ipv6Addr {
fn min_value() -> Self {
Self::UNSPECIFIED
}
fn max_value() -> Self {
Self::new(
0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
)
}
}
impl Successor for Ipv6Addr {
fn successor(self) -> Self {
let n: u128 = self.into();
Self::from(n.saturating_add(1))
}
}
impl Bounded for u16 {
fn min_value() -> Self {
0
}
fn max_value() -> Self {
Self::MAX
}
}
impl Successor for u16 {
fn successor(self) -> Self {
self.saturating_add(1)
}
}
// ── IntervalMap ──────────────────────────────────────────────────────
/// A sorted interval map where each entry means "from this key onward, flags
/// are this value". The entire key space defaults to flags=0 (allowed).
///
/// `add_rule` applies last-applied-wins semantics for overlapping ranges.
#[derive(Debug, Clone)]
struct IntervalMap<K: Ord + Clone + Bounded + Successor> {
/// Sorted breakpoints: from this key onward, flags are the stored value.
map: BTreeMap<K, u32>,
}
impl<K: Ord + Clone + Bounded + Successor> IntervalMap<K> {
fn new() -> Self {
let mut map = BTreeMap::new();
// Entire space starts at 0 (allowed)
map.insert(K::min_value(), 0);
Self { map }
}
/// Set flags for the range `[first, last]`.
#[allow(
clippy::needless_pass_by_value,
reason = "K is consumed via .clone().successor() — taking &K just adds a clone at every call site"
)]
fn add_rule(&mut self, first: K, last: K, flags: u32) {
if first > last {
return;
}
// Save the flags that were in effect at `last.successor()` before we modify anything,
// so we can restore them after the range.
let after_key = last.clone().successor();
let flags_after = self.access(&after_key);
// Save the flags that were in effect just before `first`.
// We need this in case first == K::min_value().
// Actually we just need to set the breakpoint at `first` to `flags`.
// Remove all breakpoints strictly between first (exclusive) and after_key (exclusive)
// We collect keys to remove to avoid borrowing issues
let keys_to_remove: Vec<K> = self
.map
.range(first.clone()..after_key.clone())
.map(|(k, _)| k.clone())
.collect();
for k in keys_to_remove {
self.map.remove(&k);
}
// Set the start of our range
self.map.insert(first, flags);
// Restore the flags after our range (only if after_key is still in bounds)
if after_key > last {
self.map.insert(after_key, flags_after);
}
self.minimize();
}
/// Look up flags for a key. O(log n).
fn access(&self, key: &K) -> u32 {
self.map
.range(..=key.clone())
.next_back()
.map_or(0, |(_, &v)| v)
}
/// Remove consecutive entries with the same flags.
fn minimize(&mut self) {
let mut prev_flags: Option<u32> = None;
let mut to_remove = Vec::new();
for (k, &flags) in &self.map {
if prev_flags == Some(flags) {
to_remove.push(k.clone());
}
prev_flags = Some(flags);
}
for k in to_remove {
self.map.remove(&k);
}
}
/// Number of breakpoints in the map.
fn num_ranges(&self) -> usize {
// Count segments with non-zero flags
let mut count = 0;
for &flags in self.map.values() {
if flags != 0 {
count += 1;
}
}
count
}
fn is_empty(&self) -> bool {
self.num_ranges() == 0
}
}
// ── IpFilter ─────────────────────────────────────────────────────────
/// IP address filter supporting both IPv4 and IPv6 ranges.
///
/// Flags: 0 = allowed, non-zero = blocked.
/// Local/private network addresses are always exempt from filtering.
#[derive(Debug, Clone)]
pub struct IpFilter {
v4: IntervalMap<Ipv4Addr>,
v6: IntervalMap<Ipv6Addr>,
/// Master enabled switch — when `false`, `is_blocked` short-circuits to
/// `false` regardless of configured ranges (live bans rebuild semantics).
///
/// **Plain `bool` not `AtomicBool`** (per M225 OV F2b): `IpFilter` carries
/// `#[derive(Clone)]` and `AtomicBool` is not `Clone`. Mutation is safe
/// because the live filter is wrapped in `Arc<RwLock<IpFilter>>` at the
/// session level; writers take the write lock through `apply_settings`.
pub enabled: bool,
}
impl IpFilter {
/// Create a new filter that allows everything.
#[must_use]
pub fn new() -> Self {
Self {
v4: IntervalMap::new(),
v6: IntervalMap::new(),
enabled: true,
}
}
/// Add a rule blocking (or allowing) a range of IP addresses.
///
/// Both endpoints must be the same address family (both v4 or both v6).
/// Mixed families are silently ignored.
pub fn add_rule(&mut self, first: IpAddr, last: IpAddr, flags: u32) {
match (first, last) {
(IpAddr::V4(f), IpAddr::V4(l)) => self.v4.add_rule(f, l, flags),
(IpAddr::V6(f), IpAddr::V6(l)) => self.v6.add_rule(f, l, flags),
_ => {} // mixed families: ignore
}
}
/// Return the flags for an address. 0 = allowed.
#[must_use]
pub fn access(&self, addr: IpAddr) -> u32 {
match addr {
IpAddr::V4(ip) => self.v4.access(&ip),
IpAddr::V6(ip) => self.v6.access(&ip),
}
}
/// Check if an address is blocked by the filter.
///
/// Local/private network addresses (RFC 1918, loopback, link-local) are
/// always exempt and return `false` even if they fall within a blocked range.
#[must_use]
pub fn is_blocked(&self, addr: IpAddr) -> bool {
if !self.enabled {
return false;
}
if is_local_network(addr) {
return false;
}
self.access(addr) != 0
}
/// Total number of non-zero-flag ranges across both address families.
#[must_use]
pub fn num_ranges(&self) -> usize {
self.v4.num_ranges() + self.v6.num_ranges()
}
/// True if no rules have been added.
#[must_use]
pub fn is_empty(&self) -> bool {
self.v4.is_empty() && self.v6.is_empty()
}
}
impl Default for IpFilter {
fn default() -> Self {
Self::new()
}
}
// ── PortFilter ───────────────────────────────────────────────────────
/// Port range filter.
///
/// Flags: 0 = allowed, non-zero = blocked.
#[derive(Debug, Clone)]
pub struct PortFilter {
ports: IntervalMap<u16>,
}
impl PortFilter {
/// Create a new filter that allows all ports.
#[must_use]
pub fn new() -> Self {
Self {
ports: IntervalMap::new(),
}
}
/// Add a rule for a port range.
pub fn add_rule(&mut self, first: u16, last: u16, flags: u32) {
self.ports.add_rule(first, last, flags);
}
/// Return the flags for a port. 0 = allowed.
#[must_use]
pub fn access(&self, port: u16) -> u32 {
self.ports.access(&port)
}
/// Check if a port is blocked.
#[must_use]
pub fn is_blocked(&self, port: u16) -> bool {
self.access(port) != 0
}
}
impl Default for PortFilter {
fn default() -> Self {
Self::new()
}
}
// ── File Parsers ─────────────────────────────────────────────────────
/// Errors from parsing IP filter files.
#[derive(Debug, thiserror::Error)]
pub enum IpFilterError {
/// An IP address could not be parsed.
#[error("invalid IP address on line {line}: {message}")]
InvalidAddress {
/// One-based line number in the filter file.
line: usize,
/// Parse error description.
message: String,
},
/// A line could not be parsed (wrong number of fields, etc.).
#[error("malformed line {line}: {message}")]
MalformedLine {
/// One-based line number in the filter file.
line: usize,
/// Description of the formatting problem.
message: String,
},
}
/// Parse an eMule `.dat` format blocklist.
///
/// Format: `first_ip - last_ip , level , description`
/// Lines starting with `#` are comments.
///
/// # Errors
///
/// Returns an error if the data cannot be parsed or I/O fails.
pub fn parse_dat(input: &str) -> Result<IpFilter, IpFilterError> {
let mut filter = IpFilter::new();
for (line_num, line) in input.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on comma to get: "first_ip - last_ip", "level", "description"
let parts: Vec<&str> = line.splitn(3, ',').collect();
if parts.len() < 2 {
return Err(IpFilterError::MalformedLine {
line: line_num + 1,
message: "expected 'first_ip - last_ip , level , description'".into(),
});
}
// Parse IP range
let ip_range = parts[0].trim();
let ips: Vec<&str> = ip_range.splitn(2, '-').collect();
if ips.len() != 2 {
return Err(IpFilterError::MalformedLine {
line: line_num + 1,
message: "expected 'first_ip - last_ip'".into(),
});
}
let first: IpAddr = ips[0]
.trim()
.parse()
.map_err(
|e: std::net::AddrParseError| IpFilterError::InvalidAddress {
line: line_num + 1,
message: e.to_string(),
},
)?;
let last: IpAddr = ips[1]
.trim()
.parse()
.map_err(
|e: std::net::AddrParseError| IpFilterError::InvalidAddress {
line: line_num + 1,
message: e.to_string(),
},
)?;
// Parse level (flags)
let level: u32 = parts[1]
.trim()
.parse()
.map_err(|_| IpFilterError::MalformedLine {
line: line_num + 1,
message: "invalid level (expected integer)".into(),
})?;
filter.add_rule(first, last, level);
}
Ok(filter)
}
/// Parse a P2P plaintext format blocklist.
///
/// Format: `description:first_ip-last_ip`
/// Lines starting with `#` are comments.
///
/// # Errors
///
/// Returns an error if the data cannot be parsed or I/O fails.
pub fn parse_p2p(input: &str) -> Result<IpFilter, IpFilterError> {
let mut filter = IpFilter::new();
for (line_num, line) in input.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on last ':' to separate description from IP range
let colon_pos = line
.rfind(':')
.ok_or_else(|| IpFilterError::MalformedLine {
line: line_num + 1,
message: "expected 'description:first_ip-last_ip'".into(),
})?;
let ip_range = &line[colon_pos + 1..];
let ips: Vec<&str> = ip_range.splitn(2, '-').collect();
if ips.len() != 2 {
return Err(IpFilterError::MalformedLine {
line: line_num + 1,
message: "expected 'first_ip-last_ip' after ':'".into(),
});
}
let first: IpAddr = ips[0]
.trim()
.parse()
.map_err(
|e: std::net::AddrParseError| IpFilterError::InvalidAddress {
line: line_num + 1,
message: e.to_string(),
},
)?;
let last: IpAddr = ips[1]
.trim()
.parse()
.map_err(
|e: std::net::AddrParseError| IpFilterError::InvalidAddress {
line: line_num + 1,
message: e.to_string(),
},
)?;
// P2P format always blocks (flags=1)
filter.add_rule(first, last, 1);
}
Ok(filter)
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// Test 1: IntervalMap: empty returns allowed for any key
#[test]
fn interval_map_empty_returns_zero() {
let map: IntervalMap<Ipv4Addr> = IntervalMap::new();
assert_eq!(map.access(&Ipv4Addr::UNSPECIFIED), 0);
assert_eq!(map.access(&Ipv4Addr::new(192, 168, 1, 1)), 0);
assert_eq!(map.access(&Ipv4Addr::BROADCAST), 0);
}
// Test 2: IntervalMap: single range add + lookup inside/outside
#[test]
fn interval_map_single_range() {
let mut map: IntervalMap<Ipv4Addr> = IntervalMap::new();
map.add_rule(Ipv4Addr::new(10, 0, 0, 0), Ipv4Addr::new(10, 0, 0, 255), 1);
// Inside range
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 0)), 1);
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 128)), 1);
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 255)), 1);
// Outside range
assert_eq!(map.access(&Ipv4Addr::new(9, 255, 255, 255)), 0);
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 1, 0)), 0);
assert_eq!(map.access(&Ipv4Addr::new(192, 168, 1, 1)), 0);
}
// Test 3: IntervalMap: overlapping ranges — last-applied-wins
#[test]
fn interval_map_overlapping_last_wins() {
let mut map: IntervalMap<Ipv4Addr> = IntervalMap::new();
// Block 10.0.0.0 - 10.0.0.255 with flags=1
map.add_rule(Ipv4Addr::new(10, 0, 0, 0), Ipv4Addr::new(10, 0, 0, 255), 1);
// Allow 10.0.0.100 - 10.0.0.200 with flags=0 (override)
map.add_rule(
Ipv4Addr::new(10, 0, 0, 100),
Ipv4Addr::new(10, 0, 0, 200),
0,
);
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 50)), 1); // still blocked
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 100)), 0); // allowed (override)
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 150)), 0); // allowed (override)
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 200)), 0); // allowed (override)
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 201)), 1); // blocked again
assert_eq!(map.access(&Ipv4Addr::new(10, 0, 0, 255)), 1); // blocked
}
// Test 4: IpFilter IPv4: block /24, verify access inside/outside
#[test]
fn ip_filter_v4_block_range() {
let mut filter = IpFilter::new();
filter.add_rule(
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 0)),
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 255)),
1,
);
// Inside blocked range (public IPs, not local)
assert!(filter.is_blocked("203.0.113.0".parse().unwrap()));
assert!(filter.is_blocked("203.0.113.128".parse().unwrap()));
assert!(filter.is_blocked("203.0.113.255".parse().unwrap()));
// Outside
assert!(!filter.is_blocked("203.0.112.255".parse().unwrap()));
assert!(!filter.is_blocked("203.0.114.0".parse().unwrap()));
assert!(!filter.is_blocked("8.8.8.8".parse().unwrap()));
}
// Test 5: IpFilter IPv6: block range, verify access
#[test]
fn ip_filter_v6_block_range() {
let mut filter = IpFilter::new();
filter.add_rule(
IpAddr::V6("2001:db8::0".parse().unwrap()),
IpAddr::V6("2001:db8::ffff".parse().unwrap()),
1,
);
assert!(filter.is_blocked("2001:db8::1".parse().unwrap()));
assert!(filter.is_blocked("2001:db8::ff".parse().unwrap()));
assert!(!filter.is_blocked("2001:db9::1".parse().unwrap()));
}
// Test 6: Local network exemption: blocked range doesn't affect RFC 1918/loopback
#[test]
fn ip_filter_local_network_exempt() {
let mut filter = IpFilter::new();
// Block everything
filter.add_rule(
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V4(Ipv4Addr::BROADCAST),
1,
);
filter.add_rule(
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
IpAddr::V6(Ipv6Addr::new(
0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff,
)),
1,
);
// Local IPs are exempt
assert!(!filter.is_blocked("127.0.0.1".parse().unwrap()));
assert!(!filter.is_blocked("192.168.1.1".parse().unwrap()));
assert!(!filter.is_blocked("10.0.0.1".parse().unwrap()));
assert!(!filter.is_blocked("172.16.0.1".parse().unwrap()));
assert!(!filter.is_blocked("::1".parse().unwrap()));
// But the raw access() still shows blocked
assert_eq!(filter.access("127.0.0.1".parse().unwrap()), 1);
// Public IPs are blocked
assert!(filter.is_blocked("8.8.8.8".parse().unwrap()));
assert!(filter.is_blocked("2001:db8::1".parse().unwrap()));
}
// Test 7: Export: minimized non-overlapping ranges
#[test]
fn ip_filter_num_ranges() {
let mut filter = IpFilter::new();
assert_eq!(filter.num_ranges(), 0);
assert!(filter.is_empty());
filter.add_rule(
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 255)),
1,
);
assert_eq!(filter.num_ranges(), 1);
assert!(!filter.is_empty());
filter.add_rule(
IpAddr::V4(Ipv4Addr::new(172, 16, 0, 0)),
IpAddr::V4(Ipv4Addr::new(172, 16, 255, 255)),
1,
);
assert_eq!(filter.num_ranges(), 2);
// Adding overlapping range that allows part of first range
filter.add_rule(
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 255)),
0,
);
// First range is now allowed, so num_ranges drops
assert_eq!(filter.num_ranges(), 1);
}
// Test 8: parse_dat: valid lines, comments, malformed line error
#[test]
fn parse_dat_valid() {
let input = "\
# This is a comment
203.0.113.0 - 203.0.113.255 , 128 , Test range
198.51.100.0 - 198.51.100.255 , 1 , Another range
";
let filter = parse_dat(input).unwrap();
assert!(filter.is_blocked("203.0.113.50".parse().unwrap()));
assert!(filter.is_blocked("198.51.100.1".parse().unwrap()));
assert!(!filter.is_blocked("8.8.8.8".parse().unwrap()));
}
#[test]
fn parse_dat_malformed() {
let input = "this is not a valid line";
let err = parse_dat(input).unwrap_err();
assert!(matches!(err, IpFilterError::MalformedLine { line: 1, .. }));
}
// Test 9: parse_p2p: valid lines, comments, invalid IP error
#[test]
fn parse_p2p_valid() {
let input = "\
# P2P blocklist
Some Bad Range:203.0.113.0-203.0.113.255
Another Range:198.51.100.0-198.51.100.255
";
let filter = parse_p2p(input).unwrap();
assert!(filter.is_blocked("203.0.113.50".parse().unwrap()));
assert!(filter.is_blocked("198.51.100.1".parse().unwrap()));
assert!(!filter.is_blocked("8.8.8.8".parse().unwrap()));
}
#[test]
fn parse_p2p_invalid_ip() {
let input = "Bad Range:999.999.999.999-203.0.113.255";
let err = parse_p2p(input).unwrap_err();
assert!(matches!(err, IpFilterError::InvalidAddress { line: 1, .. }));
}
// Test 10: PortFilter: block port range, verify access
#[test]
fn port_filter_block_range() {
let mut filter = PortFilter::new();
filter.add_rule(6881, 6889, 1);
assert!(filter.is_blocked(6881));
assert!(filter.is_blocked(6885));
assert!(filter.is_blocked(6889));
assert!(!filter.is_blocked(6880));
assert!(!filter.is_blocked(6890));
assert!(!filter.is_blocked(80));
}
// M225 Step 3: ip_filter.enabled short-circuits is_blocked regardless of
// configured ranges. Verifies the OV F2b fix — plain bool field (not
// AtomicBool) preserves Clone derive; mutation flows through the outer
// Arc<RwLock<IpFilter>> write-lock at apply_settings.
#[test]
fn ip_filter_set_enabled_short_circuits_is_blocked() {
use std::str::FromStr;
let mut filter = IpFilter::new();
filter.add_rule(
IpAddr::from(Ipv4Addr::from_str("203.0.113.0").unwrap()),
IpAddr::from(Ipv4Addr::from_str("203.0.113.255").unwrap()),
1,
);
let blocked_ip = IpAddr::from(Ipv4Addr::from_str("203.0.113.42").unwrap());
// Default-enabled: range matches, IP is blocked.
assert!(filter.enabled);
assert!(filter.is_blocked(blocked_ip));
// Disable: same range still configured, but short-circuit fires.
filter.enabled = false;
assert!(!filter.is_blocked(blocked_ip), "disabled filter must short-circuit even for IPs in blocked range");
// Re-enable: range still active, IP blocked again.
filter.enabled = true;
assert!(filter.is_blocked(blocked_ip));
}
}