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}