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/// Represents a discovered boot entry from an ISO
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct BootEntry {
96    /// Label for the boot menu (e.g., "Arch Linux `x86_64`")
97    pub label: String,
98    /// Path to kernel (relative to ISO mount point)
99    pub kernel: PathBuf,
100    /// Path to initrd (relative to ISO mount point)
101    pub initrd: Option<PathBuf>,
102    /// Kernel command line parameters
103    pub kernel_args: Option<String>,
104    /// Distribution identifier
105    pub distribution: Distribution,
106    /// ISO filename (for reference)
107    pub source_iso: String,
108    /// Full distro name with version, extracted from `/etc/os-release`
109    /// (`PRETTY_NAME`) or fallback files on the mounted ISO. Populated
110    /// by `scan_directory`; `None` when none of the probe paths exist
111    /// (older installers or unfamiliar layouts). Surfaced as the
112    /// primary label in downstream UI when present so operators see
113    /// "Ubuntu 24.04.2 LTS (Noble Numbat)" instead of just "Ubuntu".
114    /// (#119)
115    #[serde(default)]
116    pub pretty_name: Option<String>,
117}
118
119/// Supported distribution families.
120///
121/// Ordering of detection matters: more specific matches (Alpine's
122/// `boot/vmlinuz-lts`, `NixOS`'s `boot/bzImage`, RHEL-family's `images/pxeboot`)
123/// must run before the broader ones (Arch's generic `boot/` heuristic).
124#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
125pub enum Distribution {
126    /// Arch Linux install media (`arch/boot/x86_64/vmlinuz-linux`).
127    Arch,
128    /// Debian and Ubuntu live/install media (`casper/`, `install.amd/`, `live/`).
129    Debian,
130    /// Fedora install media (`images/pxeboot/`).
131    Fedora,
132    /// RHEL / Rocky / `AlmaLinux` — same `images/pxeboot` layout as Fedora
133    /// but a distinct signing CA and stricter lockdown kexec policy.
134    RedHat,
135    /// Alpine Linux (`boot/vmlinuz-lts`).
136    Alpine,
137    /// `NixOS` install media (`boot/bzImage`).
138    NixOS,
139    /// Windows installer media. Recognized by `bootmgr`, `sources/boot.wim`,
140    /// or `efi/microsoft/boot/`. **Not kexec-bootable**: Windows uses a
141    /// fundamentally different boot protocol (NT loader, not Linux kernel).
142    /// Surfaced so the TUI can give a specific diagnostic rather than fail
143    /// silently.
144    Windows,
145    /// Layout not recognized.
146    Unknown,
147}
148
149impl Distribution {
150    /// Detect distribution from a kernel path observed inside an ISO.
151    #[must_use]
152    pub fn from_paths(kernel_path: &std::path::Path) -> Self {
153        let path_str = kernel_path.to_string_lossy().to_lowercase();
154
155        // Specific signals first — RHEL/Rocky/Alma carry distinctive markers in
156        // their ISO volume labels and filenames, but at this path-only layer
157        // we can't disambiguate from Fedora. Keep them separate variants; the
158        // caller can upgrade detection once volume-label sniffing is added.
159        if path_str.contains("bootmgr")
160            || path_str.contains("sources/boot.wim")
161            || path_str.contains("efi/microsoft")
162            || path_str.contains("windows")
163        {
164            Distribution::Windows
165        } else if path_str.contains("nixos") || path_str.ends_with("bzimage") {
166            Distribution::NixOS
167        } else if path_str.contains("alpine")
168            // Alpine's kernel filename suffix is the authoritative
169            // signal — `vmlinuz-lts` (Standard) and `vmlinuz-virt`
170            // (Virt edition). Kept case-insensitive; path_str is
171            // already lowercased. (#116)
172            || path_str.contains("vmlinuz-lts")
173            || path_str.contains("vmlinuz-virt")
174            || path_str.contains("initramfs-lts")
175            || path_str.contains("initramfs-virt")
176        {
177            Distribution::Alpine
178        } else if path_str.contains("rhel")
179            || path_str.contains("rocky")
180            || path_str.contains("almalinux")
181            || path_str.contains("centos")
182        {
183            Distribution::RedHat
184        } else if path_str.contains("fedora")
185            || path_str.contains("images")
186            || path_str.contains("pxeboot")
187        {
188            Distribution::Fedora
189        } else if path_str.contains("debian")
190            || path_str.contains("ubuntu")
191            || path_str.contains("casper")
192        {
193            Distribution::Debian
194        } else if path_str.contains("arch")
195            || (path_str.contains("boot")
196                && !path_str.contains("efi")
197                && !path_str.contains("images"))
198        {
199            Distribution::Arch
200        } else {
201            Distribution::Unknown
202        }
203    }
204}
205
206/// Environment abstraction for file system and OS operations
207///
208/// This trait enables unit testing without actual mounts by providing
209/// a mockable interface for filesystem access and process execution.
210pub trait IsoEnvironment: Send + Sync {
211    /// List files in a directory.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`std::io::Error`] on any read failure (missing path,
216    /// permission denied, I/O error mid-read).
217    fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>>;
218
219    /// Check if a file exists.
220    fn exists(&self, path: &std::path::Path) -> bool;
221
222    /// Read file metadata.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`std::io::Error`] when the path can't be stat'd
227    /// (missing, permission denied, I/O error).
228    fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata>;
229
230    /// Mount an ISO file and return the mount point.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`IsoError::MountFailed`] if the underlying mount
235    /// command (or mock handler) returned non-zero, or
236    /// [`IsoError::Io`] if a required helper (mkdir, losetup, mount)
237    /// couldn't be spawned.
238    fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError>;
239
240    /// Unmount a previously mounted ISO.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`IsoError::MountFailed`] if `umount` returned non-zero
245    /// (busy mount, stale mount point), or [`IsoError::Io`] if the
246    /// unmount helper couldn't be spawned.
247    fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError>;
248
249    /// Validate that `path` is rooted under `base` and contains no
250    /// parent-directory escapes.
251    ///
252    /// Returns [`IsoError::PathTraversal`] when:
253    ///   * any path component is `..` (could escape on normalization), OR
254    ///   * `path` does not lie under `base` (absolute paths to elsewhere).
255    ///
256    /// Symlinks are NOT resolved — callers that mount untrusted media must
257    /// constrain symlink-following at the mount layer (e.g. `nosymfollow`),
258    /// not rely on this check.
259    ///
260    /// Previous implementation silently returned `Ok(path)` when
261    /// `strip_prefix(base)` failed, meaning paths outside `base` were
262    /// accepted. Fixed in #56.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`IsoError::PathTraversal`] on either of the two
267    /// traversal conditions above.
268    fn validate_path(
269        &self,
270        base: &std::path::Path,
271        path: &std::path::Path,
272    ) -> Result<PathBuf, IsoError> {
273        if path
274            .components()
275            .any(|c| matches!(c, std::path::Component::ParentDir))
276        {
277            return Err(IsoError::PathTraversal(path.display().to_string()));
278        }
279        if !path.starts_with(base) {
280            return Err(IsoError::PathTraversal(path.display().to_string()));
281        }
282        Ok(path.to_path_buf())
283    }
284}
285
286/// OS-specific implementation of `IsoEnvironment`
287pub struct OsIsoEnvironment {
288    mount_base: PathBuf,
289}
290
291impl OsIsoEnvironment {
292    /// Construct a default `OsIsoEnvironment` with mount points under
293    /// `/tmp/iso-parser-mounts`. Callers that need a different base
294    /// path should construct the struct directly.
295    #[must_use]
296    pub fn new() -> Self {
297        Self {
298            mount_base: PathBuf::from("/tmp/iso-parser-mounts"),
299        }
300    }
301
302    /// Find a free loop device and attach `iso_path` to it. Tries
303    /// util-linux semantics (`losetup -f --show -r`) first, then falls
304    /// back to busybox semantics (scan `/dev/loop*` manually and attach
305    /// via `losetup <dev> <iso>`). Returns the allocated device path on
306    /// success.
307    fn allocate_loop_device(iso_path: &std::path::Path) -> Option<String> {
308        use std::process::Command;
309
310        // Attempt A: util-linux `-f --show -r`.
311        match Command::new("losetup")
312            .args(["-f", "--show", "-r", &iso_path.to_string_lossy()])
313            .output()
314        {
315            Ok(out) if out.status.success() => {
316                let dev = String::from_utf8_lossy(&out.stdout).trim().to_string();
317                if !dev.is_empty() && dev.starts_with("/dev/") {
318                    return Some(dev);
319                }
320                // Success exit but stdout didn't name a loop device —
321                // surface so operators see why "no ISOs found" when
322                // losetup is present. (#138)
323                tracing::warn!(
324                    iso = %iso_path.display(),
325                    stdout = %String::from_utf8_lossy(&out.stdout),
326                    "iso-parser: util-linux losetup succeeded but returned no /dev/loop* device"
327                );
328            }
329            Ok(out) => {
330                tracing::warn!(
331                    iso = %iso_path.display(),
332                    exit = ?out.status.code(),
333                    stderr = %String::from_utf8_lossy(&out.stderr),
334                    "iso-parser: util-linux losetup -f --show failed; falling back to busybox scan"
335                );
336            }
337            Err(e) => {
338                tracing::warn!(
339                    iso = %iso_path.display(),
340                    error = %e,
341                    "iso-parser: losetup exec failed (not on PATH?); falling back to busybox scan"
342                );
343            }
344        }
345
346        // Attempt B: busybox fallback. Find a free loop device manually
347        // (one that's not currently bound — busybox `losetup LOOPDEV`
348        // without args prints its binding or errors).
349        for n in 0..16 {
350            let dev = format!("/dev/loop{n}");
351            if !std::path::Path::new(&dev).exists() {
352                continue;
353            }
354            // Query — if it returns non-zero, device is free.
355            let query = match Command::new("losetup").arg(&dev).output() {
356                Ok(q) => q,
357                Err(e) => {
358                    tracing::warn!(
359                        dev = %dev,
360                        error = %e,
361                        "iso-parser: losetup query exec failed; skipping device"
362                    );
363                    continue;
364                }
365            };
366            if query.status.success() {
367                continue; // already bound
368            }
369            // Try to attach.
370            match Command::new("losetup")
371                .args(["-r", &dev, &iso_path.to_string_lossy()])
372                .output()
373            {
374                Ok(attach) if attach.status.success() => return Some(dev),
375                Ok(attach) => {
376                    tracing::warn!(
377                        dev = %dev,
378                        iso = %iso_path.display(),
379                        exit = ?attach.status.code(),
380                        stderr = %String::from_utf8_lossy(&attach.stderr),
381                        "iso-parser: losetup attach failed; trying next device"
382                    );
383                }
384                Err(e) => {
385                    tracing::warn!(
386                        dev = %dev,
387                        iso = %iso_path.display(),
388                        error = %e,
389                        "iso-parser: losetup attach exec failed; giving up"
390                    );
391                    return None;
392                }
393            }
394        }
395        tracing::warn!(
396            iso = %iso_path.display(),
397            "iso-parser: exhausted /dev/loop0..15 without a free device; cannot mount ISO"
398        );
399        None
400    }
401}
402
403impl Default for OsIsoEnvironment {
404    fn default() -> Self {
405        Self::new()
406    }
407}
408
409impl IsoEnvironment for OsIsoEnvironment {
410    fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
411        let mut entries = std::fs::read_dir(path)?
412            .map(|e| e.map(|entry| entry.path()))
413            .collect::<Result<Vec<_>, _>>()?;
414        entries.sort();
415        Ok(entries)
416    }
417
418    fn exists(&self, path: &std::path::Path) -> bool {
419        path.exists()
420    }
421
422    fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
423        std::fs::metadata(path)
424    }
425
426    fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
427        use std::process::Command;
428
429        // Generate unique mount point
430        let iso_name = iso_path
431            .file_stem()
432            .and_then(|s| s.to_str())
433            .unwrap_or("iso");
434
435        let mount_point = self.mount_base.join(format!("mount_{iso_name}"));
436        std::fs::create_dir_all(&mount_point)?;
437
438        // Attempt 1: `mount -o loop,ro`. Works with util-linux; may not
439        // work with some busybox builds where the `loop` option is a
440        // no-op (it mounts the file as if it were a raw block device,
441        // which then fails). Try it first because it's one syscall on
442        // util-linux-based systems.
443        let output = Command::new("mount")
444            .args([
445                "-o",
446                "loop,ro",
447                // Windows install ISOs are UDF-primary with a tiny
448                // iso9660 fallback volume that contains only a readme.txt
449                // shim. Mount tries types left-to-right — UDF first so
450                // we get the real filesystem on Windows ISOs, then iso9660
451                // as the fallback for pure-iso9660 media (Alpine, Ubuntu,
452                // Fedora install/live ISOs).
453                "-t",
454                "udf,iso9660",
455                &iso_path.to_string_lossy(),
456                &mount_point.to_string_lossy(),
457            ])
458            .output();
459
460        // If that fails AND we have `losetup` available, fall through to
461        // the explicit loop-setup path below. Verify by checking if the
462        // mount point now contains anything (mount silently succeeds with
463        // nothing mounted under certain busybox builds — test by listing).
464        let loop_attempt_ok = match &output {
465            Ok(out) if out.status.success() => {
466                // Verify the mount actually took by checking for directory
467                // entries. An empty dir after a "successful" mount means
468                // busybox loop-mode didn't work.
469                std::fs::read_dir(&mount_point)
470                    .ok()
471                    .and_then(|mut entries| entries.next())
472                    .is_some()
473            }
474            _ => false,
475        };
476
477        if !loop_attempt_ok {
478            // Attempt 2: explicit losetup + mount. Handles both
479            // util-linux (`losetup -f --show`) and busybox (`losetup -f`
480            // prints the allocated device on stdout as a side effect;
481            // `--show` is a util-linux long option that busybox doesn't
482            // accept). Try util-linux form first; fall back to querying
483            // /dev/loop* after a bare `losetup -f` attach.
484            let loop_dev = Self::allocate_loop_device(iso_path);
485            if let Some(loop_dev) = loop_dev {
486                let mount_out = Command::new("mount")
487                    .args([
488                        "-r",
489                        "-t",
490                        "udf,iso9660",
491                        &loop_dev,
492                        &mount_point.to_string_lossy(),
493                    ])
494                    .output();
495                if let Ok(mo) = mount_out {
496                    if mo.status.success() {
497                        debug!(
498                            "Mounted {} via losetup {} -> {:?}",
499                            iso_path.display(),
500                            loop_dev,
501                            mount_point
502                        );
503                        return Ok(mount_point);
504                    }
505                }
506                // losetup succeeded but mount failed — detach.
507                let _ = Command::new("losetup").args(["-d", &loop_dev]).output();
508            }
509        }
510
511        // Terminal dispatch. Attempt 1 may have reported status=success
512        // but left the mount_point empty (busybox loop-mode silently
513        // no-ops, or the filesystem type list didn't match the ISO's
514        // actual layout). In that case we previously returned
515        // Ok(empty mount_point) — callers then saw NoBootEntries
516        // instead of the real "mount didn't take" diagnostic. Re-verify
517        // the mount point has entries before accepting status.success.
518        let mount_point_populated = || {
519            std::fs::read_dir(&mount_point)
520                .ok()
521                .and_then(|mut entries| entries.next())
522                .is_some()
523        };
524        match output {
525            Ok(out) if out.status.success() && mount_point_populated() => {
526                debug!("Mounted {} to {:?}", iso_path.display(), mount_point);
527                Ok(mount_point)
528            }
529            Ok(out) => {
530                let stderr = String::from_utf8_lossy(&out.stderr);
531                // Explicit hint when mount claimed success but wrote
532                // nothing: typically a filesystem-type mismatch
533                // (Windows/macOS ISOs against older mount defaults).
534                let reason = if out.status.success() {
535                    format!(
536                        "mount claimed success but {} is empty — \
537                         filesystem type likely not auto-detected \
538                         (stderr: {})",
539                        mount_point.display(),
540                        stderr.trim()
541                    )
542                } else {
543                    stderr.to_string()
544                };
545                // Try fallback with fuseiso
546                let fuse_output = Command::new("fuseiso")
547                    .arg(iso_path.to_string_lossy().as_ref())
548                    .arg(mount_point.to_string_lossy().as_ref())
549                    .output();
550
551                match fuse_output {
552                    Ok(fuse_out) if fuse_out.status.success() && mount_point_populated() => {
553                        debug!("Mounted {} via fuseiso", iso_path.display());
554                        Ok(mount_point)
555                    }
556                    _ => {
557                        // Cleanup mount point on failure.
558                        let _ = std::fs::remove_dir(&mount_point);
559                        Err(IsoError::MountFailed(reason))
560                    }
561                }
562            }
563            Err(e) => Err(IsoError::Io(e)),
564        }
565    }
566
567    fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
568        use std::process::Command;
569
570        // Try umount first, then fusermount
571        let umount_result = Command::new("umount").arg(mount_point).output();
572
573        match umount_result {
574            Ok(out) if out.status.success() => {
575                let _ = std::fs::remove_dir(mount_point);
576                Ok(())
577            }
578            _ => {
579                // Try fusermount as fallback
580                let fusermount = Command::new("fusermount")
581                    .arg("-u")
582                    .arg(mount_point)
583                    .output();
584                match fusermount {
585                    Ok(out) if out.status.success() => {
586                        let _ = std::fs::remove_dir(mount_point);
587                        Ok(())
588                    }
589                    _ => Err(IsoError::MountFailed(format!(
590                        "Failed to unmount {}",
591                        mount_point.display()
592                    ))),
593                }
594            }
595        }
596    }
597}
598
599/// ISO Parser - main entry point for boot discovery
600///
601/// Generic over environment to allow testing without actual filesystem/mounts.
602pub struct IsoParser<E: IsoEnvironment> {
603    env: E,
604}
605
606impl<E: IsoEnvironment> IsoParser<E> {
607    /// Construct a parser bound to the given [`IsoEnvironment`].
608    /// Typically [`OsIsoEnvironment`] in production; a mock in tests.
609    pub fn new(env: E) -> Self {
610        Self { env }
611    }
612
613    /// Scan a directory for ISO files and extract boot entries
614    /// Scan a directory for `.iso` files, mount + parse each one, and
615    /// return the collected [`BootEntry`] records.
616    ///
617    /// The `async` signature is retained for backwards source-compat
618    /// with callers that `.await` it; the function itself performs no
619    /// async work today.
620    ///
621    /// # Errors
622    ///
623    /// Returns [`IsoError::PathTraversal`] if `path` escapes
624    /// `/` (degenerate), or [`IsoError::Io`] on a filesystem read
625    /// failure during the ISO-file discovery walk. Per-ISO parse /
626    /// mount failures are silently skipped and logged at `debug`; the
627    /// overall scan succeeds as long as at least the walk works.
628    #[instrument(skip(self))]
629    #[allow(clippy::unused_async)]
630    pub async fn scan_directory(&self, path: &std::path::Path) -> Result<Vec<BootEntry>, IsoError> {
631        let mut entries = Vec::new();
632
633        // Validate base path
634        let validated_path = self.env.validate_path(std::path::Path::new("/"), path)?;
635
636        debug!("Scanning directory: {:?}", validated_path);
637
638        let iso_files = self.find_iso_files(&validated_path)?;
639        let attempted = iso_files.len();
640        let mut skipped = 0usize;
641
642        for iso_path in iso_files {
643            debug!("Processing ISO: {:?}", iso_path);
644
645            match self.process_iso(&iso_path).await {
646                Ok(mut iso_entries) => entries.append(&mut iso_entries),
647                Err(e) => {
648                    skipped += 1;
649                    // Upgraded from debug → warn so silent-skip failures
650                    // surface on the serial console without operators
651                    // having to enable debug tracing. (#68)
652                    tracing::warn!(
653                        iso = %iso_path.display(),
654                        error = %e,
655                        "iso-parser: skipped ISO (mount/parse failed)"
656                    );
657                }
658            }
659        }
660
661        tracing::info!(
662            root = %validated_path.display(),
663            found_isos = attempted,
664            extracted_entries = entries.len(),
665            skipped_isos = skipped,
666            "iso-parser: scan summary"
667        );
668
669        if entries.is_empty() {
670            return Err(IsoError::NoBootEntries(
671                validated_path.to_string_lossy().to_string(),
672            ));
673        }
674
675        Ok(entries)
676    }
677
678    /// Find all ISO files in a directory recursively
679    fn find_iso_files(&self, path: &std::path::Path) -> Result<Vec<PathBuf>, IsoError> {
680        let mut isos = Vec::new();
681
682        for entry in self.env.list_dir(path)? {
683            let entry_path = &entry;
684
685            // Recurse into subdirectories
686            if entry.is_dir() {
687                // Skip certain directories
688                let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
689
690                if !name.starts_with('.') && name != "proc" && name != "sys" && name != "dev" {
691                    if let Ok(mut sub_isos) = self.find_iso_files(entry_path) {
692                        isos.append(&mut sub_isos);
693                    }
694                }
695            } else if let Some(ext) = entry.extension().and_then(|s| s.to_str()) {
696                if ext.eq_ignore_ascii_case("iso") {
697                    isos.push(entry.clone());
698                }
699            }
700        }
701
702        Ok(isos)
703    }
704
705    /// Process a single ISO: mount, extract boot entries, unmount
706    async fn process_iso(&self, iso_path: &Path) -> Result<Vec<BootEntry>, IsoError> {
707        let mount_point = self.env.mount_iso(iso_path)?;
708
709        let result = self.extract_boot_entries(&mount_point, iso_path).await;
710
711        // Always attempt unmount
712        let _ = self.env.unmount(&mount_point);
713
714        result
715    }
716
717    /// Extract boot entries from a mounted ISO.
718    #[allow(clippy::unused_async)]
719    async fn extract_boot_entries(
720        &self,
721        mount_point: &Path,
722        source_iso: &Path,
723    ) -> Result<Vec<BootEntry>, IsoError> {
724        let mut entries = Vec::new();
725
726        // Try each distribution pattern
727        entries.extend(self.try_arch_layout(mount_point, source_iso)?);
728        entries.extend(self.try_debian_layout(mount_point, source_iso)?);
729        entries.extend(self.try_fedora_layout(mount_point, source_iso)?);
730        entries.extend(self.try_windows_layout(mount_point, source_iso)?);
731
732        // Populate pretty_name from the mounted ISO's release files
733        // before the caller unmounts. Best-effort — if none of the
734        // known paths resolve, the field stays None and downstream UI
735        // falls back to the distribution-family label. (#119)
736        let pretty = read_pretty_name(mount_point);
737        if pretty.is_some() {
738            for entry in &mut entries {
739                entry.pretty_name.clone_from(&pretty);
740            }
741        }
742
743        Ok(entries)
744    }
745
746    /// Try Arch Linux layout: /boot/{vmlinuz,initrd.img}
747    fn try_arch_layout(
748        &self,
749        mount_point: &Path,
750        source_iso: &Path,
751    ) -> Result<Vec<BootEntry>, IsoError> {
752        let boot_dir = mount_point.join("boot");
753
754        if !self.env.exists(&boot_dir) {
755            return Ok(Vec::new());
756        }
757
758        let mut entries = Vec::new();
759
760        // Find kernel files (vmlinuz*)
761        for entry in self.env.list_dir(&boot_dir)? {
762            let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
763
764            if name.starts_with("vmlinuz") {
765                let kernel = entry.clone();
766                let mut initrd = boot_dir.join(format!(
767                    "initrd.img{}",
768                    name.strip_prefix("vmlinuz").unwrap_or("")
769                ));
770
771                // Try common initrd names
772                if !self.env.exists(&initrd) {
773                    initrd = boot_dir.join("initrd.img");
774                }
775                if !self.env.exists(&initrd) {
776                    initrd = boot_dir.join(format!(
777                        "initrd{}",
778                        name.strip_prefix("vmlinuz").unwrap_or("")
779                    ));
780                }
781
782                let has_initrd = self.env.exists(&initrd);
783
784                // Classify from the actual kernel filename — `boot/vmlinuz-lts`
785                // and `boot/vmlinuz-virt` are Alpine, not Arch, etc. This
786                // layout matches multiple distros that share the
787                // `/boot/vmlinuz*` convention; use the path classifier
788                // rather than a hardcoded `Distribution::Arch`. (#116)
789                let rel_kernel = kernel
790                    .strip_prefix(mount_point)
791                    .map(std::path::Path::to_path_buf)
792                    .map_err(|_| {
793                        IsoError::Io(std::io::Error::new(
794                            std::io::ErrorKind::InvalidData,
795                            "Kernel path escape",
796                        ))
797                    })?;
798                let distribution = Distribution::from_paths(&rel_kernel);
799                let label = match distribution {
800                    Distribution::Alpine => format!(
801                        "Alpine {}",
802                        name.strip_prefix("vmlinuz-").unwrap_or("").trim()
803                    ),
804                    Distribution::Arch => format!(
805                        "Arch Linux {}",
806                        name.strip_prefix("vmlinuz").unwrap_or("").trim()
807                    ),
808                    _ => format!(
809                        "Linux {}",
810                        name.strip_prefix("vmlinuz").unwrap_or("").trim()
811                    ),
812                };
813                // Kernel args: only set for actual Arch; leave empty for
814                // Alpine/unknown so the ISO's own boot config wins.
815                let kernel_args = if distribution == Distribution::Arch {
816                    Some(
817                        "archisobasedir=arch archiso_http_server=https://mirror.archlinux.org"
818                            .to_string(),
819                    )
820                } else {
821                    None
822                };
823
824                entries.push(BootEntry {
825                    label,
826                    kernel: rel_kernel,
827                    initrd: if has_initrd { Some(initrd) } else { None },
828                    kernel_args,
829                    distribution,
830                    source_iso: source_iso
831                        .file_name()
832                        .and_then(|n| n.to_str())
833                        .unwrap_or("unknown")
834                        .to_string(),
835                    pretty_name: None,
836                });
837            }
838        }
839
840        Ok(entries)
841    }
842
843    /// Try Debian/Ubuntu layout: /install/vmlinuz, /casper/initrd.lz
844    fn try_debian_layout(
845        &self,
846        mount_point: &Path,
847        source_iso: &Path,
848    ) -> Result<Vec<BootEntry>, IsoError> {
849        let mut entries = Vec::new();
850
851        // Debian-family ISOs have one or more of: /install (debian-
852        // installer), /casper (ubuntu live), /.disk/info (both), or
853        // /pool (package pool). Gate on at least one of those being
854        // present — without the gate, try_debian_layout also matches
855        // Alpine's /boot/vmlinuz-lts and produces spurious
856        // "Debian/Ubuntu" entries. (#122)
857        let debian_markers = [
858            mount_point.join("install"),
859            mount_point.join("casper"),
860            mount_point.join(".disk"),
861            mount_point.join("pool"),
862            mount_point.join("dists"),
863        ];
864        if !debian_markers.iter().any(|p| self.env.exists(p)) {
865            return Ok(entries);
866        }
867
868        // Try multiple potential locations
869        let search_paths = [
870            mount_point.join("install"),
871            mount_point.join("casper"),
872            mount_point.join("boot"),
873        ];
874
875        for search_dir in &search_paths {
876            if !self.env.exists(search_dir) {
877                continue;
878            }
879
880            // Find vmlinuz
881            for entry in self.env.list_dir(search_dir)? {
882                let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
883
884                if name.starts_with("vmlinuz") {
885                    let kernel = entry.clone();
886
887                    // Look for initrd in same directory or common locations
888                    let initrd_names = ["initrd.lz", "initrd.gz", "initrd.img", "initrd"];
889                    let mut found_initrd = None;
890
891                    for initrd_name in initrd_names {
892                        let initrd_path = search_dir.join(initrd_name);
893                        if self.env.exists(&initrd_path) {
894                            found_initrd = Some(initrd_path);
895                            break;
896                        }
897                    }
898
899                    // Also check casper filesystem.squashfs for live boot
900                    let kernel_args = if search_dir == &mount_point.join("casper") {
901                        Some(
902                            "boot=casper locale=en_US.UTF-8 keyboard-configuration/layoutcode=us"
903                                .to_string(),
904                        )
905                    } else {
906                        None
907                    };
908
909                    // Both casper and non-casper paths result in Debian family
910                    entries.push(BootEntry {
911                        label: format!(
912                            "Debian/Ubuntu {}",
913                            name.strip_prefix("vmlinuz").unwrap_or("").trim()
914                        ),
915                        kernel: kernel
916                            .strip_prefix(mount_point)
917                            .map(std::path::Path::to_path_buf)
918                            .map_err(|_| {
919                                IsoError::Io(std::io::Error::new(
920                                    std::io::ErrorKind::InvalidData,
921                                    "Kernel path escape",
922                                ))
923                            })?,
924                        initrd: found_initrd
925                            .map(|p| {
926                                p.strip_prefix(mount_point)
927                                    .map(std::path::Path::to_path_buf)
928                                    .map_err(|_| {
929                                        IsoError::Io(std::io::Error::new(
930                                            std::io::ErrorKind::InvalidData,
931                                            "Initrd path escape",
932                                        ))
933                                    })
934                            })
935                            .transpose()?,
936                        kernel_args,
937                        distribution: Distribution::Debian,
938                        source_iso: source_iso
939                            .file_name()
940                            .and_then(|n| n.to_str())
941                            .unwrap_or("unknown")
942                            .to_string(),
943                        pretty_name: None,
944                    });
945                }
946            }
947        }
948
949        Ok(entries)
950    }
951
952    /// Try Fedora layout: /images/pxeboot/vmlinuz, /images/pxeboot/initrd.img
953    fn try_fedora_layout(
954        &self,
955        mount_point: &Path,
956        source_iso: &Path,
957    ) -> Result<Vec<BootEntry>, IsoError> {
958        let images_dir = mount_point.join("images").join("pxeboot");
959
960        if !self.env.exists(&images_dir) {
961            // Try alternate: /isolinux/ (common Fedora live media)
962            let alt_dir = mount_point.join("isolinux");
963            if !self.env.exists(&alt_dir) {
964                return Ok(Vec::new());
965            }
966            return self.process_fedora_isolinux(&alt_dir, mount_point, source_iso);
967        }
968
969        let mut entries = Vec::new();
970
971        // Find kernel
972        for entry in self.env.list_dir(&images_dir)? {
973            let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
974
975            if name.starts_with("vmlinuz") {
976                let kernel = entry.clone();
977
978                // Find matching initrd
979                let version = name.strip_prefix("vmlinuz").unwrap_or("");
980                let initrd_names = [
981                    format!("initrd{version}.img"),
982                    "initrd.img".to_string(),
983                    format!("initrd{}.img", version.trim_end_matches('-')),
984                ];
985
986                let mut found_initrd = None;
987                for initrd_name in &initrd_names {
988                    let initrd_path = images_dir.join(initrd_name);
989                    if self.env.exists(&initrd_path) {
990                        found_initrd = Some(initrd_path);
991                        break;
992                    }
993                }
994
995                entries.push(BootEntry {
996                    label: format!("Fedora {}", version.trim()),
997                    kernel: kernel
998                        .strip_prefix(mount_point)
999                        .map(std::path::Path::to_path_buf)
1000                        .map_err(|_| {
1001                            IsoError::Io(std::io::Error::new(
1002                                std::io::ErrorKind::InvalidData,
1003                                "Kernel path escape",
1004                            ))
1005                        })?,
1006                    initrd: found_initrd
1007                        .map(|p| {
1008                            p.strip_prefix(mount_point)
1009                                .map(std::path::Path::to_path_buf)
1010                                .map_err(|_| {
1011                                    IsoError::Io(std::io::Error::new(
1012                                        std::io::ErrorKind::InvalidData,
1013                                        "Initrd path escape",
1014                                    ))
1015                                })
1016                        })
1017                        .transpose()?,
1018                    kernel_args: Some("inst.stage2=hd:LABEL=Fedora-39-x86_64".to_string()),
1019                    distribution: Distribution::Fedora,
1020                    source_iso: source_iso
1021                        .file_name()
1022                        .and_then(|n| n.to_str())
1023                        .unwrap_or("unknown")
1024                        .to_string(),
1025                    pretty_name: None,
1026                });
1027            }
1028        }
1029
1030        Ok(entries)
1031    }
1032
1033    fn process_fedora_isolinux(
1034        &self,
1035        isolinux_dir: &Path,
1036        mount_point: &Path,
1037        source_iso: &Path,
1038    ) -> Result<Vec<BootEntry>, IsoError> {
1039        let mut entries = Vec::new();
1040
1041        for entry in self.env.list_dir(isolinux_dir)? {
1042            let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
1043
1044            if name.starts_with("vmlinuz") {
1045                let kernel = entry.clone();
1046
1047                // Look for initrd in images directory
1048                let images_dir = mount_point.join("images");
1049                let initrd_path = images_dir.join("initrd.img");
1050
1051                entries.push(BootEntry {
1052                    label: format!(
1053                        "Fedora (isolinux) {}",
1054                        name.strip_prefix("vmlinuz").unwrap_or("").trim()
1055                    ),
1056                    kernel: kernel
1057                        .strip_prefix(mount_point)
1058                        .map(std::path::Path::to_path_buf)
1059                        .map_err(|_| {
1060                            IsoError::Io(std::io::Error::new(
1061                                std::io::ErrorKind::InvalidData,
1062                                "Kernel path escape",
1063                            ))
1064                        })?,
1065                    initrd: if self.env.exists(&initrd_path) {
1066                        Some(
1067                            initrd_path
1068                                .strip_prefix(mount_point)
1069                                .map(std::path::Path::to_path_buf)
1070                                .map_err(|_| {
1071                                    IsoError::Io(std::io::Error::new(
1072                                        std::io::ErrorKind::InvalidData,
1073                                        "Initrd path escape",
1074                                    ))
1075                                })?,
1076                        )
1077                    } else {
1078                        None
1079                    },
1080                    kernel_args: Some("inst.stage2=hd:LABEL=Fedora".to_string()),
1081                    distribution: Distribution::Fedora,
1082                    source_iso: source_iso
1083                        .file_name()
1084                        .and_then(|n| n.to_str())
1085                        .unwrap_or("unknown")
1086                        .to_string(),
1087                    pretty_name: None,
1088                });
1089            }
1090        }
1091
1092        Ok(entries)
1093    }
1094
1095    /// Detect Windows installer ISOs (Win10, Win11, Server). Emits a
1096    /// synthesized `BootEntry` so the ISO surfaces in rescue-tui's list
1097    /// with `Distribution::Windows` and the `NotKexecBootable` quirk —
1098    /// replaces the current behavior where Windows ISOs got silently
1099    /// skipped as `NoBootEntries`, which mismatched the `docs/
1100    /// compatibility/iso-matrix.md` + `iso-probe`'s explicit "not a
1101    /// kexec target" classification.
1102    ///
1103    /// Detection uses three independent markers (ANY match suffices):
1104    ///
1105    /// 1. `/bootmgr` — Windows NT loader, present since Vista on
1106    ///    installer and recovery media.
1107    /// 2. `/sources/boot.wim` — Windows PE boot image, the signature
1108    ///    of a Microsoft-shipped install ISO.
1109    /// 3. `/efi/microsoft/boot/` — UEFI boot directory with the
1110    ///    signed `bootmgfw.efi`.
1111    ///
1112    /// The synthesized `kernel` field points at `bootmgr` (or the
1113    /// EFI equivalent when `bootmgr` is absent). It's never passed to
1114    /// kexec — downstream code gates on the `NotKexecBootable` quirk
1115    /// surfaced by `iso-probe::lookup_quirks(Distribution::Windows)`.
1116    /// Using `bootmgr` as the semantic "kernel" makes the rendered
1117    /// evidence line ("kernel: bootmgr") self-explanatory.
1118    // `Result` parallels `try_arch_layout` / `try_debian_layout` / etc.
1119    // even though Windows detection uses only `env.exists()` (infallible
1120    // in this crate's IsoEnvironment shape) — keeps the caller site in
1121    // `extract_boot_entries` uniformly `?`-chained across all layouts.
1122    #[allow(clippy::unnecessary_wraps)]
1123    fn try_windows_layout(
1124        &self,
1125        mount_point: &Path,
1126        source_iso: &Path,
1127    ) -> Result<Vec<BootEntry>, IsoError> {
1128        let bootmgr = mount_point.join("bootmgr");
1129        let boot_wim = mount_point.join("sources/boot.wim");
1130        let efi_ms_boot = mount_point.join("efi/microsoft/boot");
1131        let bootmgfw_efi = mount_point.join("efi/boot/bootx64.efi");
1132
1133        let has_any_marker = self.env.exists(&bootmgr)
1134            || self.env.exists(&boot_wim)
1135            || self.env.exists(&efi_ms_boot);
1136        if !has_any_marker {
1137            return Ok(Vec::new());
1138        }
1139
1140        // Prefer `bootmgr` (the classic NT loader) as the synthetic
1141        // "kernel" path. Fall back to bootmgfw.efi / a synthetic marker
1142        // if a stripped-down ISO is missing bootmgr but still carries
1143        // sources/boot.wim (unusual but seen on some Windows PE rebuilds).
1144        let kernel_path = if self.env.exists(&bootmgr) {
1145            PathBuf::from("bootmgr")
1146        } else if self.env.exists(&bootmgfw_efi) {
1147            PathBuf::from("efi/boot/bootx64.efi")
1148        } else {
1149            PathBuf::from("sources/boot.wim")
1150        };
1151
1152        let label = "Windows (not kexec-bootable)".to_string();
1153
1154        Ok(vec![BootEntry {
1155            label,
1156            kernel: kernel_path,
1157            // Windows PE uses `boot.wim` as its "initrd equivalent" but
1158            // that's not something kexec could use — leave None.
1159            initrd: None,
1160            kernel_args: None,
1161            distribution: Distribution::Windows,
1162            source_iso: source_iso
1163                .file_name()
1164                .and_then(|n| n.to_str())
1165                .unwrap_or("unknown")
1166                .to_string(),
1167            pretty_name: None,
1168        }])
1169    }
1170}
1171
1172/// Best-effort "friendly" distro name for a mounted ISO.
1173///
1174/// Reads the first file in this priority order and returns the first
1175/// useful value found:
1176///
1177/// 1. `/etc/os-release` `PRETTY_NAME` — systemd convention; all
1178///    modern distros ship this (Ubuntu, Fedora, Rocky, Alma, Debian 12+,
1179///    openSUSE, Arch, `NixOS` 22+, Alpine 3.17+).
1180/// 2. `/lib/os-release` `PRETTY_NAME` — symlink target on some distros;
1181///    handled independently in case the `/etc` copy is missing.
1182/// 3. `/.disk/info` — single line of free text, Ubuntu + Debian live/install
1183///    media tradition since circa Debian 6. Form: "Ubuntu 24.04.2 LTS ...".
1184/// 4. `/etc/alpine-release` — single version string (e.g. "3.20.3") on
1185///    Alpine. We prepend "Alpine " so the returned value is self-contained.
1186///
1187/// Returns `None` if none of the paths exist or all attempts produce an
1188/// empty string. This is advisory — every caller must tolerate `None`
1189/// and fall back to the `Distribution`-family label.
1190#[must_use]
1191pub fn read_pretty_name(mount_point: &Path) -> Option<String> {
1192    for rel in ["etc/os-release", "lib/os-release", "usr/lib/os-release"] {
1193        if let Some(name) = read_os_release(&mount_point.join(rel)) {
1194            return Some(name);
1195        }
1196    }
1197    if let Some(first_line) = read_first_nonempty_line(&mount_point.join(".disk/info")) {
1198        return Some(first_line);
1199    }
1200    if let Some(version) = read_first_nonempty_line(&mount_point.join("etc/alpine-release")) {
1201        return Some(format!("Alpine Linux {version}"));
1202    }
1203    None
1204}
1205
1206/// Parse a systemd-style `os-release` file for the value of `PRETTY_NAME`.
1207/// Strips surrounding double quotes if present. Returns `None` on any
1208/// read error or if the key is missing / empty.
1209fn read_os_release(path: &Path) -> Option<String> {
1210    let content = std::fs::read_to_string(path).ok()?;
1211    parse_os_release_pretty_name(&content)
1212}
1213
1214/// Pure-string version of the `os-release` parser — split out so we can
1215/// unit-test without touching the filesystem.
1216#[must_use]
1217pub(crate) fn parse_os_release_pretty_name(content: &str) -> Option<String> {
1218    for line in content.lines() {
1219        let Some(rest) = line.strip_prefix("PRETTY_NAME=") else {
1220            continue;
1221        };
1222        // Strip surrounding " or ' (systemd spec allows either, and we
1223        // want to be forgiving of wild-in-the-field variants).
1224        let trimmed = rest
1225            .trim()
1226            .trim_matches(|c| c == '"' || c == '\'')
1227            .to_string();
1228        if trimmed.is_empty() {
1229            return None;
1230        }
1231        return Some(trimmed);
1232    }
1233    None
1234}
1235
1236/// Read the first non-empty trimmed line of a file. Used for free-text
1237/// release files (`/.disk/info`, `/etc/alpine-release`) that don't
1238/// follow the `KEY=VALUE` shape.
1239fn read_first_nonempty_line(path: &Path) -> Option<String> {
1240    let content = std::fs::read_to_string(path).ok()?;
1241    for line in content.lines() {
1242        let trimmed = line.trim();
1243        if !trimmed.is_empty() {
1244            return Some(trimmed.to_string());
1245        }
1246    }
1247    None
1248}
1249
1250#[cfg(test)]
1251#[allow(
1252    clippy::unwrap_used,
1253    clippy::expect_used,
1254    clippy::too_many_lines,
1255    clippy::missing_panics_doc,
1256    clippy::match_same_arms
1257)]
1258mod tests {
1259    use super::*;
1260    use std::collections::HashMap;
1261    use std::sync::Mutex;
1262
1263    /// Mock environment for testing without actual filesystem
1264    struct MockIsoEnvironment {
1265        files: HashMap<PathBuf, MockEntry>,
1266        mount_points: Mutex<Vec<PathBuf>>,
1267    }
1268
1269    #[derive(Debug, Clone)]
1270    enum MockEntry {
1271        File,
1272        Directory(Vec<PathBuf>),
1273    }
1274
1275    impl MockIsoEnvironment {
1276        fn new() -> Self {
1277            Self {
1278                files: HashMap::new(),
1279                mount_points: Mutex::new(Vec::new()),
1280            }
1281        }
1282
1283        fn with_iso(distribution: Distribution) -> Self {
1284            let mut env = Self::new();
1285
1286            let mount_base = PathBuf::from("/mock_mount");
1287
1288            match distribution {
1289                Distribution::Arch => {
1290                    // Arch: /boot/vmlinuz, /boot/initrd.img
1291                    env.files.insert(
1292                        mount_base.join("boot"),
1293                        MockEntry::Directory(vec![
1294                            mount_base.join("boot/vmlinuz"),
1295                            mount_base.join("boot/initrd.img"),
1296                        ]),
1297                    );
1298                    env.files
1299                        .insert(mount_base.join("boot/vmlinuz"), MockEntry::File);
1300                    env.files
1301                        .insert(mount_base.join("boot/initrd.img"), MockEntry::File);
1302                }
1303                Distribution::Debian => {
1304                    // Debian: /install/vmlinuz, /casper/initrd.lz
1305                    env.files.insert(
1306                        mount_base.join("install"),
1307                        MockEntry::Directory(vec![mount_base.join("install/vmlinuz")]),
1308                    );
1309                    env.files
1310                        .insert(mount_base.join("install/vmlinuz"), MockEntry::File);
1311                    env.files.insert(
1312                        mount_base.join("casper"),
1313                        MockEntry::Directory(vec![
1314                            mount_base.join("casper/initrd.lz"),
1315                            mount_base.join("casper/filesystem.squashfs"),
1316                        ]),
1317                    );
1318                    env.files
1319                        .insert(mount_base.join("casper/initrd.lz"), MockEntry::File);
1320                    env.files.insert(
1321                        mount_base.join("casper/filesystem.squashfs"),
1322                        MockEntry::File,
1323                    );
1324                }
1325                Distribution::Fedora => {
1326                    // Fedora: /images/pxeboot/vmlinuz, /images/pxeboot/initrd.img
1327                    env.files.insert(
1328                        mount_base.join("images"),
1329                        MockEntry::Directory(vec![mount_base.join("images/pxeboot")]),
1330                    );
1331                    env.files.insert(
1332                        mount_base.join("images/pxeboot"),
1333                        MockEntry::Directory(vec![
1334                            mount_base.join("images/pxeboot/vmlinuz"),
1335                            mount_base.join("images/pxeboot/initrd.img"),
1336                        ]),
1337                    );
1338                    env.files
1339                        .insert(mount_base.join("images/pxeboot/vmlinuz"), MockEntry::File);
1340                    env.files.insert(
1341                        mount_base.join("images/pxeboot/initrd.img"),
1342                        MockEntry::File,
1343                    );
1344                }
1345                // New variants reuse existing mock fixtures by analogue
1346                // (Alpine + NixOS behave like Arch at the path layer; RedHat
1347                // like Fedora). The scan_directory tests only care about the
1348                // 3 original categories, so nothing new to stage here.
1349                Distribution::RedHat | Distribution::Alpine | Distribution::NixOS => {}
1350                Distribution::Windows => {
1351                    // Windows installer: /bootmgr + /sources/boot.wim +
1352                    // /efi/microsoft/boot/. We stage all three canonical
1353                    // markers so try_windows_layout's any-marker detection
1354                    // logic gets exercised from multiple angles.
1355                    env.files
1356                        .insert(mount_base.join("bootmgr"), MockEntry::File);
1357                    env.files.insert(
1358                        mount_base.join("sources"),
1359                        MockEntry::Directory(vec![mount_base.join("sources/boot.wim")]),
1360                    );
1361                    env.files
1362                        .insert(mount_base.join("sources/boot.wim"), MockEntry::File);
1363                    env.files.insert(
1364                        mount_base.join("efi"),
1365                        MockEntry::Directory(vec![mount_base.join("efi/microsoft")]),
1366                    );
1367                    env.files.insert(
1368                        mount_base.join("efi/microsoft"),
1369                        MockEntry::Directory(vec![mount_base.join("efi/microsoft/boot")]),
1370                    );
1371                    env.files.insert(
1372                        mount_base.join("efi/microsoft/boot"),
1373                        MockEntry::Directory(vec![]),
1374                    );
1375                }
1376                Distribution::Unknown => {}
1377            }
1378
1379            // Add ISO file in parent directory
1380            env.files.insert(
1381                PathBuf::from("/isos"),
1382                MockEntry::Directory(vec![PathBuf::from("/isos/test.iso")]),
1383            );
1384            env.files
1385                .insert(PathBuf::from("/isos/test.iso"), MockEntry::File);
1386
1387            env
1388        }
1389    }
1390
1391    impl IsoEnvironment for MockIsoEnvironment {
1392        fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
1393            match self.files.get(path) {
1394                Some(MockEntry::Directory(entries)) => Ok(entries.clone()),
1395                Some(MockEntry::File) => Err(std::io::Error::new(
1396                    std::io::ErrorKind::NotFound,
1397                    "Not a directory",
1398                )),
1399                None => Ok(Vec::new()), // Empty for non-existent
1400            }
1401        }
1402
1403        fn exists(&self, path: &std::path::Path) -> bool {
1404            self.files.contains_key(path)
1405        }
1406
1407        fn metadata(&self, _path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
1408            // Fail closed: the previous implementation returned the real
1409            // metadata of `std::env::temp_dir()` for any path that existed
1410            // in the mock — which silently made size/mtime assertions pass
1411            // on fake data (they'd read /tmp's values, not the mock's).
1412            //
1413            // Since no caller in the workspace uses IsoEnvironment::metadata
1414            // today (the trait method is currently unused, per #138 audit),
1415            // and std::fs::Metadata has no public constructor, there is no
1416            // safe way to return a synthesized value from pure mock data.
1417            //
1418            // If a future caller needs this method, the correct fix is to
1419            // add real size/mtime fields to MockEntry and return them via a
1420            // wrapper type — not to paper over the hazard with /tmp values.
1421            Err(std::io::Error::new(
1422                std::io::ErrorKind::Unsupported,
1423                "MockIsoEnvironment::metadata is not implemented — see #138 for the design note",
1424            ))
1425        }
1426
1427        fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
1428            let mount_point = PathBuf::from(format!(
1429                "/mock_mount/{}",
1430                iso_path
1431                    .file_stem()
1432                    .and_then(|s| s.to_str())
1433                    .unwrap_or("iso")
1434            ));
1435
1436            // Poison-safe lock: if a prior test panicked while holding the
1437            // mutex, `.lock()` returns `Err(PoisonError)`. `into_inner()`
1438            // recovers the guarded value so we don't cascade-fail every
1439            // subsequent test that happens to hit this path. Mock state is
1440            // append-or-trim only, so partial updates from a poisoned
1441            // critical section are safe to observe.
1442            self.mount_points
1443                .lock()
1444                .unwrap_or_else(std::sync::PoisonError::into_inner)
1445                .push(mount_point.clone());
1446            Ok(mount_point)
1447        }
1448
1449        fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
1450            let mut points = self
1451                .mount_points
1452                .lock()
1453                .unwrap_or_else(std::sync::PoisonError::into_inner);
1454            points.retain(|p| p != mount_point);
1455            Ok(())
1456        }
1457    }
1458
1459    #[test]
1460    fn test_path_traversal_blocked() {
1461        let env = MockIsoEnvironment::new();
1462        let result = env.validate_path(
1463            PathBuf::from("/safe").as_path(),
1464            PathBuf::from("/safe/../../../etc/passwd").as_path(),
1465        );
1466
1467        assert!(result.is_err());
1468        match result {
1469            Err(IsoError::PathTraversal(_)) => {}
1470            _ => panic!("Expected PathTraversal error"),
1471        }
1472    }
1473
1474    #[test]
1475    fn test_path_allowed() {
1476        let env = MockIsoEnvironment::new();
1477        let result = env.validate_path(
1478            PathBuf::from("/safe").as_path(),
1479            PathBuf::from("/safe/subdir/file").as_path(),
1480        );
1481
1482        assert!(result.is_ok());
1483    }
1484
1485    #[test]
1486    fn test_path_outside_base_rejected() {
1487        // Regression for #56: validate_path used to silently return Ok
1488        // when strip_prefix(base) failed, accepting absolute paths to
1489        // anywhere on the filesystem.
1490        let env = MockIsoEnvironment::new();
1491        let result = env.validate_path(
1492            PathBuf::from("/mnt/iso").as_path(),
1493            PathBuf::from("/etc/passwd").as_path(),
1494        );
1495        assert!(matches!(result, Err(IsoError::PathTraversal(_))));
1496    }
1497
1498    #[test]
1499    fn test_path_sibling_of_base_rejected() {
1500        // /safe2 starts with the string "/safe" but is NOT under /safe —
1501        // Path::starts_with respects component boundaries, not prefix match.
1502        let env = MockIsoEnvironment::new();
1503        let result = env.validate_path(
1504            PathBuf::from("/safe").as_path(),
1505            PathBuf::from("/safe2/file").as_path(),
1506        );
1507        assert!(matches!(result, Err(IsoError::PathTraversal(_))));
1508    }
1509
1510    #[tokio::test]
1511    async fn test_arch_detection() {
1512        let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
1513        let parser = IsoParser::new(mock);
1514
1515        let mount_base = PathBuf::from("/mock_mount");
1516        let entries = parser
1517            .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1518            .await
1519            .unwrap();
1520
1521        // Should find at least the Arch entry (might also find via other layouts that scan /boot)
1522        assert!(!entries.is_empty());
1523        assert!(entries.iter().any(|e| e.distribution == Distribution::Arch));
1524        assert!(entries
1525            .iter()
1526            .any(|e| e.kernel.to_string_lossy().contains("vmlinuz")));
1527    }
1528
1529    #[tokio::test]
1530    async fn test_debian_detection() {
1531        let mock = MockIsoEnvironment::with_iso(Distribution::Debian);
1532        let parser = IsoParser::new(mock);
1533
1534        let mount_base = PathBuf::from("/mock_mount");
1535        let entries = parser
1536            .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1537            .await
1538            .unwrap();
1539
1540        assert!(!entries.is_empty());
1541        assert!(entries
1542            .iter()
1543            .any(|e| e.distribution == Distribution::Debian));
1544    }
1545
1546    #[tokio::test]
1547    async fn test_fedora_detection() {
1548        let mock = MockIsoEnvironment::with_iso(Distribution::Fedora);
1549        let parser = IsoParser::new(mock);
1550
1551        let mount_base = PathBuf::from("/mock_mount");
1552        let entries = parser
1553            .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1554            .await
1555            .unwrap();
1556
1557        assert!(!entries.is_empty());
1558        assert!(entries
1559            .iter()
1560            .any(|e| e.distribution == Distribution::Fedora));
1561    }
1562
1563    #[test]
1564    fn test_distribution_from_paths() {
1565        assert_eq!(
1566            Distribution::from_paths(PathBuf::from("/boot/vmlinuz").as_path()),
1567            Distribution::Arch
1568        );
1569        assert_eq!(
1570            Distribution::from_paths(PathBuf::from("/casper/vmlinuz").as_path()),
1571            Distribution::Debian
1572        );
1573        assert_eq!(
1574            Distribution::from_paths(PathBuf::from("/images/pxeboot/vmlinuz").as_path()),
1575            Distribution::Fedora
1576        );
1577    }
1578
1579    #[test]
1580    fn test_boot_entry_serialization() {
1581        let entry = BootEntry {
1582            label: "Test Linux".to_string(),
1583            kernel: PathBuf::from("boot/vmlinuz"),
1584            initrd: Some(PathBuf::from("boot/initrd.img")),
1585            kernel_args: Some("quiet".to_string()),
1586            distribution: Distribution::Arch,
1587            source_iso: "test.iso".to_string(),
1588            pretty_name: None,
1589        };
1590
1591        let json = serde_json::to_string(&entry).unwrap();
1592        let decoded: BootEntry = serde_json::from_str(&json).unwrap();
1593
1594        assert_eq!(decoded.label, "Test Linux");
1595        assert_eq!(decoded.distribution, Distribution::Arch);
1596    }
1597
1598    // ---- #119: pretty-name detection --------------------------------
1599
1600    #[test]
1601    fn parse_pretty_name_systemd_shape() {
1602        let content = r#"
1603NAME="Ubuntu"
1604VERSION_ID="24.04"
1605PRETTY_NAME="Ubuntu 24.04.2 LTS (Noble Numbat)"
1606ID=ubuntu
1607"#;
1608        assert_eq!(
1609            parse_os_release_pretty_name(content).as_deref(),
1610            Some("Ubuntu 24.04.2 LTS (Noble Numbat)"),
1611        );
1612    }
1613
1614    #[test]
1615    fn parse_pretty_name_strips_single_quotes() {
1616        let content = "PRETTY_NAME='Alpine Linux v3.20'";
1617        assert_eq!(
1618            parse_os_release_pretty_name(content).as_deref(),
1619            Some("Alpine Linux v3.20"),
1620        );
1621    }
1622
1623    #[test]
1624    fn parse_pretty_name_unquoted_value() {
1625        // Some distros omit the quotes; spec allows either.
1626        let content = "PRETTY_NAME=Arch Linux";
1627        assert_eq!(
1628            parse_os_release_pretty_name(content).as_deref(),
1629            Some("Arch Linux"),
1630        );
1631    }
1632
1633    #[test]
1634    fn parse_pretty_name_empty_returns_none() {
1635        assert!(parse_os_release_pretty_name("PRETTY_NAME=\"\"").is_none());
1636        assert!(parse_os_release_pretty_name("").is_none());
1637    }
1638
1639    #[test]
1640    fn parse_pretty_name_missing_returns_none() {
1641        let content = "NAME=\"Ubuntu\"\nID=ubuntu";
1642        assert!(parse_os_release_pretty_name(content).is_none());
1643    }
1644
1645    #[test]
1646    fn parse_pretty_name_first_match_wins() {
1647        // Defensive: if a file has two PRETTY_NAME lines, take the first.
1648        let content = "PRETTY_NAME=\"First\"\nPRETTY_NAME=\"Second\"";
1649        assert_eq!(
1650            parse_os_release_pretty_name(content).as_deref(),
1651            Some("First"),
1652        );
1653    }
1654
1655    #[test]
1656    fn read_pretty_name_finds_etc_os_release() {
1657        let tmp = tempfile::tempdir().unwrap();
1658        std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1659        std::fs::write(
1660            tmp.path().join("etc/os-release"),
1661            "PRETTY_NAME=\"Rocky Linux 9.3 (Blue Onyx)\"\n",
1662        )
1663        .unwrap();
1664        assert_eq!(
1665            read_pretty_name(tmp.path()).as_deref(),
1666            Some("Rocky Linux 9.3 (Blue Onyx)"),
1667        );
1668    }
1669
1670    #[test]
1671    fn read_pretty_name_falls_back_to_disk_info() {
1672        let tmp = tempfile::tempdir().unwrap();
1673        std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
1674        std::fs::write(
1675            tmp.path().join(".disk/info"),
1676            "Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)\n",
1677        )
1678        .unwrap();
1679        assert_eq!(
1680            read_pretty_name(tmp.path()).as_deref(),
1681            Some("Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)"),
1682        );
1683    }
1684
1685    #[test]
1686    fn read_pretty_name_alpine_release_prepends_alpine_linux() {
1687        let tmp = tempfile::tempdir().unwrap();
1688        std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1689        std::fs::write(tmp.path().join("etc/alpine-release"), "3.20.3\n").unwrap();
1690        assert_eq!(
1691            read_pretty_name(tmp.path()).as_deref(),
1692            Some("Alpine Linux 3.20.3"),
1693        );
1694    }
1695
1696    #[test]
1697    fn read_pretty_name_prefers_etc_over_lib() {
1698        let tmp = tempfile::tempdir().unwrap();
1699        std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1700        std::fs::create_dir_all(tmp.path().join("usr/lib")).unwrap();
1701        std::fs::write(
1702            tmp.path().join("etc/os-release"),
1703            "PRETTY_NAME=\"Etc Wins\"\n",
1704        )
1705        .unwrap();
1706        std::fs::write(
1707            tmp.path().join("usr/lib/os-release"),
1708            "PRETTY_NAME=\"Lib Loses\"\n",
1709        )
1710        .unwrap();
1711        assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Etc Wins"),);
1712    }
1713
1714    #[test]
1715    fn read_pretty_name_returns_none_for_empty_mount() {
1716        let tmp = tempfile::tempdir().unwrap();
1717        assert!(read_pretty_name(tmp.path()).is_none());
1718    }
1719
1720    #[test]
1721    fn read_pretty_name_skips_empty_disk_info_line() {
1722        let tmp = tempfile::tempdir().unwrap();
1723        std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
1724        std::fs::write(tmp.path().join(".disk/info"), "\n\n   \nDebian 12.8\n").unwrap();
1725        assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Debian 12.8"),);
1726    }
1727
1728    /// `MockIsoEnvironment::metadata` must fail closed — previously it
1729    /// returned the real metadata of `std::env::temp_dir()` for any path
1730    /// the mock knew about, which silently validated size/mtime assertions
1731    /// against `/tmp` values instead of mock data. Regression from #138.
1732    #[test]
1733    fn mock_metadata_fails_closed() {
1734        let env = MockIsoEnvironment::new();
1735        let err = env
1736            .metadata(std::path::Path::new("/mock_mount/boot/vmlinuz"))
1737            .expect_err("mock metadata() must surface an error");
1738        assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
1739    }
1740
1741    /// Poisoned mount-points mutex must not cascade. Simulate a poisoning
1742    /// by panicking inside a lock-holding scope and confirm subsequent
1743    /// `mount_iso` / `unmount` calls still succeed. Regression from #138.
1744    #[test]
1745    fn mock_mount_lock_recovers_from_poison() {
1746        use std::sync::Arc;
1747        let env = Arc::new(MockIsoEnvironment::new());
1748        // Force a poisoned lock by panicking inside a critical section on
1749        // a scoped thread. The spawned thread's join result is expected
1750        // to be Err (the panic); that's what poisons the Mutex.
1751        let env_for_thread = env.clone();
1752        let join = std::thread::spawn(move || {
1753            let _guard = env_for_thread.mount_points.lock().unwrap();
1754            panic!("deliberately poisoning the mutex for this test");
1755        })
1756        .join();
1757        assert!(join.is_err(), "helper thread must have panicked");
1758
1759        // Now verify the mock still functions — mount + unmount should
1760        // succeed without panicking via lock recovery.
1761        let iso = std::path::Path::new("/isos/test.iso");
1762        let mount = env
1763            .mount_iso(iso)
1764            .expect("mount_iso must recover from poison");
1765        env.unmount(&mount)
1766            .expect("unmount must recover from poison");
1767    }
1768
1769    // ---- Windows layout detection (was silently skipped before) ----
1770
1771    #[tokio::test]
1772    async fn extract_boot_entries_detects_windows_installer() {
1773        let mock = MockIsoEnvironment::with_iso(Distribution::Windows);
1774        let parser = IsoParser::new(mock);
1775
1776        let mount_base = PathBuf::from("/mock_mount");
1777        let entries = parser
1778            .extract_boot_entries(&mount_base, &PathBuf::from("Win11_25H2.iso"))
1779            .await
1780            .expect("Windows ISO should now produce a BootEntry instead of empty");
1781
1782        assert!(
1783            !entries.is_empty(),
1784            "Windows ISO must produce at least one entry"
1785        );
1786        let win = entries
1787            .iter()
1788            .find(|e| e.distribution == Distribution::Windows)
1789            .expect("one of the entries must be Distribution::Windows");
1790        assert_eq!(win.kernel.to_string_lossy(), "bootmgr");
1791        assert!(win.initrd.is_none());
1792        assert_eq!(win.kernel_args, None);
1793        assert!(win.label.contains("Windows"));
1794        assert!(win.source_iso.contains("Win11"));
1795    }
1796
1797    #[tokio::test]
1798    async fn try_windows_layout_declines_on_linux_layouts() {
1799        // Arch mock has no Windows markers; try_windows_layout must
1800        // decline (return empty) rather than synthesize an entry.
1801        let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
1802        let parser = IsoParser::new(mock);
1803
1804        let mount_base = PathBuf::from("/mock_mount");
1805        let entries = parser
1806            .extract_boot_entries(&mount_base, &PathBuf::from("arch.iso"))
1807            .await
1808            .expect("Arch ISO must produce entries");
1809
1810        // No Windows-tagged entries should sneak in.
1811        assert!(
1812            !entries
1813                .iter()
1814                .any(|e| e.distribution == Distribution::Windows),
1815            "Windows detector must not fire on Arch fixture"
1816        );
1817    }
1818
1819    #[test]
1820    fn windows_boot_entry_has_not_kexec_bootable_quirk_in_iso_probe() {
1821        // Contract between iso-parser and iso-probe: when iso-parser emits
1822        // Distribution::Windows, iso-probe's lookup_quirks returns
1823        // NotKexecBootable. This test lives here (iso-parser side) so
1824        // the pairing is guarded end-to-end even if iso-probe internals
1825        // change — the public Distribution::Windows arm is stable.
1826        //
1827        // We don't depend on iso-probe from this crate (cyclic), so this
1828        // test asserts the metadata iso-parser produces is the shape
1829        // iso-probe's mapping expects (a Windows enum variant an
1830        // external crate can pattern-match on).
1831        let iso_distro = Distribution::Windows;
1832        assert!(matches!(iso_distro, Distribution::Windows));
1833    }
1834}