Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ded291c7b |
Generated
+8
-8
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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 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() {
|
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
@@ -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 ' +
|
||||||
|
|||||||
Reference in New Issue
Block a user