//! UDF filesystem reader — read files from Blu-ray discs. //! //! Blu-ray discs use UDF 2.50 with metadata partitions. //! The read sequence follows pointers through the disc structure: //! //! Sector 256 (AVDP) //! → VDS (Partition Descriptor + Logical Volume Descriptor) //! → Metadata Partition (virtual partition stored as a file) //! → File Set Descriptor (FSD) //! → Root Directory ICB //! → Directory data (File Identifier Descriptors) //! → BDMV/PLAYLIST/*.mpls, BDMV/CLIPINF/*.clpi //! //! Each step reads one or two sectors. No bulk reads needed. //! //! References: //! ECMA-167 (UDF base) //! UDF 2.50 (OSTA) — metadata partition extension //! BD-ROM Part 3 — Blu-ray filesystem profile use crate::error::{Error, Result}; use crate::sector::SectorSource; use std::collections::HashSet; /// Upper bound on a single UNBOUNDED metadata file read (`read_file`). /// BD-ROM metadata files (.mpls/.clpi/.bdmv/.inf) are a few KiB to a few /// MiB; 64 MiB is a generous ceiling that bounds the allocation a crafted /// ICB info_length / extent length can force. /// /// The one legitimately huge file — the AACS `MKB_RO.inf`, allocated to a /// fixed ~128 MiB and zero-padded — is NOT read through the unbounded path: /// `read_aacs_inputs_from_reader` reads a bounded prefix via /// `read_file_prefix` and trims to the real record length, so it never /// trips this cap (and never reads 100+ MiB of padding). A 0.31.0 /// regression added this cap and read the MKB unbounded, so the cap /// rejected it → `read_aacs_inputs` failed → autorip reported "could not /// read this disc's key files" and never contacted the keyserver. const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; /// Upper bound on a single directory's on-disc data. Real BD-ROM /// directories are a few KiB; 1 MiB is well above any legitimate value. /// Caps the allocation so a corrupt 30-bit directory ICB allocation /// length cannot force a ~1 GiB zeroed allocation per recursion level. const MAX_DIR_BYTES: u32 = 1024 * 1024; /// A UDF filesystem parsed from disc. #[derive(Debug)] pub struct UdfFs { /// Root directory with full tree pub root: DirEntry, /// UDF Volume Identifier from Primary Volume Descriptor pub volume_id: String, /// Physical partition start (absolute sector) partition_start: u32, /// Metadata partition start (absolute sector) /// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs metadata_start: u32, /// Metadata partition size in sectors metadata_sectors: u32, } /// A directory or file entry. #[derive(Debug, Clone)] pub struct DirEntry { pub name: String, pub is_dir: bool, /// LBA within the metadata partition (add metadata_start for absolute) pub meta_lba: u32, /// File size in bytes (from ICB info_length) pub size: u64, /// Child entries (if directory) pub entries: Vec, } impl UdfFs { /// Physical partition start sector. pub fn partition_start(&self) -> u32 { self.partition_start } /// Metadata partition start sector. pub fn metadata_start(&self) -> u32 { self.metadata_start } /// Metadata partition size in sectors. pub(crate) fn metadata_sectors(&self) -> u32 { self.metadata_sectors } /// Find a directory by path (e.g. "/BDMV/PLAYLIST"). /// Path matching is case-insensitive. pub fn find_dir(&self, path: &str) -> Option<&DirEntry> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; for part in &parts { current = current .entries .iter() .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part))?; } Some(current) } /// Get the absolute starting LBA of a file's first data extent on disc. /// Used by the rip pipeline to locate m2ts content sectors. pub fn file_start_lba(&self, reader: &mut dyn SectorSource, path: &str) -> Result { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; for part in &parts[..parts.len() - 1] { current = current .entries .iter() .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part)) .ok_or_else(|| Error::UdfNotFound { path: part.to_string(), })?; } let filename = match parts.last() { Some(f) => f, None => { return Err(Error::UdfNotFound { path: path.to_string(), }); } }; let entry = current .entries .iter() .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case(filename)) .ok_or_else(|| Error::UdfNotFound { path: path.to_string(), })?; let (data_lba, _) = self.read_icb_extent(reader, entry.meta_lba)?; self.partition_start .checked_add(data_lba) .ok_or(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, }) } /// Read a file by path, returning its raw bytes. /// Reads all data extents sector by sector from disc — no buffering. pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result> { self.read_file_limited(reader, path, None) } /// Read at most `max_bytes` of a file (rounded up to a whole sector), /// stopping early rather than reading the whole file. /// /// Used to read only the real, record-length portion of the AACS /// `MKB_RO.inf` — allocated to a fixed ~128 MiB and zero-padded — instead /// of reading 100+ MiB of padding (and tripping `MAX_FILE_BYTES`). The /// caller trims the returned prefix to the MKB record length. pub fn read_file_prefix( &self, reader: &mut dyn SectorSource, path: &str, max_bytes: usize, ) -> Result> { self.read_file_limited(reader, path, Some(max_bytes)) } /// Shared implementation of [`read_file`] / [`read_file_prefix`]. When /// `max_bytes` is `Some`, reads at most that many bytes and the whole-file /// `MAX_FILE_BYTES` anti-DoS cap on the declared size / extent lengths is /// not applied (the read is already bounded by `max_bytes`). fn read_file_limited( &self, reader: &mut dyn SectorSource, path: &str, max_bytes: Option, ) -> Result> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; // Navigate to parent directory for part in &parts[..parts.len() - 1] { current = current .entries .iter() .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part)) .ok_or_else(|| Error::UdfNotFound { path: part.to_string(), })?; } // Find the file let filename = match parts.last() { Some(f) => f, None => { return Err(Error::UdfNotFound { path: path.to_string(), }); } }; let entry = current .entries .iter() .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case(filename)) .ok_or_else(|| Error::UdfNotFound { path: path.to_string(), })?; // `max_bytes == None` => read the whole file; `Some(n)` => read at most // n bytes (rounded up to a sector) and skip the anti-DoS caps below. let limit = max_bytes.unwrap_or(usize::MAX); // Tiny files (notably the AACS `*.inf` key files) may store their data // embedded inline in the ICB (AD type 3), with no out-of-line extents. // Honor that before the extent path, which would otherwise misparse the // embedded bytes as allocation descriptors and (since 0.31.0) hard-error // on the resulting bogus extent length. if let Some(mut inline) = self.read_inline_data(reader, entry.meta_lba)? { let want = (entry.size as usize).min(limit); if inline.len() > want { inline.truncate(want); } return Ok(inline); } // Read the file's data extents. Multi-extent files (fragmented or split // across dual layers) would otherwise be silently truncated to the // first extent, since the buffer is sized to entry.size and truncate() // can't grow it. let extents = self.read_icb_extents(reader, entry.meta_lba)?; // Reject an oversized declared total before allocating: entry.size is a // raw u64 off the ICB, so a crafted file could otherwise force a // multi-hundred-MiB / GiB allocation. Only for the UNBOUNDED path — a // bounded read is limited by `limit` regardless of the declared size. if max_bytes.is_none() && entry.size > MAX_FILE_BYTES { return Err(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, }); } // Read the file DATA from the physical partition (partition_start + // lba), NOT the metadata partition: ICBs are in metadata, data is in // physical. Pre-allocate to the smaller of declared size and the // requested prefix (capped so a bogus entry.size can't reserve GiB). let cap_hint = (entry.size as usize) .min(limit) .min(MAX_FILE_BYTES as usize); let mut data = Vec::with_capacity(cap_hint); let mut sector = [0u8; 2048]; 'extents: for (data_lba, data_len) in extents { if max_bytes.is_none() { // Anti-DoS guards for the unbounded path: a crafted ICB can // chain many extents whose running total grows `data` into GiB, // or declare a single oversized extent. (Skipped when bounded — // `limit` already caps the read.) if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES || data_len as u64 > MAX_FILE_BYTES { return Err(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, }); } } let abs_start = self .partition_start .checked_add(data_lba) .ok_or(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, })?; let sector_count = (data_len as u64).div_ceil(2048) as u32; for i in 0..sector_count { if data.len() >= limit { break 'extents; } let abs = abs_start.checked_add(i).ok_or(Error::DiscRead { sector: abs_start as u64, status: None, sense: None, })?; read_sector(reader, abs, &mut sector)?; data.extend_from_slice(§or); } } // Trim to the real file size, or to the requested prefix — whichever is // smaller. If extents under-covered the file (e.g. sparse), leave what // we have rather than over-reporting. let trim_to = (entry.size as usize).min(limit); if data.len() > trim_to { data.truncate(trim_to); } Ok(data) } /// Collect all sector ranges needed for disc-info and AACS. /// /// Returns a list of (start_lba, sector_count) ranges covering: /// - UDF structure (AVDP, VDS, metadata partition, directories) /// - every non-STREAM file the tree walk reaches that is <= 50 MB /// /// Skip policy (actual): directories named `STREAM` (case-insensitive) /// are not descended, and individual files larger than 50 MB are /// omitted. Nothing else is filtered by name — `BACKUP`/`DUPLICATE` /// are traversed, and `MKB_RO.inf` is excluded only because it exceeds /// the 50 MB cap. pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result> { let mut ranges = Vec::new(); // UDF structure: sector 0 through end of metadata partition // Covers AVDP, VDS, partition descriptor, metadata ICB, FSD, all directories let meta_end = self.metadata_start.saturating_add(self.metadata_sectors); ranges.push((0, meta_end)); // Walk tree, collect ranges for each metadata file self.collect_file_ranges(reader, &self.root, &mut ranges)?; // Merge overlapping/adjacent ranges and sort ranges.sort_by_key(|r| r.0); let merged = merge_ranges(&ranges); Ok(merged) } /// All sector ranges that contain data (metadata + all files including STREAM). /// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps. pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result> { let mut ranges = Vec::new(); // UDF structure sectors let meta_end = self.metadata_start.saturating_add(self.metadata_sectors); ranges.push((0, meta_end)); // Walk entire tree including STREAM directories self.collect_all_file_ranges(reader, &self.root, &mut ranges)?; // Merge overlapping/adjacent ranges and sort ranges.sort_by_key(|r| r.0); let merged = merge_ranges(&ranges); Ok(merged) } fn collect_all_file_ranges( &self, reader: &mut dyn SectorSource, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>, ) -> Result<()> { for child in &entry.entries { if child.is_dir { self.collect_all_file_ranges(reader, child, ranges)?; } else { // Include the ICB sector ranges.push((self.meta_to_abs(child.meta_lba)?, 1)); // Include ALL file data extents (large m2ts files have many) if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) { for (data_lba, data_len) in extents { let abs_start = match self.partition_start.checked_add(data_lba) { Some(v) => v, None => continue, }; let sector_count = (data_len as u64).div_ceil(2048) as u32; ranges.push((abs_start, sector_count)); } } } } Ok(()) } fn collect_file_ranges( &self, reader: &mut dyn SectorSource, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>, ) -> Result<()> { for child in &entry.entries { if child.is_dir { // Only skip STREAM — those are the multi-GB video files if child.name.eq_ignore_ascii_case("STREAM") { continue; } self.collect_file_ranges(reader, child, ranges)?; } else { // Include the ICB sector itself (in metadata partition) ranges.push((self.meta_to_abs(child.meta_lba)?, 1)); // Include file data — skip only truly huge files (MKB_RO.inf = 134MB) if child.size > 50_000_000 { continue; } // Push every extent: a fragmented AACS cert / MPLS / CLPI can // span multiple extents, and key readers downstream need all // of them (mirror collect_all_file_ranges). if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) { for (data_lba, data_len) in extents { let abs_start = match self.partition_start.checked_add(data_lba) { Some(v) => v, None => continue, }; let sector_count = (data_len as u64).div_ceil(2048) as u32; ranges.push((abs_start, sector_count)); } } } } Ok(()) } /// Convert a metadata-partition-relative LBA to an absolute sector number. /// `meta_lba` is disc-controlled, so the sum is checked to avoid a /// wrap-to-wrong-sector on a crafted ICB. fn meta_to_abs(&self, meta_lba: u32) -> Result { self.metadata_start .checked_add(meta_lba) .ok_or(Error::DiscRead { sector: self.metadata_start as u64, status: None, sense: None, }) } /// Read an Extended File Entry (tag 266) or File Entry (tag 261) /// and return its first allocation extent: (data_lba, data_length). /// The data_lba is partition-relative. fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result<(u32, u32)> { let extents = self.read_icb_extents(reader, meta_lba)?; extents.first().copied().ok_or(Error::DiscRead { // Diagnostic sector only; meta_to_abs can overflow on a crafted // meta_lba, in which case 0 is a harmless placeholder for the // error-context field. sector: self.meta_to_abs(meta_lba).unwrap_or(0) as u64, status: None, sense: None, }) } /// If this ICB stores its file data INLINE (embedded — ICB Tag flags low /// 3 bits == 3) rather than via out-of-line extents, return the embedded /// bytes. Tiny files such as the AACS `*.inf` key files are routinely /// embedded directly in the ICB; `read_icb_extents` finds no real extents /// for them (it would misparse the embedded payload as allocation /// descriptors), so `read_file` must read the inline payload here. Returns /// `Ok(None)` for the normal extent-backed case. /// /// (Regression guard: 0.31.0 added a per-extent `MAX_FILE_BYTES` cap that /// turned the misparsed-embedded case into a hard error, which surfaced as /// autorip "could not read this disc's key files" on discs whose AACS /// `.inf` files are ICB-embedded — the keyserver was then never called.) fn read_inline_data( &self, reader: &mut dyn SectorSource, meta_lba: u32, ) -> Result>> { let icb_abs = self.meta_to_abs(meta_lba)?; let mut icb = [0u8; 2048]; read_sector(reader, icb_abs, &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); let (ad_offset, l_ad) = match tag { // Extended File Entry (266) / standard File Entry (261): the // allocation-descriptors field (which, for embedded files, holds // the data itself) begins after the extended attributes. 266 => { let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize; let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize; (216 + l_ea, l_ad) } 261 => { let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize; let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize; (176 + l_ea, l_ad) } _ => return Ok(None), }; // ICB Tag flags: u16 at absolute offset 34, low 3 bits select the AD // type. 3 == data embedded inline in the ICB. let icb_flags = u16::from_le_bytes([icb[34], icb[35]]); if (icb_flags & 0x07) != 3 { return Ok(None); } if ad_offset > icb.len() || ad_offset + l_ad > icb.len() { return Err(Error::DiscRead { sector: icb_abs as u64, status: None, sense: None, }); } Ok(Some(icb[ad_offset..ad_offset + l_ad].to_vec())) } /// Read ALL allocation extents for a file from its ICB. /// Returns Vec of (partition_relative_lba, byte_length) pairs. /// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents) /// including files whose allocation descriptors span multiple blocks via /// continuation (extent_type 3) descriptors. fn read_icb_extents( &self, reader: &mut dyn SectorSource, meta_lba: u32, ) -> Result> { let icb_abs = self.meta_to_abs(meta_lba)?; let mut icb = [0u8; 2048]; read_sector(reader, icb_abs, &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); // Get allocation descriptor offset and total length based on ICB type let (ad_offset, l_ad) = match tag { // Extended File Entry (UDF 2.50, used by BD-ROM) 266 => { let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize; let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize; let ad_offset = 216 + l_ea; if ad_offset + l_ad > icb.len() { return Err(Error::DiscRead { sector: icb_abs as u64, status: None, sense: None, }); } (ad_offset, l_ad) } // Standard File Entry 261 => { let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize; let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize; let ad_offset = 176 + l_ea; if ad_offset + l_ad > icb.len() { return Err(Error::DiscRead { sector: icb_abs as u64, status: None, sense: None, }); } (ad_offset, l_ad) } _ => { return Err(Error::DiscRead { sector: icb_abs as u64, status: None, sense: None, }); } }; // Allocation-descriptor type lives in the ICB Tag flags (low 3 // bits). The ICB Tag immediately follows the 16-byte descriptor // tag, and its `flags` u16 is the last field at ICB-tag offset 18 // → absolute offset 34, for both File Entry (261) and Extended // File Entry (266). 0 = Short AD (8 bytes), 1 = Long AD (16 bytes), // 2 = Extended AD (20 bytes), 3 = data embedded inline in the ICB. // // This MUST be honoured: a Short AD and a Long AD both carry // length+lba in their first 8 bytes, so hardcoding an 8-byte // stride reads descriptor #0 of a Long-AD file correctly but lands // descriptor #1 in the middle of the first Long AD (its impl_use // bytes) — garbage that trips the terminator/unknown-type break. // Large BD-ROM .m2ts streams use Long ADs, so that bug truncated // every multi-extent title at its first extent (~973 MB-1 GiB). let icb_flags = u16::from_le_bytes([icb[34], icb[35]]); let ad_type = (icb_flags & 0x07) as usize; let ad_size: usize = match ad_type { 0 => 8, // Short AD 1 => 16, // Long AD 2 => 20, // Extended AD // 3 = inline/embedded data (no out-of-line extents) — never // used for large stream files. Anything else is unexpected; // fall back to the historical 8-byte stride rather than fail // the whole title. _ => 8, }; let mut extents = Vec::new(); // Parse the first allocation-descriptor list from the ICB. A type-3 // descriptor ("next extent of allocation descriptors") points at a // continuation block in the metadata partition holding more ADs; we // follow the chain. The hop count is bounded to avoid looping on a // crafted/corrupt disc. let mut block = icb; let mut ad_start = ad_offset; let mut ad_bytes = l_ad; const MAX_AD_BLOCKS: usize = 256; for _ in 0..MAX_AD_BLOCKS { let num_descriptors = ad_bytes / ad_size; let mut next_block: Option = None; for i in 0..num_descriptors { let off = ad_start + i * ad_size; if off + ad_size > block.len() { break; } let raw_len = u32::from_le_bytes([ block[off], block[off + 1], block[off + 2], block[off + 3], ]); let extent_type = raw_len >> 30; let data_len = raw_len & 0x3FFF_FFFF; // Short and Long ADs carry the extent LBA at off+4. Extended // ADs (20 bytes) place their extent_location lb_addr after // three length fields, at off+12. let lba_off = if ad_size == 20 { off + 12 } else { off + 4 }; let data_lba = u32::from_le_bytes([ block[lba_off], block[lba_off + 1], block[lba_off + 2], block[lba_off + 3], ]); match extent_type { // Recorded and allocated. A zero-length type-0 // descriptor is the AD-list terminator (continuation // blocks are scanned to the end of the sector, so the // trailing zero padding must not be read as extents). 0 if data_len == 0 => break, 0 => extents.push((data_lba, data_len)), 1 => {} // allocated but not recorded (sparse) 3 => { // Continuation: the rest of the ADs live in the block // at data_lba (metadata-partition-relative). Stop // scanning this block and follow the pointer. if data_len > 0 { next_block = Some(data_lba); } break; } _ => break, } } match next_block { Some(cont_lba) => { read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?; // A continuation block is a list of Short ADs from byte 0, // spanning the whole sector. ad_start = 0; ad_bytes = block.len(); } None => break, } } Ok(extents) } /// If the ICB at `meta_lba` stores its data inline (embedded, AD type 3), /// return the embedded bytes; `Ok(None)` for the normal extent-backed case. /// Public wrapper over [`read_inline_data`](Self::read_inline_data) so the /// per-file tree extractor can honor inline nav files without re-walking a /// path. The caller trims to the entry's declared `size`. pub fn inline_data_at( &self, reader: &mut dyn SectorSource, meta_lba: u32, ) -> Result>> { self.read_inline_data(reader, meta_lba) } /// Absolute disc extents `(absolute_lba, byte_length)` for the ICB at /// `meta_lba`. Like [`file_extents`](Self::file_extents) but keyed by ICB /// LBA (so the tree extractor can resolve a `DirEntry` it already holds /// without re-navigating a path) and preserving the per-extent byte length /// (so the last sector can be trimmed to the file's real size). Resolves /// multi-extent / Long-AD / continuation ICBs. pub fn extents_abs_at( &self, reader: &mut dyn SectorSource, meta_lba: u32, ) -> Result> { let alloc = self.read_icb_extents(reader, meta_lba)?; let mut out = Vec::with_capacity(alloc.len()); for (lba, byte_len) in alloc { let abs = self .partition_start .checked_add(lba) .ok_or(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, })?; out.push((abs, byte_len)); } Ok(out) } /// Get all absolute disc sector extents for a file. /// Returns Vec of (absolute_lba, sector_count) covering the entire file. pub fn file_extents( &self, reader: &mut dyn SectorSource, path: &str, ) -> Result> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; for part in &parts[..parts.len() - 1] { current = current .entries .iter() .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part)) .ok_or_else(|| Error::UdfNotFound { path: part.to_string(), })?; } let filename = match parts.last() { Some(f) => f, None => { return Err(Error::UdfNotFound { path: path.to_string(), }); } }; let entry = current .entries .iter() .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case(filename)) .ok_or_else(|| Error::UdfNotFound { path: path.to_string(), })?; let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?; let mut disc_extents = Vec::with_capacity(alloc_extents.len()); for (lba, byte_len) in alloc_extents { let abs_lba = self .partition_start .checked_add(lba) .ok_or(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, })?; let sectors = (byte_len as u64).div_ceil(2048) as u32; disc_extents.push((abs_lba, sectors)); } Ok(disc_extents) } } /// Read the UDF filesystem from a Blu-ray disc. /// /// Follows the UDF pointer chain: /// 1. AVDP (sector 256) → VDS location /// 2. VDS → Partition Descriptor (physical partition start) /// → Logical Volume Descriptor (FSD location + partition maps) /// 3. Metadata partition file → metadata content location /// 4. FSD → root directory ICB /// 5. Root directory → file tree pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result { // Step 1: Anchor Volume Descriptor Pointer at sector 256 // ECMA-167 §10.2 — always at sector 256 let mut avdp = [0u8; 2048]; read_sector(reader, 256, &mut avdp)?; let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]); if tag_id != 2 { return Err(Error::DiscRead { sector: 256, status: None, sense: None, }); } // Main VDS extent location: bytes [16:20] = LBA, [20:24] = length // (We use the VDS at sectors 32+, not the reserve copy at sector 32768+) // Step 2: Read Volume Descriptor Sequence (sectors 32-37 typically) // Find Partition Descriptor (tag 5) and Logical Volume Descriptor (tag 6) let mut partition_start: u32 = 0; let mut num_partition_maps: u32 = 0; let mut lvd_sector: Option = None; let mut volume_id = String::new(); let mut metadata_size_bytes: u32 = 0; for i in 32..64 { let mut desc = [0u8; 2048]; read_sector(reader, i, &mut desc)?; let desc_tag = u16::from_le_bytes([desc[0], desc[1]]); match desc_tag { // Primary Volume Descriptor — volume identifier at offset 24, 32-byte d-string 1 => { volume_id = parse_dstring(&desc[24..56]); } // Partition Descriptor — tells us where the physical partition starts 5 => { partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]); } // Logical Volume Descriptor — contains FSD location and partition maps 6 => { num_partition_maps = u32::from_le_bytes([desc[268], desc[269], desc[270], desc[271]]); lvd_sector = Some(i); } // Terminating Descriptor — end of VDS 8 => break, _ => continue, } } if partition_start == 0 { return Err(Error::DiscRead { sector: 0, status: None, sense: None, }); } // Step 3: Parse partition maps from LVD to find metadata partition // BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition") // The metadata file is stored at lba=0 of the physical partition let metadata_start = if num_partition_maps >= 2 { let lvd_sec = lvd_sector.ok_or(Error::DiscRead { sector: 0, status: None, sense: None, })?; // Read LVD to check partition map type let mut lvd = [0u8; 2048]; read_sector(reader, lvd_sec, &mut lvd)?; // Parse partition maps starting at offset 440 // Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata) let _pm1_type = lvd[440]; // First map type let pm1_len = lvd[441] as usize; if pm1_len > 0 && 440 + pm1_len < 2048 { let pm2_type = lvd[440 + pm1_len]; // Second map type if pm2_type == 2 { // Type 2 = metadata partition // The metadata file ICB is at physical partition lba 0 // Read it to find where the metadata content starts let meta_file_lba = partition_start; // lba 0 of partition let mut meta_icb = [0u8; 2048]; read_sector(reader, meta_file_lba, &mut meta_icb)?; let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]); if meta_tag == 266 { // Extended File Entry — get allocation extent let l_ea = u32::from_le_bytes([ meta_icb[208], meta_icb[209], meta_icb[210], meta_icb[211], ]) as usize; let ad_off = 216 + l_ea; if ad_off + 8 > meta_icb.len() { return Err(Error::DiscRead { sector: meta_file_lba as u64, status: None, sense: None, }); } let ad_len = u32::from_le_bytes([ meta_icb[ad_off], meta_icb[ad_off + 1], meta_icb[ad_off + 2], meta_icb[ad_off + 3], ]) & 0x3FFF_FFFF; metadata_size_bytes = ad_len; let ad_pos = u32::from_le_bytes([ meta_icb[ad_off + 4], meta_icb[ad_off + 5], meta_icb[ad_off + 6], meta_icb[ad_off + 7], ]); // Metadata content starts at partition_start + ad_pos partition_start.checked_add(ad_pos).ok_or(Error::DiscRead { sector: partition_start as u64, status: None, sense: None, })? } else { // Fallback: no metadata partition, use physical partition directly partition_start } } else { partition_start } } else { partition_start } } else { // Single partition map — no metadata partition (older UDF) partition_start }; // Step 4: Read File Set Descriptor from metadata partition // FSD is at metadata-relative lba 0 (first sector of metadata content) let mut fsd = [0u8; 2048]; read_sector(reader, metadata_start, &mut fsd)?; let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]); if fsd_tag != 256 { return Err(Error::DiscRead { sector: metadata_start as u64, status: None, sense: None, }); } // Root Directory ICB: long_ad at FSD offset 400 // long_ad = extent_length(4) + extent_location: lba(4) + part_ref(2) + impl_use(6) let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]); // Step 5: Read root directory and build file tree. // Pre-seed visited with the root ICB so that any FID pointing back to // root_lba is detected as a cycle immediately. let root_icb_key = ((metadata_start as u64) << 32) | root_lba as u64; let mut visited: HashSet = HashSet::from([root_icb_key]); let root = read_directory( reader, partition_start, metadata_start, root_lba, "", 0, &mut 0usize, &mut visited, )?; let metadata_sectors = (metadata_size_bytes as u64).div_ceil(2048) as u32; Ok(UdfFs { root, volume_id, partition_start, metadata_start, metadata_sectors, }) } /// Maximum directory nesting depth followed when building the tree. /// Bounds recursion on a corrupt/looping disc; real BD-ROM and DVD trees /// are far shallower (BDMV/BACKUP/BDJO is the deepest standard path at 3). const MAX_DIR_DEPTH: u32 = 8; /// Global cap on the total number of directory entries (FIDs) visited across /// the entire tree walk. Each named non-parent FID counts as one entry, /// regardless of whether it is a file or directory. /// /// Real BD-ROM discs have at most a few thousand entries; the largest real /// partition (BDMV/STREAM/) typically holds a few hundred .m2ts FIDs. /// 100 000 is well above any legitimate disc and makes the 8-level × 26k-dirs /// attack (8^26k astronomical visits) terminate in microseconds. const MAX_TOTAL_DIR_ENTRIES: usize = 100_000; /// Read a UDF directory and its children (up to [`MAX_DIR_DEPTH`] levels). /// /// Each directory is an ICB (Extended File Entry) pointing to directory data /// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir /// and points to its ICB. Directories deeper than [`MAX_DIR_DEPTH`] are /// recorded as entries but not descended into. /// /// `budget` tracks total FID entries consumed across the whole tree; the walk /// aborts with `Error::DiscRead` once it exceeds [`MAX_TOTAL_DIR_ENTRIES`]. /// `visited` is the set of metadata-relative ICB LBAs already opened as /// directories; a repeated LBA is a cycle and is skipped. // A recursive UDF directory-tree parser: the arg list (reader, partition/meta // offsets, depth, plus the global entry budget and the cycle-detection // visited-set) is inherent to the walk, not a refactor smell. #[allow(clippy::only_used_in_recursion)] #[allow(clippy::too_many_arguments)] fn read_directory( reader: &mut dyn SectorSource, part_start: u32, meta_start: u32, meta_lba: u32, name: &str, depth: u32, budget: &mut usize, visited: &mut HashSet, ) -> Result { // Read ICB for this directory let icb_abs = meta_start.checked_add(meta_lba).ok_or(Error::DiscRead { sector: meta_start as u64, status: None, sense: None, })?; let mut icb = [0u8; 2048]; read_sector(reader, icb_abs, &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); // Get allocation extent: where the directory data lives let (ad_len, ad_pos) = match tag { 266 => { let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize; let ad_off = 216 + l_ea; if ad_off + 8 > icb.len() { return Err(Error::DiscRead { sector: icb_abs as u64, status: None, sense: None, }); } let len = u32::from_le_bytes([ icb[ad_off], icb[ad_off + 1], icb[ad_off + 2], icb[ad_off + 3], ]) & 0x3FFF_FFFF; let pos = u32::from_le_bytes([ icb[ad_off + 4], icb[ad_off + 5], icb[ad_off + 6], icb[ad_off + 7], ]); (len, pos) } 261 => { let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize; let ad_off = 176 + l_ea; if ad_off + 8 > icb.len() { return Err(Error::DiscRead { sector: icb_abs as u64, status: None, sense: None, }); } let len = u32::from_le_bytes([ icb[ad_off], icb[ad_off + 1], icb[ad_off + 2], icb[ad_off + 3], ]) & 0x3FFF_FFFF; let pos = u32::from_le_bytes([ icb[ad_off + 4], icb[ad_off + 5], icb[ad_off + 6], icb[ad_off + 7], ]); (len, pos) } _ => { return Ok(DirEntry { name: name.to_string(), is_dir: true, meta_lba, size: 0, entries: Vec::new(), }); } }; // Reject an oversized directory before allocating: ad_len is the // disc-controlled 30-bit ICB allocation length, so a corrupt value // could otherwise force a ~1 GiB zeroed allocation (amplified by // recursion). Real directories are a few KiB; the 1 MiB cap still // covers a large STREAM/ dir with thousands of .m2ts FIDs. if ad_len > MAX_DIR_BYTES { return Err(Error::DiscRead { sector: meta_start as u64, status: None, sense: None, }); } // Read directory data let dir_abs = meta_start.checked_add(ad_pos).ok_or(Error::DiscRead { sector: meta_start as u64, status: None, sense: None, })?; let sector_count = ad_len.div_ceil(2048); let mut dir_data = vec![0u8; sector_count as usize * 2048]; for i in 0..sector_count { let abs = dir_abs.checked_add(i).ok_or(Error::DiscRead { sector: dir_abs as u64, status: None, sense: None, })?; read_sector( reader, abs, &mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048], )?; } // Parse File Identifier Descriptors let mut entries = Vec::new(); let mut pos = 0; while pos + 38 < dir_data.len().min(ad_len as usize) { let fid_tag = u16::from_le_bytes([dir_data[pos], dir_data[pos + 1]]); if fid_tag != 257 { break; } let file_chars = dir_data[pos + 18]; let l_fi = dir_data[pos + 19] as usize; // FID ICB is a long_ad starting at offset 20: // [20:24] = extent_length // [24:28] = extent_location (LBA within metadata partition) // [28:30] = partition_reference_number // [30:36] = implementation_use let icb_lba = u32::from_le_bytes([ dir_data[pos + 24], dir_data[pos + 25], dir_data[pos + 26], dir_data[pos + 27], ]); let l_iu = u16::from_le_bytes([dir_data[pos + 36], dir_data[pos + 37]]) as usize; let is_dir = (file_chars & 0x02) != 0; let is_parent = (file_chars & 0x08) != 0; if !is_parent && l_fi > 0 { let name_start = pos + 38 + l_iu; let name_end = name_start + l_fi; if name_end > dir_data.len() { break; } let entry_name = parse_udf_name(&dir_data[name_start..name_end]); if !entry_name.is_empty() { // Global entry budget: abort if a crafted disc tries to // enumerate an astronomically large tree. *budget = budget.saturating_add(1); if *budget > MAX_TOTAL_DIR_ENTRIES { return Err(Error::DiscRead { sector: meta_start as u64, status: None, sense: None, }); } // Read the ICB to get file size let file_size = read_file_size(reader, meta_start, icb_lba).unwrap_or(0); if is_dir && depth < MAX_DIR_DEPTH { // Cycle guard: skip any ICB LBA we have already opened as // a directory (self-referential or cross-linked dirs). let icb_key = ((meta_start as u64) << 32) | icb_lba as u64; if visited.contains(&icb_key) { // Emit as a leaf so the name is preserved but don't // recurse into the cycle. entries.push(DirEntry { name: entry_name, is_dir: true, meta_lba: icb_lba, size: file_size, entries: Vec::new(), }); } else { visited.insert(icb_key); // Recurse into subdirectory. The depth cap guards // against pathological nesting on a corrupt disc while // comfortably covering real BD-ROM nesting // (e.g. BDMV/BACKUP/BDJO/*.bdjo is 3 levels deep). let subdir = read_directory( reader, part_start, meta_start, icb_lba, &entry_name, depth + 1, budget, visited, )?; entries.push(subdir); } } else { entries.push(DirEntry { name: entry_name, is_dir, meta_lba: icb_lba, size: file_size, entries: Vec::new(), }); } } } // Advance to next FID (4-byte aligned) let fid_len = (38 + l_iu + l_fi + 3) & !3; pos += fid_len; } Ok(DirEntry { name: name.to_string(), is_dir: true, meta_lba, size: ad_len as u64, entries, }) } /// Read file size (info_length) from an Extended File Entry ICB. fn read_file_size(reader: &mut dyn SectorSource, meta_start: u32, meta_lba: u32) -> Result { let abs = meta_start.checked_add(meta_lba).ok_or(Error::DiscRead { sector: meta_start as u64, status: None, sense: None, })?; let mut icb = [0u8; 2048]; read_sector(reader, abs, &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); match tag { // Both File Entry (261) and Extended File Entry (266) have // info_length as a u64 at offset 56 261 | 266 => Ok(u64::from_le_bytes([ icb[56], icb[57], icb[58], icb[59], icb[60], icb[61], icb[62], icb[63], ])), _ => Ok(0), } } /// Parse a UDF filename from raw bytes. /// /// UDF uses a compression ID as the first byte: /// 8 = 8-bit characters (ASCII) /// 16 = 16-bit big-endian Unicode (UTF-16BE) fn parse_udf_name(data: &[u8]) -> String { if data.is_empty() { return String::new(); } match data[0] { 8 => { // 8-bit ASCII String::from_utf8_lossy(&data[1..]).trim().to_string() } 16 => { // 16-bit big-endian Unicode let mut s = String::new(); let chars = &data[1..]; for i in (0..chars.len()).step_by(2) { if i + 1 < chars.len() { let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16; if let Some(ch) = char::from_u32(c as u32) { s.push(ch); } } } s.trim().to_string() } _ => String::from_utf8_lossy(&data[1..]).trim().to_string(), } } /// Merge overlapping or adjacent (start, count) ranges. fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { if ranges.is_empty() { return Vec::new(); } let mut result = vec![ranges[0]]; for &(start, count) in &ranges[1..] { let last = result.last_mut().unwrap(); // Saturating arithmetic: ranges derive from disc-controlled ICB // LBAs/lengths, so a corrupt disc could otherwise overflow u32 // (panic in debug, wrap in release). let last_end = last.0.saturating_add(last.1); if start <= last_end.saturating_add(1) { // Overlapping or adjacent — extend let new_end = start.saturating_add(count).max(last_end); last.1 = new_end - last.0; } else { result.push((start, count)); } } result } /// Parse a UDF d-string (fixed-length field with length byte at the end). /// Used for Volume Identifier and other UDF descriptor strings. /// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE. fn parse_dstring(data: &[u8]) -> String { if data.is_empty() { return String::new(); } let len = *data.last().unwrap() as usize; if len == 0 || len > data.len() { return String::new(); } let content = &data[..len]; if content.is_empty() { return String::new(); } match content[0] { 8 => String::from_utf8_lossy(&content[1..]) .trim_end_matches('\0') .trim() .to_string(), 16 => { let mut s = String::new(); let chars = &content[1..]; for i in (0..chars.len()).step_by(2) { if i + 1 < chars.len() { let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16; if c != 0 { if let Some(ch) = char::from_u32(c as u32) { s.push(ch); } } } } s.trim().to_string() } _ => String::from_utf8_lossy(&content[1..]) .trim_end_matches('\0') .trim() .to_string(), } } /// Buffered sector reader — reduces SCSI round-trips by coalescing /// single-sector reads into `batch`-sized SCSI commands. Per-command /// latency dominates on USB drives, so serving many adjacent single-sector /// reads from one bulk read is substantially faster than issuing each /// individually. `batch` is a runtime field, not a fixed count. pub(crate) struct BufferedSectorReader<'a> { inner: &'a mut dyn SectorSource, cache_start: u32, cache: Vec, cache_sectors: u32, batch: u16, /// Pre-fetched sector data from bulk reads (sector ranges for AACS, MPLS, CLPI, etc.) prefetched: std::collections::HashMap>, } impl<'a> BufferedSectorReader<'a> { pub(crate) fn new(inner: &'a mut dyn SectorSource, batch: u16) -> Self { Self { inner, cache_start: u32::MAX, cache: Vec::new(), cache_sectors: 0, batch, prefetched: std::collections::HashMap::new(), } } } impl BufferedSectorReader<'_> { /// Pre-read a contiguous range of sectors into the sliding cache. /// Used to bulk-load the UDF metadata partition so subsequent reads are instant. pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) { // Cap to 8192 sectors (16 MiB) so a disc-controlled ad_len cannot // drive a multi-hundred-MiB allocation before any sectors are read. let count = count.min(8192); let total = count as usize * 2048; self.cache.resize(total, 0); let mut offset = 0u32; while offset < count { let batch = (count - offset).min(self.batch as u32) as u16; let buf_off = offset as usize * 2048; if self .inner .read_sectors( start_lba + offset, batch, &mut self.cache[buf_off..buf_off + batch as usize * 2048], true, ) .is_err() { break; } offset += batch as u32; } self.cache_start = start_lba; self.cache_sectors = offset; } /// Pre-read multiple sector ranges into the permanent cache. /// Each range is read in batch-sized chunks and stored per-sector in a HashMap. /// Used to bulk-load all small files (AACS, MPLS, CLPI, META) before scanning. /// /// Anti-DoS: the permanent cache holds one ~2 KB `Vec` per sector in a /// `HashMap`, so the total sector count bounds RAM. A crafted UDF (a /// bogus metadata-file size in `metadata_sector_ranges`) could otherwise /// drive that count to billions. Cap the cumulative prefetched sectors at /// `MAX_PREFETCH_SECTORS` (~1 GiB of cache); once exceeded, stop seeding /// the cache. The sliding-window read path still serves any LBA on /// demand, so this only forgoes the bulk speed-up — it never loses data. pub(crate) fn prefetch_ranges(&mut self, ranges: &[(u32, u32)]) { // 2048 bytes/sector → 512 Ki sectors ≈ 1 GiB of permanent cache. const MAX_PREFETCH_SECTORS: u64 = 512 * 1024; let mut tmp = vec![0u8; self.batch as usize * 2048]; let total: u64 = ranges.iter().map(|&(_, c)| c as u64).sum(); let mut cached: u64 = 0; let mut done: u64 = 0; let mut hb = crate::progress::Heartbeat::new("udf_prefetch"); for &(start, count) in ranges { let mut offset = 0u32; while offset < count { hb.tick(done, total); let batch = (count - offset).min(self.batch as u32) as u16; let bytes = batch as usize * 2048; if self .inner .read_sectors(start + offset, batch, &mut tmp[..bytes], true) .is_err() { break; } for i in 0..batch as u32 { if cached >= MAX_PREFETCH_SECTORS { // Cache cap hit: stop seeding the permanent HashMap. // Remaining LBAs are still served by the sliding-window // read path below, just without the bulk pre-load. return; } let s = i as usize * 2048; self.prefetched .insert(start + offset + i, tmp[s..s + 2048].to_vec()); cached += 1; } offset += batch as u32; done += batch as u64; } } } } impl SectorSource for BufferedSectorReader<'_> { fn read_sectors( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, ) -> std::result::Result { if count == 1 { // Contract: a single-sector read needs at least one sector of // destination. Return an error rather than panicking on the slice. if buf.len() < 2048 { return Err(crate::error::Error::UdfBufferTooSmall); } // Check permanent prefetch cache first (HashMap) if let Some(data) = self.prefetched.get(&lba) { buf[..2048].copy_from_slice(data); return Ok(2048); } // Check sliding cache if lba >= self.cache_start && lba < self.cache_start + self.cache_sectors { let offset = (lba - self.cache_start) as usize * 2048; buf[..2048].copy_from_slice(&self.cache[offset..offset + 2048]); return Ok(2048); } let block = self.batch; self.cache.resize(block as usize * 2048, 0); match self.inner.read_sectors(lba, block, &mut self.cache, true) { Ok(_) => { self.cache_start = lba; self.cache_sectors = block as u32; } Err(_) => { // By design: a `block`-sector batch read that starts // valid but runs past the last recorded sector fails as // a unit. Retry the one sector actually requested so a // batch overrunning the disc tail still serves the live // LBA instead of erroring; a genuinely bad single sector // then propagates via `?`. self.cache.resize(2048, 0); self.inner.read_sectors(lba, 1, &mut self.cache, true)?; self.cache_start = lba; self.cache_sectors = 1; } } buf[..2048].copy_from_slice(&self.cache[..2048]); Ok(2048) } else { // Multi-sector read — pass through self.inner.read_sectors(lba, count, buf, true) } } } /// Read a single 2048-byte sector from the drive. /// Uses standard READ(10) — no unlock required. fn read_sector(reader: &mut dyn SectorSource, lba: u32, buf: &mut [u8]) -> Result<()> { reader.read_sectors(lba, 1, buf, true)?; Ok(()) } #[cfg(test)] mod tests { 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, } 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 { 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; let s = self.sectors.get(&(lba + 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 an Extended File Entry (tag 266) ICB whose allocation /// descriptors are LONG ADs (16 bytes: len(4) | lba(4) | part_ref(2) | /// impl_use(6)). Sets the ICB Tag flags (abs offset 34) low bits to 1 /// so the parser must select the 16-byte stride. This is 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..off+10 = partition reference (0), off+10..off+16 = // impl_use (0). Leaving these zero is what trips the old // 8-byte-stride parser into reading a bogus zero-length // terminator as descriptor #1. 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] { let mut s = [0u8; 2048]; let mut off = 0usize; 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 } fn fs_with(part_start: u32, meta_start: u32, root: DirEntry) -> UdfFs { UdfFs { root, volume_id: String::new(), partition_start: part_start, metadata_start: meta_start, metadata_sectors: 0, } } 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 = fs.read_icb_extents(&mut reader, 5).expect("extents"); assert_eq!(extents, vec![(10, 4096), (20, 2048)]); } #[test] fn icb_extents_long_ad_returns_all_extents_not_just_first() { // Regression: BD-ROM large .m2ts files use Long ADs (16-byte // descriptors). The pre-fix parser hardcoded an 8-byte stride, so // it read descriptor #0 (length+lba align in both layouts) then // misread descriptor #1 from the middle of the first Long AD — // a zero terminator — and returned ONLY the first extent. That // truncated every multi-extent title at ~973 MB-1 GiB. // // Four Long ADs, each a near-max Short-AD-sized extent. The fix // must return all four; the old code returned exactly one. 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 = 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 BOTH 0.31.0 bugs through `read_file`: this is the // exact path `Disc::read_aacs_inputs_from_reader` uses to read // `/AACS/MKB_RO.inf` + `Unit_Key_RO.inf`. A Long-AD, multi-extent // file (large UHD/Blu-ray layout) must return ALL its bytes. With the // pre-fix Short-AD-only parser this read stopped after the first // extent, which (a) truncated the mux and (b) made autorip's // `key_files()` see a short/garbage AACS file → `MissingInputs` → // the online key request was never sent. 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() { // PRECOMMIT proof for the autorip online-keyserver path (no disc / no // deploy). autorip's key request is gated on Disc::read_aacs_inputs // (keysource.rs key_files()): it reads /AACS/Unit_Key_RO.inf and // /AACS/MKB_RO.inf. On a Long-AD disc (UHD / large Blu-ray) the // pre-0.31.1 Short-AD-only reader truncated those files at their first // extent, breaking key derivation. This // fixture lays a Long-AD, multi-extent Unit_Key_RO.inf under /AACS and // asserts read_aacs_inputs returns its FULL content — i.e. the keyserver // inputs are complete, so the request is built correctly. 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) = 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 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].0, u32::MAX - 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, } 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 { 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, metadata_start: 0, metadata_sectors: 0, } } #[test] fn read_file_rejects_oversized_extent_before_allocating() { // data_len 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 an Extended File Entry ICB with multiple inline short ADs, each /// `(data_len, data_lba)`. Lets a test chain extents whose individual /// lengths are all 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 individually within MAX_FILE_BYTES, that together // exceed it. The cumulative guard must fire on the second extent // (before reading it) rather than growing `data` past the cap. // First extent: a single sector (read, data.len() = 2048). Second // extent: exactly MAX_FILE_BYTES (passes the per-extent cap) — the // 2048 already buffered pushes the running total over the cap. 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, 0, 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, 0, 0, 5, "DIR", 0, &mut 0, &mut HashSet::new()) .expect("small dir parses"); assert!(dir.entries.is_empty()); assert!(dir.is_dir); } // ---- added: spec-boundary coverage for AD strides, flags, FIDs ---- /// Build an Extended File Entry (tag 266) ICB whose allocation /// descriptors are EXTENDED ADs (ECMA-167 §14.14.3, 20 bytes each): /// ExtentLength(4) | RecordedLength(4) | InformationLength(4) | /// ExtentLocation lb_addr { logicalBlockNumber(4) | partitionRef(2) } | /// impl_use(2) /// The 30-bit length + 2-bit type live in ExtentLength (offset +0); the /// logical block number lives in ExtentLocation at offset +12. Sets ICB /// Tag flags (abs offset 34) low bits to 2 = Extended AD so the parser /// must select the 20-byte stride AND read the LBA from off+12, not off+4. 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: an Extended AD is 20 bytes and its extent LBA // is at byte offset +12, NOT +4 (that's RecordedLength). The parser // branches on ICB-tag flags==2 to a 20-byte stride and lba_off=off+12. // Three extents must come back with the CORRECT LBAs and lengths. let icb = build_efe_ext(3 * 2048, &[(0, 2048, 700), (0, 2048, 800), (0, 4096, 900)]); let mut reader = MapReader::new(); reader.put(5, icb); let fs = fs_with(0, 0, file_entry("EXT", 5, 3 * 2048)); let extents = 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![(700, 2048), (800, 2048), (900, 4096)]); } #[test] fn icb_extents_short_ad_type1_sparse_extent_is_skipped_not_emitted() { // ECMA-167 §14.14.1.1: extent type 1 = "allocated but not recorded" // (a sparse hole). It carries no on-disc data, so it must NOT be // returned as a readable extent. A type-0 extent after it must still // be reached (the loop must continue past a type-1, not break). let icb = build_efe( 6144, &[ (0, 2048, 10), // recorded (1, 2048, 20), // sparse — 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"); // The sparse (type-1) middle descriptor must be absent; the two // recorded extents must both be present and in order. assert_eq!(extents, vec![(10, 2048), (30, 2048)]); } #[test] fn icb_extents_zero_length_type0_terminates_list() { // ECMA-167: a zero-length type-0 AD terminates the descriptor list. // Trailing zero padding (all-zero ADs) MUST stop parsing — otherwise // a stray non-zero AD after the terminator becomes a bogus extent. // One real extent, then a zero AD, then an AD that must NEVER be read. 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 = fs.read_icb_extents(&mut reader, 5).expect("extents"); assert_eq!( extents, vec![(10, 2048)], "parsing must stop at the zero-length terminator" ); } #[test] fn icb_extents_continuation_loop_terminates_without_hang_or_panic() { // Hostile input: a type-3 continuation descriptor whose continuation // block points back at itself (a cycle). The MAX_AD_BLOCKS bound must // make this terminate rather than loop forever. We assert it returns // a finite Vec and does not panic. The continuation block at meta-rel // lba 50 contains a recorded extent + a type-3 AD pointing to lba 50. 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)); // Must return Ok (bounded), not hang or panic. let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); // First block contributes extent (10,2048); each revisit of the // self-referential cont block adds (20,2048). The hop bound caps the // total, so the Vec is finite. (256 blocks max → < 600 extents.) assert!(extents.len() < 1024, "continuation chain must be bounded"); assert_eq!(extents[0], (10, 2048)); assert_eq!(extents[1], (20, 2048)); } #[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_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"); } #[test] fn read_directory_honors_l_iu_offset_for_fid_name() { // ECMA-167 §14.4 File Identifier Descriptor: the File Identifier // begins at offset 38 + L_IU. A non-zero L_IU must shift the name // read; ignoring it would read impl_use bytes as the name. // 0..2 tag = 257 18 file chars 19 L_FI // 24..28 ICB LBA 36..38 L_IU 38.. impl_use[L_IU] then FI[L_FI] 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, 0, 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: file characteristics bit 3 (0x08) = "parent" (the // ".." back-link). It must NOT appear as a named child entry. To // isolate the parent-flag gate (rather than the L_FI==0 gate that // real parent FIDs also have), this fixture gives the parent FID a // VALID non-zero L_FI and a real name: the ONLY reason it must be // skipped is the parent characteristic bit. 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, 0, 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_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), ""); } #[test] fn read_inline_data_rejects_oversized_lea() { // AD type=3 (inline data) with an L_EA so large that ad_offset = // 216 + L_EA overflows past the 2048-byte ICB. Before the fix, // the `.min(icb.len())` clamp produced start==end==2048 and the // function returned Ok(Some(vec![])) — silently dropping the file // content. AACS key files (Unit_Key_RO.inf) read as 0 bytes and // decryption failed without a useful diagnostic. 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))" ); } #[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); // 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() ); } /// Build a 2048-byte directory sector containing `count` minimal file FIDs. /// /// Each FID uses a 2-byte name (compression-id `8` + `b'A'`), so l_fi=2 /// and the total FID record is 40 bytes (already 4-byte aligned). /// A 2048-byte sector fits exactly 51 such FIDs. /// /// `icb_base` is the ICB LBA written into the first FID; each subsequent /// FID gets `icb_base + i`. 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 rather than visited indefinitely. `budget` is the running // global counter (threshold = MAX_TOTAL_DIR_ENTRIES); pre-load it to // within 10 of the cap and feed 51 file FIDs — the walk must error once // the counter crosses the cap, before consuming all of them. 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, 0, 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 directory whose child FID (is_dir=true) points back to the same // ICB LBA as the parent (a self-referential cycle) must NOT recurse. // It must be emitted as a leaf entry instead. // // Layout: // meta_lba 5 — root directory ICB, dir data at lba 60 // lba 60 — one FID: is_dir, ICB at lba 5 (self-reference) // // We seed visited with lba 5 (the root we are about to descend into), // so when the FID points back to lba 5 the cycle is detected immediately. 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 = HashSet::new(); let root_key: u64 = 5u64; // meta_start=0 → key = (0 << 32) | 5 visited.insert(root_key); let parsed = read_directory(&mut reader, 0, 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" ); } }