rsigma_eval/engine/mod.rs
1//! Rule evaluation engine with logsource routing.
2//!
3//! The `Engine` manages a set of compiled Sigma rules and evaluates events
4//! against them. It supports optional logsource-based pre-filtering to
5//! reduce the number of rules evaluated per event.
6
7pub(crate) mod bloom_index;
8#[cfg(feature = "daachorse-index")]
9pub(crate) mod cross_rule_ac;
10mod filters;
11#[cfg(test)]
12mod tests;
13
14use rsigma_parser::{
15 ConditionExpr, FilterRule, FilterRuleTarget, LogSource, SigmaCollection, SigmaRule,
16};
17
18use crate::compiler::{
19 CompiledRule, compile_detection, compile_rule, evaluate_rule, evaluate_rule_with_bloom,
20};
21use crate::error::{EvalError, Result};
22use crate::event::Event;
23use crate::pipeline::{Pipeline, apply_pipelines};
24use crate::result::MatchResult;
25use crate::rule_index::RuleIndex;
26
27use bloom_index::{BloomCache, FieldBloomIndex};
28
29use filters::{filter_logsource_contains, logsource_matches, rewrite_condition_identifiers};
30
31/// The main rule evaluation engine.
32///
33/// Holds a set of compiled rules and provides methods to evaluate events
34/// against them. Supports optional logsource routing for performance.
35///
36/// # Example
37///
38/// ```rust
39/// use rsigma_parser::parse_sigma_yaml;
40/// use rsigma_eval::{Engine, Event};
41/// use rsigma_eval::event::JsonEvent;
42/// use serde_json::json;
43///
44/// let yaml = r#"
45/// title: Detect Whoami
46/// logsource:
47/// product: windows
48/// category: process_creation
49/// detection:
50/// selection:
51/// CommandLine|contains: 'whoami'
52/// condition: selection
53/// level: medium
54/// "#;
55///
56/// let collection = parse_sigma_yaml(yaml).unwrap();
57/// let mut engine = Engine::new();
58/// engine.add_collection(&collection).unwrap();
59///
60/// let event_val = json!({"CommandLine": "cmd /c whoami"});
61/// let event = JsonEvent::borrow(&event_val);
62/// let matches = engine.evaluate(&event);
63/// assert_eq!(matches.len(), 1);
64/// assert_eq!(matches[0].rule_title, "Detect Whoami");
65/// ```
66pub struct Engine {
67 rules: Vec<CompiledRule>,
68 pipelines: Vec<Pipeline>,
69 /// Global override: include the full event JSON in all match results.
70 /// When `true`, overrides per-rule `rsigma.include_event` custom attributes.
71 include_event: bool,
72 /// Monotonic counter used to namespace injected filter detections,
73 /// preventing key collisions when multiple filters share detection names.
74 filter_counter: usize,
75 /// Inverted index mapping `(field, exact_value)` to candidate rule indices.
76 /// Rebuilt after every rule mutation (add, filter).
77 rule_index: RuleIndex,
78 /// Per-field bloom filter over positive substring needles. Rebuilt
79 /// alongside `rule_index`. Consulted only when `bloom_prefilter` is
80 /// enabled.
81 bloom_index: FieldBloomIndex,
82 /// Toggle for bloom pre-filtering. Off by default: the per-event probe
83 /// overhead exceeds the savings on rule sets where most events overlap
84 /// with at least one needle's trigrams. Workloads with many substring
85 /// rules and mostly-non-matching events (e.g. high-volume telemetry
86 /// streams against an active threat-intel ruleset) opt in via
87 /// [`Engine::set_bloom_prefilter`].
88 bloom_prefilter: bool,
89 /// Memory budget the bloom builder is allowed to consume across all
90 /// per-field filters. `None` means use the crate default
91 /// (`bloom_index::DEFAULT_MAX_TOTAL_BYTES`, 1 MB).
92 bloom_max_bytes: Option<usize>,
93 /// Cross-rule Aho-Corasick index for substring patterns, gated on the
94 /// `daachorse-index` feature. Built only when [`cross_rule_ac_enabled`]
95 /// is `true`; [`cross_rule_ac_prunable`] is the conservative per-rule
96 /// flag computed at the same time so the `evaluate` hot path can drop
97 /// rules safely.
98 ///
99 /// [`cross_rule_ac_enabled`]: Self::cross_rule_ac_enabled
100 /// [`cross_rule_ac_prunable`]: Self::cross_rule_ac_prunable
101 #[cfg(feature = "daachorse-index")]
102 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex,
103 /// Toggle for the cross-rule AC pre-filter. Off by default; the index
104 /// only pays off on rule sets > 5K rules with many shared substring
105 /// patterns. See [`Engine::set_cross_rule_ac`].
106 #[cfg(feature = "daachorse-index")]
107 cross_rule_ac_enabled: bool,
108 /// Per-rule conservative AC-prunability flag. `true` iff the rule's
109 /// firing requires at least one positive substring match (no `Exact`,
110 /// `Regex`, `Numeric`, `Not`, etc.), so dropping the rule on a
111 /// "no AC hit" verdict is provably correct.
112 #[cfg(feature = "daachorse-index")]
113 cross_rule_ac_prunable: Vec<bool>,
114}
115
116impl Engine {
117 /// Create a new empty engine.
118 pub fn new() -> Self {
119 Engine {
120 rules: Vec::new(),
121 pipelines: Vec::new(),
122 include_event: false,
123 filter_counter: 0,
124 rule_index: RuleIndex::empty(),
125 bloom_index: FieldBloomIndex::empty(),
126 bloom_prefilter: false,
127 bloom_max_bytes: None,
128 #[cfg(feature = "daachorse-index")]
129 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex::empty(),
130 #[cfg(feature = "daachorse-index")]
131 cross_rule_ac_enabled: false,
132 #[cfg(feature = "daachorse-index")]
133 cross_rule_ac_prunable: Vec::new(),
134 }
135 }
136
137 /// Create a new engine with a pipeline.
138 pub fn new_with_pipeline(pipeline: Pipeline) -> Self {
139 Engine {
140 rules: Vec::new(),
141 pipelines: vec![pipeline],
142 include_event: false,
143 filter_counter: 0,
144 rule_index: RuleIndex::empty(),
145 bloom_index: FieldBloomIndex::empty(),
146 bloom_prefilter: false,
147 bloom_max_bytes: None,
148 #[cfg(feature = "daachorse-index")]
149 cross_rule_ac_index: cross_rule_ac::CrossRuleAcIndex::empty(),
150 #[cfg(feature = "daachorse-index")]
151 cross_rule_ac_enabled: false,
152 #[cfg(feature = "daachorse-index")]
153 cross_rule_ac_prunable: Vec::new(),
154 }
155 }
156
157 /// Enable or disable bloom-filter pre-filtering of positive substring
158 /// detection items.
159 ///
160 /// When enabled, `evaluate*` short-circuits any positive substring
161 /// matcher (`Contains` / `StartsWith` / `EndsWith` / `AhoCorasickSet`,
162 /// alone or wrapped in `CaseInsensitiveGroup`) whose field cannot
163 /// possibly contain a needle trigram.
164 ///
165 /// Disabled by default. The per-event probe (trigram extraction +
166 /// double hashing) costs ~1 µs on a typical CommandLine field, which
167 /// outweighs the savings on rule sets where most events overlap with
168 /// at least one needle. Enable for workloads that pair many substring
169 /// rules with mostly-non-matching events; benchmark with
170 /// `eval_bloom_rejection` before flipping it on in production.
171 pub fn set_bloom_prefilter(&mut self, enabled: bool) {
172 self.bloom_prefilter = enabled;
173 }
174
175 /// Returns whether bloom pre-filtering is currently enabled.
176 pub fn bloom_prefilter_enabled(&self) -> bool {
177 self.bloom_prefilter
178 }
179
180 /// Set the memory budget for the per-field bloom index.
181 ///
182 /// Must be called **before** `add_collection` / `add_rule` for the new
183 /// budget to take effect on the existing rule set; otherwise it is
184 /// applied at the next index rebuild. The default budget is 1 MB,
185 /// shared across all per-field filters. Lower the cap on memory-
186 /// constrained deployments; raise it for large rule sets where the
187 /// default starts evicting useful filters.
188 pub fn set_bloom_max_bytes(&mut self, max_bytes: usize) {
189 self.bloom_max_bytes = Some(max_bytes);
190 if !self.rules.is_empty() {
191 self.rebuild_index();
192 }
193 }
194
195 /// Returns the configured bloom memory budget, if one has been set
196 /// explicitly. `None` means the crate default (1 MB) is in use.
197 pub fn bloom_max_bytes(&self) -> Option<usize> {
198 self.bloom_max_bytes
199 }
200
201 /// Enable or disable the cross-rule Aho-Corasick pre-filter.
202 ///
203 /// When enabled, the engine builds a single per-field
204 /// `DoubleArrayAhoCorasick` automaton over every positive substring
205 /// needle from every rule and drops AC-prunable rules from the
206 /// candidate set when none of their patterns hit the event.
207 ///
208 /// Off by default. Pays off on large rule sets (> ~5K rules) with many
209 /// shared substring patterns (threat-intel feeds, IOC packs). For
210 /// smaller rule sets the per-rule [`AhoCorasickSet`] matcher already
211 /// handles the workload optimally; the cross-rule index only adds
212 /// build-time and lookup overhead. Benchmark with `eval_cross_rule_ac`
213 /// against representative rule sets before enabling in production.
214 ///
215 /// Available behind the `daachorse-index` Cargo feature.
216 ///
217 /// [`AhoCorasickSet`]: crate::matcher::CompiledMatcher::AhoCorasickSet
218 #[cfg(feature = "daachorse-index")]
219 pub fn set_cross_rule_ac(&mut self, enabled: bool) {
220 self.cross_rule_ac_enabled = enabled;
221 if enabled && !self.rules.is_empty() {
222 self.rebuild_index();
223 }
224 }
225
226 /// Returns whether the cross-rule AC pre-filter is currently enabled.
227 /// Available behind the `daachorse-index` Cargo feature.
228 #[cfg(feature = "daachorse-index")]
229 pub fn cross_rule_ac_enabled(&self) -> bool {
230 self.cross_rule_ac_enabled
231 }
232
233 /// Set global `include_event` — when `true`, all match results include
234 /// the full event JSON regardless of per-rule custom attributes.
235 pub fn set_include_event(&mut self, include: bool) {
236 self.include_event = include;
237 }
238
239 /// Add a pipeline to the engine.
240 ///
241 /// Pipelines are applied to rules during `add_rule` / `add_collection`.
242 /// Only affects rules added **after** this call.
243 pub fn add_pipeline(&mut self, pipeline: Pipeline) {
244 self.pipelines.push(pipeline);
245 self.pipelines.sort_by_key(|p| p.priority);
246 }
247
248 /// Add a single parsed Sigma rule.
249 ///
250 /// If pipelines are set, the rule is cloned and transformed before
251 /// compilation. The rule index folds the new rule incrementally; the
252 /// bloom index also folds it incrementally and only triggers a full
253 /// rebuild when its doubling watermark is reached, so this call is
254 /// amortized O(1) per rule. With the `daachorse-index` feature
255 /// enabled **and** the cross-rule AC index turned on at runtime, the
256 /// call falls back to a full rebuild because the daachorse automaton
257 /// has no incremental update path.
258 pub fn add_rule(&mut self, rule: &SigmaRule) -> Result<()> {
259 let compiled = self.compile_with_pipelines(rule)?;
260 self.rules.push(compiled);
261 self.index_append_last_rule();
262 Ok(())
263 }
264
265 /// Add many parsed Sigma rules in a single batch.
266 ///
267 /// Each rule is compiled (with the engine's pipelines applied, if any)
268 /// and pushed onto the rule set. Compilation errors are collected and
269 /// returned as `(rule_index_in_input, error)` pairs without aborting the
270 /// batch; rules that did compile remain loaded. The inverted index and
271 /// per-field bloom filter are rebuilt **once** at the end of the batch.
272 ///
273 /// Prefer this over a loop of [`Engine::add_rule`] when loading large
274 /// rule sets: the per-call rebuild is O(N) in the total rule count, so
275 /// per-rule adds turn a 3K-rule corpus into O(N²) work.
276 pub fn add_rules<'a, I>(&mut self, rules: I) -> Vec<(usize, EvalError)>
277 where
278 I: IntoIterator<Item = &'a SigmaRule>,
279 {
280 let mut errors = Vec::new();
281 for (idx, rule) in rules.into_iter().enumerate() {
282 match self.compile_with_pipelines(rule) {
283 Ok(compiled) => self.rules.push(compiled),
284 Err(e) => errors.push((idx, e)),
285 }
286 }
287 self.rebuild_index();
288 errors
289 }
290
291 /// Add all detection rules from a parsed collection, then apply filters.
292 ///
293 /// Filter rules modify referenced detection rules by appending exclusion
294 /// conditions. Correlation rules are handled by `CorrelationEngine`.
295 /// The inverted index is rebuilt once after all rules and filters are loaded.
296 pub fn add_collection(&mut self, collection: &SigmaCollection) -> Result<()> {
297 for rule in &collection.rules {
298 let compiled = self.compile_with_pipelines(rule)?;
299 self.rules.push(compiled);
300 }
301 for filter in &collection.filters {
302 self.apply_filter_no_rebuild(filter)?;
303 }
304 self.rebuild_index();
305 Ok(())
306 }
307
308 /// Compile a rule, applying any configured pipelines first. Shared by
309 /// the single- and batched-add paths so they stay behaviourally
310 /// identical.
311 fn compile_with_pipelines(&self, rule: &SigmaRule) -> Result<CompiledRule> {
312 if self.pipelines.is_empty() {
313 compile_rule(rule)
314 } else {
315 let mut transformed = rule.clone();
316 apply_pipelines(&self.pipelines, &mut transformed)?;
317 compile_rule(&transformed)
318 }
319 }
320
321 /// Add all detection rules from a collection, applying the given pipelines.
322 ///
323 /// This is a convenience method that temporarily sets pipelines, adds the
324 /// collection, then clears them. The inverted index is rebuilt once after
325 /// all rules and filters are loaded.
326 pub fn add_collection_with_pipelines(
327 &mut self,
328 collection: &SigmaCollection,
329 pipelines: &[Pipeline],
330 ) -> Result<()> {
331 let prev = std::mem::take(&mut self.pipelines);
332 self.pipelines = pipelines.to_vec();
333 self.pipelines.sort_by_key(|p| p.priority);
334 let result = self.add_collection(collection);
335 self.pipelines = prev;
336 result
337 }
338
339 /// Apply a filter rule to all referenced detection rules and rebuild the index.
340 pub fn apply_filter(&mut self, filter: &FilterRule) -> Result<()> {
341 self.apply_filter_no_rebuild(filter)?;
342 self.rebuild_index();
343 Ok(())
344 }
345
346 /// Apply a filter rule without rebuilding the index.
347 /// Used internally when multiple mutations are batched.
348 fn apply_filter_no_rebuild(&mut self, filter: &FilterRule) -> Result<()> {
349 // Compile filter detections
350 let mut filter_detections = Vec::new();
351 for (name, detection) in &filter.detection.named {
352 let compiled = compile_detection(detection)?;
353 filter_detections.push((name.clone(), compiled));
354 }
355
356 if filter_detections.is_empty() {
357 return Ok(());
358 }
359
360 let fc = self.filter_counter;
361 self.filter_counter += 1;
362
363 // Rewrite the filter's own condition expression with namespaced identifiers
364 // so that `selection` becomes `__filter_0_selection`, etc.
365 let rewritten_cond = if let Some(cond_expr) = filter.detection.conditions.first() {
366 rewrite_condition_identifiers(cond_expr, fc)
367 } else {
368 // No explicit condition: AND all detections (legacy fallback)
369 if filter_detections.len() == 1 {
370 ConditionExpr::Identifier(format!("__filter_{fc}_{}", filter_detections[0].0))
371 } else {
372 ConditionExpr::And(
373 filter_detections
374 .iter()
375 .map(|(name, _)| ConditionExpr::Identifier(format!("__filter_{fc}_{name}")))
376 .collect(),
377 )
378 }
379 };
380
381 // Find and modify referenced rules
382 let mut matched_any = false;
383 for rule in &mut self.rules {
384 let rule_matches = match &filter.rules {
385 FilterRuleTarget::Any => true,
386 FilterRuleTarget::Specific(refs) => refs
387 .iter()
388 .any(|r| rule.id.as_deref() == Some(r.as_str()) || rule.title == *r),
389 };
390
391 // Also check logsource compatibility if the filter specifies one
392 if rule_matches {
393 if let Some(ref filter_ls) = filter.logsource
394 && !filter_logsource_contains(filter_ls, &rule.logsource)
395 {
396 continue;
397 }
398
399 // Inject filter detections into the rule
400 for (name, compiled) in &filter_detections {
401 rule.detections
402 .insert(format!("__filter_{fc}_{name}"), compiled.clone());
403 }
404
405 // Wrap each existing rule condition with the filter condition
406 rule.conditions = rule
407 .conditions
408 .iter()
409 .map(|cond| ConditionExpr::And(vec![cond.clone(), rewritten_cond.clone()]))
410 .collect();
411 matched_any = true;
412 }
413 }
414
415 if let FilterRuleTarget::Specific(_) = &filter.rules
416 && !matched_any
417 {
418 log::warn!(
419 "filter '{}' references rules {:?} but none matched any loaded rule",
420 filter.title,
421 filter.rules
422 );
423 }
424
425 Ok(())
426 }
427
428 /// Add a pre-compiled rule directly. The rule index folds the new
429 /// rule incrementally; the bloom index also folds it incrementally
430 /// and only triggers a full rebuild when its doubling watermark is
431 /// reached, so this call is amortized O(1) per rule. With the
432 /// cross-rule AC index enabled (`daachorse-index` feature, runtime
433 /// toggle), this falls back to a full rebuild because daachorse has
434 /// no incremental update path.
435 pub fn add_compiled_rule(&mut self, rule: CompiledRule) {
436 self.rules.push(rule);
437 self.index_append_last_rule();
438 }
439
440 /// Add many pre-compiled rules in a single batch. The inverted index
441 /// and bloom filter are rebuilt exactly once at the end, regardless of
442 /// how many rules are appended.
443 pub fn extend_compiled_rules<I>(&mut self, rules: I)
444 where
445 I: IntoIterator<Item = CompiledRule>,
446 {
447 self.rules.extend(rules);
448 self.rebuild_index();
449 }
450
451 /// Rebuild every per-engine index from the current rule set.
452 ///
453 /// Used by batched rule loads (`add_rules`, `extend_compiled_rules`,
454 /// `add_collection`) and by mutations that rewrite existing rules
455 /// (`apply_filter`), where rebuilding once over the final shape is
456 /// cheaper than maintaining incremental state across mutations. The
457 /// single-rule paths use [`Engine::index_append_last_rule`] instead.
458 fn rebuild_index(&mut self) {
459 self.rule_index = RuleIndex::build(&self.rules);
460 self.bloom_index = match self.bloom_max_bytes {
461 Some(budget) => FieldBloomIndex::build_with_budget(&self.rules, budget),
462 None => FieldBloomIndex::build(&self.rules),
463 };
464 #[cfg(feature = "daachorse-index")]
465 {
466 if self.cross_rule_ac_enabled {
467 self.cross_rule_ac_index = cross_rule_ac::CrossRuleAcIndex::build(&self.rules);
468 self.cross_rule_ac_prunable = self
469 .rules
470 .iter()
471 .map(cross_rule_ac::rule_is_ac_prunable)
472 .collect();
473 } else {
474 self.cross_rule_ac_index = cross_rule_ac::CrossRuleAcIndex::empty();
475 self.cross_rule_ac_prunable.clear();
476 }
477 }
478 }
479
480 /// Fold the rule most recently pushed onto `self.rules` into the
481 /// inverted and bloom indexes incrementally. Cost is bounded by the
482 /// new rule's detection tree size, not by the total rule count.
483 ///
484 /// The bloom index periodically forces a full rebuild via its
485 /// doubling watermark to re-enforce the memory budget and reset the
486 /// FPR drift that incremental inserts accumulate. Cross-rule AC
487 /// (daachorse) has no incremental story, so when it is enabled this
488 /// call falls back to [`Engine::rebuild_index`].
489 fn index_append_last_rule(&mut self) {
490 #[cfg(feature = "daachorse-index")]
491 {
492 if self.cross_rule_ac_enabled {
493 self.rebuild_index();
494 return;
495 }
496 }
497
498 let new_idx = self.rules.len() - 1;
499 let rule = &self.rules[new_idx];
500 self.rule_index.append_rule(new_idx, rule);
501 self.bloom_index.append_rule(rule);
502
503 if self.bloom_index.should_rebuild(self.rules.len()) {
504 self.bloom_index = match self.bloom_max_bytes {
505 Some(budget) => FieldBloomIndex::build_with_budget(&self.rules, budget),
506 None => FieldBloomIndex::build(&self.rules),
507 };
508 }
509 }
510
511 /// Evaluate an event against candidate rules using the inverted index.
512 pub fn evaluate<E: Event>(&self, event: &E) -> Vec<MatchResult> {
513 if self.bloom_prefilter {
514 self.evaluate_with_bloom_path(event)
515 } else {
516 self.evaluate_no_bloom_path(event)
517 }
518 }
519
520 /// Build the cross-rule AC keep-mask for `event`, or `None` when the
521 /// cross-rule index is disabled or empty (no filtering needed).
522 ///
523 /// `Some(mask)` answers "should this rule survive the cross-rule AC
524 /// filter": `mask[idx] = true` means keep, `false` means drop.
525 /// Non-AC-prunable rules are always kept.
526 #[cfg(feature = "daachorse-index")]
527 fn cross_rule_ac_keep_mask<E: Event>(&self, event: &E) -> Option<Vec<bool>> {
528 if !self.cross_rule_ac_enabled || self.cross_rule_ac_index.is_empty() {
529 return None;
530 }
531 let mut hits = vec![false; self.rules.len()];
532 self.cross_rule_ac_index.mark_hits(event, &mut hits);
533 // Compose: keep = !ac_prunable OR ac_hit. The prunable vector and
534 // the rule slice are kept aligned by `rebuild_index`.
535 for (idx, slot) in hits.iter_mut().enumerate() {
536 if !self
537 .cross_rule_ac_prunable
538 .get(idx)
539 .copied()
540 .unwrap_or(false)
541 {
542 *slot = true;
543 }
544 }
545 Some(hits)
546 }
547
548 #[cfg(not(feature = "daachorse-index"))]
549 #[inline(always)]
550 fn cross_rule_ac_keep_mask<E: Event>(&self, _event: &E) -> Option<Vec<bool>> {
551 None
552 }
553
554 fn evaluate_no_bloom_path<E: Event>(&self, event: &E) -> Vec<MatchResult> {
555 // The public `evaluate_rule` is not generic over `BloomLookup`, so
556 // the no-bloom hot path lowers to straight-line code identical to
557 // the pre-bloom engine.
558 let keep = self.cross_rule_ac_keep_mask(event);
559 let mut results = Vec::new();
560 for idx in self.rule_index.candidates(event) {
561 if let Some(ref mask) = keep
562 && !mask[idx]
563 {
564 continue;
565 }
566 let rule = &self.rules[idx];
567 if let Some(mut m) = evaluate_rule(rule, event) {
568 if self.include_event && m.event.is_none() {
569 m.event = Some(event.to_json());
570 }
571 results.push(m);
572 }
573 }
574 results
575 }
576
577 fn evaluate_with_bloom_path<E: Event>(&self, event: &E) -> Vec<MatchResult> {
578 let bloom = BloomCache::new(&self.bloom_index, event);
579 let keep = self.cross_rule_ac_keep_mask(event);
580 let mut results = Vec::new();
581 for idx in self.rule_index.candidates(event) {
582 if let Some(ref mask) = keep
583 && !mask[idx]
584 {
585 continue;
586 }
587 let rule = &self.rules[idx];
588 if let Some(mut m) = evaluate_rule_with_bloom(rule, event, &bloom) {
589 if self.include_event && m.event.is_none() {
590 m.event = Some(event.to_json());
591 }
592 results.push(m);
593 }
594 }
595 results
596 }
597
598 /// Evaluate an event against candidate rules matching the given logsource.
599 ///
600 /// Uses the inverted index for candidate pre-filtering, then applies the
601 /// logsource constraint. Only rules whose logsource is compatible with
602 /// `event_logsource` are evaluated.
603 pub fn evaluate_with_logsource<E: Event>(
604 &self,
605 event: &E,
606 event_logsource: &LogSource,
607 ) -> Vec<MatchResult> {
608 if self.bloom_prefilter {
609 self.evaluate_with_logsource_with_bloom(event, event_logsource)
610 } else {
611 self.evaluate_with_logsource_no_bloom(event, event_logsource)
612 }
613 }
614
615 fn evaluate_with_logsource_no_bloom<E: Event>(
616 &self,
617 event: &E,
618 event_logsource: &LogSource,
619 ) -> Vec<MatchResult> {
620 let keep = self.cross_rule_ac_keep_mask(event);
621 let mut results = Vec::new();
622 for idx in self.rule_index.candidates(event) {
623 if let Some(ref mask) = keep
624 && !mask[idx]
625 {
626 continue;
627 }
628 let rule = &self.rules[idx];
629 if logsource_matches(&rule.logsource, event_logsource)
630 && let Some(mut m) = evaluate_rule(rule, event)
631 {
632 if self.include_event && m.event.is_none() {
633 m.event = Some(event.to_json());
634 }
635 results.push(m);
636 }
637 }
638 results
639 }
640
641 fn evaluate_with_logsource_with_bloom<E: Event>(
642 &self,
643 event: &E,
644 event_logsource: &LogSource,
645 ) -> Vec<MatchResult> {
646 let bloom = BloomCache::new(&self.bloom_index, event);
647 let keep = self.cross_rule_ac_keep_mask(event);
648 let mut results = Vec::new();
649 for idx in self.rule_index.candidates(event) {
650 if let Some(ref mask) = keep
651 && !mask[idx]
652 {
653 continue;
654 }
655 let rule = &self.rules[idx];
656 if logsource_matches(&rule.logsource, event_logsource)
657 && let Some(mut m) = evaluate_rule_with_bloom(rule, event, &bloom)
658 {
659 if self.include_event && m.event.is_none() {
660 m.event = Some(event.to_json());
661 }
662 results.push(m);
663 }
664 }
665 results
666 }
667
668 /// Evaluate a batch of events, returning per-event match results.
669 ///
670 /// When the `parallel` feature is enabled, events are evaluated concurrently
671 /// using rayon's work-stealing thread pool. Otherwise, falls back to
672 /// sequential evaluation.
673 pub fn evaluate_batch<E: Event + Sync>(&self, events: &[&E]) -> Vec<Vec<MatchResult>> {
674 #[cfg(feature = "parallel")]
675 {
676 use rayon::prelude::*;
677 events.par_iter().map(|e| self.evaluate(e)).collect()
678 }
679 #[cfg(not(feature = "parallel"))]
680 {
681 events.iter().map(|e| self.evaluate(e)).collect()
682 }
683 }
684
685 /// Number of rules loaded in the engine.
686 pub fn rule_count(&self) -> usize {
687 self.rules.len()
688 }
689
690 /// Access the compiled rules.
691 pub fn rules(&self) -> &[CompiledRule] {
692 &self.rules
693 }
694}
695
696impl Default for Engine {
697 fn default() -> Self {
698 Self::new()
699 }
700}