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}