libmagic_rs/evaluator/offset/mod.rs
1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Offset resolution for magic rule evaluation
5//!
6//! This module provides functions for resolving different types of offset specifications
7//! into absolute byte positions within file buffers, with proper bounds checking.
8
9mod absolute;
10mod indirect;
11mod relative;
12
13pub use absolute::{OffsetError, resolve_absolute_offset};
14
15use crate::LibmagicError;
16use crate::parser::ast::OffsetSpec;
17
18/// Map an `OffsetError` to a `LibmagicError` for a given original offset value
19pub(crate) fn map_offset_error(e: &OffsetError, original_offset: i64) -> LibmagicError {
20 match e {
21 OffsetError::BufferOverrun {
22 offset,
23 buffer_len: _,
24 } => LibmagicError::EvaluationError(crate::error::EvaluationError::BufferOverrun {
25 offset: *offset,
26 }),
27 OffsetError::InvalidOffset { reason: _ } | OffsetError::ArithmeticOverflow => {
28 LibmagicError::EvaluationError(crate::error::EvaluationError::InvalidOffset {
29 offset: original_offset,
30 })
31 }
32 }
33}
34
35/// Resolve any offset specification to an absolute position.
36///
37/// Convenience wrapper for callers that do not have a relative-offset anchor
38/// (e.g., tests, top-level evaluation with no prior match). Internally
39/// delegates with `last_match_end = 0`. For `OffsetSpec::Relative`, that
40/// means non-negative deltas behave like absolute offsets from the start of
41/// the buffer (`Relative(N)` for `N >= 0` resolves to absolute `N`), but
42/// negative deltas underflow the anchor and return
43/// `EvaluationError::InvalidOffset` -- they are *not* interpreted like
44/// `OffsetSpec::Absolute(-N)` from the end of the buffer. Callers that need
45/// relative offsets to anchor against actual prior matches should use
46/// `evaluate_rules` and let the engine thread the anchor.
47///
48/// **Behavior change:** before the relative-offset feature landed in v0.5,
49/// this function returned `EvaluationError::UnsupportedType` for
50/// `OffsetSpec::Relative`. It now resolves against anchor 0, which can
51/// succeed (non-negative delta) or fail with `InvalidOffset` (negative
52/// delta) depending on the value. Callers with existing error-handling code
53/// that pattern-matched `UnsupportedType` for relative offsets must remove
54/// that arm.
55///
56/// # Arguments
57///
58/// * `spec` - The offset specification to resolve
59/// * `buffer` - The file buffer to resolve against
60///
61/// # Returns
62///
63/// Returns the resolved absolute offset as a `usize`, or a `LibmagicError` if resolution fails.
64///
65/// # Examples
66///
67/// ```rust
68/// use libmagic_rs::evaluator::offset::resolve_offset;
69/// use libmagic_rs::parser::ast::OffsetSpec;
70///
71/// let buffer = b"Test data";
72/// let spec = OffsetSpec::Absolute(4);
73///
74/// let offset = resolve_offset(&spec, buffer).unwrap();
75/// assert_eq!(offset, 4);
76/// ```
77///
78/// # Errors
79///
80/// * `LibmagicError::EvaluationError` - If offset resolution fails
81pub fn resolve_offset(spec: &OffsetSpec, buffer: &[u8]) -> Result<usize, LibmagicError> {
82 resolve_offset_with_context(spec, buffer, 0)
83}
84
85/// Resolve any offset specification, including relative offsets, against a
86/// previous-match anchor.
87///
88/// This is the full dispatcher used by the evaluation engine. It handles all
89/// `OffsetSpec` variants:
90///
91/// - [`OffsetSpec::Absolute`] / [`OffsetSpec::FromEnd`]: resolved against the
92/// buffer (sign-aware), `last_match_end` ignored.
93/// - [`OffsetSpec::Indirect`]: resolved by reading a pointer value from the
94/// buffer, `last_match_end` ignored.
95/// - [`OffsetSpec::Relative`]: resolved as `last_match_end + delta`,
96/// bounds-checked. The anchor `0` makes top-level relative offsets resolve
97/// from the file start.
98///
99/// `pub(crate)` because the anchor-threading contract is internal to the
100/// evaluation engine -- external callers use [`resolve_offset`] (which
101/// hardcodes anchor 0) or go through `evaluate_rules`.
102///
103/// # Arguments
104///
105/// * `spec` - The offset specification to resolve
106/// * `buffer` - The file buffer to resolve against
107/// * `last_match_end` - End offset of the most recent successful match.
108/// Supplied by the engine via `EvaluationContext::last_match_end()`. Pass
109/// `0` if no prior match exists.
110///
111/// # Errors
112///
113/// * `LibmagicError::EvaluationError` - If offset resolution fails for any
114/// variant. Relative-offset failures surface as `BufferOverrun` (target
115/// past end of buffer) or `InvalidOffset` (arithmetic over/underflow).
116pub(crate) fn resolve_offset_with_context(
117 spec: &OffsetSpec,
118 buffer: &[u8],
119 last_match_end: usize,
120) -> Result<usize, LibmagicError> {
121 resolve_offset_with_base(spec, buffer, last_match_end, 0)
122}
123
124/// Like [`resolve_offset_with_context`] but applies a subroutine
125/// `base_offset` to positive absolute offsets.
126///
127/// Inside a `MetaType::Use` subroutine body, `OffsetSpec::Absolute(n)`
128/// with `n >= 0` resolves to `base_offset + n`, matching magic(5)
129/// semantics where the subroutine's offsets are relative to the
130/// caller's invocation point. Negative `Absolute`, `FromEnd`,
131/// `Relative`, and `Indirect` are unaffected -- they already have
132/// well-defined frames of reference (buffer end, previous match, or
133/// a pointer read from the buffer).
134pub(crate) fn resolve_offset_with_base(
135 spec: &OffsetSpec,
136 buffer: &[u8],
137 last_match_end: usize,
138 base_offset: usize,
139) -> Result<usize, LibmagicError> {
140 match spec {
141 OffsetSpec::Absolute(offset) => {
142 // Apply base_offset only to positive absolute offsets.
143 // Negative values mean "from end" and should not be shifted
144 // by the subroutine base.
145 let effective = if *offset >= 0 {
146 // Use checked conversions so overflow is reported as
147 // InvalidOffset rather than silently producing a huge
148 // biased value that later surfaces as BufferOverrun.
149 let abs = usize::try_from(*offset).map_err(|_| {
150 LibmagicError::EvaluationError(crate::error::EvaluationError::InvalidOffset {
151 offset: *offset,
152 })
153 })?;
154 let biased = base_offset
155 .checked_add(abs)
156 .ok_or(LibmagicError::EvaluationError(
157 crate::error::EvaluationError::InvalidOffset { offset: *offset },
158 ))?;
159 i64::try_from(biased).map_err(|_| {
160 LibmagicError::EvaluationError(crate::error::EvaluationError::InvalidOffset {
161 offset: *offset,
162 })
163 })?
164 } else {
165 *offset
166 };
167 resolve_absolute_offset(effective, buffer).map_err(|e| map_offset_error(&e, effective))
168 }
169 OffsetSpec::Indirect { .. } => {
170 indirect::resolve_indirect_offset_with_anchor(spec, buffer, Some(last_match_end))
171 }
172 OffsetSpec::Relative(_) => relative::resolve_relative_offset(spec, buffer, last_match_end),
173 OffsetSpec::FromEnd(offset) => {
174 // FromEnd is handled the same as negative Absolute offsets.
175 // Base offset does not apply -- "from end" is always
176 // relative to the buffer itself.
177 resolve_absolute_offset(*offset, buffer).map_err(|e| map_offset_error(&e, *offset))
178 }
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn test_resolve_offset_absolute() {
188 let buffer = b"Test data for offset resolution";
189 let spec = OffsetSpec::Absolute(5);
190
191 let result = resolve_offset(&spec, buffer).unwrap();
192 assert_eq!(result, 5);
193 }
194
195 #[test]
196 fn test_resolve_offset_absolute_negative() {
197 let buffer = b"Test data";
198 let spec = OffsetSpec::Absolute(-4);
199
200 let result = resolve_offset(&spec, buffer).unwrap();
201 assert_eq!(result, 5); // 9 - 4 = 5
202 }
203
204 #[test]
205 fn test_resolve_offset_from_end() {
206 let buffer = b"Test data";
207 let spec = OffsetSpec::FromEnd(-3);
208
209 let result = resolve_offset(&spec, buffer).unwrap();
210 assert_eq!(result, 6); // 9 - 3 = 6
211 }
212
213 #[test]
214 fn test_resolve_offset_absolute_out_of_bounds() {
215 let buffer = b"Short";
216 let spec = OffsetSpec::Absolute(10);
217
218 let result = resolve_offset(&spec, buffer);
219 assert!(result.is_err());
220
221 match result.unwrap_err() {
222 LibmagicError::EvaluationError(crate::error::EvaluationError::BufferOverrun {
223 ..
224 }) => {
225 // Expected error type
226 }
227 _ => panic!("Expected EvaluationError with BufferOverrun"),
228 }
229 }
230
231 #[test]
232 fn test_resolve_offset_indirect_success() {
233 // Byte pointer at offset 0 with value 5 → resolves to offset 5
234 let buffer = b"\x05TestXdata";
235 let spec = OffsetSpec::Indirect {
236 base_offset: 0,
237 base_relative: false,
238 pointer_type: crate::parser::ast::TypeKind::Byte { signed: false },
239 adjustment: 0,
240 adjustment_op: crate::parser::ast::IndirectAdjustmentOp::Add,
241 result_relative: false,
242 endian: crate::parser::ast::Endianness::Little,
243 };
244
245 let result = resolve_offset(&spec, buffer).unwrap();
246 assert_eq!(result, 5);
247 }
248
249 #[test]
250 fn test_resolve_offset_relative_via_context() {
251 // Anchor 4 + delta 3 = absolute 7, in-bounds.
252 let buffer = b"0123456789ABCDEF";
253 let spec = OffsetSpec::Relative(3);
254 let resolved = resolve_offset_with_context(&spec, buffer, 4).unwrap();
255 assert_eq!(resolved, 7);
256 }
257
258 #[test]
259 fn test_resolve_offset_relative_top_level_default() {
260 // Calling resolve_offset (no context) should default the anchor to 0.
261 let buffer = b"0123456789ABCDEF";
262 let spec = OffsetSpec::Relative(5);
263 assert_eq!(resolve_offset(&spec, buffer).unwrap(), 5);
264 }
265
266 #[test]
267 fn test_resolve_offset_with_context_passthrough_absolute() {
268 // The context-aware dispatcher must not affect non-relative variants.
269 let buffer = b"Test data";
270 let spec = OffsetSpec::Absolute(4);
271 // last_match_end is irrelevant for Absolute.
272 assert_eq!(resolve_offset_with_context(&spec, buffer, 100).unwrap(), 4);
273 }
274
275 #[test]
276 fn test_resolve_offset_with_context_passthrough_from_end() {
277 let buffer = b"Test data";
278 let spec = OffsetSpec::FromEnd(-3);
279 assert_eq!(resolve_offset_with_context(&spec, buffer, 999).unwrap(), 6);
280 }
281
282 #[test]
283 fn test_resolve_offset_with_context_passthrough_indirect() {
284 // Same indirect setup as test_resolve_offset_indirect_success above.
285 let buffer = b"\x05TestXdata";
286 let spec = OffsetSpec::Indirect {
287 base_offset: 0,
288 base_relative: false,
289 pointer_type: crate::parser::ast::TypeKind::Byte { signed: false },
290 adjustment: 0,
291 adjustment_op: crate::parser::ast::IndirectAdjustmentOp::Add,
292 result_relative: false,
293 endian: crate::parser::ast::Endianness::Little,
294 };
295 assert_eq!(resolve_offset_with_context(&spec, buffer, 42).unwrap(), 5);
296 }
297
298 #[test]
299 fn test_resolve_offset_with_base_biases_positive_absolute() {
300 // Positive Absolute inside a subroutine body is biased by
301 // `base_offset`. This is the load-bearing invariant of
302 // `MetaType::Use` subroutine semantics.
303 let buffer = b"0123456789ABCDEF";
304 let spec = OffsetSpec::Absolute(4);
305 // base_offset = 10 -> resolves to 14 (not 4).
306 assert_eq!(
307 resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
308 14,
309 "positive Absolute must be biased by base_offset inside a subroutine"
310 );
311 }
312
313 #[test]
314 fn test_resolve_offset_with_base_does_not_bias_negative_absolute() {
315 // Negative Absolute means "from-end" semantics (magic(5)
316 // allows either explicit `FromEnd` or negative `Absolute`).
317 // The subroutine base_offset is relative to the file start
318 // and has no meaning for from-end positions.
319 let buffer = b"0123456789ABCDEF";
320 let spec = OffsetSpec::Absolute(-4);
321 // Without bias: resolves to len - 4 = 12.
322 // Buggy with-bias would give: 10 + (len - 4) or similar.
323 assert_eq!(
324 resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
325 12,
326 "negative Absolute must NOT be biased"
327 );
328 }
329
330 #[test]
331 fn test_resolve_offset_with_base_does_not_bias_from_end() {
332 // `FromEnd` is always relative to the buffer, not the
333 // subroutine's use-site.
334 let buffer = b"0123456789ABCDEF";
335 let spec = OffsetSpec::FromEnd(-4);
336 assert_eq!(
337 resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
338 12,
339 "FromEnd must NOT be biased"
340 );
341 }
342
343 #[test]
344 fn test_resolve_offset_with_base_does_not_bias_relative() {
345 // `Relative(N)` resolves against the previous-match anchor,
346 // not the subroutine base. Inside a subroutine body,
347 // `last_match_end` is seeded to the use-site by
348 // `SubroutineScope::enter`, so this already has the correct
349 // frame of reference without additional bias.
350 let buffer = b"0123456789ABCDEF";
351 let spec = OffsetSpec::Relative(3);
352 // last_match_end = 2, base_offset = 10.
353 // Expected: 2 + 3 = 5 (bias does NOT apply).
354 assert_eq!(
355 resolve_offset_with_base(&spec, buffer, 2, 10).unwrap(),
356 5,
357 "Relative must NOT be biased (already resolved against last_match_end)"
358 );
359 }
360
361 #[test]
362 fn test_resolve_offset_with_base_does_not_bias_indirect() {
363 // `Indirect` reads a pointer from the buffer; the pointer's
364 // value is an absolute file position, not a subroutine-
365 // relative one.
366 let buffer = b"\x05TestXdata";
367 let spec = OffsetSpec::Indirect {
368 base_offset: 0,
369 base_relative: false,
370 pointer_type: crate::parser::ast::TypeKind::Byte { signed: false },
371 adjustment: 0,
372 adjustment_op: crate::parser::ast::IndirectAdjustmentOp::Add,
373 result_relative: false,
374 endian: crate::parser::ast::Endianness::Little,
375 };
376 assert_eq!(
377 resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
378 5,
379 "Indirect must NOT be biased"
380 );
381 }
382
383 #[test]
384 fn test_resolve_offset_comprehensive() {
385 let buffer = b"0123456789ABCDEF";
386
387 // Test various absolute offsets
388 let test_cases = vec![
389 (OffsetSpec::Absolute(0), 0),
390 (OffsetSpec::Absolute(8), 8),
391 (OffsetSpec::Absolute(15), 15),
392 (OffsetSpec::Absolute(-1), 15),
393 (OffsetSpec::Absolute(-8), 8),
394 (OffsetSpec::Absolute(-16), 0),
395 (OffsetSpec::FromEnd(-1), 15),
396 (OffsetSpec::FromEnd(-8), 8),
397 (OffsetSpec::FromEnd(-16), 0),
398 ];
399
400 for (spec, expected) in test_cases {
401 let result = resolve_offset(&spec, buffer).unwrap();
402 assert_eq!(result, expected, "Failed for spec: {spec:?}");
403 }
404 }
405
406 /// Regression test for RU0: `base_offset + large_positive_absolute` that
407 /// overflows `usize` must produce `InvalidOffset`, not `BufferOverrun`.
408 ///
409 /// Before the fix, saturating arithmetic turned overflow into `usize::MAX`
410 /// (or `i64::MAX`), which then flowed into `resolve_absolute_offset` and
411 /// surfaced as a `BufferOverrun` at that giant offset -- losing the more
412 /// precise overflow signal.
413 #[test]
414 fn test_resolve_offset_with_base_overflow_yields_invalid_offset() {
415 let buffer = b"0123456789ABCDEF"; // 16 bytes
416 // base_offset near usize::MAX combined with any positive Absolute
417 // must overflow. Use usize::MAX - 1 so that adding even 2 overflows.
418 let base = usize::MAX - 1;
419 let spec = OffsetSpec::Absolute(2); // base + 2 overflows usize
420
421 let result = resolve_offset_with_base(&spec, buffer, 0, base);
422 assert!(
423 result.is_err(),
424 "overflow of base_offset + absolute must fail"
425 );
426 match result.unwrap_err() {
427 LibmagicError::EvaluationError(crate::error::EvaluationError::InvalidOffset {
428 ..
429 }) => {
430 // Correct: overflow reported as InvalidOffset, not BufferOverrun.
431 }
432 LibmagicError::EvaluationError(crate::error::EvaluationError::BufferOverrun {
433 ..
434 }) => {
435 panic!(
436 "overflow of base_offset + absolute must be InvalidOffset, not BufferOverrun"
437 );
438 }
439 other => panic!("unexpected error variant: {other:?}"),
440 }
441 }
442}