Skip to main content

udf_forensic/
lib.rs

1//! UDF (Universal Disk Format) — detection and file-entry traversal.
2//!
3//! UDF bridge discs carry both ISO 9660 and UDF structures on the same sectors.
4//! The UDF recognition sequence starts at sector 16: each Volume Structure
5//! Descriptor is 2048 bytes with a 5-byte identifier at bytes 1-5.
6//!
7//! Identifiers: "BEA01" (Extended Area Descriptor), "NSR02" or "NSR03"
8//! (OSTA CS0 UDF mark), "TEA01" (Terminating Extended Area Descriptor).
9//! NSR02/NSR03 presence is the definitive UDF indicator.
10//!
11//! # Full UDF traversal
12//!
13//! Descriptor chain: AVDP (LBA 256) → VDS → Partition Descriptor (partition
14//! start LBA) + Logical Volume Descriptor (FSD location) → File Set Descriptor
15//! (root dir FE LBA) → File Entry → File Identifier Descriptors.
16//!
17//! All physical LBAs satisfy: `phys_lba = partition_start + logical_block_num`.
18
19use std::io::{self, Read, Seek, SeekFrom};
20
21pub mod findings;
22
23/// The forensic-vfs `FileSystem` adapter (behind the `vfs` feature).
24#[cfg(feature = "vfs")]
25pub mod vfs;
26
27/// The canonical 5-level severity scale, re-exported at the crate root for
28/// convenience (the analyzer grades every finding on it).
29pub use forensicnomicon::report::Severity;
30
31// ── ECMA-167 / UDF tag identifiers ───────────────────────────────────────────
32
33const TAG_AVDP: u16 = 2;
34const TAG_PD: u16 = 5;
35const TAG_LVD: u16 = 6;
36const TAG_TERM: u16 = 8;
37const TAG_FSD: u16 = 256;
38const TAG_FID: u16 = 257;
39const TAG_FE: u16 = 260;
40/// Some UDF implementations (e.g. older genisoimage) write 261 for File Entry.
41const TAG_FE_ALT: u16 = 261;
42const TAG_EFE: u16 = 266;
43
44// FID File Characteristics bits
45const FC_DIRECTORY: u8 = 0x02;
46const FC_PARENT: u8 = 0x08;
47
48// ICB allocation type (FE flags bits 0-2)
49const ALLOC_SHORT: u16 = 0;
50const ALLOC_LONG: u16 = 1;
51const ALLOC_INLINE: u16 = 3;
52
53// Extent type bits 30-31 of extent_length field
54const EXTENT_RECORDED: u32 = 0x0000_0000; // 0b00 in bits 30-31
55
56// ── Logical block size ────────────────────────────────────────────────────────
57
58/// Largest logical block size we read into a stack sector buffer.
59const MAX_BLOCK_SIZE: usize = 4096;
60
61/// Candidate UDF logical block sizes, most-common first. Optical media (CD/DVD/
62/// BD) use 2048; hard-disk and USB UDF use 512; Advanced-Format media use 4096.
63const BLOCK_SIZE_CANDIDATES: [u32; 4] = [2048, 512, 1024, 4096];
64
65// ── Public types ──────────────────────────────────────────────────────────────
66
67/// A single entry returned by UDF directory traversal.
68#[derive(Debug, Clone)]
69pub struct UdfFileEntry {
70    /// Decoded filename (OSTA CS0: UTF-8 or UTF-16BE).
71    pub name: String,
72    /// True if this entry is a directory.
73    pub is_dir: bool,
74    /// File size in bytes (Information Length from FE).
75    pub size: u64,
76    /// Physical LBA of the File Entry descriptor sector.
77    pub fe_lba: u32,
78}
79
80// ── Partition map kinds (ECMA-167 §10.7, OSTA UDF §2.2.8) ────────────────────
81
82/// The kind of partition referenced by the UDF logical volume's file set.
83///
84/// `Physical` (Type 1) partitions resolve as `partition_start + logical_block`.
85/// `Virtual` (VAT), `Sparable` (defect-managed), and `Metadata` (UDF 2.50+,
86/// used by Blu-ray) are Type 2 partitions whose block resolution requires
87/// additional structures this crate does not yet follow — they are detected
88/// and reported so a forensic tool fails loudly rather than mis-reading.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91pub enum UdfPartitionKind {
92    /// Type 1 physical partition.
93    Physical,
94    /// Type 2 `*UDF Virtual Partition` (VAT-mapped, packet-written media).
95    Virtual,
96    /// Type 2 `*UDF Sparable Partition` (defect management).
97    Sparable,
98    /// Type 2 `*UDF Metadata Partition` (UDF 2.50+, Blu-ray).
99    Metadata,
100    /// Type 2 partition with an unrecognised identifier.
101    Unknown,
102}
103
104// ── Internal UDF state ────────────────────────────────────────────────────────
105
106#[derive(Debug)]
107pub struct UdfState {
108    pub partition_start: u32,
109    pub root_fe_lba: u32,
110    pub partition_kind: UdfPartitionKind,
111    pub partition_map_count: u32,
112    /// The medium's logical block size in bytes (512, 1024, 2048, or 4096),
113    /// detected from the Anchor Volume Descriptor Pointer location rather than
114    /// assumed — optical UDF is 2048-byte, but hard-disk media is 512-byte.
115    pub block_size: u32,
116    /// Physical LBA of the File Set Descriptor (`partition_start` + its logical
117    /// block). The findings analyzer reads its recording time and validates its
118    /// descriptor tag.
119    pub fsd_lba: u32,
120    /// Logical sector where the Volume Descriptor Sequence begins (from the
121    /// AVDP). The findings analyzer re-walks the VDS to validate each
122    /// descriptor's tag.
123    pub vds_loc: u32,
124    /// Length of the Volume Descriptor Sequence in whole logical blocks.
125    pub vds_len_sectors: u32,
126}
127
128// ── UDF detection (existing public API) ──────────────────────────────────────
129
130/// True if the image has a UDF recognition sequence (NSR02 or NSR03).
131///
132/// Scans volume structure descriptors starting at LBA 16, up to LBA 32.
133pub fn detect_udf<R: Read + Seek>(reader: &mut R) -> bool {
134    let mut buf = [0u8; 6];
135    for lba in 16u64..32 {
136        let pos = lba * 2048 + 1;
137        if reader.seek(SeekFrom::Start(pos)).is_err() {
138            break;
139        }
140        if reader.read_exact(&mut buf).is_err() {
141            break;
142        }
143        let id = &buf[..5];
144        if id == b"NSR02" || id == b"NSR03" {
145            return true;
146        }
147        if id == b"TEA01" {
148            break;
149        }
150    }
151    false
152}
153
154// ── UDF traversal (new internal API) ─────────────────────────────────────────
155
156/// Try to parse the AVDP → VDS → FSD chain, returning state needed for
157/// directory traversal. Returns `None` if the image lacks a valid UDF structure.
158///
159/// Lenient wrapper over [`parse_udf_state_checked`]: a real seek/read I/O error
160/// reading the anchor/VDS/FSD is folded into `None`, indistinguishable from a
161/// structural "not UDF". Use [`parse_udf_state_checked`] when a truncated or
162/// unreadable image must be told apart from a genuine non-UDF source.
163pub fn parse_udf_state<R: Read + Seek>(reader: &mut R) -> Option<UdfState> {
164    parse_udf_state_checked(reader).ok().flatten()
165}
166
167/// Parse the AVDP → VDS → FSD bootstrap chain, distinguishing a real read
168/// failure from a structural negative.
169///
170/// - `Err(io)` — a seek/read I/O error reading the anchor (LBA 256), the Volume
171///   Descriptor Sequence, or the File Set Descriptor. This includes
172///   [`io::ErrorKind::UnexpectedEof`] when the image is truncated before the
173///   anchor, which is itself forensically suspicious and must surface rather
174///   than masquerade as "not UDF".
175/// - `Ok(None)` — every read succeeded but the structure is not valid UDF (the
176///   anchor tag is not an AVDP, or the descriptor chain is absent/incoherent).
177///   This is the legitimate "not UDF" case.
178/// - `Ok(Some(state))` — a valid UDF structure.
179pub fn parse_udf_state_checked<R: Read + Seek>(
180    reader: &mut R,
181) -> Result<Option<UdfState>, io::Error> {
182    let Some(block_size) = detect_block_size(reader)? else {
183        return Ok(None);
184    };
185    let Some((vds_loc, vds_len)) = read_avdp_checked(reader, block_size)? else {
186        return Ok(None);
187    };
188    let Some(vds) = read_vds_checked(reader, block_size, vds_loc, vds_len)? else {
189        return Ok(None);
190    };
191    let Some(root_fe_lba) = read_fsd_checked(reader, block_size, vds.fsd_lba, vds.partition_start)?
192    else {
193        return Ok(None);
194    };
195    Ok(Some(UdfState {
196        partition_start: vds.partition_start,
197        root_fe_lba,
198        partition_kind: vds.partition_kind,
199        partition_map_count: vds.map_count,
200        block_size,
201        fsd_lba: vds.fsd_lba,
202        vds_loc,
203        vds_len_sectors: (vds_len as usize).div_ceil(block_size as usize) as u32,
204    }))
205}
206
207/// Resolved Volume Descriptor Sequence information.
208struct VdsInfo {
209    partition_start: u32,
210    fsd_lba: u32,
211    partition_kind: UdfPartitionKind,
212    map_count: u32,
213}
214
215/// A parsed partition map entry from the Logical Volume Descriptor.
216struct PartitionMap {
217    kind: UdfPartitionKind,
218    /// Partition number (Type 1 only); `None` for Type 2 maps.
219    partition_number: Option<u16>,
220}
221
222/// Classify a Type 2 partition map by scanning its identifier region for the
223/// OSTA UDF entity strings.
224fn classify_type2(map: &[u8]) -> UdfPartitionKind {
225    let scan = |needle: &[u8]| map.windows(needle.len()).any(|w| w == needle);
226    if scan(b"*UDF Metadata Partition") {
227        UdfPartitionKind::Metadata
228    } else if scan(b"*UDF Virtual Partition") {
229        UdfPartitionKind::Virtual
230    } else if scan(b"*UDF Sparable Partition") {
231        UdfPartitionKind::Sparable
232    } else {
233        UdfPartitionKind::Unknown
234    }
235}
236
237/// Parse the partition maps from a Logical Volume Descriptor sector.
238///
239/// LVD (ECMA-167 §10.6): N_PM at BP 268, Map Table Length at BP 264, maps at
240/// BP 440.  Each map: `[type(1)][length(1)]…`; Type 1 carries the partition
241/// number at RBP 4; Type 2 is identified by its embedded entity string.
242fn parse_partition_maps(lvd: &[u8]) -> Vec<PartitionMap> {
243    let n_pm = u32::from_le_bytes(lvd[268..272].try_into().unwrap()) as usize;
244    let mt_l = u32::from_le_bytes(lvd[264..268].try_into().unwrap()) as usize;
245    let maps_end = (440 + mt_l).min(lvd.len());
246    let mut out = Vec::new();
247    let mut off = 440;
248    while out.len() < n_pm && off + 2 <= maps_end {
249        let map_type = lvd[off];
250        let map_len = lvd[off + 1] as usize;
251        if map_len < 2 || off + map_len > maps_end {
252            break;
253        }
254        let map = &lvd[off..off + map_len];
255        let pm = match map_type {
256            1 if map_len >= 6 => PartitionMap {
257                kind: UdfPartitionKind::Physical,
258                partition_number: Some(u16::from_le_bytes([map[4], map[5]])),
259            },
260            2 => PartitionMap {
261                kind: classify_type2(map),
262                partition_number: None,
263            },
264            _ => PartitionMap {
265                kind: UdfPartitionKind::Unknown,
266                partition_number: None,
267            },
268        };
269        out.push(pm);
270        off += map_len;
271    }
272    out
273}
274
275/// Read all non-parent File Identifier Descriptors from the directory whose
276/// File Entry resides at `dir_fe_lba`, returning one `UdfFileEntry` per child.
277pub fn read_dir_at_lba<R: Read + Seek>(
278    reader: &mut R,
279    block_size: u32,
280    partition_start: u32,
281    dir_fe_lba: u32,
282) -> Option<Vec<UdfFileEntry>> {
283    let dir_data = read_fe_data(reader, block_size, partition_start, dir_fe_lba)?;
284    Some(parse_fids(reader, block_size, partition_start, &dir_data))
285}
286
287/// Read the data extent of the File Entry at `fe_lba`.
288pub fn read_fe_data<R: Read + Seek>(
289    reader: &mut R,
290    block_size: u32,
291    partition_start: u32,
292    fe_lba: u32,
293) -> Option<Vec<u8>> {
294    let mut sector = [0u8; MAX_BLOCK_SIZE];
295    let sector = &mut sector[..block_size as usize];
296    seek_read(reader, fe_lba as u64 * block_size as u64, sector)?;
297
298    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
299    let is_efe = tag_ident == TAG_EFE;
300    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && !is_efe {
301        return None;
302    }
303
304    let icb_flags = u16::from_le_bytes([sector[34], sector[35]]);
305    let alloc_type = icb_flags & 0x0007;
306    let info_len = u64::from_le_bytes(sector[56..64].try_into().unwrap());
307
308    // Base File Entry (ECMA-167 4/14.9): L_EA @168, L_AD @172, area @176.
309    // Extended File Entry (4/14.17) inserts ObjectSize(8), CreationTime(12),
310    // StreamDirectoryICB(16), and Reserved(4) — 40 bytes total — ahead of the
311    // extended-attr / alloc-descriptor lengths, so L_EA @208, L_AD @212, area
312    // @216. (The pre-EFE value 176/180/184 assumed only the 8-byte ObjectSize
313    // insertion and is wrong for a real EFE — verified against a real mkudffs
314    // Extended-File-Entry root directory.)
315    let (ea_off, ad_off, header) = if is_efe {
316        (208usize, 212usize, 216usize)
317    } else {
318        (168usize, 172usize, 176usize)
319    };
320
321    if ad_off + 4 > sector.len() {
322        return None;
323    }
324    let ea_len = u32::from_le_bytes(sector[ea_off..ea_off + 4].try_into().unwrap()) as usize;
325    let ad_len = u32::from_le_bytes(sector[ad_off..ad_off + 4].try_into().unwrap()) as usize;
326
327    let ad_start = header + ea_len;
328    let ad_end = ad_start + ad_len;
329    if ad_end > sector.len() {
330        return None;
331    }
332    let ad_area = sector[ad_start..ad_end].to_vec();
333
334    match alloc_type {
335        ALLOC_INLINE => Some(ad_area[..info_len.min(ad_area.len() as u64) as usize].to_vec()),
336        ALLOC_SHORT => read_extents_short(reader, block_size, partition_start, &ad_area, info_len),
337        ALLOC_LONG => read_extents_long(reader, block_size, partition_start, &ad_area, info_len),
338        _ => None,
339    }
340}
341
342// ── Private helpers ───────────────────────────────────────────────────────────
343
344/// Detect the medium's logical block size by locating the Anchor Volume
345/// Descriptor Pointer (ECMA-167 §3 / OSTA UDF §2.2.3): the AVDP sits at logical
346/// sector 256, so for each candidate block size `bs` the anchor is at byte
347/// `256 * bs`. A candidate is accepted when that sector carries the AVDP tag
348/// identifier (2) AND the descriptor tag's recorded location field equals 256 —
349/// the location check rules out a stray `0x0002` at the wrong probe offset.
350///
351/// Truncation handling mirrors [`read_avdp_checked`]: if *no* candidate's anchor
352/// was even large enough to read (every probe hit `UnexpectedEof`), the image is
353/// truncated before any possible AVDP and that surfaces as `Err`; if some probe
354/// read but none matched, the source is readable-but-not-UDF (`Ok(None)`).
355fn detect_block_size<R: Read + Seek>(reader: &mut R) -> Result<Option<u32>, io::Error> {
356    let mut tag = [0u8; 16];
357    let mut last_eof: Option<io::Error> = None;
358    let mut any_read_ok = false;
359    for bs in BLOCK_SIZE_CANDIDATES {
360        match seek_read_checked(reader, 256 * bs as u64, &mut tag) {
361            Ok(()) => {
362                any_read_ok = true;
363                let tag_ident = u16::from_le_bytes([tag[0], tag[1]]);
364                let tag_location = u32::from_le_bytes(tag[12..16].try_into().unwrap());
365                if tag_ident == TAG_AVDP && tag_location == 256 {
366                    return Ok(Some(bs));
367                }
368            }
369            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => last_eof = Some(e),
370            Err(e) => return Err(e),
371        }
372    }
373    if !any_read_ok {
374        if let Some(e) = last_eof {
375            return Err(e);
376        }
377    }
378    Ok(None)
379}
380
381/// Parse the AVDP at logical sector 256 (`256 * block_size` bytes). `Err` on a
382/// read I/O failure, `Ok(None)` when the anchor read succeeds but is not an
383/// AVDP, `Ok(Some((vds_loc, vds_len)))` when the anchor is valid.
384fn read_avdp_checked<R: Read + Seek>(
385    reader: &mut R,
386    block_size: u32,
387) -> Result<Option<(u32, u32)>, io::Error> {
388    let mut sector = [0u8; MAX_BLOCK_SIZE];
389    let sector = &mut sector[..block_size as usize];
390    seek_read_checked(reader, 256 * block_size as u64, sector)?;
391    if u16::from_le_bytes([sector[0], sector[1]]) != TAG_AVDP {
392        return Ok(None);
393    }
394    let vds_len = u32::from_le_bytes(sector[16..20].try_into().unwrap());
395    let vds_loc = u32::from_le_bytes(sector[20..24].try_into().unwrap());
396    Ok(Some((vds_loc, vds_len)))
397}
398
399/// Scan the Volume Descriptor Sequence: collect every Partition Descriptor
400/// (partition number → starting location) and the Logical Volume Descriptor
401/// (file-set location, partition reference, and partition maps), then resolve
402/// the file set's partition through its map.
403fn read_vds_checked<R: Read + Seek>(
404    reader: &mut R,
405    block_size: u32,
406    vds_loc: u32,
407    vds_len: u32,
408) -> Result<Option<VdsInfo>, io::Error> {
409    use std::collections::HashMap;
410    let sectors = (vds_len as usize).div_ceil(block_size as usize);
411
412    // partition number → starting location (physical LBA).
413    let mut pd_start: HashMap<u16, u32> = HashMap::new();
414    let mut fsd_lbn: Option<u32> = None;
415    let mut fsd_part_ref: u16 = 0;
416    let mut maps: Vec<PartitionMap> = Vec::new();
417
418    for i in 0..sectors {
419        let mut sector = [0u8; MAX_BLOCK_SIZE];
420        let sector = &mut sector[..block_size as usize];
421        seek_read_checked(
422            reader,
423            (vds_loc as u64 + i as u64) * block_size as u64,
424            sector,
425        )?;
426        let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
427        match tag_ident {
428            TAG_PD => {
429                let part_num = u16::from_le_bytes([sector[22], sector[23]]);
430                let psl = u32::from_le_bytes(sector[188..192].try_into().unwrap());
431                pd_start.insert(part_num, psl);
432            }
433            TAG_LVD => {
434                // LV Contents Use long_ad at offset 248: extent_length [248..252],
435                // logical_block_num [252..256], partition_reference [256..258].
436                fsd_lbn = Some(u32::from_le_bytes(sector[252..256].try_into().unwrap()));
437                fsd_part_ref = u16::from_le_bytes([sector[256], sector[257]]);
438                maps = parse_partition_maps(sector);
439            }
440            TAG_TERM | 0 => break,
441            _ => {}
442        }
443    }
444
445    // Reads all succeeded; a missing LVD / unresolvable partition is structural.
446    let Some(fsd) = fsd_lbn else {
447        return Ok(None);
448    };
449    let map_count = maps.len() as u32;
450
451    // Resolve the file set's partition via the referenced partition map.
452    let referenced = maps.get(fsd_part_ref as usize);
453    let kind = referenced.map_or(UdfPartitionKind::Unknown, |m| m.kind);
454
455    // Type 1: resolve the partition start from the map's partition number.
456    // Type 2 (Virtual/Sparable/Metadata): block resolution needs structures we
457    // do not yet follow — fall back to the first physical partition so detection
458    // still works, and report the kind so callers know reads may be incomplete.
459    let partition_start = referenced
460        .and_then(|m| m.partition_number)
461        .and_then(|pn| pd_start.get(&pn).copied())
462        .or_else(|| pd_start.values().min().copied());
463    let Some(partition_start) = partition_start else {
464        return Ok(None);
465    };
466
467    Ok(Some(VdsInfo {
468        partition_start,
469        fsd_lba: partition_start + fsd,
470        partition_kind: kind,
471        map_count,
472    }))
473}
474
475/// Parse FSD at `fsd_lba` to find the root directory FE logical block number.
476/// `Err` on a read I/O failure, `Ok(None)` when the FSD read succeeds but its
477/// tag is not an FSD, `Ok(Some(root_fe_lba))` when the FSD is valid.
478fn read_fsd_checked<R: Read + Seek>(
479    reader: &mut R,
480    block_size: u32,
481    fsd_lba: u32,
482    partition_start: u32,
483) -> Result<Option<u32>, io::Error> {
484    let mut sector = [0u8; MAX_BLOCK_SIZE];
485    let sector = &mut sector[..block_size as usize];
486    seek_read_checked(reader, fsd_lba as u64 * block_size as u64, sector)?;
487    if u16::from_le_bytes([sector[0], sector[1]]) != TAG_FSD {
488        return Ok(None);
489    }
490    // FSD field sizes (ECMA-167 Table 20):
491    //   Tag(16) + RecordingDate(12) + Interchange/Charset fields(28) +
492    //   LV Ident CharSet(64) + LV Identifier(128) + FS CharSet(64) +
493    //   FS Identifier(32) + Copyright FI(32) + Abstract FI(32) = 408 bytes.
494    // Root Directory ICB (long_ad) starts at offset 400:
495    //   extent_length [400..404], logical_block_num [404..408]
496    let lbn = u32::from_le_bytes(sector[404..408].try_into().unwrap());
497    Ok(Some(partition_start + lbn))
498}
499
500/// Detect whether FIDs in this directory data use a standard 16-byte ECMA-167 tag
501/// or an extended 18-byte tag written by some UDF tools.
502///
503/// Some implementations append 2 extra bytes after the standard tag before the
504/// FID body, making all field offsets shift by 2. Detection heuristic: read the
505/// ICB logical block number at both candidate positions and use whichever gives a
506/// plausible value (< 65536, fitting discs up to ~128 GB).
507fn detect_fid_tag_size(data: &[u8]) -> usize {
508    let mut off = 0;
509    while off + 28 <= data.len() {
510        let ti = u16::from_le_bytes([data[off], data[off + 1]]);
511        if ti == TAG_FID {
512            let lbn16 = if off + 26 <= data.len() {
513                u32::from_le_bytes(data[off + 22..off + 26].try_into().unwrap())
514            } else {
515                u32::MAX
516            };
517            let lbn18 = if off + 28 <= data.len() {
518                u32::from_le_bytes(data[off + 24..off + 28].try_into().unwrap())
519            } else {
520                u32::MAX
521            };
522            if lbn16 < 0x10000 {
523                return 16;
524            }
525            if lbn18 < 0x10000 {
526                return 18;
527            }
528            return 16; // can't determine; fall back to standard
529        }
530        off += 4;
531    }
532    16
533}
534
535/// Parse File Identifier Descriptors from raw directory data.
536fn parse_fids<R: Read + Seek>(
537    reader: &mut R,
538    block_size: u32,
539    partition_start: u32,
540    data: &[u8],
541) -> Vec<UdfFileEntry> {
542    // Some UDF tools write an extra 2 bytes after the standard 16-byte tag.
543    // tag_size is 16 (standard) or 18 (extended); body fields follow at tag_size.
544    let tag_size = detect_fid_tag_size(data);
545    let min_fid = tag_size + 20; // tag + chars(1)+L_FI(1)+ICB(16)+L_IU(2)
546
547    let mut entries = Vec::new();
548    let mut off = 0;
549
550    while off + min_fid <= data.len() {
551        let tag_ident = u16::from_le_bytes([data[off], data[off + 1]]);
552        if tag_ident != TAG_FID {
553            // Advance 4 bytes to stay aligned; skip padding or unknown tags.
554            off += 4;
555            continue;
556        }
557
558        // CRC_len (at tag[10..12]) gives the true body extent from byte 16.
559        let crc_len = u16::from_le_bytes([data[off + 10], data[off + 11]]) as usize;
560        let fid_advance = ((16 + crc_len + 3) & !3).max(min_fid);
561        if off + fid_advance > data.len() {
562            break;
563        }
564
565        let file_chars = data[off + tag_size];
566        let file_id_len = data[off + tag_size + 1] as usize;
567        // ICB long_ad: extent_length at body[2..6], lbn at body[6..10]
568        let icb_lbn = if off + tag_size + 10 <= data.len() {
569            u32::from_le_bytes(
570                data[off + tag_size + 6..off + tag_size + 10]
571                    .try_into()
572                    .unwrap(),
573            )
574        } else {
575            off += fid_advance.max(4);
576            continue;
577        };
578        let impl_use_len = if off + tag_size + 20 <= data.len() {
579            u16::from_le_bytes([data[off + tag_size + 18], data[off + tag_size + 19]]) as usize
580        } else {
581            off += fid_advance.max(4);
582            continue;
583        };
584
585        if file_chars & FC_PARENT == 0 {
586            let is_dir = file_chars & FC_DIRECTORY != 0;
587            let fe_lba = partition_start + icb_lbn;
588
589            let id_start = off + tag_size + 20 + impl_use_len;
590            let id_end = (id_start + file_id_len).min(data.len());
591            let name = if id_end > id_start {
592                decode_osta_cs0(&data[id_start..id_end])
593            } else {
594                String::new()
595            };
596
597            // Read the FE to get the canonical file size.
598            let size = read_fe_info_len(reader, block_size, fe_lba).unwrap_or(0);
599
600            entries.push(UdfFileEntry {
601                name,
602                is_dir,
603                size,
604                fe_lba,
605            });
606        }
607
608        off += fid_advance.max(4);
609    }
610    entries
611}
612
613/// Read the Information Length (file size) from a File Entry at `fe_lba`.
614fn read_fe_info_len<R: Read + Seek>(reader: &mut R, block_size: u32, fe_lba: u32) -> Option<u64> {
615    let mut sector = [0u8; MAX_BLOCK_SIZE];
616    let sector = &mut sector[..block_size as usize];
617    seek_read(reader, fe_lba as u64 * block_size as u64, sector)?;
618    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
619    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && tag_ident != TAG_EFE {
620        return None;
621    }
622    Some(u64::from_le_bytes(sector[56..64].try_into().unwrap()))
623}
624
625/// Read the ICB Tag File Type of the File Entry at `fe_lba` (ECMA-167 4/14.6.6):
626/// the descriptor tag is 16 bytes, the ICB Tag follows it, and its File Type
627/// field sits at ICB-Tag offset 11 (FE offset 27). `4` = directory, `5` = a
628/// regular file. `None` when the sector is not a File Entry / Extended File
629/// Entry, so a non-FE LBA passed to a directory op is told apart from a file.
630///
631/// Used by the `vfs` adapter to classify an arbitrary File Entry LBA.
632#[cfg(feature = "vfs")]
633pub(crate) fn read_fe_file_type<R: Read + Seek>(
634    reader: &mut R,
635    block_size: u32,
636    fe_lba: u32,
637) -> Option<u8> {
638    let mut sector = [0u8; MAX_BLOCK_SIZE];
639    let sector = &mut sector[..block_size as usize];
640    seek_read(reader, fe_lba as u64 * block_size as u64, sector)?;
641    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
642    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && tag_ident != TAG_EFE {
643        return None;
644    }
645    sector.get(27).copied()
646}
647
648/// ECMA-167 ICB Tag File Type for a directory (4/14.6.6).
649#[cfg(feature = "vfs")]
650pub(crate) const FILE_TYPE_DIRECTORY: u8 = 4;
651
652/// Collect data from short allocation descriptors (8 bytes each).
653fn read_extents_short<R: Read + Seek>(
654    reader: &mut R,
655    block_size: u32,
656    partition_start: u32,
657    ad_area: &[u8],
658    total_len: u64,
659) -> Option<Vec<u8>> {
660    let mut data = Vec::new();
661    let mut pos = 0;
662    while pos + 8 <= ad_area.len() && (data.len() as u64) < total_len {
663        let len_raw = u32::from_le_bytes(ad_area[pos..pos + 4].try_into().unwrap());
664        let ext_pos = u32::from_le_bytes(ad_area[pos + 4..pos + 8].try_into().unwrap());
665        let ext_type = len_raw >> 30;
666        let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
667        if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
668            let phys = (partition_start as u64 + ext_pos as u64) * block_size as u64;
669            read_extent(reader, block_size, phys, ext_len, total_len, &mut data)?;
670        }
671        pos += 8;
672    }
673    data.truncate(total_len as usize);
674    Some(data)
675}
676
677/// Collect data from long allocation descriptors (16 bytes each).
678fn read_extents_long<R: Read + Seek>(
679    reader: &mut R,
680    block_size: u32,
681    partition_start: u32,
682    ad_area: &[u8],
683    total_len: u64,
684) -> Option<Vec<u8>> {
685    let mut data = Vec::new();
686    let mut pos = 0;
687    while pos + 16 <= ad_area.len() && (data.len() as u64) < total_len {
688        let len_raw = u32::from_le_bytes(ad_area[pos..pos + 4].try_into().unwrap());
689        let lbn = u32::from_le_bytes(ad_area[pos + 4..pos + 8].try_into().unwrap());
690        let ext_type = len_raw >> 30;
691        let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
692        if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
693            let phys = (partition_start as u64 + lbn as u64) * block_size as u64;
694            read_extent(reader, block_size, phys, ext_len, total_len, &mut data)?;
695        }
696        pos += 16;
697    }
698    data.truncate(total_len as usize);
699    Some(data)
700}
701
702/// Read `ext_len` bytes from `byte_pos`, appending to `data` up to `total_len`.
703fn read_extent<R: Read + Seek>(
704    reader: &mut R,
705    block_size: u32,
706    byte_pos: u64,
707    ext_len: usize,
708    total_len: u64,
709    data: &mut Vec<u8>,
710) -> Option<()> {
711    let bs = block_size as usize;
712    let sectors = ext_len.div_ceil(bs);
713    for i in 0..sectors {
714        let mut sector = [0u8; MAX_BLOCK_SIZE];
715        let sector = &mut sector[..bs];
716        seek_read(reader, byte_pos + i as u64 * block_size as u64, sector)?;
717        let already = data.len() as u64;
718        let remaining = total_len.saturating_sub(already) as usize;
719        let sector_bytes = (ext_len - i * bs).min(bs);
720        let take = sector_bytes.min(remaining);
721        data.extend_from_slice(&sector[..take]);
722    }
723    Some(())
724}
725
726/// Decode an OSTA CS0 encoded identifier: first byte is compression ID
727/// (8 = UTF-8, 16 = UTF-16BE), remainder is character data.
728fn decode_osta_cs0(bytes: &[u8]) -> String {
729    if bytes.is_empty() {
730        return String::new();
731    }
732    let comp_id = bytes[0];
733    let payload = &bytes[1..];
734    match comp_id {
735        8 => String::from_utf8_lossy(payload).into_owned(),
736        16 => {
737            let pairs: Vec<u16> = payload
738                .chunks_exact(2)
739                .map(|c| u16::from_be_bytes([c[0], c[1]]))
740                .collect();
741            String::from_utf16_lossy(&pairs)
742        }
743        _ => String::from_utf8_lossy(payload).into_owned(),
744    }
745}
746
747/// Seek to `byte_pos` and read exactly `buf.len()` bytes; returns `None` on any error.
748fn seek_read<R: Read + Seek>(reader: &mut R, byte_pos: u64, buf: &mut [u8]) -> Option<()> {
749    seek_read_checked(reader, byte_pos, buf).ok()
750}
751
752/// Seek to `byte_pos` and read exactly `buf.len()` bytes, propagating the real
753/// [`io::Error`] (a truncated image yields [`io::ErrorKind::UnexpectedEof`]).
754fn seek_read_checked<R: Read + Seek>(
755    reader: &mut R,
756    byte_pos: u64,
757    buf: &mut [u8],
758) -> Result<(), io::Error> {
759    reader.seek(SeekFrom::Start(byte_pos))?;
760    reader.read_exact(buf)?;
761    Ok(())
762}
763
764// ── Forensic-findings support (used by `findings`) ───────────────────────────
765
766/// The ECMA-167 descriptor-tag checksum (3/7.2): the mod-256 sum of the 16 tag
767/// bytes excluding byte 4 (the checksum field itself).
768pub(crate) fn tag_checksum(tag: &[u8]) -> u8 {
769    let mut sum: u32 = 0;
770    for (i, &b) in tag.iter().take(16).enumerate() {
771        if i == 4 {
772            continue;
773        }
774        sum = sum.wrapping_add(u32::from(b));
775    }
776    (sum & 0xFF) as u8
777}
778
779/// The ECMA-167 descriptor CRC (3/7.2): CRC-CCITT with polynomial `0x1021`,
780/// initial value `0x0000`, no input/output reflection and no final XOR,
781/// computed over the descriptor body (the bytes after the 16-byte tag).
782pub(crate) fn ecma167_crc(body: &[u8]) -> u16 {
783    let mut crc: u16 = 0;
784    for &b in body {
785        crc ^= u16::from(b) << 8;
786        for _ in 0..8 {
787            if crc & 0x8000 != 0 {
788                crc = (crc << 1) ^ 0x1021;
789            } else {
790                crc <<= 1;
791            }
792        }
793    }
794    crc
795}
796
797/// Human-readable label for a descriptor tag identifier, or `None` for an
798/// identifier this crate does not recognise (so the caller does not validate a
799/// non-descriptor sector).
800pub(crate) fn descriptor_label(tag_ident: u16) -> Option<&'static str> {
801    Some(match tag_ident {
802        TAG_AVDP => "AVDP",
803        TAG_PD => "PartitionDescriptor",
804        TAG_LVD => "LogicalVolumeDescriptor",
805        TAG_TERM => "TerminatingDescriptor",
806        TAG_FSD => "FileSetDescriptor",
807        TAG_FID => "FileIdentifierDescriptor",
808        TAG_FE | TAG_FE_ALT => "FileEntry",
809        TAG_EFE => "ExtendedFileEntry",
810        1 => "PrimaryVolumeDescriptor",
811        3 => "VolumeDescriptorPointer",
812        4 => "ImplementationUseVolumeDescriptor",
813        7 => "UnallocatedSpaceDescriptor",
814        9 => "LogicalVolumeIntegrityDescriptor",
815        258 => "AllocationExtentDescriptor",
816        259 => "IndirectEntry",
817        262 => "SpaceBitmapDescriptor",
818        263 => "PartitionIntegrityEntry",
819        264 => "ExtendedAttributeHeaderDescriptor",
820        265 => "UnallocatedSpaceEntry",
821        _ => return None,
822    })
823}
824
825/// Decode an ECMA-167 `timestamp` (1/7.3, 12 bytes) to `YYYY-MM-DD HH:MM:SS`,
826/// or `None` when the year is implausible (0 / out of the 1970..=2200 range),
827/// which marks an unset or non-timestamp field rather than a real time.
828pub(crate) fn decode_timestamp(b: &[u8]) -> Option<String> {
829    if b.len() < 12 {
830        return None;
831    }
832    let year = i16::from_le_bytes([b[2], b[3]]);
833    if !(1970..=2200).contains(&year) {
834        return None;
835    }
836    let (month, day, hour, minute, second) = (b[4], b[5], b[6], b[7], b[8]);
837    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
838        return None;
839    }
840    Some(format!(
841        "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}"
842    ))
843}
844
845/// Read the File Set Descriptor's recording time (4/14.1, offset 16) from the
846/// FSD at `fsd_lba`. `Ok(None)` when the sector is not an FSD or the time is
847/// unset.
848pub(crate) fn fsd_recording_time<R: Read + Seek>(
849    reader: &mut R,
850    block_size: u32,
851    fsd_lba: u32,
852) -> Result<Option<String>, io::Error> {
853    let mut buf = [0u8; MAX_BLOCK_SIZE];
854    let sector = &mut buf[..block_size as usize];
855    seek_read_checked(reader, u64::from(fsd_lba) * u64::from(block_size), sector)?;
856    if u16::from_le_bytes([sector[0], sector[1]]) != TAG_FSD {
857        return Ok(None);
858    }
859    Ok(decode_timestamp(&sector[16..28]))
860}
861
862/// The Modification Time of a File Entry sector (`is_efe` selects the Extended
863/// File Entry layout, whose extra Object Size + Creation Time fields shift the
864/// timestamps): base FE modification time is at offset 84, EFE at offset 92.
865pub(crate) fn fe_modification_time(sector: &[u8], is_efe: bool) -> Option<String> {
866    let off = if is_efe { 92 } else { 84 };
867    decode_timestamp(sector.get(off..off + 12)?)
868}
869
870/// Count the non-zero bytes in a File Entry's final-block slack — the unused
871/// tail of the last logical block after `InformationLength`, since a file
872/// occupies whole logical blocks.
873///
874/// Returns `(nonzero_bytes, slack_bytes)`, or `None` when the file has no
875/// trailing slack (size is a whole-block multiple), is zero-length, has its data
876/// stored inline in the File Entry (no allocated block to hold slack), or its
877/// final block cannot be located/read.
878pub(crate) fn fe_slack_nonzero<R: Read + Seek>(
879    reader: &mut R,
880    block_size: u32,
881    partition_start: u32,
882    fe_lba: u32,
883) -> Option<(u32, u32)> {
884    let info_len = read_fe_info_len(reader, block_size, fe_lba)?;
885    let bs = u64::from(block_size);
886    let slack = (bs - info_len % bs) % bs;
887    if info_len == 0 || slack == 0 {
888        return None;
889    }
890    // Physical byte position of the file's last allocated block, walked through
891    // the FE's allocation descriptors so the slack inspected is the true final
892    // block (not a guess). Inline-stored files have no allocated block and so no
893    // slack to inspect.
894    let last_block_pos = fe_last_block_pos(reader, block_size, partition_start, fe_lba)?;
895    let mut buf = [0u8; MAX_BLOCK_SIZE];
896    let block = &mut buf[..block_size as usize];
897    seek_read_checked(reader, last_block_pos, block).ok()?;
898
899    let slack_start = (info_len % bs) as usize;
900    let nonzero = block[slack_start..].iter().filter(|&&b| b != 0).count() as u32;
901    Some((nonzero, slack as u32))
902}
903
904/// Physical byte position of the *last* logical block holding a File Entry's
905/// data, resolved by walking its allocation descriptors. `None` for inline
906/// (in-ICB) data, an unreadable FE, or an FE with no recorded extent.
907fn fe_last_block_pos<R: Read + Seek>(
908    reader: &mut R,
909    block_size: u32,
910    partition_start: u32,
911    fe_lba: u32,
912) -> Option<u64> {
913    let mut buf = [0u8; MAX_BLOCK_SIZE];
914    let sector = &mut buf[..block_size as usize];
915    seek_read_checked(reader, u64::from(fe_lba) * u64::from(block_size), sector).ok()?;
916
917    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
918    let is_efe = tag_ident == TAG_EFE;
919    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && !is_efe {
920        return None;
921    }
922
923    let icb_flags = u16::from_le_bytes([sector[34], sector[35]]);
924    let alloc_type = icb_flags & 0x0007;
925    // See `read_fe_data`: EFE shifts L_EA/L_AD/area to 208/212/216 (40 bytes of
926    // extra fields), not the 176/180/184 an 8-byte-only shift would give.
927    let (ea_off, ad_off, header) = if is_efe {
928        (208usize, 212usize, 216usize)
929    } else {
930        (168usize, 172usize, 176usize)
931    };
932    if ad_off + 4 > sector.len() {
933        return None; // cov:unreachable: header offsets fit a >=512-byte block
934    }
935    let ea_len = u32::from_le_bytes(sector[ea_off..ea_off + 4].try_into().ok()?) as usize;
936    let ad_len = u32::from_le_bytes(sector[ad_off..ad_off + 4].try_into().ok()?) as usize;
937    let ad_start = header + ea_len;
938    let ad_end = ad_start.checked_add(ad_len)?;
939    if ad_end > sector.len() {
940        return None;
941    }
942    let ad_area = &sector[ad_start..ad_end];
943
944    // Both short_ad (8 bytes) and long_ad (16 bytes) record the extent length at
945    // bytes 0..4 and the logical block number at bytes 4..8; only the stride to
946    // the next descriptor differs. Inline (in-ICB) data has no allocated block.
947    let stride = match alloc_type {
948        ALLOC_SHORT => 8,
949        ALLOC_LONG => 16,
950        _ => return None,
951    };
952    let mut last: Option<u64> = None;
953    let mut pos = 0;
954    while pos + stride <= ad_area.len() {
955        let len_raw = read_le_u32(ad_area, pos);
956        let ext_type = len_raw >> 30;
957        let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
958        if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
959            let lbn = read_le_u32(ad_area, pos + 4);
960            let blocks_in_ext = ext_len.div_ceil(block_size as usize) as u64;
961            let last_lbn = u64::from(partition_start) + u64::from(lbn) + (blocks_in_ext - 1);
962            last = Some(last_lbn * u64::from(block_size));
963        }
964        pos += stride;
965    }
966    last
967}
968
969/// Read a little-endian `u32` at `off`, returning 0 if out of range (the caller
970/// has already bounds-checked the slice length, so the fallback is defensive).
971fn read_le_u32(data: &[u8], off: usize) -> u32 {
972    let mut b = [0u8; 4];
973    if let Some(s) = data.get(off..off + 4) {
974        b.copy_from_slice(s);
975    }
976    u32::from_le_bytes(b)
977}
978
979#[cfg(test)]
980mod real_media_tests {
981    //! Validate partition-map classification against real mkudffs-authored
982    //! pure-UDF images, cross-checked against the independent `udfinfo`
983    //! (udftools) oracle. The images and the verbatim `mkudffs` commands that
984    //! produced them are documented in `tests/data/README.md`; they are
985    //! committed, so these tests run (the skip-if-missing arm is a defensive
986    //! fallback for a checkout where the fixtures were stripped).
987    use super::{parse_udf_state, UdfPartitionKind};
988    use std::fs::File;
989
990    fn state(name: &str) -> Option<super::UdfState> {
991        let path = format!("{}/tests/data/{}", env!("CARGO_MANIFEST_DIR"), name);
992        let mut f = File::open(&path).ok()?;
993        parse_udf_state(&mut f)
994    }
995
996    #[test]
997    fn vat_image_classified_virtual() {
998        let Some(st) = state("udf_vat.img") else {
999            eprintln!("skip: udf_vat.img");
1000            return;
1001        };
1002        assert_eq!(
1003            st.partition_kind,
1004            UdfPartitionKind::Virtual,
1005            "mkudffs cdr/1.50 image must classify as Virtual (VAT)"
1006        );
1007    }
1008
1009    #[test]
1010    fn sparable_image_classified_sparable() {
1011        let Some(st) = state("udf_spar.img") else {
1012            eprintln!("skip: udf_spar.img");
1013            return;
1014        };
1015        assert_eq!(
1016            st.partition_kind,
1017            UdfPartitionKind::Sparable,
1018            "mkudffs dvdrw/2.01 image must classify as Sparable"
1019        );
1020    }
1021
1022    /// Differential reconciliation against the independent `udfinfo` oracle
1023    /// (udftools 2.3, a separate codebase from this crate). The expected values
1024    /// below are the *oracle's* reported ground truth — partition-space start
1025    /// and partition-map shape derived from `udfinfo`'s output, NOT recomputed
1026    /// by this crate. See `tests/data/README.md` for the captured oracle output.
1027    ///
1028    /// `udfinfo udf_vat.img` reports `udfrev=1.50`, `accesstype=writeonce`, and
1029    /// `start=257, blocks=3839, type=PSPACE`; the cdr/1.50 layout carries a
1030    /// physical map plus a Type-2 `*UDF Virtual Partition` map (VAT), so the
1031    /// file-set partition resolves to physical start 257 and two partition maps.
1032    #[test]
1033    fn vat_image_matches_udfinfo_oracle() {
1034        let Some(st) = state("udf_vat.img") else {
1035            eprintln!("skip: udf_vat.img");
1036            return;
1037        };
1038        assert_eq!(st.partition_kind, UdfPartitionKind::Virtual);
1039        // udfinfo PSPACE start block.
1040        assert_eq!(
1041            st.partition_start, 257,
1042            "partition start must match udfinfo PSPACE start=257"
1043        );
1044        // Physical + Virtual (VAT) Type-2 map.
1045        assert_eq!(
1046            st.partition_map_count, 2,
1047            "cdr/1.50 carries a physical map plus the VAT Type-2 map"
1048        );
1049    }
1050
1051    /// `udfinfo udf_spar.img` reports `udfrev=2.01`, `accesstype=overwritable`,
1052    /// a `type=SSPACE` (sparing) region, and `start=1296, blocks=2528,
1053    /// type=PSPACE`; the dvdrw/2.01 layout uses a single Type-2 `*UDF Sparable
1054    /// Partition` map, so the file-set partition resolves to physical start 1296
1055    /// with one partition map.
1056    #[test]
1057    fn sparable_image_matches_udfinfo_oracle() {
1058        let Some(st) = state("udf_spar.img") else {
1059            eprintln!("skip: udf_spar.img");
1060            return;
1061        };
1062        assert_eq!(st.partition_kind, UdfPartitionKind::Sparable);
1063        // udfinfo PSPACE start block.
1064        assert_eq!(
1065            st.partition_start, 1296,
1066            "partition start must match udfinfo PSPACE start=1296"
1067        );
1068        assert_eq!(
1069            st.partition_map_count, 1,
1070            "dvdrw/2.01 carries a single Sparable Type-2 map"
1071        );
1072    }
1073
1074    /// `udfinfo udf_plain.img` reports `udfrev=2.01`, `blocksize=512`, and a
1075    /// single physical partition at `start=257, type=PSPACE`. The mkudffs `hd`
1076    /// profile writes 512-byte logical blocks, so the AVDP lives at byte
1077    /// 256×512, not 256×2048 — this image only parses once the block size is
1078    /// detected from the medium rather than assumed to be 2048.
1079    #[test]
1080    fn plain_512_block_image_parses_via_detected_block_size() {
1081        let path = format!("{}/tests/data/udf_plain.img", env!("CARGO_MANIFEST_DIR"));
1082        let mut f = File::open(&path).expect("udf_plain.img fixture must be present");
1083        let st = super::parse_udf_state(&mut f)
1084            .expect("512-byte-block UDF must parse once the block size is detected from the AVDP");
1085        assert_eq!(st.block_size, 512, "udfinfo reports blocksize=512");
1086        assert_eq!(
1087            st.partition_kind,
1088            UdfPartitionKind::Physical,
1089            "mkudffs hd image is a Type-1 physical partition"
1090        );
1091        assert_eq!(
1092            st.partition_start, 257,
1093            "partition start must match udfinfo PSPACE start=257"
1094        );
1095        assert_eq!(
1096            st.partition_map_count, 1,
1097            "hd/2.01 carries a single physical map"
1098        );
1099    }
1100}
1101
1102#[cfg(test)]
1103mod checked_bootstrap_tests {
1104    //! `parse_udf_state_checked` must distinguish a real seek/read I/O failure
1105    //! (a bootstrap read failure — truncated/unreadable image) from a structural
1106    //! negative (reads succeeded but the anchor is not a valid AVDP → not UDF).
1107    use super::parse_udf_state_checked;
1108    use std::io::{self, Cursor, Read, Seek, SeekFrom};
1109
1110    /// A `Read + Seek` whose seeks always succeed but whose reads always fail
1111    /// with a non-EOF I/O error — models an unreadable image / device fault.
1112    struct FaultyReader;
1113
1114    impl Read for FaultyReader {
1115        fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
1116            Err(io::Error::other("device read fault"))
1117        }
1118    }
1119    impl Seek for FaultyReader {
1120        fn seek(&mut self, _pos: SeekFrom) -> io::Result<u64> {
1121            Ok(0)
1122        }
1123    }
1124
1125    #[test]
1126    fn io_error_at_anchor_surfaces_as_err() {
1127        let mut r = FaultyReader;
1128        let res = parse_udf_state_checked(&mut r);
1129        assert!(
1130            res.is_err(),
1131            "a device read fault reading the anchor must surface as Err, not Ok(None)"
1132        );
1133    }
1134
1135    #[test]
1136    fn truncated_before_anchor_surfaces_as_err() {
1137        // A buffer too short to reach LBA 256 (256 * 2048 = 524288 bytes) — the
1138        // read_exact at the anchor hits UnexpectedEof, which is a truncated-image
1139        // bootstrap failure and must surface, not be swallowed into Ok(None).
1140        let buf = vec![0u8; 4096];
1141        let mut r = Cursor::new(buf);
1142        let res = parse_udf_state_checked(&mut r);
1143        assert!(
1144            res.is_err(),
1145            "truncation before the AVDP anchor must surface as Err (UnexpectedEof)"
1146        );
1147        let err = res.err().unwrap();
1148        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1149    }
1150
1151    #[test]
1152    fn full_size_but_wrong_anchor_is_ok_none() {
1153        // A buffer large enough to reach and read LBA 256, but whose sector 256 is
1154        // all zeros (tag identifier 0, not TAG_AVDP=2) — reads succeed, the
1155        // structure is simply not UDF. This is the legitimate "not UDF" case.
1156        let buf = vec![0u8; 257 * 2048];
1157        let mut r = Cursor::new(buf);
1158        let res = parse_udf_state_checked(&mut r);
1159        assert!(
1160            matches!(res, Ok(None)),
1161            "a readable image with a non-AVDP anchor must be Ok(None), got {res:?}"
1162        );
1163    }
1164}
1165
1166#[cfg(test)]
1167mod findings_support_tests {
1168    //! Unit coverage for the findings-support primitives. The CRC/checksum
1169    //! implementations are additionally validated against the real `mkudffs`
1170    //! corpus by the `findings` integration tests (a clean image's descriptors
1171    //! must all verify — a true negative); these tests cover the pure helpers
1172    //! and the allocated-extent slack path the inline-data corpus cannot reach.
1173    use super::*;
1174    use std::io::Cursor;
1175
1176    #[test]
1177    fn crc_ccitt_matches_known_vectors() {
1178        // CRC-CCITT (poly 0x1021, init 0x0000): "123456789" → 0x31C3 is the
1179        // standard published vector for this parameterisation.
1180        assert_eq!(ecma167_crc(b"123456789"), 0x31C3);
1181        assert_eq!(ecma167_crc(&[]), 0x0000);
1182    }
1183
1184    #[test]
1185    fn tag_checksum_skips_byte_4() {
1186        let mut tag = [0u8; 16];
1187        tag[0] = 2; // contributes
1188        tag[4] = 0xFF; // the checksum byte itself — must be excluded
1189        tag[6] = 3; // contributes
1190        assert_eq!(tag_checksum(&tag), 5);
1191    }
1192
1193    #[test]
1194    fn descriptor_label_known_and_unknown() {
1195        // Every mapped ECMA-167 / UDF descriptor identifier resolves to a name
1196        // so a walked descriptor of any type is reported by name, not a number.
1197        for (id, name) in [
1198            (TAG_FSD, "FileSetDescriptor"),
1199            (TAG_EFE, "ExtendedFileEntry"),
1200            (TAG_FID, "FileIdentifierDescriptor"),
1201            (TAG_FE, "FileEntry"),
1202            (TAG_FE_ALT, "FileEntry"),
1203            (1, "PrimaryVolumeDescriptor"),
1204            (3, "VolumeDescriptorPointer"),
1205            (4, "ImplementationUseVolumeDescriptor"),
1206            (7, "UnallocatedSpaceDescriptor"),
1207            (9, "LogicalVolumeIntegrityDescriptor"),
1208            (258, "AllocationExtentDescriptor"),
1209            (259, "IndirectEntry"),
1210            (262, "SpaceBitmapDescriptor"),
1211            (263, "PartitionIntegrityEntry"),
1212            (264, "ExtendedAttributeHeaderDescriptor"),
1213            (265, "UnallocatedSpaceEntry"),
1214        ] {
1215            assert_eq!(descriptor_label(id), Some(name), "tag {id}");
1216        }
1217        assert_eq!(descriptor_label(0xFFFF), None);
1218    }
1219
1220    #[test]
1221    fn fsd_recording_time_none_for_non_fsd() {
1222        let img = vec![0u8; 512];
1223        let mut r = Cursor::new(img);
1224        assert_eq!(fsd_recording_time(&mut r, 512, 0).unwrap(), None);
1225    }
1226
1227    #[test]
1228    fn last_block_pos_none_for_non_file_entry() {
1229        let img = vec![0u8; 512];
1230        let mut r = Cursor::new(img);
1231        assert_eq!(fe_last_block_pos(&mut r, 512, 0, 0), None);
1232    }
1233
1234    #[test]
1235    fn slack_via_long_allocation_descriptor() {
1236        // A 16-byte long_ad (alloc type 1) exercises the ALLOC_LONG stride.
1237        let bs = 512usize;
1238        let mut img = vec![0u8; bs * 8];
1239        let fe = 4 * bs;
1240        img[fe..fe + 2].copy_from_slice(&TAG_FE.to_le_bytes());
1241        img[fe + 34..fe + 36].copy_from_slice(&1u16.to_le_bytes()); // long_ad alloc
1242        img[fe + 56..fe + 64].copy_from_slice(&100u64.to_le_bytes());
1243        img[fe + 168..fe + 172].copy_from_slice(&0u32.to_le_bytes()); // L_EA
1244        img[fe + 172..fe + 176].copy_from_slice(&16u32.to_le_bytes()); // L_AD = one long_ad
1245        img[fe + 176..fe + 180].copy_from_slice(&100u32.to_le_bytes()); // extent_length
1246        img[fe + 180..fe + 184].copy_from_slice(&5u32.to_le_bytes()); // logical block num
1247        let data = 5 * bs + 100;
1248        img[data] = 0x7F;
1249        let mut r = Cursor::new(img);
1250        let (nonzero, slack) = fe_slack_nonzero(&mut r, 512, 0, 4).expect("slack present");
1251        assert_eq!(slack, 412);
1252        assert_eq!(nonzero, 1);
1253    }
1254
1255    #[test]
1256    fn timestamp_decodes_and_rejects_implausible() {
1257        let mut t = [0u8; 12];
1258        t[2..4].copy_from_slice(&2026i16.to_le_bytes());
1259        t[4] = 6; // month
1260        t[5] = 21; // day
1261        t[6] = 8; // hour
1262        t[7] = 46; // minute
1263        t[8] = 57; // second
1264        assert_eq!(decode_timestamp(&t).as_deref(), Some("2026-06-21 08:46:57"));
1265
1266        // Year out of range → None (unset/garbage field).
1267        let mut bad = t;
1268        bad[2..4].copy_from_slice(&0i16.to_le_bytes());
1269        assert_eq!(decode_timestamp(&bad), None);
1270
1271        // Month out of range → None.
1272        let mut badmon = t;
1273        badmon[4] = 0;
1274        assert_eq!(decode_timestamp(&badmon), None);
1275
1276        // Short buffer → None.
1277        assert_eq!(decode_timestamp(&[0u8; 4]), None);
1278    }
1279
1280    #[test]
1281    fn fe_modification_time_offset_differs_for_efe() {
1282        // Base FE: mtime at offset 84; EFE: mtime at offset 92.
1283        let mut fe = vec![0u8; 512];
1284        let stamp = |buf: &mut [u8], off: usize, year: i16| {
1285            buf[off + 2..off + 4].copy_from_slice(&year.to_le_bytes());
1286            buf[off + 4] = 1; // month
1287            buf[off + 5] = 1; // day
1288        };
1289        stamp(&mut fe, 84, 2030);
1290        assert_eq!(
1291            fe_modification_time(&fe, false).as_deref(),
1292            Some("2030-01-01 00:00:00")
1293        );
1294        let mut efe = vec![0u8; 512];
1295        stamp(&mut efe, 92, 2031);
1296        assert_eq!(
1297            fe_modification_time(&efe, true).as_deref(),
1298            Some("2031-01-01 00:00:00")
1299        );
1300    }
1301
1302    /// Build a minimal 512-byte-block image with a base File Entry that points,
1303    /// via a short allocation descriptor, to a single data block whose tail
1304    /// (past `InformationLength`) holds non-zero slack — the allocated-extent
1305    /// path the inline-data `mkudffs` corpus cannot exercise.
1306    fn image_with_slack(info_len: u64, slack_fill: &[u8]) -> (Vec<u8>, u32, u32) {
1307        let bs = 512usize;
1308        let part_start = 0u32;
1309        let fe_lba = 4u32;
1310        let data_lbn = 5u32; // physical = part_start + 5
1311        let mut img = vec![0u8; bs * 8];
1312
1313        let fe = fe_lba as usize * bs;
1314        // Tag identifier = File Entry (260).
1315        img[fe..fe + 2].copy_from_slice(&TAG_FE.to_le_bytes());
1316        // ICB flags: allocation type 0 (short_ad).
1317        img[fe + 34..fe + 36].copy_from_slice(&0u16.to_le_bytes());
1318        // InformationLength.
1319        img[fe + 56..fe + 64].copy_from_slice(&info_len.to_le_bytes());
1320        // Base-FE header offsets: L_EA @168, L_AD @172, AD area @176.
1321        img[fe + 168..fe + 172].copy_from_slice(&0u32.to_le_bytes()); // L_EA = 0
1322        img[fe + 172..fe + 176].copy_from_slice(&8u32.to_le_bytes()); // L_AD = 8 (one short_ad)
1323                                                                      // short_ad: extent_length (recorded, type 0) = info_len, position = data_lbn.
1324        let ad = fe + 176;
1325        img[ad..ad + 4].copy_from_slice(&(info_len as u32).to_le_bytes());
1326        img[ad + 4..ad + 8].copy_from_slice(&data_lbn.to_le_bytes());
1327
1328        // Data block: fill the slack region (past info_len within the block).
1329        let data = (part_start + data_lbn) as usize * bs;
1330        let slack_start = data + (info_len as usize % bs);
1331        for (i, &b) in slack_fill.iter().enumerate() {
1332            img[slack_start + i] = b;
1333        }
1334        (img, part_start, fe_lba)
1335    }
1336
1337    #[test]
1338    fn slack_counts_nonzero_tail_bytes() {
1339        // info_len 100 in a 512-byte block → 412 slack bytes; place 3 non-zero.
1340        let (img, ps, fe) = image_with_slack(100, &[0xAA, 0x00, 0xBB, 0xCC]);
1341        let mut r = Cursor::new(img);
1342        let (nonzero, slack) = fe_slack_nonzero(&mut r, 512, ps, fe).expect("slack present");
1343        assert_eq!(slack, 412);
1344        assert_eq!(nonzero, 3);
1345    }
1346
1347    #[test]
1348    fn slack_none_when_block_aligned() {
1349        // info_len exactly one block → no slack.
1350        let (img, ps, fe) = image_with_slack(512, &[0xFF]);
1351        let mut r = Cursor::new(img);
1352        assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
1353    }
1354
1355    #[test]
1356    fn slack_none_when_zero_length() {
1357        let (img, ps, fe) = image_with_slack(0, &[]);
1358        let mut r = Cursor::new(img);
1359        assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
1360    }
1361
1362    #[test]
1363    fn slack_none_for_inline_data() {
1364        // Allocation type 3 (inline) has no allocated block to inspect.
1365        let (mut img, ps, fe) = image_with_slack(100, &[0xAA]);
1366        let fe_off = fe as usize * 512;
1367        img[fe_off + 34..fe_off + 36].copy_from_slice(&3u16.to_le_bytes());
1368        let mut r = Cursor::new(img);
1369        assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
1370    }
1371}