#![allow(clippy::doc_markdown)]
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
pub mod minisign;
pub mod sidecar;
pub mod signature;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub use iso_parser::{BootEntry, Distribution, IsoError, ScanFailure, ScanFailureKind, ScanReport};
pub use minisign::{SignatureVerification, verify_iso_signature};
pub use sidecar::{
IsoSidecar, SidecarError, load_sidecar, sidecar_path_for, to_toml as sidecar_to_toml,
write_sidecar,
};
pub use signature::{
HashVerification, compute_iso_sha256, verify_iso_hash, verify_iso_hash_with_progress,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredIso {
pub iso_path: PathBuf,
pub label: String,
#[serde(default)]
pub pretty_name: Option<String>,
pub distribution: Distribution,
pub kernel: PathBuf,
pub initrd: Option<PathBuf>,
pub cmdline: Option<String>,
pub quirks: Vec<Quirk>,
pub hash_verification: HashVerification,
pub signature_verification: SignatureVerification,
pub size_bytes: Option<u64>,
pub contains_installer: bool,
#[serde(default)]
pub sidecar: Option<IsoSidecar>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Quirk {
UnsignedKernel,
BiosOnly,
RequiresWholeDeviceWrite,
CrossDistroKexecRefused,
NotKexecBootable,
}
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("iso parser: {0}")]
Parser(#[from] IsoError),
#[error("no ISOs found in supplied roots")]
NoIsosFound,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryReport {
pub isos: Vec<DiscoveredIso>,
pub failed: Vec<FailedIso>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FailedIso {
pub iso_path: PathBuf,
pub reason: String,
pub kind: FailureKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureKind {
IoError,
MountFailed,
NoBootEntries,
}
impl From<ScanFailureKind> for FailureKind {
fn from(k: ScanFailureKind) -> Self {
match k {
ScanFailureKind::IoError => Self::IoError,
ScanFailureKind::MountFailed => Self::MountFailed,
ScanFailureKind::NoBootEntries => Self::NoBootEntries,
}
}
}
impl From<ScanFailure> for FailedIso {
fn from(f: ScanFailure) -> Self {
Self {
iso_path: f.iso_path,
reason: f.reason,
kind: FailureKind::from(f.kind),
}
}
}
pub fn discover(roots: &[PathBuf]) -> Result<DiscoveryReport, ProbeError> {
let parser = iso_parser::IsoParser::new(iso_parser::OsIsoEnvironment::new());
let mut isos: Vec<DiscoveredIso> = Vec::new();
let mut failed: Vec<FailedIso> = Vec::new();
let mut seen: std::collections::HashSet<(String, u64)> = std::collections::HashSet::new();
let mut seen_failed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let mut walk_succeeded_somewhere = false;
for root in roots {
if !root.exists() {
tracing::info!(
root = %root.display(),
"iso-probe: root does not exist — skipping"
);
continue;
}
tracing::info!(root = %root.display(), "iso-probe: scanning root");
match pollster::block_on(parser.scan_directory_with_failures(root)) {
Ok(report) => {
walk_succeeded_somewhere = true;
let before_ok = isos.len();
let before_fail = failed.len();
for entry in &report.entries {
let size = find_iso_size(root, &entry.source_iso).unwrap_or(0);
let key = (entry.source_iso.clone(), size);
if !seen.insert(key) {
continue;
}
isos.push(boot_entry_to_discovered(entry, root));
}
for failure in report.failures {
if seen_failed.insert(failure.iso_path.clone()) {
failed.push(FailedIso::from(failure));
}
}
tracing::info!(
root = %root.display(),
extracted = report.entries.len(),
kept = isos.len() - before_ok,
failed_added = failed.len() - before_fail,
"iso-probe: scan extracted entries"
);
}
Err(IsoError::NoBootEntries(_)) => {
tracing::info!(
root = %root.display(),
"iso-probe: scan returned NoBootEntries (zero .iso files under this root)"
);
walk_succeeded_somewhere = true;
}
Err(IsoError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!(
root = %root.display(),
"iso-probe: root disappeared during scan"
);
}
Err(e) => return Err(ProbeError::Parser(e)),
}
}
if !walk_succeeded_somewhere || (isos.is_empty() && failed.is_empty()) {
return Err(ProbeError::NoIsosFound);
}
Ok(DiscoveryReport { isos, failed })
}
fn walk_for_iso_size(dir: &Path, filename: &str, depth: u32) -> Option<u64> {
if depth == 0 {
return None;
}
let iter = std::fs::read_dir(dir).ok()?;
for entry in iter.flatten() {
let p = entry.path();
if let Ok(ft) = entry.file_type() {
if ft.is_file() && p.file_name().and_then(|n| n.to_str()) == Some(filename) {
return entry.metadata().ok().map(|m| m.len());
}
if ft.is_dir() {
if let Some(size) = walk_for_iso_size(&p, filename, depth - 1) {
return Some(size);
}
}
}
}
None
}
fn find_iso_size(root: &Path, filename: &str) -> Option<u64> {
let direct = root.join(filename);
if let Ok(m) = std::fs::metadata(&direct) {
if m.is_file() {
return Some(m.len());
}
}
walk_for_iso_size(root, filename, 3)
}
fn boot_entry_to_discovered(entry: &BootEntry, search_root: &Path) -> DiscoveredIso {
let iso_path = search_root.join(&entry.source_iso);
let hash_verification = verify_iso_hash(&iso_path).unwrap_or_else(|e| {
tracing::warn!(
iso = %iso_path.display(),
error = %e,
"iso-probe: ISO hash read failed (I/O error on ISO itself)"
);
HashVerification::Unreadable {
source: iso_path.display().to_string(),
reason: e.to_string(),
}
});
match &hash_verification {
HashVerification::Verified { source, .. } => tracing::info!(
iso = %iso_path.display(),
source = %source,
"iso-probe: hash verified"
),
HashVerification::Mismatch { source, .. } => tracing::warn!(
iso = %iso_path.display(),
source = %source,
"iso-probe: HASH MISMATCH — checksum file disagrees with ISO bytes"
),
HashVerification::NotPresent => tracing::debug!(
iso = %iso_path.display(),
"iso-probe: no sibling checksum file"
),
HashVerification::Unreadable { source, reason } => tracing::warn!(
iso = %iso_path.display(),
source = %source,
reason = %reason,
"iso-probe: checksum file present but unreadable — verification suppressed"
),
}
let signature_verification = verify_iso_signature(&iso_path);
match &signature_verification {
SignatureVerification::Verified { key_id, .. } => tracing::info!(
iso = %iso_path.display(),
key_id = %key_id,
"iso-probe: signature verified against trusted key"
),
SignatureVerification::KeyNotTrusted { key_id } => tracing::warn!(
iso = %iso_path.display(),
key_id = %key_id,
"iso-probe: signature key is not in AEGIS_TRUSTED_KEYS"
),
SignatureVerification::Forged { sig_path } => tracing::warn!(
iso = %iso_path.display(),
sig = %sig_path.display(),
"iso-probe: SIGNATURE FORGED — bytes don't match sig"
),
SignatureVerification::Error { reason } => tracing::warn!(
iso = %iso_path.display(),
error = %reason,
"iso-probe: signature verification errored"
),
SignatureVerification::NotPresent => tracing::debug!(
iso = %iso_path.display(),
"iso-probe: no sibling .minisig"
),
}
let size_bytes = std::fs::metadata(&iso_path).ok().map(|m| m.len());
let contains_installer = detect_installer(&iso_path);
let sidecar = match load_sidecar(&iso_path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
iso = %iso_path.display(),
error = %e,
"iso-probe: sidecar present but unreadable — falling back to filename"
);
None
}
};
DiscoveredIso {
iso_path,
label: entry.label.clone(),
pretty_name: entry.pretty_name.clone(),
distribution: entry.distribution,
kernel: entry.kernel.clone(),
initrd: entry.initrd.clone(),
cmdline: entry.kernel_args.clone(),
quirks: lookup_quirks(entry.distribution),
hash_verification,
signature_verification,
size_bytes,
contains_installer,
sidecar,
}
}
#[must_use]
pub fn display_name(iso: &DiscoveredIso) -> &str {
iso.sidecar
.as_ref()
.and_then(|s| s.display_name.as_deref())
.or(iso.pretty_name.as_deref())
.unwrap_or(&iso.label)
}
#[must_use]
pub fn display_description(iso: &DiscoveredIso) -> Option<&str> {
iso.sidecar.as_ref().and_then(|s| s.description.as_deref())
}
const INSTALLER_MARKERS: &[&str] = &[
"live-server",
"live-desktop",
"desktop-amd64",
"server-amd64",
"netinst",
"netinstall",
"xubuntu",
"kubuntu",
"lubuntu",
"workstation",
"server-",
"-boot.iso",
"dvd-",
"dvd1",
"everything",
"netboot",
"opensuse",
"tumbleweed",
"leap",
"anaconda",
"windows",
"win10",
"win11",
];
#[must_use]
pub fn detect_installer(iso_path: &Path) -> bool {
let name = match iso_path.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_ascii_lowercase(),
None => return false,
};
INSTALLER_MARKERS.iter().any(|m| name.contains(m))
}
#[must_use]
pub fn lookup_quirks(distribution: Distribution) -> Vec<Quirk> {
match distribution {
Distribution::Debian => Vec::new(),
Distribution::Fedora | Distribution::RedHat => vec![Quirk::CrossDistroKexecRefused],
Distribution::Arch | Distribution::Alpine | Distribution::NixOS | Distribution::Unknown => {
vec![Quirk::UnsignedKernel]
}
Distribution::Windows => vec![Quirk::NotKexecBootable],
}
}
pub struct PreparedIso {
mount_point: PathBuf,
pub kernel: PathBuf,
pub initrd: Option<PathBuf>,
pub cmdline: Option<String>,
}
impl PreparedIso {
#[must_use]
pub fn mount_point(&self) -> &Path {
&self.mount_point
}
}
impl Drop for PreparedIso {
fn drop(&mut self) {
let env = iso_parser::OsIsoEnvironment::new();
if let Err(e) = iso_parser::IsoEnvironment::unmount(&env, &self.mount_point) {
tracing::warn!(
mount = %self.mount_point.display(),
error = %e,
"iso-probe: unmount on drop failed; rescue env may have stale mount"
);
}
}
}
pub fn prepare(iso: &DiscoveredIso) -> Result<PreparedIso, ProbeError> {
let env = iso_parser::OsIsoEnvironment::new();
let mount_point = iso_parser::IsoEnvironment::mount_iso(&env, &iso.iso_path)?;
Ok(PreparedIso {
kernel: mount_point.join(&iso.kernel),
initrd: iso.initrd.as_ref().map(|p| mount_point.join(p)),
cmdline: iso.cmdline.clone(),
mount_point,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debian_has_no_known_quirks() {
assert!(lookup_quirks(Distribution::Debian).is_empty());
}
#[test]
fn fedora_flags_cross_distro_kexec_refusal() {
let q = lookup_quirks(Distribution::Fedora);
assert!(q.contains(&Quirk::CrossDistroKexecRefused));
assert!(!q.contains(&Quirk::UnsignedKernel));
}
#[test]
fn arch_flags_unsigned_kernel() {
let q = lookup_quirks(Distribution::Arch);
assert!(q.contains(&Quirk::UnsignedKernel));
}
#[test]
fn unknown_defaults_to_unsigned_warning() {
let q = lookup_quirks(Distribution::Unknown);
assert!(q.contains(&Quirk::UnsignedKernel));
}
#[test]
fn redhat_inherits_cross_distro_refusal() {
let q = lookup_quirks(Distribution::RedHat);
assert!(q.contains(&Quirk::CrossDistroKexecRefused));
assert!(!q.contains(&Quirk::UnsignedKernel));
}
#[test]
fn alpine_flags_unsigned_kernel() {
assert!(lookup_quirks(Distribution::Alpine).contains(&Quirk::UnsignedKernel));
}
#[test]
fn nixos_flags_unsigned_kernel() {
assert!(lookup_quirks(Distribution::NixOS).contains(&Quirk::UnsignedKernel));
}
#[test]
fn windows_flags_not_kexec_bootable() {
let q = lookup_quirks(Distribution::Windows);
assert!(q.contains(&Quirk::NotKexecBootable));
assert!(!q.contains(&Quirk::UnsignedKernel));
}
#[test]
fn boot_entry_conversion_preserves_paths_and_metadata() {
let entry = BootEntry {
label: "Ubuntu 24.04".to_string(),
kernel: PathBuf::from("casper/vmlinuz"),
initrd: Some(PathBuf::from("casper/initrd")),
kernel_args: Some("boot=casper".to_string()),
distribution: Distribution::Debian,
source_iso: "ubuntu-24.04.iso".to_string(),
pretty_name: Some("Ubuntu 24.04.2 LTS (Noble Numbat)".to_string()),
};
let root = PathBuf::from("/run/media/usb1");
let discovered = boot_entry_to_discovered(&entry, &root);
assert_eq!(
discovered.iso_path,
PathBuf::from("/run/media/usb1/ubuntu-24.04.iso")
);
assert_eq!(discovered.label, "Ubuntu 24.04");
assert_eq!(discovered.kernel, PathBuf::from("casper/vmlinuz"));
assert_eq!(discovered.initrd, Some(PathBuf::from("casper/initrd")));
assert_eq!(discovered.cmdline.as_deref(), Some("boot=casper"));
assert_eq!(discovered.distribution, Distribution::Debian);
assert_eq!(
discovered.pretty_name.as_deref(),
Some("Ubuntu 24.04.2 LTS (Noble Numbat)"),
);
assert_eq!(
display_name(&discovered),
"Ubuntu 24.04.2 LTS (Noble Numbat)"
);
}
#[test]
fn display_name_falls_back_to_label_when_no_pretty_name() {
let entry = BootEntry {
label: "Alpine".to_string(),
kernel: PathBuf::from("boot/vmlinuz-lts"),
initrd: Some(PathBuf::from("boot/initramfs-lts")),
kernel_args: None,
distribution: Distribution::Alpine,
source_iso: "alpine.iso".to_string(),
pretty_name: None,
};
let discovered = boot_entry_to_discovered(&entry, &PathBuf::from("/run/media/usb1"));
assert_eq!(display_name(&discovered), "Alpine");
}
#[test]
fn discover_on_empty_dir_returns_no_isos_found() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let Err(err) = discover(&[dir.path().to_path_buf()]) else {
panic!("discover on empty dir should fail");
};
assert!(matches!(err, ProbeError::NoIsosFound));
}
#[test]
fn failure_kind_maps_from_scan_failure_kind() {
assert_eq!(
FailureKind::from(ScanFailureKind::IoError),
FailureKind::IoError
);
assert_eq!(
FailureKind::from(ScanFailureKind::MountFailed),
FailureKind::MountFailed
);
assert_eq!(
FailureKind::from(ScanFailureKind::NoBootEntries),
FailureKind::NoBootEntries
);
}
#[test]
fn failed_iso_from_scan_failure_preserves_path_and_reason() {
let sf = ScanFailure {
iso_path: PathBuf::from("/isos/broken.iso"),
reason: "mount: wrong fs type".to_string(),
kind: ScanFailureKind::MountFailed,
};
let fi = FailedIso::from(sf);
assert_eq!(fi.iso_path, PathBuf::from("/isos/broken.iso"));
assert_eq!(fi.reason, "mount: wrong fs type");
assert_eq!(fi.kind, FailureKind::MountFailed);
}
#[test]
fn discovery_report_has_both_isos_and_failed_accessors() {
let report = DiscoveryReport {
isos: Vec::new(),
failed: vec![FailedIso {
iso_path: PathBuf::from("/isos/x.iso"),
reason: "test".to_string(),
kind: FailureKind::NoBootEntries,
}],
};
assert_eq!(report.isos.len(), 0);
assert_eq!(report.failed.len(), 1);
}
#[test]
fn prepare_uses_discovered_paths() {
let iso = DiscoveredIso {
iso_path: PathBuf::from("/tmp/x.iso"),
label: "x".to_string(),
distribution: Distribution::Unknown,
kernel: PathBuf::from("boot/vmlinuz"),
initrd: Some(PathBuf::from("boot/initrd")),
cmdline: Some("quiet".to_string()),
quirks: vec![],
hash_verification: HashVerification::NotPresent,
signature_verification: SignatureVerification::NotPresent,
size_bytes: None,
contains_installer: false,
pretty_name: None,
sidecar: None,
};
let mount = PathBuf::from("/mnt/test");
let kernel = mount.join(&iso.kernel);
assert_eq!(kernel, PathBuf::from("/mnt/test/boot/vmlinuz"));
}
}