Compare commits

..
1 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 1ded291c7b v0.7.5: Joliet namespace fallback, gap-tolerant El Torito walk, Unattended pagination
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) <[email protected]>
2026-06-12 17:04:01 -04:00
5 changed files with 360 additions and 87 deletions
Generated
+8 -8
View File
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.7.4" version = "0.7.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2858,7 +2858,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.7.4" version = "0.7.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -2885,7 +2885,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.7.4" version = "0.7.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2902,7 +2902,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.7.4" version = "0.7.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2938,7 +2938,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.7.4" version = "0.7.5"
dependencies = [ dependencies = [
"openpxe-core", "openpxe-core",
"rust-embed", "rust-embed",
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-iso-store" name = "openpxe-iso-store"
version = "0.7.4" version = "0.7.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.7.4" version = "0.7.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2991,7 +2991,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.7.4" version = "0.7.5"
[[package]] [[package]]
name = "p256" name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.7.4" version = "0.7.5"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+83 -30
View File
@@ -56,7 +56,11 @@ pub enum DistroFamily {
/// kernel/initrd paths exist before emitting them, and adds the Debian /// kernel/initrd paths exist before emitting them, and adds the Debian
/// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection /// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection
/// caches key off this rev too, so the cache self-invalidates. /// 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntrospectionReport { pub struct IntrospectionReport {
@@ -210,25 +214,28 @@ pub async fn introspect_reader<R: IsoReadAt + Send>(
..Default::default() ..Default::default()
}; };
// ISO9660 Primary Volume Descriptor at LBA 16. Bytes 40..72 are the // Everything sector-shaped goes through one caching wrapper: the
// volume identifier (space-padded). // descriptor-set sectors are read once and shared between the label
if let Ok(pvd) = r.read_at(16 * SECTOR, 2048).await { // scan, the El Torito walk, and the namespace-root lookups; the
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" { // 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(); let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string();
if !label.is_empty() { if !label.is_empty() {
report.family = family_from_label(&label); report.family = family_from_label(&label);
report.volume_label = Some(label); report.volume_label = Some(label);
} }
} }
}
report.el_torito = detect_el_torito(r).await; report.el_torito = detect_el_torito(&mut cr).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);
if iso_fs::exists(&mut cr, "/sources/boot.wim").await { if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
report.has_boot_wim = true; 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`). /// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
/// ///
/// The ISO9660 Volume Descriptor Set starts at LBA 16 and runs one /// The ISO9660 Volume Descriptor Set starts at LBA 16 and runs one
/// 2048-byte descriptor per sector until a Set Terminator (type 0xFF). /// 2048-byte descriptor per sector; a Boot Record descriptor (type 0x00)
/// A Boot Record descriptor (type 0x00) whose 32-byte boot system /// whose 32-byte boot system identifier reads "EL TORITO SPECIFICATION"
/// identifier reads "EL TORITO SPECIFICATION" means the image declares a /// means the image declares a boot catalog. We only confirm its presence
/// boot catalog. We only confirm its presence — we don't parse the /// — we don't parse the catalog (sanboot/the firmware does that).
/// 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. /// 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: IsoReadAt + Send>(r: &mut R) -> bool { async fn detect_el_torito<R: IsoReadAt + Send>(r: &mut R) -> bool {
for lba in 16u64..32 { for lba in 16u64..32 {
let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else { let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else {
// Past end of a tiny image — nothing more to examine.
return false; 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" { if &vd[1..6] != b"CD001" {
return false; continue; // filler/garbage sector — keep walking
} }
match vd[0] { if vd[0] == 0x00 && vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID {
// Boot Record descriptor carrying the El Torito signature. return true;
0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true,
// Volume Descriptor Set Terminator — nothing bootable found.
0xFF => return false,
// Any other descriptor (incl. a non-El-Torito boot record) —
// keep walking the set.
_ => {}
} }
} }
false 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] #[test]
fn filename_hint_catches_windows_isos() { fn filename_hint_catches_windows_isos() {
assert!(filename_looks_windows( assert!(filename_looks_windows(
+230 -37
View File
@@ -14,11 +14,13 @@
//! SFTP seek-read connection, which is what finally classifies //! SFTP seek-read connection, which is what finally classifies
//! share-sourced ISOs instead of registering them all as `Unknown`. //! share-sourced ISOs instead of registering them all as `Unknown`.
//! //!
//! We parse only the Primary Volume Descriptor namespace. Joliet and Rock //! Namespaces: the primary ISO9660 tree is tried first; on a miss the
//! Ridge are deliberately ignored — matching is case-insensitive against //! walk falls back to the **Joliet** supplementary namespace (v0.7.5) —
//! plain ISO9660 identifiers (`;1` version suffix and the trailing dot of //! Windows-oriented mastering tools often write a minimal/mangled
//! extension-less strict-mastered names stripped), which is how the local //! primary tree with the real names only in Joliet. Rock Ridge stays
//! serving path has always behaved in production. //! 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::collections::HashMap;
use std::future::Future; use std::future::Future;
@@ -118,14 +120,14 @@ impl<R: IsoReadAt + Send> IsoReadAt for CachingReadAt<'_, R> {
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in /// Look up `in_iso_path` (leading slash optional, case-insensitive) in
/// the image behind `r`. Returns `None` on any parsing or IO failure — /// 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. /// "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: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> { pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> {
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 let components: Vec<&str> = in_iso_path
.trim_start_matches('/') .trim_start_matches('/')
.split('/') .split('/')
@@ -134,15 +136,78 @@ pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option
if components.is_empty() { if components.is_empty() {
return None; 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 /// Find the namespace root: the Primary Volume Descriptor (`joliet =
// stays a plain (non-boxed) state machine. /// 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: IsoReadAt + Send>(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: IsoReadAt + Send>(
r: &mut R,
joliet: bool,
) -> Option<Vec<u8>> {
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 13).
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: IsoReadAt + Send>(
r: &mut R,
root: (u64, u64),
components: &[&str],
joliet: bool,
) -> Option<FileLocation> {
let (mut lba, mut len) = root;
for (idx, comp) in components.iter().enumerate() { for (idx, comp) in components.iter().enumerate() {
if len == 0 || len > MAX_DIR_BYTES { if len == 0 || len > MAX_DIR_BYTES {
return None; return None;
} }
let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?; 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(); let last = idx + 1 == components.len();
match (last, hit.is_dir) { match (last, hit.is_dir) {
(true, false) => { (true, false) => {
@@ -184,7 +249,9 @@ struct DirHit {
/// Scan one directory extent for an identifier. Pure function over the /// Scan one directory extent for an identifier. Pure function over the
/// buffered extent — all protocol/IO concerns live in the caller. /// buffered extent — all protocol/IO concerns live in the caller.
fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> { /// `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<DirHit> {
let mut i = 0; let mut i = 0;
while i < dir.len() { while i < dir.len() {
let len = dir[i] as usize; let len = dir[i] as usize;
@@ -202,7 +269,7 @@ fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
break; break;
} }
let rec = &dir[i..i + len]; 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; let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries. // Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = 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 /// quirks: the `;N` version suffix and the trailing dot that strict
/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the /// mastering appends to extension-less names (`VMLINUZ.;1`). Without the
/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`. /// 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<u16> = 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; let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len { if name_len == 0 || rec.len() < 33 + name_len {
return String::new(); return String::new();
@@ -271,6 +358,7 @@ pub mod testiso {
root: Node, root: Node,
volume_label: String, volume_label: String,
el_torito: bool, el_torito: bool,
joliet_only: bool,
} }
impl TestIsoBuilder { impl TestIsoBuilder {
@@ -279,6 +367,7 @@ pub mod testiso {
root: Node::default(), root: Node::default(),
volume_label: volume_label.to_string(), volume_label: volume_label.to_string(),
el_torito: false, el_torito: false,
joliet_only: false,
} }
} }
@@ -288,6 +377,16 @@ pub mod testiso {
self 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`. /// Add a file at `path` (e.g. "/casper/vmlinuz") with `content`.
#[must_use] #[must_use]
pub fn file(mut self, path: &str, content: &[u8]) -> Self { pub fn file(mut self, path: &str, content: &[u8]) -> Self {
@@ -307,8 +406,10 @@ pub mod testiso {
} }
pub fn build(self) -> Vec<u8> { pub fn build(self) -> Vec<u8> {
// Pass 1: allocate extents. Directories first (1 sector each), // Pass 1: allocate extents. Primary directories first (1
// then file contents. // 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 next_lba: u64 = 20;
let mut dirs: Vec<(*const Node, u64)> = Vec::new(); let mut dirs: Vec<(*const Node, u64)> = Vec::new();
fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) { fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) {
@@ -327,6 +428,17 @@ pub mod testiso {
.map(|(_, l)| *l) .map(|(_, l)| *l)
.expect("dir allocated") .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(); 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)>) { fn alloc_files(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64, usize)>) {
for child in n.children.values() { for child in n.children.values() {
@@ -371,14 +483,19 @@ pub mod testiso {
r 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( fn write_dir(
img: &mut [u8], img: &mut [u8],
n: &Node, n: &Node,
self_lba: u64, self_lba: u64,
parent_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, file_lba_of: &dyn Fn(&Node) -> u64,
joliet: bool,
skip_children: bool,
) { ) {
let base = self_lba as usize * SECTOR as usize; let base = self_lba as usize * SECTOR as usize;
let mut off = 0usize; let mut off = 0usize;
@@ -388,32 +505,61 @@ pub mod testiso {
}; };
put(record(&[0x00], self_lba, SECTOR, true), &mut off); put(record(&[0x00], self_lba, SECTOR, true), &mut off);
put(record(&[0x01], parent_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<u8> {
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 { for (name, child) in &n.children {
if let Some(c) = &child.content { 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( 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, &mut off,
); );
} else { } else {
let stored = name.to_ascii_uppercase();
put( put(
record(stored.as_bytes(), lba_of(child), SECTOR, true), record(&encode(name, false), dir_lba_of(child), SECTOR, true),
&mut off, &mut off,
); );
} }
} }
for (name, child) in &n.children { for child in n.children.values() {
if child.content.is_none() { 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 { } else if let Some(c) = &child.content {
let b = file_lba_of(child) as usize * SECTOR as usize; let b = file_lba_of(child) as usize * SECTOR as usize;
img[b..b + c.len()].copy_from_slice(c); img[b..b + c.len()].copy_from_slice(c);
} }
let _ = name;
} }
} }
let root_lba = lba_of(&self.root); let root_lba = lba_of(&self.root);
@@ -424,8 +570,25 @@ pub mod testiso {
root_lba, root_lba,
&lba_of, &lba_of,
&file_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. // PVD @ 16.
let pvd = 16 * SECTOR as usize; let pvd = 16 * SECTOR as usize;
img[pvd] = 0x01; img[pvd] = 0x01;
@@ -437,7 +600,8 @@ pub mod testiso {
let root_rec = record(&[0x00], root_lba, SECTOR, true); let root_rec = record(&[0x00], root_lba, SECTOR, true);
img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]); 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; let mut vd = 17 * SECTOR as usize;
if self.el_torito { if self.el_torito {
img[vd] = 0x00; img[vd] = 0x00;
@@ -446,6 +610,15 @@ pub mod testiso {
img[vd + 7..vd + 7 + id.len()].copy_from_slice(id); img[vd + 7..vd + 7 + id.len()].copy_from_slice(id);
vd += SECTOR as usize; 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] = 0xFF;
img[vd + 1..vd + 6].copy_from_slice(b"CD001"); 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/two")));
assert!(!block_on(exists(&mut cr, "/a/three"))); assert!(!block_on(exists(&mut cr, "/a/three")));
drop(cr); drop(cr);
// 3 probes × (PVD + root dir + subdir) = 9 uncached; the cache // 3 probes × (PVD + root dir + subdir) collapse to the 3 distinct
// collapses the repeats to the 3 distinct extents. // extents, plus one: the "/a/three" miss falls back to the Joliet
assert_eq!(counting.calls, 3, "all repeat reads must hit the cache"); // 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] #[test]
+38 -11
View File
@@ -1294,6 +1294,36 @@
])) ]))
: [el('div', {class:'empty'}, 'No unattended files yet.')]; : [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'}, [ const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
el('summary', {class:'advanced-summary'}, 'Advanced'), el('summary', {class:'advanced-summary'}, 'Advanced'),
el('div', {class:'card', style:'margin-top:14px'}, [ el('div', {class:'card', style:'margin-top:14px'}, [
@@ -1303,18 +1333,15 @@
]), ]),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg, unattDrop, unattFile, unattMsg,
// v0.7.2: filter for big answer-file libraries. unattendedFiles.length > 1
unattendedFiles.length > 1 ? (() => { ? el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, unattSearch)
const search = el('input', {type:'search', placeholder:'Filter files by name or kind', : null,
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,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows), 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'}, el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' + 'These answer files drive unattended installs. Attach one to a ' +
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' + 'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +