Files
MattJackson f1926c38dc v0.20.1: delete SectorReader, extract Disc::patch, doc/stub cleanup
WO-2 (delete SectorReader trait):
- The 0.18 trait split into SectorSource (read-only) and SectorSink
  (write-only) is final; the legacy SectorReader alias was a bridge.
- Renames every internal &mut dyn SectorReader (~25 sites) to
  &mut dyn SectorSource. The trait method capacity() becomes
  capacity_sectors() with a default of 0 (preserves SectorReader's
  default-0 behavior).
- Deletes the SectorReader trait, its blanket-to-Source bridge, and
  the FileSectorReader type alias. Adds explicit forwarding impls
  for Box<dyn SectorSource> and &mut dyn SectorSource so generic
  decorators like DecryptingSectorSource<S: SectorSource> compose.

WO-3a (extract Disc::patch):
- Moves Disc::patch (1230 lines) and bytes_bad_in_title from
  disc/mod.rs into disc/patch.rs as a split inherent impl. Zero
  behavior change — pure mechanical relocation. disc/mod.rs drops
  from 3,945 to 2,714 LOC.

WO-6 (partial):
- Deletes src/labels/png_filenames.rs — was a 72-LOC stub with
  detect() returning false, never wired into the PARSERS registry.

project docs doc drift fixes (audited 2026-05-13):
- JUMP_BASE_SECTORS: 256→1024 (64 MB base for UHD, not 8 MB)
- PASSN_DAMAGE_THRESHOLD_PCT: 12→6
- PASSN_SKIP_SECTORS_BASE: 64→32
- MAX_RANGE_SECS=180: replaced by proportional range_sectors × 25,
  capped at RANGE_BUDGET_CAP_SECS=1800.
2026-05-13 11:36:55 -07:00

476 lines
17 KiB
Rust

