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