Skip to main content

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}