Skip to main content

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}