Skip to main content

xfs_forensic/
lib.rs

1//! `xfs-forensic` — anomaly auditor for XFS filesystems.
2//!
3//! Emits graded [`forensicnomicon::report::Finding`]s for XFS-specific forensic
4//! signals: deleted-inode recovery (extent records surviving in inode slack),
5//! directory-slack residue (freed dirents keeping their inode number), and v5
6//! self-describing-metadata integrity (CRC / owner / blkno mismatches).
7//!
8//! Built on `xfs-core` for valid-path reading; where the audit must see slack
9//! and malformed structure the reader normalizes away, it parses the raw bytes
10//! directly (the reader/analyzer-split principle).
11//!
12//! Each finding is an **observation** ("consistent with …"); the examiner draws
13//! the conclusions. Mirrors the fleet producer pattern (typed `AnomalyKind` +
14//! `impl Observation` + `audit_*` → `Vec<Anomaly>` + `audit_findings` →
15//! `Vec<Finding>`), as in `ntfs-forensic`.
16
17#![forbid(unsafe_code)]
18#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
19
20pub use forensicnomicon::report::Severity;
21use forensicnomicon::report::{Evidence, Finding, Location, Observation, Source};
22
23use xfs::{
24    assemble_extents, Agf, Agi, BmbtRec, Inode, Superblock, XfsTimestamp, XFS_DINODE_MAGIC,
25    XFS_SB_MAGIC,
26};
27
28// ── F3: structural-integrity anomaly kinds ───────────────────────────────────
29
30/// Classification of an XFS structural-integrity anomaly (F3). Each variant
31/// carries the evidence needed to reproduce the observation.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum AnomalyKind {
34    /// A v5 self-describing metadata block whose stored CRC32c does not verify
35    /// over its buffer — corruption or post-write tampering.
36    CrcMismatch {
37        /// The metadata structure that failed (`superblock`, `AGI`, `AGF`,
38        /// `inode`, …).
39        structure: &'static str,
40        /// Absolute byte offset of the block in the image.
41        offset: u64,
42    },
43    /// A secondary superblock (AG 1..n) whose geometry differs from the AG-0
44    /// primary — consistent with a spliced/edited image.
45    SbMirrorDivergence {
46        /// The allocation group whose secondary SB diverged.
47        agno: u64,
48        /// The geometry field that differs (e.g. `agblocks`).
49        field: &'static str,
50        /// The primary AG-0 value.
51        primary: u64,
52        /// The secondary (diverging) value.
53        secondary: u64,
54        /// Absolute byte offset of the secondary superblock.
55        offset: u64,
56    },
57    /// A non-null entry in an AGI `agi_unlinked[64]` bucket — an inode unlinked
58    /// while still open (orphaned-but-live), a recovery lead.
59    OrphanedInode {
60        /// The allocation group whose AGI carried the entry.
61        agno: u64,
62        /// The `unlinked[64]` bucket index (`0..64`).
63        bucket: usize,
64        /// The AG-relative inode number the bucket points at.
65        agino: u32,
66    },
67    /// A geometry field beyond sane bounds relative to the image size — an
68    /// allocation-bomb / corruption guard.
69    ImpossibleGeometry {
70        /// The offending field name.
71        field: &'static str,
72        /// The value read from the structure.
73        value: u64,
74        /// The sane upper bound derived from the image size / spec.
75        limit: u64,
76    },
77}
78
79impl AnomalyKind {
80    /// Severity — the single source of truth for this kind.
81    #[must_use]
82    pub fn severity(&self) -> Severity {
83        match self {
84            AnomalyKind::CrcMismatch { .. }
85            | AnomalyKind::SbMirrorDivergence { .. }
86            | AnomalyKind::ImpossibleGeometry { .. } => Severity::High,
87            AnomalyKind::OrphanedInode { .. } => Severity::Medium,
88        }
89    }
90
91    /// Stable machine-readable, scheme-prefixed code.
92    #[must_use]
93    pub fn code(&self) -> &'static str {
94        match self {
95            AnomalyKind::CrcMismatch { .. } => "XFS-CRC-MISMATCH",
96            AnomalyKind::SbMirrorDivergence { .. } => "XFS-SB-MIRROR-DIVERGENCE",
97            AnomalyKind::OrphanedInode { .. } => "XFS-ORPHANED-INODE",
98            AnomalyKind::ImpossibleGeometry { .. } => "XFS-IMPOSSIBLE-GEOMETRY",
99        }
100    }
101
102    /// Human-readable, "consistent with" note.
103    #[must_use]
104    pub fn note(&self) -> String {
105        match self {
106            AnomalyKind::CrcMismatch { structure, offset } => format!(
107                "{structure} at byte {offset}: stored v5 CRC32c does not verify — consistent with corruption or post-write tampering"
108            ),
109            AnomalyKind::SbMirrorDivergence {
110                agno,
111                field,
112                primary,
113                secondary,
114                ..
115            } => format!(
116                "AG {agno} secondary superblock: {field} = {secondary} differs from AG-0 primary {primary} — consistent with a spliced or edited image"
117            ),
118            AnomalyKind::OrphanedInode {
119                agno,
120                bucket,
121                agino,
122            } => format!(
123                "AG {agno} AGI unlinked bucket {bucket} points at agino {agino} — an inode unlinked while still open (orphaned-but-live), a recovery lead"
124            ),
125            AnomalyKind::ImpossibleGeometry {
126                field,
127                value,
128                limit,
129            } => format!(
130                "geometry field {field} = {value} exceeds the sane bound {limit} for this image — consistent with corruption or an allocation-bomb"
131            ),
132        }
133    }
134
135    fn evidence(&self) -> Vec<Evidence> {
136        match self {
137            AnomalyKind::CrcMismatch { structure, offset } => vec![Evidence {
138                field: "structure".to_string(),
139                value: (*structure).to_string(),
140                location: Some(Location::ByteOffset(*offset)),
141            }],
142            AnomalyKind::SbMirrorDivergence {
143                agno,
144                field,
145                primary,
146                secondary,
147                offset,
148            } => vec![Evidence {
149                field: (*field).to_string(),
150                value: format!("AG{agno} secondary={secondary} vs primary={primary}"),
151                location: Some(Location::ByteOffset(*offset)),
152            }],
153            AnomalyKind::OrphanedInode {
154                agno,
155                bucket,
156                agino,
157            } => vec![Evidence {
158                field: "agi_unlinked".to_string(),
159                value: format!("AG{agno} bucket[{bucket}] -> agino {agino}"),
160                location: Some(Location::Other {
161                    space: "xfs:agino".to_string(),
162                    value: u64::from(*agino),
163                }),
164            }],
165            AnomalyKind::ImpossibleGeometry {
166                field,
167                value,
168                limit,
169            } => vec![Evidence {
170                field: (*field).to_string(),
171                value: format!("{value} (limit {limit})"),
172                location: None,
173            }],
174        }
175    }
176}
177
178/// An XFS structural-integrity anomaly: an observation graded by severity, with
179/// a stable code and note derived from its [`AnomalyKind`] so they cannot drift.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Anomaly {
182    /// Severity, derived from `kind`.
183    pub severity: Severity,
184    /// Stable machine-readable code, derived from `kind`.
185    pub code: &'static str,
186    /// The classified anomaly with its evidence.
187    pub kind: AnomalyKind,
188    /// Human-readable note, derived from `kind`.
189    pub note: String,
190}
191
192impl Anomaly {
193    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
194    #[must_use]
195    pub fn new(kind: AnomalyKind) -> Self {
196        Anomaly {
197            severity: kind.severity(),
198            code: kind.code(),
199            note: kind.note(),
200            kind,
201        }
202    }
203}
204
205impl Observation for Anomaly {
206    fn severity(&self) -> Option<Severity> {
207        Some(self.severity)
208    }
209    fn code(&self) -> &'static str {
210        self.code
211    }
212    fn note(&self) -> String {
213        self.note.clone()
214    }
215    fn evidence(&self) -> Vec<Evidence> {
216        self.kind.evidence()
217    }
218}
219
220// ── F3: the image auditor ─────────────────────────────────────────────────────
221
222/// Audit a whole XFS image for structural-integrity anomalies (F3): parse the
223/// primary superblock, walk every AG, and check each of CRC validity, secondary
224/// superblock divergence, orphaned inodes, and impossible geometry.
225///
226/// A clean image yields an empty vector. Malformed input never panics.
227#[must_use]
228pub fn audit_image(image: &[u8]) -> Vec<Anomaly> {
229    let mut out = Vec::new();
230
231    // Too small to hold a superblock, or not XFS: nothing to audit (never panic).
232    if image.len() < 220 || be_u32(image, 0) != XFS_SB_MAGIC {
233        return out;
234    }
235
236    // Sector size (`xfs_dsb.sb_sectsize` @102) locates the per-AG headers. Clamp an
237    // absurd value so a corrupt field cannot mis-slice; a real image is unaffected.
238    let raw_sect = usize::from(be_u16(image, 102));
239    let sect = if raw_sect.is_power_of_two() && (512..=65536).contains(&raw_sect) {
240        raw_sect
241    } else {
242        512
243    };
244
245    // Parse the primary superblock over EXACTLY its sector so its CRC covers the
246    // sector — parsing over the whole image would CRC the whole image and mis-fire
247    // `Some(false)` on a clean filesystem.
248    let sb_end = sect.min(image.len());
249    let Ok(sb) = Superblock::parse(&image[..sb_end]) else {
250        return out; // cov:unreachable: magic + length already validated above
251    };
252    let is_v5 = sb.is_v5();
253
254    if is_v5 && sb.crc_valid == Some(false) {
255        out.push(Anomaly::new(AnomalyKind::CrcMismatch {
256            structure: "superblock",
257            offset: 0,
258        }));
259    }
260
261    let bsize = u64::from(sb.blocksize);
262    let agblocks = u64::from(sb.agblocks);
263    let agcount = u64::from(sb.agcount);
264    let ag_bytes = agblocks.saturating_mul(bsize);
265    let image_len = image.len() as u64;
266
267    // Impossible geometry: `agcount` so large the last AG's base lies past the
268    // image (a spliced/corrupt count or an allocation-bomb); `agcount == 0` too.
269    if agcount == 0 {
270        out.push(Anomaly::new(AnomalyKind::ImpossibleGeometry {
271            field: "agcount",
272            value: 0,
273            limit: 1,
274        }));
275    } else if ag_bytes > 0 {
276        let last_base = agcount.saturating_sub(1).saturating_mul(ag_bytes);
277        if last_base >= image_len {
278            out.push(Anomaly::new(AnomalyKind::ImpossibleGeometry {
279                field: "agcount",
280                value: agcount,
281                limit: image_len / ag_bytes + 1,
282            }));
283        }
284    }
285
286    // Walk each allocation group that actually fits in the image: check the
287    // secondary superblock (CRC + geometry divergence), the AGF CRC, the AGI CRC,
288    // and the AGI `unlinked[64]` orphan buckets. The loop stops at the first AG
289    // whose base lies past the image, so an absurd `agcount` cannot spin.
290    if ag_bytes > 0 {
291        for agno in 0..agcount {
292            let base = agno.saturating_mul(ag_bytes);
293            let base_us = usize::try_from(base).unwrap_or(usize::MAX);
294            if base_us >= image.len() {
295                break;
296            }
297
298            // Secondary superblock (AG 1..n): CRC + geometry divergence vs AG-0.
299            if agno >= 1 {
300                if let Some(slice) = image.get(base_us..base_us.saturating_add(sect)) {
301                    if let Ok(sec) = Superblock::parse(slice) {
302                        if is_v5 && sec.crc_valid == Some(false) {
303                            out.push(Anomaly::new(AnomalyKind::CrcMismatch {
304                                structure: "superblock",
305                                offset: base,
306                            }));
307                        }
308                        push_sb_divergence(&mut out, agno, base, &sb, &sec);
309                    }
310                }
311            }
312
313            // AGF at sector 1.
314            let agf_off = base_us.saturating_add(sect);
315            if let Some(slice) = image.get(agf_off..agf_off.saturating_add(sect)) {
316                if let Ok(agf) = Agf::parse_verified(slice, is_v5) {
317                    if agf.crc_valid == Some(false) {
318                        out.push(Anomaly::new(AnomalyKind::CrcMismatch {
319                            structure: "AGF",
320                            offset: agf_off as u64,
321                        }));
322                    }
323                }
324            }
325
326            // AGI at sector 2 — CRC + the `unlinked[64]` orphan buckets.
327            let agi_off = base_us.saturating_add(sect.saturating_mul(2));
328            if let Some(slice) = image.get(agi_off..agi_off.saturating_add(sect)) {
329                if let Ok(agi) = Agi::parse_verified(slice, is_v5) {
330                    if agi.crc_valid == Some(false) {
331                        out.push(Anomaly::new(AnomalyKind::CrcMismatch {
332                            structure: "AGI",
333                            offset: agi_off as u64,
334                        }));
335                    }
336                    for (bucket, &agino) in agi.unlinked.iter().enumerate() {
337                        if agino != NULL_AGINO {
338                            out.push(Anomaly::new(AnomalyKind::OrphanedInode {
339                                agno,
340                                bucket,
341                                agino,
342                            }));
343                        }
344                    }
345                }
346            }
347        }
348    }
349
350    // Inode CRC sweep (v5 only — v4 inodes carry no CRC). A slot is a genuine
351    // inode iff its `di_ino` self-reference equals the inode number its byte
352    // offset decodes to; that filter keeps a stray `IN` in file data from
353    // mis-flagging as a corrupt inode.
354    if is_v5 {
355        let inode_size = usize::from(sb.inodesize);
356        if inode_size >= 176 {
357            let mut off = 0usize;
358            while off.saturating_add(inode_size) <= image.len() {
359                if be_u16(image, off) == XFS_DINODE_MAGIC {
360                    if let Some(slice) = image.get(off..off.saturating_add(inode_size)) {
361                        if let Ok(inode) = Inode::parse(slice) {
362                            if let Some((_, ino)) = offset_to_inode(&sb, off as u64) {
363                                if inode.di_ino == Some(ino) && inode.crc_valid == Some(false) {
364                                    out.push(Anomaly::new(AnomalyKind::CrcMismatch {
365                                        structure: "inode",
366                                        offset: off as u64,
367                                    }));
368                                }
369                            }
370                        } // cov:unreachable: Inode::parse cannot fail after the IN magic check on a full inode-size slice
371                    } // cov:unreachable: image.get is in range by the while-guard bound
372                }
373                off = off.saturating_add(inode_size);
374            }
375        }
376    }
377
378    out
379}
380
381/// Emit an [`AnomalyKind::SbMirrorDivergence`] for each geometry field of a
382/// secondary superblock that differs from the AG-0 primary.
383fn push_sb_divergence(
384    out: &mut Vec<Anomaly>,
385    agno: u64,
386    offset: u64,
387    primary: &Superblock,
388    secondary: &Superblock,
389) {
390    let checks: [(&'static str, u64, u64); 4] = [
391        (
392            "agblocks",
393            u64::from(primary.agblocks),
394            u64::from(secondary.agblocks),
395        ),
396        (
397            "agcount",
398            u64::from(primary.agcount),
399            u64::from(secondary.agcount),
400        ),
401        (
402            "blocksize",
403            u64::from(primary.blocksize),
404            u64::from(secondary.blocksize),
405        ),
406        (
407            "inodesize",
408            u64::from(primary.inodesize),
409            u64::from(secondary.inodesize),
410        ),
411    ];
412    for (field, p, s) in checks {
413        if p != s {
414            out.push(Anomaly::new(AnomalyKind::SbMirrorDivergence {
415                agno,
416                field,
417                primary: p,
418                secondary: s,
419                offset,
420            }));
421        }
422    }
423}
424
425/// Audit an image and convert each F3 anomaly to a canonical [`Finding`] tagged
426/// with `scope`.
427#[must_use]
428pub fn audit_findings(image: &[u8], scope: &str) -> Vec<Finding> {
429    let source = Source {
430        analyzer: "xfs-forensic".to_string(),
431        scope: scope.to_string(),
432        version: None,
433    };
434    audit_image(image)
435        .iter()
436        .map(|a| a.to_finding(source.clone()))
437        .collect()
438}
439
440// ── F1: deleted-inode recovery ────────────────────────────────────────────────
441
442/// A recovered deleted inode: a freed (`di_mode == 0`) inode whose residual
443/// extent records survived the delete, so its content is carvable.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct DeletedInode {
446    /// The allocation group the inode lives in.
447    pub agno: u64,
448    /// The absolute inode number.
449    pub inode_number: u64,
450    /// The residual `xfs_bmbt_rec` extent records recovered from the freed
451    /// inode's data fork (offset 176 v3 / 100 v2), which the delete did not zero.
452    pub residual_extents: Vec<BmbtRec>,
453    /// `di_ctime` — the deletion time (updated on unlink).
454    pub ctime: XfsTimestamp,
455    /// Estimated recoverable size (bytes) — the residual extents' block span.
456    pub recovered_size_estimate: u64,
457    /// The carved bytes, when the residual extents point within the image.
458    pub carved: Vec<u8>,
459}
460
461/// Scan the inode space for deleted inodes with residual extent records (F1).
462///
463/// On delete XFS zeroes `di_mode`/`di_nlink`/`di_size`/`di_nblocks`/`di_nextents`
464/// and increments the generation, but the extent records at the inode's data-fork
465/// offset (176 v3 / 100 v2) survive. This scans for `di_mode == 0` inodes whose
466/// data fork still holds non-zero residual extent records, decodes them, and
467/// carves their bytes via the extent reader where the blocks are readable.
468///
469/// Malformed input never panics.
470#[must_use]
471pub fn recover_deleted(image: &[u8], sb: &Superblock) -> Vec<DeletedInode> {
472    let mut out = Vec::new();
473    let inode_size = usize::from(sb.inodesize);
474    let bsize = u64::from(sb.blocksize);
475    // Need room for a v3 core + at least one 16-byte extent record in the fork,
476    // and a non-zero block size to bound the extents.
477    if inode_size < 176 || bsize == 0 {
478        return out;
479    }
480    let total_blocks = image_len_blocks(image.len(), bsize);
481
482    let mut off = 0usize;
483    while off.saturating_add(inode_size) <= image.len() {
484        let Some(slice) = image.get(off..off.saturating_add(inode_size)) else {
485            break; // cov:unreachable: the while-guard already proved the range fits
486        };
487        if be_u16(slice, 0) == XFS_DINODE_MAGIC {
488            if let Ok(inode) = Inode::parse(slice) {
489                // A freed inode: `di_mode` is zeroed on unlink (as are nlink /
490                // size / nblocks / nextents), but the extent records in the data
491                // fork survive. v2 inodes carry no `di_ino` and no CRC; the minted
492                // oracle is v5, so require v3 here.
493                if inode.version >= 3 && inode.mode == 0 {
494                    let residual = decode_residual(&inode.data_fork, total_blocks);
495                    if !residual.is_empty() {
496                        if let Some((agno, ino)) = offset_to_inode(sb, off as u64) {
497                            let blocks: u64 = residual.iter().map(|e| e.blockcount).sum();
498                            let recovered_size_estimate = blocks.saturating_mul(bsize);
499                            let carved =
500                                assemble_extents(image, sb, &residual, recovered_size_estimate)
501                                    .unwrap_or_default();
502                            out.push(DeletedInode {
503                                agno,
504                                inode_number: ino,
505                                residual_extents: residual,
506                                ctime: inode.ctime,
507                                recovered_size_estimate,
508                                carved,
509                            });
510                        } // cov:unreachable: a superblock from a real image has nonzero agblocks, so offset_to_inode resolves
511                    }
512                }
513            } // cov:unreachable: Inode::parse cannot fail after the IN magic check on a full inode-size slice
514        }
515        off = off.saturating_add(inode_size);
516    }
517    out
518}
519
520// ── shared private helpers ────────────────────────────────────────────────────
521
522/// Null sentinel for an empty AGI `unlinked[64]` bucket / null AG-relative inode.
523const NULL_AGINO: u32 = 0xffff_ffff;
524
525/// Total whole filesystem blocks an image can hold (`len / blocksize`); `0` when
526/// the block size is degenerate. Used to reject an extent that points past the
527/// image during residual-extent recovery.
528fn image_len_blocks(len: usize, bsize: u64) -> u64 {
529    if bsize == 0 {
530        return 0; // cov:unreachable: callers guard bsize != 0 before calling
531    }
532    len as u64 / bsize
533}
534
535/// Decode residual `xfs_bmbt_rec` records from a freed inode's data fork.
536///
537/// A freed inode has `di_nextents == 0`, so the records cannot be counted from
538/// the core; instead read consecutive 16-byte records until the first empty or
539/// out-of-range one. A record is a real surviving extent iff it is non-zero, has
540/// a positive block count, a non-zero start block (block 0 is the superblock,
541/// never file data), and points within the image.
542fn decode_residual(fork: &[u8], total_blocks: u64) -> Vec<BmbtRec> {
543    let mut recs = Vec::new();
544    let mut p = 0usize;
545    while p.saturating_add(16) <= fork.len() {
546        let Some(chunk) = fork.get(p..p.saturating_add(16)) else {
547            break; // cov:unreachable: the while-guard already proved the range fits
548        };
549        let mut raw = [0u8; 16];
550        raw.copy_from_slice(chunk);
551        if raw == [0u8; 16] {
552            break;
553        }
554        let rec = BmbtRec::unpack(&raw);
555        if rec.blockcount == 0 || rec.startblock == 0 {
556            break;
557        }
558        if rec.startblock.saturating_add(rec.blockcount) > total_blocks {
559            break;
560        }
561        recs.push(rec);
562        p = p.saturating_add(16);
563    }
564    recs
565}
566
567/// Reverse of [`Superblock::inode_to_location`]: map an absolute image byte
568/// offset back to `(agno, inode_number)` using the superblock's geometry and
569/// log2 shift fields. Returns `None` for degenerate geometry (a zero divisor or
570/// a shift width ≥ 64), never panicking.
571fn offset_to_inode(sb: &Superblock, off: u64) -> Option<(u64, u64)> {
572    let bsize = u64::from(sb.blocksize);
573    let agblocks = u64::from(sb.agblocks);
574    let inode_size = u64::from(sb.inodesize);
575    if bsize == 0 || agblocks == 0 || inode_size == 0 {
576        return None; // cov:unreachable: a superblock parsed from a real image has nonzero geometry
577    }
578    let ag_bytes = agblocks.checked_mul(bsize)?;
579    let agno = off / ag_bytes;
580    let within = off % ag_bytes;
581    let agblock = within / bsize;
582    let slot = (within % bsize) / inode_size;
583    let inopblog = u32::from(sb.inopblog);
584    let agino_bits = u32::from(sb.agblklog) + inopblog;
585    if inopblog >= 64 || agino_bits >= 64 {
586        return None; // cov:unreachable: real XFS shift widths are far below 64
587    }
588    let agino = (agblock << inopblog) | slot;
589    let ino = (agno << agino_bits) | agino;
590    Some((agno, ino))
591}
592
593/// Bounds-checked big-endian `u16` read (yields `0` out of range). The analyzer
594/// parses raw image bytes directly (the reader/analyzer split), so it carries
595/// its own panic-free readers rather than reaching into the core's private ones.
596fn be_u16(d: &[u8], o: usize) -> u16 {
597    d.get(o..o.saturating_add(2))
598        .and_then(|b| <[u8; 2]>::try_from(b).ok())
599        .map_or(0, u16::from_be_bytes)
600}
601
602/// Bounds-checked big-endian `u32` read (yields `0` out of range).
603fn be_u32(d: &[u8], o: usize) -> u32 {
604    d.get(o..o.saturating_add(4))
605        .and_then(|b| <[u8; 4]>::try_from(b).ok())
606        .map_or(0, u32::from_be_bytes)
607}
608
609#[cfg(test)]
610mod unit {
611    use super::{be_u16, be_u32, decode_residual, image_len_blocks};
612
613    #[test]
614    fn be_readers_yield_zero_out_of_range() {
615        assert_eq!(be_u16(&[0x12], 0), 0); // slice too short
616        assert_eq!(be_u16(&[0x12, 0x34], 0), 0x1234);
617        assert_eq!(be_u32(&[0, 0, 0], 0), 0); // slice too short
618        assert_eq!(be_u32(&[0, 0, 0, 5], 0), 5);
619    }
620
621    #[test]
622    fn image_len_blocks_divides_by_block_size() {
623        assert_eq!(image_len_blocks(4096 * 10, 4096), 10);
624    }
625
626    #[test]
627    fn decode_residual_stops_at_each_boundary() {
628        // empty fork and an all-zero record both yield nothing.
629        assert!(decode_residual(&[], 100).is_empty());
630        assert!(decode_residual(&[0u8; 16], 100).is_empty());
631
632        // one real extent [startoff=0, startblock=32, blockcount=8] then zeros.
633        let mut fork = vec![0u8; 48];
634        fork[8..16].copy_from_slice(&0x0400_0008u64.to_be_bytes()); // l1
635        let recs = decode_residual(&fork, 131_072);
636        assert_eq!(recs.len(), 1);
637        assert_eq!(recs[0].startblock, 32);
638        assert_eq!(recs[0].blockcount, 8);
639
640        // same extent but the image is too small to hold it → out-of-range stop.
641        assert!(decode_residual(&fork, 10).is_empty());
642
643        // blockcount == 0 (nonzero l0 startoff, zero l1) → stop.
644        let mut zero_count = vec![0u8; 16];
645        zero_count[0..8].copy_from_slice(&0x0000_0200u64.to_be_bytes()); // l0 only
646        assert!(decode_residual(&zero_count, 100).is_empty());
647
648        // startblock == 0 (blockcount 8, no start block) → stop (block 0 is the SB).
649        let mut zero_start = vec![0u8; 16];
650        zero_start[8..16].copy_from_slice(&0x0000_0008u64.to_be_bytes());
651        assert!(decode_residual(&zero_start, 100).is_empty());
652    }
653}