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;