Skip to main content

ext4fs/forensic/
findings.rs

1//! ext4 forensic findings: a thin [`forensicnomicon::report::Observation`]
2//! adapter over the existing `forensic` engine.
3//!
4//! This module performs **no new parsing**. It *surfaces* what the engine has
5//! already computed — superblock-backup comparisons, deleted inodes, file slack,
6//! and journal transactions — as graded `EXT4-*` [`Finding`](forensicnomicon::report::Finding)s, so a
7//! disk-forensic / Issen orchestrator can aggregate ext4 findings uniformly
8//! alongside the partition and other filesystem layers.
9//!
10//! Every variant's severity, stable machine-readable code, and human-readable
11//! note are *derived* from its classification, so they cannot drift. Findings
12//! are observations in "consistent with" language, never verdicts: a
13//! superblock-backup divergence, for instance, has benign causes (a resize that
14//! never propagated, a `tune2fs` edit) and is graded as a consistency anomaly,
15//! not "tamper detected".
16
17use crate::forensic::deleted::DeletedInode;
18use crate::forensic::journal::Journal;
19use crate::forensic::slack::SlackSpace;
20use crate::forensic::superblock_verify::SuperblockComparison;
21use crate::ondisk::FileType;
22
23pub use forensicnomicon::report::Severity;
24use forensicnomicon::report::{Category, Observation};
25
26/// A classified ext4 forensic anomaly, carrying the engine evidence needed to
27/// reproduce the observation. The `benign` / suspicious framing lives in
28/// [`Ext4Anomaly::note`]: an anomaly is an *observation*, never an assertion of
29/// intent.
30#[derive(Debug, Clone, PartialEq)]
31pub enum Ext4Anomaly {
32    /// A backup superblock disagrees with the primary on one or more invariant
33    /// fields. ext4 replicates the superblock at group 0 (primary), group 1,
34    /// and powers of 3/5/7; the copies are written together and should be
35    /// identical. A divergence is consistent with a filesystem resize that did
36    /// not propagate, a `tune2fs` edit, on-disk corruption, or deliberate
37    /// tampering of one copy.
38    SuperblockBackupMismatch {
39        /// Block group holding the diverging backup.
40        group: u32,
41        /// Block number of the backup superblock.
42        block: u64,
43        /// Field names that differ from the primary (or `unparseable`).
44        differences: Vec<String>,
45    },
46
47    /// An inode whose deletion time (`dtime`) is set — the kernel stamps `dtime`
48    /// when a file is removed. The inode metadata (and, when the block bitmap
49    /// still shows its data blocks free, the file content) may be recoverable.
50    DeletedInode {
51        /// Inode number.
52        ino: u64,
53        /// File type recorded in the (now deleted) inode.
54        file_type: FileType,
55        /// File size in bytes.
56        size: u64,
57        /// Raw 32-bit deletion time (seconds; non-zero by construction here).
58        dtime: u32,
59        /// Estimated recoverability: fraction of the file's data blocks still
60        /// unallocated in the block bitmap, in `[0.0, 1.0]`.
61        recoverability: f64,
62    },
63
64    /// Non-zero bytes in a file's block slack — the unused tail of its final
65    /// allocated block, past the recorded file size. ext4 does not zero this
66    /// region on allocation, so it commonly retains fragments of previously
67    /// resident data; consistent with leaked residue from prior block use
68    /// (often benign) or, rarely, deliberately hidden bytes.
69    SlackResidue {
70        /// Inode owning the slack.
71        ino: u64,
72        /// File size in bytes.
73        file_size: u64,
74        /// Physical block number holding the slack.
75        block: u64,
76        /// Byte offset within the block where slack begins.
77        slack_offset: usize,
78        /// Number of non-zero bytes in the slack region.
79        nonzero_bytes: usize,
80    },
81
82    /// A committed journal (jbd2) transaction whose commit timestamp precedes
83    /// that of an earlier-sequenced, already-committed transaction. jbd2 commits
84    /// transactions in strictly increasing sequence order, so commit times
85    /// should be non-decreasing along that order. A regression is consistent
86    /// with a wall-clock change between commits, a journal assembled from
87    /// out-of-order sources, or tampering — and is replay-relevant, since
88    /// recovery replays by sequence regardless of the timestamp.
89    JournalInconsistent {
90        /// Sequence number of the later (out-of-order) transaction.
91        sequence: u32,
92        /// Its commit time (epoch seconds).
93        commit_seconds: i64,
94        /// Sequence number of the earlier transaction it regressed against.
95        prior_sequence: u32,
96        /// That earlier transaction's commit time (epoch seconds).
97        prior_commit_seconds: i64,
98    },
99}
100
101impl Ext4Anomaly {
102    /// Severity assigned to this kind — the single source of truth.
103    #[must_use]
104    pub fn severity(&self) -> Severity {
105        // Each variant's severity is an independent forensic judgment; two of
106        // them currently coincide at Medium, but they are kept as separate arms
107        // so either can be re-graded without disturbing the other.
108        #[allow(clippy::match_same_arms)]
109        match self {
110            // A primary/backup superblock divergence is a strong integrity
111            // signal, but has benign causes (resize, tune2fs) — Medium, not High.
112            Ext4Anomaly::SuperblockBackupMismatch { .. } => Severity::Medium,
113            // Recoverable deleted content: Low when the data has been overwritten
114            // (nothing to recover), Medium when blocks are still recoverable.
115            Ext4Anomaly::DeletedInode { recoverability, .. } => {
116                if *recoverability > 0.0 {
117                    Severity::Medium
118                } else {
119                    Severity::Low
120                }
121            }
122            Ext4Anomaly::SlackResidue { .. } => Severity::Low,
123            Ext4Anomaly::JournalInconsistent { .. } => Severity::Medium,
124        }
125    }
126
127    /// Stable machine-readable code (a published contract).
128    #[must_use]
129    pub fn code(&self) -> &'static str {
130        match self {
131            Ext4Anomaly::SuperblockBackupMismatch { .. } => "EXT4-SUPERBLOCK-BACKUP-MISMATCH",
132            Ext4Anomaly::DeletedInode { .. } => "EXT4-DELETED-INODE",
133            Ext4Anomaly::SlackResidue { .. } => "EXT4-SLACK-RESIDUE",
134            Ext4Anomaly::JournalInconsistent { .. } => "EXT4-JOURNAL-INCONSISTENT",
135        }
136    }
137
138    /// Analytical lens. The keyword classifier in
139    /// [`forensicnomicon::report::Category::from_code`] does not recognise these
140    /// ext4-specific codes, so each is set explicitly.
141    #[must_use]
142    pub fn category(&self) -> Category {
143        match self {
144            // Primary vs backup field divergence is a structural-integrity concern.
145            Ext4Anomaly::SuperblockBackupMismatch { .. } => Category::Integrity,
146            // Recoverable deleted/leaked content.
147            Ext4Anomaly::DeletedInode { .. } | Ext4Anomaly::SlackResidue { .. } => {
148                Category::Residue
149            }
150            // The journal is the filesystem's temporal record.
151            Ext4Anomaly::JournalInconsistent { .. } => Category::History,
152        }
153    }
154
155    /// Human-readable description (observation, not a conclusion).
156    #[must_use]
157    pub fn note(&self) -> String {
158        match self {
159            Ext4Anomaly::SuperblockBackupMismatch { group, block, differences } => format!(
160                "backup superblock in block group {group} (block {block}) disagrees with the primary \
161                 on field(s) [{}] — ext4 writes the primary and its backups together, so they should \
162                 be identical; a divergence is consistent with a resize that did not propagate, a \
163                 tune2fs edit, on-disk corruption, or one copy having been edited",
164                differences.join(", ")
165            ),
166            Ext4Anomaly::DeletedInode { ino, file_type, size, dtime, recoverability } => format!(
167                "inode {ino} ({file_type:?}, {size} bytes) has its deletion time set (dtime={dtime}) \
168                 — the kernel stamps dtime when a file is removed; {} of its data blocks remain \
169                 unallocated, so its content is consistent with being recoverable",
170                recoverability_phrase(*recoverability)
171            ),
172            Ext4Anomaly::SlackResidue { ino, file_size, block, slack_offset, nonzero_bytes } => format!(
173                "inode {ino} ({file_size} bytes) has {nonzero_bytes} non-zero byte(s) in the slack of \
174                 its final block (physical block {block}, from offset {slack_offset}) — ext4 does not \
175                 zero block slack on allocation, so this is consistent with leaked residue from the \
176                 block's prior use (often benign) or, rarely, deliberately hidden bytes"
177            ),
178            Ext4Anomaly::JournalInconsistent {
179                sequence,
180                commit_seconds,
181                prior_sequence,
182                prior_commit_seconds,
183            } => format!(
184                "journal transaction seq {sequence} committed at epoch {commit_seconds}, before \
185                 earlier transaction seq {prior_sequence} at epoch {prior_commit_seconds} — jbd2 \
186                 commits in increasing sequence order, so commit times should be non-decreasing; a \
187                 regression is consistent with a wall-clock change between commits, a journal \
188                 assembled from out-of-order sources, or tampering (replay-relevant: recovery replays \
189                 by sequence, not by timestamp)"
190            ),
191        }
192    }
193}
194
195impl Observation for Ext4Anomaly {
196    fn severity(&self) -> Option<Severity> {
197        Some(Ext4Anomaly::severity(self))
198    }
199    fn code(&self) -> &'static str {
200        Ext4Anomaly::code(self)
201    }
202    fn note(&self) -> String {
203        Ext4Anomaly::note(self)
204    }
205    fn category(&self) -> Category {
206        Ext4Anomaly::category(self)
207    }
208}
209
210/// Render a recoverability fraction as a short human phrase for notes.
211fn recoverability_phrase(recoverability: f64) -> String {
212    if recoverability <= 0.0 {
213        "none".to_string()
214    } else if recoverability >= 1.0 {
215        "all".to_string()
216    } else {
217        format!("{:.0}%", recoverability * 100.0)
218    }
219}
220
221/// Convert superblock-backup comparisons into findings.
222///
223/// One [`Ext4Anomaly::SuperblockBackupMismatch`] per backup that does not match
224/// the primary; matching backups produce no finding.
225#[must_use]
226pub fn superblock_findings(comparisons: &[SuperblockComparison]) -> Vec<Ext4Anomaly> {
227    comparisons
228        .iter()
229        .filter(|c| !c.matches_primary)
230        .map(|c| Ext4Anomaly::SuperblockBackupMismatch {
231            group: c.group,
232            block: c.block,
233            differences: c.differences.clone(),
234        })
235        .collect()
236}
237
238/// Convert deleted inodes into findings (one [`Ext4Anomaly::DeletedInode`] each).
239#[must_use]
240pub fn deleted_inode_findings(deleted: &[DeletedInode]) -> Vec<Ext4Anomaly> {
241    deleted
242        .iter()
243        .map(|d| Ext4Anomaly::DeletedInode {
244            ino: d.ino,
245            file_type: d.file_type,
246            size: d.size,
247            dtime: d.dtime,
248            recoverability: d.recoverability,
249        })
250        .collect()
251}
252
253/// Convert file slack into findings.
254///
255/// Only slack regions that actually contain non-zero bytes produce a finding —
256/// an all-zero tail is the expected, uninteresting case.
257#[must_use]
258pub fn slack_findings(slacks: &[SlackSpace]) -> Vec<Ext4Anomaly> {
259    slacks
260        .iter()
261        .filter_map(|s| {
262            let nonzero = s.data.iter().filter(|&&b| b != 0).count();
263            if nonzero == 0 {
264                return None;
265            }
266            Some(Ext4Anomaly::SlackResidue {
267                ino: s.ino,
268                file_size: s.file_size,
269                block: s.block,
270                slack_offset: s.slack_offset,
271                nonzero_bytes: nonzero,
272            })
273        })
274        .collect()
275}
276
277/// Convert journal transactions into findings.
278///
279/// Surfaces commit-timestamp regressions: jbd2 commits in increasing sequence
280/// order, so a transaction whose commit time precedes that of an earlier
281/// committed transaction is a replay-relevant inconsistency. Transactions are
282/// examined in their parsed (sequence) order; the running maximum commit time
283/// is the baseline each transaction is compared against.
284#[must_use]
285pub fn journal_findings(journal: &Journal) -> Vec<Ext4Anomaly> {
286    let mut findings = Vec::new();
287    // The running maximum commit instant (seconds, nanoseconds) among the
288    // transactions seen so far, with the sequence that set it. Full-precision
289    // comparison: a regression that is only visible at sub-second granularity is
290    // still a regression.
291    let mut max_seen: Option<(u32, i64, u32)> = None;
292    for txn in &journal.transactions {
293        let now = (txn.commit_seconds, txn.commit_nanoseconds);
294        if let Some((prior_seq, prior_secs, prior_nsec)) = max_seen {
295            if now < (prior_secs, prior_nsec) {
296                findings.push(Ext4Anomaly::JournalInconsistent {
297                    sequence: txn.sequence,
298                    commit_seconds: txn.commit_seconds,
299                    prior_sequence: prior_seq,
300                    prior_commit_seconds: prior_secs,
301                });
302                // A regression does not advance the baseline: subsequent
303                // transactions are still compared against the last in-order max.
304                continue;
305            }
306        }
307        max_seen = Some((txn.sequence, txn.commit_seconds, txn.commit_nanoseconds));
308    }
309    findings
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::block::BlockReader;
316    use crate::forensic::deleted::find_deleted_inodes;
317    use crate::forensic::journal::{parse_journal, Journal, JournalMapping, Transaction};
318    use crate::forensic::slack::{scan_all_slack, SlackSpace};
319    use crate::forensic::superblock_verify::{verify_superblock_backups, SuperblockComparison};
320    use crate::inode::InodeReader;
321    use std::io::Cursor;
322
323    fn open_forensic() -> Option<InodeReader<Cursor<Vec<u8>>>> {
324        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
325        let data = std::fs::read(path).ok()?;
326        let br = BlockReader::open(Cursor::new(data)).ok()?;
327        Some(InodeReader::new(br))
328    }
329
330    // -- trait wiring: code / category / severity are derived, cannot drift --
331
332    #[test]
333    fn deleted_inode_maps_to_residue_code() {
334        let a = Ext4Anomaly::DeletedInode {
335            ino: 21,
336            file_type: FileType::RegularFile,
337            size: 100,
338            dtime: 1,
339            recoverability: 0.5,
340        };
341        assert_eq!(Observation::code(&a), "EXT4-DELETED-INODE");
342        assert_eq!(Observation::category(&a), Category::Residue);
343        assert_eq!(Observation::severity(&a), Some(Severity::Medium));
344        let f = a.to_finding(forensicnomicon::report::Source::default());
345        assert_eq!(f.code, "EXT4-DELETED-INODE");
346        assert_eq!(f.category, Category::Residue);
347    }
348
349    #[test]
350    fn overwritten_deleted_inode_is_low() {
351        let a = Ext4Anomaly::DeletedInode {
352            ino: 21,
353            file_type: FileType::RegularFile,
354            size: 100,
355            dtime: 1,
356            recoverability: 0.0,
357        };
358        assert_eq!(Observation::severity(&a), Some(Severity::Low));
359    }
360
361    #[test]
362    fn slack_maps_to_residue_code() {
363        let a = Ext4Anomaly::SlackResidue {
364            ino: 12,
365            file_size: 12,
366            block: 100,
367            slack_offset: 12,
368            nonzero_bytes: 3,
369        };
370        assert_eq!(Observation::code(&a), "EXT4-SLACK-RESIDUE");
371        assert_eq!(Observation::category(&a), Category::Residue);
372        assert_eq!(Observation::severity(&a), Some(Severity::Low));
373    }
374
375    #[test]
376    fn superblock_maps_to_integrity_code() {
377        let a = Ext4Anomaly::SuperblockBackupMismatch {
378            group: 1,
379            block: 32768,
380            differences: vec!["blocks_count".to_string()],
381        };
382        assert_eq!(Observation::code(&a), "EXT4-SUPERBLOCK-BACKUP-MISMATCH");
383        assert_eq!(Observation::category(&a), Category::Integrity);
384        assert_eq!(Observation::severity(&a), Some(Severity::Medium));
385        assert!(Observation::note(&a).contains("blocks_count"));
386    }
387
388    #[test]
389    fn journal_maps_to_history_code() {
390        let a = Ext4Anomaly::JournalInconsistent {
391            sequence: 3,
392            commit_seconds: 100,
393            prior_sequence: 2,
394            prior_commit_seconds: 200,
395        };
396        assert_eq!(Observation::code(&a), "EXT4-JOURNAL-INCONSISTENT");
397        assert_eq!(Observation::category(&a), Category::History);
398        assert_eq!(Observation::severity(&a), Some(Severity::Medium));
399    }
400
401    #[test]
402    fn every_variant_has_a_consistent_with_note() {
403        let variants = [
404            Ext4Anomaly::SuperblockBackupMismatch {
405                group: 1,
406                block: 32768,
407                differences: vec!["uuid".to_string()],
408            },
409            Ext4Anomaly::DeletedInode {
410                ino: 21,
411                file_type: FileType::RegularFile,
412                size: 100,
413                dtime: 1,
414                recoverability: 0.5,
415            },
416            Ext4Anomaly::SlackResidue {
417                ino: 12,
418                file_size: 12,
419                block: 100,
420                slack_offset: 12,
421                nonzero_bytes: 3,
422            },
423            Ext4Anomaly::JournalInconsistent {
424                sequence: 3,
425                commit_seconds: 100,
426                prior_sequence: 2,
427                prior_commit_seconds: 200,
428            },
429        ];
430        for v in &variants {
431            let note = Observation::note(v);
432            assert!(
433                note.contains("consistent with"),
434                "{} note must use consistent-with framing: {note}",
435                Observation::code(v)
436            );
437        }
438    }
439
440    #[test]
441    fn recoverability_phrase_extremes_in_note() {
442        let none = Ext4Anomaly::DeletedInode {
443            ino: 1,
444            file_type: FileType::RegularFile,
445            size: 10,
446            dtime: 1,
447            recoverability: 0.0,
448        };
449        let all = Ext4Anomaly::DeletedInode {
450            ino: 2,
451            file_type: FileType::RegularFile,
452            size: 10,
453            dtime: 1,
454            recoverability: 1.0,
455        };
456        assert!(none.note().contains("none of its data blocks"));
457        assert!(all.note().contains("all of its data blocks"));
458    }
459
460    // -- conversion functions on synthetic engine outputs --
461
462    #[test]
463    fn superblock_findings_only_for_mismatches() {
464        let comparisons = vec![
465            SuperblockComparison {
466                group: 1,
467                block: 32768,
468                matches_primary: true,
469                differences: vec![],
470            },
471            SuperblockComparison {
472                group: 3,
473                block: 98304,
474                matches_primary: false,
475                differences: vec!["uuid".to_string(), "blocks_count".to_string()],
476            },
477        ];
478        let findings = superblock_findings(&comparisons);
479        assert_eq!(
480            findings.len(),
481            1,
482            "only the mismatching backup yields a finding"
483        );
484        match &findings[0] {
485            Ext4Anomaly::SuperblockBackupMismatch {
486                group, differences, ..
487            } => {
488                assert_eq!(*group, 3);
489                assert_eq!(differences.len(), 2);
490            }
491            other => panic!("expected SuperblockBackupMismatch, got {other:?}"),
492        }
493    }
494
495    #[test]
496    fn slack_findings_skip_all_zero_slack() {
497        let zero = SlackSpace {
498            ino: 10,
499            file_size: 5,
500            block: 50,
501            slack_offset: 5,
502            data: vec![0u8; 4091],
503        };
504        let nonzero = SlackSpace {
505            ino: 11,
506            file_size: 5,
507            block: 51,
508            slack_offset: 5,
509            data: vec![0, 0, 0x41, 0x42, 0],
510        };
511        let findings = slack_findings(&[zero, nonzero]);
512        assert_eq!(findings.len(), 1, "only non-zero slack yields a finding");
513        match &findings[0] {
514            Ext4Anomaly::SlackResidue {
515                ino, nonzero_bytes, ..
516            } => {
517                assert_eq!(*ino, 11);
518                assert_eq!(*nonzero_bytes, 2);
519            }
520            other => panic!("expected SlackResidue, got {other:?}"),
521        }
522    }
523
524    fn txn(sequence: u32, commit_seconds: i64) -> Transaction {
525        Transaction {
526            sequence,
527            commit_seconds,
528            commit_nanoseconds: 0,
529            mappings: Vec::<JournalMapping>::new(),
530            revoked_blocks: Vec::new(),
531        }
532    }
533
534    fn journal_of(transactions: Vec<Transaction>) -> Journal {
535        Journal {
536            block_size: 4096,
537            total_blocks: 1024,
538            first_block: 1,
539            transactions,
540            is_64bit: true,
541            has_csum_v3: false,
542        }
543    }
544
545    #[test]
546    fn journal_findings_flag_timestamp_regression() {
547        // seq 2 @ t=200, seq 3 @ t=150 (earlier) -> one regression finding.
548        let j = journal_of(vec![txn(2, 200), txn(3, 150)]);
549        let findings = journal_findings(&j);
550        assert_eq!(findings.len(), 1);
551        match &findings[0] {
552            Ext4Anomaly::JournalInconsistent {
553                sequence,
554                prior_sequence,
555                commit_seconds,
556                prior_commit_seconds,
557            } => {
558                assert_eq!(*sequence, 3);
559                assert_eq!(*prior_sequence, 2);
560                assert_eq!(*commit_seconds, 150);
561                assert_eq!(*prior_commit_seconds, 200);
562            }
563            other => panic!("expected JournalInconsistent, got {other:?}"),
564        }
565    }
566
567    #[test]
568    fn journal_findings_none_when_monotonic() {
569        let j = journal_of(vec![txn(2, 100), txn(3, 100), txn(4, 200)]);
570        assert!(
571            journal_findings(&j).is_empty(),
572            "non-decreasing commit times are clean"
573        );
574    }
575
576    // -- real-image cross-check (forensic.img; skips cleanly if absent) --
577
578    #[test]
579    fn real_image_deleted_inodes_match_tsk() {
580        let mut reader = if let Some(r) = open_forensic() {
581            r
582        } else {
583            eprintln!("skip: forensic.img not found");
584            return;
585        };
586        let deleted = find_deleted_inodes(&mut reader).unwrap();
587        let findings = deleted_inode_findings(&deleted);
588        // TSK `fls -rd forensic.img` lists deleted inodes 21 and 22.
589        let inos: Vec<u64> = findings
590            .iter()
591            .filter_map(|f| match f {
592                Ext4Anomaly::DeletedInode { ino, .. } => Some(*ino),
593                _ => None,
594            })
595            .collect();
596        assert!(
597            inos.contains(&21),
598            "expected deleted inode 21, got {inos:?}"
599        );
600        assert!(
601            inos.contains(&22),
602            "expected deleted inode 22, got {inos:?}"
603        );
604        for f in &findings {
605            assert_eq!(Observation::code(f), "EXT4-DELETED-INODE");
606            assert!(Observation::severity(f).is_some());
607        }
608    }
609
610    #[test]
611    fn real_image_slack_residue_present() {
612        let mut reader = if let Some(r) = open_forensic() {
613            r
614        } else {
615            eprintln!("skip: forensic.img not found");
616            return;
617        };
618        let slacks = scan_all_slack(&mut reader).unwrap();
619        let findings = slack_findings(&slacks);
620        // Every emitted finding must be a non-zero-slack residue finding.
621        for f in &findings {
622            match f {
623                Ext4Anomaly::SlackResidue { nonzero_bytes, .. } => assert!(*nonzero_bytes > 0),
624                other => panic!("expected SlackResidue, got {other:?}"),
625            }
626        }
627    }
628
629    #[test]
630    fn real_image_journal_is_monotonic_so_no_findings() {
631        // The engine parses forensic.img's journal as two committed transactions,
632        // seq 2 @ 1774998586.843380012 then seq 3 @ 1774998586.873380012 — commit
633        // instants increase with sequence (verified against the engine output;
634        // TSK `jls` additionally lists stale/unallocated commit blocks that the
635        // engine does not treat as committed transactions). A monotonic journal
636        // is the clean case, so the adapter emits NO EXT4-JOURNAL-INCONSISTENT
637        // finding here. (The regression-detection path is exercised by the
638        // synthetic `journal_findings_flag_timestamp_regression` test above.)
639        let mut reader = if let Some(r) = open_forensic() {
640            r
641        } else {
642            eprintln!("skip: forensic.img not found");
643            return;
644        };
645        let journal = parse_journal(&mut reader).unwrap();
646        let findings = journal_findings(&journal);
647        assert!(
648            findings.is_empty(),
649            "forensic.img journal commit times are monotonic; expected no findings, got {findings:?}"
650        );
651    }
652
653    #[test]
654    fn real_image_superblock_single_group_no_backups() {
655        // forensic.img is a single block group (TSK fsstat: Number of Block
656        // Groups: 1), so there are no backup superblocks to compare and the
657        // adapter emits no superblock findings — the correct empty result, not a
658        // bootstrap failure (the comparison list itself is empty).
659        let mut reader = if let Some(r) = open_forensic() {
660            r
661        } else {
662            eprintln!("skip: forensic.img not found");
663            return;
664        };
665        let comparisons = verify_superblock_backups(&mut reader).unwrap();
666        let findings = superblock_findings(&comparisons);
667        for f in &findings {
668            assert_eq!(Observation::code(f), "EXT4-SUPERBLOCK-BACKUP-MISMATCH");
669        }
670    }
671}