use core::fmt;
use std::collections::HashSet;
use std::io::{self, Read, Seek};
use crate::{
descriptor_label, ecma167_crc, fsd_recording_time, tag_checksum, UdfState, MAX_BLOCK_SIZE,
TAG_EFE, TAG_FE, TAG_FE_ALT, TAG_TERM,
};
pub use forensicnomicon::report::Severity;
use forensicnomicon::report::Category;
impl forensicnomicon::report::Observation for UdfAnomaly {
fn severity(&self) -> Option<Severity> {
Some(self.severity)
}
fn code(&self) -> &'static str {
self.code
}
fn note(&self) -> String {
self.note.clone()
}
fn category(&self) -> Category {
self.kind.category()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum UdfAnomalyKind {
TagCrcMismatch {
descriptor: String,
lba: u32,
stored: u16,
computed: u16,
},
TagChecksumBad {
descriptor: String,
lba: u32,
stored: u8,
computed: u8,
},
FileAfterVolume {
lba: u32,
file_time: String,
volume_time: String,
},
SlackData {
lba: u32,
nonzero_bytes: u32,
slack_bytes: u32,
},
}
impl UdfAnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
UdfAnomalyKind::TagCrcMismatch { .. } | UdfAnomalyKind::TagChecksumBad { .. } => {
Severity::High
}
UdfAnomalyKind::FileAfterVolume { .. } => Severity::Medium,
UdfAnomalyKind::SlackData { .. } => Severity::Low,
}
}
#[must_use]
pub fn category(&self) -> Category {
match self {
UdfAnomalyKind::TagCrcMismatch { .. } | UdfAnomalyKind::TagChecksumBad { .. } => {
Category::Integrity
}
UdfAnomalyKind::SlackData { .. } => Category::Residue,
UdfAnomalyKind::FileAfterVolume { .. } => Category::History,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
UdfAnomalyKind::TagCrcMismatch { .. } => "UDF-TAG-CRC-MISMATCH",
UdfAnomalyKind::TagChecksumBad { .. } => "UDF-TAG-CHECKSUM-BAD",
UdfAnomalyKind::FileAfterVolume { .. } => "UDF-TIME-AFTER-VOLUME",
UdfAnomalyKind::SlackData { .. } => "UDF-SLACK-DATA",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
UdfAnomalyKind::TagCrcMismatch {
descriptor,
lba,
stored,
computed,
} => format!(
"{descriptor} descriptor at LBA {lba}: recorded DescriptorCRC {stored:#06x} \
disagrees with the CRC-CCITT recomputed over its body ({computed:#06x}) — \
ECMA-167 mandates a valid CRC on every descriptor, so a mismatch is consistent \
with the descriptor body having been edited without recomputing the CRC, or with \
corruption"
),
UdfAnomalyKind::TagChecksumBad {
descriptor,
lba,
stored,
computed,
} => format!(
"{descriptor} descriptor at LBA {lba}: recorded tag checksum {stored} disagrees \
with the recomputed mod-256 sum of the tag bytes ({computed}) — ECMA-167 mandates \
a valid descriptor-tag checksum, so a mismatch is consistent with the 16-byte tag \
header having been edited, or with corruption"
),
UdfAnomalyKind::FileAfterVolume {
lba,
file_time,
volume_time,
} => format!(
"File Entry at LBA {lba} has modification time {file_time}, after the volume File \
Set Descriptor recording time {volume_time} — files normally predate volume \
finalization, so this is consistent with a post-authoring addition/touch or a \
backdated volume recording time"
),
UdfAnomalyKind::SlackData {
lba,
nonzero_bytes,
slack_bytes,
} => format!(
"File Entry at LBA {lba} has {nonzero_bytes} non-zero byte(s) in its {slack_bytes}-\
byte final-block slack — data unaccounted for by InformationLength; consistent \
with buffer/RAM fragments leaked by the authoring host (often benign: not \
zero-filled) or with hidden bytes"
),
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct UdfAnomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: UdfAnomalyKind,
pub note: String,
}
impl UdfAnomaly {
#[must_use]
pub fn new(kind: UdfAnomalyKind) -> Self {
UdfAnomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
}
}
}
impl fmt::Display for UdfAnomaly {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}: {}", self.severity, self.code, self.note)
}
}
pub fn analyze<R: Read + Seek>(reader: &mut R) -> io::Result<Vec<UdfAnomaly>> {
let mut out = Vec::new();
let Some(state) = crate::parse_udf_state_checked(reader)? else {
return Ok(out);
};
let bs = state.block_size;
audit_bootstrap_tags(reader, &state, &mut out)?;
let volume_time = fsd_recording_time(reader, bs, state.fsd_lba)?;
walk_directory_tree(reader, &state, volume_time.as_deref(), &mut out)?;
Ok(out)
}
fn audit_tag_at<R: Read + Seek>(
reader: &mut R,
bs: u32,
lba: u32,
out: &mut Vec<UdfAnomaly>,
) -> io::Result<u16> {
let mut buf = [0u8; MAX_BLOCK_SIZE];
let sector = &mut buf[..bs as usize];
crate::seek_read_checked(reader, u64::from(lba) * u64::from(bs), sector)?;
let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
if tag_ident == 0 {
return Ok(0);
}
let Some(label) = descriptor_label(tag_ident) else {
return Ok(tag_ident);
};
let stored_cksum = sector[4];
let computed_cksum = tag_checksum(§or[..16]);
if stored_cksum != computed_cksum {
out.push(UdfAnomaly::new(UdfAnomalyKind::TagChecksumBad {
descriptor: label.to_string(),
lba,
stored: stored_cksum,
computed: computed_cksum,
}));
}
let stored_crc = u16::from_le_bytes([sector[8], sector[9]]);
let crc_len = u16::from_le_bytes([sector[10], sector[11]]) as usize;
let body_end = 16usize.saturating_add(crc_len);
if body_end <= sector.len() {
let computed_crc = ecma167_crc(§or[16..body_end]);
if stored_crc != computed_crc {
out.push(UdfAnomaly::new(UdfAnomalyKind::TagCrcMismatch {
descriptor: label.to_string(),
lba,
stored: stored_crc,
computed: computed_crc,
}));
}
}
Ok(tag_ident)
}
fn audit_bootstrap_tags<R: Read + Seek>(
reader: &mut R,
state: &UdfState,
out: &mut Vec<UdfAnomaly>,
) -> io::Result<()> {
let bs = state.block_size;
audit_tag_at(reader, bs, 256, out)?;
for i in 0..state.vds_len_sectors {
let lba = state.vds_loc.saturating_add(i);
let tag = audit_tag_at(reader, bs, lba, out)?;
if tag == TAG_TERM || tag == 0 {
break;
}
}
audit_tag_at(reader, bs, state.fsd_lba, out)?;
Ok(())
}
fn walk_directory_tree<R: Read + Seek>(
reader: &mut R,
state: &UdfState,
volume_time: Option<&str>,
out: &mut Vec<UdfAnomaly>,
) -> io::Result<()> {
let bs = state.block_size;
let part_start = state.partition_start;
let mut queue: Vec<u32> = vec![state.root_fe_lba];
let mut visited: HashSet<u32> = HashSet::new();
while let Some(dir_fe_lba) = queue.pop() {
if !visited.insert(dir_fe_lba) {
continue;
}
audit_file_entry(reader, bs, part_start, dir_fe_lba, volume_time, out)?;
let Some(children) = crate::read_dir_at_lba(reader, bs, part_start, dir_fe_lba) else {
continue; };
for child in children {
if child.is_dir {
queue.push(child.fe_lba);
} else {
audit_file_entry(reader, bs, part_start, child.fe_lba, volume_time, out)?;
}
}
}
Ok(())
}
fn audit_file_entry<R: Read + Seek>(
reader: &mut R,
bs: u32,
partition_start: u32,
fe_lba: u32,
volume_time: Option<&str>,
out: &mut Vec<UdfAnomaly>,
) -> io::Result<()> {
audit_tag_at(reader, bs, fe_lba, out)?;
let mut buf = [0u8; MAX_BLOCK_SIZE];
let sector = &mut buf[..bs as usize];
crate::seek_read_checked(reader, u64::from(fe_lba) * u64::from(bs), sector)?;
let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && tag_ident != TAG_EFE {
return Ok(()); }
let is_efe = tag_ident == TAG_EFE;
if let (Some(vol), Some(mtime)) = (volume_time, crate::fe_modification_time(sector, is_efe)) {
if mtime.as_str() > vol {
out.push(UdfAnomaly::new(UdfAnomalyKind::FileAfterVolume {
lba: fe_lba,
file_time: mtime,
volume_time: vol.to_string(),
}));
}
}
if let Some((nonzero, slack)) = crate::fe_slack_nonzero(reader, bs, partition_start, fe_lba) {
if nonzero > 0 {
out.push(UdfAnomaly::new(UdfAnomalyKind::SlackData {
lba: fe_lba,
nonzero_bytes: nonzero,
slack_bytes: slack,
}));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use forensicnomicon::report::{Category, Observation, Source};
use std::io::Cursor;
fn slack_image(info_len: u64, slack: &[u8]) -> Vec<u8> {
let bs = 512usize;
let mut img = vec![0u8; bs * 8];
let fe = 4 * bs;
img[fe..fe + 2].copy_from_slice(&crate::TAG_FE.to_le_bytes());
img[fe + 34..fe + 36].copy_from_slice(&0u16.to_le_bytes()); img[fe + 56..fe + 64].copy_from_slice(&info_len.to_le_bytes());
img[fe + 168..fe + 172].copy_from_slice(&0u32.to_le_bytes()); img[fe + 172..fe + 176].copy_from_slice(&8u32.to_le_bytes()); img[fe + 176..fe + 180].copy_from_slice(&(info_len as u32).to_le_bytes());
img[fe + 180..fe + 184].copy_from_slice(&5u32.to_le_bytes()); let data = 5 * bs + (info_len as usize % bs);
img[data..data + slack.len()].copy_from_slice(slack);
img
}
#[test]
fn audit_file_entry_emits_slack_finding() {
let mut r = Cursor::new(slack_image(100, &[0x01, 0x00, 0x02]));
let mut out = Vec::new();
audit_file_entry(&mut r, 512, 0, 4, None, &mut out).unwrap();
let slack: Vec<_> = out
.iter()
.filter(|a| matches!(a.kind, UdfAnomalyKind::SlackData { .. }))
.collect();
assert_eq!(slack.len(), 1);
assert_eq!(slack[0].code, "UDF-SLACK-DATA");
assert_eq!(slack[0].severity, Severity::Low);
assert!(matches!(
slack[0].kind,
UdfAnomalyKind::SlackData {
nonzero_bytes: 2,
..
}
));
}
#[test]
fn slack_anomaly_surface() {
let an = UdfAnomaly::new(UdfAnomalyKind::SlackData {
lba: 5,
nonzero_bytes: 2,
slack_bytes: 412,
});
assert_eq!(an.code, "UDF-SLACK-DATA");
assert_eq!(an.severity, Severity::Low);
assert_eq!(an.kind.category(), Category::Residue);
assert!(an.note.contains("consistent with"));
let shown = an.to_string();
assert!(
shown.starts_with("[LOW] UDF-SLACK-DATA: "),
"unexpected Display: {shown}"
);
assert_eq!(Observation::severity(&an), Some(Severity::Low));
assert_eq!(an.to_finding(Source::default()).category, Category::Residue);
}
#[test]
fn file_after_volume_surface() {
let an = UdfAnomaly::new(UdfAnomalyKind::FileAfterVolume {
lba: 7,
file_time: "2099-01-01 00:00:00".into(),
volume_time: "2026-06-21 08:46:57".into(),
});
assert_eq!(an.code, "UDF-TIME-AFTER-VOLUME");
assert_eq!(an.severity, Severity::Medium);
assert_eq!(an.kind.category(), Category::History);
assert!(an.note.contains("consistent with"));
}
const BS: usize = 512;
fn stamp_tag(img: &mut [u8], lba: usize, tag_id: u16, crc_len: u16) {
let o = lba * BS;
img[o..o + 2].copy_from_slice(&tag_id.to_le_bytes());
img[o + 2..o + 4].copy_from_slice(&0x0102u16.to_le_bytes()); img[o + 12..o + 16].copy_from_slice(&(lba as u32).to_le_bytes()); let crc = crate::ecma167_crc(&img[o + 16..o + 16 + crc_len as usize]);
img[o + 8..o + 10].copy_from_slice(&crc.to_le_bytes());
img[o + 10..o + 12].copy_from_slice(&crc_len.to_le_bytes());
img[o + 4] = crate::tag_checksum(&img[o..o + 16]);
}
fn write_fid(data: &mut [u8], off: usize, child_lbn: u32, is_dir: bool, name: &[u8]) -> usize {
data[off..off + 2].copy_from_slice(&crate::TAG_FID.to_le_bytes());
let body = off + 16;
data[body] = 1; data[body + 2] = if is_dir { 0x02 } else { 0x00 }; data[body + 3] = name.len() as u8; data[body + 8..body + 12].copy_from_slice(&child_lbn.to_le_bytes());
let id = body + 20;
data[id..id + name.len()].copy_from_slice(name);
let raw = 16 + 2 + 1 + 1 + 16 + 2 + name.len(); let advance = (raw + 3) & !3;
data[off + 10..off + 12].copy_from_slice(&((advance - 16) as u16).to_le_bytes());
advance
}
fn stamp_dir_fe(img: &mut [u8], lba: usize, fids: &[u8]) {
let o = lba * BS;
img[o + 34..o + 36].copy_from_slice(&3u16.to_le_bytes()); img[o + 27] = 4; img[o + 56..o + 64].copy_from_slice(&(fids.len() as u64).to_le_bytes());
img[o + 168..o + 172].copy_from_slice(&0u32.to_le_bytes()); img[o + 172..o + 176].copy_from_slice(&(fids.len() as u32).to_le_bytes()); img[o + 176..o + 176 + fids.len()].copy_from_slice(fids);
stamp_tag(img, lba, crate::TAG_FE, 200);
}
fn minimal_udf() -> Vec<u8> {
let mut img = vec![0u8; BS * 280];
let part_start = 0u32;
let a = 256 * BS;
img[a + 16..a + 20].copy_from_slice(&(4u32 * BS as u32).to_le_bytes()); img[a + 20..a + 24].copy_from_slice(&260u32.to_le_bytes()); stamp_tag(&mut img, 256, crate::TAG_AVDP, 16);
let p = 260 * BS;
img[p + 22..p + 24].copy_from_slice(&0u16.to_le_bytes());
img[p + 188..p + 192].copy_from_slice(&part_start.to_le_bytes());
stamp_tag(&mut img, 260, crate::TAG_PD, 200);
let l = 261 * BS;
img[l + 252..l + 256].copy_from_slice(&3u32.to_le_bytes()); img[l + 256..l + 258].copy_from_slice(&0u16.to_le_bytes()); img[l + 264..l + 268].copy_from_slice(&6u32.to_le_bytes()); img[l + 268..l + 272].copy_from_slice(&1u32.to_le_bytes()); img[l + 440] = 1; img[l + 441] = 6; img[l + 444..l + 446].copy_from_slice(&0u16.to_le_bytes()); stamp_tag(&mut img, 261, crate::TAG_LVD, 400);
stamp_tag(&mut img, 262, 9, 80);
let f = 3 * BS;
img[f + 16 + 2..f + 16 + 4].copy_from_slice(&2026i16.to_le_bytes()); img[f + 16 + 4] = 6;
img[f + 16 + 5] = 21;
img[f + 404..f + 408].copy_from_slice(&4u32.to_le_bytes()); stamp_tag(&mut img, 3, crate::TAG_FSD, 400);
let mut fids = vec![0u8; 160];
let n = write_fid(&mut fids, 0, 6, true, b"sub");
let _ = write_fid(&mut fids, n, 7, false, b"f.txt");
let total = n + ((16 + 2 + 1 + 1 + 16 + 2 + 5 + 3) & !3);
stamp_dir_fe(&mut img, 4, &fids[..total]);
stamp_dir_fe(&mut img, 6, &[]);
let ff = 7 * BS;
img[ff + 34..ff + 36].copy_from_slice(&3u16.to_le_bytes()); img[ff + 27] = 5; img[ff + 56..ff + 64].copy_from_slice(&4u64.to_le_bytes());
img[ff + 168..ff + 172].copy_from_slice(&0u32.to_le_bytes());
img[ff + 172..ff + 176].copy_from_slice(&4u32.to_le_bytes());
img[ff + 176..ff + 180].copy_from_slice(b"abcd");
stamp_tag(&mut img, 7, crate::TAG_FE, 200);
img
}
#[test]
fn analyze_walks_subdirectory_clean() {
let img = minimal_udf();
let mut r = Cursor::new(img);
let a = analyze(&mut r).expect("minimal UDF analyzes");
assert!(
a.is_empty(),
"internally consistent minimal UDF must be clean, got: {a:?}"
);
}
#[test]
fn audit_tag_at_ignores_unrecognised_identifier() {
let mut img = vec![0u8; BS];
img[0..2].copy_from_slice(&0x7FFFu16.to_le_bytes());
let mut r = Cursor::new(img);
let mut out = Vec::new();
let tag = audit_tag_at(&mut r, BS as u32, 0, &mut out).unwrap();
assert_eq!(tag, 0x7FFF);
assert!(out.is_empty());
}
#[test]
fn audit_tag_at_skips_crc_when_length_overflows_block() {
let mut img = vec![0u8; BS];
img[0..2].copy_from_slice(&crate::TAG_FSD.to_le_bytes());
img[10..12].copy_from_slice(&0xFFFFu16.to_le_bytes()); img[4] = crate::tag_checksum(&img[..16]); let mut r = Cursor::new(img);
let mut out = Vec::new();
audit_tag_at(&mut r, BS as u32, 0, &mut out).unwrap();
assert!(
out.is_empty(),
"oversized crc_len skips CRC, valid checksum is clean"
);
}
#[test]
fn walk_skips_already_visited_directory_cycle() {
let mut img = minimal_udf();
let mut fids = vec![0u8; 64];
let total = write_fid(&mut fids, 0, 4, true, b"up");
stamp_dir_fe(&mut img, 6, &fids[..total]);
let mut r = Cursor::new(img);
let a = analyze(&mut r).expect("cyclic UDF still terminates");
assert!(a.is_empty(), "cycle must be skipped cleanly, got: {a:?}");
}
}