1use std::sync::Arc;
32
33use super::ast::{RuleCondition, RuleFormula};
34
35pub trait RuleEvaluationContext {
40 fn pattern_count(&self, _pattern_id: u32) -> u32 {
44 0
45 }
46
47 fn file_size(&self) -> u64 {
50 0
51 }
52
53 fn field_value(&self, _name: &str) -> Option<&str> {
62 None
63 }
64}
65
66#[must_use]
69pub fn evaluate_formula<C: RuleEvaluationContext + ?Sized>(formula: &RuleFormula, ctx: &C) -> bool {
70 match formula {
71 RuleFormula::Condition(cond) => evaluate_condition(cond, ctx),
72 RuleFormula::And(left, right) => {
73 evaluate_formula(left, ctx) && evaluate_formula(right, ctx)
75 }
76 RuleFormula::Or(left, right) => evaluate_formula(left, ctx) || evaluate_formula(right, ctx),
77 RuleFormula::Not(inner) => !evaluate_formula(inner, ctx),
78 }
79}
80
81#[must_use]
97pub fn evaluate_condition<C: RuleEvaluationContext + ?Sized>(
98 condition: &RuleCondition,
99 ctx: &C,
100) -> bool {
101 match condition {
102 RuleCondition::PatternExists { pattern_id } => ctx.pattern_count(*pattern_id) > 0,
103 RuleCondition::PatternCountGt {
104 pattern_id,
105 threshold,
106 } => ctx.pattern_count(*pattern_id) > *threshold,
107 RuleCondition::PatternCountGte {
108 pattern_id,
109 threshold,
110 } => ctx.pattern_count(*pattern_id) >= *threshold,
111 RuleCondition::FileSizeLt(t) => ctx.file_size() < *t,
112 RuleCondition::FileSizeLte(t) => ctx.file_size() <= *t,
113 RuleCondition::FileSizeGt(t) => ctx.file_size() > *t,
114 RuleCondition::FileSizeGte(t) => ctx.file_size() >= *t,
115 RuleCondition::FileSizeEq(t) => ctx.file_size() == *t,
116 RuleCondition::FileSizeNe(t) => ctx.file_size() != *t,
117 RuleCondition::LiteralTrue => true,
118 RuleCondition::LiteralFalse => false,
119 RuleCondition::RegexMatch { field, pattern } => {
120 let Some(value) = ctx.field_value(field.as_ref()) else {
124 return false;
125 };
126 use std::collections::HashMap;
127 use std::sync::LazyLock;
128 use std::sync::Mutex;
129 static REGEX_CACHE: LazyLock<Mutex<HashMap<String, regex::Regex>>> =
130 LazyLock::new(|| Mutex::new(HashMap::new()));
131 let Ok(cache) = REGEX_CACHE.lock() else {
132 return false;
133 };
134 let re = cache.get(pattern.as_ref()).cloned();
135 drop(cache);
136 match re {
137 Some(re) => re.is_match(value),
138 None => match regex::Regex::new(pattern.as_ref()) {
139 Ok(re) => {
140 let Ok(mut cache) = REGEX_CACHE.lock() else {
141 return false;
142 };
143 cache.insert(pattern.to_string(), re.clone());
144 re.is_match(value)
145 }
146 Err(_) => false,
147 },
148 }
149 }
150 RuleCondition::SubstringMatch { haystack, needle } => ctx
151 .field_value(haystack.as_ref())
152 .map(|h| h.contains(needle.as_ref()))
153 .unwrap_or(false),
154 RuleCondition::PrefixMatch { value, prefix } => ctx
155 .field_value(value.as_ref())
156 .map(|v| v.starts_with(prefix.as_ref()))
157 .unwrap_or(false),
158 RuleCondition::SuffixMatch { value, suffix } => ctx
159 .field_value(value.as_ref())
160 .map(|v| v.ends_with(suffix.as_ref()))
161 .unwrap_or(false),
162 RuleCondition::RangeMatch { value, min, max } => *value >= *min && *value <= *max,
163 RuleCondition::SetMembership { value, set } => {
164 set.iter().map(Arc::as_ref).any(|m| m == value.as_ref())
165 }
166 RuleCondition::FieldInSet { field, set } => {
167 let Some(value) = ctx.field_value(field.as_ref()) else {
168 return false;
169 };
170 set.iter().map(Arc::as_ref).any(|m| m == value)
171 }
172 RuleCondition::Opaque(ext) => {
173 ext.evaluate_opaque(&() as &dyn std::any::Any)
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 struct StaticCtx<'a> {
191 counts: &'a [(u32, u32)],
192 size: u64,
193 fields: &'a [(&'a str, &'a str)],
194 }
195
196 impl<'a> RuleEvaluationContext for StaticCtx<'a> {
197 fn pattern_count(&self, pid: u32) -> u32 {
198 self.counts
199 .iter()
200 .find(|(p, _)| *p == pid)
201 .map(|(_, c)| *c)
202 .unwrap_or(0)
203 }
204 fn file_size(&self) -> u64 {
205 self.size
206 }
207 fn field_value(&self, name: &str) -> Option<&str> {
208 self.fields
209 .iter()
210 .find(|(n, _)| *n == name)
211 .map(|(_, v)| *v)
212 }
213 }
214
215 fn empty_ctx() -> StaticCtx<'static> {
216 StaticCtx {
217 counts: &[],
218 size: 0,
219 fields: &[],
220 }
221 }
222
223 #[test]
224 fn literal_true_and_false() {
225 assert!(evaluate_condition(
226 &RuleCondition::LiteralTrue,
227 &empty_ctx()
228 ));
229 assert!(!evaluate_condition(
230 &RuleCondition::LiteralFalse,
231 &empty_ctx()
232 ));
233 }
234
235 #[test]
236 fn pattern_exists_uses_count() {
237 let ctx = StaticCtx {
238 counts: &[(7, 3)],
239 size: 0,
240 fields: &[],
241 };
242 assert!(evaluate_condition(
243 &RuleCondition::PatternExists { pattern_id: 7 },
244 &ctx
245 ));
246 assert!(!evaluate_condition(
247 &RuleCondition::PatternExists { pattern_id: 8 },
248 &ctx
249 ));
250 }
251
252 #[test]
253 fn pattern_count_gt_gte() {
254 let ctx = StaticCtx {
255 counts: &[(1, 5)],
256 size: 0,
257 fields: &[],
258 };
259 assert!(evaluate_condition(
260 &RuleCondition::PatternCountGt {
261 pattern_id: 1,
262 threshold: 4,
263 },
264 &ctx
265 ));
266 assert!(!evaluate_condition(
267 &RuleCondition::PatternCountGt {
268 pattern_id: 1,
269 threshold: 5,
270 },
271 &ctx
272 ));
273 assert!(evaluate_condition(
274 &RuleCondition::PatternCountGte {
275 pattern_id: 1,
276 threshold: 5,
277 },
278 &ctx
279 ));
280 assert!(!evaluate_condition(
281 &RuleCondition::PatternCountGte {
282 pattern_id: 1,
283 threshold: 6,
284 },
285 &ctx
286 ));
287 }
288
289 #[test]
290 fn file_size_predicates() {
291 let ctx = StaticCtx {
292 counts: &[],
293 size: 100,
294 fields: &[],
295 };
296 assert!(evaluate_condition(&RuleCondition::FileSizeLt(101), &ctx));
297 assert!(!evaluate_condition(&RuleCondition::FileSizeLt(100), &ctx));
298 assert!(evaluate_condition(&RuleCondition::FileSizeLte(100), &ctx));
299 assert!(evaluate_condition(&RuleCondition::FileSizeGt(99), &ctx));
300 assert!(evaluate_condition(&RuleCondition::FileSizeGte(100), &ctx));
301 assert!(evaluate_condition(&RuleCondition::FileSizeEq(100), &ctx));
302 assert!(evaluate_condition(&RuleCondition::FileSizeNe(99), &ctx));
303 assert!(!evaluate_condition(&RuleCondition::FileSizeNe(100), &ctx));
304 }
305
306 #[test]
307 fn substring_prefix_suffix() {
308 let ctx = StaticCtx {
309 counts: &[],
310 size: 0,
311 fields: &[("path", "src/foo/bar.rs")],
312 };
313 assert!(evaluate_condition(
314 &RuleCondition::SubstringMatch {
315 haystack: "path".into(),
316 needle: "/foo/".into(),
317 },
318 &ctx
319 ));
320 assert!(evaluate_condition(
321 &RuleCondition::PrefixMatch {
322 value: "path".into(),
323 prefix: "src/".into(),
324 },
325 &ctx
326 ));
327 assert!(evaluate_condition(
328 &RuleCondition::SuffixMatch {
329 value: "path".into(),
330 suffix: ".rs".into(),
331 },
332 &ctx
333 ));
334 assert!(!evaluate_condition(
335 &RuleCondition::SuffixMatch {
336 value: "path".into(),
337 suffix: ".py".into(),
338 },
339 &ctx
340 ));
341 assert!(!evaluate_condition(
342 &RuleCondition::SubstringMatch {
343 haystack: "missing".into(),
344 needle: "x".into(),
345 },
346 &ctx
347 ));
348 }
349
350 #[test]
351 fn range_match_inclusive() {
352 let cond = RuleCondition::RangeMatch {
353 value: 50,
354 min: 10,
355 max: 100,
356 };
357 assert!(evaluate_condition(&cond, &empty_ctx()));
358 let cond = RuleCondition::RangeMatch {
359 value: 5,
360 min: 10,
361 max: 100,
362 };
363 assert!(!evaluate_condition(&cond, &empty_ctx()));
364 }
365
366 #[test]
367 fn field_in_set_resolves_via_context() {
368 let ctx = StaticCtx {
369 counts: &[],
370 size: 0,
371 fields: &[("detector_id", "aws-access-key")],
372 };
373 use smallvec::smallvec;
374 let cond = RuleCondition::FieldInSet {
375 field: "detector_id".into(),
376 set: smallvec!["github-pat".into(), "aws-access-key".into()],
377 };
378 assert!(evaluate_condition(&cond, &ctx));
379 let cond = RuleCondition::FieldInSet {
380 field: "detector_id".into(),
381 set: smallvec!["stripe".into()],
382 };
383 assert!(!evaluate_condition(&cond, &ctx));
384 let cond = RuleCondition::FieldInSet {
385 field: "missing".into(),
386 set: smallvec!["x".into()],
387 };
388 assert!(!evaluate_condition(&cond, &ctx));
389 }
390
391 #[test]
392 fn set_membership() {
393 use smallvec::smallvec;
394 let cond = RuleCondition::SetMembership {
395 value: "blue".into(),
396 set: smallvec!["red".into(), "blue".into(), "green".into()],
397 };
398 assert!(evaluate_condition(&cond, &empty_ctx()));
399 let cond = RuleCondition::SetMembership {
400 value: "yellow".into(),
401 set: smallvec!["red".into(), "blue".into()],
402 };
403 assert!(!evaluate_condition(&cond, &empty_ctx()));
404 }
405
406 #[test]
407 fn regex_match_uses_field_value() {
408 let ctx = StaticCtx {
409 counts: &[],
410 size: 0,
411 fields: &[("commit", "abcdef1234567890")],
412 };
413 let cond = RuleCondition::RegexMatch {
414 field: "commit".into(),
415 pattern: "^[0-9a-f]+$".into(),
416 };
417 assert!(evaluate_condition(&cond, &ctx));
418 let cond = RuleCondition::RegexMatch {
419 field: "commit".into(),
420 pattern: "^[A-Z]+$".into(),
421 };
422 assert!(!evaluate_condition(&cond, &ctx));
423 let cond = RuleCondition::RegexMatch {
425 field: "missing".into(),
426 pattern: ".*".into(),
427 };
428 assert!(!evaluate_condition(&cond, &ctx));
429 }
430
431 #[test]
432 fn formula_and_or_not_short_circuit() {
433 let ctx = empty_ctx();
434 let f = RuleFormula::and(
435 RuleFormula::condition(RuleCondition::LiteralTrue),
436 RuleFormula::condition(RuleCondition::LiteralTrue),
437 );
438 assert!(evaluate_formula(&f, &ctx));
439
440 let f = RuleFormula::and(
441 RuleFormula::condition(RuleCondition::LiteralTrue),
442 RuleFormula::condition(RuleCondition::LiteralFalse),
443 );
444 assert!(!evaluate_formula(&f, &ctx));
445
446 let f = RuleFormula::or(
447 RuleFormula::condition(RuleCondition::LiteralFalse),
448 RuleFormula::condition(RuleCondition::LiteralTrue),
449 );
450 assert!(evaluate_formula(&f, &ctx));
451
452 let f = RuleFormula::not_formula(RuleFormula::condition(RuleCondition::LiteralFalse));
453 assert!(evaluate_formula(&f, &ctx));
454 }
455
456 #[test]
457 fn nested_formula() {
458 let ctx = StaticCtx {
460 counts: &[(7, 3), (99, 50)],
461 size: 1024,
462 fields: &[],
463 };
464 let f = RuleFormula::or(
465 RuleFormula::and(
466 RuleFormula::condition(RuleCondition::PatternExists { pattern_id: 7 }),
467 RuleFormula::condition(RuleCondition::FileSizeLt(2048)),
468 ),
469 RuleFormula::not_formula(RuleFormula::condition(RuleCondition::PatternCountGt {
470 pattern_id: 99,
471 threshold: 1000,
472 })),
473 );
474 assert!(evaluate_formula(&f, &ctx));
475 }
476}