use std::io::{self, Read, Seek, SeekFrom};
pub mod findings;
#[cfg(feature = "vfs")]
pub mod vfs;
pub use forensicnomicon::report::Severity;
const TAG_AVDP: u16 = 2;
const TAG_PD: u16 = 5;
const TAG_LVD: u16 = 6;
const TAG_TERM: u16 = 8;
const TAG_FSD: u16 = 256;
const TAG_FID: u16 = 257;
const TAG_FE: u16 = 260;
const TAG_FE_ALT: u16 = 261;
const TAG_EFE: u16 = 266;
const FC_DIRECTORY: u8 = 0x02;
const FC_PARENT: u8 = 0x08;
const ALLOC_SHORT: u16 = 0;
const ALLOC_LONG: u16 = 1;
const ALLOC_INLINE: u16 = 3;
const EXTENT_RECORDED: u32 = 0x0000_0000;
const MAX_BLOCK_SIZE: usize = 4096;
const BLOCK_SIZE_CANDIDATES: [u32; 4] = [2048, 512, 1024, 4096];
#[derive(Debug, Clone)]
pub struct UdfFileEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub fe_lba: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum UdfPartitionKind {
Physical,
Virtual,
Sparable,
Metadata,
Unknown,
}
#[derive(Debug)]
pub struct UdfState {
pub partition_start: u32,
pub root_fe_lba: u32,
pub partition_kind: UdfPartitionKind,
pub partition_map_count: u32,
pub block_size: u32,
pub fsd_lba: u32,
pub vds_loc: u32,
pub vds_len_sectors: u32,
}
pub fn detect_udf<R: Read + Seek>(reader: &mut R) -> bool {
let mut buf = [0u8; 6];
for lba in 16u64..32 {
let pos = lba * 2048 + 1;
if reader.seek(SeekFrom::Start(pos)).is_err() {
break;
}
if reader.read_exact(&mut buf).is_err() {
break;
}
let id = &buf[..5];
if id == b"NSR02" || id == b"NSR03" {
return true;
}
if id == b"TEA01" {
break;
}
}
false
}
pub fn parse_udf_state<R: Read + Seek>(reader: &mut R) -> Option<UdfState> {
parse_udf_state_checked(reader).ok().flatten()
}
pub fn parse_udf_state_checked<R: Read + Seek>(
reader: &mut R,
) -> Result<Option<UdfState>, io::Error> {
let Some(block_size) = detect_block_size(reader)? else {
return Ok(None);
};
let Some((vds_loc, vds_len)) = read_avdp_checked(reader, block_size)? else {
return Ok(None);
};
let Some(vds) = read_vds_checked(reader, block_size, vds_loc, vds_len)? else {
return Ok(None);
};
let Some(root_fe_lba) = read_fsd_checked(reader, block_size, vds.fsd_lba, vds.partition_start)?
else {
return Ok(None);
};
Ok(Some(UdfState {
partition_start: vds.partition_start,
root_fe_lba,
partition_kind: vds.partition_kind,
partition_map_count: vds.map_count,
block_size,
fsd_lba: vds.fsd_lba,
vds_loc,
vds_len_sectors: (vds_len as usize).div_ceil(block_size as usize) as u32,
}))
}
struct VdsInfo {
partition_start: u32,
fsd_lba: u32,
partition_kind: UdfPartitionKind,
map_count: u32,
}
struct PartitionMap {
kind: UdfPartitionKind,
partition_number: Option<u16>,
}
fn classify_type2(map: &[u8]) -> UdfPartitionKind {
let scan = |needle: &[u8]| map.windows(needle.len()).any(|w| w == needle);
if scan(b"*UDF Metadata Partition") {
UdfPartitionKind::Metadata
} else if scan(b"*UDF Virtual Partition") {
UdfPartitionKind::Virtual
} else if scan(b"*UDF Sparable Partition") {
UdfPartitionKind::Sparable
} else {
UdfPartitionKind::Unknown
}
}
fn parse_partition_maps(lvd: &[u8]) -> Vec<PartitionMap> {
let n_pm = u32::from_le_bytes(lvd[268..272].try_into().unwrap()) as usize;
let mt_l = u32::from_le_bytes(lvd[264..268].try_into().unwrap()) as usize;
let maps_end = (440 + mt_l).min(lvd.len());
let mut out = Vec::new();
let mut off = 440;
while out.len() < n_pm && off + 2 <= maps_end {
let map_type = lvd[off];
let map_len = lvd[off + 1] as usize;
if map_len < 2 || off + map_len > maps_end {
break;
}
let map = &lvd[off..off + map_len];
let pm = match map_type {
1 if map_len >= 6 => PartitionMap {
kind: UdfPartitionKind::Physical,
partition_number: Some(u16::from_le_bytes([map[4], map[5]])),
},
2 => PartitionMap {
kind: classify_type2(map),
partition_number: None,
},
_ => PartitionMap {
kind: UdfPartitionKind::Unknown,
partition_number: None,
},
};
out.push(pm);
off += map_len;
}
out
}
pub fn read_dir_at_lba<R: Read + Seek>(
reader: &mut R,
block_size: u32,
partition_start: u32,
dir_fe_lba: u32,
) -> Option<Vec<UdfFileEntry>> {
let dir_data = read_fe_data(reader, block_size, partition_start, dir_fe_lba)?;
Some(parse_fids(reader, block_size, partition_start, &dir_data))
}
pub fn read_fe_data<R: Read + Seek>(
reader: &mut R,
block_size: u32,
partition_start: u32,
fe_lba: u32,
) -> Option<Vec<u8>> {
let mut sector = [0u8; MAX_BLOCK_SIZE];
let sector = &mut sector[..block_size as usize];
seek_read(reader, fe_lba as u64 * block_size as u64, sector)?;
let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
let is_efe = tag_ident == TAG_EFE;
if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && !is_efe {
return None;
}
let icb_flags = u16::from_le_bytes([sector[34], sector[35]]);
let alloc_type = icb_flags & 0x0007;
let info_len = u64::from_le_bytes(sector[56..64].try_into().unwrap());
let (ea_off, ad_off, header) = if is_efe {
(208usize, 212usize, 216usize)
} else {
(168usize, 172usize, 176usize)
};
if ad_off + 4 > sector.len() {
return None;
}
let ea_len = u32::from_le_bytes(sector[ea_off..ea_off + 4].try_into().unwrap()) as usize;
let ad_len = u32::from_le_bytes(sector[ad_off..ad_off + 4].try_into().unwrap()) as usize;
let ad_start = header + ea_len;
let ad_end = ad_start + ad_len;
if ad_end > sector.len() {
return None;
}
let ad_area = sector[ad_start..ad_end].to_vec();
match alloc_type {
ALLOC_INLINE => Some(ad_area[..info_len.min(ad_area.len() as u64) as usize].to_vec()),
ALLOC_SHORT => read_extents_short(reader, block_size, partition_start, &ad_area, info_len),
ALLOC_LONG => read_extents_long(reader, block_size, partition_start, &ad_area, info_len),
_ => None,
}
}
fn detect_block_size<R: Read + Seek>(reader: &mut R) -> Result<Option<u32>, io::Error> {
let mut tag = [0u8; 16];
let mut last_eof: Option<io::Error> = None;
let mut any_read_ok = false;
for bs in BLOCK_SIZE_CANDIDATES {
match seek_read_checked(reader, 256 * bs as u64, &mut tag) {
Ok(()) => {
any_read_ok = true;
let tag_ident = u16::from_le_bytes([tag[0], tag[1]]);
let tag_location = u32::from_le_bytes(tag[12..16].try_into().unwrap());
if tag_ident == TAG_AVDP && tag_location == 256 {
return Ok(Some(bs));
}
}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => last_eof = Some(e),
Err(e) => return Err(e),
}
}
if !any_read_ok {
if let Some(e) = last_eof {
return Err(e);
}
}
Ok(None)
}
fn read_avdp_checked<R: Read + Seek>(
reader: &mut R,
block_size: u32,
) -> Result<Option<(u32, u32)>, io::Error> {
let mut sector = [0u8; MAX_BLOCK_SIZE];
let sector = &mut sector[..block_size as usize];
seek_read_checked(reader, 256 * block_size as u64, sector)?;
if u16::from_le_bytes([sector[0], sector[1]]) != TAG_AVDP {
return Ok(None);
}
let vds_len = u32::from_le_bytes(sector[16..20].try_into().unwrap());
let vds_loc = u32::from_le_bytes(sector[20..24].try_into().unwrap());
Ok(Some((vds_loc, vds_len)))
}
fn read_vds_checked<R: Read + Seek>(
reader: &mut R,
block_size: u32,
vds_loc: u32,
vds_len: u32,
) -> Result<Option<VdsInfo>, io::Error> {
use std::collections::HashMap;
let sectors = (vds_len as usize).div_ceil(block_size as usize);
let mut pd_start: HashMap<u16, u32> = HashMap::new();
let mut fsd_lbn: Option<u32> = None;
let mut fsd_part_ref: u16 = 0;
let mut maps: Vec<PartitionMap> = Vec::new();
for i in 0..sectors {
let mut sector = [0u8; MAX_BLOCK_SIZE];
let sector = &mut sector[..block_size as usize];
seek_read_checked(
reader,
(vds_loc as u64 + i as u64) * block_size as u64,
sector,
)?;
let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
match tag_ident {
TAG_PD => {
let part_num = u16::from_le_bytes([sector[22], sector[23]]);
let psl = u32::from_le_bytes(sector[188..192].try_into().unwrap());
pd_start.insert(part_num, psl);
}
TAG_LVD => {
fsd_lbn = Some(u32::from_le_bytes(sector[252..256].try_into().unwrap()));
fsd_part_ref = u16::from_le_bytes([sector[256], sector[257]]);
maps = parse_partition_maps(sector);
}
TAG_TERM | 0 => break,
_ => {}
}
}
let Some(fsd) = fsd_lbn else {
return Ok(None);
};
let map_count = maps.len() as u32;
let referenced = maps.get(fsd_part_ref as usize);
let kind = referenced.map_or(UdfPartitionKind::Unknown, |m| m.kind);
let partition_start = referenced
.and_then(|m| m.partition_number)
.and_then(|pn| pd_start.get(&pn).copied())
.or_else(|| pd_start.values().min().copied());
let Some(partition_start) = partition_start else {
return Ok(None);
};
Ok(Some(VdsInfo {
partition_start,
fsd_lba: partition_start + fsd,
partition_kind: kind,
map_count,
}))
}
fn read_fsd_checked<R: Read + Seek>(
reader: &mut R,
block_size: u32,
fsd_lba: u32,
partition_start: u32,
) -> Result<Option<u32>, io::Error> {
let mut sector = [0u8; MAX_BLOCK_SIZE];
let sector = &mut sector[..block_size as usize];
seek_read_checked(reader, fsd_lba as u64 * block_size as u64, sector)?;
if u16::from_le_bytes([sector[0], sector[1]]) != TAG_FSD {
return Ok(None);
}
let lbn = u32::from_le_bytes(sector[404..408].try_into().unwrap());
Ok(Some(partition_start + lbn))
}
fn detect_fid_tag_size(data: &[u8]) -> usize {
let mut off = 0;
while off + 28 <= data.len() {
let ti = u16::from_le_bytes([data[off], data[off + 1]]);
if ti == TAG_FID {
let lbn16 = if off + 26 <= data.len() {
u32::from_le_bytes(data[off + 22..off + 26].try_into().unwrap())
} else {
u32::MAX
};
let lbn18 = if off + 28 <= data.len() {
u32::from_le_bytes(data[off + 24..off + 28].try_into().unwrap())
} else {
u32::MAX
};
if lbn16 < 0x10000 {
return 16;
}
if lbn18 < 0x10000 {
return 18;
}
return 16; }
off += 4;
}
16
}
fn parse_fids<R: Read + Seek>(
reader: &mut R,
block_size: u32,
partition_start: u32,
data: &[u8],
) -> Vec<UdfFileEntry> {
let tag_size = detect_fid_tag_size(data);
let min_fid = tag_size + 20;
let mut entries = Vec::new();
let mut off = 0;
while off + min_fid <= data.len() {
let tag_ident = u16::from_le_bytes([data[off], data[off + 1]]);
if tag_ident != TAG_FID {
off += 4;
continue;
}
let crc_len = u16::from_le_bytes([data[off + 10], data[off + 11]]) as usize;
let fid_advance = ((16 + crc_len + 3) & !3).max(min_fid);
if off + fid_advance > data.len() {
break;
}
let file_chars = data[off + tag_size];
let file_id_len = data[off + tag_size + 1] as usize;
let icb_lbn = if off + tag_size + 10 <= data.len() {
u32::from_le_bytes(
data[off + tag_size + 6..off + tag_size + 10]
.try_into()
.unwrap(),
)
} else {
off += fid_advance.max(4);
continue;
};
let impl_use_len = if off + tag_size + 20 <= data.len() {
u16::from_le_bytes([data[off + tag_size + 18], data[off + tag_size + 19]]) as usize
} else {
off += fid_advance.max(4);
continue;
};
if file_chars & FC_PARENT == 0 {
let is_dir = file_chars & FC_DIRECTORY != 0;
let fe_lba = partition_start + icb_lbn;
let id_start = off + tag_size + 20 + impl_use_len;
let id_end = (id_start + file_id_len).min(data.len());
let name = if id_end > id_start {
decode_osta_cs0(&data[id_start..id_end])
} else {
String::new()
};
let size = read_fe_info_len(reader, block_size, fe_lba).unwrap_or(0);
entries.push(UdfFileEntry {
name,
is_dir,
size,
fe_lba,
});
}
off += fid_advance.max(4);
}
entries
}
fn read_fe_info_len<R: Read + Seek>(reader: &mut R, block_size: u32, fe_lba: u32) -> Option<u64> {
let mut sector = [0u8; MAX_BLOCK_SIZE];
let sector = &mut sector[..block_size as usize];
seek_read(reader, fe_lba as u64 * block_size as u64, 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 None;
}
Some(u64::from_le_bytes(sector[56..64].try_into().unwrap()))
}
#[cfg(feature = "vfs")]
pub(crate) fn read_fe_file_type<R: Read + Seek>(
reader: &mut R,
block_size: u32,
fe_lba: u32,
) -> Option<u8> {
let mut sector = [0u8; MAX_BLOCK_SIZE];
let sector = &mut sector[..block_size as usize];
seek_read(reader, fe_lba as u64 * block_size as u64, 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 None;
}
sector.get(27).copied()
}
#[cfg(feature = "vfs")]
pub(crate) const FILE_TYPE_DIRECTORY: u8 = 4;
fn read_extents_short<R: Read + Seek>(
reader: &mut R,
block_size: u32,
partition_start: u32,
ad_area: &[u8],
total_len: u64,
) -> Option<Vec<u8>> {
let mut data = Vec::new();
let mut pos = 0;
while pos + 8 <= ad_area.len() && (data.len() as u64) < total_len {
let len_raw = u32::from_le_bytes(ad_area[pos..pos + 4].try_into().unwrap());
let ext_pos = u32::from_le_bytes(ad_area[pos + 4..pos + 8].try_into().unwrap());
let ext_type = len_raw >> 30;
let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
let phys = (partition_start as u64 + ext_pos as u64) * block_size as u64;
read_extent(reader, block_size, phys, ext_len, total_len, &mut data)?;
}
pos += 8;
}
data.truncate(total_len as usize);
Some(data)
}
fn read_extents_long<R: Read + Seek>(
reader: &mut R,
block_size: u32,
partition_start: u32,
ad_area: &[u8],
total_len: u64,
) -> Option<Vec<u8>> {
let mut data = Vec::new();
let mut pos = 0;
while pos + 16 <= ad_area.len() && (data.len() as u64) < total_len {
let len_raw = u32::from_le_bytes(ad_area[pos..pos + 4].try_into().unwrap());
let lbn = u32::from_le_bytes(ad_area[pos + 4..pos + 8].try_into().unwrap());
let ext_type = len_raw >> 30;
let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
let phys = (partition_start as u64 + lbn as u64) * block_size as u64;
read_extent(reader, block_size, phys, ext_len, total_len, &mut data)?;
}
pos += 16;
}
data.truncate(total_len as usize);
Some(data)
}
fn read_extent<R: Read + Seek>(
reader: &mut R,
block_size: u32,
byte_pos: u64,
ext_len: usize,
total_len: u64,
data: &mut Vec<u8>,
) -> Option<()> {
let bs = block_size as usize;
let sectors = ext_len.div_ceil(bs);
for i in 0..sectors {
let mut sector = [0u8; MAX_BLOCK_SIZE];
let sector = &mut sector[..bs];
seek_read(reader, byte_pos + i as u64 * block_size as u64, sector)?;
let already = data.len() as u64;
let remaining = total_len.saturating_sub(already) as usize;
let sector_bytes = (ext_len - i * bs).min(bs);
let take = sector_bytes.min(remaining);
data.extend_from_slice(§or[..take]);
}
Some(())
}
fn decode_osta_cs0(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
let comp_id = bytes[0];
let payload = &bytes[1..];
match comp_id {
8 => String::from_utf8_lossy(payload).into_owned(),
16 => {
let pairs: Vec<u16> = payload
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&pairs)
}
_ => String::from_utf8_lossy(payload).into_owned(),
}
}
fn seek_read<R: Read + Seek>(reader: &mut R, byte_pos: u64, buf: &mut [u8]) -> Option<()> {
seek_read_checked(reader, byte_pos, buf).ok()
}
fn seek_read_checked<R: Read + Seek>(
reader: &mut R,
byte_pos: u64,
buf: &mut [u8],
) -> Result<(), io::Error> {
reader.seek(SeekFrom::Start(byte_pos))?;
reader.read_exact(buf)?;
Ok(())
}
pub(crate) fn tag_checksum(tag: &[u8]) -> u8 {
let mut sum: u32 = 0;
for (i, &b) in tag.iter().take(16).enumerate() {
if i == 4 {
continue;
}
sum = sum.wrapping_add(u32::from(b));
}
(sum & 0xFF) as u8
}
pub(crate) fn ecma167_crc(body: &[u8]) -> u16 {
let mut crc: u16 = 0;
for &b in body {
crc ^= u16::from(b) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
crc
}
pub(crate) fn descriptor_label(tag_ident: u16) -> Option<&'static str> {
Some(match tag_ident {
TAG_AVDP => "AVDP",
TAG_PD => "PartitionDescriptor",
TAG_LVD => "LogicalVolumeDescriptor",
TAG_TERM => "TerminatingDescriptor",
TAG_FSD => "FileSetDescriptor",
TAG_FID => "FileIdentifierDescriptor",
TAG_FE | TAG_FE_ALT => "FileEntry",
TAG_EFE => "ExtendedFileEntry",
1 => "PrimaryVolumeDescriptor",
3 => "VolumeDescriptorPointer",
4 => "ImplementationUseVolumeDescriptor",
7 => "UnallocatedSpaceDescriptor",
9 => "LogicalVolumeIntegrityDescriptor",
258 => "AllocationExtentDescriptor",
259 => "IndirectEntry",
262 => "SpaceBitmapDescriptor",
263 => "PartitionIntegrityEntry",
264 => "ExtendedAttributeHeaderDescriptor",
265 => "UnallocatedSpaceEntry",
_ => return None,
})
}
pub(crate) fn decode_timestamp(b: &[u8]) -> Option<String> {
if b.len() < 12 {
return None;
}
let year = i16::from_le_bytes([b[2], b[3]]);
if !(1970..=2200).contains(&year) {
return None;
}
let (month, day, hour, minute, second) = (b[4], b[5], b[6], b[7], b[8]);
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
Some(format!(
"{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}"
))
}
pub(crate) fn fsd_recording_time<R: Read + Seek>(
reader: &mut R,
block_size: u32,
fsd_lba: u32,
) -> Result<Option<String>, io::Error> {
let mut buf = [0u8; MAX_BLOCK_SIZE];
let sector = &mut buf[..block_size as usize];
seek_read_checked(reader, u64::from(fsd_lba) * u64::from(block_size), sector)?;
if u16::from_le_bytes([sector[0], sector[1]]) != TAG_FSD {
return Ok(None);
}
Ok(decode_timestamp(§or[16..28]))
}
pub(crate) fn fe_modification_time(sector: &[u8], is_efe: bool) -> Option<String> {
let off = if is_efe { 92 } else { 84 };
decode_timestamp(sector.get(off..off + 12)?)
}
pub(crate) fn fe_slack_nonzero<R: Read + Seek>(
reader: &mut R,
block_size: u32,
partition_start: u32,
fe_lba: u32,
) -> Option<(u32, u32)> {
let info_len = read_fe_info_len(reader, block_size, fe_lba)?;
let bs = u64::from(block_size);
let slack = (bs - info_len % bs) % bs;
if info_len == 0 || slack == 0 {
return None;
}
let last_block_pos = fe_last_block_pos(reader, block_size, partition_start, fe_lba)?;
let mut buf = [0u8; MAX_BLOCK_SIZE];
let block = &mut buf[..block_size as usize];
seek_read_checked(reader, last_block_pos, block).ok()?;
let slack_start = (info_len % bs) as usize;
let nonzero = block[slack_start..].iter().filter(|&&b| b != 0).count() as u32;
Some((nonzero, slack as u32))
}
fn fe_last_block_pos<R: Read + Seek>(
reader: &mut R,
block_size: u32,
partition_start: u32,
fe_lba: u32,
) -> Option<u64> {
let mut buf = [0u8; MAX_BLOCK_SIZE];
let sector = &mut buf[..block_size as usize];
seek_read_checked(reader, u64::from(fe_lba) * u64::from(block_size), sector).ok()?;
let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
let is_efe = tag_ident == TAG_EFE;
if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && !is_efe {
return None;
}
let icb_flags = u16::from_le_bytes([sector[34], sector[35]]);
let alloc_type = icb_flags & 0x0007;
let (ea_off, ad_off, header) = if is_efe {
(208usize, 212usize, 216usize)
} else {
(168usize, 172usize, 176usize)
};
if ad_off + 4 > sector.len() {
return None; }
let ea_len = u32::from_le_bytes(sector[ea_off..ea_off + 4].try_into().ok()?) as usize;
let ad_len = u32::from_le_bytes(sector[ad_off..ad_off + 4].try_into().ok()?) as usize;
let ad_start = header + ea_len;
let ad_end = ad_start.checked_add(ad_len)?;
if ad_end > sector.len() {
return None;
}
let ad_area = §or[ad_start..ad_end];
let stride = match alloc_type {
ALLOC_SHORT => 8,
ALLOC_LONG => 16,
_ => return None,
};
let mut last: Option<u64> = None;
let mut pos = 0;
while pos + stride <= ad_area.len() {
let len_raw = read_le_u32(ad_area, pos);
let ext_type = len_raw >> 30;
let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
let lbn = read_le_u32(ad_area, pos + 4);
let blocks_in_ext = ext_len.div_ceil(block_size as usize) as u64;
let last_lbn = u64::from(partition_start) + u64::from(lbn) + (blocks_in_ext - 1);
last = Some(last_lbn * u64::from(block_size));
}
pos += stride;
}
last
}
fn read_le_u32(data: &[u8], off: usize) -> u32 {
let mut b = [0u8; 4];
if let Some(s) = data.get(off..off + 4) {
b.copy_from_slice(s);
}
u32::from_le_bytes(b)
}
#[cfg(test)]
mod real_media_tests {
use super::{parse_udf_state, UdfPartitionKind};
use std::fs::File;
fn state(name: &str) -> Option<super::UdfState> {
let path = format!("{}/tests/data/{}", env!("CARGO_MANIFEST_DIR"), name);
let mut f = File::open(&path).ok()?;
parse_udf_state(&mut f)
}
#[test]
fn vat_image_classified_virtual() {
let Some(st) = state("udf_vat.img") else {
eprintln!("skip: udf_vat.img");
return;
};
assert_eq!(
st.partition_kind,
UdfPartitionKind::Virtual,
"mkudffs cdr/1.50 image must classify as Virtual (VAT)"
);
}
#[test]
fn sparable_image_classified_sparable() {
let Some(st) = state("udf_spar.img") else {
eprintln!("skip: udf_spar.img");
return;
};
assert_eq!(
st.partition_kind,
UdfPartitionKind::Sparable,
"mkudffs dvdrw/2.01 image must classify as Sparable"
);
}
#[test]
fn vat_image_matches_udfinfo_oracle() {
let Some(st) = state("udf_vat.img") else {
eprintln!("skip: udf_vat.img");
return;
};
assert_eq!(st.partition_kind, UdfPartitionKind::Virtual);
assert_eq!(
st.partition_start, 257,
"partition start must match udfinfo PSPACE start=257"
);
assert_eq!(
st.partition_map_count, 2,
"cdr/1.50 carries a physical map plus the VAT Type-2 map"
);
}
#[test]
fn sparable_image_matches_udfinfo_oracle() {
let Some(st) = state("udf_spar.img") else {
eprintln!("skip: udf_spar.img");
return;
};
assert_eq!(st.partition_kind, UdfPartitionKind::Sparable);
assert_eq!(
st.partition_start, 1296,
"partition start must match udfinfo PSPACE start=1296"
);
assert_eq!(
st.partition_map_count, 1,
"dvdrw/2.01 carries a single Sparable Type-2 map"
);
}
#[test]
fn plain_512_block_image_parses_via_detected_block_size() {
let path = format!("{}/tests/data/udf_plain.img", env!("CARGO_MANIFEST_DIR"));
let mut f = File::open(&path).expect("udf_plain.img fixture must be present");
let st = super::parse_udf_state(&mut f)
.expect("512-byte-block UDF must parse once the block size is detected from the AVDP");
assert_eq!(st.block_size, 512, "udfinfo reports blocksize=512");
assert_eq!(
st.partition_kind,
UdfPartitionKind::Physical,
"mkudffs hd image is a Type-1 physical partition"
);
assert_eq!(
st.partition_start, 257,
"partition start must match udfinfo PSPACE start=257"
);
assert_eq!(
st.partition_map_count, 1,
"hd/2.01 carries a single physical map"
);
}
}
#[cfg(test)]
mod checked_bootstrap_tests {
use super::parse_udf_state_checked;
use std::io::{self, Cursor, Read, Seek, SeekFrom};
struct FaultyReader;
impl Read for FaultyReader {
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
Err(io::Error::other("device read fault"))
}
}
impl Seek for FaultyReader {
fn seek(&mut self, _pos: SeekFrom) -> io::Result<u64> {
Ok(0)
}
}
#[test]
fn io_error_at_anchor_surfaces_as_err() {
let mut r = FaultyReader;
let res = parse_udf_state_checked(&mut r);
assert!(
res.is_err(),
"a device read fault reading the anchor must surface as Err, not Ok(None)"
);
}
#[test]
fn truncated_before_anchor_surfaces_as_err() {
let buf = vec![0u8; 4096];
let mut r = Cursor::new(buf);
let res = parse_udf_state_checked(&mut r);
assert!(
res.is_err(),
"truncation before the AVDP anchor must surface as Err (UnexpectedEof)"
);
let err = res.err().unwrap();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn full_size_but_wrong_anchor_is_ok_none() {
let buf = vec![0u8; 257 * 2048];
let mut r = Cursor::new(buf);
let res = parse_udf_state_checked(&mut r);
assert!(
matches!(res, Ok(None)),
"a readable image with a non-AVDP anchor must be Ok(None), got {res:?}"
);
}
}
#[cfg(test)]
mod findings_support_tests {
use super::*;
use std::io::Cursor;
#[test]
fn crc_ccitt_matches_known_vectors() {
assert_eq!(ecma167_crc(b"123456789"), 0x31C3);
assert_eq!(ecma167_crc(&[]), 0x0000);
}
#[test]
fn tag_checksum_skips_byte_4() {
let mut tag = [0u8; 16];
tag[0] = 2; tag[4] = 0xFF; tag[6] = 3; assert_eq!(tag_checksum(&tag), 5);
}
#[test]
fn descriptor_label_known_and_unknown() {
for (id, name) in [
(TAG_FSD, "FileSetDescriptor"),
(TAG_EFE, "ExtendedFileEntry"),
(TAG_FID, "FileIdentifierDescriptor"),
(TAG_FE, "FileEntry"),
(TAG_FE_ALT, "FileEntry"),
(1, "PrimaryVolumeDescriptor"),
(3, "VolumeDescriptorPointer"),
(4, "ImplementationUseVolumeDescriptor"),
(7, "UnallocatedSpaceDescriptor"),
(9, "LogicalVolumeIntegrityDescriptor"),
(258, "AllocationExtentDescriptor"),
(259, "IndirectEntry"),
(262, "SpaceBitmapDescriptor"),
(263, "PartitionIntegrityEntry"),
(264, "ExtendedAttributeHeaderDescriptor"),
(265, "UnallocatedSpaceEntry"),
] {
assert_eq!(descriptor_label(id), Some(name), "tag {id}");
}
assert_eq!(descriptor_label(0xFFFF), None);
}
#[test]
fn fsd_recording_time_none_for_non_fsd() {
let img = vec![0u8; 512];
let mut r = Cursor::new(img);
assert_eq!(fsd_recording_time(&mut r, 512, 0).unwrap(), None);
}
#[test]
fn last_block_pos_none_for_non_file_entry() {
let img = vec![0u8; 512];
let mut r = Cursor::new(img);
assert_eq!(fe_last_block_pos(&mut r, 512, 0, 0), None);
}
#[test]
fn slack_via_long_allocation_descriptor() {
let bs = 512usize;
let mut img = vec![0u8; bs * 8];
let fe = 4 * bs;
img[fe..fe + 2].copy_from_slice(&TAG_FE.to_le_bytes());
img[fe + 34..fe + 36].copy_from_slice(&1u16.to_le_bytes()); img[fe + 56..fe + 64].copy_from_slice(&100u64.to_le_bytes());
img[fe + 168..fe + 172].copy_from_slice(&0u32.to_le_bytes()); img[fe + 172..fe + 176].copy_from_slice(&16u32.to_le_bytes()); img[fe + 176..fe + 180].copy_from_slice(&100u32.to_le_bytes()); img[fe + 180..fe + 184].copy_from_slice(&5u32.to_le_bytes()); let data = 5 * bs + 100;
img[data] = 0x7F;
let mut r = Cursor::new(img);
let (nonzero, slack) = fe_slack_nonzero(&mut r, 512, 0, 4).expect("slack present");
assert_eq!(slack, 412);
assert_eq!(nonzero, 1);
}
#[test]
fn timestamp_decodes_and_rejects_implausible() {
let mut t = [0u8; 12];
t[2..4].copy_from_slice(&2026i16.to_le_bytes());
t[4] = 6; t[5] = 21; t[6] = 8; t[7] = 46; t[8] = 57; assert_eq!(decode_timestamp(&t).as_deref(), Some("2026-06-21 08:46:57"));
let mut bad = t;
bad[2..4].copy_from_slice(&0i16.to_le_bytes());
assert_eq!(decode_timestamp(&bad), None);
let mut badmon = t;
badmon[4] = 0;
assert_eq!(decode_timestamp(&badmon), None);
assert_eq!(decode_timestamp(&[0u8; 4]), None);
}
#[test]
fn fe_modification_time_offset_differs_for_efe() {
let mut fe = vec![0u8; 512];
let stamp = |buf: &mut [u8], off: usize, year: i16| {
buf[off + 2..off + 4].copy_from_slice(&year.to_le_bytes());
buf[off + 4] = 1; buf[off + 5] = 1; };
stamp(&mut fe, 84, 2030);
assert_eq!(
fe_modification_time(&fe, false).as_deref(),
Some("2030-01-01 00:00:00")
);
let mut efe = vec![0u8; 512];
stamp(&mut efe, 92, 2031);
assert_eq!(
fe_modification_time(&efe, true).as_deref(),
Some("2031-01-01 00:00:00")
);
}
fn image_with_slack(info_len: u64, slack_fill: &[u8]) -> (Vec<u8>, u32, u32) {
let bs = 512usize;
let part_start = 0u32;
let fe_lba = 4u32;
let data_lbn = 5u32; let mut img = vec![0u8; bs * 8];
let fe = fe_lba as usize * bs;
img[fe..fe + 2].copy_from_slice(&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()); let ad = fe + 176;
img[ad..ad + 4].copy_from_slice(&(info_len as u32).to_le_bytes());
img[ad + 4..ad + 8].copy_from_slice(&data_lbn.to_le_bytes());
let data = (part_start + data_lbn) as usize * bs;
let slack_start = data + (info_len as usize % bs);
for (i, &b) in slack_fill.iter().enumerate() {
img[slack_start + i] = b;
}
(img, part_start, fe_lba)
}
#[test]
fn slack_counts_nonzero_tail_bytes() {
let (img, ps, fe) = image_with_slack(100, &[0xAA, 0x00, 0xBB, 0xCC]);
let mut r = Cursor::new(img);
let (nonzero, slack) = fe_slack_nonzero(&mut r, 512, ps, fe).expect("slack present");
assert_eq!(slack, 412);
assert_eq!(nonzero, 3);
}
#[test]
fn slack_none_when_block_aligned() {
let (img, ps, fe) = image_with_slack(512, &[0xFF]);
let mut r = Cursor::new(img);
assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
}
#[test]
fn slack_none_when_zero_length() {
let (img, ps, fe) = image_with_slack(0, &[]);
let mut r = Cursor::new(img);
assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
}
#[test]
fn slack_none_for_inline_data() {
let (mut img, ps, fe) = image_with_slack(100, &[0xAA]);
let fe_off = fe as usize * 512;
img[fe_off + 34..fe_off + 36].copy_from_slice(&3u16.to_le_bytes());
let mut r = Cursor::new(img);
assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
}
}