Skip to main content

tellaro_query_language/mutators/
network.rs

1//! Network and security mutators for TQL.
2//!
3//! Provides transformations for defanging and refanging URLs and indicators.
4
5use super::{Mutator, MutatorParams};
6use crate::error::Result;
7use once_cell::sync::Lazy;
8use regex::Regex;
9use serde_json::{json, Value as JsonValue};
10use std::net::IpAddr;
11
12// ============================================================================
13// Pre-compiled regex patterns for refang/defang operations
14// ============================================================================
15
16// Refang: protocol patterns
17static RE_REFANG_HTTPS: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)h[xX]{1,2}ps://").unwrap());
18static RE_REFANG_HTTP: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)h[xX]{1,2}p://").unwrap());
19static RE_REFANG_FTP: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)f[xX]p://").unwrap());
20
21// Refang: bracketed replacements
22static RE_REFANG_BRACKET_DOT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\[\.\]\s*").unwrap());
23static RE_REFANG_BRACKET_COLON: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\[:\]\s*").unwrap());
24static RE_REFANG_BRACKET_AT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\[@\]\s*").unwrap());
25static RE_REFANG_BRACKET_SLASH: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\[/\]\s*").unwrap());
26
27// Refang: parentheses replacements
28static RE_REFANG_PAREN_DOT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\(\.\)\s*").unwrap());
29static RE_REFANG_PAREN_COLON: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\(:\)\s*").unwrap());
30static RE_REFANG_PAREN_AT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\(@\)\s*").unwrap());
31static RE_REFANG_PAREN_SLASH: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\(/\)\s*").unwrap());
32
33// Refang: braces replacements
34static RE_REFANG_BRACE_DOT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\{\.\}\s*").unwrap());
35static RE_REFANG_BRACE_COLON: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\{:\}\s*").unwrap());
36static RE_REFANG_BRACE_AT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\{@\}\s*").unwrap());
37static RE_REFANG_BRACE_SLASH: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*\{/\}\s*").unwrap());
38
39// Refang: word replacements
40static RE_REFANG_WORD_AT_BRACKET: Lazy<Regex> =
41    Lazy::new(|| Regex::new(r"(?i)\s*\[at\]\s*").unwrap());
42static RE_REFANG_WORD_AT_PAREN: Lazy<Regex> =
43    Lazy::new(|| Regex::new(r"(?i)\s*\(at\)\s*").unwrap());
44static RE_REFANG_WORD_AT_BRACE: Lazy<Regex> =
45    Lazy::new(|| Regex::new(r"(?i)\s*\{at\}\s*").unwrap());
46static RE_REFANG_WORD_DOT_BRACKET: Lazy<Regex> =
47    Lazy::new(|| Regex::new(r"(?i)\s*\[dot\]\s*").unwrap());
48static RE_REFANG_WORD_DOT_PAREN: Lazy<Regex> =
49    Lazy::new(|| Regex::new(r"(?i)\s*\(dot\)\s*").unwrap());
50static RE_REFANG_WORD_DOT_BRACE: Lazy<Regex> =
51    Lazy::new(|| Regex::new(r"(?i)\s*\{dot\}\s*").unwrap());
52
53// Defang: protocol patterns
54static RE_DEFANG_HTTPS: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)https://").unwrap());
55static RE_DEFANG_HTTP: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)http://").unwrap());
56static RE_DEFANG_FTP: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)ftp://").unwrap());
57
58// Defang: URL and port patterns
59static RE_DEFANG_URL: Lazy<Regex> =
60    Lazy::new(|| Regex::new(r"((?:hxxps?|fxp|https?|ftp)://[^\s]+)").unwrap());
61static RE_DEFANG_PORT: Lazy<Regex> = Lazy::new(|| Regex::new(r":(\d+)").unwrap());
62
63/// Mutator that refangs (un-defangs) URLs and indicators
64///
65/// Reverses common defanging patterns to make URLs and indicators active again:
66/// - hXXp:// -> http://
67/// - hXXps:// -> https://
68/// - [.] -> .
69/// - [:] -> :
70/// - [at] -> @
71/// - fXp:// -> ftp://
72pub struct RefangMutator {
73    _params: MutatorParams,
74}
75
76impl RefangMutator {
77    pub fn new(params: MutatorParams) -> Self {
78        Self { _params: params }
79    }
80
81    fn refang_string(&self, s: &str) -> String {
82        let mut result = s.to_string();
83
84        // Handle various protocol defanging patterns (case insensitive)
85        // Important: Check for 'ps' suffix first to avoid false matches
86        result = RE_REFANG_HTTPS.replace_all(&result, "https://").to_string();
87        result = RE_REFANG_HTTP.replace_all(&result, "http://").to_string();
88        result = RE_REFANG_FTP.replace_all(&result, "ftp://").to_string();
89
90        // Handle bracketed replacements with optional spaces
91        let patterns: &[(&Lazy<Regex>, &str)] = &[
92            (&RE_REFANG_BRACKET_DOT, "."),
93            (&RE_REFANG_BRACKET_COLON, ":"),
94            (&RE_REFANG_BRACKET_AT, "@"),
95            (&RE_REFANG_BRACKET_SLASH, "/"),
96            (&RE_REFANG_PAREN_DOT, "."),
97            (&RE_REFANG_PAREN_COLON, ":"),
98            (&RE_REFANG_PAREN_AT, "@"),
99            (&RE_REFANG_PAREN_SLASH, "/"),
100            (&RE_REFANG_BRACE_DOT, "."),
101            (&RE_REFANG_BRACE_COLON, ":"),
102            (&RE_REFANG_BRACE_AT, "@"),
103            (&RE_REFANG_BRACE_SLASH, "/"),
104        ];
105
106        for (re, replacement) in patterns {
107            result = re.replace_all(&result, *replacement).to_string();
108        }
109
110        // Handle word replacements with optional brackets/parentheses/braces (case insensitive)
111        result = RE_REFANG_WORD_AT_BRACKET
112            .replace_all(&result, "@")
113            .to_string();
114        result = RE_REFANG_WORD_AT_PAREN
115            .replace_all(&result, "@")
116            .to_string();
117        result = RE_REFANG_WORD_AT_BRACE
118            .replace_all(&result, "@")
119            .to_string();
120        result = RE_REFANG_WORD_DOT_BRACKET
121            .replace_all(&result, ".")
122            .to_string();
123        result = RE_REFANG_WORD_DOT_PAREN
124            .replace_all(&result, ".")
125            .to_string();
126        result = RE_REFANG_WORD_DOT_BRACE
127            .replace_all(&result, ".")
128            .to_string();
129
130        result
131    }
132}
133
134impl Mutator for RefangMutator {
135    fn apply(
136        &self,
137        _field_name: &str,
138        _record: &JsonValue,
139        value: &JsonValue,
140    ) -> Result<JsonValue> {
141        match value {
142            JsonValue::String(s) => Ok(JsonValue::String(self.refang_string(s))),
143            JsonValue::Array(arr) => {
144                let transformed: Vec<JsonValue> = arr
145                    .iter()
146                    .map(|item| {
147                        if let JsonValue::String(s) = item {
148                            JsonValue::String(self.refang_string(s))
149                        } else {
150                            item.clone()
151                        }
152                    })
153                    .collect();
154                Ok(JsonValue::Array(transformed))
155            }
156            _ => Ok(value.clone()),
157        }
158    }
159
160    fn name(&self) -> &str {
161        "refang"
162    }
163}
164
165/// Mutator that defangs URLs and indicators to make them unclickable
166///
167/// Applies common defanging patterns to prevent accidental clicks:
168/// - http:// -> hxxp://
169/// - https:// -> hxxps://
170/// - . -> [.]
171/// - : -> [:]
172/// - @ -> [at]
173/// - ftp:// -> fxp://
174pub struct DefangMutator {
175    _params: MutatorParams,
176}
177
178impl DefangMutator {
179    pub fn new(params: MutatorParams) -> Self {
180        Self { _params: params }
181    }
182
183    fn defang_string(&self, s: &str) -> String {
184        let mut result = s.to_string();
185
186        // Check if already defanged to avoid double-defanging
187        let has_defanged_protocol =
188            result.to_lowercase().contains("hxxp") || result.to_lowercase().contains("fxp");
189        let has_defanged_dots = result.contains("[.]");
190        let has_defanged_at = result.contains("[at]");
191
192        // If it's a URL with protocol, check if dots are defanged
193        if has_defanged_protocol && result.contains("://") {
194            if let Some(after_protocol) = result.split("://").nth(1) {
195                if !after_protocol.contains('.') || has_defanged_dots {
196                    return result;
197                }
198            }
199        } else if has_defanged_dots && has_defanged_at {
200            return result;
201        }
202
203        // Replace protocols (case-insensitive) with lowercase hxxp/hxxps/fxp
204        result = RE_DEFANG_HTTPS.replace_all(&result, "hxxps://").to_string();
205        result = RE_DEFANG_HTTP.replace_all(&result, "hxxp://").to_string();
206        result = RE_DEFANG_FTP.replace_all(&result, "fxp://").to_string();
207
208        let mut defanged_result = String::new();
209        let mut last_end = 0;
210
211        for cap in RE_DEFANG_URL.captures_iter(&result) {
212            let url_match = cap.get(0).unwrap();
213
214            // Add text before the URL
215            defanged_result.push_str(&result[last_end..url_match.start()]);
216
217            // Process the URL
218            let url = url_match.as_str();
219            if let Some((protocol, rest)) = url.split_once("://") {
220                defanged_result.push_str(protocol);
221                defanged_result.push_str("://");
222
223                // Defang dots in the domain/path (avoid double-defanging)
224                let mut rest_defanged = if !rest.contains("[.]") {
225                    rest.replace('.', "[.]")
226                } else {
227                    rest.to_string()
228                };
229
230                // Defang @ if present (for URLs with auth)
231                if !rest_defanged.contains("[at]") {
232                    rest_defanged = rest_defanged.replace('@', "[at]");
233                }
234
235                // Defang colons in port numbers (e.g., :8080)
236                rest_defanged = RE_DEFANG_PORT
237                    .replace_all(&rest_defanged, "[:]$1")
238                    .to_string();
239
240                defanged_result.push_str(&rest_defanged);
241            } else {
242                defanged_result.push_str(url);
243            }
244
245            last_end = url_match.end();
246        }
247
248        // Add remaining text
249        defanged_result.push_str(&result[last_end..]);
250
251        // If no URLs were found, defang the whole string
252        if last_end == 0 {
253            // Defang dots, @ signs, and colons for emails and domains
254            if !defanged_result.contains("[.]") {
255                defanged_result = defanged_result.replace('.', "[.]");
256            }
257            if !defanged_result.contains("[at]") {
258                defanged_result = defanged_result.replace('@', "[at]");
259            }
260        }
261
262        defanged_result
263    }
264}
265
266impl Mutator for DefangMutator {
267    fn apply(
268        &self,
269        _field_name: &str,
270        _record: &JsonValue,
271        value: &JsonValue,
272    ) -> Result<JsonValue> {
273        match value {
274            JsonValue::String(s) => Ok(JsonValue::String(self.defang_string(s))),
275            JsonValue::Array(arr) => {
276                let transformed: Vec<JsonValue> = arr
277                    .iter()
278                    .map(|item| {
279                        if let JsonValue::String(s) = item {
280                            JsonValue::String(self.defang_string(s))
281                        } else {
282                            item.clone()
283                        }
284                    })
285                    .collect();
286                Ok(JsonValue::Array(transformed))
287            }
288            _ => Ok(value.clone()),
289        }
290    }
291
292    fn name(&self) -> &str {
293        "defang"
294    }
295}
296
297// ---------------------------------------------------------------------------
298// Special-use address ranges (ui#584)
299// ---------------------------------------------------------------------------
300//
301// `is_private` and `is_global` are defined here as "not globally reachable",
302// matching Python's `ipaddress` module, which is the definition the platform
303// converged on. Rust previously hand-rolled RFC1918 + loopback + link-local +
304// IPv6 ULA and called everything else public, so the two shipped engines
305// returned DIFFERENT records for the same query: `tellaro-backend` evaluates
306// with Python and `tellaro-agent` with this crate.
307//
308// The tables mirror CPython's `_private_networks` / `_private_networks_exceptions`
309// / `_reserved_networks` verbatim so the two can be diffed by eye. Do not
310// "tidy" them into prefix arithmetic — the point is that they are checkable
311// against the upstream source.
312//
313// NOTE the counter-intuitive member: 100.64.0.0/10 (CGNAT / RFC 6598 shared
314// address space) is deliberately NOT here. CPython does not count it as
315// private, so neither do we; it is nonetheless not GLOBAL, and `is_global`
316// excludes it separately. An address can be neither private nor global.
317
318/// IPv4 networks CPython's `IPv4Address.is_private` covers, as (network, prefix).
319const V4_PRIVATE: &[(u32, u8)] = &[
320    (0x0000_0000, 8),  // 0.0.0.0/8        "this" network
321    (0x0A00_0000, 8),  // 10.0.0.0/8       RFC 1918
322    (0x7F00_0000, 8),  // 127.0.0.0/8      loopback
323    (0xA9FE_0000, 16), // 169.254.0.0/16   link-local
324    (0xAC10_0000, 12), // 172.16.0.0/12    RFC 1918
325    (0xC000_0000, 24), // 192.0.0.0/24     IETF protocol assignments
326    (0xC000_00AA, 31), // 192.0.0.170/31   NAT64/DNS64 discovery
327    (0xC000_0200, 24), // 192.0.2.0/24     TEST-NET-1
328    (0xC0A8_0000, 16), // 192.168.0.0/16   RFC 1918
329    (0xC612_0000, 15), // 198.18.0.0/15    benchmarking
330    (0xC633_6400, 24), // 198.51.100.0/24  TEST-NET-2
331    (0xCB00_7100, 24), // 203.0.113.0/24   TEST-NET-3
332    (0xF000_0000, 4),  // 240.0.0.0/4      reserved / class E
333    (0xFFFF_FFFF, 32), // 255.255.255.255  broadcast
334];
335
336/// Carve-outs CPython applies on top of [`V4_PRIVATE`] — these ARE global.
337const V4_PRIVATE_EXCEPTIONS: &[(u32, u8)] = &[
338    (0xC000_0009, 32), // 192.0.0.9/32   PCP anycast
339    (0xC000_000A, 32), // 192.0.0.10/32  NAT64/DNS64 anycast
340];
341
342/// IPv6 networks CPython's `IPv6Address.is_private` covers.
343const V6_PRIVATE: &[(u128, u8)] = &[
344    (0x0000_0000_0000_0000_0000_0000_0000_0001, 128), // ::1/128        loopback
345    (0x0000_0000_0000_0000_0000_0000_0000_0000, 128), // ::/128         unspecified
346    (0x0000_0000_0000_0000_0000_FFFF_0000_0000, 96),  // ::ffff:0:0/96  v4-mapped
347    (0x0064_FF9B_0001_0000_0000_0000_0000_0000, 48),  // 64:ff9b:1::/48 local v4/v6 xlat
348    (0x0100_0000_0000_0000_0000_0000_0000_0000, 64),  // 100::/64       discard-only
349    (0x2001_0000_0000_0000_0000_0000_0000_0000, 23),  // 2001::/23      IETF protocol
350    (0x2001_0DB8_0000_0000_0000_0000_0000_0000, 32),  // 2001:db8::/32  documentation
351    (0x2002_0000_0000_0000_0000_0000_0000_0000, 16),  // 2002::/16      6to4
352    (0x3FFF_0000_0000_0000_0000_0000_0000_0000, 20),  // 3fff::/20      documentation
353    (0xFC00_0000_0000_0000_0000_0000_0000_0000, 7),   // fc00::/7       unique local
354    (0xFE80_0000_0000_0000_0000_0000_0000_0000, 10),  // fe80::/10      link-local
355];
356
357/// Carve-outs CPython applies on top of [`V6_PRIVATE`] — these ARE global.
358const V6_PRIVATE_EXCEPTIONS: &[(u128, u8)] = &[
359    (0x2001_0001_0000_0000_0000_0000_0000_0001, 128), // 2001:1::1/128     PCP anycast
360    (0x2001_0001_0000_0000_0000_0000_0000_0002, 128), // 2001:1::2/128     TURN anycast
361    (0x2001_0003_0000_0000_0000_0000_0000_0000, 32),  // 2001:3::/32       AMT
362    (0x2001_0004_0112_0000_0000_0000_0000_0000, 48),  // 2001:4:112::/48   AS112-v6
363    (0x2001_0020_0000_0000_0000_0000_0000_0000, 28),  // 2001:20::/28      ORCHIDv2
364    (0x2001_0030_0000_0000_0000_0000_0000_0000, 28),  // 2001:30::/28      drone remote id
365];
366
367/// IPv6 networks CPython's `IPv6Address.is_reserved` covers.
368///
369/// Everything outside 2000::/3 (global unicast), essentially.
370const V6_RESERVED: &[(u128, u8)] = &[
371    (0x0000_0000_0000_0000_0000_0000_0000_0000, 8),
372    (0x0100_0000_0000_0000_0000_0000_0000_0000, 8),
373    (0x0200_0000_0000_0000_0000_0000_0000_0000, 7),
374    (0x0400_0000_0000_0000_0000_0000_0000_0000, 6),
375    (0x0800_0000_0000_0000_0000_0000_0000_0000, 5),
376    (0x1000_0000_0000_0000_0000_0000_0000_0000, 4),
377    (0x4000_0000_0000_0000_0000_0000_0000_0000, 3),
378    (0x6000_0000_0000_0000_0000_0000_0000_0000, 3),
379    (0x8000_0000_0000_0000_0000_0000_0000_0000, 3),
380    (0xA000_0000_0000_0000_0000_0000_0000_0000, 3),
381    (0xC000_0000_0000_0000_0000_0000_0000_0000, 3),
382    (0xE000_0000_0000_0000_0000_0000_0000_0000, 4),
383    (0xF000_0000_0000_0000_0000_0000_0000_0000, 5),
384    (0xF800_0000_0000_0000_0000_0000_0000_0000, 6),
385    (0xFE00_0000_0000_0000_0000_0000_0000_0000, 9),
386];
387
388/// Is `addr` inside `network/prefix`? A /0 matches everything.
389fn v4_in(addr: u32, network: u32, prefix: u8) -> bool {
390    if prefix == 0 {
391        return true;
392    }
393    let mask = u32::MAX << (32 - prefix);
394    (addr & mask) == (network & mask)
395}
396
397/// Is `addr` inside `network/prefix`? A /0 matches everything.
398fn v6_in(addr: u128, network: u128, prefix: u8) -> bool {
399    if prefix == 0 {
400        return true;
401    }
402    let mask = u128::MAX << (128 - prefix);
403    (addr & mask) == (network & mask)
404}
405
406fn v4_in_any(addr: u32, nets: &[(u32, u8)]) -> bool {
407    nets.iter().any(|&(n, p)| v4_in(addr, n, p))
408}
409
410fn v6_in_any(addr: u128, nets: &[(u128, u8)]) -> bool {
411    nets.iter().any(|&(n, p)| v6_in(addr, n, p))
412}
413
414/// CPython's `IPv4Address.is_private`.
415fn v4_is_private(addr: u32) -> bool {
416    v4_in_any(addr, V4_PRIVATE) && !v4_in_any(addr, V4_PRIVATE_EXCEPTIONS)
417}
418
419/// CPython's `IPv6Address.is_private`.
420fn v6_is_private(addr: u128) -> bool {
421    v6_in_any(addr, V6_PRIVATE) && !v6_in_any(addr, V6_PRIVATE_EXCEPTIONS)
422}
423
424/// CPython's `IPv6Address.is_reserved`.
425fn v6_is_reserved(addr: u128) -> bool {
426    v6_in_any(addr, V6_RESERVED)
427}
428
429/// CPython's `IPv4Address.is_reserved` — 240.0.0.0/4.
430fn v4_is_reserved(addr: u32) -> bool {
431    v4_in(addr, 0xF000_0000, 4)
432}
433
434/// Mutator that checks if an IP address is private
435///
436/// "Private" means **not globally reachable**, matching Python's `ipaddress`
437/// module. That is broader than RFC 1918 and covers:
438/// - RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
439/// - Loopback: 127.0.0.0/8, ::1
440/// - Link-local: 169.254.0.0/16, fe80::/10
441/// - IPv6 unique local: fc00::/7
442/// - "This" network 0.0.0.0/8 and broadcast 255.255.255.255
443/// - IETF protocol assignments 192.0.0.0/24 (except the PCP and NAT64
444///   anycast carve-outs 192.0.0.9/32 and 192.0.0.10/32, which ARE global)
445/// - Documentation ranges TEST-NET-1/2/3 and 2001:db8::/32
446/// - Benchmarking 198.18.0.0/15
447/// - Reserved / class E 240.0.0.0/4
448///
449/// **CGNAT 100.64.0.0/10 is deliberately NOT private** — Python does not count
450/// it as private, and neither do we. It is not global either; see
451/// [`IsGlobalMutator`]. An address can be neither.
452pub struct IsPrivateMutator {
453    _params: MutatorParams,
454}
455
456impl IsPrivateMutator {
457    pub fn new(params: MutatorParams) -> Self {
458        Self { _params: params }
459    }
460
461    /// "Not globally reachable", matching Python's `ipaddress`.
462    ///
463    /// Mirrors the Python mutator exactly: private OR loopback OR link-local OR
464    /// reserved. Loopback and link-local are already inside the private tables,
465    /// but they are named here because the Python side names them and the two
466    /// must stay diffable.
467    fn is_private_ip(&self, ip_str: &str) -> bool {
468        match ip_str.parse::<IpAddr>() {
469            Ok(IpAddr::V4(v4)) => {
470                let a = u32::from(v4);
471                v4_is_private(a) || v4.is_loopback() || v4.is_link_local() || v4_is_reserved(a)
472            }
473            Ok(IpAddr::V6(v6)) => {
474                let a = u128::from(v6);
475                v6_is_private(a) || v6.is_loopback() || v6_is_reserved(a)
476            }
477            Err(_) => false,
478        }
479    }
480}
481
482impl Mutator for IsPrivateMutator {
483    fn apply(
484        &self,
485        _field_name: &str,
486        _record: &JsonValue,
487        value: &JsonValue,
488    ) -> Result<JsonValue> {
489        match value {
490            JsonValue::Null => Ok(json!(false)),
491            JsonValue::String(s) => Ok(json!(self.is_private_ip(s))),
492            JsonValue::Array(arr) => {
493                // Return true if any IP in the array is private
494                let result = arr.iter().any(|item| {
495                    if let JsonValue::String(s) = item {
496                        self.is_private_ip(s)
497                    } else {
498                        false
499                    }
500                });
501                Ok(json!(result))
502            }
503            _ => Ok(json!(false)),
504        }
505    }
506
507    fn name(&self) -> &str {
508        "is_private"
509    }
510
511    /// Answers a boolean ABOUT the address, so `ip | is_private` with no
512    /// operator is a FILTER (`eq true`), not a projection. See
513    /// [`Mutator::returns_boolean`].
514    fn returns_boolean(&self) -> bool {
515        true
516    }
517}
518
519/// Mutator that checks if an IP address is globally routable
520///
521/// Returns true if the IP is a public, globally routable address.
522/// This is the inverse of is_private - it excludes:
523/// - Private addresses (RFC 1918)
524/// - Loopback
525/// - Link-local
526/// - Multicast
527/// - Reserved ranges
528pub struct IsGlobalMutator {
529    _params: MutatorParams,
530}
531
532impl IsGlobalMutator {
533    pub fn new(params: MutatorParams) -> Self {
534        Self { _params: params }
535    }
536
537    fn is_global_ip(&self, ip_str: &str) -> bool {
538        // The exact inverse of the Python mutator's checks, in the same order.
539        // Note this is NOT `!is_private_ip`: an address can be neither private
540        // nor global — 100.64.0.0/10 (CGNAT) and multicast are both.
541        match ip_str.parse::<IpAddr>() {
542            Ok(IpAddr::V4(v4)) => {
543                let a = u32::from(v4);
544                if v4_is_private(a)
545                    || v4.is_loopback()
546                    || v4.is_link_local()
547                    || v4.is_multicast()
548                    || v4.is_unspecified()
549                    || v4.is_broadcast()
550                    || v4_is_reserved(a)
551                {
552                    return false;
553                }
554                // Ranges Python checks on top of `is_private`.
555                if v4_in(a, 0x0000_0000, 8)      // 0.0.0.0/8
556                    || v4_in(a, 0x6440_0000, 10) // 100.64.0.0/10 shared address space
557                    || v4_in(a, 0xC612_0000, 15) // 198.18.0.0/15 benchmarking
558                    || v4_in(a, 0xF000_0000, 4)
559                // 240.0.0.0/4
560                {
561                    return false;
562                }
563                true
564            }
565            Ok(IpAddr::V6(v6)) => {
566                let a = u128::from(v6);
567                !(v6_is_private(a)
568                    || v6.is_loopback()
569                    || v6.is_multicast()
570                    || v6.is_unspecified()
571                    || v6_is_reserved(a))
572            }
573            Err(_) => false,
574        }
575    }
576}
577
578impl Mutator for IsGlobalMutator {
579    fn apply(
580        &self,
581        _field_name: &str,
582        _record: &JsonValue,
583        value: &JsonValue,
584    ) -> Result<JsonValue> {
585        match value {
586            JsonValue::Null => Ok(json!(false)),
587            JsonValue::String(s) => Ok(json!(self.is_global_ip(s))),
588            JsonValue::Array(arr) => {
589                // Return true if any IP in the array is global
590                let result = arr.iter().any(|item| {
591                    if let JsonValue::String(s) = item {
592                        self.is_global_ip(s)
593                    } else {
594                        false
595                    }
596                });
597                Ok(json!(result))
598            }
599            _ => Ok(json!(false)),
600        }
601    }
602
603    fn name(&self) -> &str {
604        "is_global"
605    }
606
607    /// Answers a boolean ABOUT the address, so `ip | is_global` with no
608    /// operator is a FILTER (`eq true`), not a projection. See
609    /// [`Mutator::returns_boolean`].
610    fn returns_boolean(&self) -> bool {
611        true
612    }
613}
614
615/// Mutator that checks if an IP address is a multicast address.
616///
617/// Returns true if the IP is in a multicast range:
618/// - IPv4: 224.0.0.0/4 (224.0.0.0 – 239.255.255.255)
619/// - IPv6: ff00::/8
620pub struct IsMulticastMutator {
621    _params: MutatorParams,
622}
623
624impl IsMulticastMutator {
625    pub fn new(params: MutatorParams) -> Self {
626        Self { _params: params }
627    }
628
629    fn is_multicast_ip(&self, ip_str: &str) -> bool {
630        if let Ok(ip) = ip_str.parse::<IpAddr>() {
631            match ip {
632                IpAddr::V4(ipv4) => ipv4.is_multicast(),
633                IpAddr::V6(ipv6) => ipv6.is_multicast(),
634            }
635        } else {
636            false
637        }
638    }
639}
640
641impl Mutator for IsMulticastMutator {
642    fn apply(
643        &self,
644        _field_name: &str,
645        _record: &JsonValue,
646        value: &JsonValue,
647    ) -> Result<JsonValue> {
648        match value {
649            JsonValue::Null => Ok(json!(false)),
650            JsonValue::String(s) => Ok(json!(self.is_multicast_ip(s))),
651            JsonValue::Array(arr) => {
652                let result = arr.iter().any(|item| {
653                    if let JsonValue::String(s) = item {
654                        self.is_multicast_ip(s)
655                    } else {
656                        false
657                    }
658                });
659                Ok(json!(result))
660            }
661            _ => Ok(json!(false)),
662        }
663    }
664
665    fn name(&self) -> &str {
666        "is_multicast"
667    }
668
669    /// Answers a boolean ABOUT the address, so `ip | is_multicast` with no
670    /// operator is a FILTER (`eq true`), not a projection. See
671    /// [`Mutator::returns_boolean`].
672    fn returns_boolean(&self) -> bool {
673        true
674    }
675}
676
677/// Mutator that checks if an IP address is a loopback address.
678///
679/// Returns true if the IP is a loopback address:
680/// - IPv4: 127.0.0.0/8
681/// - IPv6: ::1
682pub struct IsLoopbackMutator {
683    _params: MutatorParams,
684}
685
686impl IsLoopbackMutator {
687    pub fn new(params: MutatorParams) -> Self {
688        Self { _params: params }
689    }
690
691    fn is_loopback_ip(&self, ip_str: &str) -> bool {
692        if let Ok(ip) = ip_str.parse::<IpAddr>() {
693            ip.is_loopback()
694        } else {
695            false
696        }
697    }
698}
699
700impl Mutator for IsLoopbackMutator {
701    fn apply(
702        &self,
703        _field_name: &str,
704        _record: &JsonValue,
705        value: &JsonValue,
706    ) -> Result<JsonValue> {
707        match value {
708            JsonValue::Null => Ok(json!(false)),
709            JsonValue::String(s) => Ok(json!(self.is_loopback_ip(s))),
710            JsonValue::Array(arr) => {
711                let result = arr.iter().any(|item| {
712                    if let JsonValue::String(s) = item {
713                        self.is_loopback_ip(s)
714                    } else {
715                        false
716                    }
717                });
718                Ok(json!(result))
719            }
720            _ => Ok(json!(false)),
721        }
722    }
723
724    fn name(&self) -> &str {
725        "is_loopback"
726    }
727
728    /// Answers a boolean ABOUT the address, so `ip | is_loopback` with no
729    /// operator is a FILTER (`eq true`), not a projection. See
730    /// [`Mutator::returns_boolean`].
731    fn returns_boolean(&self) -> bool {
732        true
733    }
734}
735
736/// Mutator that checks if an IP address is a link-local address.
737///
738/// Returns true if the IP is link-local:
739/// - IPv4: 169.254.0.0/16
740/// - IPv6: fe80::/10
741pub struct IsLinkLocalMutator {
742    _params: MutatorParams,
743}
744
745impl IsLinkLocalMutator {
746    pub fn new(params: MutatorParams) -> Self {
747        Self { _params: params }
748    }
749
750    fn is_link_local_ip(&self, ip_str: &str) -> bool {
751        if let Ok(ip) = ip_str.parse::<IpAddr>() {
752            match ip {
753                IpAddr::V4(ipv4) => ipv4.is_link_local(),
754                IpAddr::V6(ipv6) => {
755                    // fe80::/10
756                    let segments = ipv6.segments();
757                    (segments[0] & 0xffc0) == 0xfe80
758                }
759            }
760        } else {
761            false
762        }
763    }
764}
765
766impl Mutator for IsLinkLocalMutator {
767    fn apply(
768        &self,
769        _field_name: &str,
770        _record: &JsonValue,
771        value: &JsonValue,
772    ) -> Result<JsonValue> {
773        match value {
774            JsonValue::Null => Ok(json!(false)),
775            JsonValue::String(s) => Ok(json!(self.is_link_local_ip(s))),
776            JsonValue::Array(arr) => {
777                let result = arr.iter().any(|item| {
778                    if let JsonValue::String(s) = item {
779                        self.is_link_local_ip(s)
780                    } else {
781                        false
782                    }
783                });
784                Ok(json!(result))
785            }
786            _ => Ok(json!(false)),
787        }
788    }
789
790    fn name(&self) -> &str {
791        "is_link_local"
792    }
793
794    /// Answers a boolean ABOUT the address, so `ip | is_link_local` with no
795    /// operator is a FILTER (`eq true`), not a projection. See
796    /// [`Mutator::returns_boolean`].
797    fn returns_boolean(&self) -> bool {
798        true
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use serde_json::json;
806    use std::collections::HashMap;
807
808    #[test]
809    fn test_refang_http() {
810        let mutator = RefangMutator::new(HashMap::new());
811        let record = json!({});
812
813        let value = json!("hXXp://example[.]com");
814        let result = mutator.apply("field", &record, &value).unwrap();
815        assert_eq!(result, json!("http://example.com"));
816
817        let value = json!("hxxp://test[.]org");
818        let result = mutator.apply("field", &record, &value).unwrap();
819        assert_eq!(result, json!("http://test.org"));
820    }
821
822    #[test]
823    fn test_refang_https() {
824        let mutator = RefangMutator::new(HashMap::new());
825        let record = json!({});
826
827        let value = json!("hXXps://secure[.]example[.]com");
828        let result = mutator.apply("field", &record, &value).unwrap();
829        assert_eq!(result, json!("https://secure.example.com"));
830    }
831
832    #[test]
833    fn test_refang_email() {
834        let mutator = RefangMutator::new(HashMap::new());
835        let record = json!({});
836
837        let value = json!("user[at]example[.]com");
838        let result = mutator.apply("field", &record, &value).unwrap();
839        assert_eq!(result, json!("user@example.com"));
840    }
841
842    #[test]
843    fn test_refang_brackets_and_parentheses() {
844        let mutator = RefangMutator::new(HashMap::new());
845        let record = json!({});
846
847        let value = json!("example(.)com");
848        let result = mutator.apply("field", &record, &value).unwrap();
849        assert_eq!(result, json!("example.com"));
850
851        let value = json!("test{.}org");
852        let result = mutator.apply("field", &record, &value).unwrap();
853        assert_eq!(result, json!("test.org"));
854    }
855
856    #[test]
857    fn test_refang_array() {
858        let mutator = RefangMutator::new(HashMap::new());
859        let record = json!({});
860
861        let value = json!(["hXXp://example[.]com", "user[at]test[.]org"]);
862        let result = mutator.apply("field", &record, &value).unwrap();
863        assert_eq!(result, json!(["http://example.com", "user@test.org"]));
864    }
865
866    #[test]
867    fn test_defang_http() {
868        let mutator = DefangMutator::new(HashMap::new());
869        let record = json!({});
870
871        let value = json!("http://example.com");
872        let result = mutator.apply("field", &record, &value).unwrap();
873        assert_eq!(result, json!("hxxp://example[.]com"));
874    }
875
876    #[test]
877    fn test_defang_https() {
878        let mutator = DefangMutator::new(HashMap::new());
879        let record = json!({});
880
881        let value = json!("https://secure.example.com");
882        let result = mutator.apply("field", &record, &value).unwrap();
883        assert_eq!(result, json!("hxxps://secure[.]example[.]com"));
884    }
885
886    #[test]
887    fn test_defang_email() {
888        let mutator = DefangMutator::new(HashMap::new());
889        let record = json!({});
890
891        let value = json!("user@example.com");
892        let result = mutator.apply("field", &record, &value).unwrap();
893        assert_eq!(result, json!("user[at]example[.]com"));
894    }
895
896    #[test]
897    fn test_defang_already_defanged() {
898        let mutator = DefangMutator::new(HashMap::new());
899        let record = json!({});
900
901        let value = json!("hxxp://example[.]com");
902        let result = mutator.apply("field", &record, &value).unwrap();
903        assert_eq!(result, json!("hxxp://example[.]com"));
904    }
905
906    #[test]
907    fn test_defang_refang_round_trip() {
908        let defang_mutator = DefangMutator::new(HashMap::new());
909        let refang_mutator = RefangMutator::new(HashMap::new());
910        let record = json!({});
911
912        let original = json!("https://example.com/path");
913        let defanged = defang_mutator.apply("field", &record, &original).unwrap();
914        let refanged = refang_mutator.apply("field", &record, &defanged).unwrap();
915
916        // Should get back to original
917        assert_eq!(refanged, original);
918    }
919
920    #[test]
921    fn test_defang_array() {
922        let mutator = DefangMutator::new(HashMap::new());
923        let record = json!({});
924
925        let value = json!(["http://example.com", "user@test.org"]);
926        let result = mutator.apply("field", &record, &value).unwrap();
927        assert_eq!(
928            result,
929            json!(["hxxp://example[.]com", "user[at]test[.]org"])
930        );
931    }
932
933    // Tests for IsPrivateMutator
934    #[test]
935    fn test_is_private_rfc1918() {
936        let mutator = IsPrivateMutator::new(HashMap::new());
937        let record = json!({});
938
939        // 10.0.0.0/8
940        assert_eq!(
941            mutator.apply("ip", &record, &json!("10.0.0.1")).unwrap(),
942            json!(true)
943        );
944        assert_eq!(
945            mutator
946                .apply("ip", &record, &json!("10.255.255.254"))
947                .unwrap(),
948            json!(true)
949        );
950
951        // 172.16.0.0/12
952        assert_eq!(
953            mutator.apply("ip", &record, &json!("172.16.0.1")).unwrap(),
954            json!(true)
955        );
956        assert_eq!(
957            mutator
958                .apply("ip", &record, &json!("172.31.255.254"))
959                .unwrap(),
960            json!(true)
961        );
962
963        // 192.168.0.0/16
964        assert_eq!(
965            mutator.apply("ip", &record, &json!("192.168.1.1")).unwrap(),
966            json!(true)
967        );
968        assert_eq!(
969            mutator
970                .apply("ip", &record, &json!("192.168.255.254"))
971                .unwrap(),
972            json!(true)
973        );
974
975        // Non-private in between ranges
976        assert_eq!(
977            mutator.apply("ip", &record, &json!("172.32.0.1")).unwrap(),
978            json!(false)
979        );
980        assert_eq!(
981            mutator
982                .apply("ip", &record, &json!("172.15.255.254"))
983                .unwrap(),
984            json!(false)
985        );
986    }
987
988    #[test]
989    fn test_is_private_special_ranges() {
990        let mutator = IsPrivateMutator::new(HashMap::new());
991        let record = json!({});
992
993        // Loopback
994        assert_eq!(
995            mutator.apply("ip", &record, &json!("127.0.0.1")).unwrap(),
996            json!(true)
997        );
998        assert_eq!(
999            mutator
1000                .apply("ip", &record, &json!("127.255.255.254"))
1001                .unwrap(),
1002            json!(true)
1003        );
1004
1005        // Link-local
1006        assert_eq!(
1007            mutator.apply("ip", &record, &json!("169.254.0.1")).unwrap(),
1008            json!(true)
1009        );
1010        assert_eq!(
1011            mutator
1012                .apply("ip", &record, &json!("169.254.255.254"))
1013                .unwrap(),
1014            json!(true)
1015        );
1016    }
1017
1018    #[test]
1019    fn test_is_private_public_ips() {
1020        let mutator = IsPrivateMutator::new(HashMap::new());
1021        let record = json!({});
1022
1023        // Common public IPs
1024        assert_eq!(
1025            mutator.apply("ip", &record, &json!("8.8.8.8")).unwrap(),
1026            json!(false)
1027        );
1028        assert_eq!(
1029            mutator.apply("ip", &record, &json!("1.1.1.1")).unwrap(),
1030            json!(false)
1031        );
1032        assert_eq!(
1033            mutator
1034                .apply("ip", &record, &json!("93.184.216.34"))
1035                .unwrap(),
1036            json!(false)
1037        );
1038    }
1039
1040    #[test]
1041    fn test_is_private_ipv6() {
1042        let mutator = IsPrivateMutator::new(HashMap::new());
1043        let record = json!({});
1044
1045        // IPv6 private (unique local)
1046        assert_eq!(
1047            mutator.apply("ip", &record, &json!("fc00::1")).unwrap(),
1048            json!(true)
1049        );
1050        assert_eq!(
1051            mutator.apply("ip", &record, &json!("fd00::1")).unwrap(),
1052            json!(true)
1053        );
1054
1055        // IPv6 link-local
1056        assert_eq!(
1057            mutator.apply("ip", &record, &json!("fe80::1")).unwrap(),
1058            json!(true)
1059        );
1060
1061        // IPv6 loopback
1062        assert_eq!(
1063            mutator.apply("ip", &record, &json!("::1")).unwrap(),
1064            json!(true)
1065        );
1066
1067        // IPv6 public
1068        assert_eq!(
1069            mutator
1070                .apply("ip", &record, &json!("2001:4860:4860::8888"))
1071                .unwrap(),
1072            json!(false)
1073        );
1074    }
1075
1076    #[test]
1077    fn test_is_private_invalid_ips() {
1078        let mutator = IsPrivateMutator::new(HashMap::new());
1079        let record = json!({});
1080
1081        // Invalid IPs should return false
1082        assert_eq!(
1083            mutator.apply("ip", &record, &json!("not-an-ip")).unwrap(),
1084            json!(false)
1085        );
1086        assert_eq!(
1087            mutator
1088                .apply("ip", &record, &json!("256.256.256.256"))
1089                .unwrap(),
1090            json!(false)
1091        );
1092        assert_eq!(
1093            mutator.apply("ip", &record, &json!("example.com")).unwrap(),
1094            json!(false)
1095        );
1096    }
1097
1098    #[test]
1099    fn test_is_private_lists() {
1100        let mutator = IsPrivateMutator::new(HashMap::new());
1101        let record = json!({});
1102
1103        // List with at least one private IP
1104        assert_eq!(
1105            mutator
1106                .apply(
1107                    "ips",
1108                    &record,
1109                    &json!(["8.8.8.8", "192.168.1.1", "1.1.1.1"])
1110                )
1111                .unwrap(),
1112            json!(true)
1113        );
1114
1115        // List with only public IPs
1116        assert_eq!(
1117            mutator
1118                .apply("ips", &record, &json!(["8.8.8.8", "1.1.1.1"]))
1119                .unwrap(),
1120            json!(false)
1121        );
1122
1123        // Empty list
1124        assert_eq!(
1125            mutator.apply("ips", &record, &json!([])).unwrap(),
1126            json!(false)
1127        );
1128    }
1129
1130    #[test]
1131    fn test_is_private_none_input() {
1132        let mutator = IsPrivateMutator::new(HashMap::new());
1133        let record = json!({});
1134        assert_eq!(
1135            mutator.apply("ip", &record, &JsonValue::Null).unwrap(),
1136            json!(false)
1137        );
1138    }
1139
1140    // Tests for IsGlobalMutator
1141    #[test]
1142    fn test_is_global_public_ips() {
1143        let mutator = IsGlobalMutator::new(HashMap::new());
1144        let record = json!({});
1145
1146        // Common public IPs
1147        assert_eq!(
1148            mutator.apply("ip", &record, &json!("8.8.8.8")).unwrap(),
1149            json!(true)
1150        );
1151        assert_eq!(
1152            mutator.apply("ip", &record, &json!("1.1.1.1")).unwrap(),
1153            json!(true)
1154        );
1155        assert_eq!(
1156            mutator
1157                .apply("ip", &record, &json!("93.184.216.34"))
1158                .unwrap(),
1159            json!(true)
1160        );
1161        assert_eq!(
1162            mutator
1163                .apply("ip", &record, &json!("151.101.1.140"))
1164                .unwrap(),
1165            json!(true)
1166        );
1167    }
1168
1169    #[test]
1170    fn test_is_global_private_ips() {
1171        let mutator = IsGlobalMutator::new(HashMap::new());
1172        let record = json!({});
1173
1174        // RFC 1918
1175        assert_eq!(
1176            mutator.apply("ip", &record, &json!("10.0.0.1")).unwrap(),
1177            json!(false)
1178        );
1179        assert_eq!(
1180            mutator.apply("ip", &record, &json!("172.16.0.1")).unwrap(),
1181            json!(false)
1182        );
1183        assert_eq!(
1184            mutator.apply("ip", &record, &json!("192.168.1.1")).unwrap(),
1185            json!(false)
1186        );
1187    }
1188
1189    #[test]
1190    fn test_is_global_special_ranges() {
1191        let mutator = IsGlobalMutator::new(HashMap::new());
1192        let record = json!({});
1193
1194        // Loopback
1195        assert_eq!(
1196            mutator.apply("ip", &record, &json!("127.0.0.1")).unwrap(),
1197            json!(false)
1198        );
1199
1200        // Link-local
1201        assert_eq!(
1202            mutator.apply("ip", &record, &json!("169.254.0.1")).unwrap(),
1203            json!(false)
1204        );
1205
1206        // Multicast
1207        assert_eq!(
1208            mutator.apply("ip", &record, &json!("224.0.0.1")).unwrap(),
1209            json!(false)
1210        );
1211        assert_eq!(
1212            mutator
1213                .apply("ip", &record, &json!("239.255.255.255"))
1214                .unwrap(),
1215            json!(false)
1216        );
1217
1218        // Unspecified
1219        assert_eq!(
1220            mutator.apply("ip", &record, &json!("0.0.0.0")).unwrap(),
1221            json!(false)
1222        );
1223
1224        // This network (0.0.0.0/8)
1225        assert_eq!(
1226            mutator.apply("ip", &record, &json!("0.1.2.3")).unwrap(),
1227            json!(false)
1228        );
1229
1230        // Shared address space (CGN)
1231        assert_eq!(
1232            mutator.apply("ip", &record, &json!("100.64.0.1")).unwrap(),
1233            json!(false)
1234        );
1235        assert_eq!(
1236            mutator
1237                .apply("ip", &record, &json!("100.127.255.254"))
1238                .unwrap(),
1239            json!(false)
1240        );
1241
1242        // Benchmarking
1243        assert_eq!(
1244            mutator.apply("ip", &record, &json!("198.18.0.1")).unwrap(),
1245            json!(false)
1246        );
1247        assert_eq!(
1248            mutator
1249                .apply("ip", &record, &json!("198.19.255.254"))
1250                .unwrap(),
1251            json!(false)
1252        );
1253
1254        // Reserved (Class E)
1255        assert_eq!(
1256            mutator.apply("ip", &record, &json!("240.0.0.1")).unwrap(),
1257            json!(false)
1258        );
1259        assert_eq!(
1260            mutator
1261                .apply("ip", &record, &json!("255.255.255.254"))
1262                .unwrap(),
1263            json!(false)
1264        );
1265
1266        // TEST-NET ranges
1267        assert_eq!(
1268            mutator.apply("ip", &record, &json!("192.0.2.1")).unwrap(),
1269            json!(false)
1270        );
1271        assert_eq!(
1272            mutator
1273                .apply("ip", &record, &json!("198.51.100.1"))
1274                .unwrap(),
1275            json!(false)
1276        );
1277        assert_eq!(
1278            mutator.apply("ip", &record, &json!("203.0.113.1")).unwrap(),
1279            json!(false)
1280        );
1281    }
1282
1283    #[test]
1284    fn test_is_global_ipv6() {
1285        let mutator = IsGlobalMutator::new(HashMap::new());
1286        let record = json!({});
1287
1288        // IPv6 global
1289        assert_eq!(
1290            mutator
1291                .apply("ip", &record, &json!("2001:4860:4860::8888"))
1292                .unwrap(),
1293            json!(true)
1294        );
1295        assert_eq!(
1296            mutator
1297                .apply("ip", &record, &json!("2606:4700:4700::1111"))
1298                .unwrap(),
1299            json!(true)
1300        );
1301
1302        // IPv6 not global
1303        assert_eq!(
1304            mutator.apply("ip", &record, &json!("fc00::1")).unwrap(),
1305            json!(false)
1306        ); // Unique local
1307        assert_eq!(
1308            mutator.apply("ip", &record, &json!("fe80::1")).unwrap(),
1309            json!(false)
1310        ); // Link-local
1311        assert_eq!(
1312            mutator.apply("ip", &record, &json!("::1")).unwrap(),
1313            json!(false)
1314        ); // Loopback
1315        assert_eq!(
1316            mutator.apply("ip", &record, &json!("::")).unwrap(),
1317            json!(false)
1318        ); // Unspecified
1319        assert_eq!(
1320            mutator.apply("ip", &record, &json!("ff02::1")).unwrap(),
1321            json!(false)
1322        ); // Multicast
1323    }
1324
1325    #[test]
1326    fn test_is_global_invalid_ips() {
1327        let mutator = IsGlobalMutator::new(HashMap::new());
1328        let record = json!({});
1329
1330        // Invalid IPs should return false
1331        assert_eq!(
1332            mutator.apply("ip", &record, &json!("not-an-ip")).unwrap(),
1333            json!(false)
1334        );
1335        assert_eq!(
1336            mutator
1337                .apply("ip", &record, &json!("256.256.256.256"))
1338                .unwrap(),
1339            json!(false)
1340        );
1341        assert_eq!(
1342            mutator.apply("ip", &record, &json!("example.com")).unwrap(),
1343            json!(false)
1344        );
1345    }
1346
1347    #[test]
1348    fn test_is_global_lists() {
1349        let mutator = IsGlobalMutator::new(HashMap::new());
1350        let record = json!({});
1351
1352        // List with at least one global IP
1353        assert_eq!(
1354            mutator
1355                .apply(
1356                    "ips",
1357                    &record,
1358                    &json!(["192.168.1.1", "8.8.8.8", "10.0.0.1"])
1359                )
1360                .unwrap(),
1361            json!(true)
1362        );
1363
1364        // List with only private IPs
1365        assert_eq!(
1366            mutator
1367                .apply("ips", &record, &json!(["192.168.1.1", "10.0.0.1"]))
1368                .unwrap(),
1369            json!(false)
1370        );
1371
1372        // Empty list
1373        assert_eq!(
1374            mutator.apply("ips", &record, &json!([])).unwrap(),
1375            json!(false)
1376        );
1377    }
1378
1379    #[test]
1380    fn test_is_global_none_input() {
1381        let mutator = IsGlobalMutator::new(HashMap::new());
1382        let record = json!({});
1383        assert_eq!(
1384            mutator.apply("ip", &record, &JsonValue::Null).unwrap(),
1385            json!(false)
1386        );
1387    }
1388
1389    #[test]
1390    fn test_boundary_addresses() {
1391        let private_mutator = IsPrivateMutator::new(HashMap::new());
1392        let global_mutator = IsGlobalMutator::new(HashMap::new());
1393        let record = json!({});
1394
1395        // RFC 1918 boundaries
1396        assert_eq!(
1397            private_mutator
1398                .apply("ip", &record, &json!("10.0.0.0"))
1399                .unwrap(),
1400            json!(true)
1401        );
1402        assert_eq!(
1403            private_mutator
1404                .apply("ip", &record, &json!("10.255.255.255"))
1405                .unwrap(),
1406            json!(true)
1407        );
1408        assert_eq!(
1409            private_mutator
1410                .apply("ip", &record, &json!("9.255.255.255"))
1411                .unwrap(),
1412            json!(false)
1413        );
1414        assert_eq!(
1415            private_mutator
1416                .apply("ip", &record, &json!("11.0.0.0"))
1417                .unwrap(),
1418            json!(false)
1419        );
1420
1421        // Global boundaries
1422        assert_eq!(
1423            global_mutator
1424                .apply("ip", &record, &json!("1.0.0.0"))
1425                .unwrap(),
1426            json!(true)
1427        );
1428        assert_eq!(
1429            global_mutator
1430                .apply("ip", &record, &json!("223.255.255.255"))
1431                .unwrap(),
1432            json!(true)
1433        );
1434        assert_eq!(
1435            global_mutator
1436                .apply("ip", &record, &json!("224.0.0.0"))
1437                .unwrap(),
1438            json!(false)
1439        ); // Multicast starts
1440    }
1441
1442    // Tests for IsMulticastMutator
1443    #[test]
1444    fn test_is_multicast_ipv4() {
1445        let mutator = IsMulticastMutator::new(HashMap::new());
1446        let record = json!({});
1447
1448        // Multicast range: 224.0.0.0 - 239.255.255.255
1449        assert_eq!(
1450            mutator.apply("ip", &record, &json!("224.0.0.1")).unwrap(),
1451            json!(true)
1452        );
1453        assert_eq!(
1454            mutator
1455                .apply("ip", &record, &json!("239.255.255.255"))
1456                .unwrap(),
1457            json!(true)
1458        );
1459        assert_eq!(
1460            mutator.apply("ip", &record, &json!("230.0.0.1")).unwrap(),
1461            json!(true)
1462        );
1463
1464        // Non-multicast
1465        assert_eq!(
1466            mutator.apply("ip", &record, &json!("8.8.8.8")).unwrap(),
1467            json!(false)
1468        );
1469        assert_eq!(
1470            mutator.apply("ip", &record, &json!("192.168.1.1")).unwrap(),
1471            json!(false)
1472        );
1473        assert_eq!(
1474            mutator.apply("ip", &record, &json!("127.0.0.1")).unwrap(),
1475            json!(false)
1476        );
1477    }
1478
1479    #[test]
1480    fn test_is_multicast_ipv6() {
1481        let mutator = IsMulticastMutator::new(HashMap::new());
1482        let record = json!({});
1483
1484        // IPv6 multicast (ff00::/8)
1485        assert_eq!(
1486            mutator.apply("ip", &record, &json!("ff02::1")).unwrap(),
1487            json!(true)
1488        );
1489        assert_eq!(
1490            mutator.apply("ip", &record, &json!("ff05::1")).unwrap(),
1491            json!(true)
1492        );
1493
1494        // Non-multicast IPv6
1495        assert_eq!(
1496            mutator
1497                .apply("ip", &record, &json!("2001:4860:4860::8888"))
1498                .unwrap(),
1499            json!(false)
1500        );
1501        assert_eq!(
1502            mutator.apply("ip", &record, &json!("::1")).unwrap(),
1503            json!(false)
1504        );
1505    }
1506
1507    #[test]
1508    fn test_is_multicast_invalid_and_special() {
1509        let mutator = IsMulticastMutator::new(HashMap::new());
1510        let record = json!({});
1511
1512        assert_eq!(
1513            mutator.apply("ip", &record, &json!("not-an-ip")).unwrap(),
1514            json!(false)
1515        );
1516        assert_eq!(
1517            mutator.apply("ip", &record, &JsonValue::Null).unwrap(),
1518            json!(false)
1519        );
1520    }
1521
1522    // Tests for IsLoopbackMutator
1523    #[test]
1524    fn test_is_loopback_ipv4() {
1525        let mutator = IsLoopbackMutator::new(HashMap::new());
1526        let record = json!({});
1527
1528        // Loopback: 127.0.0.0/8
1529        assert_eq!(
1530            mutator.apply("ip", &record, &json!("127.0.0.1")).unwrap(),
1531            json!(true)
1532        );
1533        assert_eq!(
1534            mutator
1535                .apply("ip", &record, &json!("127.255.255.254"))
1536                .unwrap(),
1537            json!(true)
1538        );
1539
1540        // Non-loopback
1541        assert_eq!(
1542            mutator.apply("ip", &record, &json!("8.8.8.8")).unwrap(),
1543            json!(false)
1544        );
1545        assert_eq!(
1546            mutator.apply("ip", &record, &json!("192.168.1.1")).unwrap(),
1547            json!(false)
1548        );
1549    }
1550
1551    #[test]
1552    fn test_is_loopback_ipv6() {
1553        let mutator = IsLoopbackMutator::new(HashMap::new());
1554        let record = json!({});
1555
1556        // IPv6 loopback
1557        assert_eq!(
1558            mutator.apply("ip", &record, &json!("::1")).unwrap(),
1559            json!(true)
1560        );
1561
1562        // Non-loopback IPv6
1563        assert_eq!(
1564            mutator
1565                .apply("ip", &record, &json!("2001:4860:4860::8888"))
1566                .unwrap(),
1567            json!(false)
1568        );
1569        assert_eq!(
1570            mutator.apply("ip", &record, &json!("fe80::1")).unwrap(),
1571            json!(false)
1572        );
1573    }
1574
1575    #[test]
1576    fn test_is_loopback_invalid_and_special() {
1577        let mutator = IsLoopbackMutator::new(HashMap::new());
1578        let record = json!({});
1579
1580        assert_eq!(
1581            mutator.apply("ip", &record, &json!("not-an-ip")).unwrap(),
1582            json!(false)
1583        );
1584        assert_eq!(
1585            mutator.apply("ip", &record, &JsonValue::Null).unwrap(),
1586            json!(false)
1587        );
1588    }
1589
1590    // Tests for IsLinkLocalMutator
1591    #[test]
1592    fn test_is_link_local_ipv4() {
1593        let mutator = IsLinkLocalMutator::new(HashMap::new());
1594        let record = json!({});
1595
1596        // Link-local: 169.254.0.0/16
1597        assert_eq!(
1598            mutator.apply("ip", &record, &json!("169.254.0.1")).unwrap(),
1599            json!(true)
1600        );
1601        assert_eq!(
1602            mutator
1603                .apply("ip", &record, &json!("169.254.255.254"))
1604                .unwrap(),
1605            json!(true)
1606        );
1607
1608        // Non-link-local
1609        assert_eq!(
1610            mutator.apply("ip", &record, &json!("8.8.8.8")).unwrap(),
1611            json!(false)
1612        );
1613        assert_eq!(
1614            mutator.apply("ip", &record, &json!("192.168.1.1")).unwrap(),
1615            json!(false)
1616        );
1617        assert_eq!(
1618            mutator.apply("ip", &record, &json!("127.0.0.1")).unwrap(),
1619            json!(false)
1620        );
1621    }
1622
1623    #[test]
1624    fn test_is_link_local_ipv6() {
1625        let mutator = IsLinkLocalMutator::new(HashMap::new());
1626        let record = json!({});
1627
1628        // IPv6 link-local (fe80::/10)
1629        assert_eq!(
1630            mutator.apply("ip", &record, &json!("fe80::1")).unwrap(),
1631            json!(true)
1632        );
1633        assert_eq!(
1634            mutator
1635                .apply("ip", &record, &json!("fe80::abcd:1234"))
1636                .unwrap(),
1637            json!(true)
1638        );
1639
1640        // Non-link-local IPv6
1641        assert_eq!(
1642            mutator
1643                .apply("ip", &record, &json!("2001:4860:4860::8888"))
1644                .unwrap(),
1645            json!(false)
1646        );
1647        assert_eq!(
1648            mutator.apply("ip", &record, &json!("::1")).unwrap(),
1649            json!(false)
1650        );
1651        assert_eq!(
1652            mutator.apply("ip", &record, &json!("fc00::1")).unwrap(),
1653            json!(false)
1654        );
1655    }
1656
1657    #[test]
1658    fn test_is_link_local_invalid_and_special() {
1659        let mutator = IsLinkLocalMutator::new(HashMap::new());
1660        let record = json!({});
1661
1662        assert_eq!(
1663            mutator.apply("ip", &record, &json!("not-an-ip")).unwrap(),
1664            json!(false)
1665        );
1666        assert_eq!(
1667            mutator.apply("ip", &record, &JsonValue::Null).unwrap(),
1668            json!(false)
1669        );
1670    }
1671
1672    #[test]
1673    fn test_is_link_local_array() {
1674        let mutator = IsLinkLocalMutator::new(HashMap::new());
1675        let record = json!({});
1676
1677        // Array with at least one link-local IP
1678        assert_eq!(
1679            mutator
1680                .apply(
1681                    "ips",
1682                    &record,
1683                    &json!(["8.8.8.8", "169.254.1.1", "1.1.1.1"])
1684                )
1685                .unwrap(),
1686            json!(true)
1687        );
1688
1689        // Array with no link-local IPs
1690        assert_eq!(
1691            mutator
1692                .apply("ips", &record, &json!(["8.8.8.8", "1.1.1.1"]))
1693                .unwrap(),
1694            json!(false)
1695        );
1696    }
1697}