ufs_forensic/lib.rs
1//! `ufs-forensic` — anomaly auditor + deleted-file recovery for UFS/FFS.
2//!
3//! UFS (the Unix File System / Berkeley FFS) leaves rich forensic residue on
4//! delete: an `rm` clears the directory entry's `d_ino` and the cylinder-group
5//! inode-used bit but leaves the dinode's `di_size`/`di_db` and the data blocks
6//! intact until they are re-allocated. That residue is the lever this crate
7//! pulls:
8//!
9//! - **F-INTEGRITY** ([`audit_image`] / [`audit_findings`]) emits graded
10//! [`forensicnomicon::report::Finding`]s for structural anomalies: an invalid
11//! superblock magic (`UFS-SUPERBLOCK-MAGIC-INVALID`), a per-cylinder-group
12//! backup superblock whose geometry diverges from the primary
13//! (`UFS-BACKUP-SUPERBLOCK-DIVERGENCE`), a cylinder-group header with a bad
14//! magic (`UFS-CG-MAGIC-INVALID`), an allocated inode reachable by no directory
15//! entry (`UFS-ORPHANED-INODE`), and geometry beyond the image
16//! (`UFS-IMPOSSIBLE-GEOMETRY`).
17//! - **F-CARVE** ([`recover_deleted`]) recovers deleted files and directory
18//! entries: `d_ino == 0` dirent slots whose residual `d_name` survives, and
19//! inodes free in the cg bitmap that still carry a valid `di_mode`/`di_size`/
20//! `di_db` (`UFS-DELETED-FILE-CARVED` / `UFS-DELETED-DIRENT`). Recovery is
21//! state-dependent: it succeeds while the freed dinode and data blocks are
22//! un-reallocated, and returns nothing rather than fabricate once the residue
23//! is gone.
24//!
25//! Built on `ufs-core` for valid-path reading; where the audit must see slack
26//! and freed structure the reader normalizes away (a `d_ino == 0` slot, a
27//! bitmap-free-but-intact dinode, the raw backup-superblock bytes), it parses the
28//! raw bytes directly (the reader/analyzer-split principle).
29//!
30//! Each finding is an **observation** ("consistent with …"); the examiner draws
31//! the conclusion. Mirrors the fleet producer pattern (typed `AnomalyKind` +
32//! `impl Observation` + `audit_image` → `Vec<Anomaly>` + `audit_findings` →
33//! `Vec<Finding>`), as in `xfs-forensic` / `zfs-forensic` / `btrfs-forensic`.
34
35#![forbid(unsafe_code)]
36#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
37
38pub use forensicnomicon::report::Severity;
39use forensicnomicon::report::{Evidence, Finding, Location, Observation, Source};
40
41use ufs::{
42 list_dir_all, read_file, read_inode, CylinderGroup, DirEntry, DirEntryType, Superblock,
43 UfsError, CG_MAGIC, SBLOCK_UFS1, SBLOCK_UFS2, UFS_ROOTINO,
44};
45
46// Re-export the reader surface an audit reasons over, so downstream code reaches
47// the geometry types without a second `ufs-core` dependency line. The `ufs-core`
48// crate sets `[lib] name = "ufs"`, so its import path is `ufs`.
49pub use ufs::{CylinderGroup as ReaderCylinderGroup, Superblock as ReaderSuperblock, UfsVersion};
50
51/// `fs_magic` byte offset within a superblock (`struct fs`).
52const FS_MAGIC_OFF: usize = 1372;
53/// `cg_magic` byte offset within a cylinder-group header (`struct cg`).
54const CG_MAGIC_OFF: usize = 4;
55
56// ── F-INTEGRITY: structural-integrity anomaly kinds ───────────────────────────
57
58/// Classification of a UFS structural-integrity anomaly (F-INTEGRITY). Each
59/// variant carries the evidence needed to reproduce the observation.
60#[derive(Debug, Clone, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum AnomalyKind {
63 /// The value at the superblock's `fs_magic` offset matched neither UFS1
64 /// (`0x00011954`) nor UFS2 (`0x19540119`) in either byte order — consistent
65 /// with corruption or a wiped/overwritten superblock. Carries the offending
66 /// bytes and the byte offset they were read at (fail-loud with the value).
67 SuperblockMagicInvalid {
68 /// Partition byte offset the primary superblock was sought at.
69 offset: u64,
70 /// The four raw bytes found at `offset + fs_magic`.
71 bytes: [u8; 4],
72 },
73 /// A per-cylinder-group backup superblock whose decoded geometry differs from
74 /// the primary — UFS writes a backup superblock in each cylinder group, so a
75 /// divergence is consistent with a spliced or edited image.
76 BackupSuperblockDivergence {
77 /// The cylinder group whose backup superblock diverged.
78 cg: u32,
79 /// The geometry field that differs (e.g. `fs_ipg`).
80 field: &'static str,
81 /// The primary superblock's value.
82 primary: u64,
83 /// The backup superblock's (diverging) value.
84 backup: u64,
85 /// Partition byte offset of the backup superblock.
86 offset: u64,
87 },
88 /// A cylinder-group header whose `cg_magic` is not `0x00090255` — consistent
89 /// with corruption or a tampered allocation map. Carries the value found.
90 CgMagicInvalid {
91 /// The cylinder-group index the bad header was found at.
92 cg: u32,
93 /// The 32-bit value read at the `cg_magic` offset.
94 found: u32,
95 /// Partition byte offset of the cylinder-group header.
96 offset: u64,
97 },
98 /// An inode marked ALLOCATED in its cylinder group's inode bitmap, with
99 /// `di_nlink > 0`, that is reachable by NO directory entry from the root —
100 /// an inode unlinked while still open, or a corruption lead.
101 OrphanedInode {
102 /// The absolute inode number.
103 inode: u64,
104 /// The inode's `di_nlink` (link count) — nonzero, yet unreferenced.
105 nlink: u16,
106 },
107 /// A geometry field beyond what the image can hold — an allocation-bomb /
108 /// corruption guard. Names the field, the value, and the sane bound.
109 ImpossibleGeometry {
110 /// The offending field name.
111 field: &'static str,
112 /// The value read from the structure.
113 value: u64,
114 /// The sane upper bound derived from the image size / spec.
115 limit: u64,
116 },
117}
118
119impl AnomalyKind {
120 /// Severity — the single source of truth for this kind.
121 #[must_use]
122 pub fn severity(&self) -> Severity {
123 match self {
124 AnomalyKind::SuperblockMagicInvalid { .. }
125 | AnomalyKind::BackupSuperblockDivergence { .. }
126 | AnomalyKind::CgMagicInvalid { .. }
127 | AnomalyKind::ImpossibleGeometry { .. } => Severity::High,
128 AnomalyKind::OrphanedInode { .. } => Severity::Medium,
129 }
130 }
131
132 /// Stable machine-readable, scheme-prefixed code.
133 #[must_use]
134 pub fn code(&self) -> &'static str {
135 match self {
136 AnomalyKind::SuperblockMagicInvalid { .. } => "UFS-SUPERBLOCK-MAGIC-INVALID",
137 AnomalyKind::BackupSuperblockDivergence { .. } => "UFS-BACKUP-SUPERBLOCK-DIVERGENCE",
138 AnomalyKind::CgMagicInvalid { .. } => "UFS-CG-MAGIC-INVALID",
139 AnomalyKind::OrphanedInode { .. } => "UFS-ORPHANED-INODE",
140 AnomalyKind::ImpossibleGeometry { .. } => "UFS-IMPOSSIBLE-GEOMETRY",
141 }
142 }
143
144 /// Human-readable, "consistent with" note.
145 #[must_use]
146 pub fn note(&self) -> String {
147 match self {
148 AnomalyKind::SuperblockMagicInvalid { offset, bytes } => format!(
149 "superblock at byte {offset}: fs_magic bytes {bytes:02x?} match neither UFS1 (0x00011954) nor UFS2 (0x19540119) in either byte order — consistent with corruption or an overwritten superblock"
150 ),
151 AnomalyKind::BackupSuperblockDivergence {
152 cg,
153 field,
154 primary,
155 backup,
156 ..
157 } => format!(
158 "cylinder group {cg} backup superblock: {field} = {backup} differs from the primary {primary} — consistent with a spliced or edited image"
159 ),
160 AnomalyKind::CgMagicInvalid { cg, found, .. } => format!(
161 "cylinder group {cg} header: cg_magic = {found:#010x} is not 0x00090255 — consistent with corruption or a tampered allocation map"
162 ),
163 AnomalyKind::OrphanedInode { inode, nlink } => format!(
164 "inode {inode} is allocated (di_nlink {nlink}) yet reachable by no directory entry from root — an inode unlinked while still open, or a corruption lead"
165 ),
166 AnomalyKind::ImpossibleGeometry {
167 field,
168 value,
169 limit,
170 } => format!(
171 "geometry field {field} = {value} exceeds the sane bound {limit} for this image — consistent with corruption or an allocation-bomb"
172 ),
173 }
174 }
175
176 fn evidence(&self) -> Vec<Evidence> {
177 match self {
178 AnomalyKind::SuperblockMagicInvalid { offset, bytes } => vec![Evidence {
179 field: "fs_magic".to_string(),
180 value: format!("{bytes:02x?}"),
181 location: Some(Location::ByteOffset(*offset)),
182 }],
183 AnomalyKind::BackupSuperblockDivergence {
184 cg,
185 field,
186 primary,
187 backup,
188 offset,
189 } => vec![Evidence {
190 field: (*field).to_string(),
191 value: format!("cg{cg} backup={backup} vs primary={primary}"),
192 location: Some(Location::ByteOffset(*offset)),
193 }],
194 AnomalyKind::CgMagicInvalid { cg, found, offset } => vec![Evidence {
195 field: "cg_magic".to_string(),
196 value: format!("cg{cg}: {found:#010x}"),
197 location: Some(Location::ByteOffset(*offset)),
198 }],
199 AnomalyKind::OrphanedInode { inode, nlink } => vec![Evidence {
200 field: "di_nlink".to_string(),
201 value: format!("inode {inode} nlink {nlink}, unreferenced"),
202 location: Some(Location::Other {
203 space: "ufs:inode".to_string(),
204 value: *inode,
205 }),
206 }],
207 AnomalyKind::ImpossibleGeometry {
208 field,
209 value,
210 limit,
211 } => vec![Evidence {
212 field: (*field).to_string(),
213 value: format!("{value} (limit {limit})"),
214 location: None,
215 }],
216 }
217 }
218}
219
220/// A UFS structural-integrity anomaly: an observation graded by severity, with a
221/// stable code and note derived from its [`AnomalyKind`] so they cannot drift.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Anomaly {
224 /// Severity, derived from `kind`.
225 pub severity: Severity,
226 /// Stable machine-readable code, derived from `kind`.
227 pub code: &'static str,
228 /// The classified anomaly with its evidence.
229 pub kind: AnomalyKind,
230 /// Human-readable note, derived from `kind`.
231 pub note: String,
232}
233
234impl Anomaly {
235 /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
236 #[must_use]
237 pub fn new(kind: AnomalyKind) -> Self {
238 Anomaly {
239 severity: kind.severity(),
240 code: kind.code(),
241 note: kind.note(),
242 kind,
243 }
244 }
245}
246
247impl Observation for Anomaly {
248 fn severity(&self) -> Option<Severity> {
249 Some(self.severity)
250 }
251 fn code(&self) -> &'static str {
252 self.code
253 }
254 fn note(&self) -> String {
255 self.note.clone()
256 }
257 fn evidence(&self) -> Vec<Evidence> {
258 self.kind.evidence()
259 }
260}
261
262// ── F-INTEGRITY: the image auditor ────────────────────────────────────────────
263
264/// Audit a whole UFS/FFS **filesystem partition** (filesystem byte 0 — a caller
265/// holding a whole disk image slices past the BSD-disklabel partition base first)
266/// for structural-integrity anomalies (F-INTEGRITY): parse the primary
267/// superblock, walk every cylinder group (backup-superblock divergence + header
268/// magic), diff the allocated-inode set against the reachable-inode set (orphans),
269/// and guard against impossible geometry.
270///
271/// A clean image yields an empty vector. Malformed input never panics.
272#[must_use]
273pub fn audit_image(partition: &[u8]) -> Vec<Anomaly> {
274 let mut out = Vec::new();
275
276 // Locate + parse the primary superblock. UFS2 lives at byte 65536, UFS1 at
277 // 8192; try both. A superblock whose magic matched neither in either order is
278 // a magic-invalid finding (fail-loud with the bytes), and there is nothing
279 // further to audit without geometry.
280 let sb = match parse_primary_sb(partition) {
281 Ok(sb) => sb,
282 Err(SbError::MagicInvalid { offset, bytes }) => {
283 out.push(Anomaly::new(AnomalyKind::SuperblockMagicInvalid {
284 offset,
285 bytes,
286 }));
287 return out;
288 }
289 Err(SbError::NotUfs) => return out,
290 };
291
292 let fsize = if sb.fsize > 0 { sb.fsize as u64 } else { 0 };
293 let fpg = if sb.fpg > 0 { sb.fpg as u64 } else { 0 };
294 let sblkno = if sb.sblkno >= 0 { sb.sblkno as u64 } else { 0 };
295 let cblkno = if sb.cblkno >= 0 { sb.cblkno as u64 } else { 0 };
296 let ncg = u64::from(sb.ncg);
297 let part_len = partition.len() as u64;
298
299 // Impossible geometry: a cylinder group whose base lies past the partition.
300 // When it fires, the geometry is unusable for the per-cg walk, so stop.
301 if check_impossible_geometry(&mut out, fsize, fpg, ncg, part_len) {
302 return out;
303 }
304
305 // Per-cylinder-group checks: backup superblock divergence + header magic.
306 check_all_cgs(&mut out, partition, &sb, fsize, fpg, sblkno, cblkno, ncg);
307
308 // Orphaned inodes: allocated in a cg bitmap with di_nlink > 0 yet reachable
309 // by no directory entry from root.
310 check_orphaned_inodes(&mut out, partition, &sb);
311
312 out
313}
314
315/// Flag `UFS-IMPOSSIBLE-GEOMETRY` when `fs_ncg` names a cylinder group whose base
316/// lies past the partition end. Returns `true` when the geometry is unusable and
317/// the per-cg walk must stop.
318fn check_impossible_geometry(
319 out: &mut Vec<Anomaly>,
320 fsize: u64,
321 fpg: u64,
322 ncg: u64,
323 part_len: u64,
324) -> bool {
325 if fsize == 0 || fpg == 0 || ncg == 0 {
326 return false;
327 }
328 let cg_bytes = fpg.saturating_mul(fsize);
329 let last_base = ncg.saturating_sub(1).saturating_mul(cg_bytes);
330 if last_base < part_len {
331 return false;
332 }
333 out.push(Anomaly::new(AnomalyKind::ImpossibleGeometry {
334 field: "fs_ncg",
335 value: ncg,
336 // cg_bytes = fpg*fsize with both > 0, so it is always positive here;
337 // checked_div keeps the analyzer panic-free regardless.
338 limit: part_len.checked_div(cg_bytes).map_or(1, |q| q + 1),
339 }));
340 true
341}
342
343/// Walk every cylinder group: check each backup superblock's geometry against the
344/// primary (cg >= 1) and each cg header's magic.
345#[allow(clippy::too_many_arguments)]
346fn check_all_cgs(
347 out: &mut Vec<Anomaly>,
348 partition: &[u8],
349 sb: &Superblock,
350 fsize: u64,
351 fpg: u64,
352 sblkno: u64,
353 cblkno: u64,
354 ncg: u64,
355) {
356 if fsize == 0 || fpg == 0 {
357 return;
358 }
359 for cg in 0..ncg {
360 let cg_base_frag = cg.saturating_mul(fpg);
361
362 // Backup superblock at (cg_base + fs_sblkno) frags. cg0's "backup" is
363 // effectively the primary region; compare cg >= 1 against the primary.
364 if cg >= 1 && sblkno > 0 {
365 let bsb_off = cg_base_frag.saturating_add(sblkno).saturating_mul(fsize);
366 check_backup_sb(out, partition, sb, cg as u32, bsb_off);
367 }
368
369 // cg header at (cg_base + fs_cblkno) frags.
370 if cblkno > 0 {
371 let cg_off = cg_base_frag.saturating_add(cblkno).saturating_mul(fsize);
372 check_cg_magic(out, partition, cg as u32, cg_off);
373 }
374 }
375}
376
377/// The verdict of locating the primary superblock.
378enum SbError {
379 /// A superblock-shaped region was found but its magic matched no UFS magic
380 /// in either byte order.
381 MagicInvalid { offset: u64, bytes: [u8; 4] },
382 /// Nothing superblock-shaped at either known offset — not a UFS partition.
383 NotUfs,
384}
385
386/// Try to parse the primary superblock at the UFS2 (65536) then UFS1 (8192)
387/// offset. Returns the parsed superblock, or a magic-invalid verdict carrying the
388/// offending bytes when a region is present but its magic is wrong, or `NotUfs`
389/// when neither offset holds superblock-sized data.
390fn parse_primary_sb(partition: &[u8]) -> Result<Superblock, SbError> {
391 for off in [SBLOCK_UFS2, SBLOCK_UFS1] {
392 let Some(slice) = partition.get(off..) else {
393 continue;
394 };
395 match Superblock::parse(slice) {
396 Ok(sb) => return Ok(sb),
397 Err(UfsError::BadMagic { bytes, .. }) => {
398 // A full-sized region with a wrong magic → magic-invalid at this
399 // offset. Report the offset the magic field actually sits at.
400 return Err(SbError::MagicInvalid {
401 offset: (off + FS_MAGIC_OFF) as u64,
402 bytes,
403 });
404 }
405 // Truncated (region too short) or geometry-rejected: not a usable SB
406 // at this offset — try the next, then fall through to NotUfs.
407 Err(_) => {}
408 }
409 }
410 Err(SbError::NotUfs)
411}
412
413/// Parse the backup superblock at `offset` and flag each geometry field that
414/// diverges from the primary. A backup region that does not parse as a
415/// superblock (bad magic / truncated) is a `UFS-CG-MAGIC-INVALID`-adjacent signal
416/// but here we treat only a *parsed* divergence as the backup-divergence finding;
417/// an unparseable backup is left to the cg-magic check.
418fn check_backup_sb(
419 out: &mut Vec<Anomaly>,
420 partition: &[u8],
421 primary: &Superblock,
422 cg: u32,
423 offset: u64,
424) {
425 let start = usize::try_from(offset).unwrap_or(usize::MAX);
426 let Some(slice) = partition.get(start..) else {
427 return;
428 };
429 let Ok(backup) = Superblock::parse(slice) else {
430 // A backup that will not parse is corruption too, but a bad-magic backup
431 // is reported via the primary/cg paths; skip silently here rather than
432 // double-report. A real edited image usually keeps a parseable backup with
433 // a diverging field, which is what we key on.
434 return;
435 };
436 let checks: [(&'static str, u64, u64); 4] = [
437 ("fs_ipg", primary.ipg as u64, backup.ipg as u64),
438 ("fs_fpg", primary.fpg as u64, backup.fpg as u64),
439 ("fs_bsize", primary.bsize as u64, backup.bsize as u64),
440 ("fs_ncg", u64::from(primary.ncg), u64::from(backup.ncg)),
441 ];
442 for (field, p, b) in checks {
443 if p != b {
444 out.push(Anomaly::new(AnomalyKind::BackupSuperblockDivergence {
445 cg,
446 field,
447 primary: p,
448 backup: b,
449 offset,
450 }));
451 }
452 }
453}
454
455/// Read the `cg_magic` at a cylinder-group header offset and flag a mismatch.
456fn check_cg_magic(out: &mut Vec<Anomaly>, partition: &[u8], cg: u32, offset: u64) {
457 let start = usize::try_from(offset).unwrap_or(usize::MAX);
458 // Only check a cg header region that is present in the image; a header past
459 // the (possibly truncated) partition is not a corruption finding.
460 let Some(slice) = partition.get(start..) else {
461 return;
462 };
463 if slice.len() < CG_MAGIC_OFF + 4 {
464 return;
465 }
466 // UFS is endian-agnostic; the primary SB's order applies. Read little- and
467 // big-endian and accept either matching CG_MAGIC as valid.
468 let le = read_u32_le(slice, CG_MAGIC_OFF);
469 let be = read_u32_be(slice, CG_MAGIC_OFF);
470 if le != CG_MAGIC && be != CG_MAGIC {
471 out.push(Anomaly::new(AnomalyKind::CgMagicInvalid {
472 cg,
473 found: le,
474 offset,
475 }));
476 }
477}
478
479/// Diff the allocated-inode set (from the cg inode bitmaps) against the set of
480/// inodes reachable by a directory entry from root; flag every allocated inode
481/// with `di_nlink > 0` that no dirent references.
482fn check_orphaned_inodes(out: &mut Vec<Anomaly>, partition: &[u8], sb: &Superblock) {
483 // Build the reachable set by walking the directory tree from root.
484 let reachable = reachable_inodes(partition, sb);
485
486 let ipg = if sb.ipg > 0 { sb.ipg as u64 } else { return };
487 let ncg = u64::from(sb.ncg);
488 let total = ipg.saturating_mul(ncg);
489
490 for cg in 0..ncg {
491 let Some(used) = cg_inode_bitmap(partition, sb, cg) else {
492 continue;
493 };
494 for within in 0..ipg {
495 let ino = cg.saturating_mul(ipg).saturating_add(within);
496 // Reserved inodes 0 and 1 (and the root, 2) are never orphans; skip.
497 if ino < UFS_ROOTINO + 1 || ino >= total {
498 continue;
499 }
500 if !bitmap_bit(&used, within as usize) {
501 continue; // free — a deleted-inode carve candidate, not an orphan
502 }
503 if reachable.contains(&ino) {
504 continue;
505 }
506 // Allocated + unreferenced: read the inode to confirm di_nlink > 0
507 // (a genuine live-but-orphaned inode, not a zeroed bitmap slot).
508 let Ok(inode) = read_inode(partition, sb, ino) else {
509 continue;
510 };
511 if inode.nlink == 0 {
512 continue;
513 }
514 out.push(Anomaly::new(AnomalyKind::OrphanedInode {
515 inode: ino,
516 nlink: inode.nlink,
517 }));
518 }
519 }
520}
521
522/// Audit an image and convert each F-INTEGRITY anomaly to a canonical [`Finding`]
523/// tagged with `scope`.
524#[must_use]
525pub fn audit_findings(partition: &[u8], scope: &str) -> Vec<Finding> {
526 let source = Source {
527 analyzer: "ufs-forensic".to_string(),
528 scope: scope.to_string(),
529 version: None,
530 };
531 audit_image(partition)
532 .iter()
533 .map(|a| a.to_finding(source.clone()))
534 .collect()
535}
536
537// ── F-CARVE: deleted-file / deleted-dirent recovery ───────────────────────────
538
539/// One recovered item from the deleted-residue sweep (F-CARVE).
540#[derive(Debug, Clone, PartialEq, Eq)]
541#[non_exhaustive]
542pub enum RecoveredItem {
543 /// A deleted file recovered from a freed-but-intact inode: free in the cg
544 /// inode bitmap, yet still carrying a valid `di_mode`/`di_size`/`di_db`, whose
545 /// data blocks were re-assembled. Recovery is state-dependent — this is only
546 /// possible while the freed dinode and blocks are un-reallocated.
547 DeletedFile {
548 /// The residual directory-entry name pointing at this inode, if a deleted
549 /// dirent with a matching residual `d_ino` was found; `None` when only the
550 /// freed inode survives.
551 name: Option<String>,
552 /// The inode number the freed dinode occupies.
553 inode: u64,
554 /// The file's `di_size` in bytes.
555 size: u64,
556 /// The carved file content (assembled from the surviving block map).
557 content: Vec<u8>,
558 /// The carved content's sha256, lower-hex — a provenance stamp checked
559 /// against an independent pre-delete hash in validation, not a runtime gate.
560 content_sha256: String,
561 },
562 /// A deleted directory entry recovered from a `d_ino == 0` slot (or residual
563 /// name in a preceding entry's reclen slack) whose `d_name` survives.
564 DeletedDirent {
565 /// The residual entry name.
566 name: String,
567 /// The residual inode number the slot pointed at (`0` when only the name
568 /// survives in reclen slack).
569 inode: u64,
570 },
571}
572
573/// Recover deleted files and directory entries from a UFS/FFS **filesystem
574/// partition** (F-CARVE).
575///
576/// Two independent residues are swept:
577///
578/// 1. **Deleted dirents**: every directory reachable from root is walked with
579/// [`list_dir_all`], which surfaces `d_ino == 0` slots whose residual `d_name`
580/// survives — the name of a removed entry.
581/// 2. **Deleted inodes**: every cylinder group's inode table is swept for inodes
582/// that are FREE in the cg inode bitmap yet still carry a valid `di_mode`,
583/// non-zero `di_size`, and a data-block pointer (the dinode UFS commonly leaves
584/// intact on delete). Their content is carved via the block-map walk.
585///
586/// A recovered file is paired with a recovered dirent name when a `d_ino == 0`
587/// slot's residual inode number (or the slot immediately preceding it) matches.
588///
589/// Recovery is state-dependent: it depends on the freed dinode and its data
590/// blocks not yet having been re-allocated. When the residue is gone this returns
591/// nothing rather than fabricate. Malformed input never panics.
592#[must_use]
593pub fn recover_deleted(partition: &[u8]) -> Vec<RecoveredItem> {
594 let mut out = Vec::new();
595
596 let Ok(sb) = parse_primary_sb(partition) else {
597 return out;
598 };
599
600 // 1) Deleted dirents: walk the directory tree and collect d_ino==0 slots,
601 // AND record, per residual name, the inode the *live* entry pointed at before
602 // deletion is not recoverable from the slot alone (d_ino is zeroed). So we
603 // key file recovery off the freed-inode sweep and attach a name when the
604 // deleted dirent's residual name sits adjacent to a freed inode.
605 let deleted_names = collect_deleted_dirents(partition, &sb);
606 for (name, ino) in &deleted_names {
607 out.push(RecoveredItem::DeletedDirent {
608 name: name.clone(),
609 inode: *ino,
610 });
611 }
612
613 // 2) Deleted inodes: sweep each cg's inode table for free-but-intact dinodes.
614 carve_deleted_inodes(partition, &sb, &deleted_names, &mut out);
615
616 out
617}
618
619/// Walk every directory reachable from root and collect the residual name +
620/// (residual) inode of each `d_ino == 0` deleted slot. Bounded against a cyclic /
621/// lying directory graph by a visited set and a budget.
622fn collect_deleted_dirents(partition: &[u8], sb: &Superblock) -> Vec<(String, u64)> {
623 let mut deleted = Vec::new();
624 let mut visited: Vec<u64> = Vec::new();
625 let mut queue: Vec<u64> = vec![UFS_ROOTINO];
626 let mut budget: usize = 1 << 20;
627
628 while let Some(dir_ino) = queue.pop() {
629 if budget == 0 {
630 break; // cov:unreachable: a real directory graph is finite and far under the budget
631 }
632 budget -= 1;
633 if visited.contains(&dir_ino) {
634 continue;
635 }
636 visited.push(dir_ino);
637
638 let Ok(entries) = list_dir_all(partition, sb, dir_ino) else {
639 continue;
640 };
641 for e in &entries {
642 if e.deleted {
643 // A d_ino==0 slot with a residual name. Skip the `.`/`..` self and
644 // parent slots and empty residual names.
645 if !e.name.is_empty() && e.name != b"." && e.name != b".." {
646 deleted.push((decode_name(&e.name), e.ino));
647 }
648 } else if is_dir_entry(e) && e.name != b"." && e.name != b".." {
649 queue.push(e.ino);
650 }
651 }
652 }
653 deleted
654}
655
656/// Sweep each cylinder group's inode table for inodes that are FREE in the cg
657/// inode bitmap yet still carry a valid regular-file dinode (mode/size/db intact),
658/// carve their content, and emit a `DeletedFile` — pairing a residual dirent name
659/// when one is available.
660fn carve_deleted_inodes(
661 partition: &[u8],
662 sb: &Superblock,
663 deleted_names: &[(String, u64)],
664 out: &mut Vec<RecoveredItem>,
665) {
666 let ipg = if sb.ipg > 0 { sb.ipg as u64 } else { return };
667 let ncg = u64::from(sb.ncg);
668 let total = ipg.saturating_mul(ncg);
669
670 for cg in 0..ncg {
671 let Some(used) = cg_inode_bitmap(partition, sb, cg) else {
672 continue;
673 };
674 for within in 0..ipg {
675 let ino = cg.saturating_mul(ipg).saturating_add(within);
676 if ino < UFS_ROOTINO + 1 || ino >= total {
677 continue;
678 }
679 // A deleted-file candidate is FREE in the bitmap.
680 if bitmap_bit(&used, within as usize) {
681 continue;
682 }
683 let Ok(inode) = read_inode(partition, sb, ino) else {
684 continue;
685 };
686 // The freed dinode must still look like a regular file with content:
687 // a valid regular-file mode, a non-zero size, and a first data block.
688 if !inode.is_regular() || inode.size == 0 || inode.direct[0] == 0 {
689 continue;
690 }
691 // Carve the content via the block-map walk. A lying di_size is already
692 // guarded by read_file (allocation-bomb check); a failed carve yields
693 // nothing for this inode rather than a fabricated buffer.
694 let Ok(content) = read_file(partition, sb, ino) else {
695 continue;
696 };
697 if content.is_empty() {
698 continue; // cov:unreachable: size>0 already checked, so content is non-empty
699 }
700 let content_sha256 = sha256_hex(&content);
701 let name = deleted_names
702 .iter()
703 .find(|(_, dino)| *dino == ino)
704 .map(|(n, _)| n.clone());
705 out.push(RecoveredItem::DeletedFile {
706 name,
707 inode: ino,
708 size: inode.size,
709 content,
710 content_sha256,
711 });
712 }
713 }
714}
715
716// ── shared private helpers ────────────────────────────────────────────────────
717
718/// Read the cylinder-group `cg`'s inode-used bitmap into an owned `Vec<u8>`.
719/// The cg header sits at `(cg*fpg + cblkno)*fsize`; the bitmap starts at
720/// `cg_iusedoff` bytes in and spans `ceil(ipg/8)` bytes. `None` when the header
721/// is absent/unparseable or the geometry is degenerate.
722fn cg_inode_bitmap(partition: &[u8], sb: &Superblock, cg: u64) -> Option<Vec<u8>> {
723 if sb.fsize <= 0 || sb.fpg <= 0 || sb.cblkno < 0 || sb.ipg <= 0 {
724 return None; // cov:unreachable: a superblock parsed from a real image has positive geometry
725 }
726 let fsize = sb.fsize as u64;
727 let fpg = sb.fpg as u64;
728 let cblkno = sb.cblkno as u64;
729 let cg_off = cg
730 .saturating_mul(fpg)
731 .saturating_add(cblkno)
732 .saturating_mul(fsize);
733 let start = usize::try_from(cg_off).ok()?;
734 let header = partition.get(start..)?;
735 let cgh = CylinderGroup::parse(header, sb.endian).ok()?;
736 let bmp_start = cgh.inosused_off();
737 let bytes = (sb.ipg as usize).div_ceil(8);
738 let slice = header.get(bmp_start..bmp_start.saturating_add(bytes))?;
739 Some(slice.to_vec())
740}
741
742/// `true` when bit `idx` (LSB-first within each byte) is set in the bitmap.
743fn bitmap_bit(bitmap: &[u8], idx: usize) -> bool {
744 let byte = idx / 8;
745 let bit = idx % 8;
746 bitmap.get(byte).is_some_and(|b| (b >> bit) & 1 == 1)
747}
748
749/// The set of inode numbers reachable by a live directory entry from root
750/// (including root itself). Bounded against a cyclic directory graph.
751fn reachable_inodes(partition: &[u8], sb: &Superblock) -> Vec<u64> {
752 let mut reachable: Vec<u64> = vec![UFS_ROOTINO];
753 let mut queue: Vec<u64> = vec![UFS_ROOTINO];
754 let mut budget: usize = 1 << 20;
755
756 while let Some(dir_ino) = queue.pop() {
757 if budget == 0 {
758 break; // cov:unreachable: a real directory graph is finite and far under the budget
759 }
760 budget -= 1;
761 let Ok(entries) = list_dir_all(partition, sb, dir_ino) else {
762 continue;
763 };
764 for e in &entries {
765 if e.deleted || e.name == b"." || e.name == b".." {
766 continue;
767 }
768 if !reachable.contains(&e.ino) {
769 reachable.push(e.ino);
770 if is_dir_entry(e) {
771 queue.push(e.ino);
772 }
773 }
774 }
775 }
776 reachable
777}
778
779/// `true` when a live directory entry names a directory (`DT_DIR`). Used to bound
780/// the recursion to directory subjects.
781fn is_dir_entry(e: &DirEntry) -> bool {
782 matches!(e.file_type, DirEntryType::Directory)
783}
784
785/// Decode a residual entry-name byte string to a `String`, replacing invalid
786/// UTF-8 lossily (a residual name may carry arbitrary bytes).
787fn decode_name(name: &[u8]) -> String {
788 String::from_utf8_lossy(name).into_owned()
789}
790
791/// SHA-256 of `data`, lower-hex — the recovery gate compared to the
792/// construction-derived pre-delete ground truth. Uses the audited `sha2` crate
793/// (never hand-rolled).
794fn sha256_hex(data: &[u8]) -> String {
795 use sha2::{Digest, Sha256};
796 let mut h = Sha256::new();
797 h.update(data);
798 let digest = h.finalize();
799 let mut hex = String::with_capacity(64);
800 use std::fmt::Write as _;
801 for b in digest {
802 let _ = write!(hex, "{b:02x}");
803 }
804 hex
805}
806
807/// Bounds-checked little-endian `u32` read (yields `0` out of range). The
808/// analyzer parses raw image bytes directly (the reader/analyzer split), so it
809/// carries its own panic-free readers.
810fn read_u32_le(d: &[u8], o: usize) -> u32 {
811 d.get(o..o.saturating_add(4))
812 .and_then(|b| <[u8; 4]>::try_from(b).ok())
813 .map_or(0, u32::from_le_bytes)
814}
815
816/// Bounds-checked big-endian `u32` read (yields `0` out of range).
817fn read_u32_be(d: &[u8], o: usize) -> u32 {
818 d.get(o..o.saturating_add(4))
819 .and_then(|b| <[u8; 4]>::try_from(b).ok())
820 .map_or(0, u32::from_be_bytes)
821}
822
823#[cfg(test)]
824mod unit {
825 use super::{
826 bitmap_bit, decode_name, read_u32_be, read_u32_le, sha256_hex, Anomaly, AnomalyKind,
827 Severity,
828 };
829 use forensicnomicon::report::{Location, Observation, Source};
830
831 #[test]
832 fn readers_yield_zero_out_of_range() {
833 assert_eq!(read_u32_le(&[0, 0, 0], 0), 0);
834 assert_eq!(read_u32_le(&[1, 0, 0, 0], 0), 1);
835 assert_eq!(read_u32_be(&[0, 0, 0], 0), 0);
836 assert_eq!(read_u32_be(&[0, 0, 0, 1], 0), 1);
837 }
838
839 #[test]
840 fn bitmap_bit_reads_lsb_first() {
841 // byte 0 = 0b0000_0101 → bits 0 and 2 set.
842 let bmp = [0b0000_0101u8, 0b1000_0000u8];
843 assert!(bitmap_bit(&bmp, 0));
844 assert!(!bitmap_bit(&bmp, 1));
845 assert!(bitmap_bit(&bmp, 2));
846 assert!(bitmap_bit(&bmp, 15)); // byte 1 bit 7
847 assert!(!bitmap_bit(&bmp, 16)); // out of range → false
848 }
849
850 #[test]
851 fn decode_name_is_lossy() {
852 assert_eq!(decode_name(b"secret.txt"), "secret.txt");
853 // invalid UTF-8 does not panic.
854 let _ = decode_name(&[0xff, 0xfe, b'a']);
855 }
856
857 #[test]
858 fn sha256_of_known_input() {
859 assert_eq!(
860 sha256_hex(b"abc"),
861 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
862 );
863 assert_eq!(
864 sha256_hex(&[]),
865 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
866 );
867 }
868
869 /// Every `AnomalyKind` carries its scheme-prefixed `UFS-*` code, phrases its
870 /// note as an observation ("consistent with"), and yields evidence — the
871 /// producer-pattern contract mirrored from xfs/zfs/btrfs-forensic.
872 #[test]
873 fn every_anomaly_kind_derives_code_severity_note_and_evidence() {
874 let kinds = [
875 AnomalyKind::SuperblockMagicInvalid {
876 offset: 66908,
877 bytes: [0xde, 0xad, 0xbe, 0xef],
878 },
879 AnomalyKind::BackupSuperblockDivergence {
880 cg: 1,
881 field: "fs_ipg",
882 primary: 128,
883 backup: 999,
884 offset: 1_146_880,
885 },
886 AnomalyKind::CgMagicInvalid {
887 cg: 2,
888 found: 0x1234_5678,
889 offset: 2_228_224,
890 },
891 AnomalyKind::OrphanedInode { inode: 6, nlink: 1 },
892 AnomalyKind::ImpossibleGeometry {
893 field: "fs_ncg",
894 value: 100_000,
895 limit: 5,
896 },
897 ];
898 for kind in kinds {
899 let a = Anomaly::new(kind.clone());
900 assert!(a.code.starts_with("UFS-"));
901 assert_eq!(a.code, kind.code());
902 assert_eq!(a.note, kind.note());
903 assert_eq!(a.severity, kind.severity());
904 assert!(
905 a.note.to_lowercase().contains("consistent with")
906 || a.note.to_lowercase().contains("unlinked while still open"),
907 "note must be an observation: {}",
908 a.note
909 );
910 assert!(!a.kind.evidence().is_empty());
911 // Observation trait surface.
912 assert_eq!(a.severity(), Some(a.severity));
913 assert_eq!(Observation::code(&a), a.code);
914 assert_eq!(Observation::note(&a), a.note);
915 assert!(!Observation::evidence(&a).is_empty());
916 }
917 }
918
919 /// Orphaned inode grades Medium; every other kind grades High.
920 #[test]
921 fn severity_grading_matches_spec() {
922 assert_eq!(
923 AnomalyKind::OrphanedInode { inode: 6, nlink: 1 }.severity(),
924 Severity::Medium
925 );
926 assert_eq!(
927 AnomalyKind::CgMagicInvalid {
928 cg: 0,
929 found: 0,
930 offset: 0
931 }
932 .severity(),
933 Severity::High
934 );
935 }
936
937 /// `to_finding` tags analyzer + scope and preserves the code, per kind.
938 #[test]
939 fn to_finding_tags_analyzer_scope() {
940 let source = Source {
941 analyzer: "ufs-forensic".to_string(),
942 scope: "part0".to_string(),
943 version: None,
944 };
945 for kind in [
946 AnomalyKind::OrphanedInode { inode: 9, nlink: 2 },
947 AnomalyKind::ImpossibleGeometry {
948 field: "x",
949 value: 2,
950 limit: 1,
951 },
952 ] {
953 let a = Anomaly::new(kind);
954 let f = a.to_finding(source.clone());
955 assert_eq!(f.source.analyzer, "ufs-forensic");
956 assert_eq!(f.source.scope, "part0");
957 assert_eq!(f.code, a.code);
958 }
959 }
960
961 /// `ImpossibleGeometry` evidence has no location; the others carry one.
962 #[test]
963 fn evidence_locations_are_kind_specific() {
964 let bomb = AnomalyKind::ImpossibleGeometry {
965 field: "f",
966 value: 2,
967 limit: 1,
968 };
969 assert!(bomb.evidence()[0].location.is_none());
970 let mag = AnomalyKind::SuperblockMagicInvalid {
971 offset: 0x1234,
972 bytes: [1, 2, 3, 4],
973 };
974 assert!(matches!(
975 mag.evidence()[0].location,
976 Some(Location::ByteOffset(0x1234))
977 ));
978 }
979}