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