Skip to main content

iso_probe/
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// `clippy::doc_markdown = allow` — README prose targets a general
5// operator audience; strict auto-backticking of product/tool names
6// is noise here. API-level `//!` docs still get the full lint.
7#![allow(clippy::doc_markdown)]
8#![doc = include_str!("../README.md")]
9//!
10//! ---
11//!
12//! # Rust API — two-phase shape
13//!
14//! Runtime ISO discovery on the live aegis-boot rescue environment.
15//!
16//! Two-phase API:
17//!
18//! 1. [`discover`] — scan a set of root paths for `.iso` files, mount each
19//!    once, extract per-ISO boot metadata (kernel + initrd + cmdline relative
20//!    to the ISO root), unmount. Returns metadata-only [`DiscoveredIso`]
21//!    records suitable for rendering in the TUI.
22//! 2. [`prepare`] — given a user-selected [`DiscoveredIso`], re-mount the ISO
23//!    and return a [`PreparedIso`] whose [`absolute paths`](PreparedIso::kernel)
24//!    can be handed to `kexec-loader::load_and_exec`. The mount is unmounted
25//!    when the [`PreparedIso`] is dropped — but `kexec` replaces the
26//!    process before that happens on the success path, so the live mount
27//!    persists exactly as long as it needs to.
28//!
29//! See [ADR 0001](../../../docs/adr/0001-runtime-architecture.md).
30
31#![forbid(unsafe_code)]
32
33pub mod minisign;
34pub mod sidecar;
35pub mod signature;
36
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40
41pub use iso_parser::{BootEntry, Distribution, IsoError};
42pub use minisign::{verify_iso_signature, SignatureVerification};
43pub use sidecar::{
44    load_sidecar, sidecar_path_for, to_toml as sidecar_to_toml, write_sidecar, IsoSidecar,
45    SidecarError,
46};
47pub use signature::{verify_iso_hash, verify_iso_hash_with_progress, HashVerification};
48
49/// Metadata for a single discovered ISO. Paths are relative to the (now
50/// unmounted) ISO root and become absolute once handed to [`prepare`].
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct DiscoveredIso {
53    /// Absolute path to the `.iso` file on the host filesystem.
54    pub iso_path: PathBuf,
55    /// Human label (e.g. "Ubuntu 24.04 LTS").
56    pub label: String,
57    /// Full distro name + version read from the mounted ISO's
58    /// `/etc/os-release` (`PRETTY_NAME`), `/.disk/info`, or
59    /// `/etc/alpine-release`. `None` when none of those files
60    /// resolved (older installers, unfamiliar layouts). Downstream
61    /// UIs should prefer this over `label` when present so operators
62    /// see "Ubuntu 24.04.2 LTS (Noble Numbat)" instead of just
63    /// "Ubuntu". (#119)
64    #[serde(default)]
65    pub pretty_name: Option<String>,
66    /// Detected distribution family.
67    pub distribution: Distribution,
68    /// Kernel path relative to the ISO root.
69    pub kernel: PathBuf,
70    /// Optional initrd path relative to the ISO root.
71    pub initrd: Option<PathBuf>,
72    /// Kernel command line as declared by the ISO's boot config.
73    pub cmdline: Option<String>,
74    /// Quirks the rescue TUI should warn about before kexec.
75    pub quirks: Vec<Quirk>,
76    /// Hash verification status (from sibling checksum files, if any).
77    pub hash_verification: HashVerification,
78    /// Minisign signature verification status (from sibling .minisig, if any).
79    pub signature_verification: SignatureVerification,
80    /// File size in bytes from `stat(2)` on `iso_path`. `None` if stat failed.
81    /// Rendered as a human-readable value in the Confirm preview pane.
82    pub size_bytes: Option<u64>,
83    /// True if this ISO is known to contain an installer that can
84    /// write to disk when the user picks the wrong boot-menu entry.
85    /// Determined heuristically from filename patterns. rescue-tui
86    /// surfaces a yellow warning strip on the Confirm screen. (#131)
87    pub contains_installer: bool,
88    /// Operator-curated metadata loaded from a sibling
89    /// `<iso>.aegis.toml` file, if present. Cosmetic only —
90    /// `display_name`, `description`, `last_verified_at`, etc. The
91    /// boot decision still keys off the sha256-attested manifest.
92    /// `None` when no sidecar exists or when parsing failed (a
93    /// malformed sidecar logs at WARN and otherwise behaves as
94    /// "not present" — the menu falls back to the bare filename).
95    /// (#246)
96    #[serde(default)]
97    pub sidecar: Option<IsoSidecar>,
98}
99
100/// Compatibility quirks the TUI should surface to the user before invoking
101/// kexec. Populated by the per-distro matrix (issue #6).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum Quirk {
104    /// ISO's kernel is not signed by a CA in the platform/MOK keyring.
105    /// `kexec_file_load` will reject without MOK enrollment.
106    UnsignedKernel,
107    /// ISO assumes BIOS isolinux only — no usable EFI/kexec path.
108    BiosOnly,
109    /// ISO is hybrid and expects to be `dd`'d to a whole block device,
110    /// not loop-mounted. Kexec may succeed but the booted kernel may not
111    /// find its expected layout.
112    RequiresWholeDeviceWrite,
113    /// Distro signs only its own CA's kernels and refuses kexec into
114    /// foreign-CA kernels even with `KEXEC_SIG` satisfied (RHEL family).
115    CrossDistroKexecRefused,
116    /// ISO uses a boot protocol incompatible with `kexec_file_load`
117    /// (Windows' NT loader, BSD bootloaders, etc.). The TUI should
118    /// disable kexec for these entries rather than fail silently.
119    NotKexecBootable,
120}
121
122/// Errors returned during probing.
123#[derive(Debug, thiserror::Error)]
124pub enum ProbeError {
125    /// Underlying I/O failure.
126    #[error("io error: {0}")]
127    Io(#[from] std::io::Error),
128    /// The wrapped ISO parser raised an error.
129    #[error("iso parser: {0}")]
130    Parser(#[from] IsoError),
131    /// No ISOs were found under any of the supplied roots.
132    #[error("no ISOs found in supplied roots")]
133    NoIsosFound,
134}
135
136/// Discover all bootable ISOs under the supplied root directories.
137///
138/// # Errors
139///
140/// Returns [`ProbeError::Parser`] if the wrapped scan fails. Individual ISOs
141/// with unrecognized layouts are skipped silently and never abort the scan.
142pub fn discover(roots: &[PathBuf]) -> Result<Vec<DiscoveredIso>, ProbeError> {
143    let parser = iso_parser::IsoParser::new(iso_parser::OsIsoEnvironment::new());
144    let mut all: Vec<DiscoveredIso> = Vec::new();
145    // Dedupe across roots that share ancestry (e.g. /run/media/aegis-isos
146    // is a subdir of /run/media; both listed in AEGIS_ISO_ROOTS). (#117)
147    // iso-parser stores source_iso as filename-only; we can't reliably
148    // canonicalize because scan-2's root.join(filename) points to a
149    // non-existent path. Dedupe by (filename, size) — effectively a
150    // content-identity key for ISOs — resolved per root via search
151    // for an existing candidate on disk.
152    let mut seen: std::collections::HashSet<(String, u64)> = std::collections::HashSet::new();
153    for root in roots {
154        // Missing / unreadable roots are not an error — the rescue environment
155        // routinely runs with `/run/media` present but `/mnt` empty or vice
156        // versa depending on whether anything was attached at boot. Log at
157        // INFO so an empty list is debuggable (#68 — operators were seeing
158        // "0 ISOs discovered" without any signal of where the scan looked).
159        if !root.exists() {
160            tracing::info!(
161                root = %root.display(),
162                "iso-probe: root does not exist — skipping"
163            );
164            continue;
165        }
166        tracing::info!(root = %root.display(), "iso-probe: scanning root");
167        match pollster::block_on(parser.scan_directory(root)) {
168            Ok(entries) => {
169                let before = all.len();
170                for entry in &entries {
171                    // Key = (source_iso filename, file size). Need size
172                    // so two ISOs with the same filename in different
173                    // dirs don't collide. Tree-walk for the actual file
174                    // under the current root — iso-parser already did
175                    // this during scan, but we need the result back.
176                    let size = find_iso_size(root, &entry.source_iso).unwrap_or(0);
177                    let key = (entry.source_iso.clone(), size);
178                    if !seen.insert(key) {
179                        continue;
180                    }
181                    all.push(boot_entry_to_discovered(entry, root));
182                }
183                tracing::info!(
184                    root = %root.display(),
185                    extracted = entries.len(),
186                    kept = all.len() - before,
187                    "iso-probe: scan extracted entries"
188                );
189            }
190            Err(IsoError::NoBootEntries(_)) => {
191                tracing::info!(
192                    root = %root.display(),
193                    "iso-probe: scan returned NoBootEntries (no .iso files found, or all skipped)"
194                );
195            }
196            Err(IsoError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
197                tracing::info!(
198                    root = %root.display(),
199                    "iso-probe: root disappeared during scan"
200                );
201            }
202            Err(e) => return Err(ProbeError::Parser(e)),
203        }
204    }
205    if all.is_empty() {
206        Err(ProbeError::NoIsosFound)
207    } else {
208        Ok(all)
209    }
210}
211
212/// Recursive walk helper for [`find_iso_size`]. Bounded depth so we
213/// don't wander into a large tree. `AEGIS_ISOS` layouts are flat;
214/// 3 levels is more than enough. (#117)
215fn walk_for_iso_size(dir: &Path, filename: &str, depth: u32) -> Option<u64> {
216    if depth == 0 {
217        return None;
218    }
219    let iter = std::fs::read_dir(dir).ok()?;
220    for entry in iter.flatten() {
221        let p = entry.path();
222        if let Ok(ft) = entry.file_type() {
223            if ft.is_file() && p.file_name().and_then(|n| n.to_str()) == Some(filename) {
224                return entry.metadata().ok().map(|m| m.len());
225            }
226            if ft.is_dir() {
227                if let Some(size) = walk_for_iso_size(&p, filename, depth - 1) {
228                    return Some(size);
229                }
230            }
231        }
232    }
233    None
234}
235
236/// Walk `root` looking for a file named `filename` at any depth and
237/// return its byte size. Used as a dedup helper in [`discover`] —
238/// iso-parser stores `source_iso` as filename-only, so we have to walk
239/// to find the real file. (#117)
240fn find_iso_size(root: &Path, filename: &str) -> Option<u64> {
241    let direct = root.join(filename);
242    if let Ok(m) = std::fs::metadata(&direct) {
243        if m.is_file() {
244            return Some(m.len());
245        }
246    }
247    walk_for_iso_size(root, filename, 3)
248}
249
250fn boot_entry_to_discovered(entry: &BootEntry, search_root: &Path) -> DiscoveredIso {
251    let iso_path = search_root.join(&entry.source_iso);
252    let hash_verification = verify_iso_hash(&iso_path).unwrap_or_else(|e| {
253        // Reading the ISO itself failed — surface as Unreadable with the
254        // ISO path as source so the operator sees "ISO bytes could not
255        // be read" rather than a silent "no verification" verdict. (#138)
256        tracing::warn!(
257            iso = %iso_path.display(),
258            error = %e,
259            "iso-probe: ISO hash read failed (I/O error on ISO itself)"
260        );
261        HashVerification::Unreadable {
262            source: iso_path.display().to_string(),
263            reason: e.to_string(),
264        }
265    });
266    match &hash_verification {
267        HashVerification::Verified { source, .. } => tracing::info!(
268            iso = %iso_path.display(),
269            source = %source,
270            "iso-probe: hash verified"
271        ),
272        HashVerification::Mismatch { source, .. } => tracing::warn!(
273            iso = %iso_path.display(),
274            source = %source,
275            "iso-probe: HASH MISMATCH — checksum file disagrees with ISO bytes"
276        ),
277        HashVerification::NotPresent => tracing::debug!(
278            iso = %iso_path.display(),
279            "iso-probe: no sibling checksum file"
280        ),
281        HashVerification::Unreadable { source, reason } => tracing::warn!(
282            iso = %iso_path.display(),
283            source = %source,
284            reason = %reason,
285            "iso-probe: checksum file present but unreadable — verification suppressed"
286        ),
287    }
288    let signature_verification = verify_iso_signature(&iso_path);
289    match &signature_verification {
290        SignatureVerification::Verified { key_id, .. } => tracing::info!(
291            iso = %iso_path.display(),
292            key_id = %key_id,
293            "iso-probe: signature verified against trusted key"
294        ),
295        SignatureVerification::KeyNotTrusted { key_id } => tracing::warn!(
296            iso = %iso_path.display(),
297            key_id = %key_id,
298            "iso-probe: signature key is not in AEGIS_TRUSTED_KEYS"
299        ),
300        SignatureVerification::Forged { sig_path } => tracing::warn!(
301            iso = %iso_path.display(),
302            sig = %sig_path.display(),
303            "iso-probe: SIGNATURE FORGED — bytes don't match sig"
304        ),
305        SignatureVerification::Error { reason } => tracing::warn!(
306            iso = %iso_path.display(),
307            error = %reason,
308            "iso-probe: signature verification errored"
309        ),
310        SignatureVerification::NotPresent => tracing::debug!(
311            iso = %iso_path.display(),
312            "iso-probe: no sibling .minisig"
313        ),
314    }
315    let size_bytes = std::fs::metadata(&iso_path).ok().map(|m| m.len());
316    let contains_installer = detect_installer(&iso_path);
317    let sidecar = match load_sidecar(&iso_path) {
318        Ok(s) => s,
319        Err(e) => {
320            // Malformed sidecar shouldn't fail the scan — degrade to
321            // "no sidecar" with a warn-level log so operators can
322            // diagnose without blocking boot. (#246)
323            tracing::warn!(
324                iso = %iso_path.display(),
325                error = %e,
326                "iso-probe: sidecar present but unreadable — falling back to filename"
327            );
328            None
329        }
330    };
331    DiscoveredIso {
332        iso_path,
333        label: entry.label.clone(),
334        pretty_name: entry.pretty_name.clone(),
335        distribution: entry.distribution,
336        kernel: entry.kernel.clone(),
337        initrd: entry.initrd.clone(),
338        cmdline: entry.kernel_args.clone(),
339        quirks: lookup_quirks(entry.distribution),
340        hash_verification,
341        signature_verification,
342        size_bytes,
343        contains_installer,
344        sidecar,
345    }
346}
347
348/// Preferred human label for display. Resolution order:
349/// 1. `sidecar.display_name` — operator-curated, wins when set (#246)
350/// 2. `pretty_name` — read from the ISO's `os-release` etc. (#119)
351/// 3. `label` — original boot-entry label
352///
353/// Downstream UIs that want a single "always non-empty" name should
354/// call this instead of reading the fields directly.
355#[must_use]
356pub fn display_name(iso: &DiscoveredIso) -> &str {
357    iso.sidecar
358        .as_ref()
359        .and_then(|s| s.display_name.as_deref())
360        .or(iso.pretty_name.as_deref())
361        .unwrap_or(&iso.label)
362}
363
364/// Optional one-line description for the menu's second row, sourced
365/// from the operator-curated sidecar. Returns `None` when no sidecar
366/// is present or its `description` field is unset. (#246)
367#[must_use]
368pub fn display_description(iso: &DiscoveredIso) -> Option<&str> {
369    iso.sidecar.as_ref().and_then(|s| s.description.as_deref())
370}
371
372/// Heuristic detection: does this ISO contain an installer that can
373/// overwrite the host's disks? Based on filename substrings of the
374/// most common installer-bearing images. Intentionally inclusive —
375/// a false-positive (showing a warning on a live-only ISO) is safer
376/// than a false-negative (silently hiding the installer risk). (#131)
377const INSTALLER_MARKERS: &[&str] = &[
378    // Ubuntu / Debian / Mint
379    "live-server",
380    "live-desktop",
381    "desktop-amd64",
382    "server-amd64",
383    "netinst",
384    "netinstall",
385    "xubuntu",
386    "kubuntu",
387    "lubuntu",
388    // Fedora / RHEL family
389    "workstation",
390    "server-",
391    "-boot.iso",
392    "dvd-",
393    "dvd1",
394    "everything",
395    "netboot",
396    // openSUSE
397    "opensuse",
398    "tumbleweed",
399    "leap",
400    // Anaconda-based installers
401    "anaconda",
402    // Windows
403    "windows",
404    "win10",
405    "win11",
406];
407
408/// Heuristic: does this ISO filename indicate an installer image?
409/// See `INSTALLER_MARKERS` for the match list. (#131)
410#[must_use]
411pub fn detect_installer(iso_path: &Path) -> bool {
412    let name = match iso_path.file_name().and_then(|s| s.to_str()) {
413        Some(n) => n.to_ascii_lowercase(),
414        None => return false,
415    };
416    INSTALLER_MARKERS.iter().any(|m| name.contains(m))
417}
418
419/// Look up quirks for a distribution family.
420///
421/// Data source: [`docs/compatibility/iso-matrix.md`][matrix]. Each mapping is
422/// a conservative default — the matrix doc is the ground truth and should be
423/// updated alongside any change here.
424///
425/// **Unknown distributions get the most cautious treatment** (assume unsigned
426/// kernel). Downstream code must **not** treat an empty return as "safe" —
427/// some verified-good layouts (e.g. Debian casper) legitimately return empty.
428///
429/// [matrix]: ../../../docs/compatibility/iso-matrix.md
430#[must_use]
431pub fn lookup_quirks(distribution: Distribution) -> Vec<Quirk> {
432    match distribution {
433        // Canonical/Debian-signed kernels (Ubuntu, Debian live/casper).
434        // shim → grub → signed vmlinuz path is well-tested; `KEXEC_SIG`
435        // accepts kernels signed by the shipped distro CA. No known quirks.
436        Distribution::Debian => Vec::new(),
437
438        // Fedora's kernel is signed by the Fedora UEFI CA. RHEL lineage
439        // enforces an additional keyring check inside `kexec_file_load`
440        // that rejects kernels signed by a *different* CA even when
441        // `KEXEC_SIG` would accept; the rescue-tui surfaces this as
442        // `CrossDistroKexecRefused` so the user sees a specific diagnostic
443        // instead of a generic EPERM.
444        Distribution::Fedora | Distribution::RedHat => vec![Quirk::CrossDistroKexecRefused],
445
446        // Arch install media ships unsigned kernels by default (no
447        // shim-review-board-approved shim). Alpine and NixOS ship unsigned
448        // ISOs by default too. Unknown distributions share the same
449        // conservative default: assume unsigned until proven otherwise.
450        Distribution::Arch | Distribution::Alpine | Distribution::NixOS | Distribution::Unknown => {
451            vec![Quirk::UnsignedKernel]
452        }
453
454        // Windows uses the NT loader / UEFI bootmgfw, not a Linux kernel.
455        // Surface the non-bootability explicitly so the TUI can disable
456        // kexec rather than fail silently after the user picks it.
457        Distribution::Windows => vec![Quirk::NotKexecBootable],
458    }
459}
460
461/// A live, loop-mounted ISO with absolute paths suitable for handoff to
462/// `kexec-loader`. Unmounts on drop.
463pub struct PreparedIso {
464    mount_point: PathBuf,
465    /// Absolute path to the kernel image on the live mount.
466    pub kernel: PathBuf,
467    /// Absolute path to the initrd, if any.
468    pub initrd: Option<PathBuf>,
469    /// Kernel command line, copied from the discovery record.
470    pub cmdline: Option<String>,
471}
472
473impl PreparedIso {
474    /// Path under which the ISO is currently loop-mounted.
475    #[must_use]
476    pub fn mount_point(&self) -> &Path {
477        &self.mount_point
478    }
479}
480
481impl Drop for PreparedIso {
482    fn drop(&mut self) {
483        let env = iso_parser::OsIsoEnvironment::new();
484        if let Err(e) = iso_parser::IsoEnvironment::unmount(&env, &self.mount_point) {
485            tracing::warn!(
486                mount = %self.mount_point.display(),
487                error = %e,
488                "iso-probe: unmount on drop failed; rescue env may have stale mount"
489            );
490        }
491    }
492}
493
494/// Re-mount the selected ISO and return absolute paths for kexec handoff.
495///
496/// # Errors
497///
498/// Returns [`ProbeError::Parser`] if the loop-mount fails (no privileges, no
499/// loop devices, malformed ISO).
500pub fn prepare(iso: &DiscoveredIso) -> Result<PreparedIso, ProbeError> {
501    let env = iso_parser::OsIsoEnvironment::new();
502    let mount_point = iso_parser::IsoEnvironment::mount_iso(&env, &iso.iso_path)?;
503    Ok(PreparedIso {
504        kernel: mount_point.join(&iso.kernel),
505        initrd: iso.initrd.as_ref().map(|p| mount_point.join(p)),
506        cmdline: iso.cmdline.clone(),
507        mount_point,
508    })
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn debian_has_no_known_quirks() {
517        // Canonical/Debian signed + casper layout: verified-good default.
518        assert!(lookup_quirks(Distribution::Debian).is_empty());
519    }
520
521    #[test]
522    fn fedora_flags_cross_distro_kexec_refusal() {
523        let q = lookup_quirks(Distribution::Fedora);
524        assert!(q.contains(&Quirk::CrossDistroKexecRefused));
525        assert!(!q.contains(&Quirk::UnsignedKernel));
526    }
527
528    #[test]
529    fn arch_flags_unsigned_kernel() {
530        let q = lookup_quirks(Distribution::Arch);
531        assert!(q.contains(&Quirk::UnsignedKernel));
532    }
533
534    #[test]
535    fn unknown_defaults_to_unsigned_warning() {
536        // Conservative default when we can't identify the distribution.
537        let q = lookup_quirks(Distribution::Unknown);
538        assert!(q.contains(&Quirk::UnsignedKernel));
539    }
540
541    #[test]
542    fn redhat_inherits_cross_distro_refusal() {
543        // RHEL/Rocky/Alma share Fedora's layout + the same lockdown policy.
544        let q = lookup_quirks(Distribution::RedHat);
545        assert!(q.contains(&Quirk::CrossDistroKexecRefused));
546        assert!(!q.contains(&Quirk::UnsignedKernel));
547    }
548
549    #[test]
550    fn alpine_flags_unsigned_kernel() {
551        assert!(lookup_quirks(Distribution::Alpine).contains(&Quirk::UnsignedKernel));
552    }
553
554    #[test]
555    fn nixos_flags_unsigned_kernel() {
556        assert!(lookup_quirks(Distribution::NixOS).contains(&Quirk::UnsignedKernel));
557    }
558
559    #[test]
560    fn windows_flags_not_kexec_bootable() {
561        let q = lookup_quirks(Distribution::Windows);
562        assert!(q.contains(&Quirk::NotKexecBootable));
563        assert!(!q.contains(&Quirk::UnsignedKernel));
564    }
565
566    #[test]
567    fn boot_entry_conversion_preserves_paths_and_metadata() {
568        let entry = BootEntry {
569            label: "Ubuntu 24.04".to_string(),
570            kernel: PathBuf::from("casper/vmlinuz"),
571            initrd: Some(PathBuf::from("casper/initrd")),
572            kernel_args: Some("boot=casper".to_string()),
573            distribution: Distribution::Debian,
574            source_iso: "ubuntu-24.04.iso".to_string(),
575            pretty_name: Some("Ubuntu 24.04.2 LTS (Noble Numbat)".to_string()),
576        };
577        let root = PathBuf::from("/run/media/usb1");
578        let discovered = boot_entry_to_discovered(&entry, &root);
579        assert_eq!(
580            discovered.iso_path,
581            PathBuf::from("/run/media/usb1/ubuntu-24.04.iso")
582        );
583        assert_eq!(discovered.label, "Ubuntu 24.04");
584        assert_eq!(discovered.kernel, PathBuf::from("casper/vmlinuz"));
585        assert_eq!(discovered.initrd, Some(PathBuf::from("casper/initrd")));
586        assert_eq!(discovered.cmdline.as_deref(), Some("boot=casper"));
587        assert_eq!(discovered.distribution, Distribution::Debian);
588        assert_eq!(
589            discovered.pretty_name.as_deref(),
590            Some("Ubuntu 24.04.2 LTS (Noble Numbat)"),
591        );
592        // display_name prefers pretty_name when present
593        assert_eq!(
594            display_name(&discovered),
595            "Ubuntu 24.04.2 LTS (Noble Numbat)"
596        );
597    }
598
599    #[test]
600    fn display_name_falls_back_to_label_when_no_pretty_name() {
601        let entry = BootEntry {
602            label: "Alpine".to_string(),
603            kernel: PathBuf::from("boot/vmlinuz-lts"),
604            initrd: Some(PathBuf::from("boot/initramfs-lts")),
605            kernel_args: None,
606            distribution: Distribution::Alpine,
607            source_iso: "alpine.iso".to_string(),
608            pretty_name: None,
609        };
610        let discovered = boot_entry_to_discovered(&entry, &PathBuf::from("/run/media/usb1"));
611        assert_eq!(display_name(&discovered), "Alpine");
612    }
613
614    #[test]
615    fn discover_on_empty_dir_returns_no_isos_found() {
616        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
617        let Err(err) = discover(&[dir.path().to_path_buf()]) else {
618            panic!("discover on empty dir should fail");
619        };
620        assert!(matches!(err, ProbeError::NoIsosFound));
621    }
622
623    #[test]
624    fn prepare_uses_discovered_paths() {
625        // Conversion test — exercises the path-joining logic without
626        // requiring an actual loop-mount (which needs root + a real ISO).
627        let iso = DiscoveredIso {
628            iso_path: PathBuf::from("/tmp/x.iso"),
629            label: "x".to_string(),
630            distribution: Distribution::Unknown,
631            kernel: PathBuf::from("boot/vmlinuz"),
632            initrd: Some(PathBuf::from("boot/initrd")),
633            cmdline: Some("quiet".to_string()),
634            quirks: vec![],
635            hash_verification: HashVerification::NotPresent,
636            signature_verification: SignatureVerification::NotPresent,
637            size_bytes: None,
638            contains_installer: false,
639            pretty_name: None,
640            sidecar: None,
641        };
642        // Sanity-check the path-joining we'd perform on a real mount.
643        let mount = PathBuf::from("/mnt/test");
644        let kernel = mount.join(&iso.kernel);
645        assert_eq!(kernel, PathBuf::from("/mnt/test/boot/vmlinuz"));
646    }
647}