#![allow(clippy::doc_markdown)]
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{debug, instrument};
#[cfg(test)]
#[path = "detection_tests.rs"]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::too_many_lines,
clippy::missing_panics_doc
)]
mod detection_tests;
#[derive(Debug, Error)]
pub enum IsoError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("No boot entries found in ISO: {0}")]
NoBootEntries(String),
#[error("Mount failed: {0}")]
MountFailed(String),
#[error("Path traversal attempt blocked: {0}")]
PathTraversal(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BootEntry {
pub label: String,
pub kernel: PathBuf,
pub initrd: Option<PathBuf>,
pub kernel_args: Option<String>,
pub distribution: Distribution,
pub source_iso: String,
#[serde(default)]
pub pretty_name: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum Distribution {
Arch,
Debian,
Fedora,
RedHat,
Alpine,
NixOS,
Windows,
Unknown,
}
impl Distribution {
#[must_use]
pub fn from_paths(kernel_path: &std::path::Path) -> Self {
let path_str = kernel_path.to_string_lossy().to_lowercase();
if path_str.contains("bootmgr")
|| path_str.contains("sources/boot.wim")
|| path_str.contains("efi/microsoft")
|| path_str.contains("windows")
{
Distribution::Windows
} else if path_str.contains("nixos") || path_str.ends_with("bzimage") {
Distribution::NixOS
} else if path_str.contains("alpine")
|| path_str.contains("vmlinuz-lts")
|| path_str.contains("vmlinuz-virt")
|| path_str.contains("initramfs-lts")
|| path_str.contains("initramfs-virt")
{
Distribution::Alpine
} else if path_str.contains("rhel")
|| path_str.contains("rocky")
|| path_str.contains("almalinux")
|| path_str.contains("centos")
{
Distribution::RedHat
} else if path_str.contains("fedora")
|| path_str.contains("images")
|| path_str.contains("pxeboot")
{
Distribution::Fedora
} else if path_str.contains("debian")
|| path_str.contains("ubuntu")
|| path_str.contains("casper")
{
Distribution::Debian
} else if path_str.contains("arch")
|| (path_str.contains("boot")
&& !path_str.contains("efi")
&& !path_str.contains("images"))
{
Distribution::Arch
} else {
Distribution::Unknown
}
}
}
pub trait IsoEnvironment: Send + Sync {
fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>>;
fn exists(&self, path: &std::path::Path) -> bool;
fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata>;
fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError>;
fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError>;
fn validate_path(
&self,
base: &std::path::Path,
path: &std::path::Path,
) -> Result<PathBuf, IsoError> {
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(IsoError::PathTraversal(path.display().to_string()));
}
if !path.starts_with(base) {
return Err(IsoError::PathTraversal(path.display().to_string()));
}
Ok(path.to_path_buf())
}
}
pub struct OsIsoEnvironment {
mount_base: PathBuf,
}
impl OsIsoEnvironment {
#[must_use]
pub fn new() -> Self {
Self {
mount_base: PathBuf::from("/tmp/iso-parser-mounts"),
}
}
fn allocate_loop_device(iso_path: &std::path::Path) -> Option<String> {
use std::process::Command;
match Command::new("losetup")
.args(["-f", "--show", "-r", &iso_path.to_string_lossy()])
.output()
{
Ok(out) if out.status.success() => {
let dev = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !dev.is_empty() && dev.starts_with("/dev/") {
return Some(dev);
}
tracing::warn!(
iso = %iso_path.display(),
stdout = %String::from_utf8_lossy(&out.stdout),
"iso-parser: util-linux losetup succeeded but returned no /dev/loop* device"
);
}
Ok(out) => {
tracing::warn!(
iso = %iso_path.display(),
exit = ?out.status.code(),
stderr = %String::from_utf8_lossy(&out.stderr),
"iso-parser: util-linux losetup -f --show failed; falling back to busybox scan"
);
}
Err(e) => {
tracing::warn!(
iso = %iso_path.display(),
error = %e,
"iso-parser: losetup exec failed (not on PATH?); falling back to busybox scan"
);
}
}
for n in 0..16 {
let dev = format!("/dev/loop{n}");
if !std::path::Path::new(&dev).exists() {
continue;
}
let query = match Command::new("losetup").arg(&dev).output() {
Ok(q) => q,
Err(e) => {
tracing::warn!(
dev = %dev,
error = %e,
"iso-parser: losetup query exec failed; skipping device"
);
continue;
}
};
if query.status.success() {
continue; }
match Command::new("losetup")
.args(["-r", &dev, &iso_path.to_string_lossy()])
.output()
{
Ok(attach) if attach.status.success() => return Some(dev),
Ok(attach) => {
tracing::warn!(
dev = %dev,
iso = %iso_path.display(),
exit = ?attach.status.code(),
stderr = %String::from_utf8_lossy(&attach.stderr),
"iso-parser: losetup attach failed; trying next device"
);
}
Err(e) => {
tracing::warn!(
dev = %dev,
iso = %iso_path.display(),
error = %e,
"iso-parser: losetup attach exec failed; giving up"
);
return None;
}
}
}
tracing::warn!(
iso = %iso_path.display(),
"iso-parser: exhausted /dev/loop0..15 without a free device; cannot mount ISO"
);
None
}
}
impl Default for OsIsoEnvironment {
fn default() -> Self {
Self::new()
}
}
impl IsoEnvironment for OsIsoEnvironment {
fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
let mut entries = std::fs::read_dir(path)?
.map(|e| e.map(|entry| entry.path()))
.collect::<Result<Vec<_>, _>>()?;
entries.sort();
Ok(entries)
}
fn exists(&self, path: &std::path::Path) -> bool {
path.exists()
}
fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
std::fs::metadata(path)
}
fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
use std::process::Command;
let iso_name = iso_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("iso");
let mount_point = self.mount_base.join(format!("mount_{iso_name}"));
std::fs::create_dir_all(&mount_point)?;
let output = Command::new("mount")
.args([
"-o",
"loop,ro",
"-t",
"udf,iso9660",
&iso_path.to_string_lossy(),
&mount_point.to_string_lossy(),
])
.output();
let loop_attempt_ok = match &output {
Ok(out) if out.status.success() => {
std::fs::read_dir(&mount_point)
.ok()
.and_then(|mut entries| entries.next())
.is_some()
}
_ => false,
};
if !loop_attempt_ok {
let loop_dev = Self::allocate_loop_device(iso_path);
if let Some(loop_dev) = loop_dev {
let mount_out = Command::new("mount")
.args([
"-r",
"-t",
"udf,iso9660",
&loop_dev,
&mount_point.to_string_lossy(),
])
.output();
if let Ok(mo) = mount_out {
if mo.status.success() {
debug!(
"Mounted {} via losetup {} -> {:?}",
iso_path.display(),
loop_dev,
mount_point
);
return Ok(mount_point);
}
}
let _ = Command::new("losetup").args(["-d", &loop_dev]).output();
}
}
let mount_point_populated = || {
std::fs::read_dir(&mount_point)
.ok()
.and_then(|mut entries| entries.next())
.is_some()
};
match output {
Ok(out) if out.status.success() && mount_point_populated() => {
debug!("Mounted {} to {:?}", iso_path.display(), mount_point);
Ok(mount_point)
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let reason = if out.status.success() {
format!(
"mount claimed success but {} is empty — \
filesystem type likely not auto-detected \
(stderr: {})",
mount_point.display(),
stderr.trim()
)
} else {
stderr.to_string()
};
let fuse_output = Command::new("fuseiso")
.arg(iso_path.to_string_lossy().as_ref())
.arg(mount_point.to_string_lossy().as_ref())
.output();
match fuse_output {
Ok(fuse_out) if fuse_out.status.success() && mount_point_populated() => {
debug!("Mounted {} via fuseiso", iso_path.display());
Ok(mount_point)
}
_ => {
let _ = std::fs::remove_dir(&mount_point);
Err(IsoError::MountFailed(reason))
}
}
}
Err(e) => Err(IsoError::Io(e)),
}
}
fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
use std::process::Command;
let umount_result = Command::new("umount").arg(mount_point).output();
match umount_result {
Ok(out) if out.status.success() => {
let _ = std::fs::remove_dir(mount_point);
Ok(())
}
_ => {
let fusermount = Command::new("fusermount")
.arg("-u")
.arg(mount_point)
.output();
match fusermount {
Ok(out) if out.status.success() => {
let _ = std::fs::remove_dir(mount_point);
Ok(())
}
_ => Err(IsoError::MountFailed(format!(
"Failed to unmount {}",
mount_point.display()
))),
}
}
}
}
}
pub struct IsoParser<E: IsoEnvironment> {
env: E,
}
impl<E: IsoEnvironment> IsoParser<E> {
pub fn new(env: E) -> Self {
Self { env }
}
#[instrument(skip(self))]
#[allow(clippy::unused_async)]
pub async fn scan_directory(&self, path: &std::path::Path) -> Result<Vec<BootEntry>, IsoError> {
let mut entries = Vec::new();
let validated_path = self.env.validate_path(std::path::Path::new("/"), path)?;
debug!("Scanning directory: {:?}", validated_path);
let iso_files = self.find_iso_files(&validated_path)?;
let attempted = iso_files.len();
let mut skipped = 0usize;
for iso_path in iso_files {
debug!("Processing ISO: {:?}", iso_path);
match self.process_iso(&iso_path).await {
Ok(mut iso_entries) => entries.append(&mut iso_entries),
Err(e) => {
skipped += 1;
tracing::warn!(
iso = %iso_path.display(),
error = %e,
"iso-parser: skipped ISO (mount/parse failed)"
);
}
}
}
tracing::info!(
root = %validated_path.display(),
found_isos = attempted,
extracted_entries = entries.len(),
skipped_isos = skipped,
"iso-parser: scan summary"
);
if entries.is_empty() {
return Err(IsoError::NoBootEntries(
validated_path.to_string_lossy().to_string(),
));
}
Ok(entries)
}
fn find_iso_files(&self, path: &std::path::Path) -> Result<Vec<PathBuf>, IsoError> {
let mut isos = Vec::new();
for entry in self.env.list_dir(path)? {
let entry_path = &entry;
if entry.is_dir() {
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.starts_with('.') && name != "proc" && name != "sys" && name != "dev" {
if let Ok(mut sub_isos) = self.find_iso_files(entry_path) {
isos.append(&mut sub_isos);
}
}
} else if let Some(ext) = entry.extension().and_then(|s| s.to_str()) {
if ext.eq_ignore_ascii_case("iso") {
isos.push(entry.clone());
}
}
}
Ok(isos)
}
async fn process_iso(&self, iso_path: &Path) -> Result<Vec<BootEntry>, IsoError> {
let mount_point = self.env.mount_iso(iso_path)?;
let result = self.extract_boot_entries(&mount_point, iso_path).await;
let _ = self.env.unmount(&mount_point);
result
}
#[allow(clippy::unused_async)]
async fn extract_boot_entries(
&self,
mount_point: &Path,
source_iso: &Path,
) -> Result<Vec<BootEntry>, IsoError> {
let mut entries = Vec::new();
entries.extend(self.try_arch_layout(mount_point, source_iso)?);
entries.extend(self.try_debian_layout(mount_point, source_iso)?);
entries.extend(self.try_fedora_layout(mount_point, source_iso)?);
entries.extend(self.try_windows_layout(mount_point, source_iso)?);
let pretty = read_pretty_name(mount_point);
if pretty.is_some() {
for entry in &mut entries {
entry.pretty_name.clone_from(&pretty);
}
}
Ok(entries)
}
fn try_arch_layout(
&self,
mount_point: &Path,
source_iso: &Path,
) -> Result<Vec<BootEntry>, IsoError> {
let boot_dir = mount_point.join("boot");
if !self.env.exists(&boot_dir) {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in self.env.list_dir(&boot_dir)? {
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("vmlinuz") {
let kernel = entry.clone();
let mut initrd = boot_dir.join(format!(
"initrd.img{}",
name.strip_prefix("vmlinuz").unwrap_or("")
));
if !self.env.exists(&initrd) {
initrd = boot_dir.join("initrd.img");
}
if !self.env.exists(&initrd) {
initrd = boot_dir.join(format!(
"initrd{}",
name.strip_prefix("vmlinuz").unwrap_or("")
));
}
let has_initrd = self.env.exists(&initrd);
let rel_kernel = kernel
.strip_prefix(mount_point)
.map(std::path::Path::to_path_buf)
.map_err(|_| {
IsoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Kernel path escape",
))
})?;
let distribution = Distribution::from_paths(&rel_kernel);
let label = match distribution {
Distribution::Alpine => format!(
"Alpine {}",
name.strip_prefix("vmlinuz-").unwrap_or("").trim()
),
Distribution::Arch => format!(
"Arch Linux {}",
name.strip_prefix("vmlinuz").unwrap_or("").trim()
),
_ => format!(
"Linux {}",
name.strip_prefix("vmlinuz").unwrap_or("").trim()
),
};
let kernel_args = if distribution == Distribution::Arch {
Some(
"archisobasedir=arch archiso_http_server=https://mirror.archlinux.org"
.to_string(),
)
} else {
None
};
entries.push(BootEntry {
label,
kernel: rel_kernel,
initrd: if has_initrd { Some(initrd) } else { None },
kernel_args,
distribution,
source_iso: source_iso
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string(),
pretty_name: None,
});
}
}
Ok(entries)
}
fn try_debian_layout(
&self,
mount_point: &Path,
source_iso: &Path,
) -> Result<Vec<BootEntry>, IsoError> {
let mut entries = Vec::new();
let debian_markers = [
mount_point.join("install"),
mount_point.join("casper"),
mount_point.join(".disk"),
mount_point.join("pool"),
mount_point.join("dists"),
];
if !debian_markers.iter().any(|p| self.env.exists(p)) {
return Ok(entries);
}
let search_paths = [
mount_point.join("install"),
mount_point.join("casper"),
mount_point.join("boot"),
];
for search_dir in &search_paths {
if !self.env.exists(search_dir) {
continue;
}
for entry in self.env.list_dir(search_dir)? {
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("vmlinuz") {
let kernel = entry.clone();
let initrd_names = ["initrd.lz", "initrd.gz", "initrd.img", "initrd"];
let mut found_initrd = None;
for initrd_name in initrd_names {
let initrd_path = search_dir.join(initrd_name);
if self.env.exists(&initrd_path) {
found_initrd = Some(initrd_path);
break;
}
}
let kernel_args = if search_dir == &mount_point.join("casper") {
Some(
"boot=casper locale=en_US.UTF-8 keyboard-configuration/layoutcode=us"
.to_string(),
)
} else {
None
};
entries.push(BootEntry {
label: format!(
"Debian/Ubuntu {}",
name.strip_prefix("vmlinuz").unwrap_or("").trim()
),
kernel: kernel
.strip_prefix(mount_point)
.map(std::path::Path::to_path_buf)
.map_err(|_| {
IsoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Kernel path escape",
))
})?,
initrd: found_initrd
.map(|p| {
p.strip_prefix(mount_point)
.map(std::path::Path::to_path_buf)
.map_err(|_| {
IsoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Initrd path escape",
))
})
})
.transpose()?,
kernel_args,
distribution: Distribution::Debian,
source_iso: source_iso
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string(),
pretty_name: None,
});
}
}
}
Ok(entries)
}
fn try_fedora_layout(
&self,
mount_point: &Path,
source_iso: &Path,
) -> Result<Vec<BootEntry>, IsoError> {
let images_dir = mount_point.join("images").join("pxeboot");
if !self.env.exists(&images_dir) {
let alt_dir = mount_point.join("isolinux");
if !self.env.exists(&alt_dir) {
return Ok(Vec::new());
}
return self.process_fedora_isolinux(&alt_dir, mount_point, source_iso);
}
let mut entries = Vec::new();
for entry in self.env.list_dir(&images_dir)? {
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("vmlinuz") {
let kernel = entry.clone();
let version = name.strip_prefix("vmlinuz").unwrap_or("");
let initrd_names = [
format!("initrd{version}.img"),
"initrd.img".to_string(),
format!("initrd{}.img", version.trim_end_matches('-')),
];
let mut found_initrd = None;
for initrd_name in &initrd_names {
let initrd_path = images_dir.join(initrd_name);
if self.env.exists(&initrd_path) {
found_initrd = Some(initrd_path);
break;
}
}
entries.push(BootEntry {
label: format!("Fedora {}", version.trim()),
kernel: kernel
.strip_prefix(mount_point)
.map(std::path::Path::to_path_buf)
.map_err(|_| {
IsoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Kernel path escape",
))
})?,
initrd: found_initrd
.map(|p| {
p.strip_prefix(mount_point)
.map(std::path::Path::to_path_buf)
.map_err(|_| {
IsoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Initrd path escape",
))
})
})
.transpose()?,
kernel_args: Some("inst.stage2=hd:LABEL=Fedora-39-x86_64".to_string()),
distribution: Distribution::Fedora,
source_iso: source_iso
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string(),
pretty_name: None,
});
}
}
Ok(entries)
}
fn process_fedora_isolinux(
&self,
isolinux_dir: &Path,
mount_point: &Path,
source_iso: &Path,
) -> Result<Vec<BootEntry>, IsoError> {
let mut entries = Vec::new();
for entry in self.env.list_dir(isolinux_dir)? {
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("vmlinuz") {
let kernel = entry.clone();
let images_dir = mount_point.join("images");
let initrd_path = images_dir.join("initrd.img");
entries.push(BootEntry {
label: format!(
"Fedora (isolinux) {}",
name.strip_prefix("vmlinuz").unwrap_or("").trim()
),
kernel: kernel
.strip_prefix(mount_point)
.map(std::path::Path::to_path_buf)
.map_err(|_| {
IsoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Kernel path escape",
))
})?,
initrd: if self.env.exists(&initrd_path) {
Some(
initrd_path
.strip_prefix(mount_point)
.map(std::path::Path::to_path_buf)
.map_err(|_| {
IsoError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Initrd path escape",
))
})?,
)
} else {
None
},
kernel_args: Some("inst.stage2=hd:LABEL=Fedora".to_string()),
distribution: Distribution::Fedora,
source_iso: source_iso
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string(),
pretty_name: None,
});
}
}
Ok(entries)
}
#[allow(clippy::unnecessary_wraps)]
fn try_windows_layout(
&self,
mount_point: &Path,
source_iso: &Path,
) -> Result<Vec<BootEntry>, IsoError> {
let bootmgr = mount_point.join("bootmgr");
let boot_wim = mount_point.join("sources/boot.wim");
let efi_ms_boot = mount_point.join("efi/microsoft/boot");
let bootmgfw_efi = mount_point.join("efi/boot/bootx64.efi");
let has_any_marker = self.env.exists(&bootmgr)
|| self.env.exists(&boot_wim)
|| self.env.exists(&efi_ms_boot);
if !has_any_marker {
return Ok(Vec::new());
}
let kernel_path = if self.env.exists(&bootmgr) {
PathBuf::from("bootmgr")
} else if self.env.exists(&bootmgfw_efi) {
PathBuf::from("efi/boot/bootx64.efi")
} else {
PathBuf::from("sources/boot.wim")
};
let label = "Windows (not kexec-bootable)".to_string();
Ok(vec![BootEntry {
label,
kernel: kernel_path,
initrd: None,
kernel_args: None,
distribution: Distribution::Windows,
source_iso: source_iso
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string(),
pretty_name: None,
}])
}
}
#[must_use]
pub fn read_pretty_name(mount_point: &Path) -> Option<String> {
for rel in ["etc/os-release", "lib/os-release", "usr/lib/os-release"] {
if let Some(name) = read_os_release(&mount_point.join(rel)) {
return Some(name);
}
}
if let Some(first_line) = read_first_nonempty_line(&mount_point.join(".disk/info")) {
return Some(first_line);
}
if let Some(version) = read_first_nonempty_line(&mount_point.join("etc/alpine-release")) {
return Some(format!("Alpine Linux {version}"));
}
None
}
fn read_os_release(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
parse_os_release_pretty_name(&content)
}
#[must_use]
pub(crate) fn parse_os_release_pretty_name(content: &str) -> Option<String> {
for line in content.lines() {
let Some(rest) = line.strip_prefix("PRETTY_NAME=") else {
continue;
};
let trimmed = rest
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string();
if trimmed.is_empty() {
return None;
}
return Some(trimmed);
}
None
}
fn read_first_nonempty_line(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::too_many_lines,
clippy::missing_panics_doc,
clippy::match_same_arms
)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockIsoEnvironment {
files: HashMap<PathBuf, MockEntry>,
mount_points: Mutex<Vec<PathBuf>>,
}
#[derive(Debug, Clone)]
enum MockEntry {
File,
Directory(Vec<PathBuf>),
}
impl MockIsoEnvironment {
fn new() -> Self {
Self {
files: HashMap::new(),
mount_points: Mutex::new(Vec::new()),
}
}
fn with_iso(distribution: Distribution) -> Self {
let mut env = Self::new();
let mount_base = PathBuf::from("/mock_mount");
match distribution {
Distribution::Arch => {
env.files.insert(
mount_base.join("boot"),
MockEntry::Directory(vec![
mount_base.join("boot/vmlinuz"),
mount_base.join("boot/initrd.img"),
]),
);
env.files
.insert(mount_base.join("boot/vmlinuz"), MockEntry::File);
env.files
.insert(mount_base.join("boot/initrd.img"), MockEntry::File);
}
Distribution::Debian => {
env.files.insert(
mount_base.join("install"),
MockEntry::Directory(vec![mount_base.join("install/vmlinuz")]),
);
env.files
.insert(mount_base.join("install/vmlinuz"), MockEntry::File);
env.files.insert(
mount_base.join("casper"),
MockEntry::Directory(vec![
mount_base.join("casper/initrd.lz"),
mount_base.join("casper/filesystem.squashfs"),
]),
);
env.files
.insert(mount_base.join("casper/initrd.lz"), MockEntry::File);
env.files.insert(
mount_base.join("casper/filesystem.squashfs"),
MockEntry::File,
);
}
Distribution::Fedora => {
env.files.insert(
mount_base.join("images"),
MockEntry::Directory(vec![mount_base.join("images/pxeboot")]),
);
env.files.insert(
mount_base.join("images/pxeboot"),
MockEntry::Directory(vec![
mount_base.join("images/pxeboot/vmlinuz"),
mount_base.join("images/pxeboot/initrd.img"),
]),
);
env.files
.insert(mount_base.join("images/pxeboot/vmlinuz"), MockEntry::File);
env.files.insert(
mount_base.join("images/pxeboot/initrd.img"),
MockEntry::File,
);
}
Distribution::RedHat | Distribution::Alpine | Distribution::NixOS => {}
Distribution::Windows => {
env.files
.insert(mount_base.join("bootmgr"), MockEntry::File);
env.files.insert(
mount_base.join("sources"),
MockEntry::Directory(vec![mount_base.join("sources/boot.wim")]),
);
env.files
.insert(mount_base.join("sources/boot.wim"), MockEntry::File);
env.files.insert(
mount_base.join("efi"),
MockEntry::Directory(vec![mount_base.join("efi/microsoft")]),
);
env.files.insert(
mount_base.join("efi/microsoft"),
MockEntry::Directory(vec![mount_base.join("efi/microsoft/boot")]),
);
env.files.insert(
mount_base.join("efi/microsoft/boot"),
MockEntry::Directory(vec![]),
);
}
Distribution::Unknown => {}
}
env.files.insert(
PathBuf::from("/isos"),
MockEntry::Directory(vec![PathBuf::from("/isos/test.iso")]),
);
env.files
.insert(PathBuf::from("/isos/test.iso"), MockEntry::File);
env
}
}
impl IsoEnvironment for MockIsoEnvironment {
fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
match self.files.get(path) {
Some(MockEntry::Directory(entries)) => Ok(entries.clone()),
Some(MockEntry::File) => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Not a directory",
)),
None => Ok(Vec::new()), }
}
fn exists(&self, path: &std::path::Path) -> bool {
self.files.contains_key(path)
}
fn metadata(&self, _path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"MockIsoEnvironment::metadata is not implemented — see #138 for the design note",
))
}
fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
let mount_point = PathBuf::from(format!(
"/mock_mount/{}",
iso_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("iso")
));
self.mount_points
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(mount_point.clone());
Ok(mount_point)
}
fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
let mut points = self
.mount_points
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
points.retain(|p| p != mount_point);
Ok(())
}
}
#[test]
fn test_path_traversal_blocked() {
let env = MockIsoEnvironment::new();
let result = env.validate_path(
PathBuf::from("/safe").as_path(),
PathBuf::from("/safe/../../../etc/passwd").as_path(),
);
assert!(result.is_err());
match result {
Err(IsoError::PathTraversal(_)) => {}
_ => panic!("Expected PathTraversal error"),
}
}
#[test]
fn test_path_allowed() {
let env = MockIsoEnvironment::new();
let result = env.validate_path(
PathBuf::from("/safe").as_path(),
PathBuf::from("/safe/subdir/file").as_path(),
);
assert!(result.is_ok());
}
#[test]
fn test_path_outside_base_rejected() {
let env = MockIsoEnvironment::new();
let result = env.validate_path(
PathBuf::from("/mnt/iso").as_path(),
PathBuf::from("/etc/passwd").as_path(),
);
assert!(matches!(result, Err(IsoError::PathTraversal(_))));
}
#[test]
fn test_path_sibling_of_base_rejected() {
let env = MockIsoEnvironment::new();
let result = env.validate_path(
PathBuf::from("/safe").as_path(),
PathBuf::from("/safe2/file").as_path(),
);
assert!(matches!(result, Err(IsoError::PathTraversal(_))));
}
#[tokio::test]
async fn test_arch_detection() {
let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
let parser = IsoParser::new(mock);
let mount_base = PathBuf::from("/mock_mount");
let entries = parser
.extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
.await
.unwrap();
assert!(!entries.is_empty());
assert!(entries.iter().any(|e| e.distribution == Distribution::Arch));
assert!(entries
.iter()
.any(|e| e.kernel.to_string_lossy().contains("vmlinuz")));
}
#[tokio::test]
async fn test_debian_detection() {
let mock = MockIsoEnvironment::with_iso(Distribution::Debian);
let parser = IsoParser::new(mock);
let mount_base = PathBuf::from("/mock_mount");
let entries = parser
.extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
.await
.unwrap();
assert!(!entries.is_empty());
assert!(entries
.iter()
.any(|e| e.distribution == Distribution::Debian));
}
#[tokio::test]
async fn test_fedora_detection() {
let mock = MockIsoEnvironment::with_iso(Distribution::Fedora);
let parser = IsoParser::new(mock);
let mount_base = PathBuf::from("/mock_mount");
let entries = parser
.extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
.await
.unwrap();
assert!(!entries.is_empty());
assert!(entries
.iter()
.any(|e| e.distribution == Distribution::Fedora));
}
#[test]
fn test_distribution_from_paths() {
assert_eq!(
Distribution::from_paths(PathBuf::from("/boot/vmlinuz").as_path()),
Distribution::Arch
);
assert_eq!(
Distribution::from_paths(PathBuf::from("/casper/vmlinuz").as_path()),
Distribution::Debian
);
assert_eq!(
Distribution::from_paths(PathBuf::from("/images/pxeboot/vmlinuz").as_path()),
Distribution::Fedora
);
}
#[test]
fn test_boot_entry_serialization() {
let entry = BootEntry {
label: "Test Linux".to_string(),
kernel: PathBuf::from("boot/vmlinuz"),
initrd: Some(PathBuf::from("boot/initrd.img")),
kernel_args: Some("quiet".to_string()),
distribution: Distribution::Arch,
source_iso: "test.iso".to_string(),
pretty_name: None,
};
let json = serde_json::to_string(&entry).unwrap();
let decoded: BootEntry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.label, "Test Linux");
assert_eq!(decoded.distribution, Distribution::Arch);
}
#[test]
fn parse_pretty_name_systemd_shape() {
let content = r#"
NAME="Ubuntu"
VERSION_ID="24.04"
PRETTY_NAME="Ubuntu 24.04.2 LTS (Noble Numbat)"
ID=ubuntu
"#;
assert_eq!(
parse_os_release_pretty_name(content).as_deref(),
Some("Ubuntu 24.04.2 LTS (Noble Numbat)"),
);
}
#[test]
fn parse_pretty_name_strips_single_quotes() {
let content = "PRETTY_NAME='Alpine Linux v3.20'";
assert_eq!(
parse_os_release_pretty_name(content).as_deref(),
Some("Alpine Linux v3.20"),
);
}
#[test]
fn parse_pretty_name_unquoted_value() {
let content = "PRETTY_NAME=Arch Linux";
assert_eq!(
parse_os_release_pretty_name(content).as_deref(),
Some("Arch Linux"),
);
}
#[test]
fn parse_pretty_name_empty_returns_none() {
assert!(parse_os_release_pretty_name("PRETTY_NAME=\"\"").is_none());
assert!(parse_os_release_pretty_name("").is_none());
}
#[test]
fn parse_pretty_name_missing_returns_none() {
let content = "NAME=\"Ubuntu\"\nID=ubuntu";
assert!(parse_os_release_pretty_name(content).is_none());
}
#[test]
fn parse_pretty_name_first_match_wins() {
let content = "PRETTY_NAME=\"First\"\nPRETTY_NAME=\"Second\"";
assert_eq!(
parse_os_release_pretty_name(content).as_deref(),
Some("First"),
);
}
#[test]
fn read_pretty_name_finds_etc_os_release() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
std::fs::write(
tmp.path().join("etc/os-release"),
"PRETTY_NAME=\"Rocky Linux 9.3 (Blue Onyx)\"\n",
)
.unwrap();
assert_eq!(
read_pretty_name(tmp.path()).as_deref(),
Some("Rocky Linux 9.3 (Blue Onyx)"),
);
}
#[test]
fn read_pretty_name_falls_back_to_disk_info() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
std::fs::write(
tmp.path().join(".disk/info"),
"Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)\n",
)
.unwrap();
assert_eq!(
read_pretty_name(tmp.path()).as_deref(),
Some("Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)"),
);
}
#[test]
fn read_pretty_name_alpine_release_prepends_alpine_linux() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
std::fs::write(tmp.path().join("etc/alpine-release"), "3.20.3\n").unwrap();
assert_eq!(
read_pretty_name(tmp.path()).as_deref(),
Some("Alpine Linux 3.20.3"),
);
}
#[test]
fn read_pretty_name_prefers_etc_over_lib() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
std::fs::create_dir_all(tmp.path().join("usr/lib")).unwrap();
std::fs::write(
tmp.path().join("etc/os-release"),
"PRETTY_NAME=\"Etc Wins\"\n",
)
.unwrap();
std::fs::write(
tmp.path().join("usr/lib/os-release"),
"PRETTY_NAME=\"Lib Loses\"\n",
)
.unwrap();
assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Etc Wins"),);
}
#[test]
fn read_pretty_name_returns_none_for_empty_mount() {
let tmp = tempfile::tempdir().unwrap();
assert!(read_pretty_name(tmp.path()).is_none());
}
#[test]
fn read_pretty_name_skips_empty_disk_info_line() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
std::fs::write(tmp.path().join(".disk/info"), "\n\n \nDebian 12.8\n").unwrap();
assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Debian 12.8"),);
}
#[test]
fn mock_metadata_fails_closed() {
let env = MockIsoEnvironment::new();
let err = env
.metadata(std::path::Path::new("/mock_mount/boot/vmlinuz"))
.expect_err("mock metadata() must surface an error");
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
}
#[test]
fn mock_mount_lock_recovers_from_poison() {
use std::sync::Arc;
let env = Arc::new(MockIsoEnvironment::new());
let env_for_thread = env.clone();
let join = std::thread::spawn(move || {
let _guard = env_for_thread.mount_points.lock().unwrap();
panic!("deliberately poisoning the mutex for this test");
})
.join();
assert!(join.is_err(), "helper thread must have panicked");
let iso = std::path::Path::new("/isos/test.iso");
let mount = env
.mount_iso(iso)
.expect("mount_iso must recover from poison");
env.unmount(&mount)
.expect("unmount must recover from poison");
}
#[tokio::test]
async fn extract_boot_entries_detects_windows_installer() {
let mock = MockIsoEnvironment::with_iso(Distribution::Windows);
let parser = IsoParser::new(mock);
let mount_base = PathBuf::from("/mock_mount");
let entries = parser
.extract_boot_entries(&mount_base, &PathBuf::from("Win11_25H2.iso"))
.await
.expect("Windows ISO should now produce a BootEntry instead of empty");
assert!(
!entries.is_empty(),
"Windows ISO must produce at least one entry"
);
let win = entries
.iter()
.find(|e| e.distribution == Distribution::Windows)
.expect("one of the entries must be Distribution::Windows");
assert_eq!(win.kernel.to_string_lossy(), "bootmgr");
assert!(win.initrd.is_none());
assert_eq!(win.kernel_args, None);
assert!(win.label.contains("Windows"));
assert!(win.source_iso.contains("Win11"));
}
#[tokio::test]
async fn try_windows_layout_declines_on_linux_layouts() {
let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
let parser = IsoParser::new(mock);
let mount_base = PathBuf::from("/mock_mount");
let entries = parser
.extract_boot_entries(&mount_base, &PathBuf::from("arch.iso"))
.await
.expect("Arch ISO must produce entries");
assert!(
!entries
.iter()
.any(|e| e.distribution == Distribution::Windows),
"Windows detector must not fire on Arch fixture"
);
}
#[test]
fn windows_boot_entry_has_not_kexec_bootable_quirk_in_iso_probe() {
let iso_distro = Distribution::Windows;
assert!(matches!(iso_distro, Distribution::Windows));
}
}