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