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