Skip to main content

libmagic_rs/evaluator/engine/
mod.rs

1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Core evaluation engine for magic rules.
5//!
6//! This module contains the core recursive evaluation logic for executing magic
7//! rules against file buffers. It is responsible for:
8//! - Evaluating a single rule via [`evaluate_single_rule`] (a thin wrapper
9//!   around `evaluate_rules` that delegates one rule through the full
10//!   context-aware pipeline)
11//! - Evaluating hierarchical rule sets with context (`evaluate_rules`)
12//! - Providing a convenience wrapper for evaluation with configuration
13//!   (`evaluate_rules_with_config`)
14
15use crate::parser::ast::{MagicRule, MetaType, TypeKind};
16use crate::{EvaluationConfig, LibmagicError};
17
18use super::{EvaluationContext, RecursionGuard, RuleMatch, offset, operators, types};
19use log::{debug, warn};
20use std::sync::atomic::{AtomicBool, Ordering};
21
22/// RAII guard that saves the GNU `file` previous-match anchor **and**
23/// `base_offset` on entry and restores both on drop.
24///
25/// `MetaType::Indirect` re-evaluates the root rule list at the resolved
26/// offset. The re-entered rules are top-level-semantic (`base_offset=0`)
27/// and must start with a fresh anchor (the resolved indirect offset).
28/// When `indirect` fires inside a `MetaType::Use` subroutine, the outer
29/// subroutine's non-zero `base_offset` would otherwise leak into the
30/// root re-entry, causing every positive absolute offset in the re-entered
31/// database to be biased by the outer use-site -- producing reads at the
32/// wrong positions. Saving and restoring `base_offset` here prevents that.
33///
34/// Without an RAII wrapper, every early-return path inside the indirect
35/// branch would have to remember to restore both fields manually.
36struct AnchorScope<'a> {
37    context: &'a mut EvaluationContext,
38    saved_anchor: usize,
39    saved_base: usize,
40}
41
42impl<'a> AnchorScope<'a> {
43    /// Save the current anchor and `base_offset`, then seed the context
44    /// with `new_anchor` and reset `base_offset` to 0.
45    fn enter(context: &'a mut EvaluationContext, new_anchor: usize) -> Self {
46        let saved_anchor = context.last_match_end();
47        let saved_base = context.base_offset();
48        context.set_last_match_end(new_anchor);
49        context.set_base_offset(0);
50        Self {
51            context,
52            saved_anchor,
53            saved_base,
54        }
55    }
56
57    /// Access the underlying context for the duration of the guard.
58    fn context(&mut self) -> &mut EvaluationContext {
59        self.context
60    }
61}
62
63impl Drop for AnchorScope<'_> {
64    fn drop(&mut self) {
65        self.context.set_last_match_end(self.saved_anchor);
66        self.context.set_base_offset(self.saved_base);
67    }
68}
69
70/// RAII guard for `MetaType::Use` subroutine dispatch.
71///
72/// Saves `last_match_end` and `base_offset` on entry, seeds the context
73/// with the use-site offset (for both fields so that a subroutine's
74/// `&0` relative offset resolves to the use-site and its positive
75/// absolute offsets bias against the use-site per magic(5)), and
76/// restores both on drop.
77///
78/// This is the safety net for early-return paths inside
79/// `evaluate_use_rule`: a `RecursionGuard::enter` failure or a
80/// `Timeout`/`RecursionLimitExceeded` inside the subroutine body would
81/// otherwise leave the caller's context with corrupted anchor and
82/// base-offset state. The guard's `Drop` impl restores both fields on
83/// every exit path, error or success.
84struct SubroutineScope<'a> {
85    context: &'a mut EvaluationContext,
86    saved_anchor: usize,
87    saved_base: usize,
88}
89
90impl<'a> SubroutineScope<'a> {
91    fn enter(context: &'a mut EvaluationContext, use_site: usize) -> Self {
92        let saved_anchor = context.last_match_end();
93        let saved_base = context.base_offset();
94        context.set_last_match_end(use_site);
95        context.set_base_offset(use_site);
96        Self {
97            context,
98            saved_anchor,
99            saved_base,
100        }
101    }
102
103    fn context(&mut self) -> &mut EvaluationContext {
104        self.context
105    }
106}
107
108impl Drop for SubroutineScope<'_> {
109    fn drop(&mut self) {
110        self.context.set_last_match_end(self.saved_anchor);
111        self.context.set_base_offset(self.saved_base);
112    }
113}
114
115/// Process-local once guard for the "use directive without rule environment"
116/// warning. Ensures we surface the misconfiguration exactly once per process
117/// so low-level programmatic consumers of [`evaluate_rules`] (tests, fuzz
118/// harnesses) that intentionally run without a `MagicDatabase`-attached
119/// environment do not flood the log on every `Use` rule they encounter.
120static USE_WITHOUT_RULE_ENV_WARNED: AtomicBool = AtomicBool::new(false);
121
122/// Process-local once guard for the "`evaluate_rules_with_config` called
123/// with an `indirect` rule but without a `RuleEnvironment`" warning.
124/// Same rationale as `USE_WITHOUT_RULE_ENV_WARNED`: surface the
125/// misconfiguration exactly once per process so a large corpus of
126/// env-less `indirect` rules does not flood the log.
127// Gated to debug builds like its only use site (the diagnostic guard in
128// `evaluate_rules_with_config`); in release builds the item would be dead
129// code, which the workspace `warnings = "deny"` lint rejects.
130#[cfg(debug_assertions)]
131static INDIRECT_WITHOUT_RULE_ENV_WARNED: AtomicBool = AtomicBool::new(false);
132
133/// Evaluate a single magic rule against a file buffer
134///
135/// This is a thin wrapper around [`evaluate_rules`] that evaluates exactly
136/// one top-level rule (and any of its children) against a buffer, using the
137/// caller-provided [`EvaluationContext`] to enforce timeout, recursion, and
138/// string-size limits. It is a BREAKING API change introduced in pre-1.0:
139/// earlier versions took no context and returned `Option<(usize, Value)>`.
140///
141/// # Arguments
142///
143/// * `rule` - The magic rule to evaluate
144/// * `buffer` - The file buffer to evaluate against
145/// * `context` - Mutable evaluation context that carries the configured
146///   safety limits (timeout, max recursion depth, max string length) and
147///   the GNU `file` previous-match anchor used for relative-offset
148///   resolution. Callers reusing a context across multiple buffers must
149///   call [`EvaluationContext::reset`](crate::evaluator::EvaluationContext::reset)
150///   between calls -- see [`evaluate_rules`] for details.
151///
152/// # Returns
153///
154/// Returns `Ok(Vec<RuleMatch>)` containing the parent match (if the rule
155/// matched) plus any child matches collected recursively. An empty vector
156/// means the rule did not match or was skipped due to a data-dependent
157/// evaluation error (buffer overrun, invalid offset, etc.). Only critical
158/// failures such as `LibmagicError::Timeout` or recursion-limit exhaustion
159/// are returned as `Err`.
160///
161/// # Examples
162///
163/// ```rust
164/// use libmagic_rs::evaluator::{evaluate_single_rule, EvaluationContext};
165/// use libmagic_rs::EvaluationConfig;
166/// use libmagic_rs::parser::ast::{MagicRule, OffsetSpec, TypeKind, Operator, Value};
167///
168/// // Create a rule to check for ELF magic bytes at offset 0
169/// let rule = MagicRule::new(OffsetSpec::Absolute(0), TypeKind::Byte { signed: true }, Operator::Equal, Value::Uint(0x7f), "ELF magic".to_string());
170///
171/// let mut context = EvaluationContext::new(EvaluationConfig::default());
172/// let elf_buffer = &[0x7f, 0x45, 0x4c, 0x46]; // ELF magic bytes
173/// let matches = evaluate_single_rule(&rule, elf_buffer, &mut context).unwrap();
174/// assert_eq!(matches.len(), 1); // Should match
175///
176/// context.reset();
177/// let non_elf_buffer = &[0x50, 0x4b, 0x03, 0x04]; // ZIP magic bytes
178/// let matches = evaluate_single_rule(&rule, non_elf_buffer, &mut context).unwrap();
179/// assert!(matches.is_empty()); // Should not match
180/// ```
181///
182/// # Errors
183///
184/// * `LibmagicError::Timeout` - If evaluation exceeds the configured timeout
185/// * `LibmagicError::EvaluationError` - For critical failures such as the
186///   recursion limit being exceeded. Data-dependent errors (buffer overrun,
187///   invalid offset, malformed pstring length) are handled gracefully by
188///   [`evaluate_rules`] and surface as an empty match vector rather than
189///   an error.
190pub fn evaluate_single_rule(
191    rule: &MagicRule,
192    buffer: &[u8],
193    context: &mut EvaluationContext,
194) -> Result<Vec<RuleMatch>, LibmagicError> {
195    evaluate_rules(std::slice::from_ref(rule), buffer, context)
196}
197
198/// Internal: evaluate a single rule against a buffer, supplying an explicit
199/// anchor for relative-offset resolution.
200///
201/// This is the worker behind both [`evaluate_single_rule`] (which defaults
202/// the anchor to 0) and [`evaluate_rules`] (which threads the anchor from
203/// `EvaluationContext::last_match_end()`).
204fn evaluate_single_rule_with_anchor(
205    rule: &MagicRule,
206    buffer: &[u8],
207    last_match_end: usize,
208    base_offset: usize,
209    max_string_length: usize,
210) -> Result<Option<(usize, crate::parser::ast::Value)>, LibmagicError> {
211    use crate::parser::ast::TypeKind;
212
213    // Step 1: Resolve the offset specification to an absolute position.
214    // `base_offset` is non-zero only inside a `MetaType::Use` subroutine
215    // body, where it biases positive absolute offsets to the use-site.
216    let absolute_offset =
217        offset::resolve_offset_with_base(&rule.offset, buffer, last_match_end, base_offset)?;
218
219    // Step 2 & 3: Dispatch on type category. Pattern-bearing types
220    // (Regex, Search) take a different path from fixed-width types
221    // because the rule's `value` operand is the *pattern*, not an
222    // expected matched value. Running those through `apply_operator`
223    // would compare matched text ("123") against the pattern literal
224    // ("[0-9]+") and produce false negatives on any regex with
225    // metacharacters.
226    //
227    // Meta-type directives (`default`, `clear`, `name`, `use`,
228    // `indirect`, `offset`) are dispatched by `evaluate_rules` at the
229    // outer loop level (not here) -- this single-rule helper is only
230    // invoked for non-meta rules. Short-circuiting the Meta arms here
231    // with `Ok(None)` is defense-in-depth for programmatic callers
232    // (property tests, fuzz harnesses) that hand-build a Meta rule
233    // and feed it directly to `evaluate_single_rule`; without the
234    // guard, the value/pattern paths would surface
235    // `TypeReadError::UnsupportedType`.
236    let (matched, read_value) = match &rule.typ {
237        TypeKind::Meta(MetaType::Name(name)) => {
238            // `Name` rules are normally hoisted into the name table at
239            // parse time and should not reach the evaluator. Programmatic
240            // consumers (e.g. fuzz harnesses, property tests) can still
241            // construct them directly; treat that as a no-op rather than
242            // a hard failure so the evaluator-never-panics invariant is
243            // preserved.
244            debug!(
245                "Name rule '{name}' reached evaluator (likely bypassed name-table extraction); treating as no-op"
246            );
247            return Ok(None);
248        }
249        TypeKind::Meta(MetaType::Use(_)) => {
250            // `Use` is dispatched inline by `evaluate_rules` so it can
251            // push the subroutine's matches into the caller's match
252            // vector. Reaching this arm means the rule went through the
253            // single-rule path (e.g. via `evaluate_single_rule`) which
254            // lacks that wiring; treat it as a silent no-op.
255            return Ok(None);
256        }
257        TypeKind::Meta(_) => return Ok(None),
258        TypeKind::Regex { .. } | TypeKind::Search { .. } => {
259            evaluate_pattern_rule(rule, buffer, absolute_offset, max_string_length)?
260        }
261        // Flagged `string` rules route through the pattern-bearing path
262        // (see GOTCHAS S2.4 for the contract) so `compare_string_with_flags`
263        // can do the case-fold / whitespace-flexible match in one pass.
264        // Default-flag strings (the common case) take the existing
265        // value-rule fast path with byte-exact `apply_equal`.
266        TypeKind::String { flags, .. } if !flags.is_empty() => {
267            evaluate_pattern_rule(rule, buffer, absolute_offset, max_string_length)?
268        }
269        _ => evaluate_value_rule(rule, buffer, absolute_offset, max_string_length)?,
270    };
271    Ok(matched.then_some((absolute_offset, read_value)))
272}
273
274/// Evaluate a `TypeKind::Meta(MetaType::Use(name))` rule inline.
275///
276/// Looks up `name` in the context's rule environment, temporarily sets the
277/// GNU `file` previous-match anchor to the resolved offset, and recursively
278/// evaluates the subroutine's rules against `buffer`. Any matches produced
279/// by the subroutine are returned in document order and are intended to be
280/// pushed into the caller's match vector *before* the synthetic `Use` match
281/// itself (matching GNU `file` behavior where a `use` site is replaced by
282/// its expansion in the output).
283///
284/// Returns `Ok((Some(absolute_offset), matches))` on a successful resolution
285/// (even if the subroutine produced no matches), or `Ok((None, vec![]))`
286/// when:
287/// - the context has no rule environment attached (programmatic consumers
288///   bypassing `MagicDatabase`)
289/// - the referenced name is not in the table (logged at warn level)
290///
291/// Recursion-limit propagation is handled via [`RecursionGuard`] so that a
292/// subroutine calling `use` on itself triggers `RecursionLimitExceeded`
293/// instead of a stack overflow.
294fn evaluate_use_rule(
295    rule: &MagicRule,
296    name: &str,
297    buffer: &[u8],
298    context: &mut EvaluationContext,
299) -> Result<(Option<usize>, Vec<RuleMatch>), LibmagicError> {
300    let Some(env) = context.rule_env() else {
301        // Surface the misconfiguration once per process at warn! level so
302        // it is visible in default logging, then gate subsequent hits so a
303        // magic file with many `use` directives does not flood the log.
304        // Use `Ordering::Relaxed`: the flag is an idempotent diagnostic
305        // latch, not a synchronization primitive guarding other state.
306        if USE_WITHOUT_RULE_ENV_WARNED.swap(true, Ordering::Relaxed) {
307            debug!("use directive '{name}' evaluated without a rule environment; no-op");
308        } else {
309            warn!(
310                "use directive '{name}' evaluated without a rule environment; treating as no-op (subsequent occurrences suppressed)"
311            );
312        }
313        return Ok((None, Vec::new()));
314    };
315
316    let Some(subroutine_rules) = env.name_table.get(name) else {
317        warn!("use directive references unknown name '{name}'");
318        return Ok((None, Vec::new()));
319    };
320    // `NameTable::get` returns an `Arc<[MagicRule]>`, so this clone is a
321    // reference-count increment rather than a deep copy of the rule tree.
322    // The Arc is cloned here to release the immutable borrow of `context`
323    // (via `env`) before we mutably borrow the context below.
324
325    // Resolve the use-site offset under the *caller's* base, not the
326    // subroutine's -- the use rule itself is in the caller's scope.
327    let absolute_offset = offset::resolve_offset_with_base(
328        &rule.offset,
329        buffer,
330        context.last_match_end(),
331        context.base_offset(),
332    )?;
333
334    // `SubroutineScope` seeds `last_match_end` and `base_offset` with
335    // the use-site offset and restores both on drop. This is the
336    // safety net for early-return paths below -- if
337    // `RecursionGuard::enter` or the inner `evaluate_rules` returns
338    // `Err(Timeout)` / `Err(RecursionLimitExceeded)`, the `?` unwinds
339    // through the guard's `Drop` impl and the caller's context
340    // returns to its pre-use state. Without the RAII wrapper a manual
341    // save/restore pair would be bypassed on every error path.
342    // Capture both the subroutine's matches AND the terminal anchor
343    // where the subroutine left `last_match_end`. The terminal anchor
344    // is what GNU `file`-compatible inlining semantics require: sibling
345    // rules after the `use` site must resolve `&N` against the position
346    // the subroutine reached, not the use-site offset. Reading the
347    // anchor INSIDE the scope (before Drop restores the caller's value)
348    // preserves it for the caller.
349    let (subroutine_matches, terminal_anchor) = {
350        let mut scope = SubroutineScope::enter(context, absolute_offset);
351        let mut guard = RecursionGuard::enter(scope.context())?;
352        let matches = evaluate_rules(&subroutine_rules, buffer, guard.context())?;
353        let terminal = guard.context().last_match_end();
354        (matches, terminal)
355    };
356
357    Ok((Some(terminal_anchor), subroutine_matches))
358}
359
360/// Evaluate a pattern-bearing rule (`TypeKind::Regex` / `TypeKind::Search`).
361///
362/// `read_pattern_match` returns `Some(value)` on a successful match
363/// (possibly zero-width, e.g., `a*`) and `None` on a genuine miss; the
364/// engine translates those directly into `Equal`/`NotEqual`. Any other
365/// operator on a pattern-bearing type is a magic-file semantic bug and
366/// surfaces as [`crate::evaluator::types::TypeReadError::UnsupportedType`] -- the earlier
367/// fallthrough to `apply_operator` masked this by producing nonsense
368/// ordering comparisons against the pattern source text.
369///
370/// On a miss we return `Value::String(String::new())` as a display
371/// placeholder; the engine has already decided `matched = false` by
372/// then, so the placeholder only affects display and
373/// `bytes_consumed_with_pattern` (which re-derives the match position
374/// from the pattern, not this value).
375fn evaluate_pattern_rule(
376    rule: &MagicRule,
377    buffer: &[u8],
378    absolute_offset: usize,
379    max_string_length: usize,
380) -> Result<(bool, crate::parser::ast::Value), LibmagicError> {
381    let match_outcome = types::read_pattern_match(
382        buffer,
383        absolute_offset,
384        &rule.typ,
385        Some(&rule.value),
386        max_string_length,
387    )
388    .map_err(|e| LibmagicError::EvaluationError(e.into()))?;
389    let pattern_found = match_outcome.is_some();
390    let matched = match &rule.op {
391        crate::parser::ast::Operator::Equal => pattern_found,
392        crate::parser::ast::Operator::NotEqual => !pattern_found,
393        other => {
394            return Err(LibmagicError::EvaluationError(
395                types::TypeReadError::UnsupportedType {
396                    type_name: format!(
397                        "operator {other:?} is not supported for pattern-bearing type {:?}; only Equal (=) and NotEqual (!=) are allowed",
398                        rule.typ
399                    ),
400                }
401                .into(),
402            ));
403        }
404    };
405    let value = match_outcome.unwrap_or_else(|| crate::parser::ast::Value::String(String::new()));
406    Ok((matched, value))
407}
408
409/// Evaluate a value-based rule (all non-pattern-bearing `TypeKind` variants).
410///
411/// Reads the typed value at `absolute_offset`, coerces the rule's
412/// expected value to the target type's signedness/width (zero-copy via
413/// `Cow::Borrowed` on the hot path), and applies the operator.
414/// `BitwiseNot` needs type-aware width masking so the complement is
415/// computed at the type's natural width (e.g. byte `NOT 0x00 = 0xFF`,
416/// not `u64::MAX`).
417fn evaluate_value_rule(
418    rule: &MagicRule,
419    buffer: &[u8],
420    absolute_offset: usize,
421    max_string_length: usize,
422) -> Result<(bool, crate::parser::ast::Value), LibmagicError> {
423    let read_value = types::read_typed_value_with_pattern(
424        buffer,
425        absolute_offset,
426        &rule.typ,
427        Some(&rule.value),
428        max_string_length,
429    )
430    .map_err(|e| LibmagicError::EvaluationError(e.into()))?;
431
432    // Apply any pre-comparison value transform (`type+N`/`type-N`/`type*N`/
433    // `type/N`/`type%N`/`type|N`/`type^N`). The transform runs on the read
434    // value before the comparison operator and before printf-style format
435    // substitution, so `%d` in the message renders the post-transform
436    // number. `&MASK` is *not* handled here -- it lives at the operator
437    // layer via `Operator::BitwiseAndMask`.
438    let transformed_value = match rule.value_transform {
439        None => read_value,
440        Some(t) => operators::apply_value_transform(&read_value, t)
441            .map_err(LibmagicError::EvaluationError)?,
442    };
443
444    let expected_value = types::coerce_value_to_type(&rule.value, &rule.typ);
445    let expected_ref: &crate::parser::ast::Value = expected_value.as_ref();
446
447    let matched = match &rule.op {
448        crate::parser::ast::Operator::BitwiseNot => operators::apply_bitwise_not_with_width(
449            &transformed_value,
450            expected_ref,
451            rule.typ.bit_width(),
452        ),
453        op => operators::apply_operator(op, &transformed_value, expected_ref),
454    };
455    Ok((matched, transformed_value))
456}
457
458/// Evaluate a rule's children under the standard recursion-guard/graceful-skip discipline.
459///
460/// This helper centralises the `RecursionGuard` + `evaluate_rules` + error-dispatch
461/// pattern that is identical across the `Default`, `Indirect`, `Offset`, and `Use`
462/// meta-type arms in [`evaluate_rules`]. Extracting it prevents the four copies
463/// from drifting apart during future maintenance.
464///
465/// # Behaviour
466///
467/// * If `rule.children` is empty the function is a no-op (returns `Ok(())`).
468/// * Child matches are appended to `matches` in document order.
469/// * `LibmagicError::Timeout` and `LibmagicError::EvaluationError(RecursionLimitExceeded)`
470///   propagate immediately as `Err` so the caller can bail out.
471/// * Data-dependent errors (`BufferOverrun`, `InvalidOffset`,
472///   `TypeReadError::BufferOverrun`, `TypeReadError::InvalidPStringLength`,
473///   `IoError`) are logged at `warn!` and swallowed; the parent match
474///   already in `matches` is left intact. This mirrors the defensive
475///   comment in each arm: the inner `evaluate_rules` already catches and
476///   logs individual child failures, so this arm only fires if that
477///   strategy changes.
478///
479/// # Arguments
480///
481/// * `rule`      – The parent rule whose children will be evaluated.
482/// * `rule_kind` – A short label for the rule kind used in the `warn!`
483///   message (e.g. `"default"`, `"indirect"`, `"offset"`, `"use"`).
484/// * `buffer`    – The file buffer passed to the recursive call.
485/// * `context`   – Mutable evaluation context; the recursion depth is
486///   incremented on entry and decremented on drop via [`RecursionGuard`].
487/// * `matches`   – Output vector; child matches are appended here.
488fn evaluate_children_or_warn(
489    rule: &MagicRule,
490    rule_kind: &str,
491    buffer: &[u8],
492    context: &mut EvaluationContext,
493    matches: &mut Vec<RuleMatch>,
494) -> Result<(), LibmagicError> {
495    if rule.children.is_empty() {
496        return Ok(());
497    }
498    let mut guard = RecursionGuard::enter(context)?;
499    match evaluate_rules(&rule.children, buffer, guard.context()) {
500        Ok(child_matches) => {
501            matches.extend(child_matches);
502        }
503        Err(LibmagicError::Timeout { timeout_ms }) => {
504            return Err(LibmagicError::Timeout { timeout_ms });
505        }
506        // `RecursionLimitExceeded` is listed explicitly (rather than
507        // relying on the catch-all below) so a future maintainer adding
508        // another swallowed variant cannot accidentally swallow it.
509        // Both this arm and the catch-all intentionally propagate via
510        // `return Err(e)`; `match_same_arms` is suppressed because the
511        // explicit arm's purpose is documentation and future-proofing,
512        // not different behavior. See GOTCHAS S13 for the recursion-
513        // depth guard contract.
514        #[allow(clippy::match_same_arms)]
515        Err(
516            e @ LibmagicError::EvaluationError(
517                crate::error::EvaluationError::RecursionLimitExceeded { .. },
518            ),
519        ) => return Err(e),
520        Err(
521            e @ (LibmagicError::EvaluationError(
522                crate::error::EvaluationError::BufferOverrun { .. }
523                | crate::error::EvaluationError::InvalidOffset { .. }
524                | crate::error::EvaluationError::InvalidValueTransform { .. }
525                | crate::error::EvaluationError::TypeReadError(
526                    crate::evaluator::types::TypeReadError::BufferOverrun { .. }
527                    | crate::evaluator::types::TypeReadError::InvalidPStringLength { .. },
528                ),
529            )
530            | LibmagicError::IoError(_)),
531        ) => {
532            warn!(
533                "Discarding child evaluation under {} rule '{}' due to unexpected error: {} -- parent match is still emitted",
534                rule_kind, rule.message, e
535            );
536        }
537        Err(e) => return Err(e),
538    }
539    Ok(())
540}
541
542/// Evaluate a list of magic rules against a file buffer with hierarchical processing
543///
544/// This function implements the core hierarchical rule evaluation algorithm with graceful
545/// error handling:
546/// 1. Evaluates each top-level rule in sequence
547/// 2. If a parent rule matches, evaluates its child rules for refinement
548/// 3. Collects all matches or stops at first match based on configuration
549/// 4. Maintains evaluation context for recursion limits and state
550/// 5. Implements graceful degradation by skipping problematic rules and continuing evaluation
551///
552/// The hierarchical evaluation follows these principles:
553/// - Parent rules must match before children are evaluated
554/// - Child rules provide refinement and additional detail
555/// - Evaluation can stop at first match or continue for all matches
556/// - Recursion depth is limited to prevent infinite loops
557/// - Problematic rules are skipped to allow evaluation to continue
558///
559/// # Arguments
560///
561/// * `rules` - The list of magic rules to evaluate
562/// * `buffer` - The file buffer to evaluate against
563/// * `context` - Mutable evaluation context for state management. **Callers
564///   reusing a context across multiple buffers must call
565///   [`EvaluationContext::reset`](crate::evaluator::EvaluationContext::reset)
566///   between calls** -- the GNU `file` previous-match anchor and the
567///   recursion-depth counter both advance during evaluation and would
568///   otherwise leak across buffers. The same applies when this function
569///   returns `Err` mid-evaluation (e.g., `LibmagicError::Timeout` or
570///   `RecursionLimitExceeded`): both the anchor and (potentially) the
571///   recursion depth are left in a partially-advanced state, and a retry
572///   on the same context without `reset()` will resolve relative offsets
573///   against the stale anchor and apply the wrong recursion budget.
574///   [`evaluate_rules_with_config`] always builds a fresh context and is the
575///   safer choice when context reuse isn't required.
576///
577/// # Returns
578///
579/// Returns `Ok(Vec<RuleMatch>)` containing all matches found. Errors in individual rules
580/// are skipped to allow evaluation to continue. Only returns `Err(LibmagicError)`
581/// for critical failures like timeout or recursion limit exceeded.
582///
583/// # Examples
584///
585/// ```rust
586/// use libmagic_rs::evaluator::{evaluate_rules, EvaluationContext, RuleMatch};
587/// use libmagic_rs::parser::ast::{MagicRule, OffsetSpec, TypeKind, Operator, Value};
588/// use libmagic_rs::EvaluationConfig;
589///
590/// // Create a hierarchical rule set for ELF files
591/// let parent_rule = MagicRule::new(
592///     OffsetSpec::Absolute(0),
593///     TypeKind::Byte { signed: true },
594///     Operator::Equal,
595///     Value::Uint(0x7f),
596///     "ELF".to_string(),
597/// )
598/// .with_children(vec![
599///     MagicRule::new(
600///         OffsetSpec::Absolute(4),
601///         TypeKind::Byte { signed: true },
602///         Operator::Equal,
603///         Value::Uint(2),
604///         "64-bit".to_string(),
605///     )
606///     .with_level(1),
607/// ]);
608///
609/// let rules = vec![parent_rule];
610/// let buffer = &[0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01]; // ELF64 header
611/// let config = EvaluationConfig::default();
612/// let mut context = EvaluationContext::new(config);
613///
614/// let matches = evaluate_rules(&rules, buffer, &mut context).unwrap();
615/// assert_eq!(matches.len(), 2); // Parent and child should both match
616/// ```
617///
618/// # Errors
619///
620/// * `LibmagicError::Timeout` - If evaluation exceeds configured timeout
621/// * `LibmagicError::EvaluationError` - Only for critical failures like recursion limit exceeded
622///
623/// Individual rule evaluation errors are handled gracefully and do not stop the overall evaluation.
624#[allow(clippy::too_many_lines)]
625pub fn evaluate_rules(
626    rules: &[MagicRule],
627    buffer: &[u8],
628    context: &mut EvaluationContext,
629) -> Result<Vec<RuleMatch>, LibmagicError> {
630    let mut matches = Vec::with_capacity(8);
631    let start_time = std::time::Instant::now();
632    let mut rule_count = 0u32;
633
634    // Per-level "did any sibling match yet?" flag for `default`/`clear`
635    // dispatch. Each recursive descent gets its own fresh flag, so child
636    // sibling chains track their own state independently of the parent.
637    let mut sibling_matched = false;
638
639    // Per-level entry anchor: captured at the start of this sibling list's
640    // evaluation. For CHILD sibling lists (recursion_depth > 0), the
641    // GNU `file`/libmagic previous-match anchor is reset to this value
642    // between sibling iterations so that `&N` offsets on continuation
643    // siblings resolve against the parent-level anchor, not against
644    // whatever the *previous sibling* left the anchor at. This matches
645    // libmagic's continuation-level model (`ms->c.li[cont_level]`)
646    // where each level tracks its own anchor; a sibling at level L does
647    // not inherit the post-match anchor of another sibling at level L.
648    //
649    // TOP-LEVEL siblings (recursion_depth == 0) are independent
650    // classification attempts -- each top-level rule intentionally sees
651    // the anchor advance that prior top-level rules produced (see
652    // GOTCHAS S3.8 and the `relative_anchor_can_decrease_...`
653    // integration test). Gate the reset on recursion_depth to preserve
654    // that documented discipline while still fixing the continuation-
655    // sibling behavior that the GNU `file` `searchbug.magic` fixture
656    // relies on.
657    //
658    // Recursing into a matched rule's children still carries forward the
659    // post-match anchor (via the current value of `last_match_end()` at
660    // the point of recursion), so child sibling lists see their parent's
661    // resolved position as their own entry anchor.
662    //
663    // INDIRECT RE-ENTRY exception: `MetaType::Indirect` dispatches its
664    // sub-evaluation via `RecursionGuard::enter` (to bound the recursion
665    // cycle), which forces `recursion_depth > 0`. But an indirect
666    // re-entry semantically evaluates the root rule list with TOP-LEVEL
667    // sibling semantics -- each rule is an independent classification
668    // attempt against the re-entered sub-buffer, NOT a continuation
669    // list. The indirect dispatch sets `context.set_indirect_reentry(true)`
670    // just before this call; `take_indirect_reentry()` consumes it at
671    // entry so only this iteration treats siblings as top-level.
672    // Children of matched rules inside the re-entry still see the flag
673    // as false (consumed) and correctly fall back to continuation
674    // semantics via `recursion_depth > 0`.
675    let entry_anchor = context.last_match_end();
676    let is_indirect_reentry = context.take_indirect_reentry();
677    let is_child_sibling_list = context.recursion_depth() > 0 && !is_indirect_reentry;
678
679    // Entry-point timeout check: ensures every recursive descent is bounded
680    // and that evaluations of small rule sets (< 16 rules) are still guarded.
681    // Without this, the periodic every-16-rules check below never fires for
682    // flat rule lists with fewer than 16 rules, and recursion into children
683    // also restarts `rule_count` at 0.
684    if let Some(timeout_ms) = context.timeout_ms()
685        && start_time.elapsed().as_millis() >= u128::from(timeout_ms)
686    {
687        return Err(LibmagicError::Timeout { timeout_ms });
688    }
689
690    for rule in rules {
691        // For continuation siblings (child recursion), reset the
692        // previous-match anchor to the entry anchor so `&N` offsets
693        // resolve against the parent-level position. Top-level
694        // siblings (depth 0) keep the chaining behavior documented in
695        // GOTCHAS S3.8. See the `entry_anchor` comment above.
696        if is_child_sibling_list {
697            context.set_last_match_end(entry_anchor);
698        }
699
700        // Check timeout periodically (every 16 rules) to reduce syscall overhead
701        rule_count = rule_count.wrapping_add(1);
702        if rule_count.trailing_zeros() >= 4
703            && let Some(timeout_ms) = context.timeout_ms()
704            && start_time.elapsed().as_millis() >= u128::from(timeout_ms)
705        {
706            return Err(LibmagicError::Timeout { timeout_ms });
707        }
708
709        // `Clear` resets the per-level "sibling matched" flag so a
710        // subsequent `default` sibling can fire even if an earlier
711        // sibling matched. It does not produce a match, evaluate
712        // children, or advance the anchor.
713        if let TypeKind::Meta(MetaType::Clear) = &rule.typ {
714            sibling_matched = false;
715            continue;
716        }
717
718        // `Default` fires only when no earlier sibling at this level has
719        // matched yet. The anchor is intentionally not advanced -- the
720        // directive does not consume bytes -- but its children are
721        // evaluated and the per-level "sibling matched" flag is set so
722        // any later `default` sibling at the same level is suppressed.
723        if let TypeKind::Meta(MetaType::Default) = &rule.typ {
724            if !sibling_matched {
725                let matches_before = matches.len();
726
727                let match_result = RuleMatch::new(
728                    rule.message.clone(),
729                    context.last_match_end(),
730                    rule.level,
731                    crate::parser::ast::Value::Uint(0),
732                    rule.typ.clone(),
733                    RuleMatch::calculate_confidence(rule.level),
734                );
735                matches.push(match_result);
736
737                // `default` is treated as a successful match at this
738                // level, so its children are evaluated under the same
739                // recursion-guard pattern as every other successful rule.
740                evaluate_children_or_warn(rule, "default", buffer, context, &mut matches)?;
741
742                sibling_matched = true;
743
744                if matches.len() > matches_before && context.should_stop_at_first_match() {
745                    break;
746                }
747            }
748            continue;
749        }
750
751        // `Indirect` re-evaluates the root rule list at the resolved
752        // offset, mirroring libmagic's indirect-type semantics. The
753        // sub-evaluation runs against `buffer[absolute_offset..]` with a
754        // fresh anchor (0) so relative offsets inside the root rules
755        // resolve correctly; the caller's anchor is restored on exit
756        // via `AnchorScope`. Without an attached `RuleEnvironment`
757        // (programmatic consumers bypassing `MagicDatabase`) the
758        // directive is a silent no-op.
759        if let TypeKind::Meta(MetaType::Indirect) = &rule.typ {
760            // Resolve the offset first so a malformed offset surfaces
761            // as a graceful skip rather than a hard error.
762            let absolute_offset = match offset::resolve_offset_with_base(
763                &rule.offset,
764                buffer,
765                context.last_match_end(),
766                context.base_offset(),
767            ) {
768                Ok(o) => o,
769                Err(
770                    e @ LibmagicError::EvaluationError(
771                        crate::error::EvaluationError::BufferOverrun { .. }
772                        | crate::error::EvaluationError::InvalidOffset { .. },
773                    ),
774                ) => {
775                    debug!("Skipping indirect rule '{}': {}", rule.message, e);
776                    continue;
777                }
778                Err(e) => return Err(e),
779            };
780
781            // Pull the root rules out of the rule environment. Without
782            // an environment there is nothing to re-enter, so this is a
783            // silent no-op (matching the `Use`-without-env behavior).
784            //
785            // We use `debug!` rather than `debug_assert!` here because
786            // property tests (`prop_arbitrary_rule_evaluation_never_panics`)
787            // synthesize arbitrary `TypeKind::Meta(MetaType::Indirect)`
788            // rules and run them without attaching a `RuleEnvironment`;
789            // a panic on this path would break the never-panics invariant.
790            // See GOTCHAS S2.1 for the same rationale on the leaked-Name arm.
791            let Some(root_rules) = context
792                .rule_env()
793                .map(|e| std::sync::Arc::clone(&e.root_rules))
794            else {
795                debug!(
796                    "indirect rule '{}' evaluated without a rule environment; treating as no-op",
797                    rule.message
798                );
799                continue;
800            };
801
802            // Bounds-check before slicing. An indirect offset past the
803            // end of the buffer is a data-dependent skip, not an error.
804            let Some(sub_buffer) = buffer.get(absolute_offset..) else {
805                debug!(
806                    "Skipping indirect rule '{}': offset {} past buffer end ({} bytes)",
807                    rule.message,
808                    absolute_offset,
809                    buffer.len()
810                );
811                continue;
812            };
813
814            let matches_before = matches.len();
815
816            // Advance the GNU `file` previous-match anchor to the indirect's
817            // resolved offset and emit a `RuleMatch` for the indirect rule
818            // itself BEFORE descending into the root re-entry or children.
819            // This matches the shared successful-match flow used by every
820            // other rule kind: advance anchor first, record the match, then
821            // recurse. Without this, sibling rules of the `indirect` resolve
822            // their relative offsets against the stale anchor and the
823            // directive's own `message` never surfaces in the output.
824            context.set_last_match_end(absolute_offset);
825
826            let indirect_match = RuleMatch::new(
827                rule.message.clone(),
828                absolute_offset,
829                rule.level,
830                crate::parser::ast::Value::String("indirect".to_string()),
831                rule.typ.clone(),
832                RuleMatch::calculate_confidence(rule.level),
833            );
834            matches.push(indirect_match);
835
836            // Indirect counts as a match for `sibling_matched` regardless of
837            // whether the sub-evaluation produced any matches -- the directive
838            // itself successfully dispatched.
839            sibling_matched = true;
840
841            // Recursion guard + anchor scope: nested indirect / use cycles
842            // surface as `RecursionLimitExceeded` instead of a stack overflow,
843            // and the caller's anchor is restored on every exit path.
844            //
845            // Mark the upcoming `evaluate_rules` call as a top-level
846            // re-entry (consumed at entry) so sibling anchor-reset
847            // semantics do NOT fire -- root rules in the re-entered
848            // database chain their anchors across siblings like any
849            // other top-level evaluation.
850            {
851                let mut guard = RecursionGuard::enter(context)?;
852                let mut anchor_scope = AnchorScope::enter(guard.context(), 0);
853                anchor_scope.context().set_indirect_reentry(true);
854                match evaluate_rules(&root_rules, sub_buffer, anchor_scope.context()) {
855                    Ok(sub_matches) => {
856                        matches.extend(sub_matches);
857                    }
858                    Err(LibmagicError::Timeout { timeout_ms }) => {
859                        return Err(LibmagicError::Timeout { timeout_ms });
860                    }
861                    Err(e) => return Err(e),
862                }
863                // anchor_scope drops here, restoring the saved anchor
864                // (which is now `absolute_offset`, set above before the
865                // scope was entered).
866                // guard drops next, decrementing the recursion depth.
867            }
868
869            // Evaluate the indirect rule's own children under the same
870            // recursion-guard pattern used by every other successful rule.
871            evaluate_children_or_warn(rule, "indirect", buffer, context, &mut matches)?;
872
873            if matches.len() > matches_before && context.should_stop_at_first_match() {
874                break;
875            }
876            continue;
877        }
878
879        // `Offset` reports the resolved file offset as the rule's read
880        // value, matching GNU `file`'s `FILE_OFFSET` semantics: the match
881        // emits a value-bearing `RuleMatch` whose `value` is the absolute
882        // position, which downstream message formatting substitutes into
883        // `%lld` / `%d` specifiers via `output::format::format_magic_message`.
884        //
885        // Per magic(5) the only legal operator is `x` (AnyValue); any
886        // other operator is a magic-file semantic error. Matching the
887        // evaluator's graceful-skip discipline, we `debug!`-log and skip
888        // rather than erroring -- a rogue rule shouldn't poison the rest
889        // of the evaluation.
890        if let TypeKind::Meta(MetaType::Offset) = &rule.typ {
891            if !matches!(rule.op, crate::parser::ast::Operator::AnyValue) {
892                debug!(
893                    "offset rule '{}': non-`x` operator {:?} not supported; skipping",
894                    rule.message, rule.op
895                );
896                continue;
897            }
898
899            // Resolve the offset first so a malformed offset surfaces as
900            // a graceful skip rather than a hard error. Mirrors the
901            // `Indirect` dispatch above.
902            let absolute_offset = match offset::resolve_offset_with_base(
903                &rule.offset,
904                buffer,
905                context.last_match_end(),
906                context.base_offset(),
907            ) {
908                Ok(o) => o,
909                Err(
910                    e @ LibmagicError::EvaluationError(
911                        crate::error::EvaluationError::BufferOverrun { .. }
912                        | crate::error::EvaluationError::InvalidOffset { .. },
913                    ),
914                ) => {
915                    debug!("Skipping offset rule '{}': {}", rule.message, e);
916                    continue;
917                }
918                Err(e) => return Err(e),
919            };
920
921            let matches_before = matches.len();
922
923            // Advance the anchor BEFORE emitting the match so sibling
924            // rules resolve their relative offsets against the offset
925            // directive's resolved position. Same discipline as
926            // `Indirect` and every other value-bearing rule.
927            context.set_last_match_end(absolute_offset);
928
929            let offset_match = RuleMatch::new(
930                rule.message.clone(),
931                absolute_offset,
932                rule.level,
933                crate::parser::ast::Value::Uint(absolute_offset as u64),
934                rule.typ.clone(),
935                RuleMatch::calculate_confidence(rule.level),
936            );
937            matches.push(offset_match);
938
939            sibling_matched = true;
940
941            // Evaluate children under the recursion-guard pattern used
942            // by every other successful rule.
943            evaluate_children_or_warn(rule, "offset", buffer, context, &mut matches)?;
944
945            if matches.len() > matches_before && context.should_stop_at_first_match() {
946                break;
947            }
948            continue;
949        }
950
951        // `Use` is handled inline so the subroutine's matches can be
952        // spliced into the caller's match vector in document order.
953        // Routing this through `evaluate_single_rule_with_anchor` would
954        // force the helper to return a `Vec<RuleMatch>`, which would
955        // reshape the single-rule return type for every other variant.
956        //
957        // On a successful use path we must also descend into the rule's
958        // own children, matching the flow of every other successful rule
959        // kind. libmagic chains like `>>0 use part2` often carry
960        // continuation rules (siblings and descendants of the `use` site)
961        // that depend on the anchor the subroutine left behind; skipping
962        // them produces user-visible false negatives.
963        if let TypeKind::Meta(MetaType::Use(name)) = &rule.typ {
964            let matches_before = matches.len();
965            let use_resolved = match evaluate_use_rule(rule, name, buffer, context) {
966                Ok((Some(terminal_anchor), subroutine_matches)) => {
967                    matches.extend(subroutine_matches);
968
969                    // A `use` rule does not produce a surface
970                    // `RuleMatch` itself -- the subroutine's rules
971                    // carry the visible messages. Advance the
972                    // caller's anchor to the subroutine's TERMINAL
973                    // anchor (where the subroutine left `last_match_end`),
974                    // not the use-site offset. This makes `use`
975                    // behave like inlining the subroutine: sibling
976                    // rules after the `use` see `&N` resolve against
977                    // the subroutine's final match position.
978                    context.set_last_match_end(terminal_anchor);
979                    true
980                }
981                Ok((None, _)) => {
982                    // No environment, or name not found -- silent no-op.
983                    false
984                }
985                Err(
986                    e @ LibmagicError::EvaluationError(
987                        crate::error::EvaluationError::BufferOverrun { .. }
988                        | crate::error::EvaluationError::InvalidOffset { .. },
989                    ),
990                ) => {
991                    debug!("Skipping use rule '{name}': {e}");
992                    false
993                }
994                Err(e) => return Err(e),
995            };
996
997            // Evaluate the use rule's own children exactly like any other
998            // successful rule. Subroutine matches are already appended
999            // above, so children are spliced in after them to preserve
1000            // document order. The recursion guard mirrors the non-`Use`
1001            // path so a `use`-site chain cannot blow past the configured
1002            // recursion limit.
1003            if use_resolved {
1004                evaluate_children_or_warn(rule, "use", buffer, context, &mut matches)?;
1005            }
1006
1007            // A successful `use` site is treated as a sibling match for
1008            // `default`/`clear` dispatch purposes -- subsequent `default`
1009            // siblings should not fire if the subroutine resolved.
1010            if use_resolved {
1011                sibling_matched = true;
1012            }
1013
1014            // Apply stop-at-first-match with the same semantics as every
1015            // other successful rule kind: if this `use` site contributed
1016            // any matches (either from the subroutine or from its own
1017            // children) and the caller configured first-match
1018            // short-circuiting, halt evaluation of further siblings.
1019            if matches.len() > matches_before && context.should_stop_at_first_match() {
1020                break;
1021            }
1022            continue;
1023        }
1024
1025        // Evaluate the current rule with graceful error handling.
1026        // Pass the GNU `file` anchor so OffsetSpec::Relative resolves
1027        // correctly against the previous match's end position.
1028        let match_data = match evaluate_single_rule_with_anchor(
1029            rule,
1030            buffer,
1031            context.last_match_end(),
1032            context.base_offset(),
1033            context.max_string_length(),
1034        ) {
1035            Ok(data) => data,
1036            Err(
1037                e @ (LibmagicError::EvaluationError(
1038                    crate::error::EvaluationError::BufferOverrun { .. }
1039                    | crate::error::EvaluationError::InvalidOffset { .. }
1040                    | crate::error::EvaluationError::InvalidValueTransform { .. }
1041                    | crate::error::EvaluationError::TypeReadError(
1042                        crate::evaluator::types::TypeReadError::BufferOverrun { .. }
1043                        | crate::evaluator::types::TypeReadError::InvalidPStringLength { .. },
1044                    ),
1045                )
1046                | LibmagicError::IoError(_)),
1047            ) => {
1048                // Expected data-dependent evaluation errors -- skip gracefully.
1049                // TypeReadError::UnsupportedType is intentionally NOT caught here
1050                // so that evaluator capability gaps propagate as errors.
1051                debug!("Skipping rule '{}': {}", rule.message, e);
1052                continue;
1053            }
1054            Err(e) => {
1055                // Unexpected errors (InternalError, UnsupportedType, etc.) should propagate
1056                return Err(e);
1057            }
1058        };
1059
1060        if let Some((absolute_offset, read_value)) = match_data {
1061            // Advance the GNU `file` previous-match anchor BEFORE recursing
1062            // into children, so children and their descendants see the new
1063            // anchor. The anchor is updated unconditionally to the end of
1064            // this match -- it may move forward or backward depending on
1065            // where successive rules match (it is *not* a high-watermark).
1066            let consumed = types::bytes_consumed_with_pattern(
1067                buffer,
1068                absolute_offset,
1069                &rule.typ,
1070                Some(&rule.value),
1071            );
1072            let new_anchor = absolute_offset.saturating_add(consumed);
1073            context.set_last_match_end(new_anchor);
1074
1075            // Mark this level as "matched" so any subsequent `default`
1076            // sibling at the same level is suppressed, matching libmagic's
1077            // default-after-match semantics.
1078            sibling_matched = true;
1079
1080            let match_result = RuleMatch::new(
1081                rule.message.clone(),
1082                absolute_offset,
1083                rule.level,
1084                read_value,
1085                rule.typ.clone(),
1086                RuleMatch::calculate_confidence(rule.level),
1087            );
1088            matches.push(match_result);
1089
1090            // If this rule has children, evaluate them recursively
1091            if !rule.children.is_empty() {
1092                // Check recursion depth limit - this is a critical error that should stop evaluation.
1093                // `RecursionGuard` decrements the depth on drop, so every exit path below
1094                // (Ok, graceful warn!, or early-return via `?`) restores the counter.
1095                let mut guard = RecursionGuard::enter(context)?;
1096
1097                // Recursively evaluate child rules with graceful error handling
1098                match evaluate_rules(&rule.children, buffer, guard.context()) {
1099                    Ok(child_matches) => {
1100                        matches.extend(child_matches);
1101                    }
1102                    Err(LibmagicError::Timeout { timeout_ms }) => {
1103                        // Timeout is critical, propagate it up (guard drops here).
1104                        return Err(LibmagicError::Timeout { timeout_ms });
1105                    }
1106                    Err(
1107                        e @ (LibmagicError::EvaluationError(
1108                            crate::error::EvaluationError::BufferOverrun { .. }
1109                            | crate::error::EvaluationError::InvalidOffset { .. }
1110                            | crate::error::EvaluationError::InvalidValueTransform { .. }
1111                            | crate::error::EvaluationError::TypeReadError(
1112                                crate::evaluator::types::TypeReadError::BufferOverrun { .. }
1113                                | crate::evaluator::types::TypeReadError::InvalidPStringLength {
1114                                    ..
1115                                },
1116                            ),
1117                        )
1118                        | LibmagicError::IoError(_)),
1119                    ) => {
1120                        // Defensive: under the current implementation, individual child
1121                        // failures are caught and logged inside the recursive evaluate_rules
1122                        // call (they never propagate here). This arm guards against future
1123                        // changes that might alter that error-handling strategy.
1124                        //
1125                        // If this fires, the parent match is still emitted but the entire
1126                        // child subtree is silently dropped -- which means a partial,
1127                        // possibly-incorrect classification is returned to the caller.
1128                        // Logged at warn! (not debug!) so the asymmetry is visible.
1129                        warn!(
1130                            "Discarding child evaluation under rule '{}' due to unexpected error: {} -- parent match is still emitted; investigate the recursive evaluate_rules error-handling path",
1131                            rule.message, e
1132                        );
1133                    }
1134                    Err(e) => {
1135                        // Unexpected errors in children (including RecursionLimitExceeded)
1136                        // should propagate. The guard drops here, decrementing the depth.
1137                        return Err(e);
1138                    }
1139                }
1140                // `guard` drops here, decrementing the recursion depth.
1141            }
1142
1143            // Stop at first match if configured to do so
1144            if context.should_stop_at_first_match() {
1145                break;
1146            }
1147        }
1148    }
1149
1150    Ok(matches)
1151}
1152
1153/// Evaluate magic rules with a fresh context
1154///
1155/// This is a convenience function that creates a new evaluation context
1156/// and evaluates the rules. Useful for simple evaluation scenarios.
1157///
1158/// # Arguments
1159///
1160/// * `rules` - The list of magic rules to evaluate
1161/// * `buffer` - The file buffer to evaluate against
1162/// * `config` - Configuration for evaluation behavior
1163///
1164/// # Returns
1165///
1166/// Returns `Ok(Vec<RuleMatch>)` containing all matches found, or `Err(LibmagicError)`
1167/// if evaluation fails.
1168///
1169/// # Examples
1170///
1171/// ```rust
1172/// use libmagic_rs::evaluator::{evaluate_rules_with_config, RuleMatch};
1173/// use libmagic_rs::parser::ast::{MagicRule, OffsetSpec, TypeKind, Operator, Value};
1174/// use libmagic_rs::EvaluationConfig;
1175///
1176/// let rule = MagicRule::new(OffsetSpec::Absolute(0), TypeKind::Byte { signed: true }, Operator::Equal, Value::Uint(0x7f), "ELF magic".to_string());
1177///
1178/// let rules = vec![rule];
1179/// let buffer = &[0x7f, 0x45, 0x4c, 0x46];
1180/// let config = EvaluationConfig::default();
1181///
1182/// let matches = evaluate_rules_with_config(&rules, buffer, &config).unwrap();
1183/// assert_eq!(matches.len(), 1);
1184/// assert_eq!(matches[0].message, "ELF magic");
1185/// ```
1186///
1187/// # Errors
1188///
1189/// * `LibmagicError::EvaluationError` - If rule evaluation fails
1190/// * `LibmagicError::Timeout` - If evaluation exceeds configured timeout
1191pub fn evaluate_rules_with_config(
1192    rules: &[MagicRule],
1193    buffer: &[u8],
1194    config: &EvaluationConfig,
1195) -> Result<Vec<RuleMatch>, LibmagicError> {
1196    // Validate the configuration before constructing a context so that
1197    // out-of-range values (e.g. zero recursion depth, excessive timeouts)
1198    // are rejected at the API boundary rather than triggering subtle
1199    // failures during evaluation.
1200    config.validate()?;
1201    // Diagnostic guard: `evaluate_rules_with_config` builds a context
1202    // without an attached `RuleEnvironment`, which means any
1203    // `MetaType::Indirect` rule reached during evaluation is silently
1204    // no-op'd at runtime. That is the intentional behavior for low-level
1205    // callers (matching the `Use`-without-env contract), but we surface
1206    // the misconfiguration at `warn!` level (once per process) so a
1207    // consumer who wires up env-less `indirect` rules will see the
1208    // diagnostic in default logging rather than only at debug level.
1209    // The tree walk runs only in debug builds -- in release builds the
1210    // `cfg(debug_assertions)` gate prevents the O(n) scan on every
1211    // top-level evaluation. Using `debug_assert!` would panic in test
1212    // builds and break the "evaluator never panics" invariant documented
1213    // in GOTCHAS S2.4 -- a misconfigured caller should get a no-op with
1214    // a log entry, not a crash.
1215    #[cfg(debug_assertions)]
1216    if contains_indirect_rule(rules)
1217        && !INDIRECT_WITHOUT_RULE_ENV_WARNED.swap(true, Ordering::Relaxed)
1218    {
1219        warn!(
1220            "{} (subsequent occurrences suppressed)",
1221            crate::error::EvaluationError::indirect_without_environment()
1222        );
1223    }
1224    // Clear the thread-local regex compile cache so it is bounded to
1225    // the lifetime of a single top-level evaluation call. Cache
1226    // entries from a previous rule set would otherwise persist on the
1227    // current thread until process exit. See
1228    // `evaluator::types::regex::reset_regex_cache` for rationale.
1229    crate::evaluator::types::regex::reset_regex_cache();
1230    let mut context = EvaluationContext::new(config.clone());
1231    evaluate_rules(rules, buffer, &mut context)
1232}
1233
1234/// Recursively walk `rules` (including children) looking for any
1235/// [`MetaType::Indirect`] directive.
1236///
1237/// Used by the diagnostic guard in [`evaluate_rules_with_config`]: the
1238/// low-level `_with_config` entry point builds a context without a
1239/// [`crate::evaluator::RuleEnvironment`], so any `indirect` rule is
1240/// silently no-op'd at runtime. The check logs the misconfiguration at
1241/// `debug!` level so consumer tests can detect it without panicking (see
1242/// GOTCHAS S2.4 for why `debug_assert!` would be wrong here).
1243// Gated to debug builds like its only caller (see the diagnostic guard in
1244// `evaluate_rules_with_config`).
1245#[cfg(debug_assertions)]
1246fn contains_indirect_rule(rules: &[MagicRule]) -> bool {
1247    rules.iter().any(|rule| {
1248        matches!(rule.typ, TypeKind::Meta(MetaType::Indirect))
1249            || contains_indirect_rule(&rule.children)
1250    })
1251}
1252
1253#[cfg(test)]
1254mod tests;