//! ISO introspection — identify the distro family and locate kernel/initrd. //! //! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number //! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on //! the path. As a pure-Rust fallback we do a crude scan: read the volume //! descriptor at offset 0x8000 to grab the volume label, and grep for known //! filenames by scanning raw sectors — good enough to tell Debian from RHEL //! most of the time, without shelling out. //! //! The returned `IntrospectionReport` is what `BootEntry`s get generated from. use serde::{Deserialize, Serialize}; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DistroFamily { DebianUbuntu, RhelFedora, OpenSuse, Arch, Alpine, WindowsPe, #[default] Unknown, } /// Bumped whenever the introspection logic changes in a way that should /// re-classify already-uploaded ISOs. On startup the store re-runs /// `introspect` on any *local* ISO whose persisted report predates this /// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale /// metadata — e.g. a Windows 11 ISO tagged `Unknown` by an older binary — /// without the operator having to delete and re-upload it. /// /// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened /// Windows (UDF/UTF-16) detection becomes retroactive. pub const INTROSPECT_REV: u32 = 1; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct IntrospectionReport { pub family: DistroFamily, pub volume_label: Option, /// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`). pub kernel_path: Option, /// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups. pub initrd_paths: Vec, /// True if `sources/boot.wim` present — Windows install media. pub has_boot_wim: bool, /// True if the ISO carries an El Torito boot catalog — i.e. it is /// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot` /// (emulated CD). This is the authoritative "can this boot at all?" /// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi, /// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter /// appliance bundle) has no boot catalog and reports `false`. v0.5.9. #[serde(default)] pub el_torito: bool, /// Revision of the introspection logic that produced this report. Old /// `meta.json` files without the field deserialize as 0, which is /// below [`INTROSPECT_REV`], triggering a one-time re-introspect on /// the next startup. v0.5.9. #[serde(default)] pub introspect_rev: u32, } /// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log /// and return an `Unknown` family so the uploader still sees a record. pub fn introspect(path: &Path) -> IntrospectionReport { let mut report = IntrospectionReport { introspect_rev: INTROSPECT_REV, ..Default::default() }; let Ok(mut f) = std::fs::File::open(path) else { tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display()); return report; }; // ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes. // Bytes 40..72 are the Volume Identifier (space-padded, d-characters). let mut pvd = [0u8; 2048]; if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() { // Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001". if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" { let label_raw = &pvd[40..72]; let label = String::from_utf8_lossy(label_raw).trim().to_string(); if !label.is_empty() { report.volume_label = Some(label.clone()); report.family = family_from_label(&label); } } } // Does the ISO have an El Torito boot catalog? This is what decides // whether an ISO we *can't* otherwise classify is bootable at all — // a bootable ISO sanboots; a data/appliance ISO (no catalog) can't. report.el_torito = detect_el_torito(&mut f); // Cheap content scan: read the first ~64 MiB, look for signature filenames. // This is enough to identify `sources/boot.wim` (Windows) and common // kernel/initrd paths for the major Linux distros. let _ = f.seek(SeekFrom::Start(0)); let scan_bytes = 64 * 1024 * 1024; let mut buf = vec![0u8; 1024 * 1024]; let mut read_total = 0usize; let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024)); while read_total < scan_bytes { let n = f.read(&mut buf).unwrap_or(0); if n == 0 { break; } haystack.extend_from_slice(&buf[..n]); read_total += n; } // `sources/boot.wim` is the definitive Windows-install-media marker // when the ISO exposes ASCII (ISO9660/Joliet) names. `contains_ascii` // is case-insensitive, so one form covers BOOT.WIM / boot.wim and the // backslash variant. if contains_ascii(&haystack, b"sources/boot.wim") || contains_ascii(&haystack, b"sources\\boot.wim") { report.has_boot_wim = true; report.family = DistroFamily::WindowsPe; } // v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are // UDF — filenames are stored as UTF-16 (so the ASCII scan above misses // them) and the volume label is a cryptic Microsoft string (so // `family_from_label` misses it too). Booting is via HTTP sanboot of // the raw ISO (no boot.wim extraction), so we only need the *family*. // Catch the common cases: well-known Windows markers in either ASCII // or UTF-16LE within the first 16 MiB, plus a filename hint. if report.family == DistroFamily::Unknown { let head = &haystack[..haystack.len().min(16 * 1024 * 1024)]; let ascii_markers: [&[u8]; 4] = [ b"bootmgr", b"sources/install.wim", b"sources/install.esd", b"efi/microsoft", ]; let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"]; let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m)) || utf16_markers.iter().any(|m| contains_utf16le_ci(head, m)) || filename_looks_windows(path); if looks_windows { report.family = DistroFamily::WindowsPe; } } // Best-effort kernel/initrd path guess from family. These paths are what // distro ISOs conventionally ship at — we don't verify extraction here; // that happens in the store after introspection. let (k, i) = guess_kernel_initrd(report.family); report.kernel_path = k.map(str::to_string); report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect(); report } fn family_from_label(label: &str) -> DistroFamily { let l = label.to_ascii_lowercase(); if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") { DistroFamily::DebianUbuntu } else if l.contains("rhel") || l.contains("centos") || l.contains("fedora") || l.contains("rocky") || l.contains("alma") { DistroFamily::RhelFedora } else if l.contains("suse") || l.contains("opensuse") { DistroFamily::OpenSuse } else if l.contains("arch") { DistroFamily::Arch } else if l.contains("alpine") { DistroFamily::Alpine } else if l.contains("windows") || l.contains("winpe") { DistroFamily::WindowsPe } else { DistroFamily::Unknown } } fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) { match family { DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]), DistroFamily::RhelFedora => ( Some("/images/pxeboot/vmlinuz"), vec!["/images/pxeboot/initrd.img"], ), DistroFamily::OpenSuse => ( Some("/boot/x86_64/loader/linux"), vec!["/boot/x86_64/loader/initrd"], ), DistroFamily::Arch => ( Some("/arch/boot/x86_64/vmlinuz-linux"), vec!["/arch/boot/x86_64/initramfs-linux.img"], ), DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]), DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()), } } fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { if needle.is_empty() || haystack.len() < needle.len() { return false; } haystack .windows(needle.len()) .any(|w| w.eq_ignore_ascii_case(needle)) } /// Case-insensitive search for an ASCII string encoded as UTF-16LE — the /// way UDF (and thus modern Windows ISOs) store filenames. Each character /// is two bytes: the ASCII low byte (compared case-insensitively) followed /// by a 0 high byte. v0.5.8. fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool { let n = ascii.len(); if n == 0 || haystack.len() < n * 2 { return false; } let lower: Vec = ascii.bytes().map(|b| b.to_ascii_lowercase()).collect(); haystack.windows(n * 2).any(|w| { lower .iter() .enumerate() .all(|(i, &c)| w[i * 2 + 1] == 0 && w[i * 2].to_ascii_lowercase() == c) }) } /// Filename heuristic: a stock Windows ISO almost always carries an obvious /// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`). /// Used only as a last-resort family hint when the content scan and volume /// label are inconclusive. v0.5.8. fn filename_looks_windows(path: &Path) -> bool { let name = path .file_name() .and_then(|s| s.to_str()) .unwrap_or("") .to_ascii_lowercase(); const TOKENS: [&str; 6] = [ "windows", "winpe", "win10", "win11", "winserver", "win-server", ]; TOKENS.iter().any(|t| name.contains(t)) } /// The boot-system identifier string in an El Torito Boot Record Volume /// Descriptor (offset 7, NUL-padded to 32 bytes). const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION"; /// Detect an El Torito boot catalog — the marker that an ISO is bootable /// by BIOS/UEFI firmware (and thus by iPXE `sanboot`). /// /// The ISO9660 Volume Descriptor Set starts at LBA 16 (offset 0x8000) and /// runs one 2048-byte descriptor per sector until a Set Terminator /// (type 0xFF). A Boot Record descriptor (type 0x00) whose 32-byte boot /// system identifier reads "EL TORITO SPECIFICATION" means the image /// declares an El Torito boot catalog. We only confirm its presence — we /// don't parse the catalog (sanboot/the firmware does that). The walk is /// capped so a malformed/huge image can't spin us. v0.5.9. fn detect_el_torito(f: &mut std::fs::File) -> bool { let mut vd = [0u8; 2048]; for lba in 16u64..32 { if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() { return false; } // Every descriptor in the set carries the "CD001" magic; once it's // missing we've walked off the end of a valid set. if &vd[1..6] != b"CD001" { return false; } match vd[0] { // Boot Record descriptor carrying the El Torito signature. 0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true, // Volume Descriptor Set Terminator — nothing bootable found. 0xFF => return false, // Any other descriptor (incl. a non-El-Torito boot record) — // keep walking the set. _ => {} } } false } #[cfg(test)] mod tests { use super::*; #[test] fn label_matching() { assert_eq!( family_from_label("Ubuntu 24.04"), DistroFamily::DebianUbuntu ); assert_eq!( family_from_label("Rocky-9-x86_64-dvd"), DistroFamily::RhelFedora ); assert_eq!( family_from_label("openSUSE-Leap-15.6"), DistroFamily::OpenSuse ); assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); } #[test] fn utf16le_marker_matches_case_insensitively() { // "boot.wim" encoded UTF-16LE, mixed case — UDF stores Windows // filenames this way, which the ASCII scan can't see. let s = "BOOT.WIM"; let utf16: Vec = s.bytes().flat_map(|b| [b, 0]).collect(); let mut hay = vec![0u8; 8]; hay.extend_from_slice(&utf16); hay.extend_from_slice(&[1, 2, 3]); assert!(contains_utf16le_ci(&hay, "boot.wim")); assert!(contains_utf16le_ci(&hay, "Boot.Wim")); assert!(!contains_utf16le_ci(&hay, "install.wim")); // An ASCII (not UTF-16) occurrence must NOT match the UTF-16 scan. assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim")); } #[test] fn el_torito_boot_catalog_detected() { let dir = tempfile::tempdir().unwrap(); // Helper: stamp a 2048-byte descriptor at `lba` with type + magic. let stamp = |img: &mut [u8], lba: usize, ty: u8| { let off = lba * 2048; img[off] = ty; img[off + 1..off + 6].copy_from_slice(b"CD001"); }; // Bootable image: PVD @16, El Torito Boot Record @17, terminator @18. let mut boot = vec![0u8; 2048 * 19]; stamp(&mut boot, 16, 0x01); stamp(&mut boot, 17, 0x00); boot[17 * 2048 + 7..17 * 2048 + 7 + EL_TORITO_ID.len()].copy_from_slice(EL_TORITO_ID); stamp(&mut boot, 18, 0xFF); let bp = dir.path().join("boot.iso"); std::fs::write(&bp, &boot).unwrap(); let mut f = std::fs::File::open(&bp).unwrap(); assert!( detect_el_torito(&mut f), "El Torito boot record should match" ); // Data/appliance image: PVD @16, terminator @17, no boot record. let mut data = vec![0u8; 2048 * 18]; stamp(&mut data, 16, 0x01); stamp(&mut data, 17, 0xFF); let dp = dir.path().join("data.iso"); std::fs::write(&dp, &data).unwrap(); let mut f2 = std::fs::File::open(&dp).unwrap(); assert!(!detect_el_torito(&mut f2), "data ISO has no boot catalog"); } #[test] fn filename_hint_catches_windows_isos() { use std::path::Path; assert!(filename_looks_windows(Path::new( "en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso" ))); assert!(filename_looks_windows(Path::new( "Win10_22H2_English_x64.iso" ))); assert!(filename_looks_windows(Path::new("winserver2022.iso"))); assert!(!filename_looks_windows(Path::new( "ubuntu-24.04-desktop.iso" ))); assert!(!filename_looks_windows(Path::new( "Rocky-9.4-x86_64-dvd.iso" ))); } }