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]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6524aa4118
commit
74c2a51442
Generated
+8
-8
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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<R: IsoReadAt + Send>(
|
||||
..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: IsoReadAt + Send>(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(
|
||||
|
||||
+230
-37
@@ -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<R: IsoReadAt + Send> 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: 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
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
@@ -134,15 +136,78 @@ pub async fn lookup<R: IsoReadAt + Send>(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: 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 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: 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() {
|
||||
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<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;
|
||||
while i < dir.len() {
|
||||
let len = dir[i] as usize;
|
||||
@@ -202,7 +269,7 @@ fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
|
||||
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<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;
|
||||
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<u8> {
|
||||
// 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<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 {
|
||||
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]
|
||||
|
||||
+38
-11
@@ -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 ' +
|
||||
|
||||
Reference in New Issue
Block a user