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