v0.8.2: fix iPXE menu item selection (issue #2)

In 0.8.1 on x86_64 UEFI (snponly.efi) the boot menu drew and the keyboard
worked, but every item except the first one bounced straight back to the
menu with the default Queued Deployment entry highlighted; the `x` Exit
item did the same. Root cause was the generated dispatch lines:

  iseq ${target} linux && chain .../boot/_linux_menu.ipxe || goto menu

iPXE evaluates `&&`/`||` strictly left to right (src/core/exec.c
`system()`), so for `A && B || C` the `goto menu` runs whenever A fails --
for every item except the first. Only `local` ever reached its chain line,
which is exactly the reported symptom. The same shape made `x` useless,
and `goto menu` redraws with `--default queue`. The bug report's Ctrl-B
test (chaining an entry script by hand) booted fine, confirming the
scripts and HTTP/TFTP paths were healthy.

Fix, all in crates/http-api/src/ipxe_script.rs:

- Dispatch with `goto ${target} || goto menu` to `:label` blocks (the
  canonical iPXE menu pattern) in the top menu, both installer submenus,
  the tools menu and the utilities menu, so a non-matching item can never
  fall through into a stray `goto menu`.
- Menu-to-menu transitions use `chain --replace`, so the previous menu
  image is replaced: `exit` from any submenu reaches the firmware and
  repeated Back/forward navigation no longer nests script images.
- Boot-entry scripts now recover to the menu themselves (kernel/initrd
  fetches `|| goto failed`, `:failed` chain --replace boot.ipxe), since
  with --replace the calling menu can no longer catch their errors.
- Arch-label mapping: three `|| echo` probes instead of one chain ending
  in `|| true` (`true` is not an iPXE command; non-allowlisted pairs such
  as i386+efi aborted the script before the menu was drawn).
- Utilities menu Back really returns to Tools (it used to redraw
  Utilities); `prompt --timeout` expiry no longer aborts NIC info;
  password prompt tests emptiness with `isset` instead of a literal `""`
  token (iPXE has no quoting, so an empty Enter skipped the main-menu
  path); queue entry view falls back to the main menu if the queue
  endpoint is unreachable.

Tests: new `menu_dispatch_tests` interpreter that mirrors iPXE's
`system()`/`process_script()` (whitespace tokenisation, left-associative
`&&`/`||`, goto labels, abort on nonzero line) and asserts each top-menu
and submenu item reaches its expected chain URL (the old lines would
fail these), plus exit/reboot/firmware, unknown-architecture, password
prompt, shell and queue flows. Text assertions, the insta snapshot and the
tools-menu wiring assertion in tests/full_flow.rs updated to match.
`cargo fmt --check` clean, no new clippy warnings, `cargo test
--workspace` green (all crates).
This commit is contained in:
Miles Ward committed 2026-10-18 06:36:23 -04:00
1 parent eef317fb3a
commit e80a66b0da
5 files changed
+640 -91

No files matched your search

Generated
+8 -8
View File
@@ -2758,7 +2758,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openpxe"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"axum",
@@ -2778,7 +2778,7 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"base64",
@@ -2803,7 +2803,7 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"dhcproto",
@@ -2818,7 +2818,7 @@ dependencies = [
[[package]]
name = "openpxe-http-api"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"axum",
@@ -2852,7 +2852,7 @@ dependencies = [
[[package]]
name = "openpxe-ipxe-assets"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"openpxe-core",
"rust-embed",
@@ -2861,7 +2861,7 @@ dependencies = [
[[package]]
name = "openpxe-iso-store"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"bcrypt",
@@ -2887,7 +2887,7 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"openpxe-core",
@@ -2899,7 +2899,7 @@ dependencies = [
[[package]]
name = "openpxe-webui"
version = "0.8.1"
version = "0.8.2"
[[package]]
name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.8.1"
version = "0.8.2"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
+612 -72
View File
@@ -85,10 +85,25 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
// iPXE evaluates `iseq` lazily, so we only set whichever line
// matches. Anything not on the allowlist falls through to a generic
// `<buildarch> <platform>` display.
//
// v0.8.2: one probe per line, each terminated with `|| echo` (`echo`
// is the harmless rc-0 no-op; there is no `true` command in iPXE).
// The previous single line tried to chain the probes with `||` and
// ended in `|| true`, which is not an iPXE command: on any
// arch/platform pair outside the allowlist (e.g. i386 + efi) the
// command failed and aborted the script before the menu was drawn.
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"
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || echo"
);
let _ = writeln!(
s,
"iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || echo"
);
let _ = writeln!(
s,
"iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || echo"
);
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu");
@@ -151,34 +166,44 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
);
}
// 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.
// v0.8.2: dispatch via `goto <item-value>` labels. This is the
// canonical iPXE menu pattern (ipxe.org examples) and, unlike
// `iseq ... && chain ... || goto menu`, it is not sensitive to the
// left-associative `&&`/`||` evaluation order that broke item
// selection in issue #2: for `A && B || C` iPXE runs C whenever A
// fails, so every item except the first used to skip its chain and
// bounce straight back to `goto menu` — redrawing the menu with the
// default (Queued Deployment) item highlighted, as if Enter did
// nothing.
//
// Menu-to-menu transitions use `chain --replace`: the replacement
// image becomes the top-most image, so `exit` from any submenu
// reaches the firmware and repeated Back/forward navigation does not
// nest script images (iPXE tail-recurses into replacements, so the
// stack stays flat). A failed fetch still returns an error, which
// the trailing `|| goto menu` turns into a menu redraw.
let _ = writeln!(s, "goto ${{target}} || goto menu");
let _ = writeln!(s, ":local");
let _ = writeln!(s, "chain --replace {base}/boot/_local.ipxe || goto menu");
let _ = writeln!(s, ":linux");
let _ = writeln!(
s,
"iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"
"chain --replace {base}/boot/_linux_menu.ipxe || goto menu"
);
let _ = writeln!(s, ":windows");
let _ = writeln!(
s,
"iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"
"chain --replace {base}/boot/_windows_menu.ipxe || goto menu"
);
let _ = writeln!(s, ":tools");
let _ = writeln!(
s,
"iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"
"chain --replace {base}/boot/_tools_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");
let _ = writeln!(s, ":queue");
let _ = writeln!(s, "chain --replace {base}/boot/_queue.ipxe || goto menu");
let _ = writeln!(s, ":exit");
let _ = writeln!(s, "exit 0");
s
}
@@ -244,18 +269,21 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
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"
);
// v0.8.2: `back` is the only fixed item here; every other item value
// is a boot-entry id that can only be resolved at runtime, so dispatch
// on it directly. `echo` is iPXE's rc-0 no-op and keeps a
// non-matching test from aborting the script (issue #2).
let _ = writeln!(s, "iseq ${{target}} back && goto back || echo");
// 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"
"chain --replace {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
let _ = writeln!(s, ":back");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe || goto menu");
s
}
@@ -331,36 +359,34 @@ pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String {
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"
);
// v0.8.2: fixed item labels jump to their block; category-Tools ISO
// ids (only known at runtime) fall through to the entry chain. Every
// probe ends in `|| echo` so a non-matching test cannot abort the
// script (issue #2).
let _ = writeln!(s, "iseq ${{target}} util && goto util || echo");
let _ = writeln!(s, "iseq ${{target}} shell && goto shell || echo");
let _ = writeln!(s, "iseq ${{target}} nic && goto nic || echo");
let _ = writeln!(s, "iseq ${{target}} reboot && goto reboot || echo");
let _ = writeln!(s, "iseq ${{target}} firmware && goto firmware || echo");
let _ = writeln!(s, "iseq ${{target}} back && goto back || echo");
// 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"
"chain --replace {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
let _ = writeln!(s, ":util");
let _ = writeln!(s, "chain --replace {base}/boot/_util.ipxe || goto menu");
let _ = writeln!(s, ":shell");
let _ = writeln!(s, "chain --replace {base}/boot/_shell.ipxe || goto menu");
let _ = writeln!(s, ":nic");
let _ = writeln!(s, "chain --replace {base}/boot/_nic.ipxe || goto menu");
let _ = writeln!(s, ":reboot");
let _ = writeln!(s, "reboot || goto menu");
let _ = writeln!(s, ":firmware");
let _ = writeln!(s, "exit 0");
let _ = writeln!(s, ":back");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe || goto menu");
s
}
@@ -384,7 +410,7 @@ pub fn render_local_hdd(base_url: &str) -> String {
);
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");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -402,15 +428,18 @@ pub fn render_util(base_url: &str) -> String {
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu");
// v0.8.2: dispatch to `:memtest`; anything else (only `back` exists
// today) returns to the Tools menu. The old `iseq back && chain
// _tools_menu || goto menu` pair never ran the Tools chain: the
// `back` line's own `iseq` failed first, so `|| goto menu` redrew
// Utilities and Back appeared dead (issue #2).
let _ = writeln!(s, "iseq ${{target}} memtest && goto memtest || echo");
let _ = writeln!(
s,
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu"
"chain --replace {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(s, "goto menu");
let _ = writeln!(s, ":memtest");
let _ = writeln!(s, "chain --replace {base}/ipxe/memtest.bin || goto menu");
s
}
@@ -425,7 +454,7 @@ pub fn render_shell(base_url: &str) -> String {
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");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -443,8 +472,11 @@ pub fn render_nic_info(base_url: &str) -> String {
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");
// v0.8.2: `prompt` returns -ETIMEDOUT when nobody presses a key, and
// a bare failing line aborts the script (dropping the client back to
// firmware). `|| echo` treats the timeout as "return to menu now".
let _ = writeln!(s, "prompt --timeout 30000 || echo");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -464,7 +496,13 @@ pub fn render_queue_entry(base_url: &str) -> String {
// 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}}");
// v0.8.2: if the queue endpoint is unreachable, fall back to the main
// menu instead of aborting the whole boot chain into firmware.
let _ = writeln!(
s,
"chain --replace {base}/api/queue/join?mac=${{mac}} \
|| chain --replace {base}/boot.ipxe"
);
s
}
@@ -505,16 +543,21 @@ pub fn render_entry(
cmdline.push_str(extra.trim());
}
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
// v0.8.2: every fetch line carries its own failure branch.
// Menus now enter boot entries with `chain --replace`, so a
// failing line here can no longer be caught by the *menu's*
// `|| goto menu` — the entry script must return to the menu
// itself.
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline} || goto failed");
for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}");
let _ = writeln!(s, "initrd {base}/{u} || goto failed");
}
let _ = writeln!(s, "boot || goto failed");
}
BootKind::Wimboot { wimboot_url, files } => {
let _ = writeln!(s, "kernel {base}/{wimboot_url}");
let _ = writeln!(s, "kernel {base}/{wimboot_url} || goto failed");
for (tag, url) in files {
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}");
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag} || goto failed");
}
let _ = writeln!(s, "boot || goto failed");
}
@@ -525,7 +568,7 @@ pub fn render_entry(
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");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -589,9 +632,14 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "set password ");
let _ = writeln!(s, "read --secret password");
// v0.8.2: `isset` (empty → nonzero rc) is the correct emptiness
// test. `iseq ${password} ""` compared against the *literal* token
// `""` — iPXE's tokenizer is whitespace-only and has no quoting, so
// an empty Enter never matched and fell through to the failed-login
// path instead of returning to the menu as the banner promises.
let _ = writeln!(
s,
"iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit"
"isset ${{password}} && goto submit || chain --replace {base}/boot.ipxe"
);
let _ = writeln!(s, ":submit");
let _ = writeln!(s, "echo Verifying...");
@@ -601,8 +649,8 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str
// 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"
"chain --replace {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|| chain --replace {base}/boot.ipxe"
);
s
}
@@ -621,7 +669,7 @@ pub fn render_password_failed(entry_id: &str, base_url: &str) -> String {
let _ = writeln!(s, "sleep 2");
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe"
"chain --replace {base}/boot/{entry_id}.ipxe || chain --replace {base}/boot.ipxe"
);
s
}
@@ -640,7 +688,13 @@ mod password_tests {
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"));
assert!(
s.contains(
"isset ${password} && goto submit \
|| chain --replace http://10.0.0.5/boot.ipxe"
),
"empty-password path missing:\n{s}"
);
// Never log/echo the value.
assert!(!s.contains("echo ${password"));
}
@@ -650,7 +704,7 @@ mod password_tests {
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"));
assert!(s.contains("chain --replace http://10.0.0.5/boot/alpha-linux.ipxe"));
}
#[test]
@@ -741,3 +795,489 @@ mod password_tests {
}
}
}
/// Control-flow regression tests for the generated iPXE menus.
///
/// Issue #2 ("menu cannot select items") was a script-semantics bug:
/// iPXE evaluates `&&` / `||` strictly left to right, so
/// `iseq ${target} X && chain … || goto menu` runs `goto menu` whenever
/// the `iseq` fails — for every item except the first one. Text
/// assertions cannot catch that, so these tests run the generated
/// scripts through a small interpreter that mirrors the parts of iPXE's
/// `system()` (`src/core/exec.c`) and `process_script()`
/// (`src/image/script.c`) that the menus rely on, and check which URL
/// each menu item actually reaches.
#[cfg(test)]
mod menu_dispatch_tests {
use super::*;
use openpxe_iso_store::{IsoCategory, IsoSource, KernelArgs};
use std::collections::HashMap;
const BASE: &str = "http://10.0.0.5";
/// What a script did before it left the interpreter.
#[derive(Debug, Default)]
struct Run {
/// URLs passed to `chain`, in order (normally exactly one).
chains: Vec<String>,
/// Exit status if the script reached `exit`.
exit: Option<u32>,
/// Whether the script reached `reboot`.
rebooted: bool,
}
#[derive(Clone, Copy)]
enum Sep {
/// `&&`
And,
/// `||`
Or,
/// `;`
Always,
}
enum LineOutcome {
Continue,
Jump(usize),
Done,
Abort(String),
}
enum CmdOutcome {
Rc(i32),
Jump(usize),
Done,
Abort(String),
}
/// Minimal interpreter for the subset of iPXE's script language used
/// by the generated menus.
struct IpxeSim {
vars: HashMap<String, String>,
selection: String,
/// Value the simulated operator types at `read`.
input: String,
/// Model `prompt --timeout` expiring with no key pressed.
prompt_times_out: bool,
run: Run,
}
impl IpxeSim {
fn new(selection: &str) -> Self {
let mut vars = HashMap::new();
vars.insert("buildarch".to_string(), "x86_64".to_string());
vars.insert("platform".to_string(), "efi".to_string());
vars.insert("mac".to_string(), "aa:bb:cc:dd:ee:ff".to_string());
vars.insert("password".to_string(), String::new());
Self {
vars,
selection: selection.to_string(),
input: String::new(),
prompt_times_out: false,
run: Run::default(),
}
}
fn with_var(mut self, name: &str, value: &str) -> Self {
self.vars.insert(name.to_string(), value.to_string());
self
}
fn with_input(mut self, value: &str) -> Self {
self.input = value.to_string();
self
}
/// Run a script to completion and report what it did. Panics if
/// the script aborts or never reaches a terminal command — which
/// is exactly how a bad dispatch line manifests on a real client.
fn run(mut self, script: &str) -> Run {
let lines: Vec<&str> = script.lines().collect();
let mut labels: HashMap<String, usize> = HashMap::new();
for (index, line) in lines.iter().enumerate() {
if let Some(name) = line.trim().strip_prefix(':') {
labels.insert(name.trim().to_string(), index);
}
}
let mut pc = 0;
let mut steps = 0;
while pc < lines.len() {
steps += 1;
assert!(
steps < 10_000,
"script looped without making progress\n--- script ---\n{script}"
);
let line = lines[pc].trim().to_string();
if line.is_empty() || line.starts_with('#') || line.starts_with(':') {
pc += 1;
continue;
}
match self.exec_line(&line, &labels) {
LineOutcome::Continue => pc += 1,
LineOutcome::Jump(target) => pc = target + 1,
LineOutcome::Done => return self.run,
LineOutcome::Abort(why) => {
panic!("script aborted at line {pc}: {why}\n--- script ---\n{script}");
}
}
}
panic!("script ended without booting or exiting\n--- script ---\n{script}");
}
/// Expand `${name}` / `${name:modifier}` from the simulated
/// settings. iPXE expands *after* tokenising, so a value
/// containing spaces stays a single argument.
fn expand(&self, token: &str) -> String {
let mut out = String::new();
let mut rest = token;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
out.push_str(&rest[start..]);
return out;
};
let spec = &after[..end];
let name = spec.split(':').next().unwrap_or(spec);
if let Some(value) = self.vars.get(name) {
out.push_str(value);
}
rest = &after[end + 1..];
}
out.push_str(rest);
out
}
/// Execute one command line, mirroring `system()`: commands are
/// separated by `&&` / `||` / `;`, and each separator decides
/// whether the *next* command runs based on the last status.
fn exec_line(&mut self, line: &str, labels: &HashMap<String, usize>) -> LineOutcome {
let mut commands: Vec<(Vec<String>, Option<Sep>)> = Vec::new();
let mut current: Vec<String> = Vec::new();
for token in tokenize(line) {
match token.as_str() {
"&&" => commands.push((std::mem::take(&mut current), Some(Sep::And))),
"||" => commands.push((std::mem::take(&mut current), Some(Sep::Or))),
";" => commands.push((std::mem::take(&mut current), Some(Sep::Always))),
_ => current.push(token),
}
}
commands.push((current, None));
let mut rc = 0;
let mut process = true;
for (argv, sep) in &commands {
if process {
if argv.is_empty() {
return LineOutcome::Abort(format!("empty command in `{line}`"));
}
match self.exec_cmd(argv, labels) {
CmdOutcome::Rc(status) => rc = status,
CmdOutcome::Jump(target) => return LineOutcome::Jump(target),
CmdOutcome::Done => return LineOutcome::Done,
CmdOutcome::Abort(why) => return LineOutcome::Abort(why),
}
}
match sep {
Some(Sep::And) => process = rc == 0,
Some(Sep::Or) => process = rc != 0,
Some(Sep::Always) => process = true,
None => break,
}
}
if rc != 0 {
return LineOutcome::Abort(format!("`{line}` failed with status {rc}"));
}
LineOutcome::Continue
}
fn exec_cmd(&mut self, argv: &[String], labels: &HashMap<String, usize>) -> CmdOutcome {
let cmd = self.expand(&argv[0]);
let args: Vec<String> = argv[1..].iter().map(|a| self.expand(a)).collect();
match cmd.as_str() {
"set" => {
if let Some(name) = args.first() {
let value = if args.len() > 1 {
args[1..].join(" ")
} else {
String::new()
};
self.vars.insert(name.clone(), value);
}
CmdOutcome::Rc(0)
}
"iseq" => {
let equal = args.len() >= 2 && args[0] == args[1];
CmdOutcome::Rc(i32::from(!equal))
}
"isset" => {
let nonempty = args.first().is_some_and(|value| !value.is_empty());
CmdOutcome::Rc(i32::from(!nonempty))
}
"goto" => match args.first().and_then(|label| labels.get(label)) {
Some(target) => CmdOutcome::Jump(*target),
None => CmdOutcome::Abort(format!("goto to missing label in `{argv:?}`")),
},
"exit" => {
let code = args
.first()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(0);
self.run.exit = Some(code);
CmdOutcome::Done
}
// A boot takes over the machine (and `chain --replace`
// additionally replaces the current script image), so the
// interpreter records the URL and stops.
"chain" | "boot" | "imgexec" | "sanboot" => {
let url = args
.iter()
.find(|arg| !arg.starts_with('-'))
.cloned()
.unwrap_or_default();
self.run.chains.push(url);
CmdOutcome::Done
}
"reboot" => {
self.run.rebooted = true;
CmdOutcome::Done
}
"choose" => {
let mut index = 0;
let mut setting = None;
while index < args.len() {
match args[index].as_str() {
"--default" | "--timeout" | "--key" => index += 2,
option if option.starts_with('-') => index += 1,
name => {
setting = Some(name.to_string());
index += 1;
}
}
}
if let Some(name) = setting {
self.vars.insert(name, self.selection.clone());
}
CmdOutcome::Rc(0)
}
"read" => {
if let Some(name) = args.iter().find(|arg| !arg.starts_with('-')) {
let typed = self.input.clone();
self.vars.insert(name.clone(), typed);
}
CmdOutcome::Rc(0)
}
"prompt" => CmdOutcome::Rc(i32::from(self.prompt_times_out)),
"echo" | "console" | "menu" | "item" | "sleep" | "ifstat" | "route" | "shell"
| "kernel" | "initrd" | "imgfetch" => CmdOutcome::Rc(0),
other => CmdOutcome::Abort(format!("unknown iPXE command `{other}`")),
}
}
}
/// iPXE splits on whitespace only — it has no shell quoting.
fn tokenize(line: &str) -> Vec<String> {
line.split_whitespace().map(str::to_string).collect()
}
// ---- fixtures ----
fn test_iso(id: &str, family: DistroFamily, entries: Vec<BootEntry>) -> IsoMeta {
IsoMeta {
id: id.to_string(),
filename: format!("{id}.iso"),
size_bytes: 700 * 1024 * 1024,
sha256_hex: None,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
introspection: openpxe_iso_store::IntrospectionReport {
family,
..Default::default()
},
boot_entries: entries,
source: IsoSource::Local,
password_hash: None,
category: IsoCategory::default(),
}
}
fn linux_entry(id: &str) -> BootEntry {
BootEntry {
id: id.to_string(),
title: format!("Title {id}"),
kind: BootKind::LinuxKernel {
kernel_url: format!("iso/{id}/boot/vmlinuz"),
initrd_urls: vec![format!("iso/{id}/boot/initrd")],
args: KernelArgs::default(),
},
}
}
// ---- top-level menu ----
#[test]
fn every_top_menu_item_reaches_its_own_script() {
let settings = Settings::default();
let menu = render_menu(&[], &settings, BASE);
for (item, expected) in [
("local", "/boot/_local.ipxe"),
("linux", "/boot/_linux_menu.ipxe"),
("windows", "/boot/_windows_menu.ipxe"),
("tools", "/boot/_tools_menu.ipxe"),
("queue", "/boot/_queue.ipxe"),
] {
let run = IpxeSim::new(item).run(&menu);
assert_eq!(
run.chains,
vec![format!("{BASE}{expected}")],
"item `{item}` did not reach its script"
);
assert_eq!(run.exit, None, "item `{item}` should not exit");
}
}
#[test]
fn exit_item_exits_the_firmware() {
let menu = render_menu(&[], &Settings::default(), BASE);
let run = IpxeSim::new("exit").run(&menu);
assert_eq!(run.exit, Some(0));
assert!(run.chains.is_empty(), "{:?}", run.chains);
}
#[test]
fn unknown_architecture_does_not_abort_the_menu() {
// The old arch-label line ended in `|| true`, which is not an
// iPXE command: any arch/platform pair outside the allowlist
// aborted the whole script. `|| echo` keeps the generic label.
for (arch, platform) in [("riscv64", "efi"), ("arm64", "pcbios"), ("i386", "efi")] {
let run = IpxeSim::new("local")
.with_var("buildarch", arch)
.with_var("platform", platform)
.run(&render_menu(&[], &Settings::default(), BASE));
assert_eq!(run.chains.len(), 1, "menu aborted on {arch}/{platform}");
}
}
// ---- family (installer) menu ----
#[test]
fn family_menu_dispatches_each_entry_and_back() {
let isos = vec![
test_iso(
"alpha",
DistroFamily::DebianUbuntu,
vec![linux_entry("alpha-linux")],
),
test_iso(
"beta",
DistroFamily::Alpine,
vec![linux_entry("beta-linux")],
),
];
let menu = render_family_menu(&isos, BASE, false);
for id in ["alpha-linux", "beta-linux"] {
let run = IpxeSim::new(id).run(&menu);
assert_eq!(
run.chains,
vec![format!("{BASE}/boot/{id}.ipxe?mac=aa:bb:cc:dd:ee:ff")],
"entry `{id}` fell through to the menu"
);
}
let run = IpxeSim::new("back").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
}
// ---- tools menu ----
#[test]
fn tools_menu_dispatches_every_item() {
let mut iso = test_iso(
"tool",
DistroFamily::Unknown,
vec![linux_entry("tool-disk")],
);
iso.category = IsoCategory::Tools;
let menu = render_tools_menu(&[iso], BASE);
for (item, expected) in [
("util", format!("{BASE}/boot/_util.ipxe")),
("shell", format!("{BASE}/boot/_shell.ipxe")),
("nic", format!("{BASE}/boot/_nic.ipxe")),
("back", format!("{BASE}/boot.ipxe")),
(
"tool-disk",
format!("{BASE}/boot/tool-disk.ipxe?mac=aa:bb:cc:dd:ee:ff"),
),
] {
let run = IpxeSim::new(item).run(&menu);
assert_eq!(run.chains, vec![expected], "item `{item}`");
}
let run = IpxeSim::new("reboot").run(&menu);
assert!(run.rebooted, "reboot item did not reboot");
let run = IpxeSim::new("firmware").run(&menu);
assert_eq!(run.exit, Some(0), "firmware item did not exit");
}
// ---- utilities menu ----
#[test]
fn util_menu_dispatches_memtest_and_back() {
let menu = render_util(BASE);
let run = IpxeSim::new("memtest").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/ipxe/memtest.bin")]);
let run = IpxeSim::new("back").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/boot/_tools_menu.ipxe")]);
}
// ---- small scripts reached from the menus ----
#[test]
fn shell_and_queue_scripts_end_in_their_chain() {
let run = IpxeSim::new("").run(&render_shell(BASE));
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
let run = IpxeSim::new("").run(&render_queue_entry(BASE));
assert_eq!(
run.chains,
vec![format!("{BASE}/api/queue/join?mac=aa:bb:cc:dd:ee:ff")]
);
}
#[test]
fn nic_info_prompt_timeout_returns_to_menu() {
let mut sim = IpxeSim::new("");
sim.prompt_times_out = true;
let run = sim.run(&render_nic_info(BASE));
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
}
#[test]
fn password_prompt_empty_enter_returns_to_main_menu() {
let menu = render_password_prompt("alpha-linux", "Alpha.iso", BASE);
let run = IpxeSim::new("").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
let run = IpxeSim::new("").with_input("hunter2").run(&menu);
assert_eq!(
run.chains,
vec![format!(
"{BASE}/boot/alpha-linux.ipxe?token=hunter2&mac=aa:bb:cc:dd:ee:ff"
)]
);
}
#[test]
fn boot_entry_scripts_recover_to_the_menu_on_failed_fetches() {
// Menus now chain entries with `--replace`, so the entry script —
// not the caller — has to recover from a failed kernel/initrd
// fetch. Otherwise a flaky mirror drops the client to firmware.
let settings = Settings::default();
let s = render_entry(&linux_entry("alpha-linux"), &settings, BASE, None);
for line in s
.lines()
.filter(|line| line.starts_with("kernel ") || line.starts_with("initrd "))
{
assert!(
line.ends_with("|| goto failed"),
"missing failure branch on `{line}`"
);
}
assert!(s.contains("chain --replace http://10.0.0.5/boot.ipxe"));
}
}
@@ -1,5 +1,6 @@
---
source: crates/http-api/src/ipxe_script.rs
assertion_line: 787
expression: rendered
---
#!ipxe
@@ -9,7 +10,9 @@ set esc:hex 1b
set cls ${esc:string}[2J
iseq ${platform} efi && console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
set arch-label ${buildarch} ${platform}
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
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || echo
iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || echo
iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || echo
:menu
menu OpenPXE - network boot menu
item --gap
@@ -27,10 +30,16 @@ item --key x exit Exit iPXE
item --gap
item --gap -- OpenPXE vX.Y.Z - ${arch-label}
choose --default queue --timeout 600000 target || goto menu
iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu
iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu
iseq ${target} exit && exit || goto menu
goto menu
goto ${target} || goto menu
:local
chain --replace http://10.0.0.5/boot/_local.ipxe || goto menu
:linux
chain --replace http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
:windows
chain --replace http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
:tools
chain --replace http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
:queue
chain --replace http://10.0.0.5/boot/_queue.ipxe || goto menu
:exit
exit 0
+2 -2
View File
@@ -495,11 +495,11 @@ async fn reboot_and_firmware_exit_in_tools_menu() {
"tools menu missing firmware-exit item:\n{tools}"
);
assert!(
tools.contains("&& reboot"),
tools.contains(":reboot\nreboot || goto menu"),
"reboot command not wired:\n{tools}"
);
assert!(
tools.contains("&& exit 0"),
tools.contains(":firmware\nexit 0"),
"firmware exit command not wired:\n{tools}"
);
}