Skip to main content

udf_forensic/
lib.rs

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