From 55b97ac5760f075b5bf81f8a87a2ab99adfda7c8 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:32:06 -0700 Subject: [PATCH] fix(udf): skip deleted File Identifier Descriptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/udf.rs | 666 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 665 insertions(+), 1 deletion(-) diff --git a/src/udf.rs b/src/udf.rs index 9f0ad80..46500b5 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1195,8 +1195,17 @@ fn read_directory( let is_dir = (file_chars & 0x02) != 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_end = name_start + l_fi; if name_end > dir_data.len() { @@ -2899,6 +2908,661 @@ mod tests { assert_eq!(fs.root.entries.len(), 1); 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, + name: &str, + icb_lba: u32, + is_dir: bool, + is_parent: bool, + l_iu: u16, + ) { + let start = buf.len(); + let name_field: Vec = 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 { + 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.