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};
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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct DiscoveredIso {
53 pub iso_path: PathBuf,
55 pub label: String,
57 #[serde(default)]
65 pub pretty_name: Option<String>,
66 pub distribution: Distribution,
68 pub kernel: PathBuf,
70 pub initrd: Option<PathBuf>,
72 pub cmdline: Option<String>,
74 pub quirks: Vec<Quirk>,
76 pub hash_verification: HashVerification,
78 pub signature_verification: SignatureVerification,
80 pub size_bytes: Option<u64>,
83 pub contains_installer: bool,
88 #[serde(default)]
97 pub sidecar: Option<IsoSidecar>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum Quirk {
104 UnsignedKernel,
107 BiosOnly,
109 RequiresWholeDeviceWrite,
113 CrossDistroKexecRefused,
116 NotKexecBootable,
120}
121
122#[derive(Debug, thiserror::Error)]
124pub enum ProbeError {
125 #[error("io error: {0}")]
127 Io(#[from] std::io::Error),
128 #[error("iso parser: {0}")]
130 Parser(#[from] IsoError),
131 #[error("no ISOs found in supplied roots")]
133 NoIsosFound,
134}
135
136pub 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 let mut seen: std::collections::HashSet<(String, u64)> = std::collections::HashSet::new();
153 for root in roots {
154 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 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
212fn 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
236fn 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 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 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#[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#[must_use]
368pub fn display_description(iso: &DiscoveredIso) -> Option<&str> {
369 iso.sidecar.as_ref().and_then(|s| s.description.as_deref())
370}
371
372const INSTALLER_MARKERS: &[&str] = &[
378 "live-server",
380 "live-desktop",
381 "desktop-amd64",
382 "server-amd64",
383 "netinst",
384 "netinstall",
385 "xubuntu",
386 "kubuntu",
387 "lubuntu",
388 "workstation",
390 "server-",
391 "-boot.iso",
392 "dvd-",
393 "dvd1",
394 "everything",
395 "netboot",
396 "opensuse",
398 "tumbleweed",
399 "leap",
400 "anaconda",
402 "windows",
404 "win10",
405 "win11",
406];
407
408#[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#[must_use]
431pub fn lookup_quirks(distribution: Distribution) -> Vec<Quirk> {
432 match distribution {
433 Distribution::Debian => Vec::new(),
437
438 Distribution::Fedora | Distribution::RedHat => vec![Quirk::CrossDistroKexecRefused],
445
446 Distribution::Arch | Distribution::Alpine | Distribution::NixOS | Distribution::Unknown => {
451 vec![Quirk::UnsignedKernel]
452 }
453
454 Distribution::Windows => vec![Quirk::NotKexecBootable],
458 }
459}
460
461pub struct PreparedIso {
464 mount_point: PathBuf,
465 pub kernel: PathBuf,
467 pub initrd: Option<PathBuf>,
469 pub cmdline: Option<String>,
471}
472
473impl PreparedIso {
474 #[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
494pub 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 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 let q = lookup_quirks(Distribution::Unknown);
538 assert!(q.contains(&Quirk::UnsignedKernel));
539 }
540
541 #[test]
542 fn redhat_inherits_cross_distro_refusal() {
543 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 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 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 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}