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::{Read, Seek, SeekFrom};
20
21// ── ECMA-167 / UDF tag identifiers ───────────────────────────────────────────
22
23const TAG_AVDP: u16 = 2;
24const TAG_PD: u16 = 5;
25const TAG_LVD: u16 = 6;
26const TAG_TERM: u16 = 8;
27const TAG_FSD: u16 = 256;
28const TAG_FID: u16 = 257;
29const TAG_FE: u16 = 260;
30/// Some UDF implementations (e.g. older genisoimage) write 261 for File Entry.
31const TAG_FE_ALT: u16 = 261;
32const TAG_EFE: u16 = 266;
33
34// FID File Characteristics bits
35const FC_DIRECTORY: u8 = 0x02;
36const FC_PARENT: u8 = 0x08;
37
38// ICB allocation type (FE flags bits 0-2)
39const ALLOC_SHORT: u16 = 0;
40const ALLOC_LONG: u16 = 1;
41const ALLOC_INLINE: u16 = 3;
42
43// Extent type bits 30-31 of extent_length field
44const EXTENT_RECORDED: u32 = 0x0000_0000; // 0b00 in bits 30-31
45
46// ── Public types ──────────────────────────────────────────────────────────────
47
48/// A single entry returned by UDF directory traversal.
49#[derive(Debug, Clone)]
50pub struct UdfFileEntry {
51    /// Decoded filename (OSTA CS0: UTF-8 or UTF-16BE).
52    pub name: String,
53    /// True if this entry is a directory.
54    pub is_dir: bool,
55    /// File size in bytes (Information Length from FE).
56    pub size: u64,
57    /// Physical LBA of the File Entry descriptor sector.
58    pub fe_lba: u32,
59}
60
61// ── Partition map kinds (ECMA-167 §10.7, OSTA UDF §2.2.8) ────────────────────
62
63/// The kind of partition referenced by the UDF logical volume's file set.
64///
65/// `Physical` (Type 1) partitions resolve as `partition_start + logical_block`.
66/// `Virtual` (VAT), `Sparable` (defect-managed), and `Metadata` (UDF 2.50+,
67/// used by Blu-ray) are Type 2 partitions whose block resolution requires
68/// additional structures this crate does not yet follow — they are detected
69/// and reported so a forensic tool fails loudly rather than mis-reading.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub enum UdfPartitionKind {
73    /// Type 1 physical partition.
74    Physical,
75    /// Type 2 `*UDF Virtual Partition` (VAT-mapped, packet-written media).
76    Virtual,
77    /// Type 2 `*UDF Sparable Partition` (defect management).
78    Sparable,
79    /// Type 2 `*UDF Metadata Partition` (UDF 2.50+, Blu-ray).
80    Metadata,
81    /// Type 2 partition with an unrecognised identifier.
82    Unknown,
83}
84
85// ── Internal UDF state ────────────────────────────────────────────────────────
86
87pub struct UdfState {
88    pub partition_start: u32,
89    pub root_fe_lba: u32,
90    pub partition_kind: UdfPartitionKind,
91    pub partition_map_count: u32,
92}
93
94// ── UDF detection (existing public API) ──────────────────────────────────────
95
96/// True if the image has a UDF recognition sequence (NSR02 or NSR03).
97///
98/// Scans volume structure descriptors starting at LBA 16, up to LBA 32.
99pub fn detect_udf<R: Read + Seek>(reader: &mut R) -> bool {
100    let mut buf = [0u8; 6];
101    for lba in 16u64..32 {
102        let pos = lba * 2048 + 1;
103        if reader.seek(SeekFrom::Start(pos)).is_err() {
104            break;
105        }
106        if reader.read_exact(&mut buf).is_err() {
107            break;
108        }
109        let id = &buf[..5];
110        if id == b"NSR02" || id == b"NSR03" {
111            return true;
112        }
113        if id == b"TEA01" {
114            break;
115        }
116    }
117    false
118}
119
120// ── UDF traversal (new internal API) ─────────────────────────────────────────
121
122/// Try to parse the AVDP → VDS → FSD chain, returning state needed for
123/// directory traversal. Returns `None` if the image lacks a valid UDF structure.
124pub fn parse_udf_state<R: Read + Seek>(reader: &mut R) -> Option<UdfState> {
125    let (vds_loc, vds_len) = read_avdp(reader)?;
126    let vds = read_vds(reader, vds_loc, vds_len)?;
127    let root_fe_lba = read_fsd(reader, vds.fsd_lba, vds.partition_start)?;
128    Some(UdfState {
129        partition_start: vds.partition_start,
130        root_fe_lba,
131        partition_kind: vds.partition_kind,
132        partition_map_count: vds.map_count,
133    })
134}
135
136/// Resolved Volume Descriptor Sequence information.
137struct VdsInfo {
138    partition_start: u32,
139    fsd_lba: u32,
140    partition_kind: UdfPartitionKind,
141    map_count: u32,
142}
143
144/// A parsed partition map entry from the Logical Volume Descriptor.
145struct PartitionMap {
146    kind: UdfPartitionKind,
147    /// Partition number (Type 1 only); `None` for Type 2 maps.
148    partition_number: Option<u16>,
149}
150
151/// Classify a Type 2 partition map by scanning its identifier region for the
152/// OSTA UDF entity strings.
153fn classify_type2(map: &[u8]) -> UdfPartitionKind {
154    let scan = |needle: &[u8]| map.windows(needle.len()).any(|w| w == needle);
155    if scan(b"*UDF Metadata Partition") {
156        UdfPartitionKind::Metadata
157    } else if scan(b"*UDF Virtual Partition") {
158        UdfPartitionKind::Virtual
159    } else if scan(b"*UDF Sparable Partition") {
160        UdfPartitionKind::Sparable
161    } else {
162        UdfPartitionKind::Unknown
163    }
164}
165
166/// Parse the partition maps from a Logical Volume Descriptor sector.
167///
168/// LVD (ECMA-167 §10.6): N_PM at BP 268, Map Table Length at BP 264, maps at
169/// BP 440.  Each map: `[type(1)][length(1)]…`; Type 1 carries the partition
170/// number at RBP 4; Type 2 is identified by its embedded entity string.
171fn parse_partition_maps(lvd: &[u8]) -> Vec<PartitionMap> {
172    let n_pm = u32::from_le_bytes(lvd[268..272].try_into().unwrap()) as usize;
173    let mt_l = u32::from_le_bytes(lvd[264..268].try_into().unwrap()) as usize;
174    let maps_end = (440 + mt_l).min(lvd.len());
175    let mut out = Vec::new();
176    let mut off = 440;
177    while out.len() < n_pm && off + 2 <= maps_end {
178        let map_type = lvd[off];
179        let map_len = lvd[off + 1] as usize;
180        if map_len < 2 || off + map_len > maps_end {
181            break;
182        }
183        let map = &lvd[off..off + map_len];
184        let pm = match map_type {
185            1 if map_len >= 6 => PartitionMap {
186                kind: UdfPartitionKind::Physical,
187                partition_number: Some(u16::from_le_bytes([map[4], map[5]])),
188            },
189            2 => PartitionMap {
190                kind: classify_type2(map),
191                partition_number: None,
192            },
193            _ => PartitionMap {
194                kind: UdfPartitionKind::Unknown,
195                partition_number: None,
196            },
197        };
198        out.push(pm);
199        off += map_len;
200    }
201    out
202}
203
204/// Read all non-parent File Identifier Descriptors from the directory whose
205/// File Entry resides at `dir_fe_lba`, returning one `UdfFileEntry` per child.
206pub fn read_dir_at_lba<R: Read + Seek>(
207    reader: &mut R,
208    partition_start: u32,
209    dir_fe_lba: u32,
210) -> Option<Vec<UdfFileEntry>> {
211    let dir_data = read_fe_data(reader, partition_start, dir_fe_lba)?;
212    Some(parse_fids(reader, partition_start, &dir_data))
213}
214
215/// Read the data extent of the File Entry at `fe_lba`.
216pub fn read_fe_data<R: Read + Seek>(
217    reader: &mut R,
218    partition_start: u32,
219    fe_lba: u32,
220) -> Option<Vec<u8>> {
221    let mut sector = [0u8; 2048];
222    seek_read(reader, fe_lba as u64 * 2048, &mut sector)?;
223
224    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
225    let is_efe = tag_ident == TAG_EFE;
226    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && !is_efe {
227        return None;
228    }
229
230    let icb_flags = u16::from_le_bytes([sector[34], sector[35]]);
231    let alloc_type = icb_flags & 0x0007;
232    let info_len = u64::from_le_bytes(sector[56..64].try_into().unwrap());
233
234    // EFE has an additional ObjectSize (8 bytes) field before L_EA / L_AD.
235    let (ea_off, ad_off, header) = if is_efe {
236        (176usize, 180usize, 184usize)
237    } else {
238        (168usize, 172usize, 176usize)
239    };
240
241    if ad_off + 4 > sector.len() {
242        return None;
243    }
244    let ea_len = u32::from_le_bytes(sector[ea_off..ea_off + 4].try_into().unwrap()) as usize;
245    let ad_len = u32::from_le_bytes(sector[ad_off..ad_off + 4].try_into().unwrap()) as usize;
246
247    let ad_start = header + ea_len;
248    let ad_end = ad_start + ad_len;
249    if ad_end > sector.len() {
250        return None;
251    }
252    let ad_area = sector[ad_start..ad_end].to_vec();
253
254    match alloc_type {
255        ALLOC_INLINE => Some(ad_area[..info_len.min(ad_area.len() as u64) as usize].to_vec()),
256        ALLOC_SHORT => read_extents_short(reader, partition_start, &ad_area, info_len),
257        ALLOC_LONG => read_extents_long(reader, partition_start, &ad_area, info_len),
258        _ => None,
259    }
260}
261
262// ── Private helpers ───────────────────────────────────────────────────────────
263
264/// Parse AVDP at LBA 256. Returns (vds_location, vds_length).
265fn read_avdp<R: Read + Seek>(reader: &mut R) -> Option<(u32, u32)> {
266    let mut sector = [0u8; 2048];
267    seek_read(reader, 256 * 2048, &mut sector)?;
268    if u16::from_le_bytes([sector[0], sector[1]]) != TAG_AVDP {
269        return None;
270    }
271    let vds_len = u32::from_le_bytes(sector[16..20].try_into().unwrap());
272    let vds_loc = u32::from_le_bytes(sector[20..24].try_into().unwrap());
273    Some((vds_loc, vds_len))
274}
275
276/// Scan the Volume Descriptor Sequence: collect every Partition Descriptor
277/// (partition number → starting location) and the Logical Volume Descriptor
278/// (file-set location, partition reference, and partition maps), then resolve
279/// the file set's partition through its map.
280fn read_vds<R: Read + Seek>(reader: &mut R, vds_loc: u32, vds_len: u32) -> Option<VdsInfo> {
281    use std::collections::HashMap;
282    let sectors = (vds_len as usize).div_ceil(2048);
283
284    // partition number → starting location (physical LBA).
285    let mut pd_start: HashMap<u16, u32> = HashMap::new();
286    let mut fsd_lbn: Option<u32> = None;
287    let mut fsd_part_ref: u16 = 0;
288    let mut maps: Vec<PartitionMap> = Vec::new();
289
290    for i in 0..sectors {
291        let mut sector = [0u8; 2048];
292        seek_read(reader, (vds_loc as u64 + i as u64) * 2048, &mut sector)?;
293        let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
294        match tag_ident {
295            TAG_PD => {
296                let part_num = u16::from_le_bytes([sector[22], sector[23]]);
297                let psl = u32::from_le_bytes(sector[188..192].try_into().unwrap());
298                pd_start.insert(part_num, psl);
299            }
300            TAG_LVD => {
301                // LV Contents Use long_ad at offset 248: extent_length [248..252],
302                // logical_block_num [252..256], partition_reference [256..258].
303                fsd_lbn = Some(u32::from_le_bytes(sector[252..256].try_into().unwrap()));
304                fsd_part_ref = u16::from_le_bytes([sector[256], sector[257]]);
305                maps = parse_partition_maps(&sector);
306            }
307            TAG_TERM | 0 => break,
308            _ => {}
309        }
310    }
311
312    let fsd = fsd_lbn?;
313    let map_count = maps.len() as u32;
314
315    // Resolve the file set's partition via the referenced partition map.
316    let referenced = maps.get(fsd_part_ref as usize);
317    let kind = referenced.map_or(UdfPartitionKind::Unknown, |m| m.kind);
318
319    // Type 1: resolve the partition start from the map's partition number.
320    // Type 2 (Virtual/Sparable/Metadata): block resolution needs structures we
321    // do not yet follow — fall back to the first physical partition so detection
322    // still works, and report the kind so callers know reads may be incomplete.
323    let partition_start = referenced
324        .and_then(|m| m.partition_number)
325        .and_then(|pn| pd_start.get(&pn).copied())
326        .or_else(|| pd_start.values().min().copied())?;
327
328    Some(VdsInfo {
329        partition_start,
330        fsd_lba: partition_start + fsd,
331        partition_kind: kind,
332        map_count,
333    })
334}
335
336/// Parse FSD at `fsd_lba` to find the root directory FE logical block number.
337fn read_fsd<R: Read + Seek>(reader: &mut R, fsd_lba: u32, partition_start: u32) -> Option<u32> {
338    let mut sector = [0u8; 2048];
339    seek_read(reader, fsd_lba as u64 * 2048, &mut sector)?;
340    if u16::from_le_bytes([sector[0], sector[1]]) != TAG_FSD {
341        return None;
342    }
343    // FSD field sizes (ECMA-167 Table 20):
344    //   Tag(16) + RecordingDate(12) + Interchange/Charset fields(28) +
345    //   LV Ident CharSet(64) + LV Identifier(128) + FS CharSet(64) +
346    //   FS Identifier(32) + Copyright FI(32) + Abstract FI(32) = 408 bytes.
347    // Root Directory ICB (long_ad) starts at offset 400:
348    //   extent_length [400..404], logical_block_num [404..408]
349    let lbn = u32::from_le_bytes(sector[404..408].try_into().unwrap());
350    Some(partition_start + lbn)
351}
352
353/// Detect whether FIDs in this directory data use a standard 16-byte ECMA-167 tag
354/// or an extended 18-byte tag written by some UDF tools.
355///
356/// Some implementations append 2 extra bytes after the standard tag before the
357/// FID body, making all field offsets shift by 2. Detection heuristic: read the
358/// ICB logical block number at both candidate positions and use whichever gives a
359/// plausible value (< 65536, fitting discs up to ~128 GB).
360fn detect_fid_tag_size(data: &[u8]) -> usize {
361    let mut off = 0;
362    while off + 28 <= data.len() {
363        let ti = u16::from_le_bytes([data[off], data[off + 1]]);
364        if ti == TAG_FID {
365            let lbn16 = if off + 26 <= data.len() {
366                u32::from_le_bytes(data[off + 22..off + 26].try_into().unwrap())
367            } else {
368                u32::MAX
369            };
370            let lbn18 = if off + 28 <= data.len() {
371                u32::from_le_bytes(data[off + 24..off + 28].try_into().unwrap())
372            } else {
373                u32::MAX
374            };
375            if lbn16 < 0x10000 {
376                return 16;
377            }
378            if lbn18 < 0x10000 {
379                return 18;
380            }
381            return 16; // can't determine; fall back to standard
382        }
383        off += 4;
384    }
385    16
386}
387
388/// Parse File Identifier Descriptors from raw directory data.
389fn parse_fids<R: Read + Seek>(
390    reader: &mut R,
391    partition_start: u32,
392    data: &[u8],
393) -> Vec<UdfFileEntry> {
394    // Some UDF tools write an extra 2 bytes after the standard 16-byte tag.
395    // tag_size is 16 (standard) or 18 (extended); body fields follow at tag_size.
396    let tag_size = detect_fid_tag_size(data);
397    let min_fid = tag_size + 20; // tag + chars(1)+L_FI(1)+ICB(16)+L_IU(2)
398
399    let mut entries = Vec::new();
400    let mut off = 0;
401
402    while off + min_fid <= data.len() {
403        let tag_ident = u16::from_le_bytes([data[off], data[off + 1]]);
404        if tag_ident != TAG_FID {
405            // Advance 4 bytes to stay aligned; skip padding or unknown tags.
406            off += 4;
407            continue;
408        }
409
410        // CRC_len (at tag[10..12]) gives the true body extent from byte 16.
411        let crc_len = u16::from_le_bytes([data[off + 10], data[off + 11]]) as usize;
412        let fid_advance = ((16 + crc_len + 3) & !3).max(min_fid);
413        if off + fid_advance > data.len() {
414            break;
415        }
416
417        let file_chars = data[off + tag_size];
418        let file_id_len = data[off + tag_size + 1] as usize;
419        // ICB long_ad: extent_length at body[2..6], lbn at body[6..10]
420        let icb_lbn = if off + tag_size + 10 <= data.len() {
421            u32::from_le_bytes(
422                data[off + tag_size + 6..off + tag_size + 10]
423                    .try_into()
424                    .unwrap(),
425            )
426        } else {
427            off += fid_advance.max(4);
428            continue;
429        };
430        let impl_use_len = if off + tag_size + 20 <= data.len() {
431            u16::from_le_bytes([data[off + tag_size + 18], data[off + tag_size + 19]]) as usize
432        } else {
433            off += fid_advance.max(4);
434            continue;
435        };
436
437        if file_chars & FC_PARENT == 0 {
438            let is_dir = file_chars & FC_DIRECTORY != 0;
439            let fe_lba = partition_start + icb_lbn;
440
441            let id_start = off + tag_size + 20 + impl_use_len;
442            let id_end = (id_start + file_id_len).min(data.len());
443            let name = if id_end > id_start {
444                decode_osta_cs0(&data[id_start..id_end])
445            } else {
446                String::new()
447            };
448
449            // Read the FE to get the canonical file size.
450            let size = read_fe_info_len(reader, fe_lba).unwrap_or(0);
451
452            entries.push(UdfFileEntry {
453                name,
454                is_dir,
455                size,
456                fe_lba,
457            });
458        }
459
460        off += fid_advance.max(4);
461    }
462    entries
463}
464
465/// Read the Information Length (file size) from a File Entry at `fe_lba`.
466fn read_fe_info_len<R: Read + Seek>(reader: &mut R, fe_lba: u32) -> Option<u64> {
467    let mut sector = [0u8; 2048];
468    seek_read(reader, fe_lba as u64 * 2048, &mut sector)?;
469    let tag_ident = u16::from_le_bytes([sector[0], sector[1]]);
470    if tag_ident != TAG_FE && tag_ident != TAG_FE_ALT && tag_ident != TAG_EFE {
471        return None;
472    }
473    Some(u64::from_le_bytes(sector[56..64].try_into().unwrap()))
474}
475
476/// Collect data from short allocation descriptors (8 bytes each).
477fn read_extents_short<R: Read + Seek>(
478    reader: &mut R,
479    partition_start: u32,
480    ad_area: &[u8],
481    total_len: u64,
482) -> Option<Vec<u8>> {
483    let mut data = Vec::new();
484    let mut pos = 0;
485    while pos + 8 <= ad_area.len() && (data.len() as u64) < total_len {
486        let len_raw = u32::from_le_bytes(ad_area[pos..pos + 4].try_into().unwrap());
487        let ext_pos = u32::from_le_bytes(ad_area[pos + 4..pos + 8].try_into().unwrap());
488        let ext_type = len_raw >> 30;
489        let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
490        if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
491            let phys = (partition_start as u64 + ext_pos as u64) * 2048;
492            read_extent(reader, phys, ext_len, total_len, &mut data)?;
493        }
494        pos += 8;
495    }
496    data.truncate(total_len as usize);
497    Some(data)
498}
499
500/// Collect data from long allocation descriptors (16 bytes each).
501fn read_extents_long<R: Read + Seek>(
502    reader: &mut R,
503    partition_start: u32,
504    ad_area: &[u8],
505    total_len: u64,
506) -> Option<Vec<u8>> {
507    let mut data = Vec::new();
508    let mut pos = 0;
509    while pos + 16 <= ad_area.len() && (data.len() as u64) < total_len {
510        let len_raw = u32::from_le_bytes(ad_area[pos..pos + 4].try_into().unwrap());
511        let lbn = u32::from_le_bytes(ad_area[pos + 4..pos + 8].try_into().unwrap());
512        let ext_type = len_raw >> 30;
513        let ext_len = (len_raw & 0x3FFF_FFFF) as usize;
514        if ext_type == (EXTENT_RECORDED >> 30) && ext_len > 0 {
515            let phys = (partition_start as u64 + lbn as u64) * 2048;
516            read_extent(reader, phys, ext_len, total_len, &mut data)?;
517        }
518        pos += 16;
519    }
520    data.truncate(total_len as usize);
521    Some(data)
522}
523
524/// Read `ext_len` bytes from `byte_pos`, appending to `data` up to `total_len`.
525fn read_extent<R: Read + Seek>(
526    reader: &mut R,
527    byte_pos: u64,
528    ext_len: usize,
529    total_len: u64,
530    data: &mut Vec<u8>,
531) -> Option<()> {
532    let sectors = ext_len.div_ceil(2048);
533    for i in 0..sectors {
534        let mut sector = [0u8; 2048];
535        seek_read(reader, byte_pos + i as u64 * 2048, &mut sector)?;
536        let already = data.len() as u64;
537        let remaining = total_len.saturating_sub(already) as usize;
538        let sector_bytes = (ext_len - i * 2048).min(2048);
539        let take = sector_bytes.min(remaining);
540        data.extend_from_slice(&sector[..take]);
541    }
542    Some(())
543}
544
545/// Decode an OSTA CS0 encoded identifier: first byte is compression ID
546/// (8 = UTF-8, 16 = UTF-16BE), remainder is character data.
547fn decode_osta_cs0(bytes: &[u8]) -> String {
548    if bytes.is_empty() {
549        return String::new();
550    }
551    let comp_id = bytes[0];
552    let payload = &bytes[1..];
553    match comp_id {
554        8 => String::from_utf8_lossy(payload).into_owned(),
555        16 => {
556            let pairs: Vec<u16> = payload
557                .chunks_exact(2)
558                .map(|c| u16::from_be_bytes([c[0], c[1]]))
559                .collect();
560            String::from_utf16_lossy(&pairs)
561        }
562        _ => String::from_utf8_lossy(payload).into_owned(),
563    }
564}
565
566/// Seek to `byte_pos` and read exactly `buf.len()` bytes; returns `None` on any error.
567fn seek_read<R: Read + Seek>(reader: &mut R, byte_pos: u64, buf: &mut [u8]) -> Option<()> {
568    reader.seek(SeekFrom::Start(byte_pos)).ok()?;
569    reader.read_exact(buf).ok()?;
570    Some(())
571}
572
573#[cfg(test)]
574mod real_media_tests {
575    //! Validate partition-map classification against real mkudffs-authored
576    //! pure-UDF images (skip-if-missing; generated by corpus/gen_udf_type2.sh).
577    use super::{parse_udf_state, UdfPartitionKind};
578    use std::fs::File;
579
580    fn state(name: &str) -> Option<super::UdfState> {
581        let path = format!("{}/tests/data/{}", env!("CARGO_MANIFEST_DIR"), name);
582        let mut f = File::open(&path).ok()?;
583        parse_udf_state(&mut f)
584    }
585
586    #[test]
587    fn vat_image_classified_virtual() {
588        let Some(st) = state("udf_vat.img") else {
589            eprintln!("skip: udf_vat.img");
590            return;
591        };
592        assert_eq!(
593            st.partition_kind,
594            UdfPartitionKind::Virtual,
595            "mkudffs cdr/1.50 image must classify as Virtual (VAT)"
596        );
597    }
598
599    #[test]
600    fn sparable_image_classified_sparable() {
601        let Some(st) = state("udf_spar.img") else {
602            eprintln!("skip: udf_spar.img");
603            return;
604        };
605        assert_eq!(
606            st.partition_kind,
607            UdfPartitionKind::Sparable,
608            "mkudffs dvdrw/2.01 image must classify as Sparable"
609        );
610    }
611}