//! On-disk ISO store with sidecar metadata files. use crate::entry::{BootEntry, BootKind, KernelArgs}; use crate::introspect::{introspect, DistroFamily, IntrospectionReport}; use bytes::Bytes; use openpxe_core::{Error, Result}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use time::OffsetDateTime; use tokio::io::AsyncWriteExt; /// Where the bytes for an ISO actually live. /// /// `Local` — uploaded ISO, sits at `/.iso`. /// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's /// userspace `smbclient` CLI subprocess. No kernel mount, no local /// cache. Sequential whole-file streaming; HTTP Range requests /// return 416. /// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust /// `nfs3_client` crate (in-process, no subprocess). Same "works in /// any container" property as SMB, plus Range requests work because /// NFSv3 READ3 takes an explicit offset. /// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the /// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it /// supports HTTP Range requests because SFTP opens a seekable file /// handle (`SSH_FXP_READ` at offset). #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum IsoSource { #[default] Local, /// v0.4.65: SMB via userspace `smbclient` works in any container. Smb { share_id: String, /// Filename at the share root. We don't support nested paths /// in v0.4.65; ISOs live at the top of the share. relative_path: String, }, /// v0.4.67: NFSv3 via the in-process `nfs3_client` crate. Nfs { share_id: String, /// Filename at the export root. relative_path: String, }, /// v0.5.5: SFTP-over-SSH via the in-process `russh` + `russh-sftp` /// crates. Sftp { share_id: String, /// Filename at the export root. relative_path: String, }, } /// Where the ISO lands in the PXE menu hierarchy. /// /// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI /// behaviour and per-entry boot args, but the *menu placement* is /// operator-controlled — an operator who's uploaded a TinyCore live ISO /// to use as a recovery shim, or a SystemRescue image, can flip its /// category to `Tools` so it lands next to memtest/shell instead of /// under Linux Installers. /// /// Old `meta.json` files without this field deserialize as `Os`, which /// matches v0.4.1 behaviour (everything goes under OS Installers). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IsoCategory { /// "OS Installer" — routed via the auto-detected family into the /// Linux / Windows installer submenus. #[default] Os, /// "Tool" — surfaced under the Tools menu next to memtest, shell, /// NIC info, etc. Family detection still decides BIOS/UEFI vs /// wimboot vs sanboot at boot time. Tools, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IsoMeta { /// Stable slug used in URLs (derived from the uploaded filename). pub id: String, pub filename: String, pub size_bytes: u64, pub sha256_hex: Option, #[serde(with = "time::serde::rfc3339")] pub uploaded_at: OffsetDateTime, pub introspection: IntrospectionReport, /// Boot entries this ISO currently exposes in the PXE menu. Usually one, /// occasionally two (BIOS + UEFI variant for some RHEL ISOs). pub boot_entries: Vec, /// Source of the bytes — local upload (default) or NFS mount. /// Old `meta.json` files without this field deserialize as `Local`. #[serde(default)] pub source: IsoSource, /// Optional bcrypt hash of an operator-set password. When present, /// `/boot/.ipxe` returns a `read --secret` prompt instead of /// the boot script until the client chains back with the correct /// `?token=...`. We never store, log, or transmit the plaintext. /// Skipped on serialize when None to keep meta.json clean for /// the common no-password case. #[serde(default, skip_serializing_if = "Option::is_none")] pub password_hash: Option, /// Where the ISO sits in the PXE menu hierarchy — operator-controlled, /// not driven by family detection. Defaults to [`IsoCategory::Os`]. #[serde(default)] pub category: IsoCategory, } impl IsoMeta { /// Convenience predicate the HTTP layer + UI can both use. #[must_use] pub fn is_password_protected(&self) -> bool { self.password_hash .as_deref() .map(str::trim) .is_some_and(|h| !h.is_empty()) } } pub struct UploadHandle { pub id: String, pub partial_path: PathBuf, final_path: PathBuf, filename: String, hasher: Sha256, bytes_written: u64, file: tokio::fs::File, } impl UploadHandle { pub async fn write_chunk(&mut self, chunk: &Bytes) -> Result<()> { self.file.write_all(chunk).await?; self.hasher.update(chunk); self.bytes_written += chunk.len() as u64; Ok(()) } /// Flush, close, and atomically rename to the final path. Returns the /// final `IsoMeta` including introspection results. pub async fn finish(mut self, store: &IsoStore) -> Result { self.file.flush().await?; self.file.sync_all().await?; drop(self.file); tokio::fs::rename(&self.partial_path, &self.final_path).await?; let hash = hex::encode(self.hasher.finalize()); let introspection = { let p = self.final_path.clone(); tokio::task::spawn_blocking(move || introspect(&p)) .await .map_err(|e| Error::Other(e.into()))? }; let boot_entries = generate_boot_entries(&self.id, &self.filename, &introspection); let meta = IsoMeta { id: self.id.clone(), filename: self.filename, size_bytes: self.bytes_written, sha256_hex: Some(hash), uploaded_at: OffsetDateTime::now_utc(), introspection, boot_entries, source: IsoSource::Local, password_hash: None, category: IsoCategory::default(), }; store.persist_meta(&meta).await?; store.insert(meta.clone()); Ok(meta) } pub async fn abort(self) -> Result<()> { drop(self.file); let _ = tokio::fs::remove_file(&self.partial_path).await; Ok(()) } } #[derive(Debug, Default)] struct Inner { isos: HashMap, } #[derive(Debug, Clone)] pub struct IsoStore { iso_dir: Arc, inner: Arc>, } impl IsoStore { pub fn new(iso_dir: PathBuf) -> Self { Self { iso_dir: Arc::new(iso_dir), inner: Arc::new(RwLock::new(Inner::default())), } } pub async fn ensure_dirs(&self) -> Result<()> { tokio::fs::create_dir_all(self.iso_dir.as_path()).await?; Ok(()) } /// Scan the ISO directory on startup and load any sidecar `.meta.json` /// files. ISOs without metadata are introspected lazily — we don't block /// startup on potentially many GB of scanning. pub async fn load_from_disk(&self) -> Result<()> { self.ensure_dirs().await?; let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?; while let Some(e) = entries.next_entry().await? { let p = e.path(); if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } if !p .file_name() .and_then(|s| s.to_str()) .is_some_and(|n| n.ends_with(".meta.json")) { continue; } if let Ok(text) = tokio::fs::read_to_string(&p).await { if let Ok(mut meta) = serde_json::from_str::(&text) { self.reintrospect_if_stale(&mut meta).await; self.insert(meta); } } } Ok(()) } /// v0.5.9: re-run introspection on a *local* ISO whose persisted report /// predates the current logic. ISOs uploaded by an older binary carry a /// stale family/boot profile — most visibly a Windows 11 ISO tagged /// `Unknown` before the UDF/El-Torito detection landed, which then shows /// as "won't boot" forever. Re-probing on startup fixes them in place, /// no delete-and-re-upload. Bounded: only `Local` sources (we have the /// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at /// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we /// push it onto the blocking pool to keep the async runtime responsive. async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) { if !matches!(meta.source, IsoSource::Local) || meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV { return; } let path = self.iso_path(&meta.id); if !path.exists() { return; } let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else { tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed"); return; }; let before = meta.introspection.family; meta.introspection = fresh; meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection); if let Err(e) = self.persist_meta(meta).await { tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}"); return; } tracing::info!( target: "openpxe::iso", id = %meta.id, from = ?before, to = ?meta.introspection.family, el_torito = meta.introspection.el_torito, "re-introspected stale ISO metadata" ); } fn insert(&self, meta: IsoMeta) { self.inner.write().isos.insert(meta.id.clone(), meta); } async fn persist_meta(&self, meta: &IsoMeta) -> Result<()> { let path = self.meta_path(&meta.id); let text = serde_json::to_string_pretty(meta).map_err(|e| Error::Other(e.into()))?; tokio::fs::write(path, text).await?; Ok(()) } fn meta_path(&self, id: &str) -> PathBuf { self.iso_dir.join(format!("{id}.meta.json")) } fn iso_path(&self, id: &str) -> PathBuf { self.iso_dir.join(format!("{id}.iso")) } pub async fn begin_upload(&self, filename: &str) -> Result { self.ensure_dirs().await?; let id = slugify(filename); let final_path = self.iso_path(&id); if final_path.exists() { return Err(Error::Invalid(format!("iso '{id}' already exists"))); } let partial_path = self.iso_dir.join(format!("{id}.partial")); if partial_path.exists() { return Err(Error::Invalid(format!("iso '{id}' is already uploading"))); } let file = tokio::fs::File::create(&partial_path).await?; Ok(UploadHandle { id, partial_path, final_path, filename: filename.to_string(), hasher: Sha256::new(), bytes_written: 0, file, }) } /// Readiness probe — is the backing directory reachable? Distinct from /// "is there content in it", to avoid an empty store failing health. #[must_use] pub fn list_ok(&self) -> bool { std::fs::read_dir(self.iso_dir.as_path()).is_ok() } #[must_use] pub fn list(&self) -> Vec { let g = self.inner.read(); let mut v: Vec<_> = g.isos.values().cloned().collect(); // Newest-first by upload time. v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at)); v } #[must_use] pub fn get(&self, id: &str) -> Option { self.inner.read().isos.get(id).cloned() } /// Resolve an ISO id to its on-disk path, if any. For local /// (uploaded) ISOs this is `/.iso`. For SMB-sourced /// ISOs there is no on-disk path — the HTTP handler must stream /// via `SmbShareManager::stream_iso` instead. Returns `None` for /// SMB sources or when the file is missing. pub fn iso_path_for(&self, id: &str) -> Option { let meta = self.get(id)?; self.local_path(&meta) } /// Same resolution as [`Self::iso_path_for`], but for a meta the /// caller already holds — skips the second registry lock + deep /// clone, which matters on the per-range-request ISO serving path /// (a sanboot install issues hundreds of those). #[must_use] pub fn local_path(&self, meta: &IsoMeta) -> Option { match &meta.source { IsoSource::Local => { let path = self.iso_path(&meta.id); if path.exists() { Some(path) } else { None } } // SMB, NFS, and SFTP sources have no local path — they're // streamed in-process. Callers must inspect the source // kind first and dispatch to the appropriate share // manager. IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None, } } /// Delete an ISO and its sidecar metadata. Only acts on local /// (uploaded) ISOs; for SMB-backed ISOs the operator must remove /// the file from the share or unregister the share entirely. pub async fn delete(&self, id: &str) -> Result<()> { let meta = self.get(id); let is_local = matches!( meta.as_ref().map(|m| &m.source), Some(IsoSource::Local) | None ); if is_local { let iso = self.iso_path(id); let meta_path = self.meta_path(id); let _ = tokio::fs::remove_file(&iso).await; let _ = tokio::fs::remove_file(&meta_path).await; } self.inner.write().isos.remove(id); Ok(()) } /// Register an externally-sourced ISO (SMB share, etc.). Used by /// `SmbShareManager` after listing a share. We do **not** persist /// a `meta.json` on disk for these — the source of truth is the /// share itself, and the manager re-scans on startup. pub fn register_external( &self, id: String, filename: String, size_bytes: u64, introspection: IntrospectionReport, boot_entries: Vec, source: IsoSource, ) { let meta = IsoMeta { id: id.clone(), filename, size_bytes, sha256_hex: None, uploaded_at: OffsetDateTime::now_utc(), introspection, boot_entries, source, password_hash: None, category: IsoCategory::default(), }; self.inner.write().isos.insert(id, meta); } /// v0.7.4: swap in a completed introspection for an external ISO and /// regenerate its boot entries. Used by the NFS/SFTP managers' /// background probe pass — the scan registers a provisional /// (filename-only) report immediately so startup and share-add stay /// fast, then this upgrades each entry as its probe finishes. /// Operator-set fields (category, password) are preserved; returns /// `false` when the id is gone (share removed or re-scanned away /// mid-probe), which callers treat as a benign no-op. pub fn update_external_introspection( &self, id: &str, introspection: IntrospectionReport, ) -> bool { let mut g = self.inner.write(); let Some(m) = g.isos.get_mut(id) else { return false; }; m.boot_entries = generate_boot_entries(&m.id, &m.filename, &introspection); m.introspection = introspection; true } /// Drop every entry that belongs to `share_id`. Used by the SMB /// and NFS share managers when an operator removes a share, or /// before re-scanning to clean out stale entries. The same id /// space serves both protocols — share ids are slugified from /// `server+share` (SMB) or `server+export` (NFS) and the /// protocol-specific prefix prevents collisions. pub fn drop_external_source(&self, share_id: &str) { let mut g = self.inner.write(); g.isos.retain(|_, m| match &m.source { IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } | IsoSource::Sftp { share_id: sid, .. } => sid != share_id, IsoSource::Local => true, }); } /// Set or clear an ISO's boot password. /// /// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for /// an interactive iPXE prompt, slow enough to be hostile to brute /// force on a leaked meta.json) and persists. /// /// `None` removes the password — the next /boot/.ipxe request /// returns the script directly without a prompt. /// /// We never store, log, or transmit the plaintext. pub async fn set_password(&self, id: &str, password: Option<&str>) -> Result<()> { let new_hash = match password { None => None, Some(pw) => { let pw = pw.trim(); if pw.is_empty() { None } else { let h = bcrypt::hash(pw, bcrypt::DEFAULT_COST) .map_err(|e| Error::Other(e.into()))?; Some(h) } } }; // Update in-memory + grab a clone for persistence outside the lock. let updated = { let mut g = self.inner.write(); let m = g .isos .get_mut(id) .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?; m.password_hash = new_hash; m.clone() }; // NFS-sourced ISOs have no on-disk meta.json — skip persistence // for them (the password lives in memory until the manager // re-scans the share, then it's gone). Document this in the API // handler so the operator knows. if matches!(updated.source, IsoSource::Local) { self.persist_meta(&updated).await?; } Ok(()) } /// Flip an ISO's menu category. Persists to `meta.json` for local /// ISOs; NFS-sourced ISOs keep the change in memory only (the next /// re-scan would overwrite it anyway). pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result { let updated = { let mut g = self.inner.write(); let m = g .isos .get_mut(id) .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?; m.category = category; m.clone() }; if matches!(updated.source, IsoSource::Local) { self.persist_meta(&updated).await?; } Ok(updated) } /// Absolute path to the directory holding local ISO uploads. Used /// by the HTTP layer for the disk-space endpoint — the volume that /// hosts this directory is what runs out of room first. #[must_use] pub fn iso_dir(&self) -> PathBuf { self.iso_dir.as_path().to_path_buf() } /// `(total_bytes, available_bytes)` for the filesystem hosting the /// ISO directory. Returns `None` if `statvfs` fails (read-only /// filesystem with no quota, mount disappeared, …) — callers /// should treat that as "unknown" rather than zero. /// /// Lives here rather than the HTTP crate because `http-api`'s /// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and /// because this is naturally an `IsoStore` question — the volume /// of interest is whatever's hosting the iso dir. #[must_use] pub fn disk_usage(&self) -> Option<(u64, u64)> { disk_usage_for(self.iso_dir.as_path()) } /// Verify a candidate password against the stored bcrypt hash. /// Returns: /// - `Ok(true)` — match (or the ISO has no password set; boot is open) /// - `Ok(false)` — mismatch /// - `Err(_)` — id not found, or bcrypt error pub fn verify_password(&self, id: &str, candidate: &str) -> Result { let meta = self .get(id) .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?; let Some(hash) = meta.password_hash else { return Ok(true); // no password set — anyone can boot }; bcrypt::verify(candidate, &hash).map_err(|e| Error::Other(e.into())) } } /// Resolve `(total, available)` bytes for the filesystem hosting `path`. /// Returns `None` if `statvfs` fails. #[allow(unsafe_code)] fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; let c = CString::new(path.as_os_str().as_bytes()).ok()?; // SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial // state per POSIX. The FFI call writes every field we then read. let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; // SAFETY: `c` is a NUL-terminated C string pointing into a stack // CString that outlives this call; `&mut stat` is a unique aligned // pointer to a stack-local `statvfs`. The kernel writes through // it but does not retain the pointer past return. let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) }; if rc != 0 { return None; } // Use f_frsize (fundamental block size). f_bsize is "preferred I/O // block" and doesn't always match the unit f_blocks is denominated // in — on some BSDs it would over-report by a factor of 8. let frsize = stat.f_frsize as u64; let total = stat.f_blocks as u64 * frsize; let avail = stat.f_bavail as u64 * frsize; Some((total, avail)) } fn slugify(filename: &str) -> String { let stem = Path::new(filename) .file_stem() .and_then(|s| s.to_str()) .unwrap_or("iso"); slugify_str(stem) } /// Slugify an arbitrary string to lowercase ASCII alphanumerics, hyphens, /// and underscores. Public so the NFS manager can mint ids that follow the /// same rules as upload-time ISO ids. #[must_use] pub fn slugify_str(input: &str) -> String { input .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' } }) .collect::() .trim_matches('-') .to_string() } /// Public wrapper around [`generate_boot_entries`] so the NFS manager can /// build entries for shares it just scanned, using the same logic as the /// upload pipeline. Re-exported via the crate root. #[must_use] pub fn generate_boot_entries_for( id: &str, filename: &str, r: &IntrospectionReport, ) -> Vec { generate_boot_entries(id, filename, r) } /// Build `BootEntry`s from the introspection report. URLs are relative — /// the HTTP layer rewrites them with the public base URL per request. fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec { let title = r .volume_label .clone() .unwrap_or_else(|| filename.to_string()); match r.family { DistroFamily::WindowsPe => { // v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE // exposes the raw ISO as an emulated CD backed by on-demand // HTTP range reads, and Windows Setup boots from it. This // replaces the old wimboot+SMB chain, which (a) needed an SMB // server the host often can't provide (port 445 collisions), // (b) served in-ISO files via an ISO9660 lookup that failed on // UDF-only Windows 11 ISOs, and (c) required an operator // toggle. sanboot needs none of that — just the HTTP port, // which works in any environment. The unmodified, stock ISO is // served at iso/.iso; nothing is injected into Windows. vec![BootEntry { id: format!("{id}-windows"), title: format!("{title} (Windows)"), kind: BootKind::SanBootIso { iso_url: format!("iso/{id}.iso"), }, }] } fam if r.kernel_path.is_some() => { let base = format!("iso/{id}"); let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or("")); let initrd_urls = r .initrd_paths .iter() .map(|p| format!("{base}{p}")) .collect(); let args = KernelArgs { cmdline: linux_cmdline(fam, id), }; vec![BootEntry { id: format!("{id}-linux"), title, kind: BootKind::LinuxKernel { kernel_url, initrd_urls, args, }, }] } _ => { // No Windows-install media and no Linux kernel/initrd. Decide // whether the ISO is bootable at all (v0.6.0): // * `el_torito` — it carries a boot catalog, so iPXE sanboots // the raw image as an emulated CD: BSDs, ESXi/VMvisor // installers, firmware tools, custom spins. The emulated CD // is backed by HTTP range reads, so ISO size is a non-issue // (this is the same path Windows uses since v0.5.8) — hence // no more "may fail for >1GiB ISOs" disclaimer. // * `introspect_rev == 0` — a remote-share ISO we couldn't // introspect (SMB/NFS/SFTP listings don't seek into the ISO). // Offer sanboot optimistically rather than hide a // likely-bootable installer. // Otherwise it's a local image we *did* introspect and found to // carry no boot catalog — a data/appliance ISO (e.g. a VMware // vCenter Server Appliance bundle). It genuinely cannot boot, so // we expose no menu entry; the dashboard flags it instead. if r.el_torito || r.introspect_rev == 0 { vec![BootEntry { id: format!("{id}-sanboot"), title, kind: BootKind::SanBootIso { iso_url: format!("iso/{id}.iso"), }, }] } else { Vec::new() } } } } fn linux_cmdline(family: DistroFamily, id: &str) -> String { // The HTTP layer resolves `${base-url}` at render time. let iso_url = format!("${{base-url}}/iso/{id}.iso"); match family { // VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper // patch): drop `netboot=url url=… ---` in favour of the // canonical Casper option `iso-url=` and add `ds=nocloud` so // cloud-init / subiquity (live-server) doesn't stall waiting on // a metadata datasource that doesn't exist in PXE. Without // `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary // ISOs would boot fine on bare-metal UEFI but hang at "cloud-init // running" on VMware-UEFI guests because the vmxnet3 driver's // late-init upsets cloud-init's network probe. DistroFamily::DebianUbuntu => format!( "boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}" ), DistroFamily::RhelFedora => format!( "inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp" ), DistroFamily::OpenSuse => format!( "install={iso_url} netsetup=dhcp" ), DistroFamily::Arch => { "archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string() } DistroFamily::Alpine => format!( "alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp" ), _ => "ip=dhcp".into(), } } #[cfg(test)] mod tests { use super::*; use crate::introspect::{DistroFamily, IntrospectionReport}; use tempfile::tempdir; #[test] fn slugify_basic() { // Upload filenames come from multipart parts (no path components); // file_stem drops the extension, then non-alphanumerics become `-`. assert_eq!(slugify("Ubuntu 24.04 Desktop.iso"), "ubuntu-24-04-desktop"); assert_eq!(slugify("Rocky-9.4-x86_64-dvd.iso"), "rocky-9-4-x86_64-dvd"); assert_eq!(slugify("arch.iso"), "arch"); // If a path sneaks in, file_stem strips the directory — OK, not a hazard. assert_eq!(slugify("/etc/passwd"), "passwd"); } #[test] fn casper_cmdline_vmware_uefi_safe() { // v0.4.5 regression guard: the Debian/Ubuntu cmdline must use // the canonical Casper `iso-url=` option and include // `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init // running" waiting on a metadata datasource that PXE can't // provide. The legacy `netboot=url url=… ---` form is gone for // good. let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04"); assert!(s.contains("boot=casper"), "{s}"); assert!( s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}" ); assert!(s.contains("ds=nocloud"), "{s}"); assert!(s.contains("ip=dhcp"), "{s}"); assert!(!s.contains("netboot=url"), "legacy option leaked: {s}"); assert!(!s.contains(" --- "), "stray ---: {s}"); } #[test] fn boot_entries_respect_el_torito_and_source() { use crate::introspect::INTROSPECT_REV; // ESXi / VMvisor installer shape: bootable (carries an El Torito // catalog) but not classifiable as Windows or Linux. Must yield a // single sanboot entry so it's selectable + boots via emulated CD. let esxi = IntrospectionReport { family: DistroFamily::Unknown, volume_label: Some("ESXI-7.0U3".into()), el_torito: true, introspect_rev: INTROSPECT_REV, ..Default::default() }; let e = generate_boot_entries("esxi", "VMware-VMvisor-Installer-7.0U3n.iso", &esxi); assert_eq!(e.len(), 1, "ESXi should get exactly one boot entry"); assert!(matches!(e[0].kind, BootKind::SanBootIso { .. })); // Clean title — no stale ">1GiB may fail" disclaimer. assert!(!e[0].title.contains("may fail"), "title: {}", e[0].title); // VCSA / data-appliance shape: locally introspected (rev set), no // boot catalog, not Windows/Linux. Genuinely unbootable → no entry, // so it stays out of the iPXE menu (the dashboard flags it instead). let vcsa = IntrospectionReport { family: DistroFamily::Unknown, el_torito: false, introspect_rev: INTROSPECT_REV, ..Default::default() }; assert!( generate_boot_entries("vcsa", "VMware-VCSA-all-8.0.iso", &vcsa).is_empty(), "data/appliance ISO must produce no boot entry" ); // Remote-share ISO: never introspected (rev 0, no random access over // SMB/NFS/SFTP). Assume bootable and offer sanboot rather than hide a // likely-bootable installer. let remote = IntrospectionReport::default(); let r = generate_boot_entries("remote", "unknown-remote.iso", &remote); assert_eq!(r.len(), 1, "remote (uninspected) ISO keeps a sanboot entry"); assert!(matches!(r[0].kind, BootKind::SanBootIso { .. })); } fn fake_meta(id: &str) -> IsoMeta { IsoMeta { id: id.into(), filename: format!("{id}.iso"), size_bytes: 0, sha256_hex: None, uploaded_at: OffsetDateTime::now_utc(), introspection: IntrospectionReport::default(), boot_entries: vec![], source: IsoSource::Local, password_hash: None, category: IsoCategory::default(), } } #[tokio::test] async fn password_round_trip_set_verify_clear() { let dir = tempdir().unwrap(); let store = IsoStore::new(dir.path().to_path_buf()); store.ensure_dirs().await.unwrap(); store .inner .write() .isos .insert("alpha".into(), fake_meta("alpha")); // No password set — verify_password returns Ok(true) for any input. assert!(store.verify_password("alpha", "anything").unwrap()); assert!(!store.get("alpha").unwrap().is_password_protected()); // Set a password. store.set_password("alpha", Some("hunter2")).await.unwrap(); let m = store.get("alpha").unwrap(); assert!(m.is_password_protected()); assert!(m.password_hash.unwrap().starts_with("$2")); // Verify correct + wrong. assert!(store.verify_password("alpha", "hunter2").unwrap()); assert!(!store.verify_password("alpha", "wrong").unwrap()); assert!(!store.verify_password("alpha", "").unwrap()); // Clear by passing None or an empty string. store.set_password("alpha", None).await.unwrap(); assert!(!store.get("alpha").unwrap().is_password_protected()); store.set_password("alpha", Some("again")).await.unwrap(); store.set_password("alpha", Some(" ")).await.unwrap(); assert!(!store.get("alpha").unwrap().is_password_protected()); } #[tokio::test] async fn set_password_for_unknown_id_errors() { let dir = tempdir().unwrap(); let store = IsoStore::new(dir.path().to_path_buf()); store.ensure_dirs().await.unwrap(); let r = store.set_password("does-not-exist", Some("pw")).await; assert!(matches!(r, Err(Error::Invalid(_)))); } #[tokio::test] async fn begin_upload_rejects_existing_partial_file() { let dir = tempdir().unwrap(); let store = IsoStore::new(dir.path().to_path_buf()); store.ensure_dirs().await.unwrap(); tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight") .await .unwrap(); let r = store.begin_upload("ubuntu.iso").await; assert!(matches!(r, Err(Error::Invalid(_)))); } #[tokio::test] async fn password_persists_via_meta_json_for_local_isos() { // Hash makes it onto disk so it survives a restart. let dir = tempdir().unwrap(); let store = IsoStore::new(dir.path().to_path_buf()); store.ensure_dirs().await.unwrap(); let meta = fake_meta("alpha"); store.persist_meta(&meta).await.unwrap(); store.insert(meta); store.set_password("alpha", Some("s3cret")).await.unwrap(); // Re-load from disk and confirm the hash came back. let store2 = IsoStore::new(dir.path().to_path_buf()); store2.load_from_disk().await.unwrap(); let reloaded = store2.get("alpha").expect("reloaded"); assert!(reloaded.is_password_protected()); assert!(store2.verify_password("alpha", "s3cret").unwrap()); assert!(!store2.verify_password("alpha", "wrong").unwrap()); } }