//! iPXE script generator. //! //! ## Menu hierarchy (per Phase 2 spec) //! //! ```text //! Top level: //! Default //! > Boot from Local HDD //! Installers //! > Linux Installers -> submenu of Linux ISOs //! > Windows Installers -> submenu of Windows ISOs (controlled by Settings::windows_enabled) //! Tools //! > Utilities -> memtest, etc. (embedded assets only) //! > OpenPXE Shell -> drop to iPXE shell with branded prompt //! > Network Card Info -> ifstat / config / route dump //! Queued Deployment -> join the deployment queue //! ``` //! //! ## iPXE is entirely backend — users do not see or write iPXE //! //! All user-facing knobs live in `Settings`. Script generation translates //! those knobs into iPXE primitives (chain, menu, item, choose, etc.). //! There is intentionally no UI path to upload a custom `.ipxe` script. use openpxe_core::{Settings, TimeoutAction}; use openpxe_iso_store::introspect::DistroFamily; use openpxe_iso_store::{BootEntry, BootKind, IsoMeta}; use std::fmt::Write as _; /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI /// clients because iPXE normalises the menu primitives across firmwares. /// /// v0.4.6: rendered with an iVentoy-style polished frame — centered /// OpenPXE wordmark banner at the top (ASCII so every iPXE build can /// paint it), a footer carrying version + arch + firmware kind, and an /// optional `console --picture` directive that paints the operator's /// uploaded raster logo on top when the iPXE binary on the wire was /// built with PNG support. The ASCII banner is always rendered so /// even when the picture call no-ops the screen still reads as /// "OpenPXE — here is the menu" rather than a featureless box. #[must_use] pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { let mut s = String::new(); let base = base_url.trim_end_matches('/'); let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000); let default_item = match settings.timeout_action { TimeoutAction::QueuedDeployment => "queue", // Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default // label; the client waits for keypress. We use the same label as // LocalHdd to keep the menu's pre-highlight stable. TimeoutAction::LocalHdd | TimeoutAction::Stay => "local", }; let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "# OpenPXE top-level menu - auto-generated, do not edit"); let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set cls ${{esc:string}}[2J"); // v0.4.6: best-effort graphics console with the operator-uploaded // raster logo. Falls back to plain text console on iPXE builds // without PNG support — the `||` chain keeps a parse-clean // single-statement form so even the strictest iPXE parsers accept // it. The `console` reset at the end re-syncs the menu output. let _ = writeln!( s, "console --picture {base}/branding/pxe-logo || console" ); // Map iPXE's ${{buildarch}} + ${{platform}} into the human form the // user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI"). // iPXE evaluates `iseq` lazily, so we only set whichever line // matches. Anything not on the allowlist falls through to a generic // ` ` display. let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}"); let _ = writeln!( s, "iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true" ); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu"); // Centered ASCII wordmark. iPXE menus are ~76 columns wide on the // default VGA text console; the lines below are padded to sit // approximately centered. `item --gap -- ` emits text without // a selectable hotkey. let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___"); let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|"); let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|"); let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|"); let _ = writeln!(s, "item --gap -- |_|"); let _ = writeln!(s, "item --gap"); let _ = writeln!( s, "item --gap -- ------------------------- Default -------------------------" ); let _ = writeln!(s, "item local Boot from Local HDD"); let _ = writeln!( s, "item --gap -- ----------------------- Installers -----------------------" ); if has_family(isos, is_linux_family) { let _ = writeln!(s, "item linux Linux Installers >"); } else { let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)"); } if settings.windows_enabled && has_family(isos, is_windows_family) { let _ = writeln!(s, "item windows Windows Installers >"); } else if settings.windows_enabled { let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)"); } else { let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)"); } let _ = writeln!( s, "item --gap -- -------------------------- Tools --------------------------" ); let _ = writeln!(s, "item tools Tools >"); let _ = writeln!( s, "item --gap -- ---------------------- Queued Deployment ---------------------" ); let _ = writeln!(s, "item queue Queued Deployment (join queue)"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key x exit Exit iPXE"); // v0.4.6 footer line. Sits just above the `choose` line so it's // always visible regardless of how the menu paginates. iPXE // interpolates `${arch-label}` (set near the top of this script) // and `${version}` is the binary-baked iPXE version — *not* the // OpenPXE version — so we hard-code the OpenPXE version string // here. let openpxe_version = env!("CARGO_PKG_VERSION"); let _ = writeln!(s, "item --gap"); let _ = writeln!( s, "item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}" ); if matches!(settings.timeout_action, TimeoutAction::Stay) { let _ = writeln!(s, "choose --default {default_item} target || goto menu"); } else { let _ = writeln!( s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu" ); } // iPXE's `||` is strict about what follows. Each test uses `goto menu` // as the fallthrough target so the parser never sees a bare `||` with // trailing whitespace — some iPXE builds reject that. let _ = writeln!( s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} exit && exit || goto menu" ); let _ = writeln!(s, "goto menu"); s } /// Per-family submenu (Linux or Windows). Each item shows the ISO size /// in MiB, iVentoy-style (`[ 4376 MB] ubuntu-22.04-desktop-amd64`). #[must_use] pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String { let base = base_url.trim_end_matches('/'); let title = if is_windows { "Windows Installers" } else { "Linux Installers" }; let label = if is_windows { "windows" } else { "linux" }; let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - {title}"); let filter: fn(DistroFamily) -> bool = if is_windows { is_windows_family } else { is_linux_family }; let mut count = 0; for iso in isos { if !filter(iso.introspection.family) { continue; } // v0.4.4: ISOs the operator flipped to the Tools category move // out of the OS installer submenus entirely — they only appear // under Tools. Without this filter the operator would see the // same ISO in both menus. if matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) { continue; } for entry in &iso.boot_entries { let size_label = fmt_size_mib(iso.size_bytes); let key = hotkey_for_index(count); // Visual hint: a leading `*` marks password-protected entries. // ASCII only — iPXE's menu console mangles non-ASCII on some // firmwares. let lock = if iso.is_password_protected() { "*" } else { " " }; let _ = writeln!( s, "item {}{} {}[{:>6}] {}", key, entry.id, lock, size_label, escape_label(&entry.title), ); count += 1; } } if count == 0 { let _ = writeln!(s, "item --gap -- (no {label} images uploaded yet)"); } let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!( s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu" ); // Pass `?mac=${mac}` so the per-entry handler can record the booting // client into the Host log. iPXE substitutes `${mac}` before // the HTTP fetch; if the firmware can't resolve it the literal // `${mac}` is sent and the server treats it as "unknown". let _ = writeln!( s, "chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu" ); s } /// Format a byte count as `NNNN MB` (iVentoy-style — MB not MiB, to match /// operator expectations from the original tool). fn fmt_size_mib(bytes: u64) -> String { let mib = bytes / (1024 * 1024); format!("{mib} MB") } /// Assign `--key N ` hotkeys 1..9, then nothing for positions >=9. /// iPXE's menu needs the --key prefix as a separate token before the id. fn hotkey_for_index(i: usize) -> String { if i < 9 { format!("--key {} ", i + 1) } else { String::new() } } /// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware, /// plus any ISOs the operator flipped to [`IsoCategory::Tools`] in the /// Storage tab. The category-Tools ISOs render first so frequently used /// recovery / hardware tools are reachable with a single number key /// before the built-in shortcuts. #[must_use] pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - Tools"); // Operator-categorized tool ISOs (hotkeys 1..9), each chained the // same way as a per-family menu pick — through the boot-entry id // route, carrying `?mac=${mac}` for Host log attribution. let mut count = 0; for iso in isos { if !matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) { continue; } for entry in &iso.boot_entries { let size_label = fmt_size_mib(iso.size_bytes); let key = hotkey_for_index(count); let lock = if iso.is_password_protected() { "*" } else { " " }; let _ = writeln!( s, "item {}{} {}[{:>6}] {}", key, entry.id, lock, size_label, escape_label(&entry.title), ); count += 1; } } if count > 0 { let _ = writeln!(s, "item --gap"); } let _ = writeln!(s, "item --key u util Utilities (memtest, ...)"); let _ = writeln!(s, "item --key s shell OpenPXE Shell"); let _ = writeln!(s, "item --key n nic Network Card Info"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key r reboot Reboot Computer"); let _ = writeln!(s, "item --key e firmware Exit and continue BIOS boot"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!( s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} reboot && reboot || goto menu" ); let _ = writeln!( s, "iseq ${{target}} firmware && exit 0 || goto menu" ); let _ = writeln!( s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu" ); // Fall-through for category-Tools ISO ids — same as the family // submenu, carrying `?mac=${mac}` for the boot log. let _ = writeln!( s, "chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu" ); s } /// "Boot from Local HDD". On BIOS, we sanboot the first local drive; on /// UEFI we `exit` so the firmware moves to the next boot entry (normally /// the internal disk). #[must_use] pub fn render_local_hdd(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive"); let _ = writeln!( s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || goto uefi" ); let _ = writeln!(s, ":uefi"); let _ = writeln!( s, "# UEFI path: fall through to the firmware's next boot entry" ); let _ = writeln!(s, "exit 0"); let _ = writeln!(s, "# If the above exit returns, loop back to the main menu"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } /// Utilities submenu. For Phase 2 we bundle memtest86+ as an optional /// asset (if absent, the item is listed but errors gracefully). No third- /// party tools are fetched at runtime. #[must_use] pub fn render_util(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - Utilities"); let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item back < Back"); let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!( s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu" ); let _ = writeln!( s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu" ); let _ = writeln!(s, "goto menu"); s } /// "OpenPXE Shell" — iPXE shell, branded. #[must_use] pub fn render_shell(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo OpenPXE Shell"); let _ = writeln!(s, "echo 'exit' returns to the main menu"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "shell"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } /// "Network Card Info" — print ifstat + route + config. #[must_use] pub fn render_nic_info(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo Network Card Info"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "ifstat"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "route"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "echo 'Press any key to return to menu'"); let _ = writeln!(s, "prompt --timeout 30000"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } /// Queued Deployment entry point. Joins the queue, then enters a long-poll /// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a /// real script comes back). #[must_use] pub fn render_queue_entry(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!( s, "# Queued Deployment - join the queue and wait for operator" ); let _ = writeln!(s, "echo Joining deployment queue..."); // imgfetch writes the body to a file in iPXE's transient FS; we read // the queue entry id out of the Location-style header by asking the server // to put it in the response body as a single token. let _ = writeln!(s, "chain --replace {base}/api/queue/join?mac=${{mac}}"); s } /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). #[must_use] pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String { let mut s = String::new(); let base = base_url.trim_end_matches('/'); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "set base-url {base}"); match &entry.kind { BootKind::LinuxKernel { kernel_url, initrd_urls, args, } => { let mut cmdline = args.cmdline.replace("${base-url}", base); if !settings.extra_kernel_args.trim().is_empty() { cmdline.push(' '); cmdline.push_str(settings.extra_kernel_args.trim()); } let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); for u in initrd_urls { let _ = writeln!(s, "initrd {base}/{u}"); } let _ = writeln!(s, "boot || goto failed"); } BootKind::Wimboot { wimboot_url, files } => { let _ = writeln!(s, "kernel {base}/{wimboot_url}"); for (tag, url) in files { let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}"); } let _ = writeln!(s, "boot || goto failed"); } BootKind::SanBootIso { iso_url } => { let _ = writeln!(s, "sanboot --no-describe {base}/{iso_url} || goto failed"); } } let _ = writeln!(s, ":failed"); let _ = writeln!(s, "echo Boot failed - returning to menu in 5s"); let _ = writeln!(s, "sleep 5"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } fn is_linux_family(f: DistroFamily) -> bool { matches!( f, DistroFamily::DebianUbuntu | DistroFamily::RhelFedora | DistroFamily::OpenSuse | DistroFamily::Arch | DistroFamily::Alpine | DistroFamily::Unknown ) } fn is_windows_family(f: DistroFamily) -> bool { matches!(f, DistroFamily::WindowsPe) } fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool { isos.iter().any(|i| pred(i.introspection.family)) } fn escape_label(s: &str) -> String { s.chars() .map(|c| match c { '\n' | '\r' => ' ', c => c, }) .collect() } /// Render the password-prompt script for a protected boot entry. /// /// Flow on the client: /// 1. iPXE clears any leftover ${password}, prints a banner naming the /// ISO so the operator knows what they're being asked for. /// 2. `read --secret password` accepts input without echoing it to /// the screen. /// 3. An empty input bails back to the main menu (lets the operator /// back out of a misclick). /// 4. Otherwise the script chains the same /boot/.ipxe URL but /// with `?token=${password:uristring}`. iPXE's `:uristring` /// modifier URL-encodes the value so `&`, `?`, `=`, spaces, etc. /// survive transport. /// 5. The server replies with either the boot script (correct /// password) or [`render_password_failed`] (wrong password). On /// transport failure we fall back to the main menu. #[must_use] pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let label = escape_label(iso_filename); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "# OpenPXE password prompt for {label}"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo This image requires a password"); let _ = writeln!(s, "echo {label}"); let _ = writeln!(s, "echo (enter alone returns to main menu)"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "set password "); let _ = writeln!(s, "read --secret password"); let _ = writeln!( s, "iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit" ); let _ = writeln!(s, ":submit"); let _ = writeln!(s, "echo Verifying..."); // Carry `mac=${mac}` alongside the token so a successful unlock // records the actual client MAC into the Host log. On // older iPXE that can't resolve `${mac}` the server just stores it // as "unknown" rather than refusing to boot. let _ = writeln!( s, "chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \ || chain {base}/boot.ipxe" ); s } /// Render the "wrong password" script. Tells the operator, sleeps for /// two seconds (gives the eye time to register the message and dampens /// brute-force rate without help from the server), and chains back to /// the same entry — which sends them through the prompt flow again. #[must_use] pub fn render_password_failed(entry_id: &str, base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "echo Wrong password."); let _ = writeln!(s, "sleep 2"); let _ = writeln!( s, "chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe" ); s } #[cfg(test)] mod password_tests { use super::*; #[test] fn prompt_uses_secret_read_and_uri_escape() { let s = render_password_prompt("alpha-linux", "Alpha Test.iso", "http://10.0.0.5"); assert!(s.starts_with("#!ipxe\n")); assert!(s.contains("read --secret password")); assert!(s.contains("Alpha Test.iso")); // URI-string modifier on the var so passwords with `&`/spaces survive. assert!(s.contains("token=${password:uristring}")); // Empty enter sends back to the main menu, not back into the prompt // (avoids a wedged client if the operator chose by mistake). assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit")); // Never log/echo the value. assert!(!s.contains("echo ${password")); } #[test] fn failed_chains_back_to_entry() { let s = render_password_failed("alpha-linux", "http://10.0.0.5"); assert!(s.contains("Wrong password.")); // Re-target the entry so the prompt flow runs again. assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe")); } #[test] fn top_menu_has_polished_branding_and_arch_footer() { // v0.4.6 polish: a `console --picture` line for operator // logos, an ASCII OpenPXE wordmark visible across iPXE // builds (graphics or not), and a single-line footer carrying // the current OpenPXE version + the resolved arch label. let settings = Settings::default(); let s = render_menu(&[], &settings, "http://10.0.0.5"); assert!( s.contains("console --picture http://10.0.0.5/branding/pxe-logo"), "missing console --picture line:\n{s}" ); // Picture-or-text-console must be a single statement so older // iPXE parsers don't choke on the chain. assert!(s.contains("|| console"), "missing graceful fallback:\n{s}"); // ASCII wordmark — at least one of the banner lines must // contain the trailing pipe segment, plus the leading "_"s. assert!( s.contains("___ ___ __ __ ___"), "ascii banner missing first row:\n{s}" ); // Footer with version + arch interpolation. The version comes // from CARGO_PKG_VERSION at compile time. let version = env!("CARGO_PKG_VERSION"); assert!( s.contains(&format!("OpenPXE v{version}")), "footer missing OpenPXE version:\n{s}" ); assert!( s.contains("${arch-label}"), "footer missing arch-label interpolation:\n{s}" ); // No website URL — the design brief calls that out as tacky. assert!( !s.to_ascii_lowercase().contains("openpxe.com"), "footer should not advertise the website:\n{s}" ); // Arch-label mapping covers the three labels from the brief: // "x86 BIOS", "x86_64 UEFI", "arm64 UEFI". assert!(s.contains("x86 BIOS"), "{s}"); assert!(s.contains("x86_64 UEFI"), "{s}"); assert!(s.contains("arm64 UEFI"), "{s}"); } #[test] fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() { let settings = Settings::default(); let scripts = [ render_menu(&[], &settings, "http://10.0.0.5"), render_tools_menu(&[], "http://10.0.0.5"), render_local_hdd("http://10.0.0.5"), render_util("http://10.0.0.5"), render_shell("http://10.0.0.5"), render_nic_info("http://10.0.0.5"), render_queue_entry("http://10.0.0.5"), render_password_failed("alpha-linux", "http://10.0.0.5"), ]; for script in scripts { for line in script.lines() { assert!( !line.trim_end().ends_with("||"), "bare iPXE fallback operator in line: {line}\nscript:\n{script}" ); } } } }