Files
libfreemkv/src/udf_tests.rs
T
Matthew Jackson 15f7829468 scan, keys: read less on a live drive; FMTS discs on the trusted key path
- Key evidence reads the filesystem in batches with the metadata partition
  prefetched, not one command per sector (~1 s on a USB UHD).
- The live Blu-ray scan prefetches only the files it parses (~22 MB and
  three long seeks less on a UHD). The MKB is read from a 4 MiB prefix with
  its record headers walked; its bytes are unchanged.
- macOS: no fixed 500 ms sleep after unmounting before claiming the drive.
- FMTS discs use the trusted CPS path; unit-key samples skip forensic
  segment units. The parity_image_fmts_alternate_phase golden changes only
  in request count, on a fixture title under two keys (not a real disc).
2026-10-07 08:47:00 -07:00

4167 lines
151 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use std::collections::HashMap;
/// In-memory SectorSource backed by an explicit absolute-LBA → sector map.
/// Unmapped sectors read as zeroes.
struct MapReader {
sectors: HashMap<u32, [u8; 2048]>,
}
impl MapReader {
fn new() -> Self {
Self {
sectors: HashMap::new(),
}
}
fn put(&mut self, lba: u32, data: [u8; 2048]) {
self.sectors.insert(lba, data);
}
}
impl SectorSource for MapReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let need = count as usize * 2048;
if buf.len() < need {
return Err(Error::UdfBufferTooSmall);
}
for i in 0..count as u32 {
let off = i as usize * 2048;
// `wrapping_add`: the near-`u32::MAX` overflow regressions below
// hand this harness LBAs at the top of the space on purpose, and
// the harness's own bookkeeping must not be what panics.
let s = self
.sectors
.get(&lba.wrapping_add(i))
.copied()
.unwrap_or([0u8; 2048]);
buf[off..off + 2048].copy_from_slice(&s);
}
Ok(need)
}
}
/// Build an Extended File Entry (tag 266) ICB sector with the given
/// info_length and a list of (extent_type, data_len, data_lba) short ADs.
fn build_efe(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag
s[56..64].copy_from_slice(&info_length.to_le_bytes()); // info_length
let l_ea: u32 = 0;
let l_ad: u32 = (ads.len() * 8) as u32;
s[208..212].copy_from_slice(&l_ea.to_le_bytes());
s[212..216].copy_from_slice(&l_ad.to_le_bytes());
let mut off = 216 + l_ea as usize;
for &(etype, dlen, dlba) in ads {
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes());
off += 8;
}
s
}
// Build a tag-266 ICB with LONG ADs (16 bytes each); sets ICB Tag flags
// low bits to 1 so the parser selects the 16-byte stride, matching the
// layout large BD-ROM .m2ts streams actually use.
fn build_efe_long(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag
// ICB Tag flags at abs offset 34: AD type 1 = Long AD.
s[34..36].copy_from_slice(&1u16.to_le_bytes());
s[56..64].copy_from_slice(&info_length.to_le_bytes());
let l_ea: u32 = 0;
let l_ad: u32 = (ads.len() * 16) as u32;
s[208..212].copy_from_slice(&l_ea.to_le_bytes());
s[212..216].copy_from_slice(&l_ad.to_le_bytes());
let mut off = 216 + l_ea as usize;
for &(etype, dlen, dlba) in ads {
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes());
// off+8..16 = partition ref + impl_use, left zero: this is what trips
// the old 8-byte-stride parser into reading a bogus zero-length terminator.
off += 16;
}
s
}
/// A continuation block: a bare list of short ADs from byte 0.
fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
// Allocation Extent Descriptor (ECMA-167 4/14.5): 16-byte tag, then
// prev_allocation_extent_location (@16) and length_of_allocation_descriptors
// (@20); ADs begin at offset 24, which the parser skips past.
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&258u16.to_le_bytes());
let l_ad = (ads.len() * 8) as u32;
s[20..24].copy_from_slice(&l_ad.to_le_bytes());
let mut off = 24usize;
for &(etype, dlen, dlba) in ads {
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes());
off += 8;
}
s
}
// Build a tag-261/266 entry with short ADs and every disc-controlled field of the
// descriptor area exposed (l_ea/l_ad/extra).
fn build_entry_ads(
tag: u16,
l_ea: usize,
l_ad: u32,
ads: &[(u32, u32, u32)],
extra: &[(u32, u32, u32)],
) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&tag.to_le_bytes());
let (l_ea_off, base) = if tag == 266 {
(208usize, 216usize)
} else {
(168usize, 176usize)
};
s[l_ea_off..l_ea_off + 4].copy_from_slice(&(l_ea as u32).to_le_bytes());
s[l_ea_off + 4..l_ea_off + 8].copy_from_slice(&l_ad.to_le_bytes());
s[base..base + l_ea].fill(0xA5);
let mut off = base + l_ea;
for &(etype, dlen, dlba) in ads.iter().chain(extra) {
if off + 8 > s.len() {
break;
}
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes());
off += 8;
}
s
}
// file_start_lba must skip a leading UNRECORDED extent and report where the file's DATA
// starts, not where the hole's space lives.
#[test]
fn file_start_lba_skips_a_leading_unrecorded_extent() {
use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, lay_dir};
const HOLE_LBA: u32 = 900;
const DATA_LBA: u32 = 40;
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(
&mut disc,
&DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![fixture::file("VIDEO_TS.IFO", 12, DATA_LBA, 2048, false)],
subdirs: Vec::new(),
},
);
// Rewrite the file's ICB: a type-1 hole FIRST, then the real data.
let icb = build_entry_ads(266, 0, 16, &[(1, 2048, HOLE_LBA), (0, 2048, DATA_LBA)], &[]);
disc.put_bytes(PART_START + 12, &icb);
let fs = super::read_filesystem(&mut disc).expect("volume mounts");
let lba = fs
.file_start_lba(&mut disc, "/VIDEO_TS.IFO")
.expect("a file whose first descriptor is a hole still has data");
assert_eq!(
lba,
PART_START + DATA_LBA,
"must report the first RECORDED extent; reporting the hole's LBA \\
({}) would rebase every VTS VOB extent onto unrelated sectors",
PART_START + HOLE_LBA
);
}
#[test]
fn icb_extents_reads_both_entry_types_at_their_own_descriptor_offsets() {
// ECMA-167 4/14.9 File Entry: L_EA @168, ADs @176+L_EA. 4/14.17 Extended
// File Entry: @208, @216+L_EA (261 is UDF 1.02 DVD-Video). Offset must be
// computed per-tag, not assumed, or real extended attributes get misread.
let want = vec![(4096, 8192), (16384, 2048)];
for tag in [261u16, 266u16] {
for l_ea in [0usize, 88] {
let icb = build_entry_ads(tag, l_ea, 16, &[(0, 8192, 4096), (0, 2048, 16384)], &[]);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("F", 5, 10240));
let got = tuples(
&fs.read_icb_extents(&mut reader, 5)
.unwrap_or_else(|e| panic!("tag {tag}, L_EA {l_ea}: {e:?}")),
);
assert_eq!(got, want, "tag {tag}, L_EA {l_ea}");
}
}
}
#[test]
fn icb_extents_reads_a_descriptor_list_flush_with_the_end_of_the_entry() {
// A final short_ad may exactly fill the rest of the 2048-byte block; it's
// still wholly inside the entry and must be read, or the file's only
// extent is dropped and it reports no data instead of an error.
for tag in [261u16, 266u16] {
let base = if tag == 266 { 216 } else { 176 };
let icb = build_entry_ads(tag, 2048 - base - 8, 8, &[(0, 2048, 4096)], &[]);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("F", 5, 2048));
let got = tuples(
&fs.read_icb_extents(&mut reader, 5)
.unwrap_or_else(|e| panic!("tag {tag}: a flush descriptor is in bounds: {e:?}")),
);
assert_eq!(got, vec![(4096, 2048)], "tag {tag}");
}
}
#[test]
fn icb_extents_refuses_a_descriptor_area_that_runs_past_the_entry() {
// L_AD is a disc-controlled Uint32. One that puts the end of the
// descriptor area past the end of the 2048-byte entry is malformed,
// and reading it walks off the sector buffer.
for tag in [261u16, 266u16] {
let icb = build_entry_ads(tag, 0, 4000, &[(0, 2048, 4096)], &[]);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("F", 5, 2048));
let err = fs
.read_icb_extents(&mut reader, 5)
.expect_err("a descriptor area larger than the entry cannot be read");
assert!(matches!(err, Error::DiscRead { .. }), "tag {tag}: {err:?}");
}
}
#[test]
fn icb_extents_stops_at_the_declared_descriptor_area_length() {
// L_AD bounds the allocation descriptors; what follows is padding or
// nothing, never descriptors. Reading past it invents extents from
// whatever bytes are there — silent corruption appended to the file.
let icb = build_entry_ads(266, 0, 8, &[(0, 2048, 4096)], &[(0, 2048, 999_999)]);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("F", 5, 2048));
let got = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
assert_eq!(
got,
vec![(4096, 2048)],
"bytes past the declared L_AD are not allocation descriptors"
);
}
#[test]
fn icb_extents_does_not_follow_a_zero_length_continuation_pointer() {
// ECMA-167 4/14.14.1.1: a zero-length descriptor (incl. type-3 "next
// extent" pointer) designates no extent. Following it anyway reads a
// bogus sector and parses it as an AED, appending extents the file never had.
let icb = build_efe(2048, &[(0, 2048, 10), (3, 0, 50)]);
let mut reader = MapReader::new();
reader.put(105, icb); // meta_start 100 + meta_lba 5
reader.put(150, build_cont_block(&[(0, 4096, 700), (0, 4096, 800)]));
let fs = fs_with(1000, 100, file_entry("F", 5, 2048));
let got = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
assert_eq!(
got,
vec![(10, 2048)],
"a zero-length continuation pointer points at nothing"
);
}
fn fs_with(part_start: u32, meta_start: u32, root: DirEntry) -> UdfFs {
UdfFs {
root,
volume_id: String::new(),
partition_start: part_start,
meta: MetaMap::contiguous(meta_start),
metadata_sectors: 0,
}
}
/// `(lba, len)` of each extent, in file order, for the tests that care
/// about placement rather than about the recorded/unrecorded distinction.
fn tuples(extents: &[IcbExtent]) -> Vec<(u32, u32)> {
extents.iter().map(|e| (e.lba, e.len)).collect()
}
fn file_entry(name: &str, meta_lba: u32, size: u64) -> DirEntry {
DirEntry {
name: name.to_string(),
is_dir: false,
meta_lba,
size,
entries: Vec::new(),
}
}
#[test]
fn icb_extents_follow_type3_continuation() {
let part_start = 1000;
let meta_start = 100;
// ICB at meta_lba 5: one real extent + a type-3 continuation pointer.
let icb = build_efe(
6144,
&[
(0, 4096, 10), // recorded extent at part-rel lba 10
(3, 2048, 50), // continuation block at meta-rel lba 50
],
);
// Continuation block holds the tail extent.
let cont = build_cont_block(&[(0, 2048, 20)]);
let mut reader = MapReader::new();
reader.put(meta_start + 5, icb);
reader.put(meta_start + 50, cont);
let fs = fs_with(part_start, meta_start, file_entry("X", 5, 6144));
let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
}
/// per spec; do not change without a spec citation — KS-20, KS-21 ECMA-167 4/14.14.1.1:
/// "The 30 least significant bits … the length"; "1 Extent not recorded but allocated".
#[test]
fn udf_extent_type_bits() {
use crate::spec::keys::{KS_20_UDF_EXTENT_LENGTH, KS_21_UDF_EXTENT_TYPE};
assert!(
KS_20_UDF_EXTENT_LENGTH
.text
.starts_with("The 30 least significant bits")
);
assert!(
KS_20_UDF_EXTENT_LENGTH
.text
.contains("2 most significant bits")
);
let t = KS_21_UDF_EXTENT_TYPE.text;
assert!(t.starts_with("0 Extent recorded and allocated / 1 Extent not recorded"));
assert!(t.ends_with("3 The extent is the next extent of allocation descriptors"));
let big = 0x3FFF_F800; // type 0 with the top length bits set: length, not type
let icb = build_efe(
big as u64 + 2048 + 4096,
&[(0, big, 10), (1, 2048, 20), (3, 2048, 50)],
);
let mut reader = MapReader::new();
reader.put(105, icb);
reader.put(150, build_cont_block(&[(0, 4096, 30)]));
let fs = fs_with(0, 100, file_entry("T", 5, big as u64 + 2048 + 4096));
let got = fs.read_icb_extents(&mut reader, 5).expect("extents");
let want = [(10, big, true), (20, 2048, false), (30, 4096, true)];
let want: Vec<IcbExtent> = want
.iter()
.map(|&(lba, len, recorded)| IcbExtent { lba, len, recorded })
.collect();
assert_eq!(
got, want,
"type 0 read, type 1 kept unrecorded, type 3 chained"
);
}
#[test]
fn icb_extents_continuation_skips_aed_header_not_read_as_extent() {
// Regression (Blu-ray 3D): a continuation block opens with a 24-byte AED whose tag holds
// non-zero bytes (id 258, CRC); reading ADs from offset 0 parses garbage and breaks,
// losing fragments past the first continuation (25.8 GB -> ~1.8 GB). Read ADs from +24.
let icb = build_efe(6144, &[(0, 4096, 10), (3, 2048, 50)]);
let mut cont = build_cont_block(&[(0, 2048, 20)]);
// Stamp a realistic AED descriptor tag (id 258) into the header so a
// regression that reads from offset 0 mis-parses it as a bogus extent
// instead of finding the real (20, 2048) at offset 24.
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont);
let fs = fs_with(0, 0, file_entry("3D", 5, 6144));
let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
assert_eq!(
extents,
vec![(10, 4096), (20, 2048)],
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
);
}
#[test]
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
// Regression: BD-ROM large .m2ts use 16-byte Long ADs. The pre-fix parser hardcoded an
// 8-byte stride, misreading descriptor #1 from mid-Long-AD as a zero terminator, so
// multi-extent titles truncated at ~1 GiB. Four near-max Long ADs; all four must return.
let icb = build_efe_long(
4 * 1_000_000_000,
&[
(0, 0x3FFF_F800, 100), // ~1 GiB extent
(0, 0x3FFF_F800, 600_000), // next extent
(0, 0x3FFF_F800, 1_100_000),
(0, 0x1000_0000, 1_600_000), // shorter tail extent
],
);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("BIG", 5, 4 * 1_000_000_000));
let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
assert_eq!(
extents,
vec![
(100, 0x3FFF_F800),
(600_000, 0x3FFF_F800),
(1_100_000, 0x3FFF_F800),
(1_600_000, 0x1000_0000),
],
"Long-AD file must return ALL extents, not just the first"
);
}
#[test]
fn read_file_spans_multiple_extents() {
let part_start = 0;
let meta_start = 0;
// Two extents of one sector each; distinct fill bytes per data sector.
let icb = build_efe(4096, &[(0, 2048, 10), (0, 2048, 30)]);
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(10, [0xAA; 2048]);
reader.put(30, [0xBB; 2048]);
let root = DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![file_entry("F", 5, 4096)],
};
let fs = fs_with(part_start, meta_start, root);
let data = fs.read_file(&mut reader, "/F").expect("read");
assert_eq!(data.len(), 4096);
assert!(data[..2048].iter().all(|&b| b == 0xAA));
assert!(data[2048..].iter().all(|&b| b == 0xBB));
}
#[test]
fn read_file_long_ad_returns_full_content_not_truncated() {
// Regression for 0.31.0: `read_file` reads `/AACS/MKB_RO.inf` + `Unit_Key_RO.inf`. The
// pre-fix Short-AD-only parser truncated Long-AD multi-extent files after extent #0, so
// autorip's `key_files()` saw a garbage AACS file and skipped the key request.
let icb = build_efe_long(6144, &[(0, 2048, 10), (0, 2048, 30), (0, 2048, 50)]);
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(10, [0xAA; 2048]);
reader.put(30, [0xBB; 2048]);
reader.put(50, [0xCC; 2048]);
let root = DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![file_entry("MKB", 5, 6144)],
};
let fs = fs_with(0, 0, root);
let data = fs.read_file(&mut reader, "/MKB").expect("read");
assert_eq!(
data.len(),
6144,
"Long-AD file must not truncate at extent #0"
);
assert!(data[..2048].iter().all(|&b| b == 0xAA));
assert!(data[2048..4096].iter().all(|&b| b == 0xBB));
assert!(data[4096..].iter().all(|&b| b == 0xCC));
}
#[test]
fn read_aacs_inputs_reads_long_ad_files_in_full() {
// autorip reads /AACS/Unit_Key_RO.inf + MKB_RO.inf via Disc::read_aacs_inputs. On Long-AD
// discs (UHD/large Blu-ray) the pre-0.31.1 Short-AD-only reader truncated these at the
// first extent, breaking key derivation; assert the full multi-extent content returns.
let aacs = DirEntry {
name: "AACS".to_string(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![
file_entry("Unit_Key_RO.inf", 5, 4096), // Long-AD, 2 extents
file_entry("MKB_RO.inf", 7, 2048),
],
};
let root = DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![aacs],
};
let mut reader = MapReader::new();
// Unit_Key_RO.inf: Long-AD ICB with two recorded extents.
reader.put(5, build_efe_long(4096, &[(0, 2048, 10), (0, 2048, 30)]));
reader.put(10, [0xAA; 2048]);
reader.put(30, [0xBB; 2048]);
// MKB_RO.inf: single Long-AD extent (content is opaque to this test).
reader.put(7, build_efe_long(2048, &[(0, 2048, 50)]));
reader.put(50, [0xCC; 2048]);
let fs = fs_with(0, 0, root);
let (inf, _mkb, _version) = crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must succeed for a Long-AD disc");
assert_eq!(
inf.len(),
4096,
"Unit_Key_RO.inf (Long-AD, multi-extent) must read in full — the \
pre-0.31.1 Short-AD parser truncated it to the first 2048-byte extent"
);
assert!(inf[..2048].iter().all(|&b| b == 0xAA));
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
}
#[test]
fn read_aacs_inputs_falls_through_to_hddvd_any_dir() {
// HD DVD keeps AACS material under /ANY!/ (VTKF000.AACS + MKBROM.AACS),
// not /AACS/Unit_Key_RO.inf + MKB_RO.inf, so candidate lookup must fall
// through to /ANY!/ with no disc-type branch; the server classifies by magic.
let any = DirEntry {
name: "ANY!".to_string(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![
file_entry("VTKF000.AACS", 5, 2048),
file_entry("MKBROM.AACS", 7, 2048),
],
};
let root = DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![any], // deliberately NO /AACS/ dir
};
let mut reader = MapReader::new();
// VTKF000.AACS: one extent whose content opens with the HD DVD magic.
let mut vtkf = [0u8; 2048];
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
reader.put(5, build_efe_long(2048, &[(0, 2048, 10)]));
reader.put(10, vtkf);
// MKBROM.AACS: one extent with a type-0x10 AACS-1.0 (HD DVD) version record.
let mut mkb = [0u8; 2048];
mkb[..12].copy_from_slice(&[
0x10, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x10, 0x03, 0x00, 0x00, 0x00, 0x03,
]);
reader.put(7, build_efe_long(2048, &[(0, 2048, 50)]));
reader.put(50, mkb);
let fs = fs_with(0, 0, root);
let (inf, _mkb, _version) = crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must source the HD DVD /ANY!/ files");
assert_eq!(
&inf[..12],
b"DVD_HD_V_TKF",
"inf must be the HD DVD VTKF (its magic), sourced from /ANY!/ via the \
candidate fall-through — not /AACS/Unit_Key_RO.inf"
);
}
// merge_ranges takes HALF-OPEN (start, count) ranges: two touch exactly
// when the next start equals the previous exclusive end. Merging across a
// real gap wrongly folds a nav/UDF/padding sector into content.
#[test]
fn merge_ranges_keeps_a_genuine_one_sector_gap() {
// [0,5) and [6,9): sector 5 is in neither. The ranges are NOT adjacent.
assert_eq!(
merge_ranges(&[(0, 5), (6, 3)]),
vec![(0, 5), (6, 3)],
"sector 5 is in neither input range, so the two must stay disjoint"
);
// Exactly touching ([0,5) then [5,3)) still merges — that is adjacency.
assert_eq!(
merge_ranges(&[(0, 5), (5, 3)]),
vec![(0, 8)],
"start == exclusive end is a true touch and must still merge"
);
}
#[test]
fn merge_ranges_saturates_near_u32_max() {
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
let ranges = [(u32::MAX - 1, 2), (u32::MAX, 5)];
let merged = merge_ranges(&ranges);
// No panic; result is a single merged range starting at the first.
assert_eq!(merged.len(), 1);
assert_eq!(merged[0], (u32::MAX - 1, 1));
}
#[test]
fn buffered_reader_short_buf_errors_not_panics() {
let mut inner = MapReader::new();
inner.put(0, [0u8; 2048]);
let mut br = BufferedSectorReader::new(&mut inner, 8);
let mut tiny = [0u8; 100];
let err = br.read_sectors(0, 1, &mut tiny, true);
assert!(matches!(err, Err(Error::UdfBufferTooSmall)));
}
/// Minimal in-memory SectorSource that serves pre-loaded 2048-byte
/// sectors by LBA. Unmapped LBAs read as zeros.
struct MemReader {
sectors: HashMap<u32, [u8; 2048]>,
}
impl MemReader {
fn new() -> Self {
Self {
sectors: HashMap::new(),
}
}
fn put(&mut self, lba: u32, sector: [u8; 2048]) {
self.sectors.insert(lba, sector);
}
}
impl SectorSource for MemReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
for i in 0..count as u32 {
let off = i as usize * 2048;
let dst = &mut buf[off..off + 2048];
match self.sectors.get(&(lba + i)) {
Some(s) => dst.copy_from_slice(s),
None => dst.fill(0),
}
}
Ok(count as usize * 2048)
}
}
/// Build an Extended File Entry (tag 266) ICB sector with a single
/// short allocation descriptor declaring `data_len` bytes at `data_lba`.
/// `info_length` (offset 56) is set to `info_len`.
fn build_efe_icb(info_len: u64, data_len: u32, data_lba: u32) -> [u8; 2048] {
let mut icb = [0u8; 2048];
// tag identifier 266 (Extended File Entry)
icb[0..2].copy_from_slice(&266u16.to_le_bytes());
// info_length at offset 56
icb[56..64].copy_from_slice(&info_len.to_le_bytes());
// l_ea = 0 at offset 208, l_ad = 8 (one short AD) at offset 212
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
icb[212..216].copy_from_slice(&8u32.to_le_bytes());
// ad_offset = 216 + l_ea = 216. Short AD: len(4) | lba(4).
// extent_type 0 (recorded) is the top 2 bits = 0, so raw == len.
icb[216..220].copy_from_slice(&(data_len & 0x3FFF_FFFF).to_le_bytes());
icb[220..224].copy_from_slice(&data_lba.to_le_bytes());
icb
}
/// Build a UdfFs with a single file entry under root, for read_file tests.
fn fs_with_file(meta_lba: u32, size: u64) -> UdfFs {
UdfFs {
root: DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![DirEntry {
name: "F".to_string(),
is_dir: false,
meta_lba,
size,
entries: Vec::new(),
}],
},
volume_id: String::new(),
partition_start: 0,
meta: MetaMap::contiguous(0),
metadata_sectors: 0,
}
}
#[test]
fn read_file_rejects_oversized_file_before_allocating() {
// A file (entry.size and its extent) just over the 64 MiB cap must
// error, not allocate.
let oversized = MAX_FILE_BYTES as u32 + 2048;
let icb = build_efe_icb(oversized as u64, oversized, 100);
let mut reader = MemReader::new();
reader.put(10, icb); // ICB at meta_lba 10 (metadata_start 0)
let fs = fs_with_file(10, oversized as u64);
let err = fs.read_file(&mut reader, "/F").unwrap_err();
assert!(matches!(err, Error::DiscRead { .. }));
}
// Build a tag-266 ICB with multiple inline short ADs, each (data_len,
// data_lba), so a test can chain extents whose individual lengths are
// under the per-extent cap but whose running total exceeds MAX_FILE_BYTES.
fn build_efe_icb_multi(info_len: u64, ads: &[(u32, u32)]) -> [u8; 2048] {
let mut icb = [0u8; 2048];
icb[0..2].copy_from_slice(&266u16.to_le_bytes());
icb[56..64].copy_from_slice(&info_len.to_le_bytes());
let l_ad = (ads.len() * 8) as u32;
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
icb[212..216].copy_from_slice(&l_ad.to_le_bytes());
for (i, (data_len, data_lba)) in ads.iter().enumerate() {
let off = 216 + i * 8;
icb[off..off + 4].copy_from_slice(&(data_len & 0x3FFF_FFFF).to_le_bytes());
icb[off + 4..off + 8].copy_from_slice(&data_lba.to_le_bytes());
}
icb
}
#[test]
fn read_file_rejects_cumulative_extents_over_cap() {
// Two extents each within MAX_FILE_BYTES individually but exceeding it
// together: first extent buffers 2048 bytes, second is exactly the cap,
// so the cumulative guard must fire on the second before reading it.
let big = MAX_FILE_BYTES as u32;
let icb = build_efe_icb_multi(MAX_FILE_BYTES * 2, &[(2048, 100), (big, 200_000)]);
let mut reader = MemReader::new();
reader.put(10, icb);
let mut data_sector = [0u8; 2048];
data_sector[0] = 0xCD;
reader.put(100, data_sector);
// entry.size declared small so the entry.size cap passes; the
// cumulative extent total is what must trip the guard.
let fs = fs_with_file(10, 2048);
let err = fs.read_file(&mut reader, "/F").unwrap_err();
assert!(matches!(err, Error::DiscRead { .. }));
}
#[test]
fn read_file_rejects_oversized_info_length() {
// Small extent but a crafted huge info_length (entry.size) must also
// be rejected before truncate could be reached.
let icb = build_efe_icb(0, 2048, 100);
let mut reader = MemReader::new();
reader.put(10, icb);
let fs = fs_with_file(10, MAX_FILE_BYTES + 1);
let err = fs.read_file(&mut reader, "/F").unwrap_err();
assert!(matches!(err, Error::DiscRead { .. }));
}
#[test]
fn read_file_accepts_small_file() {
// A 1-sector file within the cap reads back its declared size.
let icb = build_efe_icb(2048, 2048, 100);
let mut reader = MemReader::new();
reader.put(10, icb);
// file data sector at partition_start + data_lba = 0 + 100
let mut data_sector = [0u8; 2048];
data_sector[0] = 0xAB;
reader.put(100, data_sector);
let fs = fs_with_file(10, 2048);
let data = fs
.read_file(&mut reader, "/F")
.expect("small file should read");
assert_eq!(data.len(), 2048);
assert_eq!(data[0], 0xAB);
}
#[test]
fn read_directory_rejects_oversized_dir_before_allocating() {
// A directory ICB declaring an allocation length above the 1 MiB
// ceiling must error rather than allocate a huge buffer.
let oversized = MAX_DIR_BYTES + 2048;
let icb = build_efe_icb(oversized as u64, oversized, 50);
let mut reader = MemReader::new();
reader.put(5, icb); // directory ICB at meta_start(0) + meta_lba(5)
let err = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"DIR",
0,
&mut 0,
&mut HashSet::new(),
)
.unwrap_err();
assert!(matches!(err, Error::DiscRead { .. }));
}
#[test]
fn read_directory_accepts_small_empty_dir() {
// ad_len within the cap, pointing at zeroed directory data → an empty
// (no valid FID) directory parses without error.
let icb = build_efe_icb(2048, 2048, 50);
let mut reader = MemReader::new();
reader.put(5, icb);
// directory data at meta_start(0) + ad_pos(50) = 50 reads as zeros.
let dir = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"DIR",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("small dir parses");
assert!(dir.entries.is_empty());
assert!(dir.is_dir);
}
// ---- spec-boundary coverage for AD strides, flags, FIDs ---- Build a tag-266 ICB with
// EXTENDED ADs (20 bytes each, LBA at offset +12); sets ICB Tag flags to 2 = Extended AD.
fn build_efe_ext(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag
// ICB Tag flags at abs offset 34: AD type 2 = Extended AD.
s[34..36].copy_from_slice(&2u16.to_le_bytes());
s[56..64].copy_from_slice(&info_length.to_le_bytes());
let l_ea: u32 = 0;
let l_ad: u32 = (ads.len() * 20) as u32;
s[208..212].copy_from_slice(&l_ea.to_le_bytes());
s[212..216].copy_from_slice(&l_ad.to_le_bytes());
let mut off = 216 + l_ea as usize;
for &(etype, dlen, dlba) in ads {
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
// ExtentLength at +0 (carries type + 30-bit length).
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
// RecordedLength (+4) and InformationLength (+8) set to distinct
// non-zero junk so a parser misreading the LBA at off+4 would
// pick THESE up instead of the real LBA at off+12.
s[off + 4..off + 8].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
s[off + 8..off + 12].copy_from_slice(&0xCAFE_BABEu32.to_le_bytes());
// ExtentLocation logicalBlockNumber at +12.
s[off + 12..off + 16].copy_from_slice(&dlba.to_le_bytes());
off += 20;
}
s
}
#[test]
fn icb_extents_extended_ad_uses_20byte_stride_and_lba_at_off12() {
// ECMA-167 §14.14.3: Extended AD is 20 bytes, extent LBA at +12 (not
// +4, which is RecordedLength). Flags==2 selects this 20-byte stride.
// Each LBA uses 4 distinct non-zero bytes so a wrong-offset read changes the result.
let icb = build_efe_ext(
3 * 2048,
&[
(0, 2048, 0x0102_0304),
(0, 2048, 0x0506_0708),
(0, 4096, 0x090A_0B0C),
],
);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("EXT", 5, 3 * 2048));
let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
// If the stride were wrong (8 or 16) or lba_off were off+4, the LBAs
// would be the 0xDEADBEEF junk or misaligned garbage, not these.
assert_eq!(
extents,
vec![
(0x0102_0304, 2048),
(0x0506_0708, 2048),
(0x090A_0B0C, 4096)
]
);
}
#[test]
fn icb_extents_short_ad_type1_sparse_extent_is_kept_and_flagged_unrecorded() {
// ECMA-167 4/14.14.1.1: type 1 = allocated-not-recorded, no on-disc data,
// but it still occupies file byte space, so it must be kept (flagged
// unrecorded), not dropped — dropping it would shift later extents.
let icb = build_efe(
6144,
&[
(0, 2048, 10), // recorded
(1, 2048, 20), // allocated, not recorded
(0, 2048, 30), // recorded, after the hole
],
);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("SP", 5, 6144));
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
assert_eq!(
extents,
vec![
IcbExtent {
lba: 10,
len: 2048,
recorded: true
},
IcbExtent {
lba: 20,
len: 2048,
recorded: false
},
IcbExtent {
lba: 30,
len: 2048,
recorded: true
},
]
);
}
// AD type 3 is EMBEDDED data, not a descriptor list; the old `_ => 8` fallback misdecoded
// it as an extent, so a rip would read the wrong sector at rc=0.
#[test]
fn icb_extents_embedded_data_is_refused_not_decoded_as_descriptors() {
let mut icb = build_efe(2048, &[(0, 2048, 999)]);
// ICB Tag flags at abs offset 34: low 3 bits = 3 (embedded).
icb[34..36].copy_from_slice(&3u16.to_le_bytes());
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("EMB", 5, 2048));
let err = fs
.read_icb_extents(&mut reader, 5)
.expect_err("an embedded ICB has no extents to hand out");
assert!(
matches!(err, Error::UdfEmbeddedData),
"expected UdfEmbeddedData, got {err:?}"
);
assert_eq!(err.code(), crate::error::E_UDF_EMBEDDED_DATA);
}
/// ...but a ZERO-LENGTH file may legally be encoded as embedded-with-
/// nothing-embedded. It has no content and no extents, so it is not a
/// failure — refusing it would fail a rip over a legal empty file.
#[test]
fn icb_extents_embedded_but_empty_is_not_an_error() {
let mut icb = build_efe(0, &[]);
icb[34..36].copy_from_slice(&3u16.to_le_bytes());
icb[212..216].copy_from_slice(&0u32.to_le_bytes()); // l_ad = 0
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("EMPTY", 5, 0));
assert_eq!(
fs.read_icb_extents(&mut reader, 5)
.expect("legal empty file"),
vec![]
);
}
// ECMA-167 4/12: an AD whose 30-bit length is 0 ends the list whatever its type
// (Linux udf_get_fileshortad); a zero-length type-1 AD is not a skippable hole.
#[test]
fn icb_extents_stop_at_a_zero_length_unrecorded_extent() {
for ext_type in [1u32, 2] {
let icb = build_efe(
4096,
&[(0, 2048, 5000), (ext_type, 0, 4999), (0, 2048, 6000)],
);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("ZL", 5, 4096));
assert_eq!(
fs.extents_abs_at(&mut reader, 5).expect("extents"),
vec![AbsExtent {
lba: 5000,
len: 2048,
recorded: true
}],
"type {ext_type}: zero-length AD must terminate the list"
);
}
}
#[test]
fn icb_extents_short_ad_type2_sparse_extent_is_kept_and_flagged_unrecorded() {
// ECMA-167 4/14.14.1.1 type 2 = "not recorded, not allocated", same
// byte-space semantics as type 1: must be flagged unrecorded, not
// dropped via the catch-all `_ => break` that used to truncate the list.
let icb = build_efe(
6144,
&[
(0, 2048, 10), // recorded
(2, 2048, 20), // not recorded, not allocated
(0, 2048, 30), // recorded, after the hole
],
);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("SP", 5, 6144));
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
assert_eq!(
extents,
vec![
IcbExtent {
lba: 10,
len: 2048,
recorded: true
},
IcbExtent {
lba: 20,
len: 2048,
recorded: false
},
IcbExtent {
lba: 30,
len: 2048,
recorded: true
},
],
"a type-2 hole must not truncate the list: the type-0 extent after \
it is part of the file"
);
}
// file_extents builds the extent list a title is actually ripped through; its tuple can't
// flag a hole, so folding or dropping one is both wrong — refusal is the only truthful
// answer.
#[test]
fn file_extents_refuses_a_file_with_an_unrecorded_extent() {
let icb = build_efe(
6144,
&[
(0, 2048, 10), // recorded
(1, 2048, 20), // allocated, NOT recorded — undefined sectors
(0, 2048, 30), // recorded, after the hole
],
);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(
0,
0,
DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![file_entry("SP", 5, 6144)],
},
);
let res = fs.file_extents(&mut reader, "/SP");
assert!(
matches!(res, Err(Error::UdfUnrecordedExtent { .. })),
"an unrecorded extent must never reach the title's extent list as \
readable content; got {res:?}"
);
}
#[test]
fn icb_extents_zero_length_type0_terminates_list() {
// ECMA-167: a zero-length type-0 AD terminates the descriptor list, so
// trailing zero padding must stop parsing before a stray non-zero AD
// after it becomes a bogus extent.
let icb = build_efe(
2048,
&[
(0, 2048, 10), // recorded extent
(0, 0, 0), // zero-length type-0 = terminator
(0, 4096, 999), // must NOT be parsed
],
);
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with(0, 0, file_entry("T", 5, 2048));
let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
assert_eq!(
extents,
vec![(10, 2048)],
"parsing must stop at the zero-length terminator"
);
}
// Hostile input: a self-cycling continuation descriptor. MAX_AD_BLOCKS must
// terminate this with an error, not a silently short-truncated Ok(extents)
// that disc/extract.rs would zero-pad into a fake "complete" rip. See docs.
#[test]
fn icb_extents_exhausted_continuation_budget_is_an_error_not_a_short_list() {
let icb = build_efe(2048, &[(0, 2048, 10), (3, 2048, 50)]);
let cont = build_cont_block(&[(0, 2048, 20), (3, 2048, 50)]);
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont);
let fs = fs_with(0, 0, file_entry("LOOP", 5, 2048));
// Bounded: must not hang or panic. And must not report success.
match fs.read_icb_extents(&mut reader, 5) {
Err(e) => assert!(
matches!(e, Error::UdfAdChainTooLong),
"the hop budget must report itself with its own code: {e:?}"
),
Ok(extents) => panic!(
"an unterminated AD chain returned Ok with {} extents — a \
SHORT extent list reported as the whole file; the caller \
zero-pads the tail and calls the result complete",
extents.len()
),
}
}
/// The honest path this fix must not break: a continuation chain that ENDS
/// within the budget still returns its full extent list, in file order.
#[test]
fn a_terminating_continuation_chain_still_returns_every_extent() {
// ICB → cont block at 50 → cont block at 51 → end.
let icb = build_efe(2048, &[(0, 2048, 10), (3, 2048, 50)]);
let cont_a = build_cont_block(&[(0, 2048, 20), (3, 2048, 51)]);
let cont_b = build_cont_block(&[(0, 2048, 30), (0, 4096, 40)]);
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont_a);
reader.put(51, cont_b);
let fs = fs_with(0, 0, file_entry("CHAIN", 5, 2048));
let got = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents"));
assert_eq!(
got,
vec![(10, 2048), (20, 2048), (30, 2048), (40, 4096)],
"a chain under the hop budget is a normal file, not an error"
);
}
#[test]
fn parse_udf_name_decodes_utf16be_compression_id_16() {
// UDF dchar: compression ID 16 = 16-bit big-endian Unicode. A FID
// whose filename uses ID 16 must decode correctly, not as mojibake.
// Bytes: [16][00 'A'][00 'Z'].
let raw = [16u8, 0x00, b'A', 0x00, b'Z'];
assert_eq!(parse_udf_name(&raw), "AZ");
}
#[test]
fn parse_udf_name_utf16be_uses_both_bytes_of_each_dchar_and_stops_on_an_odd_tail() {
// ECMA-167 1/7.2.2: id 16 = 16-bit BIG-endian dchars (real discs carry
// non-Latin-1 names). Byte run is deliberately ODD-length, as a truncated
// field would be: the trailing lone byte must not pair with what follows.
let raw = [16u8, 0x4E, 0x2D, 0x00, b'A', 0x42];
assert_eq!(
parse_udf_name(&raw),
"\u{4E2D}A",
"0x4E,0x2D is one dchar U+4E2D, and the lone 0x42 is not a character"
);
}
#[test]
fn parse_udf_name_8bit_compression_id_8() {
// Compression ID 8 = 8-bit (OSTA CS0 / ASCII). "BDMV" must round-trip.
let mut raw = vec![8u8];
raw.extend_from_slice(b"BDMV");
assert_eq!(parse_udf_name(&raw), "BDMV");
}
// A disc read failure fetching an entry's ICB must propagate, not become a
// file size of zero (indistinguishable from a genuinely empty file).
#[test]
fn read_directory_propagates_an_icb_read_failure_instead_of_size_zero() {
/// Serves every sector from an inner MemReader except one, which fails
/// the way a bad sector does.
struct FailingAt {
inner: MemReader,
fail_lba: u32,
}
impl SectorSource for FailingAt {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
if lba == self.fail_lba {
return Err(Error::DiscRead {
sector: lba as u64,
status: None,
sense: None,
});
}
self.inner.read_sectors(lba, count, buf, recovery)
}
}
// One directory holding one file entry whose ICB lives at LBA 7.
let mut dir = [0u8; 2048];
let mut name_bytes = vec![8u8];
name_bytes.extend_from_slice(b"CLPI");
dir[0..2].copy_from_slice(&257u16.to_le_bytes());
dir[18] = 0x00; // a file, not a parent, not a dir
dir[19] = name_bytes.len() as u8;
dir[24..28].copy_from_slice(&7u32.to_le_bytes());
dir[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
let mut inner = MemReader::new();
inner.put(5, build_efe_icb(2048, 2048, 60));
inner.put(60, dir);
inner.put(7, build_efe_icb(123, 2048, 0));
// Sanity: with every sector readable the entry parses and carries its
// real size, so the failure below is the ICB read and nothing else.
let ok = read_directory(
&mut inner,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("dir parses when every sector reads");
assert_eq!(ok.entries.len(), 1);
assert_eq!(ok.entries[0].size, 123);
let mut reader = FailingAt {
inner: MemReader::new(),
fail_lba: 7,
};
reader.inner.put(5, build_efe_icb(2048, 2048, 60));
reader.inner.put(60, dir);
reader.inner.put(7, build_efe_icb(123, 2048, 0));
let err = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect_err("an unreadable entry ICB must fail the scan, not report size 0");
assert!(
matches!(err, Error::DiscRead { sector: 7, .. }),
"the propagated error must name the sector that failed, got {err:?}"
);
}
#[test]
fn read_directory_honors_l_iu_offset_for_fid_name() {
// ECMA-167 §14.4 File Identifier: the name begins at offset 38 + L_IU.
// A non-zero L_IU must shift the name read; ignoring it reads impl_use
// bytes as the name instead.
let mut dir = [0u8; 2048];
let l_iu: u16 = 4;
let mut name_bytes = vec![8u8]; // compression id 8
name_bytes.extend_from_slice(b"CLPI");
let l_fi = name_bytes.len() as u8;
dir[0..2].copy_from_slice(&257u16.to_le_bytes());
dir[18] = 0x00; // not parent, not dir → a file
dir[19] = l_fi;
dir[24..28].copy_from_slice(&7u32.to_le_bytes()); // child ICB LBA
dir[36..38].copy_from_slice(&l_iu.to_le_bytes());
dir[38..42].copy_from_slice(&[0xFF, 0xFE, 0xFD, 0xFC]); // impl_use junk
let name_start = 38 + l_iu as usize;
dir[name_start..name_start + name_bytes.len()].copy_from_slice(&name_bytes);
let dir_icb = build_efe_icb(2048, 2048, 60); // dir data at ad_pos 60
let mut reader = MemReader::new();
reader.put(5, dir_icb);
reader.put(60, dir);
reader.put(7, build_efe_icb(123, 2048, 0)); // child size ICB
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("dir parses");
assert_eq!(parsed.entries.len(), 1, "exactly one FID entry");
assert_eq!(
parsed.entries[0].name, "CLPI",
"name must be read at 38+L_IU, not from impl_use bytes"
);
assert!(!parsed.entries[0].is_dir);
}
#[test]
fn read_directory_skips_parent_fid_entry() {
// ECMA-167 §14.4.3: characteristics bit 0x08 = "parent" (the ".." link)
// and must never appear as a named child. Fixture gives it a valid
// non-zero L_FI and real name to isolate that gate from L_FI==0.
let mut dir = [0u8; 2048];
let mut name_bytes = vec![8u8];
name_bytes.extend_from_slice(b"PARENT");
let l_fi = name_bytes.len() as u8;
dir[0..2].copy_from_slice(&257u16.to_le_bytes());
dir[18] = 0x08 | 0x02; // parent + directory bits
dir[19] = l_fi; // non-zero L_FI: name present but must be ignored
dir[24..28].copy_from_slice(&9u32.to_le_bytes());
dir[36..38].copy_from_slice(&0u16.to_le_bytes()); // L_IU = 0
dir[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
let dir_icb = build_efe_icb(2048, 2048, 60);
let mut reader = MemReader::new();
reader.put(5, dir_icb);
reader.put(60, dir);
reader.put(9, build_efe_icb(0, 2048, 0)); // child size ICB
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("dir parses");
assert!(
parsed.entries.is_empty(),
"the parent (..) FID must not be emitted even with a valid name"
);
}
#[test]
fn parse_dstring_length_byte_caps_content() {
// UDF d-string: the final byte of the fixed field is the length of
// valid content (compression id + chars). Bytes past that length must
// be ignored. Field: [8]['V']['O']['L'] ... last byte = 4.
let mut field = [0u8; 32];
field[0] = 8; // compression id 8
field[1] = b'V';
field[2] = b'O';
field[3] = b'L';
field[10] = b'X'; // garbage beyond declared length — must be ignored
*field.last_mut().unwrap() = 4; // 4 valid bytes (id + 3 chars)
assert_eq!(parse_dstring(&field), "VOL");
}
#[test]
fn parse_dstring_decodes_a_utf16be_volume_identifier() {
// ECMA-167 1/7.2.12 d-strings: same OSTA CS0 encoding as file names, used
// byte count in the last field byte. Content covers a char above Latin-1,
// an embedded NUL pair (padding, not a char), and an odd trailing byte.
let mut field = [0u8; 32];
let content: [u8; 10] = [16, 0x4E, 0x2D, 0x00, b'A', 0x00, 0x00, 0x00, b'B', 0x43];
field[..content.len()].copy_from_slice(&content);
*field.last_mut().unwrap() = content.len() as u8;
assert_eq!(
parse_dstring(&field),
"\u{4E2D}AB",
"big-endian pairs, no NUL padding, and no character made from the odd tail byte"
);
}
#[test]
fn parse_dstring_oversized_length_byte_returns_empty_not_panic() {
// Hostile/corrupt input: a length byte larger than the field must not
// index out of bounds. parse_dstring guards len > data.len() → "".
let mut field = [0u8; 8];
field[0] = 8;
field[1] = b'A';
*field.last_mut().unwrap() = 200; // way past the 8-byte field
assert_eq!(parse_dstring(&field), "");
}
// Build a tag-266 ICB whose data is EMBEDDED (ICB Tag flags low 3 bits ==
// 3, ECMA-167 4/14.6.8): the AD area holds the file's bytes, not descriptors.
fn build_inline_icb(info_len: u64, payload: &[u8]) -> [u8; 2048] {
build_inline_icb_tagged(266, 0, info_len, payload)
}
/// As [`build_inline_icb`], for either entry type and with an explicit
/// extended-attribute length: a 261 File Entry keeps L_EA at 168 and its
/// descriptor area at 176 + L_EA, a 266 at 208 and 216 + L_EA.
fn build_inline_icb_tagged(tag: u16, l_ea: usize, info_len: u64, payload: &[u8]) -> [u8; 2048] {
let mut icb = [0u8; 2048];
icb[0..2].copy_from_slice(&tag.to_le_bytes());
icb[34..36].copy_from_slice(&3u16.to_le_bytes()); // AD type 3 = embedded
icb[56..64].copy_from_slice(&info_len.to_le_bytes());
let (l_ea_off, base) = if tag == 266 {
(208usize, 216usize)
} else {
(168usize, 176usize)
};
icb[l_ea_off..l_ea_off + 4].copy_from_slice(&(l_ea as u32).to_le_bytes());
icb[l_ea_off + 4..l_ea_off + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
icb[base..base + l_ea].fill(0xA5);
let at = base + l_ea;
if at + payload.len() <= icb.len() {
icb[at..at + payload.len()].copy_from_slice(payload);
}
icb
}
#[test]
fn read_file_reads_embedded_data_from_both_entry_types_at_their_own_offsets() {
// Embedded data starts at 176 + L_EA (File Entry, ECMA-167 4/14.9) or
// 216 + L_EA (Extended File Entry, 4/14.17); a wrong offset doesn't fail
// loudly, it just reads extended-attribute bytes as an AACS `*.inf` key.
let payload: Vec<u8> = (0..48u8)
.map(|i| i.wrapping_mul(11).wrapping_add(3))
.collect();
for tag in [261u16, 266u16] {
for l_ea in [0usize, 24] {
let mut reader = MapReader::new();
reader.put(5, build_inline_icb_tagged(tag, l_ea, 48, &payload));
let fs = fs_with_file(5, 48);
let got = fs
.read_file(&mut reader, "/F")
.unwrap_or_else(|e| panic!("tag {tag}, L_EA {l_ea}: {e:?}"));
assert_eq!(got, payload, "tag {tag}, L_EA {l_ea}");
}
}
}
#[test]
fn read_file_reads_embedded_data_flush_with_the_end_of_the_entry() {
// The embedded payload may run exactly to the end of the 2048-byte
// logical block. Those bytes are inside the entry and are the file:
// refusing them turns a readable file into a disc-read failure.
let len = 2048 - 216;
let payload: Vec<u8> = (0..len).map(|i| (i % 251) as u8 + 1).collect();
let mut reader = MapReader::new();
reader.put(5, build_inline_icb(len as u64, &payload));
let fs = fs_with_file(5, len as u64);
let got = fs
.read_file(&mut reader, "/F")
.expect("a payload flush with the end of the entry is in bounds");
assert_eq!(got, payload);
}
#[test]
fn read_file_refuses_embedded_data_that_runs_past_the_entry() {
// L_AD is disc-controlled; one claiming more embedded bytes than the
// entry holds must be refused, not clamped to a short-but-valid-looking
// prefix of a key file.
let mut icb = build_inline_icb(64, &[0xAB; 64]);
icb[212..216].copy_from_slice(&4000u32.to_le_bytes()); // L_AD past the sector
let mut reader = MapReader::new();
reader.put(5, icb);
let fs = fs_with_file(5, 64);
let err = fs
.read_file(&mut reader, "/F")
.expect_err("an embedded payload larger than the entry cannot be read");
assert!(matches!(err, Error::DiscRead { .. }), "got {err:?}");
}
#[test]
fn read_sectors_batches_a_cold_single_sector_read_into_a_full_window() {
// One single-sector request should pull a `batch`-sector window so the
// neighbouring requests UDF parsing issues constantly cost nothing; a
// wrong window still returns correct bytes, so only the call count shows it.
let mut inner = CountReader::new();
{
let mut br = BufferedSectorReader::new(&mut inner, 4);
let mut buf = [0u8; 2048];
for lba in [500u32, 501, 502, 503] {
br.read_sectors(lba, 1, &mut buf, true)
.unwrap_or_else(|e| panic!("read of {lba}: {e:?}"));
assert_eq!(buf, CountReader::expected(lba), "sector {lba}");
}
}
assert_eq!(
inner.calls, 1,
"one batch command must serve all four sectors of the window"
);
assert_eq!(inner.sectors_read, 4);
}
#[test]
fn read_file_trims_embedded_data_to_the_declared_information_length() {
// ECMA-167 4/14.17: Information Length is the real size; the embedded
// area (L_AD bytes) is alignment-padded and routinely longer. Returning
// the padding makes an AACS `*.inf` or nav file parse with trailing garbage.
let payload: Vec<u8> = (0..64u8)
.map(|i| i.wrapping_mul(7).wrapping_add(1))
.collect();
let mut reader = MapReader::new();
reader.put(5, build_inline_icb(20, &payload));
let fs = fs_with_file(5, 20);
let got = fs
.read_file(&mut reader, "/F")
.expect("embedded file reads");
assert_eq!(
got,
payload[..20].to_vec(),
"an embedded file is Information Length bytes long, not L_AD bytes"
);
}
#[test]
fn read_file_prefix_caps_embedded_data_at_the_requested_length() {
// `read_file_prefix` bounds a read (e.g. the AACS `MKB_RO.inf`'s ~128 MiB
// of padding, when only the leading record is wanted); that bound must
// apply on the embedded path too, since nothing else limits it there.
let payload: Vec<u8> = (0..64u8)
.map(|i| i.wrapping_mul(7).wrapping_add(1))
.collect();
let mut reader = MapReader::new();
reader.put(5, build_inline_icb(64, &payload));
let fs = fs_with_file(5, 64);
let got = fs
.read_file_prefix(&mut reader, "/F", 8)
.expect("embedded prefix reads");
assert_eq!(got, payload[..8].to_vec(), "the requested prefix bounds it");
}
#[test]
fn file_start_lba_descends_only_through_directories_that_match_the_path() {
// Each path component must match a child that is BOTH a directory AND
// named for it. Matching either alone walks into the first directory
// met — e.g. `AACS/` next to `BDMV/` — and reports an LBA from the wrong subtree.
let fs = fs_with(
1000,
0,
DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![
DirEntry {
name: "AACS".to_string(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![file_entry("INDEX.BDMV", 6, 2048)],
},
DirEntry {
name: "BDMV".to_string(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![file_entry("INDEX.BDMV", 7, 2048)],
},
],
},
);
let mut reader = MapReader::new();
reader.put(6, build_efe(2048, &[(0, 2048, 111)])); // the AACS/ copy
reader.put(7, build_efe(2048, &[(0, 2048, 222)])); // the BDMV/ copy
let lba = fs
.file_start_lba(&mut reader, "/BDMV/INDEX.BDMV")
.expect("the path resolves");
assert_eq!(lba, 1000 + 222, "BDMV/INDEX.BDMV, not AACS/INDEX.BDMV");
}
#[test]
fn read_file_accepts_a_file_exactly_at_the_size_ceiling() {
// `MAX_FILE_BYTES` bounds what an unbounded read may allocate. A file
// exactly at the ceiling is admissible: rejecting it makes the largest
// legal read indistinguishable from a corrupt disc.
let cap = MAX_FILE_BYTES as u32;
let mut reader = MapReader::new();
reader.put(5, build_efe(MAX_FILE_BYTES, &[(0, cap, 40)]));
let fs = fs_with_file(5, MAX_FILE_BYTES);
let got = fs
.read_file(&mut reader, "/F")
.expect("a file exactly at the ceiling must be readable");
assert_eq!(got.len(), MAX_FILE_BYTES as usize);
}
#[test]
fn read_file_sums_extent_lengths_rather_than_combining_them_some_other_way() {
// The running-total guard stops many legal extents adding up to a GiB
// allocation; it must compare the SUM read against the ceiling, or two
// legal 16 MiB extents (32 MiB total, well within bounds) get refused.
const EXT: u32 = 16 * 1024 * 1024;
let mut reader = MapReader::new();
reader.put(
5,
build_efe(2 * EXT as u64, &[(0, EXT, 100), (0, EXT, 20_000)]),
);
let fs = fs_with_file(5, 2 * EXT as u64);
let got = fs
.read_file(&mut reader, "/F")
.expect("32 MiB across two extents is under the 64 MiB ceiling");
assert_eq!(got.len(), 2 * EXT as usize);
}
#[test]
fn read_inline_data_rejects_oversized_lea() {
// AD type=3 with L_EA so large that ad_offset = 216 + L_EA overflows past
// the 2048-byte ICB. Before the fix `.min(icb.len())` clamped to
// start==end==2048, silently returning an empty file (e.g. a 0-byte key).
let mut icb = [0u8; 2048];
// tag = 266 (Extended File Entry)
icb[0..2].copy_from_slice(&266u16.to_le_bytes());
// ICB Tag flags at offset 34: low 3 bits = 3 → inline data
icb[34..36].copy_from_slice(&3u16.to_le_bytes());
// L_EA = 2000 → ad_offset = 216 + 2000 = 2216 > 2048
let l_ea: u32 = 2000;
let l_ad: u32 = 4;
icb[208..212].copy_from_slice(&l_ea.to_le_bytes());
icb[212..216].copy_from_slice(&l_ad.to_le_bytes());
let mut reader = MapReader::new();
reader.put(0, icb); // meta_start=0 + meta_lba=0 → abs lba 0
let fs = fs_with(0, 0, file_entry("inline", 0, l_ad as u64));
let result = fs.read_inline_data(&mut reader, 0);
assert!(
result.is_err(),
"oversized L_EA must return Err, not Ok(Some(empty vec))"
);
}
// A SectorSource whose content is derived from LBA and counts reads: the
// content says which sector a caller got, the count says if cache served it.
struct CountReader {
calls: usize,
sectors_read: usize,
}
impl CountReader {
fn new() -> Self {
Self {
calls: 0,
sectors_read: 0,
}
}
/// Sector `lba` is filled with a byte pattern unique to `lba`.
fn expected(lba: u32) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..4].copy_from_slice(&lba.to_le_bytes());
s[2044..2048].copy_from_slice(&(!lba).to_le_bytes());
s
}
}
impl SectorSource for CountReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let need = count as usize * 2048;
if buf.len() < need {
return Err(Error::UdfBufferTooSmall);
}
self.calls += 1;
self.sectors_read += count as usize;
for i in 0..count as u32 {
let off = i as usize * 2048;
buf[off..off + 2048].copy_from_slice(&Self::expected(lba.wrapping_add(i)));
}
Ok(need)
}
}
#[test]
fn prefetch_serves_every_sector_of_the_window_from_the_cache() {
// `prefetch` turns thousands of single-sector metadata reads into a few
// batched commands, but only if the window is actually loaded, placed so
// each sector serves from its own offset, and consulted afterwards.
let mut inner = CountReader::new();
{
let mut br = BufferedSectorReader::new(&mut inner, 3);
br.prefetch(100, 8).unwrap(); // 8 sectors in batches of 3 → 3 commands
let mut buf = [0u8; 2048];
for lba in [100u32, 103, 107] {
br.read_sectors(lba, 1, &mut buf, true)
.unwrap_or_else(|e| panic!("cached read of {lba}: {e:?}"));
assert_eq!(
buf,
CountReader::expected(lba),
"sector {lba} came back as some other sector"
);
}
}
assert_eq!(
inner.calls, 3,
"the window is loaded in batch-sized commands and then answers reads without the drive"
);
assert_eq!(inner.sectors_read, 8);
}
#[test]
fn prefetch_window_does_not_answer_for_the_sector_just_past_its_end() {
// The window covers `count` sectors from `start_lba`; the sector at
// start_lba + count belongs to whatever is next, and answering for it
// would hand back bytes from beyond the loaded data.
let mut inner = CountReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 8);
br.prefetch(100, 8).unwrap();
let mut buf = [0u8; 2048];
br.read_sectors(108, 1, &mut buf, true)
.expect("the sector past the window is read from the drive");
assert_eq!(buf, CountReader::expected(108));
}
#[test]
fn prefetch_ranges_serves_every_sector_of_every_range_from_the_permanent_cache() {
// `prefetch_ranges` seeds the per-sector cache from the scattered ranges
// `metadata_sector_ranges` produced; each sector must be keyed at its
// own LBA, or a wrong-base key answers unrelated later reads (e.g. AVDP/VDS).
let mut inner = CountReader::new();
{
let mut br = BufferedSectorReader::new(&mut inner, 2);
br.prefetch_ranges(&[(200, 5), (300, 2)]).unwrap();
let mut buf = [0u8; 2048];
for lba in [200u32, 202, 204, 300, 301] {
br.read_sectors(lba, 1, &mut buf, true)
.unwrap_or_else(|e| panic!("cached read of {lba}: {e:?}"));
assert_eq!(
buf,
CountReader::expected(lba),
"sector {lba} came back as some other sector"
);
}
}
// 5 sectors in batches of 2 → 3 commands; 2 sectors → 1 command. The
// five reads above add none.
assert_eq!(inner.calls, 4);
assert_eq!(inner.sectors_read, 7);
}
#[test]
fn metadata_sector_ranges_covers_the_structure_and_each_small_files_extents() {
// The prefetch plan decides what's bulk-read before scanning: UDF
// structure, every non-STREAM ICB, RECORDED extents under the size cap,
// but never STREAM payloads. A wrong plan only costs perf, never correctness.
const PART: u32 = 1000;
let fs = UdfFs {
root: DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![
DirEntry {
name: "BDMV".to_string(),
is_dir: true,
meta_lba: 1,
size: 0,
entries: vec![file_entry("INDEX.BDMV", 10, 4096)],
},
DirEntry {
name: "STREAM".to_string(),
is_dir: true,
meta_lba: 2,
size: 0,
entries: vec![file_entry("00000.M2TS", 11, 2048)],
},
// Exactly at the 50 MB ceiling — cacheable.
file_entry("EXACT.BIN", 12, 50_000_000),
// One byte over — its data is not cached, but its ICB is.
file_entry("HUGE.BIN", 13, 50_000_001),
file_entry("SPARSE.BIN", 14, 6144),
],
},
volume_id: String::new(),
partition_start: PART,
meta: MetaMap::contiguous(PART),
metadata_sectors: 4,
};
let mut reader = MapReader::new();
reader.put(PART + 10, build_efe(4096, &[(0, 4096, 500)]));
reader.put(PART + 11, build_efe(2048, &[(0, 2048, 600)])); // STREAM: never read
reader.put(PART + 12, build_efe(50_000_000, &[(0, 2048, 800)]));
reader.put(PART + 13, build_efe(50_000_001, &[(0, 2048, 850)]));
// An unrecorded extent holds nothing to cache; the recorded one does.
reader.put(
PART + 14,
build_efe(6144, &[(1, 4096, 700), (0, 2048, 900)]),
);
let ranges = fs
.metadata_sector_ranges(&mut reader)
.expect("the plan is built");
// (1010,1) is INDEX.BDMV's ICB alone, stopping short of 1011 (STREAM's
// ICB, deliberately never descended) rather than merging across it; this
// used to read (1010,5), a regression where `merge_ranges` closed that gap.
assert_eq!(
ranges,
vec![
(0, 1004),
(1010, 1),
(1012, 3),
(1500, 2),
(1800, 1),
(1900, 1)
]
);
assert!(
!ranges.iter().any(|&(s, c)| (s..s + c).contains(&1011)),
"STREAM's ICB at 1011 is deliberately not descended, so no range \
may cover it: {ranges:?}"
);
}
// `metadata_sector_ranges_for`: `wanted` sees each file's absolute path; a file it refuses gets no range, neither its
// data nor its File Entry, and its File Entry is never read.
#[test]
fn metadata_sector_ranges_leave_out_the_files_wanted_refuses() {
const PART: u32 = 1000;
let fs = UdfFs {
root: DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![DirEntry {
name: "BDMV".to_string(),
is_dir: true,
meta_lba: 1,
size: 0,
entries: vec![
file_entry("index.bdmv", 10, 2048),
DirEntry {
name: "BACKUP".to_string(),
is_dir: true,
meta_lba: 2,
size: 0,
entries: vec![file_entry("index.bdmv", 20, 2048)],
},
],
}],
},
volume_id: String::new(),
partition_start: PART,
meta: MetaMap::contiguous(PART),
metadata_sectors: 4,
};
struct Reads(MapReader, Vec<u32>);
impl SectorSource for Reads {
fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], r: bool) -> Result<usize> {
self.1.push(lba);
self.0.read_sectors(lba, n, buf, r)
}
}
let mut reader = Reads(MapReader::new(), Vec::new());
reader.0.put(PART + 10, build_efe(2048, &[(0, 2048, 500)]));
reader.0.put(PART + 20, build_efe(2048, &[(0, 2048, 600)]));
let seen = std::cell::RefCell::new(Vec::new());
let ranges = fs
.metadata_sector_ranges_for(&mut reader, &|p| {
seen.borrow_mut().push(p.to_string());
!p.contains("/BACKUP/")
})
.expect("the plan is built");
assert_eq!(
*seen.borrow(),
["/BDMV/index.bdmv", "/BDMV/BACKUP/index.bdmv"]
);
assert_eq!(ranges, vec![(0, 1004), (1010, 1), (1500, 1)]);
assert!(
!reader.1.contains(&(PART + 20)),
"the refused file's File Entry was read: {:?}",
reader.1
);
}
#[test]
fn prefetch_huge_count_is_capped() {
// A disc-controlled sector count far exceeding the 8192-sector cap must
// not allocate more than 8192 * 2048 bytes in the sliding cache.
let mut inner = MapReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 32);
// Pass a count that would allocate ~512 MiB if uncapped (262144 sectors).
br.prefetch(0, 262_144).unwrap();
// The cache must be no larger than the cap: 8192 sectors × 2048 bytes.
assert!(
br.cache.len() <= 8192 * 2048,
"prefetch cache exceeded cap: {} bytes",
br.cache.len()
);
}
// prefetch's start_lba + offset must not overflow past u32::MAX for a disc-declared
// partition near the top of LBA space.
#[test]
fn prefetch_near_u32_max_does_not_overflow() {
let mut inner = MapReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 60);
// start + count = 0xFFFF_FFC0 + 200 > u32::MAX: the second batch
// iteration evaluates 0xFFFF_FFC0 + 60.
br.prefetch(0xFFFF_FFC0, 200).unwrap();
// Nothing read (MapReader serves no sector here), but crucially the
// walk must never form an LBA above u32::MAX.
assert!(
br.cache_start.checked_add(br.cache_sectors).is_some(),
"cache window must stay inside the u32 LBA space: {} + {}",
br.cache_start,
br.cache_sectors
);
}
// prefetch_ranges's start + offset + i must not overflow for a disc-derived
// range near u32::MAX, or it wrongly seeds the permanent cache at LBA 0.
#[test]
fn prefetch_ranges_near_u32_max_does_not_overflow() {
let mut inner = MapReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 60);
br.prefetch_ranges(&[(0xFFFF_FFF0, 512)]).unwrap();
// Every key the permanent cache holds must be a real LBA, i.e. inside
// the declared range — never a wrapped low sector.
for &lba in br.prefetched.keys() {
assert!(
lba >= 0xFFFF_FFF0,
"prefetch_ranges wrapped a near-u32::MAX LBA to {lba}"
);
}
// Clamping must drop only unaddressable sectors — dropping addressable
// ones too silently defeats the bulk read this function exists for.
for lba in 0xFFFF_FFF0u32..=0xFFFF_FFFE {
assert!(
br.prefetched.contains_key(&lba),
"addressable sector {lba} was dropped from the prefetch"
);
}
}
// The sliding-cache hit test's cache_start + cache_sectors must not
// overflow when cache_start is a disc-controlled LBA near u32::MAX.
#[test]
fn cache_hit_test_near_u32_max_does_not_overflow() {
let mut inner = MapReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 60);
let mut buf = [0u8; 2048];
// First read seeds cache_start = 0xFFFF_FFF5, cache_sectors = 60.
br.read_sectors(0xFFFF_FFF5, 1, &mut buf, true)
.expect("seed read");
// Second read of the same LBA evaluates the hit test: 0xFFFF_FFF5 + 60.
br.read_sectors(0xFFFF_FFF5, 1, &mut buf, true)
.expect("cache hit test must not overflow");
}
// Build a 2048-byte dir sector of `count` minimal file FIDs (2-byte name,
// 40-byte record, 51 fit per sector); icb_base + i gives each FID's ICB LBA.
fn build_dir_sector_with_file_fids(count: usize, icb_base: u32) -> [u8; 2048] {
let mut sector = [0u8; 2048];
let l_fi: u8 = 2;
let name: [u8; 2] = [8, b'A'];
let fid_stride = 40usize; // 38 + l_fi=2, already 4-byte aligned
let mut pos = 0;
for i in 0..count {
if pos + fid_stride > sector.len() {
break;
}
sector[pos..pos + 2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
sector[pos + 18] = 0x00; // file (not dir, not parent)
sector[pos + 19] = l_fi;
let lba = icb_base.wrapping_add(i as u32);
sector[pos + 24..pos + 28].copy_from_slice(&lba.to_le_bytes());
// l_iu = 0 at +36
sector[pos + 38..pos + 40].copy_from_slice(&name);
pos += fid_stride;
}
sector
}
#[test]
fn read_directory_budget_exceeded_returns_err() {
// A crafted disc emitting more FIDs than MAX_TOTAL_DIR_ENTRIES must be
// rejected, not visited indefinitely: pre-load `budget` to within 10 of
// the cap and feed 51 FIDs — the walk must error before consuming them all.
let dir_sector = build_dir_sector_with_file_fids(51, 200);
let dir_icb = build_efe_icb(2048, 2048, 50);
let mut reader = MemReader::new();
reader.put(5, dir_icb);
reader.put(50, dir_sector);
// MemReader returns zeros for unmapped ICB LBAs → tag=0 → read_file_size=0, fine.
let mut budget: usize = MAX_TOTAL_DIR_ENTRIES - 10;
let err = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut budget,
&mut HashSet::new(),
)
.unwrap_err();
assert!(
matches!(err, Error::DiscRead { .. }),
"budget exceeded must return DiscRead"
);
}
#[test]
fn read_directory_icb_cycle_does_not_recurse() {
// A child FID pointing back at the parent's own ICB LBA (self-reference)
// must NOT recurse — it must be emitted as a leaf. `visited` is seeded
// with lba 5 (the root about to be descended into) so the cycle is caught.
let mut dir = [0u8; 2048];
let mut name_bytes = vec![8u8];
name_bytes.extend_from_slice(b"LOOP");
let l_fi = name_bytes.len() as u8;
dir[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
dir[18] = 0x02; // is_dir
dir[19] = l_fi;
dir[24..28].copy_from_slice(&5u32.to_le_bytes()); // ICB LBA = 5 (self)
// l_iu = 0, name at offset 38
dir[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
let dir_icb = build_efe_icb(2048, 2048, 60);
let mut reader = MemReader::new();
reader.put(5, dir_icb);
reader.put(60, dir);
// Seed visited with the root ICB key so the child (lba 5) is
// immediately recognised as a cycle.
let mut visited: HashSet<u64> = HashSet::new();
let root_key: u64 = 5u64; // meta_start=0 → key = (0 << 32) | 5
visited.insert(root_key);
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut visited,
)
.expect("cycle must not blow up");
// The cyclic entry is emitted as a leaf (no children), not recursed into.
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].name, "LOOP");
assert!(parsed.entries[0].is_dir);
assert!(
parsed.entries[0].entries.is_empty(),
"cycle entry must be a leaf, not recursed"
);
}
#[test]
fn read_directory_rejects_an_icb_whose_tag_is_not_a_file_entry() {
// Only tags 261/266 (ECMA-167 4/14.9, 4/14.17) can be a directory's ICB;
// any other tag (here 258, a real but wrong descriptor) leaves contents
// UNKNOWN — an empty DirEntry would make it look genuinely empty.
let mut icb = build_efe_icb(2048, 2048, 60);
icb[0..2].copy_from_slice(&258u16.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, icb);
let err = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"BDMV",
0,
&mut 0,
&mut HashSet::new(),
)
.expect_err("a non-File-Entry directory ICB must fail, not read as an empty directory");
assert!(
matches!(err, Error::DiscRead { sector: 5, .. }),
"the error must name the ICB sector that carried the bad tag, got {err:?}"
);
// And the genuinely-empty case still succeeds: a real Extended File
// Entry (266) whose directory data holds no FID is Ok and empty, so
// this test is about the TAG and not about emptiness.
let mut ok_reader = MemReader::new();
ok_reader.put(5, build_efe_icb(2048, 2048, 60));
let dir = read_directory(
&mut ok_reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"BDMV",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a real Extended File Entry with no FIDs is a genuinely empty directory");
assert!(dir.entries.is_empty());
}
#[test]
fn read_file_unrecorded_extent_contributes_zeros_and_does_not_shift_later_extents() {
// ECMA-167 4/14.14.1.1: type 1 = "allocated but not recorded" — logical
// zeros that STILL OCCUPY file byte space. Dropping the descriptor slides
// later extents down by the hole's length with no error at all.
let icb = build_efe(
3 * 2048,
&[
(0, 2048, 10), // recorded
(1, 2048, 20), // allocated but NOT recorded
(0, 2048, 30), // recorded, after the hole
],
);
let mut reader = MemReader::new();
reader.put(5, icb);
reader.put(10, [0xAAu8; 2048]);
// LBA 20 is deliberately given NON-zero bytes: an unrecorded extent's
// sectors must never be read, so 0xBB must not appear in the output.
reader.put(20, [0xBBu8; 2048]);
reader.put(30, [0xCCu8; 2048]);
let fs = fs_with_file(5, 3 * 2048);
let data = fs.read_file(&mut reader, "/F").expect("file reads");
assert_eq!(data.len(), 6144, "the hole occupies file space");
assert!(data[0..2048].iter().all(|&b| b == 0xAA));
assert!(
data[2048..4096].iter().all(|&b| b == 0x00),
"an unrecorded extent reads as zeros, not as whatever is on the media"
);
assert!(
data[4096..6144].iter().all(|&b| b == 0xCC),
"the extent after the hole must land at byte 4096, not 2048"
);
}
#[test]
fn read_filesystem_follows_the_avdp_main_vds_extent_pointer() {
// ECMA-167 3/10.2.1: the AVDP DEFINES the Main VDS via its extent_ad
// (length at [16:20], location at [20:24]) — not fixed at sector 32.
// A conformant volume recording its VDS elsewhere must still mount.
use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir};
// Where this volume really keeps its Volume Descriptor Sequence.
const VDS_LBA: u32 = 300;
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(
&mut disc,
&DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("INDEX.BDMV", 12, 13, 2048, false)],
subdirs: Vec::new(),
},
);
// Re-point the anchor at the real sequence and MOVE the descriptors
// there, leaving 32..64 blank (as a conformant volume that never used
// sector 32 would).
let mut avdp = vec![0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
// 16 sectors is the ECMA-167 3/10.2.1 minimum VDS extent length.
avdp[16..20].copy_from_slice(&(16u32 * 2048).to_le_bytes());
avdp[20..24].copy_from_slice(&VDS_LBA.to_le_bytes());
disc.put_bytes(256, &avdp);
let mut pd = vec![0u8; 2048];
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
let mut lvd = vec![0u8; 2048];
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
let mut td = vec![0u8; 2048];
td[0..2].copy_from_slice(&8u16.to_le_bytes());
disc.put_bytes(VDS_LBA, &pd);
disc.put_bytes(VDS_LBA + 1, &lvd);
disc.put_bytes(VDS_LBA + 2, &td);
// Blank the hardcoded window.
disc.put_bytes(32, &vec![0u8; 3 * 2048]);
let fs = super::read_filesystem(&mut disc)
.expect("a volume whose AVDP points its VDS elsewhere must still mount");
assert_eq!(fs.partition_start(), PART_START);
assert_eq!(fs.root.entries.len(), 1);
assert_eq!(fs.root.entries[0].name, "INDEX.BDMV");
}
// An anchor whose recorded VDS extent passes every SHAPE check but holds no sequence must
// still mount, by sweeping the customary location — shape alone can't prove content.
#[test]
fn a_shape_valid_vds_pointer_that_holds_no_sequence_falls_back_to_the_customary_location() {
use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir};
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(
&mut disc,
&DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("INDEX.BDMV", 12, 13, 2048, false)],
subdirs: Vec::new(),
},
);
// Point the anchor at LBA 500 with a well-formed extent_ad but nothing written
// there, so the sweep reads zeroed sectors (tag 0, no Partition Descriptor).
// The real sequence stays at the customary location for the fallback to find.
let mut avdp = vec![0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
avdp[16..20].copy_from_slice(&(16u32 * 2048).to_le_bytes());
avdp[20..24].copy_from_slice(&500u32.to_le_bytes());
disc.put_bytes(256, &avdp);
let fs = super::read_filesystem(&mut disc).expect(
"a well-formed pointer to nothing must not be the end of the road; \
the customary sequence is still there and still valid",
);
assert_eq!(fs.partition_start(), PART_START);
assert_eq!(fs.root.entries[0].name, "INDEX.BDMV");
}
// The recorded extent and the customary fallback are two SWEEPS, not two locations:
// de-duplicating them on start LBA alone would throw the wider sweep away.
#[test]
fn a_recorded_vds_narrower_than_the_customary_window_still_gets_the_wider_sweep() {
use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir};
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(
&mut disc,
&DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("INDEX.BDMV", 12, 13, 2048, false)],
subdirs: Vec::new(),
},
);
// Move the sequence from 32/33/34 to 50/51, past the declared 16-sector window.
// 32..48 is left zeroed (tag 0, no descriptor), crucially with no tag-8
// Terminating Descriptor to stop the wider sweep early.
disc.put_bytes(32, &[0u8; 3 * 2048]);
let mut pd = [0u8; 2048];
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
disc.put_bytes(50, &pd);
let mut lvd = [0u8; 2048];
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
disc.put_bytes(51, &lvd);
let mut avdp = vec![0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
avdp[16..20].copy_from_slice(&(VDS_MIN_SECTORS * 2048).to_le_bytes());
avdp[20..24].copy_from_slice(&VDS_FALLBACK_START.to_le_bytes());
disc.put_bytes(256, &avdp);
let fs = super::read_filesystem(&mut disc).expect(
"an anchor whose window is narrower than the customary one must \
still get the customary sweep — it is a different sweep, not a \
duplicate of the one already tried",
);
assert_eq!(fs.partition_start(), PART_START);
assert_eq!(fs.root.entries[0].name, "INDEX.BDMV");
}
// ---- UDF 2.50 Metadata Partition coverage. Every BD-ROM's LVD carries TWO
// partition maps (Type 1 physical + Type 2 metadata, UDF 2.50 2.2.10); the tree
// lives in a Metadata File whose FE sits in the physical partition — untested.
/// How one synthetic UDF 2.50 metadata-partition volume is laid out.
/// Every field a disc controls is a knob so a single builder can produce
/// the conformant case and each malformed variant.
struct MetaVol {
/// Map type byte of the first partition map (1 = physical).
pm1_type: u8,
/// Length byte of the Type 1 physical partition map (real value: 6).
/// The Type 2 map begins at 440 + this.
pm1_len: u8,
/// Map type byte of the second partition map (2 = Type 2).
pm2_type: u8,
/// The Type 2 map's partition type identifier (UDF 2.50 2.2.10).
ident: &'static [u8],
/// UDF 2.50 2.2.10 Metadata File Location: the partition-relative
/// block recorded in the map as holding the Metadata File's FE.
meta_file_loc: u32,
/// Where the Metadata File's File Entry is ACTUALLY written
/// (partition-relative). Equal to `meta_file_loc` on a sane volume.
meta_fe_at: u32,
/// L_EA of that File Entry — its allocation descriptor starts at
/// 216 + L_EA, not at 216.
l_ea: usize,
/// The Metadata File's single extent: byte length and its
/// partition-relative LBA. Together these define the metadata
/// partition, i.e. `metadata_start` and `metadata_sectors`.
meta_bytes: u32,
meta_pos: u32,
/// A second, DIFFERENT Metadata-File-shaped File Entry planted at this
/// partition-relative block. Following the wrong one yields a
/// different metadata partition, so a reader that picks it up is
/// caught by `metadata_start`, not merely by an error.
decoy_meta_fe: Option<u32>,
}
// A conformant UDF 2.50 metadata-partition volume with the Metadata File FE where the
// partition map says it is.
fn conformant_meta_vol() -> MetaVol {
MetaVol {
pm1_type: 1,
pm1_len: 6,
pm2_type: 2,
ident: b"*UDF Metadata Partition",
meta_file_loc: 9,
meta_fe_at: 9,
l_ea: 8,
meta_bytes: 0x0102_0800, // 16 910 336 bytes == exactly 8257 sectors
meta_pos: 0x0203_0405,
decoy_meta_fe: None,
}
}
/// Metadata-partition-relative LBAs of the tree inside the Metadata File.
const MV_ROOT_ICB: u32 = 3;
const MV_ROOT_DATA: u32 = 4;
const MV_FILE_ICB: u32 = 5;
const MV_VOLUME_ID: &str = "FREEMKV-META";
const MV_FILE_SIZE: u64 = 100;
// Partition-relative LBAs of a SECOND, decoy tree rooted at block 0 of the physical
// partition. Distinguishes "used the metadata partition" from "fell back to physical" by
// CONTENT, not an error code.
const MV_FB_ROOT_ICB: u32 = 40;
const MV_FB_ROOT_DATA: u32 = 41;
const MV_FB_FILE_ICB: u32 = 42;
const MV_FB_FILE_SIZE: u64 = 777;
/// Lay out `spec` as a complete disc image. Also returns the absolute LBA
/// the metadata partition starts at, which is what `metadata_start()`
/// must come out as when the volume is conformant.
fn build_meta_vol(spec: &MetaVol) -> (fixture::MemDisc, u32) {
use fixture::{MemDisc, PART_START};
let mut disc = MemDisc::new();
// Anchor Volume Descriptor Pointer with no usable extent → the
// customary 32.. window is swept (exercised on its own elsewhere).
let mut avdp = vec![0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
disc.put_bytes(256, &avdp);
// Primary Volume Descriptor: Volume Identifier is a 32-byte d-string
// (ECMA-167 1/7.2.12) at offset 24 — compression id, characters, and
// the used length in the LAST byte of the field.
let mut pvd = vec![0u8; 2048];
pvd[0..2].copy_from_slice(&1u16.to_le_bytes());
pvd[24] = 8; // OSTA CS0 compression id 8 (8-bit characters)
pvd[25..25 + MV_VOLUME_ID.len()].copy_from_slice(MV_VOLUME_ID.as_bytes());
pvd[55] = 1 + MV_VOLUME_ID.len() as u8;
disc.put_bytes(32, &pvd);
let mut pd = vec![0u8; 2048];
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
disc.put_bytes(33, &pd);
// Logical Volume Descriptor with two partition maps (ECMA-167 3/10.6:
// number of maps at 268, maps themselves from 440; 3/10.7 gives each
// map a type byte then a length byte).
let mut lvd = vec![0u8; 2048];
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
lvd[268..272].copy_from_slice(&2u32.to_le_bytes());
// Second map first, so a spec whose first map has length 0 puts both
// maps in the same slot exactly as a corrupt descriptor would.
let map2 = 440 + spec.pm1_len as usize;
lvd[map2] = spec.pm2_type;
lvd[map2 + 1] = 64; // UDF 2.50 2.2.10: the map is 64 bytes
// EntityID (ECMA-167 1/7.4): flags byte then 23 identifier chars.
lvd[map2 + 5..map2 + 5 + spec.ident.len()].copy_from_slice(spec.ident);
lvd[map2 + 40..map2 + 44].copy_from_slice(&spec.meta_file_loc.to_le_bytes());
lvd[440] = spec.pm1_type;
lvd[441] = spec.pm1_len;
disc.put_bytes(34, &lvd);
let mut td = vec![0u8; 2048];
td[0..2].copy_from_slice(&8u16.to_le_bytes());
disc.put_bytes(35, &td);
// The Metadata File's own FE, in the PHYSICAL partition — its single allocation
// descriptor defines the whole metadata partition. The EA area is filled with a
// recognisable pattern so a descriptor read from the wrong offset can't be usable.
let mut meta_fe = vec![0u8; 2048];
meta_fe[0..2].copy_from_slice(&266u16.to_le_bytes());
meta_fe[208..212].copy_from_slice(&(spec.l_ea as u32).to_le_bytes());
meta_fe[212..216].copy_from_slice(&8u32.to_le_bytes()); // L_AD: one short_ad
meta_fe[216..216 + spec.l_ea].fill(0xA5);
let ad = 216 + spec.l_ea;
// A spec whose L_EA pushes the allocation descriptor off the end of
// the File Entry records no descriptor at all — that is the point of
// such a spec.
if ad + 8 <= meta_fe.len() {
meta_fe[ad..ad + 4].copy_from_slice(&spec.meta_bytes.to_le_bytes());
meta_fe[ad + 4..ad + 8].copy_from_slice(&spec.meta_pos.to_le_bytes());
}
disc.put_bytes(PART_START + spec.meta_fe_at, &meta_fe);
if let Some(block) = spec.decoy_meta_fe {
let mut decoy = vec![0u8; 2048];
decoy[0..2].copy_from_slice(&266u16.to_le_bytes());
decoy[212..216].copy_from_slice(&8u32.to_le_bytes());
decoy[216..220].copy_from_slice(&0x0011_2233u32.to_le_bytes());
decoy[220..224].copy_from_slice(&(spec.meta_pos.wrapping_add(1000)).to_le_bytes());
disc.put_bytes(PART_START + block, &decoy);
}
// Decoy file set at block 0, laid only when the Metadata File's FE isn't there.
// A failed metadata-partition resolution falls back to `partition_start`, where
// this tree mounts — turning a silent skip into a plausible, WRONG filesystem.
if spec.meta_fe_at != 0 {
let mut fb_fsd = vec![0u8; 2048];
fb_fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
fb_fsd[404..408].copy_from_slice(&MV_FB_ROOT_ICB.to_le_bytes());
disc.put_bytes(PART_START, &fb_fsd);
let mut fb_fids = Vec::new();
push_fid_iu(&mut fb_fids, "", MV_FB_ROOT_ICB, true, true, 0);
push_fid_iu(
&mut fb_fids,
"FALLBACK.BDMV",
MV_FB_FILE_ICB,
false,
false,
0,
);
disc.put_bytes(
PART_START + MV_FB_ROOT_ICB,
&build_dir_icb_tagged(266, 0, fb_fids.len() as u32, MV_FB_ROOT_DATA),
);
disc.put_bytes(PART_START + MV_FB_ROOT_DATA, &fb_fids);
disc.put_bytes(
PART_START + MV_FB_FILE_ICB,
&build_efe_icb(MV_FB_FILE_SIZE, MV_FB_FILE_SIZE as u32, 60),
);
}
// Inside the metadata partition: File Set Descriptor, root directory
// ICB, its FID list, and one file's ICB.
let meta_start = PART_START + spec.meta_pos;
let mut fsd = vec![0u8; 2048];
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
fsd[404..408].copy_from_slice(&MV_ROOT_ICB.to_le_bytes());
disc.put_bytes(meta_start, &fsd);
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", MV_ROOT_ICB, true, true, 0);
push_fid_iu(&mut fids, "INDEX.BDMV", MV_FILE_ICB, false, false, 0);
disc.put_bytes(
meta_start + MV_ROOT_ICB,
&build_dir_icb_tagged(266, 0, fids.len() as u32, MV_ROOT_DATA),
);
disc.put_bytes(meta_start + MV_ROOT_DATA, &fids);
disc.put_bytes(
meta_start + MV_FILE_ICB,
&build_efe_icb(MV_FILE_SIZE, MV_FILE_SIZE as u32, 20),
);
(disc, meta_start)
}
// A read fault while locating the Metadata File must surface as a read error, NOT "this is
// not a UDF disc" — mux::resolve memoises the latter per-disc and would silently drop
// content.
#[test]
fn a_read_fault_locating_the_metadata_file_is_not_reported_as_a_non_udf_disc() {
use fixture::PART_START;
/// Wraps a built volume and fails every read of the two candidate LBAs.
struct FailsMetaCandidates {
inner: fixture::MemDisc,
deny: Vec<u32>,
}
impl SectorSource for FailsMetaCandidates {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> crate::error::Result<usize> {
if (0..count as u32).any(|i| self.deny.contains(&(lba + i))) {
return Err(Error::DiscRead {
sector: lba as u64,
status: None,
sense: None,
});
}
self.inner.read_sectors(lba, count, buf, recovery)
}
}
let spec = MetaVol {
meta_file_loc: 9,
meta_fe_at: 9,
..conformant_meta_vol()
};
let (disc, _meta_start) = build_meta_vol(&spec);
// ONLY the recorded location faults; block 0 reads fine but holds Volume
// Descriptor content, not a File Entry, so meta_tag is non-zero-not-266 — a
// guard keyed on "nothing read" misses it. Denying both candidates wouldn't test this.
let mut reader = FailsMetaCandidates {
inner: disc,
deny: vec![PART_START + 9],
};
match super::read_filesystem(&mut reader) {
Err(Error::DiscRead { .. }) => {}
Err(Error::UdfNotFilesystem) => panic!(
"a transient read fault was reported as the deterministic verdict \
'not a UDF disc'; mux::resolve caches that for the whole disc"
),
other => panic!("expected a read error, got {other:?}"),
}
}
#[test]
fn read_filesystem_locates_the_metadata_file_where_the_partition_map_records_it() {
// UDF 2.50 2.2.10 Metadata Partition Map: Metadata File Location (Uint32 at
// map offset 40) is the ONLY thing that says where the Metadata File's File
// Entry lives; block 0 is common but not required, so it must mount elsewhere.
use fixture::PART_START;
let spec = MetaVol {
meta_file_loc: 9,
meta_fe_at: 9,
..conformant_meta_vol()
};
let (mut disc, meta_start) = build_meta_vol(&spec);
let fs = super::read_filesystem(&mut disc)
.expect("a UDF 2.50 metadata partition whose File Entry is not at block 0 must mount");
assert_eq!(fs.partition_start(), PART_START);
assert_eq!(
fs.metadata_start(),
meta_start,
"the metadata partition begins at the Metadata File's extent"
);
assert_eq!(
spec.meta_bytes, 16_910_336,
"the sector count below is computed from this byte length by hand"
);
assert_eq!(
fs.metadata_sectors(),
8257, // 16 910 336 / 2048, exactly
"the metadata partition is as long as the Metadata File's extent"
);
assert_eq!(fs.volume_id, MV_VOLUME_ID);
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
assert_eq!(fs.root.entries[0].size, MV_FILE_SIZE);
}
#[test]
fn read_filesystem_counts_a_partial_final_metadata_sector() {
// extent_length is a BYTE count (ECMA-167 4/14.14.1); metadata_sectors is a
// sector COUNT, so a remainder must round UP or the final partly-used sector
// (and its directory bytes) falls outside the range: 16910337 = 8257*2048+1 -> 8258.
let spec = MetaVol {
meta_bytes: 16_910_337,
..conformant_meta_vol()
};
let (mut disc, meta_start) = build_meta_vol(&spec);
let fs = super::read_filesystem(&mut disc)
.expect("a metadata extent that is not a whole number of sectors must still mount");
assert_eq!(fs.metadata_start(), meta_start);
assert_eq!(
fs.metadata_sectors(),
8258,
"the sector holding the final partial 1 byte belongs to the metadata partition"
);
}
#[test]
fn read_filesystem_still_finds_a_metadata_file_entry_recorded_at_block_zero() {
// The overwhelmingly common layout: the Metadata File's File Entry is at block
// 0 and the map's Metadata File Location says so. Honouring the recorded
// location must not change this ordinary case.
let spec = MetaVol {
meta_file_loc: 0,
meta_fe_at: 0,
..conformant_meta_vol()
};
let (mut disc, meta_start) = build_meta_vol(&spec);
let fs = super::read_filesystem(&mut disc).expect("the ordinary BD-ROM layout must mount");
assert_eq!(fs.metadata_start(), meta_start);
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
#[test]
fn read_filesystem_falls_back_to_block_zero_when_the_recorded_location_holds_no_file_entry() {
// A Metadata File Location pointing at a block with no File Entry is broken,
// not unmountable: the file is still discoverable at block 0. Trusting the
// field blindly would refuse a disc that used to mount.
let spec = MetaVol {
meta_file_loc: 999, // nothing is recorded there
meta_fe_at: 0,
..conformant_meta_vol()
};
let (mut disc, meta_start) = build_meta_vol(&spec);
let fs = super::read_filesystem(&mut disc)
.expect("a wrong Metadata File Location must not lose a volume readable at block 0");
assert_eq!(fs.metadata_start(), meta_start);
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
#[test]
fn read_filesystem_reads_no_metadata_file_location_out_of_a_sparable_partition_map() {
// UDF 2.2.9 Sparable / 2.2.8 Virtual maps are also Type 2 (ECMA-167 3/10.7.3)
// but record unrelated fields where 2.2.10 puts the Metadata File Location —
// misreading those bytes as a location would silently mount a decoy volume.
let spec = MetaVol {
ident: b"*UDF Sparable Partition",
meta_file_loc: 9,
decoy_meta_fe: Some(9),
meta_fe_at: 0,
..conformant_meta_vol()
};
let (mut disc, meta_start) = build_meta_vol(&spec);
let fs = super::read_filesystem(&mut disc)
.expect("a Type 2 map that is not a Metadata Partition Map must not misdirect the read");
assert_eq!(
fs.metadata_start(),
meta_start,
"the Metadata File at block 0 defines the partition, not a byte read out of a sparable map"
);
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
#[test]
fn read_filesystem_reads_a_metadata_file_extent_that_ends_flush_with_the_file_entry() {
// ECMA-167 4/14.17: allocation descriptors start at 216 + L_EA, and the EA area
// may run right up to where the short_ad exactly fills the rest of the block.
// That descriptor is still in-bounds and must be read, or the volume is lost.
let spec = MetaVol {
l_ea: 2048 - 216 - 8, // short_ad occupies the final 8 bytes
..conformant_meta_vol()
};
let (mut disc, meta_start) = build_meta_vol(&spec);
let fs = super::read_filesystem(&mut disc)
.expect("an allocation descriptor flush with the end of the File Entry is in bounds");
assert_eq!(fs.metadata_start(), meta_start);
assert_eq!(fs.metadata_sectors(), 8257); // 16 910 336 / 2048, exactly
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
#[test]
fn read_filesystem_refuses_a_metadata_file_whose_descriptor_runs_off_the_file_entry() {
// L_EA is disc-controlled; if 216 + L_EA leaves under 8 bytes, reading the
// short_ad anyway runs past the sector buffer. 1830 makes it STRADDLE the end
// (216+1830+8==2054), defeating a bound check computed the other way round.
let spec = MetaVol {
l_ea: 1830,
..conformant_meta_vol()
};
let (mut disc, _) = build_meta_vol(&spec);
let err = super::read_filesystem(&mut disc)
.expect_err("a short_ad that does not fit inside the File Entry cannot be read");
assert!(matches!(err, Error::DiscRead { .. }), "got {err:?}");
}
#[test]
fn read_filesystem_ignores_a_zero_length_first_partition_map() {
// ECMA-167 3/10.7 walks partition maps by each map's own length byte. A
// length-0 first map can't advance the walk, so misreading the map at 440 as
// the "second" one lets it hijack the mount onto a different, plausible fs.
let spec = MetaVol {
pm1_type: 2,
pm1_len: 0,
..conformant_meta_vol()
};
let (mut disc, _) = build_meta_vol(&spec);
let fs = super::read_filesystem(&mut disc)
.expect("a zero-length partition map must not abort the mount");
assert_eq!(fs.metadata_start(), fixture::PART_START);
assert_eq!(child_names(&fs.root), vec!["FALLBACK.BDMV".to_string()]);
}
#[test]
fn read_filesystem_seeds_the_cycle_guard_with_the_metadata_relative_root_key() {
// The cycle guard keys on (metadata_start, ICB LBA), pre-seeded with root's key
// so a self-referential FID is recognised. A wrongly composed key (e.g. ANDing
// instead of ORing the halves) misses the seed, descending root twice.
let spec = conformant_meta_vol();
let (mut disc, meta_start) = build_meta_vol(&spec);
// Re-lay the root's FID list with a directory entry pointing straight
// back at the root ICB.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", MV_ROOT_ICB, true, true, 0);
push_fid_iu(&mut fids, "LOOP", MV_ROOT_ICB, true, false, 0);
disc.put_bytes(meta_start + MV_ROOT_DATA, &fids);
let fs = super::read_filesystem(&mut disc).expect("a self-referential root must not hang");
assert_eq!(child_names(&fs.root), vec!["LOOP".to_string()]);
assert!(
fs.root.entries[0].entries.is_empty(),
"the root's own ICB is already visited, so LOOP is a leaf, not a second copy of the \
root: got {:?}",
child_names(&fs.root.entries[0])
);
}
#[test]
fn read_filesystem_stops_the_volume_descriptor_sweep_at_the_terminating_descriptor() {
// ECMA-167 3/8.4.2: a Terminating Descriptor ends the VDS. Sectors past it are
// not part of it — often a stale copy left by a rewritten volume — so reading on
// lets a stale Partition Descriptor overwrite the live one and offset every LBA.
use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir};
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(
&mut disc,
&DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("INDEX.BDMV", 12, 13, 2048, false)],
subdirs: Vec::new(),
},
);
// Stale Partition Descriptor recorded after the Terminating
// Descriptor the skeleton puts at sector 34.
let mut stale_pd = vec![0u8; 2048];
stale_pd[0..2].copy_from_slice(&5u16.to_le_bytes());
stale_pd[188..192].copy_from_slice(&(PART_START + 4096).to_le_bytes());
disc.put_bytes(35, &stale_pd);
let fs = super::read_filesystem(&mut disc)
.expect("descriptors past the terminator must not be swept");
assert_eq!(
fs.partition_start(),
PART_START,
"the live Partition Descriptor is the one before the Terminating Descriptor"
);
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
#[test]
fn read_filesystem_ignores_an_anchor_whose_main_vds_extent_starts_at_block_zero() {
// ECMA-167 3/10.2.1: block 0 is the reserved area, never a Volume Descriptor
// Sequence, so an anchor pointing there yields no Partition Descriptor and the
// disc looks unmountable — the customary window is the right fallback.
use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir};
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(
&mut disc,
&DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("INDEX.BDMV", 12, 13, 2048, false)],
subdirs: Vec::new(),
},
);
let mut avdp = vec![0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
// A length that satisfies the 16-sector minimum, at block 0.
avdp[16..20].copy_from_slice(&(VDS_MIN_SECTORS * 2048).to_le_bytes());
avdp[20..24].copy_from_slice(&0u32.to_le_bytes());
disc.put_bytes(256, &avdp);
let fs = super::read_filesystem(&mut disc)
.expect("an anchor pointing its VDS at block 0 must fall back, not fail the mount");
assert_eq!(fs.partition_start(), PART_START);
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
// Exercise directory-descriptor boundaries with untrusted field lengths and offsets.
fn build_dir_icb_tagged(tag: u16, l_ea: usize, raw_len: u32, ad_pos: u32) -> [u8; 2048] {
let mut icb = [0u8; 2048];
icb[0..2].copy_from_slice(&tag.to_le_bytes());
let (l_ea_off, ad_base) = if tag == 266 {
(208usize, 216usize)
} else {
(168usize, 176usize)
};
icb[l_ea_off..l_ea_off + 4].copy_from_slice(&(l_ea as u32).to_le_bytes());
// L_AD = 8: exactly one short_ad (ECMA-167 4/14.14.1).
icb[l_ea_off + 4..l_ea_off + 8].copy_from_slice(&8u32.to_le_bytes());
icb[ad_base..ad_base + l_ea].fill(0xA5);
let ad = ad_base + l_ea;
if ad + 8 <= icb.len() {
icb[ad..ad + 4].copy_from_slice(&raw_len.to_le_bytes());
icb[ad + 4..ad + 8].copy_from_slice(&ad_pos.to_le_bytes());
}
icb
}
// Append one FID (ECMA-167 4/14.4) to buf with an explicit L_IU area
// (4/14.4.7) between the header and name, padded per 4/14.4.9.
fn push_fid_iu(
buf: &mut Vec<u8>,
name: &str,
icb_lba: u32,
is_dir: bool,
is_parent: bool,
l_iu: u16,
) {
let start = buf.len();
let name_field: Vec<u8> = if name.is_empty() {
Vec::new()
} else {
let mut v = vec![0x08u8]; // OSTA CS0 compression id 8
v.extend_from_slice(name.as_bytes());
v
};
let mut fid = vec![0u8; 38];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
let mut fc = 0u8;
if is_dir {
fc |= 0x02;
}
if is_parent {
fc |= 0x08;
}
fid[18] = fc;
fid[19] = name_field.len() as u8;
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
fid[36..38].copy_from_slice(&l_iu.to_le_bytes());
buf.extend_from_slice(&fid);
buf.extend_from_slice(&vec![0xC3u8; l_iu as usize]);
buf.extend_from_slice(&name_field);
let used = buf.len() - start;
buf.resize(start + ((used + 3) & !3), 0);
}
/// Names of the children `read_directory` reported, in order.
fn child_names(d: &DirEntry) -> Vec<String> {
d.entries.iter().map(|e| e.name.clone()).collect()
}
#[test]
fn read_directory_reads_a_plain_file_entry_tag_261_directory_icb() {
// ECMA-167 4/14.9 File Entry (tag 261) is a valid directory ICB (UDF 1.02 DVD-Video),
// vs the Extended File Entry (266) of UDF 2.50. Its L_EA is at byte 168 and ADs begin at
// 176 + L_EA, NOT the 208/216 of a 266; mishandling it fails every UDF 1.02 directory.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..)
push_fid_iu(&mut fids, "VIDEO_TS.IFO", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(261, 0, fids.len() as u32, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(4096, 4096, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a File Entry (261) directory ICB is valid and must be readable");
assert_eq!(child_names(&parsed), vec!["VIDEO_TS.IFO".to_string()]);
assert_eq!(
parsed.entries[0].size, 4096,
"the child's size must come from its own ICB info_length"
);
}
/// Build a directory ICB whose ICB Tag flags (ECMA-167 4/14.6.8, Uint16 at
/// offset 34) select `ad_type`, with `body` written where the allocation
/// descriptors live (216 + L_EA for a 266) and L_AD set to its length.
fn build_dir_icb_flagged(ad_type: u16, body: &[u8]) -> [u8; 2048] {
let mut icb = [0u8; 2048];
icb[0..2].copy_from_slice(&266u16.to_le_bytes());
icb[34..36].copy_from_slice(&ad_type.to_le_bytes());
icb[56..64].copy_from_slice(&(body.len() as u64).to_le_bytes());
icb[208..212].copy_from_slice(&0u32.to_le_bytes()); // L_EA
icb[212..216].copy_from_slice(&(body.len() as u32).to_le_bytes()); // L_AD
icb[216..216 + body.len()].copy_from_slice(body);
icb
}
#[test]
fn read_directory_reads_a_directory_whose_fids_are_embedded_in_the_icb() {
// ECMA-167 4/14.6.8 ICB Tag flags bits 0-2 select storage; 3 = EMBEDDED (bytes sit in
// the entry's AD field, no out-of-line extent). Decoding it as a short_ad reads the first
// FID's bytes as a length/LBA, walking an unrelated sector and returning empty.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..)
push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(3, &fids));
reader.put(7, build_efe_icb(1024, 1024, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("an embedded (AD type 3) directory is valid and must be readable");
assert_eq!(child_names(&parsed), vec!["INDEX.BDMV".to_string()]);
assert_eq!(parsed.entries[0].size, 1024);
}
#[test]
fn read_directory_reads_fids_spanning_multiple_allocation_descriptors() {
// A FID list can span several ADs (ECMA-167 4/14.14.1). Extent 0 (sector 60) fills
// exactly 2048 bytes so the walk flows into extent 1 (sector 61) for the 2nd FID.
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0); // parent (..), 40 bytes
// Pad FIRST.CLPI's implementation-use area so e0 ends exactly at 2048.
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
assert_eq!(e0.len(), 2048, "extent 0 must fill its sector exactly");
let mut sec0 = [0u8; 2048];
sec0.copy_from_slice(&e0);
let mut e1 = Vec::new();
push_fid_iu(&mut e1, "SECOND.CLPI", 8, false, false, 0);
let mut sec1 = [0u8; 2048];
sec1[..e1.len()].copy_from_slice(&e1);
// Two short_ads: (recorded, len=2048, lba=60) then (recorded, len, lba=61).
let mut body = Vec::new();
body.extend_from_slice(&2048u32.to_le_bytes());
body.extend_from_slice(&60u32.to_le_bytes());
body.extend_from_slice(&(e1.len() as u32).to_le_bytes());
body.extend_from_slice(&61u32.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(61, sec1);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a multi-extent directory must be readable");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()],
"the FID in the SECOND allocation extent must be read, not truncated away"
);
assert_eq!(
parsed.size,
2048 + e1.len() as u64,
"the reported size is the sum of both recorded extents' declared lengths"
);
}
// read_directory follows a type-3 CONTINUATION pointer (ECMA-167 4/14.14.1.1):
// extent 0 (sector 60) has the first FID; a type-3 AD → block 50 → tail AD →
// extent 1 (sector 61) with the second FID. MUTATION: not following loses it.
#[test]
fn read_directory_follows_type3_continuation() {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0); // parent (..)
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
assert_eq!(e0.len(), 2048, "extent 0 must fill its sector exactly");
let mut sec0 = [0u8; 2048];
sec0.copy_from_slice(&e0);
let mut e1 = Vec::new();
push_fid_iu(&mut e1, "SECOND.CLPI", 8, false, false, 0);
let mut sec1 = [0u8; 2048];
sec1[..e1.len()].copy_from_slice(&e1);
// ICB ADs: (recorded, 2048, lba=60) then a type-3 continuation (→ lba 50).
let mut body = Vec::new();
body.extend_from_slice(&2048u32.to_le_bytes());
body.extend_from_slice(&60u32.to_le_bytes());
body.extend_from_slice(&((3u32 << 30) | 2048).to_le_bytes());
body.extend_from_slice(&50u32.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(50, build_cont_block(&[(0, e1.len() as u32, 61)]));
reader.put(61, sec1);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a directory with a continuation AD must be readable");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()],
"the FID in the continuation block must be read, not dropped"
);
assert_eq!(parsed.size, 2048 + e1.len() as u64);
}
// A continuation block opens with a 24-byte AED (tag 258); read_directory reads
// the tail ADs from offset 24, not 0. MUTATION: reading from 0 mis-parses the
// AED tag as a bogus AD and loses the continuation extent (Blu-ray 3D bug).
#[test]
fn read_directory_continuation_skips_aed_header() {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0);
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
let mut sec0 = [0u8; 2048];
sec0.copy_from_slice(&e0);
let mut e1 = Vec::new();
push_fid_iu(&mut e1, "SECOND.CLPI", 8, false, false, 0);
let mut sec1 = [0u8; 2048];
sec1[..e1.len()].copy_from_slice(&e1);
let mut body = Vec::new();
body.extend_from_slice(&2048u32.to_le_bytes());
body.extend_from_slice(&60u32.to_le_bytes());
body.extend_from_slice(&((3u32 << 30) | 2048).to_le_bytes());
body.extend_from_slice(&50u32.to_le_bytes());
let mut cont = build_cont_block(&[(0, e1.len() as u32, 61)]);
// Stamp a realistic AED tag (258) so a from-offset-0 regression mis-parses it.
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(50, cont);
reader.put(61, sec1);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("continuation with an AED header must be readable");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()],
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
);
}
// Hostile input: a continuation pointer that cycles to its own block must
// error (not loop, and not re-read the cycle's data extents as duplicates).
#[test]
fn read_directory_self_cycling_continuation_is_an_error() {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0);
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
let mut sec0 = [0u8; 2048];
sec0.copy_from_slice(&e0);
let mut body = Vec::new();
body.extend_from_slice(&2048u32.to_le_bytes());
body.extend_from_slice(&60u32.to_le_bytes());
body.extend_from_slice(&((3u32 << 30) | 2048).to_le_bytes());
body.extend_from_slice(&50u32.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
// Continuation block 50 holds a data extent, then points at ITSELF.
let (sec1, len1) = fid_sector("SECOND.CLPI", 8);
reader.put(50, build_cont_block(&[(0, len1, 61), (3, 2048, 50)]));
reader.put(61, sec1);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
let err = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect_err("a cyclic AD chain is corruption, like read_icb_extents");
assert!(matches!(err, Error::UdfAdChainTooLong), "got {err:?}");
}
#[test]
fn read_directory_reads_an_extended_ad_directorys_extent_location() {
// ECMA-167 4/14.14.3 extended_ad (20B) keeps extent_location at offset 12, NOT offset 4
// like short_ad/long_ad; ICB Tag flags = 2 selects it (4/14.6.8). Reading offset 4 gets
// recorded_length (here 2048), reading an empty sector so the directory comes back empty.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0);
push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut ext_ad = [0u8; 20];
ext_ad[0..4].copy_from_slice(&(fids.len() as u32).to_le_bytes()); // extent_length
ext_ad[4..8].copy_from_slice(&2048u32.to_le_bytes()); // recorded_length
ext_ad[8..12].copy_from_slice(&(fids.len() as u32).to_le_bytes()); // information_length
ext_ad[12..16].copy_from_slice(&60u32.to_le_bytes()); // extent_location
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(2, &ext_ad));
reader.put(60, dir);
reader.put(7, build_efe_icb(1024, 1024, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("an extended_ad directory must be readable");
assert_eq!(child_names(&parsed), vec!["INDEX.BDMV".to_string()]);
}
#[test]
fn read_directory_reads_a_long_ad_directorys_extent() {
// ICB Tag flags = 1 selects long_ad (16 bytes): extent_length(4) +
// extent_location{ lba(4) + part_ref(2) } + impl_use(6). The LBA is at offset 4
// (like short_ad), NOT offset 12 like extended_ad — the wrong offset walks an empty sector and the directory comes back empty.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0);
push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut long_ad = [0u8; 16];
long_ad[0..4].copy_from_slice(&(fids.len() as u32).to_le_bytes()); // extent_length
long_ad[4..8].copy_from_slice(&60u32.to_le_bytes()); // extent_location lba
// long_ad[8..10] = partition_ref, long_ad[10..16] = implementation_use (0).
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(1, &long_ad));
reader.put(60, dir);
reader.put(7, build_efe_icb(1024, 1024, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a long_ad directory must be readable");
assert_eq!(child_names(&parsed), vec!["INDEX.BDMV".to_string()]);
assert_eq!(parsed.size, fids.len() as u64);
}
#[test]
fn read_directory_masks_type_bits_so_a_zero_length_typed_ad_terminates() {
// ECMA-167 4/14.14.1.1: masking + the zero-length terminator only. A type-1 AD of
// length 0 ends the list; unmasked it reads non-zero and the walk reaches GHOST.
// (The type 1/2 skip: read_directory_does_not_read_unrecorded_extents_as_fids.)
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0);
push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 1959);
assert_eq!(fids.len(), 2048);
let mut dir = [0u8; 2048];
dir.copy_from_slice(&fids);
let (ghost, ghost_len) = fid_sector("GHOST.CLPI", 9);
let mut body = Vec::new();
for (raw, lba) in [(2048u32, 60u32), (1 << 30, 0), (ghost_len, 61)] {
body.extend_from_slice(&raw.to_le_bytes());
body.extend_from_slice(&lba.to_le_bytes());
}
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, dir);
reader.put(61, ghost);
reader.put(7, build_efe_icb(1024, 1024, 0));
reader.put(9, build_efe_icb(1024, 1024, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("directory parses");
assert_eq!(child_names(&parsed), vec!["INDEX.BDMV".to_string()]);
assert_eq!(parsed.size, 2048);
}
#[test]
fn read_directory_ignores_fid_bytes_past_the_declared_directory_length() {
// Information length (short_ad length) bounds the FID list; the sector-rounded buffer
// has trailing bytes past it that aren't the directory, so over-scanning reads a stale
// deleted FID as live. Layout: FID "A" (40B) at 0, "GHOST" at 40, declared length 40.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "A", 7, false, false, 0);
assert_eq!(
fids.len(),
40,
"first FID must be 40 bytes for this fixture"
);
push_fid_iu(&mut fids, "GHOST", 8, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, 40, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("dir parses");
assert_eq!(
child_names(&parsed),
vec!["A".to_string()],
"a FID header starting at the declared end of the directory is not an entry"
);
}
#[test]
fn read_directory_stops_at_a_descriptor_whose_tag_is_not_a_fid() {
// ECMA-167 3/7.2.1: the tag identifier is a 16-bit LE field (257 = FID). A single-byte
// compare would accept 0x0201 (513) as 257 (same low byte); it must be an error.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "REAL.MPLS", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
// A non-FID descriptor whose LOW tag byte still reads 0x01.
let next = fids.len();
dir[next..next + 2].copy_from_slice(&513u16.to_le_bytes());
// ... followed by bytes that would parse as a named file FID if the
// scan wrongly continued.
dir[next + 18] = 0x00;
dir[next + 19] = 6;
dir[next + 38] = 0x08;
dir[next + 39..next + 44].copy_from_slice(b"AFTER");
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, 2048, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(1, 2048, 0));
let dir = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("damage stops the listing, never fails it");
assert_eq!(
dir.entries
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>(),
["REAL.MPLS"]
);
}
#[test]
fn read_directory_keeps_a_fid_name_that_ends_exactly_at_the_buffer_end() {
// A FID ending on the buffer's last byte is fully present and must decode; treating
// "ends exactly at end" as "runs past end" drops the last file of a sector-filling
// directory. Parent FID ends at 2000, so the second FID's name runs 2038..2048.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 1959);
assert_eq!(fids.len(), 2000, "parent FID must end at 2000");
push_fid_iu(&mut fids, "ENDSATEND", 7, false, false, 0);
assert_eq!(fids.len(), 2048, "second FID must end exactly at 2048");
let mut dir = [0u8; 2048];
dir.copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, 2048, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(99, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("dir parses");
assert_eq!(child_names(&parsed), vec!["ENDSATEND".to_string()]);
assert_eq!(parsed.entries[0].size, 99);
}
#[test]
fn read_directory_stops_instead_of_panicking_when_a_name_runs_past_the_buffer() {
// L_IU (ECMA-167 4/14.4.7) is disc-controlled, so a malformed FID can
// place the name tens of KB past the buffer end. The scan must error;
// slicing there would panic out of the public API on a damaged disc.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "OK.MPLS", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
// Second FID: header inside the buffer, name far outside it.
let next = fids.len();
dir[next..next + 2].copy_from_slice(&257u16.to_le_bytes());
dir[next + 18] = 0x00;
dir[next + 19] = 8; // L_FI
dir[next + 36..next + 38].copy_from_slice(&60000u16.to_le_bytes()); // L_IU
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, 2048, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(5, 2048, 0));
let dir = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("damage stops the listing, never fails it");
assert_eq!(
dir.entries
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>(),
["OK.MPLS"]
);
}
#[test]
fn read_directory_admits_a_tree_that_exactly_fills_the_entry_budget() {
// The entry budget is an anti-DoS ceiling; a tree that exactly reaches
// it is still legal and must be enumerated, or the largest admissible
// disc becomes unreadable. Counter starts one below the cap.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "LAST.M2TS", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, fids.len() as u32, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(7, 2048, 0));
let mut budget = MAX_TOTAL_DIR_ENTRIES - 1;
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut budget,
&mut HashSet::new(),
)
.expect("a tree that exactly reaches the entry budget must still be enumerated");
assert_eq!(child_names(&parsed), vec!["LAST.M2TS".to_string()]);
assert_eq!(budget, MAX_TOTAL_DIR_ENTRIES, "the entry was counted");
}
/// Lay a chain of `levels` nested directories into `reader`: directory
/// *n* has ICB at LBA `100 + n`, FID list at LBA `200 + n`, and one
/// subdirectory FID naming `D{n+1}`.
fn lay_dir_chain(reader: &mut MemReader, levels: u32) {
for n in 0..levels {
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 100 + n, true, true, 0);
push_fid_iu(
&mut fids,
&format!("D{}", n + 1),
100 + n + 1,
true,
false,
0,
);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
reader.put(
100 + n,
build_dir_icb_tagged(266, 0, fids.len() as u32, 200 + n),
);
reader.put(200 + n, dir);
}
}
/// Length of the chain of first-children hanging off `root`, counting
/// `root` itself.
fn chain_len(root: &DirEntry) -> usize {
let mut n = 1;
let mut cur = root;
while let Some(next) = cur.entries.first() {
n += 1;
cur = next;
}
n
}
#[test]
fn read_directory_stops_descending_at_the_nesting_cap() {
// MAX_DIR_DEPTH bounds recursion; the directory AT the cap must be a
// leaf, not descended into (an off-by-one removes the termination
// bound). Chain is deeper than the cap so depth is cap-decided.
let deeper = MAX_DIR_DEPTH + 4;
let mut reader = MemReader::new();
lay_dir_chain(&mut reader, deeper);
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
100,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a deep but well-formed chain parses");
// Root is read at depth 0 and descends only while d < MAX_DIR_DEPTH, so
// the deepest directory actually READ is at MAX_DIR_DEPTH, with its
// child FID emitted as a leaf: MAX_DIR_DEPTH + 2 nodes total.
assert_eq!(
chain_len(&parsed),
MAX_DIR_DEPTH as usize + 2,
"recursion must stop at the nesting cap, not before or after it"
);
// The deepest node is still reported (its NAME is known from the
// FID) but carries no children.
let mut cur = &parsed;
while let Some(next) = cur.entries.first() {
cur = next;
}
assert!(
cur.is_dir,
"the capped node is still known to be a directory"
);
assert!(cur.entries.is_empty());
}
#[test]
fn read_directory_advances_past_a_fids_implementation_use_area() {
// ECMA-167 4/14.4.9: next FID starts at (38 + L_IU + L_FI) rounded up
// to 4. Omitting L_IU from the stride lands mid-descriptor, dropping
// every file after one with a non-empty IU area (here: stride 48 vs 40).
let mut fids = Vec::new();
push_fid_iu(&mut fids, "AAAAA", 7, false, false, 4);
assert_eq!(
fids.len(),
48,
"first FID stride must be 48 for this fixture"
);
push_fid_iu(&mut fids, "BBBBB", 8, false, false, 4);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, fids.len() as u32, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(1, 2048, 0));
reader.put(8, build_efe_icb(2, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("dir parses");
assert_eq!(
child_names(&parsed),
vec!["AAAAA".to_string(), "BBBBB".to_string()],
"the FID stride must include L_IU"
);
}
#[test]
fn read_directory_reads_an_ad_that_ends_exactly_at_the_icb_sector_end() {
// AD sits at 216 + L_EA (tag 266) or 176 + L_EA (tag 261). A short_ad
// whose last byte is the sector's last byte is entirely present, so
// the guard must reject only ADs that run PAST it, not an exact fit.
for (tag, ad_base) in [(266u16, 216usize), (261u16, 176usize)] {
let l_ea = 2048 - 8 - ad_base; // ad_off + 8 == 2048 exactly
let mut fids = Vec::new();
push_fid_iu(&mut fids, "TIGHT.CLPI", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(tag, l_ea, fids.len() as u32, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(3, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.unwrap_or_else(|e| panic!("tag {tag}: exact-fit AD must be read: {e:?}"));
assert_eq!(child_names(&parsed), vec!["TIGHT.CLPI".to_string()]);
}
}
#[test]
fn read_directory_rejects_an_ad_running_past_the_icb_sector() {
// L_EA is a disc-controlled 32-bit byte count. One large enough to
// push the allocation descriptor past the end of the 2048-byte ICB
// sector must be an error, not an out-of-bounds read.
for (tag, ad_base) in [(266u16, 216usize), (261u16, 176usize)] {
let l_ea = 2048 - 4 - ad_base; // ad_off + 8 == 2052 > 2048
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(tag, l_ea, 2048, 60));
let err = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect_err("an AD past the end of the ICB sector must fail");
assert!(
matches!(err, Error::DiscRead { sector: 5, .. }),
"tag {tag}: the error must name the ICB sector, got {err:?}"
);
}
}
#[test]
fn read_directory_locates_the_ad_after_a_non_empty_extended_attribute_area() {
// ECMA-167 4/14.9 / 4/14.17: Extended Attributes occupy L_EA bytes
// immediately before the ADs, so ADs begin at base + L_EA. Computing
// the offset any other way reads attribute/header bytes as an extent.
for (tag, _base) in [(266u16, 216usize), (261u16, 176usize)] {
let mut fids = Vec::new();
push_fid_iu(&mut fids, "WITHEA.MPLS", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(tag, 88, fids.len() as u32, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(6, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.unwrap_or_else(|e| panic!("tag {tag}: L_EA offset must be honoured: {e:?}"));
assert_eq!(child_names(&parsed), vec!["WITHEA.MPLS".to_string()]);
}
}
#[test]
fn read_directory_skips_a_deleted_fid_and_never_follows_its_icb() {
// ECMA-167 4/14.4.4 Deleted bit: the ICB MAY be a zero-length extent,
// so it must be skipped outright — following it reads whatever sits at
// metadata LBA 0, a hard error (dir) or phantom zero-byte file (file).
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..)
// Deleted directory FID with a zeroed ICB field, exactly as 4/14.4.3
// permits.
let del = fids.len();
push_fid_iu(&mut fids, "OLDDIR", 0, true, false, 0);
fids[del + 18] |= 0x04; // Deleted
push_fid_iu(&mut fids, "LIVE.MPLS", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, fids.len() as u32, 60));
reader.put(60, dir);
reader.put(7, build_efe_icb(64, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a deleted FID must not fail the enumeration of its directory");
assert_eq!(
child_names(&parsed),
vec!["LIVE.MPLS".to_string()],
"a deleted File Identifier Descriptor is not a file"
);
}
#[test]
fn read_directory_reads_every_byte_of_the_short_ad_length_and_location() {
// ECMA-167 4/14.14.1: short_ad is two 32-bit LE fields (length, then
// location), every byte distinct non-zero here — a wrong-offset read
// changes the reported length or the sector the FID list comes from.
let ad_len: u32 = 0x0002_012C;
let ad_pos: u32 = 0x0102_033C;
let mut fids = Vec::new();
push_fid_iu(&mut fids, "BYTEWISE.CLPI", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
for tag in [261u16, 266u16] {
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(tag, 0, ad_len, ad_pos));
reader.put(ad_pos, dir);
reader.put(7, build_efe_icb(12, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.unwrap_or_else(|e| panic!("tag {tag}: short_ad must be decoded: {e:?}"));
assert_eq!(
parsed.size, ad_len as u64,
"tag {tag}: every byte of the ExtentLength field is significant"
);
assert_eq!(
child_names(&parsed),
vec!["BYTEWISE.CLPI".to_string()],
"tag {tag}: every byte of the ExtentPosition field is significant"
);
}
}
#[test]
fn read_directory_rejects_a_length_whose_high_byte_alone_exceeds_the_cap() {
// The MSB alone of the 30-bit ExtentLength can demand a 16 MiB
// allocation. It must be read and the directory refused; ignoring it
// lets a crafted descriptor through as a legitimately empty directory.
for tag in [261u16, 266u16] {
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(tag, 0, 0x0100_0000, 60));
let err = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect_err("a 16 MiB directory length must be refused, not read as empty");
assert!(
matches!(err, Error::DiscRead { .. }),
"tag {tag}: got {err:?}"
);
}
}
#[test]
fn read_directory_reads_a_directory_exactly_at_the_size_cap() {
// MAX_DIR_BYTES bounds the allocation a corrupt 30-bit length can force.
// A directory declared exactly at the ceiling is admissible and must be
// read; rejecting it would make the largest legal STREAM/ dir unreadable.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "00000.M2TS", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, MAX_DIR_BYTES, 60));
reader.put(60, dir); // remaining sectors read as zeros → scan ends
reader.put(7, build_efe_icb(4, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a directory exactly at the size ceiling must be readable");
assert_eq!(child_names(&parsed), vec!["00000.M2TS".to_string()]);
}
#[test]
fn read_directory_reads_each_sector_of_a_multi_sector_directory_into_its_own_slot() {
// Multi-sector dirs are read one sector per own window; a later sector
// overwriting an earlier one destroys already-read FIDs. Sector 1 is
// blank — if its read lands at offset 0 it erases sector 0's entry.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "FIRST.CLPI", 7, false, false, 0);
let mut sector0 = [0u8; 2048];
sector0[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_tagged(266, 0, 4096, 60));
reader.put(60, sector0);
reader.put(61, [0u8; 2048]);
reader.put(7, build_efe_icb(8, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a two-sector directory parses");
assert_eq!(child_names(&parsed), vec!["FIRST.CLPI".to_string()]);
}
/// Serves `CountReader::expected` data, except reads touching LBA >= `bad`
/// scribble 0xEE into the buffer and fail (a scratched region).
struct FailingTailReader {
bad: u32,
}
impl SectorSource for FailingTailReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let need = count as usize * 2048;
if buf.len() < need {
return Err(Error::UdfBufferTooSmall);
}
if lba.saturating_add(count as u32) > self.bad {
buf[..need].fill(0xEE);
return Err(Error::UdfBufferTooSmall);
}
for i in 0..count as u32 {
let off = i as usize * 2048;
buf[off..off + 2048].copy_from_slice(&CountReader::expected(lba + i));
}
Ok(need)
}
}
// A failed miss (batch AND single retry fail) must not leave the old window
// marked valid over a shrunk/overwritten buffer: the next hit panicked slicing
// past the 2048-byte cache, or served the failed read's bytes.
#[test]
fn buffered_reader_failed_miss_invalidates_the_window() {
let mut inner = FailingTailReader { bad: 100 };
let mut br = BufferedSectorReader::new(&mut inner, 8);
let mut buf = [0u8; 2048];
br.read_sectors(0, 1, &mut buf, true)
.expect("seed window 0..8");
assert!(br.read_sectors(100, 1, &mut buf, true).is_err());
br.read_sectors(5, 1, &mut buf, true)
.expect("sector 5 is readable");
assert_eq!(buf, CountReader::expected(5), "stale window served");
}
// Offset 0 of the old window stays in bounds after the shrink, so a failed
// miss served the failed read's junk instead of panicking.
#[test]
fn buffered_reader_failed_miss_does_not_serve_its_junk_from_the_old_window() {
let mut inner = FailingTailReader { bad: 100 };
let mut br = BufferedSectorReader::new(&mut inner, 8);
let mut buf = [0u8; 2048];
br.read_sectors(0, 1, &mut buf, true)
.expect("seed window 0..8");
assert!(br.read_sectors(100, 1, &mut buf, true).is_err());
br.read_sectors(0, 1, &mut buf, true).expect("sector 0");
assert_eq!(buf, CountReader::expected(0), "failed read's bytes served");
}
// Build a sector holding one FID for `name` whose ICB is at `icb`.
fn fid_sector(name: &str, icb: u32) -> ([u8; 2048], u32) {
let mut v = Vec::new();
push_fid_iu(&mut v, name, icb, false, false, 0);
let mut s = [0u8; 2048];
s[..v.len()].copy_from_slice(&v);
(s, v.len() as u32)
}
// ECMA-167 4/14.14.1.1: extent types 1 (allocated, not recorded) and 2 (not
// allocated) hold no FID data; their LBA must not be read as directory content.
#[test]
fn read_directory_does_not_read_unrecorded_extents_as_fids() {
for ext_type in [1u32, 2] {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0);
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
assert_eq!(e0.len(), 2048);
let mut sec0 = [0u8; 2048];
sec0.copy_from_slice(&e0);
let (ghost, _) = fid_sector("GHOST.CLPI", 9);
let (sec1, len1) = fid_sector("SECOND.CLPI", 8);
let mut body = Vec::new();
for (raw, lba) in [(2048u32, 60u32), ((ext_type << 30) | 2048, 62), (len1, 61)] {
body.extend_from_slice(&raw.to_le_bytes());
body.extend_from_slice(&lba.to_le_bytes());
}
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(61, sec1);
reader.put(62, ghost);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
reader.put(9, build_efe_icb(33, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("directory with an unrecorded extent parses");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()],
"type {ext_type} extent was read as FID data"
);
assert_eq!(parsed.size, 2048 + len1 as u64);
}
}
// A non-final extent shorter than a sector must not leave sector padding
// between it and the next extent's FIDs (the walk stopped at the padding).
#[test]
fn read_directory_joins_a_short_non_final_extent_without_padding() {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0);
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 0);
let len0 = e0.len() as u32;
let mut sec0 = [0u8; 2048];
sec0[..e0.len()].copy_from_slice(&e0);
let (sec1, len1) = fid_sector("SECOND.CLPI", 8);
let mut body = Vec::new();
for (raw, lba) in [(len0, 60u32), (len1, 61)] {
body.extend_from_slice(&raw.to_le_bytes());
body.extend_from_slice(&lba.to_le_bytes());
}
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(61, sec1);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("multi-extent directory parses");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()]
);
assert_eq!(parsed.size, (len0 + len1) as u64);
}
/// `n` short ADs of (type, len, lba), then an optional type-3 pointer.
fn short_ads(ads: &[(u32, u32, u32)]) -> Vec<u8> {
let mut v = Vec::new();
for &(t, len, lba) in ads {
v.extend_from_slice(&((t << 30) | len).to_le_bytes());
v.extend_from_slice(&lba.to_le_bytes());
}
v
}
// Each 1-byte extent costs a full sector read; without a cap on sectors read,
// hundreds of tiny extents bypass MAX_DIR_BYTES (which counts only bytes kept).
#[test]
fn read_directory_caps_sector_reads_across_many_tiny_extents() {
let mut icb_ads = vec![(0u32, 1u32, 60u32); 228];
icb_ads.push((3, 2048, 50));
let mut cont_a = vec![(0u32, 1u32, 60u32); 252];
cont_a.push((3, 2048, 51));
let cont_b = vec![(0u32, 1u32, 60u32); 252];
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &short_ads(&icb_ads)));
reader.put(50, build_cont_block(&cont_a));
reader.put(51, build_cont_block(&cont_b));
let res = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
);
assert!(
res.is_err(),
"732 one-byte extents read 732 sectors past the 512-sector budget"
);
}
// ECMA-167 4/14.5: a continuation block must be an Allocation Extent Descriptor.
#[test]
fn read_directory_rejects_a_continuation_block_that_is_not_an_aed() {
let (sec1, len1) = fid_sector("SECOND.CLPI", 8);
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &short_ads(&[(3, 2048, 50)])));
let mut cont = build_cont_block(&[(0, len1, 61)]);
cont[0..2].copy_from_slice(&0u16.to_le_bytes());
reader.put(50, cont);
reader.put(61, sec1);
reader.put(8, build_efe_icb(22, 2048, 0));
let res = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
);
assert!(res.is_err(), "non-AED continuation accepted: {res:?}");
}
#[test]
fn icb_extents_reject_a_continuation_block_that_is_not_an_aed() {
let icb = build_efe(4096, &[(0, 2048, 10), (3, 2048, 50)]);
let mut cont = build_cont_block(&[(0, 2048, 20)]);
cont[0..2].copy_from_slice(&0u16.to_le_bytes());
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont);
let fs = fs_with(0, 0, file_entry("F", 5, 4096));
let res = fs.read_icb_extents(&mut reader, 5);
assert!(res.is_err(), "non-AED continuation accepted: {res:?}");
}
/// Rewrite the Metadata File FE of a `build_meta_vol` volume with `ads`.
fn put_meta_fe(disc: &mut fixture::MemDisc, spec: &MetaVol, ads: &[(u32, u32, u32)]) {
let body = short_ads(ads);
let mut fe = vec![0u8; 2048];
fe[0..2].copy_from_slice(&266u16.to_le_bytes());
fe[208..212].copy_from_slice(&(spec.l_ea as u32).to_le_bytes());
fe[212..216].copy_from_slice(&(body.len() as u32).to_le_bytes());
let ad = 216 + spec.l_ea;
fe[ad..ad + body.len()].copy_from_slice(&body);
disc.put_bytes(fixture::PART_START + spec.meta_fe_at, &fe);
}
// The Metadata File's first AD defines metadata_start; an unrecorded (1/2) or
// continuation (3) AD holds no metadata there, so it must not be mounted.
#[test]
fn read_filesystem_rejects_a_metadata_file_whose_first_extent_is_not_recorded() {
for t in [1u32, 2, 3] {
let spec = conformant_meta_vol();
let (mut disc, _) = build_meta_vol(&spec);
put_meta_fe(&mut disc, &spec, &[(t, spec.meta_bytes, spec.meta_pos)]);
let res = super::read_filesystem(&mut disc);
assert!(res.is_err(), "type {t} first metadata AD mounted");
}
}
// A fragmented Metadata File: metadata blocks past the first extent live in
// the second extent, not contiguously after the first.
#[test]
fn read_filesystem_maps_metadata_blocks_through_every_metadata_file_extent() {
use fixture::PART_START;
let spec = conformant_meta_vol();
let (mut disc, meta_start) = build_meta_vol(&spec);
// Extent 0 = metadata blocks 0..4 (FSD, root ICB); extent 1 = blocks 4.. at pos2.
let pos2 = spec.meta_pos + 100_000;
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", MV_ROOT_ICB, true, true, 0);
push_fid_iu(&mut fids, "INDEX.BDMV", MV_FILE_ICB, false, false, 0);
disc.put_bytes(meta_start + MV_ROOT_DATA, &[0u8; 2048]);
disc.put_bytes(meta_start + MV_FILE_ICB, &[0u8; 2048]);
disc.put_bytes(PART_START + pos2, &fids);
disc.put_bytes(
PART_START + pos2 + 1,
&build_efe_icb(MV_FILE_SIZE, MV_FILE_SIZE as u32, 20),
);
put_meta_fe(
&mut disc,
&spec,
&[(0, 4 * 2048, spec.meta_pos), (0, 4 * 2048, pos2)],
);
let fs = super::read_filesystem(&mut disc).expect("fragmented metadata file mounts");
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
assert_eq!(fs.root.entries[0].size, MV_FILE_SIZE);
}
// ECMA-167 3/10.6.13: the LVD's Logical Volume Contents Use (long_ad @248)
// records where the File Set Descriptor is; it need not be block 0.
#[test]
fn read_filesystem_reads_the_fsd_where_the_lvd_records_it() {
let spec = conformant_meta_vol();
let (mut disc, meta_start) = build_meta_vol(&spec);
let mut fsd = vec![0u8; 2048];
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
fsd[404..408].copy_from_slice(&MV_ROOT_ICB.to_le_bytes());
disc.put_bytes(meta_start, &[0u8; 2048]);
disc.put_bytes(meta_start + 2, &fsd);
let mut lvd = vec![0u8; 2048];
let mut one = [0u8; 2048];
disc.read_sectors(34, 1, &mut one, true).expect("lvd");
lvd.copy_from_slice(&one);
lvd[248..252].copy_from_slice(&2048u32.to_le_bytes());
lvd[252..256].copy_from_slice(&2u32.to_le_bytes());
lvd[256..258].copy_from_slice(&1u16.to_le_bytes());
disc.put_bytes(34, &lvd);
let fs = super::read_filesystem(&mut disc).expect("FSD at LVD-recorded block mounts");
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
// Only the FIRST Metadata File AD must be recorded; a later unrecorded AD ends
// the map rather than failing a mount that worked before extents were honoured.
#[test]
fn read_filesystem_mounts_a_metadata_file_with_a_trailing_unrecorded_extent() {
let spec = conformant_meta_vol();
let (mut disc, _) = build_meta_vol(&spec);
put_meta_fe(
&mut disc,
&spec,
&[(0, spec.meta_bytes, spec.meta_pos), (1, 2048, 5)],
);
let fs = super::read_filesystem(&mut disc).expect("trailing type-1 AD mounts");
assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]);
}
// A subdirectory whose AD chain can't be followed is listed empty (as Linux and
// libudfread lose only that dir), so e.g. a broken BACKUP can't block the mount.
#[test]
fn read_directory_lists_a_subdirectory_with_a_broken_ad_chain_as_empty() {
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0);
push_fid_iu(&mut fids, "BACKUP", 9, true, false, 0);
push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0);
let mut sec = [0u8; 2048];
sec[..fids.len()].copy_from_slice(&fids);
let mut reader = MemReader::new();
reader.put(
5,
build_dir_icb_flagged(0, &short_ads(&[(0, fids.len() as u32, 60)])),
);
reader.put(60, sec);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(9, build_dir_icb_flagged(0, &short_ads(&[(3, 2048, 50)])));
let mut not_aed = build_cont_block(&[(0, 2048, 61)]);
not_aed[0..2].copy_from_slice(&0u16.to_le_bytes());
reader.put(50, not_aed);
let parsed = read_directory(
&mut reader,
&mut 0,
&MetaMap::contiguous(0),
5,
"ROOT",
0,
&mut 0,
&mut HashSet::new(),
)
.expect("a broken subdirectory must not fail the parent");
assert_eq!(
child_names(&parsed),
vec!["BACKUP".to_string(), "INDEX.BDMV".to_string()]
);
assert!(parsed.entries[0].is_dir && parsed.entries[0].entries.is_empty());
}
// An AED whose L_AD runs past its block is corrupt (Linux/libudfread reject it).
#[test]
fn icb_extents_reject_an_aed_whose_l_ad_overruns_the_block() {
let icb = build_efe(4096, &[(0, 2048, 10), (3, 2048, 50)]);
let mut cont = build_cont_block(&[(0, 2048, 20)]);
cont[20..24].copy_from_slice(&2025u32.to_le_bytes());
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont);
let fs = fs_with(0, 0, file_entry("F", 5, 4096));
let res = fs.read_icb_extents(&mut reader, 5);
assert!(res.is_err(), "overrunning L_AD accepted: {res:?}");
}