Skip to main content

laser_wire/filter/
text.rs

1use crate::error::InvalidError;
2use crate::limits::MAX_FILTER_STRING_BYTES;
3use regex::{Regex, RegexBuilder};
4use serde::{Deserialize, Serialize};
5
6/// Compiled text predicates (regex and glob) one filter may hold. With the
7/// program bound below, one compiled filter stays under 2 MiB of regex
8/// programs, and the per-shard cache of compiled filters stays bounded with
9/// it. Search time is linear in the value for each pattern, so a record costs
10/// at most this many passes over its text fields.
11pub const MAX_REGEX_PREDICATES: usize = 4;
12/// Compiled program bound for one regex or glob, NFA and lazy DFA each. This
13/// bounds memory. Time stays linear in the searched text because the engine
14/// never backtracks.
15const REGEX_SIZE_LIMIT: usize = 256 << 10;
16const REGEX_NEST_LIMIT: u32 = 64;
17const GLOB_ANY_RUN: char = '*';
18const GLOB_ANY_ONE: char = '?';
19const GLOB_ESCAPE: char = '\\';
20
21/// A text match against one payload field or one header. The value must be
22/// text: a missing value is unknown, and a value of another type is a type
23/// mismatch, which follows the filter's mismatch policy.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct TextPredicate {
26    /// A payload field path, or a header key for [`FilterExpr::HeaderText`](crate::filter::FilterExpr::HeaderText).
27    pub field: String,
28    pub kind: TextMatch,
29    pub pattern: String,
30    /// Regex uses the engine's Unicode folding. Other kinds lowercase both sides.
31    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
32    pub case_insensitive: bool,
33}
34
35/// How a [`TextPredicate`] matches, from cheapest to most expensive.
36#[derive(
37    Clone,
38    Copy,
39    Debug,
40    PartialEq,
41    Eq,
42    Serialize,
43    Deserialize,
44    strum::Display,
45    strum::EnumString,
46    strum::VariantArray,
47)]
48#[serde(rename_all = "snake_case")]
49#[strum(serialize_all = "snake_case")]
50pub enum TextMatch {
51    /// The whole value equals the pattern.
52    Equals,
53    /// The value starts with the pattern.
54    Prefix,
55    /// The value ends with the pattern.
56    Suffix,
57    /// The pattern occurs anywhere in the value.
58    Contains,
59    /// The whole value matches a glob: `*` is any run of characters, `?` is
60    /// one character, and `\` escapes the next character.
61    Glob,
62    /// The value contains a match of a regular expression, run by the
63    /// server's Rust engine in time linear in the value. Lookaround,
64    /// backreferences, and inline flags are refused. `\d`, `\w`, `\s`, and
65    /// `\b` are Unicode-aware, and case-insensitive matching uses the
66    /// engine's simple case folding, where the other kinds lowercase both
67    /// sides. The server's verdict is the contract, and the Rust, Python, and
68    /// TypeScript local evaluators follow the same rules.
69    Regex,
70}
71
72/// A validated [`TextPredicate`] ready to run per record.
73#[derive(Clone, Debug)]
74pub struct TextMatcher {
75    kind: TextMatch,
76    /// The pattern as written, for explanations.
77    source: String,
78    case_insensitive: bool,
79    program: Program,
80}
81
82/// What one evaluation runs: a plain comparison against the pattern, lowercased
83/// when case-insensitive, or a compiled regex for the `glob` and `regex` kinds.
84#[derive(Clone, Debug)]
85enum Program {
86    Plain(String),
87    Compiled(Regex),
88}
89
90impl TextPredicate {
91    /// Compiling is the validation, so a predicate is checked exactly once.
92    pub fn validate(&self) -> Result<(), InvalidError> {
93        TextMatcher::compile(self).map(|_| ())
94    }
95}
96
97impl TextMatcher {
98    pub fn compile(predicate: &TextPredicate) -> Result<Self, InvalidError> {
99        if predicate.pattern.len() > MAX_FILTER_STRING_BYTES {
100            return Err(InvalidError::new(format!(
101                "a text pattern exceeds {MAX_FILTER_STRING_BYTES}B"
102            )));
103        }
104        let program = match predicate.kind {
105            TextMatch::Regex => Program::Compiled(compile_regex(
106                &predicate.pattern,
107                predicate.case_insensitive,
108            )?),
109            TextMatch::Glob => Program::Compiled(compile_glob(
110                &predicate.pattern,
111                predicate.case_insensitive,
112            )?),
113            _ if predicate.case_insensitive => Program::Plain(predicate.pattern.to_lowercase()),
114            _ => Program::Plain(predicate.pattern.clone()),
115        };
116        Ok(Self {
117            kind: predicate.kind,
118            source: predicate.pattern.clone(),
119            case_insensitive: predicate.case_insensitive,
120            program,
121        })
122    }
123
124    pub fn matches(&self, value: &str) -> bool {
125        let pattern = match &self.program {
126            Program::Compiled(regex) => {
127                return if self.kind == TextMatch::Glob && self.case_insensitive {
128                    regex.is_match(&value.to_lowercase())
129                } else {
130                    regex.is_match(value)
131                };
132            }
133            Program::Plain(pattern) => pattern.as_str(),
134        };
135        let folded;
136        let value = if self.case_insensitive {
137            folded = value.to_lowercase();
138            folded.as_str()
139        } else {
140            value
141        };
142        match self.kind {
143            TextMatch::Equals => value == pattern,
144            TextMatch::Prefix => value.starts_with(pattern),
145            TextMatch::Suffix => value.ends_with(pattern),
146            TextMatch::Contains => value.contains(pattern),
147            TextMatch::Glob | TextMatch::Regex => false,
148        }
149    }
150
151    pub fn kind(&self) -> TextMatch {
152        self.kind
153    }
154
155    /// The pattern as written, with its case mode, for explanations.
156    pub fn label(&self) -> String {
157        if self.case_insensitive {
158            format!("{:?} ignoring case", self.source)
159        } else {
160            format!("{:?}", self.source)
161        }
162    }
163}
164
165// A glob compiles to an anchored regex, so it runs in linear time like every
166// other kind. Case-insensitive globs lowercase their literals, and `matches`
167// lowercases the value, the same rule the plain kinds follow.
168fn compile_glob(pattern: &str, case_insensitive: bool) -> Result<Regex, InvalidError> {
169    let tokens = glob_tokens(pattern).ok_or_else(|| {
170        InvalidError::new("a glob pattern cannot end with an unescaped backslash")
171    })?;
172    let mut expression = String::from("\\A(?:");
173    for token in tokens {
174        match token {
175            GlobToken::AnyRun => expression.push_str(".*"),
176            GlobToken::AnyOne => expression.push('.'),
177            GlobToken::Literal(character) => {
178                let literal = character.to_string();
179                let literal = if case_insensitive {
180                    literal.to_lowercase()
181                } else {
182                    literal
183                };
184                expression.push_str(&regex::escape(&literal));
185            }
186        }
187    }
188    expression.push_str(")\\z");
189    RegexBuilder::new(&expression)
190        .dot_matches_new_line(true)
191        .size_limit(REGEX_SIZE_LIMIT)
192        .dfa_size_limit(REGEX_SIZE_LIMIT)
193        .nest_limit(REGEX_NEST_LIMIT)
194        .build()
195        .map_err(|error| InvalidError::new(format!("invalid glob: {error}")))
196}
197
198// Inline flags, lookaround, and backreferences have no shared meaning across
199// the SDK regex engines, so they are refused before the engine sees them.
200fn compile_regex(pattern: &str, case_insensitive: bool) -> Result<Regex, InvalidError> {
201    if let Some(construct) = unportable_regex_construct(pattern) {
202        return Err(InvalidError::new(format!(
203            "regex {construct} is not supported, write the pattern without it"
204        )));
205    }
206    RegexBuilder::new(pattern)
207        .case_insensitive(case_insensitive)
208        .size_limit(REGEX_SIZE_LIMIT)
209        .dfa_size_limit(REGEX_SIZE_LIMIT)
210        .nest_limit(REGEX_NEST_LIMIT)
211        .build()
212        .map_err(|error| InvalidError::new(format!("invalid regex: {error}")))
213}
214
215fn unportable_regex_construct(pattern: &str) -> Option<&'static str> {
216    let mut characters = pattern.chars().peekable();
217    let mut in_class = false;
218    while let Some(character) = characters.next() {
219        match character {
220            '\\' => match characters.next() {
221                Some('1'..='9') => return Some("backreferences"),
222                Some('k') => return Some("named backreferences"),
223                _ => {}
224            },
225            '[' => in_class = true,
226            ']' => in_class = false,
227            '(' if !in_class && characters.peek() == Some(&'?') => {
228                characters.next();
229                if characters.peek() != Some(&':') {
230                    return Some("groups starting with (? other than (?:");
231                }
232            }
233            _ => {}
234        }
235    }
236    None
237}
238
239/// One glob element, after escapes are resolved.
240#[derive(Clone, Copy, PartialEq, Eq)]
241enum GlobToken {
242    AnyRun,
243    AnyOne,
244    Literal(char),
245}
246
247fn glob_tokens(pattern: &str) -> Option<Vec<GlobToken>> {
248    let mut tokens = Vec::with_capacity(pattern.len());
249    let mut characters = pattern.chars();
250    while let Some(character) = characters.next() {
251        tokens.push(match character {
252            GLOB_ANY_RUN => GlobToken::AnyRun,
253            GLOB_ANY_ONE => GlobToken::AnyOne,
254            GLOB_ESCAPE => GlobToken::Literal(characters.next()?),
255            literal => GlobToken::Literal(literal),
256        });
257    }
258    Some(tokens)
259}
260
261// The classic two-pointer match: on a mismatch after a `*`, the star absorbs
262// one more character and matching resumes, which can repeat prefix comparisons on adversarial inputs.
263#[cfg(test)]
264fn glob_matches(pattern: &str, value: &str) -> bool {
265    let Some(tokens) = glob_tokens(pattern) else {
266        return false;
267    };
268    let text: Vec<char> = value.chars().collect();
269    let (mut token, mut position) = (0, 0);
270    let mut resume: Option<(usize, usize)> = None;
271    while position < text.len() {
272        match tokens.get(token) {
273            Some(GlobToken::AnyRun) => {
274                resume = Some((token, position));
275                token += 1;
276            }
277            Some(GlobToken::AnyOne) => {
278                token += 1;
279                position += 1;
280            }
281            Some(GlobToken::Literal(literal)) if *literal == text[position] => {
282                token += 1;
283                position += 1;
284            }
285            _ => match resume {
286                Some((star, absorbed)) => {
287                    token = star + 1;
288                    position = absorbed + 1;
289                    resume = Some((star, absorbed + 1));
290                }
291                None => return false,
292            },
293        }
294    }
295    tokens[token..]
296        .iter()
297        .all(|token| *token == GlobToken::AnyRun)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn matcher(kind: TextMatch, pattern: &str, case_insensitive: bool) -> TextMatcher {
305        TextMatcher::compile(&TextPredicate {
306            field: "type".to_owned(),
307            kind,
308            pattern: pattern.to_owned(),
309            case_insensitive,
310        })
311        .expect("the pattern compiles")
312    }
313
314    #[test]
315    fn given_each_kind_when_matching_event_types_then_should_follow_its_rule() {
316        let value = "metrics.cpu.v1.reported";
317        assert!(matcher(TextMatch::Equals, value, false).matches(value));
318        assert!(matcher(TextMatch::Prefix, "metrics.", false).matches(value));
319        assert!(matcher(TextMatch::Suffix, ".reported", false).matches(value));
320        assert!(matcher(TextMatch::Contains, ".v1.", false).matches(value));
321        assert!(matcher(TextMatch::Glob, "metrics.*.v?.reported", false).matches(value));
322        assert!(matcher(TextMatch::Regex, r"^metrics\.[a-z]+\.v[0-9]+\.", false).matches(value));
323        assert!(!matcher(TextMatch::Suffix, ".deleted", false).matches(value));
324        assert!(!matcher(TextMatch::Glob, "metrics.*", false).matches("storage.metrics.x"));
325    }
326
327    #[test]
328    fn given_case_insensitive_matching_when_the_case_differs_then_should_still_match() {
329        assert!(matcher(TextMatch::Equals, "Safe", true).matches("SAFE"));
330        assert!(matcher(TextMatch::Contains, "CPU", true).matches("metrics.cpu.v1"));
331        assert!(matcher(TextMatch::Glob, "METRICS.*", true).matches("metrics.x"));
332        assert!(matcher(TextMatch::Regex, "^metrics", true).matches("METRICS.x"));
333        assert!(!matcher(TextMatch::Equals, "Safe", false).matches("SAFE"));
334    }
335
336    #[test]
337    fn given_globs_with_escapes_and_stars_when_matching_then_should_match_the_whole_value() {
338        assert!(matcher(TextMatch::Glob, r"a\*b", false).matches("a*b"));
339        assert!(!matcher(TextMatch::Glob, r"a\*b", false).matches("axb"));
340        assert!(matcher(TextMatch::Glob, "*", false).matches(""));
341        assert!(matcher(TextMatch::Glob, "a*b*c", false).matches("aXXbYYbZc"));
342        assert!(!matcher(TextMatch::Glob, "a*b*c", false).matches("aXXbYY"));
343        assert!(matcher(TextMatch::Glob, "?", false).matches("ł"));
344    }
345
346    #[test]
347    fn given_glob_patterns_when_compiled_then_should_match_the_independent_original_oracle() {
348        for pattern in [
349            "", "*", "**", "?", "*?", "a*b*c", "*aaab", "a**b", r"a\*b", r"a\?b", r"a\\b", "*ł?",
350            "*\n*", "[a]", "(?x)",
351        ] {
352            let compiled = matcher(TextMatch::Glob, pattern, false);
353            for value in [
354                "",
355                "a",
356                "ab",
357                "ac",
358                "aaab",
359                "aaaaac",
360                "a*b",
361                "a?b",
362                r"a\b",
363                "aXXbYYbZc",
364                "ł🙂",
365                "aaał🙂",
366                "a\nb",
367                "[a]",
368                "(?x)",
369            ] {
370                assert_eq!(
371                    compiled.matches(value),
372                    glob_matches(pattern, value),
373                    "{pattern:?} {value:?}"
374                );
375            }
376        }
377        assert!(matcher(TextMatch::Glob, "İ?", true).matches("İx"));
378        assert_eq!(matcher(TextMatch::Glob, "a*", false).label(), "\"a*\"");
379    }
380
381    #[test]
382    fn given_a_long_repeated_prefix_when_matching_a_glob_then_should_reject_a_missing_suffix() {
383        let pattern = format!("*{}b", "a".repeat(256));
384        let value = "a".repeat(65_536);
385        assert!(!matcher(TextMatch::Glob, &pattern, false).matches(&value));
386    }
387
388    #[test]
389    fn given_unportable_or_broken_patterns_when_validated_then_should_refuse_them() {
390        for (kind, pattern) in [
391            (TextMatch::Regex, r"(a)\1"),
392            (TextMatch::Regex, "(?=a)"),
393            (TextMatch::Regex, "(?i)a"),
394            (TextMatch::Regex, "(?P<name>a)"),
395            (TextMatch::Regex, "("),
396            (TextMatch::Glob, "a\\"),
397        ] {
398            let predicate = TextPredicate {
399                field: "type".to_owned(),
400                kind,
401                pattern: pattern.to_owned(),
402                case_insensitive: false,
403            };
404            assert!(predicate.validate().is_err(), "{pattern}");
405        }
406        assert!(matcher(TextMatch::Regex, "(?:a|b)[(?]", false).matches("b?"));
407    }
408}