Skip to main content

rsigma_eval/
matcher.rs

1//! Compiled matchers for zero-allocation hot-path evaluation.
2//!
3//! Each `CompiledMatcher` variant is pre-compiled at rule load time.
4//! At evaluation time, `matches()` performs the comparison against an
5//! [`EventValue`] from the event with no dynamic dispatch or allocation.
6
7use std::net::IpAddr;
8
9use chrono::{Datelike, Timelike};
10use ipnet::IpNet;
11use regex::Regex;
12
13use crate::event::{Event, EventValue};
14
15/// A pre-compiled matcher for a single value comparison.
16///
17/// All string matchers store their values in the form needed for comparison
18/// (Unicode-lowercased for case-insensitive). The `case_insensitive` flag
19/// controls whether the input is lowercased before comparison.
20#[derive(Debug, Clone)]
21pub enum CompiledMatcher {
22    // -- String matchers --
23    /// Exact string equality.
24    Exact {
25        value: String,
26        case_insensitive: bool,
27    },
28    /// Substring containment.
29    Contains {
30        value: String,
31        case_insensitive: bool,
32    },
33    /// String starts with prefix.
34    StartsWith {
35        value: String,
36        case_insensitive: bool,
37    },
38    /// String ends with suffix.
39    EndsWith {
40        value: String,
41        case_insensitive: bool,
42    },
43    /// Compiled regex pattern (flags baked in at compile time).
44    Regex(Regex),
45
46    // -- Network --
47    /// CIDR network match for IP addresses.
48    Cidr(IpNet),
49
50    // -- Numeric --
51    /// Numeric equality.
52    NumericEq(f64),
53    /// Numeric greater-than.
54    NumericGt(f64),
55    /// Numeric greater-than-or-equal.
56    NumericGte(f64),
57    /// Numeric less-than.
58    NumericLt(f64),
59    /// Numeric less-than-or-equal.
60    NumericLte(f64),
61
62    // -- Special --
63    /// Field existence check. `true` = field must exist, `false` = must not exist.
64    Exists(bool),
65    /// Compare against another field's value.
66    FieldRef {
67        field: String,
68        case_insensitive: bool,
69    },
70    /// Match null / missing values.
71    Null,
72    /// Boolean equality.
73    BoolEq(bool),
74
75    // -- Expand --
76    /// Placeholder expansion: `%fieldname%` is resolved from the event at match time.
77    Expand {
78        template: Vec<ExpandPart>,
79        case_insensitive: bool,
80    },
81
82    // -- Timestamp --
83    /// Extract a time component from a timestamp field value and match it.
84    TimestampPart {
85        part: TimePart,
86        inner: Box<CompiledMatcher>,
87    },
88
89    // -- Negation --
90    /// Negated matcher: matches if the inner matcher does NOT match.
91    Not(Box<CompiledMatcher>),
92
93    // -- Composite --
94    /// Match if ANY child matches (OR).
95    AnyOf(Vec<CompiledMatcher>),
96    /// Match if ALL children match (AND).
97    AllOf(Vec<CompiledMatcher>),
98}
99
100/// A part of an expand template.
101#[derive(Debug, Clone)]
102pub enum ExpandPart {
103    /// Literal text.
104    Literal(String),
105    /// A placeholder field name (between `%` delimiters).
106    Placeholder(String),
107}
108
109/// Which time component to extract from a timestamp.
110#[derive(Debug, Clone, Copy)]
111pub enum TimePart {
112    Minute,
113    Hour,
114    Day,
115    Week,
116    Month,
117    Year,
118}
119
120impl CompiledMatcher {
121    /// Check if this matcher matches an [`EventValue`] from an event.
122    ///
123    /// The `event` parameter is needed for `FieldRef` to access other fields.
124    #[inline]
125    pub fn matches(&self, value: &EventValue, event: &impl Event) -> bool {
126        match self {
127            // -- String matchers --
128            CompiledMatcher::Exact {
129                value: expected,
130                case_insensitive,
131            } => match_str_value(value, |s| {
132                if *case_insensitive {
133                    s.to_lowercase() == *expected
134                } else {
135                    s == expected
136                }
137            }),
138
139            CompiledMatcher::Contains {
140                value: needle,
141                case_insensitive,
142            } => match_str_value(value, |s| {
143                if *case_insensitive {
144                    s.to_lowercase().contains(needle.as_str())
145                } else {
146                    s.contains(needle.as_str())
147                }
148            }),
149
150            CompiledMatcher::StartsWith {
151                value: prefix,
152                case_insensitive,
153            } => match_str_value(value, |s| {
154                if *case_insensitive {
155                    s.to_lowercase().starts_with(prefix.as_str())
156                } else {
157                    s.starts_with(prefix.as_str())
158                }
159            }),
160
161            CompiledMatcher::EndsWith {
162                value: suffix,
163                case_insensitive,
164            } => match_str_value(value, |s| {
165                if *case_insensitive {
166                    s.to_lowercase().ends_with(suffix.as_str())
167                } else {
168                    s.ends_with(suffix.as_str())
169                }
170            }),
171
172            CompiledMatcher::Regex(re) => match_str_value(value, |s| re.is_match(s)),
173
174            // -- Network --
175            CompiledMatcher::Cidr(net) => match_str_value(value, |s| {
176                s.parse::<IpAddr>().is_ok_and(|ip| net.contains(&ip))
177            }),
178
179            // -- Numeric --
180            CompiledMatcher::NumericEq(n) => {
181                match_numeric_value(value, |v| (v - n).abs() < f64::EPSILON)
182            }
183            CompiledMatcher::NumericGt(n) => match_numeric_value(value, |v| v > *n),
184            CompiledMatcher::NumericGte(n) => match_numeric_value(value, |v| v >= *n),
185            CompiledMatcher::NumericLt(n) => match_numeric_value(value, |v| v < *n),
186            CompiledMatcher::NumericLte(n) => match_numeric_value(value, |v| v <= *n),
187
188            // -- Special --
189            CompiledMatcher::Exists(expect) => {
190                let exists = !value.is_null();
191                exists == *expect
192            }
193
194            CompiledMatcher::FieldRef {
195                field: ref_field,
196                case_insensitive,
197            } => {
198                if let Some(ref_value) = event.get_field(ref_field) {
199                    if *case_insensitive {
200                        match (value.as_str(), ref_value.as_str()) {
201                            (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
202                            _ => value == &ref_value,
203                        }
204                    } else {
205                        value == &ref_value
206                    }
207                } else {
208                    false
209                }
210            }
211
212            CompiledMatcher::Null => value.is_null(),
213
214            CompiledMatcher::BoolEq(expected) => match value {
215                EventValue::Bool(b) => b == expected,
216                EventValue::Str(s) => match s.to_lowercase().as_str() {
217                    "true" | "1" | "yes" => *expected,
218                    "false" | "0" | "no" => !*expected,
219                    _ => false,
220                },
221                _ => false,
222            },
223
224            // -- Expand --
225            CompiledMatcher::Expand {
226                template,
227                case_insensitive,
228            } => {
229                let expanded = expand_template(template, event);
230                match_str_value(value, |s| {
231                    if *case_insensitive {
232                        s.to_lowercase() == expanded.to_lowercase()
233                    } else {
234                        s == expanded
235                    }
236                })
237            }
238
239            // -- Timestamp --
240            CompiledMatcher::TimestampPart { part, inner } => {
241                match extract_timestamp_part(value, *part) {
242                    Some(n) => {
243                        let num_val = EventValue::Int(n);
244                        inner.matches(&num_val, event)
245                    }
246                    None => false,
247                }
248            }
249
250            // -- Negation --
251            CompiledMatcher::Not(inner) => !inner.matches(value, event),
252
253            // -- Composite --
254            CompiledMatcher::AnyOf(matchers) => matchers.iter().any(|m| m.matches(value, event)),
255            CompiledMatcher::AllOf(matchers) => matchers.iter().all(|m| m.matches(value, event)),
256        }
257    }
258
259    /// Check if this matcher matches any string value in the event.
260    /// Used for keyword detection (field-less matching).
261    ///
262    /// Avoids allocating a `Vec` of all strings and a `String` per value by
263    /// using `matches_str` with a short-circuiting traversal.
264    #[inline]
265    pub fn matches_keyword(&self, event: &impl Event) -> bool {
266        event.any_string_value(&|s| self.matches_str(s))
267    }
268
269    /// Check if this matcher matches a plain `&str` value.
270    ///
271    /// Handles the string-matching subset of `CompiledMatcher`. Matchers that
272    /// require a full `EventValue` (numeric comparisons, field refs, etc.)
273    /// return `false` — those are never used in keyword detection.
274    fn matches_str(&self, s: &str) -> bool {
275        match self {
276            CompiledMatcher::Exact {
277                value: expected,
278                case_insensitive,
279            } => {
280                if *case_insensitive {
281                    s.to_lowercase() == *expected
282                } else {
283                    s == expected
284                }
285            }
286            CompiledMatcher::Contains {
287                value: needle,
288                case_insensitive,
289            } => {
290                if *case_insensitive {
291                    s.to_lowercase().contains(needle.as_str())
292                } else {
293                    s.contains(needle.as_str())
294                }
295            }
296            CompiledMatcher::StartsWith {
297                value: prefix,
298                case_insensitive,
299            } => {
300                if *case_insensitive {
301                    s.to_lowercase().starts_with(prefix.as_str())
302                } else {
303                    s.starts_with(prefix.as_str())
304                }
305            }
306            CompiledMatcher::EndsWith {
307                value: suffix,
308                case_insensitive,
309            } => {
310                if *case_insensitive {
311                    s.to_lowercase().ends_with(suffix.as_str())
312                } else {
313                    s.ends_with(suffix.as_str())
314                }
315            }
316            CompiledMatcher::Regex(re) => re.is_match(s),
317            CompiledMatcher::Not(inner) => !inner.matches_str(s),
318            CompiledMatcher::AnyOf(matchers) => matchers.iter().any(|m| m.matches_str(s)),
319            CompiledMatcher::AllOf(matchers) => matchers.iter().all(|m| m.matches_str(s)),
320            _ => false,
321        }
322    }
323}
324
325// ---------------------------------------------------------------------------
326// Helper functions
327// ---------------------------------------------------------------------------
328
329/// Try to extract a string representation from an [`EventValue`] and apply a predicate.
330///
331/// Handles `Str` directly and coerces numbers/bools to string for comparison.
332fn match_str_value(value: &EventValue, pred: impl Fn(&str) -> bool) -> bool {
333    match_str_value_ref(value, &pred)
334}
335
336fn match_str_value_ref(value: &EventValue, pred: &dyn Fn(&str) -> bool) -> bool {
337    match value {
338        EventValue::Str(s) => pred(s),
339        EventValue::Int(n) => pred(&n.to_string()),
340        EventValue::Float(f) => pred(&f.to_string()),
341        EventValue::Bool(b) => pred(if *b { "true" } else { "false" }),
342        EventValue::Array(arr) => arr.iter().any(|v| match_str_value_ref(v, pred)),
343        _ => false,
344    }
345}
346
347/// Try to extract a numeric value and apply a predicate.
348///
349/// Handles numeric values directly and tries to parse strings as numbers.
350fn match_numeric_value(value: &EventValue, pred: impl Fn(f64) -> bool) -> bool {
351    match_numeric_value_ref(value, &pred)
352}
353
354fn match_numeric_value_ref(value: &EventValue, pred: &dyn Fn(f64) -> bool) -> bool {
355    match value {
356        EventValue::Int(n) => pred(*n as f64),
357        EventValue::Float(f) => pred(*f),
358        EventValue::Str(s) => s.parse::<f64>().is_ok_and(pred),
359        EventValue::Array(arr) => arr.iter().any(|v| match_numeric_value_ref(v, pred)),
360        _ => false,
361    }
362}
363
364/// Convert a [`SigmaString`](rsigma_parser::SigmaString) to a regex pattern string.
365///
366/// Wildcards are converted: `*` → `.*`, `?` → `.`
367/// Plain text is regex-escaped.
368pub fn sigma_string_to_regex(
369    parts: &[rsigma_parser::value::StringPart],
370    case_insensitive: bool,
371) -> String {
372    use rsigma_parser::value::{SpecialChar, StringPart};
373
374    let mut pattern = String::new();
375    if case_insensitive {
376        pattern.push_str("(?i)");
377    }
378    pattern.push('^');
379    for part in parts {
380        match part {
381            StringPart::Plain(text) => {
382                pattern.push_str(&regex::escape(text));
383            }
384            StringPart::Special(SpecialChar::WildcardMulti) => {
385                pattern.push_str(".*");
386            }
387            StringPart::Special(SpecialChar::WildcardSingle) => {
388                pattern.push('.');
389            }
390        }
391    }
392    pattern.push('$');
393    pattern
394}
395
396// ---------------------------------------------------------------------------
397// Expand helpers
398// ---------------------------------------------------------------------------
399
400/// Resolve all placeholders in an expand template from the event.
401fn expand_template(template: &[ExpandPart], event: &impl Event) -> String {
402    let mut result = String::new();
403    for part in template {
404        match part {
405            ExpandPart::Literal(s) => result.push_str(s),
406            ExpandPart::Placeholder(field) => {
407                if let Some(val) = event.get_field(field)
408                    && let Some(s) = val.as_str()
409                {
410                    result.push_str(&s);
411                }
412            }
413        }
414    }
415    result
416}
417
418/// Parse an expand template string like `C:\Users\%user%\AppData` into parts.
419pub fn parse_expand_template(s: &str) -> Vec<ExpandPart> {
420    let mut parts = Vec::new();
421    let mut current = String::new();
422    let mut in_placeholder = false;
423    let mut placeholder = String::new();
424
425    for ch in s.chars() {
426        if ch == '%' {
427            if in_placeholder {
428                if !placeholder.is_empty() {
429                    parts.push(ExpandPart::Placeholder(placeholder.clone()));
430                    placeholder.clear();
431                }
432                in_placeholder = false;
433            } else {
434                if !current.is_empty() {
435                    parts.push(ExpandPart::Literal(current.clone()));
436                    current.clear();
437                }
438                in_placeholder = true;
439            }
440        } else if in_placeholder {
441            placeholder.push(ch);
442        } else {
443            current.push(ch);
444        }
445    }
446
447    if in_placeholder && !placeholder.is_empty() {
448        current.push('%');
449        current.push_str(&placeholder);
450    }
451    if !current.is_empty() {
452        parts.push(ExpandPart::Literal(current));
453    }
454
455    parts
456}
457
458// ---------------------------------------------------------------------------
459// Timestamp part helpers
460// ---------------------------------------------------------------------------
461
462/// Extract a time component from an [`EventValue`] (timestamp string or number).
463fn extract_timestamp_part(value: &EventValue, part: TimePart) -> Option<i64> {
464    match value {
465        EventValue::Str(s) => parse_timestamp_str(s, part),
466        EventValue::Int(n) => {
467            let secs = if *n > 1_000_000_000_000 { n / 1000 } else { *n };
468            let dt = chrono::DateTime::from_timestamp(secs, 0)?;
469            Some(extract_part_from_datetime(&dt, part))
470        }
471        EventValue::Float(f) => {
472            let secs = *f as i64;
473            let secs = if secs > 1_000_000_000_000 {
474                secs / 1000
475            } else {
476                secs
477            };
478            let dt = chrono::DateTime::from_timestamp(secs, 0)?;
479            Some(extract_part_from_datetime(&dt, part))
480        }
481        _ => None,
482    }
483}
484
485fn parse_timestamp_str(ts_str: &str, part: TimePart) -> Option<i64> {
486    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
487        return Some(extract_part_from_datetime(&dt.to_utc(), part));
488    }
489    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H:%M:%S") {
490        let dt = naive.and_utc();
491        return Some(extract_part_from_datetime(&dt, part));
492    }
493    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d %H:%M:%S") {
494        let dt = naive.and_utc();
495        return Some(extract_part_from_datetime(&dt, part));
496    }
497    if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H:%M:%S%.f") {
498        let dt = naive.and_utc();
499        return Some(extract_part_from_datetime(&dt, part));
500    }
501    None
502}
503
504/// Extract a specific time component from a UTC DateTime.
505fn extract_part_from_datetime(dt: &chrono::DateTime<chrono::Utc>, part: TimePart) -> i64 {
506    match part {
507        TimePart::Minute => dt.minute() as i64,
508        TimePart::Hour => dt.hour() as i64,
509        TimePart::Day => dt.day() as i64,
510        TimePart::Week => dt.iso_week().week() as i64,
511        TimePart::Month => dt.month() as i64,
512        TimePart::Year => dt.year() as i64,
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::event::JsonEvent;
520    use serde_json::json;
521
522    fn empty_event() -> serde_json::Value {
523        json!({})
524    }
525
526    #[test]
527    fn test_exact_case_insensitive() {
528        let m = CompiledMatcher::Exact {
529            value: "whoami".into(),
530            case_insensitive: true,
531        };
532        let e = empty_event();
533        let event = JsonEvent::borrow(&e);
534        assert!(m.matches(&EventValue::Str("whoami".into()), &event));
535        assert!(m.matches(&EventValue::Str("WHOAMI".into()), &event));
536        assert!(m.matches(&EventValue::Str("Whoami".into()), &event));
537        assert!(!m.matches(&EventValue::Str("other".into()), &event));
538    }
539
540    #[test]
541    fn test_exact_case_sensitive() {
542        let m = CompiledMatcher::Exact {
543            value: "whoami".into(),
544            case_insensitive: false,
545        };
546        let e = empty_event();
547        let event = JsonEvent::borrow(&e);
548        assert!(m.matches(&EventValue::Str("whoami".into()), &event));
549        assert!(!m.matches(&EventValue::Str("WHOAMI".into()), &event));
550    }
551
552    #[test]
553    fn test_contains() {
554        let m = CompiledMatcher::Contains {
555            value: "admin".to_lowercase(),
556            case_insensitive: true,
557        };
558        let e = empty_event();
559        let event = JsonEvent::borrow(&e);
560        assert!(m.matches(&EventValue::Str("superadminuser".into()), &event));
561        assert!(m.matches(&EventValue::Str("ADMIN".into()), &event));
562        assert!(!m.matches(&EventValue::Str("user".into()), &event));
563    }
564
565    #[test]
566    fn test_starts_with() {
567        let m = CompiledMatcher::StartsWith {
568            value: "cmd".into(),
569            case_insensitive: true,
570        };
571        let e = empty_event();
572        let event = JsonEvent::borrow(&e);
573        assert!(m.matches(&EventValue::Str("cmd.exe".into()), &event));
574        assert!(m.matches(&EventValue::Str("CMD.EXE".into()), &event));
575        assert!(!m.matches(&EventValue::Str("xcmd".into()), &event));
576    }
577
578    #[test]
579    fn test_ends_with() {
580        let m = CompiledMatcher::EndsWith {
581            value: ".exe".into(),
582            case_insensitive: true,
583        };
584        let e = empty_event();
585        let event = JsonEvent::borrow(&e);
586        assert!(m.matches(&EventValue::Str("cmd.exe".into()), &event));
587        assert!(m.matches(&EventValue::Str("CMD.EXE".into()), &event));
588        assert!(!m.matches(&EventValue::Str("cmd.bat".into()), &event));
589    }
590
591    #[test]
592    fn test_regex() {
593        let re = Regex::new("(?i)^test.*value$").unwrap();
594        let m = CompiledMatcher::Regex(re);
595        let e = empty_event();
596        let event = JsonEvent::borrow(&e);
597        assert!(m.matches(&EventValue::Str("testXYZvalue".into()), &event));
598        assert!(m.matches(&EventValue::Str("TESTvalue".into()), &event));
599        assert!(!m.matches(&EventValue::Str("notamatch".into()), &event));
600    }
601
602    #[test]
603    fn test_cidr() {
604        let net: IpNet = "10.0.0.0/8".parse().unwrap();
605        let m = CompiledMatcher::Cidr(net);
606        let e = empty_event();
607        let event = JsonEvent::borrow(&e);
608        assert!(m.matches(&EventValue::Str("10.1.2.3".into()), &event));
609        assert!(!m.matches(&EventValue::Str("192.168.1.1".into()), &event));
610    }
611
612    #[test]
613    fn test_numeric() {
614        let m = CompiledMatcher::NumericGte(100.0);
615        let e = empty_event();
616        let event = JsonEvent::borrow(&e);
617        assert!(m.matches(&EventValue::Int(100), &event));
618        assert!(m.matches(&EventValue::Int(200), &event));
619        assert!(!m.matches(&EventValue::Int(50), &event));
620        assert!(m.matches(&EventValue::Str("150".into()), &event));
621    }
622
623    #[test]
624    fn test_null() {
625        let m = CompiledMatcher::Null;
626        let e = empty_event();
627        let event = JsonEvent::borrow(&e);
628        assert!(m.matches(&EventValue::Null, &event));
629        assert!(!m.matches(&EventValue::Str("".into()), &event));
630    }
631
632    #[test]
633    fn test_bool() {
634        let m = CompiledMatcher::BoolEq(true);
635        let e = empty_event();
636        let event = JsonEvent::borrow(&e);
637        assert!(m.matches(&EventValue::Bool(true), &event));
638        assert!(!m.matches(&EventValue::Bool(false), &event));
639        assert!(m.matches(&EventValue::Str("true".into()), &event));
640    }
641
642    #[test]
643    fn test_field_ref() {
644        let e = json!({"src": "10.0.0.1", "dst": "10.0.0.1"});
645        let event = JsonEvent::borrow(&e);
646        let m = CompiledMatcher::FieldRef {
647            field: "dst".into(),
648            case_insensitive: true,
649        };
650        assert!(m.matches(&EventValue::Str("10.0.0.1".into()), &event));
651    }
652
653    #[test]
654    fn test_any_of() {
655        let m = CompiledMatcher::AnyOf(vec![
656            CompiledMatcher::Exact {
657                value: "a".into(),
658                case_insensitive: false,
659            },
660            CompiledMatcher::Exact {
661                value: "b".into(),
662                case_insensitive: false,
663            },
664        ]);
665        let e = empty_event();
666        let event = JsonEvent::borrow(&e);
667        assert!(m.matches(&EventValue::Str("a".into()), &event));
668        assert!(m.matches(&EventValue::Str("b".into()), &event));
669        assert!(!m.matches(&EventValue::Str("c".into()), &event));
670    }
671
672    #[test]
673    fn test_all_of() {
674        let m = CompiledMatcher::AllOf(vec![
675            CompiledMatcher::Contains {
676                value: "admin".into(),
677                case_insensitive: false,
678            },
679            CompiledMatcher::Contains {
680                value: "user".into(),
681                case_insensitive: false,
682            },
683        ]);
684        let e = empty_event();
685        let event = JsonEvent::borrow(&e);
686        assert!(m.matches(&EventValue::Str("adminuser".into()), &event));
687        assert!(!m.matches(&EventValue::Str("admin".into()), &event));
688    }
689
690    #[test]
691    fn test_array_value_matching() {
692        let m = CompiledMatcher::Exact {
693            value: "target".into(),
694            case_insensitive: true,
695        };
696        let e = empty_event();
697        let event = JsonEvent::borrow(&e);
698        let arr = EventValue::Array(vec![
699            EventValue::Str("other".into()),
700            EventValue::Str("target".into()),
701            EventValue::Str("more".into()),
702        ]);
703        assert!(m.matches(&arr, &event));
704        let arr2 = EventValue::Array(vec![
705            EventValue::Str("other".into()),
706            EventValue::Str("nope".into()),
707        ]);
708        assert!(!m.matches(&arr2, &event));
709    }
710
711    #[test]
712    fn test_number_coercion_to_string() {
713        let m = CompiledMatcher::Exact {
714            value: "42".into(),
715            case_insensitive: false,
716        };
717        let e = empty_event();
718        let event = JsonEvent::borrow(&e);
719        assert!(m.matches(&EventValue::Int(42), &event));
720    }
721
722    // =========================================================================
723    // Unicode case folding tests
724    // =========================================================================
725
726    #[test]
727    fn test_exact_unicode_case_insensitive() {
728        let m = CompiledMatcher::Exact {
729            value: "ärzte".to_lowercase(),
730            case_insensitive: true,
731        };
732        let e = empty_event();
733        let event = JsonEvent::borrow(&e);
734        assert!(m.matches(&EventValue::Str("ÄRZTE".into()), &event));
735        assert!(m.matches(&EventValue::Str("Ärzte".into()), &event));
736        assert!(m.matches(&EventValue::Str("ärzte".into()), &event));
737    }
738
739    #[test]
740    fn test_contains_unicode_case_insensitive() {
741        let m = CompiledMatcher::Contains {
742            value: "ñ".to_lowercase(),
743            case_insensitive: true,
744        };
745        let e = empty_event();
746        let event = JsonEvent::borrow(&e);
747        assert!(m.matches(&EventValue::Str("España".into()), &event));
748        assert!(m.matches(&EventValue::Str("ESPAÑA".into()), &event));
749    }
750
751    #[test]
752    fn test_startswith_unicode_case_insensitive() {
753        let m = CompiledMatcher::StartsWith {
754            value: "über".to_lowercase(),
755            case_insensitive: true,
756        };
757        let e = empty_event();
758        let event = JsonEvent::borrow(&e);
759        assert!(m.matches(&EventValue::Str("Übersicht".into()), &event));
760        assert!(m.matches(&EventValue::Str("ÜBERSICHT".into()), &event));
761        assert!(!m.matches(&EventValue::Str("not-uber".into()), &event));
762    }
763
764    #[test]
765    fn test_endswith_unicode_case_insensitive() {
766        let m = CompiledMatcher::EndsWith {
767            value: "ção".to_lowercase(),
768            case_insensitive: true,
769        };
770        let e = empty_event();
771        let event = JsonEvent::borrow(&e);
772        assert!(m.matches(&EventValue::Str("Aplicação".into()), &event));
773        assert!(m.matches(&EventValue::Str("APLICAÇÃO".into()), &event));
774        assert!(!m.matches(&EventValue::Str("Aplicacao".into()), &event));
775    }
776
777    #[test]
778    fn test_greek_case_insensitive() {
779        let m = CompiledMatcher::Exact {
780            value: "σίγμα".to_lowercase(),
781            case_insensitive: true,
782        };
783        let e = empty_event();
784        let event = JsonEvent::borrow(&e);
785        assert!(m.matches(&EventValue::Str("ΣΊΓΜΑ".into()), &event));
786        assert!(m.matches(&EventValue::Str("σίγμα".into()), &event));
787    }
788
789    // =========================================================================
790    // Expand modifier tests
791    // =========================================================================
792
793    #[test]
794    fn test_parse_expand_template() {
795        let parts = parse_expand_template("C:\\Users\\%user%\\AppData");
796        assert_eq!(parts.len(), 3);
797        assert!(matches!(&parts[0], ExpandPart::Literal(s) if s == "C:\\Users\\"));
798        assert!(matches!(&parts[1], ExpandPart::Placeholder(s) if s == "user"));
799        assert!(matches!(&parts[2], ExpandPart::Literal(s) if s == "\\AppData"));
800    }
801
802    #[test]
803    fn test_parse_expand_template_no_placeholders() {
804        let parts = parse_expand_template("just a literal");
805        assert_eq!(parts.len(), 1);
806        assert!(matches!(&parts[0], ExpandPart::Literal(s) if s == "just a literal"));
807    }
808
809    #[test]
810    fn test_parse_expand_template_multiple_placeholders() {
811        let parts = parse_expand_template("%a%:%b%");
812        assert_eq!(parts.len(), 3);
813        assert!(matches!(&parts[0], ExpandPart::Placeholder(s) if s == "a"));
814        assert!(matches!(&parts[1], ExpandPart::Literal(s) if s == ":"));
815        assert!(matches!(&parts[2], ExpandPart::Placeholder(s) if s == "b"));
816    }
817
818    #[test]
819    fn test_expand_matcher() {
820        let template = parse_expand_template("C:\\Users\\%user%\\Downloads");
821        let m = CompiledMatcher::Expand {
822            template,
823            case_insensitive: true,
824        };
825        let e = json!({"user": "admin", "path": "C:\\Users\\admin\\Downloads"});
826        let event = JsonEvent::borrow(&e);
827        assert!(m.matches(
828            &EventValue::Str("C:\\Users\\admin\\Downloads".into()),
829            &event
830        ));
831        assert!(!m.matches(
832            &EventValue::Str("C:\\Users\\other\\Downloads".into()),
833            &event
834        ));
835    }
836
837    #[test]
838    fn test_expand_matcher_missing_field() {
839        let template = parse_expand_template("%user%@%domain%");
840        let m = CompiledMatcher::Expand {
841            template,
842            case_insensitive: false,
843        };
844        let e = json!({"user": "admin"});
845        let event = JsonEvent::borrow(&e);
846        assert!(m.matches(&EventValue::Str("admin@".into()), &event));
847    }
848
849    // =========================================================================
850    // Timestamp part tests
851    // =========================================================================
852
853    #[test]
854    fn test_timestamp_part_hour() {
855        let m = CompiledMatcher::TimestampPart {
856            part: TimePart::Hour,
857            inner: Box::new(CompiledMatcher::NumericEq(12.0)),
858        };
859        let e = json!({});
860        let event = JsonEvent::borrow(&e);
861        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
862        assert!(!m.matches(&EventValue::Str("2024-07-10T15:30:00Z".into()), &event));
863    }
864
865    #[test]
866    fn test_timestamp_part_month() {
867        let m = CompiledMatcher::TimestampPart {
868            part: TimePart::Month,
869            inner: Box::new(CompiledMatcher::NumericEq(7.0)),
870        };
871        let e = json!({});
872        let event = JsonEvent::borrow(&e);
873        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
874        assert!(!m.matches(&EventValue::Str("2024-08-10T12:30:00Z".into()), &event));
875    }
876
877    #[test]
878    fn test_timestamp_part_day() {
879        let m = CompiledMatcher::TimestampPart {
880            part: TimePart::Day,
881            inner: Box::new(CompiledMatcher::NumericEq(10.0)),
882        };
883        let e = json!({});
884        let event = JsonEvent::borrow(&e);
885        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
886        assert!(!m.matches(&EventValue::Str("2024-07-15T12:30:00Z".into()), &event));
887    }
888
889    #[test]
890    fn test_timestamp_part_year() {
891        let m = CompiledMatcher::TimestampPart {
892            part: TimePart::Year,
893            inner: Box::new(CompiledMatcher::NumericEq(2024.0)),
894        };
895        let e = json!({});
896        let event = JsonEvent::borrow(&e);
897        assert!(m.matches(&EventValue::Str("2024-07-10T12:30:00Z".into()), &event));
898        assert!(!m.matches(&EventValue::Str("2023-07-10T12:30:00Z".into()), &event));
899    }
900
901    #[test]
902    fn test_timestamp_part_from_epoch() {
903        let m = CompiledMatcher::TimestampPart {
904            part: TimePart::Hour,
905            inner: Box::new(CompiledMatcher::NumericEq(12.0)),
906        };
907        let e = json!({});
908        let event = JsonEvent::borrow(&e);
909        // 2024-07-10T12:30:00Z = 1720614600
910        assert!(m.matches(&EventValue::Int(1720614600), &event));
911    }
912}
913
914// =============================================================================
915// Property-based tests
916// =============================================================================
917
918#[cfg(test)]
919mod proptests {
920    use super::*;
921    use crate::event::JsonEvent;
922    use proptest::prelude::*;
923    use rsigma_parser::value::{SpecialChar, StringPart};
924    use serde_json::json;
925
926    fn arb_string_parts() -> impl Strategy<Value = Vec<StringPart>> {
927        prop::collection::vec(
928            prop_oneof![
929                "[[:print:]]{0,20}".prop_map(StringPart::Plain),
930                Just(StringPart::Special(SpecialChar::WildcardMulti)),
931                Just(StringPart::Special(SpecialChar::WildcardSingle)),
932            ],
933            0..8,
934        )
935    }
936
937    proptest! {
938        #[test]
939        fn wildcard_regex_always_valid(parts in arb_string_parts(), ci in any::<bool>()) {
940            let pattern = sigma_string_to_regex(&parts, ci);
941            prop_assert!(regex::Regex::new(&pattern).is_ok(),
942                "sigma_string_to_regex produced invalid regex: {}", pattern);
943        }
944    }
945
946    proptest! {
947        #[test]
948        fn plain_text_matches_itself(text in "[[:print:]]{1,30}") {
949            let parts = vec![StringPart::Plain(text.clone())];
950            let pattern = sigma_string_to_regex(&parts, false);
951            let re = regex::Regex::new(&pattern).unwrap();
952            prop_assert!(re.is_match(&text),
953                "plain text should match itself: text={:?}, pattern={}", text, pattern);
954        }
955    }
956
957    proptest! {
958        #[test]
959        fn plain_text_rejects_different_string(
960            text in "[a-zA-Z0-9]{1,10}",
961            other in "[a-zA-Z0-9]{1,10}",
962        ) {
963            prop_assume!(text != other);
964            let parts = vec![StringPart::Plain(text.clone())];
965            let pattern = sigma_string_to_regex(&parts, false);
966            let re = regex::Regex::new(&pattern).unwrap();
967            prop_assert!(!re.is_match(&other),
968                "plain {:?} should not match {:?}", text, other);
969        }
970    }
971
972    proptest! {
973        #[test]
974        fn exact_ci_symmetric(s in "[[:alpha:]]{1,20}") {
975            let m = CompiledMatcher::Exact {
976                value: s.to_lowercase(),
977                case_insensitive: true,
978            };
979            let e = json!({});
980            let event = JsonEvent::borrow(&e);
981            let upper = EventValue::Str(s.to_uppercase().into());
982            let lower = EventValue::Str(s.to_lowercase().into());
983            prop_assert!(m.matches(&upper, &event),
984                "CI exact should match uppercase: {:?}", s.to_uppercase());
985            prop_assert!(m.matches(&lower, &event),
986                "CI exact should match lowercase: {:?}", s.to_lowercase());
987        }
988    }
989
990    proptest! {
991        #[test]
992        fn contains_agrees_with_stdlib(
993            haystack in "[[:print:]]{0,30}",
994            needle in "[[:print:]]{1,10}",
995        ) {
996            let expected = haystack.contains(&needle);
997            let m = CompiledMatcher::Contains {
998                value: needle.clone(),
999                case_insensitive: false,
1000            };
1001            let e = json!({});
1002            let event = JsonEvent::borrow(&e);
1003            let val = EventValue::Str(haystack.clone().into());
1004            prop_assert_eq!(m.matches(&val, &event), expected,
1005                "Contains({:?}) on {:?}", needle, haystack);
1006        }
1007    }
1008
1009    proptest! {
1010        #[test]
1011        fn startswith_agrees_with_stdlib(
1012            haystack in "[[:print:]]{0,30}",
1013            prefix in "[[:print:]]{1,10}",
1014        ) {
1015            let expected = haystack.starts_with(&prefix);
1016            let m = CompiledMatcher::StartsWith {
1017                value: prefix.clone(),
1018                case_insensitive: false,
1019            };
1020            let e = json!({});
1021            let event = JsonEvent::borrow(&e);
1022            let val = EventValue::Str(haystack.clone().into());
1023            prop_assert_eq!(m.matches(&val, &event), expected,
1024                "StartsWith({:?}) on {:?}", prefix, haystack);
1025        }
1026    }
1027
1028    proptest! {
1029        #[test]
1030        fn endswith_agrees_with_stdlib(
1031            haystack in "[[:print:]]{0,30}",
1032            suffix in "[[:print:]]{1,10}",
1033        ) {
1034            let expected = haystack.ends_with(&suffix);
1035            let m = CompiledMatcher::EndsWith {
1036                value: suffix.clone(),
1037                case_insensitive: false,
1038            };
1039            let e = json!({});
1040            let event = JsonEvent::borrow(&e);
1041            let val = EventValue::Str(haystack.clone().into());
1042            prop_assert_eq!(m.matches(&val, &event), expected,
1043                "EndsWith({:?}) on {:?}", suffix, haystack);
1044        }
1045    }
1046
1047    proptest! {
1048        #[test]
1049        fn ci_contains_agrees_with_lowercased(
1050            haystack in "[[:alpha:]]{0,20}",
1051            needle in "[[:alpha:]]{1,8}",
1052        ) {
1053            let expected = haystack.to_lowercase().contains(&needle.to_lowercase());
1054            let m = CompiledMatcher::Contains {
1055                value: needle.to_lowercase(),
1056                case_insensitive: true,
1057            };
1058            let e = json!({});
1059            let event = JsonEvent::borrow(&e);
1060            let val = EventValue::Str(haystack.clone().into());
1061            prop_assert_eq!(m.matches(&val, &event), expected,
1062                "CI Contains({:?}) on {:?}", needle, haystack);
1063        }
1064
1065        #[test]
1066        fn ci_startswith_agrees_with_lowercased(
1067            haystack in "[[:alpha:]]{0,20}",
1068            prefix in "[[:alpha:]]{1,8}",
1069        ) {
1070            let expected = haystack.to_lowercase().starts_with(&prefix.to_lowercase());
1071            let m = CompiledMatcher::StartsWith {
1072                value: prefix.to_lowercase(),
1073                case_insensitive: true,
1074            };
1075            let e = json!({});
1076            let event = JsonEvent::borrow(&e);
1077            let val = EventValue::Str(haystack.clone().into());
1078            prop_assert_eq!(m.matches(&val, &event), expected,
1079                "CI StartsWith({:?}) on {:?}", prefix, haystack);
1080        }
1081
1082        #[test]
1083        fn ci_endswith_agrees_with_lowercased(
1084            haystack in "[[:alpha:]]{0,20}",
1085            suffix in "[[:alpha:]]{1,8}",
1086        ) {
1087            let expected = haystack.to_lowercase().ends_with(&suffix.to_lowercase());
1088            let m = CompiledMatcher::EndsWith {
1089                value: suffix.to_lowercase(),
1090                case_insensitive: true,
1091            };
1092            let e = json!({});
1093            let event = JsonEvent::borrow(&e);
1094            let val = EventValue::Str(haystack.clone().into());
1095            prop_assert_eq!(m.matches(&val, &event), expected,
1096                "CI EndsWith({:?}) on {:?}", suffix, haystack);
1097        }
1098    }
1099
1100    proptest! {
1101        #[test]
1102        fn wildcard_star_matches_anything(s in "[[:print:]]{0,30}") {
1103            let parts = vec![StringPart::Special(SpecialChar::WildcardMulti)];
1104            let pattern = sigma_string_to_regex(&parts, false);
1105            let re = regex::Regex::new(&pattern).unwrap();
1106            prop_assert!(re.is_match(&s), "* should match any string: {:?}", s);
1107        }
1108
1109        #[test]
1110        fn wildcard_question_matches_single_char(c in proptest::char::range('!', '~')) {
1111            let parts = vec![StringPart::Special(SpecialChar::WildcardSingle)];
1112            let pattern = sigma_string_to_regex(&parts, false);
1113            let re = regex::Regex::new(&pattern).unwrap();
1114            let s = c.to_string();
1115            prop_assert!(re.is_match(&s), "? should match single char: {:?}", s);
1116        }
1117    }
1118}