Skip to main content

iso9660_forensic/
lib.rs

1//! Pure-Rust forensic ISO 9660 reader.
2//!
3//! Handles multi-session discs, Rock Ridge (RRIP), Joliet (UCS-2 filenames),
4//! El Torito boot images, and 2352-byte raw CD sectors.
5
6mod analysis;
7pub mod audit;
8pub mod bw5;
9pub mod ccd;
10pub mod cdi;
11pub mod cdtext;
12pub mod cdtoc;
13pub mod cue;
14pub mod dir;
15pub mod el_torito;
16pub mod error;
17pub mod file_reader;
18pub mod findings;
19pub mod mds;
20pub mod nrg;
21pub mod offset;
22mod opener;
23pub mod path_table;
24pub mod pvd;
25pub mod rock_ridge;
26pub mod sector;
27pub mod session;
28pub mod subq;
29pub mod toc;
30
31pub use analysis::{
32    analyse, analyse_with_options, AnalyseOptions, BootRecord, IsoAnalysis, IsoVolumeInfo,
33};
34pub use error::IsoError;
35pub use opener::{open, ReadSeek};
36
37/// Maximum bytes that `read_dir` will allocate for a single directory.
38///
39/// Prevents DoS via crafted `root_dir_size` or directory entry size fields.
40pub const MAX_DIR_SIZE: u32 = 64 * 1024 * 1024; // 64 MB
41
42/// Maximum directory nesting depth for [`IsoReader::walk`].
43///
44/// Prevents stack overflow on cyclic or deeply nested directory structures.
45pub const MAX_WALK_DEPTH: usize = 256;
46pub use file_reader::IsoFileReader;
47pub use pvd::IsoDateTime;
48pub use sector::SectorMode;
49
50/// A single entry produced by [`IsoReader::walk`].
51#[derive(Debug, Clone)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53pub struct WalkEntry {
54    /// Full path from the root, using `/` as separator (e.g. `"DIR/CHILD.TXT"`).
55    pub path: String,
56    /// Depth from the root (root entries = 0, one directory deep = 1, …).
57    pub depth: usize,
58    /// The parsed directory record for this entry.
59    pub record: DirRecord,
60}
61
62pub use audit::{BothEndianMismatch, GapHit, PreSysHit, SlackHit, SymlinkIssue};
63
64/// Mastering-tool identification based on PVD metadata patterns.
65#[derive(Debug, Clone)]
66pub struct ToolFingerprint {
67    /// Tool name, e.g. `"xorriso"`, `"mkisofs"`, `"unknown"`.
68    pub tool: String,
69    /// Version string extracted from the data-preparer or application field.
70    pub version: Option<String>,
71    /// Confidence level: `"HIGH"`, `"MEDIUM"`, or `"LOW"`.
72    pub confidence: &'static str,
73    /// Human-readable evidence strings.
74    pub evidence: Vec<String>,
75}
76
77/// Result of comparing the L-path table against the directory tree.
78#[derive(Debug, Clone)]
79pub struct PathTableAudit {
80    pub path_table_lbas: Vec<u32>,
81    pub tree_lbas: Vec<u32>,
82    /// Directories in the path table but not reachable from the tree.
83    pub phantom_lbas: Vec<u32>,
84    /// Directories reachable from the tree but absent from the path table.
85    pub ghost_lbas: Vec<u32>,
86}
87
88/// A file found inside an orphaned directory extent — present on the disc but
89/// not reachable from the active directory tree (a recovered "lost" file).
90#[derive(Debug, Clone)]
91pub struct LostFile {
92    /// ISO 9660 name of the file.
93    pub name: String,
94    /// LBA of the file's data extent.
95    pub lba: u32,
96    /// File size in bytes.
97    pub size: u32,
98    /// LBA of the orphaned directory extent the file was found in.
99    pub parent_lba: u32,
100}
101
102/// A directory entry with its modification timestamp for timeline analysis.
103#[derive(Debug, Clone)]
104pub struct TimelineEntry {
105    /// Full path in the ISO.
106    pub path: String,
107    pub is_dir: bool,
108    pub size: u32,
109    /// Short (7-byte) Rock Ridge modify timestamp, if present.
110    pub modify_ts: Option<[u8; 7]>,
111    /// Detected anomaly, e.g. `"epoch-date"`.
112    pub anomaly: Option<String>,
113}
114
115/// SHA-256 hash of a file in the ISO.
116#[derive(Debug, Clone)]
117pub struct FileHash {
118    pub path: String,
119    pub size: u32,
120    /// Lowercase hexadecimal SHA-256, 64 characters.
121    pub sha256_hex: String,
122}
123
124pub use dir::{DirRecord, FILE_FLAG_MULTI_EXTENT};
125
126use std::io::{Read, Seek, SeekFrom};
127
128use dir::parse_dir_records;
129use el_torito::{boot_catalog_lba, parse_boot_catalog, BootEntry};
130use pvd::{
131    PrimaryVolumeDescriptor, SupplementaryVolumeDescriptor, BOOT_RECORD_TYPE, PVD_TYPE, SVD_TYPE,
132    TERMINATOR_TYPE,
133};
134use rock_ridge::{continuation, has_sp_entry, sp_skip as extract_sp_skip};
135use sector::read_sector_data;
136
137/// Forensic ISO 9660 reader.
138///
139/// Wraps any `Read + Seek` source and exposes multi-session, Rock Ridge,
140/// Joliet, and El Torito metadata alongside raw file data.
141pub struct IsoReader<R> {
142    inner: R,
143    mode: SectorMode,
144    pvd: PrimaryVolumeDescriptor,
145    svd: Option<SupplementaryVolumeDescriptor>,
146    boot_catalog_lba: Option<u32>,
147    /// All LBAs at which a PVD was detected (ascending). Last = active session.
148    pub session_pvd_lbas: Vec<u64>,
149    pub has_rock_ridge: bool,
150    /// `true` when a UDF NSR02/NSR03 descriptor is present in the Volume
151    /// Recognition Sequence (an ISO 9660 / UDF bridge disc).
152    has_udf: bool,
153    /// SUSP SP LEN_SKP: bytes to skip at start of each System Use field (IEEE P1282 §5.3).
154    sp_skip: usize,
155}
156
157impl<R: Read + Seek> IsoReader<R> {
158    /// Open an ISO image, detecting sector mode and parsing the active session.
159    pub fn open(mut reader: R) -> Result<Self, IsoError> {
160        let mode = SectorMode::detect(&mut reader)?;
161
162        // Scan for all sessions (PVD LBAs). An image with no ISO 9660 PVD is not
163        // one this reader can interpret — other filesystems are out of scope.
164        let session_pvd_lbas = scan_sessions(&mut reader, mode)?;
165        let Some(&active_pvd_lba) = session_pvd_lbas.last() else {
166            return Err(IsoError::NotAnIso);
167        };
168
169        let (pvd, svd, boot_cat_lba, has_rock_ridge, sp_skip) =
170            read_volume_descriptors(&mut reader, mode, active_pvd_lba)?;
171
172        let has_udf = detect_udf(&mut reader, mode)?;
173
174        Ok(Self {
175            inner: reader,
176            mode,
177            pvd,
178            svd,
179            boot_catalog_lba: boot_cat_lba,
180            session_pvd_lbas,
181            has_rock_ridge,
182            has_udf,
183            sp_skip,
184        })
185    }
186
187    /// Read the raw 2048-byte user-data payload of a single logical sector.
188    ///
189    /// Handles both ISO (2048-byte) and raw CD-ROM (2352-byte) images
190    /// transparently.  Returns an error if `lba` is beyond the image.
191    pub fn read_sector_raw(&mut self, lba: u64) -> Result<[u8; 2048], IsoError> {
192        let mut buf = [0u8; 2048];
193        read_sector_data(&mut self.inner, self.mode, lba, &mut buf)?;
194        Ok(buf)
195    }
196
197    /// Sector mode of the image (2048-byte ISO or 2352-byte raw CD-ROM).
198    pub fn sector_mode(&self) -> SectorMode {
199        self.mode
200    }
201
202    /// Read and decode the 12-byte Q subchannel for a logical sector.
203    ///
204    /// Returns `Ok(None)` unless the image is a 2448-byte (subchannel-bearing)
205    /// raw format; otherwise extracts the interleaved Q channel from the 96
206    /// subcode bytes at offset 2352 of the physical sector (see
207    /// [`subq::extract_q`]).
208    pub fn read_subchannel_q(&mut self, lba: u64) -> Result<Option<[u8; 12]>, IsoError> {
209        match self.mode {
210            SectorMode::Raw2448 | SectorMode::Raw2448Mode2 => {}
211            _ => return Ok(None),
212        }
213        let pos = lba * self.mode.physical_sector_size() + 2352;
214        self.inner.seek(SeekFrom::Start(pos))?;
215        let mut sub = [0u8; 96];
216        self.inner.read_exact(&mut sub)?;
217        Ok(subq::extract_q(&sub))
218    }
219
220    /// Scan every sector's Q subchannel and summarise disc-level identifiers.
221    ///
222    /// Reads each physical sector in order, extracts the interleaved Q frame,
223    /// keeps only CRC-valid frames (blank/garbage subchannel is discarded), and
224    /// folds them into a [`subq::QSummary`] (disc catalogue + per-track ISRCs).
225    /// Returns an empty summary for images without a 2448-byte subchannel.
226    pub fn scan_subchannel_q(&mut self) -> Result<subq::QSummary, IsoError> {
227        match self.mode {
228            SectorMode::Raw2448 | SectorMode::Raw2448Mode2 => {}
229            _ => return Ok(subq::QSummary::default()),
230        }
231        let phys = self.mode.physical_sector_size();
232        let mut frames = Vec::new();
233        let mut lba = 0u64;
234        let mut sub = [0u8; 96];
235        loop {
236            self.inner.seek(SeekFrom::Start(lba * phys + 2352))?;
237            match self.inner.read_exact(&mut sub) {
238                Ok(()) => {}
239                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
240                Err(e) => return Err(e.into()),
241            }
242            if let Some(raw) = subq::extract_q(&sub) {
243                if subq::q_crc_valid(&raw) {
244                    if let Some(frame) = subq::decode_q(&raw) {
245                        frames.push(frame);
246                    }
247                }
248            }
249            lba += 1;
250        }
251        Ok(subq::summarize_q(frames))
252    }
253
254    /// Volume label from the Primary Volume Descriptor (trimmed).
255    pub fn volume_label(&self) -> &str {
256        &self.pvd.volume_label
257    }
258
259    // ── PVD metadata getters (ECMA-119 §8.4) ─────────────────────────────────
260
261    pub fn system_id(&self) -> &str {
262        &self.pvd.system_id
263    }
264    pub fn volume_set_id(&self) -> &str {
265        &self.pvd.volume_set_id
266    }
267    pub fn publisher_id(&self) -> &str {
268        &self.pvd.publisher_id
269    }
270    pub fn data_preparer_id(&self) -> &str {
271        &self.pvd.data_preparer_id
272    }
273    pub fn application_id(&self) -> &str {
274        &self.pvd.application_id
275    }
276    pub fn copyright_file_id(&self) -> &str {
277        &self.pvd.copyright_file_id
278    }
279    pub fn abstract_file_id(&self) -> &str {
280        &self.pvd.abstract_file_id
281    }
282    pub fn bibliographic_file_id(&self) -> &str {
283        &self.pvd.bibliographic_file_id
284    }
285    pub fn volume_creation_time(&self) -> Option<&IsoDateTime> {
286        self.pvd.volume_creation_time.as_ref()
287    }
288    pub fn volume_modification_time(&self) -> Option<&IsoDateTime> {
289        self.pvd.volume_modification_time.as_ref()
290    }
291    pub fn volume_expiration_time(&self) -> Option<&IsoDateTime> {
292        self.pvd.volume_expiration_time.as_ref()
293    }
294    pub fn volume_effective_time(&self) -> Option<&IsoDateTime> {
295        self.pvd.volume_effective_time.as_ref()
296    }
297    pub fn volume_space_size(&self) -> u32 {
298        self.pvd.volume_space_size
299    }
300    pub fn logical_block_size(&self) -> u16 {
301        self.pvd.logical_block_size
302    }
303    pub fn path_table_size(&self) -> u32 {
304        self.pvd.path_table_size
305    }
306    pub fn l_path_table_lba(&self) -> u32 {
307        self.pvd.l_path_table_lba
308    }
309    pub fn m_path_table_lba(&self) -> u32 {
310        self.pvd.m_path_table_lba
311    }
312
313    /// Joliet volume label from the Supplementary VD, if present.
314    pub fn joliet_label(&self) -> Option<&str> {
315        self.svd.as_ref().filter(|s| s.is_joliet).map(|s| s.volume_label.as_str())
316    }
317
318    /// Number of sessions detected (≥ 1 for a valid ISO).
319    pub fn session_count(&self) -> usize {
320        self.session_pvd_lbas.len()
321    }
322
323    /// True if Rock Ridge RRIP extensions are present.
324    pub fn has_rock_ridge(&self) -> bool {
325        self.has_rock_ridge
326    }
327
328    /// `true` when a UDF volume structure (NSR02/NSR03) is present in the Volume
329    /// Recognition Sequence — an ISO 9660 / UDF bridge disc.
330    #[must_use]
331    pub fn has_udf(&self) -> bool {
332        self.has_udf
333    }
334
335    /// True if a Joliet Supplementary Volume Descriptor is present.
336    pub fn has_joliet(&self) -> bool {
337        self.svd.as_ref().is_some_and(|s| s.is_joliet)
338    }
339
340    /// True if an Enhanced Volume Descriptor is present (ISO 9660:1999, the
341    /// "Level 4" volume): a type-2 descriptor with version byte 2 and no Joliet
342    /// escape, distinct from a Joliet SVD.
343    pub fn has_enhanced_volume_descriptor(&self) -> bool {
344        self.svd.as_ref().is_some_and(SupplementaryVolumeDescriptor::is_enhanced)
345    }
346
347    /// Read the root directory of the active (last) session.
348    pub fn read_root_dir(&mut self) -> Result<Vec<DirRecord>, IsoError> {
349        self.read_dir(self.pvd.root_dir_lba, self.pvd.root_dir_size)
350    }
351
352    /// Read the root directory of an arbitrary session by index (0 = oldest).
353    ///
354    /// Returns an error if `idx >= session_count()`.
355    pub fn read_session_root_dir(&mut self, idx: usize) -> Result<Vec<DirRecord>, IsoError> {
356        let pvd_lba = *self.session_pvd_lbas.get(idx).ok_or_else(|| {
357            IsoError::NotFound(format!(
358                "session index {idx} out of range ({})",
359                self.session_pvd_lbas.len()
360            ))
361        })?;
362        let (pvd, _svd, _boot, _rr, _skip) =
363            read_volume_descriptors(&mut self.inner, self.mode, pvd_lba)?;
364        self.read_dir(pvd.root_dir_lba, pvd.root_dir_size)
365    }
366
367    /// Read a directory given its LBA and size in bytes.
368    pub fn read_dir(&mut self, lba: u32, size: u32) -> Result<Vec<DirRecord>, IsoError> {
369        if size > MAX_DIR_SIZE {
370            return Err(IsoError::ResourceLimit(format!(
371                "directory size {size} bytes exceeds limit {MAX_DIR_SIZE}"
372            )));
373        }
374        let mut data = vec![0u8; size as usize];
375        let sector_size = 2048;
376        let sectors = (size as usize).div_ceil(sector_size);
377        for i in 0..sectors {
378            let offset = i * sector_size;
379            let end = (offset + sector_size).min(size as usize);
380            let mut sector_buf = [0u8; 2048];
381            read_sector_data(&mut self.inner, self.mode, lba as u64 + i as u64, &mut sector_buf)?;
382            data[offset..end].copy_from_slice(&sector_buf[..end - offset]);
383        }
384        let mut records = parse_dir_records(&data)?;
385
386        // Apply SUSP SP skip (IEEE P1282 §5.3): trim pre-SUSP padding bytes from
387        // the beginning of each directory record's System Use field.  Without this,
388        // all SUSP scanners break at `len=0 < 3` when the padding is zero-filled.
389        if self.sp_skip > 0 {
390            for rec in &mut records {
391                let skip = self.sp_skip.min(rec.system_use.len());
392                rec.system_use.drain(..skip);
393            }
394        }
395
396        // Follow Rock Ridge CE (Continuation Area) pointers.
397        for rec in &mut records {
398            if let Some(ce) = continuation(&rec.system_use) {
399                let start = ce.offset as usize;
400                let end = start + ce.len as usize;
401                if end <= 2048 {
402                    let mut ce_buf = [0u8; 2048];
403                    read_sector_data(&mut self.inner, self.mode, ce.lba as u64, &mut ce_buf)?;
404                    rec.system_use.extend_from_slice(&ce_buf[start..end]);
405                }
406            }
407        }
408
409        // Merge multi-extent chains (ECMA-119 §9.1.6).
410        // Consecutive same-name records with FILE_FLAG_MULTI_EXTENT form a chain;
411        // merge them into the first record's extra_extents and clear the flag.
412        let mut merged: Vec<DirRecord> = Vec::with_capacity(records.len());
413        let mut iter = records.into_iter().peekable();
414        while let Some(mut rec) = iter.next() {
415            if rec.flags & FILE_FLAG_MULTI_EXTENT != 0 {
416                while let Some(next) = iter.peek() {
417                    if next.name_bytes != rec.name_bytes {
418                        break;
419                    }
420                    let next = iter.next().unwrap();
421                    rec.extra_extents.push((next.lba, next.size));
422                    rec.flags &= !FILE_FLAG_MULTI_EXTENT;
423                    if next.flags & FILE_FLAG_MULTI_EXTENT == 0 {
424                        break;
425                    }
426                }
427            }
428            merged.push(rec);
429        }
430
431        Ok(merged)
432    }
433
434    /// Open a streaming reader for a file entry without loading it into memory.
435    ///
436    /// The returned [`IsoFileReader`] implements [`std::io::Read`] and reads
437    /// one sector at a time.  For multi-extent files, it chains all extents.
438    pub fn open_file(&self, entry: &DirRecord) -> Result<IsoFileReader<R>, IsoError>
439    where
440        R: Clone,
441    {
442        if entry.is_dir() {
443            return Err(IsoError::NotFound("entry is a directory".into()));
444        }
445        Ok(IsoFileReader::new(
446            self.inner.clone(),
447            self.mode,
448            entry.lba,
449            entry.size,
450            entry.extra_extents.clone(),
451        ))
452    }
453
454    /// Read the full contents of a file entry.
455    ///
456    /// For multi-extent files, concatenates all extents in directory order.
457    pub fn read_file_entry(&mut self, entry: &DirRecord) -> Result<Vec<u8>, IsoError> {
458        if entry.is_dir() {
459            return Err(IsoError::NotFound("entry is a directory".into()));
460        }
461        let mut data = Vec::new();
462        self.append_extent(entry.lba, entry.size, &mut data)?;
463        for &(lba, size) in &entry.extra_extents {
464            self.append_extent(lba, size, &mut data)?;
465        }
466        Ok(data)
467    }
468
469    fn append_extent(&mut self, lba: u32, size: u32, out: &mut Vec<u8>) -> Result<(), IsoError> {
470        let sector_size = 2048usize;
471        let sectors = (size as usize).div_ceil(sector_size);
472        for i in 0..sectors {
473            let offset = i * sector_size;
474            let end = (offset + sector_size).min(size as usize);
475            let mut sector_buf = [0u8; 2048];
476            read_sector_data(&mut self.inner, self.mode, lba as u64 + i as u64, &mut sector_buf)?;
477            out.extend_from_slice(&sector_buf[..end - offset]);
478        }
479        Ok(())
480    }
481
482    /// Recursively walk the entire directory tree, returning every file and
483    /// directory in DFS pre-order.
484    ///
485    /// Each [`WalkEntry`] contains the full path (root-relative, `/`-separated),
486    /// the depth (0 = root level), and the `DirRecord`.
487    pub fn walk(&mut self) -> Result<Vec<WalkEntry>, IsoError> {
488        let root_lba = self.pvd.root_dir_lba;
489        let root_size = self.pvd.root_dir_size;
490        let mut out = Vec::new();
491        let mut visited = std::collections::HashSet::new();
492        self.walk_dir(root_lba, root_size, String::new(), 0, &mut out, &mut visited)?;
493        Ok(out)
494    }
495
496    /// Walk the Joliet (supplementary) directory tree, depth-first.
497    ///
498    /// Returns an empty vec when the image has no Joliet SVD. Paths are built
499    /// from the raw ISO identifiers (not UCS-2-decoded); this is intended for
500    /// structural comparison against the primary tree (shared data extents), so
501    /// the `record.lba` values are what matter.
502    pub fn walk_joliet(&mut self) -> Result<Vec<WalkEntry>, IsoError> {
503        let Some((lba, size)) =
504            self.svd.as_ref().filter(|s| s.is_joliet).map(|s| (s.root_dir_lba, s.root_dir_size))
505        else {
506            return Ok(Vec::new());
507        };
508        let mut out = Vec::new();
509        let mut visited = std::collections::HashSet::new();
510        self.walk_dir(lba, size, String::new(), 0, &mut out, &mut visited)?;
511        Ok(out)
512    }
513
514    /// Walk the directory tree of an arbitrary session (0 = oldest), depth-first.
515    ///
516    /// Reads that session's own PVD for its root directory, then walks it like
517    /// [`walk`](Self::walk). `walk_session(session_count() - 1)` is the active
518    /// session and equals [`walk`](Self::walk). Used to compare an earlier
519    /// session's files against the active tree (superseded / recoverable
520    /// content).
521    ///
522    /// # Errors
523    /// Returns [`IsoError::NotFound`] if `idx >= session_count()`.
524    pub fn walk_session(&mut self, idx: usize) -> Result<Vec<WalkEntry>, IsoError> {
525        let pvd_lba = *self.session_pvd_lbas.get(idx).ok_or_else(|| {
526            IsoError::NotFound(format!(
527                "session index {idx} out of range ({})",
528                self.session_pvd_lbas.len()
529            ))
530        })?;
531        let (pvd, _svd, _boot, _rr, _skip) =
532            read_volume_descriptors(&mut self.inner, self.mode, pvd_lba)?;
533        let mut out = Vec::new();
534        let mut visited = std::collections::HashSet::new();
535        self.walk_dir(
536            pvd.root_dir_lba,
537            pvd.root_dir_size,
538            String::new(),
539            0,
540            &mut out,
541            &mut visited,
542        )?;
543        Ok(out)
544    }
545
546    fn walk_dir(
547        &mut self,
548        lba: u32,
549        size: u32,
550        prefix: String,
551        depth: usize,
552        out: &mut Vec<WalkEntry>,
553        visited: &mut std::collections::HashSet<u32>,
554    ) -> Result<(), IsoError> {
555        // A directory extent already on this traversal is a cycle (or a shared
556        // extent) — list its entries once but do not re-descend, so a crafted or
557        // corrupt loop terminates instead of exhausting the depth limit.
558        if !visited.insert(lba) {
559            return Ok(());
560        }
561        if depth > MAX_WALK_DEPTH {
562            return Err(IsoError::ResourceLimit(format!(
563                "directory nesting depth {depth} exceeds limit {MAX_WALK_DEPTH}"
564            )));
565        }
566        for rec in self.read_dir(lba, size)? {
567            let name = if let Some(rr) = rock_ridge::alternate_name(&rec.system_use) {
568                rr
569            } else {
570                rec.iso_name()
571            };
572            let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };
573            if rec.is_dir() {
574                let child_lba = rec.lba;
575                let child_size = rec.size;
576                out.push(WalkEntry { path: path.clone(), depth, record: rec });
577                // A subdirectory whose extent lies past the image (truncation /
578                // corruption / dangling reference) is left as a listed entry but
579                // not descended into; the unreadable extent is surfaced
580                // separately (analyse()'s ISO-OOB-EXTENT). Real I/O errors still
581                // propagate.
582                match self.walk_dir(child_lba, child_size, path, depth + 1, out, visited) {
583                    Ok(()) => {}
584                    Err(IsoError::Io(io)) if io.kind() == std::io::ErrorKind::UnexpectedEof => {}
585                    Err(e) => return Err(e),
586                }
587            } else {
588                out.push(WalkEntry { path, depth, record: rec });
589            }
590        }
591        Ok(())
592    }
593
594    /// Find a file or directory by path (e.g. `"docs/readme.txt"`).
595    ///
596    /// Rejects path components that escape the root (`..`).
597    pub fn find_entry(&mut self, path: &str) -> Result<DirRecord, IsoError> {
598        let parts: Vec<&str> =
599            path.trim_matches('/').split('/').filter(|p| !p.is_empty()).collect();
600
601        let mut lba = self.pvd.root_dir_lba;
602        let mut size = self.pvd.root_dir_size;
603
604        for (depth, part) in parts.iter().enumerate() {
605            if *part == ".." {
606                return Err(IsoError::PathTraversal);
607            }
608            let entries = self.read_dir(lba, size)?;
609            let is_last = depth == parts.len() - 1;
610            let needle = part.to_ascii_uppercase();
611            let found = entries
612                .into_iter()
613                .find(|e| {
614                    let iso = e.iso_name().to_ascii_uppercase();
615                    let rr =
616                        rock_ridge::alternate_name(&e.system_use).map(|n| n.to_ascii_uppercase());
617                    iso == needle || rr.as_deref() == Some(needle.as_str())
618                })
619                .ok_or_else(|| IsoError::NotFound(part.to_string()))?;
620
621            if is_last {
622                return Ok(found);
623            }
624            if !found.is_dir() {
625                return Err(IsoError::NotFound(format!("{part} is not a directory")));
626            }
627            lba = found.lba;
628            size = found.size;
629        }
630        Err(IsoError::NotFound(path.into()))
631    }
632
633    /// Find a file or directory by path, returning `None` if not found.
634    ///
635    /// Like [`find_entry`] but returns `Ok(None)` instead of `Err(NotFound)`.
636    /// Leading `/` is ignored; components are matched case-insensitively against
637    /// both the ISO 9660 name and any Rock Ridge NM alternate name.
638    pub fn find_path(&mut self, path: &str) -> Result<Option<DirRecord>, IsoError> {
639        match self.find_entry(path) {
640            Ok(entry) => Ok(Some(entry)),
641            Err(IsoError::NotFound(_)) => Ok(None),
642            Err(e) => Err(e),
643        }
644    }
645
646    /// Parse El Torito boot catalog entries, if an El Torito BRVD is present.
647    pub fn boot_entries(&mut self) -> Result<Vec<BootEntry>, IsoError> {
648        let cat_lba = match self.boot_catalog_lba {
649            Some(l) => l,
650            None => return Ok(Vec::new()),
651        };
652        let mut buf = [0u8; 2048];
653        read_sector_data(&mut self.inner, self.mode, cat_lba as u64, &mut buf)?;
654        Ok(parse_boot_catalog(&buf))
655    }
656
657    // ── Forensic audit methods ────────────────────────────────────────────────
658
659    /// Identify the mastering tool from PVD metadata patterns.
660    ///
661    /// Inspects `data_preparer_id` and `application_id` for known tool
662    /// signatures (xorriso, mkisofs, genisoimage, ImgBurn, hdiutil, etc.).
663    pub fn fingerprint_tool(&self) -> ToolFingerprint {
664        const SIGS: &[(&str, &str, &str)] = &[
665            ("XORRISO", "xorriso", "HIGH"),
666            ("xorriso", "xorriso", "HIGH"),
667            ("MKISOFS", "mkisofs", "HIGH"),
668            ("mkisofs", "mkisofs", "HIGH"),
669            ("GENISOIMAGE", "genisoimage", "HIGH"),
670            ("genisoimage", "genisoimage", "HIGH"),
671            ("IMGBURN", "ImgBurn", "HIGH"),
672            ("ImgBurn", "ImgBurn", "HIGH"),
673            ("HDIUTIL", "hdiutil (macOS)", "HIGH"),
674            ("hdiutil", "hdiutil (macOS)", "HIGH"),
675            ("ISOMASTER", "IsoMaster", "HIGH"),
676            ("NERO", "Nero", "MEDIUM"),
677        ];
678        let haystack = format!("{} {}", self.data_preparer_id(), self.application_id());
679        for (needle, name, conf) in SIGS {
680            if let Some(pos) = haystack.find(needle) {
681                // Extract the version that follows the tool name: scan forward from
682                // the end of the matched needle for the first run of [0-9.] that
683                // contains a dot (e.g. "XORRISO-1.5.8" -> "1.5.8").  This avoids
684                // picking up a trailing build date like "2026.05.22".
685                let after = &haystack[pos + needle.len()..];
686                let version = extract_version(after).or_else(|| extract_version(&haystack));
687                let conf: &'static str = match *conf {
688                    "HIGH" => "HIGH",
689                    "MEDIUM" => "MEDIUM",
690                    _ => "LOW",
691                };
692                return ToolFingerprint {
693                    tool: (*name).to_owned(),
694                    version,
695                    confidence: conf,
696                    evidence: vec![format!("PVD field contains '{needle}'")],
697                };
698            }
699        }
700        ToolFingerprint {
701            tool: "unknown".to_owned(),
702            version: None,
703            confidence: "LOW",
704            evidence: Vec::new(),
705        }
706    }
707
708    /// Read a path table's raw bytes from `lba`, spanning as many sectors as
709    /// `path_table_size` requires, truncated to that size.
710    fn read_path_table_bytes(&mut self, lba: u32) -> Result<Vec<u8>, IsoError> {
711        let size = self.pvd.path_table_size as usize;
712        let sectors = size.div_ceil(2048).max(1);
713        let mut data = Vec::with_capacity(sectors * 2048);
714        for i in 0..sectors {
715            let raw = self.read_sector_raw(u64::from(lba) + i as u64)?;
716            data.extend_from_slice(&raw);
717        }
718        data.truncate(size.min(data.len()));
719        Ok(data)
720    }
721
722    /// Compare the L-path table against the directory tree.
723    ///
724    /// Returns LBAs that appear only in the path table (`phantom`) or only
725    /// in the tree (`ghost`).  Either indicates inconsistency or tampering.
726    pub fn audit_path_table(&mut self) -> Result<PathTableAudit, IsoError> {
727        use path_table::parse_l_path_table;
728        use std::collections::HashSet;
729
730        // Read the L-path table (may span several sectors for large images).
731        let pt_data = self.read_path_table_bytes(self.pvd.l_path_table_lba)?;
732        let pt_entries = parse_l_path_table(&pt_data).unwrap_or_default();
733        let path_table_lbas: Vec<u32> = pt_entries.iter().map(|e| e.lba).collect();
734        let pt_set: HashSet<u32> = path_table_lbas.iter().copied().collect();
735
736        // Collect directory LBAs from the tree (always include the root).
737        let tree_entries = self.walk()?;
738        let mut tree_set: HashSet<u32> =
739            tree_entries.iter().filter(|e| e.record.is_dir()).map(|e| e.record.lba).collect();
740        tree_set.insert(self.pvd.root_dir_lba);
741
742        let mut tree_lbas: Vec<u32> = tree_set.iter().copied().collect();
743        tree_lbas.sort_unstable();
744
745        let mut phantom_lbas: Vec<u32> = pt_set.difference(&tree_set).copied().collect();
746        let mut ghost_lbas: Vec<u32> = tree_set.difference(&pt_set).copied().collect();
747        phantom_lbas.sort_unstable();
748        ghost_lbas.sort_unstable();
749
750        Ok(PathTableAudit { path_table_lbas, tree_lbas, phantom_lbas, ghost_lbas })
751    }
752
753    /// Cross-validate the Type-L (little-endian) and Type-M (big-endian) path
754    /// tables.
755    ///
756    /// ECMA-119 stores the path table twice in opposite byte orders; the two
757    /// copies must describe an identical directory hierarchy. Returns any
758    /// content discrepancy (entry count, extent LBA, parent, or name) between
759    /// them — a disagreement is consistent with editing one copy (an OS-specific
760    /// view, since tools differ on which table they trust) or corruption.
761    ///
762    /// Returns empty when either table pointer is zero (the table is absent);
763    /// a missing mandatory path table is a separate structural concern, not an
764    /// L↔M content divergence.
765    pub fn audit_path_table_endian(
766        &mut self,
767    ) -> Result<Vec<path_table::PathTableMismatch>, IsoError> {
768        use path_table::{parse_l_path_table, parse_m_path_table, validate_path_tables};
769
770        let l_lba = self.pvd.l_path_table_lba;
771        let m_lba = self.pvd.m_path_table_lba;
772        if l_lba == 0 || m_lba == 0 {
773            return Ok(Vec::new());
774        }
775        let l_bytes = self.read_path_table_bytes(l_lba)?;
776        let m_bytes = self.read_path_table_bytes(m_lba)?;
777        let l = parse_l_path_table(&l_bytes).unwrap_or_default();
778        let m = parse_m_path_table(&m_bytes).unwrap_or_default();
779        Ok(validate_path_tables(&l, &m))
780    }
781
782    /// Recover files from orphaned directory extents — directories the path
783    /// table references but the active directory tree cannot reach (e.g.
784    /// unlinked or superseded folders).  IsoBuster's "find missing files and
785    /// folders" for ISO 9660.
786    ///
787    /// Reads each phantom directory extent (sized from its own `.` record) and
788    /// returns the files within it.  Nested phantom subdirectories are reported
789    /// by the path-table audit in their own right.
790    pub fn recover_lost_files(&mut self) -> Result<Vec<LostFile>, IsoError> {
791        let phantom = self.audit_path_table()?.phantom_lbas;
792        let mut lost = Vec::new();
793        for dir_lba in phantom {
794            // The directory's own `.` record carries its extent size.
795            let probe = self.read_dir(dir_lba, 2048)?;
796            let dir_size = probe.first().map_or(2048, |r| r.size.max(2048));
797            let records = if dir_size > 2048 { self.read_dir(dir_lba, dir_size)? } else { probe };
798            for r in records {
799                if !r.is_dir() {
800                    lost.push(LostFile {
801                        name: r.iso_name(),
802                        lba: r.lba,
803                        size: r.size,
804                        parent_lba: dir_lba,
805                    });
806                }
807            }
808        }
809        Ok(lost)
810    }
811
812    pub fn audit_both_endian(&mut self) -> Result<Vec<audit::BothEndianMismatch>, IsoError> {
813        use audit::BothEndianMismatch;
814        let mut out: Vec<BothEndianMismatch> = Vec::new();
815
816        // ── PVD (sector 16) ──
817        let pvd_raw = self.read_sector_raw(16)?;
818        let pvd_off = self.mode.user_data_pos(16);
819
820        macro_rules! chk32 {
821            ($off:expr, $name:expr) => {{
822                let le = u32::from_le_bytes(pvd_raw[$off..$off + 4].try_into().unwrap()) as u64;
823                let be = u32::from_be_bytes(pvd_raw[$off + 4..$off + 8].try_into().unwrap()) as u64;
824                if le != be {
825                    out.push(BothEndianMismatch {
826                        context: "PVD".into(),
827                        field: $name.into(),
828                        byte_offset: pvd_off + $off as u64,
829                        le_val: le,
830                        be_val: be,
831                    });
832                }
833            }};
834        }
835        macro_rules! chk16 {
836            ($off:expr, $name:expr) => {{
837                let le = u16::from_le_bytes(pvd_raw[$off..$off + 2].try_into().unwrap()) as u64;
838                let be = u16::from_be_bytes(pvd_raw[$off + 2..$off + 4].try_into().unwrap()) as u64;
839                if le != be {
840                    out.push(BothEndianMismatch {
841                        context: "PVD".into(),
842                        field: $name.into(),
843                        byte_offset: pvd_off + $off as u64,
844                        le_val: le,
845                        be_val: be,
846                    });
847                }
848            }};
849        }
850        chk32!(80, "volume_space_size");
851        chk16!(120, "volume_set_size");
852        chk16!(124, "volume_sequence_number");
853        chk16!(128, "logical_block_size");
854        chk32!(132, "path_table_size");
855
856        // ── Directory sectors ──
857        let entries = self.walk()?;
858        let mut seen = std::collections::HashSet::new();
859        // Always include root dir lba
860        seen.insert(self.pvd.root_dir_lba);
861        for e in &entries {
862            if e.record.is_dir() {
863                seen.insert(e.record.lba);
864            }
865        }
866        for dir_lba in seen {
867            // A directory whose extent lies past the image (truncation /
868            // corruption) has no readable records to reconcile; skip it. The
869            // out-of-bounds extent is surfaced separately. Real errors propagate.
870            let raw = match self.read_sector_raw(dir_lba as u64) {
871                Ok(raw) => raw,
872                Err(IsoError::Io(io)) if io.kind() == std::io::ErrorKind::UnexpectedEof => continue,
873                Err(e) => return Err(e),
874            };
875            let sec_off = self.mode.user_data_pos(dir_lba as u64);
876            let ctx = format!("dir:lba={dir_lba}");
877            let mut pos = 0usize;
878            while pos < raw.len() {
879                let rl = raw[pos] as usize;
880                if rl == 0 {
881                    pos += 1;
882                    continue;
883                }
884                if rl < 33 || pos + rl > raw.len() {
885                    break;
886                }
887                // lba
888                let le = u32::from_le_bytes(raw[pos + 2..pos + 6].try_into().unwrap()) as u64;
889                let be = u32::from_be_bytes(raw[pos + 6..pos + 10].try_into().unwrap()) as u64;
890                if le != be {
891                    out.push(BothEndianMismatch {
892                        context: ctx.clone(),
893                        field: "entry_lba".into(),
894                        byte_offset: sec_off + pos as u64 + 2,
895                        le_val: le,
896                        be_val: be,
897                    });
898                }
899                // size
900                let le = u32::from_le_bytes(raw[pos + 10..pos + 14].try_into().unwrap()) as u64;
901                let be = u32::from_be_bytes(raw[pos + 14..pos + 18].try_into().unwrap()) as u64;
902                if le != be {
903                    out.push(BothEndianMismatch {
904                        context: ctx.clone(),
905                        field: "entry_size".into(),
906                        byte_offset: sec_off + pos as u64 + 10,
907                        le_val: le,
908                        be_val: be,
909                    });
910                }
911                pos += rl;
912            }
913        }
914        Ok(out)
915    }
916
917    pub fn audit_pre_system(&mut self) -> Result<Vec<audit::PreSysHit>, IsoError> {
918        const MAGIC: &[(&[u8], &str)] = &[
919            (b"MZ", "MZ/PE"),
920            (&[0x7F, b'E', b'L', b'F'], "ELF"),
921            (&[b'P', b'K', 0x03, 0x04], "ZIP"),
922            (b"%PDF", "PDF"),
923            (&[0x37, 0x7A, 0xBC, 0xAF], "7z"),
924        ];
925        let mut out = Vec::new();
926        for sector in 0u8..16 {
927            let raw = self.read_sector_raw(sector as u64)?;
928            if raw.iter().all(|&b| b == 0) {
929                continue;
930            }
931            let kind = MAGIC
932                .iter()
933                .find(|(sig, _)| raw.starts_with(sig))
934                .map(|(_, k)| *k)
935                .unwrap_or("non-zero");
936            out.push(audit::PreSysHit { sector, kind });
937        }
938        Ok(out)
939    }
940
941    pub fn audit_symlinks(&mut self) -> Result<Vec<audit::SymlinkIssue>, IsoError> {
942        let entries = self.walk()?;
943        let mut out = Vec::new();
944        for e in entries {
945            if e.record.is_dir() {
946                continue;
947            }
948            if let Some(target) = rock_ridge::symlink_target(&e.record.system_use) {
949                let issue = if target.contains("..") {
950                    "path-traversal"
951                } else if target.starts_with('/') {
952                    "absolute"
953                } else {
954                    continue;
955                };
956                out.push(audit::SymlinkIssue { entry_path: e.path, target, issue });
957            }
958        }
959        Ok(out)
960    }
961
962    pub fn audit_file_slack(&mut self) -> Result<Vec<audit::SlackHit>, IsoError> {
963        let entries = self.walk()?;
964        let mut out = Vec::new();
965        for e in entries {
966            if e.record.is_dir() {
967                continue;
968            }
969            let size = e.record.size;
970            let remainder = size % 2048;
971            let slack_bytes = if remainder == 0 { 0 } else { 2048 - remainder };
972            if slack_bytes == 0 {
973                out.push(audit::SlackHit {
974                    entry_path: e.path,
975                    lba: e.record.lba,
976                    file_size: size,
977                    slack_bytes: 0,
978                    nonzero: false,
979                });
980                continue;
981            }
982            let sectors = (size as u64).div_ceil(2048);
983            let last_lba = e.record.lba as u64 + sectors - 1;
984            // An extent whose final sector lies past the image (truncation /
985            // corruption / dangling reference) has no readable slack to audit;
986            // skip it. The out-of-bounds extent itself is surfaced separately
987            // (analyse()'s ISO-OOB-EXTENT). Genuine I/O errors still propagate.
988            let raw = match self.read_sector_raw(last_lba) {
989                Ok(raw) => raw,
990                Err(IsoError::Io(io)) if io.kind() == std::io::ErrorKind::UnexpectedEof => continue,
991                Err(e) => return Err(e),
992            };
993            let data_end = remainder as usize;
994            let nonzero = raw[data_end..].iter().any(|&b| b != 0);
995            out.push(audit::SlackHit {
996                entry_path: e.path,
997                lba: e.record.lba,
998                file_size: size,
999                slack_bytes,
1000                nonzero,
1001            });
1002        }
1003        Ok(out)
1004    }
1005
1006    /// Sort all directory entries by Rock Ridge modification timestamp.
1007    ///
1008    /// Entries without a timestamp appear last.  Detects `"epoch-date"`
1009    /// anomalies (year 1970, month 1, day 1).
1010    pub fn timeline(&mut self) -> Result<Vec<TimelineEntry>, IsoError> {
1011        let entries = self.walk()?;
1012        let mut out: Vec<TimelineEntry> = entries
1013            .into_iter()
1014            .filter(|e| !e.record.is_dir())
1015            .map(|e| {
1016                let modify_ts =
1017                    rock_ridge::timestamps(&e.record.system_use).and_then(|ts| ts.modify);
1018                let anomaly = modify_ts.and_then(|ts| {
1019                    if ts[0] == 70
1020                        && ts[1] == 1
1021                        && ts[2] == 1
1022                        && ts[3] == 0
1023                        && ts[4] == 0
1024                        && ts[5] == 0
1025                    {
1026                        Some("epoch-date".to_string())
1027                    } else {
1028                        None
1029                    }
1030                });
1031                TimelineEntry {
1032                    path: e.path,
1033                    is_dir: false,
1034                    size: e.record.size,
1035                    modify_ts,
1036                    anomaly,
1037                }
1038            })
1039            .collect();
1040        // Sort by modify_ts ascending; None (no timestamp) goes last.
1041        out.sort_by_key(|a| a.modify_ts);
1042        Ok(out)
1043    }
1044
1045    pub fn hashlist(&mut self) -> Result<Vec<FileHash>, IsoError> {
1046        use sha2::{Digest, Sha256};
1047        let entries = self.walk()?;
1048        let mut out: Vec<FileHash> = Vec::new();
1049        for e in entries {
1050            if e.record.is_dir() {
1051                continue;
1052            }
1053            let data = self.read_file_entry(&e.record)?;
1054            let hash = Sha256::digest(&data);
1055            let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
1056            out.push(FileHash { path: e.path, size: e.record.size, sha256_hex: hex });
1057        }
1058        out.sort_by(|a, b| a.path.cmp(&b.path));
1059        Ok(out)
1060    }
1061
1062    pub fn audit_sector_gaps(&mut self) -> Result<Vec<audit::GapHit>, IsoError> {
1063        let total = self.volume_space_size();
1064        let entries = self.walk()?;
1065
1066        // Pre-system area (0-15) plus the volume-descriptor chain (16 → the
1067        // terminator, inclusive).  Scanning the chain handles images with extra
1068        // descriptors (Boot Record VD, SVD) that push the terminator past 18.
1069        let mut alloc: std::collections::HashSet<u32> = (0..=15).collect();
1070        for lba in 16u32..512 {
1071            let raw = match self.read_sector_raw(lba as u64) {
1072                Ok(r) => r,
1073                Err(_) => break,
1074            };
1075            if &raw[1..6] != b"CD001" {
1076                break;
1077            }
1078            alloc.insert(lba);
1079            if raw[0] == 0xFF {
1080                break; // VD Terminator
1081            }
1082        }
1083        alloc.insert(self.pvd.root_dir_lba);
1084
1085        // Both path tables (L little-endian and M big-endian) are legitimate
1086        // structures.  Each may span several sectors; mark all of them so the
1087        // standard M-path table is not mistaken for hidden data.
1088        let pt_sectors = (self.pvd.path_table_size as u64).div_ceil(2048).max(1) as u32;
1089        for base in [self.pvd.l_path_table_lba, self.pvd.m_path_table_lba] {
1090            for s in 0..pt_sectors {
1091                alloc.insert(base + s);
1092            }
1093        }
1094
1095        // Helper: mark all sectors spanned by a CE (Continuation Area) pointer.
1096        let mark_ce = |alloc: &mut std::collections::HashSet<u32>, su: &[u8]| {
1097            if let Some(ce) = rock_ridge::continuation(su) {
1098                let end = ce.offset.saturating_add(ce.len);
1099                let ce_sectors = (end as u64).div_ceil(2048).max(1) as u32;
1100                for s in 0..ce_sectors {
1101                    alloc.insert(ce.lba + s);
1102                }
1103            }
1104        };
1105
1106        for e in &entries {
1107            let sectors = (e.record.size as u64).div_ceil(2048) as u32;
1108            for s in 0..sectors.max(1) {
1109                alloc.insert(e.record.lba + s);
1110            }
1111            // Rock Ridge CE sectors referenced from this entry are legitimate.
1112            mark_ce(&mut alloc, &e.record.system_use);
1113        }
1114
1115        // The root directory's "." record carries the Rock Ridge ER (Extensions
1116        // Reference), usually via a CE continuation area.  walk() skips dot
1117        // entries, so read the root dir records directly and mark their CEs.
1118        if let Ok(root_records) = self.read_dir(self.pvd.root_dir_lba, self.pvd.root_dir_size) {
1119            for rec in &root_records {
1120                mark_ce(&mut alloc, &rec.system_use);
1121            }
1122        }
1123        // read_dir already follows and appends the root "." CE, but the dot
1124        // record itself is filtered out; read its raw System Use too.
1125        if let Ok(raw) = self.read_sector_raw(self.pvd.root_dir_lba as u64) {
1126            let len = raw[0] as usize;
1127            if len >= 34 && len <= raw.len() {
1128                let name_len = raw[32] as usize;
1129                let su_start = 33 + name_len + (if name_len % 2 == 0 { 1 } else { 0 });
1130                if su_start < len {
1131                    mark_ce(&mut alloc, &raw[su_start..len]);
1132                }
1133            }
1134        }
1135
1136        // ── Supplementary (Joliet) volume structures ──
1137        // The SVD has its own path tables and a parallel directory tree (the
1138        // file *data* is shared with the PVD tree, but the directory sectors
1139        // and path tables are distinct).  Mark them all as legitimate.
1140        if let Some(svd) = self.svd.as_ref() {
1141            let svd_root_lba = svd.root_dir_lba;
1142            let svd_root_size = svd.root_dir_size;
1143            let svd_pt_sectors = (svd.path_table_size as u64).div_ceil(2048).max(1) as u32;
1144            let svd_l = svd.l_path_table_lba;
1145            let svd_m = svd.m_path_table_lba;
1146            for base in [svd_l, svd_m] {
1147                if base != 0 {
1148                    for s in 0..svd_pt_sectors {
1149                        alloc.insert(base + s);
1150                    }
1151                }
1152            }
1153            // BFS over the Joliet directory tree, marking directory sectors.
1154            let mut worklist = vec![(svd_root_lba, svd_root_size)];
1155            let mut visited = std::collections::HashSet::new();
1156            while let Some((lba, size)) = worklist.pop() {
1157                if !visited.insert(lba) {
1158                    continue;
1159                }
1160                let dir_sectors = (size as u64).div_ceil(2048).max(1) as u32;
1161                for s in 0..dir_sectors {
1162                    alloc.insert(lba + s);
1163                }
1164                if let Ok(children) = self.read_dir(lba, size) {
1165                    for c in children {
1166                        if c.is_dir() {
1167                            worklist.push((c.lba, c.size));
1168                        } else {
1169                            let fs = (c.size as u64).div_ceil(2048).max(1) as u32;
1170                            for s in 0..fs {
1171                                alloc.insert(c.lba + s);
1172                            }
1173                        }
1174                    }
1175                }
1176            }
1177        }
1178
1179        // ── El Torito boot catalog + boot images ──
1180        if let Some(cat) = self.boot_catalog_lba {
1181            alloc.insert(cat);
1182        }
1183        if let Ok(boot) = self.boot_entries() {
1184            for b in &boot {
1185                // sector_count is in 512-byte virtual sectors; convert to
1186                // 2048-byte logical sectors (round up, minimum one).
1187                let bytes = b.sector_count as u64 * 512;
1188                let bs = bytes.div_ceil(2048).max(1) as u32;
1189                for s in 0..bs {
1190                    alloc.insert(b.lba + s);
1191                }
1192            }
1193        }
1194
1195        let cap = total.min(512);
1196        let mut out = Vec::new();
1197        for lba in 0..cap {
1198            if alloc.contains(&lba) {
1199                continue;
1200            }
1201            let raw = self.read_sector_raw(lba as u64)?;
1202            let nonzero = raw.iter().any(|&b| b != 0);
1203            out.push(audit::GapHit { lba, nonzero });
1204        }
1205        Ok(out)
1206    }
1207}
1208
1209// ── Private helpers ──────────────────────────────────────────────────────────
1210
1211/// Extract the first dotted version run (e.g. "1.5.8") from `s`.
1212///
1213/// Returns the longest leading `[0-9.]` run that contains at least one dot,
1214/// after skipping any leading non-version characters up to the first digit.
1215fn extract_version(s: &str) -> Option<String> {
1216    let bytes = s.as_bytes();
1217    let mut i = 0;
1218    while i < bytes.len() {
1219        if bytes[i].is_ascii_digit() {
1220            let start = i;
1221            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
1222                i += 1;
1223            }
1224            let run = &s[start..i];
1225            if run.contains('.') {
1226                return Some(run.trim_end_matches('.').to_owned());
1227            }
1228        } else {
1229            i += 1;
1230        }
1231    }
1232    None
1233}
1234
1235/// Scan for all PVD LBAs by reading every sector starting from 16.
1236fn scan_sessions<R: Read + Seek>(reader: &mut R, mode: SectorMode) -> Result<Vec<u64>, IsoError> {
1237    let mut lbas = Vec::new();
1238    let mut buf = [0u8; 2048];
1239
1240    for lba in 16u64..4096 {
1241        let pos = mode.user_data_pos(lba);
1242        reader.seek(SeekFrom::Start(pos))?;
1243        match reader.read_exact(&mut buf) {
1244            Ok(()) => {}
1245            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
1246            Err(e) => return Err(e.into()),
1247        }
1248        if buf[0] == 0x01 && &buf[1..6] == b"CD001" && buf[6] == 0x01 {
1249            lbas.push(lba);
1250        }
1251        if buf[0] == TERMINATOR_TYPE && &buf[1..6] == b"CD001" {
1252            // Terminator found — but there may be more sessions after a gap.
1253            // Continue scanning until EOF.
1254        }
1255    }
1256    Ok(lbas)
1257}
1258
1259/// Detect a UDF filesystem by scanning the Volume Recognition Sequence (sector
1260/// 16 onward) for an NSR02/NSR03 descriptor. UDF bridge discs carry both the
1261/// ISO 9660 CD001 descriptors and a UDF NSR descriptor in the same area.
1262fn detect_udf<R: Read + Seek>(reader: &mut R, mode: SectorMode) -> Result<bool, IsoError> {
1263    let mut buf = [0u8; 2048];
1264    for lba in 16u64..32 {
1265        let pos = mode.user_data_pos(lba);
1266        reader.seek(SeekFrom::Start(pos))?;
1267        match reader.read_exact(&mut buf) {
1268            Ok(()) => {}
1269            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
1270            Err(e) => return Err(e.into()),
1271        }
1272        if &buf[1..6] == b"NSR02" || &buf[1..6] == b"NSR03" {
1273            return Ok(true);
1274        }
1275    }
1276    Ok(false)
1277}
1278
1279/// The volume-descriptor chain extracted from a session:
1280/// `(pvd, svd, boot_cat_lba, has_rock_ridge, sp_skip)`.
1281type VolumeDescriptors =
1282    (PrimaryVolumeDescriptor, Option<SupplementaryVolumeDescriptor>, Option<u32>, bool, usize);
1283
1284/// Read the VD chain starting at `first_pvd_lba`, extracting PVD, SVD, boot.
1285fn read_volume_descriptors<R: Read + Seek>(
1286    reader: &mut R,
1287    mode: SectorMode,
1288    first_pvd_lba: u64,
1289) -> Result<VolumeDescriptors, IsoError> {
1290    let mut buf = [0u8; 2048];
1291    let mut pvd: Option<PrimaryVolumeDescriptor> = None;
1292    let mut svd: Option<SupplementaryVolumeDescriptor> = None;
1293    let mut boot_cat: Option<u32> = None;
1294    let mut has_rr = false;
1295    let mut sp_skip = 0usize;
1296
1297    let mut lba = first_pvd_lba;
1298    loop {
1299        read_sector_data(reader, mode, lba, &mut buf)?;
1300        match buf[0] {
1301            PVD_TYPE => {
1302                let p = PrimaryVolumeDescriptor::parse(&buf)?;
1303                // Check the root dir's System Use for the Rock Ridge SP entry.
1304                if !has_rr {
1305                    let (rr, skip) = check_rock_ridge(reader, mode, p.root_dir_lba)?;
1306                    has_rr = rr;
1307                    sp_skip = skip;
1308                }
1309                pvd = Some(p);
1310            }
1311            SVD_TYPE => {
1312                if let Ok(s) = SupplementaryVolumeDescriptor::parse(&buf) {
1313                    // Prefer a Joliet SVD (drives UCS-2 listing); otherwise keep
1314                    // an Enhanced VD (ISO 9660:1999) so it can be reported — but
1315                    // never let an EVD displace a Joliet SVD already found.
1316                    let keep = s.is_joliet
1317                        || (s.is_enhanced() && svd.as_ref().is_none_or(|e| !e.is_joliet));
1318                    if keep {
1319                        svd = Some(s);
1320                    }
1321                }
1322            }
1323            BOOT_RECORD_TYPE => {
1324                boot_cat = boot_catalog_lba(&buf);
1325            }
1326            TERMINATOR_TYPE => break,
1327            _ => {}
1328        }
1329        lba += 1;
1330    }
1331
1332    pvd.ok_or_else(|| IsoError::BadDescriptor("no PVD found in VD chain".into()))
1333        .map(|p| (p, svd, boot_cat, has_rr, sp_skip))
1334}
1335
1336/// Check the root directory's first (dot) record for a Rock Ridge SP entry.
1337///
1338/// Returns `(has_rock_ridge, sp_skip)` — the skip is the SUSP LEN_SKP value
1339/// from the SP entry (IEEE P1282 §5.3), or 0 if no SP entry is found.
1340fn check_rock_ridge<R: Read + Seek>(
1341    reader: &mut R,
1342    mode: SectorMode,
1343    root_dir_lba: u32,
1344) -> Result<(bool, usize), IsoError> {
1345    let mut buf = [0u8; 2048];
1346    read_sector_data(reader, mode, root_dir_lba as u64, &mut buf)?;
1347    let offset = 0usize;
1348    if buf[offset] == 0 {
1349        return Ok((false, 0));
1350    }
1351    let len = buf[offset] as usize;
1352    if len < 34 {
1353        return Ok((false, 0));
1354    }
1355    let name_len = buf[offset + 32] as usize;
1356    let su_start = 33 + name_len + (if name_len % 2 == 0 { 1 } else { 0 });
1357    if su_start >= len {
1358        return Ok((false, 0));
1359    }
1360    let su = &buf[offset + su_start..offset + len];
1361    let found = has_sp_entry(su);
1362    let skip = if found { extract_sp_skip(su) } else { 0 };
1363    Ok((found, skip))
1364}