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    // Both operands are u32 fields from the descriptors, so their sum can leave
476    // the address space. A wrapped LBA would point at a real but wrong block --
477    // a confidently incorrect File Set Descriptor -- so an overflow means the
478    // descriptor pair does not describe a location and there is nothing to
479    // report.
480    let Some(fsd_lba) = partition_start.checked_add(fsd) else {
481        return Ok(None);
482    };
483
484    Ok(Some(VdsInfo {
485        partition_start,
486        fsd_lba,
487        partition_kind: kind,
488        map_count,
489    }))
490}
491
492/// Parse FSD at `fsd_lba` to find the root directory FE logical block number.
493/// `Err` on a read I/O failure, `Ok(None)` when the FSD read succeeds but its
494/// tag is not an FSD, `Ok(Some(root_fe_lba))` when the FSD is valid.
495fn read_fsd_checked<R: Read + Seek>(
496    reader: &mut R,
497    block_size: u32,
498    fsd_lba: u32,
499    partition_start: u32,
500) -> Result<Option<u32>, io::Error> {
501    let mut sector = [0u8; MAX_BLOCK_SIZE];
502    let sector = &mut sector[..block_size as usize];
503    seek_read_checked(reader, u64::from(fsd_lba) * u64::from(block_size), sector)?;
504    if u16::from_le_bytes([sector[0], sector[1]]) != TAG_FSD {
505        return Ok(None);
506    }
507    // FSD field sizes (ECMA-167 Table 20):
508    //   Tag(16) + RecordingDate(12) + Interchange/Charset fields(28) +
509    //   LV Ident CharSet(64) + LV Identifier(128) + FS CharSet(64) +
510    //   FS Identifier(32) + Copyright FI(32) + Abstract FI(32) = 408 bytes.
511    // Root Directory ICB (long_ad) starts at offset 400:
512    //   extent_length [400..404], logical_block_num [404..408]
513    let lbn = le_u32(sector, 404);
514    Ok(Some(partition_start + lbn))
515}
516
517/// Detect whether FIDs in this directory data use a standard 16-byte ECMA-167 tag
518/// or an extended 18-byte tag written by some UDF tools.
519///
520/// Some implementations append 2 extra bytes after the standard tag before the
521/// FID body, making all field offsets shift by 2. Detection heuristic: read the
522/// ICB logical block number at both candidate positions and use whichever gives a
523/// plausible value (< 65536, fitting discs up to ~128 GB).
524fn detect_fid_tag_size(data: &[u8]) -> usize {
525    let mut off = 0;
526    while off + 28 <= data.len() {
527        let ti = u16::from_le_bytes([data[off], data[off + 1]]);
528        if ti == TAG_FID {
529            // Keep the length guards: the "too short" fallback is `u32::MAX`
530            // (which fails the `< 0x10000` plausibility test), NOT 0 — a bare
531            // 0-returning read would wrongly make a truncated tail look valid.
532            let lbn16 = if off + 26 <= data.len() {
533                le_u32(data, off + 22)
534            } else {
535                u32::MAX
536            };
537            let lbn18 = if off + 28 <= data.len() {
538                le_u32(data, off + 24)
539            } else {
540                u32::MAX
541            };
542            if lbn16 < 0x10000 {
543                return 16;
544            }
545            if lbn18 < 0x10000 {
546                return 18;
547            }
548            return 16; // can't determine; fall back to standard
549        }
550        off += 4;
551    }
552    16
553}
554
555/// Parse File Identifier Descriptors from raw directory data.
556fn parse_fids<R: Read + Seek>(
557    reader: &mut R,
558    block_size: u32,
559    partition_start: u32,
560    data: &[u8],
561) -> Vec<UdfFileEntry> {
562    // Some UDF tools write an extra 2 bytes after the standard 16-byte tag.
563    // tag_size is 16 (standard) or 18 (extended); body fields follow at tag_size.
564    let tag_size = detect_fid_tag_size(data);
565    let min_fid = tag_size + 20; // tag + chars(1)+L_FI(1)+ICB(16)+L_IU(2)
566
567    let mut entries = Vec::new();
568    let mut off = 0;
569
570    while off + min_fid <= data.len() {
571        let tag_ident = u16::from_le_bytes([data[off], data[off + 1]]);
572        if tag_ident != TAG_FID {
573            // Advance 4 bytes to stay aligned; skip padding or unknown tags.
574            off += 4;
575            continue;
576        }
577
578        // CRC_len (at tag[10..12]) gives the true body extent from byte 16.
579        let crc_len = u16::from_le_bytes([data[off + 10], data[off + 11]]) as usize;
580        let fid_advance = ((16 + crc_len + 3) & !3).max(min_fid);
581        if off + fid_advance > data.len() {
582            break;
583        }
584
585        let file_chars = data[off + tag_size];
586        let file_id_len = data[off + tag_size + 1] as usize;
587        // ICB long_ad: extent_length at body[2..6], lbn at body[6..10]
588        let icb_lbn = if off + tag_size + 10 <= data.len() {
589            le_u32(data, off + tag_size + 6)
590        } else {
591            // cov:unreachable: loop guard `off + min_fid <= len` with min_fid = tag_size + 20
592            off += fid_advance.max(4);
593            continue;
594        };
595        let impl_use_len = if off + tag_size + 20 <= data.len() {
596            u16::from_le_bytes([data[off + tag_size + 18], data[off + tag_size + 19]]) as usize
597        } else {
598            // cov:unreachable: loop guard `off + min_fid <= len` with min_fid = tag_size + 20
599            off += fid_advance.max(4);
600            continue;
601        };
602
603        if file_chars & FC_PARENT == 0 {
604            let is_dir = file_chars & FC_DIRECTORY != 0;
605            // The name-field bounds are header-derived too. Saturating is right
606            // here rather than skipping: an out-of-range span clamps to the end
607            // of the buffer, and the `id_end > id_start` test below then leaves
608            // the name empty instead of decoding anything.
609            let id_start = off
610                .saturating_add(tag_size)
611                .saturating_add(20)
612                .saturating_add(impl_use_len);
613            let id_end = id_start.saturating_add(file_id_len).min(data.len());
614            let name = if id_end > id_start {
615                decode_osta_cs0(&data[id_start..id_end])
616            } else {
617                String::new()
618            };
619
620            // Same hazard as the FSD address above, per directory entry. Both
621            // operands are u32 from the image, so their sum can leave the
622            // address space; a wrapped LBA would send the File Entry read at a
623            // real but wrong block and report a confidently incorrect size.
624            //
625            // Dropping the entry rather than `continue`-ing is deliberate: the
626            // loop advances `off` below this block, so skipping the iteration
627            // would leave the cursor where it was and spin forever on the same
628            // descriptor.
629            if let Some(fe_lba) = partition_start.checked_add(icb_lbn) {
630                // Read the FE to get the canonical file size.
631                let size = read_fe_info_len(reader, block_size, fe_lba).unwrap_or(0);
632
633                entries.push(UdfFileEntry {
634                    name,
635                    is_dir,
636                    size,
637                    fe_lba,
638                });
639            }
640        }
641
642        off += fid_advance.max(4);
643    }
644    entries
645}
646
647/// Read the Information Length (file size) from a File Entry at `fe_lba`.
648fn read_fe_info_len<R: Read + Seek>(reader: &mut R, block_size: u32, fe_lba: u32) -> Option<u64> {
649    let mut sector = [0u8; MAX_BLOCK_SIZE];
650    let sector = &mut sector[..block_size as usize];
651    seek_read(reader, u64::from(fe_lba) * u64::from(block_size), sector)?;
652    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
653    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && tag_ident != TAG_EFE {
654        return None;
655    }
656    Some(le_u64(sector, 56))
657}
658
659/// Read the ICB Tag File Type of the File Entry at `fe_lba` (ECMA-167 4/14.6.6):
660/// the descriptor tag is 16 bytes, the ICB Tag follows it, and its File Type
661/// field sits at ICB-Tag offset 11 (FE offset 27). `4` = directory, `5` = a
662/// regular file. `None` when the sector is not a File Entry / Extended File
663/// Entry, so a non-FE LBA passed to a directory op is told apart from a file.
664///
665/// Used by the `vfs` adapter to classify an arbitrary File Entry LBA.
666#[cfg(feature = "vfs")]
667pub(crate) fn read_fe_file_type<R: Read + Seek>(
668    reader: &mut R,
669    block_size: u32,
670    fe_lba: u32,
671) -> Option<u8> {
672    let mut sector = [0u8; MAX_BLOCK_SIZE];
673    let sector = &mut sector[..block_size as usize];
674    seek_read(reader, u64::from(fe_lba) * u64::from(block_size), sector)?;
675    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
676    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && tag_ident != TAG_EFE {
677        return None;
678    }
679    sector.get(27).copied()
680}
681
682/// ECMA-167 ICB Tag File Type for a directory (4/14.6.6).
683#[cfg(feature = "vfs")]
684pub(crate) const FILE_TYPE_DIRECTORY: u8 = 4;
685
686/// Collect data from short allocation descriptors (8 bytes each).
687fn read_extents_short<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 + 8 <= ad_area.len() && (data.len() as u64) < total_len {
697        let len_raw = le_u32(ad_area, pos);
698        let ext_pos = 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(ext_pos)) * u64::from(block_size);
703            read_extent(reader, block_size, phys, ext_len, total_len, &mut data)?;
704        }
705        pos += 8;
706    }
707    data.truncate(total_len as usize);
708    Some(data)
709}
710
711/// Collect data from long allocation descriptors (16 bytes each).
712fn read_extents_long<R: Read + Seek>(
713    reader: &mut R,
714    block_size: u32,
715    partition_start: u32,
716    ad_area: &[u8],
717    total_len: u64,
718) -> Option<Vec<u8>> {
719    let mut data = Vec::new();
720    let mut pos = 0;
721    while pos + 16 <= ad_area.len() && (data.len() as u64) < total_len {
722        let len_raw = le_u32(ad_area, pos);
723        let lbn = le_u32(ad_area, pos + 4);
724        let ext_type = len_raw >> 30;
725        let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
726        if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
727            let phys = (u64::from(partition_start) + u64::from(lbn)) * u64::from(block_size);
728            read_extent(reader, block_size, phys, ext_len, total_len, &mut data)?;
729        }
730        pos += 16;
731    }
732    data.truncate(total_len as usize);
733    Some(data)
734}
735
736/// Read `ext_len` bytes from `byte_pos`, appending to `data` up to `total_len`.
737fn read_extent<R: Read + Seek>(
738    reader: &mut R,
739    block_size: u32,
740    byte_pos: u64,
741    ext_len: usize,
742    total_len: u64,
743    data: &mut Vec<u8>,
744) -> Option<()> {
745    let bs = block_size as usize;
746    let sectors = ext_len.div_ceil(bs);
747    for i in 0..sectors {
748        let mut sector = [0u8; MAX_BLOCK_SIZE];
749        let sector = &mut sector[..bs];
750        seek_read(reader, byte_pos + i as u64 * u64::from(block_size), sector)?;
751        let already = data.len() as u64;
752        let remaining = total_len.saturating_sub(already) as usize;
753        let sector_bytes = (ext_len - i * bs).min(bs);
754        let take = sector_bytes.min(remaining);
755        data.extend_from_slice(&sector[..take]);
756    }
757    Some(())
758}
759
760/// Decode an OSTA CS0 encoded identifier: first byte is compression ID
761/// (8 = UTF-8, 16 = UTF-16BE), remainder is character data.
762fn decode_osta_cs0(bytes: &[u8]) -> String {
763    if bytes.is_empty() {
764        return String::new();
765    }
766    let comp_id = bytes[0];
767    let payload = &bytes[1..];
768    // 16 = UTF-16BE; compression ID 8 (UTF-8) and any other value decode as
769    // UTF-8 lossy (the OSTA CS0 default path).
770    if comp_id == 16 {
771        let pairs: Vec<u16> = payload
772            .chunks_exact(2)
773            .map(|c| u16::from_be_bytes([c[0], c[1]]))
774            .collect();
775        String::from_utf16_lossy(&pairs)
776    } else {
777        String::from_utf8_lossy(payload).into_owned()
778    }
779}
780
781/// Seek to `byte_pos` and read exactly `buf.len()` bytes; returns `None` on any error.
782fn seek_read<R: Read + Seek>(reader: &mut R, byte_pos: u64, buf: &mut [u8]) -> Option<()> {
783    seek_read_checked(reader, byte_pos, buf).ok()
784}
785
786/// Seek to `byte_pos` and read exactly `buf.len()` bytes, propagating the real
787/// [`io::Error`] (a truncated image yields [`io::ErrorKind::UnexpectedEof`]).
788fn seek_read_checked<R: Read + Seek>(
789    reader: &mut R,
790    byte_pos: u64,
791    buf: &mut [u8],
792) -> Result<(), io::Error> {
793    reader.seek(SeekFrom::Start(byte_pos))?;
794    reader.read_exact(buf)?;
795    Ok(())
796}
797
798// ── Forensic-findings support (used by `findings`) ───────────────────────────
799
800/// The ECMA-167 descriptor-tag checksum (3/7.2): the mod-256 sum of the 16 tag
801/// bytes excluding byte 4 (the checksum field itself).
802pub(crate) fn tag_checksum(tag: &[u8]) -> u8 {
803    let mut sum: u32 = 0;
804    for (i, &b) in tag.iter().take(16).enumerate() {
805        if i == 4 {
806            continue;
807        }
808        sum = sum.wrapping_add(u32::from(b));
809    }
810    (sum & 0xFF) as u8
811}
812
813/// The ECMA-167 descriptor CRC (3/7.2): CRC-CCITT with polynomial `0x1021`,
814/// initial value `0x0000`, no input/output reflection and no final XOR,
815/// computed over the descriptor body (the bytes after the 16-byte tag).
816pub(crate) fn ecma167_crc(body: &[u8]) -> u16 {
817    let mut crc: u16 = 0;
818    for &b in body {
819        crc ^= u16::from(b) << 8;
820        for _ in 0..8 {
821            if crc & 0x8000 != 0 {
822                crc = (crc << 1) ^ 0x1021;
823            } else {
824                crc <<= 1;
825            }
826        }
827    }
828    crc
829}
830
831/// Human-readable label for a descriptor tag identifier, or `None` for an
832/// identifier this crate does not recognise (so the caller does not validate a
833/// non-descriptor sector).
834pub(crate) fn descriptor_label(tag_ident: u16) -> Option<&'static str> {
835    Some(match tag_ident {
836        TAG_AVDP => "AVDP",
837        TAG_PD => "PartitionDescriptor",
838        TAG_LVD => "LogicalVolumeDescriptor",
839        TAG_TERM => "TerminatingDescriptor",
840        TAG_FSD => "FileSetDescriptor",
841        TAG_FID => "FileIdentifierDescriptor",
842        TAG_FE | TAG_FE_ALT => "FileEntry",
843        TAG_EFE => "ExtendedFileEntry",
844        1 => "PrimaryVolumeDescriptor",
845        3 => "VolumeDescriptorPointer",
846        4 => "ImplementationUseVolumeDescriptor",
847        7 => "UnallocatedSpaceDescriptor",
848        9 => "LogicalVolumeIntegrityDescriptor",
849        258 => "AllocationExtentDescriptor",
850        259 => "IndirectEntry",
851        262 => "SpaceBitmapDescriptor",
852        263 => "PartitionIntegrityEntry",
853        264 => "ExtendedAttributeHeaderDescriptor",
854        265 => "UnallocatedSpaceEntry",
855        _ => return None,
856    })
857}
858
859/// Decode an ECMA-167 `timestamp` (1/7.3, 12 bytes) to `YYYY-MM-DD HH:MM:SS`,
860/// or `None` when the year is implausible (0 / out of the 1970..=2200 range),
861/// which marks an unset or non-timestamp field rather than a real time.
862pub(crate) fn decode_timestamp(b: &[u8]) -> Option<String> {
863    if b.len() < 12 {
864        return None;
865    }
866    let year = i16::from_le_bytes([b[2], b[3]]);
867    if !(1970..=2200).contains(&year) {
868        return None;
869    }
870    let (month, day, hour, minute, second) = (b[4], b[5], b[6], b[7], b[8]);
871    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
872        return None;
873    }
874    Some(format!(
875        "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}"
876    ))
877}
878
879/// Read the File Set Descriptor's recording time (4/14.1, offset 16) from the
880/// FSD at `fsd_lba`. `Ok(None)` when the sector is not an FSD or the time is
881/// unset.
882pub(crate) fn fsd_recording_time<R: Read + Seek>(
883    reader: &mut R,
884    block_size: u32,
885    fsd_lba: u32,
886) -> Result<Option<String>, io::Error> {
887    let mut buf = [0u8; MAX_BLOCK_SIZE];
888    let sector = &mut buf[..block_size as usize];
889    seek_read_checked(reader, u64::from(fsd_lba) * u64::from(block_size), sector)?;
890    if u16::from_le_bytes([sector[0], sector[1]]) != TAG_FSD {
891        return Ok(None);
892    }
893    Ok(decode_timestamp(&sector[16..28]))
894}
895
896/// The Modification Time of a File Entry sector (`is_efe` selects the Extended
897/// File Entry layout, whose extra Object Size + Creation Time fields shift the
898/// timestamps): base FE modification time is at offset 84, EFE at offset 92.
899pub(crate) fn fe_modification_time(sector: &[u8], is_efe: bool) -> Option<String> {
900    let off = if is_efe { 92 } else { 84 };
901    decode_timestamp(sector.get(off..off + 12)?)
902}
903
904/// Count the non-zero bytes in a File Entry's final-block slack — the unused
905/// tail of the last logical block after `InformationLength`, since a file
906/// occupies whole logical blocks.
907///
908/// Returns `(nonzero_bytes, slack_bytes)`, or `None` when the file has no
909/// trailing slack (size is a whole-block multiple), is zero-length, has its data
910/// stored inline in the File Entry (no allocated block to hold slack), or its
911/// final block cannot be located/read.
912pub(crate) fn fe_slack_nonzero<R: Read + Seek>(
913    reader: &mut R,
914    block_size: u32,
915    partition_start: u32,
916    fe_lba: u32,
917) -> Option<(u32, u32)> {
918    let info_len = read_fe_info_len(reader, block_size, fe_lba)?;
919    let bs = u64::from(block_size);
920    let slack = (bs - info_len % bs) % bs;
921    if info_len == 0 || slack == 0 {
922        return None;
923    }
924    // Physical byte position of the file's last allocated block, walked through
925    // the FE's allocation descriptors so the slack inspected is the true final
926    // block (not a guess). Inline-stored files have no allocated block and so no
927    // slack to inspect.
928    let last_block_pos = fe_last_block_pos(reader, block_size, partition_start, fe_lba)?;
929    let mut buf = [0u8; MAX_BLOCK_SIZE];
930    let block = &mut buf[..block_size as usize];
931    seek_read_checked(reader, last_block_pos, block).ok()?;
932
933    let slack_start = (info_len % bs) as usize;
934    let nonzero = block[slack_start..].iter().filter(|&&b| b != 0).count() as u32;
935    Some((nonzero, slack as u32))
936}
937
938/// Physical byte position of the *last* logical block holding a File Entry's
939/// data, resolved by walking its allocation descriptors. `None` for inline
940/// (in-ICB) data, an unreadable FE, or an FE with no recorded extent.
941fn fe_last_block_pos<R: Read + Seek>(
942    reader: &mut R,
943    block_size: u32,
944    partition_start: u32,
945    fe_lba: u32,
946) -> Option<u64> {
947    let mut buf = [0u8; MAX_BLOCK_SIZE];
948    let sector = &mut buf[..block_size as usize];
949    seek_read_checked(reader, u64::from(fe_lba) * u64::from(block_size), sector).ok()?;
950
951    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
952    let is_efe = tag_ident == TAG_EFE;
953    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && !is_efe {
954        return None;
955    }
956
957    let icb_flags = u16::from_le_bytes([sector[34], sector[35]]);
958    let alloc_type = icb_flags & 0x0007;
959    // See `read_fe_data`: EFE shifts L_EA/L_AD/area to 208/212/216 (40 bytes of
960    // extra fields), not the 176/180/184 an 8-byte-only shift would give.
961    let (ea_off, ad_off, header) = if is_efe {
962        (208usize, 212usize, 216usize)
963    } else {
964        (168usize, 172usize, 176usize)
965    };
966    if ad_off + 4 > sector.len() {
967        return None; // cov:unreachable: header offsets fit a >=512-byte block
968    }
969    let ea_len = u32::from_le_bytes(sector[ea_off..ea_off + 4].try_into().ok()?) as usize;
970    let ad_len = u32::from_le_bytes(sector[ad_off..ad_off + 4].try_into().ok()?) as usize;
971    let ad_start = header + ea_len;
972    let ad_end = ad_start.checked_add(ad_len)?;
973    if ad_end > sector.len() {
974        return None;
975    }
976    let ad_area = &sector[ad_start..ad_end];
977
978    // Both short_ad (8 bytes) and long_ad (16 bytes) record the extent length at
979    // bytes 0..4 and the logical block number at bytes 4..8; only the stride to
980    // the next descriptor differs. Inline (in-ICB) data has no allocated block.
981    let stride = match alloc_type {
982        ALLOC_SHORT => 8,
983        ALLOC_LONG => 16,
984        _ => return None,
985    };
986    let mut last: Option<u64> = None;
987    let mut pos = 0;
988    while pos + stride <= ad_area.len() {
989        let len_raw = le_u32(ad_area, pos);
990        let ext_type = len_raw >> 30;
991        let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
992        if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
993            let lbn = le_u32(ad_area, pos + 4);
994            let blocks_in_ext = ext_len.div_ceil(block_size as usize) as u64;
995            let last_lbn = u64::from(partition_start) + u64::from(lbn) + (blocks_in_ext - 1);
996            last = Some(last_lbn * u64::from(block_size));
997        }
998        pos += stride;
999    }
1000    last
1001}
1002
1003#[cfg(test)]
1004mod real_media_tests {
1005    //! Validate partition-map classification against real mkudffs-authored
1006    //! pure-UDF images, cross-checked against the independent `udfinfo`
1007    //! (udftools) oracle. The images and the verbatim `mkudffs` commands that
1008    //! produced them are documented in `tests/data/README.md`; they are
1009    //! committed, so these tests run (the skip-if-missing arm is a defensive
1010    //! fallback for a checkout where the fixtures were stripped).
1011    use super::{parse_udf_state, UdfPartitionKind};
1012    use std::fs::File;
1013
1014    fn state(name: &str) -> Option<super::UdfState> {
1015        let path = format!("{}/tests/data/{}", env!("CARGO_MANIFEST_DIR"), name);
1016        let mut f = File::open(&path).ok()?;
1017        parse_udf_state(&mut f)
1018    }
1019
1020    #[test]
1021    fn vat_image_classified_virtual() {
1022        let Some(st) = state("udf_vat.img") else {
1023            eprintln!("skip: udf_vat.img");
1024            return;
1025        };
1026        assert_eq!(
1027            st.partition_kind,
1028            UdfPartitionKind::Virtual,
1029            "mkudffs cdr/1.50 image must classify as Virtual (VAT)"
1030        );
1031    }
1032
1033    #[test]
1034    fn sparable_image_classified_sparable() {
1035        let Some(st) = state("udf_spar.img") else {
1036            eprintln!("skip: udf_spar.img");
1037            return;
1038        };
1039        assert_eq!(
1040            st.partition_kind,
1041            UdfPartitionKind::Sparable,
1042            "mkudffs dvdrw/2.01 image must classify as Sparable"
1043        );
1044    }
1045
1046    /// Differential reconciliation against the independent `udfinfo` oracle
1047    /// (udftools 2.3, a separate codebase from this crate). The expected values
1048    /// below are the *oracle's* reported ground truth — partition-space start
1049    /// and partition-map shape derived from `udfinfo`'s output, NOT recomputed
1050    /// by this crate. See `tests/data/README.md` for the captured oracle output.
1051    ///
1052    /// `udfinfo udf_vat.img` reports `udfrev=1.50`, `accesstype=writeonce`, and
1053    /// `start=257, blocks=3839, type=PSPACE`; the cdr/1.50 layout carries a
1054    /// physical map plus a Type-2 `*UDF Virtual Partition` map (VAT), so the
1055    /// file-set partition resolves to physical start 257 and two partition maps.
1056    #[test]
1057    fn vat_image_matches_udfinfo_oracle() {
1058        let Some(st) = state("udf_vat.img") else {
1059            eprintln!("skip: udf_vat.img");
1060            return;
1061        };
1062        assert_eq!(st.partition_kind, UdfPartitionKind::Virtual);
1063        // udfinfo PSPACE start block.
1064        assert_eq!(
1065            st.partition_start, 257,
1066            "partition start must match udfinfo PSPACE start=257"
1067        );
1068        // Physical + Virtual (VAT) Type-2 map.
1069        assert_eq!(
1070            st.partition_map_count, 2,
1071            "cdr/1.50 carries a physical map plus the VAT Type-2 map"
1072        );
1073    }
1074
1075    /// `udfinfo udf_spar.img` reports `udfrev=2.01`, `accesstype=overwritable`,
1076    /// a `type=SSPACE` (sparing) region, and `start=1296, blocks=2528,
1077    /// type=PSPACE`; the dvdrw/2.01 layout uses a single Type-2 `*UDF Sparable
1078    /// Partition` map, so the file-set partition resolves to physical start 1296
1079    /// with one partition map.
1080    #[test]
1081    fn sparable_image_matches_udfinfo_oracle() {
1082        let Some(st) = state("udf_spar.img") else {
1083            eprintln!("skip: udf_spar.img");
1084            return;
1085        };
1086        assert_eq!(st.partition_kind, UdfPartitionKind::Sparable);
1087        // udfinfo PSPACE start block.
1088        assert_eq!(
1089            st.partition_start, 1296,
1090            "partition start must match udfinfo PSPACE start=1296"
1091        );
1092        assert_eq!(
1093            st.partition_map_count, 1,
1094            "dvdrw/2.01 carries a single Sparable Type-2 map"
1095        );
1096    }
1097
1098    /// `udfinfo udf_plain.img` reports `udfrev=2.01`, `blocksize=512`, and a
1099    /// single physical partition at `start=257, type=PSPACE`. The mkudffs `hd`
1100    /// profile writes 512-byte logical blocks, so the AVDP lives at byte
1101    /// 256×512, not 256×2048 — this image only parses once the block size is
1102    /// detected from the medium rather than assumed to be 2048.
1103    #[test]
1104    fn plain_512_block_image_parses_via_detected_block_size() {
1105        let path = format!("{}/tests/data/udf_plain.img", env!("CARGO_MANIFEST_DIR"));
1106        let mut f = File::open(&path).expect("udf_plain.img fixture must be present");
1107        let st = super::parse_udf_state(&mut f)
1108            .expect("512-byte-block UDF must parse once the block size is detected from the AVDP");
1109        assert_eq!(st.block_size, 512, "udfinfo reports blocksize=512");
1110        assert_eq!(
1111            st.partition_kind,
1112            UdfPartitionKind::Physical,
1113            "mkudffs hd image is a Type-1 physical partition"
1114        );
1115        assert_eq!(
1116            st.partition_start, 257,
1117            "partition start must match udfinfo PSPACE start=257"
1118        );
1119        assert_eq!(
1120            st.partition_map_count, 1,
1121            "hd/2.01 carries a single physical map"
1122        );
1123    }
1124}
1125
1126#[cfg(test)]
1127mod checked_bootstrap_tests {
1128    //! `parse_udf_state_checked` must distinguish a real seek/read I/O failure
1129    //! (a bootstrap read failure — truncated/unreadable image) from a structural
1130    //! negative (reads succeeded but the anchor is not a valid AVDP → not UDF).
1131    use super::parse_udf_state_checked;
1132    use std::io::{self, Cursor, Read, Seek, SeekFrom};
1133
1134    /// A `Read + Seek` whose seeks always succeed but whose reads always fail
1135    /// with a non-EOF I/O error — models an unreadable image / device fault.
1136    struct FaultyReader;
1137
1138    impl Read for FaultyReader {
1139        fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
1140            Err(io::Error::other("device read fault"))
1141        }
1142    }
1143    impl Seek for FaultyReader {
1144        fn seek(&mut self, _pos: SeekFrom) -> io::Result<u64> {
1145            Ok(0)
1146        }
1147    }
1148
1149    #[test]
1150    fn io_error_at_anchor_surfaces_as_err() {
1151        let mut r = FaultyReader;
1152        let res = parse_udf_state_checked(&mut r);
1153        assert!(
1154            res.is_err(),
1155            "a device read fault reading the anchor must surface as Err, not Ok(None)"
1156        );
1157    }
1158
1159    #[test]
1160    fn truncated_before_anchor_surfaces_as_err() {
1161        // A buffer too short to reach LBA 256 (256 * 2048 = 524288 bytes) — the
1162        // read_exact at the anchor hits UnexpectedEof, which is a truncated-image
1163        // bootstrap failure and must surface, not be swallowed into Ok(None).
1164        let buf = vec![0u8; 4096];
1165        let mut r = Cursor::new(buf);
1166        let res = parse_udf_state_checked(&mut r);
1167        assert!(
1168            res.is_err(),
1169            "truncation before the AVDP anchor must surface as Err (UnexpectedEof)"
1170        );
1171        let err = res.err().unwrap();
1172        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
1173    }
1174
1175    #[test]
1176    fn full_size_but_wrong_anchor_is_ok_none() {
1177        // A buffer large enough to reach and read LBA 256, but whose sector 256 is
1178        // all zeros (tag identifier 0, not TAG_AVDP=2) — reads succeed, the
1179        // structure is simply not UDF. This is the legitimate "not UDF" case.
1180        let buf = vec![0u8; 257 * 2048];
1181        let mut r = Cursor::new(buf);
1182        let res = parse_udf_state_checked(&mut r);
1183        assert!(
1184            matches!(res, Ok(None)),
1185            "a readable image with a non-AVDP anchor must be Ok(None), got {res:?}"
1186        );
1187    }
1188}
1189
1190#[cfg(test)]
1191mod findings_support_tests {
1192    //! Unit coverage for the findings-support primitives. The CRC/checksum
1193    //! implementations are additionally validated against the real `mkudffs`
1194    //! corpus by the `findings` integration tests (a clean image's descriptors
1195    //! must all verify — a true negative); these tests cover the pure helpers
1196    //! and the allocated-extent slack path the inline-data corpus cannot reach.
1197    use super::*;
1198    use std::io::Cursor;
1199
1200    #[test]
1201    fn crc_ccitt_matches_known_vectors() {
1202        // CRC-CCITT (poly 0x1021, init 0x0000): "123456789" → 0x31C3 is the
1203        // standard published vector for this parameterisation.
1204        assert_eq!(ecma167_crc(b"123456789"), 0x31C3);
1205        assert_eq!(ecma167_crc(&[]), 0x0000);
1206    }
1207
1208    #[test]
1209    fn tag_checksum_skips_byte_4() {
1210        let mut tag = [0u8; 16];
1211        tag[0] = 2; // contributes
1212        tag[4] = 0xFF; // the checksum byte itself — must be excluded
1213        tag[6] = 3; // contributes
1214        assert_eq!(tag_checksum(&tag), 5);
1215    }
1216
1217    #[test]
1218    fn descriptor_label_known_and_unknown() {
1219        // Every mapped ECMA-167 / UDF descriptor identifier resolves to a name
1220        // so a walked descriptor of any type is reported by name, not a number.
1221        for (id, name) in [
1222            (TAG_FSD, "FileSetDescriptor"),
1223            (TAG_EFE, "ExtendedFileEntry"),
1224            (TAG_FID, "FileIdentifierDescriptor"),
1225            (TAG_FE, "FileEntry"),
1226            (TAG_FE_ALT, "FileEntry"),
1227            (1, "PrimaryVolumeDescriptor"),
1228            (3, "VolumeDescriptorPointer"),
1229            (4, "ImplementationUseVolumeDescriptor"),
1230            (7, "UnallocatedSpaceDescriptor"),
1231            (9, "LogicalVolumeIntegrityDescriptor"),
1232            (258, "AllocationExtentDescriptor"),
1233            (259, "IndirectEntry"),
1234            (262, "SpaceBitmapDescriptor"),
1235            (263, "PartitionIntegrityEntry"),
1236            (264, "ExtendedAttributeHeaderDescriptor"),
1237            (265, "UnallocatedSpaceEntry"),
1238        ] {
1239            assert_eq!(descriptor_label(id), Some(name), "tag {id}");
1240        }
1241        assert_eq!(descriptor_label(0xFFFF), None);
1242    }
1243
1244    #[test]
1245    fn fsd_recording_time_none_for_non_fsd() {
1246        let img = vec![0u8; 512];
1247        let mut r = Cursor::new(img);
1248        assert_eq!(fsd_recording_time(&mut r, 512, 0).unwrap(), None);
1249    }
1250
1251    #[test]
1252    fn last_block_pos_none_for_non_file_entry() {
1253        let img = vec![0u8; 512];
1254        let mut r = Cursor::new(img);
1255        assert_eq!(fe_last_block_pos(&mut r, 512, 0, 0), None);
1256    }
1257
1258    #[test]
1259    fn slack_via_long_allocation_descriptor() {
1260        // A 16-byte long_ad (alloc type 1) exercises the ALLOC_LONG stride.
1261        let bs = 512usize;
1262        let mut img = vec![0u8; bs * 8];
1263        let fe = 4 * bs;
1264        img[fe..fe + 2].copy_from_slice(&TAG_FE.to_le_bytes());
1265        img[fe + 34..fe + 36].copy_from_slice(&1u16.to_le_bytes()); // long_ad alloc
1266        img[fe + 56..fe + 64].copy_from_slice(&100u64.to_le_bytes());
1267        img[fe + 168..fe + 172].copy_from_slice(&0u32.to_le_bytes()); // L_EA
1268        img[fe + 172..fe + 176].copy_from_slice(&16u32.to_le_bytes()); // L_AD = one long_ad
1269        img[fe + 176..fe + 180].copy_from_slice(&100u32.to_le_bytes()); // extent_length
1270        img[fe + 180..fe + 184].copy_from_slice(&5u32.to_le_bytes()); // logical block num
1271        let data = 5 * bs + 100;
1272        img[data] = 0x7F;
1273        let mut r = Cursor::new(img);
1274        let (nonzero, slack) = fe_slack_nonzero(&mut r, 512, 0, 4).expect("slack present");
1275        assert_eq!(slack, 412);
1276        assert_eq!(nonzero, 1);
1277    }
1278
1279    #[test]
1280    fn timestamp_decodes_and_rejects_implausible() {
1281        let mut t = [0u8; 12];
1282        t[2..4].copy_from_slice(&2026i16.to_le_bytes());
1283        t[4] = 6; // month
1284        t[5] = 21; // day
1285        t[6] = 8; // hour
1286        t[7] = 46; // minute
1287        t[8] = 57; // second
1288        assert_eq!(decode_timestamp(&t).as_deref(), Some("2026-06-21 08:46:57"));
1289
1290        // Year out of range → None (unset/garbage field).
1291        let mut bad = t;
1292        bad[2..4].copy_from_slice(&0i16.to_le_bytes());
1293        assert_eq!(decode_timestamp(&bad), None);
1294
1295        // Month out of range → None.
1296        let mut badmon = t;
1297        badmon[4] = 0;
1298        assert_eq!(decode_timestamp(&badmon), None);
1299
1300        // Short buffer → None.
1301        assert_eq!(decode_timestamp(&[0u8; 4]), None);
1302    }
1303
1304    #[test]
1305    fn fe_modification_time_offset_differs_for_efe() {
1306        // Base FE: mtime at offset 84; EFE: mtime at offset 92.
1307        let mut fe = vec![0u8; 512];
1308        let stamp = |buf: &mut [u8], off: usize, year: i16| {
1309            buf[off + 2..off + 4].copy_from_slice(&year.to_le_bytes());
1310            buf[off + 4] = 1; // month
1311            buf[off + 5] = 1; // day
1312        };
1313        stamp(&mut fe, 84, 2030);
1314        assert_eq!(
1315            fe_modification_time(&fe, false).as_deref(),
1316            Some("2030-01-01 00:00:00")
1317        );
1318        let mut efe = vec![0u8; 512];
1319        stamp(&mut efe, 92, 2031);
1320        assert_eq!(
1321            fe_modification_time(&efe, true).as_deref(),
1322            Some("2031-01-01 00:00:00")
1323        );
1324    }
1325
1326    /// Build a minimal 512-byte-block image with a base File Entry that points,
1327    /// via a short allocation descriptor, to a single data block whose tail
1328    /// (past `InformationLength`) holds non-zero slack — the allocated-extent
1329    /// path the inline-data `mkudffs` corpus cannot exercise.
1330    fn image_with_slack(info_len: u64, slack_fill: &[u8]) -> (Vec<u8>, u32, u32) {
1331        let bs = 512usize;
1332        let part_start = 0u32;
1333        let fe_lba = 4u32;
1334        let data_lbn = 5u32; // physical = part_start + 5
1335        let mut img = vec![0u8; bs * 8];
1336
1337        let fe = fe_lba as usize * bs;
1338        // Tag identifier = File Entry (260).
1339        img[fe..fe + 2].copy_from_slice(&TAG_FE.to_le_bytes());
1340        // ICB flags: allocation type 0 (short_ad).
1341        img[fe + 34..fe + 36].copy_from_slice(&0u16.to_le_bytes());
1342        // InformationLength.
1343        img[fe + 56..fe + 64].copy_from_slice(&info_len.to_le_bytes());
1344        // Base-FE header offsets: L_EA @168, L_AD @172, AD area @176.
1345        img[fe + 168..fe + 172].copy_from_slice(&0u32.to_le_bytes()); // L_EA = 0
1346        img[fe + 172..fe + 176].copy_from_slice(&8u32.to_le_bytes()); // L_AD = 8 (one short_ad)
1347                                                                      // short_ad: extent_length (recorded, type 0) = info_len, position = data_lbn.
1348        let ad = fe + 176;
1349        img[ad..ad + 4].copy_from_slice(&(info_len as u32).to_le_bytes());
1350        img[ad + 4..ad + 8].copy_from_slice(&data_lbn.to_le_bytes());
1351
1352        // Data block: fill the slack region (past info_len within the block).
1353        let data = (part_start + data_lbn) as usize * bs;
1354        let slack_start = data + (info_len as usize % bs);
1355        for (i, &b) in slack_fill.iter().enumerate() {
1356            img[slack_start + i] = b;
1357        }
1358        (img, part_start, fe_lba)
1359    }
1360
1361    #[test]
1362    fn slack_counts_nonzero_tail_bytes() {
1363        // info_len 100 in a 512-byte block → 412 slack bytes; place 3 non-zero.
1364        let (img, ps, fe) = image_with_slack(100, &[0xAA, 0x00, 0xBB, 0xCC]);
1365        let mut r = Cursor::new(img);
1366        let (nonzero, slack) = fe_slack_nonzero(&mut r, 512, ps, fe).expect("slack present");
1367        assert_eq!(slack, 412);
1368        assert_eq!(nonzero, 3);
1369    }
1370
1371    #[test]
1372    fn slack_none_when_block_aligned() {
1373        // info_len exactly one block → no slack.
1374        let (img, ps, fe) = image_with_slack(512, &[0xFF]);
1375        let mut r = Cursor::new(img);
1376        assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
1377    }
1378
1379    #[test]
1380    fn slack_none_when_zero_length() {
1381        let (img, ps, fe) = image_with_slack(0, &[]);
1382        let mut r = Cursor::new(img);
1383        assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
1384    }
1385
1386    #[test]
1387    fn slack_none_for_inline_data() {
1388        // Allocation type 3 (inline) has no allocated block to inspect.
1389        let (mut img, ps, fe) = image_with_slack(100, &[0xAA]);
1390        let fe_off = fe as usize * 512;
1391        img[fe_off + 34..fe_off + 36].copy_from_slice(&3u16.to_le_bytes());
1392        let mut r = Cursor::new(img);
1393        assert_eq!(fe_slack_nonzero(&mut r, 512, ps, fe), None);
1394    }
1395}
1396
1397#[cfg(test)]
1398mod synth_traversal_tests {
1399    //! Directory (FID) traversal, filename decoding, and the short/long
1400    //! allocation extent readers, driven over the hand-built populated UDF image
1401    //! in `test_support` (the committed `mkudffs` corpus has empty roots and
1402    //! inline-only data, so it cannot reach these paths). Expected values are
1403    //! derived from the documented ECMA-167 construction of the fixture.
1404    use super::*;
1405    use crate::test_support as ts;
1406    use std::io::Cursor;
1407
1408    #[test]
1409    fn read_dir_lists_children_with_decoded_names_and_sizes() {
1410        let mut r = Cursor::new(ts::image());
1411        let st = parse_udf_state(&mut r).expect("synthetic UDF parses");
1412        assert_eq!(st.block_size, 512);
1413        assert_eq!(st.partition_kind, UdfPartitionKind::Physical);
1414
1415        let entries = read_dir_at_lba(&mut r, st.block_size, st.partition_start, st.root_fe_lba)
1416            .expect("root directory reads");
1417        // The parent FID is skipped; the six real children are surfaced.
1418        assert_eq!(entries.len(), 6, "entries: {entries:?}");
1419        let by_name = |n: &str| entries.iter().find(|e| e.name == n);
1420
1421        assert!(by_name("sub").expect("sub").is_dir);
1422        assert!(by_name("bd").expect("bd").is_dir);
1423        let inline = by_name("inline.txt").expect("inline.txt");
1424        assert!(!inline.is_dir);
1425        assert_eq!(inline.size, 4);
1426        assert_eq!(
1427            by_name("short.bin").expect("short.bin").size,
1428            ts::SHORT_FILE_LEN
1429        );
1430        assert_eq!(
1431            by_name("long.bin").expect("long.bin").size,
1432            ts::LONG_FILE_LEN
1433        );
1434        // OSTA CS0 compression id 16 (UTF-16BE) decodes to "U".
1435        assert!(by_name("U").is_some(), "utf-16 name decoded: {entries:?}");
1436    }
1437
1438    #[test]
1439    fn read_fe_data_reads_short_extents() {
1440        let mut r = Cursor::new(ts::image());
1441        let st = parse_udf_state(&mut r).unwrap();
1442        let data = read_fe_data(&mut r, st.block_size, st.partition_start, ts::SHORT_FILE_FE)
1443            .expect("short-extent file data");
1444        assert_eq!(data.len() as u64, ts::SHORT_FILE_LEN);
1445        assert!(data[..512].iter().all(|&b| b == 0x41));
1446        assert!(data[512..].iter().all(|&b| b == 0x42));
1447    }
1448
1449    #[test]
1450    fn read_fe_data_reads_long_extents() {
1451        let mut r = Cursor::new(ts::image());
1452        let st = parse_udf_state(&mut r).unwrap();
1453        let data = read_fe_data(&mut r, st.block_size, st.partition_start, ts::LONG_FILE_FE)
1454            .expect("long-extent file data");
1455        assert_eq!(data.len() as u64, ts::LONG_FILE_LEN);
1456        assert!(data.iter().all(|&b| b == 0x43));
1457    }
1458
1459    #[test]
1460    fn read_fe_data_reads_inline() {
1461        let mut r = Cursor::new(ts::image());
1462        let st = parse_udf_state(&mut r).unwrap();
1463        let data = read_fe_data(
1464            &mut r,
1465            st.block_size,
1466            st.partition_start,
1467            ts::INLINE_FILE_FE,
1468        )
1469        .expect("inline file data");
1470        assert_eq!(data, b"abcd");
1471    }
1472
1473    #[test]
1474    fn read_fe_data_none_for_non_file_entry() {
1475        let mut r = Cursor::new(vec![0u8; 4096]);
1476        assert!(read_fe_data(&mut r, 512, 0, 0).is_none());
1477    }
1478
1479    #[test]
1480    fn read_fe_data_none_for_broken_directory() {
1481        // L_AD overruns the block, so the allocation area cannot be sliced.
1482        let mut r = Cursor::new(ts::image());
1483        let st = parse_udf_state(&mut r).unwrap();
1484        assert!(
1485            read_fe_data(&mut r, st.block_size, st.partition_start, ts::BROKEN_DIR_FE).is_none()
1486        );
1487    }
1488
1489    #[test]
1490    fn read_fe_data_none_for_unsupported_alloc_type() {
1491        // Allocation type 2 (extended alloc descriptors) is not resolved here.
1492        let mut img = vec![0u8; 1024];
1493        img[0..2].copy_from_slice(&TAG_FE.to_le_bytes());
1494        img[34..36].copy_from_slice(&2u16.to_le_bytes()); // alloc type 2
1495        let mut r = Cursor::new(img);
1496        assert!(read_fe_data(&mut r, 512, 0, 0).is_none());
1497    }
1498
1499    #[test]
1500    fn decode_osta_cs0_utf8_utf16_and_empty() {
1501        assert_eq!(decode_osta_cs0(&[]), "");
1502        assert_eq!(decode_osta_cs0(b"\x08hello"), "hello");
1503        // Compression id 16 = UTF-16BE.
1504        assert_eq!(decode_osta_cs0(&[16, 0x00, 0x41, 0x00, 0x42]), "AB");
1505    }
1506
1507    #[cfg(feature = "vfs")]
1508    #[test]
1509    fn read_fe_file_type_classifies_and_rejects() {
1510        let mut r = Cursor::new(ts::image());
1511        let st = parse_udf_state(&mut r).unwrap();
1512        assert_eq!(
1513            read_fe_file_type(&mut r, st.block_size, ts::SUBDIR_FE),
1514            Some(FILE_TYPE_DIRECTORY)
1515        );
1516        assert_eq!(
1517            read_fe_file_type(&mut r, st.block_size, ts::INLINE_FILE_FE),
1518            Some(5)
1519        );
1520        let mut z = Cursor::new(vec![0u8; 4096]);
1521        assert_eq!(read_fe_file_type(&mut z, 512, 0), None);
1522    }
1523
1524    #[test]
1525    fn parse_fids_skips_padding_and_stops_on_overflow() {
1526        // All-zero directory data: every 4-byte step sees a non-FID tag.
1527        let mut r = Cursor::new(vec![0u8; 64]);
1528        assert!(parse_fids(&mut r, 512, 0, &[0u8; 64]).is_empty());
1529        // A FID whose DescriptorCRCLength drives fid_advance past the buffer end
1530        // must break the loop rather than read out of bounds.
1531        let mut data = vec![0u8; 40];
1532        data[0..2].copy_from_slice(&TAG_FID.to_le_bytes());
1533        data[10..12].copy_from_slice(&0xFFFFu16.to_le_bytes());
1534        assert!(parse_fids(&mut r, 512, 0, &data).is_empty());
1535    }
1536
1537    #[test]
1538    fn parse_state_none_when_vds_has_no_lvd() {
1539        // Valid AVDP + detectable block size, but the VDS carries no Logical
1540        // Volume Descriptor → a structural "not UDF" (Ok(None)), never an error.
1541        let mut img = vec![0u8; 512 * 264];
1542        let avdp = 256 * 512;
1543        img[avdp..avdp + 2].copy_from_slice(&2u16.to_le_bytes()); // TAG_AVDP
1544        img[avdp + 12..avdp + 16].copy_from_slice(&256u32.to_le_bytes()); // tag location
1545        img[avdp + 16..avdp + 20].copy_from_slice(&512u32.to_le_bytes()); // VDS length: 1 block
1546        img[avdp + 20..avdp + 24].copy_from_slice(&260u32.to_le_bytes()); // VDS location
1547                                                                          // LBA 260 left zero → no PD/LVD.
1548        let mut r = Cursor::new(img);
1549        assert!(parse_udf_state_checked(&mut r).unwrap().is_none());
1550    }
1551
1552    #[test]
1553    fn parse_state_none_when_fsd_tag_wrong() {
1554        // Valid AVDP + VDS(PD+LVD) but the FSD sector is not an FSD → Ok(None).
1555        let mut img = ts::image();
1556        img[3 * 512..3 * 512 + 2].copy_from_slice(&0u16.to_le_bytes());
1557        let mut r = Cursor::new(img);
1558        assert!(parse_udf_state_checked(&mut r).unwrap().is_none());
1559    }
1560
1561    #[test]
1562    fn descriptor_label_terminating() {
1563        assert_eq!(descriptor_label(TAG_TERM), Some("TerminatingDescriptor"));
1564    }
1565
1566    #[test]
1567    fn detect_udf_recognises_nsr_and_stops_on_terminator() {
1568        // NSR03 in the volume recognition sequence (LBA 16) → recognised.
1569        let mut img = vec![0u8; 20 * 2048];
1570        img[16 * 2048 + 1..16 * 2048 + 6].copy_from_slice(b"NSR03");
1571        assert!(detect_udf(&mut Cursor::new(img)));
1572        // A TEA01 terminator with no NSR → not UDF (the scan stops at TEA01).
1573        let mut tea = vec![0u8; 20 * 2048];
1574        tea[16 * 2048 + 1..16 * 2048 + 6].copy_from_slice(b"TEA01");
1575        assert!(!detect_udf(&mut Cursor::new(tea)));
1576        // A source too short to reach LBA 16 breaks out and reports false.
1577        assert!(!detect_udf(&mut Cursor::new(vec![0u8; 100])));
1578    }
1579
1580    #[test]
1581    fn classify_type2_maps_entity_strings() {
1582        assert_eq!(
1583            classify_type2(b"....*UDF Metadata Partition...."),
1584            UdfPartitionKind::Metadata
1585        );
1586        assert_eq!(
1587            classify_type2(b"....*UDF Virtual Partition...."),
1588            UdfPartitionKind::Virtual
1589        );
1590        assert_eq!(
1591            classify_type2(b"....*UDF Sparable Partition...."),
1592            UdfPartitionKind::Sparable
1593        );
1594        assert_eq!(
1595            classify_type2(b"no entity string"),
1596            UdfPartitionKind::Unknown
1597        );
1598    }
1599
1600    #[test]
1601    fn parse_partition_maps_type1_type2_and_unknown() {
1602        let mut lvd = vec![0u8; 512];
1603        lvd[268..272].copy_from_slice(&3u32.to_le_bytes()); // N_PM = 3
1604        let mut off = 440usize;
1605        // Type-1 physical map (length 6) carrying partition number 2.
1606        lvd[off] = 1;
1607        lvd[off + 1] = 6;
1608        lvd[off + 4..off + 6].copy_from_slice(&2u16.to_le_bytes());
1609        off += 6;
1610        // Type-2 map (length 40) carrying the Metadata entity string.
1611        lvd[off] = 2;
1612        lvd[off + 1] = 40;
1613        lvd[off + 4..off + 4 + 23].copy_from_slice(b"*UDF Metadata Partition");
1614        off += 40;
1615        // An unrecognised map type (length 4).
1616        lvd[off] = 9;
1617        lvd[off + 1] = 4;
1618        off += 4;
1619        lvd[264..268].copy_from_slice(&((off - 440) as u32).to_le_bytes()); // Map Table Length
1620
1621        let maps = parse_partition_maps(&lvd);
1622        assert_eq!(maps.len(), 3);
1623        assert_eq!(maps[0].kind, UdfPartitionKind::Physical);
1624        assert_eq!(maps[0].partition_number, Some(2));
1625        assert_eq!(maps[1].kind, UdfPartitionKind::Metadata);
1626        assert_eq!(maps[2].kind, UdfPartitionKind::Unknown);
1627    }
1628}