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