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:
Miles Ward
2026-06-12 17:04:01 -04:00
co-authored by Claude Opus 4.8
parent 6524aa4118
commit 1ded291c7b
5 changed files with 360 additions and 87 deletions
+38 -11
View File
@@ -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 ' +