1#![allow(clippy::doc_markdown)]
8#![doc = include_str!("../README.md")]
9#![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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct DiscoveredIso {
55 pub iso_path: PathBuf,
57 pub label: String,
59 #[serde(default)]
67 pub pretty_name: Option<String>,
68 pub distribution: Distribution,
70 pub kernel: PathBuf,
72 pub initrd: Option<PathBuf>,
74 pub cmdline: Option<String>,
76 pub quirks: Vec<Quirk>,
78 pub hash_verification: HashVerification,
80 pub signature_verification: SignatureVerification,
82 pub size_bytes: Option<u64>,
85 pub contains_installer: bool,
90 #[serde(default)]
99 pub sidecar: Option<IsoSidecar>,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105pub enum Quirk {
106 UnsignedKernel,
109 BiosOnly,
111 RequiresWholeDeviceWrite,
115 CrossDistroKexecRefused,
118 NotKexecBootable,
122}
123
124#[derive(Debug, thiserror::Error)]
126pub enum ProbeError {
127 #[error("io error: {0}")]
129 Io(#[from] std::io::Error),
130 #[error("iso parser: {0}")]
132 Parser(#[from] IsoError),
133 #[error("no ISOs found in supplied roots")]
140 NoIsosFound,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct DiscoveryReport {
153 pub isos: Vec<DiscoveredIso>,
155 pub failed: Vec<FailedIso>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct FailedIso {
164 pub iso_path: PathBuf,
166 pub reason: String,
168 pub kind: FailureKind,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum FailureKind {
177 IoError,
179 MountFailed,
181 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
205pub 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 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 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 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 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
292fn 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
316fn 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 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 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#[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#[must_use]
448pub fn display_description(iso: &DiscoveredIso) -> Option<&str> {
449 iso.sidecar.as_ref().and_then(|s| s.description.as_deref())
450}
451
452const INSTALLER_MARKERS: &[&str] = &[
458 "live-server",
460 "live-desktop",
461 "desktop-amd64",
462 "server-amd64",
463 "netinst",
464 "netinstall",
465 "xubuntu",
466 "kubuntu",
467 "lubuntu",
468 "workstation",
470 "server-",
471 "-boot.iso",
472 "dvd-",
473 "dvd1",
474 "everything",
475 "netboot",
476 "opensuse",
478 "tumbleweed",
479 "leap",
480 "anaconda",
482 "windows",
484 "win10",
485 "win11",
486];
487
488#[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#[must_use]
511pub fn lookup_quirks(distribution: Distribution) -> Vec<Quirk> {
512 match distribution {
513 Distribution::Debian => Vec::new(),
517
518 Distribution::Fedora | Distribution::RedHat => vec![Quirk::CrossDistroKexecRefused],
525
526 Distribution::Arch | Distribution::Alpine | Distribution::NixOS | Distribution::Unknown => {
531 vec![Quirk::UnsignedKernel]
532 }
533
534 Distribution::Windows => vec![Quirk::NotKexecBootable],
538 }
539}
540
541pub struct PreparedIso {
544 mount_point: PathBuf,
545 pub kernel: PathBuf,
547 pub initrd: Option<PathBuf>,
549 pub cmdline: Option<String>,
551}
552
553impl PreparedIso {
554 #[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
574pub 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 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 let q = lookup_quirks(Distribution::Unknown);
618 assert!(q.contains(&Quirk::UnsignedKernel));
619 }
620
621 #[test]
622 fn redhat_inherits_cross_distro_refusal() {
623 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 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 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 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 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}