From 1ded291c7b660ee5b88bf8895d924800321b6118 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Fri, 12 Jun 2026 17:04:01 -0400 Subject: [PATCH] v0.7.5: Joliet namespace fallback, gap-tolerant El Torito walk, Unattended pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the v0.7.4 dashboard triage: VCSA/ultravnc data ISOs are *correctly* flagged non-bootable (no boot catalog exists to find), but two real false-negative holes could mislabel genuinely bootable appliance ISOs — both closed here. iso_fs: - Joliet fallback (the big one): lookup() now tries the primary ISO9660 namespace first and falls back to the Joliet SVD (UCS-2 big-endian identifiers, escape-sequence detected). Windows-oriented mastering tools — common for vendor/appliance ISOs — write a minimal or mangled primary tree and keep the real filenames only in Joliet; those images probed as "no installer files" and their in-ISO fetches 404'd. Applies everywhere the walker is used: introspection probes (local + NFS/SFTP) and /iso/{id}/{*path} serving. - find_descriptor(): the PVD/SVD search scans the whole descriptor area (LBA 16..32), skipping non-CD001 filler sectors instead of requiring a pristine sector 16. - TestIsoBuilder grows a joliet_only mode (bare primary tree, real names only in the SVD) modeling the mastering worst case. introspect: - detect_el_torito() no longer aborts at the first non-CD001 sector or stops at a Set Terminator — sloppy mastering leaves zeroed filler sectors that used to hide a real boot record and flag a bootable image as a data ISO. All 16 descriptor sectors are examined; the 25-byte exact signature can't false-positive on what follows the set. - Volume-label read now uses the same tolerant descriptor scan, and label + El Torito + namespace probes all share one CachingReadAt, so remote probes spend fewer round-trips than before despite scanning more sectors. - INTROSPECT_REV bumped to 3 so everything probed by the rev-2 logic re-probes with the Joliet fallback: local ISOs on first startup, and remote ISOs via the rev-gated cache self-invalidating. webui: - Unattended files: the same 5-per-page pager as Available images (Showing X–Y of N · Prev/Next), composed with the existing filter, page resets on input. Validation: clippy pedantic clean, fmt clean, 319 workspace tests green (+3: joliet fallback lookup, joliet-only classification, filler- sector boot record), webui syntax-checked. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/iso-store/src/introspect.rs | 113 ++++++++---- crates/iso-store/src/iso_fs.rs | 267 +++++++++++++++++++++++++---- crates/webui/src/app.js | 49 ++++-- 5 files changed, 360 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d374cc8..fb8128a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.7.4" +version = "0.7.5" dependencies = [ "anyhow", "axum", @@ -2858,7 +2858,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.7.4" +version = "0.7.5" dependencies = [ "anyhow", "base64", @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.7.4" +version = "0.7.5" dependencies = [ "anyhow", "bytes", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.7.4" +version = "0.7.5" dependencies = [ "anyhow", "axum", @@ -2938,7 +2938,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.7.4" +version = "0.7.5" dependencies = [ "openpxe-core", "rust-embed", @@ -2948,7 +2948,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.7.4" +version = "0.7.5" dependencies = [ "anyhow", "bcrypt", @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.7.4" +version = "0.7.5" dependencies = [ "anyhow", "bytes", @@ -2991,7 +2991,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.7.4" +version = "0.7.5" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index 1ef5ee5..3dbfbdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.7.4" +version = "0.7.5" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/iso-store/src/introspect.rs b/crates/iso-store/src/introspect.rs index 84c577f..edcf885 100644 --- a/crates/iso-store/src/introspect.rs +++ b/crates/iso-store/src/introspect.rs @@ -56,7 +56,11 @@ pub enum DistroFamily { /// 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; +/// rev 3 (v0.7.5): Joliet namespace fallback + gap-tolerant El Torito / +/// descriptor scans. Without this bump, images the rev-2 logic flagged +/// as data ISOs (mangled-primary appliance images, filler-sector boot +/// records) would never re-probe and stay mislabeled. +pub const INTROSPECT_REV: u32 = 3; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct IntrospectionReport { @@ -210,25 +214,28 @@ pub async fn introspect_reader( ..Default::default() }; - // 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" { + // Everything sector-shaped goes through one caching wrapper: the + // descriptor-set sectors are read once and shared between the label + // scan, the El Torito walk, and the namespace-root lookups; the + // probe table's repeated root/subdirectory reads collapse the same + // way — over NFS/SFTP that's the difference between ~6 and ~60+ + // round-trips per ISO. + { + let mut cr = CachingReadAt::new(r); + + // ISO9660 Primary Volume Descriptor: bytes 40..72 are the volume + // identifier (space-padded). v0.7.5: located by scanning the + // descriptor set (tolerating filler sectors) instead of assuming + // a pristine sector 16. + if let Some(pvd) = iso_fs::find_descriptor(&mut cr, false).await { let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string(); if !label.is_empty() { report.family = family_from_label(&label); report.volume_label = Some(label); } } - } - report.el_torito = detect_el_torito(r).await; - - // 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. - { - let mut cr = CachingReadAt::new(r); + report.el_torito = detect_el_torito(&mut cr).await; if iso_fs::exists(&mut cr, "/sources/boot.wim").await { report.has_boot_wim = true; @@ -471,30 +478,31 @@ const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION"; /// by BIOS/UEFI firmware (and thus by iPXE `sanboot`). /// /// 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. +/// 2048-byte descriptor per sector; 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). +/// +/// v0.7.5: the walk no longer aborts at the first non-`CD001` sector or +/// stops at a Set Terminator. Sloppy mastering tools (appliance ISOs +/// especially) leave zeroed filler sectors inside the descriptor area or +/// odd descriptor ordering, which used to hide a real boot record and +/// flag a bootable image as a data ISO. All 16 sectors are examined — +/// the signature is 25 exact bytes, so scanning past the terminator +/// (into e.g. a UDF volume recognition sequence) cannot false-positive. +/// The cap keeps a malformed image from spinning us. v0.5.9 originally; +/// reader-generic since v0.7.4. async fn detect_el_torito(r: &mut R) -> bool { for lba in 16u64..32 { let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else { + // Past end of a tiny image — nothing more to examine. return false; }; - // Every descriptor in the set carries the "CD001" magic; once it's - // missing we've walked off the end of a valid set. if &vd[1..6] != b"CD001" { - return false; + continue; // filler/garbage sector — keep walking } - match vd[0] { - // Boot Record descriptor carrying the El Torito signature. - 0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true, - // Volume Descriptor Set Terminator — nothing bootable found. - 0xFF => return false, - // Any other descriptor (incl. a non-El-Torito boot record) — - // keep walking the set. - _ => {} + if vd[0] == 0x00 && vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID { + return true; } } false @@ -700,6 +708,51 @@ mod tests { ))); } + #[test] + fn el_torito_survives_filler_sector_in_descriptor_area() { + // v0.7.5 tolerance test: sloppy appliance mastering leaves a + // zeroed sector inside the Volume Descriptor Set. The old walk + // aborted at the first non-CD001 sector and flagged a genuinely + // bootable image as a data ISO. + let sector = SECTOR as usize; + let mut img = TestIsoBuilder::new("GAPPY").el_torito(true).build(); + // Builder layout: PVD @16, Boot Record @17, terminator @18. + // Move the BR to 18 (over the terminator) and zero out 17. + img.copy_within(17 * sector..18 * sector, 18 * sector); + img[17 * sector..18 * sector].fill(0); + assert!( + futures::executor::block_on(detect_el_torito(&mut MemReadAt(img))), + "boot record behind a zeroed filler sector must still be found" + ); + } + + #[test] + fn joliet_only_image_classifies_via_fallback() { + // Primary namespace bare, real tree only in Joliet — the v0.7.5 + // fallback must classify it (boot.wim probe) where v0.7.4 saw + // "no installer files". + let img = TestIsoBuilder::new("WIN_APPLIANCE") + .el_torito(true) + .joliet_only(true) + .file("/sources/boot.wim", b"WIMWIM") + .build(); + let r = introspect_mem(img, "appliance.iso", false); + assert_eq!(r.family, DistroFamily::WindowsPe); + assert!(r.has_boot_wim); + assert!(r.el_torito); + + // Same for a Linux shape: verified kernel paths via Joliet. + let img2 = TestIsoBuilder::new("CUSTOM-EL9") + .el_torito(true) + .joliet_only(true) + .file("/images/pxeboot/vmlinuz", b"K") + .file("/images/pxeboot/initrd.img", b"I") + .build(); + let r2 = introspect_mem(img2, "custom-el9.iso", false); + assert_eq!(r2.family, DistroFamily::RhelFedora); + assert_eq!(r2.kernel_path.as_deref(), Some("/images/pxeboot/vmlinuz")); + } + #[test] fn filename_hint_catches_windows_isos() { assert!(filename_looks_windows( diff --git a/crates/iso-store/src/iso_fs.rs b/crates/iso-store/src/iso_fs.rs index 0337e0b..28fe3c1 100644 --- a/crates/iso-store/src/iso_fs.rs +++ b/crates/iso-store/src/iso_fs.rs @@ -14,11 +14,13 @@ //! 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. +//! Namespaces: the primary ISO9660 tree is tried first; on a miss the +//! walk falls back to the **Joliet** supplementary namespace (v0.7.5) — +//! Windows-oriented mastering tools often write a minimal/mangled +//! primary tree with the real names only in Joliet. Rock Ridge stays +//! ignored. Matching is case-insensitive with the `;1` version suffix +//! and the trailing dot of extension-less strict-mastered names +//! stripped. use std::collections::HashMap; use std::future::Future; @@ -118,14 +120,14 @@ impl IsoReadAt for CachingReadAt<'_, R> { /// 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. +/// +/// v0.7.5: tries the primary ISO9660 namespace first, then falls back +/// to the **Joliet** supplementary namespace. Windows-oriented mastering +/// tools (common for appliance ISOs) often write a minimal or mangled +/// primary tree and keep the real filenames only in Joliet — without the +/// fallback those images probed as "no installer files" and their in-ISO +/// kernel fetches 404'd. 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('/') @@ -134,15 +136,78 @@ pub async fn lookup(r: &mut R, in_iso_path: &str) -> Option if components.is_empty() { return None; } + if let Some(root) = find_root(r, false).await { + if let Some(loc) = walk_namespace(r, root, &components, false).await { + return Some(loc); + } + } + if let Some(root) = find_root(r, true).await { + if let Some(loc) = walk_namespace(r, root, &components, true).await { + return Some(loc); + } + } + None +} - // The original walk was tail-recursive; iterate instead so the future - // stays a plain (non-boxed) state machine. +/// Find the namespace root: the Primary Volume Descriptor (`joliet = +/// false`) or the Joliet Supplementary Volume Descriptor (`joliet = +/// true`, identified by its UCS-2 escape sequence). Scans the whole +/// descriptor area rather than assuming fixed sectors, skipping any +/// non-`CD001` sector — sloppy mastering tools leave gaps. Returns the +/// root directory's `(lba, len)`. +async fn find_root(r: &mut R, joliet: bool) -> Option<(u64, u64)> { + let vd = find_descriptor(r, joliet).await?; + // Root directory record at descriptor offset 156, 34 bytes. + parse_dir_record_ext(&vd[156..156 + 34]) +} + +/// Scan the Volume Descriptor Set (LBA 16..32) for the wanted +/// descriptor: PVD (type 0x01) or Joliet SVD (type 0x02 carrying a +/// UCS-2 level 1/2/3 escape sequence at offset 88). Tolerant of +/// non-`CD001` filler sectors; stops at the Set Terminator. +pub(crate) async fn find_descriptor( + r: &mut R, + joliet: bool, +) -> Option> { + for lba in 16u64..32 { + let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else { + return None; + }; + if &vd[1..6] != b"CD001" { + continue; + } + match vd[0] { + 0x01 if !joliet => return Some(vd), + 0x02 if joliet && has_joliet_escape(&vd) => return Some(vd), + 0xFF => return None, + _ => {} + } + } + None +} + +/// Joliet SVDs declare a UCS-2 escape sequence at offset 88: `%/@`, +/// `%/C`, or `%/E` (levels 1–3). +fn has_joliet_escape(vd: &[u8]) -> bool { + matches!(vd.get(88..91), Some([0x25, 0x2F, 0x40 | 0x43 | 0x45])) +} + +/// Walk path components down one namespace's directory tree. The +/// original walk was tail-recursive; iterate instead so the future +/// stays a plain (non-boxed) state machine. +async fn walk_namespace( + r: &mut R, + root: (u64, u64), + components: &[&str], + joliet: bool, +) -> Option { + let (mut lba, mut len) = root; 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 hit = scan_dir(&dir, comp, joliet)?; let last = idx + 1 == components.len(); match (last, hit.is_dir) { (true, false) => { @@ -184,7 +249,9 @@ struct DirHit { /// 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 { +/// `joliet` switches the identifier decoding (UCS-2 big-endian vs +/// d-characters); the record layout is otherwise identical. +fn scan_dir(dir: &[u8], target: &str, joliet: bool) -> Option { let mut i = 0; while i < dir.len() { let len = dir[i] as usize; @@ -202,7 +269,7 @@ fn scan_dir(dir: &[u8], target: &str) -> Option { break; } let rec = &dir[i..i + len]; - let name = dir_record_name(rec); + let name = dir_record_name(rec, joliet); let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0; // Skip "." (0x00) and ".." (0x01) pseudo-entries. let is_pseudo = @@ -236,7 +303,27 @@ fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> { /// 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 { +/// Joliet identifiers are UCS-2 big-endian; decode then normalize the +/// same way (the `;1` suffix is two UCS-2 characters there). +fn dir_record_name(rec: &[u8], joliet: bool) -> String { + if joliet { + let name_len = *rec.get(32).unwrap_or(&0) as usize; + if name_len < 2 || rec.len() < 33 + name_len { + return String::new(); + } + let raw = &rec[33..33 + name_len]; + let units: Vec = raw + .chunks_exact(2) + .map(|p| u16::from_be_bytes([p[0], p[1]])) + .collect(); + let s = String::from_utf16_lossy(&units); + let s = s.rfind(';').map_or_else(|| s.as_str(), |i| &s[..i]); + return s.strip_suffix('.').unwrap_or(s).to_string(); + } + primary_record_name(rec) +} + +fn primary_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(); @@ -271,6 +358,7 @@ pub mod testiso { root: Node, volume_label: String, el_torito: bool, + joliet_only: bool, } impl TestIsoBuilder { @@ -279,6 +367,7 @@ pub mod testiso { root: Node::default(), volume_label: volume_label.to_string(), el_torito: false, + joliet_only: false, } } @@ -288,6 +377,16 @@ pub mod testiso { self } + /// Model the Windows-mastering worst case: the primary ISO9660 + /// tree is empty (just `.`/`..` in the root) and every real name + /// lives only in the Joliet supplementary namespace. Exercises + /// the v0.7.5 Joliet fallback end to end. + #[must_use] + pub fn joliet_only(mut self, on: bool) -> Self { + self.joliet_only = 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 { @@ -307,8 +406,10 @@ pub mod testiso { } pub fn build(self) -> Vec { - // Pass 1: allocate extents. Directories first (1 sector each), - // then file contents. + // Pass 1: allocate extents. Primary directories first (1 + // sector each), then an optional parallel set of Joliet + // directory extents, then file contents (shared by both + // namespaces — only the directory trees differ). 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)>) { @@ -327,6 +428,17 @@ pub mod testiso { .map(|(_, l)| *l) .expect("dir allocated") }; + let mut jdirs: Vec<(*const Node, u64)> = Vec::new(); + if self.joliet_only { + alloc_dirs(&self.root, &mut next_lba, &mut jdirs); + } + let jlba_of = |n: &Node| -> u64 { + jdirs + .iter() + .find(|(p, _)| std::ptr::eq(*p, n)) + .map(|(_, l)| *l) + .expect("joliet 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() { @@ -371,14 +483,19 @@ pub mod testiso { r } - // Pass 2: write each directory extent. + // Pass 2: write each directory extent. `joliet` switches the + // identifier encoding; `skip_children` writes a bare ./.. + // directory (the mangled-primary worst case). + #[allow(clippy::too_many_arguments)] fn write_dir( img: &mut [u8], n: &Node, self_lba: u64, parent_lba: u64, - lba_of: &dyn Fn(&Node) -> u64, + dir_lba_of: &dyn Fn(&Node) -> u64, file_lba_of: &dyn Fn(&Node) -> u64, + joliet: bool, + skip_children: bool, ) { let base = self_lba as usize * SECTOR as usize; let mut off = 0usize; @@ -388,32 +505,61 @@ pub mod testiso { }; put(record(&[0x00], self_lba, SECTOR, true), &mut off); put(record(&[0x01], parent_lba, SECTOR, true), &mut off); + if skip_children { + return; + } + let encode = |name: &str, file: bool| -> Vec { + if joliet { + // Joliet preserves case; files still carry `;1`. + let s = if file { + format!("{name};1") + } else { + name.to_string() + }; + s.encode_utf16().flat_map(u16::to_be_bytes).collect() + } else if file { + // Primary gets the ISO9660 uppercase `;1` treatment + // so case-insensitive + version-strip matching is + // what the tests actually exercise. + format!("{};1", name.to_ascii_uppercase()).into_bytes() + } else { + name.to_ascii_uppercase().into_bytes() + } + }; 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), + record( + &encode(name, true), + 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), + record(&encode(name, false), dir_lba_of(child), SECTOR, true), &mut off, ); } } - for (name, child) in &n.children { + for child in n.children.values() { if child.content.is_none() { - write_dir(img, child, lba_of(child), self_lba, lba_of, file_lba_of); + write_dir( + img, + child, + dir_lba_of(child), + self_lba, + dir_lba_of, + file_lba_of, + joliet, + false, + ); } 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); @@ -424,8 +570,25 @@ pub mod testiso { root_lba, &lba_of, &file_lba_of, + false, + self.joliet_only, ); - + let jroot_lba = if self.joliet_only { + let jroot = jlba_of(&self.root); + write_dir( + &mut img, + &self.root, + jroot, + jroot, + &jlba_of, + &file_lba_of, + true, + false, + ); + Some(jroot) + } else { + None + }; // PVD @ 16. let pvd = 16 * SECTOR as usize; img[pvd] = 0x01; @@ -437,7 +600,8 @@ pub mod testiso { 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. + // Optional El Torito boot record @ 17, then the optional + // Joliet SVD, then the set terminator. let mut vd = 17 * SECTOR as usize; if self.el_torito { img[vd] = 0x00; @@ -446,6 +610,15 @@ pub mod testiso { img[vd + 7..vd + 7 + id.len()].copy_from_slice(id); vd += SECTOR as usize; } + if let Some(jroot) = jroot_lba { + img[vd] = 0x02; + img[vd + 1..vd + 6].copy_from_slice(b"CD001"); + // Joliet level-3 UCS-2 escape sequence. + img[vd + 88..vd + 91].copy_from_slice(&[0x25, 0x2F, 0x45]); + let jroot_rec = record(&[0x00], jroot, SECTOR, true); + img[vd + 156..vd + 156 + 34].copy_from_slice(&jroot_rec[..34]); + vd += SECTOR as usize; + } img[vd] = 0xFF; img[vd + 1..vd + 6].copy_from_slice(b"CD001"); @@ -548,9 +721,29 @@ mod tests { 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"); + // 3 probes × (PVD + root dir + subdir) collapse to the 3 distinct + // extents, plus one: the "/a/three" miss falls back to the Joliet + // namespace search (v0.7.5), which reads the terminator sector + // once before concluding there is no SVD. + assert_eq!(counting.calls, 4, "all repeat reads must hit the cache"); + } + + #[test] + fn joliet_fallback_finds_names_missing_from_primary() { + // Windows-mastering worst case: primary tree is bare (./.. only), + // real names live only in the Joliet SVD. The lookup must fall + // back and still resolve nested paths case-insensitively. + let img = TestIsoBuilder::new("APPLIANCE") + .joliet_only(true) + .file("/images/pxeboot/vmlinuz", b"JKERNEL") + .file("/sources/boot.wim", b"JWIM") + .build(); + let mut r = MemReadAt(img); + let loc = block_on(lookup(&mut r, "/images/pxeboot/vmlinuz")).expect("joliet fallback"); + let bytes = block_on(r.read_at(loc.offset, loc.length as u32)).unwrap(); + assert_eq!(&bytes, b"JKERNEL"); + assert!(block_on(lookup(&mut r, "/SOURCES/BOOT.WIM")).is_some()); + assert!(block_on(lookup(&mut r, "/images/pxeboot/missing")).is_none()); } #[test] diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 49d5a6c..38abe09 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -1294,6 +1294,36 @@ ])) : [el('div', {class:'empty'}, 'No unattended files yet.')]; + // v0.7.2: filter for big answer-file libraries; v0.7.5: paged 5 at + // a time, the same filter-then-page view the image table uses. + const UNATT_PAGE_SIZE = 5; + let unattPage = 0; + const unattPagerInfo = el('span', {}); + const unattPrev = el('button', {class:'ghost', onclick: () => { unattPage -= 1; applyUnattListView(); }}, '‹ Prev'); + const unattNext = el('button', {class:'ghost', onclick: () => { unattPage += 1; applyUnattListView(); }}, 'Next ›'); + const unattSearch = el('input', {type:'search', placeholder:'Filter files by name or kind', + spellcheck:'false', oninput: () => { unattPage = 0; applyUnattListView(); }}); + function applyUnattListView() { + if (!unattendedFiles.length) return; // empty-state div carries no dataset + const q = unattSearch.value.trim().toLowerCase(); + const visible = unattRows.filter(r => { + r.style.display = 'none'; + return !q || (r.dataset.search || '').includes(q); + }); + const pages = Math.max(1, Math.ceil(visible.length / UNATT_PAGE_SIZE)); + if (unattPage >= pages) unattPage = pages - 1; + if (unattPage < 0) unattPage = 0; + visible.slice(unattPage * UNATT_PAGE_SIZE, (unattPage + 1) * UNATT_PAGE_SIZE) + .forEach(r => { r.style.display = ''; }); + unattPagerInfo.textContent = visible.length + ? 'Showing ' + (unattPage * UNATT_PAGE_SIZE + 1) + '–' + + Math.min(visible.length, (unattPage + 1) * UNATT_PAGE_SIZE) + ' of ' + visible.length + : 'No files match'; + unattPrev.disabled = unattPage === 0; + unattNext.disabled = unattPage >= pages - 1; + } + applyUnattListView(); + const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [ el('summary', {class:'advanced-summary'}, 'Advanced'), el('div', {class:'card', style:'margin-top:14px'}, [ @@ -1303,18 +1333,15 @@ ]), el('div', {class:'body'}, [ unattDrop, unattFile, unattMsg, - // v0.7.2: filter for big answer-file libraries. - unattendedFiles.length > 1 ? (() => { - const search = el('input', {type:'search', placeholder:'Filter files by name or kind', - spellcheck:'false', oninput: () => { - const q = search.value.trim().toLowerCase(); - unattRows.forEach(r => { - r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none'; - }); - }}); - return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search); - })() : null, + unattendedFiles.length > 1 + ? el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, unattSearch) + : null, el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows), + unattendedFiles.length > UNATT_PAGE_SIZE + ? el('div', {class:'list-pager', style:'padding:12px 0 0'}, [ + unattPagerInfo, el('span', {class:'spacer'}), unattPrev, unattNext, + ]) + : null, el('p', {class:'msg', style:'margin-top:14px'}, 'These answer files drive unattended installs. Attach one to a ' + 'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +