diff --git a/Cargo.lock b/Cargo.lock index 75932f3..d374cc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.7.3" +version = "0.7.4" dependencies = [ "anyhow", "axum", @@ -2858,7 +2858,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.7.3" +version = "0.7.4" dependencies = [ "anyhow", "base64", @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.7.3" +version = "0.7.4" dependencies = [ "anyhow", "bytes", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.7.3" +version = "0.7.4" dependencies = [ "anyhow", "axum", @@ -2938,7 +2938,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.7.3" +version = "0.7.4" dependencies = [ "openpxe-core", "rust-embed", @@ -2948,7 +2948,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.7.3" +version = "0.7.4" dependencies = [ "anyhow", "bcrypt", @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.7.3" +version = "0.7.4" dependencies = [ "anyhow", "bytes", @@ -2991,7 +2991,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.7.3" +version = "0.7.4" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index 82d8b9f..1ef5ee5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.7.3" +version = "0.7.4" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index 21ac7fc..01b5c34 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -49,6 +49,10 @@ base64.workspace = true tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tower = { workspace = true } tempfile = "3.12" +# v0.7.4: probe-based introspection verifies kernel paths against the +# real ISO9660 tree, so the full-flow tests synthesize images with the +# shared test builder instead of label-only blobs. +openpxe-iso-store = { workspace = true, features = ["test-image"] } serde_json = { workspace = true } time = { workspace = true } # v0.4.61: integration tests need to generate real PNG bytes for the diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index b132fab..207596e 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -19,7 +19,6 @@ use crate::ipxe_script::{ render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info, render_queue_entry, render_shell, render_tools_menu, render_util, }; -use crate::iso_fs; use crate::log_stream; use crate::state::AppState; use crate::terminal; @@ -36,6 +35,7 @@ use openpxe_core::{ LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_slice; +use openpxe_iso_store::iso_fs; use openpxe_iso_store::{ render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest, SmbState, UnattendedKind, UnattendedMeta, @@ -1087,28 +1087,105 @@ async fn iso_file( State(state): State, AxumPath((id, path)): AxumPath<(String, String)>, ) -> Response { - // In-ISO file extraction is only supported for local ISOs — it - // needs random-access reads into the ISO9660 directory tree, which - // smbclient's whole-file streaming can't do efficiently. SMB- - // sourced ISOs use the raw streaming endpoint above instead. - let Some(iso_path) = state.iso_store.iso_path_for(&id) else { + let Some(meta) = state.iso_store.get(&id) else { return (StatusCode::NOT_FOUND, "no such iso").into_response(); }; - let p = iso_path.clone(); let in_path = format!("/{path}"); - let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path)) - .await - .ok() - .flatten(); - let Some(loc) = loc else { - return (StatusCode::NOT_FOUND, "not found inside iso").into_response(); - }; - match stream_byte_range(&iso_path, loc.offset, loc.length).await { - Ok(r) => r, - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + match &meta.source { + IsoSource::Local => { + let Some(iso_path) = state.iso_store.local_path(&meta) else { + return (StatusCode::NOT_FOUND, "no such iso").into_response(); + }; + let p = iso_path.clone(); + let loc = tokio::task::spawn_blocking(move || iso_fs::lookup_local(&p, &in_path)) + .await + .ok() + .flatten(); + let Some(loc) = loc else { + return (StatusCode::NOT_FOUND, "not found inside iso").into_response(); + }; + match stream_byte_range(&iso_path, loc.offset, loc.length).await { + Ok(r) => r, + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } + } + // v0.7.4: remote ISOs serve in-ISO files too — the same ISO9660 + // walk runs over NFS READ3 / SFTP seek-reads, then the located + // byte range streams through the share manager. This is what + // makes the verified kernel/initrd boot entries on share-hosted + // Linux ISOs actually bootable. + IsoSource::Nfs { + share_id, + relative_path, + } => { + match state + .nfs_shares + .locate_in_iso(share_id, relative_path, &in_path) + .await + { + Ok(Some(loc)) => { + match state + .nfs_shares + .stream_iso(share_id, relative_path, loc.offset, Some(loc.length)) + .await + { + Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length), + Err(e) => { + (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response() + } + } + } + Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(), + Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs lookup: {e}")).into_response(), + } + } + IsoSource::Sftp { + share_id, + relative_path, + } => { + match state + .sftp_shares + .locate_in_iso(share_id, relative_path, &in_path) + .await + { + Ok(Some(loc)) => { + match state + .sftp_shares + .stream_iso(share_id, relative_path, loc.offset, Some(loc.length)) + .await + { + Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length), + Err(e) => { + (StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response() + } + } + } + Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(), + Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp lookup: {e}")).into_response(), + } + } + // smbclient streams sequentially — no seeks, no ISO9660 walk. + // SMB ISOs never emit kernel entries, so nothing requests this. + IsoSource::Smb { .. } => ( + StatusCode::NOT_FOUND, + "in-ISO files are not available for SMB-sourced ISOs", + ) + .into_response(), } } +/// 200 response wrapping an in-ISO byte-range stream from a share +/// manager. Content-Length is the located file's length — the stream is +/// already bounded to exactly that range. +fn in_iso_stream_response(body: Body, length: u64) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, length) + .body(body) + .unwrap() +} + async fn stream_file_range( path: &std::path::Path, range: Option<&HeaderValue>, diff --git a/crates/http-api/src/iso_fs.rs b/crates/http-api/src/iso_fs.rs deleted file mode 100644 index 12c2aa9..0000000 --- a/crates/http-api/src/iso_fs.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Minimal read-only ISO9660 lookup. Given an uploaded ISO file and an -//! in-ISO path (e.g. `/casper/vmlinuz`), locate the file and return a -//! `(start_byte, length_bytes)` pair so the HTTP handler can stream just -//! that range from the on-disk ISO without full extraction. -//! -//! We only implement what we need: the Primary Volume Descriptor and Rock -//! Ridge / Joliet extensions are ignored. Paths are matched case-insensitive -//! against plain ISO9660 filenames (uppercase, `;1` version suffix stripped). -//! This is sufficient for the kernel/initrd and wimboot files we serve; -//! if a requested path isn't found, the handler returns 404 and the user -//! can still download the whole ISO via `/iso/.iso`. - -use std::io::{Read, Seek, SeekFrom}; -use std::path::Path; - -const SECTOR: u64 = 2048; - -#[derive(Debug, Clone)] -pub struct FileLocation { - pub offset: u64, - pub length: u64, -} - -/// Look up `in_iso_path` (leading slash optional, case-insensitive) in the -/// ISO at `iso_path`. Returns None on any parsing or IO failure. -pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option { - let mut f = std::fs::File::open(iso_path).ok()?; - let root = read_root_directory(&mut f)?; - let components: Vec<&str> = in_iso_path - .trim_start_matches('/') - .split('/') - .filter(|c| !c.is_empty()) - .collect(); - if components.is_empty() { - return None; - } - walk(&mut f, root.offset, root.length, &components) -} - -fn read_root_directory(f: &mut std::fs::File) -> Option { - // Primary Volume Descriptor at LBA 16. - let mut pvd = [0u8; 2048]; - f.seek(SeekFrom::Start(16 * SECTOR)).ok()?; - f.read_exact(&mut pvd).ok()?; - if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { - return None; - } - // Root directory record is at offset 156, length 34. - let rec = &pvd[156..156 + 34]; - let (offset, length) = parse_dir_record_ext(rec)?; - Some(FileLocation { - offset: offset * SECTOR, - length, - }) -} - -/// Walk components down the directory tree starting at `dir_offset`. -fn walk( - f: &mut std::fs::File, - dir_offset: u64, - dir_len: u64, - components: &[&str], -) -> Option { - let mut dir = vec![0u8; dir_len as usize]; - f.seek(SeekFrom::Start(dir_offset)).ok()?; - f.read_exact(&mut dir).ok()?; - - let target = components[0]; - let rest = &components[1..]; - let mut i = 0; - while i < dir.len() { - let len = dir[i] as usize; - if len == 0 { - // Padding to sector boundary. - let next = (i / SECTOR as usize + 1) * SECTOR as usize; - if next <= i { - break; - } - i = next; - continue; - } - if i + len > dir.len() { - break; - } - let rec = &dir[i..i + len]; - let name = dir_record_name(rec); - let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0; - // Skip "." (0x00) and ".." (0x01) pseudo-entries. - let is_pseudo = matches!(rec.get(32).copied(), Some(1)) - && rec.get(33).copied() == Some(0x00) - || matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01); - if !is_pseudo && name.eq_ignore_ascii_case(target) { - let (child_off, child_len) = parse_dir_record_ext(rec)?; - if rest.is_empty() && !is_dir { - return Some(FileLocation { - offset: child_off * SECTOR, - length: child_len, - }); - } else if !rest.is_empty() && is_dir { - return walk(f, child_off * SECTOR, child_len, rest); - } - } - i += len; - } - None -} - -/// Extract (extent LBA, data length in bytes) from a directory record. -/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18 -/// data length (LE+BE duplicate). We trust the little-endian copy. -fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> { - if rec.len() < 34 { - return None; - } - let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64; - let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64; - Some((lba, len)) -} - -/// Extract the identifier from a directory record, stripping ISO9660's -/// `;1` version suffix. -fn dir_record_name(rec: &[u8]) -> String { - let name_len = *rec.get(32).unwrap_or(&0) as usize; - if name_len == 0 || rec.len() < 33 + name_len { - return String::new(); - } - let raw = &rec[33..33 + name_len]; - let s = String::from_utf8_lossy(raw).to_string(); - // Strip `;N` version suffix. - if let Some(i) = s.rfind(';') { - s[..i].to_string() - } else { - s - } -} diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs index a9b6e83..f0cedfb 100644 --- a/crates/http-api/src/lib.rs +++ b/crates/http-api/src/lib.rs @@ -9,8 +9,9 @@ //! and Linux kernel/initrd, without having to //! re-extract on every request) //! -//! The `/` handler uses a read-only ISO9660 shim (see `iso_fs`) -//! that lseeks into the ISO on disk — so we never keep extracted copies. +//! The `/` handler uses the read-only ISO9660 walker from +//! `openpxe_iso_store::iso_fs` — seeking into the image wherever it +//! lives (local disk, NFS, SFTP), so we never keep extracted copies. #![forbid(unsafe_code)] pub mod app; @@ -18,7 +19,6 @@ pub mod auth; pub mod error; pub mod grub_script; pub mod ipxe_script; -pub mod iso_fs; pub mod log_stream; pub mod notify; pub mod saml_routes; diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 5fe9f89..8dc72d0 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -18,23 +18,16 @@ use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareMan use tempfile::tempdir; use tower::ServiceExt; -/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so -/// introspection identifies it as Alpine. +/// Build a tiny Alpine-shaped ISO9660 image: volume label "ALPINE-TEST" +/// plus the real `/boot/vmlinuz-lts` + `/boot/initramfs-lts` tree. +/// v0.7.4's probe-based introspection verifies those paths exist before +/// emitting a kernel boot entry — a label-only blob no longer counts. fn fake_alpine_iso() -> Vec { - let mut buf = vec![0u8; 32 * 2048]; - let off = 16 * 2048; - buf[off] = 0x01; - buf[off + 1..off + 6].copy_from_slice(b"CD001"); - buf[off + 6] = 0x01; - let label = b"ALPINE-TEST".to_vec(); - let mut padded = label.clone(); - padded.resize(32, b' '); - buf[off + 40..off + 40 + 32].copy_from_slice(&padded); - let term = 17 * 2048; - buf[term] = 0xFF; - buf[term + 1..term + 6].copy_from_slice(b"CD001"); - buf[term + 6] = 0x01; - buf + openpxe_iso_store::iso_fs::testiso::TestIsoBuilder::new("ALPINE-TEST") + .el_torito(true) + .file("/boot/vmlinuz-lts", b"fake-kernel-bytes") + .file("/boot/initramfs-lts", b"fake-initramfs-bytes") + .build() } fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec) { @@ -1248,9 +1241,10 @@ async fn chunked_upload_writes_progressively_and_finishes_iso() { .method("POST") .uri("/api/uploads") .header("content-type", "application/json") - .body(Body::from( - r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#, - )) + .body(Body::from(format!( + r#"{{"filename":"chunked-alpine.iso","size_bytes":{}}}"#, + iso.len() + ))) .unwrap(), ) .await diff --git a/crates/iso-store/Cargo.toml b/crates/iso-store/Cargo.toml index 626dba2..201a4b5 100644 --- a/crates/iso-store/Cargo.toml +++ b/crates/iso-store/Cargo.toml @@ -49,3 +49,11 @@ futures = { workspace = true } [dev-dependencies] tempfile = "3.12" + +[features] +# v0.7.4: exposes the in-memory ISO9660 test-image builder +# (`iso_fs::testiso`) to other crates' integration tests, so http-api's +# full-flow tests can synthesize ISOs with real directory trees — the +# probe-based introspection no longer classifies label-only blobs. +# Never enabled in production builds. +test-image = [] diff --git a/crates/iso-store/src/introspect.rs b/crates/iso-store/src/introspect.rs index b91abca..84c577f 100644 --- a/crates/iso-store/src/introspect.rs +++ b/crates/iso-store/src/introspect.rs @@ -1,16 +1,33 @@ //! 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. +//! v0.7.4 rewrite: detection is **probe-based**. Instead of grepping raw +//! sectors for filename strings (which false-positived — any Linux ISO +//! shipping GRUB/syslinux chainload modules contains the literal +//! "bootmgr", so gparted-live classified as Windows), we walk the +//! ISO9660 directory tree via [`crate::iso_fs`] and check whether the +//! well-known boot files actually exist. The same probes run over local +//! files and remote NFS/SFTP shares — remote ISOs finally classify +//! instead of registering as `Unknown`. //! -//! The returned `IntrospectionReport` is what `BootEntry`s get generated from. +//! Layered, first-decisive-answer-wins: +//! 1. PVD volume label → family hint. +//! 2. El Torito boot-catalog presence (the "bootable at all" signal). +//! 3. `/sources/boot.wim` directory probe → Windows install media. +//! 4. Linux probe table → verified kernel+initrd paths. A probe match +//! both classifies the family and (for the families whose boot +//! arguments we render) yields kernel paths that are *known to +//! exist* — no more guessed paths that 404 at boot. +//! 5. Bulk byte scan for UDF Windows markers — local images only +//! (modern Windows ISOs hide their tree from ISO9660; remote scans +//! skip this so a share rescan doesn't stream 16 MiB per ISO). +//! 6. Filename tokens — the last-resort hint, and the only signal +//! available for SMB shares (smbclient cannot seek). +//! +//! The returned `IntrospectionReport` is what `BootEntry`s get generated +//! from. +use crate::iso_fs::{self, CachingReadAt, FileReadAt, IsoReadAt, SECTOR}; use serde::{Deserialize, Serialize}; -use std::io::{Read, Seek, SeekFrom}; use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -30,18 +47,26 @@ pub enum DistroFamily { /// 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. +/// metadata without the operator having to delete and re-upload. /// /// 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; +/// Windows (UDF/UTF-16) detection. +/// rev 2 (v0.7.4): probe-based detection. Fixes Linux live ISOs that +/// classified as Windows via the raw "bootmgr" byte grep, verifies +/// kernel/initrd paths exist before emitting them, and adds the Debian +/// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection +/// caches key off this rev too, so the cache self-invalidates. +pub const INTROSPECT_REV: u32 = 2; #[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`). + /// Kernel path inside the ISO (e.g. `/casper/vmlinuz`). v0.7.4: only + /// set when the path was verified to exist *and* the family's boot + /// arguments are known-good for direct kernel boot; families we can + /// only classify (Debian live, CoreOS live) leave it `None` so the + /// entry generator falls back to sanboot instead of a broken boot. pub kernel_path: Option, /// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups. pub initrd_paths: Vec, @@ -55,124 +80,242 @@ pub struct IntrospectionReport { /// 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. + /// Revision of the introspection logic that produced this report. + /// `0` means "never introspected" (pre-v0.5.9 metadata, or a remote + /// ISO whose probe hasn't run / can't run) — the entry generator + /// treats those optimistically (sanboot) and the UI labels them. #[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. +/// One row of the Linux detection table. +/// +/// `emit_kernel` distinguishes "we can boot this directly" from "we can +/// only classify it". Families marked `false` have boot protocols our +/// cmdline renderer doesn't speak yet (Debian-live `boot=live fetch=`, +/// d-i netinst, CoreOS `coreos.live.rootfs_url=`) — for those the probe +/// sets the family for the UI/menu but leaves `kernel_path` unset so the +/// ISO keeps its (working) sanboot entry instead of gaining a broken +/// kernel one. Strictly fewer broken boots than guessing. +struct LinuxProbe { + family: DistroFamily, + kernel: &'static str, + initrd_candidates: &'static [&'static str], + emit_kernel: bool, +} + +const LINUX_PROBES: &[LinuxProbe] = &[ + // Ubuntu and friends (casper) — the classic direct-boot shape. + LinuxProbe { + family: DistroFamily::DebianUbuntu, + kernel: "/casper/vmlinuz", + initrd_candidates: &["/casper/initrd", "/casper/initrd.lz", "/casper/initrd.gz"], + emit_kernel: true, + }, + // Debian-live derivatives: gparted-live, Clonezilla, Kali live, tails. + // Classification only — live-boot needs `boot=live fetch=` + // which we don't render yet; sanboot of these images works today. + LinuxProbe { + family: DistroFamily::DebianUbuntu, + kernel: "/live/vmlinuz", + initrd_candidates: &["/live/initrd.img", "/live/initrd"], + emit_kernel: false, + }, + // Debian installer (netinst/DVD). Classification only for the same + // reason — d-i sanboots fine. + LinuxProbe { + family: DistroFamily::DebianUbuntu, + kernel: "/install.amd/vmlinuz", + initrd_candidates: &["/install.amd/initrd.gz"], + emit_kernel: false, + }, + // Anaconda family: RHEL, CentOS, Alma, Rocky, Fedora — and their + // many derivatives (Cisco ISE, Nagios appliances, …). The CoreOS + // variant of this shape is special-cased after the table. + LinuxProbe { + family: DistroFamily::RhelFedora, + kernel: "/images/pxeboot/vmlinuz", + initrd_candidates: &["/images/pxeboot/initrd.img"], + emit_kernel: true, + }, + LinuxProbe { + family: DistroFamily::OpenSuse, + kernel: "/boot/x86_64/loader/linux", + initrd_candidates: &["/boot/x86_64/loader/initrd"], + emit_kernel: true, + }, + LinuxProbe { + family: DistroFamily::Arch, + kernel: "/arch/boot/x86_64/vmlinuz-linux", + initrd_candidates: &["/arch/boot/x86_64/initramfs-linux.img"], + emit_kernel: true, + }, + LinuxProbe { + family: DistroFamily::Alpine, + kernel: "/boot/vmlinuz-lts", + initrd_candidates: &["/boot/initramfs-lts"], + emit_kernel: true, + }, +]; + +/// CoreOS-style live images (RHCOS, FCOS, OpenShift agent ISOs) carry +/// the anaconda pxeboot layout *plus* a rootfs image. Direct kernel boot +/// of those requires `coreos.live.rootfs_url=` (and for agent ISOs, the +/// ignition config embedded in the ISO device) — neither of which a +/// plain `inst.repo=` cmdline provides. Their sanboot path works, so +/// they classify as RHEL-family but keep the sanboot entry. +const COREOS_ROOTFS: &str = "/images/pxeboot/rootfs.img"; + +/// 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 filename = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let Ok(f) = std::fs::File::open(path) else { + tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display()); + return IntrospectionReport { + introspect_rev: INTROSPECT_REV, + ..Default::default() + }; + }; + let len = f.metadata().map_or(0, |m| m.len()); + // `FileReadAt` completes every read inline (no real awaits), so this + // light-weight block_on never parks; callers already run us on the + // blocking pool. + futures::executor::block_on(introspect_reader( + &mut FileReadAt::new(f), + len, + &filename, + true, + )) +} + +/// The detection core, generic over any random-access source. `total_len` +/// is the image size (every caller knows it — file metadata locally, the +/// share listing remotely) and bounds the bulk scan, since `IsoReadAt` +/// reads are exact-or-error. `filename` feeds the last-resort token +/// heuristics; `allow_bulk_scan` gates the 16 MiB UDF-Windows byte scan +/// (local files only — remote shares would stream that much per ISO per +/// rescan). +pub async fn introspect_reader( + r: &mut R, + total_len: u64, + filename: &str, + allow_bulk_scan: bool, +) -> 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". + // ISO9660 Primary Volume Descriptor at LBA 16. Bytes 40..72 are the + // volume identifier (space-padded). + if let Ok(pvd) = r.read_at(16 * SECTOR, 2048).await { 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(); + let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string(); if !label.is_empty() { - report.volume_label = Some(label.clone()); report.family = family_from_label(&label); + report.volume_label = Some(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); + report.el_torito = detect_el_torito(r).await; - // 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; - // Size the haystack to what will actually be read — the scan cap or - // the file itself, whichever is smaller — so the fill never reallocs - // and a small ISO doesn't reserve the full 64 MiB. - let file_len = f.metadata().map_or(usize::MAX, |m| { - usize::try_from(m.len()).unwrap_or(usize::MAX) - }); - let mut haystack = Vec::with_capacity(scan_bytes.min(file_len)); - 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") + // Directory-tree probes. The caching wrapper collapses the repeated + // root/subdirectory reads the probe table would otherwise issue — + // over NFS/SFTP that's the difference between ~6 and ~60 round-trips. { - report.has_boot_wim = true; - report.family = DistroFamily::WindowsPe; - } + let mut cr = CachingReadAt::new(r); - // 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 { + if iso_fs::exists(&mut cr, "/sources/boot.wim").await { + report.has_boot_wim = true; report.family = DistroFamily::WindowsPe; + } else { + for probe in LINUX_PROBES { + if !iso_fs::exists(&mut cr, probe.kernel).await { + continue; + } + let mut initrd = None; + for cand in probe.initrd_candidates { + if iso_fs::exists(&mut cr, cand).await { + initrd = Some((*cand).to_string()); + break; + } + } + let Some(initrd) = initrd else { continue }; + // Content beats label: a rebadged derivative (volume + // label "ISE-3.2") with the anaconda layout is + // RHEL-family no matter what the label says. + report.family = probe.family; + let coreos = probe.family == DistroFamily::RhelFedora + && iso_fs::exists(&mut cr, COREOS_ROOTFS).await; + if probe.emit_kernel && !coreos { + report.kernel_path = Some(probe.kernel.to_string()); + report.initrd_paths = vec![initrd]; + } + break; + } } } - // 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(); + // Modern Windows 10/11 ISOs are UDF — their tree is invisible to the + // ISO9660 walk and the volume label is a cryptic Microsoft string. + // Scan the first 16 MiB for well-known markers, ASCII and UTF-16LE. + // Runs after the Linux probes so a Linux ISO that *contains* the + // string "bootmgr" (GRUB/syslinux chainload modules do) has already + // classified and never reaches this — that ordering is the v0.7.4 + // gparted-misdetection fix. + if report.family == DistroFamily::Unknown && allow_bulk_scan { + if let Some(win) = bulk_windows_scan(r, total_len).await { + report.family = DistroFamily::WindowsPe; + report.has_boot_wim = win; + } + } + + // Last resort: filename tokens. The only signal for SMB-sourced ISOs + // and renamed/UDF images that defeated everything above. + if report.family == DistroFamily::Unknown { + report.family = family_from_filename(filename); + } report } +/// Provisional report for a remote ISO that hasn't been (or can't be) +/// content-probed yet: family from the filename, `introspect_rev` left +/// at 0 so the entry generator keeps the optimistic sanboot entry and +/// the UI shows it as awaiting introspection. Used by all three share +/// managers at registration; NFS/SFTP upgrade it in the background. +#[must_use] +pub fn provisional_report(filename: &str) -> IntrospectionReport { + IntrospectionReport { + family: family_from_filename(filename), + ..Default::default() + } +} + fn family_from_label(label: &str) -> DistroFamily { let l = label.to_ascii_lowercase(); - if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") { + if l.contains("ubuntu") + || l.contains("debian") + || l.contains("mint") + || l.contains("kali") + || l.contains("gparted") + || l.contains("clonezilla") + { DistroFamily::DebianUbuntu } else if l.contains("rhel") || l.contains("centos") || l.contains("fedora") || l.contains("rocky") || l.contains("alma") + || l.contains("rhcos") + || l.contains("coreos") + || l.contains("openshift") + || l.contains("okd") { DistroFamily::RhelFedora } else if l.contains("suse") || l.contains("opensuse") { @@ -188,23 +331,91 @@ fn family_from_label(label: &str) -> DistroFamily { } } -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()), +/// Filename token heuristic — `AlmaLinux-9.5-x86_64-dvd.iso` says what +/// it is even when we can't read a byte of it. Tokens are the filename +/// split on every non-alphanumeric character, so "almalinux", "rhel", +/// "win11" match without "search" tripping the "arch" token. +pub fn family_from_filename(filename: &str) -> DistroFamily { + if filename_looks_windows(filename) { + return DistroFamily::WindowsPe; + } + let lower = filename.to_ascii_lowercase(); + let tokens: Vec<&str> = lower + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|t| !t.is_empty()) + .collect(); + let has = |t: &str| tokens.contains(&t); + if has("ubuntu") + || has("debian") + || has("mint") + || has("kali") + || has("gparted") + || has("clonezilla") + || has("tails") + { + DistroFamily::DebianUbuntu + } else if has("rhel") + || has("centos") + || has("almalinux") + || has("alma") + || has("rocky") + || has("rockylinux") + || has("fedora") + || has("rhcos") + || has("coreos") + || has("openshift") + || has("okd") + { + DistroFamily::RhelFedora + } else if has("opensuse") || has("suse") || has("sles") { + DistroFamily::OpenSuse + } else if has("arch") || has("archlinux") || has("manjaro") { + DistroFamily::Arch + } else if has("alpine") { + DistroFamily::Alpine + } else { + DistroFamily::Unknown + } +} + +/// Scan the first 16 MiB (or the whole image when smaller) for Windows +/// markers. Returns `Some(has_boot_wim)` on a hit, `None` when nothing +/// Windows-shaped is found. +async fn bulk_windows_scan(r: &mut R, total_len: u64) -> Option { + const SCAN_BYTES: u64 = 16 * 1024 * 1024; + const CHUNK: u64 = 1024 * 1024; + let budget = SCAN_BYTES.min(total_len); + let mut haystack = Vec::with_capacity(usize::try_from(budget).unwrap_or(0)); + let mut offset = 0u64; + while offset < budget { + // Reads are exact-or-error, so clamp the final chunk to what the + // image actually has — netboot.xyz is 2.3 MB, not 16. + let want = u32::try_from(CHUNK.min(budget - offset)).unwrap_or(u32::MAX); + let Ok(chunk) = r.read_at(offset, want).await else { + break; // read error: scan what we have + }; + offset += chunk.len() as u64; + haystack.extend_from_slice(&chunk); + } + if haystack.is_empty() { + return None; + } + let boot_wim = contains_ascii(&haystack, b"sources/boot.wim") + || contains_ascii(&haystack, b"sources\\boot.wim") + || contains_utf16le_ci(&haystack, "boot.wim"); + if boot_wim { + return Some(true); + } + let ascii_markers: [&[u8]; 3] = [b"bootmgr", b"sources/install.wim", b"sources/install.esd"]; + let utf16_markers = ["bootmgr", "install.wim", "microsoft"]; + let hit = ascii_markers.iter().any(|m| contains_ascii(&haystack, m)) + || utf16_markers + .iter() + .any(|m| contains_utf16le_ci(&haystack, m)); + if hit { + Some(false) + } else { + None } } @@ -237,14 +448,10 @@ fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool { /// 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(); +/// v0.7.4: takes the bare filename instead of a `Path` so the same check +/// runs against remote share listings. +fn filename_looks_windows(filename: &str) -> bool { + let name = filename.to_ascii_lowercase(); const TOKENS: [&str; 6] = [ "windows", "winpe", @@ -263,19 +470,18 @@ 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]; +/// The ISO9660 Volume Descriptor Set starts at LBA 16 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 a +/// 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 image can't spin us. v0.5.9; reader-generic since v0.7.4. +async fn detect_el_torito(r: &mut R) -> bool { for lba in 16u64..32 { - if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() { + let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else { 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" { @@ -297,6 +503,12 @@ fn detect_el_torito(f: &mut std::fs::File) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::iso_fs::testiso::{MemReadAt, TestIsoBuilder}; + + fn introspect_mem(img: Vec, filename: &str, bulk: bool) -> IntrospectionReport { + let len = img.len() as u64; + futures::executor::block_on(introspect_reader(&mut MemReadAt(img), len, filename, bulk)) + } #[test] fn label_matching() { @@ -313,13 +525,158 @@ mod tests { DistroFamily::OpenSuse ); assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); + assert_eq!( + family_from_label("GParted-live"), + DistroFamily::DebianUbuntu + ); + assert_eq!(family_from_label("rhcos-417"), DistroFamily::RhelFedora); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); } + #[test] + fn gparted_shape_is_not_windows() { + // The v0.7.4 regression test: a Debian-live image whose payload + // contains the literal string "bootmgr" (as GRUB/syslinux + // chainload modules do). The old byte-grep classified this as + // WindowsPe; the probe order must classify Debian first. + let img = TestIsoBuilder::new("GParted-live") + .el_torito(true) + .file("/live/vmlinuz", b"KERNEL") + .file("/live/initrd.img", b"INITRD") + .file("/boot/grub/chain.mod", b"xxx bootmgr xxx") + .build(); + let r = introspect_mem(img, "gparted-live-1.8.1-3-amd64.iso", true); + assert_eq!(r.family, DistroFamily::DebianUbuntu); + // Classification only — live-boot args aren't rendered yet, so no + // kernel entry; sanboot (via el_torito) keeps working. + assert!(r.kernel_path.is_none()); + assert!(r.el_torito); + assert_eq!(r.introspect_rev, INTROSPECT_REV); + } + + #[test] + fn casper_shape_verifies_kernel_and_initrd() { + let img = TestIsoBuilder::new("Ubuntu-Server 24.04.1 LTS amd64") + .el_torito(true) + .file("/casper/vmlinuz", b"K") + .file("/casper/initrd", b"I") + .build(); + let r = introspect_mem(img, "ubuntu-24.04.1-live-server-amd64.iso", true); + assert_eq!(r.family, DistroFamily::DebianUbuntu); + assert_eq!(r.kernel_path.as_deref(), Some("/casper/vmlinuz")); + assert_eq!(r.initrd_paths, vec!["/casper/initrd".to_string()]); + } + + #[test] + fn anaconda_shape_emits_verified_paths() { + let img = TestIsoBuilder::new("AlmaLinux-9-5-x86_64-dvd") + .el_torito(true) + .file("/images/pxeboot/vmlinuz", b"K") + .file("/images/pxeboot/initrd.img", b"I") + .build(); + let r = introspect_mem(img, "AlmaLinux-9.5-x86_64-dvd.iso", true); + assert_eq!(r.family, DistroFamily::RhelFedora); + assert_eq!(r.kernel_path.as_deref(), Some("/images/pxeboot/vmlinuz")); + } + + #[test] + fn coreos_shape_classifies_but_keeps_sanboot() { + // RHCOS / OpenShift agent ISOs: anaconda layout + rootfs.img. + // Direct kernel boot needs coreos.live.rootfs_url (and agent + // ISOs their embedded ignition), so kernel_path must stay None. + let img = TestIsoBuilder::new("rhcos-417.94.202501") + .el_torito(true) + .file("/images/pxeboot/vmlinuz", b"K") + .file("/images/pxeboot/initrd.img", b"I") + .file("/images/pxeboot/rootfs.img", b"R") + .build(); + let r = introspect_mem(img, "rhcos-live.x86_64.iso", true); + assert_eq!(r.family, DistroFamily::RhelFedora); + assert!( + r.kernel_path.is_none(), + "CoreOS must not get a kernel entry" + ); + assert!(r.el_torito); + } + + #[test] + fn boot_wim_probe_classifies_windows() { + let img = TestIsoBuilder::new("CCCOMA_X64FRE_EN-US_DV9") + .el_torito(true) + .file("/sources/boot.wim", b"MSWIMMSWIM") + .build(); + let r = introspect_mem(img, "whatever.iso", false); + assert_eq!(r.family, DistroFamily::WindowsPe); + assert!(r.has_boot_wim); + } + + #[test] + fn label_only_linux_without_verified_kernel_gets_no_kernel_path() { + // Label says RHEL but the tree has no pxeboot files — the old + // code guessed `/images/pxeboot/vmlinuz` and emitted an entry + // that 404'd at boot. Now: family yes, kernel paths no. + let img = TestIsoBuilder::new("RHEL-9-5-CUSTOM") + .el_torito(true) + .file("/readme.txt", b"hi") + .build(); + let r = introspect_mem(img, "rhel-custom.iso", true); + assert_eq!(r.family, DistroFamily::RhelFedora); + assert!(r.kernel_path.is_none()); + assert!(r.initrd_paths.is_empty()); + } + + #[test] + fn remote_skips_bulk_scan_but_filename_still_hints() { + // No ISO9660 signatures at all (e.g. pure-UDF image read over a + // share), bulk scan off: filename is the only signal. + let img = vec![0u8; 64 * 1024]; + let r = introspect_mem(img.clone(), "Win11_24H2_English_x64.iso", false); + assert_eq!(r.family, DistroFamily::WindowsPe); + let r2 = introspect_mem(img, "mystery.iso", false); + assert_eq!(r2.family, DistroFamily::Unknown); + } + + #[test] + fn filename_family_table() { + use DistroFamily::*; + let cases = [ + ("AlmaLinux-9.5-x86_64-dvd.iso", RhelFedora), + ("CentOS-Stream-10-latest-x86_64-dvd1.iso", RhelFedora), + ("rhel-9.0-x86_64-boot.iso", RhelFedora), + ("rhcos-live.x86_64.iso", RhelFedora), + ("openshift-4-21-9.agent.x86_64.iso", RhelFedora), + ("ubuntu-24.04-desktop.iso", DebianUbuntu), + ("gparted-live-1.8.1-3-amd64.iso", DebianUbuntu), + ("archlinux-2026.05.01-x86_64.iso", Arch), + ("arch-2026.05.01.iso", Arch), + ("alpine-standard-3.21.0-x86_64.iso", Alpine), + ("openSUSE-Leap-15.6-DVD-x86_64.iso", OpenSuse), + ("en-us_windows_11_iot_enterprise.iso", WindowsPe), + ("Win10_22H2_English_x64.iso", WindowsPe), + ("netboot.xyz.iso", Unknown), + ("ise-3.2.0.542a.SPA.x86_64.iso", Unknown), + ("Macrium_5860_v2.iso", Unknown), + // "search" must not trip the "arch" token. + ("research-data.iso", Unknown), + ]; + for (name, want) in cases { + assert_eq!(family_from_filename(name), want, "{name}"); + } + } + + #[test] + fn provisional_report_keeps_rev_zero() { + let r = provisional_report("rhel-9.0-x86_64-dvd.iso"); + assert_eq!(r.family, DistroFamily::RhelFedora); + assert_eq!( + r.introspect_rev, 0, + "provisional must keep optimistic sanboot" + ); + assert!(!r.el_torito); + } + #[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]; @@ -328,59 +685,41 @@ mod tests { 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"); + fn el_torito_detected_through_reader() { + let with = TestIsoBuilder::new("BOOTABLE").el_torito(true).build(); + let without = TestIsoBuilder::new("DATA").build(); + assert!(futures::executor::block_on(detect_el_torito( + &mut MemReadAt(with) + ))); + assert!(!futures::executor::block_on(detect_el_torito( + &mut MemReadAt(without) + ))); } #[test] fn filename_hint_catches_windows_isos() { - use std::path::Path; - assert!(filename_looks_windows(Path::new( + assert!(filename_looks_windows( "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" - ))); + )); + assert!(filename_looks_windows("Win10_22H2_English_x64.iso")); + assert!(filename_looks_windows("winserver2022.iso")); + assert!(!filename_looks_windows("ubuntu-24.04-desktop.iso")); + assert!(!filename_looks_windows("Rocky-9.4-x86_64-dvd.iso")); + } + + #[test] + fn bulk_scan_catches_udf_windows_markers() { + // A blob with no ISO9660 tree but a UTF-16 "install.wim" — the + // UDF Windows shape after every probe missed. + let mut img = vec![0u8; 256 * 1024]; + let marker: Vec = "install.wim".bytes().flat_map(|b| [b, 0]).collect(); + img[100_000..100_000 + marker.len()].copy_from_slice(&marker); + let r = introspect_mem(img, "renamed.iso", true); + assert_eq!(r.family, DistroFamily::WindowsPe); + assert!(!r.has_boot_wim); } } diff --git a/crates/iso-store/src/iso_fs.rs b/crates/iso-store/src/iso_fs.rs new file mode 100644 index 0000000..0337e0b --- /dev/null +++ b/crates/iso-store/src/iso_fs.rs @@ -0,0 +1,568 @@ +//! Read-only ISO9660 lookup over any random-access byte source. +//! +//! v0.7.4: generalized from the http-api crate's local-file-only walker so +//! the same directory walk drives three consumers: +//! +//! 1. `iso_file` HTTP serving — locate `/casper/vmlinuz` inside a local +//! *or remote* (NFS/SFTP) ISO and stream just that byte range. +//! 2. Introspection — probe for well-known kernel/initrd/boot.wim paths +//! instead of grepping raw sectors for filename strings (which +//! false-positived: any Linux ISO shipping GRUB/syslinux chainload +//! modules contains the literal "bootmgr" and used to classify as +//! Windows). +//! 3. Remote introspection — the same probes over an NFSv3 READ3 / +//! SFTP seek-read connection, which is what finally classifies +//! share-sourced ISOs instead of registering them all as `Unknown`. +//! +//! We parse only the Primary Volume Descriptor namespace. Joliet and Rock +//! Ridge are deliberately ignored — matching is case-insensitive against +//! plain ISO9660 identifiers (`;1` version suffix and the trailing dot of +//! extension-less strict-mastered names stripped), which is how the local +//! serving path has always behaved in production. + +use std::collections::HashMap; +use std::future::Future; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +pub const SECTOR: u64 = 2048; + +/// Upper bound on a single directory extent we'll buffer. Real distro ISO +/// directories are a handful of KiB; the cap keeps a malformed or hostile +/// image from asking us to allocate gigabytes. +const MAX_DIR_BYTES: u64 = 4 * 1024 * 1024; + +/// Byte range of one file inside the ISO image. +#[derive(Debug, Clone)] +pub struct FileLocation { + pub offset: u64, + pub length: u64, +} + +/// Random-access reads into an ISO image. Implemented by a local +/// `std::fs::File`, the NFS and SFTP share readers, and the in-memory +/// test image. +/// +/// The contract is `read_exact`-like: the returned buffer is exactly +/// `len` bytes or the call errors. The future must be `Send` because +/// remote introspection runs inside spawned tokio tasks. +pub trait IsoReadAt { + fn read_at( + &mut self, + offset: u64, + len: u32, + ) -> impl Future>> + Send; +} + +/// Local-file reader. The reads are synchronous inside an async fn — +/// callers run it either on the blocking pool (introspection at upload) +/// or through [`lookup_local`]'s `block_on`, never on a hot runtime +/// worker with real awaits pending. +pub struct FileReadAt(std::fs::File); + +impl FileReadAt { + #[must_use] + pub fn new(f: std::fs::File) -> Self { + Self(f) + } +} + +impl IsoReadAt for FileReadAt { + async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result> { + self.0.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; len as usize]; + self.0.read_exact(&mut buf)?; + Ok(buf) + } +} + +/// Exact-key read cache for the probe phase of introspection. The probe +/// table looks up ~20 paths and every one of them re-reads the root +/// directory (and usually one shared subdirectory); over NFS/SFTP that +/// would be 20 identical round-trips. Directory reads repeat with the +/// exact same `(offset, len)`, so a plain map keyed on the pair hits +/// every time. Large data reads bypass the cache. +pub struct CachingReadAt<'a, R: IsoReadAt + Send> { + inner: &'a mut R, + cache: HashMap<(u64, u32), Vec>, +} + +/// Don't cache reads bigger than this (file payloads, bulk scans). +const CACHE_MAX_READ: u32 = 256 * 1024; +/// Bound the cache so a pathological image can't grow it unbounded. +const CACHE_MAX_ENTRIES: usize = 256; + +impl<'a, R: IsoReadAt + Send> CachingReadAt<'a, R> { + pub fn new(inner: &'a mut R) -> Self { + Self { + inner, + cache: HashMap::new(), + } + } +} + +impl IsoReadAt for CachingReadAt<'_, R> { + async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result> { + let key = (offset, len); + if let Some(hit) = self.cache.get(&key) { + return Ok(hit.clone()); + } + let buf = self.inner.read_at(offset, len).await?; + if len <= CACHE_MAX_READ && self.cache.len() < CACHE_MAX_ENTRIES { + self.cache.insert(key, buf.clone()); + } + Ok(buf) + } +} + +/// Look up `in_iso_path` (leading slash optional, case-insensitive) in +/// the image behind `r`. Returns `None` on any parsing or IO failure — +/// "not found" and "couldn't read" are the same answer to a prober. +pub async fn lookup(r: &mut R, in_iso_path: &str) -> Option { + let pvd = r.read_at(16 * SECTOR, 2048).await.ok()?; + if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { + return None; + } + // Root directory record at PVD offset 156, 34 bytes. + let (mut lba, mut len) = parse_dir_record_ext(&pvd[156..156 + 34])?; + + let components: Vec<&str> = in_iso_path + .trim_start_matches('/') + .split('/') + .filter(|c| !c.is_empty()) + .collect(); + if components.is_empty() { + return None; + } + + // The original walk was tail-recursive; iterate instead so the future + // stays a plain (non-boxed) state machine. + for (idx, comp) in components.iter().enumerate() { + if len == 0 || len > MAX_DIR_BYTES { + return None; + } + let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?; + let hit = scan_dir(&dir, comp)?; + let last = idx + 1 == components.len(); + match (last, hit.is_dir) { + (true, false) => { + return Some(FileLocation { + offset: hit.lba * SECTOR, + length: hit.len, + }) + } + (false, true) => { + lba = hit.lba; + len = hit.len; + } + _ => return None, + } + } + None +} + +/// Convenience probe: does `in_iso_path` exist as a file? +pub async fn exists(r: &mut R, in_iso_path: &str) -> bool { + lookup(r, in_iso_path).await.is_some() +} + +/// Synchronous wrapper for local files — the shape the HTTP handler's +/// `spawn_blocking` call site wants. `block_on` is safe here because +/// `FileReadAt`'s reads never actually await (they complete inline), so +/// the executor never parks. +#[must_use] +pub fn lookup_local(iso_path: &Path, in_iso_path: &str) -> Option { + let f = std::fs::File::open(iso_path).ok()?; + futures::executor::block_on(lookup(&mut FileReadAt::new(f), in_iso_path)) +} + +struct DirHit { + lba: u64, + len: u64, + is_dir: bool, +} + +/// Scan one directory extent for an identifier. Pure function over the +/// buffered extent — all protocol/IO concerns live in the caller. +fn scan_dir(dir: &[u8], target: &str) -> Option { + let mut i = 0; + while i < dir.len() { + let len = dir[i] as usize; + if len == 0 { + // Records never span sectors; a zero length byte means the + // rest of this sector is padding. Hop to the next one. + let next = (i / SECTOR as usize + 1) * SECTOR as usize; + if next <= i { + break; + } + i = next; + continue; + } + if i + len > dir.len() { + break; + } + let rec = &dir[i..i + len]; + let name = dir_record_name(rec); + let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0; + // Skip "." (0x00) and ".." (0x01) pseudo-entries. + let is_pseudo = + rec.get(32).copied() == Some(1) && matches!(rec.get(33).copied(), Some(0x00 | 0x01)); + if !is_pseudo && name.eq_ignore_ascii_case(target) { + let (lba, dlen) = parse_dir_record_ext(rec)?; + return Some(DirHit { + lba, + len: dlen, + is_dir, + }); + } + i += len; + } + None +} + +/// Extract (extent LBA, data length in bytes) from a directory record. +/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18 +/// data length (LE+BE duplicate). We trust the little-endian copy. +fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> { + if rec.len() < 34 { + return None; + } + let lba = u64::from(u32::from_le_bytes(rec[2..6].try_into().ok()?)); + let len = u64::from(u32::from_le_bytes(rec[10..14].try_into().ok()?)); + Some((lba, len)) +} + +/// Extract the identifier from a directory record, normalizing ISO9660 +/// quirks: the `;N` version suffix and the trailing dot that strict +/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the +/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`. +fn dir_record_name(rec: &[u8]) -> String { + let name_len = *rec.get(32).unwrap_or(&0) as usize; + if name_len == 0 || rec.len() < 33 + name_len { + return String::new(); + } + let raw = &rec[33..33 + name_len]; + let s = String::from_utf8_lossy(raw); + let s = s.rfind(';').map_or_else(|| s.as_ref(), |i| &s[..i]); + s.strip_suffix('.').unwrap_or(s).to_string() +} + +// ── test support ───────────────────────────────────────────────────── +// +// A tiny ISO9660 image builder used by this module's tests, the +// introspection tests, and (behind the `test-image` feature) other +// crates' integration tests. Lays out: PVD @ LBA 16, optional El Torito +// boot record @ 17, set terminator @ 18, directories from LBA 20, file +// data after. Only what `lookup`/introspection read is populated. + +#[cfg(any(test, feature = "test-image"))] +#[doc(hidden)] +pub mod testiso { + use super::SECTOR; + use std::collections::BTreeMap; + + #[derive(Default)] + struct Node { + children: BTreeMap, + content: Option>, + } + + pub struct TestIsoBuilder { + root: Node, + volume_label: String, + el_torito: bool, + } + + impl TestIsoBuilder { + pub fn new(volume_label: &str) -> Self { + Self { + root: Node::default(), + volume_label: volume_label.to_string(), + el_torito: false, + } + } + + #[must_use] + pub fn el_torito(mut self, on: bool) -> Self { + self.el_torito = on; + self + } + + /// Add a file at `path` (e.g. "/casper/vmlinuz") with `content`. + #[must_use] + pub fn file(mut self, path: &str, content: &[u8]) -> Self { + let mut node = &mut self.root; + let comps: Vec<&str> = path + .trim_start_matches('/') + .split('/') + .filter(|c| !c.is_empty()) + .collect(); + for (i, c) in comps.iter().enumerate() { + node = node.children.entry((*c).to_string()).or_default(); + if i + 1 == comps.len() { + node.content = Some(content.to_vec()); + } + } + self + } + + pub fn build(self) -> Vec { + // Pass 1: allocate extents. Directories first (1 sector each), + // then file contents. + let mut next_lba: u64 = 20; + let mut dirs: Vec<(*const Node, u64)> = Vec::new(); + fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) { + out.push((std::ptr::from_ref(n), *next)); + *next += 1; + for child in n.children.values() { + if child.content.is_none() { + alloc_dirs(child, next, out); + } + } + } + alloc_dirs(&self.root, &mut next_lba, &mut dirs); + let lba_of = |n: &Node| -> u64 { + dirs.iter() + .find(|(p, _)| std::ptr::eq(*p, n)) + .map(|(_, l)| *l) + .expect("dir allocated") + }; + let mut file_lbas: Vec<(*const Node, u64, usize)> = Vec::new(); + fn alloc_files(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64, usize)>) { + for child in n.children.values() { + if let Some(c) = &child.content { + out.push((std::ptr::from_ref(child), *next, c.len())); + *next += c.len().div_ceil(SECTOR as usize).max(1) as u64; + } else { + alloc_files(child, next, out); + } + } + } + alloc_files(&self.root, &mut next_lba, &mut file_lbas); + let file_lba_of = |n: &Node| -> u64 { + file_lbas + .iter() + .find(|(p, _, _)| std::ptr::eq(*p, n)) + .map(|(_, l, _)| *l) + .expect("file allocated") + }; + + let total = next_lba as usize * SECTOR as usize; + let mut img = vec![0u8; total]; + + // Directory record encoder. + fn record(name_bytes: &[u8], lba: u64, len: u64, is_dir: bool) -> Vec { + let mut rec_len = 33 + name_bytes.len(); + if rec_len % 2 == 1 { + rec_len += 1; // pad to even + } + let rec_len = rec_len.max(34); + let mut r = vec![0u8; rec_len]; + r[0] = rec_len as u8; + r[2..6].copy_from_slice(&(lba as u32).to_le_bytes()); + r[6..10].copy_from_slice(&(lba as u32).to_be_bytes()); + r[10..14].copy_from_slice(&(len as u32).to_le_bytes()); + r[14..18].copy_from_slice(&(len as u32).to_be_bytes()); + if is_dir { + r[25] = 0x02; + } + r[32] = name_bytes.len() as u8; + r[33..33 + name_bytes.len()].copy_from_slice(name_bytes); + r + } + + // Pass 2: write each directory extent. + fn write_dir( + img: &mut [u8], + n: &Node, + self_lba: u64, + parent_lba: u64, + lba_of: &dyn Fn(&Node) -> u64, + file_lba_of: &dyn Fn(&Node) -> u64, + ) { + let base = self_lba as usize * SECTOR as usize; + let mut off = 0usize; + let mut put = |rec: Vec, off: &mut usize| { + img[base + *off..base + *off + rec.len()].copy_from_slice(&rec); + *off += rec.len(); + }; + put(record(&[0x00], self_lba, SECTOR, true), &mut off); + put(record(&[0x01], parent_lba, SECTOR, true), &mut off); + for (name, child) in &n.children { + if let Some(c) = &child.content { + // Files get the ISO9660 uppercase `;1` treatment so + // the case-insensitive + version-strip matching is + // what the tests actually exercise. + let stored = format!("{};1", name.to_ascii_uppercase()); + put( + record(stored.as_bytes(), file_lba_of(child), c.len() as u64, false), + &mut off, + ); + } else { + let stored = name.to_ascii_uppercase(); + put( + record(stored.as_bytes(), lba_of(child), SECTOR, true), + &mut off, + ); + } + } + for (name, child) in &n.children { + if child.content.is_none() { + write_dir(img, child, lba_of(child), self_lba, lba_of, file_lba_of); + } else if let Some(c) = &child.content { + let b = file_lba_of(child) as usize * SECTOR as usize; + img[b..b + c.len()].copy_from_slice(c); + } + let _ = name; + } + } + let root_lba = lba_of(&self.root); + write_dir( + &mut img, + &self.root, + root_lba, + root_lba, + &lba_of, + &file_lba_of, + ); + + // PVD @ 16. + let pvd = 16 * SECTOR as usize; + img[pvd] = 0x01; + img[pvd + 1..pvd + 6].copy_from_slice(b"CD001"); + let label = self.volume_label.as_bytes(); + let label_field = &mut img[pvd + 40..pvd + 72]; + label_field.fill(b' '); + label_field[..label.len().min(32)].copy_from_slice(&label[..label.len().min(32)]); + let root_rec = record(&[0x00], root_lba, SECTOR, true); + img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]); + + // Optional El Torito boot record @ 17, terminator after. + let mut vd = 17 * SECTOR as usize; + if self.el_torito { + img[vd] = 0x00; + img[vd + 1..vd + 6].copy_from_slice(b"CD001"); + let id = b"EL TORITO SPECIFICATION"; + img[vd + 7..vd + 7 + id.len()].copy_from_slice(id); + vd += SECTOR as usize; + } + img[vd] = 0xFF; + img[vd + 1..vd + 6].copy_from_slice(b"CD001"); + + img + } + } + + /// In-memory `IsoReadAt` over a built test image. + pub struct MemReadAt(pub Vec); + + impl super::IsoReadAt for MemReadAt { + async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result> { + let start = usize::try_from(offset).unwrap_or(usize::MAX); + let end = start.saturating_add(len as usize); + if end > self.0.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "read past end of test image", + )); + } + Ok(self.0[start..end].to_vec()) + } + } +} + +#[cfg(test)] +mod tests { + use super::testiso::{MemReadAt, TestIsoBuilder}; + use super::*; + + fn block_on(f: impl Future) -> T { + futures::executor::block_on(f) + } + + #[test] + fn lookup_finds_nested_file_case_insensitively() { + let img = TestIsoBuilder::new("UBUNTU 24.04") + .file("/casper/vmlinuz", b"KERNELDATA") + .file("/casper/initrd", b"INITRDDATA") + .build(); + let mut r = MemReadAt(img); + let loc = block_on(lookup(&mut r, "/CASPER/VMLINUZ")).expect("found"); + assert_eq!(loc.length, 10); + let bytes = block_on(r.read_at(loc.offset, 10)).unwrap(); + assert_eq!(&bytes, b"KERNELDATA"); + // Missing file and missing dir both miss cleanly. + assert!(block_on(lookup(&mut r, "/casper/missing")).is_none()); + assert!(block_on(lookup(&mut r, "/nodir/vmlinuz")).is_none()); + // A directory path that resolves to a directory is not a file hit. + assert!(block_on(lookup(&mut r, "/casper")).is_none()); + } + + #[test] + fn strict_mastered_extensionless_names_match() { + // Strict level-1 mastering stores "VMLINUZ" as "VMLINUZ.;1" — the + // trailing dot must be normalized away or kernels never match. + let img = TestIsoBuilder::new("STRICT") + .file("/boot/vmlinuz.", b"K") // builder stores "VMLINUZ.;1" + .build(); + let mut r = MemReadAt(img); + assert!( + block_on(lookup(&mut r, "/boot/vmlinuz")).is_some(), + "trailing-dot ISO9660 name must match the dotless path" + ); + } + + #[test] + fn lookup_three_levels_deep() { + let img = TestIsoBuilder::new("DEEP") + .file("/images/pxeboot/vmlinuz", b"ANACONDA") + .build(); + let mut r = MemReadAt(img); + let loc = block_on(lookup(&mut r, "images/pxeboot/vmlinuz")).expect("no leading slash ok"); + assert_eq!(loc.length, 8); + } + + #[test] + fn caching_reader_dedupes_repeated_directory_reads() { + struct Counting<'a> { + inner: &'a mut MemReadAt, + calls: usize, + } + impl IsoReadAt for Counting<'_> { + async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result> { + self.calls += 1; + self.inner.read_at(offset, len).await + } + } + let img = TestIsoBuilder::new("CACHE") + .file("/a/one", b"1") + .file("/a/two", b"2") + .build(); + let mut mem = MemReadAt(img); + let mut counting = Counting { + inner: &mut mem, + calls: 0, + }; + let mut cr = CachingReadAt::new(&mut counting); + assert!(block_on(exists(&mut cr, "/a/one"))); + assert!(block_on(exists(&mut cr, "/a/two"))); + assert!(!block_on(exists(&mut cr, "/a/three"))); + drop(cr); + // 3 probes × (PVD + root dir + subdir) = 9 uncached; the cache + // collapses the repeats to the 3 distinct extents. + assert_eq!(counting.calls, 3, "all repeat reads must hit the cache"); + } + + #[test] + fn lookup_local_reads_a_real_file() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("t.iso"); + let img = TestIsoBuilder::new("LOCAL") + .file("/sources/boot.wim", b"WIMWIM") + .build(); + std::fs::write(&p, &img).unwrap(); + let loc = lookup_local(&p, "/sources/boot.wim").expect("found"); + assert_eq!(loc.length, 6); + assert!(lookup_local(&p, "/sources/none").is_none()); + } +} diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index 26e3205..91b6b2f 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -18,8 +18,15 @@ pub mod entry; pub mod introspect; +// v0.7.4: read-only ISO9660 walker generic over any random-access byte +// source (local file, NFS READ3, SFTP seek-read). Powers both in-ISO +// HTTP serving and the probe-based introspection. +pub mod iso_fs; pub mod nfs_share; pub mod pxe_logo; +// v0.7.4: persisted cache of remote-share introspection results so a +// container restart doesn't re-probe an unchanged 40-ISO library. +pub mod remote_cache; pub mod sftp_share; pub mod smb; pub mod smb_share; @@ -29,6 +36,7 @@ pub mod windows; pub use entry::{BootEntry, BootKind, KernelArgs}; pub use introspect::{DistroFamily, IntrospectionReport}; +pub use iso_fs::FileLocation; // v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace // `smbclient` CLI replaced it — works in any container (no // CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and diff --git a/crates/iso-store/src/nfs_share.rs b/crates/iso-store/src/nfs_share.rs index db83c58..332fa53 100644 --- a/crates/iso-store/src/nfs_share.rs +++ b/crates/iso-store/src/nfs_share.rs @@ -57,7 +57,9 @@ //! UI to ask for. (If a future server needs Kerberos or non-default //! uid mapping we can add those, but for ISO read access nobody does.) -use crate::introspect::IntrospectionReport; +use crate::introspect::{introspect_reader, provisional_report}; +use crate::iso_fs::{self, FileLocation, IsoReadAt}; +use crate::remote_cache::RemoteIntrospectCache; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use bytes::Bytes; use nfs3_client::tokio::TokioConnector; @@ -100,6 +102,18 @@ const READ_CHUNK_BYTES: u32 = 64 * 1024; /// client park gigabytes of decoded ISO in RAM. const STREAM_BUFFER_DEPTH: usize = 16; +/// v0.7.4: per-ISO budget for a background introspection probe. A probe +/// is one connection plus a few dozen KiB-sized reads — sub-second on a +/// LAN — so anything past this is a wedged server, not a slow one. +const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// One queued background-introspection unit (v0.7.4). +struct ProbeJob { + iso_id: String, + filename: String, + size: u64, +} + /// One configured NFS share. The id is derived from server+export so /// re-adding the same coordinates is idempotent. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -177,6 +191,9 @@ pub struct NfsShareManager { /// opens its own NFS connection so concurrency isn't a hard /// requirement, but serializing keeps log output predictable. op_lock: Arc>, + /// v0.7.4: persisted introspection results keyed `share/path@size`, + /// so a restart re-probes only new or replaced ISOs. + introspect_cache: RemoteIntrospectCache, } impl NfsShareManager { @@ -190,6 +207,7 @@ impl NfsShareManager { inner: Arc::new(Mutex::new(Inner::default())), iso_store, op_lock: Arc::new(tokio::sync::Mutex::new(())), + introspect_cache: RemoteIntrospectCache::open(work_dir, "nfs_introspect_cache.json"), } } @@ -387,12 +405,26 @@ impl NfsShareManager { }; let mut count = 0u32; + let mut to_probe: Vec = Vec::new(); for entry in listing { let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename)); - // Same approach as SMB: no real introspection over the - // network in v0.4.67. The boot-entry generator falls back - // to filename-based sanboot detection. - let report = IntrospectionReport::default(); + // v0.7.4: real introspection over the share — NFSv3 READ3 + // takes an offset, so the ISO9660 probes work remotely. A + // cache hit registers the full report immediately; a miss + // registers a provisional filename-based report (so the scan + // returns fast) and queues a background probe that upgrades + // the entry in place. + let cached = self + .introspect_cache + .get(&share.id, &entry.filename, entry.size); + let report = cached.unwrap_or_else(|| { + to_probe.push(ProbeJob { + iso_id: iso_id.clone(), + filename: entry.filename.clone(), + size: entry.size, + }); + provisional_report(&entry.filename) + }); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let source = IsoSource::Nfs { share_id: share.id.clone(), @@ -413,11 +445,88 @@ impl NfsShareManager { target: "openpxe::nfs", id = %id, server = %share.server, export = %share.export, iso_count = count, + pending_introspection = to_probe.len(), "NFS share scanned" ); + if !to_probe.is_empty() { + self.spawn_introspection_pass(&share, to_probe); + } Ok(count) } + /// v0.7.4: probe each queued ISO over its own NFS connection and swap + /// the full introspection into the store as results land. Runs + /// detached so neither startup nor the share-add API call waits on a + /// 40-ISO library; per-ISO failures (or a share removed mid-pass) + /// leave the provisional entry in place, which still sanboots. + fn spawn_introspection_pass(&self, share: &NfsShare, work: Vec) { + let store = self.iso_store.clone(); + let cache = self.introspect_cache.clone(); + let share_id = share.id.clone(); + let server = share.server.clone(); + let export = share.export.clone(); + let port = share.port; + let queued = work.len(); + tokio::spawn(async move { + let mut upgraded = 0usize; + for job in work { + let probe = async { + let mut reader = NfsReadAt::open(&server, &export, port, &job.filename).await?; + let report = + introspect_reader(&mut reader, job.size, &job.filename, false).await; + reader.finish().await; + Ok::<_, NfsClientError>(report) + }; + match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await { + Ok(Ok(report)) => { + cache.put(&share_id, &job.filename, job.size, report.clone()); + if store.update_external_introspection(&job.iso_id, report) { + upgraded += 1; + } + } + Ok(Err(e)) => tracing::warn!( + target: "openpxe::nfs", + share = %share_id, iso = %job.filename, + "introspection failed: {e}" + ), + Err(_) => tracing::warn!( + target: "openpxe::nfs", + share = %share_id, iso = %job.filename, + "introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs() + ), + } + } + tracing::info!( + target: "openpxe::nfs", + share = %share_id, queued, upgraded, + "remote introspection pass complete" + ); + }); + } + + /// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the + /// byte range so the HTTP layer can serve kernel/initrd files out of + /// remote ISOs with a follow-up ranged [`Self::stream_iso`]. + pub async fn locate_in_iso( + &self, + share_id: &str, + filename: &str, + in_iso_path: &str, + ) -> Result> { + let share = self + .get(share_id) + .ok_or_else(|| Error::Invalid(format!("no such NFS share '{share_id}'")))?; + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(Error::Invalid(format!("invalid filename '{filename}'"))); + } + let mut reader = NfsReadAt::open(&share.server, &share.export, share.port, filename) + .await + .map_err(|e| Error::Invalid(format!("nfs open '{filename}': {e}")))?; + let loc = iso_fs::lookup(&mut reader, in_iso_path).await; + reader.finish().await; + Ok(loc) + } + fn update_status( &self, id: &str, @@ -490,6 +599,96 @@ struct NfsListEntry { size: u64, } +/// The connection type [`build_connection`] yields. +type NfsConn = nfs3_client::Nfs3Connection>; + +/// v0.7.4: random-access reader over one NFS connection + file handle — +/// the [`IsoReadAt`] impl that lets the ISO9660 walker and introspection +/// probes run against share-hosted images. +struct NfsReadAt { + conn: NfsConn, + fh: nfs_fh3, +} + +impl NfsReadAt { + /// Connect, mount, and LOOKUP `filename` at the export root. + async fn open( + server: &str, + export: &str, + port: u16, + filename: &str, + ) -> std::result::Result { + let mut conn = build_connection(server, export, port).await?; + let root = conn.root_nfs_fh3(); + let lookup = conn + .lookup(&LOOKUP3args { + what: diropargs3 { + dir: root, + name: filename3(Opaque::borrowed(filename.as_bytes())), + }, + }) + .await + .map_err(NfsClientError::Rpc)?; + let fh = match lookup { + Nfs3Result::Ok(o) => o.object, + Nfs3Result::Err((status, _)) => { + return Err(NfsClientError::Nfsstat(status_label(status))); + } + }; + Ok(Self { conn, fh }) + } + + /// Best-effort unmount. Consumes the reader — it's done. + async fn finish(self) { + let _ = self.conn.unmount().await; + } +} + +impl IsoReadAt for NfsReadAt { + async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result> { + let mut out: Vec = Vec::with_capacity(len as usize); + let mut off = offset; + // READ3 may legally return fewer bytes than asked (server cap); + // loop until the exact-read contract is satisfied or the file + // genuinely ends short. + while (out.len() as u32) < len { + let want = (len - out.len() as u32).min(READ_CHUNK_BYTES); + let res = self + .conn + .read(&READ3args { + file: self.fh.clone(), + offset: off, + count: want, + }) + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let ok = match res { + Nfs3Result::Ok(o) => o, + Nfs3Result::Err((status, _)) => { + return Err(std::io::Error::other(status_label(status))); + } + }; + let data = ok.data.as_ref(); + if data.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "NFS read past end of file", + )); + } + out.extend_from_slice(data); + off += data.len() as u64; + if ok.eof && (out.len() as u32) < len { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "NFS read past end of file", + )); + } + } + out.truncate(len as usize); + Ok(out) + } +} + /// Connect, READDIR the export root, look up each `*.iso` to get its /// size + file handle. Returns a flat list. Errors are returned with /// a human-readable message; the caller decides how to surface them. diff --git a/crates/iso-store/src/remote_cache.rs b/crates/iso-store/src/remote_cache.rs new file mode 100644 index 0000000..5147ef4 --- /dev/null +++ b/crates/iso-store/src/remote_cache.rs @@ -0,0 +1,142 @@ +//! Persisted cache of remote-share introspection results. +//! +//! NFS/SFTP introspection costs a connection plus a few dozen small +//! reads per ISO. Shares are rescanned on every startup and share-add, +//! so without a cache a 40-ISO library would re-probe 40 ISOs on every +//! container restart. The cache keys on `share/path@size` — a replaced +//! file (new size) re-probes, an untouched one is free — and entries +//! only count as hits when their `introspect_rev` matches the current +//! logic, so an upgrade that changes detection re-probes everything +//! exactly once. +//! +//! One file per protocol (`nfs_introspect_cache.json`, +//! `sftp_introspect_cache.json`) so the two managers never contend over +//! one writer. + +use crate::introspect::{IntrospectionReport, INTROSPECT_REV}; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Hard cap on cached entries; beyond it the cache resets rather than +/// growing unbounded (a cache wipe only costs one re-probe pass). +const MAX_ENTRIES: usize = 4096; + +#[derive(Clone, Debug)] +pub struct RemoteIntrospectCache { + path: Arc, + map: Arc>>, +} + +impl RemoteIntrospectCache { + /// Open (or start empty) the cache at `/`. + /// A corrupt or missing file is an empty cache, never an error. + #[must_use] + pub fn open(work_dir: &Path, file_name: &str) -> Self { + let path = work_dir.join(file_name); + let map = std::fs::read_to_string(&path) + .ok() + .and_then(|text| { + serde_json::from_str::>(&text).ok() + }) + .unwrap_or_default(); + Self { + path: Arc::new(path), + map: Arc::new(Mutex::new(map)), + } + } + + fn key(share_id: &str, relative_path: &str, size: u64) -> String { + format!("{share_id}/{relative_path}@{size}") + } + + /// A hit requires the entry to have been produced by the *current* + /// introspection logic — stale-rev entries are misses, which is how + /// the cache self-invalidates across upgrades. + #[must_use] + pub fn get( + &self, + share_id: &str, + relative_path: &str, + size: u64, + ) -> Option { + self.map + .lock() + .get(&Self::key(share_id, relative_path, size)) + .filter(|r| r.introspect_rev == INTROSPECT_REV) + .cloned() + } + + pub fn put(&self, share_id: &str, relative_path: &str, size: u64, report: IntrospectionReport) { + let snapshot = { + let mut g = self.map.lock(); + if g.len() >= MAX_ENTRIES { + g.clear(); + } + g.insert(Self::key(share_id, relative_path, size), report); + g.clone() + }; + // Persist outside the lock; tmp+rename so a crash mid-write + // leaves the previous cache intact. + let path = self.path.as_path(); + let tmp = path.with_extension("json.tmp"); + let Ok(body) = serde_json::to_vec_pretty(&snapshot) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if std::fs::write(&tmp, body).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::introspect::DistroFamily; + + #[test] + fn round_trips_across_reopen_and_rev_gates() { + let dir = tempfile::tempdir().unwrap(); + let cache = RemoteIntrospectCache::open(dir.path(), "t.json"); + assert!(cache.get("s1", "a.iso", 100).is_none()); + + let fresh = IntrospectionReport { + family: DistroFamily::RhelFedora, + introspect_rev: INTROSPECT_REV, + el_torito: true, + ..Default::default() + }; + cache.put("s1", "a.iso", 100, fresh.clone()); + assert_eq!( + cache.get("s1", "a.iso", 100).unwrap().family, + DistroFamily::RhelFedora + ); + // Different size = different file = miss. + assert!(cache.get("s1", "a.iso", 101).is_none()); + + // Survives a reopen. + let cache2 = RemoteIntrospectCache::open(dir.path(), "t.json"); + assert!(cache2.get("s1", "a.iso", 100).is_some()); + + // Stale-rev entries never hit. + let stale = IntrospectionReport { + family: DistroFamily::Arch, + introspect_rev: INTROSPECT_REV - 1, + ..Default::default() + }; + cache2.put("s1", "b.iso", 7, stale); + assert!(cache2.get("s1", "b.iso", 7).is_none()); + } + + #[test] + fn corrupt_cache_file_starts_empty() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("t.json"), b"{nope").unwrap(); + let cache = RemoteIntrospectCache::open(dir.path(), "t.json"); + assert!(cache.get("s", "x.iso", 1).is_none()); + } +} diff --git a/crates/iso-store/src/sftp_share.rs b/crates/iso-store/src/sftp_share.rs index 5f36e01..e0eefc2 100644 --- a/crates/iso-store/src/sftp_share.rs +++ b/crates/iso-store/src/sftp_share.rs @@ -56,7 +56,9 @@ //! hint}` error shape is shared so the storage tab renders all three //! protocols through one code path. -use crate::introspect::IntrospectionReport; +use crate::introspect::{introspect_reader, provisional_report}; +use crate::iso_fs::{self, FileLocation, IsoReadAt}; +use crate::remote_cache::RemoteIntrospectCache; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use bytes::Bytes; use openpxe_core::{Error, Result}; @@ -96,6 +98,18 @@ const READ_CHUNK_BYTES: usize = 64 * 1024; /// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream. const STREAM_BUFFER_DEPTH: usize = 16; +/// v0.7.4: per-ISO budget for a background introspection probe — one SSH +/// connection plus a few dozen KiB-sized reads. SSH handshakes cost more +/// than NFS mounts, but 30s still only trips on a wedged server. +const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// One queued background-introspection unit (v0.7.4). +struct ProbeJob { + iso_id: String, + filename: String, + size: u64, +} + /// Which credential the share authenticates with. The secret itself /// lives in the 0600 creds file, never here. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -209,6 +223,9 @@ pub struct SftpShareManager { /// Serializes scan operations on the same manager for predictable /// log output; each scan opens its own SSH connection. op_lock: Arc>, + /// v0.7.4: persisted introspection results keyed `share/path@size`, + /// so a restart re-probes only new or replaced ISOs. + introspect_cache: RemoteIntrospectCache, } impl SftpShareManager { @@ -222,6 +239,7 @@ impl SftpShareManager { inner: Arc::new(Mutex::new(Inner::default())), iso_store, op_lock: Arc::new(tokio::sync::Mutex::new(())), + introspect_cache: RemoteIntrospectCache::open(work_dir, "sftp_introspect_cache.json"), } } @@ -474,13 +492,25 @@ impl SftpShareManager { }; let mut count = 0u32; + let mut to_probe: Vec = Vec::new(); for entry in listing { let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename)); - // Same as NFS/SMB: no over-the-network introspection yet, so - // register `Unknown` and let the boot-entry generator fall - // back to filename-based detection. SFTP *could* do bounded - // PVD reads (it has random access) — a follow-up can add it. - let report = IntrospectionReport::default(); + // v0.7.4: real introspection over the share — SFTP file + // handles are seekable, so the ISO9660 probes work remotely. + // Cache hit → full report now; miss → provisional filename- + // based report (scan returns fast) + a queued background + // probe that upgrades the entry in place. + let cached = self + .introspect_cache + .get(&share.id, &entry.filename, entry.size); + let report = cached.unwrap_or_else(|| { + to_probe.push(ProbeJob { + iso_id: iso_id.clone(), + filename: entry.filename.clone(), + size: entry.size, + }); + provisional_report(&entry.filename) + }); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let source = IsoSource::Sftp { share_id: share.id.clone(), @@ -506,11 +536,88 @@ impl SftpShareManager { target: "openpxe::sftp", id = %id, server = %share.server, export = %share.export, iso_count = count, + pending_introspection = to_probe.len(), "SFTP share scanned" ); + if !to_probe.is_empty() { + self.spawn_introspection_pass(&share, &creds, to_probe); + } Ok(count) } + /// v0.7.4: probe each queued ISO over its own SSH connection and swap + /// the full introspection into the store as results land. Detached so + /// neither startup nor the share-add API call waits on a big library; + /// per-ISO failures leave the provisional entry, which still sanboots. + fn spawn_introspection_pass(&self, share: &SftpShare, creds: &SftpCreds, work: Vec) { + let store = self.iso_store.clone(); + let cache = self.introspect_cache.clone(); + let share_id = share.id.clone(); + let params = ConnParams::from_share(share); + let creds = creds.clone(); + let queued = work.len(); + tokio::spawn(async move { + let mut upgraded = 0usize; + for job in work { + let probe = async { + let mut reader = SftpReadAt::open(¶ms, &creds, &job.filename).await?; + let report = + introspect_reader(&mut reader, job.size, &job.filename, false).await; + Ok::<_, SftpClientError>(report) + }; + match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await { + Ok(Ok(report)) => { + cache.put(&share_id, &job.filename, job.size, report.clone()); + if store.update_external_introspection(&job.iso_id, report) { + upgraded += 1; + } + } + Ok(Err(e)) => tracing::warn!( + target: "openpxe::sftp", + share = %share_id, iso = %job.filename, + "introspection failed: {e}" + ), + Err(_) => tracing::warn!( + target: "openpxe::sftp", + share = %share_id, iso = %job.filename, + "introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs() + ), + } + } + tracing::info!( + target: "openpxe::sftp", + share = %share_id, queued, upgraded, + "remote introspection pass complete" + ); + }); + } + + /// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the + /// byte range so the HTTP layer can serve kernel/initrd files out of + /// remote ISOs with a follow-up ranged [`Self::stream_iso`]. + pub async fn locate_in_iso( + &self, + share_id: &str, + filename: &str, + in_iso_path: &str, + ) -> Result> { + let share = self + .get(share_id) + .ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?; + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(Error::Invalid(format!("invalid filename '{filename}'"))); + } + let creds = self + .read_creds(share_id) + .await + .map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?; + let params = ConnParams::from_share(&share); + let mut reader = SftpReadAt::open(¶ms, &creds, filename) + .await + .map_err(|e| Error::Invalid(format!("sftp open '{filename}': {e}")))?; + Ok(iso_fs::lookup(&mut reader, in_iso_path).await) + } + fn pin_fingerprint(&self, id: &str, fingerprint: String) { if fingerprint.is_empty() { return; @@ -652,6 +759,43 @@ struct SftpConn { sftp: SftpSession, } +/// v0.7.4: random-access reader over one SSH connection + open file +/// handle — the [`IsoReadAt`] impl that lets the ISO9660 walker and +/// introspection probes run against share-hosted images. Holds the +/// `SftpConn` so the SSH session outlives every read. +struct SftpReadAt { + _conn: SftpConn, + file: russh_sftp::client::fs::File, +} + +impl SftpReadAt { + async fn open( + p: &ConnParams, + creds: &SftpCreds, + filename: &str, + ) -> std::result::Result { + let (conn, _fp) = connect(p, creds).await?; + let full = format!("{}/{}", p.export.trim_end_matches('/'), filename); + let file = conn + .sftp + .open(full) + .await + .map_err(|e| SftpClientError::Sftp(e.to_string()))?; + Ok(Self { _conn: conn, file }) + } +} + +impl IsoReadAt for SftpReadAt { + async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result> { + self.file.seek(SeekFrom::Start(offset)).await?; + let mut buf = vec![0u8; len as usize]; + // read_exact loops over the transport's short reads and fails + // with UnexpectedEof past end-of-file — exactly the contract. + self.file.read_exact(&mut buf).await?; + Ok(buf) + } +} + /// russh client handler implementing trust-on-first-use host-key /// verification. We never construct an `Err` from `check_server_key`; /// returning `Ok(false)` makes russh abort the handshake, and the diff --git a/crates/iso-store/src/smb_share.rs b/crates/iso-store/src/smb_share.rs index 10905a3..b207280 100644 --- a/crates/iso-store/src/smb_share.rs +++ b/crates/iso-store/src/smb_share.rs @@ -56,7 +56,7 @@ //! streaming. A follow-up release can add libsmbclient-based seek if //! a real workload needs it. -use crate::introspect::IntrospectionReport; +use crate::introspect::provisional_report; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use openpxe_core::{Error, Result}; use parking_lot::Mutex; @@ -455,15 +455,14 @@ impl SmbShareManager { let mut count = 0u32; for entry in listing { let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename)); - // SMB sources don't get a real introspection pass — that - // would require seeking into the ISO9660 PVD over the - // network, and smbclient CLI doesn't seek. We register an - // `Unknown` family so the boot-entry generator falls back - // to generic sanboot/wimboot detection from the filename - // and the operator gets *something* bootable. A follow-up - // release can do a bounded `smbclient get` of the first - // 64 KiB for real detection. - let report = IntrospectionReport::default(); + // SMB sources can't get the content-probe pass NFS/SFTP got + // in v0.7.4 — the ISO9660 probes need seeks, and smbclient's + // CLI streaming doesn't seek. The provisional filename-token + // report is as far as SMB detection goes: family for the UI + // when the name says it ("rhel-9.0…", "Win11_…"), and + // `introspect_rev = 0` so the entry generator keeps the + // optimistic sanboot entry. + let report = provisional_report(&entry.filename); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let source = IsoSource::Smb { share_id: share.id.clone(), diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 1b6fcfe..c21ad03 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -415,6 +415,28 @@ impl IsoStore { self.inner.write().isos.insert(id, meta); } + /// v0.7.4: swap in a completed introspection for an external ISO and + /// regenerate its boot entries. Used by the NFS/SFTP managers' + /// background probe pass — the scan registers a provisional + /// (filename-only) report immediately so startup and share-add stay + /// fast, then this upgrades each entry as its probe finishes. + /// Operator-set fields (category, password) are preserved; returns + /// `false` when the id is gone (share removed or re-scanned away + /// mid-probe), which callers treat as a benign no-op. + pub fn update_external_introspection( + &self, + id: &str, + introspection: IntrospectionReport, + ) -> bool { + let mut g = self.inner.write(); + let Some(m) = g.isos.get_mut(id) else { + return false; + }; + m.boot_entries = generate_boot_entries(&m.id, &m.filename, &introspection); + m.introspection = introspection; + true + } + /// Drop every entry that belongs to `share_id`. Used by the SMB /// and NFS share managers when an operator removes a share, or /// before re-scanning to clean out stale entries. The same id diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 6803ee3..b3c0ecd 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -925,3 +925,15 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } and matches every other input in the app; this wrapper just insets it from the card edges so it lines up with the header text above. */ .list-search { padding: 14px 16px; } + +/* v0.7.4: pager footer under the Available-images table — quiet status + text on the left, ghost Prev/Next on the right. */ +.list-pager { + display: flex; align-items: center; gap: 8px; + padding: 12px 16px; + color: var(--fg-dim); font-size: 12px; + font-variant-numeric: tabular-nums; +} +.list-pager .spacer { flex: 1; } +.list-pager button { padding: 4px 12px; font-size: 12px; } +.list-pager button:disabled { opacity: 0.45; cursor: default; } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 021e9cb..49d5a6c 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -178,12 +178,14 @@ if (iso.introspection.el_torito) { return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' }; } - // Remote-share ISOs aren't introspected (no random access over the - // network), so el_torito is unknown — assume bootable and let sanboot - // try rather than cry wolf. + // v0.7.4: NFS/SFTP ISOs now introspect over the share, so a probed + // remote ISO flows through the kernel/el_torito branches above like + // a local one. introspect_rev 0 means the probe hasn't landed yet + // (it runs in the background right after a scan) or never can (SMB — + // smbclient can't seek): stay optimistic and let sanboot try. const remote = iso.source && iso.source.kind && iso.source.kind !== 'local'; - if (remote) { - return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' }; + if (remote && (iso.introspection.introspect_rev || 0) === 0) { + return { ok: true, warn: 'remote ISO — awaiting introspection; sanboot is attempted at boot' }; } // Local ISO with no Windows/Linux boot files and no El Torito catalog: // a data/appliance image (e.g. a VMware vCenter bundle), not a bootable @@ -267,7 +269,7 @@ el('label', {class:'field'}, [ el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]), el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Unattended file'), sel]), + el('span', {class:'name'}, 'Unattended file (in Storage → Advanced)'), sel]), ]); return { wrap, @@ -894,20 +896,38 @@ rowsAndEditors.push(tr, editorRow); }); - // v0.7.2: client-side filter over the image table. Rows travel in - // (row, password-editor) pairs; filtering hides both, and an open - // editor stays closed for filtered-out rows. + // v0.7.2: client-side filter over the image table; v0.7.4: paged + // 5 at a time so a 50-image library doesn't become a scroll wall. + // Rows travel in (row, password-editor) pairs. One view function + // applies filter-then-page; editors close on any view change. + const ISO_PAGE_SIZE = 5; + let isoPage = 0; + const pagerInfo = el('span', {}); + const prevBtn = el('button', {class:'ghost', onclick: () => { isoPage -= 1; applyIsoListView(); }}, '‹ Prev'); + const nextBtn = el('button', {class:'ghost', onclick: () => { isoPage += 1; applyIsoListView(); }}, 'Next ›'); + function applyIsoListView() { + const q = isoSearch.value.trim().toLowerCase(); + const visible = []; + for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) { + const row = rowsAndEditors[k]; + rowsAndEditors[k + 1].style.display = 'none'; + row.style.display = 'none'; + if (!q || (row.dataset.search || '').includes(q)) visible.push(row); + } + const pages = Math.max(1, Math.ceil(visible.length / ISO_PAGE_SIZE)); + if (isoPage >= pages) isoPage = pages - 1; + if (isoPage < 0) isoPage = 0; + visible.slice(isoPage * ISO_PAGE_SIZE, (isoPage + 1) * ISO_PAGE_SIZE) + .forEach(r => { r.style.display = ''; }); + pagerInfo.textContent = visible.length + ? 'Showing ' + (isoPage * ISO_PAGE_SIZE + 1) + '–' + + Math.min(visible.length, (isoPage + 1) * ISO_PAGE_SIZE) + ' of ' + visible.length + : 'No images match'; + prevBtn.disabled = isoPage === 0; + nextBtn.disabled = isoPage >= pages - 1; + } const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source', - spellcheck:'false', oninput: () => { - const q = isoSearch.value.trim().toLowerCase(); - for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) { - const row = rowsAndEditors[k]; - const editor = rowsAndEditors[k + 1]; - const show = !q || (row.dataset.search || '').includes(q); - row.style.display = show ? '' : 'none'; - if (!show) editor.style.display = 'none'; - } - }}); + spellcheck:'false', oninput: () => { isoPage = 0; applyIsoListView(); }}); const isoTable = isos.length ? el('table', {}, [ el('thead', {}, el('tr', {}, [ @@ -919,6 +939,13 @@ el('tbody', {}, rowsAndEditors), ]) : el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.'); + // v0.7.4: pager footer, shown once the library outgrows one page. + const isoPager = isos.length > ISO_PAGE_SIZE + ? el('div', {class:'list-pager'}, [ + pagerInfo, el('span', {class:'spacer'}), prevBtn, nextBtn, + ]) + : null; + if (isos.length) applyIsoListView(); // ── Remote shares section (v0.5.1) ── // SMB + NFS unified into one "Remote shares" card with a protocol @@ -1337,6 +1364,7 @@ ? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch)) : null, isoTable, + isoPager, ]), ]), unattendedAdvanced]); },