Skip to main content

iso_parser/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3// Phase 6 of #286 — README.md becomes the rustdoc landing page
4// alongside the Rust-specific module docs below. docs.rs + local
5// `cargo doc --open` visitors see the operator-level overview
6// first; the Rust-API detail (Safety, Supported Distributions,
7// Usage) stays inline.
8//
9// `clippy::doc_markdown = allow` at module scope because the README
10// is prose for a general operator audience — strict auto-backticking
11// of distro names / tool names / product names (clippy::doc_markdown
12// wants `Arch Linux` → `` `Arch Linux` ``) is noise without signal
13// for the README's readers. The module-level `//!` API docs still
14// get the full lint benefit below.
15#![allow(clippy::doc_markdown)]
16#![doc = include_str!("../README.md")]
17//!
18//! ---
19//!
20//! # Safety
21//!
22//! `forbid(unsafe_code)` at the crate level — `iso-parser` ships to crates.io
23//! per [#51](https://github.com/aegis-boot/aegis-boot/issues/51) and a
24//! library that parses untrusted ISO content has no business calling raw
25//! syscalls. The kexec syscall lives in `kexec-loader`, the only crate in the
26//! workspace that's exempt from this constraint.
27//!
28//! # Supported Distributions
29//! - **Arch Linux**: `/boot/` contains `vmlinuz` and `initrd.img`
30//! - **Debian/Ubuntu**: `/install/` or `/casper/` contains `vmlinuz` and `initrd.gz`
31//! - **Fedora**: `/images/pxeboot/` contains `vmlinuz` and `initrd.img`
32//!
33//! # Usage
34//! ```text
35//! // Illustrative only — OsIsoEnvironment doesn't exist in this
36//! // crate (real callers supply their own IsoEnvironment impl).
37//! // `text` fence so this doesn't compile under `cargo test --
38//! // --ignored` either.
39//! use iso_parser::{IsoParser, OsIsoEnvironment};
40//! use std::path::Path;
41//!
42//! async fn example() {
43//!     let parser = IsoParser::new(OsIsoEnvironment::new());
44//!     let entries = parser.scan_directory(Path::new("/media/isos")).await?;
45//!     for entry in entries {
46//!         println!("Found: {} ({:?})", entry.label, entry.distribution);
47//!     }
48//! }
49//! ```
50
51#![forbid(unsafe_code)]
52
53use serde::{Deserialize, Serialize};
54use std::path::{Path, PathBuf};
55use thiserror::Error;
56use tracing::{debug, instrument};
57
58#[cfg(test)]
59#[path = "detection_tests.rs"]
60#[allow(
61    clippy::unwrap_used,
62    clippy::expect_used,
63    clippy::too_many_lines,
64    clippy::missing_panics_doc
65)]
66mod detection_tests;
67
68/// Errors that can occur during ISO parsing
69#[derive(Debug, Error)]
70pub enum IsoError {
71    /// Underlying I/O failure — path read, file stat, or directory
72    /// listing. Wraps [`std::io::Error`] transparently.
73    #[error("IO error: {0}")]
74    Io(#[from] std::io::Error),
75
76    /// Scan completed but no recognized boot entries were found inside
77    /// the ISO. The inner string names the ISO path for context.
78    #[error("No boot entries found in ISO: {0}")]
79    NoBootEntries(String),
80
81    /// `mount` (or the configured `IsoEnvironment`'s `mount_iso`) failed
82    /// — inner string is the mounter's stderr or a descriptive message.
83    #[error("Mount failed: {0}")]
84    MountFailed(String),
85
86    /// Requested path escaped the expected base directory (contains
87    /// `..` components or doesn't live under the mount root). Inner
88    /// string is the offending path.
89    #[error("Path traversal attempt blocked: {0}")]
90    PathTraversal(String),
91}
92
93/// Result of a directory scan — successful boot entries plus any
94/// per-file failures that the caller should surface to the user.
95///
96/// Returned by [`IsoParser::scan_directory_with_failures`]. Unlike the
97/// legacy [`IsoParser::scan_directory`] which silently drops failed
98/// ISOs, this shape preserves the full on-disk inventory so a UI
99/// (e.g. rescue-tui) can render a descriptive row for each broken
100/// ISO instead of hiding it behind a "skipped" count. (#456)
101#[derive(Debug, Clone)]
102pub struct ScanReport {
103    /// ISOs that were mounted, parsed, and yielded at least one boot
104    /// entry.
105    pub entries: Vec<BootEntry>,
106    /// ISOs that were found on disk but could not be processed.
107    /// `reason` is human-readable; `kind` is structured for tier
108    /// decisions downstream.
109    pub failures: Vec<ScanFailure>,
110}
111
112/// A single ISO file that failed to yield boot entries during a
113/// directory scan.
114#[derive(Debug, Clone)]
115pub struct ScanFailure {
116    /// Absolute path to the `.iso` file that failed.
117    pub iso_path: PathBuf,
118    /// Human-readable reason, rendered safely in TUIs (no control
119    /// characters, source-error `Display` already applied).
120    pub reason: String,
121    /// Structured classification for downstream tier mapping.
122    pub kind: ScanFailureKind,
123}
124
125/// Structured classification of why an ISO failed to yield boot
126/// entries. A 1-to-1 map from the per-file variants of [`IsoError`].
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum ScanFailureKind {
129    /// Filesystem error reading the ISO or its mount point.
130    IoError,
131    /// Loop-mounting the ISO failed (wrong format, permission denied,
132    /// no loop device available).
133    MountFailed,
134    /// Mount succeeded but no recognized boot entries were found on
135    /// the ISO's filesystem.
136    NoBootEntries,
137}
138
139impl ScanFailureKind {
140    fn from_iso_error(e: &IsoError) -> Self {
141        match e {
142            IsoError::MountFailed(_) => Self::MountFailed,
143            IsoError::NoBootEntries(_) => Self::NoBootEntries,
144            // Io and PathTraversal both map to IoError. PathTraversal
145            // is a caller-supplied error that should never surface at
146            // this layer (path validation runs before the per-ISO
147            // loop); defensively funneled here so a future regression
148            // surfaces as a generic IoError rather than a panic.
149            IsoError::Io(_) | IsoError::PathTraversal(_) => Self::IoError,
150        }
151    }
152}
153
154/// Maximum length (in bytes) of a [`ScanFailure::reason`] string.
155/// Long enough to include the original error's meaningful prefix
156/// (mount errors typically fit in ~120 chars) while keeping TUI
157/// rendering bounded.
158const MAX_REASON_LEN: usize = 256;
159
160/// Produce a TUI-safe version of an error string: control characters
161/// replaced with spaces, trimmed, truncated to [`MAX_REASON_LEN`].
162/// Non-ASCII is preserved (UTF-8 safe).
163fn sanitize_reason(raw: &str) -> String {
164    let cleaned: String = raw
165        .chars()
166        .map(|c| {
167            // Allow printable + space; replace any other control char
168            // with a single space so the TUI's line-layout math doesn't
169            // break. Tab is also dropped (would shift columns).
170            if c.is_control() { ' ' } else { c }
171        })
172        .collect();
173    let trimmed = cleaned.trim();
174    if trimmed.len() <= MAX_REASON_LEN {
175        return trimmed.to_string();
176    }
177    // Truncate on a char boundary so we never split a multibyte char.
178    let mut end = MAX_REASON_LEN;
179    while !trimmed.is_char_boundary(end) {
180        end -= 1;
181    }
182    format!("{}…", &trimmed[..end])
183}
184
185/// Represents a discovered boot entry from an ISO
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187pub struct BootEntry {
188    /// Label for the boot menu (e.g., "Arch Linux `x86_64`")
189    pub label: String,
190    /// Path to kernel (relative to ISO mount point)
191    pub kernel: PathBuf,
192    /// Path to initrd (relative to ISO mount point)
193    pub initrd: Option<PathBuf>,
194    /// Kernel command line parameters
195    pub kernel_args: Option<String>,
196    /// Distribution identifier
197    pub distribution: Distribution,
198    /// ISO filename (for reference)
199    pub source_iso: String,
200    /// Full distro name with version, extracted from `/etc/os-release`
201    /// (`PRETTY_NAME`) or fallback files on the mounted ISO. Populated
202    /// by `scan_directory`; `None` when none of the probe paths exist
203    /// (older installers or unfamiliar layouts). Surfaced as the
204    /// primary label in downstream UI when present so operators see
205    /// "Ubuntu 24.04.2 LTS (Noble Numbat)" instead of just "Ubuntu".
206    /// (#119)
207    #[serde(default)]
208    pub pretty_name: Option<String>,
209}
210
211/// Supported distribution families.
212///
213/// Ordering of detection matters: more specific matches (Alpine's
214/// `boot/vmlinuz-lts`, `NixOS`'s `boot/bzImage`, RHEL-family's `images/pxeboot`)
215/// must run before the broader ones (Arch's generic `boot/` heuristic).
216#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
217pub enum Distribution {
218    /// Arch Linux install media (`arch/boot/x86_64/vmlinuz-linux`).
219    Arch,
220    /// Debian and Ubuntu live/install media (`casper/`, `install.amd/`, `live/`).
221    Debian,
222    /// Fedora install media (`images/pxeboot/`).
223    Fedora,
224    /// RHEL / Rocky / `AlmaLinux` — same `images/pxeboot` layout as Fedora
225    /// but a distinct signing CA and stricter lockdown kexec policy.
226    RedHat,
227    /// Alpine Linux (`boot/vmlinuz-lts`).
228    Alpine,
229    /// `NixOS` install media (`boot/bzImage`).
230    NixOS,
231    /// Windows installer media. Recognized by `bootmgr`, `sources/boot.wim`,
232    /// or `efi/microsoft/boot/`. **Not kexec-bootable**: Windows uses a
233    /// fundamentally different boot protocol (NT loader, not Linux kernel).
234    /// Surfaced so the TUI can give a specific diagnostic rather than fail
235    /// silently.
236    Windows,
237    /// Layout not recognized.
238    Unknown,
239}
240
241impl Distribution {
242    /// Detect distribution from a kernel path observed inside an ISO.
243    #[must_use]
244    pub fn from_paths(kernel_path: &std::path::Path) -> Self {
245        let path_str = kernel_path.to_string_lossy().to_lowercase();
246
247        // Specific signals first — RHEL/Rocky/Alma carry distinctive markers in
248        // their ISO volume labels and filenames, but at this path-only layer
249        // we can't disambiguate from Fedora. Keep them separate variants; the
250        // caller can upgrade detection once volume-label sniffing is added.
251        if path_str.contains("bootmgr")
252            || path_str.contains("sources/boot.wim")
253            || path_str.contains("efi/microsoft")
254            || path_str.contains("windows")
255        {
256            Distribution::Windows
257        } else if path_str.contains("nixos") || path_str.ends_with("bzimage") {
258            Distribution::NixOS
259        } else if path_str.contains("alpine")
260            // Alpine's kernel filename suffix is the authoritative
261            // signal — `vmlinuz-lts` (Standard) and `vmlinuz-virt`
262            // (Virt edition). Kept case-insensitive; path_str is
263            // already lowercased. (#116)
264            || path_str.contains("vmlinuz-lts")
265            || path_str.contains("vmlinuz-virt")
266            || path_str.contains("initramfs-lts")
267            || path_str.contains("initramfs-virt")
268        {
269            Distribution::Alpine
270        } else if path_str.contains("rhel")
271            || path_str.contains("rocky")
272            || path_str.contains("almalinux")
273            || path_str.contains("centos")
274        {
275            Distribution::RedHat
276        } else if path_str.contains("fedora")
277            || path_str.contains("images")
278            || path_str.contains("pxeboot")
279        {
280            Distribution::Fedora
281        } else if path_str.contains("debian")
282            || path_str.contains("ubuntu")
283            || path_str.contains("casper")
284        {
285            Distribution::Debian
286        } else if path_str.contains("arch")
287            || (path_str.contains("boot")
288                && !path_str.contains("efi")
289                && !path_str.contains("images"))
290        {
291            Distribution::Arch
292        } else {
293            Distribution::Unknown
294        }
295    }
296}
297
298/// Environment abstraction for file system and OS operations
299///
300/// This trait enables unit testing without actual mounts by providing
301/// a mockable interface for filesystem access and process execution.
302pub trait IsoEnvironment: Send + Sync {
303    /// List files in a directory.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`std::io::Error`] on any read failure (missing path,
308    /// permission denied, I/O error mid-read).
309    fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>>;
310
311    /// Check if a file exists.
312    fn exists(&self, path: &std::path::Path) -> bool;
313
314    /// Read file metadata.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`std::io::Error`] when the path can't be stat'd
319    /// (missing, permission denied, I/O error).
320    fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata>;
321
322    /// Mount an ISO file and return the mount point.
323    ///
324    /// # Errors
325    ///
326    /// Returns [`IsoError::MountFailed`] if the underlying mount
327    /// command (or mock handler) returned non-zero, or
328    /// [`IsoError::Io`] if a required helper (mkdir, losetup, mount)
329    /// couldn't be spawned.
330    fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError>;
331
332    /// Unmount a previously mounted ISO.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`IsoError::MountFailed`] if `umount` returned non-zero
337    /// (busy mount, stale mount point), or [`IsoError::Io`] if the
338    /// unmount helper couldn't be spawned.
339    fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError>;
340
341    /// Validate that `path` is rooted under `base` and contains no
342    /// parent-directory escapes.
343    ///
344    /// Returns [`IsoError::PathTraversal`] when:
345    ///   * any path component is `..` (could escape on normalization), OR
346    ///   * `path` does not lie under `base` (absolute paths to elsewhere).
347    ///
348    /// Symlinks are NOT resolved — callers that mount untrusted media must
349    /// constrain symlink-following at the mount layer (e.g. `nosymfollow`),
350    /// not rely on this check.
351    ///
352    /// Previous implementation silently returned `Ok(path)` when
353    /// `strip_prefix(base)` failed, meaning paths outside `base` were
354    /// accepted. Fixed in #56.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`IsoError::PathTraversal`] on either of the two
359    /// traversal conditions above.
360    fn validate_path(
361        &self,
362        base: &std::path::Path,
363        path: &std::path::Path,
364    ) -> Result<PathBuf, IsoError> {
365        if path
366            .components()
367            .any(|c| matches!(c, std::path::Component::ParentDir))
368        {
369            return Err(IsoError::PathTraversal(path.display().to_string()));
370        }
371        if !path.starts_with(base) {
372            return Err(IsoError::PathTraversal(path.display().to_string()));
373        }
374        Ok(path.to_path_buf())
375    }
376}
377
378/// OS-specific implementation of `IsoEnvironment`
379pub struct OsIsoEnvironment {
380    mount_base: PathBuf,
381}
382
383impl OsIsoEnvironment {
384    /// Construct a default `OsIsoEnvironment` with mount points under
385    /// `/tmp/iso-parser-mounts`. Callers that need a different base
386    /// path should construct the struct directly.
387    #[must_use]
388    pub fn new() -> Self {
389        Self {
390            mount_base: PathBuf::from("/tmp/iso-parser-mounts"),
391        }
392    }
393
394    /// Find a free loop device and attach `iso_path` to it. Tries
395    /// util-linux semantics (`losetup -f --show -r`) first, then falls
396    /// back to busybox semantics (scan `/dev/loop*` manually and attach
397    /// via `losetup <dev> <iso>`). Returns the allocated device path on
398    /// success.
399    fn allocate_loop_device(iso_path: &std::path::Path) -> Option<String> {
400        use std::process::Command;
401
402        // Attempt A: util-linux `-f --show -r`.
403        match Command::new("losetup")
404            .args(["-f", "--show", "-r", &iso_path.to_string_lossy()])
405            .output()
406        {
407            Ok(out) if out.status.success() => {
408                let dev = String::from_utf8_lossy(&out.stdout).trim().to_string();
409                if !dev.is_empty() && dev.starts_with("/dev/") {
410                    return Some(dev);
411                }
412                // Success exit but stdout didn't name a loop device —
413                // surface so operators see why "no ISOs found" when
414                // losetup is present. (#138)
415                tracing::warn!(
416                    iso = %iso_path.display(),
417                    stdout = %String::from_utf8_lossy(&out.stdout),
418                    "iso-parser: util-linux losetup succeeded but returned no /dev/loop* device"
419                );
420            }
421            Ok(out) => {
422                tracing::warn!(
423                    iso = %iso_path.display(),
424                    exit = ?out.status.code(),
425                    stderr = %String::from_utf8_lossy(&out.stderr),
426                    "iso-parser: util-linux losetup -f --show failed; falling back to busybox scan"
427                );
428            }
429            Err(e) => {
430                tracing::warn!(
431                    iso = %iso_path.display(),
432                    error = %e,
433                    "iso-parser: losetup exec failed (not on PATH?); falling back to busybox scan"
434                );
435            }
436        }
437
438        // Attempt B: busybox fallback. Find a free loop device manually
439        // (one that's not currently bound — busybox `losetup LOOPDEV`
440        // without args prints its binding or errors).
441        for n in 0..16 {
442            let dev = format!("/dev/loop{n}");
443            if !std::path::Path::new(&dev).exists() {
444                continue;
445            }
446            // Query — if it returns non-zero, device is free.
447            let query = match Command::new("losetup").arg(&dev).output() {
448                Ok(q) => q,
449                Err(e) => {
450                    tracing::warn!(
451                        dev = %dev,
452                        error = %e,
453                        "iso-parser: losetup query exec failed; skipping device"
454                    );
455                    continue;
456                }
457            };
458            if query.status.success() {
459                continue; // already bound
460            }
461            // Try to attach.
462            match Command::new("losetup")
463                .args(["-r", &dev, &iso_path.to_string_lossy()])
464                .output()
465            {
466                Ok(attach) if attach.status.success() => return Some(dev),
467                Ok(attach) => {
468                    tracing::warn!(
469                        dev = %dev,
470                        iso = %iso_path.display(),
471                        exit = ?attach.status.code(),
472                        stderr = %String::from_utf8_lossy(&attach.stderr),
473                        "iso-parser: losetup attach failed; trying next device"
474                    );
475                }
476                Err(e) => {
477                    tracing::warn!(
478                        dev = %dev,
479                        iso = %iso_path.display(),
480                        error = %e,
481                        "iso-parser: losetup attach exec failed; giving up"
482                    );
483                    return None;
484                }
485            }
486        }
487        tracing::warn!(
488            iso = %iso_path.display(),
489            "iso-parser: exhausted /dev/loop0..15 without a free device; cannot mount ISO"
490        );
491        None
492    }
493}
494
495impl Default for OsIsoEnvironment {
496    fn default() -> Self {
497        Self::new()
498    }
499}
500
501impl IsoEnvironment for OsIsoEnvironment {
502    fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
503        let mut entries = std::fs::read_dir(path)?
504            .map(|e| e.map(|entry| entry.path()))
505            .collect::<Result<Vec<_>, _>>()?;
506        entries.sort();
507        Ok(entries)
508    }
509
510    fn exists(&self, path: &std::path::Path) -> bool {
511        path.exists()
512    }
513
514    fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
515        std::fs::metadata(path)
516    }
517
518    fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
519        use std::process::Command;
520
521        // Generate unique mount point
522        let iso_name = iso_path
523            .file_stem()
524            .and_then(|s| s.to_str())
525            .unwrap_or("iso");
526
527        let mount_point = self.mount_base.join(format!("mount_{iso_name}"));
528        std::fs::create_dir_all(&mount_point)?;
529
530        // Attempt 1: `mount -o loop,ro`. Works with util-linux; may not
531        // work with some busybox builds where the `loop` option is a
532        // no-op (it mounts the file as if it were a raw block device,
533        // which then fails). Try it first because it's one syscall on
534        // util-linux-based systems.
535        let output = Command::new("mount")
536            .args([
537                "-o",
538                "loop,ro",
539                // Windows install ISOs are UDF-primary with a tiny
540                // iso9660 fallback volume that contains only a readme.txt
541                // shim. Mount tries types left-to-right — UDF first so
542                // we get the real filesystem on Windows ISOs, then iso9660
543                // as the fallback for pure-iso9660 media (Alpine, Ubuntu,
544                // Fedora install/live ISOs).
545                "-t",
546                "udf,iso9660",
547                &iso_path.to_string_lossy(),
548                &mount_point.to_string_lossy(),
549            ])
550            .output();
551
552        // If that fails AND we have `losetup` available, fall through to
553        // the explicit loop-setup path below. Verify by checking if the
554        // mount point now contains anything (mount silently succeeds with
555        // nothing mounted under certain busybox builds — test by listing).
556        let loop_attempt_ok = match &output {
557            Ok(out) if out.status.success() => {
558                // Verify the mount actually took by checking for directory
559                // entries. An empty dir after a "successful" mount means
560                // busybox loop-mode didn't work.
561                std::fs::read_dir(&mount_point)
562                    .ok()
563                    .and_then(|mut entries| entries.next())
564                    .is_some()
565            }
566            _ => false,
567        };
568
569        if !loop_attempt_ok {
570            // Attempt 2: explicit losetup + mount. Handles both
571            // util-linux (`losetup -f --show`) and busybox (`losetup -f`
572            // prints the allocated device on stdout as a side effect;
573            // `--show` is a util-linux long option that busybox doesn't
574            // accept). Try util-linux form first; fall back to querying
575            // /dev/loop* after a bare `losetup -f` attach.
576            let loop_dev = Self::allocate_loop_device(iso_path);
577            if let Some(loop_dev) = loop_dev {
578                let mount_out = Command::new("mount")
579                    .args([
580                        "-r",
581                        "-t",
582                        "udf,iso9660",
583                        &loop_dev,
584                        &mount_point.to_string_lossy(),
585                    ])
586                    .output();
587                if let Ok(mo) = mount_out
588                    && mo.status.success()
589                {
590                    debug!(
591                        "Mounted {} via losetup {} -> {:?}",
592                        iso_path.display(),
593                        loop_dev,
594                        mount_point
595                    );
596                    return Ok(mount_point);
597                }
598                // losetup succeeded but mount failed — detach.
599                let _ = Command::new("losetup").args(["-d", &loop_dev]).output();
600            }
601        }
602
603        // Terminal dispatch. Attempt 1 may have reported status=success
604        // but left the mount_point empty (busybox loop-mode silently
605        // no-ops, or the filesystem type list didn't match the ISO's
606        // actual layout). In that case we previously returned
607        // Ok(empty mount_point) — callers then saw NoBootEntries
608        // instead of the real "mount didn't take" diagnostic. Re-verify
609        // the mount point has entries before accepting status.success.
610        let mount_point_populated = || {
611            std::fs::read_dir(&mount_point)
612                .ok()
613                .and_then(|mut entries| entries.next())
614                .is_some()
615        };
616        match output {
617            Ok(out) if out.status.success() && mount_point_populated() => {
618                debug!("Mounted {} to {:?}", iso_path.display(), mount_point);
619                Ok(mount_point)
620            }
621            Ok(out) => {
622                let stderr = String::from_utf8_lossy(&out.stderr);
623                // Explicit hint when mount claimed success but wrote
624                // nothing: typically a filesystem-type mismatch
625                // (Windows/macOS ISOs against older mount defaults).
626                let reason = if out.status.success() {
627                    format!(
628                        "mount claimed success but {} is empty — \
629                         filesystem type likely not auto-detected \
630                         (stderr: {})",
631                        mount_point.display(),
632                        stderr.trim()
633                    )
634                } else {
635                    stderr.to_string()
636                };
637                // Try fallback with fuseiso
638                let fuse_output = Command::new("fuseiso")
639                    .arg(iso_path.to_string_lossy().as_ref())
640                    .arg(mount_point.to_string_lossy().as_ref())
641                    .output();
642
643                match fuse_output {
644                    Ok(fuse_out) if fuse_out.status.success() && mount_point_populated() => {
645                        debug!("Mounted {} via fuseiso", iso_path.display());
646                        Ok(mount_point)
647                    }
648                    _ => {
649                        // Cleanup mount point on failure.
650                        let _ = std::fs::remove_dir(&mount_point);
651                        Err(IsoError::MountFailed(reason))
652                    }
653                }
654            }
655            Err(e) => Err(IsoError::Io(e)),
656        }
657    }
658
659    fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
660        use std::process::Command;
661
662        // Try umount first, then fusermount
663        let umount_result = Command::new("umount").arg(mount_point).output();
664
665        match umount_result {
666            Ok(out) if out.status.success() => {
667                let _ = std::fs::remove_dir(mount_point);
668                Ok(())
669            }
670            _ => {
671                // Try fusermount as fallback
672                let fusermount = Command::new("fusermount")
673                    .arg("-u")
674                    .arg(mount_point)
675                    .output();
676                match fusermount {
677                    Ok(out) if out.status.success() => {
678                        let _ = std::fs::remove_dir(mount_point);
679                        Ok(())
680                    }
681                    _ => Err(IsoError::MountFailed(format!(
682                        "Failed to unmount {}",
683                        mount_point.display()
684                    ))),
685                }
686            }
687        }
688    }
689}
690
691/// ISO Parser - main entry point for boot discovery
692///
693/// Generic over environment to allow testing without actual filesystem/mounts.
694pub struct IsoParser<E: IsoEnvironment> {
695    env: E,
696}
697
698impl<E: IsoEnvironment> IsoParser<E> {
699    /// Construct a parser bound to the given [`IsoEnvironment`].
700    /// Typically [`OsIsoEnvironment`] in production; a mock in tests.
701    pub fn new(env: E) -> Self {
702        Self { env }
703    }
704
705    /// Scan a directory for ISO files and extract boot entries.
706    ///
707    /// The `async` signature is retained for backwards source-compat
708    /// with callers that `.await` it; the function itself performs no
709    /// async work today.
710    ///
711    /// This is the legacy entry point — it discards per-ISO failures.
712    /// Prefer [`IsoParser::scan_directory_with_failures`] for new
713    /// callers that need to surface broken ISOs to the user (#456).
714    ///
715    /// # Errors
716    ///
717    /// Returns [`IsoError::PathTraversal`] if `path` escapes
718    /// `/` (degenerate), [`IsoError::Io`] on a filesystem read failure
719    /// during the ISO-file discovery walk, or [`IsoError::NoBootEntries`]
720    /// when every discovered ISO failed to yield entries (legacy
721    /// behavior — preserved for callers that still rely on it).
722    #[instrument(skip(self))]
723    pub async fn scan_directory(&self, path: &std::path::Path) -> Result<Vec<BootEntry>, IsoError> {
724        let report = self.scan_directory_with_failures(path).await?;
725        if report.entries.is_empty() {
726            // Preserve the legacy contract: "no usable entries" is a
727            // NoBootEntries error even when .iso files were found but
728            // all failed to parse.
729            return Err(IsoError::NoBootEntries(path.to_string_lossy().to_string()));
730        }
731        Ok(report.entries)
732    }
733
734    /// Scan a directory for `.iso` files, mount + parse each one, and
735    /// return a [`ScanReport`] with both successful entries and
736    /// per-file failures.
737    ///
738    /// Unlike [`IsoParser::scan_directory`], this does NOT return
739    /// [`IsoError::NoBootEntries`] when every on-disk ISO failed to
740    /// parse — instead it returns `Ok(ScanReport { entries: [],
741    /// failures: […] })`. `NoBootEntries` is reserved for the stricter
742    /// case "the walk found zero `.iso` files", which lets the caller
743    /// distinguish an empty stick from a stick full of broken ISOs.
744    /// (#456)
745    ///
746    /// # Errors
747    ///
748    /// Returns [`IsoError::PathTraversal`] if `path` escapes `/`,
749    /// [`IsoError::Io`] on a filesystem read failure during the walk,
750    /// or [`IsoError::NoBootEntries`] when zero `.iso` files were
751    /// found under `path`.
752    #[instrument(skip(self))]
753    #[allow(clippy::unused_async)]
754    pub async fn scan_directory_with_failures(
755        &self,
756        path: &std::path::Path,
757    ) -> Result<ScanReport, IsoError> {
758        let validated_path = self.env.validate_path(std::path::Path::new("/"), path)?;
759
760        debug!("Scanning directory: {:?}", validated_path);
761
762        let iso_files = self.find_iso_files(&validated_path)?;
763        let attempted = iso_files.len();
764
765        if attempted == 0 {
766            // Walk found zero `.iso` files — this is the only case we
767            // treat as "no ISOs". A directory that had files but they
768            // all failed to parse returns Ok with populated failures.
769            return Err(IsoError::NoBootEntries(
770                validated_path.to_string_lossy().to_string(),
771            ));
772        }
773
774        let mut entries = Vec::new();
775        let mut failures = Vec::new();
776
777        for iso_path in iso_files {
778            debug!("Processing ISO: {:?}", iso_path);
779
780            match self.process_iso(&iso_path).await {
781                Ok(mut iso_entries) => entries.append(&mut iso_entries),
782                Err(e) => {
783                    // Warn-level so silent-skip failures surface on the
784                    // serial console without operators needing debug
785                    // tracing. (#68) The failure is ALSO captured in
786                    // the ScanReport so TUIs can render a descriptive
787                    // row. (#456)
788                    tracing::warn!(
789                        iso = %iso_path.display(),
790                        error = %e,
791                        "iso-parser: skipped ISO (mount/parse failed)"
792                    );
793                    failures.push(ScanFailure {
794                        iso_path: iso_path.clone(),
795                        reason: sanitize_reason(&e.to_string()),
796                        kind: ScanFailureKind::from_iso_error(&e),
797                    });
798                }
799            }
800        }
801
802        tracing::info!(
803            root = %validated_path.display(),
804            found_isos = attempted,
805            extracted_entries = entries.len(),
806            skipped_isos = failures.len(),
807            "iso-parser: scan summary"
808        );
809
810        Ok(ScanReport { entries, failures })
811    }
812
813    /// Find all ISO files in a directory recursively
814    fn find_iso_files(&self, path: &std::path::Path) -> Result<Vec<PathBuf>, IsoError> {
815        let mut isos = Vec::new();
816
817        for entry in self.env.list_dir(path)? {
818            let entry_path = &entry;
819
820            // Recurse into subdirectories
821            if entry.is_dir() {
822                // Skip certain directories
823                let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
824
825                if !name.starts_with('.')
826                    && name != "proc"
827                    && name != "sys"
828                    && name != "dev"
829                    && let Ok(mut sub_isos) = self.find_iso_files(entry_path)
830                {
831                    isos.append(&mut sub_isos);
832                }
833            } else if let Some(ext) = entry.extension().and_then(|s| s.to_str())
834                && ext.eq_ignore_ascii_case("iso")
835            {
836                isos.push(entry.clone());
837            }
838        }
839
840        Ok(isos)
841    }
842
843    /// Process a single ISO: mount, extract boot entries, unmount
844    async fn process_iso(&self, iso_path: &Path) -> Result<Vec<BootEntry>, IsoError> {
845        let mount_point = self.env.mount_iso(iso_path)?;
846
847        let result = self.extract_boot_entries(&mount_point, iso_path).await;
848
849        // Always attempt unmount
850        let _ = self.env.unmount(&mount_point);
851
852        result
853    }
854
855    /// Extract boot entries from a mounted ISO.
856    #[allow(clippy::unused_async)]
857    async fn extract_boot_entries(
858        &self,
859        mount_point: &Path,
860        source_iso: &Path,
861    ) -> Result<Vec<BootEntry>, IsoError> {
862        let mut entries = Vec::new();
863
864        // Try each distribution pattern
865        entries.extend(self.try_arch_layout(mount_point, source_iso)?);
866        entries.extend(self.try_debian_layout(mount_point, source_iso)?);
867        entries.extend(self.try_fedora_layout(mount_point, source_iso)?);
868        entries.extend(self.try_windows_layout(mount_point, source_iso)?);
869
870        // Populate pretty_name from the mounted ISO's release files
871        // before the caller unmounts. Best-effort — if none of the
872        // known paths resolve, the field stays None and downstream UI
873        // falls back to the distribution-family label. (#119)
874        let pretty = read_pretty_name(mount_point);
875        if pretty.is_some() {
876            for entry in &mut entries {
877                entry.pretty_name.clone_from(&pretty);
878            }
879        }
880
881        Ok(entries)
882    }
883
884    /// Try Arch Linux layout: /boot/{vmlinuz,initrd.img}
885    fn try_arch_layout(
886        &self,
887        mount_point: &Path,
888        source_iso: &Path,
889    ) -> Result<Vec<BootEntry>, IsoError> {
890        let boot_dir = mount_point.join("boot");
891
892        if !self.env.exists(&boot_dir) {
893            return Ok(Vec::new());
894        }
895
896        let mut entries = Vec::new();
897
898        // Find kernel files (vmlinuz*)
899        for entry in self.env.list_dir(&boot_dir)? {
900            let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
901
902            if name.starts_with("vmlinuz") {
903                let kernel = entry.clone();
904                let mut initrd = boot_dir.join(format!(
905                    "initrd.img{}",
906                    name.strip_prefix("vmlinuz").unwrap_or("")
907                ));
908
909                // Try common initrd names
910                if !self.env.exists(&initrd) {
911                    initrd = boot_dir.join("initrd.img");
912                }
913                if !self.env.exists(&initrd) {
914                    initrd = boot_dir.join(format!(
915                        "initrd{}",
916                        name.strip_prefix("vmlinuz").unwrap_or("")
917                    ));
918                }
919
920                let has_initrd = self.env.exists(&initrd);
921
922                // Classify from the actual kernel filename — `boot/vmlinuz-lts`
923                // and `boot/vmlinuz-virt` are Alpine, not Arch, etc. This
924                // layout matches multiple distros that share the
925                // `/boot/vmlinuz*` convention; use the path classifier
926                // rather than a hardcoded `Distribution::Arch`. (#116)
927                let rel_kernel = kernel
928                    .strip_prefix(mount_point)
929                    .map(std::path::Path::to_path_buf)
930                    .map_err(|_| {
931                        IsoError::Io(std::io::Error::new(
932                            std::io::ErrorKind::InvalidData,
933                            "Kernel path escape",
934                        ))
935                    })?;
936                let distribution = Distribution::from_paths(&rel_kernel);
937                let label = match distribution {
938                    Distribution::Alpine => format!(
939                        "Alpine {}",
940                        name.strip_prefix("vmlinuz-").unwrap_or("").trim()
941                    ),
942                    Distribution::Arch => format!(
943                        "Arch Linux {}",
944                        name.strip_prefix("vmlinuz").unwrap_or("").trim()
945                    ),
946                    _ => format!(
947                        "Linux {}",
948                        name.strip_prefix("vmlinuz").unwrap_or("").trim()
949                    ),
950                };
951                // Kernel args: only set for actual Arch; leave empty for
952                // Alpine/unknown so the ISO's own boot config wins.
953                let kernel_args = if distribution == Distribution::Arch {
954                    Some(
955                        "archisobasedir=arch archiso_http_server=https://mirror.archlinux.org"
956                            .to_string(),
957                    )
958                } else {
959                    None
960                };
961
962                entries.push(BootEntry {
963                    label,
964                    kernel: rel_kernel,
965                    initrd: if has_initrd { Some(initrd) } else { None },
966                    kernel_args,
967                    distribution,
968                    source_iso: source_iso
969                        .file_name()
970                        .and_then(|n| n.to_str())
971                        .unwrap_or("unknown")
972                        .to_string(),
973                    pretty_name: None,
974                });
975            }
976        }
977
978        Ok(entries)
979    }
980
981    /// Try Debian/Ubuntu layout: /install/vmlinuz, /casper/initrd.lz
982    fn try_debian_layout(
983        &self,
984        mount_point: &Path,
985        source_iso: &Path,
986    ) -> Result<Vec<BootEntry>, IsoError> {
987        let mut entries = Vec::new();
988
989        // Debian-family ISOs have one or more of: /install (debian-
990        // installer), /casper (ubuntu live), /.disk/info (both), or
991        // /pool (package pool). Gate on at least one of those being
992        // present — without the gate, try_debian_layout also matches
993        // Alpine's /boot/vmlinuz-lts and produces spurious
994        // "Debian/Ubuntu" entries. (#122)
995        let debian_markers = [
996            mount_point.join("install"),
997            mount_point.join("casper"),
998            mount_point.join(".disk"),
999            mount_point.join("pool"),
1000            mount_point.join("dists"),
1001        ];
1002        if !debian_markers.iter().any(|p| self.env.exists(p)) {
1003            return Ok(entries);
1004        }
1005
1006        // Try multiple potential locations
1007        let search_paths = [
1008            mount_point.join("install"),
1009            mount_point.join("casper"),
1010            mount_point.join("boot"),
1011        ];
1012
1013        for search_dir in &search_paths {
1014            if !self.env.exists(search_dir) {
1015                continue;
1016            }
1017
1018            // Find vmlinuz
1019            for entry in self.env.list_dir(search_dir)? {
1020                let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
1021
1022                if name.starts_with("vmlinuz") {
1023                    let kernel = entry.clone();
1024
1025                    // Look for initrd in same directory or common locations
1026                    let initrd_names = ["initrd.lz", "initrd.gz", "initrd.img", "initrd"];
1027                    let mut found_initrd = None;
1028
1029                    for initrd_name in initrd_names {
1030                        let initrd_path = search_dir.join(initrd_name);
1031                        if self.env.exists(&initrd_path) {
1032                            found_initrd = Some(initrd_path);
1033                            break;
1034                        }
1035                    }
1036
1037                    // Also check casper filesystem.squashfs for live boot
1038                    let kernel_args = if search_dir == &mount_point.join("casper") {
1039                        Some(
1040                            "boot=casper locale=en_US.UTF-8 keyboard-configuration/layoutcode=us"
1041                                .to_string(),
1042                        )
1043                    } else {
1044                        None
1045                    };
1046
1047                    // Both casper and non-casper paths result in Debian family
1048                    entries.push(BootEntry {
1049                        label: format!(
1050                            "Debian/Ubuntu {}",
1051                            name.strip_prefix("vmlinuz").unwrap_or("").trim()
1052                        ),
1053                        kernel: kernel
1054                            .strip_prefix(mount_point)
1055                            .map(std::path::Path::to_path_buf)
1056                            .map_err(|_| {
1057                                IsoError::Io(std::io::Error::new(
1058                                    std::io::ErrorKind::InvalidData,
1059                                    "Kernel path escape",
1060                                ))
1061                            })?,
1062                        initrd: found_initrd
1063                            .map(|p| {
1064                                p.strip_prefix(mount_point)
1065                                    .map(std::path::Path::to_path_buf)
1066                                    .map_err(|_| {
1067                                        IsoError::Io(std::io::Error::new(
1068                                            std::io::ErrorKind::InvalidData,
1069                                            "Initrd path escape",
1070                                        ))
1071                                    })
1072                            })
1073                            .transpose()?,
1074                        kernel_args,
1075                        distribution: Distribution::Debian,
1076                        source_iso: source_iso
1077                            .file_name()
1078                            .and_then(|n| n.to_str())
1079                            .unwrap_or("unknown")
1080                            .to_string(),
1081                        pretty_name: None,
1082                    });
1083                }
1084            }
1085        }
1086
1087        Ok(entries)
1088    }
1089
1090    /// Try Fedora layout: /images/pxeboot/vmlinuz, /images/pxeboot/initrd.img
1091    fn try_fedora_layout(
1092        &self,
1093        mount_point: &Path,
1094        source_iso: &Path,
1095    ) -> Result<Vec<BootEntry>, IsoError> {
1096        let images_dir = mount_point.join("images").join("pxeboot");
1097
1098        if !self.env.exists(&images_dir) {
1099            // Try alternate: /isolinux/ (common Fedora live media)
1100            let alt_dir = mount_point.join("isolinux");
1101            if !self.env.exists(&alt_dir) {
1102                return Ok(Vec::new());
1103            }
1104            return self.process_fedora_isolinux(&alt_dir, mount_point, source_iso);
1105        }
1106
1107        let mut entries = Vec::new();
1108
1109        // Find kernel
1110        for entry in self.env.list_dir(&images_dir)? {
1111            let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
1112
1113            if name.starts_with("vmlinuz") {
1114                let kernel = entry.clone();
1115
1116                // Find matching initrd
1117                let version = name.strip_prefix("vmlinuz").unwrap_or("");
1118                let initrd_names = [
1119                    format!("initrd{version}.img"),
1120                    "initrd.img".to_string(),
1121                    format!("initrd{}.img", version.trim_end_matches('-')),
1122                ];
1123
1124                let mut found_initrd = None;
1125                for initrd_name in &initrd_names {
1126                    let initrd_path = images_dir.join(initrd_name);
1127                    if self.env.exists(&initrd_path) {
1128                        found_initrd = Some(initrd_path);
1129                        break;
1130                    }
1131                }
1132
1133                entries.push(BootEntry {
1134                    label: format!("Fedora {}", version.trim()),
1135                    kernel: kernel
1136                        .strip_prefix(mount_point)
1137                        .map(std::path::Path::to_path_buf)
1138                        .map_err(|_| {
1139                            IsoError::Io(std::io::Error::new(
1140                                std::io::ErrorKind::InvalidData,
1141                                "Kernel path escape",
1142                            ))
1143                        })?,
1144                    initrd: found_initrd
1145                        .map(|p| {
1146                            p.strip_prefix(mount_point)
1147                                .map(std::path::Path::to_path_buf)
1148                                .map_err(|_| {
1149                                    IsoError::Io(std::io::Error::new(
1150                                        std::io::ErrorKind::InvalidData,
1151                                        "Initrd path escape",
1152                                    ))
1153                                })
1154                        })
1155                        .transpose()?,
1156                    kernel_args: Some("inst.stage2=hd:LABEL=Fedora-39-x86_64".to_string()),
1157                    distribution: Distribution::Fedora,
1158                    source_iso: source_iso
1159                        .file_name()
1160                        .and_then(|n| n.to_str())
1161                        .unwrap_or("unknown")
1162                        .to_string(),
1163                    pretty_name: None,
1164                });
1165            }
1166        }
1167
1168        Ok(entries)
1169    }
1170
1171    fn process_fedora_isolinux(
1172        &self,
1173        isolinux_dir: &Path,
1174        mount_point: &Path,
1175        source_iso: &Path,
1176    ) -> Result<Vec<BootEntry>, IsoError> {
1177        let mut entries = Vec::new();
1178
1179        for entry in self.env.list_dir(isolinux_dir)? {
1180            let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
1181
1182            if name.starts_with("vmlinuz") {
1183                let kernel = entry.clone();
1184
1185                // Look for initrd in images directory
1186                let images_dir = mount_point.join("images");
1187                let initrd_path = images_dir.join("initrd.img");
1188
1189                entries.push(BootEntry {
1190                    label: format!(
1191                        "Fedora (isolinux) {}",
1192                        name.strip_prefix("vmlinuz").unwrap_or("").trim()
1193                    ),
1194                    kernel: kernel
1195                        .strip_prefix(mount_point)
1196                        .map(std::path::Path::to_path_buf)
1197                        .map_err(|_| {
1198                            IsoError::Io(std::io::Error::new(
1199                                std::io::ErrorKind::InvalidData,
1200                                "Kernel path escape",
1201                            ))
1202                        })?,
1203                    initrd: if self.env.exists(&initrd_path) {
1204                        Some(
1205                            initrd_path
1206                                .strip_prefix(mount_point)
1207                                .map(std::path::Path::to_path_buf)
1208                                .map_err(|_| {
1209                                    IsoError::Io(std::io::Error::new(
1210                                        std::io::ErrorKind::InvalidData,
1211                                        "Initrd path escape",
1212                                    ))
1213                                })?,
1214                        )
1215                    } else {
1216                        None
1217                    },
1218                    kernel_args: Some("inst.stage2=hd:LABEL=Fedora".to_string()),
1219                    distribution: Distribution::Fedora,
1220                    source_iso: source_iso
1221                        .file_name()
1222                        .and_then(|n| n.to_str())
1223                        .unwrap_or("unknown")
1224                        .to_string(),
1225                    pretty_name: None,
1226                });
1227            }
1228        }
1229
1230        Ok(entries)
1231    }
1232
1233    /// Detect Windows installer ISOs (Win10, Win11, Server). Emits a
1234    /// synthesized `BootEntry` so the ISO surfaces in rescue-tui's list
1235    /// with `Distribution::Windows` and the `NotKexecBootable` quirk —
1236    /// replaces the current behavior where Windows ISOs got silently
1237    /// skipped as `NoBootEntries`, which mismatched the `docs/
1238    /// compatibility/iso-matrix.md` + `iso-probe`'s explicit "not a
1239    /// kexec target" classification.
1240    ///
1241    /// Detection uses three independent markers (ANY match suffices):
1242    ///
1243    /// 1. `/bootmgr` — Windows NT loader, present since Vista on
1244    ///    installer and recovery media.
1245    /// 2. `/sources/boot.wim` — Windows PE boot image, the signature
1246    ///    of a Microsoft-shipped install ISO.
1247    /// 3. `/efi/microsoft/boot/` — UEFI boot directory with the
1248    ///    signed `bootmgfw.efi`.
1249    ///
1250    /// The synthesized `kernel` field points at `bootmgr` (or the
1251    /// EFI equivalent when `bootmgr` is absent). It's never passed to
1252    /// kexec — downstream code gates on the `NotKexecBootable` quirk
1253    /// surfaced by `iso-probe::lookup_quirks(Distribution::Windows)`.
1254    /// Using `bootmgr` as the semantic "kernel" makes the rendered
1255    /// evidence line ("kernel: bootmgr") self-explanatory.
1256    // `Result` parallels `try_arch_layout` / `try_debian_layout` / etc.
1257    // even though Windows detection uses only `env.exists()` (infallible
1258    // in this crate's IsoEnvironment shape) — keeps the caller site in
1259    // `extract_boot_entries` uniformly `?`-chained across all layouts.
1260    #[allow(clippy::unnecessary_wraps)]
1261    fn try_windows_layout(
1262        &self,
1263        mount_point: &Path,
1264        source_iso: &Path,
1265    ) -> Result<Vec<BootEntry>, IsoError> {
1266        let bootmgr = mount_point.join("bootmgr");
1267        let boot_wim = mount_point.join("sources/boot.wim");
1268        let efi_ms_boot = mount_point.join("efi/microsoft/boot");
1269        let bootmgfw_efi = mount_point.join("efi/boot/bootx64.efi");
1270
1271        let has_any_marker = self.env.exists(&bootmgr)
1272            || self.env.exists(&boot_wim)
1273            || self.env.exists(&efi_ms_boot);
1274        if !has_any_marker {
1275            return Ok(Vec::new());
1276        }
1277
1278        // Prefer `bootmgr` (the classic NT loader) as the synthetic
1279        // "kernel" path. Fall back to bootmgfw.efi / a synthetic marker
1280        // if a stripped-down ISO is missing bootmgr but still carries
1281        // sources/boot.wim (unusual but seen on some Windows PE rebuilds).
1282        let kernel_path = if self.env.exists(&bootmgr) {
1283            PathBuf::from("bootmgr")
1284        } else if self.env.exists(&bootmgfw_efi) {
1285            PathBuf::from("efi/boot/bootx64.efi")
1286        } else {
1287            PathBuf::from("sources/boot.wim")
1288        };
1289
1290        let label = "Windows (not kexec-bootable)".to_string();
1291
1292        Ok(vec![BootEntry {
1293            label,
1294            kernel: kernel_path,
1295            // Windows PE uses `boot.wim` as its "initrd equivalent" but
1296            // that's not something kexec could use — leave None.
1297            initrd: None,
1298            kernel_args: None,
1299            distribution: Distribution::Windows,
1300            source_iso: source_iso
1301                .file_name()
1302                .and_then(|n| n.to_str())
1303                .unwrap_or("unknown")
1304                .to_string(),
1305            pretty_name: None,
1306        }])
1307    }
1308}
1309
1310/// Best-effort "friendly" distro name for a mounted ISO.
1311///
1312/// Reads the first file in this priority order and returns the first
1313/// useful value found:
1314///
1315/// 1. `/etc/os-release` `PRETTY_NAME` — systemd convention; all
1316///    modern distros ship this (Ubuntu, Fedora, Rocky, Alma, Debian 12+,
1317///    openSUSE, Arch, `NixOS` 22+, Alpine 3.17+).
1318/// 2. `/lib/os-release` `PRETTY_NAME` — symlink target on some distros;
1319///    handled independently in case the `/etc` copy is missing.
1320/// 3. `/.disk/info` — single line of free text, Ubuntu + Debian live/install
1321///    media tradition since circa Debian 6. Form: "Ubuntu 24.04.2 LTS ...".
1322/// 4. `/etc/alpine-release` — single version string (e.g. "3.20.3") on
1323///    Alpine. We prepend "Alpine " so the returned value is self-contained.
1324///
1325/// Returns `None` if none of the paths exist or all attempts produce an
1326/// empty string. This is advisory — every caller must tolerate `None`
1327/// and fall back to the `Distribution`-family label.
1328#[must_use]
1329pub fn read_pretty_name(mount_point: &Path) -> Option<String> {
1330    for rel in ["etc/os-release", "lib/os-release", "usr/lib/os-release"] {
1331        if let Some(name) = read_os_release(&mount_point.join(rel)) {
1332            return Some(name);
1333        }
1334    }
1335    if let Some(first_line) = read_first_nonempty_line(&mount_point.join(".disk/info")) {
1336        return Some(first_line);
1337    }
1338    if let Some(version) = read_first_nonempty_line(&mount_point.join("etc/alpine-release")) {
1339        return Some(format!("Alpine Linux {version}"));
1340    }
1341    None
1342}
1343
1344/// Parse a systemd-style `os-release` file for the value of `PRETTY_NAME`.
1345/// Strips surrounding double quotes if present. Returns `None` on any
1346/// read error or if the key is missing / empty.
1347fn read_os_release(path: &Path) -> Option<String> {
1348    let content = std::fs::read_to_string(path).ok()?;
1349    parse_os_release_pretty_name(&content)
1350}
1351
1352/// Pure-string version of the `os-release` parser — split out so we can
1353/// unit-test without touching the filesystem.
1354#[must_use]
1355pub(crate) fn parse_os_release_pretty_name(content: &str) -> Option<String> {
1356    for line in content.lines() {
1357        let Some(rest) = line.strip_prefix("PRETTY_NAME=") else {
1358            continue;
1359        };
1360        // Strip surrounding " or ' (systemd spec allows either, and we
1361        // want to be forgiving of wild-in-the-field variants).
1362        let trimmed = rest
1363            .trim()
1364            .trim_matches(|c| c == '"' || c == '\'')
1365            .to_string();
1366        if trimmed.is_empty() {
1367            return None;
1368        }
1369        return Some(trimmed);
1370    }
1371    None
1372}
1373
1374/// Read the first non-empty trimmed line of a file. Used for free-text
1375/// release files (`/.disk/info`, `/etc/alpine-release`) that don't
1376/// follow the `KEY=VALUE` shape.
1377fn read_first_nonempty_line(path: &Path) -> Option<String> {
1378    let content = std::fs::read_to_string(path).ok()?;
1379    for line in content.lines() {
1380        let trimmed = line.trim();
1381        if !trimmed.is_empty() {
1382            return Some(trimmed.to_string());
1383        }
1384    }
1385    None
1386}
1387
1388#[cfg(test)]
1389#[allow(
1390    clippy::unwrap_used,
1391    clippy::expect_used,
1392    clippy::too_many_lines,
1393    clippy::missing_panics_doc,
1394    clippy::match_same_arms
1395)]
1396mod tests {
1397    use super::*;
1398    use std::collections::HashMap;
1399    use std::sync::Mutex;
1400
1401    /// Mock environment for testing without actual filesystem
1402    struct MockIsoEnvironment {
1403        files: HashMap<PathBuf, MockEntry>,
1404        mount_points: Mutex<Vec<PathBuf>>,
1405        /// Per-ISO mount failure injection for exercising the
1406        /// failure-surfacing path in [`IsoParser::scan_directory_with_failures`].
1407        /// Key = absolute ISO path, value = `MountFailed` reason string.
1408        mount_failures: HashMap<PathBuf, String>,
1409    }
1410
1411    #[derive(Debug, Clone)]
1412    enum MockEntry {
1413        File,
1414        Directory(Vec<PathBuf>),
1415    }
1416
1417    impl MockIsoEnvironment {
1418        fn new() -> Self {
1419            Self {
1420                files: HashMap::new(),
1421                mount_points: Mutex::new(Vec::new()),
1422                mount_failures: HashMap::new(),
1423            }
1424        }
1425
1426        /// Register an ISO path whose [`IsoEnvironment::mount_iso`] call
1427        /// should fail with [`IsoError::MountFailed`] carrying `reason`.
1428        /// Used by the scan-failure surfacing tests.
1429        fn with_failing_mount(mut self, iso_path: &Path, reason: &str) -> Self {
1430            self.mount_failures
1431                .insert(iso_path.to_path_buf(), reason.to_string());
1432            self
1433        }
1434
1435        fn with_iso(distribution: Distribution) -> Self {
1436            let mut env = Self::new();
1437
1438            let mount_base = PathBuf::from("/mock_mount");
1439
1440            match distribution {
1441                Distribution::Arch => {
1442                    // Arch: /boot/vmlinuz, /boot/initrd.img
1443                    env.files.insert(
1444                        mount_base.join("boot"),
1445                        MockEntry::Directory(vec![
1446                            mount_base.join("boot/vmlinuz"),
1447                            mount_base.join("boot/initrd.img"),
1448                        ]),
1449                    );
1450                    env.files
1451                        .insert(mount_base.join("boot/vmlinuz"), MockEntry::File);
1452                    env.files
1453                        .insert(mount_base.join("boot/initrd.img"), MockEntry::File);
1454                }
1455                Distribution::Debian => {
1456                    // Debian: /install/vmlinuz, /casper/initrd.lz
1457                    env.files.insert(
1458                        mount_base.join("install"),
1459                        MockEntry::Directory(vec![mount_base.join("install/vmlinuz")]),
1460                    );
1461                    env.files
1462                        .insert(mount_base.join("install/vmlinuz"), MockEntry::File);
1463                    env.files.insert(
1464                        mount_base.join("casper"),
1465                        MockEntry::Directory(vec![
1466                            mount_base.join("casper/initrd.lz"),
1467                            mount_base.join("casper/filesystem.squashfs"),
1468                        ]),
1469                    );
1470                    env.files
1471                        .insert(mount_base.join("casper/initrd.lz"), MockEntry::File);
1472                    env.files.insert(
1473                        mount_base.join("casper/filesystem.squashfs"),
1474                        MockEntry::File,
1475                    );
1476                }
1477                Distribution::Fedora => {
1478                    // Fedora: /images/pxeboot/vmlinuz, /images/pxeboot/initrd.img
1479                    env.files.insert(
1480                        mount_base.join("images"),
1481                        MockEntry::Directory(vec![mount_base.join("images/pxeboot")]),
1482                    );
1483                    env.files.insert(
1484                        mount_base.join("images/pxeboot"),
1485                        MockEntry::Directory(vec![
1486                            mount_base.join("images/pxeboot/vmlinuz"),
1487                            mount_base.join("images/pxeboot/initrd.img"),
1488                        ]),
1489                    );
1490                    env.files
1491                        .insert(mount_base.join("images/pxeboot/vmlinuz"), MockEntry::File);
1492                    env.files.insert(
1493                        mount_base.join("images/pxeboot/initrd.img"),
1494                        MockEntry::File,
1495                    );
1496                }
1497                // New variants reuse existing mock fixtures by analogue
1498                // (Alpine + NixOS behave like Arch at the path layer; RedHat
1499                // like Fedora). The scan_directory tests only care about the
1500                // 3 original categories, so nothing new to stage here.
1501                Distribution::RedHat | Distribution::Alpine | Distribution::NixOS => {}
1502                Distribution::Windows => {
1503                    // Windows installer: /bootmgr + /sources/boot.wim +
1504                    // /efi/microsoft/boot/. We stage all three canonical
1505                    // markers so try_windows_layout's any-marker detection
1506                    // logic gets exercised from multiple angles.
1507                    env.files
1508                        .insert(mount_base.join("bootmgr"), MockEntry::File);
1509                    env.files.insert(
1510                        mount_base.join("sources"),
1511                        MockEntry::Directory(vec![mount_base.join("sources/boot.wim")]),
1512                    );
1513                    env.files
1514                        .insert(mount_base.join("sources/boot.wim"), MockEntry::File);
1515                    env.files.insert(
1516                        mount_base.join("efi"),
1517                        MockEntry::Directory(vec![mount_base.join("efi/microsoft")]),
1518                    );
1519                    env.files.insert(
1520                        mount_base.join("efi/microsoft"),
1521                        MockEntry::Directory(vec![mount_base.join("efi/microsoft/boot")]),
1522                    );
1523                    env.files.insert(
1524                        mount_base.join("efi/microsoft/boot"),
1525                        MockEntry::Directory(vec![]),
1526                    );
1527                }
1528                Distribution::Unknown => {}
1529            }
1530
1531            // Add ISO file in parent directory
1532            env.files.insert(
1533                PathBuf::from("/isos"),
1534                MockEntry::Directory(vec![PathBuf::from("/isos/test.iso")]),
1535            );
1536            env.files
1537                .insert(PathBuf::from("/isos/test.iso"), MockEntry::File);
1538
1539            env
1540        }
1541    }
1542
1543    impl IsoEnvironment for MockIsoEnvironment {
1544        fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
1545            match self.files.get(path) {
1546                Some(MockEntry::Directory(entries)) => Ok(entries.clone()),
1547                Some(MockEntry::File) => Err(std::io::Error::new(
1548                    std::io::ErrorKind::NotFound,
1549                    "Not a directory",
1550                )),
1551                None => Ok(Vec::new()), // Empty for non-existent
1552            }
1553        }
1554
1555        fn exists(&self, path: &std::path::Path) -> bool {
1556            self.files.contains_key(path)
1557        }
1558
1559        fn metadata(&self, _path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
1560            // Fail closed: the previous implementation returned the real
1561            // metadata of `std::env::temp_dir()` for any path that existed
1562            // in the mock — which silently made size/mtime assertions pass
1563            // on fake data (they'd read /tmp's values, not the mock's).
1564            //
1565            // Since no caller in the workspace uses IsoEnvironment::metadata
1566            // today (the trait method is currently unused, per #138 audit),
1567            // and std::fs::Metadata has no public constructor, there is no
1568            // safe way to return a synthesized value from pure mock data.
1569            //
1570            // If a future caller needs this method, the correct fix is to
1571            // add real size/mtime fields to MockEntry and return them via a
1572            // wrapper type — not to paper over the hazard with /tmp values.
1573            Err(std::io::Error::new(
1574                std::io::ErrorKind::Unsupported,
1575                "MockIsoEnvironment::metadata is not implemented — see #138 for the design note",
1576            ))
1577        }
1578
1579        fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
1580            if let Some(reason) = self.mount_failures.get(iso_path) {
1581                return Err(IsoError::MountFailed(reason.clone()));
1582            }
1583            let mount_point = PathBuf::from(format!(
1584                "/mock_mount/{}",
1585                iso_path
1586                    .file_stem()
1587                    .and_then(|s| s.to_str())
1588                    .unwrap_or("iso")
1589            ));
1590
1591            // Poison-safe lock: if a prior test panicked while holding the
1592            // mutex, `.lock()` returns `Err(PoisonError)`. `into_inner()`
1593            // recovers the guarded value so we don't cascade-fail every
1594            // subsequent test that happens to hit this path. Mock state is
1595            // append-or-trim only, so partial updates from a poisoned
1596            // critical section are safe to observe.
1597            self.mount_points
1598                .lock()
1599                .unwrap_or_else(std::sync::PoisonError::into_inner)
1600                .push(mount_point.clone());
1601            Ok(mount_point)
1602        }
1603
1604        fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
1605            let mut points = self
1606                .mount_points
1607                .lock()
1608                .unwrap_or_else(std::sync::PoisonError::into_inner);
1609            points.retain(|p| p != mount_point);
1610            Ok(())
1611        }
1612    }
1613
1614    #[test]
1615    fn test_path_traversal_blocked() {
1616        let env = MockIsoEnvironment::new();
1617        let result = env.validate_path(
1618            PathBuf::from("/safe").as_path(),
1619            PathBuf::from("/safe/../../../etc/passwd").as_path(),
1620        );
1621
1622        assert!(result.is_err());
1623        match result {
1624            Err(IsoError::PathTraversal(_)) => {}
1625            _ => panic!("Expected PathTraversal error"),
1626        }
1627    }
1628
1629    #[test]
1630    fn test_path_allowed() {
1631        let env = MockIsoEnvironment::new();
1632        let result = env.validate_path(
1633            PathBuf::from("/safe").as_path(),
1634            PathBuf::from("/safe/subdir/file").as_path(),
1635        );
1636
1637        assert!(result.is_ok());
1638    }
1639
1640    #[test]
1641    fn test_path_outside_base_rejected() {
1642        // Regression for #56: validate_path used to silently return Ok
1643        // when strip_prefix(base) failed, accepting absolute paths to
1644        // anywhere on the filesystem.
1645        let env = MockIsoEnvironment::new();
1646        let result = env.validate_path(
1647            PathBuf::from("/mnt/iso").as_path(),
1648            PathBuf::from("/etc/passwd").as_path(),
1649        );
1650        assert!(matches!(result, Err(IsoError::PathTraversal(_))));
1651    }
1652
1653    #[test]
1654    fn test_path_sibling_of_base_rejected() {
1655        // /safe2 starts with the string "/safe" but is NOT under /safe —
1656        // Path::starts_with respects component boundaries, not prefix match.
1657        let env = MockIsoEnvironment::new();
1658        let result = env.validate_path(
1659            PathBuf::from("/safe").as_path(),
1660            PathBuf::from("/safe2/file").as_path(),
1661        );
1662        assert!(matches!(result, Err(IsoError::PathTraversal(_))));
1663    }
1664
1665    #[test]
1666    fn test_dots_embedded_in_filename_are_not_traversal() {
1667        // Regression for the nightly-fuzz panic on 2026-04-19..23:
1668        // filenames like `..\x03|.` or `foo..bar` contain `..` as a
1669        // substring but are single legitimate path components (no `/`
1670        // separator around the dots). validate_path correctly accepts
1671        // them because `Path::components()` reports them as Normal,
1672        // not ParentDir. Real ISOs in the wild can carry such
1673        // filenames; rejecting them would block legitimate extraction.
1674        let env = MockIsoEnvironment::new();
1675
1676        for weird_name in [
1677            "foo..bar",
1678            "..\x03|.",
1679            "..hidden",
1680            "trailing..",
1681            "..".repeat(4).as_str(),
1682        ] {
1683            let candidate = PathBuf::from(format!("/safe/{weird_name}"));
1684            let result = env.validate_path(PathBuf::from("/safe").as_path(), candidate.as_path());
1685            // filenames that are LITERALLY ".." are ParentDir and
1686            // should reject; anything else with embedded dots is a
1687            // Normal component and should pass through.
1688            if weird_name == ".." {
1689                assert!(
1690                    matches!(result, Err(IsoError::PathTraversal(_))),
1691                    "literal `..` must reject, got {result:?} for {weird_name:?}"
1692                );
1693            } else {
1694                assert!(
1695                    result.is_ok(),
1696                    "`..`-substring but not a ParentDir component must pass: {weird_name:?} got {result:?}"
1697                );
1698            }
1699        }
1700    }
1701
1702    #[test]
1703    fn test_parent_dir_in_middle_of_path_rejected() {
1704        // Genuine traversal: `..` as a path component between
1705        // `/` boundaries. validate_path catches this via
1706        // `Component::ParentDir` detection.
1707        let env = MockIsoEnvironment::new();
1708        let result = env.validate_path(
1709            PathBuf::from("/safe").as_path(),
1710            PathBuf::from("/safe/a/../b").as_path(),
1711        );
1712        assert!(matches!(result, Err(IsoError::PathTraversal(_))));
1713    }
1714
1715    #[tokio::test]
1716    async fn test_arch_detection() {
1717        let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
1718        let parser = IsoParser::new(mock);
1719
1720        let mount_base = PathBuf::from("/mock_mount");
1721        let entries = parser
1722            .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1723            .await
1724            .unwrap();
1725
1726        // Should find at least the Arch entry (might also find via other layouts that scan /boot)
1727        assert!(!entries.is_empty());
1728        assert!(entries.iter().any(|e| e.distribution == Distribution::Arch));
1729        assert!(
1730            entries
1731                .iter()
1732                .any(|e| e.kernel.to_string_lossy().contains("vmlinuz"))
1733        );
1734    }
1735
1736    #[tokio::test]
1737    async fn test_debian_detection() {
1738        let mock = MockIsoEnvironment::with_iso(Distribution::Debian);
1739        let parser = IsoParser::new(mock);
1740
1741        let mount_base = PathBuf::from("/mock_mount");
1742        let entries = parser
1743            .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1744            .await
1745            .unwrap();
1746
1747        assert!(!entries.is_empty());
1748        assert!(
1749            entries
1750                .iter()
1751                .any(|e| e.distribution == Distribution::Debian)
1752        );
1753    }
1754
1755    #[tokio::test]
1756    async fn test_fedora_detection() {
1757        let mock = MockIsoEnvironment::with_iso(Distribution::Fedora);
1758        let parser = IsoParser::new(mock);
1759
1760        let mount_base = PathBuf::from("/mock_mount");
1761        let entries = parser
1762            .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1763            .await
1764            .unwrap();
1765
1766        assert!(!entries.is_empty());
1767        assert!(
1768            entries
1769                .iter()
1770                .any(|e| e.distribution == Distribution::Fedora)
1771        );
1772    }
1773
1774    #[test]
1775    fn test_distribution_from_paths() {
1776        assert_eq!(
1777            Distribution::from_paths(PathBuf::from("/boot/vmlinuz").as_path()),
1778            Distribution::Arch
1779        );
1780        assert_eq!(
1781            Distribution::from_paths(PathBuf::from("/casper/vmlinuz").as_path()),
1782            Distribution::Debian
1783        );
1784        assert_eq!(
1785            Distribution::from_paths(PathBuf::from("/images/pxeboot/vmlinuz").as_path()),
1786            Distribution::Fedora
1787        );
1788    }
1789
1790    #[test]
1791    fn test_boot_entry_serialization() {
1792        let entry = BootEntry {
1793            label: "Test Linux".to_string(),
1794            kernel: PathBuf::from("boot/vmlinuz"),
1795            initrd: Some(PathBuf::from("boot/initrd.img")),
1796            kernel_args: Some("quiet".to_string()),
1797            distribution: Distribution::Arch,
1798            source_iso: "test.iso".to_string(),
1799            pretty_name: None,
1800        };
1801
1802        let json = serde_json::to_string(&entry).unwrap();
1803        let decoded: BootEntry = serde_json::from_str(&json).unwrap();
1804
1805        assert_eq!(decoded.label, "Test Linux");
1806        assert_eq!(decoded.distribution, Distribution::Arch);
1807    }
1808
1809    // ---- #119: pretty-name detection --------------------------------
1810
1811    #[test]
1812    fn parse_pretty_name_systemd_shape() {
1813        let content = r#"
1814NAME="Ubuntu"
1815VERSION_ID="24.04"
1816PRETTY_NAME="Ubuntu 24.04.2 LTS (Noble Numbat)"
1817ID=ubuntu
1818"#;
1819        assert_eq!(
1820            parse_os_release_pretty_name(content).as_deref(),
1821            Some("Ubuntu 24.04.2 LTS (Noble Numbat)"),
1822        );
1823    }
1824
1825    #[test]
1826    fn parse_pretty_name_strips_single_quotes() {
1827        let content = "PRETTY_NAME='Alpine Linux v3.20'";
1828        assert_eq!(
1829            parse_os_release_pretty_name(content).as_deref(),
1830            Some("Alpine Linux v3.20"),
1831        );
1832    }
1833
1834    #[test]
1835    fn parse_pretty_name_unquoted_value() {
1836        // Some distros omit the quotes; spec allows either.
1837        let content = "PRETTY_NAME=Arch Linux";
1838        assert_eq!(
1839            parse_os_release_pretty_name(content).as_deref(),
1840            Some("Arch Linux"),
1841        );
1842    }
1843
1844    #[test]
1845    fn parse_pretty_name_empty_returns_none() {
1846        assert!(parse_os_release_pretty_name("PRETTY_NAME=\"\"").is_none());
1847        assert!(parse_os_release_pretty_name("").is_none());
1848    }
1849
1850    #[test]
1851    fn parse_pretty_name_missing_returns_none() {
1852        let content = "NAME=\"Ubuntu\"\nID=ubuntu";
1853        assert!(parse_os_release_pretty_name(content).is_none());
1854    }
1855
1856    #[test]
1857    fn parse_pretty_name_first_match_wins() {
1858        // Defensive: if a file has two PRETTY_NAME lines, take the first.
1859        let content = "PRETTY_NAME=\"First\"\nPRETTY_NAME=\"Second\"";
1860        assert_eq!(
1861            parse_os_release_pretty_name(content).as_deref(),
1862            Some("First"),
1863        );
1864    }
1865
1866    #[test]
1867    fn read_pretty_name_finds_etc_os_release() {
1868        let tmp = tempfile::tempdir().unwrap();
1869        std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1870        std::fs::write(
1871            tmp.path().join("etc/os-release"),
1872            "PRETTY_NAME=\"Rocky Linux 9.3 (Blue Onyx)\"\n",
1873        )
1874        .unwrap();
1875        assert_eq!(
1876            read_pretty_name(tmp.path()).as_deref(),
1877            Some("Rocky Linux 9.3 (Blue Onyx)"),
1878        );
1879    }
1880
1881    #[test]
1882    fn read_pretty_name_falls_back_to_disk_info() {
1883        let tmp = tempfile::tempdir().unwrap();
1884        std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
1885        std::fs::write(
1886            tmp.path().join(".disk/info"),
1887            "Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)\n",
1888        )
1889        .unwrap();
1890        assert_eq!(
1891            read_pretty_name(tmp.path()).as_deref(),
1892            Some("Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)"),
1893        );
1894    }
1895
1896    #[test]
1897    fn read_pretty_name_alpine_release_prepends_alpine_linux() {
1898        let tmp = tempfile::tempdir().unwrap();
1899        std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1900        std::fs::write(tmp.path().join("etc/alpine-release"), "3.20.3\n").unwrap();
1901        assert_eq!(
1902            read_pretty_name(tmp.path()).as_deref(),
1903            Some("Alpine Linux 3.20.3"),
1904        );
1905    }
1906
1907    #[test]
1908    fn read_pretty_name_prefers_etc_over_lib() {
1909        let tmp = tempfile::tempdir().unwrap();
1910        std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1911        std::fs::create_dir_all(tmp.path().join("usr/lib")).unwrap();
1912        std::fs::write(
1913            tmp.path().join("etc/os-release"),
1914            "PRETTY_NAME=\"Etc Wins\"\n",
1915        )
1916        .unwrap();
1917        std::fs::write(
1918            tmp.path().join("usr/lib/os-release"),
1919            "PRETTY_NAME=\"Lib Loses\"\n",
1920        )
1921        .unwrap();
1922        assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Etc Wins"),);
1923    }
1924
1925    #[test]
1926    fn read_pretty_name_returns_none_for_empty_mount() {
1927        let tmp = tempfile::tempdir().unwrap();
1928        assert!(read_pretty_name(tmp.path()).is_none());
1929    }
1930
1931    #[test]
1932    fn read_pretty_name_skips_empty_disk_info_line() {
1933        let tmp = tempfile::tempdir().unwrap();
1934        std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
1935        std::fs::write(tmp.path().join(".disk/info"), "\n\n   \nDebian 12.8\n").unwrap();
1936        assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Debian 12.8"),);
1937    }
1938
1939    /// `MockIsoEnvironment::metadata` must fail closed — previously it
1940    /// returned the real metadata of `std::env::temp_dir()` for any path
1941    /// the mock knew about, which silently validated size/mtime assertions
1942    /// against `/tmp` values instead of mock data. Regression from #138.
1943    #[test]
1944    fn mock_metadata_fails_closed() {
1945        let env = MockIsoEnvironment::new();
1946        let err = env
1947            .metadata(std::path::Path::new("/mock_mount/boot/vmlinuz"))
1948            .expect_err("mock metadata() must surface an error");
1949        assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
1950    }
1951
1952    /// Poisoned mount-points mutex must not cascade. Simulate a poisoning
1953    /// by panicking inside a lock-holding scope and confirm subsequent
1954    /// `mount_iso` / `unmount` calls still succeed. Regression from #138.
1955    #[test]
1956    fn mock_mount_lock_recovers_from_poison() {
1957        use std::sync::Arc;
1958        let env = Arc::new(MockIsoEnvironment::new());
1959        // Force a poisoned lock by panicking inside a critical section on
1960        // a scoped thread. The spawned thread's join result is expected
1961        // to be Err (the panic); that's what poisons the Mutex.
1962        let env_for_thread = env.clone();
1963        let join = std::thread::spawn(move || {
1964            let _guard = env_for_thread.mount_points.lock().unwrap();
1965            panic!("deliberately poisoning the mutex for this test");
1966        })
1967        .join();
1968        assert!(join.is_err(), "helper thread must have panicked");
1969
1970        // Now verify the mock still functions — mount + unmount should
1971        // succeed without panicking via lock recovery.
1972        let iso = std::path::Path::new("/isos/test.iso");
1973        let mount = env
1974            .mount_iso(iso)
1975            .expect("mount_iso must recover from poison");
1976        env.unmount(&mount)
1977            .expect("unmount must recover from poison");
1978    }
1979
1980    // ---- Windows layout detection (was silently skipped before) ----
1981
1982    #[tokio::test]
1983    async fn extract_boot_entries_detects_windows_installer() {
1984        let mock = MockIsoEnvironment::with_iso(Distribution::Windows);
1985        let parser = IsoParser::new(mock);
1986
1987        let mount_base = PathBuf::from("/mock_mount");
1988        let entries = parser
1989            .extract_boot_entries(&mount_base, &PathBuf::from("Win11_25H2.iso"))
1990            .await
1991            .expect("Windows ISO should now produce a BootEntry instead of empty");
1992
1993        assert!(
1994            !entries.is_empty(),
1995            "Windows ISO must produce at least one entry"
1996        );
1997        let win = entries
1998            .iter()
1999            .find(|e| e.distribution == Distribution::Windows)
2000            .expect("one of the entries must be Distribution::Windows");
2001        assert_eq!(win.kernel.to_string_lossy(), "bootmgr");
2002        assert!(win.initrd.is_none());
2003        assert_eq!(win.kernel_args, None);
2004        assert!(win.label.contains("Windows"));
2005        assert!(win.source_iso.contains("Win11"));
2006    }
2007
2008    #[tokio::test]
2009    async fn try_windows_layout_declines_on_linux_layouts() {
2010        // Arch mock has no Windows markers; try_windows_layout must
2011        // decline (return empty) rather than synthesize an entry.
2012        let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
2013        let parser = IsoParser::new(mock);
2014
2015        let mount_base = PathBuf::from("/mock_mount");
2016        let entries = parser
2017            .extract_boot_entries(&mount_base, &PathBuf::from("arch.iso"))
2018            .await
2019            .expect("Arch ISO must produce entries");
2020
2021        // No Windows-tagged entries should sneak in.
2022        assert!(
2023            !entries
2024                .iter()
2025                .any(|e| e.distribution == Distribution::Windows),
2026            "Windows detector must not fire on Arch fixture"
2027        );
2028    }
2029
2030    #[test]
2031    fn windows_boot_entry_has_not_kexec_bootable_quirk_in_iso_probe() {
2032        // Contract between iso-parser and iso-probe: when iso-parser emits
2033        // Distribution::Windows, iso-probe's lookup_quirks returns
2034        // NotKexecBootable. This test lives here (iso-parser side) so
2035        // the pairing is guarded end-to-end even if iso-probe internals
2036        // change — the public Distribution::Windows arm is stable.
2037        //
2038        // We don't depend on iso-probe from this crate (cyclic), so this
2039        // test asserts the metadata iso-parser produces is the shape
2040        // iso-probe's mapping expects (a Windows enum variant an
2041        // external crate can pattern-match on).
2042        let iso_distro = Distribution::Windows;
2043        assert!(matches!(iso_distro, Distribution::Windows));
2044    }
2045
2046    // ---- #456 — ScanReport / ScanFailure surfacing ----
2047
2048    #[test]
2049    fn sanitize_reason_trims_whitespace() {
2050        assert_eq!(sanitize_reason("  hello  "), "hello");
2051    }
2052
2053    #[test]
2054    fn sanitize_reason_replaces_control_chars_with_spaces() {
2055        // Newlines, tabs, and C0 controls all become single spaces so
2056        // the TUI's line-layout math doesn't break on multi-line error
2057        // strings (common from mount's stderr).
2058        let input = "mount failed:\nwrong fs type\tor bad\x01option";
2059        let out = sanitize_reason(input);
2060        assert!(!out.contains('\n'));
2061        assert!(!out.contains('\t'));
2062        assert!(!out.contains('\x01'));
2063        assert!(out.contains("mount failed"));
2064        assert!(out.contains("wrong fs type"));
2065    }
2066
2067    #[test]
2068    fn sanitize_reason_preserves_utf8() {
2069        let out = sanitize_reason("données non prises en charge — système Win32 ≠ ext4");
2070        assert!(out.contains("données"));
2071        assert!(out.contains('≠'));
2072    }
2073
2074    #[test]
2075    fn sanitize_reason_truncates_at_char_boundary() {
2076        // Long string with multibyte chars near the truncation point
2077        // must not split a char.
2078        let long = "é".repeat(200); // 400 bytes, well over MAX_REASON_LEN
2079        let out = sanitize_reason(&long);
2080        // Must end with the ellipsis we appended.
2081        assert!(
2082            out.ends_with('…'),
2083            "truncated output must end with …, got {out}"
2084        );
2085        // Must be valid UTF-8 (implicit — Rust guarantees this for String).
2086        assert!(out.chars().all(|c| c == 'é' || c == '…'));
2087    }
2088
2089    #[test]
2090    fn scan_failure_kind_maps_from_iso_error() {
2091        assert_eq!(
2092            ScanFailureKind::from_iso_error(&IsoError::MountFailed("x".into())),
2093            ScanFailureKind::MountFailed
2094        );
2095        assert_eq!(
2096            ScanFailureKind::from_iso_error(&IsoError::NoBootEntries("x".into())),
2097            ScanFailureKind::NoBootEntries
2098        );
2099        assert_eq!(
2100            ScanFailureKind::from_iso_error(&IsoError::Io(std::io::Error::other("io"))),
2101            ScanFailureKind::IoError
2102        );
2103        // PathTraversal is not a per-file error; map defensively to IoError.
2104        assert_eq!(
2105            ScanFailureKind::from_iso_error(&IsoError::PathTraversal("x".into())),
2106            ScanFailureKind::IoError
2107        );
2108    }
2109
2110    /// Build a MockIsoEnvironment with `/isos/` containing `a.iso` and
2111    /// `b.iso` — `a.iso` mounts successfully with an Arch layout;
2112    /// `b.iso` can be configured to fail via `with_failing_mount`.
2113    ///
2114    /// The `/mock_mount/a` subtree is populated with an Arch-style
2115    /// layout so `a.iso` parses; `b.iso` mounts to `/mock_mount/b`
2116    /// which is intentionally empty (so even if b.iso mounts, it
2117    /// produces no entries — callers that want a mount-failure
2118    /// specifically must call `with_failing_mount`).
2119    fn mock_with_two_isos() -> MockIsoEnvironment {
2120        let mut env = MockIsoEnvironment::new();
2121        // Register the top-level /isos directory.
2122        env.files.insert(
2123            PathBuf::from("/isos"),
2124            MockEntry::Directory(vec![
2125                PathBuf::from("/isos/a.iso"),
2126                PathBuf::from("/isos/b.iso"),
2127            ]),
2128        );
2129        env.files
2130            .insert(PathBuf::from("/isos/a.iso"), MockEntry::File);
2131        env.files
2132            .insert(PathBuf::from("/isos/b.iso"), MockEntry::File);
2133
2134        // Arch layout under /mock_mount/a (matches mount_iso's
2135        // filename-based mount-point derivation).
2136        let a_root = PathBuf::from("/mock_mount/a");
2137        env.files.insert(
2138            a_root.clone(),
2139            MockEntry::Directory(vec![a_root.join("boot")]),
2140        );
2141        env.files.insert(
2142            a_root.join("boot"),
2143            MockEntry::Directory(vec![
2144                a_root.join("boot/vmlinuz"),
2145                a_root.join("boot/initrd.img"),
2146            ]),
2147        );
2148        env.files
2149            .insert(a_root.join("boot/vmlinuz"), MockEntry::File);
2150        env.files
2151            .insert(a_root.join("boot/initrd.img"), MockEntry::File);
2152
2153        env
2154    }
2155
2156    #[tokio::test]
2157    async fn scan_directory_with_failures_empty_dir_errors_no_boot_entries() {
2158        // Walk found zero .iso files — still an error so callers can
2159        // distinguish empty-stick from stick-with-broken-ISOs.
2160        let mut env = MockIsoEnvironment::new();
2161        env.files
2162            .insert(PathBuf::from("/isos"), MockEntry::Directory(Vec::new()));
2163        let parser = IsoParser::new(env);
2164        let err = parser
2165            .scan_directory_with_failures(Path::new("/isos"))
2166            .await
2167            .expect_err("empty dir must error");
2168        assert!(matches!(err, IsoError::NoBootEntries(_)));
2169    }
2170
2171    #[tokio::test]
2172    async fn scan_directory_with_failures_all_failed_returns_ok_with_failures() {
2173        // Directory has ISOs but every mount fails — we return Ok with
2174        // empty entries + populated failures so rescue-tui can show a
2175        // descriptive row per broken ISO instead of hiding them.
2176        let env = mock_with_two_isos()
2177            .with_failing_mount(
2178                Path::new("/isos/a.iso"),
2179                "mount: wrong fs type, bad option, bad superblock",
2180            )
2181            .with_failing_mount(Path::new("/isos/b.iso"), "mount: no loop device available");
2182        let parser = IsoParser::new(env);
2183
2184        let report = parser
2185            .scan_directory_with_failures(Path::new("/isos"))
2186            .await
2187            .expect("all-failed is Ok, not an error");
2188        assert!(report.entries.is_empty(), "no ISOs should parse");
2189        assert_eq!(report.failures.len(), 2);
2190        // Failures must carry path + sanitized reason + kind.
2191        let by_path: HashMap<_, _> = report
2192            .failures
2193            .iter()
2194            .map(|f| (f.iso_path.clone(), f.clone()))
2195            .collect();
2196        let a = &by_path[&PathBuf::from("/isos/a.iso")];
2197        assert_eq!(a.kind, ScanFailureKind::MountFailed);
2198        assert!(a.reason.contains("wrong fs type"));
2199        let b = &by_path[&PathBuf::from("/isos/b.iso")];
2200        assert_eq!(b.kind, ScanFailureKind::MountFailed);
2201        assert!(b.reason.contains("no loop device"));
2202    }
2203
2204    #[tokio::test]
2205    async fn scan_directory_with_failures_mixed_returns_entries_and_failures() {
2206        // a.iso mounts (Arch), b.iso fails — report carries both.
2207        let env = mock_with_two_isos()
2208            .with_failing_mount(Path::new("/isos/b.iso"), "mount: input/output error");
2209        let parser = IsoParser::new(env);
2210
2211        let report = parser
2212            .scan_directory_with_failures(Path::new("/isos"))
2213            .await
2214            .expect("mixed is Ok");
2215        assert!(
2216            !report.entries.is_empty(),
2217            "a.iso should produce at least one entry"
2218        );
2219        assert!(
2220            report.entries.iter().any(|e| e.source_iso == "a.iso"),
2221            "entries must include a.iso"
2222        );
2223        assert_eq!(report.failures.len(), 1);
2224        assert_eq!(report.failures[0].iso_path, PathBuf::from("/isos/b.iso"));
2225        assert!(report.failures[0].reason.contains("input/output"));
2226    }
2227
2228    #[tokio::test]
2229    async fn scan_directory_legacy_preserves_no_boot_entries_on_all_failed() {
2230        // The old scan_directory contract: when every on-disk ISO
2231        // fails to parse, the overall result is NoBootEntries. This
2232        // preserves the callsite behavior of any pre-#456 consumer
2233        // that pattern-matches on that error.
2234        let env = mock_with_two_isos()
2235            .with_failing_mount(Path::new("/isos/a.iso"), "mount fail a")
2236            .with_failing_mount(Path::new("/isos/b.iso"), "mount fail b");
2237        let parser = IsoParser::new(env);
2238
2239        let err = parser
2240            .scan_directory(Path::new("/isos"))
2241            .await
2242            .expect_err("legacy wrapper must error when all failed");
2243        assert!(matches!(err, IsoError::NoBootEntries(_)));
2244    }
2245}