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    saved_flip: bool,
89}
90
91impl<'a> SubroutineScope<'a> {
92    /// Enter a subroutine body. `flip_use` is the `\^` prefix on the
93    /// invoking `use` site; the effective flip inside the body is the
94    /// caller's flip state XOR `flip_use` -- matching libmagic's `flip =
95    /// !flip` toggle, which nests: a `\^use` inside an already-flipped
96    /// subroutine un-flips. Restored on drop along with anchor and base.
97    fn enter(context: &'a mut EvaluationContext, use_site: usize, flip_use: bool) -> Self {
98        let saved_anchor = context.last_match_end();
99        let saved_base = context.base_offset();
100        let saved_flip = context.flip_endian();
101        context.set_last_match_end(use_site);
102        context.set_base_offset(use_site);
103        context.set_flip_endian(saved_flip ^ flip_use);
104        Self {
105            context,
106            saved_anchor,
107            saved_base,
108            saved_flip,
109        }
110    }
111
112    fn context(&mut self) -> &mut EvaluationContext {
113        self.context
114    }
115}
116
117impl Drop for SubroutineScope<'_> {
118    fn drop(&mut self) {
119        self.context.set_last_match_end(self.saved_anchor);
120        self.context.set_base_offset(self.saved_base);
121        self.context.set_flip_endian(self.saved_flip);
122    }
123}
124
125/// Process-local once guard for the "use directive without rule environment"
126/// warning. Ensures we surface the misconfiguration exactly once per process
127/// so low-level programmatic consumers of [`evaluate_rules`] (tests, fuzz
128/// harnesses) that intentionally run without a `MagicDatabase`-attached
129/// environment do not flood the log on every `Use` rule they encounter.
130static USE_WITHOUT_RULE_ENV_WARNED: AtomicBool = AtomicBool::new(false);
131
132/// Process-local once guard for the "`evaluate_rules_with_config` called
133/// with an `indirect` rule but without a `RuleEnvironment`" warning.
134/// Same rationale as `USE_WITHOUT_RULE_ENV_WARNED`: surface the
135/// misconfiguration exactly once per process so a large corpus of
136/// env-less `indirect` rules does not flood the log.
137// Gated to debug builds like its only use site (the diagnostic guard in
138// `evaluate_rules_with_config`); in release builds the item would be dead
139// code, which the workspace `warnings = "deny"` lint rejects.
140#[cfg(debug_assertions)]
141static INDIRECT_WITHOUT_RULE_ENV_WARNED: AtomicBool = AtomicBool::new(false);
142
143/// Evaluate a single magic rule against a file buffer
144///
145/// This is a thin wrapper around [`evaluate_rules`] that evaluates exactly
146/// one top-level rule (and any of its children) against a buffer, using the
147/// caller-provided [`EvaluationContext`] to enforce timeout, recursion, and
148/// string-size limits. It is a BREAKING API change introduced in pre-1.0:
149/// earlier versions took no context and returned `Option<(usize, Value)>`.
150///
151/// # Arguments
152///
153/// * `rule` - The magic rule to evaluate
154/// * `buffer` - The file buffer to evaluate against
155/// * `context` - Mutable evaluation context that carries the configured
156///   safety limits (timeout, max recursion depth, max string length) and
157///   the GNU `file` previous-match anchor used for relative-offset
158///   resolution. Callers reusing a context across multiple buffers must
159///   call [`EvaluationContext::reset`](crate::evaluator::EvaluationContext::reset)
160///   between calls -- see [`evaluate_rules`] for details.
161///
162/// # Returns
163///
164/// Returns `Ok(Vec<RuleMatch>)` containing the parent match (if the rule
165/// matched) plus any child matches collected recursively. An empty vector
166/// means the rule did not match or was skipped due to a data-dependent
167/// evaluation error (buffer overrun, invalid offset, etc.). Only critical
168/// failures such as `LibmagicError::Timeout` or recursion-limit exhaustion
169/// are returned as `Err`.
170///
171/// # Examples
172///
173/// ```rust
174/// use libmagic_rs::evaluator::{evaluate_single_rule, EvaluationContext};
175/// use libmagic_rs::EvaluationConfig;
176/// use libmagic_rs::parser::ast::{MagicRule, OffsetSpec, TypeKind, Operator, Value};
177///
178/// // Create a rule to check for ELF magic bytes at offset 0
179/// let rule = MagicRule::new(OffsetSpec::Absolute(0), TypeKind::Byte { signed: true }, Operator::Equal, Value::Uint(0x7f), "ELF magic".to_string());
180///
181/// let mut context = EvaluationContext::new(EvaluationConfig::default());
182/// let elf_buffer = &[0x7f, 0x45, 0x4c, 0x46]; // ELF magic bytes
183/// let matches = evaluate_single_rule(&rule, elf_buffer, &mut context).unwrap();
184/// assert_eq!(matches.len(), 1); // Should match
185///
186/// context.reset();
187/// let non_elf_buffer = &[0x50, 0x4b, 0x03, 0x04]; // ZIP magic bytes
188/// let matches = evaluate_single_rule(&rule, non_elf_buffer, &mut context).unwrap();
189/// assert!(matches.is_empty()); // Should not match
190/// ```
191///
192/// # Errors
193///
194/// * `LibmagicError::Timeout` - If evaluation exceeds the configured timeout
195/// * `LibmagicError::EvaluationError` - For critical failures such as the
196///   recursion limit being exceeded. Data-dependent errors (buffer overrun,
197///   invalid offset, malformed pstring length) are handled gracefully by
198///   [`evaluate_rules`] and surface as an empty match vector rather than
199///   an error.
200pub fn evaluate_single_rule(
201    rule: &MagicRule,
202    buffer: &[u8],
203    context: &mut EvaluationContext,
204) -> Result<Vec<RuleMatch>, LibmagicError> {
205    evaluate_rules(std::slice::from_ref(rule), buffer, context)
206}
207
208/// Internal: evaluate a single rule against a buffer, supplying an explicit
209/// anchor for relative-offset resolution.
210///
211/// This is the worker behind both [`evaluate_single_rule`] (which defaults
212/// the anchor to 0) and [`evaluate_rules`] (which threads the anchor from
213/// `EvaluationContext::last_match_end()`).
214fn evaluate_single_rule_with_anchor(
215    rule: &MagicRule,
216    buffer: &[u8],
217    last_match_end: usize,
218    base_offset: usize,
219    max_string_length: usize,
220    flip_endian: bool,
221) -> Result<Option<(usize, crate::parser::ast::Value)>, LibmagicError> {
222    use crate::parser::ast::TypeKind;
223
224    // Step 1: Resolve the offset specification to an absolute position.
225    // `base_offset` is non-zero only inside a `MetaType::Use` subroutine
226    // body, where it biases positive absolute offsets to the use-site.
227    let absolute_offset =
228        offset::resolve_offset_with_base(&rule.offset, buffer, last_match_end, base_offset)?;
229
230    // Step 2 & 3: Dispatch on type category. Pattern-bearing types
231    // (Regex, Search) take a different path from fixed-width types
232    // because the rule's `value` operand is the *pattern*, not an
233    // expected matched value. Running those through `apply_operator`
234    // would compare matched text ("123") against the pattern literal
235    // ("[0-9]+") and produce false negatives on any regex with
236    // metacharacters.
237    //
238    // Meta-type directives (`default`, `clear`, `name`, `use`,
239    // `indirect`, `offset`) are dispatched by `evaluate_rules` at the
240    // outer loop level (not here) -- this single-rule helper is only
241    // invoked for non-meta rules. Short-circuiting the Meta arms here
242    // with `Ok(None)` is defense-in-depth for programmatic callers
243    // (property tests, fuzz harnesses) that hand-build a Meta rule
244    // and feed it directly to `evaluate_single_rule`; without the
245    // guard, the value/pattern paths would surface
246    // `TypeReadError::UnsupportedType`.
247    let (matched, read_value) = match &rule.typ {
248        TypeKind::Meta(MetaType::Name(name)) => {
249            // `Name` rules are normally hoisted into the name table at
250            // parse time and should not reach the evaluator. Programmatic
251            // consumers (e.g. fuzz harnesses, property tests) can still
252            // construct them directly; treat that as a no-op rather than
253            // a hard failure so the evaluator-never-panics invariant is
254            // preserved.
255            debug!(
256                "Name rule '{name}' reached evaluator (likely bypassed name-table extraction); treating as no-op"
257            );
258            return Ok(None);
259        }
260        TypeKind::Meta(MetaType::Use { .. }) => {
261            // `Use` is dispatched inline by `evaluate_rules` so it can
262            // push the subroutine's matches into the caller's match
263            // vector. Reaching this arm means the rule went through the
264            // single-rule path (e.g. via `evaluate_single_rule`) which
265            // lacks that wiring; treat it as a silent no-op.
266            return Ok(None);
267        }
268        TypeKind::Meta(_) => return Ok(None),
269        TypeKind::Regex { .. } | TypeKind::Search { .. } => {
270            evaluate_pattern_rule(rule, buffer, absolute_offset, max_string_length)?
271        }
272        // Flagged `string` rules route through the pattern-bearing path
273        // (see GOTCHAS S2.4 for the contract) so `compare_string_with_flags`
274        // can do the case-fold / whitespace-flexible match in one pass --
275        // but ONLY for the equality operators the pattern path supports.
276        // An ORDERING operator on a flagged string (e.g. the ubiquitous
277        // `string/t >\0` / `string/b >\0` "non-empty text here, print it with
278        // %s" idiom in `varied.script`, `sgml`, `linux`, ...) is a
279        // lexicographic comparison, not a pattern match; routing it to the
280        // pattern path made it a fatal `UnsupportedType` abort that killed the
281        // whole file's evaluation. The `/t`/`/b` flags are MIME-output hints
282        // with no comparison effect, so such a rule behaves like an unflagged
283        // `string >VALUE` and belongs on the value path. Default-flag strings
284        // (the common case) also take that value-rule fast path.
285        TypeKind::String { flags, .. }
286            if !flags.is_empty()
287                && matches!(
288                    rule.op,
289                    crate::parser::ast::Operator::Equal | crate::parser::ast::Operator::NotEqual
290                ) =>
291        {
292            evaluate_pattern_rule(rule, buffer, absolute_offset, max_string_length)?
293        }
294        _ => evaluate_value_rule(
295            rule,
296            buffer,
297            absolute_offset,
298            max_string_length,
299            flip_endian,
300        )?,
301    };
302    Ok(matched.then_some((absolute_offset, read_value)))
303}
304
305/// Evaluate a `TypeKind::Meta(MetaType::Use { name, .. })` rule inline.
306///
307/// Looks up `name` in the context's rule environment, temporarily sets the
308/// GNU `file` previous-match anchor to the resolved offset, and recursively
309/// evaluates the subroutine's rules against `buffer`. Any matches produced
310/// by the subroutine are returned in document order and are intended to be
311/// pushed into the caller's match vector *before* the synthetic `Use` match
312/// itself (matching GNU `file` behavior where a `use` site is replaced by
313/// its expansion in the output).
314///
315/// Returns `Ok((Some(absolute_offset), matches))` on a successful resolution
316/// (even if the subroutine produced no matches), or `Ok((None, vec![]))`
317/// when:
318/// - the context has no rule environment attached (programmatic consumers
319///   bypassing `MagicDatabase`)
320/// - the referenced name is not in the table (logged at warn level)
321///
322/// Recursion-limit propagation is handled via [`RecursionGuard`] so that a
323/// subroutine calling `use` on itself triggers `RecursionLimitExceeded`
324/// instead of a stack overflow.
325fn evaluate_use_rule(
326    rule: &MagicRule,
327    name: &str,
328    flip_endian: bool,
329    buffer: &[u8],
330    context: &mut EvaluationContext,
331) -> Result<(Option<usize>, Vec<RuleMatch>), LibmagicError> {
332    let Some(env) = context.rule_env() else {
333        // Surface the misconfiguration once per process at warn! level so
334        // it is visible in default logging, then gate subsequent hits so a
335        // magic file with many `use` directives does not flood the log.
336        // Use `Ordering::Relaxed`: the flag is an idempotent diagnostic
337        // latch, not a synchronization primitive guarding other state.
338        if USE_WITHOUT_RULE_ENV_WARNED.swap(true, Ordering::Relaxed) {
339            debug!("use directive '{name}' evaluated without a rule environment; no-op");
340        } else {
341            warn!(
342                "use directive '{name}' evaluated without a rule environment; treating as no-op (subsequent occurrences suppressed)"
343            );
344        }
345        return Ok((None, Vec::new()));
346    };
347
348    let Some(subroutine_rules) = env.name_table.get(name) else {
349        warn!("use directive references unknown name '{name}'");
350        return Ok((None, Vec::new()));
351    };
352    // The `name` line can carry its own description (e.g. Mach-O universal
353    // `0 name mach-o \b [`, `0 name matlab4 Matlab v4 mat-file`). GNU `file`
354    // emits it ahead of the subroutine body, attached with no separating
355    // space. Capture it here while the env borrow is live; the owned `String`
356    // lets us drop that borrow before mutating the context below. `None` for
357    // a bare `name <id>`.
358    let name_message = env.name_table.name_message(name);
359    // `NameTable::get` returns an `Arc<[MagicRule]>`, so this clone is a
360    // reference-count increment rather than a deep copy of the rule tree.
361    // The Arc is cloned here to release the immutable borrow of `context`
362    // (via `env`) before we mutably borrow the context below.
363
364    // Resolve the use-site offset under the *caller's* base, not the
365    // subroutine's -- the use rule itself is in the caller's scope.
366    let absolute_offset = offset::resolve_offset_with_base(
367        &rule.offset,
368        buffer,
369        context.last_match_end(),
370        context.base_offset(),
371    )?;
372
373    // `SubroutineScope` seeds `last_match_end` and `base_offset` with
374    // the use-site offset and restores both on drop. This is the
375    // safety net for early-return paths below -- if
376    // `RecursionGuard::enter` or the inner `evaluate_rules` returns
377    // `Err(Timeout)` / `Err(RecursionLimitExceeded)`, the `?` unwinds
378    // through the guard's `Drop` impl and the caller's context
379    // returns to its pre-use state. Without the RAII wrapper a manual
380    // save/restore pair would be bypassed on every error path.
381    // Capture both the subroutine's matches AND the terminal anchor
382    // where the subroutine left `last_match_end`. The terminal anchor
383    // is what GNU `file`-compatible inlining semantics require: sibling
384    // rules after the `use` site must resolve `&N` against the position
385    // the subroutine reached, not the use-site offset. Reading the
386    // anchor INSIDE the scope (before Drop restores the caller's value)
387    // preserves it for the caller.
388    let (subroutine_matches, terminal_anchor) = {
389        let mut scope = SubroutineScope::enter(context, absolute_offset, flip_endian);
390        let mut guard = RecursionGuard::enter(scope.context())?;
391        let matches = evaluate_rules(&subroutine_rules, buffer, guard.context())?;
392        let terminal = guard.context().last_match_end();
393        (matches, terminal)
394    };
395
396    // Prepend the `name` line's own description (if any) ahead of the body's
397    // matches, matching GNU `file`: `use mach-o` emits the mach-o subroutine's
398    // `\b [` before the per-arch body. The name line reads no bytes, so this
399    // synthetic match carries a dummy value and does NOT touch the anchor
400    // (`terminal_anchor` still comes from the body's evaluation). To reproduce
401    // `file`'s no-separator attachment (`ParentSUBMSG`, not `Parent SUBMSG`),
402    // ensure the message begins with the `\b` no-separator marker
403    // (`concatenate_messages` strips a leading literal `\b` / U+0008); a name
404    // message that already starts with one (mach-o's `\b [`) is left as-is so
405    // it is not double-marked.
406    let matches = match name_message {
407        Some(msg) if !msg.is_empty() => {
408            let attached = if crate::evaluator::strip_no_separator_marker(&msg).is_some() {
409                msg
410            } else {
411                format!("\\b{msg}")
412            };
413            let name_match = RuleMatch::new(
414                attached,
415                absolute_offset,
416                rule.level,
417                crate::parser::ast::Value::Uint(0),
418                rule.typ.clone(),
419                RuleMatch::calculate_confidence(rule.level),
420            );
421            let mut combined = Vec::with_capacity(subroutine_matches.len() + 1);
422            combined.push(name_match);
423            combined.extend(subroutine_matches);
424            combined
425        }
426        _ => subroutine_matches,
427    };
428
429    Ok((Some(terminal_anchor), matches))
430}
431
432/// Evaluate a pattern-bearing rule (`TypeKind::Regex` / `TypeKind::Search`).
433///
434/// `read_pattern_match` returns `Some(value)` on a successful match
435/// (possibly zero-width, e.g., `a*`) and `None` on a genuine miss; the
436/// engine translates those directly into `Equal`/`NotEqual`. Any other
437/// operator on a pattern-bearing type is a magic-file semantic bug and
438/// surfaces as [`crate::evaluator::types::TypeReadError::UnsupportedType`] -- the earlier
439/// fallthrough to `apply_operator` masked this by producing nonsense
440/// ordering comparisons against the pattern source text.
441///
442/// On a miss we return `Value::String(String::new())` as a display
443/// placeholder; the engine has already decided `matched = false` by
444/// then, so the placeholder only affects display and
445/// `bytes_consumed_with_pattern` (which re-derives the match position
446/// from the pattern, not this value).
447fn evaluate_pattern_rule(
448    rule: &MagicRule,
449    buffer: &[u8],
450    absolute_offset: usize,
451    max_string_length: usize,
452) -> Result<(bool, crate::parser::ast::Value), LibmagicError> {
453    let match_outcome = types::read_pattern_match(
454        buffer,
455        absolute_offset,
456        &rule.typ,
457        Some(&rule.value),
458        max_string_length,
459    )
460    .map_err(|e| LibmagicError::EvaluationError(e.into()))?;
461    let pattern_found = match_outcome.is_some();
462    let matched = match &rule.op {
463        crate::parser::ast::Operator::Equal => pattern_found,
464        crate::parser::ast::Operator::NotEqual => !pattern_found,
465        other => {
466            return Err(LibmagicError::EvaluationError(
467                types::TypeReadError::UnsupportedType {
468                    type_name: format!(
469                        "operator {other:?} is not supported for pattern-bearing type {:?}; only Equal (=) and NotEqual (!=) are allowed",
470                        rule.typ
471                    ),
472                }
473                .into(),
474            ));
475        }
476    };
477    let value = match_outcome.unwrap_or_else(|| crate::parser::ast::Value::String(String::new()));
478    Ok((matched, value))
479}
480
481/// Evaluate a value-based rule (all non-pattern-bearing `TypeKind` variants).
482///
483/// Reads the typed value at `absolute_offset`, coerces the rule's
484/// expected value to the target type's signedness/width (zero-copy via
485/// `Cow::Borrowed` on the hot path), and applies the operator.
486/// `BitwiseNot` needs type-aware width masking so the complement is
487/// computed at the type's natural width (e.g. byte `NOT 0x00 = 0xFF`,
488/// not `u64::MAX`).
489fn evaluate_value_rule(
490    rule: &MagicRule,
491    buffer: &[u8],
492    absolute_offset: usize,
493    max_string_length: usize,
494    flip_endian: bool,
495) -> Result<(bool, crate::parser::ast::Value), LibmagicError> {
496    // Apply the `use \^name` endian flip (issue #236) at read time, exactly
497    // as libmagic's `cvt_flip(m->type, flip)` does in `softmagic.c`. Only the
498    // typed READ needs the flipped endianness -- `bit_width()`,
499    // `coerce_value_to_type` (the literal's numeric value is endian-invariant),
500    // the relative-offset `bytes_consumed` advance, and the string-ordering
501    // display read are all endian-invariant, so they keep `rule.typ`.
502    // `flip_type_endian` is a cheap no-op clone for the common `flip == false`
503    // path.
504    let read_typ: std::borrow::Cow<'_, crate::parser::ast::TypeKind> = if flip_endian {
505        std::borrow::Cow::Owned(types::flip_type_endian(&rule.typ))
506    } else {
507        std::borrow::Cow::Borrowed(&rule.typ)
508    };
509    let read_value = types::read_typed_value_with_pattern(
510        buffer,
511        absolute_offset,
512        read_typ.as_ref(),
513        Some(&rule.value),
514        max_string_length,
515    )
516    .map_err(|e| LibmagicError::EvaluationError(e.into()))?;
517
518    // Apply any pre-comparison value transform (`type+N`/`type-N`/`type*N`/
519    // `type/N`/`type%N`/`type|N`/`type^N`). The transform runs on the read
520    // value before the comparison operator and before printf-style format
521    // substitution, so `%d` in the message renders the post-transform
522    // number. `&MASK` is *not* handled here -- it lives at the operator
523    // layer via `Operator::BitwiseAndMask`.
524    let transformed_value = match rule.value_transform {
525        None => read_value,
526        Some(t) => operators::apply_value_transform(&read_value, t)
527            .map_err(LibmagicError::EvaluationError)?,
528    };
529
530    let expected_value = types::coerce_value_to_type(&rule.value, &rule.typ);
531    let expected_ref: &crate::parser::ast::Value = expected_value.as_ref();
532
533    let matched = match &rule.op {
534        crate::parser::ast::Operator::BitwiseNot => operators::apply_bitwise_not_with_width(
535            &transformed_value,
536            expected_ref,
537            rule.typ.bit_width(),
538        ),
539        // Masked equality (`type&MASK VALUE`) must re-normalize the masked
540        // result to the type's natural width so a signed read whose high bits
541        // are cleared by the mask still compares equal to the sign-extended
542        // rule literal (e.g. the Mach-O `0 lelong&0xfffffffe 0xfeedface`
543        // rule). See `apply_bitwise_and_mask_with_width`.
544        crate::parser::ast::Operator::BitwiseAndMask(mask) => {
545            operators::apply_bitwise_and_mask_with_width(
546                *mask,
547                &transformed_value,
548                expected_ref,
549                rule.typ.bit_width(),
550            )
551        }
552        op => operators::apply_operator(op, &transformed_value, expected_ref),
553    };
554
555    // libmagic renders the FULL string field (`p->s`) for a matched string
556    // comparison, while the comparison itself is prefix-limited to
557    // `pattern.len()` (`file_strncmp` with `vallen`). The comparison above
558    // already read exactly `pattern.len()` bytes -- correct and unchanged --
559    // but for an ORDERING operator the rendered detail needs the whole field,
560    // not the compared prefix. sgml's `>15 string/t >\0 %.3s document text`
561    // is the motivating case: comparing `>\0` reads 1 byte ("1"), but the
562    // `%.3s` must render the full field ("1.0") to produce `XML 1.0 ...`.
563    // Re-read the full field for DISPLAY only; the `matched` decision above is
564    // left byte-identical, so this cannot change any match result.
565    let display_value = string_ordering_display_value(
566        rule,
567        buffer,
568        absolute_offset,
569        max_string_length,
570        transformed_value,
571    );
572    Ok((matched, display_value))
573}
574
575/// Compute the DISPLAY value for a value-rule match, decoupling it from the
576/// value used in the comparison.
577///
578/// For a `string` rule compared with an ORDERING operator (`<`/`>`/`<=`/`>=`),
579/// libmagic renders the full string field (`p->s`, read until NUL/EOF) even
580/// though the comparison is prefix-limited to `pattern.len()`. This re-reads
581/// that full field so `%s`/`%.Ns` format specifiers render the whole value
582/// rather than only the compared prefix. For every other type or operator the
583/// compared value already IS the field libmagic renders, so `compared` is
584/// returned unchanged.
585///
586/// Only `TypeKind::String` needs this: `PString` (`read_pstring`) and
587/// `String16` (`read_string16`) already read their full field independent of
588/// `pattern.len()`, and numeric types render the whole value.
589///
590/// On a display-side read error after a successful match, the compared value
591/// is returned rather than propagating -- a matched rule must not abort on a
592/// display-only read.
593fn string_ordering_display_value(
594    rule: &MagicRule,
595    buffer: &[u8],
596    absolute_offset: usize,
597    max_string_length: usize,
598    compared: crate::parser::ast::Value,
599) -> crate::parser::ast::Value {
600    use crate::parser::ast::Operator::{GreaterEqual, GreaterThan, LessEqual, LessThan};
601
602    let is_ordering = matches!(rule.op, LessThan | GreaterThan | LessEqual | GreaterEqual);
603    if is_ordering && matches!(rule.typ, TypeKind::String { .. }) {
604        match types::read_string(buffer, absolute_offset, Some(max_string_length)) {
605            Ok(full_field) => full_field,
606            // A matched rule must not abort on a display-only read (the compared
607            // prefix was already read successfully at this offset moments ago),
608            // so fall back to it -- but `debug!` first rather than swallowing the
609            // error silently, matching this file's graceful-skip logging
610            // discipline. A latent regression here (e.g. `max_string_length`
611            // disagreeing with the original read) would otherwise render the
612            // truncated prefix with no trace of why the full field was dropped.
613            Err(e) => {
614                debug!(
615                    "string_ordering_display_value: full-field read failed at offset {absolute_offset} for rule '{}': {e}; rendering compared prefix",
616                    rule.message
617                );
618                compared
619            }
620        }
621    } else {
622        compared
623    }
624}
625
626/// Logs the graceful skip of a pattern-bearing-type rule whose
627/// `TypeReadError::UnsupportedType` condition falls in the narrow
628/// missing-pattern-operand or regex-compile-failure allowlist (see
629/// `types::is_missing_pattern_operand` / `types::is_regex_compile_failure`).
630///
631/// Shared by all three engine catch sites (`evaluate_children_or_warn`, the
632/// top-level dispatch match, and the inline child-recursion match) so a
633/// future rewording of the log message only needs one touch point (DRY,
634/// AGENTS.md). Split by KTD5 (fix-system-magic-regex-graceful plan): the
635/// ordinary missing-pattern case is `debug!`-logged (an expected,
636/// low-severity data condition -- e.g. the root-cause parser
637/// miscategorization this plan also fixes), while a regex compile failure
638/// (which includes the `REGEX_COMPILE_SIZE_LIMIT` CWE-1333 denial-of-service guard) is
639/// `warn!`-logged so a malicious or pathological magic file's rejection is
640/// not silently invisible, even though the rest of the file's evaluation
641/// continues (R1: no fatal abort of the whole evaluation).
642fn log_pattern_operand_skip(site_label: &str, rule_message: &str, type_name: &str) {
643    if types::is_regex_compile_failure(type_name) {
644        warn!(
645            "Skipping {site_label} rule '{rule_message}' due to regex compile failure: {type_name} -- this may indicate a malicious or pathological magic file"
646        );
647    } else {
648        debug!("Skipping {site_label} rule '{rule_message}': {type_name}");
649    }
650}
651
652/// Whether `message` carries any usable description text.
653///
654/// A message is considered message-less (and thus does not count as
655/// "producing output") if, after trimming ASCII/Unicode whitespace and
656/// stripping a leading GNU `file` no-separator marker (see GOTCHAS S14.1),
657/// nothing remains. This covers three shapes GNU `file` magic files use
658/// for structural/gating rules that carry no description of their own:
659/// a genuinely empty message (`""`), a whitespace-only message, and a
660/// `\b`-only message (used purely to suppress a separator when appended
661/// to a sibling's text -- with nothing else to append, it contributes no
662/// content either).
663///
664/// The marker is recognized in BOTH forms -- the raw byte `U+0008` and the
665/// literal `\b` (backslash + `'b'`) -- via the shared
666/// [`crate::evaluator::strip_no_separator_marker`], so this predicate agrees
667/// with `concatenate_messages`: a message that renders to empty there (e.g.
668/// exactly `"\b"`, the literal marker) is classified message-less here and
669/// therefore cannot win the `stop_at_first_match` race and shadow a later,
670/// more specific rule that would produce real output (the S13.2 bug class).
671fn is_message_bearing(message: &str) -> bool {
672    let trimmed = message.trim_matches(|c: char| c.is_whitespace() || c == '\u{8}');
673    let stripped = crate::evaluator::strip_no_separator_marker(trimmed).unwrap_or(trimmed);
674    !stripped
675        .trim_matches(|c: char| c.is_whitespace() || c == '\u{8}')
676        .is_empty()
677}
678
679/// Whether any match in `matches[from..]` carries usable description text
680/// (see [`is_message_bearing`]).
681///
682/// Used to decide whether a top-level rule's match -- together with any
683/// descendant matches produced by its children -- should be treated as
684/// the "winning" match for `stop_at_first_match` purposes. GNU `file`
685/// magic files commonly use message-less top-level rules purely as
686/// gating conditions for child rules (for example the `c-lang` search
687/// rules that test for `#include`/`pragma`/etc. before dispatching to a
688/// message-bearing regex child); under the old all-or-nothing contract, a
689/// message-less rule matching first under `stop_at_first_match: true`
690/// would silently shadow a later, more specific rule that actually
691/// produces a description (GOTCHAS S13.2, the assembler-source-text /
692/// plain-ASCII-text blank-output bug). A rule only "wins" the race if it
693/// (or a descendant) contributes real output text; otherwise evaluation
694/// continues to the next top-level sibling.
695///
696/// Takes `from` (the length of `matches` before this rule's dispatch) and
697/// slices via `.get()` (rather than the caller indexing `matches[from..]`
698/// directly) so this is panic-free per the project's bounds-checking
699/// discipline; `from` is always `<= matches.len()` by construction (it is
700/// captured from `matches.len()` earlier in the same call), so `.get()`
701/// always returns `Some`, but the panic-free form is required regardless.
702fn has_message_bearing_match(matches: &[RuleMatch], from: usize) -> bool {
703    matches
704        .get(from..)
705        .is_some_and(|tail| tail.iter().any(|m| is_message_bearing(&m.message)))
706}
707
708/// Evaluate a rule's children under the standard recursion-guard/graceful-skip discipline.
709///
710/// This helper centralises the `RecursionGuard` + `evaluate_rules` + error-dispatch
711/// pattern that is identical across the `Default`, `Indirect`, `Offset`, and `Use`
712/// meta-type arms in [`evaluate_rules`]. Extracting it prevents the four copies
713/// from drifting apart during future maintenance.
714///
715/// # Behaviour
716///
717/// * If `rule.children` is empty the function is a no-op (returns `Ok(())`).
718/// * Child matches are appended to `matches` in document order.
719/// * `LibmagicError::Timeout` and `LibmagicError::EvaluationError(RecursionLimitExceeded)`
720///   propagate immediately as `Err` so the caller can bail out.
721/// * Data-dependent errors (`BufferOverrun`, `InvalidOffset`,
722///   `TypeReadError::BufferOverrun`, `TypeReadError::InvalidPStringLength`,
723///   `IoError`) are logged at `warn!` and swallowed; the parent match
724///   already in `matches` is left intact. This mirrors the defensive
725///   comment in each arm: the inner `evaluate_rules` already catches and
726///   logs individual child failures, so this arm only fires if that
727///   strategy changes.
728///
729/// # Arguments
730///
731/// * `rule`      – The parent rule whose children will be evaluated.
732/// * `rule_kind` – A short label for the rule kind used in the `warn!`
733///   message (e.g. `"default"`, `"indirect"`, `"offset"`, `"use"`).
734/// * `buffer`    – The file buffer passed to the recursive call.
735/// * `context`   – Mutable evaluation context; the recursion depth is
736///   incremented on entry and decremented on drop via [`RecursionGuard`].
737/// * `matches`   – Output vector; child matches are appended here.
738fn evaluate_children_or_warn(
739    rule: &MagicRule,
740    rule_kind: &str,
741    buffer: &[u8],
742    context: &mut EvaluationContext,
743    matches: &mut Vec<RuleMatch>,
744) -> Result<(), LibmagicError> {
745    if rule.children.is_empty() {
746        return Ok(());
747    }
748    let mut guard = RecursionGuard::enter(context)?;
749    match evaluate_rules(&rule.children, buffer, guard.context()) {
750        Ok(child_matches) => {
751            matches.extend(child_matches);
752        }
753        Err(LibmagicError::Timeout { timeout_ms }) => {
754            return Err(LibmagicError::Timeout { timeout_ms });
755        }
756        // `RecursionLimitExceeded` is listed explicitly (rather than
757        // relying on the catch-all below) so a future maintainer adding
758        // another swallowed variant cannot accidentally swallow it.
759        // Both this arm and the catch-all intentionally propagate via
760        // `return Err(e)`; `match_same_arms` is suppressed because the
761        // explicit arm's purpose is documentation and future-proofing,
762        // not different behavior. See GOTCHAS S13 for the recursion-
763        // depth guard contract.
764        #[allow(clippy::match_same_arms)]
765        Err(
766            e @ LibmagicError::EvaluationError(
767                crate::error::EvaluationError::RecursionLimitExceeded { .. },
768            ),
769        ) => return Err(e),
770        Err(
771            e @ (LibmagicError::EvaluationError(
772                crate::error::EvaluationError::BufferOverrun { .. }
773                | crate::error::EvaluationError::InvalidOffset { .. }
774                | crate::error::EvaluationError::InvalidValueTransform { .. }
775                | crate::error::EvaluationError::TypeReadError(
776                    crate::evaluator::types::TypeReadError::BufferOverrun { .. }
777                    | crate::evaluator::types::TypeReadError::InvalidPStringLength { .. },
778                ),
779            )
780            | LibmagicError::IoError(_)),
781        ) => {
782            warn!(
783                "Discarding child evaluation under {} rule '{}' due to unexpected error: {} -- parent match is still emitted",
784                rule_kind, rule.message, e
785            );
786        }
787        // Narrow graceful-skip (KTD4): a pattern-bearing type evaluated
788        // without a usable pattern operand, or a regex compile failure
789        // (including the REGEX_COMPILE_SIZE_LIMIT DoS guard), must not
790        // abort the whole evaluation -- see `log_pattern_operand_skip` and
791        // the top-level dispatch match below for the full contract. This
792        // arm is defensive: under the current implementation, individual
793        // child failures are already caught and logged inside the
794        // recursive `evaluate_rules` call (they never propagate here); it
795        // guards against a future change to that strategy.
796        Err(LibmagicError::EvaluationError(crate::error::EvaluationError::TypeReadError(
797            crate::evaluator::types::TypeReadError::UnsupportedType { ref type_name },
798        ))) if types::is_missing_pattern_operand(type_name)
799            || types::is_regex_compile_failure(type_name) =>
800        {
801            log_pattern_operand_skip(rule_kind, &rule.message, type_name);
802        }
803        Err(e) => return Err(e),
804    }
805    Ok(())
806}
807
808/// Evaluate a list of magic rules against a file buffer with hierarchical processing
809///
810/// This function implements the core hierarchical rule evaluation algorithm with graceful
811/// error handling:
812/// 1. Evaluates each top-level rule in sequence
813/// 2. If a parent rule matches, evaluates its child rules for refinement
814/// 3. Collects all matches or stops at first match based on configuration
815/// 4. Maintains evaluation context for recursion limits and state
816/// 5. Implements graceful degradation by skipping problematic rules and continuing evaluation
817///
818/// The hierarchical evaluation follows these principles:
819/// - Parent rules must match before children are evaluated
820/// - Child rules provide refinement and additional detail
821/// - Evaluation can stop at first match or continue for all matches
822/// - Recursion depth is limited to prevent infinite loops
823/// - Problematic rules are skipped to allow evaluation to continue
824///
825/// # Arguments
826///
827/// * `rules` - The list of magic rules to evaluate
828/// * `buffer` - The file buffer to evaluate against
829/// * `context` - Mutable evaluation context for state management. **Callers
830///   reusing a context across multiple buffers must call
831///   [`EvaluationContext::reset`](crate::evaluator::EvaluationContext::reset)
832///   between calls** -- the GNU `file` previous-match anchor and the
833///   recursion-depth counter both advance during evaluation and would
834///   otherwise leak across buffers. The same applies when this function
835///   returns `Err` mid-evaluation (e.g., `LibmagicError::Timeout` or
836///   `RecursionLimitExceeded`): both the anchor and (potentially) the
837///   recursion depth are left in a partially-advanced state, and a retry
838///   on the same context without `reset()` will resolve relative offsets
839///   against the stale anchor and apply the wrong recursion budget.
840///   [`evaluate_rules_with_config`] always builds a fresh context and is the
841///   safer choice when context reuse isn't required.
842///
843/// # Returns
844///
845/// Returns `Ok(Vec<RuleMatch>)` containing all matches found. Errors in individual rules
846/// are skipped to allow evaluation to continue. Only returns `Err(LibmagicError)`
847/// for critical failures like timeout or recursion limit exceeded.
848///
849/// # Examples
850///
851/// ```rust
852/// use libmagic_rs::evaluator::{evaluate_rules, EvaluationContext, RuleMatch};
853/// use libmagic_rs::parser::ast::{MagicRule, OffsetSpec, TypeKind, Operator, Value};
854/// use libmagic_rs::EvaluationConfig;
855///
856/// // Create a hierarchical rule set for ELF files
857/// let parent_rule = MagicRule::new(
858///     OffsetSpec::Absolute(0),
859///     TypeKind::Byte { signed: true },
860///     Operator::Equal,
861///     Value::Uint(0x7f),
862///     "ELF".to_string(),
863/// )
864/// .with_children(vec![
865///     MagicRule::new(
866///         OffsetSpec::Absolute(4),
867///         TypeKind::Byte { signed: true },
868///         Operator::Equal,
869///         Value::Uint(2),
870///         "64-bit".to_string(),
871///     )
872///     .with_level(1),
873/// ]);
874///
875/// let rules = vec![parent_rule];
876/// let buffer = &[0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01]; // ELF64 header
877/// let config = EvaluationConfig::default();
878/// let mut context = EvaluationContext::new(config);
879///
880/// let matches = evaluate_rules(&rules, buffer, &mut context).unwrap();
881/// assert_eq!(matches.len(), 2); // Parent and child should both match
882/// ```
883///
884/// # Errors
885///
886/// * `LibmagicError::Timeout` - If evaluation exceeds configured timeout
887/// * `LibmagicError::EvaluationError` - Only for critical failures like recursion limit exceeded
888///
889/// Individual rule evaluation errors are handled gracefully and do not stop the overall evaluation.
890#[allow(clippy::too_many_lines)]
891pub fn evaluate_rules(
892    rules: &[MagicRule],
893    buffer: &[u8],
894    context: &mut EvaluationContext,
895) -> Result<Vec<RuleMatch>, LibmagicError> {
896    let mut matches = Vec::with_capacity(8);
897    let start_time = std::time::Instant::now();
898    let mut rule_count = 0u32;
899
900    // Per-level "did any sibling match yet?" flag for `default`/`clear`
901    // dispatch. Each recursive descent gets its own fresh flag, so child
902    // sibling chains track their own state independently of the parent.
903    let mut sibling_matched = false;
904
905    // Per-level entry anchor: captured at the start of this sibling list's
906    // evaluation. For CHILD sibling lists (recursion_depth > 0), the
907    // GNU `file`/libmagic previous-match anchor is reset to this value
908    // between sibling iterations so that `&N` offsets on continuation
909    // siblings resolve against the parent-level anchor, not against
910    // whatever the *previous sibling* left the anchor at. This matches
911    // libmagic's continuation-level model (`ms->c.li[cont_level]`)
912    // where each level tracks its own anchor; a sibling at level L does
913    // not inherit the post-match anchor of another sibling at level L.
914    //
915    // TOP-LEVEL siblings (recursion_depth == 0) are independent
916    // classification attempts -- each top-level rule intentionally sees
917    // the anchor advance that prior top-level rules produced (see
918    // GOTCHAS S3.8 and the `relative_anchor_can_decrease_...`
919    // integration test). Gate the reset on recursion_depth to preserve
920    // that documented discipline while still fixing the continuation-
921    // sibling behavior that the GNU `file` `searchbug.magic` fixture
922    // relies on.
923    //
924    // Recursing into a matched rule's children still carries forward the
925    // post-match anchor (via the current value of `last_match_end()` at
926    // the point of recursion), so child sibling lists see their parent's
927    // resolved position as their own entry anchor.
928    //
929    // INDIRECT RE-ENTRY exception: `MetaType::Indirect` dispatches its
930    // sub-evaluation via `RecursionGuard::enter` (to bound the recursion
931    // cycle), which forces `recursion_depth > 0`. But an indirect
932    // re-entry semantically evaluates the root rule list with TOP-LEVEL
933    // sibling semantics -- each rule is an independent classification
934    // attempt against the re-entered sub-buffer, NOT a continuation
935    // list. The indirect dispatch sets `context.set_indirect_reentry(true)`
936    // just before this call; `take_indirect_reentry()` consumes it at
937    // entry so only this iteration treats siblings as top-level.
938    // Children of matched rules inside the re-entry still see the flag
939    // as false (consumed) and correctly fall back to continuation
940    // semantics via `recursion_depth > 0`.
941    let entry_anchor = context.last_match_end();
942    let is_indirect_reentry = context.take_indirect_reentry();
943    let is_child_sibling_list = context.recursion_depth() > 0 && !is_indirect_reentry;
944
945    // `stop_at_first_match` is a TOP-LEVEL classification concept (see the
946    // `EvaluationConfig::stop_at_first_match` doc): once an outermost rule --
947    // or an indirect re-entry, which is itself a fresh top-level
948    // classification -- produces a message-bearing match, we stop trying
949    // other top-level candidates. It must NOT short-circuit a child /
950    // continuation sibling list or a `use` subroutine body: every matching
951    // sibling there contributes a detail fragment to the description (e.g.
952    // gzip's "max compression", "from Unix", "original size modulo 2^32 N"),
953    // and truncating them silently drops multi-part descriptions. This
954    // mirrors libmagic, where continuation levels always evaluate every
955    // sibling and only the top-level `match()` loop stops at first success.
956    // (An earlier revision applied the break at every recursion level, which
957    // violated the documented top-level-only contract and truncated gzip's
958    // trailing detail after its first message-bearing child.)
959    let stop_at_first_match_applies = !is_child_sibling_list;
960
961    // Entry-point timeout check: ensures every recursive descent is bounded
962    // and that evaluations of small rule sets (< 16 rules) are still guarded.
963    // Without this, the periodic every-16-rules check below never fires for
964    // flat rule lists with fewer than 16 rules, and recursion into children
965    // also restarts `rule_count` at 0.
966    if let Some(timeout_ms) = context.timeout_ms()
967        && start_time.elapsed().as_millis() >= u128::from(timeout_ms)
968    {
969        return Err(LibmagicError::Timeout { timeout_ms });
970    }
971
972    for rule in rules {
973        // For continuation siblings (child recursion), reset the
974        // previous-match anchor to the entry anchor so `&N` offsets
975        // resolve against the parent-level position. Top-level
976        // siblings (depth 0) keep the chaining behavior documented in
977        // GOTCHAS S3.8. See the `entry_anchor` comment above.
978        if is_child_sibling_list {
979            context.set_last_match_end(entry_anchor);
980        }
981
982        // Check timeout periodically (every 16 rules) to reduce syscall overhead
983        rule_count = rule_count.wrapping_add(1);
984        if rule_count.trailing_zeros() >= 4
985            && let Some(timeout_ms) = context.timeout_ms()
986            && start_time.elapsed().as_millis() >= u128::from(timeout_ms)
987        {
988            return Err(LibmagicError::Timeout { timeout_ms });
989        }
990
991        // `Clear` resets the per-level "sibling matched" flag so a
992        // subsequent `default` sibling can fire even if an earlier
993        // sibling matched. Matching libmagic's `FILE_CLEAR`, the flag is
994        // unconditionally reset and NEVER re-set to `true` afterward
995        // (clear does not participate in the "a sibling matched" chain).
996        //
997        // libmagic's `FILE_CLEAR` also COUNTS as a match -- its `x` test
998        // always succeeds -- and `mprint` renders its description when it
999        // is non-empty. So a `clear` carrying message text must emit that
1000        // text (c-lang's `>>&0 clear x program text` is the only such rule
1001        // in the system DB, producing the "program text" fragment of
1002        // `c program text`). Verified against real `file` (file-5.41):
1003        // a message-bearing `clear` child prints its message AND still
1004        // resets the flag so a trailing `default` sibling fires.
1005        //
1006        // Emission is guarded on a non-empty message so the many bare
1007        // `clear x` flag-reset directives throughout the system DB (apple,
1008        // coff, elf, pmem, ...) behave exactly as before -- no match, no
1009        // anchor advance. `clear` is 0-width, so the previous-match anchor
1010        // is intentionally not advanced in either case. Children are
1011        // evaluated for a message-bearing clear for libmagic fidelity;
1012        // `evaluate_children_or_warn` is a no-op when there are none.
1013        if let TypeKind::Meta(MetaType::Clear) = &rule.typ {
1014            sibling_matched = false;
1015
1016            if !rule.message.is_empty() {
1017                let matches_before = matches.len();
1018
1019                let match_result = RuleMatch::new(
1020                    rule.message.clone(),
1021                    context.last_match_end(),
1022                    rule.level,
1023                    crate::parser::ast::Value::Uint(0),
1024                    rule.typ.clone(),
1025                    RuleMatch::calculate_confidence(rule.level),
1026                );
1027                matches.push(match_result);
1028
1029                evaluate_children_or_warn(rule, "clear", buffer, context, &mut matches)?;
1030
1031                if stop_at_first_match_applies
1032                    && matches.len() > matches_before
1033                    && context.should_stop_at_first_match()
1034                    && has_message_bearing_match(&matches, matches_before)
1035                {
1036                    break;
1037                }
1038            }
1039            continue;
1040        }
1041
1042        // `Default` fires only when no earlier sibling at this level has
1043        // matched yet. The anchor is intentionally not advanced -- the
1044        // directive does not consume bytes -- but its children are
1045        // evaluated and the per-level "sibling matched" flag is set so
1046        // any later `default` sibling at the same level is suppressed.
1047        if let TypeKind::Meta(MetaType::Default) = &rule.typ {
1048            if !sibling_matched {
1049                let matches_before = matches.len();
1050
1051                let match_result = RuleMatch::new(
1052                    rule.message.clone(),
1053                    context.last_match_end(),
1054                    rule.level,
1055                    crate::parser::ast::Value::Uint(0),
1056                    rule.typ.clone(),
1057                    RuleMatch::calculate_confidence(rule.level),
1058                );
1059                matches.push(match_result);
1060
1061                // `default` is treated as a successful match at this
1062                // level, so its children are evaluated under the same
1063                // recursion-guard pattern as every other successful rule.
1064                evaluate_children_or_warn(rule, "default", buffer, context, &mut matches)?;
1065
1066                sibling_matched = true;
1067
1068                if stop_at_first_match_applies
1069                    && matches.len() > matches_before
1070                    && context.should_stop_at_first_match()
1071                    && has_message_bearing_match(&matches, matches_before)
1072                {
1073                    break;
1074                }
1075            }
1076            continue;
1077        }
1078
1079        // `Indirect` re-evaluates the root rule list at the resolved
1080        // offset, mirroring libmagic's indirect-type semantics. The
1081        // sub-evaluation runs against `buffer[absolute_offset..]` with a
1082        // fresh anchor (0) so relative offsets inside the root rules
1083        // resolve correctly; the caller's anchor is restored on exit
1084        // via `AnchorScope`. Without an attached `RuleEnvironment`
1085        // (programmatic consumers bypassing `MagicDatabase`) the
1086        // directive is a silent no-op.
1087        if let TypeKind::Meta(MetaType::Indirect) = &rule.typ {
1088            // Resolve the offset first so a malformed offset surfaces
1089            // as a graceful skip rather than a hard error.
1090            let absolute_offset = match offset::resolve_offset_with_base(
1091                &rule.offset,
1092                buffer,
1093                context.last_match_end(),
1094                context.base_offset(),
1095            ) {
1096                Ok(o) => o,
1097                Err(
1098                    e @ LibmagicError::EvaluationError(
1099                        crate::error::EvaluationError::BufferOverrun { .. }
1100                        | crate::error::EvaluationError::InvalidOffset { .. },
1101                    ),
1102                ) => {
1103                    debug!("Skipping indirect rule '{}': {}", rule.message, e);
1104                    continue;
1105                }
1106                Err(e) => return Err(e),
1107            };
1108
1109            // Pull the root rules out of the rule environment. Without
1110            // an environment there is nothing to re-enter, so this is a
1111            // silent no-op (matching the `Use`-without-env behavior).
1112            //
1113            // We use `debug!` rather than `debug_assert!` here because
1114            // property tests (`prop_arbitrary_rule_evaluation_never_panics`)
1115            // synthesize arbitrary `TypeKind::Meta(MetaType::Indirect)`
1116            // rules and run them without attaching a `RuleEnvironment`;
1117            // a panic on this path would break the never-panics invariant.
1118            // See GOTCHAS S2.1 for the same rationale on the leaked-Name arm.
1119            let Some(root_rules) = context
1120                .rule_env()
1121                .map(|e| std::sync::Arc::clone(&e.root_rules))
1122            else {
1123                debug!(
1124                    "indirect rule '{}' evaluated without a rule environment; treating as no-op",
1125                    rule.message
1126                );
1127                continue;
1128            };
1129
1130            // Bounds-check before slicing. An indirect offset past the
1131            // end of the buffer is a data-dependent skip, not an error.
1132            let Some(sub_buffer) = buffer.get(absolute_offset..) else {
1133                debug!(
1134                    "Skipping indirect rule '{}': offset {} past buffer end ({} bytes)",
1135                    rule.message,
1136                    absolute_offset,
1137                    buffer.len()
1138                );
1139                continue;
1140            };
1141
1142            let matches_before = matches.len();
1143
1144            // Advance the GNU `file` previous-match anchor to the indirect's
1145            // resolved offset and emit a `RuleMatch` for the indirect rule
1146            // itself BEFORE descending into the root re-entry or children.
1147            // This matches the shared successful-match flow used by every
1148            // other rule kind: advance anchor first, record the match, then
1149            // recurse. Without this, sibling rules of the `indirect` resolve
1150            // their relative offsets against the stale anchor and the
1151            // directive's own `message` never surfaces in the output.
1152            context.set_last_match_end(absolute_offset);
1153
1154            let indirect_match = RuleMatch::new(
1155                rule.message.clone(),
1156                absolute_offset,
1157                rule.level,
1158                crate::parser::ast::Value::String("indirect".to_string()),
1159                rule.typ.clone(),
1160                RuleMatch::calculate_confidence(rule.level),
1161            );
1162            matches.push(indirect_match);
1163
1164            // Indirect counts as a match for `sibling_matched` regardless of
1165            // whether the sub-evaluation produced any matches -- the directive
1166            // itself successfully dispatched.
1167            sibling_matched = true;
1168
1169            // Recursion guard + anchor scope: nested indirect / use cycles
1170            // surface as `RecursionLimitExceeded` instead of a stack overflow,
1171            // and the caller's anchor is restored on every exit path.
1172            //
1173            // Mark the upcoming `evaluate_rules` call as a top-level
1174            // re-entry (consumed at entry) so sibling anchor-reset
1175            // semantics do NOT fire -- root rules in the re-entered
1176            // database chain their anchors across siblings like any
1177            // other top-level evaluation.
1178            {
1179                let mut guard = RecursionGuard::enter(context)?;
1180                let mut anchor_scope = AnchorScope::enter(guard.context(), 0);
1181                anchor_scope.context().set_indirect_reentry(true);
1182                match evaluate_rules(&root_rules, sub_buffer, anchor_scope.context()) {
1183                    Ok(sub_matches) => {
1184                        matches.extend(sub_matches);
1185                    }
1186                    Err(LibmagicError::Timeout { timeout_ms }) => {
1187                        return Err(LibmagicError::Timeout { timeout_ms });
1188                    }
1189                    Err(e) => return Err(e),
1190                }
1191                // anchor_scope drops here, restoring the saved anchor
1192                // (which is now `absolute_offset`, set above before the
1193                // scope was entered).
1194                // guard drops next, decrementing the recursion depth.
1195            }
1196
1197            // Evaluate the indirect rule's own children under the same
1198            // recursion-guard pattern used by every other successful rule.
1199            evaluate_children_or_warn(rule, "indirect", buffer, context, &mut matches)?;
1200
1201            if stop_at_first_match_applies
1202                && matches.len() > matches_before
1203                && context.should_stop_at_first_match()
1204                && has_message_bearing_match(&matches, matches_before)
1205            {
1206                break;
1207            }
1208            continue;
1209        }
1210
1211        // `Offset` reports the resolved file offset as the rule's read
1212        // value, matching GNU `file`'s `FILE_OFFSET` semantics: the match
1213        // emits a value-bearing `RuleMatch` whose `value` is the absolute
1214        // position, which downstream message formatting substitutes into
1215        // `%lld` / `%d` specifiers via `output::format::format_magic_message`.
1216        //
1217        // Per magic(5) the only legal operator is `x` (AnyValue); any
1218        // other operator is a magic-file semantic error. Matching the
1219        // evaluator's graceful-skip discipline, we `debug!`-log and skip
1220        // rather than erroring -- a rogue rule shouldn't poison the rest
1221        // of the evaluation.
1222        if let TypeKind::Meta(MetaType::Offset) = &rule.typ {
1223            // Resolve the offset first so a malformed offset surfaces as
1224            // a graceful skip rather than a hard error. Mirrors the
1225            // `Indirect` dispatch above.
1226            let absolute_offset = match offset::resolve_offset_with_base(
1227                &rule.offset,
1228                buffer,
1229                context.last_match_end(),
1230                context.base_offset(),
1231            ) {
1232                Ok(o) => o,
1233                Err(
1234                    e @ LibmagicError::EvaluationError(
1235                        crate::error::EvaluationError::BufferOverrun { .. }
1236                        | crate::error::EvaluationError::InvalidOffset { .. },
1237                    ),
1238                ) => {
1239                    debug!("Skipping offset rule '{}': {}", rule.message, e);
1240                    continue;
1241                }
1242                Err(e) => return Err(e),
1243            };
1244
1245            // The magic(5) `offset` pseudo-type treats the resolved offset
1246            // itself as the read value. `offset x` is a bare AnyValue
1247            // placeholder that always matches (used purely to report the
1248            // position via `%lld`). A comparison operator (`offset >48`,
1249            // `offset <48`, `offset =N`, ...) tests the resolved offset
1250            // against the operand -- e.g. gzip's `>>-0 offset >48` gates
1251            // the trailing "original size modulo 2^32" trailer on the file
1252            // being long enough to carry it, and its `>>-0 offset <48`
1253            // sibling reports "truncated" otherwise. Skip the rule (a
1254            // non-match) when the comparison fails so the false branch and
1255            // its children do not render.
1256            let offset_value = crate::parser::ast::Value::Uint(absolute_offset as u64);
1257            let offset_matched = match &rule.op {
1258                crate::parser::ast::Operator::AnyValue => true,
1259                op => operators::apply_operator(op, &offset_value, &rule.value),
1260            };
1261            if !offset_matched {
1262                continue;
1263            }
1264
1265            let matches_before = matches.len();
1266
1267            // Advance the anchor BEFORE emitting the match so sibling
1268            // rules resolve their relative offsets against the offset
1269            // directive's resolved position. Same discipline as
1270            // `Indirect` and every other value-bearing rule.
1271            context.set_last_match_end(absolute_offset);
1272
1273            let offset_match = RuleMatch::new(
1274                rule.message.clone(),
1275                absolute_offset,
1276                rule.level,
1277                offset_value,
1278                rule.typ.clone(),
1279                RuleMatch::calculate_confidence(rule.level),
1280            );
1281            matches.push(offset_match);
1282
1283            sibling_matched = true;
1284
1285            // Evaluate children under the recursion-guard pattern used
1286            // by every other successful rule.
1287            evaluate_children_or_warn(rule, "offset", buffer, context, &mut matches)?;
1288
1289            if stop_at_first_match_applies
1290                && matches.len() > matches_before
1291                && context.should_stop_at_first_match()
1292                && has_message_bearing_match(&matches, matches_before)
1293            {
1294                break;
1295            }
1296            continue;
1297        }
1298
1299        // `Use` is handled inline so the subroutine's matches can be
1300        // spliced into the caller's match vector in document order.
1301        // Routing this through `evaluate_single_rule_with_anchor` would
1302        // force the helper to return a `Vec<RuleMatch>`, which would
1303        // reshape the single-rule return type for every other variant.
1304        //
1305        // On a successful use path we must also descend into the rule's
1306        // own children, matching the flow of every other successful rule
1307        // kind. libmagic chains like `>>0 use part2` often carry
1308        // continuation rules (siblings and descendants of the `use` site)
1309        // that depend on the anchor the subroutine left behind; skipping
1310        // them produces user-visible false negatives.
1311        if let TypeKind::Meta(MetaType::Use { name, flip_endian }) = &rule.typ {
1312            let matches_before = matches.len();
1313            let use_resolved = match evaluate_use_rule(rule, name, *flip_endian, buffer, context) {
1314                Ok((Some(terminal_anchor), subroutine_matches)) => {
1315                    matches.extend(subroutine_matches);
1316
1317                    // A `use` rule does not produce a surface
1318                    // `RuleMatch` itself -- the subroutine's rules
1319                    // carry the visible messages. Advance the
1320                    // caller's anchor to the subroutine's TERMINAL
1321                    // anchor (where the subroutine left `last_match_end`),
1322                    // not the use-site offset. This makes `use`
1323                    // behave like inlining the subroutine: sibling
1324                    // rules after the `use` see `&N` resolve against
1325                    // the subroutine's final match position.
1326                    context.set_last_match_end(terminal_anchor);
1327                    true
1328                }
1329                Ok((None, _)) => {
1330                    // No environment, or name not found -- silent no-op.
1331                    false
1332                }
1333                Err(
1334                    e @ LibmagicError::EvaluationError(
1335                        crate::error::EvaluationError::BufferOverrun { .. }
1336                        | crate::error::EvaluationError::InvalidOffset { .. },
1337                    ),
1338                ) => {
1339                    debug!("Skipping use rule '{name}': {e}");
1340                    false
1341                }
1342                Err(e) => return Err(e),
1343            };
1344
1345            // Evaluate the use rule's own children exactly like any other
1346            // successful rule. Subroutine matches are already appended
1347            // above, so children are spliced in after them to preserve
1348            // document order. The recursion guard mirrors the non-`Use`
1349            // path so a `use`-site chain cannot blow past the configured
1350            // recursion limit.
1351            if use_resolved {
1352                evaluate_children_or_warn(rule, "use", buffer, context, &mut matches)?;
1353            }
1354
1355            // A successful `use` site is treated as a sibling match for
1356            // `default`/`clear` dispatch purposes -- subsequent `default`
1357            // siblings should not fire if the subroutine resolved.
1358            if use_resolved {
1359                sibling_matched = true;
1360            }
1361
1362            // Apply stop-at-first-match with the same semantics as every
1363            // other successful rule kind: if this `use` site contributed
1364            // any matches (either from the subroutine or from its own
1365            // children) and the caller configured first-match
1366            // short-circuiting, halt evaluation of further siblings --
1367            // but only once one of those matches actually carries usable
1368            // description text (see `has_message_bearing_match`).
1369            if stop_at_first_match_applies
1370                && matches.len() > matches_before
1371                && context.should_stop_at_first_match()
1372                && has_message_bearing_match(&matches, matches_before)
1373            {
1374                break;
1375            }
1376            continue;
1377        }
1378
1379        // Evaluate the current rule with graceful error handling.
1380        // Pass the GNU `file` anchor so OffsetSpec::Relative resolves
1381        // correctly against the previous match's end position.
1382        let match_data = match evaluate_single_rule_with_anchor(
1383            rule,
1384            buffer,
1385            context.last_match_end(),
1386            context.base_offset(),
1387            context.max_string_length(),
1388            context.flip_endian(),
1389        ) {
1390            Ok(data) => data,
1391            Err(
1392                e @ (LibmagicError::EvaluationError(
1393                    crate::error::EvaluationError::BufferOverrun { .. }
1394                    | crate::error::EvaluationError::InvalidOffset { .. }
1395                    | crate::error::EvaluationError::InvalidValueTransform { .. }
1396                    | crate::error::EvaluationError::TypeReadError(
1397                        crate::evaluator::types::TypeReadError::BufferOverrun { .. }
1398                        | crate::evaluator::types::TypeReadError::InvalidPStringLength { .. },
1399                    ),
1400                )
1401                | LibmagicError::IoError(_)),
1402            ) => {
1403                // Expected data-dependent evaluation errors -- skip gracefully.
1404                // TypeReadError::UnsupportedType is intentionally NOT caught
1405                // here (except the narrow exception in the arm immediately
1406                // below) so that evaluator capability gaps propagate as
1407                // errors.
1408                debug!("Skipping rule '{}': {}", rule.message, e);
1409                continue;
1410            }
1411            // Narrow graceful-skip (KTD4, fix-system-magic-regex-graceful
1412            // plan): a pattern-bearing type (`Regex`/`Search`/flagged
1413            // `String`) evaluated without a usable `String`/`Bytes` pattern
1414            // operand, or a regex compile failure (including the
1415            // `REGEX_COMPILE_SIZE_LIMIT` CWE-1333 DoS guard), must not
1416            // abort the whole file's evaluation (R1/R2). This is
1417            // deliberately an exhaustive allowlist keyed on the
1418            // `UnsupportedType` diagnostic string
1419            // (`types::is_missing_pattern_operand` /
1420            // `types::is_regex_compile_failure`), not a broadening of the
1421            // general `UnsupportedType` exclusion above -- any OTHER
1422            // `UnsupportedType` (an unwired `TypeKind` variant, a
1423            // non-Equal/NotEqual operator on a pattern-bearing type, etc.)
1424            // still falls through to the catch-all below and propagates
1425            // (R3). See `log_pattern_operand_skip` for the debug!/warn!
1426            // split.
1427            Err(LibmagicError::EvaluationError(crate::error::EvaluationError::TypeReadError(
1428                crate::evaluator::types::TypeReadError::UnsupportedType { ref type_name },
1429            ))) if types::is_missing_pattern_operand(type_name)
1430                || types::is_regex_compile_failure(type_name) =>
1431            {
1432                log_pattern_operand_skip("top-level", &rule.message, type_name);
1433                continue;
1434            }
1435            Err(e) => {
1436                // Unexpected errors (InternalError, other UnsupportedType
1437                // conditions, etc.) should propagate.
1438                return Err(e);
1439            }
1440        };
1441
1442        if let Some((absolute_offset, read_value)) = match_data {
1443            let matches_before = matches.len();
1444
1445            // Advance the GNU `file` previous-match anchor BEFORE recursing
1446            // into children, so children and their descendants see the new
1447            // anchor. The anchor is updated unconditionally to the end of
1448            // this match -- it may move forward or backward depending on
1449            // where successive rules match (it is *not* a high-watermark).
1450            let consumed = types::bytes_consumed_with_pattern(
1451                buffer,
1452                absolute_offset,
1453                &rule.typ,
1454                Some(&rule.value),
1455            );
1456            let new_anchor = absolute_offset.saturating_add(consumed);
1457            context.set_last_match_end(new_anchor);
1458
1459            // Mark this level as "matched" so any subsequent `default`
1460            // sibling at the same level is suppressed, matching libmagic's
1461            // default-after-match semantics.
1462            sibling_matched = true;
1463
1464            let match_result = RuleMatch::new(
1465                rule.message.clone(),
1466                absolute_offset,
1467                rule.level,
1468                read_value,
1469                rule.typ.clone(),
1470                RuleMatch::calculate_confidence(rule.level),
1471            );
1472            matches.push(match_result);
1473
1474            // If this rule has children, evaluate them recursively
1475            if !rule.children.is_empty() {
1476                // Check recursion depth limit - this is a critical error that should stop evaluation.
1477                // `RecursionGuard` decrements the depth on drop, so every exit path below
1478                // (Ok, graceful warn!, or early-return via `?`) restores the counter.
1479                let mut guard = RecursionGuard::enter(context)?;
1480
1481                // Recursively evaluate child rules with graceful error handling
1482                match evaluate_rules(&rule.children, buffer, guard.context()) {
1483                    Ok(child_matches) => {
1484                        matches.extend(child_matches);
1485                    }
1486                    Err(LibmagicError::Timeout { timeout_ms }) => {
1487                        // Timeout is critical, propagate it up (guard drops here).
1488                        return Err(LibmagicError::Timeout { timeout_ms });
1489                    }
1490                    Err(
1491                        e @ (LibmagicError::EvaluationError(
1492                            crate::error::EvaluationError::BufferOverrun { .. }
1493                            | crate::error::EvaluationError::InvalidOffset { .. }
1494                            | crate::error::EvaluationError::InvalidValueTransform { .. }
1495                            | crate::error::EvaluationError::TypeReadError(
1496                                crate::evaluator::types::TypeReadError::BufferOverrun { .. }
1497                                | crate::evaluator::types::TypeReadError::InvalidPStringLength {
1498                                    ..
1499                                },
1500                            ),
1501                        )
1502                        | LibmagicError::IoError(_)),
1503                    ) => {
1504                        // Defensive: under the current implementation, individual child
1505                        // failures are caught and logged inside the recursive evaluate_rules
1506                        // call (they never propagate here). This arm guards against future
1507                        // changes that might alter that error-handling strategy.
1508                        //
1509                        // If this fires, the parent match is still emitted but the entire
1510                        // child subtree is silently dropped -- which means a partial,
1511                        // possibly-incorrect classification is returned to the caller.
1512                        // Logged at warn! (not debug!) so the asymmetry is visible.
1513                        warn!(
1514                            "Discarding child evaluation under rule '{}' due to unexpected error: {} -- parent match is still emitted; investigate the recursive evaluate_rules error-handling path",
1515                            rule.message, e
1516                        );
1517                    }
1518                    // Narrow graceful-skip (KTD4): same allowlist as the
1519                    // top-level dispatch match above and
1520                    // `evaluate_children_or_warn` -- a pattern-bearing type
1521                    // evaluated without a usable pattern operand, or a
1522                    // regex compile failure, must not abort the parent's
1523                    // match. Defensive: individual child failures are
1524                    // already caught inside the recursive `evaluate_rules`
1525                    // call and never reach here under the current
1526                    // implementation; this arm guards against a future
1527                    // change to that strategy.
1528                    Err(LibmagicError::EvaluationError(
1529                        crate::error::EvaluationError::TypeReadError(
1530                            crate::evaluator::types::TypeReadError::UnsupportedType {
1531                                ref type_name,
1532                            },
1533                        ),
1534                    )) if types::is_missing_pattern_operand(type_name)
1535                        || types::is_regex_compile_failure(type_name) =>
1536                    {
1537                        log_pattern_operand_skip("child", &rule.message, type_name);
1538                    }
1539                    Err(e) => {
1540                        // Unexpected errors in children (including RecursionLimitExceeded)
1541                        // should propagate. The guard drops here, decrementing the depth.
1542                        return Err(e);
1543                    }
1544                }
1545                // `guard` drops here, decrementing the recursion depth.
1546            }
1547
1548            // Stop at first match if configured to do so -- but only once
1549            // this rule (or one of its descendants) actually contributed
1550            // usable description text. A message-less match (e.g. a
1551            // gating rule used purely to trigger a child) must not shadow
1552            // a later, more specific top-level rule that would otherwise
1553            // produce real output (GOTCHAS S13.2).
1554            if stop_at_first_match_applies
1555                && context.should_stop_at_first_match()
1556                && has_message_bearing_match(&matches, matches_before)
1557            {
1558                break;
1559            }
1560        }
1561    }
1562
1563    Ok(matches)
1564}
1565
1566/// Evaluate magic rules with a fresh context
1567///
1568/// This is a convenience function that creates a new evaluation context
1569/// and evaluates the rules. Useful for simple evaluation scenarios.
1570///
1571/// # Arguments
1572///
1573/// * `rules` - The list of magic rules to evaluate
1574/// * `buffer` - The file buffer to evaluate against
1575/// * `config` - Configuration for evaluation behavior
1576///
1577/// # Returns
1578///
1579/// Returns `Ok(Vec<RuleMatch>)` containing all matches found, or `Err(LibmagicError)`
1580/// if evaluation fails.
1581///
1582/// # Examples
1583///
1584/// ```rust
1585/// use libmagic_rs::evaluator::{evaluate_rules_with_config, RuleMatch};
1586/// use libmagic_rs::parser::ast::{MagicRule, OffsetSpec, TypeKind, Operator, Value};
1587/// use libmagic_rs::EvaluationConfig;
1588///
1589/// let rule = MagicRule::new(OffsetSpec::Absolute(0), TypeKind::Byte { signed: true }, Operator::Equal, Value::Uint(0x7f), "ELF magic".to_string());
1590///
1591/// let rules = vec![rule];
1592/// let buffer = &[0x7f, 0x45, 0x4c, 0x46];
1593/// let config = EvaluationConfig::default();
1594///
1595/// let matches = evaluate_rules_with_config(&rules, buffer, &config).unwrap();
1596/// assert_eq!(matches.len(), 1);
1597/// assert_eq!(matches[0].message, "ELF magic");
1598/// ```
1599///
1600/// # Errors
1601///
1602/// * `LibmagicError::EvaluationError` - If rule evaluation fails
1603/// * `LibmagicError::Timeout` - If evaluation exceeds configured timeout
1604pub fn evaluate_rules_with_config(
1605    rules: &[MagicRule],
1606    buffer: &[u8],
1607    config: &EvaluationConfig,
1608) -> Result<Vec<RuleMatch>, LibmagicError> {
1609    // Validate the configuration before constructing a context so that
1610    // out-of-range values (e.g. zero recursion depth, excessive timeouts)
1611    // are rejected at the API boundary rather than triggering subtle
1612    // failures during evaluation.
1613    config.validate()?;
1614    // Diagnostic guard: `evaluate_rules_with_config` builds a context
1615    // without an attached `RuleEnvironment`, which means any
1616    // `MetaType::Indirect` rule reached during evaluation is silently
1617    // no-op'd at runtime. That is the intentional behavior for low-level
1618    // callers (matching the `Use`-without-env contract), but we surface
1619    // the misconfiguration at `warn!` level (once per process) so a
1620    // consumer who wires up env-less `indirect` rules will see the
1621    // diagnostic in default logging rather than only at debug level.
1622    // The tree walk runs only in debug builds -- in release builds the
1623    // `cfg(debug_assertions)` gate prevents the O(n) scan on every
1624    // top-level evaluation. Using `debug_assert!` would panic in test
1625    // builds and break the "evaluator never panics" invariant documented
1626    // in GOTCHAS S2.4 -- a misconfigured caller should get a no-op with
1627    // a log entry, not a crash.
1628    #[cfg(debug_assertions)]
1629    if contains_indirect_rule(rules)
1630        && !INDIRECT_WITHOUT_RULE_ENV_WARNED.swap(true, Ordering::Relaxed)
1631    {
1632        warn!(
1633            "{} (subsequent occurrences suppressed)",
1634            crate::error::EvaluationError::indirect_without_environment()
1635        );
1636    }
1637    // Clear the thread-local regex compile cache so it is bounded to
1638    // the lifetime of a single top-level evaluation call. Cache
1639    // entries from a previous rule set would otherwise persist on the
1640    // current thread until process exit. See
1641    // `evaluator::types::regex::reset_regex_cache` for rationale.
1642    crate::evaluator::types::regex::reset_regex_cache();
1643    let mut context = EvaluationContext::new(config.clone());
1644    evaluate_rules(rules, buffer, &mut context)
1645}
1646
1647/// Recursively walk `rules` (including children) looking for any
1648/// [`MetaType::Indirect`] directive.
1649///
1650/// Used by the diagnostic guard in [`evaluate_rules_with_config`]: the
1651/// low-level `_with_config` entry point builds a context without a
1652/// [`crate::evaluator::RuleEnvironment`], so any `indirect` rule is
1653/// silently no-op'd at runtime. The check logs the misconfiguration at
1654/// `debug!` level so consumer tests can detect it without panicking (see
1655/// GOTCHAS S2.4 for why `debug_assert!` would be wrong here).
1656// Gated to debug builds like its only caller (see the diagnostic guard in
1657// `evaluate_rules_with_config`).
1658#[cfg(debug_assertions)]
1659fn contains_indirect_rule(rules: &[MagicRule]) -> bool {
1660    rules.iter().any(|rule| {
1661        matches!(rule.typ, TypeKind::Meta(MetaType::Indirect))
1662            || contains_indirect_rule(&rule.children)
1663    })
1664}
1665
1666#[cfg(test)]
1667mod tests;