Skip to main content

rsigma_eval/matcher/
matching.rs

1use regex::RegexSet;
2use rsigma_ir::IrStrOp;
3
4use super::helpers::{
5    ascii_lowercase_cow, expand_template, extract_timestamp_part, match_cidr, match_numeric_value,
6    match_str_value,
7};
8use super::{CompiledMatcher, GroupMode};
9use crate::event::{Event, EventValue};
10
11/// Reduce a [`RegexSet`] match against `s` according to `mode`.
12///
13/// `Any` short-circuits via [`RegexSet::is_match`]; `All` requires every
14/// pattern in the set to fire, so we materialize the matched-pattern bitset
15/// and check its population against the set length.
16#[inline]
17fn regex_set_matches(set: &RegexSet, mode: GroupMode, s: &str) -> bool {
18    match mode {
19        GroupMode::Any => set.is_match(s),
20        GroupMode::All => {
21            let hits = set.matches(s);
22            hits.iter().count() == set.len()
23        }
24    }
25}
26
27/// Substring, prefix, or suffix comparison of two runtime strings.
28fn fieldref_substr(op: IrStrOp, haystack: &str, needle: &str, case_insensitive: bool) -> bool {
29    if case_insensitive {
30        let haystack = haystack.to_lowercase();
31        let needle = needle.to_lowercase();
32        fieldref_substr_cmp(op, &haystack, &needle)
33    } else {
34        fieldref_substr_cmp(op, haystack, needle)
35    }
36}
37
38fn fieldref_substr_cmp(op: IrStrOp, haystack: &str, needle: &str) -> bool {
39    match op {
40        IrStrOp::Contains => haystack.contains(needle),
41        IrStrOp::StartsWith => haystack.starts_with(needle),
42        IrStrOp::EndsWith => haystack.ends_with(needle),
43        IrStrOp::Exact => haystack == needle,
44    }
45}
46
47impl CompiledMatcher {
48    /// Check if this matcher matches an [`EventValue`] from an event.
49    ///
50    /// The `event` parameter is needed for `FieldRef` to access other fields.
51    #[inline]
52    pub fn matches(&self, value: &EventValue, event: &impl Event) -> bool {
53        match self {
54            // -- String matchers --
55            CompiledMatcher::Exact {
56                value: expected,
57                case_insensitive,
58            } => match_str_value(value, |s| {
59                if *case_insensitive {
60                    s.to_lowercase() == *expected
61                } else {
62                    s == expected
63                }
64            }),
65
66            CompiledMatcher::Contains {
67                value: needle,
68                case_insensitive,
69            } => match_str_value(value, |s| {
70                if *case_insensitive {
71                    s.to_lowercase().contains(needle.as_str())
72                } else {
73                    s.contains(needle.as_str())
74                }
75            }),
76
77            CompiledMatcher::StartsWith {
78                value: prefix,
79                case_insensitive,
80            } => match_str_value(value, |s| {
81                if *case_insensitive {
82                    s.to_lowercase().starts_with(prefix.as_str())
83                } else {
84                    s.starts_with(prefix.as_str())
85                }
86            }),
87
88            CompiledMatcher::EndsWith {
89                value: suffix,
90                case_insensitive,
91            } => match_str_value(value, |s| {
92                if *case_insensitive {
93                    s.to_lowercase().ends_with(suffix.as_str())
94                } else {
95                    s.ends_with(suffix.as_str())
96                }
97            }),
98
99            CompiledMatcher::Regex(re) => match_str_value(value, |s| re.is_match(s)),
100
101            CompiledMatcher::AhoCorasickSet {
102                automaton,
103                case_insensitive,
104                ..
105            } => match_str_value(value, |s| {
106                if *case_insensitive {
107                    automaton.is_match(ascii_lowercase_cow(s).as_ref())
108                } else {
109                    automaton.is_match(s)
110                }
111            }),
112
113            CompiledMatcher::RegexSetMatch { set, mode } => {
114                match_str_value(value, |s| regex_set_matches(set, *mode, s))
115            }
116
117            // -- Network --
118            CompiledMatcher::Cidr(net) => match_cidr(value, net),
119
120            // -- Numeric --
121            CompiledMatcher::NumericEq(n) => {
122                match_numeric_value(value, |v| (v - n).abs() < f64::EPSILON)
123            }
124            CompiledMatcher::NumericGt(n) => match_numeric_value(value, |v| v > *n),
125            CompiledMatcher::NumericGte(n) => match_numeric_value(value, |v| v >= *n),
126            CompiledMatcher::NumericLt(n) => match_numeric_value(value, |v| v < *n),
127            CompiledMatcher::NumericLte(n) => match_numeric_value(value, |v| v <= *n),
128
129            // -- Special --
130            CompiledMatcher::Exists(expect) => {
131                let exists = !value.is_null();
132                exists == *expect
133            }
134
135            CompiledMatcher::FieldRef {
136                field: ref_field,
137                op,
138                case_insensitive,
139            } => {
140                let Some(ref_value) = event.get_field(ref_field) else {
141                    return false;
142                };
143                match op {
144                    IrStrOp::Exact => {
145                        if *case_insensitive {
146                            match (value.as_str(), ref_value.as_str()) {
147                                (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
148                                _ => value == &ref_value,
149                            }
150                        } else {
151                            value == &ref_value
152                        }
153                    }
154                    IrStrOp::Contains | IrStrOp::StartsWith | IrStrOp::EndsWith => {
155                        let Some(needle) = ref_value.as_str() else {
156                            return false;
157                        };
158                        match_str_value(value, |s| {
159                            fieldref_substr(*op, s, needle.as_ref(), *case_insensitive)
160                        })
161                    }
162                }
163            }
164
165            CompiledMatcher::Null => value.is_null(),
166
167            CompiledMatcher::BoolEq(expected) => match value {
168                EventValue::Bool(b) => b == expected,
169                EventValue::Str(s) => match s.to_lowercase().as_str() {
170                    "true" | "1" | "yes" => *expected,
171                    "false" | "0" | "no" => !*expected,
172                    _ => false,
173                },
174                EventValue::Array(arr) => arr.iter().any(|v| self.matches(v, event)),
175                _ => false,
176            },
177
178            // -- Expand --
179            CompiledMatcher::Expand {
180                template,
181                case_insensitive,
182            } => {
183                let expanded = expand_template(template, event);
184                match_str_value(value, |s| {
185                    if *case_insensitive {
186                        s.to_lowercase() == expanded.to_lowercase()
187                    } else {
188                        s == expanded
189                    }
190                })
191            }
192
193            // -- Timestamp --
194            CompiledMatcher::TimestampPart { part, inner } => {
195                if let EventValue::Array(arr) = value {
196                    return arr.iter().any(|v| self.matches(v, event));
197                }
198                match extract_timestamp_part(value, *part) {
199                    Some(n) => {
200                        let num_val = EventValue::Int(n);
201                        inner.matches(&num_val, event)
202                    }
203                    None => false,
204                }
205            }
206
207            // -- Negation --
208            CompiledMatcher::Not(inner) => !inner.matches(value, event),
209
210            // -- Composite --
211            CompiledMatcher::AnyOf(matchers) => matchers.iter().any(|m| m.matches(value, event)),
212            CompiledMatcher::AllOf(matchers) => matchers.iter().all(|m| m.matches(value, event)),
213
214            CompiledMatcher::CaseInsensitiveGroup { children, mode } => {
215                match_str_value(value, |s| {
216                    let lowered = ascii_lowercase_cow(s);
217                    match mode {
218                        GroupMode::Any => children
219                            .iter()
220                            .any(|c| c.matches_pre_lowered(lowered.as_ref())),
221                        GroupMode::All => children
222                            .iter()
223                            .all(|c| c.matches_pre_lowered(lowered.as_ref())),
224                    }
225                })
226            }
227        }
228    }
229
230    /// Match a haystack that has already been Unicode-lowercased.
231    ///
232    /// **Precondition**: `lowered_str` was produced by `ascii_lowercase_cow`
233    /// (or an equivalent full-Unicode lowercaser) AND every child of the
234    /// surrounding [`CompiledMatcher::CaseInsensitiveGroup`] is pre-lowerable
235    /// (see `compiler::optimizer::is_pre_lowerable`).
236    ///
237    /// This method is internal to the crate. Optimizer bugs that violate the
238    /// precondition trip a `debug_assert!`; in release the conservative
239    /// fallback (`false`) avoids producing a false positive but may miss a
240    /// match.
241    ///
242    /// No `event` parameter: pre-lowerable matchers are pure string predicates
243    /// that never reference cross-event state. If a future event-aware matcher
244    /// becomes pre-lowerable, the signature gains `event` then.
245    pub(crate) fn matches_pre_lowered(&self, lowered_str: &str) -> bool {
246        match self {
247            CompiledMatcher::Contains {
248                value,
249                case_insensitive: true,
250            } => lowered_str.contains(value.as_str()),
251            CompiledMatcher::StartsWith {
252                value,
253                case_insensitive: true,
254            } => lowered_str.starts_with(value.as_str()),
255            CompiledMatcher::EndsWith {
256                value,
257                case_insensitive: true,
258            } => lowered_str.ends_with(value.as_str()),
259            CompiledMatcher::Exact {
260                value,
261                case_insensitive: true,
262            } => lowered_str == value,
263            CompiledMatcher::Regex(re) => re.is_match(lowered_str),
264            CompiledMatcher::AhoCorasickSet {
265                automaton,
266                case_insensitive: true,
267                ..
268            } => automaton.is_match(lowered_str),
269            CompiledMatcher::RegexSetMatch { set, mode } => {
270                regex_set_matches(set, *mode, lowered_str)
271            }
272
273            CompiledMatcher::Not(inner) => !inner.matches_pre_lowered(lowered_str),
274            CompiledMatcher::AnyOf(ms) => ms.iter().any(|m| m.matches_pre_lowered(lowered_str)),
275            CompiledMatcher::AllOf(ms) => ms.iter().all(|m| m.matches_pre_lowered(lowered_str)),
276            CompiledMatcher::CaseInsensitiveGroup { children, mode } => match mode {
277                GroupMode::Any => children.iter().any(|c| c.matches_pre_lowered(lowered_str)),
278                GroupMode::All => children.iter().all(|c| c.matches_pre_lowered(lowered_str)),
279            },
280
281            other => {
282                debug_assert!(
283                    false,
284                    "matches_pre_lowered called with non-pre-lowerable matcher: {other:?}"
285                );
286                false
287            }
288        }
289    }
290
291    /// Check if this matcher matches a plain `&str` value.
292    ///
293    /// Handles the string-matching subset of `CompiledMatcher`. Matchers that
294    /// require a full `EventValue` (numeric comparisons, field refs, etc.)
295    /// return `false` — those are never used in keyword detection.
296    pub(crate) fn matches_str(&self, s: &str) -> bool {
297        match self {
298            CompiledMatcher::Exact {
299                value: expected,
300                case_insensitive,
301            } => {
302                if *case_insensitive {
303                    s.to_lowercase() == *expected
304                } else {
305                    s == expected
306                }
307            }
308            CompiledMatcher::Contains {
309                value: needle,
310                case_insensitive,
311            } => {
312                if *case_insensitive {
313                    s.to_lowercase().contains(needle.as_str())
314                } else {
315                    s.contains(needle.as_str())
316                }
317            }
318            CompiledMatcher::StartsWith {
319                value: prefix,
320                case_insensitive,
321            } => {
322                if *case_insensitive {
323                    s.to_lowercase().starts_with(prefix.as_str())
324                } else {
325                    s.starts_with(prefix.as_str())
326                }
327            }
328            CompiledMatcher::EndsWith {
329                value: suffix,
330                case_insensitive,
331            } => {
332                if *case_insensitive {
333                    s.to_lowercase().ends_with(suffix.as_str())
334                } else {
335                    s.ends_with(suffix.as_str())
336                }
337            }
338            CompiledMatcher::Regex(re) => re.is_match(s),
339            CompiledMatcher::AhoCorasickSet {
340                automaton,
341                case_insensitive,
342                ..
343            } => {
344                if *case_insensitive {
345                    automaton.is_match(ascii_lowercase_cow(s).as_ref())
346                } else {
347                    automaton.is_match(s)
348                }
349            }
350            CompiledMatcher::RegexSetMatch { set, mode } => regex_set_matches(set, *mode, s),
351            CompiledMatcher::Not(inner) => !inner.matches_str(s),
352            CompiledMatcher::AnyOf(matchers) => matchers.iter().any(|m| m.matches_str(s)),
353            CompiledMatcher::AllOf(matchers) => matchers.iter().all(|m| m.matches_str(s)),
354            CompiledMatcher::CaseInsensitiveGroup { children, mode } => {
355                let lowered = ascii_lowercase_cow(s);
356                match mode {
357                    GroupMode::Any => children
358                        .iter()
359                        .any(|c| c.matches_pre_lowered(lowered.as_ref())),
360                    GroupMode::All => children
361                        .iter()
362                        .all(|c| c.matches_pre_lowered(lowered.as_ref())),
363                }
364            }
365            _ => false,
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::event::JsonEvent;
374    use crate::matcher::helpers::parse_expand_template;
375    use crate::matcher::{ExpandPart, TimePart};
376    use ipnet::IpNet;
377    use regex::Regex;
378    use serde_json::json;
379
380    fn empty_event() -> serde_json::Value {
381        json!({})
382    }
383
384    #[test]
385    fn test_exact_case_insensitive() {
386        let m = CompiledMatcher::Exact {
387            value: "whoami".into(),
388            case_insensitive: true,
389        };
390        let e = empty_event();
391        let event = JsonEvent::borrow(&e);
392        assert!(m.matches(&EventValue::Str("whoami".into()), &event));
393        assert!(m.matches(&EventValue::Str("WHOAMI".into()), &event));
394        assert!(m.matches(&EventValue::Str("Whoami".into()), &event));
395        assert!(!m.matches(&EventValue::Str("other".into()), &event));
396    }
397
398    #[test]
399    fn test_exact_case_sensitive() {
400        let m = CompiledMatcher::Exact {
401            value: "whoami".into(),
402            case_insensitive: false,
403        };
404        let e = empty_event();
405        let event = JsonEvent::borrow(&e);
406        assert!(m.matches(&EventValue::Str("whoami".into()), &event));
407        assert!(!m.matches(&EventValue::Str("WHOAMI".into()), &event));
408    }
409
410    #[test]
411    fn test_contains() {
412        let m = CompiledMatcher::Contains {
413            value: "admin".to_lowercase(),
414            case_insensitive: true,
415        };
416        let e = empty_event();
417        let event = JsonEvent::borrow(&e);
418        assert!(m.matches(&EventValue::Str("superadminuser".into()), &event));
419        assert!(m.matches(&EventValue::Str("ADMIN".into()), &event));
420        assert!(!m.matches(&EventValue::Str("user".into()), &event));
421    }
422
423    #[test]
424    fn test_starts_with() {
425        let m = CompiledMatcher::StartsWith {
426            value: "cmd".into(),
427            case_insensitive: true,
428        };
429        let e = empty_event();
430        let event = JsonEvent::borrow(&e);
431        assert!(m.matches(&EventValue::Str("cmd.exe".into()), &event));
432        assert!(m.matches(&EventValue::Str("CMD.EXE".into()), &event));
433        assert!(!m.matches(&EventValue::Str("xcmd".into()), &event));
434    }
435
436    #[test]
437    fn test_ends_with() {
438        let m = CompiledMatcher::EndsWith {
439            value: ".exe".into(),
440            case_insensitive: true,
441        };
442        let e = empty_event();
443        let event = JsonEvent::borrow(&e);
444        assert!(m.matches(&EventValue::Str("cmd.exe".into()), &event));
445        assert!(m.matches(&EventValue::Str("CMD.EXE".into()), &event));
446        assert!(!m.matches(&EventValue::Str("cmd.bat".into()), &event));
447    }
448
449    #[test]
450    fn test_regex() {
451        let re = Regex::new("(?i)^test.*value$").unwrap();
452        let m = CompiledMatcher::Regex(re);
453        let e = empty_event();
454        let event = JsonEvent::borrow(&e);
455        assert!(m.matches(&EventValue::Str("testXYZvalue".into()), &event));
456        assert!(m.matches(&EventValue::Str("TESTvalue".into()), &event));
457        assert!(!m.matches(&EventValue::Str("notamatch".into()), &event));
458    }
459
460    #[test]
461    fn test_cidr() {
462        let net: IpNet = "10.0.0.0/8".parse().unwrap();
463        let m = CompiledMatcher::Cidr(net);
464        let e = empty_event();
465        let event = JsonEvent::borrow(&e);
466        assert!(m.matches(&EventValue::Str("10.1.2.3".into()), &event));
467        assert!(!m.matches(&EventValue::Str("192.168.1.1".into()), &event));
468    }
469
470    #[test]
471    fn test_numeric() {
472        let m = CompiledMatcher::NumericGte(100.0);
473        let e = empty_event();
474        let event = JsonEvent::borrow(&e);
475        assert!(m.matches(&EventValue::Int(100), &event));
476        assert!(m.matches(&EventValue::Int(200), &event));
477        assert!(!m.matches(&EventValue::Int(50), &event));
478        assert!(m.matches(&EventValue::Str("150".into()), &event));
479    }
480
481    #[test]
482    fn test_null() {
483        let m = CompiledMatcher::Null;
484        let e = empty_event();
485        let event = JsonEvent::borrow(&e);
486        assert!(m.matches(&EventValue::Null, &event));
487        assert!(!m.matches(&EventValue::Str("".into()), &event));
488    }
489
490    #[test]
491    fn test_bool() {
492        let m = CompiledMatcher::BoolEq(true);
493        let e = empty_event();
494        let event = JsonEvent::borrow(&e);
495        assert!(m.matches(&EventValue::Bool(true), &event));
496        assert!(!m.matches(&EventValue::Bool(false), &event));
497        assert!(m.matches(&EventValue::Str("true".into()), &event));
498    }
499
500    #[test]
501    fn test_field_ref() {
502        let e = json!({"src": "10.0.0.1", "dst": "10.0.0.1"});
503        let event = JsonEvent::borrow(&e);
504        let m = CompiledMatcher::FieldRef {
505            field: "dst".into(),
506            op: IrStrOp::Exact,
507            case_insensitive: true,
508        };
509        assert!(m.matches(&EventValue::Str("10.0.0.1".into()), &event));
510    }
511
512    #[test]
513    fn test_field_ref_contains() {
514        let e = json!({"user": "Alice", "arn": "arn:aws:iam::123:user/alice"});
515        let event = JsonEvent::borrow(&e);
516        let m = CompiledMatcher::FieldRef {
517            field: "user".into(),
518            op: IrStrOp::Contains,
519            case_insensitive: true,
520        };
521        assert!(m.matches(
522            &EventValue::Str("arn:aws:iam::123:user/alice".into()),
523            &event
524        ));
525        let cased = CompiledMatcher::FieldRef {
526            field: "user".into(),
527            op: IrStrOp::Contains,
528            case_insensitive: false,
529        };
530        assert!(!cased.matches(
531            &EventValue::Str("arn:aws:iam::123:user/alice".into()),
532            &event
533        ));
534        let missing_type = CompiledMatcher::FieldRef {
535            field: "missing".into(),
536            op: IrStrOp::StartsWith,
537            case_insensitive: true,
538        };
539        assert!(!missing_type.matches(&EventValue::Str("alice".into()), &event));
540    }
541
542    #[test]
543    fn test_any_of() {
544        let m = CompiledMatcher::AnyOf(vec![
545            CompiledMatcher::Exact {
546                value: "a".into(),
547                case_insensitive: false,
548            },
549            CompiledMatcher::Exact {
550                value: "b".into(),
551                case_insensitive: false,
552            },
553        ]);
554        let e = empty_event();
555        let event = JsonEvent::borrow(&e);
556        assert!(m.matches(&EventValue::Str("a".into()), &event));
557        assert!(m.matches(&EventValue::Str("b".into()), &event));
558        assert!(!m.matches(&EventValue::Str("c".into()), &event));
559    }
560
561    #[test]
562    fn test_all_of() {
563        let m = CompiledMatcher::AllOf(vec![
564            CompiledMatcher::Contains {
565                value: "admin".into(),
566                case_insensitive: false,
567            },
568            CompiledMatcher::Contains {
569                value: "user".into(),
570                case_insensitive: false,
571            },
572        ]);
573        let e = empty_event();
574        let event = JsonEvent::borrow(&e);
575        assert!(m.matches(&EventValue::Str("adminuser".into()), &event));
576        assert!(!m.matches(&EventValue::Str("admin".into()), &event));
577    }
578
579    #[test]
580    fn test_array_value_matching() {
581        let m = CompiledMatcher::Exact {
582            value: "target".into(),
583            case_insensitive: true,
584        };
585        let e = empty_event();
586        let event = JsonEvent::borrow(&e);
587        let arr = EventValue::Array(vec![
588            EventValue::Str("other".into()),
589            EventValue::Str("target".into()),
590            EventValue::Str("more".into()),
591        ]);
592        assert!(m.matches(&arr, &event));
593        let arr2 = EventValue::Array(vec![
594            EventValue::Str("other".into()),
595            EventValue::Str("nope".into()),
596        ]);
597        assert!(!m.matches(&arr2, &event));
598    }
599
600    #[test]
601    fn test_number_coercion_to_string() {
602        let m = CompiledMatcher::Exact {
603            value: "42".into(),
604            case_insensitive: false,
605        };
606        let e = empty_event();
607        let event = JsonEvent::borrow(&e);
608        assert!(m.matches(&EventValue::Int(42), &event));
609    }
610
611    // =========================================================================
612    // Unicode case folding tests
613    // =========================================================================
614
615    #[test]
616    fn test_exact_unicode_case_insensitive() {
617        let m = CompiledMatcher::Exact {
618            value: "ärzte".to_lowercase(),
619            case_insensitive: true,
620        };
621        let e = empty_event();
622        let event = JsonEvent::borrow(&e);
623        assert!(m.matches(&EventValue::Str("ÄRZTE".into()), &event));
624        assert!(m.matches(&EventValue::Str("Ärzte".into()), &event));
625        assert!(m.matches(&EventValue::Str("ärzte".into()), &event));
626    }
627
628    #[test]
629    fn test_contains_unicode_case_insensitive() {
630        let m = CompiledMatcher::Contains {
631            value: "ñ".to_lowercase(),
632            case_insensitive: true,
633        };
634        let e = empty_event();
635        let event = JsonEvent::borrow(&e);
636        assert!(m.matches(&EventValue::Str("España".into()), &event));
637        assert!(m.matches(&EventValue::Str("ESPAÑA".into()), &event));
638    }
639
640    #[test]
641    fn test_startswith_unicode_case_insensitive() {
642        let m = CompiledMatcher::StartsWith {
643            value: "über".to_lowercase(),
644            case_insensitive: true,
645        };
646        let e = empty_event();
647        let event = JsonEvent::borrow(&e);
648        assert!(m.matches(&EventValue::Str("Übersicht".into()), &event));
649        assert!(m.matches(&EventValue::Str("ÜBERSICHT".into()), &event));
650        assert!(!m.matches(&EventValue::Str("not-uber".into()), &event));
651    }
652
653    #[test]
654    fn test_endswith_unicode_case_insensitive() {
655        let m = CompiledMatcher::EndsWith {
656            value: "ção".to_lowercase(),
657            case_insensitive: true,
658        };
659        let e = empty_event();
660        let event = JsonEvent::borrow(&e);
661        assert!(m.matches(&EventValue::Str("Aplicação".into()), &event));
662        assert!(m.matches(&EventValue::Str("APLICAÇÃO".into()), &event));
663        assert!(!m.matches(&EventValue::Str("Aplicacao".into()), &event));
664    }
665
666    #[test]
667    fn test_greek_case_insensitive() {
668        let m = CompiledMatcher::Exact {
669            value: "σίγμα".to_lowercase(),
670            case_insensitive: true,
671        };
672        let e = empty_event();
673        let event = JsonEvent::borrow(&e);
674        assert!(m.matches(&EventValue::Str("ΣΊΓΜΑ".into()), &event));
675        assert!(m.matches(&EventValue::Str("σίγμα".into()), &event));
676    }
677
678    // =========================================================================
679    // Expand modifier tests
680    // =========================================================================
681
682    #[test]
683    fn test_parse_expand_template() {
684        let parts = parse_expand_template("C:\\Users\\%user%\\AppData");
685        assert_eq!(parts.len(), 3);
686        assert!(matches!(&parts[0], ExpandPart::Literal(s) if s == "C:\\Users\\"));
687        assert!(matches!(&parts[1], ExpandPart::Placeholder(s) if s == "user"));
688        assert!(matches!(&parts[2], ExpandPart::Literal(s) if s == "\\AppData"));
689    }
690
691    #[test]
692    fn test_parse_expand_template_no_placeholders() {
693        let parts = parse_expand_template("just a literal");
694        assert_eq!(parts.len(), 1);
695        assert!(matches!(&parts[0], ExpandPart::Literal(s) if s == "just a literal"));
696    }
697
698    #[test]
699    fn test_parse_expand_template_multiple_placeholders() {
700        let parts = parse_expand_template("%a%:%b%");
701        assert_eq!(parts.len(), 3);
702        assert!(matches!(&parts[0], ExpandPart::Placeholder(s) if s == "a"));
703        assert!(matches!(&parts[1], ExpandPart::Literal(s) if s == ":"));
704        assert!(matches!(&parts[2], ExpandPart::Placeholder(s) if s == "b"));
705    }
706
707    #[test]
708    fn test_expand_matcher() {
709        let template = parse_expand_template("C:\\Users\\%user%\\Downloads");
710        let m = CompiledMatcher::Expand {
711            template,
712            case_insensitive: true,
713        };
714        let e = json!({"user": "admin", "path": "C:\\Users\\admin\\Downloads"});
715        let event = JsonEvent::borrow(&e);
716        assert!(m.matches(
717            &EventValue::Str("C:\\Users\\admin\\Downloads".into()),
718            &event
719        ));
720        assert!(!m.matches(
721            &EventValue::Str("C:\\Users\\other\\Downloads".into()),
722            &event
723        ));
724    }
725
726    #[test]
727    fn test_expand_matcher_missing_field() {
728        let template = parse_expand_template("%user%@%domain%");
729        let m = CompiledMatcher::Expand {
730            template,
731            case_insensitive: false,
732        };
733        let e = json!({"user": "admin"});
734        let event = JsonEvent::borrow(&e);
735        assert!(m.matches(&EventValue::Str("admin@".into()), &event));
736    }
737
738    // =========================================================================
739    // Timestamp part tests
740    // =========================================================================
741
742    #[test]
743    fn test_timestamp_part_hour() {
744        let m = CompiledMatcher::TimestampPart {
745            part: TimePart::Hour,
746            inner: Box::new(CompiledMatcher::NumericEq(12.0)),
747        };
748        let e = json!({});
749        let event = JsonEvent::borrow(&e);
750        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
751        assert!(!m.matches(&EventValue::Str("2024-07-10T15:30:00Z".into()), &event));
752    }
753
754    #[test]
755    fn test_timestamp_part_month() {
756        let m = CompiledMatcher::TimestampPart {
757            part: TimePart::Month,
758            inner: Box::new(CompiledMatcher::NumericEq(7.0)),
759        };
760        let e = json!({});
761        let event = JsonEvent::borrow(&e);
762        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
763        assert!(!m.matches(&EventValue::Str("2024-08-10T12:30:00Z".into()), &event));
764    }
765
766    #[test]
767    fn test_timestamp_part_day() {
768        let m = CompiledMatcher::TimestampPart {
769            part: TimePart::Day,
770            inner: Box::new(CompiledMatcher::NumericEq(10.0)),
771        };
772        let e = json!({});
773        let event = JsonEvent::borrow(&e);
774        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
775        assert!(!m.matches(&EventValue::Str("2024-07-15T12:30:00Z".into()), &event));
776    }
777
778    #[test]
779    fn test_timestamp_part_year() {
780        let m = CompiledMatcher::TimestampPart {
781            part: TimePart::Year,
782            inner: Box::new(CompiledMatcher::NumericEq(2024.0)),
783        };
784        let e = json!({});
785        let event = JsonEvent::borrow(&e);
786        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
787        assert!(!m.matches(&EventValue::Str("2023-07-10T12:30:00Z".into()), &event));
788    }
789
790    #[test]
791    fn test_timestamp_part_from_epoch() {
792        let m = CompiledMatcher::TimestampPart {
793            part: TimePart::Hour,
794            inner: Box::new(CompiledMatcher::NumericEq(12.0)),
795        };
796        let e = json!({});
797        let event = JsonEvent::borrow(&e);
798        // 2024-07-10T12:30:00Z = 1720614600
799        assert!(m.matches(&EventValue::Int(1720614600), &event));
800    }
801}