1use 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#[derive(Debug, Clone, PartialEq)]
31pub enum Ext4Anomaly {
32 SuperblockBackupMismatch {
39 group: u32,
41 block: u64,
43 differences: Vec<String>,
45 },
46
47 DeletedInode {
51 ino: u64,
53 file_type: FileType,
55 size: u64,
57 dtime: u32,
59 recoverability: f64,
62 },
63
64 SlackResidue {
70 ino: u64,
72 file_size: u64,
74 block: u64,
76 slack_offset: usize,
78 nonzero_bytes: usize,
80 },
81
82 JournalInconsistent {
90 sequence: u32,
92 commit_seconds: i64,
94 prior_sequence: u32,
96 prior_commit_seconds: i64,
98 },
99}
100
101impl Ext4Anomaly {
102 #[must_use]
104 pub fn severity(&self) -> Severity {
105 #[allow(clippy::match_same_arms)]
109 match self {
110 Ext4Anomaly::SuperblockBackupMismatch { .. } => Severity::Medium,
113 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 #[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 #[must_use]
142 pub fn category(&self) -> Category {
143 match self {
144 Ext4Anomaly::SuperblockBackupMismatch { .. } => Category::Integrity,
146 Ext4Anomaly::DeletedInode { .. } | Ext4Anomaly::SlackResidue { .. } => {
148 Category::Residue
149 }
150 Ext4Anomaly::JournalInconsistent { .. } => Category::History,
152 }
153 }
154
155 #[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
210fn 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#[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#[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#[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#[must_use]
285pub fn journal_findings(journal: &Journal) -> Vec<Ext4Anomaly> {
286 let mut findings = Vec::new();
287 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 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 #[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 #[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 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 #[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 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 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 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 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}