//! UDF parser tests using a MockSectorReader.
use libfreemkv::error::Result;
use libfreemkv::{SectorSource, read_filesystem};
use std::collections::HashMap;
const SECTOR_SIZE: usize = 2048;
/// In-memory sector reader backed by a HashMap<LBA, sector_data>.
/// Any LBA not in the map returns zeroed sectors.
struct MockSectorReader {
sectors: HashMap<u32, Vec<u8>>,
}
impl MockSectorReader {
fn new() -> Self {
Self {
sectors: HashMap::new(),
}
}
/// Write a full 2048-byte sector at the given LBA.
fn set_sector(&mut self, lba: u32, data: Vec<u8>) {
assert_eq!(
data.len(),
SECTOR_SIZE,
"sector data must be exactly 2048 bytes"
);
self.sectors.insert(lba, data);
}
/// Write partial data into a sector (rest is zeroed).
fn set_sector_partial(&mut self, lba: u32, data: &[u8]) {
let mut sector = vec![0u8; SECTOR_SIZE];
let len = data.len().min(SECTOR_SIZE);
sector[..len].copy_from_slice(&data[..len]);
self.sectors.insert(lba, sector);
}
}
impl SectorSource for MockSectorReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
assert!(buf.len() >= total, "buffer too small");
for i in 0..count as u32 {
let offset = i as usize * SECTOR_SIZE;
if let Some(data) = self.sectors.get(&(lba + i)) {
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
} else {
// Return zeros for unmapped sectors
buf[offset..offset + SECTOR_SIZE].fill(0);
}
}
Ok(total)
}
}
// ── Helper: build raw sector data ──────────────────────────────────────────
/// Build an AVDP sector (tag_id=2) pointing to VDS at the given LBA.
fn make_avdp_sector(vds_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
// Tag ID = 2 (AVDP) at bytes [0..2]
s[0..2].copy_from_slice(&2u16.to_le_bytes());
// Main VDS extent location at bytes [16..20]
s[16..20].copy_from_slice(&vds_lba.to_le_bytes());
// Main VDS extent length at bytes [20..24] (arbitrary, say 6 sectors)
s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes());
s
}
/// Build a Primary Volume Descriptor (tag_id=1) with the given volume ID.
fn make_pvd_sector(volume_id: &str) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&1u16.to_le_bytes());
// Volume ID at offset 24, d-string format:
// compression_id(1 byte) + ASCII chars + length byte at position 55
if !volume_id.is_empty() {
let id_bytes = volume_id.as_bytes();
s[24] = 8; // compression ID = ASCII
let copy_len = id_bytes.len().min(30);
s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]);
// d-string length byte at end of 32-byte field (offset 55)
s[55] = (1 + copy_len) as u8; // compression byte + chars
}
s
}
/// Build a Partition Descriptor (tag_id=5) with partition_start.
fn make_partition_desc(partition_start: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&5u16.to_le_bytes());
// Partition start at bytes [188..192]
s[188..192].copy_from_slice(&partition_start.to_le_bytes());
s
}
/// Build a Logical Volume Descriptor (tag_id=6) with a single partition map
/// (no metadata partition -- simplest case).
fn make_lvd_sector_simple() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&6u16.to_le_bytes());
// num_partition_maps at bytes [268..272] = 1 (no metadata partition)
s[268..272].copy_from_slice(&1u32.to_le_bytes());
s
}
/// Build a Terminating Descriptor (tag_id=8).
fn make_terminator() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&8u16.to_le_bytes());
s
}
/// Build a File Set Descriptor (tag_id=256) with root ICB at the given meta LBA.
fn make_fsd_sector(root_meta_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&256u16.to_le_bytes());
// Root directory ICB long_ad at offset 400:
// [400..404] = extent_length (one sector = 2048)
s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
// [404..408] = extent_location (LBA within metadata partition)
s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes());
s
}
/// Build an Extended File Entry (tag_id=266) for a directory with the given
/// allocation extent pointing to directory data.
fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&266u16.to_le_bytes());
// info_length at offset 56 (u64)
s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes());
// L_EA at offset 208 = 0 (no extended attributes)
s[208..212].copy_from_slice(&0u32.to_le_bytes());
// L_AD at offset 212 = 8 (one short allocation descriptor)
s[212..216].copy_from_slice(&8u32.to_le_bytes());
// Short allocation descriptor at offset 216:
// [216..220] = extent_length (type=0 in top 2 bits, length in low 30 bits)
s[216..220].copy_from_slice(&data_len.to_le_bytes());
// [220..224] = extent_position (LBA within metadata partition)
s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes());
s
}
/// Build a File Identifier Descriptor (tag_id=257) for a named entry.
/// Returns raw bytes (not padded to full sector).
fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec<u8> {
// FID name: compression_id(1) + ASCII bytes
let mut name_bytes = vec![8u8]; // compression ID = 8 (ASCII)
name_bytes.extend_from_slice(name.as_bytes());
let l_fi = name_bytes.len() as u8;
let file_chars: u8 = if is_dir { 0x02 } else { 0x00 };
// Fixed header = 38 bytes, L_IU = 0
let fid_len = (38 + l_fi as usize + 3) & !3; // 4-byte aligned
let mut fid = vec![0u8; fid_len];
// tag_id = 257
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
// file_characteristics at offset 18
fid[18] = file_chars;
// L_FI at offset 19
fid[19] = l_fi;
// ICB long_ad at offset 20:
// [20..24] = extent_length (2048)
fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
// [24..28] = extent_location (LBA)
fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes());
// L_IU at offset 36 = 0
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
// Name starts at offset 38
fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
fid
}
/// Build a parent FID (file_chars = 0x08, no name).
fn make_parent_fid() -> Vec<u8> {
let fid_len = (38 + 3) & !3;
let mut fid = vec![0u8; fid_len];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
fid[18] = 0x08; // parent
fid[19] = 0; // L_FI = 0
fid
}
/// Build a File Entry ICB (tag_id=261) for a file, with the given extent.
fn make_file_icb(data_lba: u32, data_len: u32, file_size: u64) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&261u16.to_le_bytes());
// info_length at offset 56 (u64)
s[56..64].copy_from_slice(&file_size.to_le_bytes());
// L_EA at offset 168 = 0
s[168..172].copy_from_slice(&0u32.to_le_bytes());
// L_AD at offset 172 = 8
s[172..176].copy_from_slice(&8u32.to_le_bytes());
// Short allocation descriptor at offset 176:
s[176..180].copy_from_slice(&data_len.to_le_bytes());
s[180..184].copy_from_slice(&data_lba.to_le_bytes());
s
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[test]
fn mock_sector_reader_roundtrip() {
let mut reader = MockSectorReader::new();
// Write a recognizable pattern to sector 100
let mut data = vec![0u8; SECTOR_SIZE];
data[0] = 0xAB;
data[1] = 0xCD;
data[2047] = 0xFF;
reader.set_sector(100, data.clone());
// Read it back
let mut buf = vec![0u8; SECTOR_SIZE];
let n = reader.read_sectors(100, 1, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 0xAB);
assert_eq!(buf[1], 0xCD);
assert_eq!(buf[2047], 0xFF);
// Reading an unmapped sector returns zeros
let mut buf2 = vec![0xFFu8; SECTOR_SIZE];
let n2 = reader.read_sectors(999, 1, &mut buf2, true).unwrap();
assert_eq!(n2, SECTOR_SIZE);
assert_eq!(buf2[0], 0);
assert_eq!(buf2[2047], 0);
}
#[test]
fn mock_sector_reader_multi_sector() {
let mut reader = MockSectorReader::new();
let mut s10 = vec![0u8; SECTOR_SIZE];
s10[0] = 10;
reader.set_sector(10, s10);
let mut s11 = vec![0u8; SECTOR_SIZE];
s11[0] = 11;
reader.set_sector(11, s11);
// Read 2 consecutive sectors
let mut buf = vec![0u8; SECTOR_SIZE * 2];
let n = reader.read_sectors(10, 2, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE * 2);
assert_eq!(buf[0], 10);
assert_eq!(buf[SECTOR_SIZE], 11);
}
#[test]
fn read_filesystem_no_avdp() {
// Empty reader — sector 256 is all zeros, tag_id=0 != 2
let mut reader = MockSectorReader::new();
let result = read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when no AVDP at sector 256");
}
#[test]
fn read_filesystem_bad_avdp_tag() {
// Put a sector at 256 with wrong tag_id
let mut reader = MockSectorReader::new();
let mut bad = vec![0u8; SECTOR_SIZE];
bad[0..2].copy_from_slice(&99u16.to_le_bytes()); // tag_id=99, not 2
reader.set_sector(256, bad);
let result = read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when AVDP tag_id is not 2");
}
#[test]
fn read_filesystem_no_partition_descriptor() {
// Valid AVDP but VDS has no Partition Descriptor (tag 5)
let mut reader = MockSectorReader::new();
reader.set_sector(256, make_avdp_sector(32));
// Put a terminator immediately at sector 32
reader.set_sector(32, make_terminator());
let result = read_filesystem(&mut reader);
assert!(
result.is_err(),
"should fail when no partition descriptor in VDS"
);
}
#[test]
fn read_filesystem_bad_fsd_tag() {
// Valid AVDP + VDS with partition desc + LVD, but FSD at metadata_start has wrong tag
let mut reader = MockSectorReader::new();
let partition_start = 512;
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("TEST_DISC"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// With 1 partition map, metadata_start = partition_start.
// FSD should be at sector partition_start but we leave it as zeros (tag_id=0 != 256).
let result = read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when FSD tag_id is not 256");
}
#[test]
fn read_filesystem_minimal_valid() {
// Build a minimal valid UDF image: AVDP -> VDS -> FSD -> empty root dir
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
let root_icb_meta_lba: u32 = 1; // relative to metadata_start
let root_data_meta_lba: u32 = 2;
// Sector 256: AVDP
reader.set_sector(256, make_avdp_sector(32));
// VDS at sectors 32..35
reader.set_sector(32, make_pvd_sector("MY_DISC"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD at partition_start (since single partition map, metadata_start = partition_start)
reader.set_sector(partition_start, make_fsd_sector(root_icb_meta_lba));
// Root directory ICB at metadata_start + root_icb_meta_lba
// Points to directory data at root_data_meta_lba, length = 40 (one parent FID)
let parent_fid = make_parent_fid();
let dir_data_len = parent_fid.len() as u32;
reader.set_sector(
partition_start + root_icb_meta_lba,
make_dir_icb(root_data_meta_lba, dir_data_len),
);
// Root directory data: just a parent FID (empty directory)
reader.set_sector_partial(partition_start + root_data_meta_lba, &parent_fid);
let fs = read_filesystem(&mut reader).expect("should parse minimal UDF");
assert_eq!(fs.volume_id, "MY_DISC");
assert!(fs.root.is_dir);
assert!(fs.root.entries.is_empty(), "root should have no children");
}
#[test]
fn read_filesystem_with_subdirectory() {
// Build a UDF image with root -> BDMV (dir) -> test.mpls (file)
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
// Layout (all relative to partition_start which equals metadata_start):
// meta LBA 0 = FSD
// meta LBA 1 = root ICB
// meta LBA 2 = root dir data
// meta LBA 3 = BDMV ICB
// meta LBA 4 = BDMV dir data
// meta LBA 5 = test.mpls file ICB
// meta LBA 10 = test.mpls file data (partition-relative)
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("DISC_WITH_BDMV"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD -> root ICB at meta LBA 1
reader.set_sector(partition_start, make_fsd_sector(1));
// Root dir: parent FID + BDMV dir FID
let parent_fid = make_parent_fid();
let bdmv_fid = make_fid("BDMV", 3, true);
let mut root_data = Vec::new();
root_data.extend_from_slice(&parent_fid);
root_data.extend_from_slice(&bdmv_fid);
let root_data_len = root_data.len() as u32;
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data_len));
reader.set_sector_partial(partition_start + 2, &root_data);
// BDMV dir: parent FID + test.mpls file FID
let bdmv_parent = make_parent_fid();
let mpls_fid = make_fid("test.mpls", 5, false);
let mut bdmv_data = Vec::new();
bdmv_data.extend_from_slice(&bdmv_parent);
bdmv_data.extend_from_slice(&mpls_fid);
let bdmv_data_len = bdmv_data.len() as u32;
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data_len));
reader.set_sector_partial(partition_start + 4, &bdmv_data);
// test.mpls file ICB (File Entry tag 261)
reader.set_sector(partition_start + 5, make_file_icb(10, 1024, 1024));
let fs = read_filesystem(&mut reader).expect("should parse UDF with subdir");
assert_eq!(fs.volume_id, "DISC_WITH_BDMV");
// Root should have one child: BDMV
assert_eq!(fs.root.entries.len(), 1);
let bdmv = &fs.root.entries[0];
assert_eq!(bdmv.name, "BDMV");
assert!(bdmv.is_dir);
// BDMV should have one child: test.mpls
assert_eq!(bdmv.entries.len(), 1);
let mpls = &bdmv.entries[0];
assert_eq!(mpls.name, "test.mpls");
assert!(!mpls.is_dir);
assert_eq!(mpls.size, 1024);
}
#[test]
fn find_dir_case_insensitive() {
// Build a UDF image with BDMV/PLAYLIST directories, then search with various cases
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("CASE_TEST"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD
reader.set_sector(partition_start, make_fsd_sector(1));
// Root -> BDMV
let root_data = [make_parent_fid(), make_fid("BDMV", 3, true)].concat();
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data.len() as u32));
reader.set_sector_partial(partition_start + 2, &root_data);
// BDMV -> PLAYLIST
let bdmv_data = [make_parent_fid(), make_fid("PLAYLIST", 5, true)].concat();
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data.len() as u32));
reader.set_sector_partial(partition_start + 4, &bdmv_data);
// PLAYLIST (empty)
let playlist_data = make_parent_fid();
reader.set_sector(
partition_start + 5,
make_dir_icb(6, playlist_data.len() as u32),
);
reader.set_sector_partial(partition_start + 6, &playlist_data);
let fs = read_filesystem(&mut reader).expect("should parse");
// Exact case
assert!(fs.find_dir("BDMV/PLAYLIST").is_some());
// Lower case
assert!(fs.find_dir("bdmv/playlist").is_some());
// Mixed case
assert!(fs.find_dir("Bdmv/Playlist").is_some());
assert!(fs.find_dir("BdMv/PlayList").is_some());
// Leading/trailing slashes
assert!(fs.find_dir("/BDMV/PLAYLIST/").is_some());
// Nonexistent
assert!(fs.find_dir("BDMV/STREAM").is_none());
assert!(fs.find_dir("NONEXISTENT").is_none());
}
#[test]
fn sector_reader_is_object_safe() {
// Verify SectorSource can be used as a trait object
let mut reader = MockSectorReader::new();
reader.set_sector(0, vec![42u8; SECTOR_SIZE]);
let dyn_reader: &mut dyn SectorSource = &mut reader;
let mut buf = vec![0u8; SECTOR_SIZE];
let n = dyn_reader.read_sectors(0, 1, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 42);
}