#![forbid(unsafe_code)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub use forensicnomicon::report::Severity;
use forensicnomicon::report::{Evidence, Finding, Location, Observation, Source};
use xfs::{
assemble_extents, Agf, Agi, BmbtRec, Inode, Superblock, XfsTimestamp, XFS_DINODE_MAGIC,
XFS_SB_MAGIC,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnomalyKind {
CrcMismatch {
structure: &'static str,
offset: u64,
},
SbMirrorDivergence {
agno: u64,
field: &'static str,
primary: u64,
secondary: u64,
offset: u64,
},
OrphanedInode {
agno: u64,
bucket: usize,
agino: u32,
},
ImpossibleGeometry {
field: &'static str,
value: u64,
limit: u64,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::CrcMismatch { .. }
| AnomalyKind::SbMirrorDivergence { .. }
| AnomalyKind::ImpossibleGeometry { .. } => Severity::High,
AnomalyKind::OrphanedInode { .. } => Severity::Medium,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::CrcMismatch { .. } => "XFS-CRC-MISMATCH",
AnomalyKind::SbMirrorDivergence { .. } => "XFS-SB-MIRROR-DIVERGENCE",
AnomalyKind::OrphanedInode { .. } => "XFS-ORPHANED-INODE",
AnomalyKind::ImpossibleGeometry { .. } => "XFS-IMPOSSIBLE-GEOMETRY",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::CrcMismatch { structure, offset } => format!(
"{structure} at byte {offset}: stored v5 CRC32c does not verify — consistent with corruption or post-write tampering"
),
AnomalyKind::SbMirrorDivergence {
agno,
field,
primary,
secondary,
..
} => format!(
"AG {agno} secondary superblock: {field} = {secondary} differs from AG-0 primary {primary} — consistent with a spliced or edited image"
),
AnomalyKind::OrphanedInode {
agno,
bucket,
agino,
} => format!(
"AG {agno} AGI unlinked bucket {bucket} points at agino {agino} — an inode unlinked while still open (orphaned-but-live), a recovery lead"
),
AnomalyKind::ImpossibleGeometry {
field,
value,
limit,
} => format!(
"geometry field {field} = {value} exceeds the sane bound {limit} for this image — consistent with corruption or an allocation-bomb"
),
}
}
fn evidence(&self) -> Vec<Evidence> {
match self {
AnomalyKind::CrcMismatch { structure, offset } => vec![Evidence {
field: "structure".to_string(),
value: (*structure).to_string(),
location: Some(Location::ByteOffset(*offset)),
}],
AnomalyKind::SbMirrorDivergence {
agno,
field,
primary,
secondary,
offset,
} => vec![Evidence {
field: (*field).to_string(),
value: format!("AG{agno} secondary={secondary} vs primary={primary}"),
location: Some(Location::ByteOffset(*offset)),
}],
AnomalyKind::OrphanedInode {
agno,
bucket,
agino,
} => vec![Evidence {
field: "agi_unlinked".to_string(),
value: format!("AG{agno} bucket[{bucket}] -> agino {agino}"),
location: Some(Location::Other {
space: "xfs:agino".to_string(),
value: u64::from(*agino),
}),
}],
AnomalyKind::ImpossibleGeometry {
field,
value,
limit,
} => vec![Evidence {
field: (*field).to_string(),
value: format!("{value} (limit {limit})"),
location: None,
}],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: AnomalyKind,
pub note: String,
}
impl Anomaly {
#[must_use]
pub fn new(kind: AnomalyKind) -> Self {
Anomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
}
}
}
impl Observation for Anomaly {
fn severity(&self) -> Option<Severity> {
Some(self.severity)
}
fn code(&self) -> &'static str {
self.code
}
fn note(&self) -> String {
self.note.clone()
}
fn evidence(&self) -> Vec<Evidence> {
self.kind.evidence()
}
}
#[must_use]
pub fn audit_image(image: &[u8]) -> Vec<Anomaly> {
let mut out = Vec::new();
if image.len() < 220 || be_u32(image, 0) != XFS_SB_MAGIC {
return out;
}
let raw_sect = usize::from(be_u16(image, 102));
let sect = if raw_sect.is_power_of_two() && (512..=65536).contains(&raw_sect) {
raw_sect
} else {
512
};
let sb_end = sect.min(image.len());
let Ok(sb) = Superblock::parse(&image[..sb_end]) else {
return out; };
let is_v5 = sb.is_v5();
if is_v5 && sb.crc_valid == Some(false) {
out.push(Anomaly::new(AnomalyKind::CrcMismatch {
structure: "superblock",
offset: 0,
}));
}
let bsize = u64::from(sb.blocksize);
let agblocks = u64::from(sb.agblocks);
let agcount = u64::from(sb.agcount);
let ag_bytes = agblocks.saturating_mul(bsize);
let image_len = image.len() as u64;
if agcount == 0 {
out.push(Anomaly::new(AnomalyKind::ImpossibleGeometry {
field: "agcount",
value: 0,
limit: 1,
}));
} else if ag_bytes > 0 {
let last_base = agcount.saturating_sub(1).saturating_mul(ag_bytes);
if last_base >= image_len {
out.push(Anomaly::new(AnomalyKind::ImpossibleGeometry {
field: "agcount",
value: agcount,
limit: image_len / ag_bytes + 1,
}));
}
}
if ag_bytes > 0 {
for agno in 0..agcount {
let base = agno.saturating_mul(ag_bytes);
let base_us = usize::try_from(base).unwrap_or(usize::MAX);
if base_us >= image.len() {
break;
}
if agno >= 1 {
if let Some(slice) = image.get(base_us..base_us.saturating_add(sect)) {
if let Ok(sec) = Superblock::parse(slice) {
if is_v5 && sec.crc_valid == Some(false) {
out.push(Anomaly::new(AnomalyKind::CrcMismatch {
structure: "superblock",
offset: base,
}));
}
push_sb_divergence(&mut out, agno, base, &sb, &sec);
}
}
}
let agf_off = base_us.saturating_add(sect);
if let Some(slice) = image.get(agf_off..agf_off.saturating_add(sect)) {
if let Ok(agf) = Agf::parse_verified(slice, is_v5) {
if agf.crc_valid == Some(false) {
out.push(Anomaly::new(AnomalyKind::CrcMismatch {
structure: "AGF",
offset: agf_off as u64,
}));
}
}
}
let agi_off = base_us.saturating_add(sect.saturating_mul(2));
if let Some(slice) = image.get(agi_off..agi_off.saturating_add(sect)) {
if let Ok(agi) = Agi::parse_verified(slice, is_v5) {
if agi.crc_valid == Some(false) {
out.push(Anomaly::new(AnomalyKind::CrcMismatch {
structure: "AGI",
offset: agi_off as u64,
}));
}
for (bucket, &agino) in agi.unlinked.iter().enumerate() {
if agino != NULL_AGINO {
out.push(Anomaly::new(AnomalyKind::OrphanedInode {
agno,
bucket,
agino,
}));
}
}
}
}
}
}
if is_v5 {
let inode_size = usize::from(sb.inodesize);
if inode_size >= 176 {
let mut off = 0usize;
while off.saturating_add(inode_size) <= image.len() {
if be_u16(image, off) == XFS_DINODE_MAGIC {
if let Some(slice) = image.get(off..off.saturating_add(inode_size)) {
if let Ok(inode) = Inode::parse(slice) {
if let Some((_, ino)) = offset_to_inode(&sb, off as u64) {
if inode.di_ino == Some(ino) && inode.crc_valid == Some(false) {
out.push(Anomaly::new(AnomalyKind::CrcMismatch {
structure: "inode",
offset: off as u64,
}));
}
}
} } }
off = off.saturating_add(inode_size);
}
}
}
out
}
fn push_sb_divergence(
out: &mut Vec<Anomaly>,
agno: u64,
offset: u64,
primary: &Superblock,
secondary: &Superblock,
) {
let checks: [(&'static str, u64, u64); 4] = [
(
"agblocks",
u64::from(primary.agblocks),
u64::from(secondary.agblocks),
),
(
"agcount",
u64::from(primary.agcount),
u64::from(secondary.agcount),
),
(
"blocksize",
u64::from(primary.blocksize),
u64::from(secondary.blocksize),
),
(
"inodesize",
u64::from(primary.inodesize),
u64::from(secondary.inodesize),
),
];
for (field, p, s) in checks {
if p != s {
out.push(Anomaly::new(AnomalyKind::SbMirrorDivergence {
agno,
field,
primary: p,
secondary: s,
offset,
}));
}
}
}
#[must_use]
pub fn audit_findings(image: &[u8], scope: &str) -> Vec<Finding> {
let source = Source {
analyzer: "xfs-forensic".to_string(),
scope: scope.to_string(),
version: None,
};
audit_image(image)
.iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeletedInode {
pub agno: u64,
pub inode_number: u64,
pub residual_extents: Vec<BmbtRec>,
pub ctime: XfsTimestamp,
pub recovered_size_estimate: u64,
pub carved: Vec<u8>,
}
#[must_use]
pub fn recover_deleted(image: &[u8], sb: &Superblock) -> Vec<DeletedInode> {
let mut out = Vec::new();
let inode_size = usize::from(sb.inodesize);
let bsize = u64::from(sb.blocksize);
if inode_size < 176 || bsize == 0 {
return out;
}
let total_blocks = image_len_blocks(image.len(), bsize);
let mut off = 0usize;
while off.saturating_add(inode_size) <= image.len() {
let Some(slice) = image.get(off..off.saturating_add(inode_size)) else {
break; };
if be_u16(slice, 0) == XFS_DINODE_MAGIC {
if let Ok(inode) = Inode::parse(slice) {
if inode.version >= 3 && inode.mode == 0 {
let residual = decode_residual(&inode.data_fork, total_blocks);
if !residual.is_empty() {
if let Some((agno, ino)) = offset_to_inode(sb, off as u64) {
let blocks: u64 = residual.iter().map(|e| e.blockcount).sum();
let recovered_size_estimate = blocks.saturating_mul(bsize);
let carved =
assemble_extents(image, sb, &residual, recovered_size_estimate)
.unwrap_or_default();
out.push(DeletedInode {
agno,
inode_number: ino,
residual_extents: residual,
ctime: inode.ctime,
recovered_size_estimate,
carved,
});
} }
}
} }
off = off.saturating_add(inode_size);
}
out
}
const NULL_AGINO: u32 = 0xffff_ffff;
fn image_len_blocks(len: usize, bsize: u64) -> u64 {
if bsize == 0 {
return 0; }
len as u64 / bsize
}
fn decode_residual(fork: &[u8], total_blocks: u64) -> Vec<BmbtRec> {
let mut recs = Vec::new();
let mut p = 0usize;
while p.saturating_add(16) <= fork.len() {
let Some(chunk) = fork.get(p..p.saturating_add(16)) else {
break; };
let mut raw = [0u8; 16];
raw.copy_from_slice(chunk);
if raw == [0u8; 16] {
break;
}
let rec = BmbtRec::unpack(&raw);
if rec.blockcount == 0 || rec.startblock == 0 {
break;
}
if rec.startblock.saturating_add(rec.blockcount) > total_blocks {
break;
}
recs.push(rec);
p = p.saturating_add(16);
}
recs
}
fn offset_to_inode(sb: &Superblock, off: u64) -> Option<(u64, u64)> {
let bsize = u64::from(sb.blocksize);
let agblocks = u64::from(sb.agblocks);
let inode_size = u64::from(sb.inodesize);
if bsize == 0 || agblocks == 0 || inode_size == 0 {
return None; }
let ag_bytes = agblocks.checked_mul(bsize)?;
let agno = off / ag_bytes;
let within = off % ag_bytes;
let agblock = within / bsize;
let slot = (within % bsize) / inode_size;
let inopblog = u32::from(sb.inopblog);
let agino_bits = u32::from(sb.agblklog) + inopblog;
if inopblog >= 64 || agino_bits >= 64 {
return None; }
let agino = (agblock << inopblog) | slot;
let ino = (agno << agino_bits) | agino;
Some((agno, ino))
}
fn be_u16(d: &[u8], o: usize) -> u16 {
d.get(o..o.saturating_add(2))
.and_then(|b| <[u8; 2]>::try_from(b).ok())
.map_or(0, u16::from_be_bytes)
}
fn be_u32(d: &[u8], o: usize) -> u32 {
d.get(o..o.saturating_add(4))
.and_then(|b| <[u8; 4]>::try_from(b).ok())
.map_or(0, u32::from_be_bytes)
}
#[cfg(test)]
mod unit {
use super::{be_u16, be_u32, decode_residual, image_len_blocks};
#[test]
fn be_readers_yield_zero_out_of_range() {
assert_eq!(be_u16(&[0x12], 0), 0); assert_eq!(be_u16(&[0x12, 0x34], 0), 0x1234);
assert_eq!(be_u32(&[0, 0, 0], 0), 0); assert_eq!(be_u32(&[0, 0, 0, 5], 0), 5);
}
#[test]
fn image_len_blocks_divides_by_block_size() {
assert_eq!(image_len_blocks(4096 * 10, 4096), 10);
}
#[test]
fn decode_residual_stops_at_each_boundary() {
assert!(decode_residual(&[], 100).is_empty());
assert!(decode_residual(&[0u8; 16], 100).is_empty());
let mut fork = vec![0u8; 48];
fork[8..16].copy_from_slice(&0x0400_0008u64.to_be_bytes()); let recs = decode_residual(&fork, 131_072);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].startblock, 32);
assert_eq!(recs[0].blockcount, 8);
assert!(decode_residual(&fork, 10).is_empty());
let mut zero_count = vec![0u8; 16];
zero_count[0..8].copy_from_slice(&0x0000_0200u64.to_be_bytes()); assert!(decode_residual(&zero_count, 100).is_empty());
let mut zero_start = vec![0u8; 16];
zero_start[8..16].copy_from_slice(&0x0000_0008u64.to_be_bytes());
assert!(decode_residual(&zero_start, 100).is_empty());
}
}