Skip to main content

iso9660_forensic/
analysis.rs

1//! Forensic analyzer entry point: [`analyse`].
2//!
3//! Mirrors the sibling partition crates' `analyse(reader) -> Analysis`
4//! contract (`gpt-forensic`, `mbr-forensic`, `apm-forensic`) so a disk-forensic
5//! orchestrator can report on an ISO 9660 volume uniformly alongside the
6//! partition and other filesystem layers. It returns a [`IsoVolumeInfo`]
7//! provenance summary (authoring-tool fingerprints, timestamps, extension flags)
8//! plus a list of structural [`Anomaly`]s.
9//!
10//! This is a batch *analysis* surface, distinct from the navigation/mount
11//! surface ([`IsoReader`]); both share the same parser underneath.
12
13use std::io::{Read, Seek, SeekFrom};
14
15use crate::findings::{Anomaly, AnomalyKind, Severity};
16use crate::pvd::IsoDateTime;
17use crate::{IsoError, IsoReader};
18
19/// Options controlling [`analyse_with_options`]. Currently empty; reserved for
20/// future toggles (slack carving, full directory-record redundancy walk, …).
21#[derive(Debug, Clone, Copy, Default)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize))]
23pub struct AnalyseOptions {}
24
25/// One El Torito boot entry, summarised for the provenance report.
26#[derive(Debug, Clone)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize))]
28pub struct BootRecord {
29    /// Boot platform (e.g. `X86`, `EFI`, `Mac`, `Other`).
30    pub platform: String,
31    /// Whether the entry is marked bootable.
32    pub bootable: bool,
33    /// LBA of the boot image (for carving / hashing).
34    pub load_lba: u32,
35    /// Boot image length in virtual 512-byte sectors.
36    pub sectors: u16,
37    /// Lowercase hex SHA-256 of the boot image bytes (for matching against
38    /// known-malicious images); `None` if the image is unreadable.
39    pub sha256: Option<String>,
40}
41
42/// Volume provenance summary — the authoring/context "breadcrumbs" a forensic
43/// report leads with. All fields are observations from the active session's PVD.
44#[derive(Debug, Clone)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize))]
46pub struct IsoVolumeInfo {
47    pub volume_label: String,
48    pub system_id: String,
49    pub volume_set_id: String,
50    pub publisher_id: String,
51    /// Data preparer — usually the mastering tool's signature/version.
52    pub data_preparer_id: String,
53    pub application_id: String,
54    /// Volume creation time, `YYYY-MM-DD HH:MM:SS`, if present.
55    pub creation_time: Option<String>,
56    /// Volume modification time, `YYYY-MM-DD HH:MM:SS`, if present.
57    pub modification_time: Option<String>,
58    /// Detected sector mode (e.g. `Iso2048`, `Raw2352`).
59    pub sector_mode: String,
60    /// Number of PVD sessions detected.
61    pub session_count: usize,
62    pub has_rock_ridge: bool,
63    pub has_joliet: bool,
64    pub has_enhanced_volume_descriptor: bool,
65    /// El Torito boot entries (empty if not bootable).
66    pub boot_entries: Vec<BootRecord>,
67    /// Distinct Rock Ridge `PX` owner UIDs across the tree (authoring account
68    /// intel; empty without Rock Ridge), sorted ascending.
69    pub rock_ridge_uids: Vec<u32>,
70    /// Distinct Rock Ridge `PX` owner GIDs across the tree, sorted ascending.
71    pub rock_ridge_gids: Vec<u32>,
72    /// Distinct Rock Ridge `PX` inode serials (present only with PX v1; empty
73    /// otherwise), sorted ascending — authoring-filesystem intel.
74    pub rock_ridge_inodes: Vec<u64>,
75    /// Earliest file recorded time across the tree (`YYYY-MM-DD HH:MM:SS`), if
76    /// any file carries one — the lower bound of the authoring window.
77    pub earliest_file_time: Option<String>,
78    /// Latest file recorded time across the tree — the upper bound.
79    pub latest_file_time: Option<String>,
80}
81
82/// Result of a forensic analysis of an ISO 9660 volume.
83#[derive(Debug)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize))]
85pub struct IsoAnalysis {
86    /// Provenance / volume summary from the active PVD.
87    pub volume: IsoVolumeInfo,
88    /// Structural anomalies, in discovery order.
89    pub anomalies: Vec<Anomaly>,
90}
91
92impl IsoAnalysis {
93    /// The highest severity among all anomalies, or `None` when clean.
94    #[must_use]
95    pub fn max_severity(&self) -> Option<Severity> {
96        self.anomalies.iter().map(|a| a.severity).max()
97    }
98}
99
100/// Forensically analyse an ISO 9660 image.
101///
102/// # Errors
103/// Returns [`IsoError`] if the image is not a readable ISO 9660 volume.
104pub fn analyse<R: Read + Seek>(reader: &mut R) -> Result<IsoAnalysis, IsoError> {
105    analyse_with_options(reader, AnalyseOptions::default())
106}
107
108/// Like [`analyse`], with explicit [`AnalyseOptions`].
109///
110/// # Errors
111/// Returns [`IsoError`] if the image is not a readable ISO 9660 volume.
112pub fn analyse_with_options<R: Read + Seek>(
113    reader: &mut R,
114    _opts: AnalyseOptions,
115) -> Result<IsoAnalysis, IsoError> {
116    // Total image size, for the trailing-data check below.
117    let image_bytes = reader.seek(SeekFrom::End(0))?;
118    reader.seek(SeekFrom::Start(0))?;
119
120    // Gather the volume summary, both-endian mismatches, and the geometry needed
121    // for the trailing-data check, then drop the IsoReader so we can re-read raw
122    // bytes past the volume end.
123    let (
124        volume,
125        declared_sectors,
126        phys,
127        be_mismatches,
128        slack_hits,
129        presys_hits,
130        symlink_issues,
131        lost_files,
132        pt_divergence,
133        pt_endian,
134        time_anomalies,
135    ) = {
136        let mut iso = IsoReader::open(&mut *reader)?;
137        // El Torito boot provenance (empty when not bootable). Computed before
138        // the volume literal so its &mut borrow doesn't overlap the &self getters.
139        let raw_boots = iso.boot_entries()?;
140        let mut boot_entries: Vec<BootRecord> = Vec::with_capacity(raw_boots.len());
141        for b in &raw_boots {
142            // Boot image = sector_count virtual 512-byte sectors at LBA `lba`.
143            let want = usize::from(b.sector_count) * 512;
144            let nsec = want.div_ceil(2048);
145            let mut data = Vec::with_capacity(nsec * 2048);
146            let mut readable = want > 0;
147            for i in 0..nsec {
148                match iso.read_sector_raw(u64::from(b.lba) + i as u64) {
149                    Ok(s) => data.extend_from_slice(&s),
150                    Err(_) => {
151                        readable = false;
152                        break;
153                    }
154                }
155            }
156            let sha256 = if readable {
157                use sha2::{Digest, Sha256};
158                data.truncate(want);
159                Some(Sha256::digest(&data).iter().map(|x| format!("{x:02x}")).collect())
160            } else {
161                None
162            };
163            boot_entries.push(BootRecord {
164                platform: format!("{:?}", b.platform),
165                bootable: b.bootable,
166                load_lba: b.lba,
167                sectors: b.sector_count,
168                sha256,
169            });
170        }
171        let mut volume = IsoVolumeInfo {
172            volume_label: iso.volume_label().to_string(),
173            system_id: iso.system_id().to_string(),
174            volume_set_id: iso.volume_set_id().to_string(),
175            publisher_id: iso.publisher_id().to_string(),
176            data_preparer_id: iso.data_preparer_id().to_string(),
177            application_id: iso.application_id().to_string(),
178            creation_time: iso.volume_creation_time().map(fmt_dt),
179            modification_time: iso.volume_modification_time().map(fmt_dt),
180            sector_mode: format!("{:?}", iso.sector_mode()),
181            session_count: iso.session_count(),
182            has_rock_ridge: iso.has_rock_ridge(),
183            has_joliet: iso.has_joliet(),
184            has_enhanced_volume_descriptor: iso.has_enhanced_volume_descriptor(),
185            boot_entries,
186            rock_ridge_uids: Vec::new(),
187            rock_ridge_gids: Vec::new(),
188            rock_ridge_inodes: Vec::new(),
189            earliest_file_time: None,
190            latest_file_time: None,
191        };
192
193        // Rock Ridge PX identity + file-time span (the authoring window), in one
194        // walk of the tree.
195        {
196            let mut uids = std::collections::BTreeSet::new();
197            let mut gids = std::collections::BTreeSet::new();
198            let mut inodes = std::collections::BTreeSet::new();
199            let mut earliest: Option<IsoDateTime> = None;
200            let mut latest: Option<IsoDateTime> = None;
201            for e in iso.walk()? {
202                if let Some(px) = crate::rock_ridge::posix_attrs(&e.record.system_use) {
203                    uids.insert(px.uid);
204                    gids.insert(px.gid);
205                    if let Some(ino) = px.ino {
206                        inodes.insert(ino);
207                    }
208                }
209                if !e.record.is_dir() {
210                    if let Some(dt) = &e.record.recorded {
211                        if earliest.as_ref().is_none_or(|m| utc_key(dt) < utc_key(m)) {
212                            earliest = Some(dt.clone());
213                        }
214                        if latest.as_ref().is_none_or(|m| utc_key(dt) > utc_key(m)) {
215                            latest = Some(dt.clone());
216                        }
217                    }
218                }
219            }
220            volume.rock_ridge_uids = uids.into_iter().collect();
221            volume.rock_ridge_gids = gids.into_iter().collect();
222            volume.rock_ridge_inodes = inodes.into_iter().collect();
223            volume.earliest_file_time = earliest.as_ref().map(fmt_dt);
224            volume.latest_file_time = latest.as_ref().map(fmt_dt);
225        }
226        let be = iso.audit_both_endian()?;
227        let slack: Vec<_> = iso.audit_file_slack()?.into_iter().filter(|s| s.nonzero).collect();
228        let presys = iso.audit_pre_system()?;
229        let symlinks = iso.audit_symlinks()?;
230        let lost = iso.recover_lost_files()?;
231
232        // Path table vs directory tree: the path table is ISO 9660's redundant
233        // flattened directory index and must agree with the walked tree. A
234        // `phantom` dir is in the table but unreachable; a `ghost` dir is in the
235        // tree but missing from the table — either is a one-sided edit.
236        let pt = iso.audit_path_table()?;
237        let pt_div: Vec<(String, u32)> = pt
238            .phantom_lbas
239            .iter()
240            .map(|&lba| ("phantom".to_string(), lba))
241            .chain(pt.ghost_lbas.iter().map(|&lba| ("ghost".to_string(), lba)))
242            .collect();
243
244        // L-path-table (little-endian) vs M-path-table (big-endian): the two
245        // redundant copies of the directory index must be identical.
246        let pt_endian = iso.audit_path_table_endian()?;
247
248        // Non-zero PVD reserved fields (ECMA-119 mandates zero) — a tool
249        // fingerprint or data stashed in unused structure.
250        let mut pvd_reserved: Vec<Anomaly> = Vec::new();
251        {
252            let pvd_lba = *iso.session_pvd_lbas.last().unwrap_or(&16);
253            let raw = iso.read_sector_raw(pvd_lba)?;
254            for (region, start, end) in [
255                ("byte 7 (unused)", 7usize, 8usize),
256                ("byte 882 (unused)", 882, 883),
257                ("reserved tail", 1395, 2048),
258            ] {
259                let nz = raw[start..end].iter().filter(|&&b| b != 0).count();
260                if nz > 0 {
261                    pvd_reserved.push(Anomaly::new(AnomalyKind::ReservedFieldData {
262                        region: region.to_string(),
263                        pvd_offset: start as u32,
264                        nonzero_bytes: nz as u32,
265                    }));
266                }
267            }
268        }
269
270        // Three-namespace name divergence: a file's Rock Ridge (Unix) and Joliet
271        // (Windows) long names must agree. The ISO 8.3 short name is evidence
272        // only (legitimately mangled). Matched by shared data extent LBA.
273        let mut name_div: Vec<Anomaly> = Vec::new();
274        if iso.has_joliet() {
275            let norm = |s: &str| -> String {
276                let s = s.trim();
277                s.rsplit_once(';').map_or(s, |(a, _)| a).to_ascii_lowercase()
278            };
279            let mut prim: std::collections::HashMap<u32, (String, String)> =
280                std::collections::HashMap::new();
281            for e in iso.walk()? {
282                if e.record.is_dir() {
283                    continue;
284                }
285                if let Some(rr) = crate::rock_ridge::alternate_name(&e.record.system_use) {
286                    prim.entry(e.record.lba).or_insert((e.record.iso_name(), rr));
287                }
288            }
289            for e in iso.walk_joliet()? {
290                if e.record.is_dir() {
291                    continue;
292                }
293                let jol = e.record.joliet_name();
294                if let Some((iso_n, rr)) = prim.get(&e.record.lba) {
295                    if norm(rr) != norm(&jol) {
296                        name_div.push(Anomaly::new(AnomalyKind::NameDivergence {
297                            lba: e.record.lba,
298                            iso_name: iso_n.clone(),
299                            joliet_name: jol,
300                            rock_ridge_name: rr.clone(),
301                        }));
302                    }
303                }
304            }
305        }
306
307        // Non-standard ISO 9660 file version (anything but ;1): multiple
308        // retained versions or non-standard authoring.
309        let mut versioned: Vec<Anomaly> = Vec::new();
310        for e in iso.walk()? {
311            if e.record.is_dir() {
312                continue;
313            }
314            if let Some(pos) = e.record.name_bytes.iter().position(|&b| b == b';') {
315                let digits = &e.record.name_bytes[pos + 1..];
316                let ver: u16 =
317                    digits.iter().take_while(|b| b.is_ascii_digit()).fold(0u16, |acc, &b| {
318                        acc.saturating_mul(10).saturating_add(u16::from(b - b'0'))
319                    });
320                if !digits.is_empty() && ver != 1 {
321                    versioned.push(Anomaly::new(AnomalyKind::VersionedFile {
322                        entry_path: e.path,
323                        version: ver,
324                    }));
325                }
326            }
327        }
328
329        // ISO directory time vs Rock Ridge TF modify time: both written at
330        // mastering, so a per-file divergence is consistent with an edited stamp.
331        // Compared to minute granularity (validated identical on clean discs).
332        let mut time_mismatch: Vec<Anomaly> = Vec::new();
333        for e in iso.walk()? {
334            if e.record.is_dir() {
335                continue;
336            }
337            let (Some(it), Some(rr)) =
338                (e.record.recorded.as_ref(), crate::rock_ridge::timestamps(&e.record.system_use))
339            else {
340                continue;
341            };
342            if let Some(m) = rr.modify {
343                let iso_key = (it.year, it.month, it.day, it.hour, it.minute);
344                let rr_key = (u16::from(m[0]) + 1900, m[1], m[2], m[3], m[4]);
345                if iso_key != rr_key {
346                    time_mismatch.push(Anomaly::new(AnomalyKind::IsoRrTimeMismatch {
347                        entry_path: e.path,
348                        iso_time: fmt_dt(it),
349                        rock_ridge_time: fmt_short(&m),
350                    }));
351                }
352            }
353        }
354
355        // Disguised executables: a file with a document/media extension whose
356        // content begins with an executable magic (concealment). ISO-layer magic
357        // check only; deep analysis is a dedicated PE/ELF analyzer's job.
358        let mut disguised: Vec<Anomaly> = Vec::new();
359        {
360            const DOC_EXTS: &[&str] = &[
361                "txt", "doc", "docx", "pdf", "jpg", "jpeg", "png", "gif", "csv", "xml", "html",
362                "htm", "md", "rtf", "log", "json", "bmp", "tif", "tiff",
363            ];
364            for e in iso.walk()? {
365                if e.record.is_dir() || e.record.size < 4 {
366                    continue;
367                }
368                let lower = e.path.to_ascii_lowercase();
369                let Some(ext) = lower.rsplit('.').next().filter(|_| lower.contains('.')) else {
370                    continue;
371                };
372                if !DOC_EXTS.contains(&ext) {
373                    continue;
374                }
375                let Ok(hdr) = iso.read_sector_raw(u64::from(e.record.lba)) else {
376                    continue;
377                };
378                if let Some(format) = exe_magic(&hdr) {
379                    disguised.push(Anomaly::new(AnomalyKind::DisguisedExecutable {
380                        entry_path: e.path,
381                        format: format.to_string(),
382                        claimed_ext: ext.to_string(),
383                    }));
384                }
385            }
386        }
387
388        // Directory cycles: a directory whose extent is one of its own
389        // ancestors. Detected from the (cycle-safe) walk output by checking each
390        // directory against the extent LBAs of its path ancestors.
391        let mut dir_cycles: Vec<Anomaly> = Vec::new();
392        {
393            let entries = iso.walk()?;
394            let mut dir_lba: std::collections::HashMap<String, u32> =
395                std::collections::HashMap::new();
396            dir_lba.insert(String::new(), iso.pvd.root_dir_lba);
397            for e in &entries {
398                if e.record.is_dir() {
399                    dir_lba.insert(e.path.clone(), e.record.lba);
400                }
401            }
402            for e in &entries {
403                if !e.record.is_dir() {
404                    continue;
405                }
406                let parts: Vec<&str> = e.path.split('/').collect();
407                // Proper ancestors only (exclude the entry's own full path).
408                let cycles_back = (0..parts.len()).any(|i| {
409                    let anc = parts[..i].join("/");
410                    dir_lba.get(&anc) == Some(&e.record.lba)
411                });
412                if cycles_back {
413                    dir_cycles.push(Anomaly::new(AnomalyKind::DirectoryCycle {
414                        entry_path: e.path.clone(),
415                        lba: e.record.lba,
416                    }));
417                }
418            }
419        }
420
421        // Overlapping extents: distinct files must occupy distinct extents. A
422        // partial overlap (intersecting, not identical) is consistent with
423        // corruption or concealment; identical-extent dedup is benign and
424        // excluded. Running-coverage sweep keeps this O(n log n).
425        let mut overlaps: Vec<Anomaly> = Vec::new();
426        {
427            let mut exts: Vec<(u32, u32, String)> = iso
428                .walk()?
429                .into_iter()
430                .filter(|e| !e.record.is_dir() && e.record.size > 0)
431                .map(|e| {
432                    let start = e.record.lba;
433                    let sectors =
434                        u32::try_from(u64::from(e.record.size).div_ceil(2048)).unwrap_or(u32::MAX);
435                    (start, start.saturating_add(sectors), e.path)
436                })
437                .collect();
438            exts.sort();
439            let mut cover: Option<(u32, u32, String)> = None;
440            for (start, end, path) in exts {
441                if let Some((cs, ce, cpath)) = cover.as_ref() {
442                    if start < *ce && !(start == *cs && end == *ce) {
443                        overlaps.push(Anomaly::new(AnomalyKind::OverlappingExtents {
444                            path: path.clone(),
445                            lba: start,
446                            overlaps_path: cpath.clone(),
447                            overlaps_lba: *cs,
448                        }));
449                    }
450                }
451                if cover.as_ref().is_none_or(|(_, ce, _)| end > *ce) {
452                    cover = Some((start, end, path));
453                }
454            }
455        }
456
457        // Superseded content across sessions: a file present in an earlier
458        // session but no longer referenced by the active tree (or pointing to a
459        // different extent) remains readable from that earlier session.
460        let mut superseded: Vec<Anomaly> = Vec::new();
461        let session_n = iso.session_count();
462        if session_n > 1 {
463            let active: std::collections::HashMap<String, u32> = iso
464                .walk()?
465                .into_iter()
466                .filter(|e| !e.record.is_dir())
467                .map(|e| (e.path, e.record.lba))
468                .collect();
469            for idx in 0..session_n - 1 {
470                for e in iso.walk_session(idx)? {
471                    if e.record.is_dir() {
472                        continue;
473                    }
474                    let status = match active.get(&e.path) {
475                        None => "deleted",
476                        Some(&l) if l != e.record.lba => "replaced",
477                        Some(_) => continue, // still live at the same extent
478                    };
479                    superseded.push(Anomaly::new(AnomalyKind::SupersededFile {
480                        entry_path: e.path,
481                        session: idx,
482                        lba: e.record.lba,
483                        status: status.to_string(),
484                    }));
485                }
486            }
487        }
488
489        // Out-of-bounds extents: an entry whose data extent points past the
490        // readable image (truncation, corruption, or a dangling reference).
491        let image_sectors = image_bytes / iso.sector_mode().physical_sector_size();
492        let mut oob_anoms: Vec<Anomaly> = Vec::new();
493        for e in iso.walk()? {
494            if e.record.size == 0 {
495                continue;
496            }
497            let end = u64::from(e.record.lba) + u64::from(e.record.size).div_ceil(2048);
498            if u64::from(e.record.lba) >= image_sectors || end > image_sectors {
499                oob_anoms.push(Anomaly::new(AnomalyKind::OutOfBoundsExtent {
500                    entry_path: e.path,
501                    lba: e.record.lba,
502                    size: e.record.size,
503                    image_sectors: u32::try_from(image_sectors).unwrap_or(u32::MAX),
504                }));
505            }
506        }
507
508        // Files recorded after the volume creation date (post-mastering add /
509        // backdated volume). Compared as UTC instants so timezone offsets don't
510        // cause false ordering.
511        // Volume dates outside the optical era are impossible for the volume
512        // itself (year 0 = unset, skipped). 1985 ≈ first CD-ROMs; nothing
513        // legitimately claims a year past the far-future ceiling.
514        const OPTICAL_ERA_FLOOR: u16 = 1985;
515        const OPTICAL_ERA_CEILING: u16 = 2100;
516        let mut time_anoms: Vec<Anomaly> = Vec::new();
517        for (which, t) in [
518            ("creation", iso.volume_creation_time().cloned()),
519            ("modification", iso.volume_modification_time().cloned()),
520        ] {
521            if let Some(dt) = t {
522                if (1..OPTICAL_ERA_FLOOR).contains(&dt.year) || dt.year > OPTICAL_ERA_CEILING {
523                    time_anoms.push(Anomaly::new(AnomalyKind::ImplausibleVolumeDate {
524                        which: which.to_string(),
525                        year: dt.year,
526                    }));
527                }
528            }
529        }
530        if let Some(vt) = iso.volume_creation_time().cloned() {
531            let vkey = utc_key(&vt);
532            let mut tz_offsets = std::collections::BTreeSet::new();
533            tz_offsets.insert(vt.tz_offset_15min);
534            for e in iso.walk()? {
535                if e.record.is_dir() {
536                    continue;
537                }
538                if let Some(ft) = &e.record.recorded {
539                    tz_offsets.insert(ft.tz_offset_15min);
540                    if utc_key(ft) > vkey {
541                        time_anoms.push(Anomaly::new(AnomalyKind::FileAfterVolume {
542                            entry_path: e.path,
543                            file_time: fmt_dt(ft),
544                            volume_time: fmt_dt(&vt),
545                        }));
546                    }
547                }
548            }
549            if tz_offsets.len() > 1 {
550                time_anoms.push(Anomaly::new(AnomalyKind::MixedTimezones {
551                    offsets: tz_offsets.into_iter().collect(),
552                }));
553            }
554        }
555
556        // Joliet ↔ primary divergence: on a hybrid disc both trees describe the
557        // same files (shared data extents). A file extent in only one tree is
558        // consistent with concealment from one OS's view.
559        if iso.has_joliet() {
560            let extents =
561                |entries: Vec<crate::WalkEntry>| -> std::collections::BTreeMap<u32, String> {
562                    entries
563                        .into_iter()
564                        .filter(|e| !e.record.is_dir() && e.record.size > 0)
565                        .map(|e| (e.record.lba, e.path))
566                        .collect()
567                };
568            let primary = extents(iso.walk()?);
569            let joliet = extents(iso.walk_joliet()?);
570            for (lba, path) in &primary {
571                if !joliet.contains_key(lba) {
572                    time_anoms.push(Anomaly::new(AnomalyKind::TreeDivergence {
573                        tree: "primary-only".to_string(),
574                        lba: *lba,
575                        path: path.clone(),
576                    }));
577                }
578            }
579            for (lba, path) in &joliet {
580                if !primary.contains_key(lba) {
581                    time_anoms.push(Anomaly::new(AnomalyKind::TreeDivergence {
582                        tree: "joliet-only".to_string(),
583                        lba: *lba,
584                        path: path.clone(),
585                    }));
586                }
587            }
588        }
589
590        (
591            volume,
592            u64::from(iso.volume_space_size()),
593            iso.sector_mode().physical_sector_size(),
594            be,
595            slack,
596            presys,
597            symlinks,
598            lost,
599            pt_div,
600            pt_endian,
601            {
602                time_anoms.extend(oob_anoms);
603                time_anoms.extend(superseded);
604                time_anoms.extend(pvd_reserved);
605                time_anoms.extend(overlaps);
606                time_anoms.extend(dir_cycles);
607                time_anoms.extend(name_div);
608                time_anoms.extend(disguised);
609                time_anoms.extend(time_mismatch);
610                time_anoms.extend(versioned);
611                time_anoms
612            },
613        )
614    };
615
616    let mut anomalies = Vec::new();
617
618    // Both-endian redundancy: reuse the (tested) audit, which reconciles the PVD
619    // and directory-record both-endian copies, and map each mismatch to a
620    // unified [`Anomaly`].
621    for m in be_mismatches {
622        anomalies.push(Anomaly::new(AnomalyKind::BothEndianMismatch {
623            context: m.context,
624            field: m.field,
625            byte_offset: m.byte_offset,
626            le: m.le_val,
627            be: m.be_val,
628        }));
629    }
630
631    // Non-zero file slack: leaked buffer/RAM fragments past a file's data.
632    for s in slack_hits {
633        anomalies.push(Anomaly::new(AnomalyKind::SlackData {
634            entry_path: s.entry_path,
635            lba: s.lba,
636            slack_bytes: s.slack_bytes,
637        }));
638    }
639
640    // Pre-system area (sectors 0–15) non-zero data.
641    for p in presys_hits {
642        anomalies.push(Anomaly::new(AnomalyKind::PreSystemData {
643            sector: p.sector,
644            kind: p.kind.to_string(),
645        }));
646    }
647
648    // Rock Ridge symlink targets that escape the volume or leak host paths.
649    for s in symlink_issues {
650        anomalies.push(Anomaly::new(AnomalyKind::SymlinkAnomaly {
651            entry_path: s.entry_path,
652            target: s.target,
653            issue: s.issue.to_string(),
654        }));
655    }
656
657    // Path table vs directory tree divergence: a directory present in only one
658    // of the two redundant indexes (phantom = path-table-only, ghost = tree-only).
659    for (direction, lba) in pt_divergence {
660        anomalies.push(Anomaly::new(AnomalyKind::PathTableDivergence { direction, lba }));
661    }
662
663    // L/M path-table both-endian divergence: the two redundant byte-order copies
664    // of the directory index disagree on an entry's content.
665    for m in pt_endian {
666        anomalies.push(Anomaly::new(AnomalyKind::PathTableEndianDivergence {
667            index: m.index,
668            description: m.description,
669        }));
670    }
671
672    // Files in orphaned directory extents (path-table dirs unreachable from the tree).
673    for l in lost_files {
674        anomalies.push(Anomaly::new(AnomalyKind::OrphanedFile {
675            name: l.name,
676            lba: l.lba,
677            size: l.size,
678            parent_lba: l.parent_lba,
679        }));
680    }
681
682    // Files recorded after the volume creation date (built inside the scope above).
683    anomalies.extend(time_anomalies);
684
685    // Trailing data: bytes past the declared volume end. Only flagged when the
686    // trailing region is non-zero (benign zero padding is ignored).
687    let declared_bytes = declared_sectors.saturating_mul(phys);
688    if image_bytes > declared_bytes && trailing_has_nonzero(reader, declared_bytes, image_bytes)? {
689        anomalies.push(Anomaly::new(AnomalyKind::TrailingData {
690            declared_bytes,
691            image_bytes,
692            trailing_bytes: image_bytes - declared_bytes,
693        }));
694    }
695
696    // EDC/ECC integrity (raw 2352 Mode-1 sectors only): a genuine optical dump
697    // carries valid EDC and Reed-Solomon P/Q ECC; zero/invalid values indicate a
698    // synthesized image or tampered data. Skipped for 2048-byte ISO images.
699    if phys >= 2352 {
700        let total = image_bytes / phys;
701        let mut sec = vec![0u8; 2352];
702        let mut checked = 0u32;
703        let mut edc_invalid = 0u32;
704        let mut edc_first = 0u32;
705        let mut ecc_invalid = 0u32;
706        let mut ecc_first = 0u32;
707        for lba in 0..total {
708            reader.seek(SeekFrom::Start(lba * phys))?;
709            if reader.read_exact(&mut sec).is_err() {
710                break;
711            }
712            let sync_ok = sec[0] == 0 && sec[1..11].iter().all(|&b| b == 0xFF) && sec[11] == 0;
713            if !sync_ok || sec[15] != 1 {
714                continue; // not a Mode-1 sector with EDC/ECC
715            }
716            checked += 1;
717            let stored = u32::from_le_bytes([sec[2064], sec[2065], sec[2066], sec[2067]]);
718            if crate::sector::cd_edc(&sec[0..2064]) != stored {
719                if edc_invalid == 0 {
720                    edc_first = u32::try_from(lba).unwrap_or(u32::MAX);
721                }
722                edc_invalid += 1;
723            }
724            if !crate::sector::mode1_ecc_valid(&sec) {
725                if ecc_invalid == 0 {
726                    ecc_first = u32::try_from(lba).unwrap_or(u32::MAX);
727                }
728                ecc_invalid += 1;
729            }
730        }
731        if edc_invalid > 0 {
732            anomalies.push(Anomaly::new(AnomalyKind::EdcInvalid {
733                sectors_checked: checked,
734                sectors_invalid: edc_invalid,
735                first_invalid_lba: edc_first,
736            }));
737        }
738        if ecc_invalid > 0 {
739            anomalies.push(Anomaly::new(AnomalyKind::EccInvalid {
740                sectors_checked: checked,
741                sectors_invalid: ecc_invalid,
742                first_invalid_lba: ecc_first,
743            }));
744        }
745    }
746
747    Ok(IsoAnalysis { volume, anomalies })
748}
749
750/// True if the byte range `[start, end)` contains any non-zero byte.
751/// Identify an executable file format from its leading magic bytes, or `None`.
752fn exe_magic(header: &[u8]) -> Option<&'static str> {
753    if header.len() < 4 {
754        return None;
755    }
756    if header[0] == 0x4D && header[1] == 0x5A {
757        return Some("PE"); // "MZ" (DOS/PE)
758    }
759    if header[0] == 0x7F && &header[1..4] == b"ELF" {
760        return Some("ELF");
761    }
762    let be = u32::from_be_bytes([header[0], header[1], header[2], header[3]]);
763    // Mach-O 32/64-bit (both byte orders) and universal ("fat") binaries.
764    if matches!(be, 0xFEED_FACE | 0xFEED_FACF | 0xCEFA_EDFE | 0xCFFA_EDFE | 0xCAFE_BABE) {
765        return Some("Mach-O");
766    }
767    None
768}
769
770fn trailing_has_nonzero<R: Read + Seek>(
771    reader: &mut R,
772    start: u64,
773    end: u64,
774) -> Result<bool, IsoError> {
775    reader.seek(SeekFrom::Start(start))?;
776    let mut remaining = end - start;
777    let mut buf = [0u8; 65536];
778    while remaining > 0 {
779        let want = remaining.min(buf.len() as u64) as usize;
780        reader.read_exact(&mut buf[..want])?;
781        if buf[..want].iter().any(|&b| b != 0) {
782            return Ok(true);
783        }
784        remaining -= want as u64;
785    }
786    Ok(false)
787}
788
789/// A comparable UTC-seconds key for an [`IsoDateTime`], normalising the stored
790/// `tz_offset_15min` so two timestamps in different zones order correctly.
791/// Uses Howard Hinnant's days-from-civil algorithm (proleptic Gregorian).
792fn utc_key(dt: &IsoDateTime) -> i64 {
793    let y = i64::from(dt.year);
794    let m = i64::from(dt.month.max(1));
795    let d = i64::from(dt.day.max(1));
796    let y = if m <= 2 { y - 1 } else { y };
797    let era = if y >= 0 { y } else { y - 399 } / 400;
798    let yoe = y - era * 400;
799    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
800    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
801    let days = era * 146_097 + doe - 719_468; // days since 1970-01-01
802    let local = days * 86_400
803        + i64::from(dt.hour) * 3600
804        + i64::from(dt.minute) * 60
805        + i64::from(dt.second);
806    // Stored times are local; subtract the GMT offset (15-minute units) to get UTC.
807    local - i64::from(dt.tz_offset_15min) * 15 * 60
808}
809
810fn fmt_dt(dt: &IsoDateTime) -> String {
811    format!(
812        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
813        dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
814    )
815}
816
817/// Format a Rock Ridge short (7-byte) timestamp `[yr-1900, mo, day, hr, min, sec, tz]`.
818fn fmt_short(t: &[u8; 7]) -> String {
819    format!(
820        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
821        u16::from(t[0]) + 1900,
822        t[1],
823        t[2],
824        t[3],
825        t[4],
826        t[5]
827    )
828}