fix(udf): skip deleted File Identifier Descriptors
read_directory decoded file-characteristics bit 1 (Directory) and bit 3 (Parent) but never bit 2 (Deleted) — ECMA-167 4/14.4.4. It followed the ICB of a descriptor naming a file that no longer exists. 4/14.4.3 permits a deleted FID's ICB field to specify an extent of length zero, so it need not point at a File Entry at all. Following it reads whatever descriptor occupies that metadata LBA: - deleted DIRECTORY FID: recursion lands on the File Set Descriptor (tag 256), hits the not-a-File-Entry arm and returns Err. One stale descriptor in one directory fails enumeration of the entire volume. - deleted FILE FID: read_file_size returns Ok(0) for a non-File-Entry tag, so a deleted name is reported as a real zero-byte file. Both directions of the same shape at once: a recoverable condition becoming a hard failure, and a non-existent entry becoming a plausible success. Bit 0 (Existence) is still unread. Skipping hidden files could hide real content, so it is left alone deliberately rather than folded in. Found by mutation testing: 48 of the 51 surviving mutants in read_directory are killed by the 17 tests added here, covering the short_ad decode byte by byte (ECMA-167 4/14.14.1), the extent-type mask, the AD bounds guard at the exact sector end, the tag-261 File Entry directory arm that no test reached at all, the multi-sector read offset, the FID stride including L_IU, and the nesting cap. Three survivors are genuine equivalents and are documented as such: l_fi > 0 vs >= 0 (the emptiness guard below reaches the same state), and the << 32 / | in the visited-set key (meta_start is constant across a walk, and the two halves are disjoint).
This commit is contained in:
+665
-1
@@ -1195,8 +1195,17 @@ fn read_directory(
|
|||||||
|
|
||||||
let is_dir = (file_chars & 0x02) != 0;
|
let is_dir = (file_chars & 0x02) != 0;
|
||||||
let is_parent = (file_chars & 0x08) != 0;
|
let is_parent = (file_chars & 0x08) != 0;
|
||||||
|
// ECMA-167 4/14.4.4 file characteristics bit 2 = Deleted. Such a
|
||||||
|
// descriptor names a file that no longer exists, and 4/14.4.3 lets its
|
||||||
|
// ICB field specify an extent of length zero — it need not point at a
|
||||||
|
// File Entry at all. Following it reads whatever descriptor happens to
|
||||||
|
// occupy that metadata LBA: on a deleted DIRECTORY that is a hard
|
||||||
|
// "not a File Entry" error which fails the whole volume enumeration,
|
||||||
|
// and on a deleted FILE it invents a zero-byte entry that was never
|
||||||
|
// recorded.
|
||||||
|
let is_deleted = (file_chars & 0x04) != 0;
|
||||||
|
|
||||||
if !is_parent && l_fi > 0 {
|
if !is_parent && !is_deleted && l_fi > 0 {
|
||||||
let name_start = pos + 38 + l_iu;
|
let name_start = pos + 38 + l_iu;
|
||||||
let name_end = name_start + l_fi;
|
let name_end = name_start + l_fi;
|
||||||
if name_end > dir_data.len() {
|
if name_end > dir_data.len() {
|
||||||
@@ -2899,6 +2908,661 @@ mod tests {
|
|||||||
assert_eq!(fs.root.entries.len(), 1);
|
assert_eq!(fs.root.entries.len(), 1);
|
||||||
assert_eq!(fs.root.entries[0].name, "INDEX.BDMV");
|
assert_eq!(fs.root.entries[0].name, "INDEX.BDMV");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- directory-descriptor boundary coverage (ECMA-167 4/14.4, 4/14.9,
|
||||||
|
// 4/14.17). Every field below is disc-controlled, so each boundary is a
|
||||||
|
// place a malformed image can push the parser off the end of a buffer or
|
||||||
|
// turn a failure into a plausible-looking success.
|
||||||
|
|
||||||
|
/// Build a directory ICB with an explicit descriptor tag, extended
|
||||||
|
/// attribute length, RAW (unmasked) allocation-descriptor length field
|
||||||
|
/// and allocation position.
|
||||||
|
///
|
||||||
|
/// ECMA-167 4/14.17 Extended File Entry (tag 266): L_EA at byte 208,
|
||||||
|
/// L_AD at 212, allocation descriptors start at 216 + L_EA.
|
||||||
|
/// ECMA-167 4/14.9 File Entry (tag 261): L_EA at 168, L_AD at 172,
|
||||||
|
/// allocation descriptors start at 176 + L_EA.
|
||||||
|
/// The extended-attribute area is filled with a recognisable non-zero
|
||||||
|
/// pattern so that reading the allocation descriptor from the wrong
|
||||||
|
/// offset cannot silently produce a usable value.
|
||||||
|
fn build_dir_icb_tagged(tag: u16, l_ea: usize, raw_len: u32, ad_pos: u32) -> [u8; 2048] {
|
||||||
|
let mut icb = [0u8; 2048];
|
||||||
|
icb[0..2].copy_from_slice(&tag.to_le_bytes());
|
||||||
|
let (l_ea_off, ad_base) = if tag == 266 {
|
||||||
|
(208usize, 216usize)
|
||||||
|
} else {
|
||||||
|
(168usize, 176usize)
|
||||||
|
};
|
||||||
|
icb[l_ea_off..l_ea_off + 4].copy_from_slice(&(l_ea as u32).to_le_bytes());
|
||||||
|
// L_AD = 8: exactly one short_ad (ECMA-167 4/14.14.1).
|
||||||
|
icb[l_ea_off + 4..l_ea_off + 8].copy_from_slice(&8u32.to_le_bytes());
|
||||||
|
icb[ad_base..ad_base + l_ea].fill(0xA5);
|
||||||
|
let ad = ad_base + l_ea;
|
||||||
|
if ad + 8 <= icb.len() {
|
||||||
|
icb[ad..ad + 4].copy_from_slice(&raw_len.to_le_bytes());
|
||||||
|
icb[ad + 4..ad + 8].copy_from_slice(&ad_pos.to_le_bytes());
|
||||||
|
}
|
||||||
|
icb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one File Identifier Descriptor (ECMA-167 4/14.4) to `buf`,
|
||||||
|
/// with an explicit L_IU implementation-use area (4/14.4.7) sitting
|
||||||
|
/// between the 38-byte header and the File Identifier (4/14.4.8).
|
||||||
|
/// The descriptor is padded to a 4-byte multiple per 4/14.4.9.
|
||||||
|
fn push_fid_iu(
|
||||||
|
buf: &mut Vec<u8>,
|
||||||
|
name: &str,
|
||||||
|
icb_lba: u32,
|
||||||
|
is_dir: bool,
|
||||||
|
is_parent: bool,
|
||||||
|
l_iu: u16,
|
||||||
|
) {
|
||||||
|
let start = buf.len();
|
||||||
|
let name_field: Vec<u8> = if name.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let mut v = vec![0x08u8]; // OSTA CS0 compression id 8
|
||||||
|
v.extend_from_slice(name.as_bytes());
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let mut fid = vec![0u8; 38];
|
||||||
|
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||||
|
let mut fc = 0u8;
|
||||||
|
if is_dir {
|
||||||
|
fc |= 0x02;
|
||||||
|
}
|
||||||
|
if is_parent {
|
||||||
|
fc |= 0x08;
|
||||||
|
}
|
||||||
|
fid[18] = fc;
|
||||||
|
fid[19] = name_field.len() as u8;
|
||||||
|
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
|
||||||
|
fid[36..38].copy_from_slice(&l_iu.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&fid);
|
||||||
|
buf.extend_from_slice(&vec![0xC3u8; l_iu as usize]);
|
||||||
|
buf.extend_from_slice(&name_field);
|
||||||
|
let used = buf.len() - start;
|
||||||
|
buf.resize(start + ((used + 3) & !3), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of the children `read_directory` reported, in order.
|
||||||
|
fn child_names(d: &DirEntry) -> Vec<String> {
|
||||||
|
d.entries.iter().map(|e| e.name.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_reads_a_plain_file_entry_tag_261_directory_icb() {
|
||||||
|
// ECMA-167 4/14.9 File Entry (tag 261) is a directory ICB in its own
|
||||||
|
// right — UDF 2.50 discs use the Extended File Entry (266), but a
|
||||||
|
// 261 File Entry is equally valid and is what UDF 1.02 DVD-Video
|
||||||
|
// discs carry. Its L_EA lives at byte 168 and its allocation
|
||||||
|
// descriptors begin at 176 + L_EA, NOT at the 208/216 offsets of a
|
||||||
|
// 266. Dropping the 261 arm, or testing the ad-bounds guard the
|
||||||
|
// wrong way round, turns every UDF 1.02 directory into a hard read
|
||||||
|
// error — the disc mounts and reports no titles.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..)
|
||||||
|
push_fid_iu(&mut fids, "VIDEO_TS.IFO", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(261, 0, fids.len() as u32, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(4096, 4096, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("a File Entry (261) directory ICB is valid and must be readable");
|
||||||
|
assert_eq!(child_names(&parsed), vec!["VIDEO_TS.IFO".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.entries[0].size, 4096,
|
||||||
|
"the child's size must come from its own ICB info_length"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_masks_the_extent_type_bits_out_of_the_ad_length() {
|
||||||
|
// ECMA-167 4/14.14.1.1: the 32-bit field at the head of a short_ad is
|
||||||
|
// a 30-bit extent length plus a 2-bit extent TYPE in bits 30..31. The
|
||||||
|
// type bits must be masked off before the value is used as a byte
|
||||||
|
// count; folding them in yields a length of >= 1 GiB, which then trips
|
||||||
|
// the MAX_DIR_BYTES guard and turns a perfectly readable directory
|
||||||
|
// into a read error.
|
||||||
|
//
|
||||||
|
// Encoded here as extent type 1 (bits = 01) over a real, tiny length.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "", 5, true, true, 0);
|
||||||
|
push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
let raw = 0x4000_0000u32 | fids.len() as u32;
|
||||||
|
|
||||||
|
for tag in [261u16, 266u16] {
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(tag, 0, raw, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(1024, 1024, 0));
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
panic!("tag {tag}: extent-type bits must not inflate the length: {e:?}")
|
||||||
|
});
|
||||||
|
assert_eq!(child_names(&parsed), vec!["INDEX.BDMV".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.size,
|
||||||
|
fids.len() as u64,
|
||||||
|
"tag {tag}: the reported directory size is the 30-bit length only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_ignores_fid_bytes_past_the_declared_directory_length() {
|
||||||
|
// The directory's information length (the short_ad length field) is
|
||||||
|
// the authority on how far the FID list extends; the read buffer is
|
||||||
|
// rounded up to a whole sector, so bytes between the declared end and
|
||||||
|
// the end of the sector are NOT part of the directory. Scanning one
|
||||||
|
// descriptor too far invents a file that the disc never recorded —
|
||||||
|
// stale bytes from a previously deleted FID read as a live entry.
|
||||||
|
//
|
||||||
|
// Layout: a 40-byte FID for "A" at 0, then a well-formed FID for
|
||||||
|
// "GHOST" at 40. The declared length is 78 = 40 + 38, i.e. exactly
|
||||||
|
// the header of the second FID and not one byte more.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "A", 7, false, false, 0);
|
||||||
|
assert_eq!(
|
||||||
|
fids.len(),
|
||||||
|
40,
|
||||||
|
"first FID must be 40 bytes for this fixture"
|
||||||
|
);
|
||||||
|
push_fid_iu(&mut fids, "GHOST", 8, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, 78, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(11, 2048, 0));
|
||||||
|
reader.put(8, build_efe_icb(22, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("dir parses");
|
||||||
|
assert_eq!(
|
||||||
|
child_names(&parsed),
|
||||||
|
vec!["A".to_string()],
|
||||||
|
"a FID header starting at the declared end of the directory is not an entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_stops_at_a_descriptor_whose_tag_is_not_a_fid() {
|
||||||
|
// ECMA-167 3/7.2.1: the tag identifier is a 16-bit little-endian
|
||||||
|
// field; 257 is File Identifier Descriptor. The FID list ends at the
|
||||||
|
// first descriptor that is not a FID. Reading only the low byte of
|
||||||
|
// the tag would accept tag 0x0201 (=513, Partition Descriptor's
|
||||||
|
// neighbourhood) as if it were 257, because 257 is 0x0101 and both of
|
||||||
|
// its bytes are 0x01 — a single-byte comparison cannot tell them
|
||||||
|
// apart. Anything after the terminator is then parsed as FIDs.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "REAL.MPLS", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
// A non-FID descriptor whose LOW tag byte still reads 0x01.
|
||||||
|
let next = fids.len();
|
||||||
|
dir[next..next + 2].copy_from_slice(&513u16.to_le_bytes());
|
||||||
|
// ... followed by bytes that would parse as a named file FID if the
|
||||||
|
// scan wrongly continued.
|
||||||
|
dir[next + 18] = 0x00;
|
||||||
|
dir[next + 19] = 6;
|
||||||
|
dir[next + 38] = 0x08;
|
||||||
|
dir[next + 39..next + 44].copy_from_slice(b"AFTER");
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, 2048, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(1, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("dir parses");
|
||||||
|
assert_eq!(
|
||||||
|
child_names(&parsed),
|
||||||
|
vec!["REAL.MPLS".to_string()],
|
||||||
|
"both bytes of the descriptor tag must be compared"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_keeps_a_fid_name_that_ends_exactly_at_the_buffer_end() {
|
||||||
|
// A File Identifier that ends on the last byte of the directory's
|
||||||
|
// read buffer is entirely present and must be decoded. Treating
|
||||||
|
// "ends exactly at the end" as "runs past the end" silently drops the
|
||||||
|
// last file of a directory that happens to fill its final sector —
|
||||||
|
// and a dropped .m2ts is a missing title, not a visible failure.
|
||||||
|
//
|
||||||
|
// The first FID is a parent (..) with a 1959-byte implementation-use
|
||||||
|
// area, which lands the second FID at offset 2000; its name then runs
|
||||||
|
// 2038..2048 inclusive.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "", 5, true, true, 1959);
|
||||||
|
assert_eq!(fids.len(), 2000, "parent FID must end at 2000");
|
||||||
|
push_fid_iu(&mut fids, "ENDSATEND", 7, false, false, 0);
|
||||||
|
assert_eq!(fids.len(), 2048, "second FID must end exactly at 2048");
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir.copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, 2048, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(99, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("dir parses");
|
||||||
|
assert_eq!(child_names(&parsed), vec!["ENDSATEND".to_string()]);
|
||||||
|
assert_eq!(parsed.entries[0].size, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_stops_instead_of_panicking_when_a_name_runs_past_the_buffer() {
|
||||||
|
// L_IU (ECMA-167 4/14.4.7) is a disc-controlled 16-bit field, so a
|
||||||
|
// malformed FID can place the File Identifier tens of kilobytes past
|
||||||
|
// the end of the directory's read buffer. The scan must stop; slicing
|
||||||
|
// there would panic out of the public API on a damaged disc.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "OK.MPLS", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
// Second FID: header inside the buffer, name far outside it.
|
||||||
|
let next = fids.len();
|
||||||
|
dir[next..next + 2].copy_from_slice(&257u16.to_le_bytes());
|
||||||
|
dir[next + 18] = 0x00;
|
||||||
|
dir[next + 19] = 8; // L_FI
|
||||||
|
dir[next + 36..next + 38].copy_from_slice(&60000u16.to_le_bytes()); // L_IU
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, 2048, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(5, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("an out-of-range File Identifier must end the scan, not fail the read");
|
||||||
|
assert_eq!(
|
||||||
|
child_names(&parsed),
|
||||||
|
vec!["OK.MPLS".to_string()],
|
||||||
|
"entries read before the malformed FID are kept"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_admits_a_tree_that_exactly_fills_the_entry_budget() {
|
||||||
|
// The global entry budget is an anti-DoS ceiling on a crafted disc.
|
||||||
|
// A tree that reaches the ceiling exactly is still a legal tree and
|
||||||
|
// must be enumerated; rejecting it makes the largest admissible disc
|
||||||
|
// unreadable. Pre-load the counter to one below the cap so that this
|
||||||
|
// directory's single entry lands exactly on it.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "LAST.M2TS", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, fids.len() as u32, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(7, 2048, 0));
|
||||||
|
|
||||||
|
let mut budget = MAX_TOTAL_DIR_ENTRIES - 1;
|
||||||
|
let parsed = read_directory(
|
||||||
|
&mut reader,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
"ROOT",
|
||||||
|
0,
|
||||||
|
&mut budget,
|
||||||
|
&mut HashSet::new(),
|
||||||
|
)
|
||||||
|
.expect("a tree that exactly reaches the entry budget must still be enumerated");
|
||||||
|
assert_eq!(child_names(&parsed), vec!["LAST.M2TS".to_string()]);
|
||||||
|
assert_eq!(budget, MAX_TOTAL_DIR_ENTRIES, "the entry was counted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay a chain of `levels` nested directories into `reader`: directory
|
||||||
|
/// *n* has ICB at LBA `100 + n`, FID list at LBA `200 + n`, and one
|
||||||
|
/// subdirectory FID naming `D{n+1}`.
|
||||||
|
fn lay_dir_chain(reader: &mut MemReader, levels: u32) {
|
||||||
|
for n in 0..levels {
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "", 100 + n, true, true, 0);
|
||||||
|
push_fid_iu(
|
||||||
|
&mut fids,
|
||||||
|
&format!("D{}", n + 1),
|
||||||
|
100 + n + 1,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
reader.put(
|
||||||
|
100 + n,
|
||||||
|
build_dir_icb_tagged(266, 0, fids.len() as u32, 200 + n),
|
||||||
|
);
|
||||||
|
reader.put(200 + n, dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length of the chain of first-children hanging off `root`, counting
|
||||||
|
/// `root` itself.
|
||||||
|
fn chain_len(root: &DirEntry) -> usize {
|
||||||
|
let mut n = 1;
|
||||||
|
let mut cur = root;
|
||||||
|
while let Some(next) = cur.entries.first() {
|
||||||
|
n += 1;
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_stops_descending_at_the_nesting_cap() {
|
||||||
|
// MAX_DIR_DEPTH bounds how far a crafted disc can drive recursion.
|
||||||
|
// The directory sitting AT the cap must be emitted as a leaf and not
|
||||||
|
// descended into: descending one level further (an off-by-one on the
|
||||||
|
// comparison), or failing to increment the depth at all, removes the
|
||||||
|
// bound that the recursion depends on for termination.
|
||||||
|
//
|
||||||
|
// The chain laid here is deliberately deeper than the cap, so the
|
||||||
|
// returned tree's depth is decided by the cap and not by the fixture.
|
||||||
|
let deeper = MAX_DIR_DEPTH + 4;
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
lay_dir_chain(&mut reader, deeper);
|
||||||
|
|
||||||
|
let parsed = read_directory(
|
||||||
|
&mut reader,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
"ROOT",
|
||||||
|
0,
|
||||||
|
&mut 0,
|
||||||
|
&mut HashSet::new(),
|
||||||
|
)
|
||||||
|
.expect("a deep but well-formed chain parses");
|
||||||
|
|
||||||
|
// The root is read at depth 0; a directory read at depth d descends
|
||||||
|
// into its children only while d < MAX_DIR_DEPTH, so the deepest
|
||||||
|
// directory that is itself READ is the one at depth MAX_DIR_DEPTH,
|
||||||
|
// and the child named by its FID is emitted as a leaf. That is
|
||||||
|
// MAX_DIR_DEPTH + 2 nodes on the chain, root and leaf included.
|
||||||
|
assert_eq!(
|
||||||
|
chain_len(&parsed),
|
||||||
|
MAX_DIR_DEPTH as usize + 2,
|
||||||
|
"recursion must stop at the nesting cap, not before or after it"
|
||||||
|
);
|
||||||
|
// The deepest node is still reported (its NAME is known from the
|
||||||
|
// FID) but carries no children.
|
||||||
|
let mut cur = &parsed;
|
||||||
|
while let Some(next) = cur.entries.first() {
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cur.is_dir,
|
||||||
|
"the capped node is still known to be a directory"
|
||||||
|
);
|
||||||
|
assert!(cur.entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_advances_past_a_fids_implementation_use_area() {
|
||||||
|
// ECMA-167 4/14.4.9: the next FID starts at
|
||||||
|
// (38 + L_IU + L_FI) rounded up to a multiple of 4. Leaving L_IU out
|
||||||
|
// of the stride lands the scan in the middle of the current
|
||||||
|
// descriptor, so every file after the first one with a non-empty
|
||||||
|
// implementation-use area disappears.
|
||||||
|
//
|
||||||
|
// L_IU = 4, L_FI = 6 → stride 48. Dropping L_IU gives 40, which lands
|
||||||
|
// inside the first descriptor's name field.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "AAAAA", 7, false, false, 4);
|
||||||
|
assert_eq!(
|
||||||
|
fids.len(),
|
||||||
|
48,
|
||||||
|
"first FID stride must be 48 for this fixture"
|
||||||
|
);
|
||||||
|
push_fid_iu(&mut fids, "BBBBB", 8, false, false, 4);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, fids.len() as u32, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(1, 2048, 0));
|
||||||
|
reader.put(8, build_efe_icb(2, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("dir parses");
|
||||||
|
assert_eq!(
|
||||||
|
child_names(&parsed),
|
||||||
|
vec!["AAAAA".to_string(), "BBBBB".to_string()],
|
||||||
|
"the FID stride must include L_IU"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_reads_an_ad_that_ends_exactly_at_the_icb_sector_end() {
|
||||||
|
// The allocation descriptor sits at 216 + L_EA (tag 266) or
|
||||||
|
// 176 + L_EA (tag 261). A short_ad whose last byte is the last byte
|
||||||
|
// of the 2048-byte ICB sector is entirely present, so the guard must
|
||||||
|
// reject only descriptors that run PAST the sector. Rejecting the
|
||||||
|
// exact fit turns a readable directory into a read error.
|
||||||
|
for (tag, ad_base) in [(266u16, 216usize), (261u16, 176usize)] {
|
||||||
|
let l_ea = 2048 - 8 - ad_base; // ad_off + 8 == 2048 exactly
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "TIGHT.CLPI", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(tag, l_ea, fids.len() as u32, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(3, 2048, 0));
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.unwrap_or_else(|e| panic!("tag {tag}: exact-fit AD must be read: {e:?}"));
|
||||||
|
assert_eq!(child_names(&parsed), vec!["TIGHT.CLPI".to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_rejects_an_ad_running_past_the_icb_sector() {
|
||||||
|
// L_EA is a disc-controlled 32-bit byte count. One large enough to
|
||||||
|
// push the allocation descriptor past the end of the 2048-byte ICB
|
||||||
|
// sector must be an error, not an out-of-bounds read.
|
||||||
|
for (tag, ad_base) in [(266u16, 216usize), (261u16, 176usize)] {
|
||||||
|
let l_ea = 2048 - 4 - ad_base; // ad_off + 8 == 2052 > 2048
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(tag, l_ea, 2048, 60));
|
||||||
|
|
||||||
|
let err = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect_err("an AD past the end of the ICB sector must fail");
|
||||||
|
assert!(
|
||||||
|
matches!(err, Error::DiscRead { sector: 5, .. }),
|
||||||
|
"tag {tag}: the error must name the ICB sector, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_locates_the_ad_after_a_non_empty_extended_attribute_area() {
|
||||||
|
// ECMA-167 4/14.9 / 4/14.17: the Extended Attributes field occupies
|
||||||
|
// L_EA bytes immediately before the allocation descriptors, so the
|
||||||
|
// descriptors begin at base + L_EA. Real UDF file entries do carry
|
||||||
|
// extended attributes; computing the offset any other way reads the
|
||||||
|
// attribute bytes (or the fixed header) as an extent.
|
||||||
|
for (tag, _base) in [(266u16, 216usize), (261u16, 176usize)] {
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "WITHEA.MPLS", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(tag, 88, fids.len() as u32, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(6, 2048, 0));
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.unwrap_or_else(|e| panic!("tag {tag}: L_EA offset must be honoured: {e:?}"));
|
||||||
|
assert_eq!(child_names(&parsed), vec!["WITHEA.MPLS".to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_skips_a_deleted_fid_and_never_follows_its_icb() {
|
||||||
|
// ECMA-167 4/14.4.4 File Characteristics bit 2 = Deleted: "the file
|
||||||
|
// ... has been deleted". 4/14.4.3 then says the ICB field of such a
|
||||||
|
// descriptor MAY specify an extent of length zero — i.e. it need not
|
||||||
|
// point at anything at all.
|
||||||
|
//
|
||||||
|
// So a deleted FID must be skipped outright. Following its ICB reads
|
||||||
|
// whatever descriptor happens to sit at metadata LBA 0 (the File Set
|
||||||
|
// Descriptor, tag 256), which is not a File Entry. On a deleted
|
||||||
|
// DIRECTORY FID that is a hard read error — one stale descriptor in
|
||||||
|
// one directory and the entire volume fails to enumerate. On a
|
||||||
|
// deleted FILE FID it is worse than an error: the phantom name is
|
||||||
|
// reported as a real zero-byte file.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..)
|
||||||
|
// Deleted directory FID with a zeroed ICB field, exactly as 4/14.4.3
|
||||||
|
// permits.
|
||||||
|
let del = fids.len();
|
||||||
|
push_fid_iu(&mut fids, "OLDDIR", 0, true, false, 0);
|
||||||
|
fids[del + 18] |= 0x04; // Deleted
|
||||||
|
push_fid_iu(&mut fids, "LIVE.MPLS", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, fids.len() as u32, 60));
|
||||||
|
reader.put(60, dir);
|
||||||
|
reader.put(7, build_efe_icb(64, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("a deleted FID must not fail the enumeration of its directory");
|
||||||
|
assert_eq!(
|
||||||
|
child_names(&parsed),
|
||||||
|
vec!["LIVE.MPLS".to_string()],
|
||||||
|
"a deleted File Identifier Descriptor is not a file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_reads_every_byte_of_the_short_ad_length_and_location() {
|
||||||
|
// ECMA-167 4/14.14.1: a short_ad is two little-endian 32-bit fields —
|
||||||
|
// ExtentLength then ExtentPosition — and each of their four bytes
|
||||||
|
// carries weight. This fixture gives every byte a distinct non-zero
|
||||||
|
// value (except the length's most-significant byte, which any legal
|
||||||
|
// directory keeps at zero — see the oversized-length test), so a
|
||||||
|
// descriptor byte sourced from the wrong offset changes either the
|
||||||
|
// reported directory length or the sector the FID list is read from.
|
||||||
|
//
|
||||||
|
// length = 0x0002_012C = 131 372 bytes (a large but legal dir)
|
||||||
|
// location = 0x0102_033C = 16 909 628 (a high-LBA volume)
|
||||||
|
let ad_len: u32 = 0x0002_012C;
|
||||||
|
let ad_pos: u32 = 0x0102_033C;
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "BYTEWISE.CLPI", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
for tag in [261u16, 266u16] {
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(tag, 0, ad_len, ad_pos));
|
||||||
|
reader.put(ad_pos, dir);
|
||||||
|
reader.put(7, build_efe_icb(12, 2048, 0));
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.unwrap_or_else(|e| panic!("tag {tag}: short_ad must be decoded: {e:?}"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed.size, ad_len as u64,
|
||||||
|
"tag {tag}: every byte of the ExtentLength field is significant"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
child_names(&parsed),
|
||||||
|
vec!["BYTEWISE.CLPI".to_string()],
|
||||||
|
"tag {tag}: every byte of the ExtentPosition field is significant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_rejects_a_length_whose_high_byte_alone_exceeds_the_cap() {
|
||||||
|
// The most-significant byte of the 30-bit ExtentLength is enough on
|
||||||
|
// its own to demand a 16 MiB allocation — sixteen times the ceiling.
|
||||||
|
// It must be read, and the directory refused; ignoring that byte
|
||||||
|
// lets a crafted descriptor through with a length of zero, which
|
||||||
|
// then reads as a legitimately empty directory.
|
||||||
|
for tag in [261u16, 266u16] {
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(tag, 0, 0x0100_0000, 60));
|
||||||
|
|
||||||
|
let err = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect_err("a 16 MiB directory length must be refused, not read as empty");
|
||||||
|
assert!(
|
||||||
|
matches!(err, Error::DiscRead { .. }),
|
||||||
|
"tag {tag}: got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_reads_a_directory_exactly_at_the_size_cap() {
|
||||||
|
// MAX_DIR_BYTES bounds the allocation a corrupt 30-bit length can
|
||||||
|
// force. A directory whose declared length is exactly the ceiling is
|
||||||
|
// admissible and must be read; rejecting it would make the largest
|
||||||
|
// legal STREAM/ directory unreadable.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "00000.M2TS", 7, false, false, 0);
|
||||||
|
let mut dir = [0u8; 2048];
|
||||||
|
dir[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, MAX_DIR_BYTES, 60));
|
||||||
|
reader.put(60, dir); // remaining sectors read as zeros → scan ends
|
||||||
|
reader.put(7, build_efe_icb(4, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("a directory exactly at the size ceiling must be readable");
|
||||||
|
assert_eq!(child_names(&parsed), vec!["00000.M2TS".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_directory_reads_each_sector_of_a_multi_sector_directory_into_its_own_slot() {
|
||||||
|
// A directory larger than one sector is read a sector at a time into
|
||||||
|
// successive 2048-byte windows of one buffer. Every sector must land
|
||||||
|
// in its OWN window: a later sector written over an earlier one
|
||||||
|
// destroys FIDs that were already read correctly.
|
||||||
|
//
|
||||||
|
// Sector 0 of the FID list holds the only entry; sector 1 is blank.
|
||||||
|
// If sector 1's read lands at offset 0 it erases the entry, and the
|
||||||
|
// directory reads as empty rather than failing.
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid_iu(&mut fids, "FIRST.CLPI", 7, false, false, 0);
|
||||||
|
let mut sector0 = [0u8; 2048];
|
||||||
|
sector0[..fids.len()].copy_from_slice(&fids);
|
||||||
|
|
||||||
|
let mut reader = MemReader::new();
|
||||||
|
reader.put(5, build_dir_icb_tagged(266, 0, 4096, 60));
|
||||||
|
reader.put(60, sector0);
|
||||||
|
reader.put(61, [0u8; 2048]);
|
||||||
|
reader.put(7, build_efe_icb(8, 2048, 0));
|
||||||
|
|
||||||
|
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
|
||||||
|
.expect("a two-sector directory parses");
|
||||||
|
assert_eq!(child_names(&parsed), vec!["FIRST.CLPI".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared UDF image fixtures for tests across the `disc::*` format scanners.
|
/// Shared UDF image fixtures for tests across the `disc::*` format scanners.
|
||||||
|
|||||||
Reference in New Issue
Block a user