Skip to main content

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(0)` is the magic(5) `-0` form: the end-of-file
175            // *position* (`buffer.len()`), one past the last readable byte.
176            // It is valid as a position value for the `offset` pseudo-type
177            // (which never reads there) even though `resolve_absolute_offset`
178            // would wrongly send `0` through its positive path and resolve to
179            // start-of-file. Negative `FromEnd` deltas keep the shared
180            // from-end resolution (identical to negative `Absolute`); base
181            // offset never applies -- "from end" is always relative to the
182            // buffer itself.
183            if *offset == 0 {
184                return Ok(buffer.len());
185            }
186            resolve_absolute_offset(*offset, buffer).map_err(|e| map_offset_error(&e, *offset))
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_resolve_offset_absolute() {
197        let buffer = b"Test data for offset resolution";
198        let spec = OffsetSpec::Absolute(5);
199
200        let result = resolve_offset(&spec, buffer).unwrap();
201        assert_eq!(result, 5);
202    }
203
204    #[test]
205    fn test_resolve_offset_absolute_negative() {
206        let buffer = b"Test data";
207        let spec = OffsetSpec::Absolute(-4);
208
209        let result = resolve_offset(&spec, buffer).unwrap();
210        assert_eq!(result, 5); // 9 - 4 = 5
211    }
212
213    #[test]
214    fn test_resolve_offset_from_end() {
215        let buffer = b"Test data";
216        let spec = OffsetSpec::FromEnd(-3);
217
218        let result = resolve_offset(&spec, buffer).unwrap();
219        assert_eq!(result, 6); // 9 - 3 = 6
220    }
221
222    #[test]
223    fn test_resolve_offset_absolute_out_of_bounds() {
224        let buffer = b"Short";
225        let spec = OffsetSpec::Absolute(10);
226
227        let result = resolve_offset(&spec, buffer);
228        assert!(result.is_err());
229
230        match result.unwrap_err() {
231            LibmagicError::EvaluationError(crate::error::EvaluationError::BufferOverrun {
232                ..
233            }) => {
234                // Expected error type
235            }
236            _ => panic!("Expected EvaluationError with BufferOverrun"),
237        }
238    }
239
240    #[test]
241    fn test_resolve_offset_indirect_success() {
242        // Byte pointer at offset 0 with value 5 → resolves to offset 5
243        let buffer = b"\x05TestXdata";
244        let spec = OffsetSpec::Indirect {
245            base_offset: 0,
246            base_relative: false,
247            pointer_type: crate::parser::ast::TypeKind::Byte { signed: false },
248            adjustment: 0,
249            adjustment_op: crate::parser::ast::IndirectAdjustmentOp::Add,
250            result_relative: false,
251            endian: crate::parser::ast::Endianness::Little,
252        };
253
254        let result = resolve_offset(&spec, buffer).unwrap();
255        assert_eq!(result, 5);
256    }
257
258    #[test]
259    fn test_resolve_offset_relative_via_context() {
260        // Anchor 4 + delta 3 = absolute 7, in-bounds.
261        let buffer = b"0123456789ABCDEF";
262        let spec = OffsetSpec::Relative(3);
263        let resolved = resolve_offset_with_context(&spec, buffer, 4).unwrap();
264        assert_eq!(resolved, 7);
265    }
266
267    #[test]
268    fn test_resolve_offset_relative_top_level_default() {
269        // Calling resolve_offset (no context) should default the anchor to 0.
270        let buffer = b"0123456789ABCDEF";
271        let spec = OffsetSpec::Relative(5);
272        assert_eq!(resolve_offset(&spec, buffer).unwrap(), 5);
273    }
274
275    #[test]
276    fn test_resolve_offset_with_context_passthrough_absolute() {
277        // The context-aware dispatcher must not affect non-relative variants.
278        let buffer = b"Test data";
279        let spec = OffsetSpec::Absolute(4);
280        // last_match_end is irrelevant for Absolute.
281        assert_eq!(resolve_offset_with_context(&spec, buffer, 100).unwrap(), 4);
282    }
283
284    #[test]
285    fn test_resolve_offset_with_context_passthrough_from_end() {
286        let buffer = b"Test data";
287        let spec = OffsetSpec::FromEnd(-3);
288        assert_eq!(resolve_offset_with_context(&spec, buffer, 999).unwrap(), 6);
289    }
290
291    #[test]
292    fn test_resolve_from_end_zero_is_eof_position() {
293        // The magic(5) `-0` form (`FromEnd(0)`) resolves to the end-of-file
294        // POSITION -- `buffer.len()`, one past the last byte -- NOT offset 0.
295        // `resolve_absolute_offset(0)` would wrongly send it through the
296        // positive path and yield 0/start; the FromEnd arm special-cases it.
297        // Used by gzip's `>>-0 offset >48` trailing-size gate.
298        let buffer = b"Test data"; // 9 bytes
299        assert_eq!(
300            resolve_offset_with_context(&OffsetSpec::FromEnd(0), buffer, 0).unwrap(),
301            buffer.len(),
302            "FromEnd(0) must resolve to the EOF position (buffer.len())"
303        );
304        // Distinct from a real absolute 0.
305        assert_eq!(
306            resolve_offset_with_context(&OffsetSpec::Absolute(0), buffer, 0).unwrap(),
307            0
308        );
309        // Empty buffer: EOF position is 0, and that must not error.
310        assert_eq!(
311            resolve_offset_with_context(&OffsetSpec::FromEnd(0), b"", 0).unwrap(),
312            0
313        );
314    }
315
316    #[test]
317    fn test_resolve_offset_with_context_passthrough_indirect() {
318        // Same indirect setup as test_resolve_offset_indirect_success above.
319        let buffer = b"\x05TestXdata";
320        let spec = OffsetSpec::Indirect {
321            base_offset: 0,
322            base_relative: false,
323            pointer_type: crate::parser::ast::TypeKind::Byte { signed: false },
324            adjustment: 0,
325            adjustment_op: crate::parser::ast::IndirectAdjustmentOp::Add,
326            result_relative: false,
327            endian: crate::parser::ast::Endianness::Little,
328        };
329        assert_eq!(resolve_offset_with_context(&spec, buffer, 42).unwrap(), 5);
330    }
331
332    #[test]
333    fn test_resolve_offset_with_base_biases_positive_absolute() {
334        // Positive Absolute inside a subroutine body is biased by
335        // `base_offset`. This is the load-bearing invariant of
336        // `MetaType::Use` subroutine semantics.
337        let buffer = b"0123456789ABCDEF";
338        let spec = OffsetSpec::Absolute(4);
339        // base_offset = 10 -> resolves to 14 (not 4).
340        assert_eq!(
341            resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
342            14,
343            "positive Absolute must be biased by base_offset inside a subroutine"
344        );
345    }
346
347    #[test]
348    fn test_resolve_offset_with_base_does_not_bias_negative_absolute() {
349        // Negative Absolute means "from-end" semantics (magic(5)
350        // allows either explicit `FromEnd` or negative `Absolute`).
351        // The subroutine base_offset is relative to the file start
352        // and has no meaning for from-end positions.
353        let buffer = b"0123456789ABCDEF";
354        let spec = OffsetSpec::Absolute(-4);
355        // Without bias: resolves to len - 4 = 12.
356        // Buggy with-bias would give: 10 + (len - 4) or similar.
357        assert_eq!(
358            resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
359            12,
360            "negative Absolute must NOT be biased"
361        );
362    }
363
364    #[test]
365    fn test_resolve_offset_with_base_does_not_bias_from_end() {
366        // `FromEnd` is always relative to the buffer, not the
367        // subroutine's use-site.
368        let buffer = b"0123456789ABCDEF";
369        let spec = OffsetSpec::FromEnd(-4);
370        assert_eq!(
371            resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
372            12,
373            "FromEnd must NOT be biased"
374        );
375    }
376
377    #[test]
378    fn test_resolve_offset_with_base_does_not_bias_relative() {
379        // `Relative(N)` resolves against the previous-match anchor,
380        // not the subroutine base. Inside a subroutine body,
381        // `last_match_end` is seeded to the use-site by
382        // `SubroutineScope::enter`, so this already has the correct
383        // frame of reference without additional bias.
384        let buffer = b"0123456789ABCDEF";
385        let spec = OffsetSpec::Relative(3);
386        // last_match_end = 2, base_offset = 10.
387        // Expected: 2 + 3 = 5 (bias does NOT apply).
388        assert_eq!(
389            resolve_offset_with_base(&spec, buffer, 2, 10).unwrap(),
390            5,
391            "Relative must NOT be biased (already resolved against last_match_end)"
392        );
393    }
394
395    #[test]
396    fn test_resolve_offset_with_base_does_not_bias_indirect() {
397        // `Indirect` reads a pointer from the buffer; the pointer's
398        // value is an absolute file position, not a subroutine-
399        // relative one.
400        let buffer = b"\x05TestXdata";
401        let spec = OffsetSpec::Indirect {
402            base_offset: 0,
403            base_relative: false,
404            pointer_type: crate::parser::ast::TypeKind::Byte { signed: false },
405            adjustment: 0,
406            adjustment_op: crate::parser::ast::IndirectAdjustmentOp::Add,
407            result_relative: false,
408            endian: crate::parser::ast::Endianness::Little,
409        };
410        assert_eq!(
411            resolve_offset_with_base(&spec, buffer, 0, 10).unwrap(),
412            5,
413            "Indirect must NOT be biased"
414        );
415    }
416
417    #[test]
418    fn test_resolve_offset_comprehensive() {
419        let buffer = b"0123456789ABCDEF";
420
421        // Test various absolute offsets
422        let test_cases = vec![
423            (OffsetSpec::Absolute(0), 0),
424            (OffsetSpec::Absolute(8), 8),
425            (OffsetSpec::Absolute(15), 15),
426            (OffsetSpec::Absolute(-1), 15),
427            (OffsetSpec::Absolute(-8), 8),
428            (OffsetSpec::Absolute(-16), 0),
429            (OffsetSpec::FromEnd(-1), 15),
430            (OffsetSpec::FromEnd(-8), 8),
431            (OffsetSpec::FromEnd(-16), 0),
432        ];
433
434        for (spec, expected) in test_cases {
435            let result = resolve_offset(&spec, buffer).unwrap();
436            assert_eq!(result, expected, "Failed for spec: {spec:?}");
437        }
438    }
439
440    /// Regression test for RU0: `base_offset + large_positive_absolute` that
441    /// overflows `usize` must produce `InvalidOffset`, not `BufferOverrun`.
442    ///
443    /// Before the fix, saturating arithmetic turned overflow into `usize::MAX`
444    /// (or `i64::MAX`), which then flowed into `resolve_absolute_offset` and
445    /// surfaced as a `BufferOverrun` at that giant offset -- losing the more
446    /// precise overflow signal.
447    #[test]
448    fn test_resolve_offset_with_base_overflow_yields_invalid_offset() {
449        let buffer = b"0123456789ABCDEF"; // 16 bytes
450        // base_offset near usize::MAX combined with any positive Absolute
451        // must overflow. Use usize::MAX - 1 so that adding even 2 overflows.
452        let base = usize::MAX - 1;
453        let spec = OffsetSpec::Absolute(2); // base + 2 overflows usize
454
455        let result = resolve_offset_with_base(&spec, buffer, 0, base);
456        assert!(
457            result.is_err(),
458            "overflow of base_offset + absolute must fail"
459        );
460        match result.unwrap_err() {
461            LibmagicError::EvaluationError(crate::error::EvaluationError::InvalidOffset {
462                ..
463            }) => {
464                // Correct: overflow reported as InvalidOffset, not BufferOverrun.
465            }
466            LibmagicError::EvaluationError(crate::error::EvaluationError::BufferOverrun {
467                ..
468            }) => {
469                panic!(
470                    "overflow of base_offset + absolute must be InvalidOffset, not BufferOverrun"
471                );
472            }
473            other => panic!("unexpected error variant: {other:?}"),
474        }
475    }
476}