1use crate::error::InvalidError;
2use crate::limits::MAX_FILTER_STRING_BYTES;
3use regex::{Regex, RegexBuilder};
4use serde::{Deserialize, Serialize};
5
6pub const MAX_REGEX_PREDICATES: usize = 4;
12const 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct TextPredicate {
26 pub field: String,
28 pub kind: TextMatch,
29 pub pattern: String,
30 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
32 pub case_insensitive: bool,
33}
34
35#[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 Equals,
53 Prefix,
55 Suffix,
57 Contains,
59 Glob,
62 Regex,
70}
71
72#[derive(Clone, Debug)]
74pub struct TextMatcher {
75 kind: TextMatch,
76 source: String,
78 case_insensitive: bool,
79 program: Program,
80}
81
82#[derive(Clone, Debug)]
85enum Program {
86 Plain(String),
87 Compiled(Regex),
88}
89
90impl TextPredicate {
91 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 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
165fn 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(®ex::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
198fn 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#[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#[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}