CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
2351 lines
93 KiB
Rust
2351 lines
93 KiB
Rust
//! 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<DirEntry>,
|
||
}
|
||
|
||
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<u32> {
|
||
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<Vec<u8>> {
|
||
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<Vec<u8>> {
|
||
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<usize>,
|
||
) -> Result<Vec<u8>> {
|
||
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<Vec<(u32, u32)>> {
|
||
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<Vec<(u32, u32)>> {
|
||
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<u32> {
|
||
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<Option<Vec<u8>>> {
|
||
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<Vec<(u32, u32)>> {
|
||
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<u32> = 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)
|
||
}
|
||
|
||
/// 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<Vec<(u32, u32)>> {
|
||
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<UdfFs> {
|
||
// 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<u32> = 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<u64> = 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<u64>,
|
||
) -> Result<DirEntry> {
|
||
// 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<u64> {
|
||
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<u8>,
|
||
cache_sectors: u32,
|
||
batch: u16,
|
||
/// Pre-fetched sector data from bulk reads (sector ranges for AACS, MPLS, CLPI, etc.)
|
||
prefetched: std::collections::HashMap<u32, Vec<u8>>,
|
||
}
|
||
|
||
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<usize, crate::error::Error> {
|
||
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<u32, [u8; 2048]>,
|
||
}
|
||
|
||
impl MapReader {
|
||
fn new() -> Self {
|
||
Self {
|
||
sectors: HashMap::new(),
|
||
}
|
||
}
|
||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||
self.sectors.insert(lba, data);
|
||
}
|
||
}
|
||
|
||
impl SectorSource for MapReader {
|
||
fn read_sectors(
|
||
&mut self,
|
||
lba: u32,
|
||
count: u16,
|
||
buf: &mut [u8],
|
||
_recovery: bool,
|
||
) -> Result<usize> {
|
||
let need = count as usize * 2048;
|
||
if buf.len() < need {
|
||
return Err(Error::UdfBufferTooSmall);
|
||
}
|
||
for i in 0..count as u32 {
|
||
let off = i as usize * 2048;
|
||
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<u32, [u8; 2048]>,
|
||
}
|
||
|
||
impl MemReader {
|
||
fn new() -> Self {
|
||
Self {
|
||
sectors: HashMap::new(),
|
||
}
|
||
}
|
||
fn put(&mut self, lba: u32, sector: [u8; 2048]) {
|
||
self.sectors.insert(lba, sector);
|
||
}
|
||
}
|
||
|
||
impl SectorSource for MemReader {
|
||
fn read_sectors(
|
||
&mut self,
|
||
lba: u32,
|
||
count: u16,
|
||
buf: &mut [u8],
|
||
_recovery: bool,
|
||
) -> Result<usize> {
|
||
for i in 0..count as u32 {
|
||
let off = i as usize * 2048;
|
||
let dst = &mut buf[off..off + 2048];
|
||
match self.sectors.get(&(lba + i)) {
|
||
Some(s) => dst.copy_from_slice(s),
|
||
None => dst.fill(0),
|
||
}
|
||
}
|
||
Ok(count as usize * 2048)
|
||
}
|
||
}
|
||
|
||
/// Build an Extended File Entry (tag 266) ICB sector with a single
|
||
/// short allocation descriptor declaring `data_len` bytes at `data_lba`.
|
||
/// `info_length` (offset 56) is set to `info_len`.
|
||
fn build_efe_icb(info_len: u64, data_len: u32, data_lba: u32) -> [u8; 2048] {
|
||
let mut icb = [0u8; 2048];
|
||
// tag identifier 266 (Extended File Entry)
|
||
icb[0..2].copy_from_slice(&266u16.to_le_bytes());
|
||
// info_length at offset 56
|
||
icb[56..64].copy_from_slice(&info_len.to_le_bytes());
|
||
// l_ea = 0 at offset 208, l_ad = 8 (one short AD) at offset 212
|
||
icb[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||
icb[212..216].copy_from_slice(&8u32.to_le_bytes());
|
||
// ad_offset = 216 + l_ea = 216. Short AD: len(4) | lba(4).
|
||
// extent_type 0 (recorded) is the top 2 bits = 0, so raw == len.
|
||
icb[216..220].copy_from_slice(&(data_len & 0x3FFF_FFFF).to_le_bytes());
|
||
icb[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
||
icb
|
||
}
|
||
|
||
/// Build a UdfFs with a single file entry under root, for read_file tests.
|
||
fn fs_with_file(meta_lba: u32, size: u64) -> UdfFs {
|
||
UdfFs {
|
||
root: DirEntry {
|
||
name: String::new(),
|
||
is_dir: true,
|
||
meta_lba: 0,
|
||
size: 0,
|
||
entries: vec![DirEntry {
|
||
name: "F".to_string(),
|
||
is_dir: false,
|
||
meta_lba,
|
||
size,
|
||
entries: Vec::new(),
|
||
}],
|
||
},
|
||
volume_id: String::new(),
|
||
partition_start: 0,
|
||
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<u64> = HashSet::new();
|
||
let root_key: u64 = 5u64; // meta_start=0 → key = (0 << 32) | 5
|
||
visited.insert(root_key);
|
||
|
||
let parsed = read_directory(&mut reader, 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"
|
||
);
|
||
}
|
||
}
|