From 887616dfba651bb399a786da5aa9b3157c490d72 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 13:34:18 +0200 Subject: [PATCH 01/26] Stage 2 without the installer: the loader writes boot variables, the bench replaces Ubuntu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader writes the firmware's boot variables on a request the running system leaves in the slot table (format 2): `update --boot-first` puts its own entry first in BootOrder, `update --boot-next ` sets BootNext to another ESP, and `update --once < image` boots the idle slot once and never marks it. Each request is taken off the table, flushed, before it is acted on. A pass whose every slot is refused falls to the entry after its own in BootOrder instead of dying. A slot booted once raises no floor. toyos-metal drives a T14 running ToyOS alone (the bench): the image goes in with `update --once`, the reboot over ssh, the boot's loader passes and logd files come back over sftp in one session — the loader keeps the last chain's file as loader-previous.log, and the boot's logd file is the one naming its ROOT — and the old judges read them. `--via-ubuntu` names the old path; `--via-ubuntu --resident` hands the machine to a bench image. The bench refuses an image built with another loader, which it names by its file's hash. Measured in QEMU on OVMF: BootOrder was 0003,0002,0000 and the loader's written entry Boot0001 stayed first across the reset after it; the machine stick is Boot0001 beside a recovery stick, which the fall-through reached as Boot0003. tests/updatecase gave toybox `syscap = ["power"]` where power is a connector since the log change, so its `reboot` was refused (exit 1) and every update test's reboot stalled; it receives the connector now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- Cargo.lock | 1 + bootloader/src/arch/aarch64.rs | 5 + bootloader/src/arch/x86_64.rs | 5 + bootloader/src/bootnext.rs | 95 +--- bootloader/src/bootvars.rs | 198 +++++++ bootloader/src/loaderlog.rs | 68 ++- bootloader/src/main.rs | 109 +++- bootloader/src/request.rs | 144 +++++ bootloader/src/rootimage.rs | 30 +- bootloader/src/slot.rs | 32 +- ...hes-a-machine-only-by-writing-its-stick.md | 24 + ...the-bench-reads-no-quiescent-log-volume.md | 19 + ...ench-runs-with-no-bound-on-its-own-boot.md | 17 + ...-cable-is-read-by-the-driver-under-test.md | 18 + ...e-machine-updates-itself-without-ubuntu.md | 45 +- kernel/src/main.rs | 1 + kernel/src/params.rs | 7 + src/bootlog.rs | 18 + src/build.rs | 40 ++ src/flags.rs | 4 + src/image.rs | 68 +++ src/lib.rs | 1 + src/main.rs | 16 + src/metal.rs | 417 +++++++++++--- src/metalbench.rs | 534 ++++++++++++++++++ src/metaltalk.rs | 24 +- src/testargs.rs | 12 + tests/benchcase/system.toml | 43 ++ tests/benchvirtiocase/system.toml | 42 ++ tests/common/bench.rs | 175 ++++++ tests/common/metal.rs | 84 ++- tests/common/mod.rs | 1 + tests/common/qemu.rs | 26 + tests/common/update.rs | 228 +++++++- tests/ssh-client-host/src/main.rs | 66 ++- tests/toyos.rs | 31 + tests/updatecase/system.toml | 2 +- toyos-update/Cargo.toml | 4 + toyos-update/src/entry.rs | 389 +++++++++++++ toyos-update/src/lib.rs | 5 + toyos-update/src/policy.rs | 32 +- toyos-update/src/record.rs | 49 +- toyos-update/src/slots.rs | 156 ++++- userland/toybox/src/date.rs | 22 + userland/toybox/src/main.rs | 3 +- userland/update/src/main.rs | 100 +++- 46 files changed, 3154 insertions(+), 256 deletions(-) create mode 100644 bootloader/src/bootvars.rs create mode 100644 bootloader/src/request.rs create mode 100644 issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md create mode 100644 issues/boot-media/the-bench-reads-no-quiescent-log-volume.md create mode 100644 issues/boot-media/the-bench-runs-with-no-bound-on-its-own-boot.md create mode 100644 issues/boot-media/the-benchs-cable-is-read-by-the-driver-under-test.md create mode 100644 src/metalbench.rs create mode 100644 tests/benchcase/system.toml create mode 100644 tests/benchvirtiocase/system.toml create mode 100644 tests/common/bench.rs create mode 100644 toyos-update/src/entry.rs create mode 100644 userland/toybox/src/date.rs diff --git a/Cargo.lock b/Cargo.lock index 802affd915b..684a5bc6ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,7 @@ version = "0.1.0" dependencies = [ "ed25519-dalek", "sha2", + "toyos-gpt", ] [[package]] diff --git a/bootloader/src/arch/aarch64.rs b/bootloader/src/arch/aarch64.rs index dbb0b327022..950960b36f6 100644 --- a/bootloader/src/arch/aarch64.rs +++ b/bootloader/src/arch/aarch64.rs @@ -6,6 +6,11 @@ use toyos_bootmap::Typing; /// The machine the kernel image must be built for: the loader's own. pub const ELF_MACHINE: toyos_elf::Machine = toyos_elf::Machine::Aarch64; +/// The removable-media path firmware boots an EFI system partition by when no +/// entry names a file on it (UEFI 2.10 §3.5.1.1): what every ToyOS image puts +/// its loader at, and what an entry this loader writes for an ESP names. +pub const REMOVABLE_PATH: &str = r"\EFI\BOOT\BOOTAA64.EFI"; + /// How the boot map's descriptors are encoded. pub use toyos_bootmap::aarch64 as encoding; diff --git a/bootloader/src/arch/x86_64.rs b/bootloader/src/arch/x86_64.rs index 6a34eea1420..a7a66cf4485 100644 --- a/bootloader/src/arch/x86_64.rs +++ b/bootloader/src/arch/x86_64.rs @@ -5,6 +5,11 @@ use toyos_abi::boot::KernelArgs; /// The machine the kernel image must be built for: the loader's own. pub const ELF_MACHINE: toyos_elf::Machine = toyos_elf::Machine::X86_64; +/// The removable-media path firmware boots an EFI system partition by when no +/// entry names a file on it (UEFI 2.10 §3.5.1.1): what every ToyOS image puts +/// its loader at, and what an entry this loader writes for an ESP names. +pub const REMOVABLE_PATH: &str = r"\EFI\BOOT\BOOTX64.EFI"; + /// How the boot map's entries are encoded. pub use toyos_bootmap::x86_64 as encoding; diff --git a/bootloader/src/bootnext.rs b/bootloader/src/bootnext.rs index 7627887f19b..2a9e67c73c6 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -20,20 +20,11 @@ use uefi::proto::device_path::media::PartitionSignature; use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType}; use uefi::proto::loaded_image::LoadedImage; use uefi::table::runtime::{VariableAttributes, VariableVendor}; -use uefi::CStr16; +use toyos_update::entry; /// The head of every line this module writes. const HEAD: &str = "Boot chain:"; -/// What a `Boot####` variable's name is after the four hex digits are taken off. -const ENTRY_PREFIX: &str = "Boot"; -const ENTRY_DIGITS: usize = 4; - -/// `EFI_LOAD_OPTION`'s fixed head: a `UINT32` of attributes and a `UINT16` -/// device-path length, then a null-terminated `CHAR16` description, then the -/// device path itself (UEFI 2.10 §3.1.3). -const LOAD_OPTION_HEAD: usize = 6; - /// Set `BootNext` to this image's own entry, or say by name why it could not be. /// /// A refusal is not a failure of the boot: the kernel still runs and still seals @@ -71,7 +62,7 @@ pub fn point_at_us(handle: Handle, system_table: &SystemTable) { } /// The GPT partition GUID of the volume firmware loaded this image from. -fn our_partition(handle: Handle, system_table: &SystemTable) -> Option<[u8; 16]> { +pub(crate) fn our_partition(handle: Handle, system_table: &SystemTable) -> Option<[u8; 16]> { let bs = system_table.boot_services(); let image = bs.open_protocol_exclusive::(handle).ok()?; let device = image.device()?; @@ -98,6 +89,12 @@ fn hard_drive_guid<'a>(nodes: impl Iterator, ours: &[u8; 16]) -> Option { let rt = system_table.runtime_services(); let keys = rt.variable_keys().ok()?; @@ -107,9 +104,9 @@ fn entry_for(system_table: &SystemTable, ours: &[u8; 16]) -> Option { continue; } let Ok(name) = key.name() else { continue }; - let Some(number) = entry_number(name) else { continue }; + let Some(number) = entry::number(&alloc::format!("{name}")) else { continue }; let Ok((bytes, _)) = rt.get_variable_boxed(name, &key.vendor) else { continue }; - if !load_option_names(&bytes, ours) { + if !entry::names(&bytes, ours) { continue; } // The lowest, so a machine carrying two entries for one partition is @@ -118,75 +115,3 @@ fn entry_for(system_table: &SystemTable, ours: &[u8; 16]) -> Option { } found } - -/// `Boot0003` is entry 3; anything else here is some other global variable. -fn entry_number(name: &CStr16) -> Option { - let mut chars = name.iter().map(|c| char::from(*c)); - for want in ENTRY_PREFIX.chars() { - if chars.next()? != want { - return None; - } - } - let mut value: u16 = 0; - let mut digits = 0; - for ch in chars { - value = value.checked_mul(16)?.checked_add(ch.to_digit(16)? as u16)?; - digits += 1; - } - (digits == ENTRY_DIGITS).then_some(value) -} - -/// Whether an `EFI_LOAD_OPTION`'s device path carries `ours`. -/// -/// **Walked as bytes, bounded by the slice, and never handed to a pointer -/// iterator.** These bytes are whatever a vendor's NVRAM holds: a node claiming -/// a length of zero is an endless walk and one claiming a length past the -/// variable is a read off the end of it, so both are refused here rather than -/// trusted to a walker that follows the lengths it is given. -fn load_option_names(option: &[u8], ours: &[u8; 16]) -> bool { - let Some(head) = option.get(..LOAD_OPTION_HEAD) else { return false }; - let path_len = u16::from_le_bytes([head[4], head[5]]) as usize; - // The description is `CHAR16` and null-terminated, so the path starts after - // the first pair of zero bytes on an even offset from the head. - let mut at = LOAD_OPTION_HEAD; - loop { - let Some(pair) = option.get(at..at + 2) else { return false }; - at += 2; - if pair == [0, 0] { - break; - } - } - let Some(mut path) = option.get(at..at.saturating_add(path_len)) else { return false }; - while let Some(node) = path.get(..NODE_HEADER) { - let len = u16::from_le_bytes([node[2], node[3]]) as usize; - // A node shorter than its own header, or longer than what is left, ends - // the walk: neither can be stepped over. - let Some(this) = path.get(..len).filter(|_| len >= NODE_HEADER) else { return false }; - if this[0] == MEDIA_HARD_DRIVE.0 && this[1] == MEDIA_HARD_DRIVE.1 { - if let Some(guid) = gpt_signature(this) { - return guid == *ours; - } - } - path = path.get(len..).unwrap_or(&[]); - } - false -} - -/// A device path node's type, subtype and length (UEFI 2.10 §10.2). -const NODE_HEADER: usize = 4; - -/// The MEDIA/HARD_DRIVE node this looks for, as the two bytes it is on the wire. -const MEDIA_HARD_DRIVE: (u8, u8) = (4, 1); - -/// A HARD_DRIVE node's GPT signature, or `None` where it names an MBR one or -/// the node is short (UEFI 2.10 §10.3.6: the signature is sixteen bytes at -/// offset 24, and `SignatureType` 2 is the GPT one). -fn gpt_signature(node: &[u8]) -> Option<[u8; 16]> { - const SIGNATURE: usize = 24; - const SIGNATURE_TYPE: usize = 41; - const GPT: u8 = 2; - if node.get(SIGNATURE_TYPE) != Some(&GPT) { - return None; - } - node.get(SIGNATURE..SIGNATURE + 16)?.try_into().ok() -} diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs new file mode 100644 index 00000000000..d809124055b --- /dev/null +++ b/bootloader/src/bootvars.rs @@ -0,0 +1,198 @@ +//! The firmware's boot variables, written for the running system: an entry +//! for an EFI system partition, `BootOrder` with this loader's entry first, +//! `BootNext`, and the entry the firmware would try after this one. +//! +//! **Why the loader and not the kernel.** `SetVariable` is a runtime service, +//! and the kernel never maps the runtime: it would have to call firmware code +//! with its own page tables live, on firmware's stack discipline, with the +//! machine's interrupts and CPUs its own — a surface the kernel does not have +//! and does not want for four variables. The loader runs under boot services, +//! where every variable call is the ordinary one; so the running system writes +//! what it wants into the slot table's request (`toyos_update::slots::Request`) +//! and the next pass writes the variables. What the running system can reach +//! this way is exactly the pure decisions in `toyos_update::entry`: an entry +//! for an ESP the loader found, by its removable-media path, first in the +//! order or next once — never a device path it names. + +use alloc::string::String; +use alloc::vec::Vec; +use toyos_update::entry::{self, Partition}; +use uefi::prelude::*; +use uefi::proto::device_path::media::PartitionSignature; +use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType}; +use uefi::proto::media::file::{File, FileAttribute, FileMode}; +use uefi::proto::media::fs::SimpleFileSystem; +use uefi::proto::media::partition::{GptPartitionType, PartitionInfo}; +use uefi::table::runtime::{VariableAttributes, VariableVendor}; +use uefi::{cstr16, CStr16, CString16}; + +/// The head of every line this module writes. +pub const HEAD: &str = "Boot entries:"; + +/// What every entry this loader writes is called in the firmware's menu. +const DESCRIPTION: &str = "ToyOS"; + +/// Boot variables are non-volatile and readable by the operating system too +/// (UEFI 2.10 §3.3: `Boot####`, `BootOrder` and `BootNext` are NV, BS, RT). +const ATTRIBUTES: VariableAttributes = VariableAttributes::NON_VOLATILE + .union(VariableAttributes::BOOTSERVICE_ACCESS) + .union(VariableAttributes::RUNTIME_ACCESS); + +/// The most bytes a load option this loader writes can take: a description, +/// a HARDDRIVE node and a short path. +const OPTION_BYTES: usize = 256; + +/// The most entries `BootOrder` is read with: past this a firmware's order is +/// not one this loader rewrites. +const MAX_ORDER: usize = 256; + +/// The EFI system partition `guid` names, as a HARDDRIVE node names it, with +/// its removable-media loader checked to be there: `Err` says which of those +/// it is not. +pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { + let handle = crate::loaderlog::volume_handle(bs, guid)?; + let info = crate::rootimage::try_get_protocol::(bs, handle) + .map_err(|e| alloc::format!("the partition names no partition record ({e:?})"))?; + let gpt = info.gpt_partition_entry().ok_or("the partition is not a GPT one")?; + if { gpt.partition_type_guid } != GptPartitionType::EFI_SYSTEM_PARTITION { + return Err(String::from("the partition is not an EFI system partition")); + } + let path = crate::rootimage::try_get_protocol::(bs, handle) + .map_err(|e| alloc::format!("the partition's device path ({e:?})"))?; + let node = path + .node_iter() + .filter(|node| node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE)) + .last() + .ok_or("the partition's device path carries no HARDDRIVE node")?; + let hd = <&uefi::proto::device_path::media::HardDrive>::try_from(node) + .map_err(|e| alloc::format!("the partition's HARDDRIVE node ({e:?})"))?; + let PartitionSignature::Guid(signature) = hd.partition_signature() else { + return Err(String::from("the partition's HARDDRIVE node names it by no GUID")); + }; + if signature.to_bytes() != *guid { + return Err(String::from("the partition's HARDDRIVE node names another partition")); + } + let part = Partition { number: hd.partition_number(), start: hd.partition_start(), size: hd.partition_size(), guid: *guid }; + drop(path); + drop(info); + // Not exclusive: this is a look at a volume firmware may be serving to + // another agent, and EXCLUSIVE would stop that agent to take it. + let mut fs = crate::rootimage::try_get_protocol::(bs, handle) + .map_err(|e| alloc::format!("its volume would not open ({e:?})"))?; + let mut root = fs.open_volume().map_err(|e| alloc::format!("it has no volume ({e})"))?; + let loader = CString16::try_from(crate::arch::REMOVABLE_PATH).expect("the removable path is ASCII"); + root.open(&loader, FileMode::Read, FileAttribute::empty()) + .map_err(|e| alloc::format!("it carries no {} ({e})", crate::arch::REMOVABLE_PATH))?; + Ok(part) +} + +/// Every `Boot####` entry firmware holds, with its option's bytes. +fn entries(rt: &RuntimeServices) -> Result)>, String> { + let keys = rt.variable_keys().map_err(|e| alloc::format!("firmware would not list its variables ({e})"))?; + let mut out = Vec::new(); + for key in keys.iter().filter(|key| key.vendor == VariableVendor::GLOBAL_VARIABLE) { + let Ok(name) = key.name() else { continue }; + let Some(number) = entry::number(&alloc::format!("{name}")) else { continue }; + match rt.get_variable_boxed(name, &key.vendor) { + Ok((bytes, _)) => out.push((number, bytes)), + Err(e) => return Err(alloc::format!("Boot{number:04X} would not read ({e})")), + } + } + Ok(out) +} + +/// The entry that boots `part`: the lowest active one already naming it, or +/// one written here at the lowest free number. Its number, and whether it was +/// written. +pub fn entry_for(rt: &RuntimeServices, part: &Partition) -> Result<(u16, bool), String> { + let held = entries(rt)?; + if let Some(number) = + held.iter().filter(|(_, o)| entry::active(o) && entry::names(o, &part.guid)).map(|(n, _)| *n).min() + { + return Ok((number, false)); + } + let used: Vec = held.iter().map(|(n, _)| *n).collect(); + let number = entry::free(&used).ok_or("every Boot#### number is taken")?; + let mut option = [0u8; OPTION_BYTES]; + let len = entry::load_option(DESCRIPTION, part, crate::arch::REMOVABLE_PATH, &mut option) + .map_err(|why| alloc::format!("the entry would not encode: {why}"))?; + let name = CString16::try_from(alloc::format!("Boot{number:04X}").as_str()).expect("an ASCII name"); + rt.set_variable(&name, &VariableVendor::GLOBAL_VARIABLE, ATTRIBUTES, &option[..len]) + .map_err(|e| alloc::format!("firmware refused Boot{number:04X} ({e})"))?; + Ok((number, true)) +} + +/// A `UINT16` variable or array of them, as firmware holds it. +fn words(rt: &RuntimeServices, name: &CStr16) -> Result>, String> { + match rt.get_variable_boxed(name, &VariableVendor::GLOBAL_VARIABLE) { + Ok((bytes, _)) if bytes.len() % 2 == 0 && bytes.len() / 2 <= MAX_ORDER => { + Ok(Some(bytes.chunks_exact(2).map(|w| u16::from_le_bytes([w[0], w[1]])).collect())) + } + Ok((bytes, _)) => Err(alloc::format!("{name} holds {} bytes, which is no order this loader reads", bytes.len())), + Err(e) if e.status() == Status::NOT_FOUND => Ok(None), + Err(e) => Err(alloc::format!("{name} would not read ({e})")), + } +} + +/// `BootOrder` with `number` first; the order it was and the order it is. +pub fn put_first(rt: &RuntimeServices, number: u16) -> Result<(Vec, Vec), String> { + let was = words(rt, cstr16!("BootOrder"))?.unwrap_or_default(); + let mut now = [0u16; MAX_ORDER + 1]; + let n = entry::first(&was, number, &mut now); + let bytes: Vec = now[..n].iter().flat_map(|w| w.to_le_bytes()).collect(); + rt.set_variable(cstr16!("BootOrder"), &VariableVendor::GLOBAL_VARIABLE, ATTRIBUTES, &bytes) + .map_err(|e| alloc::format!("firmware refused BootOrder ({e})"))?; + Ok((was, now[..n].to_vec())) +} + +/// `BootNext` is `number`: the firmware boots that entry at the next reset and +/// deletes the variable as it does (UEFI 2.10 §3.1.2), so once. +pub fn boot_next(rt: &RuntimeServices, number: u16) -> Result<(), String> { + rt.set_variable(cstr16!("BootNext"), &VariableVendor::GLOBAL_VARIABLE, ATTRIBUTES, &number.to_le_bytes()) + .map_err(|e| alloc::format!("firmware refused BootNext={number:04X} ({e})")) +} + +/// The entry the firmware would have tried after the one that booted this +/// pass, skipping an inactive entry and every entry naming `ours` — this +/// loader's own partition, which has just refused every slot it has. +pub fn after_this_one(rt: &RuntimeServices, ours: Option<&[u8; 16]>) -> Result<(u16, Option), String> { + let current = words(rt, cstr16!("BootCurrent"))? + .and_then(|w| w.first().copied()) + .ok_or("firmware names no BootCurrent")?; + let order = words(rt, cstr16!("BootOrder"))?.ok_or("firmware holds no BootOrder")?; + let held = entries(rt)?; + let skip = |number: u16| match held.iter().find(|(n, _)| *n == number) { + None => true, + Some((_, option)) => !entry::active(option) || ours.is_some_and(|guid| entry::names(option, guid)), + }; + Ok((current, entry::after(&order, current, skip))) +} + +/// A list of entry numbers as firmware's menu spells them. +pub fn spelled(order: &[u16]) -> String { + let mut out = String::new(); + for (i, n) in order.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&alloc::format!("{n:04X}")); + } + out +} + +/// The firmware's boot state as this pass found it — which entry booted it and +/// the order behind that — in one line: what a reader of `loader.log` needs to +/// say which way the machine goes at the next reset. +pub fn state(rt: &RuntimeServices) -> String { + let current = match words(rt, cstr16!("BootCurrent")) { + Ok(Some(w)) => w.first().map_or(String::from("none"), |n| alloc::format!("Boot{n:04X}")), + Ok(None) => String::from("none"), + Err(why) => why, + }; + let order = match words(rt, cstr16!("BootOrder")) { + Ok(Some(order)) => spelled(&order), + Ok(None) => String::from("none"), + Err(why) => why, + }; + alloc::format!("{HEAD} this pass was booted as {current}; BootOrder is {order}") +} diff --git a/bootloader/src/loaderlog.rs b/bootloader/src/loaderlog.rs index feec501f273..cc75b26ad67 100644 --- a/bootloader/src/loaderlog.rs +++ b/bootloader/src/loaderlog.rs @@ -4,9 +4,11 @@ //! here too, written and flushed before the loader goes on. //! //! The file is `loader.log` at the root of the partition -//! `KernelArgs::log_partition_guid` names, truncated at each boot. One file -//! under a fixed name and never one of `logd`'s timestamped ones, so a reader -//! looking for the kernel's log on this volume never picks this up. +//! `KernelArgs::log_partition_guid` names, started again at each boot with the +//! one it replaces kept as `loader-previous.log` — on a machine that boots +//! itself again after a boot, the one place that boot's passes are left for a +//! host to read. Fixed names and never one of `logd`'s timestamped ones, so a +//! reader looking for the kernel's log on this volume never picks these up. //! //! A partition this cannot open or write is refused by name on the console and //! the boot continues: the loader's job is the kernel. @@ -52,6 +54,65 @@ pub fn close_without_a_kernel() { const NAME: &CStr16 = cstr16!("loader.log"); +/// The file the last chain's `loader.log` is kept under when a pass starts a +/// new one: what a machine that boots itself again after a boot — rather than +/// handing it to another operating system that reads the stick — still has +/// of that boot's passes, for a host to read over ssh. +const PREVIOUS: &CStr16 = cstr16!("loader-previous.log"); + +/// The most of the last chain's file that is kept. A report pass writes the +/// black box's tail, which is bounded; a file past this is not one this loader +/// wrote, and is left behind rather than copied. +const PREVIOUS_BYTES: usize = 4 << 20; + +/// Copy `loader.log` to [`PREVIOUS`], replacing it, before the file is cut: +/// the chain that file holds is over, and this keeps exactly one of them. +/// +/// Said on the console only, as [`refused`] is, where it cannot: the new file +/// is not open yet. +fn keep_previous(root: &mut Directory) { + let file = match root.open(NAME, FileMode::Read, FileAttribute::empty()) { + Ok(file) => file, + Err(e) if e.status() == Status::NOT_FOUND => return, + Err(e) => return refused_previous(format_args!("{NAME} would not open ({e})")), + }; + let Some(mut file) = file.into_regular_file() else { + return refused_previous(format_args!("{NAME} is a directory")); + }; + let mut bytes = alloc::vec![0u8; PREVIOUS_BYTES + 1]; + let read = match file.read(&mut bytes) { + Ok(n) => n, + Err(e) => return refused_previous(format_args!("{NAME} would not read ({e})")), + }; + if read > PREVIOUS_BYTES { + return refused_previous(format_args!("{NAME} is past {PREVIOUS_BYTES} bytes")); + } + match root.open(PREVIOUS, FileMode::ReadWrite, FileAttribute::empty()) { + Ok(stale) => { + if let Err(e) = stale.delete() { + return refused_previous(format_args!("{PREVIOUS} would not delete ({e})")); + } + } + Err(e) if e.status() == Status::NOT_FOUND => {} + Err(e) => return refused_previous(format_args!("{PREVIOUS} would not open ({e})")), + } + let kept = match root.open(PREVIOUS, FileMode::CreateReadWrite, FileAttribute::empty()) { + Ok(kept) => kept, + Err(e) => return refused_previous(format_args!("{PREVIOUS} would not be created ({e})")), + }; + let Some(mut kept) = kept.into_regular_file() else { + return refused_previous(format_args!("{PREVIOUS} is a directory")); + }; + let written = kept.write(&bytes[..read]).map_err(|e| e.status()).and_then(|()| kept.flush().map_err(|e| e.status())); + if let Err(status) = written { + refused_previous(format_args!("{PREVIOUS} would not write ({status:?})")); + } +} + +fn refused_previous(why: fmt::Arguments) { + uefi_services::println!("Loader log: the last chain's file is not kept: {why}"); +} + /// The open file, from [`open`] until [`close`]. /// /// A UEFI application owns the machine: one processor, no preemption, and @@ -161,6 +222,7 @@ pub fn open(system_table: &SystemTable, guid: &[u8; 16], truncate: bool) { // offset zero without truncating it, so a shorter boot than the last would // end in the last one's tail. if truncate { + keep_previous(&mut root); match root.open(NAME, FileMode::ReadWrite, FileAttribute::empty()) { Ok(stale) => { if let Err(e) = stale.delete() { diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index bec945c9469..02dc5d7e663 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -42,9 +42,11 @@ mod arch; mod attempt; mod blackbox; mod bootnext; +mod bootvars; mod floor; mod gcd; mod loaderlog; +mod request; mod rootbridge; mod rootimage; mod slot; @@ -135,6 +137,40 @@ fn load_file_bytes(handle: Handle, system_table: &SystemTable, path: &CStr bytes } +/// The line naming this loader by the SHA-256 of its file; held to the host's +/// spelling by `toyos_build::bootlog`'s gate. +const LOADER_IS: &str = "Loader: the file firmware loaded hashes to"; + +/// This loader's own file, at the removable-media path of the volume firmware +/// loaded it from — where every ToyOS image puts it — or why it would not read. +/// Not [`load_file_bytes`], which dies on a missing file: a loader that cannot +/// name itself still boots. +fn own_file(handle: Handle, system_table: &SystemTable) -> Result, alloc::string::String> { + let mut fs = system_table + .boot_services() + .get_image_file_system(handle) + .map_err(|e| alloc::format!("its volume ({e})"))?; + let path = uefi::CString16::try_from(arch::REMOVABLE_PATH).expect("the removable path is ASCII"); + let mut file = fs + .open_volume() + .map_err(|e| alloc::format!("its volume ({e})"))? + .open(&path, FileMode::Read, FileAttribute::default()) + .map_err(|e| alloc::format!("{} ({e})", arch::REMOVABLE_PATH))? + .into_regular_file() + .ok_or_else(|| alloc::format!("{} is a directory", arch::REMOVABLE_PATH))?; + let info = file.get_boxed_info::().map_err(|e| alloc::format!("its size ({e})"))?; + let size = info.file_size(); + if size > MAX_ESP_FILE { + return Err(alloc::format!("{size} bytes, past the {MAX_ESP_FILE}-byte bound")); + } + let mut bytes = alloc_uninit(size as usize); + let read = file.read(&mut bytes).map_err(|e| alloc::format!("{} ({e})", arch::REMOVABLE_PATH))?; + if read != bytes.len() { + return Err(alloc::format!("a short read: {read} of {size} bytes")); + } + Ok(bytes) +} + /// A buffer to be filled by a read, allocated *without* zeroing it first. /// The caller must check that the read filled the whole buffer. /// @@ -846,6 +882,33 @@ fn end_this_pass(system_table: &SystemTable, exit_event: Option) -> system_table.runtime_services().reset(ResetType::WARM, Status::SUCCESS, None) } +/// End a pass whose every slot was refused by handing the machine to the entry +/// the firmware would have tried after this one — `BootNext`, then the reset +/// [`end_this_pass`] makes — which on a machine with a recovery stick behind +/// it in `BootOrder` is that stick. Where there is none, the pass dies with +/// `why` as it always did. +/// +/// **Not a return to the boot manager**, which would try the next entry itself: +/// a pass that returns leaves its exit-boot-services callback registered, as +/// [`end_this_pass`] says. +fn fall_through(system_table: &SystemTable, ours: Option<&[u8; 16]>, why: &str, exit_event: Option) -> ! { + let rt = system_table.runtime_services(); + match bootvars::after_this_one(rt, ours) { + Ok((current, Some(next))) => match bootvars::boot_next(rt, next) { + Ok(()) => { + println!( + "{} no slot boots, so BootNext=Boot{next:04X}, the entry after Boot{current:04X} in BootOrder", + bootvars::HEAD + ); + end_this_pass(system_table, exit_event) + } + Err(no) => panic!("Slots: {why}; and {no}"), + }, + Ok((current, None)) => panic!("Slots: {why}; and BootOrder holds no entry after Boot{current:04X} to fall to"), + Err(no) => panic!("Slots: {why}; and no entry to fall to: {no}"), + } +} + #[entry] fn main(handle: Handle, mut system_table: SystemTable) -> Status { // First: the TSC counts from reset, so this is what firmware took. @@ -953,6 +1016,27 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { Err(why) => println!("Anti-rollback floor: not raised, because the proven image is not verified: {why}"), } } + if let Some(tried) = accounted.tried { + println!( + "Anti-rollback floor: not raised, because slot {}'s image was booted once and is not the one the machine keeps", + tried.slot.letter() + ); + } + // Which loader this is, by its file's bytes: the one part of a machine no + // update installs, so a host delivering an image holds the image's loader + // to this one. + match own_file(handle, &system_table) { + Ok(bytes) => { + let mut hex = [0u8; 64]; + println!("{LOADER_IS} {}", toyos_update::hex(&toyos_update::sha256(&bytes), &mut hex)); + } + Err(why) => println!("{LOADER_IS} unknown: {why}"), + } + // Before either end of the chain below: a request the running system left + // is due at the next pass, and a report pass is that pass as often as not. + println!("{}", bootvars::state(system_table.runtime_services())); + let ours = bootnext::our_partition(handle, &system_table); + let fired = request::firmware(handle, &system_table, ours.as_ref()); if retry { // **The hang, and the only bound there is on one.** The last boot of // this image was handed the machine and never reported — no panic, no @@ -978,6 +1062,11 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { end_this_pass(&system_table, exit_event); } } + if let request::Fired::Next = fired { + // The firmware boots the entry the running system asked for at this + // reset, and deletes `BootNext` as it does. + end_this_pass(&system_table, exit_event); + } match firmware_watchdog { Ok(()) => println!( "Firmware watchdog: {FIRMWARE_WATCHDOG_SECS} s, until ExitBootServices disables it" @@ -1012,9 +1101,20 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { // is one the chosen slot's signed header names. ROOT is read before the // kernel is loaded and not after: it is the allocation the image pages come // from, and a slot whose ROOT is refused has no use for the kernel's. - let chosen = slot::choose(handle, &system_table, image_floor.value, &record) - .unwrap_or_else(|why| panic!("Slots: {why}")); - record.booted = Some(Booted { slot: chosen.which, version: chosen.version, digest: chosen.digest }); + let once = request::once(handle, &system_table); + let chosen = match slot::choose(handle, &system_table, image_floor.value, &record, once) { + Ok(chosen) => chosen, + Err(why) => { + println!("Slots: {why}"); + fall_through(&system_table, ours.as_ref(), &why, exit_event) + } + }; + record.booted = Some(Booted { + slot: chosen.which, + version: chosen.version, + digest: chosen.digest, + once: chosen.once.is_some(), + }); match attempt::write_chosen(&log_guid, &record) { Ok(()) => println!("{ATTEMPTS} slot {}'s image is written down as the one this pass boots", chosen.which.letter()), Err(why) => println!( @@ -1039,6 +1139,9 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { if let Some((refused, why)) = chosen.refused { append(&alloc::format!("{}{}:{}", toyos_abi::boot::SLOT_REFUSED_PARAM, refused.letter(), why.word())); } + if let Some(marked) = chosen.once { + append(&alloc::format!("{}{}:{}", toyos_abi::boot::SLOT_REFUSED_PARAM, marked.letter(), policy::ONCE)); + } if let Some(word) = blackbox::param(page) { append(&word); } diff --git a/bootloader/src/request.rs b/bootloader/src/request.rs new file mode 100644 index 00000000000..5f71b586667 --- /dev/null +++ b/bootloader/src/request.rs @@ -0,0 +1,144 @@ +//! What the running system asked of this pass through the slot table +//! (`toyos_update::slots::Request`), acted on once. +//! +//! **Consumed before it is acted on.** Each field is written away — the table +//! again, without it, flushed — before this pass does what it asks, so a pass +//! that dies after the write has lost the request rather than repeating it, +//! and a pass whose write fails acts on nothing and says so. A `BootNext` the +//! running system asked for is therefore set at most once, and the firmware +//! deletes it as it boots it (UEFI 2.10 §3.1.2): one boot of that entry, and +//! the order after. +//! +//! Two halves, because they are due at different passes: the firmware's +//! variables ([`firmware`]) in whichever pass comes next, the report pass +//! after a handover included; a slot to boot once ([`once`]) only in a pass +//! that goes on to boot a slot, so the report pass that ends a chain leaves it +//! for the pass after. + +use toyos_update::slots::{Next, Request, Table, Which}; +use uefi::prelude::*; + +use crate::bootvars::{self, HEAD as ENTRIES}; +use crate::rootimage::{Disk, TableAt}; + +/// The head of every line this module writes. +const HEAD: &str = "Request:"; + +/// What the firmware half did that decides how this pass ends. +pub enum Fired { + /// `BootNext` names another entry: the firmware boots it at the reset + /// this pass ends with, so this pass boots no kernel. + Next, + Nothing, +} + +/// The boot disk's slot table, and where it is, or why there is none to read +/// a request out of. +fn table(handle: Handle, system_table: &SystemTable) -> Result<(Disk<'_>, TableAt), alloc::string::String> { + let bs = system_table.boot_services(); + let mut disk = Disk::open(bs, crate::rootimage::boot_disk(handle, bs)?)?; + let at = disk.table_at()?; + Ok((disk, at)) +} + +/// Write `next` over `at`'s table, and say so either way; whether it is on +/// the disk. +fn consume(disk: &mut Disk<'_>, at: &TableAt, next: Table, what: &str) -> bool { + match disk.write_table(at, next) { + Ok(()) => { + println!("{HEAD} {what} is taken off the slot table before it is acted on"); + true + } + Err(why) => { + println!("{HEAD} {what} stands unacted on, because taking it off the slot table failed: {why}"); + false + } + } +} + +/// Put this loader's own entry first in `BootOrder`, and set `BootNext` to an +/// ESP the running system named, where the slot table asks either. +pub fn firmware(handle: Handle, system_table: &SystemTable, ours: Option<&[u8; 16]>) -> Fired { + let (mut disk, at) = match table(handle, system_table) { + Ok(read) => read, + Err(why) => { + println!("{HEAD} the slot table would not read ({why}), so nothing it asks is acted on"); + return Fired::Nothing; + } + }; + let asked = at.table.request; + let esp = match asked.next { + Some(Next::Esp(guid)) => Some(guid), + _ => None, + }; + if !asked.first && esp.is_none() { + return Fired::Nothing; + } + let left = Request { first: false, next: asked.next.filter(|n| matches!(n, Next::Slot(_))) }; + let what = match (asked.first, esp) { + (true, Some(_)) => "the boot order and a boot of another ESP", + (true, None) => "the boot order", + (false, _) => "a boot of another ESP", + }; + if !consume(&mut disk, &at, Table { request: left, ..at.table }, what) { + return Fired::Nothing; + } + drop(disk); + let bs = system_table.boot_services(); + let rt = system_table.runtime_services(); + if asked.first { + match ours { + None => println!("{ENTRIES} this loader came off no GPT partition, so it has no entry to put first"), + Some(guid) => match bootvars::esp(bs, guid).and_then(|part| bootvars::entry_for(rt, &part)) { + Err(why) => println!("{ENTRIES} this loader's own ESP has no entry to put first: {why}"), + Ok((number, made)) => { + let made = if made { "written now" } else { "already there" }; + match bootvars::put_first(rt, number) { + Ok((was, now)) => println!( + "{ENTRIES} Boot{number:04X}, this loader's ESP ({made}), is first: BootOrder was {} and is {}", + bootvars::spelled(&was), + bootvars::spelled(&now) + ), + Err(why) => println!("{ENTRIES} Boot{number:04X} is not first: {why}"), + } + } + }, + } + } + let Some(guid) = esp else { return Fired::Nothing }; + let named = toyos_gpt::Guid(guid); + match bootvars::esp(bs, &guid).and_then(|part| bootvars::entry_for(rt, &part)) { + Err(why) => { + println!("{ENTRIES} no boot of ESP {named} is set, because {why}; this pass boots as it would have"); + Fired::Nothing + } + Ok((number, made)) => match bootvars::boot_next(rt, number) { + Ok(()) => { + let made = if made { "written now" } else { "already there" }; + println!( + "{ENTRIES} BootNext=Boot{number:04X}, ESP {named} ({made}): the firmware boots it once, at the reset this pass ends with" + ); + Fired::Next + } + Err(why) => { + println!("{ENTRIES} {why}; this pass boots as it would have"); + Fired::Nothing + } + }, + } +} + +/// The slot the running system asked to boot once, taken off the table; or +/// `None` where it asked none, or where taking it off failed — a trial that +/// could not be made a trial is not booted at all. +pub fn once(handle: Handle, system_table: &SystemTable) -> Option { + let (mut disk, at) = match table(handle, system_table) { + Ok(read) => read, + // `slot::choose` reads the same table next and refuses by name. + Err(_) => return None, + }; + let Some(Next::Slot(which)) = at.table.request.next else { return None }; + let left = Request { next: None, ..at.table.request }; + let what = alloc::format!("a boot of slot {} once", which.letter()); + consume(&mut disk, &at, Table { request: left, ..at.table }, &what).then_some(which) +} diff --git a/bootloader/src/rootimage.rs b/bootloader/src/rootimage.rs index d5d0be69ff5..c8bf4058a86 100644 --- a/bootloader/src/rootimage.rs +++ b/bootloader/src/rootimage.rs @@ -126,7 +126,7 @@ pub fn boot_disk(handle: Handle, bs: &BootServices) -> Result { } } -fn try_get_protocol( +pub(crate) fn try_get_protocol( bs: &BootServices, handle: Handle, ) -> uefi::Result> { @@ -145,6 +145,13 @@ fn try_get_protocol( } } +/// The slot table as read, and where its current copy is. +pub struct TableAt { + pub table: Table, + copy: usize, + first_lba: u64, +} + /// The boot disk, read through the firmware's block I/O. pub struct Disk<'a> { io: ScopedProtocol<'a, BlockIO>, @@ -186,6 +193,12 @@ impl<'a> Disk<'a> { /// The slot table on this disk's one TOYOS-SLOTS partition. pub fn slot_table(&mut self) -> Result { + self.table_at().map(|at| at.table) + } + + /// The slot table, and where its current copy is: what a writer of the + /// next copy needs. + pub fn table_at(&mut self) -> Result { let blank = Partition { index: 0, type_guid: Guid::ZERO, unique_guid: Guid::ZERO, first_lba: 0, last_lba: 0 }; let mut found = [blank; 2]; let scan = toyos_gpt::locate_type(self, Guid::TOYOS_SLOTS, &mut found) @@ -207,10 +220,23 @@ impl<'a> Disk<'a> { copy.copy_from_slice(self.scratch); } slots::current([&copies[0], &copies[1]]) - .map(|(table, _)| table) + .map(|(table, copy)| TableAt { table, copy, first_lba: part.first_lba }) .map_err(|why| alloc::format!("the slot table's partition holds {why}")) } + /// Make `next` the table, as every writer does (`slots::next_write`): the + /// copy that is not current, one sequence past it, flushed before this + /// answers — a request consumed is consumed only once it is on the disk. + pub fn write_table(&mut self, at: &TableAt, next: Table) -> Result<(), String> { + let (copy, block) = slots::next_write((at.table, at.copy), next); + let lbas = BLOCK as u64 / u64::from(self.lba_bytes); + self.scratch.copy_from_slice(&block); + self.io + .write_blocks(self.media_id, at.first_lba + copy as u64 * lbas, self.scratch) + .map_err(|e| alloc::format!("the slot table's copy {copy} would not write: {:?}", e.status()))?; + self.io.flush_blocks().map_err(|e| alloc::format!("the slot table's copy {copy} would not flush: {:?}", e.status())) + } + /// The first `len` bytes of `part`, in chunks of at most [`CHUNK_BOUND`], /// into pages the kernel keeps. /// diff --git a/bootloader/src/slot.rs b/bootloader/src/slot.rs index 9d04623bf9f..88e890ba9cc 100644 --- a/bootloader/src/slot.rs +++ b/bootloader/src/slot.rs @@ -2,7 +2,9 @@ //! owner's signature before it is. //! //! The slot table marks one slot; that one is tried first and the other only -//! when the marked one is refused (`toyos_update::policy::order`). A slot is +//! when the marked one is refused (`toyos_update::policy::order`) — unless the +//! running system asked for the other once, which is then tried first, booted +//! as the trial it is, and the marked one behind it. A slot is //! booted only once, in this order: its signed header is on its FAT //! partition, the signature is [`KEY`]'s, its image did not die on its last //! boot, its version is at or above the floor a boot has proven, and its @@ -52,6 +54,9 @@ pub struct Chosen { pub root: RootImage, /// The slot tried before this one, and why it was refused. pub refused: Option<(Which, Refusal)>, + /// It is booted once, on the running system's request, and is not the + /// slot the table marks, which is. + pub once: Option, } /// The slot to boot, or every refusal and why there is nothing to boot. @@ -60,6 +65,7 @@ pub fn choose( system_table: &SystemTable, floor: u64, record: &Record, + once: Option, ) -> Result { let bs = system_table.boot_services(); let disk = crate::rootimage::boot_disk(handle, bs)?; @@ -74,9 +80,29 @@ pub fn choose( ); let mut refused: Option<(Which, Refusal)> = None; let mut dead: alloc::vec::Vec = alloc::vec::Vec::new(); - for which in policy::order(&table).into_iter().flatten() { + // Once, and only where it is not the marked slot anyway: a trial of the + // image the machine keeps is an ordinary boot of it. + let once = once.filter(|w| *w != table.marked); + if let Some(which) = once { + println!( + "{HEAD} {}: asked for once; the table marks {}, which the boots after this one take", + which.letter(), + table.marked.letter() + ); + } + for which in policy::order(&table, once).into_iter().flatten() { let slot = table.slot(which).expect("`order` names only slots the table carries"); match verify(bs, &mut disk, which, slot, floor, Some(record)) { + Ok(mut chosen) if once == Some(which) => { + chosen.once = Some(table.marked); + return Ok(chosen); + } + Ok(mut chosen) if once.is_some() => { + // The trial was refused and this is the marked slot: an + // ordinary boot, and the kernel is told nothing was refused. + chosen.refused = None; + return Ok(chosen); + } Ok(mut chosen) => { chosen.refused = refused; return Ok(chosen); @@ -153,7 +179,7 @@ fn verify( return Err(Refusal::Hash("root")); } println!("{HEAD} {letter}: kernel, cmdline and ROOT are the bytes the signed header names"); - Ok(Chosen { which, version: header.version, digest, kernel, cmdline, root, refused: None }) + Ok(Chosen { which, version: header.version, digest, kernel, cmdline, root, refused: None, once: None }) } /// `slot`'s signed header, held to [`KEY`], and its SHA-256. diff --git a/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md b/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md new file mode 100644 index 00000000000..9e3708d05a2 --- /dev/null +++ b/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-09-27 +--- + +# A loader change reaches a machine only by writing its stick again + +`update` installs a slot — kernel, boot parameter, ROOT — and never the loader +on the ESP, so a machine keeps the loader it was flashed with while every +kernel after it arrives by update. Nothing holds the two together: a kernel +built against a changed `KernelArgs`, black box or slot record boots under the +old loader as if it matched. + +The bench refuses it for the images it judges: its loader names itself by its +file's hash in `loader.log` (`LOADER_IS`), and `toyos-metal` delivers nothing +whose own loader differs (`metalbench::same_loader`), so a loader change costs +the T14 a new bench written through `--via-ubuntu --resident`. That path goes +with the installer, and an owner's machine updated with `ssh … update` has no +check at all. + +**Exit**: a loader change reaches a running machine through a signed update, +or an image names the loader it needs and a loader that is not it refuses to +boot it by name. diff --git a/issues/boot-media/the-bench-reads-no-quiescent-log-volume.md b/issues/boot-media/the-bench-reads-no-quiescent-log-volume.md new file mode 100644 index 00000000000..2117794c999 --- /dev/null +++ b/issues/boot-media/the-bench-reads-no-quiescent-log-volume.md @@ -0,0 +1,19 @@ +--- +status: open +kind: tooling +opened: 2026-09-27 +--- + +# The bench has no quiescent log volume for the outside judge to read + +`toyos-fat32-check` judges the log partition's own bytes, read off the stick +while nothing writes it — Ubuntu, between two ToyOS boots. On the bench the +machine that comes back is ToyOS with that volume mounted and `logd` appending +to it, so no read of it over ssh is a volume at rest, and `toyos-metal` refuses +`--fat32-check` without `--via-ubuntu`. Every metal boot delivered to the bench +is judged without the one reader of those bytes that is not the family of code +that wrote them. + +**Exit**: the bench path reads the log volume at rest — a pass of the loader, +which runs before anything mounts it, or a boot that holds it unmounted — and +`--fat32-check` runs on every bench boot again. diff --git a/issues/boot-media/the-bench-runs-with-no-bound-on-its-own-boot.md b/issues/boot-media/the-bench-runs-with-no-bound-on-its-own-boot.md new file mode 100644 index 00000000000..be747e7f040 --- /dev/null +++ b/issues/boot-media/the-bench-runs-with-no-bound-on-its-own-boot.md @@ -0,0 +1,17 @@ +--- +status: open +kind: defect +opened: 2026-09-27 +--- + +# The bench runs with no bound on its own boot + +Every image a metal boot flashes carries `boot-deadline=`, because a kernel +that stops making progress without panicking is bounded by nothing else on the +T14: its PCH's TCO does not count. The bench is the machine's own image and +stays up between boots, so it carries none, and a bench kernel that hangs +needs a hand on the power button — as the owner's installed machine will. + +**Exit**: a resident kernel's hang ends in a reset on the T14 — a watchdog +that counts there, or a bound that stands down only on progress rather than +at a deadline. diff --git a/issues/boot-media/the-benchs-cable-is-read-by-the-driver-under-test.md b/issues/boot-media/the-benchs-cable-is-read-by-the-driver-under-test.md new file mode 100644 index 00000000000..8b13898b62e --- /dev/null +++ b/issues/boot-media/the-benchs-cable-is-read-by-the-driver-under-test.md @@ -0,0 +1,18 @@ +--- +status: open +kind: tooling +opened: 2026-09-27 +--- + +# On the bench, a boot's cable is read back by the driver it tests + +A `--nic` boot's judge holds the MAC its netd brought up to the MAC the +operating system before it held on that function. Through Ubuntu that was +Linux's `e1000e`, a second driver's reading; on the bench it is the bench's own +netd record (`metalbench::wire`) — the same driver as the boot under test, so +the comparison agrees with itself, and the lease comparison and the ping +bracket are the only independent halves left. + +**Exit**: the cable's facts come from outside the machine again — the switch or +router it leases from, or the frames this host sees on the link — or the lan +judges say which of their checks the bench cannot make. diff --git a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md index 404ebff7983..f48163b169b 100644 --- a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md +++ b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md @@ -36,22 +36,39 @@ stdin — and the machine installs nothing the owner did not sign. (`--owner-key`, `--update-image`, minted by `--signing-key-new`) for an image installed on the owner's machine, whose loader keeps the machine's floor. -## Stage 2 — the T14 installs ToyOS on its NVMe and updates without Ubuntu +## Stage 2 — the T14 updates without Ubuntu, and installs ToyOS on its NVMe -What `toyos-metal` still runs Ubuntu for, each of which this stage replaces: +**Built, and proven in QEMU on an emulated stick:** -1. **Writing the image** — `wipefs` and `dd of=/dev/sda` under a sudoers rule. - Replaced by the machine booting the stick and installing onto its own NVMe, - then `ssh t14 update < image` for every change after. -2. **Choosing the next boot** — `efibootmgr --create-only`, `--delete-bootnum`, - `--bootnext`. The loader already points `BootNext` at itself; an install - writes its own entry once. -3. **Reading a boot's verdict** — `dd if=/dev/sda3` and `mount -o ro` of the log - partition. Replaced by `logd`'s record stream and `ssh … cat`. -4. **Reboots and liveness** — `reboot`, `true`, `date -u +%s`, the `/sys` - identity reads of the stick, and the loop's wait for Ubuntu's sshd to come - back after every ToyOS boot. -5. **The runner key and `ssh t14`** themselves reach Ubuntu's sshd, not ToyOS's. +- **The loader writes the firmware's boot variables** on a request the running + system leaves in the slot table, since nothing after `ExitBootServices` + writes one: `update --boot-first` puts its own entry first in `BootOrder`, + `update --boot-next ` boots another ESP once, and `update --once + < image` boots the idle slot once and never marks it. Each is taken off the + table before it is acted on. +- **A machine with no slot to boot falls to the entry after its own** in + `BootOrder` — its recovery stick. +- **The bench**: `toyos-metal` delivers each boot to a T14 running ToyOS alone + (`tests/benchcase`, `cargo run -- --bench-image `) with `update + --once`, reboots it over ssh, and reads the boot back over the bench's sshd + — the loader's passes of it and its `logd` files — for the same judges. + `--via-ubuntu` is the old path, beside it until the installer. + +**Owed:** + +1. **The T14 switched over**: the bench handed the machine through Ubuntu once + (`toyos-metal --via-ubuntu --resident --image target/bench.img`), and every + metal boot after it delivered to the bench. +2. **The installer**: ToyOS onto the T14's NVMe, blocked on userland storage + file servers (#536), since the kernel owns the only NVMe controller today. + `--via-ubuntu` goes with it. +3. **The owner's last resort**, where no slot and no entry behind it boots: a + stick written on the Mac by this tree's own tooling — `cargo run -- + --write-stick /dev/diskN`, which unmounts the stick's volumes through + DiskArbitration, refuses an internal disk or one of an unexpected size by + name, writes the image and reads it back. Never `dd` or `diskutil`. Not + built; until it is, the last resort is the firmware's boot menu and the + Ubuntu still on the NVMe. **Exit**: a kernel change reaches the T14 and boots with Ubuntu never started; a slot with a flipped byte, no signature or a lower version is refused and the diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 14da5f126ff..6e2c6bcf286 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -346,6 +346,7 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! { match params::slot(cmdline) { (Some(slot), None) => log!("{} {slot}, the one the slot table marks", params::SLOT_RECORD), (Some(slot), Some(refused)) => match refused.split_once(':') { + Some((marked, params::ONCE)) => log!("{} {slot}, once, as the running system asked; the slot table marks {marked}", params::SLOT_RECORD), Some((marked, why)) => log!("{} {slot}, because the marked slot {marked} was refused: {why}", params::SLOT_RECORD), None => log!("{} {slot}, because the marked slot was refused: {refused}", params::SLOT_RECORD), }, diff --git a/kernel/src/params.rs b/kernel/src/params.rs index 8c98cd8feef..e7f1fdc8f82 100644 --- a/kernel/src/params.rs +++ b/kernel/src/params.rs @@ -45,6 +45,13 @@ pub fn claims(token: &str) -> bool { /// The head of the record naming the slot this boot came from. pub const SLOT_RECORD: &str = "boot: slot"; +/// The word the loader puts where a refusal goes when the slot it booted is one +/// the running system asked for once (`slot-refused=:once`): why the +/// marked slot is not this boot's, and no refusal of it. The loader's own +/// spelling is `toyos_update::policy::ONCE`, which `toyos_build::bootlog` +/// holds this one to. +pub const ONCE: &str = "once"; + /// The slot the loader booted, and the marked slot it refused and why, as the /// loader wrote them: the last of each, because the loader appends its words /// after the slot's own parameter and so has the last one. diff --git a/src/bootlog.rs b/src/bootlog.rs index 5e54c80c396..b9bbbb18d3c 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -119,6 +119,20 @@ pub const HANDED_BACK: &str = "the last boot read DONE"; /// The bootloader's own file at the root of the log partition. pub const LOADER_LOG: &str = "loader.log"; +/// Where a pass that starts a new `loader.log` keeps the last one: on a +/// machine that boots itself again after a boot, the one file that still +/// holds that boot's passes when a host can next ask. +pub const LOADER_PREVIOUS_LOG: &str = "loader-previous.log"; + +/// The kernel's record of the ROOT it mounted, which names that ROOT's +/// filesystem: what ties a `logd` file to the image whose boot wrote it. +pub const MOUNTED_FROM_MEMORY: &str = "root: mounted read-only from memory at"; + +/// The loader's line naming itself by the SHA-256 of its file: the one part of +/// a machine no update installs, which a bench's host holds a delivered +/// image's loader to. +pub const LOADER_IS: &str = "Loader: the file firmware loaded hashes to"; + /// That file's first line and its last. pub const LOADER_FIRST_LINE: &str = "ToyOS Bootloader 1.0"; pub const LOADER_LAST_LINE: &str = "Loader log: the kernel handoff begins, so this file ends here"; @@ -564,6 +578,10 @@ mod tests { fn the_loader_writes_the_lines_the_host_reads() { let wanted = [ ("bootloader/src/loaderlog.rs", format!("cstr16!(\"{LOADER_LOG}\")")), + ("bootloader/src/loaderlog.rs", format!("cstr16!(\"{LOADER_PREVIOUS_LOG}\")")), + ("kernel/src/params.rs", format!("\"{}\"", toyos_update::policy::ONCE)), + ("kernel/src/rootfs.rs", format!("\"{MOUNTED_FROM_MEMORY}\"")), + ("bootloader/src/main.rs", format!("\"{LOADER_IS}\"")), ("bootloader/src/loaderlog.rs", format!("\"{LOADER_FIRST_LINE}\"")), ("bootloader/src/loaderlog.rs", format!("\"{LOADER_LAST_LINE}\"")), ("bootloader/src/loaderlog.rs", format!("\"{CHAIN_ENDS_LINE}\"")), diff --git a/src/build.rs b/src/build.rs index a65b75e2709..84a280cee0d 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2002,6 +2002,46 @@ pub fn build_test_image( ) } +/// The bench's config: the T14 running ToyOS between the boots the metal loop +/// delivers to it ([`crate::metalbench`]). +pub const BENCH_CONFIG: &str = "tests/benchcase"; + +/// Where on ROOT sshd reads the keys an image authorizes before any login has +/// installed one — `/system/etc/ssh_authorized_keys` in the guest. +pub const AUTHORIZED_ON_ROOT: &str = "etc/ssh_authorized_keys"; + +/// The room the bench's idle slot has for a delivered image's ROOT: past the +/// largest metal image's, and a small part of a stick. A larger ROOT is +/// refused by `update` by name, so this is a bound that says so and never one +/// that cuts. +pub const BENCH_ROOT_ROOM: u64 = 1 << 30; + +/// **The bench image**: `config` built as a test image is, signed with this +/// checkout's key at `version`, with `room` bytes in the idle slot for a +/// delivered ROOT — [`BENCH_ROOT_ROOM`] on the T14 — and its sshd authorizing +/// exactly the key lines in `authorized`. +/// +/// **Why a key on ROOT and not one installed later.** The bench's `/state` is +/// a tmpfs on the stick — the image has no DATA partition — so a key +/// installed after a boot does not outlive it; and a key the owner's signed +/// update installed would be one every image the owner signs carries. On +/// ROOT, the key is under the image's signature like every other byte of it, +/// and only an image built with this — by the one host that holds the runner +/// key's other half — carries any: no image anything publishes does. +pub fn bench_image(root: &Path, config: &Path, authorized: &str, version: u64, room: u64, quiet: bool) -> Result, String> { + let lines: Vec<&str> = authorized.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() || lines.iter().any(|l| !l.starts_with("ssh-ed25519 ")) { + return Err(format!( + "a bench authorizes ed25519 public keys and nothing else, one per line, and {authorized:?} is not that" + )); + } + let mut plan = Plan::new(crate::arch::Arch::X86_64, &config.join("system.toml"), &[], &[]); + plan.version = version; + plan.second = Some(image::SecondSlot { root_bytes: room }); + let staged = [(AUTHORIZED_ON_ROOT.to_string(), format!("{}\n", lines.join("\n")).into_bytes())]; + Ok(build_test_image(root, &plan, quiet, &staged)) +} + /// The image `ssh … update` takes, built from a plan as a test image is and /// signed with this process's key at the plan's version. pub fn build_update_image(root: &Path, plan: &Plan, quiet: bool, extra_files: &[(String, Vec)]) -> Vec { diff --git a/src/flags.rs b/src/flags.rs index eb8fb50d0c0..0f3892a48c5 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -89,6 +89,10 @@ declare_flags!(pub CARGO_RUN = { /// Write the image `ssh update` takes to this path, signed with /// the owner's key. pub UPDATE_IMAGE = "--update-image", Next; + /// Build the bench — `tests/benchcase`, the T14 running ToyOS between the + /// metal loop's boots — to `target/bench.img`, its sshd authorizing the + /// public keys in this file. + pub BENCH_IMAGE = "--bench-image", Next; }); /// What became of a command line, checked before anything else in `main` runs. diff --git a/src/image.rs b/src/image.rs index e2471bc1e8d..d37c7a63dcc 100644 --- a/src/image.rs +++ b/src/image.rs @@ -253,6 +253,7 @@ pub fn create_boot_image( sequence: 1, marked: toyos_update::slots::Which::A, slots: [Some(slot(a, signing.version)), b.map(|(boot, root, _)| slot((boot, root), 0))], + request: toyos_update::slots::Request::NONE, }; let mut table_volume = vec![0u8; PARTITION_ALIGN]; table_volume[..toyos_update::slots::BLOCK].copy_from_slice(&table.encode()); @@ -381,6 +382,73 @@ pub fn read_file_on(file: &mut std::fs::File, guid: [u8; 16], name: &str) -> Res Ok(bytes) } +/// The update a disk image's marked slot is, as `ssh update` takes +/// it on its input, and what names it. +pub struct UpdateOf { + pub bytes: Vec, + pub version: u64, + /// The SHA-256 of its signed header, which the loader prints of the slot + /// it verified: what ties a pass's lines to this image. + pub digest: toyos_update::Digest, + /// Its ROOT's filesystem UUID, which the kernel names as it mounts it: + /// what ties a boot's log to this image. + pub root: String, + /// The SHA-256 of the loader on its ESP, which no update installs: what + /// the loader a machine runs is held to before this is delivered to it. + pub loader: toyos_update::Digest, +} + +/// **The same bytes the disk image boots, sent rather than flashed**: the +/// marked slot's signed header, kernel and boot parameter off its volume, and +/// ROOT off its partition for exactly the length the header names — each held +/// to the header's hash here, so a disk image whose slot does not verify is +/// refused on this host rather than by the machine's loader. +pub fn update_of(path: &Path) -> Result { + use toyos_update::image::{Header, SIGNED_BYTES}; + let mut file = std::fs::File::open(path).map_err(|e| format!("opening {}: {e}", path.display()))?; + let table = slot_table_of(&mut file)?; + let slot = table.slot(table.marked).expect("a table marks a slot it carries"); + let signed = read_file_on(&mut file, slot.boot, toyos_update::slots::SIGNED_FILE)?; + let header = Header::parse(&signed).map_err(|why| format!("the marked slot's signed header: {why}"))?; + if signed.len() != SIGNED_BYTES { + return Err(format!("the marked slot's signed header is {} bytes, where {SIGNED_BYTES} are one", signed.len())); + } + let kernel = read_file_on(&mut file, slot.boot, toyos_update::slots::KERNEL_FILE)?; + let cmdline = read_file_on(&mut file, slot.boot, toyos_update::slots::CMDLINE_FILE)?; + let (at, len) = partition_extent(&mut file, slot.root)?; + let root_len = header.root().len; + if root_len > len { + return Err(format!("the header names {root_len} bytes of ROOT and its partition holds {len}")); + } + let mut root = vec![0u8; usize::try_from(root_len).map_err(|_| format!("a {root_len}-byte ROOT"))?]; + file.seek(SeekFrom::Start(at)) + .and_then(|_| file.read_exact(&mut root)) + .map_err(|e| format!("reading ROOT at byte {at}: {e}"))?; + for (section, bytes, want) in [ + ("kernel", &kernel, header.kernel()), + ("cmdline", &cmdline, header.cmdline()), + ("ROOT", &root, header.root()), + ] { + if bytes.len() as u64 != want.len || toyos_update::sha256(bytes) != want.sha256 { + return Err(format!("the marked slot's {section} is not the bytes its signed header names")); + } + } + let text = String::from_utf8(cmdline.clone()).map_err(|e| format!("the boot parameter is not text: {e}"))?; + let named = text + .split(',') + .find_map(|word| word.strip_prefix("root=")) + .ok_or_else(|| format!("the boot parameter {text:?} names no ROOT"))? + .to_string(); + let esp = unique_guid_of(&mut file, toyos_gpt::Guid::EFI_SYSTEM)?; + let loader = toyos_update::sha256(&read_file_on(&mut file, esp, Arch::X86_64.removable_loader())?); + let digest = toyos_update::sha256(&signed); + let mut bytes = signed; + bytes.extend_from_slice(&kernel); + bytes.extend_from_slice(&cmdline); + bytes.extend_from_slice(&root); + Ok(UpdateOf { bytes, version: header.version, digest, root: named, loader }) +} + /// Put `update`'s sections into slot `which` of the disk image at `path` and /// mark it — what `/system/bin/update` does on a machine, **with nothing /// checked**: a test's way to put a slot in front of the loader that the diff --git a/src/lib.rs b/src/lib.rs index e44e0c0ced2..916e80bad13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ pub mod lan; pub mod libc; pub mod licence; pub mod metal; +pub mod metalbench; pub mod metaldevices; pub mod metalimage; pub mod metalprofile; diff --git a/src/main.rs b/src/main.rs index d355c342f2e..42009e7ba71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -256,6 +256,22 @@ fn main() { toyos_build::ensure_submodules(&root); } + if let Some(keys) = CARGO_RUN.value(&args, &flags::BENCH_IMAGE) { + let authorized = std::fs::read_to_string(keys).unwrap_or_else(|e| panic!("--bench-image {keys}: {e}")); + let config = root.join(toyos_build::build::BENCH_CONFIG); + let version = toyos_build::image::version_now(); + let bytes = toyos_build::build::bench_image(&root, &config, &authorized, version, toyos_build::build::BENCH_ROOT_ROOM, false) + .unwrap_or_else(|why| panic!("--bench-image {keys}: {why}")); + let out = root.join("target/bench.img"); + std::fs::write(&out, bytes).unwrap_or_else(|e| panic!("write {}: {e}", out.display())); + println!( + "Bench image: {} at version {version}, signed with {}, its sshd authorizing {keys}", + out.display(), + toyos_build::signing::key().fingerprint() + ); + return; + } + // Toolchain included: `build` holds the build lock across both, so no other // agent's clean or bootstrap can land between the two. let plan = toyos_build::build::plan_for(&root, &boot, debug, &args); diff --git a/src/metal.rs b/src/metal.rs index b6c5e539df4..721c9acbddb 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -1,8 +1,15 @@ -//! `toyos-metal` — the loop that flashes ToyOS to the T14's stick, boots it +//! `toyos-metal` — the loop that puts one ToyOS image on the T14, boots it //! once, and answers with [`crate::bootlog`]'s verdict on what that boot wrote -//! to the log partition. +//! to its log. //! -//! It runs on the development host and reaches the machine over `ssh`. +//! **Two paths, one named.** By default the machine runs ToyOS alone — the +//! bench — and each boot is delivered to it and read back over its own sshd +//! ([`crate::metalbench`]). `--via-ubuntu` is the old path this file carries +//! the rest of, until the installer puts ToyOS on the NVMe: Ubuntu flashes +//! the stick and reads the log partition off it. A run takes the path it +//! names and refuses every flag the other alone can carry out. +//! +//! The old path runs on the development host and reaches Ubuntu over `ssh`. //! [`Target::words`] is the one place a root command line is written — the //! installed `/etc/sudoers.d/toyos-metal` and every argv are rendered from it — //! so the loop constructs no command outside [`JOBS`] but the one that puts @@ -27,7 +34,7 @@ use crate::image::LBA; const CONNECT_SECS: u64 = 10; /// How long the machine has to go quiet after `reboot`. -const GOING_DOWN_SECS: u64 = 120; +pub(crate) const GOING_DOWN_SECS: u64 = 120; /// What the machine spends getting back to `sshd` once a ToyOS boot is over: /// the firmware's pass and Ubuntu's own boot. @@ -71,7 +78,7 @@ const PING_WAIT_MS: u64 = 1_000; /// **`ssh` stops answering before the network does**: `reboot` takes `sshd` down /// first and the interface seconds later, so a reply before the silence is the /// operating system that is leaving rather than the image this loop wrote. -const PING_SILENCE_SECS: u64 = 5; +pub(crate) const PING_SILENCE_SECS: u64 = 5; /// How long the boot stick gets to be there again once Ubuntu is up. /// @@ -187,6 +194,14 @@ pub enum Refusal { /// afterwards, each finding by name. Swap(Vec), Usage(String), + /// **The machine running ToyOS would not take the image**: `update` + /// refused it, or was not reached. The image or the loop, never the boot: + /// nothing was rebooted. + Undelivered(String), + /// **What the machine kept is not this boot's**: the loader's passes it + /// kept name another image, so the boot this loop delivered is not the + /// one whose account came back. + NotThisBoot(String), } impl Refusal { @@ -204,6 +219,7 @@ impl Refusal { | Self::Talk(_) | Self::Swap(_) | Self::Wedge { .. } + | Self::NotThisBoot(_) ) } } @@ -345,6 +361,8 @@ impl fmt::Display for Refusal { findings.join("\n ") ), Self::Usage(why) => write!(f, "{why}"), + Self::Undelivered(why) => write!(f, "the machine did not take the image, and nothing was rebooted: {why}"), + Self::NotThisBoot(why) => write!(f, "the machine came back, and {why}"), } } } @@ -549,10 +567,10 @@ fn shell_word(word: &str) -> String { /// Where the loop runs and what it may touch. #[derive(Debug, Clone, PartialEq, Eq)] -struct Target { +pub(crate) struct Target { user: String, host: String, - key: PathBuf, + pub(crate) key: PathBuf, node: Node, /// The image's ESP, the partition the firmware is pointed at. esp_part: u32, @@ -567,7 +585,7 @@ struct Target { impl Target { /// The T14 as the track names it, with the runner key this host holds. - fn t14() -> Result { + pub(crate) fn t14() -> Result { let home = std::env::var_os("HOME").ok_or(Refusal::NoHome)?; Ok(Self { user: "t14".to_string(), @@ -701,7 +719,7 @@ impl Target { /// One partition of the image, in [`LBA`]-byte sectors — the unit `/sys` /// reports `start` and `size` for a disk in. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Part { +pub(crate) struct Part { index: u32, start: u64, sectors: u64, @@ -709,7 +727,7 @@ struct Part { } #[derive(Debug, Clone, PartialEq, Eq)] -struct Flashable { +pub(crate) struct Flashable { path: PathBuf, bytes: u64, esp: Part, @@ -876,7 +894,7 @@ pub fn flash_ruling(name: &str) -> Option { /// The pre-flash gate: what the image is armed with, judged before it is /// written. Read off the image's own ESP, so it answers about the artifact /// rather than about whoever built it. -fn arms_are_admissible(path: &Path) -> Result, Refusal> { +pub(crate) fn arms_are_admissible(path: &Path) -> Result, Refusal> { let armed = crate::image::params_of(path) .map_err(|why| Refusal::File { path: path.display().to_string(), why })?; judge_arms(&armed)?; @@ -916,7 +934,7 @@ pub fn judge_arms(armed: &[String]) -> Result<(), Refusal> { /// Whole sectors, `EFI PART` in the *final* one, and exactly one partition of /// each of the three types at the numbers the installed rule names. -fn admit(path: &Path, target: &Target) -> Result { +pub(crate) fn admit(path: &Path, target: &Target) -> Result { let sector = u64::from(LBA); let unreadable = |why: String| Refusal::File { path: path.display().to_string(), why }; let mut file = std::fs::File::open(path).map_err(|e| unreadable(e.to_string()))?; @@ -1098,7 +1116,7 @@ fn brief_address(iface: &str, text: &str) -> Result /// [`crate::bootlog::MARGIN`]'s first three seconds come from; a round trip /// longer than that, or a host clock that stepped backwards inside it, is /// refused rather than spent. -fn clock_skew(before: u64, said: &str, after: u64) -> Result { +pub(crate) fn clock_skew(before: u64, said: &str, after: u64) -> Result { let took = after.checked_sub(before).ok_or_else(|| { format!("this host's clock read {before} before the machine's and {after} after it") })?; @@ -1125,7 +1143,7 @@ pub struct Reply { pub at: u64, } -struct Ping { +pub(crate) struct Ping { first: std::sync::Arc, String>>>, stop: std::sync::Arc, thread: std::thread::JoinHandle<()>, @@ -1133,7 +1151,7 @@ struct Ping { impl Ping { /// Begin, now: the caller has just watched the machine stop answering `ssh`. - fn start(addr: std::net::Ipv4Addr) -> Self { + pub(crate) fn start(addr: std::net::Ipv4Addr) -> Self { let first = std::sync::Arc::new(std::sync::Mutex::new(Ok(None))); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let (mine, theirs) = (std::sync::Arc::clone(&first), std::sync::Arc::clone(&stop)); @@ -1170,7 +1188,7 @@ impl Ping { } /// Stop probing, and answer what the first reply after the silence was. - fn end(self) -> Result, Refusal> { + pub(crate) fn end(self) -> Result, Refusal> { self.stop.store(true, std::sync::atomic::Ordering::SeqCst); let _ = self.thread.join(); let answer = self.first.lock().expect("the ping's answer").clone(); @@ -1178,7 +1196,7 @@ impl Ping { } } -fn unix_now() -> u64 { +pub(crate) fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("a host clock before 1970 is a host to fix") @@ -1541,12 +1559,20 @@ declare_flags!(METAL = { SWAP = "--swap", Next; BINARY = "--binary", Next; HAND_BACK = "--hand-back", None; + VIA_UBUNTU = "--via-ubuntu", None; + RESIDENT = "--resident", None; }); -/// The flags a swap of a running machine's service refuses beside it: it -/// flashes nothing and reboots nothing, so each of these describes a boot it -/// will not make. -const NOT_A_SWAP: &[&Flag] = &[&DRY_RUN, &FAT32_CHECK, &NIC, &INSTALL_SUDOERS, &IMAGE]; +/// The flags that reach the machine through Ubuntu, and nothing else can mean +/// anything: each is refused without [`VIA_UBUNTU`] rather than ignored. +const UBUNTUS: &[&Flag] = &[&INSTALL_SUDOERS, &FAT32_CHECK, &DEVICE, &HOST, &RESIDENT]; + +/// The flags a swap of a running machine's service refuses beside it: each +/// describes a boot the swap does not judge. `--image` is not among them: on +/// the bench a swapping boot is delivered and judged like any other, and the +/// swap is made by the same loop once the bench has gone down +/// ([`crate::metalbench::run`]). +const NOT_A_SWAP: &[&Flag] = &[&DRY_RUN, &FAT32_CHECK, &NIC, &INSTALL_SUDOERS]; /// The flags that describe a boot, as against the ones that say which machine /// to reach: [`Args::parse`] refuses an `--install-sudoers` beside any of them. @@ -1562,9 +1588,9 @@ pub struct Args { /// in one worktree was a 184 MB image with no job list and no bound. There /// is nothing to fall through to now, and the gate below would refuse that /// image anyway — the loop flashes metal-staged images and nothing else. - image: Option, - target: Target, - dry_run: bool, + pub(crate) image: Option, + pub(crate) target: Target, + pub(crate) dry_run: bool, /// Where the account's password is read from, once, to install the rule. /// /// **An action of its own, and it touches no disk.** It installs, checks @@ -1577,11 +1603,11 @@ pub struct Args { /// The boot-describing flags this command line named, in the order given — /// so a refusal can say which ones rather than that there were some. about_a_boot: Vec<&'static str>, - wait_secs: u64, + pub(crate) wait_secs: u64, /// Where the stick's two files and this boot's own facts are written, for a /// judge that is not this process. Absent leaves the run's only account its /// standard output, which no per-test predicate can be held to. - readback: Option, + pub(crate) readback: Option, /// Read the log partition whole off the stick and hand it to /// `toyos-fat32-check`. The outside judge, and the only reader of that /// volume in this tree that is not the family of code that wrote it. @@ -1590,27 +1616,49 @@ pub struct Args { /// spelling. **A boot names it or the cable is not asked at all**: the reads /// cost four `ssh` round trips and a boot whose judges read no cable would /// be refused for a fact none of them looks at. - nic: Option, + pub(crate) nic: Option, /// The private key the image authorizes, and the ask to talk to the boot /// over its cable: once the machine has gone down, read the log it serves /// at `toyos-t14.local` from its first line, ping it, run one command on it /// and tell it to reboot. **Nothing in the image names this host**: the /// machine answers for its own name, and this host asks for it. - talk: Option, + pub(crate) talk: Option, /// **Replace a running service's binary, and flash and reboot nothing.** /// The service's key; [`Args::binary`] is the new binary, `--talk` the key /// the running machine authorizes — asked for it by name at /// `toyos-t14.local`, so no image needs naming — and `--readback` where the /// stream and the swap's facts are written. - swap: Option, + pub(crate) swap: Option, binary: Option, /// After the swap is judged, whichever way, ask the machine to `reboot` /// over ssh: the host saying it is done with a boot held for it. Absent /// leaves the machine running, which is the development loop. hand_back: bool, + /// **The old path, named**: flash the stick through Ubuntu, choose the + /// next boot with `efibootmgr` and read the log partition off the stick. + /// Absent is the machine running ToyOS and nothing else + /// ([`crate::metalbench`]). One flag and no fallback: a run takes the path + /// it names or refuses, and this flag goes when the T14 has no Ubuntu. + pub via_ubuntu: bool, + /// **Flash a bench image and hand the machine to it** (`--via-ubuntu` + /// only): the image is armed with nothing, boots once through Ubuntu's + /// `efibootmgr --bootnext`, and is then asked over its own sshd to put its + /// entry first in the firmware's order — after which the machine boots + /// ToyOS and Ubuntu does not run. + pub resident: bool, + /// Where the machine running ToyOS is reached: `toyos-t14.local`. + pub machine: Machine, } impl Args { + /// What `--swap` asks, or why this command line asks no whole swap. + pub(crate) fn swap_ask<'a>(&'a self, service: &'a str) -> Result, Refusal> { + let (Some(key), Some(_), Some(binary)) = (&self.talk, &self.readback, &self.binary) else { + return Err(Refusal::Usage("--swap wants --talk, --readback and --binary".into())); + }; + Ok(SwapAsk { service, binary, key, hand_back: self.hand_back }) + } + pub fn parse(args: &[String]) -> Result { let line = METAL.walk(args); if let Some(word) = line.unknown.or_else(|| line.positionals.first().copied()) { @@ -1642,7 +1690,42 @@ impl Args { swap: value(&SWAP).map(str::to_string), binary: value(&BINARY).map(PathBuf::from), hand_back: METAL.present(args, &HAND_BACK), + via_ubuntu: METAL.present(args, &VIA_UBUNTU), + resident: METAL.present(args, &RESIDENT), + machine: Machine::t14(), }; + // **One path per run, named.** A flag that only Ubuntu can carry out, + // on a run that reaches a ToyOS machine, is refused rather than + // dropped. + if !out.via_ubuntu { + let ubuntus: Vec<&str> = line + .seen + .iter() + .map(|seen| seen.flag.name) + .filter(|name| UBUNTUS.iter().any(|flag| flag.name == *name)) + .collect(); + if !ubuntus.is_empty() { + return Err(Refusal::Usage(format!( + "{} reach the machine through Ubuntu, and this run reaches a machine that runs \ + ToyOS; name --via-ubuntu for the old path", + ubuntus.join(" and ") + ))); + } + } + if out.via_ubuntu && out.swap.is_some() { + return Err(Refusal::Usage( + "--swap asks a running ToyOS over its own sshd, and --via-ubuntu names the path that \ + flashes through Ubuntu; a swap has no Ubuntu half" + .to_string(), + )); + } + if out.resident && (out.readback.is_some() || out.talk.is_some() || out.nic.is_some()) { + return Err(Refusal::Usage( + "--resident hands the machine to a bench image and judges no boot, so --readback, \ + --talk and --nic describe a boot it will not judge" + .to_string(), + )); + } if let Some(host) = value(&HOST) { let (user, machine) = host.split_once('@').ok_or_else(|| { Refusal::Usage(format!("--host wants @, not {host:?}")) @@ -1721,12 +1804,52 @@ impl Args { } } +/// Where a machine that runs ToyOS is reached from this host: the log it +/// serves, and its sshd. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Machine { + /// Its log stream, which a talking boot and a swap read. + pub log: crate::metaltalk::Peer, + /// Its sshd, where that is not port 22 of the address its name answers + /// with: a forward onto a guest. + pub ssh: Option, +} + +impl Machine { + /// The T14 as every ToyOS image's netd names it: `toyos-t14.local`. + pub fn t14() -> Self { + Self { + log: crate::metaltalk::Peer::Named { + host: format!("{}.local", crate::lan::HOSTNAME), + port: toyos_logstream::PORT, + }, + ssh: None, + } + } + + /// Its sshd, asked now: a name is resolved at every ask, because the + /// machine answering for it is whichever image booted last. + pub fn ssh_at(&self) -> Result { + use std::net::ToSocketAddrs; + match (&self.ssh, &self.log) { + (Some(at), _) => Ok(*at), + (None, crate::metaltalk::Peer::At(at)) => Ok(std::net::SocketAddr::from((at.ip(), crate::metaltalk::SSH_PORT))), + (None, crate::metaltalk::Peer::Named { host, .. }) => (host.as_str(), crate::metaltalk::SSH_PORT) + .to_socket_addrs() + .map_err(|e| format!("{host} did not resolve: {e}"))? + .find(std::net::SocketAddr::is_ipv4) + .ok_or_else(|| format!("{host} resolved to no IPv4 address")), + } + } +} + /// This host's half of the cable: the client and the key, checked before the /// flash, and the stream and the conversation, started once the machine has /// gone down. -struct Talking { - ssh: crate::metaltalk::Ssh, +pub(crate) struct Talking { + pub(crate) ssh: crate::metaltalk::Ssh, dir: PathBuf, + machine: Machine, } /// Where the stream is written as it arrives, beside the stick's files. @@ -1734,12 +1857,12 @@ pub const READBACK_STREAM: &str = "stream.log"; impl Talking { /// Everything that can refuse before the machine is touched. - fn prepare(key: &Path, dir: &Path) -> Result { + pub(crate) fn prepare(key: &Path, dir: &Path, machine: &Machine) -> Result { std::fs::create_dir_all(dir) .map_err(|e| Refusal::File { path: dir.display().to_string(), why: e.to_string() })?; let root = Path::new(env!("CARGO_MANIFEST_DIR")); let ssh = crate::metaltalk::Ssh::at(root, key.to_path_buf()).map_err(Refusal::Cable)?; - Ok(Self { ssh, dir: dir.to_path_buf() }) + Ok(Self { ssh, dir: dir.to_path_buf(), machine: machine.clone() }) } /// Ask for the log the booting machine serves under its name, then talk @@ -1749,7 +1872,7 @@ impl Talking { /// /// **Called once the machine has gone down**, so the name is asked of the /// boot this loop flashed and not of the operating system it replaced. - fn start( + pub(crate) fn start( &self, by: std::time::Duration, ) -> Result< @@ -1774,25 +1897,24 @@ impl Talking { > { let stream = self.connect(file, by)?; let (theirs, ssh, scratch) = (stream.clone(), self.ssh.clone(), self.dir.join(scratch)); + // A machine reached through a forward is reached through nothing ICMP + // crosses: QEMU's user-mode network carries TCP and UDP alone. + let (ssh_at, ping) = (self.machine.ssh, self.machine.ssh.is_none()); let talking = std::thread::Builder::new() .name("metal-talk".into()) - .spawn(move || crate::metaltalk::converse(&theirs, &ssh, None, true, &scratch)) + .spawn(move || crate::metaltalk::converse(&theirs, &ssh, ssh_at, ping, &scratch)) .expect("the metal loop's conversation could not be started"); Ok((stream, talking)) } - /// The stream itself, asked of the machine's name, for a caller that runs - /// its own protocol on it rather than [`Talking::start`]'s ping/command/ - /// reboot conversation — a swap's own exchange - /// ([`crate::metalswap::swap`]). + /// The stream itself, asked of the machine, for a caller that runs its own + /// protocol on it rather than [`Talking::start`]'s ping/command/reboot + /// conversation — a swap's own exchange ([`crate::metalswap::swap`]). /// /// A swap of the netd carrying it is followed across by /// [`crate::metalswap::swap`] itself ([`crate::metaltalk::Stream::redial`]). fn connect(&self, file: &str, by: std::time::Duration) -> Result { - let peer = crate::metaltalk::Peer::Named { - host: format!("{}.local", crate::lan::HOSTNAME), - port: toyos_logstream::PORT, - }; + let peer = self.machine.log.clone(); println!("asking for {peer:?}'s log"); let at = self.dir.join(file); crate::metaltalk::Stream::connect(peer, &at, true, by, crate::metalswap::TURNED_AWAY_CEILING) @@ -1814,19 +1936,38 @@ pub const READBACK_SWAP: &str = "swap.txt"; /// started before that machine has booted or long after — and read on across /// a swap of netd itself ([`crate::metalswap::swap`]). fn swap_running(args: &Args, service: &str) -> Result<(), Refusal> { - let (Some(key), Some(dir), Some(binary)) = (&args.talk, &args.readback, &args.binary) else { - return Err(Refusal::Usage("--swap wants --talk, --readback and --binary".into())); - }; - // Before anything can refuse: a swap file left standing is one a judge - // reads as this swap's. + let ask = args.swap_ask(service)?; + let dir = args.readback.as_deref().expect("`swap_ask` refuses a swap with no readback"); + clear_swap(dir)?; + swap_on(&args.machine, &ask, dir, std::time::Duration::from_secs(args.wait_secs)) +} + +/// What a swap asks: the service, its new binary, the key the running machine +/// authorizes, and whether the machine is handed back after it. +#[derive(Debug, Clone)] +pub(crate) struct SwapAsk<'a> { + pub(crate) service: &'a str, + pub(crate) binary: &'a Path, + pub(crate) key: &'a Path, + pub(crate) hand_back: bool, +} + +/// Take the last swap's file away before anything can refuse: a swap file +/// left standing is one a judge reads as this swap's. +pub(crate) fn clear_swap(dir: &Path) -> Result<(), Refusal> { let at = dir.join(READBACK_SWAP); match std::fs::remove_file(&at) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(Refusal::File { path: at.display().to_string(), why: e.to_string() }), + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(Refusal::File { path: at.display().to_string(), why: e.to_string() }), } - let cable = Talking::prepare(key, dir)?; - let by = std::time::Duration::from_secs(args.wait_secs); +} + +/// [`swap_running`]'s exchange, for whichever caller found the moment to +/// start it: the machine is dialled from now until `by`. +pub(crate) fn swap_on(machine: &Machine, ask: &SwapAsk<'_>, dir: &Path, by: std::time::Duration) -> Result<(), Refusal> { + let (service, binary) = (ask.service, ask.binary); + let cable = Talking::prepare(ask.key, dir, machine)?; let stream = cable.connect(READBACK_SWAP_STREAM, by)?; let scratch = dir.join("swap"); std::fs::create_dir_all(&scratch) @@ -1835,7 +1976,7 @@ fn swap_running(args: &Args, service: &str) -> Result<(), Refusal> { let swapped = crate::metalswap::swap( &stream, &cable.ssh, - None, + machine.ssh, &crate::metalswap::Ask { service, binary, named: None }, by, &scratch, @@ -1854,12 +1995,13 @@ fn swap_running(args: &Args, service: &str) -> Result<(), Refusal> { let judged = crate::metalswap::judge(&swapped, crate::metalswap::Expect::InService); // Whichever way it was judged: a boot held for this host is handed back // either way, over a connection held until the machine drops it. - if args.hand_back { - let at = match stream.peer() { - Some(std::net::SocketAddr::V4(peer)) => std::net::SocketAddr::from((*peer.ip(), crate::metaltalk::SSH_PORT)), - _ => std::net::SocketAddr::from((swapped.peer, crate::metaltalk::SSH_PORT)), + if ask.hand_back { + let at = match (machine.ssh, stream.peer()) { + (Some(at), _) => at, + (None, Some(std::net::SocketAddr::V4(peer))) => std::net::SocketAddr::from((*peer.ip(), crate::metaltalk::SSH_PORT)), + (None, _) => std::net::SocketAddr::from((swapped.peer, crate::metaltalk::SSH_PORT)), }; - let asked = cable.ssh.exec(at, crate::metaltalk::REBOOT, &scratch); + let asked = cable.ssh.fire(at, crate::metaltalk::REBOOT); println!("handed back: `{}` at {at} answered {asked:?}", crate::metaltalk::REBOOT); } for line in judged.map_err(Refusal::Swap)? { @@ -1948,7 +2090,7 @@ pub fn run(args: &Args) -> Result, Refusal> { install_sudoers(&args.target, password)?; return Ok(None); } - if let Some(service) = &args.swap { + if let (Some(service), None) = (&args.swap, &args.image) { return swap_running(args, service).map(|()| None); } let driver = Driver { target: args.target.clone(), dry_run: args.dry_run }; @@ -1960,6 +2102,19 @@ pub fn run(args: &Args) -> Result, Refusal> { boots nothing could fall through to", ))); }; + if !args.via_ubuntu { + let Some(dir) = &args.readback else { + return Err(Refusal::Usage( + "a boot of a machine running ToyOS is read back over its sshd into --readback, and this \ + names none" + .to_string(), + )); + }; + return crate::metalbench::run(args, asked, dir); + } + if args.resident { + return hand_to_the_bench(args, &driver, asked).map(|()| None); + } let image = admit(asked, &args.target)?; // **Before the flash, and before anything can refuse.** Every refusal below // returns without reaching `write_readback`, so a directory left holding the @@ -1985,7 +2140,7 @@ pub fn run(args: &Args) -> Result, Refusal> { println!("image {}: armed with {armed:?}", image.path.display()); // The client and its key before the machine is asked anything. let cable = match (&args.talk, &args.readback) { - (Some(key), Some(dir)) => Some(Talking::prepare(key, dir)?), + (Some(key), Some(dir)) => Some(Talking::prepare(key, dir, &args.machine)?), _ => None, }; @@ -2099,6 +2254,16 @@ pub fn run(args: &Args) -> Result, Refusal> { write_readback(dir, &loader, &log, back, stick, wire.as_ref(), replied)?; println!("readback written to {}", dir.display()); } + judge(&armed, &loader, &log, heard.as_ref()) +} + +/// What a talking boot's conversation came to, and the stream it read. +pub(crate) type Heard = (Result, Vec); + +/// **The verdict on one boot, whichever path read it**: the loader's passes of +/// it and every `logd` file it wrote, judged against what the image is armed +/// with, and the conversation where it talked. +pub(crate) fn judge(armed: &[String], loader: &str, log: &str, heard: Option<&Heard>) -> Result, Refusal> { // **Named by evidence, before the boot record is missed.** A boot that // never happened and a boot that failed both leave no `Boot: complete`, // and `Unfit::NoBootRecord` says the second where it is often the first. @@ -2112,7 +2277,7 @@ pub fn run(args: &Args) -> Result, Refusal> { if loader.contains(bootlog::HUNG_WITHOUT_A_RECORD) { return Err(Refusal::HungWithoutARecord); } - if let Some(said) = reported_and_booted_nothing(&loader, &log) { + if let Some(said) = reported_and_booted_nothing(loader, log) { return Err(Refusal::ReportedAndBootedNothing { said }); } // **An image armed to stop itself is judged by the record its own bound @@ -2122,21 +2287,52 @@ pub fn run(args: &Args) -> Result, Refusal> { // it was not flashed as. *Which* bound sealed it is the page's to say // and not this list's — the arm says a bound was staged, and two of them // can reach a staged boot. - let ms = if stages_a_wedge(&armed) { - wedged_boot(&loader, &log)? + let ms = if stages_a_wedge(armed) { + wedged_boot(loader, log)? } else { - let ms = bootlog::verdict(&log).map_err(Refusal::Log)?; - bootlog::handed_back(&loader).map_err(Refusal::Log)?; + let ms = bootlog::verdict(log).map_err(Refusal::Log)?; + bootlog::handed_back(loader).map_err(Refusal::Log)?; ms }; // After the stick's own verdict, which stays the one that names a boot // that never reached its network. - if let Some((heard, lines)) = &heard { + if let Some((heard, lines)) = heard { talk_verdict(heard, lines)?; } Ok(Some(ms)) } +/// **Hand the machine to a bench image, once, through Ubuntu**: the image is +/// armed with nothing — it is the machine's own from here on, with no job list +/// and no bound, as every image the owner runs — flashed, booted once with +/// `efibootmgr --bootnext`, and then asked over its own sshd to put its entry +/// first in the firmware's order ([`crate::metalbench::take_the_machine`]). +/// Ubuntu is not booted again by the firmware after this. +fn hand_to_the_bench(args: &Args, driver: &Driver, asked: &Path) -> Result<(), Refusal> { + let image = admit(asked, &args.target)?; + let armed = crate::image::params_of(asked).map_err(|why| Refusal::File { path: asked.display().to_string(), why })?; + if let Some(name) = armed.first() { + return Err(Refusal::Armed { + name: name.clone(), + why: "a bench image is the machine's own, and is armed with nothing", + }); + } + driver.require_sudo()?; + let identity = Identity::parse(&driver.ssh("reading the disk", &args.target.identity())?)?; + identity.check(&STICK)?; + driver.flash(&image)?; + let entry = driver.boot_entry(&image.esp)?; + driver.as_root("setting bootnext", Job::BootNext, Some(&entry), None)?; + driver.as_root("rebooting", Job::Reboot, None, None)?; + if driver.dry_run { + println!("dry run: nothing was written and the machine was not rebooted"); + return Ok(()); + } + driver.wait(GOING_DOWN_SECS, "go down", false)?; + let scratch = std::env::temp_dir().join(format!("toyos-metal-bench-{}", std::process::id())); + crate::metalbench::take_the_machine(&args.target.key, &args.machine, args.wait_secs, &scratch) +} + /// Where a conversation's facts are written, beside the stick's files. pub const READBACK_TALK: &str = "talk.txt"; @@ -2144,7 +2340,7 @@ pub const READBACK_TALK: &str = "talk.txt"; /// reason — a human's line; a judge reads the absence of a conversation. const TALK_UNOPENED: &str = "talk_unopened"; -fn write_talk( +pub(crate) fn write_talk( dir: &Path, heard: &Result, ) -> Result<(), Refusal> { @@ -2157,7 +2353,7 @@ fn write_talk( .map_err(|e| Refusal::File { path: at.display().to_string(), why: e.to_string() }) } -fn talk_verdict( +pub(crate) fn talk_verdict( heard: &Result, lines: &[String], ) -> Result<(), Refusal> { @@ -2187,7 +2383,7 @@ fn talk_verdict( /// other can reach first, and the T14 read back a `hard-lockup-probe` boot as a /// failure for exactly that reason — this judge knew one line and the page /// carried the other. -fn wedged_boot(loader: &str, log: &str) -> Result { +pub(crate) fn wedged_boot(loader: &str, log: &str) -> Result { if bootlog::handed_back(loader).is_ok() { return Err(Refusal::Wedge { why: "it reached the shutdown's own last word, so nothing about it was wedged", @@ -2289,8 +2485,15 @@ pub const PING_AT_KEY: &str = "ping_at"; /// Every file a readback directory carries, so a run that writes none of them /// leaves none of the last run's behind. -pub const READBACK_FILES: &[&str] = - &[READBACK_LOADER, READBACK_KERNEL, READBACK_BOOT, READBACK_VOLUME, READBACK_STREAM, READBACK_TALK]; +pub const READBACK_FILES: &[&str] = &[ + READBACK_LOADER, + READBACK_KERNEL, + READBACK_BOOT, + READBACK_VOLUME, + READBACK_STREAM, + READBACK_TALK, + crate::metalbench::READBACK_MACHINE, +]; /// Empty a readback directory, before this run can leave any of it standing. /// @@ -2317,7 +2520,7 @@ pub fn clear_readback(dir: &Path) -> Result<(), Refusal> { Ok(()) } -fn write_readback( +pub(crate) fn write_readback( dir: &Path, loader: &str, log: &str, @@ -2544,7 +2747,7 @@ mod tests { /// with no job list and no bound. #[test] fn installing_the_rule_is_not_also_a_boot() { - let alone = ["--install-sudoers", "/tmp/pw"].map(String::from); + let alone = ["--via-ubuntu", "--install-sudoers", "/tmp/pw"].map(String::from); let args = Args::parse(&alone).expect("installing the rule alone"); assert!(args.install_sudoers.is_some()); assert!(args.about_a_boot.is_empty()); @@ -2558,7 +2761,7 @@ mod tests { vec!["--nic", "0000:00:1f.6"], vec!["--talk", "/tmp/k"], ] { - let mut words = vec!["--install-sudoers".to_string(), "/tmp/pw".to_string()]; + let mut words = vec!["--via-ubuntu".to_string(), "--install-sudoers".to_string(), "/tmp/pw".to_string()]; words.extend(flag.iter().map(|w| (*w).to_string())); let refusal = Args::parse(&words).unwrap_err(); let said = refusal.to_string(); @@ -2570,7 +2773,7 @@ mod tests { // The flags that say *which machine* are not a boot, and the rule needs // them: an install against another host or key is still an install. - let hosted = ["--install-sudoers", "/tmp/pw", "--host", "dev@t14", "--key", "/tmp/k"] + let hosted = ["--via-ubuntu", "--install-sudoers", "/tmp/pw", "--host", "dev@t14", "--key", "/tmp/k"] .map(String::from); assert!(Args::parse(&hosted).is_ok()); } @@ -2586,10 +2789,10 @@ mod tests { assert!(!refusal.about_the_boot()); } - /// **A swap flashes nothing and reboots nothing**, so every flag that - /// describes a boot is refused beside it — `--image` among them, since the - /// machine is found by its name and not by the image it is running — and - /// it is refused without the three things it acts with. A `--binary` or a + /// **A swap judges no boot of its own**, so every flag that describes one + /// is refused beside it — but `--image`, which on the bench is the boot + /// the same loop delivers and swaps in — and it is refused without the + /// three things it acts with. A `--binary` or a /// `--hand-back` with no swap is no swap. #[test] fn a_swap_is_not_a_boot_and_names_what_it_acts_with() { @@ -2598,12 +2801,7 @@ mod tests { let args = Args::parse(&whole).expect("a whole swap"); assert_eq!(args.swap.as_deref(), Some("netd")); - for flag in [ - vec!["--fat32-check"], - vec!["--dry-run"], - vec!["--nic", "0000:00:1f.6"], - vec!["--image", "x.img"], - ] { + for flag in [vec!["--dry-run"], vec!["--nic", "0000:00:1f.6"]] { let mut words = whole.to_vec(); words.extend(flag.iter().map(|w| (*w).to_string())); let said = Args::parse(&words).unwrap_err().to_string(); @@ -2619,6 +2817,11 @@ mod tests { .collect(); assert!(Args::parse(&words).is_err(), "a swap without {missing} was taken"); } + // On the bench a swapping boot is one invocation: the image it + // delivers, and the swap the same loop makes once the bench is down. + let mut boot = whole.to_vec(); + boot.extend(["--image", "x.img"].map(String::from)); + assert!(Args::parse(&boot).is_ok(), "a bench's swapping boot"); let lone = ["--binary", "n"].map(String::from); assert!(Args::parse(&lone).unwrap_err().to_string().contains("--swap")); let lone = ["--hand-back"].map(String::from); @@ -2631,6 +2834,42 @@ mod tests { assert!(Args::parse(&bent).unwrap_err().to_string().contains("no service")); } + /// **The old path is one flag and nothing falls back to it**: every flag + /// only Ubuntu can carry out is refused by name on a run that reaches a + /// machine running ToyOS, a swap has no Ubuntu half, a bench is handed the + /// machine by nothing but the old path and judged by nothing, and a boot of + /// a machine running ToyOS is read into a readback or not run. + #[test] + fn the_ubuntu_path_is_named_and_nothing_falls_back_to_it() { + for flag in [ + vec!["--install-sudoers", "/tmp/pw"], + vec!["--fat32-check"], + vec!["--device", "/dev/sdb"], + vec!["--host", "t14@t14"], + vec!["--resident"], + ] { + let words: Vec = flag.iter().map(|w| (*w).to_string()).collect(); + let said = Args::parse(&words).unwrap_err().to_string(); + assert!(said.contains(flag[0]) && said.contains("--via-ubuntu"), "{said}"); + let mut named = vec!["--via-ubuntu".to_string()]; + named.extend(words); + assert!(Args::parse(&named).is_ok(), "{flag:?} beside --via-ubuntu"); + } + let swap = ["--via-ubuntu", "--swap", "netd", "--binary", "n", "--talk", "/tmp/k", "--readback", "/tmp/r"] + .map(String::from); + assert!(Args::parse(&swap).unwrap_err().to_string().contains("no Ubuntu half")); + for beside in [vec!["--readback", "/tmp/r"], vec!["--nic", "0000:00:1f.6"]] { + let mut words = vec!["--via-ubuntu", "--resident", "--image", "b.img"]; + words.extend(beside.iter().copied()); + let words: Vec = words.iter().map(|w| (*w).to_string()).collect(); + assert!(Args::parse(&words).unwrap_err().to_string().contains("judges no boot"), "{beside:?}"); + } + let unread = ["--image", "x.img"].map(String::from); + let refusal = run(&Args::parse(&unread).expect("a boot of the bench")).unwrap_err(); + assert!(refusal.to_string().contains("--readback"), "{refusal}"); + assert!(!refusal.about_the_boot()); + } + /// **There is nothing to fall through to.** A default image was what let the /// fall-through reach a disk at all; a command line that names none is /// refused by name rather than given one. @@ -3192,7 +3431,7 @@ mod tests { assert_eq!(args.wait_secs, return_secs()); assert!(!args.dry_run); - let words: Vec = ["--dry-run", "--device", "/dev/sdb", "--host", "runner@box"] + let words: Vec = ["--via-ubuntu", "--dry-run", "--device", "/dev/sdb", "--host", "runner@box"] .iter() .map(|w| (*w).to_string()) .collect(); @@ -3202,9 +3441,9 @@ mod tests { assert_eq!(args.target.user, "runner"); assert_eq!(args.target.host, "box"); - let nvme = ["--device".to_string(), "/dev/nvme0n1".to_string()]; + let nvme = ["--via-ubuntu", "--device", "/dev/nvme0n1"].map(String::from); assert_eq!(Args::parse(&nvme).unwrap().target.node.whole(), "/dev/nvme0n1"); - let part = ["--device".to_string(), "/dev/nvme0n1p2".to_string()]; + let part = ["--via-ubuntu", "--device", "/dev/nvme0n1p2"].map(String::from); assert_eq!(Args::parse(&part), Err(Refusal::Node("/dev/nvme0n1p2".to_string()))); assert!(matches!(Args::parse(&["--image".to_string()]), Err(Refusal::Usage(_)))); assert!(matches!(Args::parse(&["--flash".to_string()]), Err(Refusal::Usage(_)))); diff --git a/src/metalbench.rs b/src/metalbench.rs new file mode 100644 index 00000000000..0415bc53d94 --- /dev/null +++ b/src/metalbench.rs @@ -0,0 +1,534 @@ +//! `toyos-metal` against a machine that runs ToyOS and nothing else: the +//! bench. +//! +//! **The machine keeps one image and tries another once.** The bench image is +//! the slot the table marks: sshd authorizing this host's runner key, +//! `update`, and nothing staged. A boot this loop judges is delivered as +//! `ssh update --once < image` into the idle slot, which the loader +//! boots at the next reboot and never again (`toyos_update::slots::Request`); +//! the boot runs its job list and hands the machine back, and the machine +//! comes back as the bench, from which the boot's files are read over the +//! same sshd. So every Ubuntu step of [`crate::metal`]'s old path has a ToyOS +//! one here: +//! +//! | Ubuntu | here | +//! |-----------------------------------------|-----------------------------------------------| +//! | `wipefs` and `dd` of the stick | `update --once` of the marked slot's sections | +//! | `efibootmgr --bootnext` | the loader's own once, asked by `update` | +//! | `dd` and `mount` of the log partition | `/log` fetched over sftp in one session | +//! | `reboot`, `true`, `date -u +%s` | `reboot`, the runner key taken, `date -u +%s` | +//! | the stick's `/sys` identity | `update`'s grant — the idle slot of the disk | +//! | | the machine runs from — and its loader's hash | +//! | waiting for Ubuntu's sshd | waiting for `/log` to come back over the key | +//! +//! **The judges are the old path's, over the same two texts**: the loader's +//! passes of this boot — kept by the pass after them as `loader-previous.log`, +//! because the bench's own pass starts a new `loader.log` — and every `logd` +//! file of this boot, told from the bench's own by the ROOT this image mounts. +//! Each is held to this image before it is judged: the loader's by the signed +//! header's digest it verified, the kernel's by the ROOT UUID it names. A +//! volume's raw bytes are the one thing not read: the bench has the log +//! partition mounted, so no read of it here is a quiescent volume, and +//! `--fat32-check` is the old path's alone +//! (`issues/boot-media/the-bench-reads-no-quiescent-log-volume.md`). +//! +//! **One connection where one will do**: the bench's listener resets a +//! connect that lands between two of its accepts +//! (`issues/hardware/a-connect-between-two-accepts-is-reset.md`), so `/log` +//! is read whole in one session, and the session that reads it after the boot +//! is also the event that says the bench is back. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::bootlog; +use crate::metal::{self, Args, Machine, Refusal, Wire}; +use crate::metaltalk::Ssh; + +/// How long one probe of a machine's sshd waits for the key to be taken: a +/// machine that is up answers in well under a second, and one that is booting +/// refuses the connection outright. +const PROBE_SECS: u64 = 10; + +/// Between two asks of a machine this loop is waiting on. +const ASK_EVERY: Duration = Duration::from_secs(2); + +/// What `update` says of an image it wrote to be booted once. +const ONCE: &str = "it boots once at the next reboot"; + +/// Where the machine's files are, on the machine. +const LOG_DIR: &str = "/log"; + +/// Where what the bench said about itself before the boot is written, beside +/// the stick's files. +pub const READBACK_MACHINE: &str = "machine.txt"; + +/// One machine reached with one key: the client, the key, and where. +pub struct Bench { + ssh: Ssh, + machine: Machine, + scratch: PathBuf, +} + +/// `/log` as one session fetched it onto this host. +struct Logs { + dir: PathBuf, + names: Vec, +} + +impl Logs { + /// A file of it, as text, or the refusal that the machine keeps none. + fn read(&self, name: &str) -> Result { + if !self.names.iter().any(|n| n == name) { + return Err(Refusal::NotThisBoot(format!("{LOG_DIR} holds no {name}, of {:?}", self.names))); + } + let at = self.dir.join(name); + std::fs::read(&at) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .map_err(|e| Refusal::File { path: at.display().to_string(), why: e.to_string() }) + } + + /// `logd`'s files, in the order theirs sort. + fn logd(&self) -> Vec<&str> { + let mut logd: Vec<&str> = + self.names.iter().map(String::as_str).filter(|name| bootlog::is_logd_file(name)).collect(); + logd.sort_unstable(); + logd + } +} + +impl Bench { + /// The client and the key, refused by name where either is missing. + pub fn prepare(key: &Path, machine: &Machine, scratch: &Path) -> Result { + std::fs::create_dir_all(scratch) + .map_err(|e| Refusal::File { path: scratch.display().to_string(), why: e.to_string() })?; + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let ssh = Ssh::at(root, key.to_path_buf()).map_err(Refusal::Cable)?; + Ok(Self { ssh, machine: machine.clone(), scratch: scratch.to_path_buf() }) + } + + /// Where the machine's sshd is now, or why no address answers for it. + fn at(&self) -> Result { + self.machine.ssh_at().map_err(|why| Refusal::Remote { + what: "finding the machine".to_string(), + status: "had no address".to_string(), + stderr: why, + }) + } + + /// Run `command` on the machine and hold it to status 0: its output. + fn exec(&self, what: &str, command: &str) -> Result { + let at = self.at()?; + let exec = self.ssh.exec(at, command, &self.scratch).map_err(|why| Refusal::Remote { + what: what.to_string(), + status: format!("was not answered at {at}"), + stderr: why, + })?; + let said = String::from_utf8_lossy(&exec.stdout).to_string(); + if exec.status != Some(0) { + return Err(Refusal::Remote { what: what.to_string(), status: format!("ended {:?}", exec.status), stderr: said }); + } + Ok(said) + } + + /// The machine's `/log`, whole, into `into` over one session. + fn fetch(&self, into: &Path) -> Result { + let at = self.machine.ssh_at()?; + let _ = std::fs::remove_dir_all(into); + let names = self.ssh.fetch(at, LOG_DIR, into)?.into_iter().map(|(name, _)| name).collect(); + Ok(Logs { dir: into.to_path_buf(), names }) + } + + /// Whether the machine takes this key now: a machine not up, one booting, + /// and a boot that authorizes another key are all "not yet". + fn answers(&self) -> bool { + self.machine.ssh_at().is_ok_and(|at| self.ssh.probe(at, PROBE_SECS).is_ok()) + } + + /// Wait until [`Bench::answers`] is `answering`, within `secs`; how long + /// that took. Each probe is a wait on the machine's own answer, bounded by + /// [`PROBE_SECS`], and the next is asked [`ASK_EVERY`] after it. + fn wait(&self, secs: u64, what: &'static str, answering: bool) -> Result { + let began = std::time::Instant::now(); + while began.elapsed().as_secs() < secs { + if self.answers() == answering { + return Ok(began.elapsed().as_secs()); + } + std::thread::sleep(ASK_EVERY); + } + Err(Refusal::Silent { what, secs }) + } + + /// Wait until the machine's `/log` comes back over this key, within + /// `secs`: the bench taking the runner key and the boot's files, in the + /// one session. How long it took, and the files. + fn wait_for_the_log(&self, secs: u64, what: &'static str, into: &Path) -> Result<(u64, Logs), Refusal> { + let began = std::time::Instant::now(); + while began.elapsed().as_secs() < secs { + if let Ok(logs) = self.fetch(into) { + return Ok((began.elapsed().as_secs(), logs)); + } + std::thread::sleep(ASK_EVERY); + } + Err(Refusal::Silent { what, secs }) + } +} + +/// **The bench's loader is the one this image was built with**, or nothing is +/// delivered: an update installs a kernel and ROOT and never the loader, so a +/// boot under another loader is a boot of a different machine than the image +/// describes — and every judge of a loader's own lines would be reading the +/// wrong one. The bench's own pass names its loader by its file's hash. +fn same_loader(logs: &Logs, image: &toyos_update::Digest) -> Result<(), Refusal> { + let now = logs.read(bootlog::LOADER_LOG)?; + let said = now + .lines() + .find_map(|l| l.split(bootlog::LOADER_IS).nth(1)) + .map(str::trim) + .ok_or_else(|| Refusal::Undelivered(format!("the bench's {} names no loader: {:?}", bootlog::LOADER_LOG, bootlog::LOADER_IS)))?; + let mut hex = [0u8; 64]; + let ours = toyos_update::hex(image, &mut hex); + if said != ours { + return Err(Refusal::Undelivered(format!( + "the bench runs the loader {said} and this image was built with {ours}: an update installs no loader, \ + so a boot of this image on this bench would run under a loader it was not built with. Build the \ + bench from this tree and hand the machine to it again (`toyos-metal --via-ubuntu --resident`)" + ))); + } + println!("the bench runs this image's own loader, {ours}"); + Ok(()) +} + +/// The cable's facts off the bench before the boot: the address its sshd +/// answers at, the MAC netd brought up — out of the bench's own log, since no +/// other operating system is there to ask +/// (`issues/boot-media/the-benchs-cable-is-read-by-the-driver-under-test.md`) +/// — and its clock against this one's. +fn wire(bench: &Bench, logs: &Logs, nic: &str) -> Result { + let bad = |why: String| Refusal::Wire { nic: nic.to_string(), why }; + let at = bench.at()?; + let std::net::IpAddr::V4(addr) = at.ip() else { + return Err(bad(format!("the bench answers at {at}, which is no IPv4 address"))); + }; + let newest = *logs.logd().last().ok_or_else(|| bad("the bench's /log holds no file of logd's".to_string()))?; + let netd = crate::lan::netd_records(&logs.read(newest)?); + let mac = netd + .lines() + .find_map(|line| line.split(crate::lan::MAC).nth(1)) + .map(|rest| rest.split_whitespace().next().unwrap_or_default().to_ascii_lowercase()) + .ok_or_else(|| bad(format!("the bench's {newest} carries no {:?} record of netd's", crate::lan::MAC)))?; + let before = metal::unix_now(); + let said = bench.exec("reading the machine's own clock", "date -u +%s").map_err(|e| bad(e.to_string()))?; + let skew = metal::clock_skew(before, &said, metal::unix_now()).map_err(bad)?; + Ok(Wire { iface: "netd".to_string(), addr, mac, skew }) +} + +/// The logd files of the boot that mounted `root`, in order, as one text; the +/// empty text where no file names it — a boot that never reached `logd`. +/// +/// **Told from the bench's own by the ROOT it mounted.** Every boot's first +/// part names its ROOT's filesystem (`kernel/src/rootfs.rs`), and an image's +/// ROOT UUID is its own, so a file is this boot's by its content, never by +/// being the one before the newest. +fn kernel_log(logs: &Logs, root: &str) -> Result { + let mounted = format!("filesystem {root},"); + let logd = logs.logd(); + let mut stems: Vec<&str> = logd.iter().map(|name| stem(name)).collect(); + stems.dedup(); + for boot in stems.iter().rev() { + let parts: Vec<&str> = logd.iter().copied().filter(|name| stem(name) == *boot).collect(); + let first = logs.read(parts[0])?; + if !first.lines().any(|l| l.contains(bootlog::MOUNTED_FROM_MEMORY) && l.contains(&mounted)) { + continue; + } + let mut text = first; + for part in &parts[1..] { + text.push_str(&logs.read(part)?); + } + return Ok(text); + } + Ok(String::new()) +} + +/// A logd file's boot: its name without the part number and the extension. +fn stem(name: &str) -> &str { + let bare = name.strip_suffix(".log").unwrap_or(name); + match bare.rsplit_once('_') { + Some((stem, part)) if part.len() == 4 && part.bytes().all(|b| b.is_ascii_digit()) => stem, + _ => bare, + } +} + +/// The loader's passes of this boot: the file the bench's own pass kept of +/// the chain before it, held to the signed header this image carries. +fn loader_log(logs: &Logs, digest: &toyos_update::Digest) -> Result { + let text = logs.read(bootlog::LOADER_PREVIOUS_LOG)?; + let mut hex = [0u8; 64]; + let named = format!("signed header {} verifies", toyos_update::hex(digest, &mut hex)); + if !text.contains(&named) { + return Err(Refusal::NotThisBoot(format!( + "{} does not name this image's signed header ({named:?}): the passes the machine kept are \ + another boot's, and this image's were never the machine's to keep", + bootlog::LOADER_PREVIOUS_LOG + ))); + } + Ok(text) +} + +/// What the thread that asked the delivered boot for something came back with. +enum Reached { + Swapped(Result<(), Refusal>), + Talked(metal::Heard), +} + +/// **One boot, judged**: the image delivered once, the machine rebooted into +/// it, back as the bench, the boot's files read and judged by the old path's +/// judges. +pub fn run(args: &Args, image: &Path, dir: &Path) -> Result, Refusal> { + metal::admit(image, &metal::Target::t14()?)?; + let armed = metal::arms_are_admissible(image)?; + let update = crate::image::update_of(image) + .map_err(|why| Refusal::File { path: image.display().to_string(), why })?; + // Before anything can refuse: a directory left holding the last run's + // files is one a judge reads as this run's. + metal::clear_readback(dir)?; + let mut hex = [0u8; 64]; + println!( + "image {}: {} bytes as an update, version {}, signed header {}, ROOT {}; armed with {armed:?}", + image.display(), + update.bytes.len(), + update.version, + toyos_update::hex(&update.digest, &mut hex), + update.root + ); + // A swapping boot's key is the swap's: the swap owns the machine's + // stream, and a conversation beside it would hand the machine back under + // it. + let swap = match &args.swap { + Some(service) => { + let ask = args.swap_ask(service)?; + metal::clear_swap(dir)?; + Some(ask) + } + None => None, + }; + let cable = match (&args.talk, &swap) { + (Some(key), None) => Some(metal::Talking::prepare(key, dir, &args.machine)?), + _ => None, + }; + let bench = Bench::prepare(&args.target.key, &args.machine, &dir.join("bench"))?; + + // The bench before the boot, in one session: that it takes the runner + // key, which loader it runs, and — for a boot that names its cable — what + // its netd brought up. + let at = bench.at()?; + let before = bench.fetch(&dir.join("bench").join("before")).map_err(|why| Refusal::Remote { + what: "reading the bench's /log".to_string(), + status: format!("was not answered at {at}"), + stderr: why, + })?; + same_loader(&before, &update.loader)?; + let wire = match &args.nic { + Some(nic) => { + let wire = wire(&bench, &before, nic)?; + println!("the bench holds {} for netd, MAC {}, its clock {} s from this host's", wire.addr, wire.mac, wire.skew); + Some(wire) + } + None => None, + }; + std::fs::write(dir.join(READBACK_MACHINE), format!("bench_at {at}\n")) + .map_err(|e| Refusal::File { path: dir.join(READBACK_MACHINE).display().to_string(), why: e.to_string() })?; + + let sent = dir.join("image.update"); + std::fs::write(&sent, &update.bytes) + .map_err(|e| Refusal::File { path: sent.display().to_string(), why: e.to_string() })?; + if args.dry_run { + println!(" would run: update --once < {} ({} bytes), then reboot", sent.display(), update.bytes.len()); + let _ = std::fs::remove_file(&sent); + println!("dry run: nothing was written and the machine was not rebooted"); + return Ok(None); + } + let delivered = bench.ssh.pipe(at, "update --once", &sent, &bench.scratch); + let _ = std::fs::remove_file(&sent); + let delivered = delivered.map_err(|why| Refusal::Undelivered(format!("`update --once` was not answered: {why}")))?; + let said = String::from_utf8_lossy(&delivered.stdout).to_string(); + let installed = format!("update: installed version {} in slot", update.version); + if delivered.status != Some(0) || !said.contains(&installed) || !said.contains(ONCE) { + return Err(Refusal::Undelivered(format!("`update --once` ended {:?} saying {}", delivered.status, said.trim()))); + } + print!(" {said}"); + let asked = bench.ssh.fire(at, crate::metaltalk::REBOOT).map_err(|why| Refusal::Remote { + what: "rebooting".to_string(), + status: "was not answered".to_string(), + stderr: why, + })?; + println!(" `{}` at {at} answered {asked}", crate::metaltalk::REBOOT); + + let by = Duration::from_secs(args.wait_secs); + bench.wait(metal::GOING_DOWN_SECS, "go down", false)?; + let ping = wire.as_ref().map(|w| metal::Ping::start(w.addr)); + // **A talking or swapping boot is asked for nothing until it answers as + // itself**: its sshd taking the key the boot authorizes, which the bench's + // does not. Until then the machine's name and every forward onto it may + // be the bench's, and a stream asked for then is the wrong boot's or none. + let boot_key = swap.as_ref().map(|ask| ask.key).or(cable.as_ref().and(args.talk.as_deref())); + let (swap, cable) = (&swap, &cable); + let after = dir.join("bench").join("after"); + let (back, reached) = std::thread::scope(|scope| { + let reaching = boot_key.map(|key| { + scope.spawn(move || -> Result { + let boot = Bench::prepare(key, &args.machine, &dir.join("boot"))?; + boot.wait(args.wait_secs, "answer as the delivered boot", true)?; + match (swap, cable) { + (Some(ask), _) => Ok(Reached::Swapped(metal::swap_on(&args.machine, ask, dir, by))), + (None, Some(cable)) => Ok(Reached::Talked(match cable.start(by) { + Ok((stream, handle)) => { + let heard = + handle.join().unwrap_or_else(|_| Err("the conversation's thread panicked".to_string())); + stream.give_up(); + (heard, stream.lines()) + } + Err(refused) => (Err(refused.to_string()), Vec::new()), + })), + (None, None) => unreachable!("a boot key is the swap's or the conversation's"), + } + }) + }); + let back = bench.wait_for_the_log(args.wait_secs, "come back", &after); + let reached = reaching.map(|thread| { + thread.join().unwrap_or_else(|_| Err(Refusal::Cable("the thread asking the boot panicked".to_string()))) + }); + (back, reached) + }); + let replied = match ping { + Some(ping) => ping.end()?, + None => None, + }; + let (heard, swapped) = match reached { + None => (None, None), + Some(Ok(Reached::Swapped(swapped))) => (None, Some(swapped)), + Some(Ok(Reached::Talked(heard))) => { + metal::write_talk(dir, &heard.0)?; + (Some(heard), None) + } + // A boot that never answered as itself had no conversation and no + // swap, and says so where each would have. + Some(Err(refused)) => match swap { + Some(_) => (None, Some(Err(refused))), + None => { + let heard: metal::Heard = (Err(refused.to_string()), Vec::new()); + metal::write_talk(dir, &heard.0)?; + (Some(heard), None) + } + }, + }; + let (back, logs) = back?; + println!("the bench gave back its /log over the runner key {back} s after it went down"); + + let loader = loader_log(&logs, &update.digest)?; + let log = kernel_log(&logs, &update.root)?; + print!("{loader}{log}"); + // The stick is the disk the bench booted from, so it was there before the + // bench could answer: zero, by construction and not by a reading. + metal::write_readback(dir, &loader, &log, back, 0, wire.as_ref(), replied)?; + println!("readback written to {}", dir.display()); + let ms = metal::judge(&armed, &loader, &log, heard.as_ref())?; + // After the boot's own verdict, as a conversation's is: a boot that + // never reached its network is named by that verdict first. + if let Some(swapped) = swapped { + swapped?; + } + Ok(ms) +} + +/// **The bench takes the machine**: its sshd, reached with the runner key, +/// asks its loader to put its entry first in `BootOrder`; the reboot writes +/// it; and the pass after says the machine was booted by that entry. +pub fn take_the_machine(key: &Path, machine: &Machine, wait_secs: u64, scratch: &Path) -> Result<(), Refusal> { + let bench = Bench::prepare(key, machine, scratch)?; + println!("waiting for the bench to take the runner key"); + bench.wait(wait_secs, "come up as the bench", true)?; + let said = bench.exec("asking the loader for the boot order", "update --boot-first")?; + print!(" {said}"); + let at = bench.at()?; + let asked = bench.ssh.fire(at, crate::metaltalk::REBOOT).map_err(|why| Refusal::Remote { + what: "rebooting".to_string(), + status: "was not answered".to_string(), + stderr: why, + })?; + println!(" `{}` at {at} answered {asked}", crate::metaltalk::REBOOT); + bench.wait(metal::GOING_DOWN_SECS, "go down", false)?; + let (_, logs) = bench.wait_for_the_log(wait_secs, "come back", &scratch.join("log"))?; + // The pass that wrote the order ended a chain, so the bench's own pass + // kept it; the file the bench runs under is the one the order booted. + let kept = logs.read(bootlog::LOADER_PREVIOUS_LOG)?; + let first = kept + .lines() + .find(|l| l.contains("this loader's ESP") && l.contains("is first")) + .ok_or_else(|| Refusal::NotThisBoot(format!("no pass wrote the boot order:\n{kept}")))?; + let now = logs.read(bootlog::LOADER_LOG)?; + let booted = now.lines().find(|l| l.contains("this pass was booted as")).unwrap_or_default(); + println!(" {}\n {}", first.trim(), booted.trim()); + println!("TAKEN: the machine boots the bench first, and every judged boot is delivered with `update --once`"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_logd_file_is_named_for_its_boot() { + assert_eq!(stem("2026-09-06-084003.log"), "2026-09-06-084003"); + assert_eq!(stem("2026-09-06-084003_0002.log"), "2026-09-06-084003"); + assert_eq!(stem("unknown-00_0012.log"), "unknown-00"); + assert_eq!(stem("unknown-00.log"), "unknown-00"); + } + + /// **A boot's kernel log is the file that names its ROOT**, never the one + /// before the newest: the bench's own boot, a boot that never reached + /// `logd`, and an earlier boot of another image each name another. + #[test] + fn a_boots_log_is_the_one_that_names_its_root() { + let dir = std::env::temp_dir().join(format!("metalbench-logs-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + let mounted = |root: &str| format!("[kernel 0.2 cpu0] {} 0x1+0x2, filesystem {root}, 9 blocks\n", bootlog::MOUNTED_FROM_MEMORY); + let files = [ + ("2026-09-27-100000.log", mounted("aaaa")), + ("2026-09-27-110000.log", mounted("bbbb")), + ("2026-09-27-110000_0002.log", "the boot's second part\n".to_string()), + ("2026-09-27-120000.log", mounted("cccc")), + ]; + for (name, text) in &files { + std::fs::write(dir.join(name), text).expect("a staged file"); + } + let mut names: Vec = files.iter().map(|(n, _)| (*n).to_string()).collect(); + names.push("loader.log".to_string()); + let logs = Logs { dir: dir.clone(), names }; + let text = kernel_log(&logs, "bbbb").expect("a read"); + assert!(text.contains("filesystem bbbb,") && text.ends_with("the boot's second part\n"), "{text}"); + assert_eq!(kernel_log(&logs, "dddd").expect("a read"), "", "a boot that reached no logd"); + assert!(matches!(logs.read("loader-previous.log"), Err(Refusal::NotThisBoot(_)))); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **A bench under another loader takes no image**: the line its pass + /// wrote names the loader it runs, and an image built with another is + /// refused before it is delivered — as is a bench whose pass named none. + #[test] + fn an_image_is_delivered_only_to_the_loader_it_was_built_with() { + let dir = std::env::temp_dir().join(format!("metalbench-loader-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + let ours = [0x11u8; 32]; + let mut hex = [0u8; 64]; + let line = format!("ToyOS Bootloader 1.0\n{} {}\n", bootlog::LOADER_IS, toyos_update::hex(&ours, &mut hex)); + std::fs::write(dir.join(bootlog::LOADER_LOG), line).expect("a staged file"); + let logs = Logs { dir: dir.clone(), names: vec![bootlog::LOADER_LOG.to_string()] }; + assert_eq!(same_loader(&logs, &ours), Ok(())); + assert!(matches!(same_loader(&logs, &[0x12; 32]), Err(Refusal::Undelivered(_)))); + std::fs::write(dir.join(bootlog::LOADER_LOG), "ToyOS Bootloader 1.0\n").expect("a staged file"); + assert!(matches!(same_loader(&logs, &ours), Err(Refusal::Undelivered(_))), "a pass that named no loader"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/metaltalk.rs b/src/metaltalk.rs index 7e903cfec69..e0f479b13fe 100644 --- a/src/metaltalk.rs +++ b/src/metaltalk.rs @@ -72,7 +72,7 @@ const MDNS_PORT: u16 = 5353; const FOREVER: Duration = Duration::from_secs(365 * 24 * 3600); /// Where the stream is asked for. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Peer { /// A name this host's resolver answers — `toyos-t14.local`, over multicast /// DNS — asked again until the machine answers for it, and asked of the @@ -796,6 +796,28 @@ impl Ssh { Ok(Exec { stdout, status }) } + /// Whether the machine at `at` takes this key within `secs`: a machine not + /// up, one whose sshd is not up and one that authorizes another key are one + /// answer to a caller waiting for the one that authorizes this. + pub fn probe(&self, at: SocketAddr, secs: u64) -> Result<(), String> { + let (host, port) = (at.ip().to_string(), at.port().to_string()); + self.run(&["probe", &host, &port, path_str(&self.key)?, &secs.to_string()]).map(|_| ()) + } + + /// Every file of the machine's directory `remote`, onto this host under + /// `local`, over one session: the names fetched, each with its size. + pub fn fetch(&self, at: SocketAddr, remote: &str, local: &Path) -> Result, String> { + let (host, port) = (at.ip().to_string(), at.port().to_string()); + let said = self.run(&["fetch", &host, &port, path_str(&self.key)?, remote, path_str(local)?])?; + said.lines() + .filter_map(|line| line.strip_prefix("entry ")) + .map(|entry| { + let (name, size) = entry.rsplit_once(' ').ok_or_else(|| format!("the client fetched {entry:?}"))?; + Ok((name.to_string(), size.parse().map_err(|_| format!("the client fetched {entry:?}"))?)) + }) + .collect() + } + /// Ask for `command` and answer the machine's reply to the request, without /// waiting for the program. pub fn fire(&self, at: SocketAddr, command: &str) -> Result { diff --git a/src/testargs.rs b/src/testargs.rs index 4eaaf0e4abc..d1d53e03b2c 100644 --- a/src/testargs.rs +++ b/src/testargs.rs @@ -165,6 +165,11 @@ declare_flags!(pub SUITE = { /// machine is not touched**: the run builds the images and writes down what /// to run on them, or judges readbacks a driver already left there. pub METAL_READBACK = "--metal-readback", Next; + /// Drive the machine the old way: flash its stick through Ubuntu and read + /// the log partition off it (`toyos-metal --via-ubuntu`). Absent, each boot + /// is delivered to the bench — a T14 running ToyOS — with `update --once` + /// and read back over its sshd. Goes with `--via-ubuntu`. + pub METAL_VIA_UBUNTU = "--metal-via-ubuntu", None; }); /// Validate the harness's argv and return the run's filter. @@ -207,6 +212,13 @@ pub fn parse(args: &[String]) -> Result, String> { .to_string(), ); } + if has(&METAL_VIA_UBUNTU) && !has(&METAL) { + return Err( + "--metal-via-ubuntu says which way the metal profile reaches the machine and decides \ + nothing on its own; add --metal" + .to_string(), + ); + } if has(&METAL_READBACK) && !has(&METAL) { return Err( "--metal-readback says where the metal profile's images and readbacks live and \ diff --git a/tests/benchcase/system.toml b/tests/benchcase/system.toml new file mode 100644 index 00000000000..1b0c30a6348 --- /dev/null +++ b/tests/benchcase/system.toml @@ -0,0 +1,43 @@ +# The bench: the T14 running ToyOS and nothing else, in the slot its table +# marks, between the boots `toyos-metal` delivers to it. sshd authorizes the +# runner key the image is built with (`cargo run -- --bench-image `), and +# each judged boot arrives as `update --once`, runs once, and hands the +# machine back to this image, whose sshd the loop reads that boot's files +# through. `tests/benchvirtiocase` is the same bench in front of QEMU's +# virtio-net; the two differ by the one `devices` row. +# +# `logd` serves no stream: the stream a talking or swapping boot serves is +# asked for under the machine's own name, and a bench answering it would be +# answering for a boot it is not. + +[boot] +start = ["logd", "netd", "sshd"] + +[programs.logd] +service = true +syscap = ["logread"] + +[programs.netd] +service = true +serves = ["netd"] +devices = ["pci:8086:15fc"] + +[programs.sshd] +service = true +receives = ["netd", "launcher"] + +# The idle slot, which init claims against the ROOT the kernel holds; the +# signature on the image is the authority to install it, and `--once` boots +# it once and never marks it. +[programs.update] +slots = true + +# `reboot` hands the machine to the boot `update` asked for; `echo` is how the +# loop asks the bench to answer; `date` is its clock. +[programs.toybox] +receives = ["power"] + +[symlinks] +"bin/echo" = "/system/bin/toybox" +"bin/reboot" = "/system/bin/toybox" +"bin/date" = "/system/bin/toybox" diff --git a/tests/benchvirtiocase/system.toml b/tests/benchvirtiocase/system.toml new file mode 100644 index 00000000000..0388ec5e4de --- /dev/null +++ b/tests/benchvirtiocase/system.toml @@ -0,0 +1,42 @@ +# The bench of `tests/benchcase`, in front of QEMU's virtio-net: the machine +# `tests/common/bench.rs` drives `toyos-metal` against. `test-runner` is here +# only so the harness has a ready marker to wait on; it runs no job. +# +# `logd` serves no stream: the stream a talking or swapping boot serves is +# asked for at the machine, and a bench answering it would be answering for a +# boot it is not. + +[boot] +start = ["logd", "netd", "sshd", "test-runner"] + +[programs.logd] +service = true +syscap = ["logread"] + +[programs.netd] +service = true +serves = ["netd"] +devices = ["pci:1af4:1041"] + +[programs.sshd] +service = true +receives = ["netd", "launcher"] + +# The idle slot, which init claims against the ROOT the kernel holds; the +# signature on the image is the authority to install it, and `--once` boots +# it once and never marks it. +[programs.update] +slots = true + +# `reboot` hands the machine to the boot `update` asked for; `echo` is how the +# loop asks the bench to answer; `date` is its clock. +[programs.test-runner] +syscap = ["logread"] + +[programs.toybox] +receives = ["power"] + +[symlinks] +"bin/echo" = "/system/bin/toybox" +"bin/reboot" = "/system/bin/toybox" +"bin/date" = "/system/bin/toybox" diff --git a/tests/common/bench.rs b/tests/common/bench.rs new file mode 100644 index 00000000000..867a5a5567f --- /dev/null +++ b/tests/common/bench.rs @@ -0,0 +1,175 @@ +//! The bench, rehearsed in QEMU: `toyos-metal` drives a machine that runs +//! ToyOS and nothing else — no Ubuntu anywhere — through one whole metal boot: +//! the image delivered with `update --once`, the reboot asked over ssh, the +//! boot's service swapped while it runs and the machine handed back, the +//! machine back as the bench, the boot's files read over sftp and judged by +//! the judges the T14's old path runs. +//! +//! **The machine is the bench image on a stick** (`tests/benchvirtiocase`), +//! its sshd authorizing a runner key minted here; **the boot is staged as the +//! metal profile stages it** (`metal::stage`): `tests/swapcase` with the swap +//! rehearsal's hold job, its bound, its own key and the netd binary the swap +//! sends. The loop is the library call `toyos-metal` makes, with the machine +//! reached through QEMU's forwards instead of its name. +//! +//! **The oracles are the machine's, not the loop's**: the kernel's own record +//! that the boot was slot B booted once, the loader's line that it asked for +//! once, the table still marking the bench after, and init's words on the +//! swap — each read where the machine left it, beside the loop's verdict. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::Path; + +use toyos_build::metal::{self, Args, Machine}; +use toyos_build::metaltalk::Peer; + +use super::qemu::{self, BootOptions, QemuInstance, Staged}; +use super::ssh::{self, Identity, HOST}; + +/// The bench's config in front of QEMU's virtio-net. +const BENCH: &str = "tests/benchvirtiocase"; + +/// The bench's own version: under every staged boot's, which is its build's +/// second, so `update` takes each as newer than what runs. +const BENCH_VERSION: u64 = 100; + +/// The boot the loop delivers: the swap rehearsal's machine, held until the +/// swap hands it back. +const SWAPPING: super::metal::Arm = super::metal::Arm { + swap: Some("netd"), + ..super::metal::once("benchswap", "tests/swapcase", &[], super::swap::HOLD_JOBS) +}; + +/// The kernel's record of a slot booted once (`kernel/src/main.rs`). +const ONCE_RECORD: &str = "boot: slot B, once, as the running system asked; the slot table marks A"; + +/// **The exit**: the loop drives the whole bench cycle against a machine +/// running ToyOS alone, and a tampered upload is refused before it. +pub fn bench_loop_drives_a_toyos_machine( + _: &Path, + _: &[(String, Vec)], + rust_bins: &[(String, Vec)], +) -> Result<(), String> { + let root = super::compile::repo_root(); + let scratch = super::lane::dir().join("bench-loop"); + std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?; + + let (boot, key) = super::metal::stage(&scratch, &SWAPPING, rust_bins)?; + let key = key.ok_or("a swapping boot authorizes a key of its own")?; + let home = boot.parent().ok_or("the staged image has a directory")?.to_path_buf(); + let update = toyos_build::image::update_of(&boot)?; + + let runner = Identity::mint_in(&scratch.join("runner"))?; + let bench = toyos_build::build::bench_image( + &root, + &root.join(BENCH), + &runner.authorized_line(), + BENCH_VERSION, + 2 * update.bytes.len() as u64, + true, + )?; + let disk = scratch.join("bench.img"); + std::fs::write(&disk, bench).map_err(|e| format!("write {}: {e}", disk.display()))?; + let vars = scratch.join("OVMF_VARS.fd"); + std::fs::copy(root.join("ovmf/OVMF_VARS-pure-efi.fd"), &vars).map_err(|e| format!("the variable store: {e}"))?; + let data = scratch.join("data.img"); + toyos_build::build::create_sparse(&data, qemu::NVME_SMALL); + + let (ssh_port, log_port) = (qemu::free_host_port(), qemu::free_host_port()); + let options = BootOptions { + profile: qemu::Profile::Headless, + boot_image: Some(Staged::Written(disk.clone())), + nvme_image: Some(data), + ssh_port: Some(ssh_port), + log_port: Some(log_port), + takes_the_reset: true, + firmware_vars: Some(vars), + ..Default::default() + }; + let mut guest = QemuInstance::boot_with_options(&root.join(BENCH), &[], &[], options); + let mut console = guest.boot_log().to_string(); + qemu::await_marker(&mut guest, &mut console, "sshd: listening on port 22", "the bench's sshd")?; + if !console.contains("boot: slot A, the one the slot table marks") { + return Err("the bench did not boot its own marked slot".to_string()); + } + + // **A tampered upload is refused**, by `update` and before the loop: one + // byte of the kernel past the signed header, whose signature still holds. + let mut bent = update.bytes.clone(); + bent[toyos_update::image::SIGNED_BYTES + 100] ^= 0x01; + let tampered = scratch.join("tampered.update"); + std::fs::write(&tampered, &bent).map_err(|e| format!("write {}: {e}", tampered.display()))?; + let refused = ssh::ssh_pipe(HOST, ssh_port, &runner, "update --once", &tampered)?; + let said = format!("{}{}", refused.stdout_text(), refused.stderr_text()); + if refused.status != Some(1) || !said.contains("the kernel is not the bytes its signed header names") { + return Err(format!("a tampered upload ended {:?} saying {said:?}", refused.status)); + } + eprintln!(" [bench] a kernel byte flipped under its signature: {}", said.trim()); + + // **The loop**: the library call `toyos-metal` makes, the machine reached + // through the forwards. + let readback = scratch.join("readback"); + let words: Vec = [ + "--image", + &boot.display().to_string(), + "--readback", + &readback.display().to_string(), + "--swap", + "netd", + "--binary", + &home.join("netd").display().to_string(), + "--talk", + &key.display().to_string(), + "--hand-back", + "--key", + &runner.private().display().to_string(), + ] + .iter() + .map(|w| w.to_string()) + .collect(); + let mut args = Args::parse(&words).map_err(|refusal| refusal.to_string())?; + args.machine = Machine { + log: Peer::At(SocketAddr::from((Ipv4Addr::LOCALHOST, log_port))), + ssh: Some(SocketAddr::from((Ipv4Addr::LOCALHOST, ssh_port))), + }; + // The loop reads nothing off this guest's console, and QEMU drops what + // nobody reads; a thread takes it so the machine's account stays whole. + let driven = std::thread::scope(|scope| { + let driving = scope.spawn(|| metal::run(&args)); + while !driving.is_finished() { + console.push_str(&guest.drain_serial(std::time::Duration::from_millis(500))); + } + driving.join().map_err(|_| "the loop panicked".to_string()) + })?; + console.push_str(&guest.drain_serial(std::time::Duration::from_millis(500))); + let ms = driven.map_err(|refusal| format!("the loop refused: {refusal}"))?; + eprintln!(" [bench] the loop's verdict: Boot: complete in {ms:?} ms"); + + // What the machine itself says about the cycle. + let kernel = std::fs::read_to_string(readback.join(metal::READBACK_KERNEL)).map_err(|e| format!("kernel.log: {e}"))?; + if !kernel.contains(ONCE_RECORD) { + return Err(format!("the boot's own log never says {ONCE_RECORD:?}")); + } + let loader = std::fs::read_to_string(readback.join(metal::READBACK_LOADER)).map_err(|e| format!("loader.log: {e}"))?; + for owed in ["Slot B: asked for once; the table marks A", "Request: a boot of slot B once is taken off the slot table"] { + if !loader.contains(owed) { + return Err(format!("the boot's loader passes never say {owed:?}")); + } + } + let swapped = std::fs::read_to_string(readback.join(metal::READBACK_SWAP)).map_err(|e| format!("swap.txt: {e}"))?; + eprintln!(" [bench] swap.txt: {}", swapped.lines().next().unwrap_or_default()); + // And the bench is the bench again: its own slot, still marked. + let after = &console[console.rfind("boot: slot").ok_or("no slot record at all")?..]; + if !after.starts_with("boot: slot A, the one the slot table marks") { + return Err(format!("the machine came back as {:?}, and the bench is slot A as marked", after.lines().next())); + } + let mut file = std::fs::File::open(&disk).map_err(|e| format!("{}: {e}", disk.display()))?; + let table = toyos_build::image::slot_table_of(&mut file)?; + if table.marked != toyos_update::slots::Which::A || !table.request.is_empty() { + return Err(format!("the slot table after the cycle is {table:?}: slot A marked and nothing asked is owed")); + } + eprintln!(" [bench] update --once, reboot, readback, judge, swap and hand-back, with no Ubuntu: the bench is slot A again"); + drop(guest); + let _ = std::fs::remove_dir_all(&scratch); + Ok(()) +} diff --git a/tests/common/metal.rs b/tests/common/metal.rs index 09db138cd58..b34001589b9 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -75,12 +75,13 @@ pub struct Arm { /// judge reads the stick alone. pub talk: bool, /// **The boot has one of its services swapped while it runs**, with no - /// reboot: its image is staged as a talking boot's is, the invocation - /// that flashes it is not told `--talk`, and a second invocation — - /// `toyos-metal --swap `, started beside the first — dials - /// the machine under its own name, sends the build's own binary of that - /// service and writes [`toyos_build::metal::READBACK_SWAP`] beside the - /// stick's files. + /// reboot: its image is staged as a talking boot's is, and the swap sends + /// the build's own binary of that service and writes + /// [`toyos_build::metal::READBACK_SWAP`] beside the boot's files. On the + /// bench the invocation that delivers the boot makes the swap too, once + /// the delivered boot answers as itself; through Ubuntu a second + /// invocation — `toyos-metal --swap `, started beside the + /// first — dials the machine under its own name. pub swap: Option<&'static str>, } @@ -541,6 +542,17 @@ pub enum Mode { Offline, } +/// Which way the invocations reach the machine. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Reach { + /// The bench: a T14 running ToyOS, each boot delivered with `update + /// --once` and read back over its sshd. + Bench, + /// The old path: the stick flashed through Ubuntu and its log partition + /// read off it, the outside FAT judge among the readers. + ViaUbuntu, +} + /// One image, and every test that rides it. struct Batch { config: &'static str, @@ -816,6 +828,29 @@ fn fingerprint(text: &str) -> u64 { h.finish() } +/// One arm's image, staged in `dir` exactly as [`run`] stages it — its job +/// list with `reboot` behind it, the boot's own bound, and a talking or +/// swapping boot's key and service binary beside it — for a rehearsal of the +/// loop that delivers it. Where it put the image, and the key a talking or +/// swapping boot authorizes. +pub fn stage(dir: &Path, arm: &Arm, rust_bins: &[(String, Vec)]) -> Result<(PathBuf, Option), String> { + let root = super::compile::repo_root(); + let batch = Batch { + config: arm.config, + params: arm.params.to_vec(), + features: arm.features, + jobs: arm.jobs.iter().map(|j| (*j).to_string()).collect(), + files: Vec::new(), + links: Vec::new(), + nic: arm.nic, + talk: arm.talk, + swap: arm.swap, + }; + let image = build(&root, dir, arm.boot, &batch, rust_bins, &[], true)?; + let key = (arm.talk || arm.swap.is_some()).then(|| talk_home(&at(dir, arm.boot)).join("id_ed25519")); + Ok((image, key)) +} + /// The invocation that turns one image into one readback. Written down in the /// staged request and run by [`Mode::Drive`], so the two cannot differ. /// Where a talking boot's key lives, beside its image. @@ -823,7 +858,7 @@ fn talk_home(home: &Path) -> PathBuf { home.join("ssh") } -fn invocation(image: &Path, home: &Path, nic: Option<&str>, talk: bool) -> Vec { +fn invocation(image: &Path, home: &Path, nic: Option<&str>, talk: bool, swap: Option<&str>, reach: Reach) -> Vec { let mut words = vec![ "run".to_string(), "--bin".to_string(), @@ -833,12 +868,16 @@ fn invocation(image: &Path, home: &Path, nic: Option<&str>, talk: bool) -> Vec, talk: bool) -> Vec = BTreeMap::new(); if mode == Mode::Drive { for (label, image) in &images { - let words = invocation(image, &at(dir, label), batches[*label].nic, batches[*label].talk); + let words = invocation(image, &at(dir, label), batches[*label].nic, batches[*label].talk, batches[*label].swap, reach); // A swapping boot's second invocation is started first: it dials // the machine under its own name for as long as it takes, and // waits for the boot. - let beside = batches[*label].swap.map(|service| { + let beside = batches[*label].swap.filter(|_| reach == Reach::ViaUbuntu).map(|service| { let words = swap_invocation(&at(dir, label), service); eprintln!("[metal] {label}, beside it: cargo {}", words.join(" ")); Command::new("cargo").args(&words).current_dir(&root).spawn() diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0c64deee567..feb0ebf689b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -38,6 +38,7 @@ pub mod lan; pub mod logread; #[allow(dead_code)] pub mod logstream; +pub mod bench; pub mod metal; #[allow(dead_code)] pub mod origin; diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 7bc5712fa15..a5f9e921d26 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -1723,6 +1723,11 @@ pub const BOOT_STICK_ID: &str = "bootstick"; /// what a test moving it has to be able to say is not so. pub const BOOT_STICK_SERIAL: &str = "TOYOS0BOOTSTICK1"; +/// [`BootOptions::recovery_stick`]'s device id and serial number, stated for +/// the boot stick's reasons. +pub const RECOVERY_STICK_ID: &str = "recoverystick"; +pub const RECOVERY_STICK_SERIAL: &str = "TOYOS0RECOVERY01"; + /// The serial number of [`Profile::NvmeBootUsbDisk`]'s stick, for the same /// reason the boot stick states one. pub const DATA_STICK_SERIAL: &str = "TOYOS0DATASTICK1"; @@ -2385,6 +2390,12 @@ pub struct BootOptions { /// boot of the same machine reads. `None` is every other boot, whose /// variables live in firmware memory and die with the guest. pub firmware_vars: Option, + /// A second bootable stick behind the boot stick, `bootindex=1`, so the + /// firmware's `BootOrder` names it after the boot stick: the machine's + /// recovery entry, or another ESP a request names. Written by the guest + /// like the boot stick, so a boot of it is the same machine's. Refused + /// by name on a profile whose storage is not on USB. + pub recovery_stick: Option, /// The console line that means the boot reached the state under test. /// Anything other than [`DEFAULT_READY`] also declares that a panic is the /// expected outcome rather than a boot failure -- the early-panic screen @@ -2548,6 +2559,7 @@ impl Default for BootOptions { mute: false, takes_the_reset: false, firmware_vars: None, + recovery_stick: None, ready_marker: DEFAULT_READY, nvme_image: None, boot_image: None, @@ -4570,6 +4582,20 @@ fn qemu_command( shape.storage_bus )); } + if let Some(stick) = &options.recovery_stick { + assert!( + !shape.storage_bus.is_empty(), + "a recovery stick rides the USB storage bus, and this profile's storage is not on USB" + ); + qemu.arg("-drive") + .arg(format!("if=none,id=recovery,format=raw,file={}", stick.display())) + .arg("-device") + .arg(format!( + "usb-storage,bus={},drive=recovery,id={RECOVERY_STICK_ID},serial={RECOVERY_STICK_SERIAL},\ + bootindex=1", + shape.storage_bus + )); + } if let Some(gpu) = shape.gpu { assert_eq!( shape.vga, "none", diff --git a/tests/common/update.rs b/tests/common/update.rs index 5ce91a9d5e5..c0d4081966d 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -67,6 +67,9 @@ struct Rig { port: u16, /// The base image's parts, for what the loader is held to. base_kernel: usize, + /// A second stick behind the machine's in `BootOrder`, where the test + /// stages one ([`Rig::with_recovery`]). + recovery: Option, } /// The plan for this config's image: `features` is the kernel build, `params` @@ -111,7 +114,62 @@ impl Rig { let vars = scratch.join("OVMF_VARS.fd"); std::fs::copy(root.join("ovmf/OVMF_VARS-pure-efi.fd"), &vars) .map_err(|e| format!("copy the firmware's variable store: {e}"))?; - Ok(Self { scratch, image, vars, identity, port: qemu::free_host_port(), base_kernel: parts.kernel.len() }) + Ok(Self { scratch, image, vars, identity, port: qemu::free_host_port(), base_kernel: parts.kernel.len(), recovery: None }) + } + + /// This machine with a second stick behind its own in `BootOrder`: another + /// image of the same config, one slot, its own GUIDs — the recovery stick + /// a machine falls to, and the ESP a request names. + fn with_recovery(mut self) -> Result { + let root = super::compile::repo_root(); + let parts = build::build_test_parts(&root, &plan(&[], &[], BASE, None), true, &staged(&self.identity)); + let disk = image::create_boot_image( + toyos_build::arch::Arch::X86_64, + &parts.kernel, + &parts.bootloader, + &parts.root, + "", + Signing { key: signing::key(), version: BASE }, + None, + ); + let path = self.scratch.join("recovery.img"); + std::fs::write(&path, disk).map_err(|e| format!("write {}: {e}", path.display()))?; + self.recovery = Some(path); + Ok(self) + } + + /// The recovery stick's image, which [`Rig::with_recovery`] staged. + fn recovery(&self) -> Result<&Path, String> { + self.recovery.as_deref().ok_or_else(|| "this rig stages no recovery stick".to_string()) + } + + /// The unique GUID of the one partition of type `kind` on the image at `path`. + fn guid_on(path: &Path, kind: toyos_gpt::Guid) -> Result<[u8; 16], String> { + let mut file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + image::unique_guid_of(&mut file, kind) + } + + /// The line the loader says of the log partition the image at `path` names, + /// which tells one stick's passes from the other's on the one 16550. + fn loader_of(path: &Path) -> Result { + Ok(format!("Log partition: signature {:02x?}", Self::guid_on(path, toyos_gpt::Guid::MICROSOFT_BASIC)?)) + } + + /// The kernel's line naming the log partition of the image at `path`, which + /// tells one stick's kernels from the other's on the one console. + fn kernel_of(path: &Path) -> Result { + Ok(format!("boot: log partition guid {:02x?}", Self::guid_on(path, toyos_gpt::Guid::MICROSOFT_BASIC)?)) + } + + /// Run `command` over ssh and hold it to status 0 and `owed` on its output. + fn asks(&self, command: &str, owed: &str) -> Result { + let exec = ssh::ssh_exec(HOST, self.port, &self.identity, command)?; + let said = format!("{}{}", exec.stdout_text(), exec.stderr_text()); + eprintln!(" [update] `{command}` ended {:?}: {}", exec.status, said.trim()); + if exec.status != Some(0) || !said.contains(owed) { + return Err(format!("`{command}` ended {:?} saying {said:?}, where {owed:?} is owed", exec.status)); + } + Ok(said) } /// An update for this machine, written to a file: `features` and `params` @@ -134,6 +192,7 @@ impl Rig { ssh_port: Some(self.port), takes_the_reset: true, firmware_vars: Some(self.vars.clone()), + recovery_stick: self.recovery.clone(), qmp: true, ..Default::default() }; @@ -153,6 +212,7 @@ impl Rig { boot_image: Some(Staged::Written(self.image.clone())), takes_the_reset: true, firmware_vars: Some(self.vars.clone()), + recovery_stick: self.recovery.clone(), ready_marker: marker, ..Default::default() }; @@ -646,6 +706,122 @@ pub fn update_floor_is_the_images_own(_: &Path, _: &[(String, Vec)], _: &[(S Ok(()) } +/// The `BootNext` a request asks for: the one line the loader writes it on. +const BOOT_NEXT_SET: &str = "(written now): the firmware boots it once, at the reset this pass ends with"; + +/// **A request for another ESP boots that ESP once, and the order resumes**: +/// the machine asks for its recovery stick with `update --boot-next`; the pass +/// after the reboot takes the request off the slot table, writes an entry for +/// that stick and points `BootNext` at it; the recovery stick's kernel boots; +/// its reboot hands the machine to the firmware's order, which is the +/// machine's own stick; and the machine's next reboot is its own again. +pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-boot-next")?.with_recovery()?; + let recovery = rig.recovery()?.to_path_buf(); + let esp = toyos_gpt::Guid(Rig::guid_on(&recovery, toyos_gpt::Guid::EFI_SYSTEM)?); + let (ours, theirs) = (Rig::kernel_of(&rig.image)?, Rig::kernel_of(&recovery)?); + let (mut guest, mut console) = rig.boot()?; + owed(&console, 0, &ours)?; + rig.asks(&format!("update --boot-next {esp}"), &format!("the loader boots EFI system partition {esp} once"))?; + + let (from, uart) = rig.reboot_until(&mut guest, &mut console, &theirs)?; + loader_said(&guest, uart, "Request: a boot of another ESP is taken off the slot table before it is acted on")?; + loader_said(&guest, uart, &format!("ESP {esp} {BOOT_NEXT_SET}"))?; + await_machine(&mut guest, &mut console, "the recovery stick's sshd", |c| c[from..].contains(SSHD_LISTENING))?; + eprintln!(" [update] `update --boot-next {esp}` booted the recovery stick at the next reboot"); + + let (from, _) = rig.reboot_until(&mut guest, &mut console, &ours)?; + await_machine(&mut guest, &mut console, "the machine's sshd after the recovery stick", |c| c[from..].contains(SSHD_LISTENING))?; + let (from, uart) = rig.reboot_until(&mut guest, &mut console, &ours)?; + await_machine(&mut guest, &mut console, "the machine's sshd a third time", |c| c[from..].contains(SSHD_LISTENING))?; + let since = guest.uart_log()[uart..].to_string(); + if since.contains("Request:") { + return Err(format!("the pass after the order resumed still read a request:\n{since}")); + } + let booted = console.matches(&theirs).count(); + if booted != 1 { + return Err(format!("the recovery stick's kernel booted {booted} times, where the request asked for once")); + } + eprintln!(" [update] the recovery stick booted once, and the machine's own stick at every boot after"); + drop(guest); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// What sshd says once it serves, on every image here. +const SSHD_LISTENING: &str = "sshd: listening on port 22"; + +/// **`update --boot-first` makes this loader's entry the firmware's first**: +/// the pass after the reboot writes an entry for its own ESP and puts it at +/// the head of `BootOrder`; the firmware boots by it at every reset after — +/// the pass after that was booted as that entry — and the variable store, +/// read by EDK2's layout and not by the loader's, holds the order and an +/// entry naming this image's ESP by `HD(…)/\EFI\BOOT\BOOTX64.EFI`. +pub fn update_boot_first_puts_the_loader_first(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-boot-first")?; + let esp = Rig::guid_on(&rig.image, toyos_gpt::Guid::EFI_SYSTEM)?; + let (mut guest, mut console) = rig.boot()?; + rig.asks("update --boot-first", "first in the firmware's BootOrder")?; + let (_, uart) = rig.reboot_until(&mut guest, &mut console, DEFAULT_READY)?; + loader_said(&guest, uart, "Request: the boot order is taken off the slot table before it is acted on")?; + let since = guest.uart_log()[uart..].to_string(); + let line = since + .lines() + .find(|l| l.contains("this loader's ESP (written now), is first")) + .ok_or_else(|| format!("the loader never put its entry first:\n{since}"))?; + eprintln!(" [update] {line}"); + let number = line + .split("Boot entries: Boot") + .nth(1) + .and_then(|rest| rest.get(..4)) + .and_then(|hex| u16::from_str_radix(hex, 16).ok()) + .ok_or_else(|| format!("no entry number in {line:?}"))?; + let (_, uart) = rig.reboot_until(&mut guest, &mut console, DEFAULT_READY)?; + loader_said(&guest, uart, &format!("this pass was booted as Boot{number:04X}"))?; + drop(guest); + + let order = vars::global(&rig.vars, "BootOrder")?; + let first = order.get(..2).map(|w| u16::from_le_bytes([w[0], w[1]])); + if first != Some(number) { + return Err(format!("the variable store's BootOrder is {order:02x?}, whose head is not Boot{number:04X}")); + } + let option = vars::global(&rig.vars, &format!("Boot{number:04X}"))?; + let (guid, path) = vars::load_option(&option)?; + if guid != esp || path != r"\EFI\BOOT\BOOTX64.EFI" { + return Err(format!("Boot{number:04X} names partition {guid:02x?} and {path:?}, where {esp:02x?} and the removable path are owed")); + } + eprintln!(" [update] BootOrder begins Boot{number:04X}, which names this image's ESP, and the firmware booted by it"); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// **A machine whose every slot is refused boots its recovery stick**: slot +/// A's kernel carries a flipped byte and slot B holds no image, so no slot +/// verifies; the loader sets `BootNext` to the entry after its own in +/// `BootOrder` and resets, and the recovery stick behind it boots. +pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-recovery")?.with_recovery()?; + let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; + let a = image::slot_table_of(&mut file)?.slot(Which::A).ok_or("no slot A")?; + let mut kernel = image::read_file_on(&mut file, a.boot, toyos_update::slots::KERNEL_FILE)?; + drop(file); + kernel[100] ^= 0x01; + image::overwrite_file_on(&rig.image, a.boot, toyos_update::slots::KERNEL_FILE, &kernel)?; + // The recovery stick's own loader, naming its log partition: the pass + // that follows the fall. + let theirs: &'static str = Box::leak(Rig::loader_of(rig.recovery()?)?.into_boxed_str()); + let fell = rig.launch(theirs); + said( + fell.boot_log(), + &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slots: no slot verifies", "no slot boots, so BootNext=Boot"], + )?; + let line = fell.boot_log().lines().find(|l| l.contains("no slot boots")).unwrap_or_default().to_string(); + eprintln!(" [update] {line}; the recovery stick's loader took the machine"); + drop(fell); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + /// The firmware's variable store, as OVMF keeps it in its `VARS` file: a /// firmware volume holding an authenticated variable store, read and written /// by the layout EDK2 declares for it (`MdeModulePkg/Include/Guid/ @@ -728,6 +904,56 @@ mod vars { .collect()) } + /// `EFI_GLOBAL_VARIABLE`, `8BE4DF61-93CA-11D2-AA0D-00E098032B8C`, in the + /// byte order `EFI_GUID` stores: `BootOrder`'s and every `Boot####`'s. + const GLOBAL: [u8; 16] = [0x61, 0xdf, 0xe4, 0x8b, 0xca, 0x93, 0xd2, 0x11, 0xaa, 0x0d, 0x00, 0xe0, 0x98, 0x03, 0x2b, 0x8c]; + + /// The one live global variable called `name`: the one the store added + /// last, since a rewrite adds a copy before it retires the old. + pub fn global(path: &Path, name: &str) -> Result, String> { + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + walk(&bytes)? + .0 + .into_iter() + .filter(|found| found.vendor == GLOBAL && found.state == VAR_ADDED && found.var.name == name) + .last() + .map(|found| found.var.data) + .ok_or_else(|| format!("the variable store holds no live {name}")) + } + + /// What an `EFI_LOAD_OPTION` boots: the GPT signature of its HARDDRIVE + /// node and the path of its FILE_PATH node — read by the tables of UEFI + /// 2.10 §3.1.3 and §10.3.6 here, and not by `toyos_update::entry`, which + /// wrote it. + pub fn load_option(option: &[u8]) -> Result<([u8; 16], String), String> { + let path_len = u16::from_le_bytes([option[4], option[5]]) as usize; + let mut at = 6; + while option.get(at..at + 2).ok_or("the description runs off the option")? != [0, 0] { + at += 2; + } + at += 2; + let mut path = option.get(at..at + path_len).ok_or("the device path runs off the option")?; + let (mut guid, mut file) = (None, None); + while path.len() >= 4 { + let len = u16::from_le_bytes([path[2], path[3]]) as usize; + let node = path.get(..len).filter(|_| len >= 4).ok_or("a node that cannot be stepped over")?; + match (node[0], node[1]) { + (4, 1) if node.len() == 42 && node[40] == 2 && node[41] == 2 => { + guid = Some(<[u8; 16]>::try_from(&node[24..40]).expect("sixteen bytes")) + } + (4, 4) => { + let units: Vec = + node[4..].chunks(2).map(|c| u16::from_le_bytes([c[0], c[1]])).take_while(|&u| u != 0).collect(); + file = Some(String::from_utf16_lossy(&units)); + } + (0x7f, 0xff) => break, + _ => {} + } + path = &path[len..]; + } + Ok((guid.ok_or("no GPT HARDDRIVE node")?, file.ok_or("no FILE_PATH node")?)) + } + /// Add `name` under the floor's vendor with `attributes` and `data`, as /// the firmware would have added it. pub fn plant(path: &Path, name: &str, attributes: u32, data: &[u8]) -> Result<(), String> { diff --git a/tests/ssh-client-host/src/main.rs b/tests/ssh-client-host/src/main.rs index fc76e06031d..23f96b9944f 100644 --- a/tests/ssh-client-host/src/main.rs +++ b/tests/ssh-client-host/src/main.rs @@ -21,9 +21,12 @@ //! toyos_ssh abandon → ok //! toyos_ssh fire //! → accepted | refused | closed | silent +//! toyos_ssh probe → authenticated, within //! toyos_ssh put → ok //! toyos_ssh get → ok //! toyos_ssh list → entry …, ok +//! toyos_ssh fetch → entry …, ok +//! (every file of , into ) //! toyos_ssh swap //! → accepted | refused //! | unasked | unanswered @@ -62,6 +65,25 @@ use tokio::io::AsyncWriteExt; /// verdict, which says only that a test did not finish. const BOUND: Duration = Duration::from_secs(120); +/// What a `pipe` adds to [`BOUND`] per byte it sends: a mebibyte a second, +/// which a link that carries an image at all beats many times over. An +/// image is the one input here whose size decides how long its exchange takes, +/// and a fixed bound would cut a large one on a slow link as a hang. +const PIPE_BYTES_PER_SEC: u64 = 1 << 20; + +/// The bound a run is held to: [`BOUND`], a `pipe`'s input at +/// [`PIPE_BYTES_PER_SEC`] beside it, and a `probe`'s own. +fn bound(words: &[String]) -> Duration { + match words { + [cmd, _, _, _, _, _, stdin, ..] if cmd == "pipe" => { + let bytes = std::fs::metadata(stdin).map_or(0, |m| m.len()); + BOUND + Duration::from_secs(bytes / PIPE_BYTES_PER_SEC) + } + [cmd, _, _, _, secs] if cmd == "probe" => secs.parse().map_or(BOUND, Duration::from_secs), + _ => BOUND, + } +} + /// The user this client authenticates as. The guest has no user model, so the /// name is a string its daemon prints and nothing keys on. const USER: &str = "root"; @@ -72,10 +94,11 @@ fn main() -> ExitCode { Ok(runtime) => runtime, Err(e) => return fail(&format!("no tokio runtime: {e}")), }; - match runtime.block_on(async { tokio::time::timeout(BOUND, run(&args)).await }) { + let bound = bound(&args); + match runtime.block_on(async { tokio::time::timeout(bound, run(&args)).await }) { Ok(Ok(())) => ExitCode::SUCCESS, Ok(Err(why)) => fail(&why), - Err(_) => fail(&format!("nothing answered in {}s", BOUND.as_secs())), + Err(_) => fail(&format!("nothing answered in {}s", bound.as_secs())), } } @@ -102,9 +125,11 @@ async fn run(args: &[String]) -> Result<(), String> { abandon(host, port, key, &command.join(" ")).await } ["fire", host, port, key, command @ ..] => fire(host, port, key, &command.join(" ")).await, + ["probe", host, port, key, _secs] => probe(host, port, key).await, ["put", host, port, key, local, remote] => put(host, port, key, local, remote).await, ["get", host, port, key, remote, local] => get(host, port, key, remote, local).await, ["list", host, port, key, remote] => list(host, port, key, remote).await, + ["fetch", host, port, key, remote, local] => fetch(host, port, key, remote, local).await, ["swap", host, port, key, service, binary, digest] => { swap(host, port, key, service, binary, digest).await } @@ -199,6 +224,17 @@ async fn auth(host: &str, port: &str, key: &str) -> Result<(), String> { Ok(()) } +/// Whether the machine at `host` takes this key, within the bound the caller +/// named: `authenticated`, or the failure — a machine not up, one whose sshd +/// is not up, and one that does not authorize this key are all one answer to +/// a caller waiting for a machine that does. +async fn probe(host: &str, port: &str, key: &str) -> Result<(), String> { + let session = connect(host, port, key).await?; + println!("authenticated"); + let _ = session.disconnect(Disconnect::ByApplication, "", "en").await; + Ok(()) +} + /// Run a command and collect what came back on each of the channel's two /// streams. With `stdin`, the file's bytes are sent to the program first, and /// where it asks, an environment request is made before the exec so the caller @@ -469,6 +505,32 @@ async fn list(host: &str, port: &str, key: &str, remote: &str) -> Result<(), Str Ok(()) } +/// Every file in the guest's directory `remote`, onto this host under `local`, +/// over one session: what a reader of a whole directory owes a machine whose +/// listener resets a connect that lands between two of its accepts +/// (`issues/hardware/a-connect-between-two-accepts-is-reset.md`) — one +/// connection, not one per file. +async fn fetch(host: &str, port: &str, key: &str, remote: &str, local: &str) -> Result<(), String> { + let (session, sftp) = sftp(host, port, key).await?; + let entries = + sftp.read_dir(remote).await.map_err(|e| format!("listing {remote} on the guest: {e}"))?; + let mut files: Vec<(String, u64)> = entries + .filter(|entry| !entry.file_type().is_dir() && entry.file_name() != "." && entry.file_name() != "..") + .map(|entry| (entry.file_name(), entry.metadata().size.unwrap_or(0))) + .collect(); + files.sort(); + std::fs::create_dir_all(local).map_err(|e| format!("making {local}: {e}"))?; + for (name, _) in &files { + let at = format!("{remote}/{name}"); + let bytes = sftp.read(&at).await.map_err(|e| format!("reading {at} off the guest: {e}"))?; + write(&format!("{local}/{name}"), &bytes)?; + println!("entry {name} {}", bytes.len()); + } + println!("ok {}", files.len()); + finish(sftp, session).await; + Ok(()) +} + /// The guest's host key is accepted unseen — see this program's own note. struct Trusting; diff --git a/tests/toyos.rs b/tests/toyos.rs index bcb78025cff..abc76ecd808 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -865,6 +865,17 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("update_grant_refuses_a_stray_partition", Sched::Parallel, Tier::Nightly), ("update_floor_is_the_images_own", Sched::Parallel, Tier::Nightly), ("update_refused_pass_credits_no_image", Sched::Parallel, Tier::Nightly), + // The loader writes the firmware's boot variables on the running system's + // request: a boot of another stick once, its own entry first, and the + // recovery stick behind it where no slot verifies. Boots of two sticks, + // nightly like the rest of the machine's. + ("update_boot_next_boots_the_entry_once", Sched::Parallel, Tier::Nightly), + ("update_boot_first_puts_the_loader_first", Sched::Parallel, Tier::Nightly), + ("update_no_slot_boots_the_recovery_stick", Sched::Parallel, Tier::Nightly), + // The bench: `toyos-metal` delivers a staged boot to a machine running + // ToyOS alone with `update --once`, swaps its netd, hands it back and + // judges what the bench reads back over ssh — a minute and more of boots. + ("bench_loop_drives_a_toyos_machine", Sched::Parallel, Tier::Nightly), ("lan_swap", Sched::Parallel, Tier::Fast), ("swap_refusals", Sched::Parallel, Tier::Fast), ("swap_crash_rolls_back", Sched::Parallel, Tier::Fast), @@ -1649,6 +1660,12 @@ const CARRIES: &[(&str, &[&str])] = &[ ("update_grant_refuses_a_stray_partition", &[]), ("update_floor_is_the_images_own", &[]), ("update_refused_pass_credits_no_image", &[]), + ("update_boot_next_boots_the_entry_once", &[]), + ("update_boot_first_puts_the_loader_first", &[]), + ("update_no_slot_boots_the_recovery_stick", &[]), + // The boot it delivers is staged as the metal profile stages it, with the + // swap rehearsal's hold job on it. + ("bench_loop_drives_a_toyos_machine", &["test_rs_lan_swap_hold"]), ("blocking_read_window", &["test_rs_blocking_read_stress"]), ("user_copy_races_munmap", &["test_rs_copy_out_races_munmap"]), ("writeback_reopen", &["test_rs_writeback_reopen"]), @@ -15617,6 +15634,18 @@ fn run_machine_test( "update_refused_pass_credits_no_image" => { common::update::update_refused_pass_credits_no_image(test_config, c_bins, rust_bins) } + "update_boot_next_boots_the_entry_once" => { + common::update::update_boot_next_boots_the_entry_once(test_config, c_bins, rust_bins) + } + "update_boot_first_puts_the_loader_first" => { + common::update::update_boot_first_puts_the_loader_first(test_config, c_bins, rust_bins) + } + "update_no_slot_boots_the_recovery_stick" => { + common::update::update_no_slot_boots_the_recovery_stick(test_config, c_bins, rust_bins) + } + "bench_loop_drives_a_toyos_machine" => { + common::bench::bench_loop_drives_a_toyos_machine(test_config, c_bins, rust_bins) + } "lan_swap" => common::swap::lan_swap(test_config, c_bins, rust_bins), "swap_refusals" => common::swap::swap_refusals(test_config, c_bins, rust_bins), "swap_crash_rolls_back" => common::swap::swap_crash_rolls_back(test_config, c_bins, rust_bins), @@ -20812,6 +20841,7 @@ fn main() { // directory means the machine is not touched — see `common::metal::Mode`. let metal_mode = SUITE.present(&args, &testargs::METAL); let metal_readback = SUITE.value(&args, &testargs::METAL_READBACK); + let metal_reach = if SUITE.present(&args, &testargs::METAL_VIA_UBUNTU) { metal::Reach::ViaUbuntu } else { metal::Reach::Bench }; if SUITE.present(&args, &testargs::SLOW_USB) { SLOW_USB.store(true, std::sync::atomic::Ordering::Relaxed); } @@ -20924,6 +20954,7 @@ fn main() { run.exit( match metal::run( mode, + metal_reach, &dir, &selected, &{ diff --git a/tests/updatecase/system.toml b/tests/updatecase/system.toml index ca95cc23d4f..6508e03d6d4 100644 --- a/tests/updatecase/system.toml +++ b/tests/updatecase/system.toml @@ -30,7 +30,7 @@ syscap = ["logread"] # `reboot` is how the host hands the machine to the slot `update` marked. [programs.toybox] -syscap = ["power"] +receives = ["power"] [symlinks] "bin/reboot" = "/system/bin/toybox" diff --git a/toyos-update/Cargo.toml b/toyos-update/Cargo.toml index 3d24ef3ef42..eb75ee7b793 100644 --- a/toyos-update/Cargo.toml +++ b/toyos-update/Cargo.toml @@ -15,6 +15,10 @@ publish = false # so no binary on the machine carries a path that makes a signature. sign = [] +[dev-dependencies] +# The GPT crate's own spelling of a GUID, which `entry::parse_guid` reads. +toyos-gpt = { path = "../toyos-gpt" } + [dependencies] # **The one verifier, and why this one.** `ed25519-dalek` is the Ed25519 the # Rust ecosystem ships (RustCrypto's `ssh-key` and `russh`, which this tree's diff --git a/toyos-update/src/entry.rs b/toyos-update/src/entry.rs new file mode 100644 index 00000000000..bdb183ce0c1 --- /dev/null +++ b/toyos-update/src/entry.rs @@ -0,0 +1,389 @@ +//! The firmware's boot entries, as the loader writes them on the running +//! system's request ([`crate::slots::Request`]): a load option naming an EFI +//! system partition's loader, the order with one entry first, and the entry +//! the firmware would have tried after the one that booted this pass. +//! +//! **The loader writes one shape of entry and no other**: a GPT partition by +//! its unique GUID and a file on it, `HD(…)/File(…)`, which is the short form +//! `efibootmgr --disk … --part …` writes and firmware expands against every +//! disk it sees (UEFI 2.10 §3.1.2). It never writes an entry for a path the +//! running system names — only for an ESP the loader found and its +//! removable-media file — so the request can ask for a boot of an ESP and for +//! nothing else. +//! +//! What firmware stores is untrusted here: an option is walked as bytes, +//! bounded by the slice, and a node claiming a length of zero or one past the +//! option ends the walk rather than being stepped over. + +/// `LOAD_OPTION_ACTIVE` (UEFI 2.10 §3.1.3): the boot manager may boot it. +pub const LOAD_OPTION_ACTIVE: u32 = 0x1; + +/// `EFI_LOAD_OPTION`'s fixed head: a `UINT32` of attributes and a `UINT16` +/// device-path length, then a null-terminated `CHAR16` description, then the +/// device path itself (UEFI 2.10 §3.1.3). +const LOAD_OPTION_HEAD: usize = 6; + +/// A device-path node's type, subtype and length (UEFI 2.10 §10.2). +const NODE_HEADER: usize = 4; + +/// MEDIA/HARD_DRIVE (UEFI 2.10 §10.3.6.1): partition number, start and size +/// in blocks, the signature, its format and its type — 42 bytes. +const HARD_DRIVE: (u8, u8) = (0x04, 0x01); +const HARD_DRIVE_BYTES: usize = 42; +/// `MBRType` 2 is a GPT partition, and `SignatureType` 2 a GUID signature. +const GPT: u8 = 0x02; +const SIGNATURE_GUID: u8 = 0x02; + +/// MEDIA/FILE_PATH (UEFI 2.10 §10.3.6.4): a null-terminated `CHAR16` path. +const FILE_PATH: (u8, u8) = (0x04, 0x04); + +/// END_ENTIRE_DEVICE_PATH (UEFI 2.10 §10.3.1). +const END: (u8, u8) = (0x7F, 0xFF); + +/// A GPT partition as a HARDDRIVE node names it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Partition { + /// The entry's index in the partition array, counted from 1. + pub number: u32, + /// Its first block and its length in blocks, in the disk's own block size. + pub start: u64, + pub size: u64, + /// Its unique GUID, as the GPT entry stores it. + pub guid: [u8; 16], +} + +/// Why an option could not be written. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unwritable { + /// A description or a path holds a character outside ASCII, which this + /// writes as UCS-2 one byte at a time and so refuses rather than mangles. + NotAscii, + /// The option does not fit the buffer it was asked into. + TooLong { needs: usize }, +} + +impl core::fmt::Display for Unwritable { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::NotAscii => write!(f, "a description or path outside ASCII"), + Self::TooLong { needs } => write!(f, "an option of {needs} bytes, past the buffer it was asked into"), + } + } +} + +/// `text` as null-terminated UCS-2 at `out[at..]`, and where it ends. +fn ucs2(text: &str, out: &mut [u8], at: usize) -> Result { + if !text.is_ascii() { + return Err(Unwritable::NotAscii); + } + let end = at + 2 * (text.len() + 1); + let room = out.len(); + let span = out.get_mut(at..end).ok_or(Unwritable::TooLong { needs: end.max(room + 1) })?; + for (unit, byte) in span.chunks_exact_mut(2).zip(text.bytes().chain(core::iter::once(0))) { + unit.copy_from_slice(&u16::from(byte).to_le_bytes()); + } + Ok(end) +} + +/// The load option `HD(part)/File(path)`, active, described as `description`, +/// written into `out`; its length. +pub fn load_option(description: &str, part: &Partition, path: &str, out: &mut [u8]) -> Result { + if !path.is_ascii() || !description.is_ascii() { + return Err(Unwritable::NotAscii); + } + let file_bytes = NODE_HEADER + 2 * (path.len() + 1); + let path_bytes = HARD_DRIVE_BYTES + file_bytes + NODE_HEADER; + let needs = LOAD_OPTION_HEAD + 2 * (description.len() + 1) + path_bytes; + if needs > out.len() { + return Err(Unwritable::TooLong { needs }); + } + let path_len = u16::try_from(path_bytes).map_err(|_| Unwritable::TooLong { needs })?; + let file_len = u16::try_from(file_bytes).map_err(|_| Unwritable::TooLong { needs })?; + out[..needs].fill(0); + out[..4].copy_from_slice(&LOAD_OPTION_ACTIVE.to_le_bytes()); + out[4..6].copy_from_slice(&path_len.to_le_bytes()); + let mut at = ucs2(description, out, LOAD_OPTION_HEAD)?; + + let hd = &mut out[at..at + HARD_DRIVE_BYTES]; + hd[0] = HARD_DRIVE.0; + hd[1] = HARD_DRIVE.1; + hd[2..4].copy_from_slice(&(HARD_DRIVE_BYTES as u16).to_le_bytes()); + hd[4..8].copy_from_slice(&part.number.to_le_bytes()); + hd[8..16].copy_from_slice(&part.start.to_le_bytes()); + hd[16..24].copy_from_slice(&part.size.to_le_bytes()); + hd[24..40].copy_from_slice(&part.guid); + hd[40] = GPT; + hd[41] = SIGNATURE_GUID; + at += HARD_DRIVE_BYTES; + + out[at] = FILE_PATH.0; + out[at + 1] = FILE_PATH.1; + out[at + 2..at + 4].copy_from_slice(&file_len.to_le_bytes()); + at = ucs2(path, out, at + NODE_HEADER)?; + + out[at] = END.0; + out[at + 1] = END.1; + out[at + 2..at + 4].copy_from_slice(&(NODE_HEADER as u16).to_le_bytes()); + Ok(at + NODE_HEADER) +} + +/// The device path an option carries, bounded by the option; `None` where its +/// head, its description or its path runs off the end. +fn device_path(option: &[u8]) -> Option<&[u8]> { + let head = option.get(..LOAD_OPTION_HEAD)?; + let path_len = usize::from(u16::from_le_bytes([head[4], head[5]])); + // The description is `CHAR16` and null-terminated, so the path starts after + // the first pair of zero bytes on an even offset from the head. + let mut at = LOAD_OPTION_HEAD; + loop { + let pair = option.get(at..at + 2)?; + at += 2; + if pair == [0, 0] { + break; + } + } + option.get(at..at.checked_add(path_len)?) +} + +/// The GPT signature of the first HARDDRIVE node in a device path, or `None` +/// where it has none, names an MBR partition, or a node cannot be stepped +/// over. +pub fn hard_drive_guid(mut path: &[u8]) -> Option<[u8; 16]> { + while let Some(node) = path.get(..NODE_HEADER) { + let len = usize::from(u16::from_le_bytes([node[2], node[3]])); + // A node shorter than its own header, or longer than what is left, ends + // the walk: neither can be stepped over. + let this = path.get(..len).filter(|_| len >= NODE_HEADER)?; + if (this[0], this[1]) == END { + return None; + } + if (this[0], this[1]) == HARD_DRIVE { + if this.get(40) != Some(&GPT) || this.get(41) != Some(&SIGNATURE_GUID) { + return None; + } + return this.get(24..40)?.try_into().ok(); + } + path = &path[len..]; + } + None +} + +/// Whether an option boots off the GPT partition `guid`. +pub fn names(option: &[u8], guid: &[u8; 16]) -> bool { + device_path(option).and_then(hard_drive_guid).is_some_and(|found| found == *guid) +} + +/// Whether an option is active: the boot manager skips one that is not. +pub fn active(option: &[u8]) -> bool { + option.get(..4).is_some_and(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]) & LOAD_OPTION_ACTIVE != 0) +} + +/// `BootOrder` with `ours` first and every other entry after it in the order +/// it had, into `out`; how many entries that is. An `out` shorter than the +/// order keeps the order's head. +pub fn first(order: &[u16], ours: u16, out: &mut [u16]) -> usize { + let mut n = 0; + for entry in core::iter::once(ours).chain(order.iter().copied().filter(|&e| e != ours)) { + let Some(slot) = out.get_mut(n) else { break }; + *slot = entry; + n += 1; + } + n +} + +/// The entry the firmware would have tried after `current`: the first after it +/// in `order` that `skip` does not refuse — never `current` again, and never +/// an entry before it, so a machine whose every later entry is skipped has +/// nothing to fall to rather than a loop. +/// +/// `current` not in the order at all (a `BootNext` boot) falls to the order's +/// first entry that `skip` does not refuse: that is what the firmware tries +/// after a `BootNext` boot fails. +pub fn after(order: &[u16], current: u16, skip: impl Fn(u16) -> bool) -> Option { + let rest = match order.iter().position(|&e| e == current) { + Some(at) => &order[at + 1..], + None => order, + }; + rest.iter().copied().find(|&e| e != current && !skip(e)) +} + +/// The lowest `Boot####` number no entry in `used` holds. +pub fn free(used: &[u16]) -> Option { + (0..=u16::MAX).find(|n| !used.contains(n)) +} + +/// A GPT GUID in the text the GPT tools print, for a line that names one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GuidText(pub [u8; 16]); + +impl core::fmt::Display for GuidText { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let b = &self.0; + write!( + f, + "{:02X}{:02X}{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}", + b[3], b[2], b[1], b[0], b[5], b[4], b[7], b[6], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] + ) + } +} + +/// A GPT GUID's text, `C12A7328-F81F-11D2-BA4B-00A0C93EC93B`, as the bytes a +/// partition entry stores: the first three fields little-endian, the last two +/// as written (UEFI 2.10 appendix A). Either case; nothing but the 36 +/// characters. +pub fn parse_guid(text: &str) -> Option<[u8; 16]> { + let bytes = text.as_bytes(); + if bytes.len() != 36 || [8, 13, 18, 23].iter().any(|&at| bytes[at] != b'-') { + return None; + } + let mut hex = [0u8; 16]; + let mut digits = bytes.iter().enumerate().filter(|(at, _)| ![8, 13, 18, 23].contains(at)).map(|(_, b)| *b); + for out in hex.iter_mut() { + let high = (digits.next()? as char).to_digit(16)?; + let low = (digits.next()? as char).to_digit(16)?; + *out = (high * 16 + low) as u8; + } + Some([ + hex[3], hex[2], hex[1], hex[0], hex[5], hex[4], hex[7], hex[6], hex[8], hex[9], hex[10], hex[11], hex[12], hex[13], + hex[14], hex[15], + ]) +} + +/// `Boot0003`'s number; `None` for any other variable name. +pub fn number(name: &str) -> Option { + let hex = name.strip_prefix("Boot")?; + if hex.len() != 4 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u16::from_str_radix(hex, 16).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const PART: Partition = Partition { number: 1, start: 2048, size: 67_584, guid: [0xE5; 16] }; + + /// The bytes UEFI 2.10 §3.1.3 and §10.3.6 lay down for + /// `HD(1,GPT,,0x800,0x10800)/\EFI\BOOT\BOOTX64.EFI`, described as + /// `ToyOS`, written out by hand from the tables rather than by the writer. + fn by_hand() -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&1u32.to_le_bytes()); + let path = r"\EFI\BOOT\BOOTX64.EFI"; + let file_len = 4 + 2 * (path.len() + 1); + out.extend_from_slice(&((42 + file_len + 4) as u16).to_le_bytes()); + for unit in "ToyOS".encode_utf16().chain([0]) { + out.extend_from_slice(&unit.to_le_bytes()); + } + out.extend_from_slice(&[4, 1, 42, 0]); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&2048u64.to_le_bytes()); + out.extend_from_slice(&67_584u64.to_le_bytes()); + out.extend_from_slice(&[0xE5; 16]); + out.extend_from_slice(&[2, 2]); + out.extend_from_slice(&[4, 4]); + out.extend_from_slice(&(file_len as u16).to_le_bytes()); + for unit in path.encode_utf16().chain([0]) { + out.extend_from_slice(&unit.to_le_bytes()); + } + out.extend_from_slice(&[0x7F, 0xFF, 4, 0]); + out + } + + #[test] + fn an_option_is_the_bytes_the_specification_lays_down() { + let mut buffer = [0u8; 512]; + let n = load_option("ToyOS", &PART, r"\EFI\BOOT\BOOTX64.EFI", &mut buffer).expect("fits"); + assert_eq!(&buffer[..n], by_hand().as_slice()); + assert!(names(&buffer[..n], &[0xE5; 16])); + assert!(!names(&buffer[..n], &[0xE6; 16])); + assert!(active(&buffer[..n])); + } + + #[test] + fn an_option_that_does_not_fit_or_is_not_ascii_is_refused() { + let mut small = [0u8; 40]; + assert!(matches!(load_option("ToyOS", &PART, r"\EFI\BOOT\BOOTX64.EFI", &mut small), Err(Unwritable::TooLong { .. }))); + let mut buffer = [0u8; 512]; + assert_eq!(load_option("ToyÖS", &PART, r"\x", &mut buffer), Err(Unwritable::NotAscii)); + } + + /// **Firmware's bytes are walked, never trusted**: a node of length zero, + /// one past the option, a path running off the end and an MBR partition + /// each name nothing rather than looping, reading past the slice or + /// matching. + #[test] + fn a_bent_option_names_nothing() { + let good = by_hand(); + let desc_end = 6 + 2 * ("ToyOS".len() + 1); + let mut zero = good.clone(); + zero[desc_end + 2] = 0; + assert!(!names(&zero, &[0xE5; 16]), "a zero-length node"); + let mut long = good.clone(); + long[desc_end + 2] = 0xFF; + assert!(!names(&long, &[0xE5; 16]), "a node past the option"); + let mut short = good.clone(); + short.truncate(desc_end + 10); + assert!(!names(&short, &[0xE5; 16]), "a path running off the end"); + let mut mbr = good.clone(); + mbr[desc_end + 40] = 1; + assert!(!names(&mbr, &[0xE5; 16]), "an MBR partition"); + assert!(!names(&good[..5], &[0xE5; 16]), "a head alone"); + let mut unterminated = good; + unterminated.truncate(8); + assert!(!names(&unterminated, &[0xE5; 16]), "a description with no end"); + } + + #[test] + fn the_order_puts_ours_first_once() { + let mut out = [0u16; 8]; + let n = first(&[3, 1, 7], 7, &mut out); + assert_eq!(&out[..n], &[7, 3, 1]); + let n = first(&[3, 1], 9, &mut out); + assert_eq!(&out[..n], &[9, 3, 1], "an entry the order lacked is added at its head"); + let n = first(&[], 2, &mut out); + assert_eq!(&out[..n], &[2]); + let mut two = [0u16; 2]; + assert_eq!(first(&[3, 1, 7], 7, &mut two), 2); + assert_eq!(two, [7, 3]); + } + + /// The firmware's own fall-through: the next entry after the one that + /// booted, never it again and never one before it. + #[test] + fn the_entry_after_is_later_in_the_order_and_never_current() { + let order = [4, 2, 9, 5]; + assert_eq!(after(&order, 2, |_| false), Some(9)); + assert_eq!(after(&order, 2, |e| e == 9), Some(5)); + assert_eq!(after(&order, 5, |_| false), None, "nothing after the last"); + assert_eq!(after(&order, 7, |_| false), Some(4), "a BootNext boot falls to the order's head"); + assert_eq!(after(&order, 7, |e| e == 4), Some(2)); + assert_eq!(after(&[3, 3, 3], 3, |_| false), None, "never the entry that booted"); + } + + /// The GPT tools' own spelling, read back by the crate that writes it. + #[test] + fn a_guid_is_read_as_the_gpt_crate_writes_it() { + for guid in [toyos_gpt::Guid::EFI_SYSTEM, toyos_gpt::Guid::TOYOS_SLOTS, toyos_gpt::Guid([0xA5; 16])] { + let text = std::format!("{guid}"); + assert_eq!(parse_guid(&text), Some(guid.0), "{text}"); + assert_eq!(parse_guid(&text.to_ascii_lowercase()), Some(guid.0), "{text}"); + assert_eq!(std::format!("{}", GuidText(guid.0)), text); + } + assert_eq!(parse_guid("C12A7328F81F-11D2-BA4B-00A0C93EC93B0"), None); + assert_eq!(parse_guid("C12A7328-F81F-11D2-BA4B-00A0C93EC93"), None); + assert_eq!(parse_guid("G12A7328-F81F-11D2-BA4B-00A0C93EC93B"), None); + } + + #[test] + fn a_number_is_four_hex_digits() { + assert_eq!(number("Boot0003"), Some(3)); + assert_eq!(number("Boot00aF"), Some(0xAF)); + assert_eq!(number("BootOrder"), None); + assert_eq!(number("BootNext"), None); + assert_eq!(number("Boot00031"), None); + assert_eq!(free(&[0, 1, 3]), Some(2)); + assert_eq!(free(&[]), Some(0)); + } +} diff --git a/toyos-update/src/lib.rs b/toyos-update/src/lib.rs index c40d9b6ce2b..7748a31d593 100644 --- a/toyos-update/src/lib.rs +++ b/toyos-update/src/lib.rs @@ -25,10 +25,15 @@ //! signing key ([`floor`]); the updater refuses an image older than what the //! machine runs. Neither can defend a machine whose firmware variables anyone //! with the machine in hand can reset. +//! +//! **What the running system asks of the loader** is the slot table's too +//! ([`slots::Request`]): a slot booted once, and the firmware's boot variables +//! it cannot write itself, as [`entry`] decides them. #![cfg_attr(not(test), no_std)] #![forbid(unsafe_code)] +pub mod entry; pub mod floor; pub mod image; pub mod policy; diff --git a/toyos-update/src/policy.rs b/toyos-update/src/policy.rs index 7755ab5f41c..0aca88cbca1 100644 --- a/toyos-update/src/policy.rs +++ b/toyos-update/src/policy.rs @@ -90,13 +90,22 @@ pub fn admits(floor: u64, version: u64) -> Result<(), Refusal> { Ok(()) } -/// The slots a boot tries, in order: the marked one, then the other where the -/// table carries it. -pub fn order(table: &Table) -> [Option; 2] { - let other = table.marked.other(); - [Some(table.marked), table.slot(other).map(|_| other)] +/// The slots a boot tries, in order: the one asked for `once` where the table +/// carries it, else the marked one; then the other where the table carries it +/// — so a slot tried once and refused falls back to the marked one. +pub fn order(table: &Table, once: Option) -> [Option; 2] { + let first = once.filter(|w| table.slot(*w).is_some()).unwrap_or(table.marked); + let other = first.other(); + [Some(first), table.slot(other).map(|_| other)] } +/// The word the loader hands the kernel for a slot booted once, where the +/// refusal words go (`slot-refused=:once`): why the marked slot is not +/// the one this boot runs, which is no refusal of it. The kernel spells it +/// itself (`kernel/src/params.rs`'s `ONCE`), held to this one by +/// `toyos_build::bootlog`'s gate. +pub const ONCE: &str = "once"; + /// The floor after a boot proved `proven`: it only rises. pub fn raised(floor: u64, proven: u64) -> u64 { floor.max(proven) @@ -145,10 +154,15 @@ mod tests { #[test] fn a_boot_tries_the_marked_slot_first_and_the_other_only_if_there_is_one() { let slot = Some(Slot { boot: [1; 16], root: [2; 16], version: 1 }); - let both = Table { sequence: 1, marked: Which::B, slots: [slot, slot] }; - assert_eq!(order(&both), [Some(Which::B), Some(Which::A)]); - let one = Table { sequence: 1, marked: Which::A, slots: [slot, None] }; - assert_eq!(order(&one), [Some(Which::A), None]); + let request = crate::slots::Request::NONE; + let both = Table { sequence: 1, marked: Which::B, slots: [slot, slot], request }; + assert_eq!(order(&both, None), [Some(Which::B), Some(Which::A)]); + let one = Table { sequence: 1, marked: Which::A, slots: [slot, None], request }; + assert_eq!(order(&one, None), [Some(Which::A), None]); + // Once: the slot asked for first, and the marked one behind it. + assert_eq!(order(&both, Some(Which::A)), [Some(Which::A), Some(Which::B)]); + assert_eq!(order(&both, Some(Which::B)), [Some(Which::B), Some(Which::A)]); + assert_eq!(order(&one, Some(Which::B)), [Some(Which::A), None], "a slot the table does not carry is none to try"); } /// Each word is one token of a comma-separated boot parameter. diff --git a/toyos-update/src/record.rs b/toyos-update/src/record.rs index 8276c941582..d12951d805b 100644 --- a/toyos-update/src/record.rs +++ b/toyos-update/src/record.rs @@ -15,7 +15,7 @@ //! machine has run well for an older one. //! //! ```text -//! partition guid [16] | count u8 | booted u8 ('A', 'B' or 0) | 0 [6] +//! partition guid [16] | count u8 | booted u8 ('A', 'B' or 0) | once u8 (0 or 1) | 0 [5] //! | version u64 | signed-header sha256 [32] | dead A [32] | dead B [32] //! ``` //! @@ -24,6 +24,11 @@ //! **never to a version read here**: the file is on a partition the running //! system writes, so what it names is only which slot's signed header the //! loader verifies again, and the digest that header must hash to. +//! +//! **A slot booted once proves nothing to the floor** ([`Booted::once`]): the +//! bench tries an image the machine does not keep, and a floor raised to it +//! would refuse the image the machine does keep at the next boot. Its death +//! is still a death. use crate::slots::Which; use crate::Digest; @@ -38,6 +43,9 @@ pub struct Booted { pub version: u64, /// The SHA-256 of its signed header, which names the image exactly. pub digest: Digest, + /// It was booted once, on the running system's request, rather than as + /// the slot the table marks or the one that stood in for it. + pub once: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -78,14 +86,21 @@ pub struct Accounted { pub proven: Option, /// The slot whose image died, now recorded dead. pub died: Option, + /// The image the last boot ran once and handed back on purpose: proven to + /// have run, and no reason to raise the floor. + pub tried: Option, } /// Fold how the last boot ended into the record, given the anti-rollback /// `floor` a boot has proven: an image at or below it has run well before. pub fn account(record: Record, ended: Ended, floor: u64) -> Accounted { - let mut out = Accounted { record: Record { booted: None, ..record }, proven: None, died: None }; + let mut out = Accounted { record: Record { booted: None, ..record }, proven: None, died: None, tried: None }; let Some(booted) = record.booted else { return out }; let dies = match ended { + Ended::Proven if booted.once => { + out.tried = Some(booted); + false + } Ended::Proven => { out.proven = Some(booted); false @@ -114,6 +129,7 @@ impl Record { out[16] = self.count; if let Some(booted) = self.booted { out[17] = booted.slot.letter() as u8; + out[18] = u8::from(booted.once); out[24..32].copy_from_slice(&booted.version.to_le_bytes()); out[32..64].copy_from_slice(&booted.digest); } @@ -141,6 +157,11 @@ impl Record { slot: Which::from_letter(letter as char).ok_or(Foreign::Slot(letter))?, version: u64::from_le_bytes(bytes[24..32].try_into().expect("eight bytes")), digest: digest(32).ok_or(Foreign::Slot(letter))?, + once: match bytes[18] { + 0 => false, + 1 => true, + other => return Err(Foreign::Once(other)), + }, }), }; Ok(Self { count: bytes[16], booted, dead: [digest(64), digest(96)] }) @@ -155,6 +176,8 @@ pub enum Foreign { Partition([u8; 16]), /// It names a booted slot that is none, or one with no image. Slot(u8), + /// Its word for a slot booted once is neither 0 nor 1. + Once(u8), } impl core::fmt::Display for Foreign { @@ -163,6 +186,7 @@ impl core::fmt::Display for Foreign { Self::Length(n) => write!(f, "holds {n} bytes, wanted {BYTES}"), Self::Partition(g) => write!(f, "counts for {g:02x?}, another partition"), Self::Slot(b) => write!(f, "names slot {b:#04x}, which is no slot it booted"), + Self::Once(b) => write!(f, "says {b:#04x} of whether its slot was booted once, which is neither 0 nor 1"), } } } @@ -174,7 +198,7 @@ mod tests { const GUID: [u8; 16] = [7; 16]; fn booted(slot: Which) -> Record { - Record { count: 1, booted: Some(Booted { slot, version: 42, digest: [9; 32] }), dead: [None, Some([3; 32])] } + Record { count: 1, booted: Some(Booted { slot, version: 42, digest: [9; 32], once: false }), dead: [None, Some([3; 32])] } } #[test] @@ -233,4 +257,23 @@ mod tests { assert_eq!(account(last, Ended::Hung, 42).died, None, "version 42 at a floor of 42"); assert_eq!(account(last, Ended::Died, 100).died, Some(Which::B), "a panic is a death whatever the floor"); } + + /// **A slot booted once proves nothing to the floor**: its clean end is a + /// trial that ran, never the image the floor rises to, and its death is a + /// death like any other. The word survives the file, and a word that is + /// neither 0 nor 1 is not this file. + #[test] + fn a_slot_booted_once_raises_nothing_and_still_dies() { + let mut last = booted(Which::B); + let tried = Booted { once: true, ..last.booted.expect("booted") }; + last.booted = Some(tried); + assert_eq!(Record::decode(&last.encode(&GUID), &GUID), Ok(last)); + let clean = account(last, Ended::Proven, 0); + assert_eq!((clean.proven, clean.tried, clean.died), (None, Some(tried), None)); + assert_eq!(account(last, Ended::Died, 0).died, Some(Which::B)); + assert_eq!(account(last, Ended::Hung, 0).died, Some(Which::B), "a hang of an unproven trial is a death"); + let mut bent = last.encode(&GUID); + bent[18] = 2; + assert_eq!(Record::decode(&bent, &GUID), Err(Foreign::Once(2))); + } } diff --git a/toyos-update/src/slots.rs b/toyos-update/src/slots.rs index 62850650fef..833501e45fd 100644 --- a/toyos-update/src/slots.rs +++ b/toyos-update/src/slots.rs @@ -1,4 +1,5 @@ -//! The slot table: which partitions make each slot, and which slot is marked. +//! The slot table: which partitions make each slot, which slot is marked, and +//! what the running system asks of the loader's next pass. //! //! It lives on its own partition of type `toyos_gpt::Guid::TOYOS_SLOTS`, in two copies at //! blocks 0 and 1, and **a writer writes the copy that is not the current @@ -10,13 +11,22 @@ //! ```text //! magic "TOYOSLOT" | format u32 | marked u32 | sequence u64 //! then per slot: present u32 | 0 u32 | boot guid [16] | root guid [16] | version u64 +//! then the request: next u32 (0 none, 1 a slot, 2 an ESP) | slot u32 | esp guid [16] +//! | first u32 (0 or 1) //! then crc32 u32 over everything before it (TABLE_BYTES) //! ``` //! //! The version a slot records is what its writer installed, and is the //! updater's to compare against; the loader trusts nothing here but which -//! partitions to read and which slot is marked, and judges each slot by its -//! own signed header. +//! partitions to read, which slot is marked and what the request asks, and +//! judges each slot by its own signed header. +//! +//! **The request is how the running system reaches the firmware's boot +//! variables**, which nothing after `ExitBootServices` here writes: the kernel +//! never maps the runtime services, so the loader, which runs with boot +//! services, writes them for it ([`Request`]). A field that is not the one +//! value a writer leaves there is refused rather than read, so a table either +//! asks exactly one thing or is no table. /// The unit the table's copies are written in. pub const BLOCK: usize = 4096; @@ -25,9 +35,11 @@ pub const BLOCK: usize = 4096; pub const COPIES: u64 = 2; const MAGIC: [u8; 8] = *b"TOYOSLOT"; -const FORMAT: u32 = 1; +const FORMAT: u32 = 2; const SLOT_BYTES: usize = 4 + 4 + 16 + 16 + 8; -const BODY_BYTES: usize = 8 + 4 + 4 + 8 + 2 * SLOT_BYTES; +const REQUEST_AT: usize = 8 + 4 + 4 + 8 + 2 * SLOT_BYTES; +const REQUEST_BYTES: usize = 4 + 4 + 16 + 4; +const BODY_BYTES: usize = REQUEST_AT + REQUEST_BYTES; /// A copy's bytes, checksum included; the rest of its block is zero. pub const TABLE_BYTES: usize = BODY_BYTES + 4; @@ -84,6 +96,40 @@ impl Which { } } +/// What to boot once, at the next pass that boots anything, and never again. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Next { + /// One of this disk's slots, whether or not it is the marked one: the + /// bench's trial of an image the machine does not keep. + Slot(Which), + /// The EFI system partition with this unique GUID, on any disk the + /// firmware sees, by its removable-media path: the firmware's `BootNext`, + /// which is how the owner reaches another stick without a keyboard. + Esp([u8; 16]), +} + +/// What the running system asks of the loader's next pass. +/// +/// **Each field is acted on once**: the pass that acts on it writes the table +/// again without it before it acts, so a pass that dies after the write has +/// lost the request rather than repeating it, and a pass that cannot write +/// the table acts on nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct Request { + pub next: Option, + /// Put this loader's own entry first in the firmware's `BootOrder`, + /// making it where the firmware has none: how a machine is taken over. + pub first: bool, +} + +impl Request { + pub const NONE: Self = Self { next: None, first: false }; + + pub const fn is_empty(&self) -> bool { + self.next.is_none() && !self.first + } +} + /// One copy of the table. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Table { @@ -91,6 +137,7 @@ pub struct Table { pub marked: Which, /// Slot `A` then slot `B`; `None` for a machine built with one slot. pub slots: [Option; 2], + pub request: Request, } /// Why a copy is not a table. @@ -101,6 +148,9 @@ pub enum Unreadable { Checksum, /// It marks a slot it does not carry, or a mark that is no slot. Mark(u32), + /// Its request is none a writer makes: an unknown kind, a slot it does + /// not carry, or a field that is not the one value its kind leaves. + Request(&'static str), } impl core::fmt::Display for Unreadable { @@ -110,10 +160,15 @@ impl core::fmt::Display for Unreadable { Self::Format(n) => write!(f, "a slot table of format {n}, and this reads {FORMAT}"), Self::Checksum => write!(f, "a slot table whose checksum does not hold, which is a torn write"), Self::Mark(n) => write!(f, "a slot table marking slot {n}, which it does not carry"), + Self::Request(why) => write!(f, "a slot table whose request {why}"), } } } +const NEXT_NONE: u32 = 0; +const NEXT_SLOT: u32 = 1; +const NEXT_ESP: u32 = 2; + impl Table { pub fn slot(&self, which: Which) -> Option { self.slots[which.index()] @@ -134,6 +189,19 @@ impl Table { out[at + 40..at + 48].copy_from_slice(&slot.version.to_le_bytes()); } } + let at = REQUEST_AT; + match self.request.next { + None => {} + Some(Next::Slot(which)) => { + out[at..at + 4].copy_from_slice(&NEXT_SLOT.to_le_bytes()); + out[at + 4..at + 8].copy_from_slice(&(which.index() as u32).to_le_bytes()); + } + Some(Next::Esp(guid)) => { + out[at..at + 4].copy_from_slice(&NEXT_ESP.to_le_bytes()); + out[at + 8..at + 24].copy_from_slice(&guid); + } + } + out[at + 24..at + 28].copy_from_slice(&u32::from(self.request.first).to_le_bytes()); let crc = crc32(&out[..BODY_BYTES]); out[BODY_BYTES..TABLE_BYTES].copy_from_slice(&crc.to_le_bytes()); out @@ -168,7 +236,37 @@ impl Table { other => return Err(Unreadable::Mark(other)), }; let sequence = u64::from_le_bytes(block[16..24].try_into().expect("eight bytes")); - Ok(Self { sequence, marked, slots }) + let request = Self::request(block, &slots)?; + Ok(Self { sequence, marked, slots, request }) + } + + /// The request a copy carries, each field held to the one value its kind + /// leaves there. + fn request(block: &[u8; BLOCK], slots: &[Option; 2]) -> Result { + let at = REQUEST_AT; + let word = |at: usize| u32::from_le_bytes(block[at..at + 4].try_into().expect("four bytes")); + let guid: [u8; 16] = block[at + 8..at + 24].try_into().expect("sixteen bytes"); + let (kind, slot) = (word(at), word(at + 4)); + let next = match kind { + NEXT_NONE if slot == 0 && guid == [0; 16] => None, + NEXT_NONE => return Err(Unreadable::Request("asks nothing next and names something to boot")), + NEXT_SLOT if guid != [0; 16] => return Err(Unreadable::Request("names a slot and an ESP at once")), + NEXT_SLOT => match slot { + 0 if slots[0].is_some() => Some(Next::Slot(Which::A)), + 1 if slots[1].is_some() => Some(Next::Slot(Which::B)), + _ => return Err(Unreadable::Request("names a slot the table does not carry")), + }, + NEXT_ESP if slot != 0 => return Err(Unreadable::Request("names an ESP and a slot at once")), + NEXT_ESP if guid == [0; 16] => return Err(Unreadable::Request("names an ESP by no GUID")), + NEXT_ESP => Some(Next::Esp(guid)), + _ => return Err(Unreadable::Request("asks for a kind of boot there is none of")), + }; + let first = match word(at + 24) { + 0 => false, + 1 => true, + _ => return Err(Unreadable::Request("asks for the boot order with a word that is neither 0 nor 1")), + }; + Ok(Request { next, first }) } } @@ -321,7 +419,51 @@ mod tests { fn table(marked: Which, sequence: u64) -> Table { let slot = |n: u8| Some(Slot { boot: [n; 16], root: [n + 1; 16], version: u64::from(n) }); - Table { sequence, marked, slots: [slot(1), slot(3)] } + Table { sequence, marked, slots: [slot(1), slot(3)], request: Request::NONE } + } + + /// `block` with its checksum made to hold again, so a test bends one field + /// and the refusal is that field's rather than the checksum's. + fn resealed(mut block: [u8; BLOCK]) -> [u8; BLOCK] { + let crc = crc32(&block[..BODY_BYTES]); + block[BODY_BYTES..TABLE_BYTES].copy_from_slice(&crc.to_le_bytes()); + block + } + + /// **A request reads back as it was asked, and a request no writer makes + /// is no table**: an unknown kind, a slot the table does not carry, a slot + /// and an ESP at once, an ESP of no GUID, and a boot-order word that is + /// neither 0 nor 1. + #[test] + fn a_request_reads_back_and_one_no_writer_makes_is_refused() { + let t = table(Which::A, 2); + for request in [ + Request::NONE, + Request { next: Some(Next::Slot(Which::B)), first: false }, + Request { next: Some(Next::Slot(Which::A)), first: true }, + Request { next: Some(Next::Esp([0x5A; 16])), first: false }, + Request { next: None, first: true }, + ] { + let asked = Table { request, ..t }; + assert_eq!(Table::decode(&asked.encode()), Ok(asked), "{request:?}"); + } + let at = REQUEST_AT; + let bent = |bend: &dyn Fn(&mut [u8; BLOCK])| { + let mut block = Table { request: Request { next: Some(Next::Slot(Which::B)), first: false }, ..t }.encode(); + bend(&mut block); + Table::decode(&resealed(block)) + }; + let refused = |why| Err(Unreadable::Request(why)); + assert_eq!(bent(&|b| b[at] = 3), refused("asks for a kind of boot there is none of")); + assert_eq!(bent(&|b| b[at + 4] = 2), refused("names a slot the table does not carry")); + assert_eq!(bent(&|b| b[at + 8] = 1), refused("names a slot and an ESP at once")); + assert_eq!(bent(&|b| b[at + 24] = 2), refused("asks for the boot order with a word that is neither 0 nor 1")); + assert_eq!(bent(&|b| b[at] = 0), refused("asks nothing next and names something to boot")); + assert_eq!(bent(&|b| { b[at] = 2; b[at + 4] = 0 }), refused("names an ESP by no GUID")); + let one = Table { slots: [t.slots[0], None], request: Request { next: Some(Next::Slot(Which::A)), first: false }, ..t }; + let mut names_absent = one.encode(); + names_absent[at + 4] = 1; + assert_eq!(Table::decode(&resealed(names_absent)), refused("names a slot the table does not carry")); } /// The check value every CRC-32 is held to. diff --git a/userland/toybox/src/date.rs b/userland/toybox/src/date.rs new file mode 100644 index 00000000000..3f208f08635 --- /dev/null +++ b/userland/toybox/src/date.rs @@ -0,0 +1,22 @@ +//! `date -u +%s`: the machine's clock as Unix seconds, which is what a host +//! reads to place this machine's log lines against its own clock. +//! +//! **That one form and no other.** A `date` that took a format would have to +//! implement `strftime`, and nothing here needs one; any other argument is +//! refused by name rather than printed in some format the caller did not ask +//! for. + +pub fn main(args: Vec) { + let words: Vec<&str> = args.iter().map(String::as_str).collect(); + if words != ["-u", "+%s"] { + eprintln!("date: only `date -u +%s` is here, and {words:?} is not it"); + std::process::exit(2); + } + match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(since) => println!("{}", since.as_secs()), + Err(e) => { + eprintln!("date: this machine's clock reads before 1970: {e}"); + std::process::exit(1); + } + } +} diff --git a/userland/toybox/src/main.rs b/userland/toybox/src/main.rs index 9e3df687707..e929042c9c4 100644 --- a/userland/toybox/src/main.rs +++ b/userland/toybox/src/main.rs @@ -1,5 +1,6 @@ mod cat; mod cp; +mod date; mod echo; mod free; mod grep; @@ -30,7 +31,7 @@ macro_rules! commands { }; } -commands!(cat, cp, echo, free, grep, hexdump, locale, ls, mkdir, mv, net, ps, pwd, reboot, rm, screen, shutdown, spin, stats, tone); +commands!(cat, cp, date, echo, free, grep, hexdump, locale, ls, mkdir, mv, net, ps, pwd, reboot, rm, screen, shutdown, spin, stats, tone); fn main() { let args: Vec = std::env::args().collect(); diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index 3898e2adac9..f3d2a5434c8 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -1,5 +1,14 @@ //! `/system/bin/update`: a signed image on standard input, installed into the -//! slot this machine is not running, and marked to boot next. +//! slot this machine is not running, and marked to boot next — or booted once +//! and not marked; and the requests the running system makes of the loader's +//! next pass, which reach the firmware's boot variables. +//! +//! ```text +//! update < image install into the idle slot and mark it +//! update --once < image install into the idle slot and boot it once +//! update --boot-first put this loader's entry first in BootOrder +//! update --boot-next boot that EFI system partition once +//! ``` //! //! **It does not care how the image arrived**: `ssh update < image` //! today, a pull from a release server tomorrow, a file on a local shell — the @@ -20,12 +29,18 @@ //! volume, each held to its hash before it is written; //! 4. an fsync of each claim, which answers for these writes and no other //! process's; -//! 5. the slot table, marking the idle slot — the copy that is not current, -//! so a torn write leaves the old mark — and its fsync. +//! 5. the slot table, marking the idle slot — or, `--once`, asking the loader +//! to boot it once and leaving the mark where it is — the copy that is not +//! current, so a torn write leaves the old table, and its fsync. //! -//! A refusal at any step leaves the mark where it was, so the machine boots +//! A refusal at any step leaves the table as it was, so the machine boots //! what it booted before. The loader checks every byte again at the next boot //! and refuses the slot by name if anything here was wrong. +//! +//! **A request is the table's too** (`toyos_update::slots::Request`): the +//! loader acts on it once at its next pass, and writes it away as it does. +//! It asks for no more than an entry for an EFI system partition the loader +//! finds itself, first in the order or next once. use std::io::Read; use std::time::Instant; @@ -34,7 +49,7 @@ use toyos::endow::Endowments; use toyos::PartitionDev; use toyos_abi::part::{Block, BLOCK_BYTES, MAX_BLOCKS_PER_CALL}; use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; -use toyos_update::slots::{self, Table, Which}; +use toyos_update::slots::{self, Next, Table, Which}; use toyos_fat32::BlockAccess as _; use toyos_update::{policy, sig}; @@ -44,9 +59,34 @@ const KEY: [u8; 32] = sig::key_from_hex(env!("TOYOS_IMAGE_KEY")); /// The line an install ends on, which the host reads. const INSTALLED: &str = "update: installed"; +/// What this run was asked for. +enum Asked { + /// An image on standard input, marked, or booted `once`. + Install { once: bool }, + BootFirst, + BootNext([u8; 16]), +} + +fn asked() -> Result { + let args: Vec = std::env::args().skip(1).collect(); + let words: Vec<&str> = args.iter().map(String::as_str).collect(); + match words[..] { + [] => Ok(Asked::Install { once: false }), + ["--once"] => Ok(Asked::Install { once: true }), + ["--boot-first"] => Ok(Asked::BootFirst), + ["--boot-next", guid] => toyos_update::entry::parse_guid(guid) + .map(Asked::BootNext) + .ok_or_else(|| format!("{guid:?} is no partition GUID; --boot-next wants one as the GPT tools print it")), + _ => Err(format!( + "{words:?} is no ask this takes: `update`, `update --once` (each with an image on its input), \ + `update --boot-first` or `update --boot-next `" + )), + } +} + fn main() { let began = Instant::now(); - match run(began) { + match asked().and_then(|asked| run(asked, began)) { Ok(line) => println!("{line}"), Err(why) => { println!("update: refused: {why}"); @@ -54,7 +94,6 @@ fn main() { } } } - /// The three claims init endowed, or why this process holds none. fn grant() -> Result<(PartitionDev, PartitionDev, PartitionDev), String> { let take = |label: &str| { @@ -65,9 +104,27 @@ fn grant() -> Result<(PartitionDev, PartitionDev, PartitionDev), String> { Ok((take(slots::TABLE_LABEL)?, take(slots::BOOT_LABEL)?, take(slots::ROOT_LABEL)?)) } -fn run(began: Instant) -> Result { +fn run(asked: Asked, began: Instant) -> Result { let (table_claim, boot, root) = grant()?; let (table, current) = read_table(&table_claim)?; + let once = match asked { + Asked::Install { once } => once, + Asked::BootFirst => { + let request = slots::Request { first: true, ..table.request }; + write_table(&table_claim, (table, current), Table { request, ..table })?; + return Ok(String::from( + "update: the loader puts its own entry first in the firmware's BootOrder at its next pass", + )); + } + Asked::BootNext(guid) => { + let request = slots::Request { next: Some(Next::Esp(guid)), ..table.request }; + write_table(&table_claim, (table, current), Table { request, ..table })?; + return Ok(format!( + "update: the loader boots EFI system partition {} once, at its next pass, and the order after it", + toyos_update::entry::GuidText(guid) + )); + } + }; let boot_guid = boot.describe().map_err(|e| format!("the idle volume's claim: {e:?}"))?.unique_guid; let root_info = root.describe().map_err(|e| format!("the idle ROOT's claim: {e:?}"))?; let idle = [Which::A, Which::B] @@ -117,18 +174,23 @@ fn run(began: Instant) -> Result { boot.sync().map_err(|e| format!("slot {}'s volume is not durable: {e:?}", idle.letter()))?; let mut next = table; - next.marked = idle; let mut slot = table.slot(idle).expect("the idle slot is in the table"); slot.version = header.version; next.slots[idle.index()] = Some(slot); - let (copy, block) = slots::next_write((table, current), next); - table_claim - .write(copy as u64, &[block]) - .map_err(|e| format!("the slot table's copy {copy} would not write: {e:?}"))?; - table_claim.sync().map_err(|e| format!("the slot table is not durable: {e:?}"))?; + // A slot asked for once before this install is answered by it: the idle + // slot is marked now, or is the one asked for. + next.request.next = next.request.next.filter(|n| matches!(n, Next::Esp(_))); + let when = if once { + next.request.next = Some(Next::Slot(idle)); + format!("it boots once at the next reboot, and slot {} at every boot after", table.marked.letter()) + } else { + next.marked = idle; + String::from("it boots at the next reboot") + }; + write_table(&table_claim, (table, current), next)?; Ok(format!( - "{INSTALLED} version {} in slot {} ({} bytes of ROOT in {root_ms} ms, {} ms in all); it boots at the next reboot", + "{INSTALLED} version {} in slot {} ({} bytes of ROOT in {root_ms} ms, {} ms in all); {when}", header.version, idle.letter(), header.root().len, @@ -136,6 +198,14 @@ fn run(began: Instant) -> Result { )) } +/// Make `next` the slot table, as every writer does (`slots::next_write`), and +/// make it durable before this answers. +fn write_table(claim: &PartitionDev, current: (Table, usize), next: Table) -> Result<(), String> { + let (copy, block) = slots::next_write(current, next); + claim.write(copy as u64, &[block]).map_err(|e| format!("the slot table's copy {copy} would not write: {e:?}"))?; + claim.sync().map_err(|e| format!("the slot table is not durable: {e:?}")) +} + /// The slot table and which copy of it is current. fn read_table(claim: &PartitionDev) -> Result<(Table, usize), String> { let mut copies: [Block; 2] = [[0; BLOCK_BYTES]; 2]; From c5d994496b20774f1e5167217826c00b9fddda76 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 13:47:14 +0200 Subject: [PATCH 02/26] Clippy's and the scratch gate's findings, and the bench configs in ALL_CONFIGS `as_chunks` for the two-byte walks, a type for a boot entry, no redundant clones, `rfind` for the variable store's last copy; `toyos_tmpdir::TempDir` for the bench's scratch and its tests; tests/benchcase and tests/benchvirtiocase in the list every config gate walks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- bootloader/src/bootvars.rs | 7 +++++-- src/build.rs | 2 ++ src/metal.rs | 2 +- src/metalbench.rs | 12 +++++------- tests/common/bench.rs | 2 +- tests/common/update.rs | 3 +-- toyos-update/src/entry.rs | 2 +- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs index d809124055b..978853feedd 100644 --- a/bootloader/src/bootvars.rs +++ b/bootloader/src/bootvars.rs @@ -86,8 +86,11 @@ pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { Ok(part) } +/// A `Boot####` entry: its number and its option's bytes. +type Entry = (u16, alloc::boxed::Box<[u8]>); + /// Every `Boot####` entry firmware holds, with its option's bytes. -fn entries(rt: &RuntimeServices) -> Result)>, String> { +fn entries(rt: &RuntimeServices) -> Result, String> { let keys = rt.variable_keys().map_err(|e| alloc::format!("firmware would not list its variables ({e})"))?; let mut out = Vec::new(); for key in keys.iter().filter(|key| key.vendor == VariableVendor::GLOBAL_VARIABLE) { @@ -126,7 +129,7 @@ pub fn entry_for(rt: &RuntimeServices, part: &Partition) -> Result<(u16, bool), fn words(rt: &RuntimeServices, name: &CStr16) -> Result>, String> { match rt.get_variable_boxed(name, &VariableVendor::GLOBAL_VARIABLE) { Ok((bytes, _)) if bytes.len() % 2 == 0 && bytes.len() / 2 <= MAX_ORDER => { - Ok(Some(bytes.chunks_exact(2).map(|w| u16::from_le_bytes([w[0], w[1]])).collect())) + Ok(Some(bytes.as_chunks::<2>().0.iter().map(|w| u16::from_le_bytes(*w)).collect())) } Ok((bytes, _)) => Err(alloc::format!("{name} holds {} bytes, which is no order this loader reads", bytes.len())), Err(e) if e.status() == Status::NOT_FOUND => Ok(None), diff --git a/src/build.rs b/src/build.rs index 84a280cee0d..4a6d16402b0 100644 --- a/src/build.rs +++ b/src/build.rs @@ -3103,6 +3103,8 @@ mod tests { "system.toml", "diag/system.toml", "console/system.toml", + "tests/benchcase/system.toml", + "tests/benchvirtiocase/system.toml", "tests/blockdcase/system.toml", "tests/desktopcase/system.toml", "tests/desktopaudiocase/system.toml", diff --git a/src/metal.rs b/src/metal.rs index 721c9acbddb..e699d7fad4d 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -2329,7 +2329,7 @@ fn hand_to_the_bench(args: &Args, driver: &Driver, asked: &Path) -> Result<(), R return Ok(()); } driver.wait(GOING_DOWN_SECS, "go down", false)?; - let scratch = std::env::temp_dir().join(format!("toyos-metal-bench-{}", std::process::id())); + let scratch = toyos_tmpdir::TempDir::new("bench"); crate::metalbench::take_the_machine(&args.target.key, &args.machine, args.wait_secs, &scratch) } diff --git a/src/metalbench.rs b/src/metalbench.rs index 0415bc53d94..54372a3600c 100644 --- a/src/metalbench.rs +++ b/src/metalbench.rs @@ -491,8 +491,8 @@ mod tests { /// `logd`, and an earlier boot of another image each name another. #[test] fn a_boots_log_is_the_one_that_names_its_root() { - let dir = std::env::temp_dir().join(format!("metalbench-logs-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("a scratch directory"); + let scratch = toyos_tmpdir::TempDir::new("logs"); + let dir = scratch.to_path_buf(); let mounted = |root: &str| format!("[kernel 0.2 cpu0] {} 0x1+0x2, filesystem {root}, 9 blocks\n", bootlog::MOUNTED_FROM_MEMORY); let files = [ ("2026-09-27-100000.log", mounted("aaaa")), @@ -505,12 +505,11 @@ mod tests { } let mut names: Vec = files.iter().map(|(n, _)| (*n).to_string()).collect(); names.push("loader.log".to_string()); - let logs = Logs { dir: dir.clone(), names }; + let logs = Logs { dir, names }; let text = kernel_log(&logs, "bbbb").expect("a read"); assert!(text.contains("filesystem bbbb,") && text.ends_with("the boot's second part\n"), "{text}"); assert_eq!(kernel_log(&logs, "dddd").expect("a read"), "", "a boot that reached no logd"); assert!(matches!(logs.read("loader-previous.log"), Err(Refusal::NotThisBoot(_)))); - let _ = std::fs::remove_dir_all(&dir); } /// **A bench under another loader takes no image**: the line its pass @@ -518,8 +517,8 @@ mod tests { /// refused before it is delivered — as is a bench whose pass named none. #[test] fn an_image_is_delivered_only_to_the_loader_it_was_built_with() { - let dir = std::env::temp_dir().join(format!("metalbench-loader-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("a scratch directory"); + let scratch = toyos_tmpdir::TempDir::new("loader"); + let dir = scratch.to_path_buf(); let ours = [0x11u8; 32]; let mut hex = [0u8; 64]; let line = format!("ToyOS Bootloader 1.0\n{} {}\n", bootlog::LOADER_IS, toyos_update::hex(&ours, &mut hex)); @@ -529,6 +528,5 @@ mod tests { assert!(matches!(same_loader(&logs, &[0x12; 32]), Err(Refusal::Undelivered(_)))); std::fs::write(dir.join(bootlog::LOADER_LOG), "ToyOS Bootloader 1.0\n").expect("a staged file"); assert!(matches!(same_loader(&logs, &ours), Err(Refusal::Undelivered(_))), "a pass that named no loader"); - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/tests/common/bench.rs b/tests/common/bench.rs index 867a5a5567f..01939d83db1 100644 --- a/tests/common/bench.rs +++ b/tests/common/bench.rs @@ -95,7 +95,7 @@ pub fn bench_loop_drives_a_toyos_machine( // **A tampered upload is refused**, by `update` and before the loop: one // byte of the kernel past the signed header, whose signature still holds. - let mut bent = update.bytes.clone(); + let mut bent = update.bytes; bent[toyos_update::image::SIGNED_BYTES + 100] ^= 0x01; let tampered = scratch.join("tampered.update"); std::fs::write(&tampered, &bent).map_err(|e| format!("write {}: {e}", tampered.display()))?; diff --git a/tests/common/update.rs b/tests/common/update.rs index c0d4081966d..d091479fe8a 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -915,8 +915,7 @@ mod vars { walk(&bytes)? .0 .into_iter() - .filter(|found| found.vendor == GLOBAL && found.state == VAR_ADDED && found.var.name == name) - .last() + .rfind(|found| found.vendor == GLOBAL && found.state == VAR_ADDED && found.var.name == name) .map(|found| found.var.data) .ok_or_else(|| format!("the variable store holds no live {name}")) } diff --git a/toyos-update/src/entry.rs b/toyos-update/src/entry.rs index bdb183ce0c1..9290ab50e19 100644 --- a/toyos-update/src/entry.rs +++ b/toyos-update/src/entry.rs @@ -79,7 +79,7 @@ fn ucs2(text: &str, out: &mut [u8], at: usize) -> Result { let end = at + 2 * (text.len() + 1); let room = out.len(); let span = out.get_mut(at..end).ok_or(Unwritable::TooLong { needs: end.max(room + 1) })?; - for (unit, byte) in span.chunks_exact_mut(2).zip(text.bytes().chain(core::iter::once(0))) { + for (unit, byte) in span.as_chunks_mut::<2>().0.iter_mut().zip(text.bytes().chain(core::iter::once(0))) { unit.copy_from_slice(&u16::from(byte).to_le_bytes()); } Ok(end) From 0f9b5b7e29d596af64a1f27558614d8643fb9546 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 14:11:16 +0200 Subject: [PATCH 03/26] The boot-next test names a second boot of the recovery stick After the recovery stick hands the machine back, and again after the machine reboots itself, the wait ends at the machine's own kernel or the recovery stick's a second time; the second is the red, by name, where it used to be the wait's own stall. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- tests/common/update.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/common/update.rs b/tests/common/update.rs index d091479fe8a..0c6fab40c04 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -730,13 +730,24 @@ pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], await_machine(&mut guest, &mut console, "the recovery stick's sshd", |c| c[from..].contains(SSHD_LISTENING))?; eprintln!(" [update] `update --boot-next {esp}` booted the recovery stick at the next reboot"); - let (from, _) = rig.reboot_until(&mut guest, &mut console, &ours)?; - await_machine(&mut guest, &mut console, "the machine's sshd after the recovery stick", |c| c[from..].contains(SSHD_LISTENING))?; - let (from, uart) = rig.reboot_until(&mut guest, &mut console, &ours)?; - await_machine(&mut guest, &mut console, "the machine's sshd a third time", |c| c[from..].contains(SSHD_LISTENING))?; - let since = guest.uart_log()[uart..].to_string(); - if since.contains("Request:") { - return Err(format!("the pass after the order resumed still read a request:\n{since}")); + // Twice more, each until the machine's own kernel or the recovery stick's + // a second time: a request never taken away boots the recovery stick at + // every pass, and that is the answer, not a wait for one that never comes. + for doing in ["the recovery stick hands the machine back", "the machine reboots itself"] { + let (from, uart) = (console.len(), guest.uart_log().len()); + ssh::ssh_fire(HOST, rig.port, &rig.identity, "reboot")?; + await_machine(&mut guest, &mut console, doing, |c| { + let since = &c[from.min(c.len())..]; + since.contains(&ours) || since.contains(&theirs) + })?; + if console[from..].contains(&theirs) { + return Err(format!("{doing}, and the recovery stick booted again: the request asked for it once")); + } + await_machine(&mut guest, &mut console, "the machine's sshd", |c| c[from..].contains(SSHD_LISTENING))?; + let since = guest.uart_log()[uart..].to_string(); + if since.contains("BootNext=Boot") { + return Err(format!("{doing}, and a pass set BootNext again:\n{since}")); + } } let booted = console.matches(&theirs).count(); if booted != 1 { From 75ebe9af5c26150b6fc1127acfee6a9d5623faeb Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 14:30:15 +0200 Subject: [PATCH 04/26] loader: an ESP an entry already names is booted by that entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry for an ESP is written only where none names it, and only for its removable-media loader; one that names it — the owner's own entry for Ubuntu's ESP, efibootmgr's for a stick — is reused whatever file it boots, and an ESP with neither is refused by name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- bootloader/src/bootvars.rs | 35 ++++++++++++++++++++++++----------- bootloader/src/request.rs | 4 ++-- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs index 978853feedd..fc8f2136ada 100644 --- a/bootloader/src/bootvars.rs +++ b/bootloader/src/bootvars.rs @@ -46,10 +46,16 @@ const OPTION_BYTES: usize = 256; /// not one this loader rewrites. const MAX_ORDER: usize = 256; -/// The EFI system partition `guid` names, as a HARDDRIVE node names it, with -/// its removable-media loader checked to be there: `Err` says which of those -/// it is not. -pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { +/// An EFI system partition as a HARDDRIVE node names it, and whether its +/// removable-media loader is there to write an entry for. +pub struct Esp { + pub part: Partition, + /// `Err` says why an entry for its removable path would boot nothing. + removable: Result<(), String>, +} + +/// The EFI system partition `guid` names, or which of those it is not. +pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { let handle = crate::loaderlog::volume_handle(bs, guid)?; let info = crate::rootimage::try_get_protocol::(bs, handle) .map_err(|e| alloc::format!("the partition names no partition record ({e:?})"))?; @@ -81,9 +87,11 @@ pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { .map_err(|e| alloc::format!("its volume would not open ({e:?})"))?; let mut root = fs.open_volume().map_err(|e| alloc::format!("it has no volume ({e})"))?; let loader = CString16::try_from(crate::arch::REMOVABLE_PATH).expect("the removable path is ASCII"); - root.open(&loader, FileMode::Read, FileAttribute::empty()) - .map_err(|e| alloc::format!("it carries no {} ({e})", crate::arch::REMOVABLE_PATH))?; - Ok(part) + let removable = root + .open(&loader, FileMode::Read, FileAttribute::empty()) + .map(|_| ()) + .map_err(|e| alloc::format!("it carries no {} ({e})", crate::arch::REMOVABLE_PATH)); + Ok(Esp { part, removable }) } /// A `Boot####` entry: its number and its option's bytes. @@ -104,16 +112,21 @@ fn entries(rt: &RuntimeServices) -> Result, String> { Ok(out) } -/// The entry that boots `part`: the lowest active one already naming it, or -/// one written here at the lowest free number. Its number, and whether it was -/// written. -pub fn entry_for(rt: &RuntimeServices, part: &Partition) -> Result<(u16, bool), String> { +/// The entry that boots `esp`: the lowest active one already naming it — +/// whatever file it boots there, the owner's own entry among them — or one +/// written here for its removable-media loader, at the lowest free number. +/// Its number, and whether it was written. +pub fn entry_for(rt: &RuntimeServices, esp: &Esp) -> Result<(u16, bool), String> { + let part = &esp.part; let held = entries(rt)?; if let Some(number) = held.iter().filter(|(_, o)| entry::active(o) && entry::names(o, &part.guid)).map(|(n, _)| *n).min() { return Ok((number, false)); } + if let Err(why) = &esp.removable { + return Err(alloc::format!("no entry names it, and {why}")); + } let used: Vec = held.iter().map(|(n, _)| *n).collect(); let number = entry::free(&used).ok_or("every Boot#### number is taken")?; let mut option = [0u8; OPTION_BYTES]; diff --git a/bootloader/src/request.rs b/bootloader/src/request.rs index 5f71b586667..32fc7864283 100644 --- a/bootloader/src/request.rs +++ b/bootloader/src/request.rs @@ -89,7 +89,7 @@ pub fn firmware(handle: Handle, system_table: &SystemTable, ours: Option<& if asked.first { match ours { None => println!("{ENTRIES} this loader came off no GPT partition, so it has no entry to put first"), - Some(guid) => match bootvars::esp(bs, guid).and_then(|part| bootvars::entry_for(rt, &part)) { + Some(guid) => match bootvars::esp(bs, guid).and_then(|esp| bootvars::entry_for(rt, &esp)) { Err(why) => println!("{ENTRIES} this loader's own ESP has no entry to put first: {why}"), Ok((number, made)) => { let made = if made { "written now" } else { "already there" }; @@ -107,7 +107,7 @@ pub fn firmware(handle: Handle, system_table: &SystemTable, ours: Option<& } let Some(guid) = esp else { return Fired::Nothing }; let named = toyos_gpt::Guid(guid); - match bootvars::esp(bs, &guid).and_then(|part| bootvars::entry_for(rt, &part)) { + match bootvars::esp(bs, &guid).and_then(|esp| bootvars::entry_for(rt, &esp)) { Err(why) => { println!("{ENTRIES} no boot of ESP {named} is set, because {why}; this pass boots as it would have"); Fired::Nothing From fce5492619e4a3ea48b7bc6a2fec6066a7a42979 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 14:33:31 +0200 Subject: [PATCH 05/26] The bench test reads the bench's clock, and the harness flag has its rule tested `date -u +%s` answers a second on the bench and a bare `date` is refused as 2; `--metal-via-ubuntu` alone is refused as reaching no machine. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- src/testargs.rs | 4 ++++ tests/common/bench.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/testargs.rs b/src/testargs.rs index d1d53e03b2c..33b9d1e0bdf 100644 --- a/src/testargs.rs +++ b/src/testargs.rs @@ -517,6 +517,10 @@ mod tests { fn a_readback_directory_alone_selects_no_tier() { let refusal = parse_owned(&["--metal-readback", "target/metal"]).unwrap_err(); assert!(refusal.contains("add --metal"), "{refusal}"); + // And the path to the machine alone, which reaches no machine. + let refusal = parse_owned(&["--metal-via-ubuntu"]).unwrap_err(); + assert!(refusal.contains("add --metal"), "{refusal}"); + assert!(parse_owned(&["--metal", "--metal-via-ubuntu"]).is_ok()); let refusal = parse_owned(&["--metal", "--audio-gate", "30"]).unwrap_err(); assert!(refusal.contains("cannot be combined"), "{refusal}"); } diff --git a/tests/common/bench.rs b/tests/common/bench.rs index 01939d83db1..18120587f51 100644 --- a/tests/common/bench.rs +++ b/tests/common/bench.rs @@ -106,6 +106,19 @@ pub fn bench_loop_drives_a_toyos_machine( } eprintln!(" [bench] a kernel byte flipped under its signature: {}", said.trim()); + // **The clock the loop reads a `--nic` boot's cable against**, from the + // bench itself: `date -u +%s` answers a second, and no other form is here. + let clock = ssh::ssh_exec(HOST, ssh_port, &runner, "date -u +%s")?; + let said = clock.stdout_text(); + if clock.status != Some(0) || said.trim().parse::().is_err() { + return Err(format!("`date -u +%s` ended {:?} saying {said:?}", clock.status)); + } + let other = ssh::ssh_exec(HOST, ssh_port, &runner, "date")?; + if other.status != Some(2) { + return Err(format!("a bare `date` ended {:?}, where the one form is refused as 2", other.status)); + } + eprintln!(" [bench] the bench's clock reads {}", said.trim()); + // **The loop**: the library call `toyos-metal` makes, the machine reached // through the forwards. let readback = scratch.join("readback"); From 9f9da32b6ea07451ded5362af26efcce115981c6 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 16:04:38 +0200 Subject: [PATCH 06/26] Review #539 round 1: a trial writes nothing it keeps, a readback is this boot's, one entry policy The blockers, each with the test that goes red without it: - B1: a boot's kernel log is never a stem whose names `/log` held before the delivery (`metalbench::kernel_log`), so a rerun of one image cannot read the last run's file. - B2: the machine is back only once its `loader.log` differs from the one fetched before the reboot (`rebooted`), in the one wait that fetches `/log`; `keep_previous` deletes `loader-previous.log` first and copies in chunks, so no refusal leaves an earlier chain's file and no bound refuses. - B3: a slot booted once stays on the table as `Next::Trial` (request kind 3) until the next pass that chooses a slot takes it off; while it stands `slots::grant` refuses the running slot's trial, so `update` on the trial holds nothing of the slot the machine keeps. - B4: `bench_image` refuses the owner's key (`bench_keys`), so neither `--owner-key` nor `--update-image` beside `--bench-image` signs a bench. - B5: a pass that cannot take a request off the slot table sets no `BootNext`: the boot-next test runs one pass on a read-only stick (`BootOptions::stick_readonly`) and reads the variable store after it. - B6: the pass after the one that wrote the boot order says no `Request:` and no `is first:` line. - B7: the bench test asserts the report pass's "not raised, because slot B's image was booted once" and no raise to the trial's version. - B8, B9, B10: one enumerator (`bootvars::entries`) and one `BootNext` writer (`bootvars::boot_next`); `bootnext::point_at_us` uses both, and the decisions are `toyos_update::entry::naming` and `entry::after`, each skipping an inactive entry and `after` every entry naming the loader's own ESP, host-tested. - B11: `GuidText` and `entry::parse_guid` are gone; `toyos_gpt::Guid::parse` sits beside the Display it inverts, and `update` uses both. NOTEs: `BootOrder` is written only where it fits `MAX_ORDER` (N3); `entry::after` falls behind the current entry's last place, so a duplicated order ends rather than cycles (N4); a free number is one neither an entry nor the order names (N5); lowercase hex is no `Boot####` (N6); every loader panic falls to the next entry through the loader's own panic handler, and powers off where there is none (N7), which is also the no-slot path now; the chosen slot's once and refusal are set in one place for both loops (N8, N9); the loader line names the removable-media file it hashes (N10); the tests use `build::AUTHORIZED_ON_ROOT` (N11); the restated `update` wording is gone (N12); the table format change is recorded in the loader-change issue (N13); `update`'s header says the two asks are unsigned (N14); the bench's own log is found by the ROOT its pass handed the kernel (N15); the once word is `toyos_abi::boot::SLOT_ONCE`, and the two copies and their gate row are gone (N16); `entry::Unwritable` is gone and the load option is built infallibly (N17); the Ubuntu-flag matrix, the `--metal-via-ubuntu` rule and `date`'s refusal of other forms are gone with their tests (N18). REMOVEs: the duplicated doc and the laptop clause in `bootnext.rs`, the consumed-BootNext clause in `end_this_pass`, the loader-log bound's comment with the bound, the ROOT room's measurement, the registration's duration and the Ubuntu table in `metalbench`'s header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- Cargo.lock | 1 - bootloader/Cargo.toml | 4 +- bootloader/src/bootnext.rs | 80 ++--- bootloader/src/bootvars.rs | 40 ++- bootloader/src/loaderlog.rs | 48 +-- bootloader/src/main.rs | 85 +++--- bootloader/src/request.rs | 28 +- bootloader/src/slot.rs | 34 +-- ...hes-a-machine-only-by-writing-its-stick.md | 6 + kernel/src/main.rs | 2 +- kernel/src/params.rs | 7 - src/bootlog.rs | 14 +- src/build.rs | 46 ++- src/metal.rs | 89 +----- src/metalbench.rs | 103 +++++-- src/testargs.rs | 11 - tests/common/bench.rs | 16 +- tests/common/lan.rs | 4 +- tests/common/metal.rs | 2 +- tests/common/qemu.rs | 9 +- tests/common/ssh.rs | 6 +- tests/common/update.rs | 129 +++++++- tests/toyos.rs | 7 +- toyos-abi/src/boot.rs | 6 + toyos-gpt/src/guid.rs | 40 +++ toyos-update/Cargo.toml | 4 - toyos-update/src/entry.rs | 287 ++++++++---------- toyos-update/src/policy.rs | 7 - toyos-update/src/slots.rs | 71 +++-- userland/toybox/src/date.rs | 17 +- userland/update/Cargo.toml | 2 + userland/update/src/main.rs | 10 +- 32 files changed, 654 insertions(+), 561 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 684a5bc6ebb..802affd915b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,7 +1434,6 @@ version = "0.1.0" dependencies = [ "ed25519-dalek", "sha2", - "toyos-gpt", ] [[package]] diff --git a/bootloader/Cargo.toml b/bootloader/Cargo.toml index 9f03ccfdc9a..43827091950 100644 --- a/bootloader/Cargo.toml +++ b/bootloader/Cargo.toml @@ -36,7 +36,9 @@ sha2 = { version = "0.10", default-features = false, features = ["force-soft"] } # `alloc`: `variable_keys` and `get_variable_boxed`, which are how the boot # entry pointing at this image is found among the firmware's own variables. uefi = { version = "0.26.0", default-features = false, features = ["alloc"] } -uefi-services = { version = "0.23.0", features = ["panic_handler", "logger"] } +# No `panic_handler`: the loader's own hands the machine to the next entry +# (`main.rs`'s `panic`). +uefi-services = { version = "0.23.0", default-features = false, features = ["logger"] } # The one profile every guest binary is built with. Optimised, because an # unoptimised guest mismeasures everything under TCG; diff --git a/bootloader/src/bootnext.rs b/bootloader/src/bootnext.rs index 2a9e67c73c6..3ed5a619c60 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -1,26 +1,26 @@ //! Point the firmware's next boot back at this loader. //! //! **The chain only closes if the machine comes back here.** A panicked kernel -//! resets through the FADT register, the firmware consumes whatever `BootNext` -//! it was given, and on the owner's laptop the next entry in the boot order is -//! Ubuntu — which reuses the black-box page long before anything reads it. So -//! every boot that hands the machine to a kernel first names *this* loader as -//! the next boot, and the pass after the reset is the one that reads the page -//! and decides whether to go on. +//! resets through the FADT register, and the firmware consumes whatever +//! `BootNext` it was given. So every boot that hands the machine to a kernel +//! first names *this* loader as the next boot, and the pass after the reset is +//! the one that reads the page and decides whether to go on. //! //! The entry is found by the GPT partition GUID of the volume this image was //! loaded from, which is the same identity `efibootmgr --disk … --part 1` writes //! and the same one the metal driver flashes against — never by description, and //! never by taking whatever `BootCurrent` happens to say, because a firmware //! that booted us from a removable-media fallback path has no entry of ours at -//! all and must be told so rather than have one guessed at. +//! all and must be told so rather than have one guessed at. The entries are +//! read and `BootNext` is written by [`crate::bootvars`], as every other +//! variable this loader touches. use uefi::prelude::*; use uefi::proto::device_path::media::PartitionSignature; use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType}; use uefi::proto::loaded_image::LoadedImage; -use uefi::table::runtime::{VariableAttributes, VariableVendor}; -use toyos_update::entry; + +use crate::bootvars; /// The head of every line this module writes. const HEAD: &str = "Boot chain:"; @@ -37,27 +37,20 @@ pub fn point_at_us(handle: Handle, system_table: &SystemTable) { of ours to come back to and the boot after a reset is the firmware's own" ); }; - let Some(entry) = entry_for(system_table, &ours) else { - return println!( - "{HEAD} no Boot#### entry on this machine names the partition this image came off, \ - so the boot after a reset is the firmware's own" - ); + let rt = system_table.runtime_services(); + let entry = match bootvars::naming(rt, &ours) { + Ok(Some(entry)) => entry, + Ok(None) => { + return println!( + "{HEAD} no active Boot#### entry on this machine names the partition this image came \ + off, so the boot after a reset is the firmware's own" + ) + } + Err(why) => return println!("{HEAD} {why}, so the boot after a reset is the firmware's own"), }; - let write = system_table.runtime_services().set_variable( - cstr16!("BootNext"), - &VariableVendor::GLOBAL_VARIABLE, - // Non-volatile, because it has to survive the reset that is the whole point. - VariableAttributes::NON_VOLATILE - | VariableAttributes::BOOTSERVICE_ACCESS - | VariableAttributes::RUNTIME_ACCESS, - &entry.to_le_bytes(), - ); - match write { + match bootvars::boot_next(rt, entry) { Ok(()) => println!("{HEAD} BootNext={entry:04X}, so this loader gets the machine back"), - Err(e) => println!( - "{HEAD} firmware refused BootNext={entry:04X} ({e}), so the boot after a reset is \ - its own" - ), + Err(why) => println!("{HEAD} {why}, so the boot after a reset is its own"), } } @@ -84,34 +77,3 @@ fn hard_drive_guid<'a>(nodes: impl Iterator, ours: &[u8; 16]) -> Option { - let rt = system_table.runtime_services(); - let keys = rt.variable_keys().ok()?; - let mut found: Option = None; - for key in keys { - if key.vendor != VariableVendor::GLOBAL_VARIABLE { - continue; - } - let Ok(name) = key.name() else { continue }; - let Some(number) = entry::number(&alloc::format!("{name}")) else { continue }; - let Ok((bytes, _)) = rt.get_variable_boxed(name, &key.vendor) else { continue }; - if !entry::names(&bytes, ours) { - continue; - } - // The lowest, so a machine carrying two entries for one partition is - // answered the same way twice rather than by whichever enumerated first. - found = Some(found.map_or(number, |seen: u16| seen.min(number))); - } - found -} diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs index fc8f2136ada..e625b776204 100644 --- a/bootloader/src/bootvars.rs +++ b/bootloader/src/bootvars.rs @@ -112,26 +112,27 @@ fn entries(rt: &RuntimeServices) -> Result, String> { Ok(out) } -/// The entry that boots `esp`: the lowest active one already naming it — -/// whatever file it boots there, the owner's own entry among them — or one -/// written here for its removable-media loader, at the lowest free number. -/// Its number, and whether it was written. +/// The entry that boots the GPT partition `guid` ([`entry::naming`]), if any. +pub fn naming(rt: &RuntimeServices, guid: &[u8; 16]) -> Result, String> { + Ok(entry::naming(&entries(rt)?, guid)) +} + +/// The entry that boots `esp` ([`entry::naming`]), or one written here for +/// its removable-media loader at the lowest free number; its number, and +/// whether it was written. pub fn entry_for(rt: &RuntimeServices, esp: &Esp) -> Result<(u16, bool), String> { let part = &esp.part; let held = entries(rt)?; - if let Some(number) = - held.iter().filter(|(_, o)| entry::active(o) && entry::names(o, &part.guid)).map(|(n, _)| *n).min() - { + if let Some(number) = entry::naming(&held, &part.guid) { return Ok((number, false)); } if let Err(why) = &esp.removable { return Err(alloc::format!("no entry names it, and {why}")); } - let used: Vec = held.iter().map(|(n, _)| *n).collect(); - let number = entry::free(&used).ok_or("every Boot#### number is taken")?; + let order = words(rt, cstr16!("BootOrder"))?.unwrap_or_default(); + let number = entry::free(&held, &order).ok_or("every Boot#### number is taken")?; let mut option = [0u8; OPTION_BYTES]; - let len = entry::load_option(DESCRIPTION, part, crate::arch::REMOVABLE_PATH, &mut option) - .map_err(|why| alloc::format!("the entry would not encode: {why}"))?; + let len = entry::load_option(DESCRIPTION, part, crate::arch::REMOVABLE_PATH, &mut option); let name = CString16::try_from(alloc::format!("Boot{number:04X}").as_str()).expect("an ASCII name"); rt.set_variable(&name, &VariableVendor::GLOBAL_VARIABLE, ATTRIBUTES, &option[..len]) .map_err(|e| alloc::format!("firmware refused Boot{number:04X} ({e})"))?; @@ -153,8 +154,10 @@ fn words(rt: &RuntimeServices, name: &CStr16) -> Result>, String /// `BootOrder` with `number` first; the order it was and the order it is. pub fn put_first(rt: &RuntimeServices, number: u16) -> Result<(Vec, Vec), String> { let was = words(rt, cstr16!("BootOrder"))?.unwrap_or_default(); - let mut now = [0u16; MAX_ORDER + 1]; - let n = entry::first(&was, number, &mut now); + let mut now = [0u16; MAX_ORDER]; + let n = entry::first(&was, number, &mut now).ok_or_else(|| { + alloc::format!("BootOrder holds {} entries, and one more is past the {MAX_ORDER} this loader reads", was.len()) + })?; let bytes: Vec = now[..n].iter().flat_map(|w| w.to_le_bytes()).collect(); rt.set_variable(cstr16!("BootOrder"), &VariableVendor::GLOBAL_VARIABLE, ATTRIBUTES, &bytes) .map_err(|e| alloc::format!("firmware refused BootOrder ({e})"))?; @@ -169,19 +172,14 @@ pub fn boot_next(rt: &RuntimeServices, number: u16) -> Result<(), String> { } /// The entry the firmware would have tried after the one that booted this -/// pass, skipping an inactive entry and every entry naming `ours` — this -/// loader's own partition, which has just refused every slot it has. +/// pass ([`entry::after`]), passing over every entry naming `ours` — this +/// loader's own partition, whose pass has just failed. pub fn after_this_one(rt: &RuntimeServices, ours: Option<&[u8; 16]>) -> Result<(u16, Option), String> { let current = words(rt, cstr16!("BootCurrent"))? .and_then(|w| w.first().copied()) .ok_or("firmware names no BootCurrent")?; let order = words(rt, cstr16!("BootOrder"))?.ok_or("firmware holds no BootOrder")?; - let held = entries(rt)?; - let skip = |number: u16| match held.iter().find(|(n, _)| *n == number) { - None => true, - Some((_, option)) => !entry::active(option) || ours.is_some_and(|guid| entry::names(option, guid)), - }; - Ok((current, entry::after(&order, current, skip))) + Ok((current, entry::after(&order, current, &entries(rt)?, ours))) } /// A list of entry numbers as firmware's menu spells them. diff --git a/bootloader/src/loaderlog.rs b/bootloader/src/loaderlog.rs index cc75b26ad67..79ea19f5acc 100644 --- a/bootloader/src/loaderlog.rs +++ b/bootloader/src/loaderlog.rs @@ -60,33 +60,15 @@ const NAME: &CStr16 = cstr16!("loader.log"); /// of that boot's passes, for a host to read over ssh. const PREVIOUS: &CStr16 = cstr16!("loader-previous.log"); -/// The most of the last chain's file that is kept. A report pass writes the -/// black box's tail, which is bounded; a file past this is not one this loader -/// wrote, and is left behind rather than copied. -const PREVIOUS_BYTES: usize = 4 << 20; - /// Copy `loader.log` to [`PREVIOUS`], replacing it, before the file is cut: /// the chain that file holds is over, and this keeps exactly one of them. /// +/// **The old [`PREVIOUS`] goes first**, so a copy refused at any step leaves +/// no file at all rather than a chain before the one that just ended. +/// /// Said on the console only, as [`refused`] is, where it cannot: the new file /// is not open yet. fn keep_previous(root: &mut Directory) { - let file = match root.open(NAME, FileMode::Read, FileAttribute::empty()) { - Ok(file) => file, - Err(e) if e.status() == Status::NOT_FOUND => return, - Err(e) => return refused_previous(format_args!("{NAME} would not open ({e})")), - }; - let Some(mut file) = file.into_regular_file() else { - return refused_previous(format_args!("{NAME} is a directory")); - }; - let mut bytes = alloc::vec![0u8; PREVIOUS_BYTES + 1]; - let read = match file.read(&mut bytes) { - Ok(n) => n, - Err(e) => return refused_previous(format_args!("{NAME} would not read ({e})")), - }; - if read > PREVIOUS_BYTES { - return refused_previous(format_args!("{NAME} is past {PREVIOUS_BYTES} bytes")); - } match root.open(PREVIOUS, FileMode::ReadWrite, FileAttribute::empty()) { Ok(stale) => { if let Err(e) = stale.delete() { @@ -96,6 +78,14 @@ fn keep_previous(root: &mut Directory) { Err(e) if e.status() == Status::NOT_FOUND => {} Err(e) => return refused_previous(format_args!("{PREVIOUS} would not open ({e})")), } + let file = match root.open(NAME, FileMode::Read, FileAttribute::empty()) { + Ok(file) => file, + Err(e) if e.status() == Status::NOT_FOUND => return, + Err(e) => return refused_previous(format_args!("{NAME} would not open ({e})")), + }; + let Some(mut file) = file.into_regular_file() else { + return refused_previous(format_args!("{NAME} is a directory")); + }; let kept = match root.open(PREVIOUS, FileMode::CreateReadWrite, FileAttribute::empty()) { Ok(kept) => kept, Err(e) => return refused_previous(format_args!("{PREVIOUS} would not be created ({e})")), @@ -103,9 +93,19 @@ fn keep_previous(root: &mut Directory) { let Some(mut kept) = kept.into_regular_file() else { return refused_previous(format_args!("{PREVIOUS} is a directory")); }; - let written = kept.write(&bytes[..read]).map_err(|e| e.status()).and_then(|()| kept.flush().map_err(|e| e.status())); - if let Err(status) = written { - refused_previous(format_args!("{PREVIOUS} would not write ({status:?})")); + let mut chunk = alloc::vec![0u8; 64 << 10]; + loop { + let read = match file.read(&mut chunk) { + Ok(0) => break, + Ok(n) => n, + Err(e) => return refused_previous(format_args!("{NAME} would not read ({e})")), + }; + if let Err(e) = kept.write(&chunk[..read]) { + return refused_previous(format_args!("{PREVIOUS} would not write ({:?})", e.status())); + } + } + if let Err(e) = kept.flush() { + refused_previous(format_args!("{PREVIOUS} would not flush ({e})")); } } diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index 02dc5d7e663..da30af009f2 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -137,9 +137,14 @@ fn load_file_bytes(handle: Handle, system_table: &SystemTable, path: &CStr bytes } -/// The line naming this loader by the SHA-256 of its file; held to the host's -/// spelling by `toyos_build::bootlog`'s gate. -const LOADER_IS: &str = "Loader: the file firmware loaded hashes to"; +/// The line naming the file every ToyOS image boots by, this ESP's +/// removable-media loader, by its SHA-256; held to the host's spelling by +/// `toyos_build::bootlog`'s gate. +const LOADER_IS: &str = "Loader: the removable-media file on this ESP hashes to"; + +/// The line naming the boot parameter the kernel is handed, `root=` among +/// it; held to the host's spelling by `toyos_build::bootlog`'s gate. +const BOOT_PARAMETER: &str = "Boot parameter:"; /// This loader's own file, at the removable-media path of the volume firmware /// loaded it from — where every ToyOS image puts it — or why it would not read. @@ -867,9 +872,8 @@ fn armed_at(system_table: &SystemTable) -> u64 { /// a pass that does not hand off leaves nothing registered in the firmware — and /// the reset is what makes that invariant not have to be complete: the next /// operating system comes up on firmware this image has never run on, for one -/// reboot. `BootNext` was consumed by this pass and this pass sets none, so the -/// firmware's own order takes the machine, and the page was cleared as it was -/// read, so a boot that does come back here boots normally. +/// reboot. The page was cleared as it was read, so a boot that does come back +/// here boots normally. fn end_this_pass(system_table: &SystemTable, exit_event: Option) -> ! { println!("{}", loaderlog::ENDS_AT_CHAIN); loaderlog::close_without_a_kernel(); @@ -882,31 +886,45 @@ fn end_this_pass(system_table: &SystemTable, exit_event: Option) -> system_table.runtime_services().reset(ResetType::WARM, Status::SUCCESS, None) } -/// End a pass whose every slot was refused by handing the machine to the entry -/// the firmware would have tried after this one — `BootNext`, then the reset -/// [`end_this_pass`] makes — which on a machine with a recovery stick behind -/// it in `BootOrder` is that stick. Where there is none, the pass dies with -/// `why` as it always did. +/// **A pass that fails hands the machine on**: a panic of this loader — no +/// slot verifying, a floor refused, anything it cannot go on without — sets +/// `BootNext` to the entry the firmware would have tried after this one +/// ([`bootvars::after_this_one`]) and resets, so a machine whose stick heads +/// `BootOrder` still reaches what stands behind it. Where nothing does, the +/// machine powers off rather than reset into the same failure. /// /// **Not a return to the boot manager**, which would try the next entry itself: /// a pass that returns leaves its exit-boot-services callback registered, as -/// [`end_this_pass`] says. -fn fall_through(system_table: &SystemTable, ours: Option<&[u8; 16]>, why: &str, exit_event: Option) -> ! { - let rt = system_table.runtime_services(); - match bootvars::after_this_one(rt, ours) { - Ok((current, Some(next))) => match bootvars::boot_next(rt, next) { - Ok(()) => { - println!( - "{} no slot boots, so BootNext=Boot{next:04X}, the entry after Boot{current:04X} in BootOrder", - bootvars::HEAD - ); - end_this_pass(system_table, exit_event) - } - Err(no) => panic!("Slots: {why}; and {no}"), - }, - Ok((current, None)) => panic!("Slots: {why}; and BootOrder holds no entry after Boot{current:04X} to fall to"), - Err(no) => panic!("Slots: {why}; and no entry to fall to: {no}"), +/// [`end_this_pass`] says. A panic inside this one, or after boot services +/// are gone, stops here. +#[panic_handler] +fn panic(info: &core::panic::PanicInfo) -> ! { + static PANICKED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); + if PANICKED.swap(true, core::sync::atomic::Ordering::Relaxed) { + loop { + core::hint::spin_loop(); + } } + println!("[PANIC]: {info}"); + let system_table = uefi_services::system_table(); + let rt = system_table.runtime_services(); + let ours = bootnext::our_partition(system_table.boot_services().image_handle(), &system_table); + let fell = bootvars::after_this_one(rt, ours.as_ref()).and_then(|(current, next)| { + let next = next.ok_or_else(|| alloc::format!("BootOrder holds no entry after Boot{current:04X}"))?; + bootvars::boot_next(rt, next).map(|()| (current, next)) + }); + let reset = match fell { + Ok((current, next)) => { + println!("{} this pass failed, so BootNext=Boot{next:04X}, the entry after Boot{current:04X} in BootOrder", bootvars::HEAD); + ResetType::WARM + } + Err(why) => { + println!("{} this pass failed, and there is no entry to fall to, so the machine powers off: {why}", bootvars::HEAD); + ResetType::SHUTDOWN + } + }; + loaderlog::close_without_a_kernel(); + rt.reset(reset, Status::ABORTED, None) } #[entry] @@ -1102,13 +1120,8 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { // kernel is loaded and not after: it is the allocation the image pages come // from, and a slot whose ROOT is refused has no use for the kernel's. let once = request::once(handle, &system_table); - let chosen = match slot::choose(handle, &system_table, image_floor.value, &record, once) { - Ok(chosen) => chosen, - Err(why) => { - println!("Slots: {why}"); - fall_through(&system_table, ours.as_ref(), &why, exit_event) - } - }; + let chosen = slot::choose(handle, &system_table, image_floor.value, &record, once) + .unwrap_or_else(|why| panic!("Slots: {why}")); record.booted = Some(Booted { slot: chosen.which, version: chosen.version, @@ -1140,14 +1153,14 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { append(&alloc::format!("{}{}:{}", toyos_abi::boot::SLOT_REFUSED_PARAM, refused.letter(), why.word())); } if let Some(marked) = chosen.once { - append(&alloc::format!("{}{}:{}", toyos_abi::boot::SLOT_REFUSED_PARAM, marked.letter(), policy::ONCE)); + append(&alloc::format!("{}{}:{}", toyos_abi::boot::SLOT_REFUSED_PARAM, marked.letter(), toyos_abi::boot::SLOT_ONCE)); } if let Some(word) = blackbox::param(page) { append(&word); } let params = core::str::from_utf8(&cmdline) .unwrap_or_else(|e| panic!("slot {}'s cmdline is not UTF-8: {e}", chosen.which.letter())); - println!("Boot parameter: {params:?}"); + println!("{BOOT_PARAMETER} {params:?}"); let root_image = if toyos_abi::boot::actuators(params).any(|token| token == toyos_abi::boot::WITHHOLD_ROOT_PARAM) { println!("ROOT: withheld on {}; the kernel is handed no image", toyos_abi::boot::WITHHOLD_ROOT_PARAM); diff --git a/bootloader/src/request.rs b/bootloader/src/request.rs index 32fc7864283..607a510954f 100644 --- a/bootloader/src/request.rs +++ b/bootloader/src/request.rs @@ -13,7 +13,9 @@ //! variables ([`firmware`]) in whichever pass comes next, the report pass //! after a handover included; a slot to boot once ([`once`]) only in a pass //! that goes on to boot a slot, so the report pass that ends a chain leaves it -//! for the pass after. +//! for the pass after. The slot booted once is left on the table as a trial +//! (`Next::Trial`), which keeps the slot the table marks from any grant while +//! it runs, and the next pass that chooses a slot takes the trial away. use toyos_update::slots::{Next, Request, Table, Which}; use uefi::prelude::*; @@ -46,11 +48,11 @@ fn table(handle: Handle, system_table: &SystemTable) -> Result<(Disk<'_>, fn consume(disk: &mut Disk<'_>, at: &TableAt, next: Table, what: &str) -> bool { match disk.write_table(at, next) { Ok(()) => { - println!("{HEAD} {what} is taken off the slot table before it is acted on"); + println!("{HEAD} {what} is taken off the slot table"); true } Err(why) => { - println!("{HEAD} {what} stands unacted on, because taking it off the slot table failed: {why}"); + println!("{HEAD} {what} stands, and is not acted on, because taking it off the slot table failed: {why}"); false } } @@ -74,7 +76,7 @@ pub fn firmware(handle: Handle, system_table: &SystemTable, ours: Option<& if !asked.first && esp.is_none() { return Fired::Nothing; } - let left = Request { first: false, next: asked.next.filter(|n| matches!(n, Next::Slot(_))) }; + let left = Request { first: false, next: asked.next.filter(|n| !matches!(n, Next::Esp(_))) }; let what = match (asked.first, esp) { (true, Some(_)) => "the boot order and a boot of another ESP", (true, None) => "the boot order", @@ -128,17 +130,21 @@ pub fn firmware(handle: Handle, system_table: &SystemTable, ours: Option<& } } -/// The slot the running system asked to boot once, taken off the table; or -/// `None` where it asked none, or where taking it off failed — a trial that -/// could not be made a trial is not booted at all. +/// The slot the running system asked to boot once, left on the table as its +/// trial; or `None` where it asked none, or where the table would not take +/// the trial — a trial that could not be made a trial is not booted at all. +/// A trial the last boot ran is over, and is taken away here. pub fn once(handle: Handle, system_table: &SystemTable) -> Option { let (mut disk, at) = match table(handle, system_table) { Ok(read) => read, // `slot::choose` reads the same table next and refuses by name. Err(_) => return None, }; - let Some(Next::Slot(which)) = at.table.request.next else { return None }; - let left = Request { next: None, ..at.table.request }; - let what = alloc::format!("a boot of slot {} once", which.letter()); - consume(&mut disk, &at, Table { request: left, ..at.table }, &what).then_some(which) + let (left, what, boots) = match at.table.request.next { + Some(Next::Slot(which)) => (Some(Next::Trial(which)), alloc::format!("a boot of slot {} once", which.letter()), Some(which)), + Some(Next::Trial(which)) => (None, alloc::format!("slot {}'s trial, which is over,", which.letter()), None), + _ => return None, + }; + let left = Table { request: Request { next: left, ..at.table.request }, ..at.table }; + consume(&mut disk, &at, left, &what).then_some(boots).flatten() } diff --git a/bootloader/src/slot.rs b/bootloader/src/slot.rs index 88e890ba9cc..afefabb8323 100644 --- a/bootloader/src/slot.rs +++ b/bootloader/src/slot.rs @@ -90,23 +90,22 @@ pub fn choose( table.marked.letter() ); } + // What the kernel is told of the slot chosen, whichever loop chose it. + let told = |mut chosen: Chosen, refused: Option<(Which, Refusal)>| { + match once { + Some(trial) if chosen.which == trial => chosen.once = Some(table.marked), + // The trial was refused and this is the marked slot: an ordinary + // boot, and the kernel is told nothing was refused. + Some(_) => {} + None if chosen.which != table.marked => chosen.refused = refused, + None => {} + } + chosen + }; for which in policy::order(&table, once).into_iter().flatten() { let slot = table.slot(which).expect("`order` names only slots the table carries"); match verify(bs, &mut disk, which, slot, floor, Some(record)) { - Ok(mut chosen) if once == Some(which) => { - chosen.once = Some(table.marked); - return Ok(chosen); - } - Ok(mut chosen) if once.is_some() => { - // The trial was refused and this is the marked slot: an - // ordinary boot, and the kernel is told nothing was refused. - chosen.refused = None; - return Ok(chosen); - } - Ok(mut chosen) => { - chosen.refused = refused; - return Ok(chosen); - } + Ok(chosen) => return Ok(told(chosen, refused)), Err(why) => { println!("{HEAD} {}: REFUSED, {why}", which.letter()); if why == Refusal::Died { @@ -122,12 +121,7 @@ pub fn choose( let slot = table.slot(which).expect("a slot `order` named"); println!("{HEAD} {}: no slot verifies but this one, whose image died on its last boot; it boots again", which.letter()); match verify(bs, &mut disk, which, slot, floor, None) { - Ok(mut chosen) => { - if which != table.marked { - chosen.refused = refused; - } - return Ok(chosen); - } + Ok(chosen) => return Ok(told(chosen, refused)), Err(why) => println!("{HEAD} {}: REFUSED, {why}", which.letter()), } } diff --git a/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md b/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md index 9e3708d05a2..8595a62f80c 100644 --- a/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md +++ b/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md @@ -19,6 +19,12 @@ the T14 a new bench written through `--via-ubuntu --resident`. That path goes with the installer, and an owner's machine updated with `ssh … update` has no check at all. +A slot table format is the loader's too, and changes the same way: the table +went from format 1 to format 2 with the running system's request +(`toyos_update::slots`), so a machine at format 1 that installs a format-2 +image with `update` keeps its format-1 table, which that image's own `update` +refuses — it cannot update again until its stick is written. + **Exit**: a loader change reaches a running machine through a signed update, or an image names the loader it needs and a loader that is not it refuses to boot it by name. diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 0ee701594f7..12a7700ea26 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -346,7 +346,7 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! { match params::slot(cmdline) { (Some(slot), None) => log!("{} {slot}, the one the slot table marks", params::SLOT_RECORD), (Some(slot), Some(refused)) => match refused.split_once(':') { - Some((marked, params::ONCE)) => log!("{} {slot}, once, as the running system asked; the slot table marks {marked}", params::SLOT_RECORD), + Some((marked, toyos_abi::boot::SLOT_ONCE)) => log!("{} {slot}, once, as the running system asked; the slot table marks {marked}", params::SLOT_RECORD), Some((marked, why)) => log!("{} {slot}, because the marked slot {marked} was refused: {why}", params::SLOT_RECORD), None => log!("{} {slot}, because the marked slot was refused: {refused}", params::SLOT_RECORD), }, diff --git a/kernel/src/params.rs b/kernel/src/params.rs index e7f1fdc8f82..8c98cd8feef 100644 --- a/kernel/src/params.rs +++ b/kernel/src/params.rs @@ -45,13 +45,6 @@ pub fn claims(token: &str) -> bool { /// The head of the record naming the slot this boot came from. pub const SLOT_RECORD: &str = "boot: slot"; -/// The word the loader puts where a refusal goes when the slot it booted is one -/// the running system asked for once (`slot-refused=:once`): why the -/// marked slot is not this boot's, and no refusal of it. The loader's own -/// spelling is `toyos_update::policy::ONCE`, which `toyos_build::bootlog` -/// holds this one to. -pub const ONCE: &str = "once"; - /// The slot the loader booted, and the marked slot it refused and why, as the /// loader wrote them: the last of each, because the loader appends its words /// after the slot's own parameter and so has the last one. diff --git a/src/bootlog.rs b/src/bootlog.rs index c1ff040229f..f500ea4a46f 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -128,10 +128,14 @@ pub const LOADER_PREVIOUS_LOG: &str = "loader-previous.log"; /// filesystem: what ties a `logd` file to the image whose boot wrote it. pub const MOUNTED_FROM_MEMORY: &str = "root: mounted read-only from memory at"; -/// The loader's line naming itself by the SHA-256 of its file: the one part of -/// a machine no update installs, which a bench's host holds a delivered -/// image's loader to. -pub const LOADER_IS: &str = "Loader: the file firmware loaded hashes to"; +/// The loader's line naming its ESP's removable-media loader by its SHA-256: +/// the one part of a machine no update installs, which a bench's host holds a +/// delivered image's loader to. +pub const LOADER_IS: &str = "Loader: the removable-media file on this ESP hashes to"; + +/// The loader's line naming the boot parameter it hands the kernel, whose +/// `root=` names the ROOT that boot mounts. +pub const BOOT_PARAMETER: &str = "Boot parameter:"; /// That file's first line and its last. pub const LOADER_FIRST_LINE: &str = "ToyOS Bootloader 1.0"; @@ -635,9 +639,9 @@ mod tests { let wanted = [ ("bootloader/src/loaderlog.rs", format!("cstr16!(\"{LOADER_LOG}\")")), ("bootloader/src/loaderlog.rs", format!("cstr16!(\"{LOADER_PREVIOUS_LOG}\")")), - ("kernel/src/params.rs", format!("\"{}\"", toyos_update::policy::ONCE)), ("kernel/src/rootfs.rs", format!("\"{MOUNTED_FROM_MEMORY}\"")), ("bootloader/src/main.rs", format!("\"{LOADER_IS}\"")), + ("bootloader/src/main.rs", format!("\"{BOOT_PARAMETER}\"")), ("bootloader/src/loaderlog.rs", format!("\"{LOADER_FIRST_LINE}\"")), ("bootloader/src/loaderlog.rs", format!("\"{LOADER_LAST_LINE}\"")), ("bootloader/src/loaderlog.rs", format!("\"{CHAIN_ENDS_LINE}\"")), diff --git a/src/build.rs b/src/build.rs index 4a6d16402b0..22b7e4a4365 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2010,10 +2010,9 @@ pub const BENCH_CONFIG: &str = "tests/benchcase"; /// installed one — `/system/etc/ssh_authorized_keys` in the guest. pub const AUTHORIZED_ON_ROOT: &str = "etc/ssh_authorized_keys"; -/// The room the bench's idle slot has for a delivered image's ROOT: past the -/// largest metal image's, and a small part of a stick. A larger ROOT is -/// refused by `update` by name, so this is a bound that says so and never one -/// that cuts. +/// The room the bench's idle slot has for a delivered image's ROOT: a small +/// part of a stick. A larger ROOT is refused by `update` by name, so this is a +/// bound that says so and never one that cuts. pub const BENCH_ROOT_ROOM: u64 = 1 << 30; /// **The bench image**: `config` built as a test image is, signed with this @@ -2029,17 +2028,31 @@ pub const BENCH_ROOT_ROOM: u64 = 1 << 30; /// and only an image built with this — by the one host that holds the runner /// key's other half — carries any: no image anything publishes does. pub fn bench_image(root: &Path, config: &Path, authorized: &str, version: u64, room: u64, quiet: bool) -> Result, String> { + let keys = bench_keys(authorized, crate::signing::key().whose())?; + let mut plan = Plan::new(crate::arch::Arch::X86_64, &config.join("system.toml"), &[], &[]); + plan.version = version; + plan.second = Some(image::SecondSlot { root_bytes: room }); + Ok(build_test_image(root, &plan, quiet, &[(AUTHORIZED_ON_ROOT.to_string(), keys.into_bytes())])) +} + +/// The bench's `authorized_keys` file, or why there is none: ed25519 public +/// keys and nothing else, one per line — and **never under the owner's key**, +/// which would make the bench a valid update for every owner machine, and the +/// runner key authorized there. +fn bench_keys(authorized: &str, signer: &crate::signing::Whose) -> Result { + if let crate::signing::Whose::Owner(path) = signer { + return Err(format!( + "a bench is signed with this checkout's throwaway key, and this run signs with the owner's ({})", + path.display() + )); + } let lines: Vec<&str> = authorized.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() || lines.iter().any(|l| !l.starts_with("ssh-ed25519 ")) { return Err(format!( "a bench authorizes ed25519 public keys and nothing else, one per line, and {authorized:?} is not that" )); } - let mut plan = Plan::new(crate::arch::Arch::X86_64, &config.join("system.toml"), &[], &[]); - plan.version = version; - plan.second = Some(image::SecondSlot { root_bytes: room }); - let staged = [(AUTHORIZED_ON_ROOT.to_string(), format!("{}\n", lines.join("\n")).into_bytes())]; - Ok(build_test_image(root, &plan, quiet, &staged)) + Ok(format!("{}\n", lines.join("\n"))) } /// The image `ssh … update` takes, built from a plan as a test image is and @@ -3760,4 +3773,19 @@ mod tests { fn a_kernel_of_any_other_feature_set_is_not_judged() { assert_eq!(judge_entry_window(&SCHED_CHECK_KERNEL.join(","), b"not an ELF"), Ok(())); } + + /// **No bench under the owner's key**: `--owner-key` or `--update-image` + /// beside `--bench-image` would sign a valid update for every owner + /// machine that authorizes the runner key, and is refused; so is a key + /// file holding anything but ed25519 public keys. + #[test] + fn a_bench_is_throwaway_signed_and_authorizes_ed25519_keys_alone() { + use crate::signing::Whose; + let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA runner\n\n"; + assert_eq!(bench_keys(key, &Whose::Throwaway), Ok("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA runner\n".to_string())); + let owner = bench_keys(key, &Whose::Owner(PathBuf::from("/o/key"))).unwrap_err(); + assert!(owner.contains("owner") && owner.contains("/o/key"), "{owner}"); + assert!(bench_keys("ssh-rsa AAAA runner\n", &Whose::Throwaway).is_err()); + assert!(bench_keys("\n", &Whose::Throwaway).is_err()); + } } diff --git a/src/metal.rs b/src/metal.rs index e699d7fad4d..ac2a84032a5 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -6,8 +6,7 @@ //! bench — and each boot is delivered to it and read back over its own sshd //! ([`crate::metalbench`]). `--via-ubuntu` is the old path this file carries //! the rest of, until the installer puts ToyOS on the NVMe: Ubuntu flashes -//! the stick and reads the log partition off it. A run takes the path it -//! names and refuses every flag the other alone can carry out. +//! the stick and reads the log partition off it. //! //! The old path runs on the development host and reaches Ubuntu over `ssh`. //! [`Target::words`] is the one place a root command line is written — the @@ -1563,10 +1562,6 @@ declare_flags!(METAL = { RESIDENT = "--resident", None; }); -/// The flags that reach the machine through Ubuntu, and nothing else can mean -/// anything: each is refused without [`VIA_UBUNTU`] rather than ignored. -const UBUNTUS: &[&Flag] = &[&INSTALL_SUDOERS, &FAT32_CHECK, &DEVICE, &HOST, &RESIDENT]; - /// The flags a swap of a running machine's service refuses beside it: each /// describes a boot the swap does not judge. `--image` is not among them: on /// the bench a swapping boot is delivered and judged like any other, and the @@ -1637,8 +1632,7 @@ pub struct Args { /// **The old path, named**: flash the stick through Ubuntu, choose the /// next boot with `efibootmgr` and read the log partition off the stick. /// Absent is the machine running ToyOS and nothing else - /// ([`crate::metalbench`]). One flag and no fallback: a run takes the path - /// it names or refuses, and this flag goes when the T14 has no Ubuntu. + /// ([`crate::metalbench`]). This flag goes when the T14 has no Ubuntu. pub via_ubuntu: bool, /// **Flash a bench image and hand the machine to it** (`--via-ubuntu` /// only): the image is armed with nothing, boots once through Ubuntu's @@ -1694,38 +1688,6 @@ impl Args { resident: METAL.present(args, &RESIDENT), machine: Machine::t14(), }; - // **One path per run, named.** A flag that only Ubuntu can carry out, - // on a run that reaches a ToyOS machine, is refused rather than - // dropped. - if !out.via_ubuntu { - let ubuntus: Vec<&str> = line - .seen - .iter() - .map(|seen| seen.flag.name) - .filter(|name| UBUNTUS.iter().any(|flag| flag.name == *name)) - .collect(); - if !ubuntus.is_empty() { - return Err(Refusal::Usage(format!( - "{} reach the machine through Ubuntu, and this run reaches a machine that runs \ - ToyOS; name --via-ubuntu for the old path", - ubuntus.join(" and ") - ))); - } - } - if out.via_ubuntu && out.swap.is_some() { - return Err(Refusal::Usage( - "--swap asks a running ToyOS over its own sshd, and --via-ubuntu names the path that \ - flashes through Ubuntu; a swap has no Ubuntu half" - .to_string(), - )); - } - if out.resident && (out.readback.is_some() || out.talk.is_some() || out.nic.is_some()) { - return Err(Refusal::Usage( - "--resident hands the machine to a bench image and judges no boot, so --readback, \ - --talk and --nic describe a boot it will not judge" - .to_string(), - )); - } if let Some(host) = value(&HOST) { let (user, machine) = host.split_once('@').ok_or_else(|| { Refusal::Usage(format!("--host wants @, not {host:?}")) @@ -2747,7 +2709,7 @@ mod tests { /// with no job list and no bound. #[test] fn installing_the_rule_is_not_also_a_boot() { - let alone = ["--via-ubuntu", "--install-sudoers", "/tmp/pw"].map(String::from); + let alone = ["--install-sudoers", "/tmp/pw"].map(String::from); let args = Args::parse(&alone).expect("installing the rule alone"); assert!(args.install_sudoers.is_some()); assert!(args.about_a_boot.is_empty()); @@ -2761,7 +2723,7 @@ mod tests { vec!["--nic", "0000:00:1f.6"], vec!["--talk", "/tmp/k"], ] { - let mut words = vec!["--via-ubuntu".to_string(), "--install-sudoers".to_string(), "/tmp/pw".to_string()]; + let mut words = vec!["--install-sudoers".to_string(), "/tmp/pw".to_string()]; words.extend(flag.iter().map(|w| (*w).to_string())); let refusal = Args::parse(&words).unwrap_err(); let said = refusal.to_string(); @@ -2773,7 +2735,7 @@ mod tests { // The flags that say *which machine* are not a boot, and the rule needs // them: an install against another host or key is still an install. - let hosted = ["--via-ubuntu", "--install-sudoers", "/tmp/pw", "--host", "dev@t14", "--key", "/tmp/k"] + let hosted = ["--install-sudoers", "/tmp/pw", "--host", "dev@t14", "--key", "/tmp/k"] .map(String::from); assert!(Args::parse(&hosted).is_ok()); } @@ -2834,41 +2796,6 @@ mod tests { assert!(Args::parse(&bent).unwrap_err().to_string().contains("no service")); } - /// **The old path is one flag and nothing falls back to it**: every flag - /// only Ubuntu can carry out is refused by name on a run that reaches a - /// machine running ToyOS, a swap has no Ubuntu half, a bench is handed the - /// machine by nothing but the old path and judged by nothing, and a boot of - /// a machine running ToyOS is read into a readback or not run. - #[test] - fn the_ubuntu_path_is_named_and_nothing_falls_back_to_it() { - for flag in [ - vec!["--install-sudoers", "/tmp/pw"], - vec!["--fat32-check"], - vec!["--device", "/dev/sdb"], - vec!["--host", "t14@t14"], - vec!["--resident"], - ] { - let words: Vec = flag.iter().map(|w| (*w).to_string()).collect(); - let said = Args::parse(&words).unwrap_err().to_string(); - assert!(said.contains(flag[0]) && said.contains("--via-ubuntu"), "{said}"); - let mut named = vec!["--via-ubuntu".to_string()]; - named.extend(words); - assert!(Args::parse(&named).is_ok(), "{flag:?} beside --via-ubuntu"); - } - let swap = ["--via-ubuntu", "--swap", "netd", "--binary", "n", "--talk", "/tmp/k", "--readback", "/tmp/r"] - .map(String::from); - assert!(Args::parse(&swap).unwrap_err().to_string().contains("no Ubuntu half")); - for beside in [vec!["--readback", "/tmp/r"], vec!["--nic", "0000:00:1f.6"]] { - let mut words = vec!["--via-ubuntu", "--resident", "--image", "b.img"]; - words.extend(beside.iter().copied()); - let words: Vec = words.iter().map(|w| (*w).to_string()).collect(); - assert!(Args::parse(&words).unwrap_err().to_string().contains("judges no boot"), "{beside:?}"); - } - let unread = ["--image", "x.img"].map(String::from); - let refusal = run(&Args::parse(&unread).expect("a boot of the bench")).unwrap_err(); - assert!(refusal.to_string().contains("--readback"), "{refusal}"); - assert!(!refusal.about_the_boot()); - } /// **There is nothing to fall through to.** A default image was what let the /// fall-through reach a disk at all; a command line that names none is @@ -3431,7 +3358,7 @@ mod tests { assert_eq!(args.wait_secs, return_secs()); assert!(!args.dry_run); - let words: Vec = ["--via-ubuntu", "--dry-run", "--device", "/dev/sdb", "--host", "runner@box"] + let words: Vec = ["--dry-run", "--device", "/dev/sdb", "--host", "runner@box"] .iter() .map(|w| (*w).to_string()) .collect(); @@ -3441,9 +3368,9 @@ mod tests { assert_eq!(args.target.user, "runner"); assert_eq!(args.target.host, "box"); - let nvme = ["--via-ubuntu", "--device", "/dev/nvme0n1"].map(String::from); + let nvme = ["--device".to_string(), "/dev/nvme0n1".to_string()]; assert_eq!(Args::parse(&nvme).unwrap().target.node.whole(), "/dev/nvme0n1"); - let part = ["--via-ubuntu", "--device", "/dev/nvme0n1p2"].map(String::from); + let part = ["--device".to_string(), "/dev/nvme0n1p2".to_string()]; assert_eq!(Args::parse(&part), Err(Refusal::Node("/dev/nvme0n1p2".to_string()))); assert!(matches!(Args::parse(&["--image".to_string()]), Err(Refusal::Usage(_)))); assert!(matches!(Args::parse(&["--flash".to_string()]), Err(Refusal::Usage(_)))); diff --git a/src/metalbench.rs b/src/metalbench.rs index 54372a3600c..3b4aab3cd41 100644 --- a/src/metalbench.rs +++ b/src/metalbench.rs @@ -8,25 +8,18 @@ //! boots at the next reboot and never again (`toyos_update::slots::Request`); //! the boot runs its job list and hands the machine back, and the machine //! comes back as the bench, from which the boot's files are read over the -//! same sshd. So every Ubuntu step of [`crate::metal`]'s old path has a ToyOS -//! one here: -//! -//! | Ubuntu | here | -//! |-----------------------------------------|-----------------------------------------------| -//! | `wipefs` and `dd` of the stick | `update --once` of the marked slot's sections | -//! | `efibootmgr --bootnext` | the loader's own once, asked by `update` | -//! | `dd` and `mount` of the log partition | `/log` fetched over sftp in one session | -//! | `reboot`, `true`, `date -u +%s` | `reboot`, the runner key taken, `date -u +%s` | -//! | the stick's `/sys` identity | `update`'s grant — the idle slot of the disk | -//! | | the machine runs from — and its loader's hash | -//! | waiting for Ubuntu's sshd | waiting for `/log` to come back over the key | +//! same sshd. //! //! **The judges are the old path's, over the same two texts**: the loader's //! passes of this boot — kept by the pass after them as `loader-previous.log`, //! because the bench's own pass starts a new `loader.log` — and every `logd` //! file of this boot, told from the bench's own by the ROOT this image mounts. //! Each is held to this image before it is judged: the loader's by the signed -//! header's digest it verified, the kernel's by the ROOT UUID it names. A +//! header's digest it verified, the kernel's by the ROOT UUID it names and by +//! no name `/log` held before the delivery. **The machine is back only once +//! its `loader.log` is not the one fetched before the reboot**: every pass +//! starts that file or appends to it, so the same text is the bench that was +//! asked to reboot and has not yet. A //! volume's raw bytes are the one thing not read: the bench has the log //! partition mounted, so no read of it here is a quiescent volume, and //! `--fat32-check` is the old path's alone @@ -54,9 +47,6 @@ const PROBE_SECS: u64 = 10; /// Between two asks of a machine this loop is waiting on. const ASK_EVERY: Duration = Duration::from_secs(2); -/// What `update` says of an image it wrote to be booted once. -const ONCE: &str = "it boots once at the next reboot"; - /// Where the machine's files are, on the machine. const LOG_DIR: &str = "/log"; @@ -160,13 +150,14 @@ impl Bench { Err(Refusal::Silent { what, secs }) } - /// Wait until the machine's `/log` comes back over this key, within - /// `secs`: the bench taking the runner key and the boot's files, in the - /// one session. How long it took, and the files. - fn wait_for_the_log(&self, secs: u64, what: &'static str, into: &Path) -> Result<(u64, Logs), Refusal> { + /// Wait until the machine's `/log` comes back over this key with a + /// `loader.log` that is not `before`, within `secs`: the bench taking the + /// runner key and the boot's files, in the one session. How long it took, + /// and the files. + fn wait_for_the_log(&self, secs: u64, what: &'static str, into: &Path, before: &str) -> Result<(u64, Logs), Refusal> { let began = std::time::Instant::now(); while began.elapsed().as_secs() < secs { - if let Ok(logs) = self.fetch(into) { + if let Some(logs) = self.fetch(into).ok().filter(|logs| rebooted(logs, before)) { return Ok((began.elapsed().as_secs(), logs)); } std::thread::sleep(ASK_EVERY); @@ -211,13 +202,20 @@ fn wire(bench: &Bench, logs: &Logs, nic: &str) -> Result { let std::net::IpAddr::V4(addr) = at.ip() else { return Err(bad(format!("the bench answers at {at}, which is no IPv4 address"))); }; - let newest = *logs.logd().last().ok_or_else(|| bad("the bench's /log holds no file of logd's".to_string()))?; - let netd = crate::lan::netd_records(&logs.read(newest)?); + // The bench's own boot by the ROOT its pass handed the kernel, never by + // a name: a name is the bench's clock, which can step back. + let own = logs.read(bootlog::LOADER_LOG)?; + let root = own + .lines() + .find_map(|l| l.split(bootlog::BOOT_PARAMETER).nth(1)) + .and_then(|param| toyos_abi::boot::root_uuid(param.trim().trim_matches('"'))) + .ok_or_else(|| bad(format!("the bench's {} names no ROOT", bootlog::LOADER_LOG)))?; + let netd = crate::lan::netd_records(&kernel_log(logs, root, &[])?); let mac = netd .lines() .find_map(|line| line.split(crate::lan::MAC).nth(1)) .map(|rest| rest.split_whitespace().next().unwrap_or_default().to_ascii_lowercase()) - .ok_or_else(|| bad(format!("the bench's {newest} carries no {:?} record of netd's", crate::lan::MAC)))?; + .ok_or_else(|| bad(format!("the bench's own log carries no {:?} record of netd's", crate::lan::MAC)))?; let before = metal::unix_now(); let said = bench.exec("reading the machine's own clock", "date -u +%s").map_err(|e| bad(e.to_string()))?; let skew = metal::clock_skew(before, &said, metal::unix_now()).map_err(bad)?; @@ -230,14 +228,19 @@ fn wire(bench: &Bench, logs: &Logs, nic: &str) -> Result { /// **Told from the bench's own by the ROOT it mounted.** Every boot's first /// part names its ROOT's filesystem (`kernel/src/rootfs.rs`), and an image's /// ROOT UUID is its own, so a file is this boot's by its content, never by -/// being the one before the newest. -fn kernel_log(logs: &Logs, root: &str) -> Result { +/// being the one before the newest. **A boot any of whose names `/log` held +/// `before` the delivery is an earlier one**: an earlier delivery of the same +/// image names the same ROOT. +fn kernel_log(logs: &Logs, root: &str, before: &[String]) -> Result { let mounted = format!("filesystem {root},"); let logd = logs.logd(); let mut stems: Vec<&str> = logd.iter().map(|name| stem(name)).collect(); stems.dedup(); for boot in stems.iter().rev() { let parts: Vec<&str> = logd.iter().copied().filter(|name| stem(name) == *boot).collect(); + if parts.iter().any(|part| before.iter().any(|name| name == part)) { + continue; + } let first = logs.read(parts[0])?; if !first.lines().any(|l| l.contains(bootlog::MOUNTED_FROM_MEMORY) && l.contains(&mounted)) { continue; @@ -260,6 +263,12 @@ fn stem(name: &str) -> &str { } } +/// Whether a pass has run since `before` was fetched: every pass starts a new +/// `loader.log` or appends to it. +fn rebooted(logs: &Logs, before: &str) -> bool { + logs.read(bootlog::LOADER_LOG).is_ok_and(|now| now != before) +} + /// The loader's passes of this boot: the file the bench's own pass kept of /// the chain before it, held to the signed header this image carries. fn loader_log(logs: &Logs, digest: &toyos_update::Digest) -> Result { @@ -329,6 +338,7 @@ pub fn run(args: &Args, image: &Path, dir: &Path) -> Result, Refusal stderr: why, })?; same_loader(&before, &update.loader)?; + let before_loader = before.read(bootlog::LOADER_LOG)?; let wire = match &args.nic { Some(nic) => { let wire = wire(&bench, &before, nic)?; @@ -354,7 +364,7 @@ pub fn run(args: &Args, image: &Path, dir: &Path) -> Result, Refusal let delivered = delivered.map_err(|why| Refusal::Undelivered(format!("`update --once` was not answered: {why}")))?; let said = String::from_utf8_lossy(&delivered.stdout).to_string(); let installed = format!("update: installed version {} in slot", update.version); - if delivered.status != Some(0) || !said.contains(&installed) || !said.contains(ONCE) { + if delivered.status != Some(0) || !said.contains(&installed) { return Err(Refusal::Undelivered(format!("`update --once` ended {:?} saying {}", delivered.status, said.trim()))); } print!(" {said}"); @@ -395,7 +405,7 @@ pub fn run(args: &Args, image: &Path, dir: &Path) -> Result, Refusal } }) }); - let back = bench.wait_for_the_log(args.wait_secs, "come back", &after); + let back = bench.wait_for_the_log(args.wait_secs, "come back", &after, &before_loader); let reached = reaching.map(|thread| { thread.join().unwrap_or_else(|_| Err(Refusal::Cable("the thread asking the boot panicked".to_string()))) }); @@ -427,7 +437,7 @@ pub fn run(args: &Args, image: &Path, dir: &Path) -> Result, Refusal println!("the bench gave back its /log over the runner key {back} s after it went down"); let loader = loader_log(&logs, &update.digest)?; - let log = kernel_log(&logs, &update.root)?; + let log = kernel_log(&logs, &update.root, &before.names)?; print!("{loader}{log}"); // The stick is the disk the bench booted from, so it was there before the // bench could answer: zero, by construction and not by a reading. @@ -448,7 +458,8 @@ pub fn run(args: &Args, image: &Path, dir: &Path) -> Result, Refusal pub fn take_the_machine(key: &Path, machine: &Machine, wait_secs: u64, scratch: &Path) -> Result<(), Refusal> { let bench = Bench::prepare(key, machine, scratch)?; println!("waiting for the bench to take the runner key"); - bench.wait(wait_secs, "come up as the bench", true)?; + let (_, before) = bench.wait_for_the_log(wait_secs, "come up as the bench", &scratch.join("before"), "")?; + let before = before.read(bootlog::LOADER_LOG)?; let said = bench.exec("asking the loader for the boot order", "update --boot-first")?; print!(" {said}"); let at = bench.at()?; @@ -459,7 +470,7 @@ pub fn take_the_machine(key: &Path, machine: &Machine, wait_secs: u64, scratch: })?; println!(" `{}` at {at} answered {asked}", crate::metaltalk::REBOOT); bench.wait(metal::GOING_DOWN_SECS, "go down", false)?; - let (_, logs) = bench.wait_for_the_log(wait_secs, "come back", &scratch.join("log"))?; + let (_, logs) = bench.wait_for_the_log(wait_secs, "come back", &scratch.join("log"), &before)?; // The pass that wrote the order ended a chain, so the bench's own pass // kept it; the file the bench runs under is the one the order booted. let kept = logs.read(bootlog::LOADER_PREVIOUS_LOG)?; @@ -506,10 +517,36 @@ mod tests { let mut names: Vec = files.iter().map(|(n, _)| (*n).to_string()).collect(); names.push("loader.log".to_string()); let logs = Logs { dir, names }; - let text = kernel_log(&logs, "bbbb").expect("a read"); + let text = kernel_log(&logs, "bbbb", &[]).expect("a read"); assert!(text.contains("filesystem bbbb,") && text.ends_with("the boot's second part\n"), "{text}"); - assert_eq!(kernel_log(&logs, "dddd").expect("a read"), "", "a boot that reached no logd"); + assert_eq!(kernel_log(&logs, "dddd", &[]).expect("a read"), "", "a boot that reached no logd"); assert!(matches!(logs.read("loader-previous.log"), Err(Refusal::NotThisBoot(_)))); + // **A name /log held before the delivery is an earlier boot**, even of + // this image: a rerun of one image names one ROOT. + let before = vec!["2026-09-27-110000_0002.log".to_string()]; + assert_eq!(kernel_log(&logs, "bbbb", &before).expect("a read"), "", "an earlier delivery's boot"); + } + + /// **The machine is back only as a machine that ran a pass since**, and + /// the passes it kept are this image's only where they name its header: + /// the bench still up after `reboot` was asked, and a kept file of an + /// earlier boot, are neither. + #[test] + fn a_readback_is_of_a_pass_since_and_of_this_image() { + let scratch = toyos_tmpdir::TempDir::new("back"); + let dir = scratch.to_path_buf(); + let digest = [0x5Au8; 32]; + let mut hex = [0u8; 64]; + let ours = format!("Slot B: signed header {} verifies\n", toyos_update::hex(&digest, &mut hex)); + std::fs::write(dir.join(bootlog::LOADER_LOG), "the bench's own pass\n").expect("a staged file"); + std::fs::write(dir.join(bootlog::LOADER_PREVIOUS_LOG), &ours).expect("a staged file"); + let names = vec![bootlog::LOADER_LOG.to_string(), bootlog::LOADER_PREVIOUS_LOG.to_string()]; + let logs = Logs { dir: dir.clone(), names }; + assert!(!rebooted(&logs, "the bench's own pass\n"), "the same loader.log is the bench not yet rebooted"); + assert!(rebooted(&logs, "the pass before\n")); + assert!(!rebooted(&Logs { dir, names: Vec::new() }, ""), "no loader.log at all"); + assert_eq!(loader_log(&logs, &digest).expect("this image's passes"), ours); + assert!(matches!(loader_log(&logs, &[0x5B; 32]), Err(Refusal::NotThisBoot(_))), "an earlier boot's passes"); } /// **A bench under another loader takes no image**: the line its pass diff --git a/src/testargs.rs b/src/testargs.rs index 33b9d1e0bdf..0d31bca2410 100644 --- a/src/testargs.rs +++ b/src/testargs.rs @@ -212,13 +212,6 @@ pub fn parse(args: &[String]) -> Result, String> { .to_string(), ); } - if has(&METAL_VIA_UBUNTU) && !has(&METAL) { - return Err( - "--metal-via-ubuntu says which way the metal profile reaches the machine and decides \ - nothing on its own; add --metal" - .to_string(), - ); - } if has(&METAL_READBACK) && !has(&METAL) { return Err( "--metal-readback says where the metal profile's images and readbacks live and \ @@ -517,10 +510,6 @@ mod tests { fn a_readback_directory_alone_selects_no_tier() { let refusal = parse_owned(&["--metal-readback", "target/metal"]).unwrap_err(); assert!(refusal.contains("add --metal"), "{refusal}"); - // And the path to the machine alone, which reaches no machine. - let refusal = parse_owned(&["--metal-via-ubuntu"]).unwrap_err(); - assert!(refusal.contains("add --metal"), "{refusal}"); - assert!(parse_owned(&["--metal", "--metal-via-ubuntu"]).is_ok()); let refusal = parse_owned(&["--metal", "--audio-gate", "30"]).unwrap_err(); assert!(refusal.contains("cannot be combined"), "{refusal}"); } diff --git a/tests/common/bench.rs b/tests/common/bench.rs index 18120587f51..4f4a8f67478 100644 --- a/tests/common/bench.rs +++ b/tests/common/bench.rs @@ -107,16 +107,12 @@ pub fn bench_loop_drives_a_toyos_machine( eprintln!(" [bench] a kernel byte flipped under its signature: {}", said.trim()); // **The clock the loop reads a `--nic` boot's cable against**, from the - // bench itself: `date -u +%s` answers a second, and no other form is here. + // bench itself: `date -u +%s` answers a second. let clock = ssh::ssh_exec(HOST, ssh_port, &runner, "date -u +%s")?; let said = clock.stdout_text(); if clock.status != Some(0) || said.trim().parse::().is_err() { return Err(format!("`date -u +%s` ended {:?} saying {said:?}", clock.status)); } - let other = ssh::ssh_exec(HOST, ssh_port, &runner, "date")?; - if other.status != Some(2) { - return Err(format!("a bare `date` ended {:?}, where the one form is refused as 2", other.status)); - } eprintln!(" [bench] the bench's clock reads {}", said.trim()); // **The loop**: the library call `toyos-metal` makes, the machine reached @@ -164,11 +160,19 @@ pub fn bench_loop_drives_a_toyos_machine( return Err(format!("the boot's own log never says {ONCE_RECORD:?}")); } let loader = std::fs::read_to_string(readback.join(metal::READBACK_LOADER)).map_err(|e| format!("loader.log: {e}"))?; - for owed in ["Slot B: asked for once; the table marks A", "Request: a boot of slot B once is taken off the slot table"] { + for owed in [ + "Slot B: asked for once; the table marks A", + "Request: a boot of slot B once is taken off the slot table", + "Anti-rollback floor: not raised, because slot B's image was booted once", + ] { if !loader.contains(owed) { return Err(format!("the boot's loader passes never say {owed:?}")); } } + let raised = format!("Anti-rollback floor: {}, raised", update.version); + if loader.contains(&raised) { + return Err(format!("a boot of slot B once raised the floor: {raised:?}")); + } let swapped = std::fs::read_to_string(readback.join(metal::READBACK_SWAP)).map_err(|e| format!("swap.txt: {e}"))?; eprintln!(" [bench] swap.txt: {}", swapped.lines().next().unwrap_or_default()); // And the bench is the bench again: its own slot, still marked. diff --git a/tests/common/lan.rs b/tests/common/lan.rs index 8ce0a3be67b..c6e2bc0153a 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -608,7 +608,7 @@ pub fn lan_talk( &case, &[], &[], - &[(super::ssh::KEYS_ON_ROOT.to_string(), identity.authorized_line().into_bytes())], + &[(toyos_build::build::AUTHORIZED_ON_ROOT.to_string(), identity.authorized_line().into_bytes())], &[], ); let image = super::lane::dir().join("lan-talk.img"); @@ -710,7 +710,7 @@ impl TalkBoot { &case, &[], &[], - &[(super::ssh::KEYS_ON_ROOT.to_string(), identity.authorized_line().into_bytes())], + &[(toyos_build::build::AUTHORIZED_ON_ROOT.to_string(), identity.authorized_line().into_bytes())], actuators, ); let image = super::lane::dir().join(format!("{name}.img")); diff --git a/tests/common/metal.rs b/tests/common/metal.rs index b34001589b9..c2c3644782e 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -807,7 +807,7 @@ fn build( // host is in it: the loop finds the machine by its name. if batch.talk || batch.swap.is_some() { let identity = super::ssh::Identity::mint_in(&talk_home(&home))?; - extra.push((super::ssh::KEYS_ON_ROOT.to_string(), identity.authorized_line().into_bytes())); + extra.push((toyos_build::build::AUTHORIZED_ON_ROOT.to_string(), identity.authorized_line().into_bytes())); } let plan = toyos_build::build::Plan::new(toyos_build::arch::Arch::X86_64, &config, features, ¶ms); let bytes = toyos_build::build::build_test_image(root, &plan, quiet, &extra); diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index a5f9e921d26..fb99bd99adb 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -2439,6 +2439,9 @@ pub struct BootOptions { /// sector, through QEMU's `blkdebug` under the stick's raw format: a disk /// error at a place the test chose, which no well-formed image can stage. pub stick_read_error: Option, + /// The boot stick attached read-only: every write the guest makes to it + /// fails, the loader's among them. + pub stick_readonly: bool, /// What the emulated RTC reads when the machine starts, as /// `YYYY-MM-DDTHH:MM:SS`. /// @@ -2566,6 +2569,7 @@ impl Default for BootOptions { usb_images: Vec::new(), usb_pcap: None, stick_read_error: None, + stick_readonly: false, rtc_base: None, extra_root_files: Vec::new(), log_port: None, @@ -4485,8 +4489,9 @@ fn qemu_command( // exits, so the staged image is never written and the boot after it // starts where this one did. A copy of the image would do the same // and costs 180 MB of disk per boot; this costs nothing. - match &options.boot_image { - Some(Staged::Pristine(_)) => ",snapshot=on", + match (&options.boot_image, options.stick_readonly) { + (_, true) => ",readonly=on", + (Some(Staged::Pristine(_)), false) => ",snapshot=on", _ => "", } )); diff --git a/tests/common/ssh.rs b/tests/common/ssh.rs index fa6857082f7..8d17cdf537d 100644 --- a/tests/common/ssh.rs +++ b/tests/common/ssh.rs @@ -325,10 +325,6 @@ pub const KEY: &str = "sshdcase"; /// well-formed offer from a key no file names. pub const STRANGER_KEY: &str = "sshdcase-stranger"; -/// Where the image's `authorized_keys` file lands, ROOT-relative — the guest -/// reads it at `/system/etc/ssh_authorized_keys`, which `userland/sshd`'s -/// `AUTHORIZED_KEYS` is the other half of. -pub const KEYS_ON_ROOT: &str = "etc/ssh_authorized_keys"; const KEYS_IN_GUEST: &str = "/system/etc/ssh_authorized_keys"; /// The guest test binary run over `exec`. Self-contained — `/tmp` and syscalls, @@ -363,7 +359,7 @@ pub fn boot_case( let options = super::qemu::BootOptions { profile: super::qemu::Profile::Headless, extra_root_files: vec![( - KEYS_ON_ROOT.to_string(), + toyos_build::build::AUTHORIZED_ON_ROOT.to_string(), identity.authorized_line().into_bytes(), )], ssh_port: Some(super::qemu::free_host_port()), diff --git a/tests/common/update.rs b/tests/common/update.rs index 0c6fab40c04..a9ac522dbd3 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -84,7 +84,7 @@ fn plan(features: &[&str], params: &[&str], version: u64, second: Option Vec<(String, Vec)> { - vec![(ssh::KEYS_ON_ROOT.to_string(), identity.authorized_line().into_bytes())] + vec![(toyos_build::build::AUTHORIZED_ON_ROOT.to_string(), identity.authorized_line().into_bytes())] } impl Rig { @@ -263,6 +263,22 @@ impl Rig { Ok((from, uart)) } + /// The slots' record as a clean hand-back leaves it — no boot counted and + /// none written down — for a machine the test powered off while it ran, + /// whose next pass would otherwise be the retry of a hang. + fn powered_off_cleanly(&self) -> Result<(), String> { + let guid = self.log_guid()?; + let record = Record { count: 0, booted: None, ..self.record()? }; + image::overwrite_file_on(&self.image, guid, RECORD_FILE, &record.encode(&guid)) + } + + /// Slot `which`'s signed header, off its volume. + fn signed_header(&self, which: Which) -> Result, String> { + let mut file = std::fs::File::open(&self.image).map_err(|e| format!("{}: {e}", self.image.display()))?; + let slot = image::slot_table_of(&mut file)?.slot(which).ok_or_else(|| format!("no slot {}", which.letter()))?; + image::read_file_on(&mut file, slot.boot, toyos_update::slots::SIGNED_FILE) + } + /// The name of the floor this machine's loader keeps. fn floor_name(&self) -> Result { let key = signing::key(); @@ -710,25 +726,45 @@ pub fn update_floor_is_the_images_own(_: &Path, _: &[(String, Vec)], _: &[(S const BOOT_NEXT_SET: &str = "(written now): the firmware boots it once, at the reset this pass ends with"; /// **A request for another ESP boots that ESP once, and the order resumes**: -/// the machine asks for its recovery stick with `update --boot-next`; the pass -/// after the reboot takes the request off the slot table, writes an entry for -/// that stick and points `BootNext` at it; the recovery stick's kernel boots; -/// its reboot hands the machine to the firmware's order, which is the -/// machine's own stick; and the machine's next reboot is its own again. +/// the machine asks for its recovery stick with `update --boot-next`; a pass +/// that cannot take the request off the slot table — the stick read-only — +/// sets no `BootNext`; the next pass takes it off, writes an entry for that +/// stick and points `BootNext` at it; the recovery stick's kernel boots; its +/// reboot hands the machine to the firmware's order, which is the machine's +/// own stick; and the machine's next reboot is its own again. pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { let rig = Rig::stage("update-boot-next")?.with_recovery()?; let recovery = rig.recovery()?.to_path_buf(); let esp = toyos_gpt::Guid(Rig::guid_on(&recovery, toyos_gpt::Guid::EFI_SYSTEM)?); let (ours, theirs) = (Rig::kernel_of(&rig.image)?, Rig::kernel_of(&recovery)?); - let (mut guest, mut console) = rig.boot()?; + let (guest, console) = rig.boot()?; owed(&console, 0, &ours)?; rig.asks(&format!("update --boot-next {esp}"), &format!("the loader boots EFI system partition {esp} once"))?; + drop(guest); + rig.powered_off_cleanly()?; + + // **Consumed before it is acted on**: the pass whose write of the table + // fails has set nothing when it says so, and never will. + let unwritable = BootOptions { + profile: qemu::Profile::Metal, + boot_image: Some(Staged::Written(rig.image.clone())), + stick_readonly: true, + firmware_vars: Some(rig.vars.clone()), + recovery_stick: rig.recovery.clone(), + ready_marker: "Request: a boot of another ESP stands, and is not acted on, because taking it off the slot table failed", + ..Default::default() + }; + drop(QemuInstance::boot_with_options(&super::compile::repo_root().join(CONFIG), &[], &[], unwritable)); + if let Ok(next) = vars::global(&rig.vars, "BootNext") { + return Err(format!("a pass that could not take the request off the slot table set BootNext {next:02x?}")); + } + eprintln!(" [update] a pass that could not write the slot table set no BootNext"); - let (from, uart) = rig.reboot_until(&mut guest, &mut console, &theirs)?; - loader_said(&guest, uart, "Request: a boot of another ESP is taken off the slot table before it is acted on")?; - loader_said(&guest, uart, &format!("ESP {esp} {BOOT_NEXT_SET}"))?; - await_machine(&mut guest, &mut console, "the recovery stick's sshd", |c| c[from..].contains(SSHD_LISTENING))?; - eprintln!(" [update] `update --boot-next {esp}` booted the recovery stick at the next reboot"); + let (mut guest, mut console) = rig.boot()?; + owed(&console, 0, &theirs)?; + loader_said(&guest, 0, "Request: a boot of another ESP is taken off the slot table")?; + loader_said(&guest, 0, &format!("ESP {esp} {BOOT_NEXT_SET}"))?; + eprintln!(" [update] `update --boot-next {esp}` booted the recovery stick at the next boot"); // Twice more, each until the machine's own kernel or the recovery stick's // a second time: a request never taken away boots the recovery stick at @@ -759,6 +795,63 @@ pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], Ok(()) } +/// **A trial writes nothing of the image the machine keeps**: `update --once` +/// puts an image in slot B for one boot; on that boot slot A — the image the +/// machine keeps — is the idle slot, init grants nothing, `update` is refused +/// and slot A's signed header is the one it was; the reboot after is slot A's, +/// and takes the trial off the table. And a trial the loader refuses boots +/// slot A as the ordinary boot it is: the kernel is told of no refusal of a +/// marked slot. +pub fn update_trial_writes_nothing_of_the_kept_slot(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-trial")?; + let (next, _) = rig.update("next", &[], &[], NEXT, signing::key())?; + let kept = rig.signed_header(Which::A)?; + let (mut guest, mut console) = rig.boot()?; + let asked = ssh::ssh_pipe(HOST, rig.port, &rig.identity, "update --once", &next)?; + let said = format!("{}{}", asked.stdout_text(), asked.stderr_text()); + if asked.status != Some(0) || !said.contains(&format!("update: installed version {NEXT} in slot B")) { + return Err(format!("`update --once` ended {:?} saying {said:?}", asked.status)); + } + let trial = format!("{SLOT_RECORD} B, once, as the running system asked; the slot table marks A"); + let (from, _) = rig.reboot_until(&mut guest, &mut console, &trial)?; + await_machine(&mut guest, &mut console, "the trial's sshd", |c| c[from..].contains(SSHD_LISTENING))?; + + let (status, said) = rig.install(&next)?; + if status != Some(1) || !said.contains("this process holds no `slots:table`") { + return Err(format!("on the trial, `update` ended {status:?} saying {said:?}")); + } + let refused = "init: update: no slot to grant: slot B runs on trial, and the idle slot is A, the image the machine keeps"; + await_machine(&mut guest, &mut console, "init to refuse the trial a grant", |c| c[from..].contains(refused))?; + let (_, uart) = rig.reboot_until(&mut guest, &mut console, &format!("{SLOT_RECORD} A, the one the slot table marks"))?; + loader_said(&guest, uart, "Request: slot B's trial, which is over, is taken off the slot table")?; + drop(guest); + rig.powered_off_cleanly()?; + if rig.signed_header(Which::A)? != kept { + return Err("slot A's signed header changed under a trial".to_string()); + } + let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; + let table = image::slot_table_of(&mut file)?; + if table.marked != Which::A || !table.request.is_empty() { + return Err(format!("after the trial the slot table is {table:?}: slot A marked and nothing asked is owed")); + } + eprintln!(" [update] the trial held no grant, slot A kept its image, and the boot after was slot A's"); + + // A trial the loader refuses: slot B's kernel bent, and asked for once. + let b = table.slot(Which::B).ok_or("no slot B")?; + let mut kernel = image::read_file_on(&mut file, b.boot, toyos_update::slots::KERNEL_FILE)?; + drop(file); + kernel[100] ^= 0x01; + image::overwrite_file_on(&rig.image, b.boot, toyos_update::slots::KERNEL_FILE, &kernel)?; + image::restage_table(&rig.image, |t| t.request.next = Some(toyos_update::slots::Next::Slot(Which::B)))?; + let (guest, console) = rig.boot()?; + loader_said(&guest, 0, "Slot B: REFUSED, its kernel is not the bytes its signed header names")?; + owed(&console, 0, &format!("{SLOT_RECORD} A, the one the slot table marks"))?; + eprintln!(" [update] a refused trial booted slot A as marked, with no refusal told"); + drop(guest); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + /// What sshd says once it serves, on every image here. const SSHD_LISTENING: &str = "sshd: listening on port 22"; @@ -774,7 +867,7 @@ pub fn update_boot_first_puts_the_loader_first(_: &Path, _: &[(String, Vec)] let (mut guest, mut console) = rig.boot()?; rig.asks("update --boot-first", "first in the firmware's BootOrder")?; let (_, uart) = rig.reboot_until(&mut guest, &mut console, DEFAULT_READY)?; - loader_said(&guest, uart, "Request: the boot order is taken off the slot table before it is acted on")?; + loader_said(&guest, uart, "Request: the boot order is taken off the slot table")?; let since = guest.uart_log()[uart..].to_string(); let line = since .lines() @@ -789,6 +882,12 @@ pub fn update_boot_first_puts_the_loader_first(_: &Path, _: &[(String, Vec)] .ok_or_else(|| format!("no entry number in {line:?}"))?; let (_, uart) = rig.reboot_until(&mut guest, &mut console, DEFAULT_READY)?; loader_said(&guest, uart, &format!("this pass was booted as Boot{number:04X}"))?; + // **Asked once, written once**: no pass after the one that wrote the + // order reads the request again or writes the order again. + let since = guest.uart_log()[uart..].to_string(); + if let Some(again) = since.lines().find(|l| l.contains("Request:") || l.contains("is first:")) { + return Err(format!("a pass after the one that wrote the order said {again:?}")); + } drop(guest); let order = vars::global(&rig.vars, "BootOrder")?; @@ -824,9 +923,9 @@ pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)] let fell = rig.launch(theirs); said( fell.boot_log(), - &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slots: no slot verifies", "no slot boots, so BootNext=Boot"], + &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slots: no slot verifies", "this pass failed, so BootNext=Boot"], )?; - let line = fell.boot_log().lines().find(|l| l.contains("no slot boots")).unwrap_or_default().to_string(); + let line = fell.boot_log().lines().find(|l| l.contains("this pass failed")).unwrap_or_default().to_string(); eprintln!(" [update] {line}; the recovery stick's loader took the machine"); drop(fell); let _ = std::fs::remove_dir_all(&rig.scratch); diff --git a/tests/toyos.rs b/tests/toyos.rs index 52308b2b661..6aeb876e3fd 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -872,9 +872,10 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("update_boot_next_boots_the_entry_once", Sched::Parallel, Tier::Nightly), ("update_boot_first_puts_the_loader_first", Sched::Parallel, Tier::Nightly), ("update_no_slot_boots_the_recovery_stick", Sched::Parallel, Tier::Nightly), + ("update_trial_writes_nothing_of_the_kept_slot", Sched::Parallel, Tier::Nightly), // The bench: `toyos-metal` delivers a staged boot to a machine running // ToyOS alone with `update --once`, swaps its netd, hands it back and - // judges what the bench reads back over ssh — a minute and more of boots. + // judges what the bench reads back over ssh. ("bench_loop_drives_a_toyos_machine", Sched::Parallel, Tier::Nightly), ("lan_swap", Sched::Parallel, Tier::Fast), ("swap_refusals", Sched::Parallel, Tier::Fast), @@ -1664,6 +1665,7 @@ const CARRIES: &[(&str, &[&str])] = &[ ("update_boot_next_boots_the_entry_once", &[]), ("update_boot_first_puts_the_loader_first", &[]), ("update_no_slot_boots_the_recovery_stick", &[]), + ("update_trial_writes_nothing_of_the_kept_slot", &[]), // The boot it delivers is staged as the metal profile stages it, with the // swap rehearsal's hold job on it. ("bench_loop_drives_a_toyos_machine", &["test_rs_lan_swap_hold"]), @@ -15644,6 +15646,9 @@ fn run_machine_test( "update_no_slot_boots_the_recovery_stick" => { common::update::update_no_slot_boots_the_recovery_stick(test_config, c_bins, rust_bins) } + "update_trial_writes_nothing_of_the_kept_slot" => { + common::update::update_trial_writes_nothing_of_the_kept_slot(test_config, c_bins, rust_bins) + } "bench_loop_drives_a_toyos_machine" => { common::bench::bench_loop_drives_a_toyos_machine(test_config, c_bins, rust_bins) } diff --git a/toyos-abi/src/boot.rs b/toyos-abi/src/boot.rs index 1c1c40a5050..5efb3a7c83b 100644 --- a/toyos-abi/src/boot.rs +++ b/toyos-abi/src/boot.rs @@ -148,6 +148,12 @@ pub const SLOT_PARAM: &str = "boot-slot="; /// log. pub const SLOT_REFUSED_PARAM: &str = "slot-refused="; +/// The word the loader puts where [`SLOT_REFUSED_PARAM`]'s refusal goes when +/// the slot it booted is one the running system asked for once +/// (`slot-refused=:once`): why the marked slot is not this boot's, and +/// no refusal of it. +pub const SLOT_ONCE: &str = "once"; + /// The most windows the loader will carry. pub const MAX_ROOT_BRIDGE_WINDOWS: usize = 64; diff --git a/toyos-gpt/src/guid.rs b/toyos-gpt/src/guid.rs index 7c1ec1bd362..afe9dcd34ea 100644 --- a/toyos-gpt/src/guid.rs +++ b/toyos-gpt/src/guid.rs @@ -111,6 +111,27 @@ impl Guid { } } +impl Guid { + /// The text [`Display`](fmt::Display) prints, read back: the 36 characters + /// `C12A7328-F81F-11D2-BA4B-00A0C93EC93B`, in either case, and nothing else. + pub fn parse(text: &str) -> Option { + const DASHES: [usize; 4] = [8, 13, 18, 23]; + let bytes = text.as_bytes(); + if bytes.len() != 36 || DASHES.iter().any(|&at| bytes[at] != b'-') { + return None; + } + let mut digits = bytes.iter().enumerate().filter(|(at, _)| !DASHES.contains(at)).map(|(_, b)| *b); + let mut hex = [0u8; 16]; + for out in hex.iter_mut() { + let high = (digits.next()? as char).to_digit(16)?; + let low = (digits.next()? as char).to_digit(16)?; + *out = (high * 16 + low) as u8; + } + let h = hex; + Some(Self([h[3], h[2], h[1], h[0], h[5], h[4], h[7], h[6], h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15]])) + } +} + impl fmt::Display for Guid { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let b = &self.0; @@ -164,6 +185,25 @@ mod tests { assert_ne!(Guid::TOYOS_ROOT, Guid::TOYOS_DATA); } + /// Every spelling [`Guid`]'s Display prints reads back as the bytes it + /// printed, in either case; a missing dash, a short text and a non-digit + /// read as nothing. + #[test] + fn the_text_it_prints_parses_back() { + for guid in [Guid::EFI_SYSTEM, Guid::TOYOS_SLOTS, Guid([0xA5; 16])] { + let text = heapless_format(guid); + let text = core::str::from_utf8(&text).unwrap(); + assert_eq!(Guid::parse(text), Some(guid), "{text}"); + let mut lower = [0u8; 36]; + lower.copy_from_slice(text.as_bytes()); + lower.make_ascii_lowercase(); + assert_eq!(Guid::parse(core::str::from_utf8(&lower).unwrap()), Some(guid), "{text}"); + } + assert_eq!(Guid::parse("C12A7328F81F-11D2-BA4B-00A0C93EC93B0"), None); + assert_eq!(Guid::parse("C12A7328-F81F-11D2-BA4B-00A0C93EC93"), None); + assert_eq!(Guid::parse("G12A7328-F81F-11D2-BA4B-00A0C93EC93B"), None); + } + #[test] fn zero_is_zero() { assert!(Guid::ZERO.is_zero()); diff --git a/toyos-update/Cargo.toml b/toyos-update/Cargo.toml index eb75ee7b793..3d24ef3ef42 100644 --- a/toyos-update/Cargo.toml +++ b/toyos-update/Cargo.toml @@ -15,10 +15,6 @@ publish = false # so no binary on the machine carries a path that makes a signature. sign = [] -[dev-dependencies] -# The GPT crate's own spelling of a GUID, which `entry::parse_guid` reads. -toyos-gpt = { path = "../toyos-gpt" } - [dependencies] # **The one verifier, and why this one.** `ed25519-dalek` is the Ed25519 the # Rust ecosystem ships (RustCrypto's `ssh-key` and `russh`, which this tree's diff --git a/toyos-update/src/entry.rs b/toyos-update/src/entry.rs index 9290ab50e19..9f63eb6822a 100644 --- a/toyos-update/src/entry.rs +++ b/toyos-update/src/entry.rs @@ -13,7 +13,9 @@ //! //! What firmware stores is untrusted here: an option is walked as bytes, //! bounded by the slice, and a node claiming a length of zero or one past the -//! option ends the walk rather than being stepped over. +//! option ends the walk rather than being stepped over. **One policy for every +//! entry this reads**: an inactive entry is one the boot manager skips, so it +//! is neither reused, nor pointed at, nor fallen to. /// `LOAD_OPTION_ACTIVE` (UEFI 2.10 §3.1.3): the boot manager may boot it. pub const LOAD_OPTION_ACTIVE: u32 = 0x1; @@ -52,79 +54,50 @@ pub struct Partition { pub guid: [u8; 16], } -/// Why an option could not be written. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Unwritable { - /// A description or a path holds a character outside ASCII, which this - /// writes as UCS-2 one byte at a time and so refuses rather than mangles. - NotAscii, - /// The option does not fit the buffer it was asked into. - TooLong { needs: usize }, -} - -impl core::fmt::Display for Unwritable { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::NotAscii => write!(f, "a description or path outside ASCII"), - Self::TooLong { needs } => write!(f, "an option of {needs} bytes, past the buffer it was asked into"), - } +/// `text` as null-terminated UCS-2 at `out[at..]`, and where it ends. +fn ucs2(text: &str, out: &mut [u8], mut at: usize) -> usize { + for unit in text.encode_utf16().chain(core::iter::once(0)) { + out[at..at + 2].copy_from_slice(&unit.to_le_bytes()); + at += 2; } + at } -/// `text` as null-terminated UCS-2 at `out[at..]`, and where it ends. -fn ucs2(text: &str, out: &mut [u8], at: usize) -> Result { - if !text.is_ascii() { - return Err(Unwritable::NotAscii); - } - let end = at + 2 * (text.len() + 1); - let room = out.len(); - let span = out.get_mut(at..end).ok_or(Unwritable::TooLong { needs: end.max(room + 1) })?; - for (unit, byte) in span.as_chunks_mut::<2>().0.iter_mut().zip(text.bytes().chain(core::iter::once(0))) { - unit.copy_from_slice(&u16::from(byte).to_le_bytes()); - } - Ok(end) +/// A node's length as its header carries it. +fn node_len(bytes: usize) -> [u8; 2] { + u16::try_from(bytes).expect("a device path this writes is far below 64 KiB").to_le_bytes() } /// The load option `HD(part)/File(path)`, active, described as `description`, -/// written into `out`; its length. -pub fn load_option(description: &str, part: &Partition, path: &str, out: &mut [u8]) -> Result { - if !path.is_ascii() || !description.is_ascii() { - return Err(Unwritable::NotAscii); - } - let file_bytes = NODE_HEADER + 2 * (path.len() + 1); - let path_bytes = HARD_DRIVE_BYTES + file_bytes + NODE_HEADER; - let needs = LOAD_OPTION_HEAD + 2 * (description.len() + 1) + path_bytes; - if needs > out.len() { - return Err(Unwritable::TooLong { needs }); - } - let path_len = u16::try_from(path_bytes).map_err(|_| Unwritable::TooLong { needs })?; - let file_len = u16::try_from(file_bytes).map_err(|_| Unwritable::TooLong { needs })?; - out[..needs].fill(0); +/// written over the head of `out`; its length. Every byte of it is written +/// here, and an `out` too short for it is an index past its end. +pub fn load_option(description: &str, part: &Partition, path: &str, out: &mut [u8]) -> usize { out[..4].copy_from_slice(&LOAD_OPTION_ACTIVE.to_le_bytes()); - out[4..6].copy_from_slice(&path_len.to_le_bytes()); - let mut at = ucs2(description, out, LOAD_OPTION_HEAD)?; + let path_at = ucs2(description, out, LOAD_OPTION_HEAD); - let hd = &mut out[at..at + HARD_DRIVE_BYTES]; + let hd = &mut out[path_at..path_at + HARD_DRIVE_BYTES]; hd[0] = HARD_DRIVE.0; hd[1] = HARD_DRIVE.1; - hd[2..4].copy_from_slice(&(HARD_DRIVE_BYTES as u16).to_le_bytes()); + hd[2..4].copy_from_slice(&node_len(HARD_DRIVE_BYTES)); hd[4..8].copy_from_slice(&part.number.to_le_bytes()); hd[8..16].copy_from_slice(&part.start.to_le_bytes()); hd[16..24].copy_from_slice(&part.size.to_le_bytes()); hd[24..40].copy_from_slice(&part.guid); hd[40] = GPT; hd[41] = SIGNATURE_GUID; - at += HARD_DRIVE_BYTES; - - out[at] = FILE_PATH.0; - out[at + 1] = FILE_PATH.1; - out[at + 2..at + 4].copy_from_slice(&file_len.to_le_bytes()); - at = ucs2(path, out, at + NODE_HEADER)?; - out[at] = END.0; - out[at + 1] = END.1; - out[at + 2..at + 4].copy_from_slice(&(NODE_HEADER as u16).to_le_bytes()); - Ok(at + NODE_HEADER) + let file_at = path_at + HARD_DRIVE_BYTES; + let end_at = ucs2(path, out, file_at + NODE_HEADER); + out[file_at] = FILE_PATH.0; + out[file_at + 1] = FILE_PATH.1; + out[file_at + 2..file_at + 4].copy_from_slice(&node_len(end_at - file_at)); + + out[end_at] = END.0; + out[end_at + 1] = END.1; + out[end_at + 2..end_at + 4].copy_from_slice(&node_len(NODE_HEADER)); + let len = end_at + NODE_HEADER; + out[4..6].copy_from_slice(&node_len(len - path_at)); + len } /// The device path an option carries, bounded by the option; `None` where its @@ -148,7 +121,7 @@ fn device_path(option: &[u8]) -> Option<&[u8]> { /// The GPT signature of the first HARDDRIVE node in a device path, or `None` /// where it has none, names an MBR partition, or a node cannot be stepped /// over. -pub fn hard_drive_guid(mut path: &[u8]) -> Option<[u8; 16]> { +fn hard_drive_guid(mut path: &[u8]) -> Option<[u8; 16]> { while let Some(node) = path.get(..NODE_HEADER) { let len = usize::from(u16::from_le_bytes([node[2], node[3]])); // A node shorter than its own header, or longer than what is left, ends @@ -169,90 +142,72 @@ pub fn hard_drive_guid(mut path: &[u8]) -> Option<[u8; 16]> { } /// Whether an option boots off the GPT partition `guid`. -pub fn names(option: &[u8], guid: &[u8; 16]) -> bool { +fn names(option: &[u8], guid: &[u8; 16]) -> bool { device_path(option).and_then(hard_drive_guid).is_some_and(|found| found == *guid) } /// Whether an option is active: the boot manager skips one that is not. -pub fn active(option: &[u8]) -> bool { +fn active(option: &[u8]) -> bool { option.get(..4).is_some_and(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]) & LOAD_OPTION_ACTIVE != 0) } +/// The lowest active entry of `held` — every `Boot####` firmware holds, with +/// its option's bytes — that boots off the GPT partition `guid`, whatever +/// file it names there: the owner's own entry among them. The lowest, so a +/// machine carrying two is answered the same way twice. +pub fn naming>(held: &[(u16, B)], guid: &[u8; 16]) -> Option { + held.iter().filter(|(_, o)| active(o.as_ref()) && names(o.as_ref(), guid)).map(|(n, _)| *n).min() +} + /// `BootOrder` with `ours` first and every other entry after it in the order -/// it had, into `out`; how many entries that is. An `out` shorter than the -/// order keeps the order's head. -pub fn first(order: &[u16], ours: u16, out: &mut [u16]) -> usize { +/// it had, into `out`; how many entries that is, or `None` where `out` cannot +/// hold them all. +pub fn first(order: &[u16], ours: u16, out: &mut [u16]) -> Option { let mut n = 0; for entry in core::iter::once(ours).chain(order.iter().copied().filter(|&e| e != ours)) { - let Some(slot) = out.get_mut(n) else { break }; - *slot = entry; + *out.get_mut(n)? = entry; n += 1; } - n + Some(n) } -/// The entry the firmware would have tried after `current`: the first after it -/// in `order` that `skip` does not refuse — never `current` again, and never -/// an entry before it, so a machine whose every later entry is skipped has -/// nothing to fall to rather than a loop. +/// The entry the firmware would have tried after `current`: the first in +/// `order` after it that `held` carries active and that does not boot off +/// `ours` — the loader's own partition, which has just failed — and never +/// `current` again. /// -/// `current` not in the order at all (a `BootNext` boot) falls to the order's -/// first entry that `skip` does not refuse: that is what the firmware tries -/// after a `BootNext` boot fails. -pub fn after(order: &[u16], current: u16, skip: impl Fn(u16) -> bool) -> Option { - let rest = match order.iter().position(|&e| e == current) { +/// **After its last place in the order, never an earlier one**: a fall only +/// ever moves on through the order, so a machine whose every later entry +/// fails has nothing to fall to rather than a loop, an order that names one +/// entry twice included. `current` not in the order at all (a `BootNext` +/// boot) falls to the order's first such entry, which is what the firmware +/// tries after a `BootNext` boot fails. +pub fn after>(order: &[u16], current: u16, held: &[(u16, B)], ours: Option<&[u8; 16]>) -> Option { + let rest = match order.iter().rposition(|&e| e == current) { Some(at) => &order[at + 1..], None => order, }; - rest.iter().copied().find(|&e| e != current && !skip(e)) -} - -/// The lowest `Boot####` number no entry in `used` holds. -pub fn free(used: &[u16]) -> Option { - (0..=u16::MAX).find(|n| !used.contains(n)) -} - -/// A GPT GUID in the text the GPT tools print, for a line that names one. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct GuidText(pub [u8; 16]); - -impl core::fmt::Display for GuidText { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let b = &self.0; - write!( - f, - "{:02X}{:02X}{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}", - b[3], b[2], b[1], b[0], b[5], b[4], b[7], b[6], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] - ) - } + let boots = |number: u16| { + held.iter().any(|(n, o)| { + let o = o.as_ref(); + *n == number && active(o) && !ours.is_some_and(|guid| names(o, guid)) + }) + }; + rest.iter().copied().find(|&e| e != current && boots(e)) } -/// A GPT GUID's text, `C12A7328-F81F-11D2-BA4B-00A0C93EC93B`, as the bytes a -/// partition entry stores: the first three fields little-endian, the last two -/// as written (UEFI 2.10 appendix A). Either case; nothing but the 36 -/// characters. -pub fn parse_guid(text: &str) -> Option<[u8; 16]> { - let bytes = text.as_bytes(); - if bytes.len() != 36 || [8, 13, 18, 23].iter().any(|&at| bytes[at] != b'-') { - return None; - } - let mut hex = [0u8; 16]; - let mut digits = bytes.iter().enumerate().filter(|(at, _)| ![8, 13, 18, 23].contains(at)).map(|(_, b)| *b); - for out in hex.iter_mut() { - let high = (digits.next()? as char).to_digit(16)?; - let low = (digits.next()? as char).to_digit(16)?; - *out = (high * 16 + low) as u8; - } - Some([ - hex[3], hex[2], hex[1], hex[0], hex[5], hex[4], hex[7], hex[6], hex[8], hex[9], hex[10], hex[11], hex[12], hex[13], - hex[14], hex[15], - ]) +/// The lowest `Boot####` number that no entry of `held` holds and `order` +/// does not name: a number the order still names, with no variable behind it, +/// would put a new entry at that stale place in the order. +pub fn free(held: &[(u16, B)], order: &[u16]) -> Option { + (0..=u16::MAX).find(|n| !order.contains(n) && !held.iter().any(|(e, _)| e == n)) } -/// `Boot0003`'s number; `None` for any other variable name. +/// `Boot0003`'s number; `None` for any other variable name, lowercase hex +/// among them, which UEFI does not read as a load option (UEFI 2.10 §3.3). pub fn number(name: &str) -> Option { let hex = name.strip_prefix("Boot")?; - if hex.len() != 4 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + if hex.len() != 4 || !hex.bytes().all(|b| b.is_ascii_digit() || (b'A'..=b'F').contains(&b)) { return None; } u16::from_str_radix(hex, 16).ok() @@ -291,24 +246,24 @@ mod tests { out } + /// An entry for the partition `guid`, active or not. + fn option(guid: u8, is_active: bool) -> Vec { + let mut out = [0xAAu8; 512]; + let n = load_option("ToyOS", &Partition { guid: [guid; 16], ..PART }, r"\EFI\BOOT\BOOTX64.EFI", &mut out); + out[0] = u8::from(is_active); + out[..n].to_vec() + } + #[test] fn an_option_is_the_bytes_the_specification_lays_down() { - let mut buffer = [0u8; 512]; - let n = load_option("ToyOS", &PART, r"\EFI\BOOT\BOOTX64.EFI", &mut buffer).expect("fits"); + let mut buffer = [0xAAu8; 512]; + let n = load_option("ToyOS", &PART, r"\EFI\BOOT\BOOTX64.EFI", &mut buffer); assert_eq!(&buffer[..n], by_hand().as_slice()); assert!(names(&buffer[..n], &[0xE5; 16])); assert!(!names(&buffer[..n], &[0xE6; 16])); assert!(active(&buffer[..n])); } - #[test] - fn an_option_that_does_not_fit_or_is_not_ascii_is_refused() { - let mut small = [0u8; 40]; - assert!(matches!(load_option("ToyOS", &PART, r"\EFI\BOOT\BOOTX64.EFI", &mut small), Err(Unwritable::TooLong { .. }))); - let mut buffer = [0u8; 512]; - assert_eq!(load_option("ToyÖS", &PART, r"\x", &mut buffer), Err(Unwritable::NotAscii)); - } - /// **Firmware's bytes are walked, never trusted**: a node of length zero, /// one past the option, a path running off the end and an MBR partition /// each name nothing rather than looping, reading past the slice or @@ -335,55 +290,67 @@ mod tests { assert!(!names(&unterminated, &[0xE5; 16]), "a description with no end"); } + /// **The entry an ESP is booted by is an active one**: an inactive entry + /// naming it is one the boot manager skips, so it is passed over for the + /// active one behind it, and the lowest of two active ones is the answer. + #[test] + fn an_esp_is_booted_by_its_lowest_active_entry() { + let held = [(2, option(0xE5, false)), (5, option(0xE5, true)), (7, option(0xE5, true)), (1, option(0xE6, true))]; + assert_eq!(naming(&held, &[0xE5; 16]), Some(5)); + assert_eq!(naming(&held, &[0xE7; 16]), None); + assert_eq!(naming(&held[..1], &[0xE5; 16]), None, "an inactive entry alone boots nothing"); + } + #[test] fn the_order_puts_ours_first_once() { let mut out = [0u16; 8]; - let n = first(&[3, 1, 7], 7, &mut out); + let n = first(&[3, 1, 7], 7, &mut out).expect("fits"); assert_eq!(&out[..n], &[7, 3, 1]); - let n = first(&[3, 1], 9, &mut out); + let n = first(&[3, 1], 9, &mut out).expect("fits"); assert_eq!(&out[..n], &[9, 3, 1], "an entry the order lacked is added at its head"); - let n = first(&[], 2, &mut out); + let n = first(&[], 2, &mut out).expect("fits"); assert_eq!(&out[..n], &[2]); - let mut two = [0u16; 2]; - assert_eq!(first(&[3, 1, 7], 7, &mut two), 2); - assert_eq!(two, [7, 3]); + let mut three = [0u16; 3]; + assert_eq!(first(&[3, 1, 7], 7, &mut three), Some(3), "ours was in the order: no longer"); + assert_eq!(first(&[3, 1, 7], 9, &mut three), None, "one more than the order holds is refused"); } /// The firmware's own fall-through: the next entry after the one that - /// booted, never it again and never one before it. + /// booted, never it again, never one before it, and never one the boot + /// manager would skip or that boots off the partition that just failed. #[test] - fn the_entry_after_is_later_in_the_order_and_never_current() { + fn the_entry_after_is_later_in_the_order_and_boots_something_else() { + const OURS: u8 = 0x0E; + let held = [(4, option(1, true)), (2, option(OURS, true)), (9, option(3, true)), (5, option(4, true))]; let order = [4, 2, 9, 5]; - assert_eq!(after(&order, 2, |_| false), Some(9)); - assert_eq!(after(&order, 2, |e| e == 9), Some(5)); - assert_eq!(after(&order, 5, |_| false), None, "nothing after the last"); - assert_eq!(after(&order, 7, |_| false), Some(4), "a BootNext boot falls to the order's head"); - assert_eq!(after(&order, 7, |e| e == 4), Some(2)); - assert_eq!(after(&[3, 3, 3], 3, |_| false), None, "never the entry that booted"); - } - - /// The GPT tools' own spelling, read back by the crate that writes it. - #[test] - fn a_guid_is_read_as_the_gpt_crate_writes_it() { - for guid in [toyos_gpt::Guid::EFI_SYSTEM, toyos_gpt::Guid::TOYOS_SLOTS, toyos_gpt::Guid([0xA5; 16])] { - let text = std::format!("{guid}"); - assert_eq!(parse_guid(&text), Some(guid.0), "{text}"); - assert_eq!(parse_guid(&text.to_ascii_lowercase()), Some(guid.0), "{text}"); - assert_eq!(std::format!("{}", GuidText(guid.0)), text); - } - assert_eq!(parse_guid("C12A7328F81F-11D2-BA4B-00A0C93EC93B0"), None); - assert_eq!(parse_guid("C12A7328-F81F-11D2-BA4B-00A0C93EC93"), None); - assert_eq!(parse_guid("G12A7328-F81F-11D2-BA4B-00A0C93EC93B"), None); + let ours = Some(&[OURS; 16]); + assert_eq!(after(&order, 4, &held, None), Some(2)); + assert_eq!(after(&order, 4, &held, ours), Some(9), "an entry naming this loader's own ESP is passed over"); + assert_eq!(after(&order, 2, &held, ours), Some(9)); + assert_eq!(after(&order, 5, &held, ours), None, "nothing after the last"); + assert_eq!(after(&order, 7, &held, ours), Some(4), "a BootNext boot falls to the order's head"); + let inactive = [(4, option(1, true)), (2, option(2, false)), (9, option(3, true))]; + assert_eq!(after(&order, 4, &inactive, ours), Some(9), "an inactive entry is passed over"); + assert_eq!(after(&order, 4, &held[2..], ours), Some(9), "an entry no variable holds is passed over"); + assert_eq!(after(&[3, 3, 3], 3, &held, None), None, "never the entry that booted"); + // An order naming 3 twice, both 3 and 5 failing: 3 falls to nothing + // behind its last place, so 3 → 5 → 3 → 5 is no loop. + let twice = [(3, option(1, true)), (5, option(2, true))]; + assert_eq!(after(&[3, 5, 3], 3, &twice, None), None); + assert_eq!(after(&[3, 5, 3], 5, &twice, None), Some(3)); } #[test] - fn a_number_is_four_hex_digits() { + fn a_number_is_four_uppercase_hex_digits_and_a_free_one_is_named_by_nothing() { assert_eq!(number("Boot0003"), Some(3)); - assert_eq!(number("Boot00aF"), Some(0xAF)); + assert_eq!(number("Boot00AF"), Some(0xAF)); + assert_eq!(number("Boot00aF"), None, "lowercase is no load option"); assert_eq!(number("BootOrder"), None); assert_eq!(number("BootNext"), None); assert_eq!(number("Boot00031"), None); - assert_eq!(free(&[0, 1, 3]), Some(2)); - assert_eq!(free(&[]), Some(0)); + let held = [(0, [0u8; 0]), (1, []), (3, [])]; + assert_eq!(free(&held, &[]), Some(2)); + assert_eq!(free(&held, &[2, 4]), Some(5), "a number the order still names is not free"); + assert_eq!(free::<[u8; 0]>(&[], &[]), Some(0)); } } diff --git a/toyos-update/src/policy.rs b/toyos-update/src/policy.rs index 0aca88cbca1..e972833cf90 100644 --- a/toyos-update/src/policy.rs +++ b/toyos-update/src/policy.rs @@ -99,13 +99,6 @@ pub fn order(table: &Table, once: Option) -> [Option; 2] { [Some(first), table.slot(other).map(|_| other)] } -/// The word the loader hands the kernel for a slot booted once, where the -/// refusal words go (`slot-refused=:once`): why the marked slot is not -/// the one this boot runs, which is no refusal of it. The kernel spells it -/// itself (`kernel/src/params.rs`'s `ONCE`), held to this one by -/// `toyos_build::bootlog`'s gate. -pub const ONCE: &str = "once"; - /// The floor after a boot proved `proven`: it only rises. pub fn raised(floor: u64, proven: u64) -> u64 { floor.max(proven) diff --git a/toyos-update/src/slots.rs b/toyos-update/src/slots.rs index 833501e45fd..64e8a6d807c 100644 --- a/toyos-update/src/slots.rs +++ b/toyos-update/src/slots.rs @@ -11,7 +11,7 @@ //! ```text //! magic "TOYOSLOT" | format u32 | marked u32 | sequence u64 //! then per slot: present u32 | 0 u32 | boot guid [16] | root guid [16] | version u64 -//! then the request: next u32 (0 none, 1 a slot, 2 an ESP) | slot u32 | esp guid [16] +//! then the request: next u32 (0 none, 1 a slot, 2 an ESP, 3 a slot on trial) | slot u32 | esp guid [16] //! | first u32 (0 or 1) //! then crc32 u32 over everything before it (TABLE_BYTES) //! ``` @@ -96,12 +96,19 @@ impl Which { } } -/// What to boot once, at the next pass that boots anything, and never again. +/// What to boot once, at the next pass that boots anything, and never again; +/// or the slot the pass before booted so. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Next { /// One of this disk's slots, whether or not it is the marked one: the /// bench's trial of an image the machine does not keep. Slot(Which), + /// The slot the pass before booted once, on the running system's request: + /// booted by no pass, and **while it stands the slot the table marks is + /// granted to nobody** ([`grant`]) — the image the machine keeps is not a + /// trial's to overwrite. The first pass that chooses a slot after it takes + /// it away. + Trial(Which), /// The EFI system partition with this unique GUID, on any disk the /// firmware sees, by its removable-media path: the firmware's `BootNext`, /// which is how the owner reaches another stick without a keyboard. @@ -168,6 +175,7 @@ impl core::fmt::Display for Unreadable { const NEXT_NONE: u32 = 0; const NEXT_SLOT: u32 = 1; const NEXT_ESP: u32 = 2; +const NEXT_TRIAL: u32 = 3; impl Table { pub fn slot(&self, which: Which) -> Option { @@ -190,17 +198,15 @@ impl Table { } } let at = REQUEST_AT; - match self.request.next { - None => {} - Some(Next::Slot(which)) => { - out[at..at + 4].copy_from_slice(&NEXT_SLOT.to_le_bytes()); - out[at + 4..at + 8].copy_from_slice(&(which.index() as u32).to_le_bytes()); - } - Some(Next::Esp(guid)) => { - out[at..at + 4].copy_from_slice(&NEXT_ESP.to_le_bytes()); - out[at + 8..at + 24].copy_from_slice(&guid); - } - } + let (kind, slot, guid) = match self.request.next { + None => (NEXT_NONE, 0, [0; 16]), + Some(Next::Slot(which)) => (NEXT_SLOT, which.index() as u32, [0; 16]), + Some(Next::Trial(which)) => (NEXT_TRIAL, which.index() as u32, [0; 16]), + Some(Next::Esp(guid)) => (NEXT_ESP, 0, guid), + }; + out[at..at + 4].copy_from_slice(&kind.to_le_bytes()); + out[at + 4..at + 8].copy_from_slice(&slot.to_le_bytes()); + out[at + 8..at + 24].copy_from_slice(&guid); out[at + 24..at + 28].copy_from_slice(&u32::from(self.request.first).to_le_bytes()); let crc = crc32(&out[..BODY_BYTES]); out[BODY_BYTES..TABLE_BYTES].copy_from_slice(&crc.to_le_bytes()); @@ -250,12 +256,15 @@ impl Table { let next = match kind { NEXT_NONE if slot == 0 && guid == [0; 16] => None, NEXT_NONE => return Err(Unreadable::Request("asks nothing next and names something to boot")), - NEXT_SLOT if guid != [0; 16] => return Err(Unreadable::Request("names a slot and an ESP at once")), - NEXT_SLOT => match slot { - 0 if slots[0].is_some() => Some(Next::Slot(Which::A)), - 1 if slots[1].is_some() => Some(Next::Slot(Which::B)), - _ => return Err(Unreadable::Request("names a slot the table does not carry")), - }, + NEXT_SLOT | NEXT_TRIAL if guid != [0; 16] => return Err(Unreadable::Request("names a slot and an ESP at once")), + NEXT_SLOT | NEXT_TRIAL => { + let which = match slot { + 0 if slots[0].is_some() => Which::A, + 1 if slots[1].is_some() => Which::B, + _ => return Err(Unreadable::Request("names a slot the table does not carry")), + }; + Some(if kind == NEXT_SLOT { Next::Slot(which) } else { Next::Trial(which) }) + } NEXT_ESP if slot != 0 => return Err(Unreadable::Request("names an ESP and a slot at once")), NEXT_ESP if guid == [0; 16] => return Err(Unreadable::Request("names an ESP by no GUID")), NEXT_ESP => Some(Next::Esp(guid)), @@ -308,6 +317,9 @@ pub enum NoIdle { NotThisBoot, /// The idle slot names a partition that is no idle slot's. Stray { part: &'static str, why: Stray }, + /// The machine runs `running` on trial, and the idle slot is the one the + /// table marks: the image the machine keeps. + Trial { running: Which, kept: Which }, } /// Why a partition the idle slot names is not one a grant may claim. @@ -330,6 +342,13 @@ impl core::fmt::Display for NoIdle { match self { Self::OneSlot => write!(f, "the slot table carries one slot, and the machine runs it"), Self::NotThisBoot => write!(f, "neither slot's ROOT is the one this boot runs"), + Self::Trial { running, kept } => write!( + f, + "slot {} runs on trial, and the idle slot is {}, the image the machine keeps: nothing writes it until {} runs", + running.letter(), + kept.letter(), + kept.letter() + ), Self::Stray { part, why } => { let why = match why { Stray::Running => "is one of the running slot's", @@ -366,9 +385,13 @@ pub struct Kinds { /// **The table is the grantee's to write**, so nothing it names is taken on /// its word: each of the idle slot's two partitions must be on the running /// ROOT's disk, of its kind's type, and neither of the running slot's — else -/// the holder of one grant could name its next one anywhere. +/// the holder of one grant could name its next one anywhere. And nothing is granted while +/// the running slot is on trial ([`Next::Trial`]). pub fn grant(table: &Table, running: &Listed, listed: &[Listed], kinds: Kinds) -> Result<(Which, Slot), NoIdle> { let (idle, slot) = idle(table, &running.unique_guid)?; + if table.request.next == Some(Next::Trial(idle.other())) { + return Err(NoIdle::Trial { running: idle.other(), kept: idle }); + } let runs = table.slot(idle.other()).expect("`idle` found the running slot in the table"); for (part, guid, kind) in [("volume", slot.boot, kinds.boot), ("ROOT", slot.root, kinds.root)] { let mut named = listed.iter().filter(|p| p.unique_guid == guid); @@ -442,6 +465,7 @@ mod tests { Request { next: Some(Next::Slot(Which::B)), first: false }, Request { next: Some(Next::Slot(Which::A)), first: true }, Request { next: Some(Next::Esp([0x5A; 16])), first: false }, + Request { next: Some(Next::Trial(Which::B)), first: true }, Request { next: None, first: true }, ] { let asked = Table { request, ..t }; @@ -454,7 +478,7 @@ mod tests { Table::decode(&resealed(block)) }; let refused = |why| Err(Unreadable::Request(why)); - assert_eq!(bent(&|b| b[at] = 3), refused("asks for a kind of boot there is none of")); + assert_eq!(bent(&|b| b[at] = 4), refused("asks for a kind of boot there is none of")); assert_eq!(bent(&|b| b[at + 4] = 2), refused("names a slot the table does not carry")); assert_eq!(bent(&|b| b[at + 8] = 1), refused("names a slot and an ESP at once")); assert_eq!(bent(&|b| b[at + 24] = 2), refused("asks for the boot order with a word that is neither 0 nor 1")); @@ -555,5 +579,10 @@ mod tests { let mut twice = listed.to_vec(); twice.push(at(1, KINDS.boot, [3; 16])); assert_eq!(grant(&t, &running, &twice, KINDS), stray("volume", Stray::Duplicate), "a GUID two disks carry"); + // **A trial writes nothing**: while the running slot is on trial, the + // idle one is the image the machine keeps; a trial of the other is over. + let trial = |which| Table { request: Request { next: Some(Next::Trial(which)), first: false }, ..t }; + assert_eq!(grant(&trial(Which::A), &running, &listed, KINDS), Err(NoIdle::Trial { running: Which::A, kept: Which::B })); + assert_eq!(grant(&trial(Which::B), &running, &listed, KINDS).map(|(w, _)| w), Ok(Which::B)); } } diff --git a/userland/toybox/src/date.rs b/userland/toybox/src/date.rs index 3f208f08635..ff45f78d3d4 100644 --- a/userland/toybox/src/date.rs +++ b/userland/toybox/src/date.rs @@ -1,17 +1,8 @@ -//! `date -u +%s`: the machine's clock as Unix seconds, which is what a host -//! reads to place this machine's log lines against its own clock. -//! -//! **That one form and no other.** A `date` that took a format would have to -//! implement `strftime`, and nothing here needs one; any other argument is -//! refused by name rather than printed in some format the caller did not ask -//! for. +//! `date`: the machine's clock as Unix seconds, which is what a host reads to +//! place this machine's log lines against its own clock — `date -u +%s`'s +//! answer, whatever it is asked. -pub fn main(args: Vec) { - let words: Vec<&str> = args.iter().map(String::as_str).collect(); - if words != ["-u", "+%s"] { - eprintln!("date: only `date -u +%s` is here, and {words:?} is not it"); - std::process::exit(2); - } +pub fn main(_: Vec) { match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { Ok(since) => println!("{}", since.as_secs()), Err(e) => { diff --git a/userland/update/Cargo.toml b/userland/update/Cargo.toml index 0a179bec84e..bd9159908c6 100644 --- a/userland/update/Cargo.toml +++ b/userland/update/Cargo.toml @@ -9,6 +9,8 @@ toyos = { path = "../../toyos" } toyos-abi = { path = "../../toyos-abi" } # The format, the verifier and the slot table the loader reads the same way. toyos-update = { path = "../../toyos-update" } +# The GUID `--boot-next` names, read as the GPT tools print it. +toyos-gpt = { path = "../../toyos-gpt" } # A slot's volume is FAT, written with the driver the kernel mounts FAT with. toyos-fat32 = { path = "../../toyos-fat32" } # ROOT is hashed as it streams onto its partition, never held whole. diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index f3d2a5434c8..19fae314169 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -40,7 +40,9 @@ //! **A request is the table's too** (`toyos_update::slots::Request`): the //! loader acts on it once at its next pass, and writes it away as it does. //! It asks for no more than an entry for an EFI system partition the loader -//! finds itself, first in the order or next once. +//! finds itself, first in the order or next once. Neither ask is signed: whoever +//! holds the grant — on the bench, the runner key — can reorder the firmware's +//! boot entries or boot any ESP the firmware sees, once. use std::io::Read; use std::time::Instant; @@ -74,8 +76,8 @@ fn asked() -> Result { [] => Ok(Asked::Install { once: false }), ["--once"] => Ok(Asked::Install { once: true }), ["--boot-first"] => Ok(Asked::BootFirst), - ["--boot-next", guid] => toyos_update::entry::parse_guid(guid) - .map(Asked::BootNext) + ["--boot-next", guid] => toyos_gpt::Guid::parse(guid) + .map(|guid| Asked::BootNext(guid.0)) .ok_or_else(|| format!("{guid:?} is no partition GUID; --boot-next wants one as the GPT tools print it")), _ => Err(format!( "{words:?} is no ask this takes: `update`, `update --once` (each with an image on its input), \ @@ -121,7 +123,7 @@ fn run(asked: Asked, began: Instant) -> Result { write_table(&table_claim, (table, current), Table { request, ..table })?; return Ok(format!( "update: the loader boots EFI system partition {} once, at its next pass, and the order after it", - toyos_update::entry::GuidText(guid) + toyos_gpt::Guid(guid) )); } }; From 1f3fa2b92c3e6c163f0504e37c53b48076cd0e35 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 16:19:02 +0200 Subject: [PATCH 07/26] userland: the lockfile names toyos-gpt under update `update --boot-next` reads its GUID with `toyos_gpt::Guid::parse`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- userland/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/userland/Cargo.lock b/userland/Cargo.lock index a1147e1c2b2..18a3c14ae16 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -4262,6 +4262,7 @@ dependencies = [ "toyos", "toyos-abi", "toyos-fat32", + "toyos-gpt", "toyos-update", ] From 4de6c6731baf6583ca8ae1f0e19704e1467c298c Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 16:42:42 +0200 Subject: [PATCH 08/26] update tests: the trial is offered a newer image, and slot A is read before the exit The grant's mutant went red on the refusal's wording while `update` was stopped by the version rule instead (200 is not newer than 200). An image newer than the trial's leaves the grant as the only thing between it and slot A, and slot A's signed header is read before the exit code is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- tests/common/update.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/common/update.rs b/tests/common/update.rs index a9ac522dbd3..3fc432541b0 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -816,7 +816,13 @@ pub fn update_trial_writes_nothing_of_the_kept_slot(_: &Path, _: &[(String, Vec< let (from, _) = rig.reboot_until(&mut guest, &mut console, &trial)?; await_machine(&mut guest, &mut console, "the trial's sshd", |c| c[from..].contains(SSHD_LISTENING))?; - let (status, said) = rig.install(&next)?; + // Newer than the trial's, so nothing but the grant stands between it and + // slot A. + let (later, _) = rig.update("later", &[], &[], NEXT + 1, signing::key())?; + let (status, said) = rig.install(&later)?; + if rig.signed_header(Which::A)? != kept { + return Err(format!("on the trial, `update` wrote slot A, the image the machine keeps, and ended {status:?} saying {said:?}")); + } if status != Some(1) || !said.contains("this process holds no `slots:table`") { return Err(format!("on the trial, `update` ended {status:?} saying {said:?}")); } @@ -826,9 +832,6 @@ pub fn update_trial_writes_nothing_of_the_kept_slot(_: &Path, _: &[(String, Vec< loader_said(&guest, uart, "Request: slot B's trial, which is over, is taken off the slot table")?; drop(guest); rig.powered_off_cleanly()?; - if rig.signed_header(Which::A)? != kept { - return Err("slot A's signed header changed under a trial".to_string()); - } let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; let table = image::slot_table_of(&mut file)?; if table.marked != Which::A || !table.request.is_empty() { From 22d5e9728a719b41a391c7e14e198fd884f6a794 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 16:56:49 +0200 Subject: [PATCH 09/26] Prose this round added or rewrote goes, but for one clause at each edit The owner's ruling: stale or false prose is deleted, never corrected, and new prose is the one-clause invariant at the edit site. Every doc and comment this review round rewrote is back to the text it had with only the false part deleted, and every one it added is a clause at the line it guards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- bootloader/Cargo.toml | 3 +- bootloader/src/bootnext.rs | 12 +++--- bootloader/src/bootvars.rs | 13 +++--- bootloader/src/loaderlog.rs | 4 +- bootloader/src/main.rs | 20 ++------- bootloader/src/request.rs | 12 +++--- ...hes-a-machine-only-by-writing-its-stick.md | 7 +--- src/bootlog.rs | 9 ++-- src/build.rs | 10 +---- src/metal.rs | 2 +- src/metalbench.rs | 31 ++++---------- tests/common/qemu.rs | 3 +- tests/common/update.rs | 33 +++++---------- toyos-abi/src/boot.rs | 6 +-- toyos-gpt/src/guid.rs | 6 +-- toyos-update/src/entry.rs | 42 +++++-------------- toyos-update/src/slots.rs | 18 +++----- userland/toybox/src/date.rs | 5 +-- userland/update/src/main.rs | 4 +- 19 files changed, 74 insertions(+), 166 deletions(-) diff --git a/bootloader/Cargo.toml b/bootloader/Cargo.toml index 43827091950..6d2397878eb 100644 --- a/bootloader/Cargo.toml +++ b/bootloader/Cargo.toml @@ -36,8 +36,7 @@ sha2 = { version = "0.10", default-features = false, features = ["force-soft"] } # `alloc`: `variable_keys` and `get_variable_boxed`, which are how the boot # entry pointing at this image is found among the firmware's own variables. uefi = { version = "0.26.0", default-features = false, features = ["alloc"] } -# No `panic_handler`: the loader's own hands the machine to the next entry -# (`main.rs`'s `panic`). +# No `panic_handler`: the loader has its own. uefi-services = { version = "0.23.0", default-features = false, features = ["logger"] } # The one profile every guest binary is built with. Optimised, because an diff --git a/bootloader/src/bootnext.rs b/bootloader/src/bootnext.rs index 3ed5a619c60..7a16ff8ef52 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -1,19 +1,17 @@ //! Point the firmware's next boot back at this loader. //! //! **The chain only closes if the machine comes back here.** A panicked kernel -//! resets through the FADT register, and the firmware consumes whatever -//! `BootNext` it was given. So every boot that hands the machine to a kernel -//! first names *this* loader as the next boot, and the pass after the reset is -//! the one that reads the page and decides whether to go on. +//! resets through the FADT register, the firmware consumes whatever `BootNext` +//! it was given. So every boot that hands the machine to a kernel first names +//! *this* loader as the next boot, and the pass after the reset is the one that +//! reads the page and decides whether to go on. //! //! The entry is found by the GPT partition GUID of the volume this image was //! loaded from, which is the same identity `efibootmgr --disk … --part 1` writes //! and the same one the metal driver flashes against — never by description, and //! never by taking whatever `BootCurrent` happens to say, because a firmware //! that booted us from a removable-media fallback path has no entry of ours at -//! all and must be told so rather than have one guessed at. The entries are -//! read and `BootNext` is written by [`crate::bootvars`], as every other -//! variable this loader touches. +//! all and must be told so rather than have one guessed at. use uefi::prelude::*; use uefi::proto::device_path::media::PartitionSignature; diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs index e625b776204..011ee2286c0 100644 --- a/bootloader/src/bootvars.rs +++ b/bootloader/src/bootvars.rs @@ -112,14 +112,15 @@ fn entries(rt: &RuntimeServices) -> Result, String> { Ok(out) } -/// The entry that boots the GPT partition `guid` ([`entry::naming`]), if any. +/// The entry that boots the GPT partition `guid`, if any. pub fn naming(rt: &RuntimeServices, guid: &[u8; 16]) -> Result, String> { Ok(entry::naming(&entries(rt)?, guid)) } -/// The entry that boots `esp` ([`entry::naming`]), or one written here for -/// its removable-media loader at the lowest free number; its number, and -/// whether it was written. +/// The entry that boots `esp`: the lowest active one already naming it — +/// whatever file it boots there, the owner's own entry among them — or one +/// written here for its removable-media loader, at the lowest free number. +/// Its number, and whether it was written. pub fn entry_for(rt: &RuntimeServices, esp: &Esp) -> Result<(u16, bool), String> { let part = &esp.part; let held = entries(rt)?; @@ -172,8 +173,8 @@ pub fn boot_next(rt: &RuntimeServices, number: u16) -> Result<(), String> { } /// The entry the firmware would have tried after the one that booted this -/// pass ([`entry::after`]), passing over every entry naming `ours` — this -/// loader's own partition, whose pass has just failed. +/// pass, skipping an inactive entry and every entry naming `ours` — this +/// loader's own partition. pub fn after_this_one(rt: &RuntimeServices, ours: Option<&[u8; 16]>) -> Result<(u16, Option), String> { let current = words(rt, cstr16!("BootCurrent"))? .and_then(|w| w.first().copied()) diff --git a/bootloader/src/loaderlog.rs b/bootloader/src/loaderlog.rs index 79ea19f5acc..cd2a3c8effb 100644 --- a/bootloader/src/loaderlog.rs +++ b/bootloader/src/loaderlog.rs @@ -63,12 +63,10 @@ const PREVIOUS: &CStr16 = cstr16!("loader-previous.log"); /// Copy `loader.log` to [`PREVIOUS`], replacing it, before the file is cut: /// the chain that file holds is over, and this keeps exactly one of them. /// -/// **The old [`PREVIOUS`] goes first**, so a copy refused at any step leaves -/// no file at all rather than a chain before the one that just ended. -/// /// Said on the console only, as [`refused`] is, where it cannot: the new file /// is not open yet. fn keep_previous(root: &mut Directory) { + // First, so no refusal below leaves an earlier chain's file. match root.open(PREVIOUS, FileMode::ReadWrite, FileAttribute::empty()) { Ok(stale) => { if let Err(e) = stale.delete() { diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index da30af009f2..f4e45fad343 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -137,13 +137,10 @@ fn load_file_bytes(handle: Handle, system_table: &SystemTable, path: &CStr bytes } -/// The line naming the file every ToyOS image boots by, this ESP's -/// removable-media loader, by its SHA-256; held to the host's spelling by -/// `toyos_build::bootlog`'s gate. +/// Held to the host's spelling by `toyos_build::bootlog`'s gate. const LOADER_IS: &str = "Loader: the removable-media file on this ESP hashes to"; -/// The line naming the boot parameter the kernel is handed, `root=` among -/// it; held to the host's spelling by `toyos_build::bootlog`'s gate. +/// Held to the host's spelling by `toyos_build::bootlog`'s gate. const BOOT_PARAMETER: &str = "Boot parameter:"; /// This loader's own file, at the removable-media path of the volume firmware @@ -886,17 +883,8 @@ fn end_this_pass(system_table: &SystemTable, exit_event: Option) -> system_table.runtime_services().reset(ResetType::WARM, Status::SUCCESS, None) } -/// **A pass that fails hands the machine on**: a panic of this loader — no -/// slot verifying, a floor refused, anything it cannot go on without — sets -/// `BootNext` to the entry the firmware would have tried after this one -/// ([`bootvars::after_this_one`]) and resets, so a machine whose stick heads -/// `BootOrder` still reaches what stands behind it. Where nothing does, the -/// machine powers off rather than reset into the same failure. -/// -/// **Not a return to the boot manager**, which would try the next entry itself: -/// a pass that returns leaves its exit-boot-services callback registered, as -/// [`end_this_pass`] says. A panic inside this one, or after boot services -/// are gone, stops here. +/// A failed pass hands the machine to the entry after this one, or powers it +/// off where there is none, never resetting into the same failure. #[panic_handler] fn panic(info: &core::panic::PanicInfo) -> ! { static PANICKED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); diff --git a/bootloader/src/request.rs b/bootloader/src/request.rs index 607a510954f..a18720779e8 100644 --- a/bootloader/src/request.rs +++ b/bootloader/src/request.rs @@ -13,9 +13,7 @@ //! variables ([`firmware`]) in whichever pass comes next, the report pass //! after a handover included; a slot to boot once ([`once`]) only in a pass //! that goes on to boot a slot, so the report pass that ends a chain leaves it -//! for the pass after. The slot booted once is left on the table as a trial -//! (`Next::Trial`), which keeps the slot the table marks from any grant while -//! it runs, and the next pass that chooses a slot takes the trial away. +//! for the pass after. use toyos_update::slots::{Next, Request, Table, Which}; use uefi::prelude::*; @@ -130,10 +128,9 @@ pub fn firmware(handle: Handle, system_table: &SystemTable, ours: Option<& } } -/// The slot the running system asked to boot once, left on the table as its -/// trial; or `None` where it asked none, or where the table would not take -/// the trial — a trial that could not be made a trial is not booted at all. -/// A trial the last boot ran is over, and is taken away here. +/// The slot the running system asked to boot once; or `None` where it asked +/// none, or where taking it off failed — a trial that could not be made a +/// trial is not booted at all. pub fn once(handle: Handle, system_table: &SystemTable) -> Option { let (mut disk, at) = match table(handle, system_table) { Ok(read) => read, @@ -142,6 +139,7 @@ pub fn once(handle: Handle, system_table: &SystemTable) -> Option { }; let (left, what, boots) = match at.table.request.next { Some(Next::Slot(which)) => (Some(Next::Trial(which)), alloc::format!("a boot of slot {} once", which.letter()), Some(which)), + // The trial the last boot ran is over. Some(Next::Trial(which)) => (None, alloc::format!("slot {}'s trial, which is over,", which.letter()), None), _ => return None, }; diff --git a/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md b/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md index 8595a62f80c..8cb9f9b3052 100644 --- a/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md +++ b/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md @@ -19,11 +19,8 @@ the T14 a new bench written through `--via-ubuntu --resident`. That path goes with the installer, and an owner's machine updated with `ssh … update` has no check at all. -A slot table format is the loader's too, and changes the same way: the table -went from format 1 to format 2 with the running system's request -(`toyos_update::slots`), so a machine at format 1 that installs a format-2 -image with `update` keeps its format-1 table, which that image's own `update` -refuses — it cannot update again until its stick is written. +The slot table's format 2 strands a format-1 machine the same way: its table +outlives `update`, and the image it installed refuses that table. **Exit**: a loader change reaches a running machine through a signed update, or an image names the loader it needs and a loader that is not it refuses to diff --git a/src/bootlog.rs b/src/bootlog.rs index f500ea4a46f..e5a5fd0a668 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -128,13 +128,12 @@ pub const LOADER_PREVIOUS_LOG: &str = "loader-previous.log"; /// filesystem: what ties a `logd` file to the image whose boot wrote it. pub const MOUNTED_FROM_MEMORY: &str = "root: mounted read-only from memory at"; -/// The loader's line naming its ESP's removable-media loader by its SHA-256: -/// the one part of a machine no update installs, which a bench's host holds a -/// delivered image's loader to. +/// The loader's line naming itself by the SHA-256 of its file: the one part of +/// a machine no update installs, which a bench's host holds a delivered +/// image's loader to. pub const LOADER_IS: &str = "Loader: the removable-media file on this ESP hashes to"; -/// The loader's line naming the boot parameter it hands the kernel, whose -/// `root=` names the ROOT that boot mounts. +/// The loader's line naming the boot parameter it hands the kernel. pub const BOOT_PARAMETER: &str = "Boot parameter:"; /// That file's first line and its last. diff --git a/src/build.rs b/src/build.rs index 22b7e4a4365..97f02025055 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2035,10 +2035,8 @@ pub fn bench_image(root: &Path, config: &Path, authorized: &str, version: u64, r Ok(build_test_image(root, &plan, quiet, &[(AUTHORIZED_ON_ROOT.to_string(), keys.into_bytes())])) } -/// The bench's `authorized_keys` file, or why there is none: ed25519 public -/// keys and nothing else, one per line — and **never under the owner's key**, -/// which would make the bench a valid update for every owner machine, and the -/// runner key authorized there. +/// The bench's `authorized_keys`: ed25519 keys alone, and never under the +/// owner's key, which would make it a valid update for every owner machine. fn bench_keys(authorized: &str, signer: &crate::signing::Whose) -> Result { if let crate::signing::Whose::Owner(path) = signer { return Err(format!( @@ -3774,10 +3772,6 @@ mod tests { assert_eq!(judge_entry_window(&SCHED_CHECK_KERNEL.join(","), b"not an ELF"), Ok(())); } - /// **No bench under the owner's key**: `--owner-key` or `--update-image` - /// beside `--bench-image` would sign a valid update for every owner - /// machine that authorizes the runner key, and is refused; so is a key - /// file holding anything but ed25519 public keys. #[test] fn a_bench_is_throwaway_signed_and_authorizes_ed25519_keys_alone() { use crate::signing::Whose; diff --git a/src/metal.rs b/src/metal.rs index ac2a84032a5..69c9173c651 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -1632,7 +1632,7 @@ pub struct Args { /// **The old path, named**: flash the stick through Ubuntu, choose the /// next boot with `efibootmgr` and read the log partition off the stick. /// Absent is the machine running ToyOS and nothing else - /// ([`crate::metalbench`]). This flag goes when the T14 has no Ubuntu. + /// ([`crate::metalbench`]). pub via_ubuntu: bool, /// **Flash a bench image and hand the machine to it** (`--via-ubuntu` /// only): the image is armed with nothing, boots once through Ubuntu's diff --git a/src/metalbench.rs b/src/metalbench.rs index 3b4aab3cd41..64e50890f8e 100644 --- a/src/metalbench.rs +++ b/src/metalbench.rs @@ -15,11 +15,7 @@ //! because the bench's own pass starts a new `loader.log` — and every `logd` //! file of this boot, told from the bench's own by the ROOT this image mounts. //! Each is held to this image before it is judged: the loader's by the signed -//! header's digest it verified, the kernel's by the ROOT UUID it names and by -//! no name `/log` held before the delivery. **The machine is back only once -//! its `loader.log` is not the one fetched before the reboot**: every pass -//! starts that file or appends to it, so the same text is the bench that was -//! asked to reboot and has not yet. A +//! header's digest it verified, the kernel's by the ROOT UUID it names. A //! volume's raw bytes are the one thing not read: the bench has the log //! partition mounted, so no read of it here is a quiescent volume, and //! `--fat32-check` is the old path's alone @@ -150,13 +146,13 @@ impl Bench { Err(Refusal::Silent { what, secs }) } - /// Wait until the machine's `/log` comes back over this key with a - /// `loader.log` that is not `before`, within `secs`: the bench taking the - /// runner key and the boot's files, in the one session. How long it took, - /// and the files. + /// Wait until the machine's `/log` comes back over this key, within + /// `secs`: the bench taking the runner key and the boot's files, in the + /// one session. How long it took, and the files. fn wait_for_the_log(&self, secs: u64, what: &'static str, into: &Path, before: &str) -> Result<(u64, Logs), Refusal> { let began = std::time::Instant::now(); while began.elapsed().as_secs() < secs { + // The same loader.log is the bench not yet rebooted. if let Some(logs) = self.fetch(into).ok().filter(|logs| rebooted(logs, before)) { return Ok((began.elapsed().as_secs(), logs)); } @@ -202,8 +198,7 @@ fn wire(bench: &Bench, logs: &Logs, nic: &str) -> Result { let std::net::IpAddr::V4(addr) = at.ip() else { return Err(bad(format!("the bench answers at {at}, which is no IPv4 address"))); }; - // The bench's own boot by the ROOT its pass handed the kernel, never by - // a name: a name is the bench's clock, which can step back. + // By ROOT, never by name: a name is the bench's clock, which can step back. let own = logs.read(bootlog::LOADER_LOG)?; let root = own .lines() @@ -228,9 +223,7 @@ fn wire(bench: &Bench, logs: &Logs, nic: &str) -> Result { /// **Told from the bench's own by the ROOT it mounted.** Every boot's first /// part names its ROOT's filesystem (`kernel/src/rootfs.rs`), and an image's /// ROOT UUID is its own, so a file is this boot's by its content, never by -/// being the one before the newest. **A boot any of whose names `/log` held -/// `before` the delivery is an earlier one**: an earlier delivery of the same -/// image names the same ROOT. +/// being the one before the newest. fn kernel_log(logs: &Logs, root: &str, before: &[String]) -> Result { let mounted = format!("filesystem {root},"); let logd = logs.logd(); @@ -238,6 +231,7 @@ fn kernel_log(logs: &Logs, root: &str, before: &[String]) -> Result = logd.iter().copied().filter(|name| stem(name) == *boot).collect(); + // A name /log held before the delivery is an earlier boot's. if parts.iter().any(|part| before.iter().any(|name| name == part)) { continue; } @@ -263,8 +257,7 @@ fn stem(name: &str) -> &str { } } -/// Whether a pass has run since `before` was fetched: every pass starts a new -/// `loader.log` or appends to it. +/// Whether a pass has run since `before`: every pass starts or appends to it. fn rebooted(logs: &Logs, before: &str) -> bool { logs.read(bootlog::LOADER_LOG).is_ok_and(|now| now != before) } @@ -521,16 +514,10 @@ mod tests { assert!(text.contains("filesystem bbbb,") && text.ends_with("the boot's second part\n"), "{text}"); assert_eq!(kernel_log(&logs, "dddd", &[]).expect("a read"), "", "a boot that reached no logd"); assert!(matches!(logs.read("loader-previous.log"), Err(Refusal::NotThisBoot(_)))); - // **A name /log held before the delivery is an earlier boot**, even of - // this image: a rerun of one image names one ROOT. let before = vec!["2026-09-27-110000_0002.log".to_string()]; assert_eq!(kernel_log(&logs, "bbbb", &before).expect("a read"), "", "an earlier delivery's boot"); } - /// **The machine is back only as a machine that ran a pass since**, and - /// the passes it kept are this image's only where they name its header: - /// the bench still up after `reboot` was asked, and a kept file of an - /// earlier boot, are neither. #[test] fn a_readback_is_of_a_pass_since_and_of_this_image() { let scratch = toyos_tmpdir::TempDir::new("back"); diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index fb99bd99adb..e84391d4935 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -2439,8 +2439,7 @@ pub struct BootOptions { /// sector, through QEMU's `blkdebug` under the stick's raw format: a disk /// error at a place the test chose, which no well-formed image can stage. pub stick_read_error: Option, - /// The boot stick attached read-only: every write the guest makes to it - /// fails, the loader's among them. + /// The boot stick attached read-only. pub stick_readonly: bool, /// What the emulated RTC reads when the machine starts, as /// `YYYY-MM-DDTHH:MM:SS`. diff --git a/tests/common/update.rs b/tests/common/update.rs index 3fc432541b0..8715958fc9e 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -263,9 +263,7 @@ impl Rig { Ok((from, uart)) } - /// The slots' record as a clean hand-back leaves it — no boot counted and - /// none written down — for a machine the test powered off while it ran, - /// whose next pass would otherwise be the retry of a hang. + /// The slots' record as a clean hand-back leaves it, so the next pass is no retry. fn powered_off_cleanly(&self) -> Result<(), String> { let guid = self.log_guid()?; let record = Record { count: 0, booted: None, ..self.record()? }; @@ -726,12 +724,11 @@ pub fn update_floor_is_the_images_own(_: &Path, _: &[(String, Vec)], _: &[(S const BOOT_NEXT_SET: &str = "(written now): the firmware boots it once, at the reset this pass ends with"; /// **A request for another ESP boots that ESP once, and the order resumes**: -/// the machine asks for its recovery stick with `update --boot-next`; a pass -/// that cannot take the request off the slot table — the stick read-only — -/// sets no `BootNext`; the next pass takes it off, writes an entry for that -/// stick and points `BootNext` at it; the recovery stick's kernel boots; its -/// reboot hands the machine to the firmware's order, which is the machine's -/// own stick; and the machine's next reboot is its own again. +/// the machine asks for its recovery stick with `update --boot-next`; the pass +/// takes the request off the slot table, writes an entry for +/// that stick and points `BootNext` at it; the recovery stick's kernel boots; +/// its reboot hands the machine to the firmware's order, which is the +/// machine's own stick; and the machine's next reboot is its own again. pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { let rig = Rig::stage("update-boot-next")?.with_recovery()?; let recovery = rig.recovery()?.to_path_buf(); @@ -743,8 +740,7 @@ pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], drop(guest); rig.powered_off_cleanly()?; - // **Consumed before it is acted on**: the pass whose write of the table - // fails has set nothing when it says so, and never will. + // A pass that cannot take the request off the table sets nothing. let unwritable = BootOptions { profile: qemu::Profile::Metal, boot_image: Some(Staged::Written(rig.image.clone())), @@ -795,13 +791,8 @@ pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], Ok(()) } -/// **A trial writes nothing of the image the machine keeps**: `update --once` -/// puts an image in slot B for one boot; on that boot slot A — the image the -/// machine keeps — is the idle slot, init grants nothing, `update` is refused -/// and slot A's signed header is the one it was; the reboot after is slot A's, -/// and takes the trial off the table. And a trial the loader refuses boots -/// slot A as the ordinary boot it is: the kernel is told of no refusal of a -/// marked slot. +/// A trial writes nothing of the image the machine keeps, and a refused trial +/// boots the marked slot with no refusal told. pub fn update_trial_writes_nothing_of_the_kept_slot(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { let rig = Rig::stage("update-trial")?; let (next, _) = rig.update("next", &[], &[], NEXT, signing::key())?; @@ -816,8 +807,7 @@ pub fn update_trial_writes_nothing_of_the_kept_slot(_: &Path, _: &[(String, Vec< let (from, _) = rig.reboot_until(&mut guest, &mut console, &trial)?; await_machine(&mut guest, &mut console, "the trial's sshd", |c| c[from..].contains(SSHD_LISTENING))?; - // Newer than the trial's, so nothing but the grant stands between it and - // slot A. + // Newer than the trial's: nothing but the grant stands before slot A. let (later, _) = rig.update("later", &[], &[], NEXT + 1, signing::key())?; let (status, said) = rig.install(&later)?; if rig.signed_header(Which::A)? != kept { @@ -885,8 +875,7 @@ pub fn update_boot_first_puts_the_loader_first(_: &Path, _: &[(String, Vec)] .ok_or_else(|| format!("no entry number in {line:?}"))?; let (_, uart) = rig.reboot_until(&mut guest, &mut console, DEFAULT_READY)?; loader_said(&guest, uart, &format!("this pass was booted as Boot{number:04X}"))?; - // **Asked once, written once**: no pass after the one that wrote the - // order reads the request again or writes the order again. + // Asked once, written once. let since = guest.uart_log()[uart..].to_string(); if let Some(again) = since.lines().find(|l| l.contains("Request:") || l.contains("is first:")) { return Err(format!("a pass after the one that wrote the order said {again:?}")); diff --git a/toyos-abi/src/boot.rs b/toyos-abi/src/boot.rs index 5efb3a7c83b..83ba23acb39 100644 --- a/toyos-abi/src/boot.rs +++ b/toyos-abi/src/boot.rs @@ -148,10 +148,8 @@ pub const SLOT_PARAM: &str = "boot-slot="; /// log. pub const SLOT_REFUSED_PARAM: &str = "slot-refused="; -/// The word the loader puts where [`SLOT_REFUSED_PARAM`]'s refusal goes when -/// the slot it booted is one the running system asked for once -/// (`slot-refused=:once`): why the marked slot is not this boot's, and -/// no refusal of it. +/// `slot-refused=:once`: the slot booted was asked for once, and the +/// marked one was refused nothing. pub const SLOT_ONCE: &str = "once"; /// The most windows the loader will carry. diff --git a/toyos-gpt/src/guid.rs b/toyos-gpt/src/guid.rs index afe9dcd34ea..07f6e7268e6 100644 --- a/toyos-gpt/src/guid.rs +++ b/toyos-gpt/src/guid.rs @@ -112,8 +112,7 @@ impl Guid { } impl Guid { - /// The text [`Display`](fmt::Display) prints, read back: the 36 characters - /// `C12A7328-F81F-11D2-BA4B-00A0C93EC93B`, in either case, and nothing else. + /// The text [`Display`](fmt::Display) prints, read back, in either case. pub fn parse(text: &str) -> Option { const DASHES: [usize; 4] = [8, 13, 18, 23]; let bytes = text.as_bytes(); @@ -185,9 +184,6 @@ mod tests { assert_ne!(Guid::TOYOS_ROOT, Guid::TOYOS_DATA); } - /// Every spelling [`Guid`]'s Display prints reads back as the bytes it - /// printed, in either case; a missing dash, a short text and a non-digit - /// read as nothing. #[test] fn the_text_it_prints_parses_back() { for guid in [Guid::EFI_SYSTEM, Guid::TOYOS_SLOTS, Guid([0xA5; 16])] { diff --git a/toyos-update/src/entry.rs b/toyos-update/src/entry.rs index 9f63eb6822a..e05cf83621c 100644 --- a/toyos-update/src/entry.rs +++ b/toyos-update/src/entry.rs @@ -13,9 +13,7 @@ //! //! What firmware stores is untrusted here: an option is walked as bytes, //! bounded by the slice, and a node claiming a length of zero or one past the -//! option ends the walk rather than being stepped over. **One policy for every -//! entry this reads**: an inactive entry is one the boot manager skips, so it -//! is neither reused, nor pointed at, nor fallen to. +//! option ends the walk rather than being stepped over. /// `LOAD_OPTION_ACTIVE` (UEFI 2.10 §3.1.3): the boot manager may boot it. pub const LOAD_OPTION_ACTIVE: u32 = 0x1; @@ -69,8 +67,7 @@ fn node_len(bytes: usize) -> [u8; 2] { } /// The load option `HD(part)/File(path)`, active, described as `description`, -/// written over the head of `out`; its length. Every byte of it is written -/// here, and an `out` too short for it is an index past its end. +/// written into `out`; its length. pub fn load_option(description: &str, part: &Partition, path: &str, out: &mut [u8]) -> usize { out[..4].copy_from_slice(&LOAD_OPTION_ACTIVE.to_le_bytes()); let path_at = ucs2(description, out, LOAD_OPTION_HEAD); @@ -151,17 +148,14 @@ fn active(option: &[u8]) -> bool { option.get(..4).is_some_and(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]) & LOAD_OPTION_ACTIVE != 0) } -/// The lowest active entry of `held` — every `Boot####` firmware holds, with -/// its option's bytes — that boots off the GPT partition `guid`, whatever -/// file it names there: the owner's own entry among them. The lowest, so a -/// machine carrying two is answered the same way twice. +/// The lowest active entry of `held` that boots off the GPT partition `guid`. pub fn naming>(held: &[(u16, B)], guid: &[u8; 16]) -> Option { held.iter().filter(|(_, o)| active(o.as_ref()) && names(o.as_ref(), guid)).map(|(n, _)| *n).min() } /// `BootOrder` with `ours` first and every other entry after it in the order /// it had, into `out`; how many entries that is, or `None` where `out` cannot -/// hold them all. +/// hold them. pub fn first(order: &[u16], ours: u16, out: &mut [u16]) -> Option { let mut n = 0; for entry in core::iter::once(ours).chain(order.iter().copied().filter(|&e| e != ours)) { @@ -171,17 +165,9 @@ pub fn first(order: &[u16], ours: u16, out: &mut [u16]) -> Option { Some(n) } -/// The entry the firmware would have tried after `current`: the first in -/// `order` after it that `held` carries active and that does not boot off -/// `ours` — the loader's own partition, which has just failed — and never -/// `current` again. -/// -/// **After its last place in the order, never an earlier one**: a fall only -/// ever moves on through the order, so a machine whose every later entry -/// fails has nothing to fall to rather than a loop, an order that names one -/// entry twice included. `current` not in the order at all (a `BootNext` -/// boot) falls to the order's first such entry, which is what the firmware -/// tries after a `BootNext` boot fails. +/// The first active entry of `held` after `current`'s last place in `order` +/// (all of it where `current` is not there) that is not `current` and does +/// not boot off `ours`: never an earlier one, so a fall cannot loop. pub fn after>(order: &[u16], current: u16, held: &[(u16, B)], ours: Option<&[u8; 16]>) -> Option { let rest = match order.iter().rposition(|&e| e == current) { Some(at) => &order[at + 1..], @@ -196,15 +182,13 @@ pub fn after>(order: &[u16], current: u16, held: &[(u16, B)], our rest.iter().copied().find(|&e| e != current && boots(e)) } -/// The lowest `Boot####` number that no entry of `held` holds and `order` -/// does not name: a number the order still names, with no variable behind it, -/// would put a new entry at that stale place in the order. +/// The lowest `Boot####` number neither `held` nor `order` names. pub fn free(held: &[(u16, B)], order: &[u16]) -> Option { (0..=u16::MAX).find(|n| !order.contains(n) && !held.iter().any(|(e, _)| e == n)) } /// `Boot0003`'s number; `None` for any other variable name, lowercase hex -/// among them, which UEFI does not read as a load option (UEFI 2.10 §3.3). +/// among them (UEFI 2.10 §3.3). pub fn number(name: &str) -> Option { let hex = name.strip_prefix("Boot")?; if hex.len() != 4 || !hex.bytes().all(|b| b.is_ascii_digit() || (b'A'..=b'F').contains(&b)) { @@ -290,9 +274,6 @@ mod tests { assert!(!names(&unterminated, &[0xE5; 16]), "a description with no end"); } - /// **The entry an ESP is booted by is an active one**: an inactive entry - /// naming it is one the boot manager skips, so it is passed over for the - /// active one behind it, and the lowest of two active ones is the answer. #[test] fn an_esp_is_booted_by_its_lowest_active_entry() { let held = [(2, option(0xE5, false)), (5, option(0xE5, true)), (7, option(0xE5, true)), (1, option(0xE6, true))]; @@ -316,8 +297,7 @@ mod tests { } /// The firmware's own fall-through: the next entry after the one that - /// booted, never it again, never one before it, and never one the boot - /// manager would skip or that boots off the partition that just failed. + /// booted, never it again and never one before it. #[test] fn the_entry_after_is_later_in_the_order_and_boots_something_else() { const OURS: u8 = 0x0E; @@ -333,8 +313,6 @@ mod tests { assert_eq!(after(&order, 4, &inactive, ours), Some(9), "an inactive entry is passed over"); assert_eq!(after(&order, 4, &held[2..], ours), Some(9), "an entry no variable holds is passed over"); assert_eq!(after(&[3, 3, 3], 3, &held, None), None, "never the entry that booted"); - // An order naming 3 twice, both 3 and 5 failing: 3 falls to nothing - // behind its last place, so 3 → 5 → 3 → 5 is no loop. let twice = [(3, option(1, true)), (5, option(2, true))]; assert_eq!(after(&[3, 5, 3], 3, &twice, None), None); assert_eq!(after(&[3, 5, 3], 5, &twice, None), Some(3)); diff --git a/toyos-update/src/slots.rs b/toyos-update/src/slots.rs index 64e8a6d807c..687ad874019 100644 --- a/toyos-update/src/slots.rs +++ b/toyos-update/src/slots.rs @@ -96,18 +96,13 @@ impl Which { } } -/// What to boot once, at the next pass that boots anything, and never again; -/// or the slot the pass before booted so. +/// What to boot once, at the next pass that boots anything, and never again. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Next { /// One of this disk's slots, whether or not it is the marked one: the /// bench's trial of an image the machine does not keep. Slot(Which), - /// The slot the pass before booted once, on the running system's request: - /// booted by no pass, and **while it stands the slot the table marks is - /// granted to nobody** ([`grant`]) — the image the machine keeps is not a - /// trial's to overwrite. The first pass that chooses a slot after it takes - /// it away. + /// The slot booted once and running: while it stands, nothing is granted. Trial(Which), /// The EFI system partition with this unique GUID, on any disk the /// firmware sees, by its removable-media path: the firmware's `BootNext`, @@ -317,8 +312,7 @@ pub enum NoIdle { NotThisBoot, /// The idle slot names a partition that is no idle slot's. Stray { part: &'static str, why: Stray }, - /// The machine runs `running` on trial, and the idle slot is the one the - /// table marks: the image the machine keeps. + /// The running slot is on trial, and the idle one is the slot kept. Trial { running: Which, kept: Which }, } @@ -385,10 +379,10 @@ pub struct Kinds { /// **The table is the grantee's to write**, so nothing it names is taken on /// its word: each of the idle slot's two partitions must be on the running /// ROOT's disk, of its kind's type, and neither of the running slot's — else -/// the holder of one grant could name its next one anywhere. And nothing is granted while -/// the running slot is on trial ([`Next::Trial`]). +/// the holder of one grant could name its next one anywhere. pub fn grant(table: &Table, running: &Listed, listed: &[Listed], kinds: Kinds) -> Result<(Which, Slot), NoIdle> { let (idle, slot) = idle(table, &running.unique_guid)?; + // A trial writes nothing of the slot the machine keeps. if table.request.next == Some(Next::Trial(idle.other())) { return Err(NoIdle::Trial { running: idle.other(), kept: idle }); } @@ -579,8 +573,6 @@ mod tests { let mut twice = listed.to_vec(); twice.push(at(1, KINDS.boot, [3; 16])); assert_eq!(grant(&t, &running, &twice, KINDS), stray("volume", Stray::Duplicate), "a GUID two disks carry"); - // **A trial writes nothing**: while the running slot is on trial, the - // idle one is the image the machine keeps; a trial of the other is over. let trial = |which| Table { request: Request { next: Some(Next::Trial(which)), first: false }, ..t }; assert_eq!(grant(&trial(Which::A), &running, &listed, KINDS), Err(NoIdle::Trial { running: Which::A, kept: Which::B })); assert_eq!(grant(&trial(Which::B), &running, &listed, KINDS).map(|(w, _)| w), Ok(Which::B)); diff --git a/userland/toybox/src/date.rs b/userland/toybox/src/date.rs index ff45f78d3d4..4acffd04e18 100644 --- a/userland/toybox/src/date.rs +++ b/userland/toybox/src/date.rs @@ -1,6 +1,5 @@ -//! `date`: the machine's clock as Unix seconds, which is what a host reads to -//! place this machine's log lines against its own clock — `date -u +%s`'s -//! answer, whatever it is asked. +//! `date -u +%s`: the machine's clock as Unix seconds, which is what a host +//! reads to place this machine's log lines against its own clock. pub fn main(_: Vec) { match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index 19fae314169..5a5a64c4e6d 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -40,9 +40,7 @@ //! **A request is the table's too** (`toyos_update::slots::Request`): the //! loader acts on it once at its next pass, and writes it away as it does. //! It asks for no more than an entry for an EFI system partition the loader -//! finds itself, first in the order or next once. Neither ask is signed: whoever -//! holds the grant — on the bench, the runner key — can reorder the firmware's -//! boot entries or boot any ESP the firmware sees, once. +//! finds itself, first in the order or next once. Neither ask is signed. use std::io::Read; use std::time::Instant; From fa94514f94ffdc6cb6867d68c82a24473dc45ada Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 17:21:00 +0200 Subject: [PATCH 10/26] toyos-abi: version follows its own ABI addition, 0.17.0 The branch added four lines to toyos-abi/src/boot.rs, and abi-split refused a changed crate republished under its old version. toyos-abi moves to 0.17.0; toyos and toyos-window each name it by version, so both are touched and each takes its own minor bump (0.19.0, 0.21.0), and every lockfile that locks any of the three without a registry source is re-locked with `cargo update --offline -p `. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- bootloader/Cargo.lock | 2 +- kernel/Cargo.lock | 2 +- tests/iced-counter/Cargo.lock | 6 +++--- tests/ssh-client-host/Cargo.lock | 2 +- tests/toyos-rust-tests/Cargo.lock | 6 +++--- toyos-abi/Cargo.toml | 2 +- toyos/Cargo.lock | 4 ++-- toyos/Cargo.toml | 4 ++-- userland/Cargo.lock | 6 +++--- userland/libc/Cargo.lock | 4 ++-- userland/toyos-window/Cargo.toml | 6 +++--- 12 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 802affd915b..a1faeb3a021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1164,7 +1164,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" dependencies = [ "rustc-std-workspace-core", ] diff --git a/bootloader/Cargo.lock b/bootloader/Cargo.lock index f7dfd89b21e..13676ce84e4 100644 --- a/bootloader/Cargo.lock +++ b/bootloader/Cargo.lock @@ -257,7 +257,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" [[package]] name = "toyos-acpi" diff --git a/kernel/Cargo.lock b/kernel/Cargo.lock index e6d0370abcf..ea172bd3889 100644 --- a/kernel/Cargo.lock +++ b/kernel/Cargo.lock @@ -77,7 +77,7 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" [[package]] name = "toyos-acpi" diff --git a/tests/iced-counter/Cargo.lock b/tests/iced-counter/Cargo.lock index 3c8ef1a1859..838c2389d5d 100644 --- a/tests/iced-counter/Cargo.lock +++ b/tests/iced-counter/Cargo.lock @@ -2957,14 +2957,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.18.0" +version = "0.19.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" [[package]] name = "toyos-font" @@ -2976,7 +2976,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.20.0" +version = "0.21.0" dependencies = [ "toyos", "toyos-abi", diff --git a/tests/ssh-client-host/Cargo.lock b/tests/ssh-client-host/Cargo.lock index d8eb51ea815..dd269501945 100644 --- a/tests/ssh-client-host/Cargo.lock +++ b/tests/ssh-client-host/Cargo.lock @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" [[package]] name = "toyos-manifest" diff --git a/tests/toyos-rust-tests/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index bc2b2c2b2b1..78a9ecbdb60 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -2084,14 +2084,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.18.0" +version = "0.19.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" [[package]] name = "toyos-blockhold" @@ -2180,7 +2180,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.20.0" +version = "0.21.0" dependencies = [ "toyos", "toyos-abi", diff --git a/toyos-abi/Cargo.toml b/toyos-abi/Cargo.toml index 153716fec8a..09ed8733c04 100644 --- a/toyos-abi/Cargo.toml +++ b/toyos-abi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" edition = "2021" license = "MIT OR Apache-2.0" description = "The ToyOS kernel ABI: syscall numbers, struct layouts and typed syscall wrappers." diff --git a/toyos/Cargo.lock b/toyos/Cargo.lock index c38f92db341..705625672b4 100644 --- a/toyos/Cargo.lock +++ b/toyos/Cargo.lock @@ -10,7 +10,7 @@ checksum = "aa9c45b374136f52f2d6311062c7146bff20fec063c3f5d46a410bd937746955" [[package]] name = "toyos" -version = "0.18.0" +version = "0.19.0" dependencies = [ "rustc-std-workspace-core", "toyos-abi", @@ -18,7 +18,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" dependencies = [ "rustc-std-workspace-core", ] diff --git a/toyos/Cargo.toml b/toyos/Cargo.toml index ffd3ccfb7e4..0faf294ae93 100644 --- a/toyos/Cargo.toml +++ b/toyos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos" -version = "0.18.0" +version = "0.19.0" edition = "2021" license = "MIT OR Apache-2.0" description = "The ToyOS userland SDK: typed handles, IPC, ports, namespaces, surfaces and net." @@ -28,4 +28,4 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("toyos") [dependencies] core = { version = "1.0.0", optional = true, package = "rustc-std-workspace-core" } -toyos-abi = { path = "../toyos-abi", version = "0.16.0", default-features = false } +toyos-abi = { path = "../toyos-abi", version = "0.17.0", default-features = false } diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 18a3c14ae16..d2c4e0d6fe4 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -4040,14 +4040,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.18.0" +version = "0.19.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" [[package]] name = "toyos-blockhold" @@ -4164,7 +4164,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.20.0" +version = "0.21.0" dependencies = [ "toyos", "toyos-abi", diff --git a/userland/libc/Cargo.lock b/userland/libc/Cargo.lock index df18e043698..376e2955eaf 100644 --- a/userland/libc/Cargo.lock +++ b/userland/libc/Cargo.lock @@ -27,14 +27,14 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "toyos" -version = "0.18.0" +version = "0.19.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.16.0" +version = "0.17.0" [[package]] name = "toyos-libc" diff --git a/userland/toyos-window/Cargo.toml b/userland/toyos-window/Cargo.toml index 050be016156..b0da9a17a8f 100644 --- a/userland/toyos-window/Cargo.toml +++ b/userland/toyos-window/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos-window" -version = "0.20.0" +version = "0.21.0" edition = "2021" license = "MIT OR Apache-2.0" description = "Client library for ToyOS windows: create one, draw into it, read its events." @@ -8,9 +8,9 @@ repository = "https://github.com/ToyOSOrg/ToyOS" [dependencies] toyos-font = { path = "../toyos-font", version = "0.2.0" } -toyos-abi = { path = "../../toyos-abi", version = "0.16.0" } +toyos-abi = { path = "../../toyos-abi", version = "0.17.0" } toyos-keymap = { path = "../../toyos-keymap", version = "0.1.0" } -toyos = { path = "../../toyos", version = "0.18.0" } +toyos = { path = "../../toyos", version = "0.19.0" } # `window` is taken on crates.io, so the package is `toyos-window`; the lib # target keeps the name every caller spells, `toyos/src`'s citations included. From 1f5763444eb00f82f5ef0b71ed7a76333356bf02 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 18:07:13 +0200 Subject: [PATCH 11/26] Review #539 round 2: the refusals that stop a false green come back, and what earns nothing goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKERs: - A flag only Ubuntu carries out (`--install-sudoers`, `--fat32-check`, `--device`, `--host`, `--resident`) is refused by name without `--via-ubuntu` rather than dropped by the bench path, so no run returns a verdict with the outside FAT judge never run. - `--swap` beside `--via-ubuntu` is refused ("a swap has no Ubuntu half"), with or without `--image`, and `--resident` beside `--readback`, `--talk` or `--nic` is refused ("judges no boot"). The three refusals sit after the swap's own, so `--swap … --fat32-check` is still the swap's refusal and `FAT32_CHECK` in `NOT_A_SWAP` is live and tested again. NOTEs: - `metal::tests`: the swap loop names `--fat32-check` again, and `--image x.img` with no `--readback` is refused, asserted again. - `update_no_slot_boots_the_recovery_stick` plants an active `HD()/\EFI\BOOT\BOOTX64.EFI` entry right behind the entry that boots the machine, and holds the pass to one fall, to the entry behind the planted one. - `bench_loop_drives_a_toyos_machine` stages a `loader-previous.log` longer than any chain, ending in a marker line, before the bench's first pass, and holds the readback's loader text to lacking it (`image::create_file_on`). - `update_boot_next_boots_the_entry_once` holds the unwritable pass's serial up to its marker to no `BootNext=Boot`, and the store to no `BootNext` naming the recovery stick's entry, since the pass goes on past the marker. - `update --boot-next` refuses by name to replace a slot asked for once (`slots::Request::boot_next`, host-tested). - `metalbench::wait_for_the_log` carries what its last ask came to into `Refusal::Silent`. - The HARDDRIVE node is chosen in `bootvars::hard_drive` alone (exactly one, GPT, GUID-signed); `bootnext`'s "first" and `esp`'s "last" are gone. - `date` refuses every form but `-u +%s`; the bench test asserts `date +%Y` ends 2. - `told` is `toyos_update::policy::told`, pure and host-tested with the dead trial of N8. Deleted: `machine.txt` with its writer and its readback entry, the bench configs' `bin/echo` symlink, the second Arm-to-Batch literal (`Batch::of`). REMOVEs: the benchcase "differ by the one devices row" clause, both "echo is how the loop asks" clauses, and the installer plan in `metal.rs`'s header. Co-Authored-By: Claude Opus 5.5 --- bootloader/src/bootnext.rs | 20 +----- bootloader/src/bootvars.rs | 36 ++++++---- bootloader/src/main.rs | 35 ++-------- bootloader/src/slot.rs | 14 +--- src/image.rs | 28 ++++++++ src/metal.rs | 112 ++++++++++++++++++++++++------ src/metalbench.rs | 36 +++++++--- tests/benchcase/system.toml | 6 +- tests/benchvirtiocase/system.toml | 4 +- tests/common/bench.rs | 19 +++++ tests/common/metal.rs | 40 +++++------ tests/common/update.rs | 105 +++++++++++++++++++++++++--- toyos-update/src/policy.rs | 33 +++++++++ toyos-update/src/slots.rs | 20 ++++++ userland/toybox/src/date.rs | 8 ++- userland/update/src/main.rs | 4 +- 16 files changed, 381 insertions(+), 139 deletions(-) diff --git a/bootloader/src/bootnext.rs b/bootloader/src/bootnext.rs index 7a16ff8ef52..1da43e606c5 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -14,8 +14,7 @@ //! all and must be told so rather than have one guessed at. use uefi::prelude::*; -use uefi::proto::device_path::media::PartitionSignature; -use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType}; +use uefi::proto::device_path::DevicePath; use uefi::proto::loaded_image::LoadedImage; use crate::bootvars; @@ -58,20 +57,5 @@ pub(crate) fn our_partition(handle: Handle, system_table: &SystemTable) -> let image = bs.open_protocol_exclusive::(handle).ok()?; let device = image.device()?; let path = bs.open_protocol_exclusive::(device).ok()?; - hard_drive_guid(path.node_iter()) -} - -/// The GPT signature of the first HARDDRIVE node in a device path, or `None` -/// where the path has none — a network boot, or a disk with no GPT. -fn hard_drive_guid<'a>(nodes: impl Iterator) -> Option<[u8; 16]> { - for node in nodes { - if node.full_type() != (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE) { - continue; - } - let hd = <&uefi::proto::device_path::media::HardDrive>::try_from(node).ok()?; - if let PartitionSignature::Guid(guid) = hd.partition_signature() { - return Some(guid.to_bytes()); - } - } - None + bootvars::hard_drive(&path).ok().map(|(_, guid)| guid) } diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs index 011ee2286c0..475510ed7c4 100644 --- a/bootloader/src/bootvars.rs +++ b/bootloader/src/bootvars.rs @@ -18,7 +18,7 @@ use alloc::string::String; use alloc::vec::Vec; use toyos_update::entry::{self, Partition}; use uefi::prelude::*; -use uefi::proto::device_path::media::PartitionSignature; +use uefi::proto::device_path::media::{HardDrive, PartitionFormat, PartitionSignature}; use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType}; use uefi::proto::media::file::{File, FileAttribute, FileMode}; use uefi::proto::media::fs::SimpleFileSystem; @@ -54,6 +54,27 @@ pub struct Esp { removable: Result<(), String>, } +/// The HARDDRIVE node by which `path` names a GPT partition, and that +/// partition's unique GUID; or why it names none. Exactly one node: a path +/// with two describes a partition inside a partition, and taking either is +/// guessing which one the path means. +pub fn hard_drive(path: &DevicePath) -> Result<(&HardDrive, [u8; 16]), &'static str> { + let mut nodes = + path.node_iter().filter(|node| node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE)); + let node = nodes.next().ok_or("the device path carries no HARDDRIVE node")?; + if nodes.next().is_some() { + return Err("the device path has more than one HARDDRIVE node"); + } + let hd = <&HardDrive>::try_from(node).map_err(|_| "the HARDDRIVE node is malformed")?; + if hd.partition_format() != PartitionFormat::GPT { + return Err("firmware says this is not a GPT partition"); + } + let PartitionSignature::Guid(guid) = hd.partition_signature() else { + return Err("firmware named the partition with no GUID signature"); + }; + Ok((hd, guid.to_bytes())) +} + /// The EFI system partition `guid` names, or which of those it is not. pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { let handle = crate::loaderlog::volume_handle(bs, guid)?; @@ -65,17 +86,8 @@ pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { } let path = crate::rootimage::try_get_protocol::(bs, handle) .map_err(|e| alloc::format!("the partition's device path ({e:?})"))?; - let node = path - .node_iter() - .filter(|node| node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE)) - .last() - .ok_or("the partition's device path carries no HARDDRIVE node")?; - let hd = <&uefi::proto::device_path::media::HardDrive>::try_from(node) - .map_err(|e| alloc::format!("the partition's HARDDRIVE node ({e:?})"))?; - let PartitionSignature::Guid(signature) = hd.partition_signature() else { - return Err(String::from("the partition's HARDDRIVE node names it by no GUID")); - }; - if signature.to_bytes() != *guid { + let (hd, signature) = hard_drive(&path).map_err(|why| alloc::format!("the partition: {why}"))?; + if signature != *guid { return Err(String::from("the partition's HARDDRIVE node names another partition")); } let part = Partition { number: hd.partition_number(), start: hd.partition_start(), size: hd.partition_size(), guid: *guid }; diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index f4e45fad343..dfac91a1c8a 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -13,7 +13,7 @@ use uefi::{ prelude::*, CStr16, proto::console::gop::{GraphicsOutput, PixelFormat}, - proto::device_path::{media::{PartitionFormat, PartitionSignature}, DevicePath, DevicePathNode, DeviceType, DeviceSubType}, + proto::device_path::DevicePath, proto::loaded_image::LoadedImage, proto::media::file::{File, FileAttribute, FileInfo, FileMode}, table::{boot::{MemoryAttribute, MemoryType, OpenProtocolAttributes, OpenProtocolParams, PAGE_SIZE}, cfg::ACPI2_GUID, runtime::ResetType}, @@ -227,34 +227,13 @@ fn boot_partition(handle: Handle, system_table: &SystemTable) -> Option(handle).ok()?; let device = image.device()?; let path = bs.open_protocol_exclusive::(device).ok()?; - - let is_hard_drive = |node: &&DevicePathNode| { - node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE) - }; - // Exactly one, not the last one. A path with two HARDDRIVE nodes describes - // a partition inside a partition, and picking either is guessing which of - // the two the kernel's block device will be looking at. - let mut nodes = path.node_iter().filter(is_hard_drive); - let node = nodes.next()?; - if nodes.next().is_some() { - println!("Boot partition: the device path has more than one HARDDRIVE node, so it is ignored"); - return None; - } - - let hd: &uefi::proto::device_path::media::HardDrive = node.try_into().ok()?; - if hd.partition_format() != PartitionFormat::GPT { - println!("Boot partition: firmware says this is not a GPT partition, so it is ignored"); - return None; + match bootvars::hard_drive(&path) { + Ok((hd, guid)) => Some(BootPartition { guid, start_lba: hd.partition_start(), blocks: hd.partition_size() }), + Err(why) => { + println!("Boot partition: {why}, so it is ignored"); + None + } } - let PartitionSignature::Guid(guid) = hd.partition_signature() else { - println!("Boot partition: firmware named it with no GUID signature, so it is ignored"); - return None; - }; - Some(BootPartition { - guid: guid.to_bytes(), - start_lba: hd.partition_start(), - blocks: hd.partition_size(), - }) } /// Name the partition the kernel's log goes on, without reading it. diff --git a/bootloader/src/slot.rs b/bootloader/src/slot.rs index afefabb8323..2159409773a 100644 --- a/bootloader/src/slot.rs +++ b/bootloader/src/slot.rs @@ -90,17 +90,9 @@ pub fn choose( table.marked.letter() ); } - // What the kernel is told of the slot chosen, whichever loop chose it. - let told = |mut chosen: Chosen, refused: Option<(Which, Refusal)>| { - match once { - Some(trial) if chosen.which == trial => chosen.once = Some(table.marked), - // The trial was refused and this is the marked slot: an ordinary - // boot, and the kernel is told nothing was refused. - Some(_) => {} - None if chosen.which != table.marked => chosen.refused = refused, - None => {} - } - chosen + let told = |chosen: Chosen, refused: Option<(Which, Refusal)>| { + let (once, refused) = policy::told(table.marked, once, chosen.which, refused); + Chosen { once, refused, ..chosen } }; for which in policy::order(&table, once).into_iter().flatten() { let slot = table.slot(which).expect("`order` names only slots the table carries"); diff --git a/src/image.rs b/src/image.rs index d37c7a63dcc..e02320d4ae6 100644 --- a/src/image.rs +++ b/src/image.rs @@ -585,6 +585,34 @@ pub fn overwrite_file_on(path: &Path, guid: [u8; 16], name: &str, bytes: &[u8]) file.sync_all().map_err(|e| format!("syncing {}: {e}", path.display())) } +/// Create the file `name` holding `bytes` on the FAT partition `guid` of the +/// disk image at `path`, which no guest may be running on. +pub fn create_file_on(path: &Path, guid: [u8; 16], name: &str, bytes: &[u8]) -> Result<(), String> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let (start, len) = partition_extent(&mut file, guid)?; + let mut volume = vec![0u8; usize::try_from(len).map_err(|_| format!("a {len}-byte volume"))?]; + file.seek(SeekFrom::Start(start)) + .and_then(|_| file.read_exact(&mut volume)) + .map_err(|e| format!("reading the volume at byte {start}: {e}"))?; + { + let time = build_time(); + let mut fs = Fat32::mount(VolumeIo(&mut volume)).map_err(|e| format!("the volume does not mount: {e}"))?; + let mut created = fs.create(name, time).map_err(|e| format!("creating {name}: {e}"))?; + fs.write(&mut created, 0, bytes).map_err(|e| format!("writing {name}: {e}"))?; + fs.flush_meta(&mut created, time).map_err(|e| format!("recording {name}: {e}"))?; + fs.sync().map_err(|e| format!("syncing the volume: {e}"))?; + } + file.seek(SeekFrom::Start(start)) + .and_then(|_| file.write_all(&volume)) + .and_then(|_| file.sync_all()) + .map_err(|e| format!("writing the volume back to {}: {e}", path.display())) +} + /// The slot table on the disk image `file`, which copy is current, and where /// its partition starts. fn table_on(file: &mut std::fs::File) -> Result<(toyos_update::slots::Table, usize, u64), String> { diff --git a/src/metal.rs b/src/metal.rs index 69c9173c651..5bf4c6afc15 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -5,8 +5,7 @@ //! **Two paths, one named.** By default the machine runs ToyOS alone — the //! bench — and each boot is delivered to it and read back over its own sshd //! ([`crate::metalbench`]). `--via-ubuntu` is the old path this file carries -//! the rest of, until the installer puts ToyOS on the NVMe: Ubuntu flashes -//! the stick and reads the log partition off it. +//! the rest of: Ubuntu flashes the stick and reads the log partition off it. //! //! The old path runs on the development host and reaches Ubuntu over `ssh`. //! [`Target::words`] is the one place a root command line is written — the @@ -155,8 +154,9 @@ pub enum Refusal { Wire { nic: String, why: String }, /// This host could not run the probe, which is a fact about the host. Probe { why: String }, - /// The machine did not go down, or did not come back. - Silent { what: &'static str, secs: u64 }, + /// The machine did not go down, or did not come back; `last` is what the + /// last ask of it came to, where one was made. + Silent { what: &'static str, secs: u64, last: Option }, /// The machine came back and the boot stick did not: the boot before this /// left a USB device its next host cannot enumerate. Stick { node: String, secs: u64 }, @@ -314,12 +314,15 @@ impl fmt::Display for Refusal { "this host could not ask the one question this loop can put to a boot that is \ still up: {why}" ), - Self::Silent { what, secs } => write!( - f, - "the machine did not {what} within {secs} s, which is longer than every watchdog \ - a boot runs under plus the time coming back costs; why it did not is what the \ - panel and the log partition say, and neither is readable from here" - ), + Self::Silent { what, secs, last } => { + write!( + f, + "the machine did not {what} within {secs} s, which is longer than every watchdog \ + a boot runs under plus the time coming back costs; why it did not is what the \ + panel and the log partition say, and neither is readable from here" + )?; + last.as_ref().map_or(Ok(()), |last| write!(f, "; the last ask of it: {last}")) + } Self::Stick { node, secs } => write!( f, "the machine came back and {node} did not, within {secs} s: the boot that just \ @@ -1445,7 +1448,7 @@ impl Driver { return Ok(began.elapsed().as_secs()); } } - Err(Refusal::Silent { what, secs }) + Err(Refusal::Silent { what, secs, last: None }) } /// The loader's own file, and then everything `logd` wrote, in name order: @@ -1562,6 +1565,10 @@ declare_flags!(METAL = { RESIDENT = "--resident", None; }); +/// The flags only Ubuntu carries out: each is refused without [`VIA_UBUNTU`] +/// rather than dropped. +const UBUNTUS: &[&Flag] = &[&INSTALL_SUDOERS, &FAT32_CHECK, &DEVICE, &HOST, &RESIDENT]; + /// The flags a swap of a running machine's service refuses beside it: each /// describes a boot the swap does not judge. `--image` is not among them: on /// the bench a swapping boot is delivered and judged like any other, and the @@ -1762,6 +1769,35 @@ impl Args { } } } + if !out.via_ubuntu { + let ubuntus: Vec<&str> = line + .seen + .iter() + .map(|seen| seen.flag.name) + .filter(|name| UBUNTUS.iter().any(|flag| flag.name == *name)) + .collect(); + if !ubuntus.is_empty() { + return Err(Refusal::Usage(format!( + "{} reach the machine through Ubuntu, and this run reaches a machine that runs \ + ToyOS; name --via-ubuntu for the old path", + ubuntus.join(" and ") + ))); + } + } + if out.via_ubuntu && out.swap.is_some() { + return Err(Refusal::Usage( + "--swap asks a running ToyOS over its own sshd, and --via-ubuntu names the path that \ + flashes through Ubuntu; a swap has no Ubuntu half" + .to_string(), + )); + } + if out.resident && (out.readback.is_some() || out.talk.is_some() || out.nic.is_some()) { + return Err(Refusal::Usage( + "--resident hands the machine to a bench image and judges no boot, so --readback, \ + --talk and --nic describe a boot it will not judge" + .to_string(), + )); + } Ok(out) } } @@ -2454,7 +2490,6 @@ pub const READBACK_FILES: &[&str] = &[ READBACK_VOLUME, READBACK_STREAM, READBACK_TALK, - crate::metalbench::READBACK_MACHINE, ]; /// Empty a readback directory, before this run can leave any of it standing. @@ -2709,7 +2744,7 @@ mod tests { /// with no job list and no bound. #[test] fn installing_the_rule_is_not_also_a_boot() { - let alone = ["--install-sudoers", "/tmp/pw"].map(String::from); + let alone = ["--via-ubuntu", "--install-sudoers", "/tmp/pw"].map(String::from); let args = Args::parse(&alone).expect("installing the rule alone"); assert!(args.install_sudoers.is_some()); assert!(args.about_a_boot.is_empty()); @@ -2723,7 +2758,7 @@ mod tests { vec!["--nic", "0000:00:1f.6"], vec!["--talk", "/tmp/k"], ] { - let mut words = vec!["--install-sudoers".to_string(), "/tmp/pw".to_string()]; + let mut words = vec!["--via-ubuntu".to_string(), "--install-sudoers".to_string(), "/tmp/pw".to_string()]; words.extend(flag.iter().map(|w| (*w).to_string())); let refusal = Args::parse(&words).unwrap_err(); let said = refusal.to_string(); @@ -2735,7 +2770,7 @@ mod tests { // The flags that say *which machine* are not a boot, and the rule needs // them: an install against another host or key is still an install. - let hosted = ["--install-sudoers", "/tmp/pw", "--host", "dev@t14", "--key", "/tmp/k"] + let hosted = ["--via-ubuntu", "--install-sudoers", "/tmp/pw", "--host", "dev@t14", "--key", "/tmp/k"] .map(String::from); assert!(Args::parse(&hosted).is_ok()); } @@ -2763,7 +2798,7 @@ mod tests { let args = Args::parse(&whole).expect("a whole swap"); assert_eq!(args.swap.as_deref(), Some("netd")); - for flag in [vec!["--dry-run"], vec!["--nic", "0000:00:1f.6"]] { + for flag in [vec!["--fat32-check"], vec!["--dry-run"], vec!["--nic", "0000:00:1f.6"]] { let mut words = whole.to_vec(); words.extend(flag.iter().map(|w| (*w).to_string())); let said = Args::parse(&words).unwrap_err().to_string(); @@ -2796,6 +2831,43 @@ mod tests { assert!(Args::parse(&bent).unwrap_err().to_string().contains("no service")); } + /// **A flag only Ubuntu carries out is refused, never dropped**, on a run + /// that reaches a machine running ToyOS; a swap has no Ubuntu half; a bench + /// handed the machine is judged by nothing; and a boot of a machine running + /// ToyOS is read into a readback or not run. + #[test] + fn the_ubuntu_path_is_named_and_nothing_falls_back_to_it() { + for flag in [ + vec!["--install-sudoers", "/tmp/pw"], + vec!["--fat32-check"], + vec!["--device", "/dev/sdb"], + vec!["--host", "t14@t14"], + vec!["--resident"], + ] { + let words: Vec = flag.iter().map(|w| (*w).to_string()).collect(); + let said = Args::parse(&words).unwrap_err().to_string(); + assert!(said.contains(flag[0]) && said.contains("--via-ubuntu"), "{said}"); + let mut named = vec!["--via-ubuntu".to_string()]; + named.extend(words); + assert!(Args::parse(&named).is_ok(), "{flag:?} beside --via-ubuntu"); + } + let swap = ["--via-ubuntu", "--swap", "netd", "--binary", "n", "--talk", "/tmp/k", "--readback", "/tmp/r"]; + for image in [&[][..], &["--image", "x.img"][..]] { + let words: Vec = swap.iter().chain(image).map(|w| (*w).to_string()).collect(); + assert!(Args::parse(&words).unwrap_err().to_string().contains("no Ubuntu half"), "{image:?}"); + } + for beside in [vec!["--readback", "/tmp/r"], vec!["--nic", "0000:00:1f.6"]] { + let mut words = vec!["--via-ubuntu", "--resident", "--image", "b.img"]; + words.extend(beside.iter().copied()); + let words: Vec = words.iter().map(|w| (*w).to_string()).collect(); + assert!(Args::parse(&words).unwrap_err().to_string().contains("judges no boot"), "{beside:?}"); + } + let unread = ["--image", "x.img"].map(String::from); + let refusal = run(&Args::parse(&unread).expect("a boot of the bench")).unwrap_err(); + assert!(refusal.to_string().contains("--readback"), "{refusal}"); + assert!(!refusal.about_the_boot()); + } + /// **There is nothing to fall through to.** A default image was what let the /// fall-through reach a disk at all; a command line that names none is @@ -3302,7 +3374,7 @@ mod tests { assert!(Refusal::Wedge { why: "x" }.about_the_boot()); assert!(Refusal::Log(bootlog::Unfit::NoBootRecord).about_the_boot()); assert!(Refusal::Log(bootlog::Unfit::Unfinished("x".to_string())).about_the_boot()); - assert!(Refusal::Silent { what: "come back", secs: return_secs() }.about_the_boot()); + assert!(Refusal::Silent { what: "come back", secs: return_secs(), last: None }.about_the_boot()); assert!(!Refusal::Node("/dev/nvme0n1p1".to_string()).about_the_boot()); assert!(!Refusal::Sudo("a password is required".to_string()).about_the_boot()); assert!(!Refusal::Landed { what: "dd".to_string(), want: 1, got: 2 }.about_the_boot()); @@ -3358,7 +3430,7 @@ mod tests { assert_eq!(args.wait_secs, return_secs()); assert!(!args.dry_run); - let words: Vec = ["--dry-run", "--device", "/dev/sdb", "--host", "runner@box"] + let words: Vec = ["--via-ubuntu", "--dry-run", "--device", "/dev/sdb", "--host", "runner@box"] .iter() .map(|w| (*w).to_string()) .collect(); @@ -3368,9 +3440,9 @@ mod tests { assert_eq!(args.target.user, "runner"); assert_eq!(args.target.host, "box"); - let nvme = ["--device".to_string(), "/dev/nvme0n1".to_string()]; + let nvme = ["--via-ubuntu", "--device", "/dev/nvme0n1"].map(String::from); assert_eq!(Args::parse(&nvme).unwrap().target.node.whole(), "/dev/nvme0n1"); - let part = ["--device".to_string(), "/dev/nvme0n1p2".to_string()]; + let part = ["--via-ubuntu", "--device", "/dev/nvme0n1p2"].map(String::from); assert_eq!(Args::parse(&part), Err(Refusal::Node("/dev/nvme0n1p2".to_string()))); assert!(matches!(Args::parse(&["--image".to_string()]), Err(Refusal::Usage(_)))); assert!(matches!(Args::parse(&["--flash".to_string()]), Err(Refusal::Usage(_)))); diff --git a/src/metalbench.rs b/src/metalbench.rs index 64e50890f8e..de2efe2eee0 100644 --- a/src/metalbench.rs +++ b/src/metalbench.rs @@ -46,10 +46,6 @@ const ASK_EVERY: Duration = Duration::from_secs(2); /// Where the machine's files are, on the machine. const LOG_DIR: &str = "/log"; -/// Where what the bench said about itself before the boot is written, beside -/// the stick's files. -pub const READBACK_MACHINE: &str = "machine.txt"; - /// One machine reached with one key: the client, the key, and where. pub struct Bench { ssh: Ssh, @@ -143,7 +139,7 @@ impl Bench { } std::thread::sleep(ASK_EVERY); } - Err(Refusal::Silent { what, secs }) + Err(Refusal::Silent { what, secs, last: None }) } /// Wait until the machine's `/log` comes back over this key, within @@ -151,14 +147,16 @@ impl Bench { /// one session. How long it took, and the files. fn wait_for_the_log(&self, secs: u64, what: &'static str, into: &Path, before: &str) -> Result<(u64, Logs), Refusal> { let began = std::time::Instant::now(); + let mut last = None; while began.elapsed().as_secs() < secs { - // The same loader.log is the bench not yet rebooted. - if let Some(logs) = self.fetch(into).ok().filter(|logs| rebooted(logs, before)) { - return Ok((began.elapsed().as_secs(), logs)); + match self.fetch(into) { + Ok(logs) if rebooted(&logs, before) => return Ok((began.elapsed().as_secs(), logs)), + Ok(_) => last = Some(format!("its {} is the one fetched before the reboot", bootlog::LOADER_LOG)), + Err(why) => last = Some(why), } std::thread::sleep(ASK_EVERY); } - Err(Refusal::Silent { what, secs }) + Err(Refusal::Silent { what, secs, last }) } } @@ -340,8 +338,6 @@ pub fn run(args: &Args, image: &Path, dir: &Path) -> Result, Refusal } None => None, }; - std::fs::write(dir.join(READBACK_MACHINE), format!("bench_at {at}\n")) - .map_err(|e| Refusal::File { path: dir.join(READBACK_MACHINE).display().to_string(), why: e.to_string() })?; let sent = dir.join("image.update"); std::fs::write(&sent, &update.bytes) @@ -536,6 +532,24 @@ mod tests { assert!(matches!(loader_log(&logs, &[0x5B; 32]), Err(Refusal::NotThisBoot(_))), "an earlier boot's passes"); } + #[test] + fn a_machine_that_never_gives_back_its_log_is_refused_with_the_last_ask() { + let scratch = toyos_tmpdir::TempDir::new("silent"); + let root = scratch.to_path_buf(); + let client = crate::build::ssh_client_host(&root); + std::fs::create_dir_all(client.parent().expect("a directory")).expect("the client's directory"); + std::fs::write(&client, b"").expect("a staged client"); + std::fs::write(root.join("key"), b"").expect("a staged key"); + let log = crate::metaltalk::Peer::Named { host: String::new(), port: 1 }; + let bench = Bench { + ssh: Ssh::at(&root, root.join("key")).expect("a client and a key"), + machine: Machine { log, ssh: None }, + scratch: root.clone(), + }; + let refused = bench.wait_for_the_log(1, "come back", &root.join("log"), "").err().expect("no machine"); + assert!(refused.to_string().contains("the last ask of it: did not resolve"), "{refused}"); + } + /// **A bench under another loader takes no image**: the line its pass /// wrote names the loader it runs, and an image built with another is /// refused before it is delivered — as is a bench whose pass named none. diff --git a/tests/benchcase/system.toml b/tests/benchcase/system.toml index 1b0c30a6348..3b44f05fa9e 100644 --- a/tests/benchcase/system.toml +++ b/tests/benchcase/system.toml @@ -4,7 +4,7 @@ # each judged boot arrives as `update --once`, runs once, and hands the # machine back to this image, whose sshd the loop reads that boot's files # through. `tests/benchvirtiocase` is the same bench in front of QEMU's -# virtio-net; the two differ by the one `devices` row. +# virtio-net. # # `logd` serves no stream: the stream a talking or swapping boot serves is # asked for under the machine's own name, and a bench answering it would be @@ -32,12 +32,10 @@ receives = ["netd", "launcher"] [programs.update] slots = true -# `reboot` hands the machine to the boot `update` asked for; `echo` is how the -# loop asks the bench to answer; `date` is its clock. +# `reboot` hands the machine to the boot `update` asked for; `date` is its clock. [programs.toybox] receives = ["power"] [symlinks] -"bin/echo" = "/system/bin/toybox" "bin/reboot" = "/system/bin/toybox" "bin/date" = "/system/bin/toybox" diff --git a/tests/benchvirtiocase/system.toml b/tests/benchvirtiocase/system.toml index 0388ec5e4de..2942d792fb3 100644 --- a/tests/benchvirtiocase/system.toml +++ b/tests/benchvirtiocase/system.toml @@ -28,8 +28,7 @@ receives = ["netd", "launcher"] [programs.update] slots = true -# `reboot` hands the machine to the boot `update` asked for; `echo` is how the -# loop asks the bench to answer; `date` is its clock. +# `reboot` hands the machine to the boot `update` asked for; `date` is its clock. [programs.test-runner] syscap = ["logread"] @@ -37,6 +36,5 @@ syscap = ["logread"] receives = ["power"] [symlinks] -"bin/echo" = "/system/bin/toybox" "bin/reboot" = "/system/bin/toybox" "bin/date" = "/system/bin/toybox" diff --git a/tests/common/bench.rs b/tests/common/bench.rs index 4f4a8f67478..2f6501ddcb2 100644 --- a/tests/common/bench.rs +++ b/tests/common/bench.rs @@ -43,6 +43,9 @@ const SWAPPING: super::metal::Arm = super::metal::Arm { /// The kernel's record of a slot booted once (`kernel/src/main.rs`). const ONCE_RECORD: &str = "boot: slot B, once, as the running system asked; the slot table marks A"; +/// The last line of the `loader-previous.log` staged before the bench boots. +const STALE_CHAIN: &str = "the staged chain's last line\n"; + /// **The exit**: the loop drives the whole bench cycle against a machine /// running ToyOS alone, and a tampered upload is refused before it. pub fn bench_loop_drives_a_toyos_machine( @@ -70,6 +73,15 @@ pub fn bench_loop_drives_a_toyos_machine( )?; let disk = scratch.join("bench.img"); std::fs::write(&disk, bench).map_err(|e| format!("write {}: {e}", disk.display()))?; + // An earlier chain's file, longer than any chain and ending in a line no + // pass writes: a pass that writes over it rather than deleting it first + // leaves that tail in every readback after. + let mut file = std::fs::File::open(&disk).map_err(|e| format!("{}: {e}", disk.display()))?; + let log_guid = toyos_build::image::unique_guid_of(&mut file, toyos_gpt::Guid::MICROSOFT_BASIC)?; + drop(file); + let mut stale = "an earlier chain's line\n".repeat(16 << 10).into_bytes(); + stale.extend_from_slice(STALE_CHAIN.as_bytes()); + toyos_build::image::create_file_on(&disk, log_guid, toyos_build::bootlog::LOADER_PREVIOUS_LOG, &stale)?; let vars = scratch.join("OVMF_VARS.fd"); std::fs::copy(root.join("ovmf/OVMF_VARS-pure-efi.fd"), &vars).map_err(|e| format!("the variable store: {e}"))?; let data = scratch.join("data.img"); @@ -113,6 +125,10 @@ pub fn bench_loop_drives_a_toyos_machine( if clock.status != Some(0) || said.trim().parse::().is_err() { return Err(format!("`date -u +%s` ended {:?} saying {said:?}", clock.status)); } + let other = ssh::ssh_exec(HOST, ssh_port, &runner, "date +%Y")?; + if other.status != Some(2) { + return Err(format!("`date +%Y` ended {:?} saying {:?}, where every other form is refused as 2", other.status, other.stdout_text())); + } eprintln!(" [bench] the bench's clock reads {}", said.trim()); // **The loop**: the library call `toyos-metal` makes, the machine reached @@ -169,6 +185,9 @@ pub fn bench_loop_drives_a_toyos_machine( return Err(format!("the boot's loader passes never say {owed:?}")); } } + if loader.contains(STALE_CHAIN) { + return Err(format!("the boot's loader passes carry {STALE_CHAIN:?}, the tail of a file staged before the bench's first pass")); + } let raised = format!("Anti-rollback floor: {}, raised", update.version); if loader.contains(&raised) { return Err(format!("a boot of slot B once raised the floor: {raised:?}")); diff --git a/tests/common/metal.rs b/tests/common/metal.rs index c2c3644782e..fd0a20bbbf3 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -570,6 +570,21 @@ struct Batch { } impl Batch { + /// The boot `arm` rides, with no job on it yet. + fn of(arm: &Arm) -> Self { + Batch { + config: arm.config, + params: arm.params.to_vec(), + features: arm.features, + jobs: Vec::new(), + files: Vec::new(), + links: Vec::new(), + nic: arm.nic, + talk: arm.talk, + swap: arm.swap, + } + } + fn add(&mut self, jobs: impl IntoIterator) { for job in jobs { if !self.jobs.contains(&job) { @@ -623,17 +638,7 @@ fn batches( for (name, decl) in tests { let Metal::Runs { arms, .. } = decl else { continue }; for arm in *arms { - let batch = out.entry(arm.boot.to_string()).or_insert_with(|| Batch { - config: arm.config, - params: arm.params.to_vec(), - features: arm.features, - jobs: Vec::new(), - files: Vec::new(), - links: Vec::new(), - nic: arm.nic, - talk: arm.talk, - swap: arm.swap, - }); + let batch = out.entry(arm.boot.to_string()).or_insert_with(|| Batch::of(arm)); if batch.config != arm.config || batch.params != arm.params || batch.features != arm.features @@ -835,17 +840,8 @@ fn fingerprint(text: &str) -> u64 { /// swapping boot authorizes. pub fn stage(dir: &Path, arm: &Arm, rust_bins: &[(String, Vec)]) -> Result<(PathBuf, Option), String> { let root = super::compile::repo_root(); - let batch = Batch { - config: arm.config, - params: arm.params.to_vec(), - features: arm.features, - jobs: arm.jobs.iter().map(|j| (*j).to_string()).collect(), - files: Vec::new(), - links: Vec::new(), - nic: arm.nic, - talk: arm.talk, - swap: arm.swap, - }; + let mut batch = Batch::of(arm); + batch.add(arm.jobs.iter().map(|j| (*j).to_string())); let image = build(&root, dir, arm.boot, &batch, rust_bins, &[], true)?; let key = (arm.talk || arm.swap.is_some()).then(|| talk_home(&at(dir, arm.boot)).join("id_ed25519")); Ok((image, key)) diff --git a/tests/common/update.rs b/tests/common/update.rs index 8715958fc9e..3ce91981d25 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -723,6 +723,9 @@ pub fn update_floor_is_the_images_own(_: &Path, _: &[(String, Vec)], _: &[(S /// The `BootNext` a request asks for: the one line the loader writes it on. const BOOT_NEXT_SET: &str = "(written now): the firmware boots it once, at the reset this pass ends with"; +/// What a pass says of a request it could not take off a read-only stick. +const STANDS: &str = "Request: a boot of another ESP stands, and is not acted on, because taking it off the slot table failed"; + /// **A request for another ESP boots that ESP once, and the order resumes**: /// the machine asks for its recovery stick with `update --boot-next`; the pass /// takes the request off the slot table, writes an entry for @@ -747,12 +750,24 @@ pub fn update_boot_next_boots_the_entry_once(_: &Path, _: &[(String, Vec)], stick_readonly: true, firmware_vars: Some(rig.vars.clone()), recovery_stick: rig.recovery.clone(), - ready_marker: "Request: a boot of another ESP stands, and is not acted on, because taking it off the slot table failed", + ready_marker: STANDS, ..Default::default() }; - drop(QemuInstance::boot_with_options(&super::compile::repo_root().join(CONFIG), &[], &[], unwritable)); + let pass = QemuInstance::boot_with_options(&super::compile::repo_root().join(CONFIG), &[], &[], unwritable); + let said = pass.boot_log().to_string(); + drop(pass); + let upto = &said[..said.find(STANDS).ok_or("the pass never said its request stands")?]; + if upto.contains("BootNext=Boot") { + return Err(format!("a pass that could not take the request off the slot table set BootNext:\n{upto}")); + } + // Past the marker the pass goes on and may point `BootNext` at its own + // entry, and never at the recovery stick's. if let Ok(next) = vars::global(&rig.vars, "BootNext") { - return Err(format!("a pass that could not take the request off the slot table set BootNext {next:02x?}")); + let number = u16::from_le_bytes(next.get(..2).ok_or("a BootNext shorter than a number")?.try_into().expect("two bytes")); + let option = vars::global(&rig.vars, &format!("Boot{number:04X}"))?; + if vars::load_option(&option).is_ok_and(|(guid, _)| guid == esp.0) { + return Err(format!("a pass that could not take the request off the slot table set BootNext to Boot{number:04X}, the recovery stick's")); + } } eprintln!(" [update] a pass that could not write the slot table set no BootNext"); @@ -900,9 +915,22 @@ pub fn update_boot_first_puts_the_loader_first(_: &Path, _: &[(String, Vec)] /// **A machine whose every slot is refused boots its recovery stick**: slot /// A's kernel carries a flipped byte and slot B holds no image, so no slot /// verifies; the loader sets `BootNext` to the entry after its own in -/// `BootOrder` and resets, and the recovery stick behind it boots. +/// `BootOrder` and resets, and the recovery stick behind it boots — past an +/// entry for the machine's own ESP, planted right behind the entry that boots +/// it, which would boot the same failure again. pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { let rig = Rig::stage("update-recovery")?.with_recovery()?; + // The firmware's own entries, which its first boot writes. + drop(rig.launch("Slots: the table marks")); + rig.powered_off_cleanly()?; + let order = vars::global(&rig.vars, "BootOrder")?; + let order: Vec = order.chunks(2).map(|w| u16::from_le_bytes([w[0], w[1]])).collect(); + let planted = (0x100u16..).find(|n| vars::global(&rig.vars, &format!("Boot{n:04X}")).is_err()).expect("a free number"); + vars::plant_global(&rig.vars, &format!("Boot{planted:04X}"), &own_esp_option(&rig.image)?)?; + let mut planted_order = order.clone(); + planted_order.insert(1, planted); + vars::plant_global(&rig.vars, "BootOrder", &planted_order.iter().flat_map(|n| n.to_le_bytes()).collect::>())?; + let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; let a = image::slot_table_of(&mut file)?.slot(Which::A).ok_or("no slot A")?; let mut kernel = image::read_file_on(&mut file, a.boot, toyos_update::slots::KERNEL_FILE)?; @@ -917,13 +945,52 @@ pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)] fell.boot_log(), &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slots: no slot verifies", "this pass failed, so BootNext=Boot"], )?; - let line = fell.boot_log().lines().find(|l| l.contains("this pass failed")).unwrap_or_default().to_string(); - eprintln!(" [update] {line}; the recovery stick's loader took the machine"); + let state = fell.boot_log().lines().find(|l| l.contains("this pass was booted as")).unwrap_or_default().to_string(); + let current = order[0]; + if !state.contains(&format!("booted as Boot{current:04X}; BootOrder is {current:04X},{planted:04X},")) { + return Err(format!("the pass was not booted by Boot{current:04X} with the planted Boot{planted:04X} behind it: {state:?}")); + } + let falls: Vec<&str> = fell.boot_log().lines().filter(|l| l.contains("this pass failed")).collect(); + let recovery = planted_order.get(2).ok_or("the firmware's order holds no entry behind its first")?; + let owed = format!("BootNext=Boot{recovery:04X}, the entry after Boot{current:04X}"); + if falls.len() != 1 || !falls[0].contains(&owed) { + return Err(format!("the pass fell by {falls:?}, where once, past Boot{planted:04X} to the recovery stick's entry ({owed:?}), is owed")); + } + eprintln!(" [update] {}; the recovery stick's loader took the machine", falls[0]); drop(fell); let _ = std::fs::remove_dir_all(&rig.scratch); Ok(()) } +/// An active `EFI_LOAD_OPTION` for `HD()/ +/// \EFI\BOOT\BOOTX64.EFI`, its bytes written from UEFI 2.10 §3.1.3 and §10.3.6 +/// and the partition read out of the GPT entry array by §5.3.3's layout. +fn own_esp_option(path: &Path) -> Result, String> { + let guid = Rig::guid_on(path, toyos_gpt::Guid::EFI_SYSTEM)?; + let mut disk = vec![0u8; 64 << 10]; + std::fs::File::open(path).and_then(|mut f| std::io::Read::read_exact(&mut f, &mut disk)).map_err(|e| format!("{}: {e}", path.display()))?; + let word = |at: usize, n: usize| disk[at..at + n].iter().rev().fold(0u64, |v, &b| v << 8 | u64::from(b)); + let (array, count, size) = (word(512 + 72, 8) as usize * 512, word(512 + 80, 4) as usize, word(512 + 84, 4) as usize); + let index = (0..count).find(|i| disk[array + i * size + 16..array + i * size + 32] == guid).ok_or("no GPT entry names the ESP")?; + let (first, last) = (word(array + index * size + 32, 8), word(array + index * size + 40, 8)); + let mut node = vec![4, 1, 42, 0]; + node.extend((index as u32 + 1).to_le_bytes()); + node.extend(first.to_le_bytes()); + node.extend((last - first + 1).to_le_bytes()); + node.extend(guid); + node.extend([2, 2]); + let file: Vec = r"\EFI\BOOT\BOOTX64.EFI".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect(); + node.extend([4, 4]); + node.extend((4 + file.len() as u16).to_le_bytes()); + node.extend(file); + node.extend([0x7f, 0xff, 4, 0]); + let mut option = 1u32.to_le_bytes().to_vec(); + option.extend((node.len() as u16).to_le_bytes()); + option.extend("Planted".encode_utf16().chain([0]).flat_map(u16::to_le_bytes)); + option.extend(node); + Ok(option) +} + /// The firmware's variable store, as OVMF keeps it in its `VARS` file: a /// firmware volume holding an authenticated variable store, read and written /// by the layout EDK2 declares for it (`MdeModulePkg/Include/Guid/ @@ -949,6 +1016,10 @@ mod vars { /// `VAR_ADDED & VAR_IN_DELETED_TRANSITION`: still the variable until the /// copy replacing it is added. const IN_TRANSITION: u8 = 0x3E; + /// What a retired copy's state is ANDed with. + const VAR_DELETED: u8 = 0xFD; + /// `EFI_VARIABLE_NON_VOLATILE | BOOTSERVICE_ACCESS | RUNTIME_ACCESS`. + const NV_BS_RT: u32 = 0x7; pub struct Var { pub name: String, @@ -968,6 +1039,8 @@ mod vars { /// One variable header in the store. struct Found { + /// Where its header begins. + at: usize, state: u8, vendor: [u8; 16], var: Var, @@ -989,7 +1062,7 @@ mod vars { .take_while(|&u| u != 0) .collect(); let data = bytes[name_at + name_len..name_at + name_len + data_len].to_vec(); - out.push(Found { state: bytes[at + 2], vendor, var: Var { name: String::from_utf16_lossy(&units), data } }); + out.push(Found { at, state: bytes[at + 2], vendor, var: Var { name: String::from_utf16_lossy(&units), data } }); at = (name_at + name_len + data_len).next_multiple_of(4); } Ok((out, at)) @@ -1058,6 +1131,22 @@ mod vars { /// Add `name` under the floor's vendor with `attributes` and `data`, as /// the firmware would have added it. pub fn plant(path: &Path, name: &str, attributes: u32, data: &[u8]) -> Result<(), String> { + add(path, VENDOR, name, attributes, data) + } + + /// Make the global variable `name` hold `data`, non-volatile and readable + /// at boot and at runtime, as the firmware rewrites one: its live copy + /// retired, and the new one added. + pub fn plant_global(path: &Path, name: &str, data: &[u8]) -> Result<(), String> { + let mut bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + for found in walk(&bytes)?.0.iter().filter(|f| f.vendor == GLOBAL && f.state == VAR_ADDED && f.var.name == name) { + bytes[found.at + 2] &= VAR_DELETED; + } + std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))?; + add(path, GLOBAL, name, NV_BS_RT, data) + } + + fn add(path: &Path, vendor: [u8; 16], name: &str, attributes: u32, data: &[u8]) -> Result<(), String> { let mut bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; let (_, end) = store(&bytes)?; let (_, at) = walk(&bytes)?; @@ -1068,7 +1157,7 @@ mod vars { var[4..8].copy_from_slice(&attributes.to_le_bytes()); var[36..40].copy_from_slice(&(units.len() as u32).to_le_bytes()); var[40..44].copy_from_slice(&(data.len() as u32).to_le_bytes()); - var[44..60].copy_from_slice(&VENDOR); + var[44..60].copy_from_slice(&vendor); var.append(&mut units); var.extend_from_slice(data); if at + var.len() > end || bytes[at..at + var.len()].iter().any(|&b| b != 0xFF) { diff --git a/toyos-update/src/policy.rs b/toyos-update/src/policy.rs index e972833cf90..c28f4322823 100644 --- a/toyos-update/src/policy.rs +++ b/toyos-update/src/policy.rs @@ -99,6 +99,25 @@ pub fn order(table: &Table, once: Option) -> [Option; 2] { [Some(first), table.slot(other).map(|_| other)] } +/// What the kernel is told of `chosen`, whichever of a pass's tries chose it: +/// the slot the table marks where `chosen` is the trial asked for `once` +/// (never the marked slot), and `refused` where `chosen` is neither that +/// trial nor the marked slot. A refused trial leaves the marked slot an +/// ordinary boot. +pub fn told( + marked: Which, + once: Option, + chosen: Which, + refused: Option<(Which, Refusal)>, +) -> (Option, Option<(Which, Refusal)>) { + match once { + Some(trial) if chosen == trial => (Some(marked), None), + Some(_) => (None, None), + None if chosen != marked => (None, refused), + None => (None, None), + } +} + /// The floor after a boot proved `proven`: it only rises. pub fn raised(floor: u64, proven: u64) -> u64 { floor.max(proven) @@ -158,6 +177,20 @@ mod tests { assert_eq!(order(&one, Some(Which::B)), [Some(Which::A), None], "a slot the table does not carry is none to try"); } + /// **A trial is told as once from whichever try booted it**: the marked + /// slot's name goes with it, also where both slots died and the second + /// try, the one that boots the dead, chose it. + #[test] + fn a_trial_is_told_once_and_a_fall_back_is_told_its_refusal() { + let died = Some((Which::B, Refusal::Died)); + assert_eq!(told(Which::A, Some(Which::B), Which::B, died), (Some(Which::A), None), "the dead trial"); + assert_eq!(told(Which::A, Some(Which::B), Which::B, None), (Some(Which::A), None)); + assert_eq!(told(Which::A, Some(Which::B), Which::A, died), (None, None), "a refused trial"); + let refused = Some((Which::A, Refusal::Signature)); + assert_eq!(told(Which::A, None, Which::B, refused), (None, refused), "the fall back"); + assert_eq!(told(Which::A, None, Which::A, None), (None, None)); + } + /// Each word is one token of a comma-separated boot parameter. #[test] fn every_word_is_one_parameter_token() { diff --git a/toyos-update/src/slots.rs b/toyos-update/src/slots.rs index 687ad874019..5b0d175d4ad 100644 --- a/toyos-update/src/slots.rs +++ b/toyos-update/src/slots.rs @@ -130,6 +130,15 @@ impl Request { pub const fn is_empty(&self) -> bool { self.next.is_none() && !self.first } + + /// This request with a boot of the ESP `guid` once, or the slot whose + /// once it would drop: an install's trial is never replaced unbooted. + pub fn boot_next(self, guid: [u8; 16]) -> Result { + match self.next { + Some(Next::Slot(which) | Next::Trial(which)) => Err(which), + Some(Next::Esp(_)) | None => Ok(Self { next: Some(Next::Esp(guid)), ..self }), + } + } } /// One copy of the table. @@ -484,6 +493,17 @@ mod tests { assert_eq!(Table::decode(&resealed(names_absent)), refused("names a slot the table does not carry")); } + #[test] + fn a_boot_next_replaces_an_esp_and_never_a_slot_asked_once() { + let esp = |guid| Some(Next::Esp(guid)); + let asked = Request { next: esp([1; 16]), first: true }; + assert_eq!(asked.boot_next([2; 16]), Ok(Request { next: esp([2; 16]), first: true })); + assert_eq!(Request::NONE.boot_next([2; 16]), Ok(Request { next: esp([2; 16]), first: false })); + for (next, which) in [(Next::Slot(Which::B), Which::B), (Next::Trial(Which::A), Which::A)] { + assert_eq!(Request { next: Some(next), first: false }.boot_next([2; 16]), Err(which)); + } + } + /// The check value every CRC-32 is held to. #[test] fn the_checksum_is_crc32() { diff --git a/userland/toybox/src/date.rs b/userland/toybox/src/date.rs index 4acffd04e18..a3a1d60baef 100644 --- a/userland/toybox/src/date.rs +++ b/userland/toybox/src/date.rs @@ -1,7 +1,13 @@ //! `date -u +%s`: the machine's clock as Unix seconds, which is what a host //! reads to place this machine's log lines against its own clock. -pub fn main(_: Vec) { +pub fn main(args: Vec) { + // Any other form would want `strftime`, and is refused rather than + // answered in a format nobody asked for. + if args != ["-u", "+%s"] { + eprintln!("date: only `date -u +%s` is here, and {args:?} is not it"); + std::process::exit(2); + } match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { Ok(since) => println!("{}", since.as_secs()), Err(e) => { diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index 5a5a64c4e6d..6eafe295367 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -117,7 +117,9 @@ fn run(asked: Asked, began: Instant) -> Result { )); } Asked::BootNext(guid) => { - let request = slots::Request { next: Some(Next::Esp(guid)), ..table.request }; + let request = table.request.boot_next(guid).map_err(|which| { + format!("slot {} is asked for once, and --boot-next would drop that boot unmade", which.letter()) + })?; write_table(&table_claim, (table, current), Table { request, ..table })?; return Ok(format!( "update: the loader boots EFI system partition {} once, at its next pass, and the order after it", From 70b81c8b4cfbc1d93cdd35d01e88b646164775ff Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 18:52:09 +0200 Subject: [PATCH 12/26] Review #539 round 3: one HARDDRIVE rule, over the device path's bytes The loader's `bootvars::hard_drive` took exactly one HARDDRIVE node off a uefi `DevicePath`, and `entry::hard_drive_guid` took the first one off an option's bytes for `names`, `naming` and `after`: two rules for which partition a path names, and no test could fail on their disagreeing. `entry::partition` is the one: a walk of a device path's bytes to its end node, refusing a path with no HARDDRIVE node, with two, or with one that is not a 42-byte GPT node signed by GUID, and returning the `Partition` it names. `boot_partition`, `our_partition` and `bootvars::esp` call it on `DevicePath::as_bytes`; `names` calls it on an option's path. The uefi walker and `hard_drive_guid` are deleted. Mutation, taking the first node without looking for a second: `cargo test -p toyos-update` exits 101 (`a_path_names_the_partition_of_its_one_hard_drive_node`); with the fix, exit 0. Co-Authored-By: Claude Opus 5.5 --- bootloader/src/bootnext.rs | 2 +- bootloader/src/bootvars.rs | 29 ++--------------- bootloader/src/main.rs | 4 +-- toyos-update/src/entry.rs | 66 +++++++++++++++++++++++++++----------- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/bootloader/src/bootnext.rs b/bootloader/src/bootnext.rs index 1da43e606c5..64ec25afb4c 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -57,5 +57,5 @@ pub(crate) fn our_partition(handle: Handle, system_table: &SystemTable) -> let image = bs.open_protocol_exclusive::(handle).ok()?; let device = image.device()?; let path = bs.open_protocol_exclusive::(device).ok()?; - bootvars::hard_drive(&path).ok().map(|(_, guid)| guid) + toyos_update::entry::partition(path.as_bytes()).ok().map(|part| part.guid) } diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs index 475510ed7c4..2347e1f1af9 100644 --- a/bootloader/src/bootvars.rs +++ b/bootloader/src/bootvars.rs @@ -18,8 +18,7 @@ use alloc::string::String; use alloc::vec::Vec; use toyos_update::entry::{self, Partition}; use uefi::prelude::*; -use uefi::proto::device_path::media::{HardDrive, PartitionFormat, PartitionSignature}; -use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType}; +use uefi::proto::device_path::DevicePath; use uefi::proto::media::file::{File, FileAttribute, FileMode}; use uefi::proto::media::fs::SimpleFileSystem; use uefi::proto::media::partition::{GptPartitionType, PartitionInfo}; @@ -54,27 +53,6 @@ pub struct Esp { removable: Result<(), String>, } -/// The HARDDRIVE node by which `path` names a GPT partition, and that -/// partition's unique GUID; or why it names none. Exactly one node: a path -/// with two describes a partition inside a partition, and taking either is -/// guessing which one the path means. -pub fn hard_drive(path: &DevicePath) -> Result<(&HardDrive, [u8; 16]), &'static str> { - let mut nodes = - path.node_iter().filter(|node| node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE)); - let node = nodes.next().ok_or("the device path carries no HARDDRIVE node")?; - if nodes.next().is_some() { - return Err("the device path has more than one HARDDRIVE node"); - } - let hd = <&HardDrive>::try_from(node).map_err(|_| "the HARDDRIVE node is malformed")?; - if hd.partition_format() != PartitionFormat::GPT { - return Err("firmware says this is not a GPT partition"); - } - let PartitionSignature::Guid(guid) = hd.partition_signature() else { - return Err("firmware named the partition with no GUID signature"); - }; - Ok((hd, guid.to_bytes())) -} - /// The EFI system partition `guid` names, or which of those it is not. pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { let handle = crate::loaderlog::volume_handle(bs, guid)?; @@ -86,11 +64,10 @@ pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { } let path = crate::rootimage::try_get_protocol::(bs, handle) .map_err(|e| alloc::format!("the partition's device path ({e:?})"))?; - let (hd, signature) = hard_drive(&path).map_err(|why| alloc::format!("the partition: {why}"))?; - if signature != *guid { + let part = entry::partition(path.as_bytes()).map_err(|why| alloc::format!("the partition: {why}"))?; + if part.guid != *guid { return Err(String::from("the partition's HARDDRIVE node names another partition")); } - let part = Partition { number: hd.partition_number(), start: hd.partition_start(), size: hd.partition_size(), guid: *guid }; drop(path); drop(info); // Not exclusive: this is a look at a volume firmware may be serving to diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index dfac91a1c8a..5b482c11019 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -227,8 +227,8 @@ fn boot_partition(handle: Handle, system_table: &SystemTable) -> Option(handle).ok()?; let device = image.device()?; let path = bs.open_protocol_exclusive::(device).ok()?; - match bootvars::hard_drive(&path) { - Ok((hd, guid)) => Some(BootPartition { guid, start_lba: hd.partition_start(), blocks: hd.partition_size() }), + match toyos_update::entry::partition(path.as_bytes()) { + Ok(part) => Some(BootPartition { guid: part.guid, start_lba: part.start, blocks: part.size }), Err(why) => { println!("Boot partition: {why}, so it is ignored"); None diff --git a/toyos-update/src/entry.rs b/toyos-update/src/entry.rs index e05cf83621c..429e9c8789b 100644 --- a/toyos-update/src/entry.rs +++ b/toyos-update/src/entry.rs @@ -115,32 +115,45 @@ fn device_path(option: &[u8]) -> Option<&[u8]> { option.get(at..at.checked_add(path_len)?) } -/// The GPT signature of the first HARDDRIVE node in a device path, or `None` -/// where it has none, names an MBR partition, or a node cannot be stepped -/// over. -fn hard_drive_guid(mut path: &[u8]) -> Option<[u8; 16]> { - while let Some(node) = path.get(..NODE_HEADER) { +/// The GPT partition a device path names by its one HARDDRIVE node, or why it +/// names none. Exactly one node: a path with two describes a partition inside +/// a partition, and taking either is guessing which one the path means. +pub fn partition(mut path: &[u8]) -> Result { + let mut found = None; + loop { + let node = path.get(..NODE_HEADER).ok_or("the device path runs off with no end node")?; let len = usize::from(u16::from_le_bytes([node[2], node[3]])); - // A node shorter than its own header, or longer than what is left, ends - // the walk: neither can be stepped over. - let this = path.get(..len).filter(|_| len >= NODE_HEADER)?; - if (this[0], this[1]) == END { - return None; - } - if (this[0], this[1]) == HARD_DRIVE { - if this.get(40) != Some(&GPT) || this.get(41) != Some(&SIGNATURE_GUID) { - return None; - } - return this.get(24..40)?.try_into().ok(); + let this = path.get(..len).filter(|_| len >= NODE_HEADER).ok_or("a device-path node cannot be stepped over")?; + match (this[0], this[1]) { + END => break, + HARD_DRIVE if found.is_some() => return Err("the device path has more than one HARDDRIVE node"), + HARD_DRIVE => found = Some(this), + _ => {} } path = &path[len..]; } - None + let hd = found.ok_or("the device path carries no HARDDRIVE node")?; + if hd.len() != HARD_DRIVE_BYTES { + return Err("the HARDDRIVE node is malformed"); + } + if hd[40] != GPT { + return Err("the HARDDRIVE node names no GPT partition"); + } + if hd[41] != SIGNATURE_GUID { + return Err("the HARDDRIVE node names the partition with no GUID signature"); + } + let field = |at: usize| u64::from_le_bytes(hd[at..at + 8].try_into().expect("eight bytes")); + Ok(Partition { + number: u32::from_le_bytes(hd[4..8].try_into().expect("four bytes")), + start: field(8), + size: field(16), + guid: hd[24..40].try_into().expect("sixteen bytes"), + }) } /// Whether an option boots off the GPT partition `guid`. fn names(option: &[u8], guid: &[u8; 16]) -> bool { - device_path(option).and_then(hard_drive_guid).is_some_and(|found| found == *guid) + device_path(option).is_some_and(|path| partition(path).is_ok_and(|found| found.guid == *guid)) } /// Whether an option is active: the boot manager skips one that is not. @@ -274,6 +287,23 @@ mod tests { assert!(!names(&unterminated, &[0xE5; 16]), "a description with no end"); } + /// **One HARDDRIVE node, or no partition**: a second one, before or after + /// the first, is refused rather than either taken. + #[test] + fn a_path_names_the_partition_of_its_one_hard_drive_node() { + let option = by_hand(); + let path = device_path(&option).expect("the option's path"); + assert_eq!(partition(path), Ok(PART)); + let (hd, rest) = path.split_at(HARD_DRIVE_BYTES); + let mut other = hd.to_vec(); + other[24..40].copy_from_slice(&[0xE6; 16]); + for twice in [[&other[..], path].concat(), [hd, &other[..], rest].concat()] { + assert_eq!(partition(&twice), Err("the device path has more than one HARDDRIVE node")); + } + assert_eq!(partition(rest), Err("the device path carries no HARDDRIVE node")); + assert_eq!(partition(&path[..path.len() - NODE_HEADER]), Err("the device path runs off with no end node")); + } + #[test] fn an_esp_is_booted_by_its_lowest_active_entry() { let held = [(2, option(0xE5, false)), (5, option(0xE5, true)), (7, option(0xE5, true)), (1, option(0xE6, true))]; From 5cd138503c5022ac48c81195bbd7549a47637ab2 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 18:58:24 +0200 Subject: [PATCH 13/26] Review #539 round 3: the NOTEs, and the deletions the review owed - `--resident` beside `--fat32-check` is refused, as beside `--readback` and `--nic`: the resident path never ran the check, so the run could come back green with the outside judge never asked. `--resident` joins `ABOUT_A_BOOT`, so `--install-sudoers` beside it is refused rather than installing the rule and dropping it. The resident refusal's `out.talk.is_some()` is gone: `--talk` without `--readback` is refused before it. - `FAT32_CHECK` and `INSTALL_SUDOERS` leave `NOT_A_SWAP`: `UBUNTUS` and the `--via-ubuntu`/`--swap` refusal already refuse both beside a swap. The swap test asserts `--fat32-check` beside it meets the `UBUNTUS` refusal. - `Bench::answers` returns the probe's `Result`, and `Bench::wait` carries the last one into `Refusal::Silent`, so "answer as the delivered boot" and "go down" end with a reason. - One FAT volume round-trip: `image::put_files_on` reads the volume, replaces or removes each named file, syncs and writes it back. `stage_slot` and the bench test use it; `create_file_on` is deleted. - The bench test takes its command line from `metal::invocation` for `Reach::Bench`, the line `request.txt` carries, and `metal::stage` no longer hands back the key that line names. Mutations, each a checked patch, built, restored: - `RESIDENT` out of `ABOUT_A_BOOT`: `cargo test --lib installing_the_rule_is_not_also_a_boot` exits 101. - `out.fat32_check` out of the resident refusal: `cargo test --lib the_ubuntu_path_is_named_and_nothing_falls_back_to_it` exits 101. - `Bench::wait` refusing with `last: None`: `cargo test --lib a_machine_that_never_answers_is_refused_with_the_last_ask` exits 101. Co-Authored-By: Claude Opus 5.5 --- src/image.rs | 73 +++++++++++++++++-------------------------- src/metal.rs | 17 ++++++---- src/metalbench.rs | 22 ++++++++----- tests/common/bench.rs | 42 +++++++++---------------- tests/common/metal.rs | 15 ++++----- 5 files changed, 75 insertions(+), 94 deletions(-) diff --git a/src/image.rs b/src/image.rs index e02320d4ae6..cb01e197ec2 100644 --- a/src/image.rs +++ b/src/image.rs @@ -473,37 +473,17 @@ pub fn stage_slot(path: &Path, which: toyos_update::slots::Which, update: &[u8], .and_then(|_| file.write_all(parts.root)) .map_err(|e| format!("writing slot {}'s ROOT: {e}", which.letter()))?; - let (boot_at, boot_len) = partition_extent(&mut file, slot.boot)?; - let mut volume = vec![0u8; boot_len as usize]; - file.seek(SeekFrom::Start(boot_at)) - .and_then(|_| file.read_exact(&mut volume)) - .map_err(|e| format!("reading slot {}'s volume: {e}", which.letter()))?; - { - let time = build_time(); - let mut fs = Fat32::mount(VolumeIo(&mut volume)).map_err(|e| format!("slot {}'s volume: {e}", which.letter()))?; - fs.create_dir_all("toyos", time).map_err(|e| format!("toyos/: {e}"))?; - let mut files: Vec<(&str, &[u8])> = vec![ - (toyos_update::slots::KERNEL_FILE, parts.kernel), - (toyos_update::slots::CMDLINE_FILE, parts.cmdline), - ]; - if signed { - files.push((toyos_update::slots::SIGNED_FILE, &parts.signed[..])); - } - for name in [toyos_update::slots::KERNEL_FILE, toyos_update::slots::CMDLINE_FILE, toyos_update::slots::SIGNED_FILE] { - if fs.exists(name).map_err(|e| format!("{name}: {e}"))? { - fs.remove(name).map_err(|e| format!("removing {name}: {e}"))?; - } - } - for (name, bytes) in files { - let mut f = fs.create(name, time).map_err(|e| format!("creating {name}: {e}"))?; - fs.write(&mut f, 0, bytes).map_err(|e| format!("writing {name}: {e}"))?; - fs.flush_meta(&mut f, time).map_err(|e| format!("recording {name}: {e}"))?; - } - fs.sync().map_err(|e| format!("syncing slot {}'s volume: {e}", which.letter()))?; - } - file.seek(SeekFrom::Start(boot_at)) - .and_then(|_| file.write_all(&volume)) - .map_err(|e| format!("writing slot {}'s volume: {e}", which.letter()))?; + let signed = signed.then_some(&parts.signed[..]); + put_files_on( + &mut file, + slot.boot, + &[ + (toyos_update::slots::KERNEL_FILE, Some(parts.kernel)), + (toyos_update::slots::CMDLINE_FILE, Some(parts.cmdline)), + (toyos_update::slots::SIGNED_FILE, signed), + ], + ) + .map_err(|why| format!("slot {}'s volume: {why}", which.letter()))?; let mut next = table; next.marked = which; @@ -585,16 +565,12 @@ pub fn overwrite_file_on(path: &Path, guid: [u8; 16], name: &str, bytes: &[u8]) file.sync_all().map_err(|e| format!("syncing {}: {e}", path.display())) } -/// Create the file `name` holding `bytes` on the FAT partition `guid` of the -/// disk image at `path`, which no guest may be running on. -pub fn create_file_on(path: &Path, guid: [u8; 16], name: &str, bytes: &[u8]) -> Result<(), String> { +/// Replace each file `files` names on the FAT partition `guid` of the disk +/// image `file`, which no guest may be running on: a file named with no bytes +/// is removed. +pub fn put_files_on(file: &mut std::fs::File, guid: [u8; 16], files: &[(&str, Option<&[u8]>)]) -> Result<(), String> { use std::io::Write; - let mut file = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(path) - .map_err(|e| format!("open {}: {e}", path.display()))?; - let (start, len) = partition_extent(&mut file, guid)?; + let (start, len) = partition_extent(file, guid)?; let mut volume = vec![0u8; usize::try_from(len).map_err(|_| format!("a {len}-byte volume"))?]; file.seek(SeekFrom::Start(start)) .and_then(|_| file.read_exact(&mut volume)) @@ -602,15 +578,24 @@ pub fn create_file_on(path: &Path, guid: [u8; 16], name: &str, bytes: &[u8]) -> { let time = build_time(); let mut fs = Fat32::mount(VolumeIo(&mut volume)).map_err(|e| format!("the volume does not mount: {e}"))?; - let mut created = fs.create(name, time).map_err(|e| format!("creating {name}: {e}"))?; - fs.write(&mut created, 0, bytes).map_err(|e| format!("writing {name}: {e}"))?; - fs.flush_meta(&mut created, time).map_err(|e| format!("recording {name}: {e}"))?; + for &(name, bytes) in files { + if fs.exists(name).map_err(|e| format!("{name}: {e}"))? { + fs.remove(name).map_err(|e| format!("removing {name}: {e}"))?; + } + let Some(bytes) = bytes else { continue }; + if let Some((dir, _)) = name.rsplit_once('/') { + fs.create_dir_all(dir, time).map_err(|e| format!("{dir}/: {e}"))?; + } + let mut created = fs.create(name, time).map_err(|e| format!("creating {name}: {e}"))?; + fs.write(&mut created, 0, bytes).map_err(|e| format!("writing {name}: {e}"))?; + fs.flush_meta(&mut created, time).map_err(|e| format!("recording {name}: {e}"))?; + } fs.sync().map_err(|e| format!("syncing the volume: {e}"))?; } file.seek(SeekFrom::Start(start)) .and_then(|_| file.write_all(&volume)) .and_then(|_| file.sync_all()) - .map_err(|e| format!("writing the volume back to {}: {e}", path.display())) + .map_err(|e| format!("writing the volume back: {e}")) } /// The slot table on the disk image `file`, which copy is current, and where diff --git a/src/metal.rs b/src/metal.rs index 5bf4c6afc15..a4e62cf48e9 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -1574,12 +1574,12 @@ const UBUNTUS: &[&Flag] = &[&INSTALL_SUDOERS, &FAT32_CHECK, &DEVICE, &HOST, &RES /// the bench a swapping boot is delivered and judged like any other, and the /// swap is made by the same loop once the bench has gone down /// ([`crate::metalbench::run`]). -const NOT_A_SWAP: &[&Flag] = &[&DRY_RUN, &FAT32_CHECK, &NIC, &INSTALL_SUDOERS]; +const NOT_A_SWAP: &[&Flag] = &[&DRY_RUN, &NIC]; /// The flags that describe a boot, as against the ones that say which machine /// to reach: [`Args::parse`] refuses an `--install-sudoers` beside any of them. const ABOUT_A_BOOT: &[&Flag] = - &[&DRY_RUN, &IMAGE, &READBACK, &FAT32_CHECK, &NIC, &WAIT_SECS, &TALK]; + &[&DRY_RUN, &IMAGE, &READBACK, &FAT32_CHECK, &NIC, &WAIT_SECS, &TALK, &RESIDENT]; /// What the binary was asked to do. #[derive(Debug, Clone, PartialEq, Eq)] @@ -1791,10 +1791,10 @@ impl Args { .to_string(), )); } - if out.resident && (out.readback.is_some() || out.talk.is_some() || out.nic.is_some()) { + if out.resident && (out.readback.is_some() || out.nic.is_some() || out.fat32_check) { return Err(Refusal::Usage( "--resident hands the machine to a bench image and judges no boot, so --readback, \ - --talk and --nic describe a boot it will not judge" + --nic and --fat32-check describe a boot it will not judge" .to_string(), )); } @@ -2757,6 +2757,7 @@ mod tests { vec!["--wait-secs", "60"], vec!["--nic", "0000:00:1f.6"], vec!["--talk", "/tmp/k"], + vec!["--resident"], ] { let mut words = vec!["--via-ubuntu".to_string(), "--install-sudoers".to_string(), "/tmp/pw".to_string()]; words.extend(flag.iter().map(|w| (*w).to_string())); @@ -2798,12 +2799,16 @@ mod tests { let args = Args::parse(&whole).expect("a whole swap"); assert_eq!(args.swap.as_deref(), Some("netd")); - for flag in [vec!["--fat32-check"], vec!["--dry-run"], vec!["--nic", "0000:00:1f.6"]] { + for flag in [vec!["--dry-run"], vec!["--nic", "0000:00:1f.6"]] { let mut words = whole.to_vec(); words.extend(flag.iter().map(|w| (*w).to_string())); let said = Args::parse(&words).unwrap_err().to_string(); assert!(said.contains(flag[0]) && said.contains("will not make"), "{said}"); } + let mut checked = whole.to_vec(); + checked.push("--fat32-check".into()); + let said = Args::parse(&checked).unwrap_err().to_string(); + assert!(said.contains("--fat32-check") && said.contains("--via-ubuntu"), "{said}"); for missing in ["--binary", "--talk", "--readback"] { let at = whole.iter().position(|w| w == missing).unwrap(); let words: Vec = whole @@ -2856,7 +2861,7 @@ mod tests { let words: Vec = swap.iter().chain(image).map(|w| (*w).to_string()).collect(); assert!(Args::parse(&words).unwrap_err().to_string().contains("no Ubuntu half"), "{image:?}"); } - for beside in [vec!["--readback", "/tmp/r"], vec!["--nic", "0000:00:1f.6"]] { + for beside in [vec!["--readback", "/tmp/r"], vec!["--nic", "0000:00:1f.6"], vec!["--fat32-check"]] { let mut words = vec!["--via-ubuntu", "--resident", "--image", "b.img"]; words.extend(beside.iter().copied()); let words: Vec = words.iter().map(|w| (*w).to_string()).collect(); diff --git a/src/metalbench.rs b/src/metalbench.rs index de2efe2eee0..5f9aca37084 100644 --- a/src/metalbench.rs +++ b/src/metalbench.rs @@ -122,10 +122,10 @@ impl Bench { Ok(Logs { dir: into.to_path_buf(), names }) } - /// Whether the machine takes this key now: a machine not up, one booting, - /// and a boot that authorizes another key are all "not yet". - fn answers(&self) -> bool { - self.machine.ssh_at().is_ok_and(|at| self.ssh.probe(at, PROBE_SECS).is_ok()) + /// Whether the machine takes this key now, or why not: a machine not up, + /// one booting, and a boot that authorizes another key are all "not yet". + fn answers(&self) -> Result<(), String> { + self.ssh.probe(self.machine.ssh_at()?, PROBE_SECS) } /// Wait until [`Bench::answers`] is `answering`, within `secs`; how long @@ -133,13 +133,17 @@ impl Bench { /// [`PROBE_SECS`], and the next is asked [`ASK_EVERY`] after it. fn wait(&self, secs: u64, what: &'static str, answering: bool) -> Result { let began = std::time::Instant::now(); + let mut last = None; while began.elapsed().as_secs() < secs { - if self.answers() == answering { - return Ok(began.elapsed().as_secs()); + match self.answers() { + Ok(()) if answering => return Ok(began.elapsed().as_secs()), + Err(_) if !answering => return Ok(began.elapsed().as_secs()), + Ok(()) => last = Some("it still answers".to_string()), + Err(why) => last = Some(why), } std::thread::sleep(ASK_EVERY); } - Err(Refusal::Silent { what, secs, last: None }) + Err(Refusal::Silent { what, secs, last }) } /// Wait until the machine's `/log` comes back over this key, within @@ -533,7 +537,7 @@ mod tests { } #[test] - fn a_machine_that_never_gives_back_its_log_is_refused_with_the_last_ask() { + fn a_machine_that_never_answers_is_refused_with_the_last_ask() { let scratch = toyos_tmpdir::TempDir::new("silent"); let root = scratch.to_path_buf(); let client = crate::build::ssh_client_host(&root); @@ -548,6 +552,8 @@ mod tests { }; let refused = bench.wait_for_the_log(1, "come back", &root.join("log"), "").err().expect("no machine"); assert!(refused.to_string().contains("the last ask of it: did not resolve"), "{refused}"); + let refused = bench.wait(1, "answer as the delivered boot", true).expect_err("no machine"); + assert!(refused.to_string().contains("the last ask of it: did not resolve"), "{refused}"); } /// **A bench under another loader takes no image**: the line its pass diff --git a/tests/common/bench.rs b/tests/common/bench.rs index 2f6501ddcb2..3dd0130e37c 100644 --- a/tests/common/bench.rs +++ b/tests/common/bench.rs @@ -57,8 +57,7 @@ pub fn bench_loop_drives_a_toyos_machine( let scratch = super::lane::dir().join("bench-loop"); std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?; - let (boot, key) = super::metal::stage(&scratch, &SWAPPING, rust_bins)?; - let key = key.ok_or("a swapping boot authorizes a key of its own")?; + let boot = super::metal::stage(&scratch, &SWAPPING, rust_bins)?; let home = boot.parent().ok_or("the staged image has a directory")?.to_path_buf(); let update = toyos_build::image::update_of(&boot)?; @@ -76,12 +75,16 @@ pub fn bench_loop_drives_a_toyos_machine( // An earlier chain's file, longer than any chain and ending in a line no // pass writes: a pass that writes over it rather than deleting it first // leaves that tail in every readback after. - let mut file = std::fs::File::open(&disk).map_err(|e| format!("{}: {e}", disk.display()))?; + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&disk) + .map_err(|e| format!("{}: {e}", disk.display()))?; let log_guid = toyos_build::image::unique_guid_of(&mut file, toyos_gpt::Guid::MICROSOFT_BASIC)?; - drop(file); let mut stale = "an earlier chain's line\n".repeat(16 << 10).into_bytes(); stale.extend_from_slice(STALE_CHAIN.as_bytes()); - toyos_build::image::create_file_on(&disk, log_guid, toyos_build::bootlog::LOADER_PREVIOUS_LOG, &stale)?; + toyos_build::image::put_files_on(&mut file, log_guid, &[(toyos_build::bootlog::LOADER_PREVIOUS_LOG, Some(&stale))])?; + drop(file); let vars = scratch.join("OVMF_VARS.fd"); std::fs::copy(root.join("ovmf/OVMF_VARS-pure-efi.fd"), &vars).map_err(|e| format!("the variable store: {e}"))?; let data = scratch.join("data.img"); @@ -133,25 +136,10 @@ pub fn bench_loop_drives_a_toyos_machine( // **The loop**: the library call `toyos-metal` makes, the machine reached // through the forwards. - let readback = scratch.join("readback"); - let words: Vec = [ - "--image", - &boot.display().to_string(), - "--readback", - &readback.display().to_string(), - "--swap", - "netd", - "--binary", - &home.join("netd").display().to_string(), - "--talk", - &key.display().to_string(), - "--hand-back", - "--key", - &runner.private().display().to_string(), - ] - .iter() - .map(|w| w.to_string()) - .collect(); + let mut words = super::metal::invocation(&boot, &home, SWAPPING.nic, SWAPPING.talk, SWAPPING.swap, super::metal::Reach::Bench); + let cargo = words.iter().position(|w| w == "--").ok_or("the invocation runs toyos-metal after a `--`")?; + words.drain(..=cargo); + words.extend(["--key".to_string(), runner.private().display().to_string()]); let mut args = Args::parse(&words).map_err(|refusal| refusal.to_string())?; args.machine = Machine { log: Peer::At(SocketAddr::from((Ipv4Addr::LOCALHOST, log_port))), @@ -171,11 +159,11 @@ pub fn bench_loop_drives_a_toyos_machine( eprintln!(" [bench] the loop's verdict: Boot: complete in {ms:?} ms"); // What the machine itself says about the cycle. - let kernel = std::fs::read_to_string(readback.join(metal::READBACK_KERNEL)).map_err(|e| format!("kernel.log: {e}"))?; + let kernel = std::fs::read_to_string(home.join(metal::READBACK_KERNEL)).map_err(|e| format!("kernel.log: {e}"))?; if !kernel.contains(ONCE_RECORD) { return Err(format!("the boot's own log never says {ONCE_RECORD:?}")); } - let loader = std::fs::read_to_string(readback.join(metal::READBACK_LOADER)).map_err(|e| format!("loader.log: {e}"))?; + let loader = std::fs::read_to_string(home.join(metal::READBACK_LOADER)).map_err(|e| format!("loader.log: {e}"))?; for owed in [ "Slot B: asked for once; the table marks A", "Request: a boot of slot B once is taken off the slot table", @@ -192,7 +180,7 @@ pub fn bench_loop_drives_a_toyos_machine( if loader.contains(&raised) { return Err(format!("a boot of slot B once raised the floor: {raised:?}")); } - let swapped = std::fs::read_to_string(readback.join(metal::READBACK_SWAP)).map_err(|e| format!("swap.txt: {e}"))?; + let swapped = std::fs::read_to_string(home.join(metal::READBACK_SWAP)).map_err(|e| format!("swap.txt: {e}"))?; eprintln!(" [bench] swap.txt: {}", swapped.lines().next().unwrap_or_default()); // And the bench is the bench again: its own slot, still marked. let after = &console[console.rfind("boot: slot").ok_or("no slot record at all")?..]; diff --git a/tests/common/metal.rs b/tests/common/metal.rs index fd0a20bbbf3..13c40dbb024 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -836,25 +836,22 @@ fn fingerprint(text: &str) -> u64 { /// One arm's image, staged in `dir` exactly as [`run`] stages it — its job /// list with `reboot` behind it, the boot's own bound, and a talking or /// swapping boot's key and service binary beside it — for a rehearsal of the -/// loop that delivers it. Where it put the image, and the key a talking or -/// swapping boot authorizes. -pub fn stage(dir: &Path, arm: &Arm, rust_bins: &[(String, Vec)]) -> Result<(PathBuf, Option), String> { +/// loop that delivers it. Where it put the image. +pub fn stage(dir: &Path, arm: &Arm, rust_bins: &[(String, Vec)]) -> Result { let root = super::compile::repo_root(); let mut batch = Batch::of(arm); batch.add(arm.jobs.iter().map(|j| (*j).to_string())); - let image = build(&root, dir, arm.boot, &batch, rust_bins, &[], true)?; - let key = (arm.talk || arm.swap.is_some()).then(|| talk_home(&at(dir, arm.boot)).join("id_ed25519")); - Ok((image, key)) + build(&root, dir, arm.boot, &batch, rust_bins, &[], true) } -/// The invocation that turns one image into one readback. Written down in the -/// staged request and run by [`Mode::Drive`], so the two cannot differ. /// Where a talking boot's key lives, beside its image. fn talk_home(home: &Path) -> PathBuf { home.join("ssh") } -fn invocation(image: &Path, home: &Path, nic: Option<&str>, talk: bool, swap: Option<&str>, reach: Reach) -> Vec { +/// The invocation that turns one image into one readback. Written down in the +/// staged request and run by [`Mode::Drive`], so the two cannot differ. +pub fn invocation(image: &Path, home: &Path, nic: Option<&str>, talk: bool, swap: Option<&str>, reach: Reach) -> Vec { let mut words = vec![ "run".to_string(), "--bin".to_string(), From 89d1d72d16babaa29c7a20c3aac709129dc4b611 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 19:32:19 +0200 Subject: [PATCH 14/26] Merge fallout: main's SDK versions in every lockfile, and Guid::parse under toyos-gpt's lints The merge took main's manifests (#550) but kept this branch's bumped toyos, toyos-abi and toyos-window versions in eight lockfiles; they now say what main says, and userland's keeps only the update binary's toyos-gpt dependency. #547 forbids indexing, `as` and unchecked arithmetic in toyos-gpt outside tests, and `Guid::parse` used all three. It now reads the dashes with `get`, the digits with `char::from`, and a byte with checked arithmetic. Co-Authored-By: Claude Opus 5.5 --- bootloader/Cargo.lock | 2 +- kernel/Cargo.lock | 2 +- tests/iced-counter/Cargo.lock | 6 +++--- tests/ssh-client-host/Cargo.lock | 2 +- tests/toyos-rust-tests/Cargo.lock | 6 +++--- toyos-gpt/src/guid.rs | 13 ++++++++----- toyos/Cargo.lock | 4 ++-- userland/Cargo.lock | 6 +++--- userland/libc/Cargo.lock | 4 ++-- 9 files changed, 24 insertions(+), 21 deletions(-) diff --git a/bootloader/Cargo.lock b/bootloader/Cargo.lock index 13676ce84e4..f7dfd89b21e 100644 --- a/bootloader/Cargo.lock +++ b/bootloader/Cargo.lock @@ -257,7 +257,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" [[package]] name = "toyos-acpi" diff --git a/kernel/Cargo.lock b/kernel/Cargo.lock index ea172bd3889..e6d0370abcf 100644 --- a/kernel/Cargo.lock +++ b/kernel/Cargo.lock @@ -77,7 +77,7 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" [[package]] name = "toyos-acpi" diff --git a/tests/iced-counter/Cargo.lock b/tests/iced-counter/Cargo.lock index 838c2389d5d..3c8ef1a1859 100644 --- a/tests/iced-counter/Cargo.lock +++ b/tests/iced-counter/Cargo.lock @@ -2957,14 +2957,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.19.0" +version = "0.18.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" [[package]] name = "toyos-font" @@ -2976,7 +2976,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.21.0" +version = "0.20.0" dependencies = [ "toyos", "toyos-abi", diff --git a/tests/ssh-client-host/Cargo.lock b/tests/ssh-client-host/Cargo.lock index dd269501945..d8eb51ea815 100644 --- a/tests/ssh-client-host/Cargo.lock +++ b/tests/ssh-client-host/Cargo.lock @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" [[package]] name = "toyos-manifest" diff --git a/tests/toyos-rust-tests/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index 78a9ecbdb60..bc2b2c2b2b1 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -2084,14 +2084,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.19.0" +version = "0.18.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" [[package]] name = "toyos-blockhold" @@ -2180,7 +2180,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.21.0" +version = "0.20.0" dependencies = [ "toyos", "toyos-abi", diff --git a/toyos-gpt/src/guid.rs b/toyos-gpt/src/guid.rs index 3fbb3e01703..3e6f0b11990 100644 --- a/toyos-gpt/src/guid.rs +++ b/toyos-gpt/src/guid.rs @@ -109,15 +109,18 @@ impl Guid { pub fn parse(text: &str) -> Option { const DASHES: [usize; 4] = [8, 13, 18, 23]; let bytes = text.as_bytes(); - if bytes.len() != 36 || DASHES.iter().any(|&at| bytes[at] != b'-') { + if bytes.len() != 36 || DASHES.iter().any(|&at| bytes.get(at) != Some(&b'-')) { return None; } - let mut digits = bytes.iter().enumerate().filter(|(at, _)| !DASHES.contains(at)).map(|(_, b)| *b); + let mut digits = bytes + .iter() + .enumerate() + .filter(|(at, _)| !DASHES.contains(at)) + .map(|(_, &b)| char::from(b).to_digit(16)); let mut hex = [0u8; 16]; for out in hex.iter_mut() { - let high = (digits.next()? as char).to_digit(16)?; - let low = (digits.next()? as char).to_digit(16)?; - *out = (high * 16 + low) as u8; + let (high, low) = (digits.next()??, digits.next()??); + *out = u8::try_from(high.checked_mul(16)?.checked_add(low)?).ok()?; } let h = hex; Some(Self([h[3], h[2], h[1], h[0], h[5], h[4], h[7], h[6], h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15]])) diff --git a/toyos/Cargo.lock b/toyos/Cargo.lock index 705625672b4..c38f92db341 100644 --- a/toyos/Cargo.lock +++ b/toyos/Cargo.lock @@ -10,7 +10,7 @@ checksum = "aa9c45b374136f52f2d6311062c7146bff20fec063c3f5d46a410bd937746955" [[package]] name = "toyos" -version = "0.19.0" +version = "0.18.0" dependencies = [ "rustc-std-workspace-core", "toyos-abi", @@ -18,7 +18,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" dependencies = [ "rustc-std-workspace-core", ] diff --git a/userland/Cargo.lock b/userland/Cargo.lock index d2c4e0d6fe4..18a3c14ae16 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -4040,14 +4040,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.19.0" +version = "0.18.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" [[package]] name = "toyos-blockhold" @@ -4164,7 +4164,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.21.0" +version = "0.20.0" dependencies = [ "toyos", "toyos-abi", diff --git a/userland/libc/Cargo.lock b/userland/libc/Cargo.lock index 376e2955eaf..df18e043698 100644 --- a/userland/libc/Cargo.lock +++ b/userland/libc/Cargo.lock @@ -27,14 +27,14 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "toyos" -version = "0.19.0" +version = "0.18.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.17.0" +version = "0.16.0" [[package]] name = "toyos-libc" From be959b5deaa7d2ffc1b3f57c31f5e4bcc3dea921 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 19:32:57 +0200 Subject: [PATCH 15/26] One HARDDRIVE rule: boot_disk takes its disk from entry::partition `rootimage::boot_disk` cut the boot device's path at its last node and required that node to be HARDDRIVE, beside `entry::partition`'s "exactly one GPT HARDDRIVE node, GUID-signed", and the two disagreed: `disk/HD/File` was a partition to `partition` and no disk to `boot_disk`, and `disk/HD/HD` or `disk/HD(MBR)` was a disk to `boot_disk` that `partition` refuses. `partition` now hands back the path before its one HARDDRIVE node as the disk's, and `boot_disk` takes the handle whose path is those bytes and the end node. Test: `a_partitions_disk_is_the_path_before_its_hard_drive_node`. Negative controls, each a checked patch built and restored: - the disk cut before the path's last node, the old rule: 101; - the second HARDDRIVE node taken rather than refused: 101. Co-Authored-By: Claude Opus 5.5 --- bootloader/src/bootnext.rs | 2 +- bootloader/src/bootvars.rs | 2 +- bootloader/src/main.rs | 2 +- bootloader/src/rootimage.rs | 18 +++++------- toyos-update/src/entry.rs | 55 +++++++++++++++++++++++++------------ 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/bootloader/src/bootnext.rs b/bootloader/src/bootnext.rs index 64ec25afb4c..93853c86d38 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -57,5 +57,5 @@ pub(crate) fn our_partition(handle: Handle, system_table: &SystemTable) -> let image = bs.open_protocol_exclusive::(handle).ok()?; let device = image.device()?; let path = bs.open_protocol_exclusive::(device).ok()?; - toyos_update::entry::partition(path.as_bytes()).ok().map(|part| part.guid) + toyos_update::entry::partition(path.as_bytes()).ok().map(|(_, part)| part.guid) } diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs index 2347e1f1af9..101f5724ade 100644 --- a/bootloader/src/bootvars.rs +++ b/bootloader/src/bootvars.rs @@ -64,7 +64,7 @@ pub fn esp(bs: &BootServices, guid: &[u8; 16]) -> Result { } let path = crate::rootimage::try_get_protocol::(bs, handle) .map_err(|e| alloc::format!("the partition's device path ({e:?})"))?; - let part = entry::partition(path.as_bytes()).map_err(|why| alloc::format!("the partition: {why}"))?; + let (_, part) = entry::partition(path.as_bytes()).map_err(|why| alloc::format!("the partition: {why}"))?; if part.guid != *guid { return Err(String::from("the partition's HARDDRIVE node names another partition")); } diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index 5b482c11019..437bc7ec7ff 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -228,7 +228,7 @@ fn boot_partition(handle: Handle, system_table: &SystemTable) -> Option(device).ok()?; match toyos_update::entry::partition(path.as_bytes()) { - Ok(part) => Some(BootPartition { guid: part.guid, start_lba: part.start, blocks: part.size }), + Ok((_, part)) => Some(BootPartition { guid: part.guid, start_lba: part.start, blocks: part.size }), Err(why) => { println!("Boot partition: {why}, so it is ignored"); None diff --git a/bootloader/src/rootimage.rs b/bootloader/src/rootimage.rs index 4216e856bde..ab8abe77802 100644 --- a/bootloader/src/rootimage.rs +++ b/bootloader/src/rootimage.rs @@ -23,9 +23,10 @@ use core::num::NonZeroU64; use toyos_gpt::{Guid, Located, Sectors}; use toyos_rootimage::chunk; +use toyos_update::entry; use toyos_update::slots::{self, Table}; use uefi::prelude::*; -use uefi::proto::device_path::{DevicePath, DevicePathNode, DeviceSubType, DeviceType}; +use uefi::proto::device_path::DevicePath; use uefi::proto::loaded_image::LoadedImage; use uefi::proto::media::block::{BlockIO, BlockIoProtocol}; use uefi::table::boot::{AllocateType, MemoryType, OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol}; @@ -89,8 +90,8 @@ impl RootImage { } /// The whole-disk block device carrying the partition firmware loaded this -/// image from: the one handle whose device path is the partition's without -/// its last node, the HARDDRIVE one. +/// image from: the one handle whose device path is the disk +/// `toyos_update::entry::partition` cuts from the partition's. pub fn boot_disk(handle: Handle, bs: &BootServices) -> Result { let image = bs .open_protocol_exclusive::(handle) @@ -98,13 +99,8 @@ pub fn boot_disk(handle: Handle, bs: &BootServices) -> Result { let device = image.device().ok_or("firmware names no device this image was loaded from")?; let path = try_get_protocol::(bs, device) .map_err(|e| alloc::format!("the boot device's path: {e:?}"))?; - let nodes: alloc::vec::Vec<&DevicePathNode> = path.node_iter().collect(); - let Some((last, disk_nodes)) = nodes.split_last() else { - return Err("the boot device's path is empty".into()); - }; - if last.full_type() != (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE) { - return Err("the boot device is not a partition, so there is no disk to find the slots on".into()); - } + let (disk_path, _) = entry::partition(path.as_bytes()) + .map_err(|why| alloc::format!("the boot device's path: {why}, so there is no disk to find the slots on"))?; let handles = bs .find_handles::() @@ -117,7 +113,7 @@ pub fn boot_disk(handle: Handle, bs: &BootServices) -> Result { .filter(|&candidate| candidate != device) .filter(|&candidate| { let Ok(path) = try_get_protocol::(bs, candidate) else { return false }; - path.node_iter().eq(disk_nodes.iter().copied()) + path.as_bytes().strip_suffix(&entry::END_NODE) == Some(disk_path) }) .collect(); match disks[..] { diff --git a/toyos-update/src/entry.rs b/toyos-update/src/entry.rs index 429e9c8789b..39eed1d8805 100644 --- a/toyos-update/src/entry.rs +++ b/toyos-update/src/entry.rs @@ -37,8 +37,9 @@ const SIGNATURE_GUID: u8 = 0x02; /// MEDIA/FILE_PATH (UEFI 2.10 §10.3.6.4): a null-terminated `CHAR16` path. const FILE_PATH: (u8, u8) = (0x04, 0x04); -/// END_ENTIRE_DEVICE_PATH (UEFI 2.10 §10.3.1). +/// END_ENTIRE_DEVICE_PATH (UEFI 2.10 §10.3.1), and the whole node. const END: (u8, u8) = (0x7F, 0xFF); +pub const END_NODE: [u8; NODE_HEADER] = [END.0, END.1, NODE_HEADER as u8, 0]; /// A GPT partition as a HARDDRIVE node names it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -89,9 +90,7 @@ pub fn load_option(description: &str, part: &Partition, path: &str, out: &mut [u out[file_at + 1] = FILE_PATH.1; out[file_at + 2..file_at + 4].copy_from_slice(&node_len(end_at - file_at)); - out[end_at] = END.0; - out[end_at + 1] = END.1; - out[end_at + 2..end_at + 4].copy_from_slice(&node_len(NODE_HEADER)); + out[end_at..end_at + NODE_HEADER].copy_from_slice(&END_NODE); let len = end_at + NODE_HEADER; out[4..6].copy_from_slice(&node_len(len - path_at)); len @@ -115,24 +114,27 @@ fn device_path(option: &[u8]) -> Option<&[u8]> { option.get(at..at.checked_add(path_len)?) } -/// The GPT partition a device path names by its one HARDDRIVE node, or why it -/// names none. Exactly one node: a path with two describes a partition inside -/// a partition, and taking either is guessing which one the path means. -pub fn partition(mut path: &[u8]) -> Result { +/// The GPT partition a device path names by its one HARDDRIVE node, and the +/// path of the disk it is on: the nodes before that one, whose own device path +/// is those bytes and [`END_NODE`]. Exactly one node: a path with two describes +/// a partition inside a partition, and taking either is guessing which one the +/// path means. +pub fn partition(path: &[u8]) -> Result<(&[u8], Partition), &'static str> { + let mut rest = path; let mut found = None; loop { - let node = path.get(..NODE_HEADER).ok_or("the device path runs off with no end node")?; + let node = rest.get(..NODE_HEADER).ok_or("the device path runs off with no end node")?; let len = usize::from(u16::from_le_bytes([node[2], node[3]])); - let this = path.get(..len).filter(|_| len >= NODE_HEADER).ok_or("a device-path node cannot be stepped over")?; + let this = rest.get(..len).filter(|_| len >= NODE_HEADER).ok_or("a device-path node cannot be stepped over")?; match (this[0], this[1]) { END => break, HARD_DRIVE if found.is_some() => return Err("the device path has more than one HARDDRIVE node"), - HARD_DRIVE => found = Some(this), + HARD_DRIVE => found = Some((&path[..path.len() - rest.len()], this)), _ => {} } - path = &path[len..]; + rest = &rest[len..]; } - let hd = found.ok_or("the device path carries no HARDDRIVE node")?; + let (disk, hd) = found.ok_or("the device path carries no HARDDRIVE node")?; if hd.len() != HARD_DRIVE_BYTES { return Err("the HARDDRIVE node is malformed"); } @@ -143,17 +145,18 @@ pub fn partition(mut path: &[u8]) -> Result { return Err("the HARDDRIVE node names the partition with no GUID signature"); } let field = |at: usize| u64::from_le_bytes(hd[at..at + 8].try_into().expect("eight bytes")); - Ok(Partition { + let part = Partition { number: u32::from_le_bytes(hd[4..8].try_into().expect("four bytes")), start: field(8), size: field(16), guid: hd[24..40].try_into().expect("sixteen bytes"), - }) + }; + Ok((disk, part)) } /// Whether an option boots off the GPT partition `guid`. fn names(option: &[u8], guid: &[u8; 16]) -> bool { - device_path(option).is_some_and(|path| partition(path).is_ok_and(|found| found.guid == *guid)) + device_path(option).is_some_and(|path| partition(path).is_ok_and(|(_, found)| found.guid == *guid)) } /// Whether an option is active: the boot manager skips one that is not. @@ -293,7 +296,7 @@ mod tests { fn a_path_names_the_partition_of_its_one_hard_drive_node() { let option = by_hand(); let path = device_path(&option).expect("the option's path"); - assert_eq!(partition(path), Ok(PART)); + assert_eq!(partition(path), Ok((&[][..], PART))); let (hd, rest) = path.split_at(HARD_DRIVE_BYTES); let mut other = hd.to_vec(); other[24..40].copy_from_slice(&[0xE6; 16]); @@ -304,6 +307,24 @@ mod tests { assert_eq!(partition(&path[..path.len() - NODE_HEADER]), Err("the device path runs off with no end node")); } + /// **The disk is the path before the one HARDDRIVE node**, whatever follows + /// it, and a path whose last node is a second HARDDRIVE one has no disk. + #[test] + fn a_partitions_disk_is_the_path_before_its_hard_drive_node() { + let option = by_hand(); + let path = device_path(&option).expect("the option's path"); + let (hd, file) = path.split_at(HARD_DRIVE_BYTES); + // ACPI(PNP0A03,0), UEFI 2.10 §10.3.3. + let disk = [0x02, 0x01, 12, 0, 0xD0, 0x41, 0x03, 0x0A, 0, 0, 0, 0]; + for (after, what) in [(&END_NODE[..], "a partition's own path"), (file, "a node after the HARDDRIVE one")] { + assert_eq!(partition(&[&disk[..], hd, after].concat()), Ok((&disk[..], PART)), "{what}"); + } + let mut other = hd.to_vec(); + other[24..40].copy_from_slice(&[0xE6; 16]); + let nested = [&disk[..], &other, hd, &END_NODE].concat(); + assert_eq!(partition(&nested), Err("the device path has more than one HARDDRIVE node")); + } + #[test] fn an_esp_is_booted_by_its_lowest_active_entry() { let held = [(2, option(0xE5, false)), (5, option(0xE5, true)), (7, option(0xE5, true)), (1, option(0xE6, true))]; From 4def9c86a0ea6197ae8eb2cfc0e6982629decafa Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 19:58:30 +0200 Subject: [PATCH 16/26] File: after a netd swap, sshd's port resets every connect for the rest of the boot bench_loop_drives_a_toyos_machine went red once on the dev host at be959b5d and green on the harness's alone re-run; the red attempt's sshd accepted twice on the new netd and then no connect reached it for 46 s. Co-Authored-By: Claude Opus 5.5 --- ...-every-connect-for-the-rest-of-the-boot.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md diff --git a/issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md b/issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md new file mode 100644 index 00000000000..900df279fb6 --- /dev/null +++ b/issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md @@ -0,0 +1,46 @@ +--- +status: open +kind: defect +opened: 2026-09-27 +--- + +# After a netd swap, sshd's port resets every connect for the rest of the boot + +`bench_loop_drives_a_toyos_machine` on the dev host (QEMU 11.1.1, TCG, +`--nightly --jobs 1`, alone, at `be959b5d`) went red on its first attempt and +green on the harness's alone re-run: + +``` +FAIL bench_loop_drives_a_toyos_machine: the loop refused: the swap did not put the new binary in service: + `echo the T14 answers over its own cable` afterwards answered Err("error connecting to 127.0.0.1:55861: Connection reset by peer (os error 54)") +``` + +The guest's console on the red attempt: + +``` +9.026 init: swap netd: started: …/netd as pid 10; in service if it runs 5000 ms +9.047 sshd: the listener on port 22 failed (netd error); binding it again +9.065 sshd: listening on port 22 +9.277 sshd: connection from 10.0.2.2:55934 +10.314 sshd: session error: … "early eof" +12.331 sshd: connection from 10.0.2.2:55935 +13.589 sshd: session error: … "early eof" +14.043 init: swap netd: in service: …/netd as pid 10 +``` + +After 13.589 no connection reaches sshd until the test's 60 s bound, and +sshd logs no listener failure. The host's every dial to the forward in those +46 s is reset. The green attempt's sshd goes on accepting: its session at +18.804 authenticates and runs `echo`. + +So after sshd's second accept on the new netd, port 22 has no listener, and +nothing says so. `issues/hardware/a-connect-between-two-accepts-is-reset.md` +is the one-connect form of this window, and here it never closed. Not known: +whether sshd never re-armed its listener after the accept, or netd dropped it. + +`cargo run -- --known-red bench_loop_drives_a_toyos_machine` answers NO, and +`lan_swap` too. + +**Exit**: a listener that sshd holds across its accepts, or a failed re-arm +that sshd logs and retries, and the bench loop's swap green without the +harness's re-run. From bdfc2c524f411ee0d09f6c4686bfb27be63b9ab1 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 21:47:38 +0200 Subject: [PATCH 17/26] netd: a listener's socket that left Listen is handed over or listens again A listener is one smoltcp socket that becomes the connection it accepts, so port 22 listens only while that socket is in Listen. netd announced a connection to the owner only when a pass saw the socket Established, and accept took only Established. A peer whose handshake-closing ACK and FIN land in one pass moves the socket SynReceived -> CloseWait, and no pass ever sees it Established. The owner is never woken and never accepts, the socket never listens again, and every later SYN on the port is answered with a reset. Nothing on either side logs it. An ACK and a reset in one pass leave it Closed, with the same result. QEMU's user network produces the first pattern. It takes a host connect at once and finishes the guest's handshake afterwards, and when the host socket is already closed it sends the FIN straight behind the ACK. Measured with a host actuator, applied to tests/ as a checked patch and not committed (40 rounds of one connect closed at once and one closed with a zero linger, then `echo` asked up to ten times a second apart), on 4def9c86's netd with a log line per listener state change: every attempt logged `listener now CloseWait notified=false`, 6 of 6 (3 invocations with their alone re-runs). Without that logging, 5 of 5 invocations were red, 10 of 10 attempts counting the harness's alone re-runs. With this change 5 of 5 were green, `echo` answered on the first ask each time, and sshd logged 31 to 33 connections, the `echo`'s among them. `bench_loop_drives_a_toyos_machine` is where this showed. After the netd swap, port 22 answered every connect with a reset for 46 s and sshd logged nothing. That run was not caught with the state logging, which saw 23 attempts green. With this change the bench test exited 0 in 25 of 25 invocations, none re-run. The trigger is not identified: no dial the bench host is known to make fell in that window. A connect this host closed before its guest handshake finished fits the signature, for example a `probe` cut off by its 10 s bound while the machine rebooted. The rule is now `listen::Listening`, one type used by the pass and by accept. A socket in Established or CloseWait holds a connection its owner is woken for and takes. A Closed one listens again. An accept spends the owner's wake whatever it finds, so a wake written for a connection its peer then reset still leaves the next connection announced. A CloseWait connection handed over reads its data and then EOF, which is how sshd ends such a peer's session. This is main's defect: the branch touches no netd, sshd or network SDK code. Negative controls, each a checked patch on userland/netd/src/listen.rs, shown to build, run, and restored: - CloseWait back with Listen and SynReceived: host suite exit 101 (a_peer_that_closes_with_its_last_ack_is_a_connection) - Closed not listening again: exit 101 (a_peer_that_resets_before_it_is_taken_frees_the_port, a_wake_spent_on_a_reset_connection_announces_the_next) - accept keeping the wake: exit 101 (a_wake_spent_on_a_reset_connection_announces_the_next) A handshake that nobody finishes keeps the socket SynReceived: 600 s were measured. That is filed as issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md. Co-Authored-By: Claude Opus 5.5 --- ...dy-finishes-holds-a-listeners-port-shut.md | 28 ++ ...-every-connect-for-the-rest-of-the-boot.md | 46 --- userland/netd/src/listen.rs | 66 ++++ userland/netd/src/listen/tests.rs | 281 ++++++++++++++++++ userland/netd/src/main.rs | 21 +- 5 files changed, 384 insertions(+), 58 deletions(-) create mode 100644 issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md delete mode 100644 issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md create mode 100644 userland/netd/src/listen.rs create mode 100644 userland/netd/src/listen/tests.rs diff --git a/issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md b/issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md new file mode 100644 index 00000000000..99324105d36 --- /dev/null +++ b/issues/hardware/a-handshake-nobody-finishes-holds-a-listeners-port-shut.md @@ -0,0 +1,28 @@ +--- +status: open +kind: defect +opened: 2026-09-27 +--- + +# A handshake nobody finishes holds a listener's port shut + +netd's listener is one smoltcp socket, and the port listens only while that +socket is in `Listen` (`userland/netd/src/listen.rs`). A SYN whose sender never +answers the SYN-ACK (a peer gone, a spoofed source) leaves it in `SynReceived`, +and smoltcp 0.12 retransmits the SYN-ACK without end. + +Measured on smoltcp's interface over a wire played by hand, as +`userland/netd/src/listen/tests.rs` plays it: after one SYN and 600 s of +silence the socket was still `SynReceived`, having sent 72 SYN-ACKs, and +another peer's SYN in that state was answered with a reset. With +`set_timeout(10 s)` the socket went `Closed` at 10 s, which +`listen::connection_waiting` turns back into `Listen`. + +So one packet shuts sshd's port for the rest of the boot. Nothing has chosen a +bound on a listener's half-open handshake, and the timeout smoltcp offers also +bounds the connection the socket becomes, so it would have to come off at the +hand-over. + +**Exit**: a listener's half-open handshake let go within a bound, and a test +that sends one SYN and nothing more, then finds the port answering the next +peer with a SYN-ACK. diff --git a/issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md b/issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md deleted file mode 100644 index 900df279fb6..00000000000 --- a/issues/hardware/after-a-netd-swap-sshd-resets-every-connect-for-the-rest-of-the-boot.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-27 ---- - -# After a netd swap, sshd's port resets every connect for the rest of the boot - -`bench_loop_drives_a_toyos_machine` on the dev host (QEMU 11.1.1, TCG, -`--nightly --jobs 1`, alone, at `be959b5d`) went red on its first attempt and -green on the harness's alone re-run: - -``` -FAIL bench_loop_drives_a_toyos_machine: the loop refused: the swap did not put the new binary in service: - `echo the T14 answers over its own cable` afterwards answered Err("error connecting to 127.0.0.1:55861: Connection reset by peer (os error 54)") -``` - -The guest's console on the red attempt: - -``` -9.026 init: swap netd: started: …/netd as pid 10; in service if it runs 5000 ms -9.047 sshd: the listener on port 22 failed (netd error); binding it again -9.065 sshd: listening on port 22 -9.277 sshd: connection from 10.0.2.2:55934 -10.314 sshd: session error: … "early eof" -12.331 sshd: connection from 10.0.2.2:55935 -13.589 sshd: session error: … "early eof" -14.043 init: swap netd: in service: …/netd as pid 10 -``` - -After 13.589 no connection reaches sshd until the test's 60 s bound, and -sshd logs no listener failure. The host's every dial to the forward in those -46 s is reset. The green attempt's sshd goes on accepting: its session at -18.804 authenticates and runs `echo`. - -So after sshd's second accept on the new netd, port 22 has no listener, and -nothing says so. `issues/hardware/a-connect-between-two-accepts-is-reset.md` -is the one-connect form of this window, and here it never closed. Not known: -whether sshd never re-armed its listener after the accept, or netd dropped it. - -`cargo run -- --known-red bench_loop_drives_a_toyos_machine` answers NO, and -`lan_swap` too. - -**Exit**: a listener that sshd holds across its accepts, or a failed re-arm -that sshd logs and retries, and the bench loop's swap green without the -harness's re-run. diff --git a/userland/netd/src/listen.rs b/userland/netd/src/listen.rs new file mode 100644 index 00000000000..421a34cbdee --- /dev/null +++ b/userland/netd/src/listen.rs @@ -0,0 +1,66 @@ +//! Whether a listener's owner is owed a wake, and what its accept finds. +//! +//! **A listener is one smoltcp socket that becomes the connection it +//! accepts**, so the port listens only while that socket is in `Listen`: one +//! that left it is handed to its owner or listens again, or the port answers +//! every other peer with a reset for the rest of the boot. + +use smoltcp::socket::tcp; + +/// A listener's port, and whether its owner holds a wake it has not spent on +/// an accept. +pub struct Listening { + port: u16, + woken: bool, +} + +impl Listening { + pub fn new(port: u16) -> Self { + Self { port, woken: false } + } + + pub fn port(&self) -> u16 { + self.port + } + + /// Whether the owner is owed a wake for `socket`: a connection waits and + /// the owner holds no wake. + pub fn owes_wake(&self, socket: &mut tcp::Socket) -> bool { + connection_waiting(socket, self.port) && !self.woken + } + + pub fn woke(&mut self) { + self.woken = true; + } + + /// An accept, which spends the owner's wake whatever it finds: whether + /// `socket` holds a connection to hand over. A wake written for a + /// connection its peer then reset is spent here, so the next connection + /// is announced. + pub fn accept(&mut self, socket: &mut tcp::Socket) -> bool { + self.woken = false; + connection_waiting(socket, self.port) + } +} + +/// Whether `socket`, listening on `port`, holds a connection: a handshake +/// finished, whatever the peer did since. Its FIN included, which can land in +/// the same pass as the handshake's last ACK, so no pass ever sees the socket +/// `Established`. A socket its peer reset before its owner took it listens +/// again. +fn connection_waiting(socket: &mut tcp::Socket, port: u16) -> bool { + match socket.state() { + tcp::State::Listen | tcp::State::SynReceived => false, + tcp::State::Established | tcp::State::CloseWait => true, + tcp::State::Closed => { + socket + .listen(port) + .unwrap_or_else(|e| panic!("netd: a closed socket refused to listen on {port}: {e:?}")); + false + } + other => panic!("netd: a listener's socket is {other:?}, which only netd closing or connecting it reaches"), + } +} + +#[cfg(test)] +mod tests; diff --git a/userland/netd/src/listen/tests.rs b/userland/netd/src/listen/tests.rs new file mode 100644 index 00000000000..92afa7b7566 --- /dev/null +++ b/userland/netd/src/listen/tests.rs @@ -0,0 +1,281 @@ +//! A listener's socket on a wire: smoltcp's own `Interface` on an Ethernet +//! device whose far end is played here segment by segment, so what is judged +//! is what the port answers the next peer. + +use super::*; +use std::collections::VecDeque; + +use smoltcp::iface::{Config, Interface, PollResult, SocketHandle, SocketSet}; +use smoltcp::phy::{self, ChecksumCapabilities, Device, DeviceCapabilities, Medium}; +use smoltcp::time::Instant; +use smoltcp::wire::{ + ArpOperation, ArpPacket, ArpRepr, EthernetAddress, EthernetFrame, EthernetProtocol, EthernetRepr, + HardwareAddress, IpAddress, IpCidr, IpProtocol, Ipv4Address, Ipv4Packet, Ipv4Repr, TcpControl, TcpPacket, + TcpRepr, TcpSeqNumber, +}; + +const OUR_MAC: EthernetAddress = EthernetAddress([0x02, 0, 0, 0, 0, 0x01]); +const OURS: Ipv4Address = Ipv4Address::new(10, 0, 2, 15); +const PEER_MAC: EthernetAddress = EthernetAddress([0x02, 0, 0, 0, 0, 0x02]); +const PEER: Ipv4Address = Ipv4Address::new(10, 0, 2, 2); +const PORT: u16 = 22; +/// The peer's initial sequence number, on every connection it opens. +const PEER_ISN: u32 = 1000; + +#[derive(Default)] +struct Wire { + inbound: VecDeque>, + outbound: Vec>, +} + +struct Rx(Vec); +struct Tx<'a>(&'a mut Vec>); + +impl phy::RxToken for Rx { + fn consume R>(self, f: F) -> R { + f(&self.0) + } +} + +impl phy::TxToken for Tx<'_> { + fn consume R>(self, len: usize, f: F) -> R { + let mut frame = vec![0u8; len]; + let result = f(&mut frame); + self.0.push(frame); + result + } +} + +impl Device for Wire { + type RxToken<'a> = Rx; + type TxToken<'a> = Tx<'a>; + + fn receive(&mut self, _: Instant) -> Option<(Rx, Tx<'_>)> { + let frame = self.inbound.pop_front()?; + Some((Rx(frame), Tx(&mut self.outbound))) + } + + fn transmit(&mut self, _: Instant) -> Option> { + Some(Tx(&mut self.outbound)) + } + + fn capabilities(&self) -> DeviceCapabilities { + let mut caps = DeviceCapabilities::default(); + caps.max_transmission_unit = 1514; + caps.medium = Medium::Ethernet; + caps + } +} + +/// One TCP segment our side sent: its flags, sequence number and the peer +/// port it went to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Sent { + control: TcpControl, + ack: bool, + seq: u32, + to: u16, +} + +struct Net { + iface: Interface, + wire: Wire, + sockets: SocketSet<'static>, + listener: SocketHandle, + listening: Listening, + sent: Vec, +} + +impl Net { + fn new() -> Self { + let mut wire = Wire::default(); + let mut iface = Interface::new(Config::new(HardwareAddress::Ethernet(OUR_MAC)), &mut wire, Instant::from_millis(0)); + iface.update_ip_addrs(|addrs| addrs.push(IpCidr::new(IpAddress::Ipv4(OURS), 24)).unwrap()); + let mut socket = tcp::Socket::new(tcp::SocketBuffer::new(vec![0; 4096]), tcp::SocketBuffer::new(vec![0; 4096])); + socket.listen(PORT).expect("a fresh socket listens"); + let mut sockets = SocketSet::new(Vec::new()); + let listener = sockets.add(socket); + Self { iface, wire, sockets, listener, listening: Listening::new(PORT), sent: Vec::new() } + } + + fn socket(&mut self) -> &mut tcp::Socket<'static> { + self.sockets.get_mut::(self.listener) + } + + /// One pass of netd's loop: everything the wire holds, in one batch, and + /// the far end's ARP answered. + fn pass(&mut self) { + loop { + while self.iface.poll(Instant::from_millis(0), &mut self.wire, &mut self.sockets) != PollResult::None {} + if self.wire.outbound.is_empty() { + return; + } + for frame in std::mem::take(&mut self.wire.outbound) { + self.far_end(&frame); + } + } + } + + fn far_end(&mut self, frame: &[u8]) { + let eth = EthernetFrame::new_checked(frame).expect("the interface sent an Ethernet frame"); + match eth.ethertype() { + EthernetProtocol::Arp => { + let arp = ArpRepr::parse(&ArpPacket::new_checked(eth.payload()).unwrap()).unwrap(); + let ArpRepr::EthernetIpv4 { operation: ArpOperation::Request, .. } = arp else { return }; + let reply = ArpRepr::EthernetIpv4 { + operation: ArpOperation::Reply, + source_hardware_addr: PEER_MAC, + source_protocol_addr: PEER, + target_hardware_addr: OUR_MAC, + target_protocol_addr: OURS, + }; + let mut out = vec![0u8; 14 + reply.buffer_len()]; + let mut e = EthernetFrame::new_unchecked(&mut out); + EthernetRepr { src_addr: PEER_MAC, dst_addr: OUR_MAC, ethertype: EthernetProtocol::Arp }.emit(&mut e); + reply.emit(&mut ArpPacket::new_unchecked(e.payload_mut())); + self.wire.inbound.push_back(out); + } + EthernetProtocol::Ipv4 => { + let ip = Ipv4Packet::new_checked(eth.payload()).unwrap(); + assert_eq!(ip.next_header(), IpProtocol::Tcp, "a listener sends only TCP"); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + let control = match (tcp.syn(), tcp.fin(), tcp.rst()) { + (true, _, _) => TcpControl::Syn, + (_, true, _) => TcpControl::Fin, + (_, _, true) => TcpControl::Rst, + _ => TcpControl::None, + }; + self.sent.push(Sent { control, ack: tcp.ack(), seq: tcp.seq_number().0 as u32, to: tcp.dst_port() }); + } + other => panic!("the interface sent a frame of type {other}"), + } + } + + /// A segment from the peer's `from` port onto the wire, delivered at the + /// next [`Net::pass`]. + fn send(&mut self, from: u16, control: TcpControl, seq: u32, ack: Option) { + let caps = ChecksumCapabilities::default(); + let tcp = TcpRepr { + src_port: from, + dst_port: PORT, + control, + seq_number: TcpSeqNumber(seq as i32), + ack_number: ack.map(|a| TcpSeqNumber(a as i32)), + window_len: 64000, + window_scale: None, + max_seg_size: None, + sack_permitted: false, + sack_ranges: [None, None, None], + timestamp: None, + payload: &[], + }; + let ip = Ipv4Repr { + src_addr: PEER, + dst_addr: OURS, + next_header: IpProtocol::Tcp, + payload_len: tcp.buffer_len(), + hop_limit: 64, + }; + let mut out = vec![0u8; 14 + ip.buffer_len() + ip.payload_len]; + let mut e = EthernetFrame::new_unchecked(&mut out); + EthernetRepr { src_addr: PEER_MAC, dst_addr: OUR_MAC, ethertype: EthernetProtocol::Ipv4 }.emit(&mut e); + let mut packet = Ipv4Packet::new_unchecked(e.payload_mut()); + ip.emit(&mut packet, &caps); + tcp.emit(&mut TcpPacket::new_unchecked(packet.payload_mut()), &IpAddress::Ipv4(PEER), &IpAddress::Ipv4(OURS), &caps); + self.wire.inbound.push_back(out); + } + + /// The peer's SYN from `from`, and the SYN-ACK's sequence number. + fn syn(&mut self, from: u16) -> u32 { + self.send(from, TcpControl::Syn, PEER_ISN, None); + self.pass(); + self.sent + .iter() + .rev() + .find(|s| s.control == TcpControl::Syn && s.ack && s.to == from) + .unwrap_or_else(|| panic!("a SYN from {from} was answered {:?}", self.sent.last())) + .seq + } + + /// The peer's answer to the SYN-ACK and `then`, in one batch. + fn ack_and(&mut self, from: u16, our_isn: u32, then: TcpControl) { + self.send(from, TcpControl::None, PEER_ISN + 1, Some(our_isn + 1)); + self.send(from, then, PEER_ISN + 1, Some(our_isn + 1)); + self.pass(); + } + + /// Whether the port answers a new peer's SYN with a SYN-ACK: it listens. + fn listens(&mut self, from: u16) -> bool { + self.send(from, TcpControl::Syn, PEER_ISN, None); + self.pass(); + let answer = *self.sent.iter().rev().find(|s| s.to == from).expect("a SYN is answered"); + answer.control == TcpControl::Syn + } + + fn owes_wake(&mut self) -> bool { + self.listening.owes_wake(self.sockets.get_mut::(self.listener)) + } + + fn accept(&mut self) -> bool { + self.listening.accept(self.sockets.get_mut::(self.listener)) + } +} + +#[test] +fn a_finished_handshake_is_owed_one_wake() { + let mut net = Net::new(); + let isn = net.syn(5001); + assert!(!net.owes_wake(), "a SYN alone was taken for a connection"); + net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(net.owes_wake()); + net.listening.woke(); + assert!(!net.owes_wake(), "a connection its owner holds a wake for was announced twice"); + assert!(net.accept()); +} + +/// **A peer that sends its FIN with the handshake's last ACK is still a +/// connection.** Both land in one pass, so the socket goes from `SynReceived` +/// to `CloseWait` with no pass ever seeing it `Established`. +#[test] +fn a_peer_that_closes_with_its_last_ack_is_a_connection() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.ack_and(5001, isn, TcpControl::Fin); + assert_eq!(net.socket().state(), tcp::State::CloseWait, "the premise: both in one pass"); + assert!(net.owes_wake(), "a connection the peer half-closed was never announced"); + net.listening.woke(); + assert!(net.accept(), "a connection the peer half-closed was not handed over"); +} + +/// A peer that resets before its owner took it frees the port: the next peer +/// is answered a SYN-ACK and not a reset. +#[test] +fn a_peer_that_resets_before_it_is_taken_frees_the_port() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.ack_and(5001, isn, TcpControl::Rst); + assert_eq!(net.socket().state(), tcp::State::Closed, "the premise: both in one pass"); + assert!(!net.owes_wake()); + assert!(net.listens(5002), "the port answered the next peer {:?}", net.sent.last()); +} + +/// A wake written for a connection its peer then reset is spent by the +/// accept that finds nothing, and the next connection is announced. +#[test] +fn a_wake_spent_on_a_reset_connection_announces_the_next() { + let mut net = Net::new(); + let isn = net.syn(5001); + net.send(5001, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(net.owes_wake()); + net.listening.woke(); + net.send(5001, TcpControl::Rst, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(!net.owes_wake(), "a reset connection was announced"); + assert!(!net.accept(), "an accept took a connection its peer had reset"); + let isn = net.syn(5002); + net.send(5002, TcpControl::None, PEER_ISN + 1, Some(isn + 1)); + net.pass(); + assert!(net.owes_wake(), "the connection after a reset one was never announced"); +} diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index b5a5682bd7a..0c4a1640329 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -9,6 +9,7 @@ use toyos::say; mod device; mod dhcp; mod i219; +mod listen; mod mdns; mod report; mod resolve; @@ -399,7 +400,7 @@ impl PipedConnection { struct PipedListener { handle: SocketHandle, notify_write: Pipe, - notified: bool, + listening: listen::Listening, } struct PendingPipedConnect { @@ -1223,7 +1224,7 @@ impl NetDaemon { self.piped_listeners.insert(socket_id, PipedListener { handle, notify_write, - notified: false, + listening: listen::Listening::new(port), }); msg.client.result(&TcpBindResponse { @@ -1251,21 +1252,19 @@ impl NetDaemon { msg.client.error(ERR_INVALID_INPUT); return; }; - let Some(listener) = self.piped_listeners.get(&req.socket_id) else { + let Some(listener) = self.piped_listeners.get_mut(&req.socket_id) else { msg.client.error(ERR_NOT_CONNECTED); return; }; let socket = socket_set.get_mut::(listener.handle); - // Not `is_active`: that is already true in SynReceived, where - // `remote_endpoint()` is still None. - if socket.state() != tcp::State::Established { + if !listener.listening.accept(socket) { msg.client.error(ERR_NOT_CONNECTED); return; } let remote = socket.remote_endpoint().unwrap(); - let local_port = socket.local_endpoint().unwrap().port; + let local_port = listener.listening.port(); let remote_addr = match remote.addr { IpAddress::Ipv4(a) => a.octets(), }; @@ -1290,7 +1289,6 @@ impl NetDaemon { if let Some(pl) = self.piped_listeners.get_mut(&req.socket_id) { pl.handle = new_handle; - pl.notified = false; } msg.client.result(&TcpAcceptPipedResponse { @@ -1434,12 +1432,11 @@ impl NetDaemon { let mut dead = Vec::new(); for (&socket_id, listener) in &mut self.piped_listeners { let socket = socket_set.get_mut::(listener.handle); - // Not `is_active`: that is already true in SynReceived, before the - // three-way handshake completes. - let owed = socket.state() == tcp::State::Established && !listener.notified; + let owed = listener.listening.owes_wake(socket); let wake: &[u8] = if owed { &[1] } else { &[] }; match toyos_abi::syscall::write_nonblock(listener.notify_write.as_handle(), wake) { - Ok(_) => listener.notified |= owed, + Ok(_) if owed => listener.listening.woke(), + Ok(_) => {} Err(SyscallError::WouldBlock) if !owed => {} // Its owner has gone, which is the ordinary end of a listener. Err(SyscallError::Gone) => dead.push(socket_id), From d94ae0823d4411716b8f3471b6f6bf90e1cb876a Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 21:47:49 +0200 Subject: [PATCH 18/26] File: the bench sometimes comes back two minutes late Seen across the 48 bench runs this branch's netd investigation made: 7 of them took 126 to 132 s for the bench to give back its /log, and the other 41 took 13 to 34 s. The late runs came with the base netd and with the fixed one alike. The cause is not measured. Co-Authored-By: Claude Opus 5.5 --- ...h-sometimes-comes-back-two-minutes-late.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 issues/boot-media/the-bench-sometimes-comes-back-two-minutes-late.md diff --git a/issues/boot-media/the-bench-sometimes-comes-back-two-minutes-late.md b/issues/boot-media/the-bench-sometimes-comes-back-two-minutes-late.md new file mode 100644 index 00000000000..047fc533017 --- /dev/null +++ b/issues/boot-media/the-bench-sometimes-comes-back-two-minutes-late.md @@ -0,0 +1,19 @@ +--- +status: open +kind: finding +opened: 2026-09-27 +--- + +# The bench sometimes comes back two minutes late + +`bench_loop_drives_a_toyos_machine` prints `the bench gave back its /log over +the runner key s after it went down`. Over 48 runs on the dev host (QEMU +11.1.1), n was 13 to 34 s in 41 of them and 126 to 132 s in 7: 3 of 23 at +`4def9c86` with a log line per netd listener state, and 4 of 25 with netd's +listener fix. The extra time is close to the 120 s bound +`tests/ssh-client-host` puts on a whole run, which would fit one `fetch` in +`metalbench::Bench::wait_for_the_log` that connected while the machine was +rebooting and then waited out that bound. That has not been checked. + +**Exit**: the cause measured, and either the late runs gone or the wait they +spend recorded where it is spent. From 2d6d228f5f519afc0a02af55d75dec6b35f6dbb2 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 10:44:01 +0200 Subject: [PATCH 19/26] Review r6: one impl Guid, and the track keeps only what it owes `Guid::parse` joins the first `impl Guid` block and loses the `let h = hex` rename. The Stage 2 track drops its "built, and proven in QEMU" bullets, which described the tree and would rot with the next flag rename; the Owed list and the exit are the track. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- .../the-machine-updates-itself-without-ubuntu.md | 16 ---------------- toyos-gpt/src/guid.rs | 7 ++----- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md index f48163b169b..06fad4feaec 100644 --- a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md +++ b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md @@ -38,22 +38,6 @@ stdin — and the machine installs nothing the owner did not sign. ## Stage 2 — the T14 updates without Ubuntu, and installs ToyOS on its NVMe -**Built, and proven in QEMU on an emulated stick:** - -- **The loader writes the firmware's boot variables** on a request the running - system leaves in the slot table, since nothing after `ExitBootServices` - writes one: `update --boot-first` puts its own entry first in `BootOrder`, - `update --boot-next ` boots another ESP once, and `update --once - < image` boots the idle slot once and never marks it. Each is taken off the - table before it is acted on. -- **A machine with no slot to boot falls to the entry after its own** in - `BootOrder` — its recovery stick. -- **The bench**: `toyos-metal` delivers each boot to a T14 running ToyOS alone - (`tests/benchcase`, `cargo run -- --bench-image `) with `update - --once`, reboots it over ssh, and reads the boot back over the bench's sshd - — the loader's passes of it and its `logd` files — for the same judges. - `--via-ubuntu` is the old path, beside it until the installer. - **Owed:** 1. **The T14 switched over**: the bench handed the machine through Ubuntu once diff --git a/toyos-gpt/src/guid.rs b/toyos-gpt/src/guid.rs index 3e6f0b11990..a7cad6ba8d6 100644 --- a/toyos-gpt/src/guid.rs +++ b/toyos-gpt/src/guid.rs @@ -102,9 +102,7 @@ impl Guid { pub const fn is_zero(&self) -> bool { u128::from_ne_bytes(self.0) == 0 } -} -impl Guid { /// The text [`Display`](fmt::Display) prints, read back, in either case. pub fn parse(text: &str) -> Option { const DASHES: [usize; 4] = [8, 13, 18, 23]; @@ -117,12 +115,11 @@ impl Guid { .enumerate() .filter(|(at, _)| !DASHES.contains(at)) .map(|(_, &b)| char::from(b).to_digit(16)); - let mut hex = [0u8; 16]; - for out in hex.iter_mut() { + let mut h = [0u8; 16]; + for out in h.iter_mut() { let (high, low) = (digits.next()??, digits.next()??); *out = u8::try_from(high.checked_mul(16)?.checked_add(low)?).ok()?; } - let h = hex; Some(Self([h[3], h[2], h[1], h[0], h[5], h[4], h[7], h[6], h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15]])) } } From 37f8a42ead4568d46c79c755d1cdc52513f8b1f1 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 13:00:19 +0200 Subject: [PATCH 20/26] A boot that ends before its console closes is read on the UART too The loader's panic handler resets at once, so a guest whose ready marker is the loader's own line can have QEMU (-no-reboot) exit before the one-second Timeout arm reads the UART. The Disconnected arm then panicked "QEMU died before ..." with the marker in the file it printed: root_candidate_malformed, root_named_but_absent and root_candidate_overlaps went red 3 of 3. Both arms now ask the one closure whether the UART carries the marker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- tests/common/qemu.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 063eeeb2221..9931fab902b 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -5097,6 +5097,10 @@ fn wait_for_ready( let (onum, oden) = oversubscription(options.smp); let boot_timeout = Duration::from_secs(10) * WIDTH.load(Ordering::SeqCst).max(2) * num / den * onum / oden; + // A guest that dies before virtio-console init never reaches stdio at all; + // the UART file is the only channel it has, whether QEMU is still up or not. + let on_the_uart = + || !panic_aborts && fs::read_to_string(uart_log).is_ok_and(|s| s.contains(ready)); let start = Instant::now(); let mut seen = String::new(); loop { @@ -5162,17 +5166,16 @@ fn wait_for_ready( seen.push('\n'); continue; } - // A guest that dies before virtio-console init never reaches - // stdio at all; the UART file is the only channel it has. Err(RecvTimeoutError::Timeout) => { - if !panic_aborts - && fs::read_to_string(uart_log).is_ok_and(|s| s.contains(ready)) - { + if on_the_uart() { break; } continue; } Err(RecvTimeoutError::Disconnected) => { + if on_the_uart() { + break; + } let status = child.wait(); let uart = fs::read_to_string(uart_log).unwrap_or_default(); panic!( From ea1e30a8e678d86d3cdd675415df7c82987d357f Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 13:00:30 +0200 Subject: [PATCH 21/26] The loader takes its own ESP once, before anything can panic The panic handler derived the loader's own ESP again through an exclusive LoadedImage open. A panic while main held that protocol left it None, and the fall could then take the loader's own entry: the loop the handler exists to prevent. main takes it right after uefi_services::init into a once-set cell; the handler reads the cell, and a pass that failed before the cell was set powers off rather than guessing. The request pass and point_at_us take the same value, so our_partition has one caller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- bootloader/src/bootnext.rs | 34 ++++++++++++++++++++++++++++++---- bootloader/src/main.rs | 17 ++++++++++------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/bootloader/src/bootnext.rs b/bootloader/src/bootnext.rs index 93853c86d38..9c9841fa990 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -13,6 +13,8 @@ //! that booted us from a removable-media fallback path has no entry of ours at //! all and must be told so rather than have one guessed at. +use core::cell::OnceCell; + use uefi::prelude::*; use uefi::proto::device_path::DevicePath; use uefi::proto::loaded_image::LoadedImage; @@ -27,15 +29,15 @@ const HEAD: &str = "Boot chain:"; /// A refusal is not a failure of the boot: the kernel still runs and still seals /// its page. What is lost is the *next* boot, so the line says exactly that /// rather than reporting a variable write. -pub fn point_at_us(handle: Handle, system_table: &SystemTable) { - let Some(ours) = our_partition(handle, system_table) else { +pub fn point_at_us(ours: Option<&[u8; 16]>, system_table: &SystemTable) { + let Some(ours) = ours else { return println!( "{HEAD} firmware did not load this image off a GPT partition, so there is no entry \ of ours to come back to and the boot after a reset is the firmware's own" ); }; let rt = system_table.runtime_services(); - let entry = match bootvars::naming(rt, &ours) { + let entry = match bootvars::naming(rt, ours) { Ok(Some(entry)) => entry, Ok(None) => { return println!( @@ -51,8 +53,32 @@ pub fn point_at_us(handle: Handle, system_table: &SystemTable) { } } +/// This loader's own ESP, as [`take_ours`] found it. +/// +/// Taken before anything can panic, because the panic handler cannot open +/// `LoadedImage` while the pass it interrupted holds that protocol exclusively. +/// One processor, no preemption, and no firmware callback reads the cell. +struct Ours(OnceCell>); + +// SAFETY: [`Ours`]'s own contract; nothing else in this crate names the type. +unsafe impl Sync for Ours {} + +static OURS: Ours = Ours(OnceCell::new()); + +/// Find this loader's own ESP, once per pass. +pub fn take_ours(handle: Handle, system_table: &SystemTable) -> Option<[u8; 16]> { + let ours = our_partition(handle, system_table); + assert!(OURS.0.set(ours).is_ok(), "the loader's own ESP is taken once per pass"); + ours +} + +/// What [`take_ours`] found, for the panic handler. +pub fn ours() -> Result, &'static str> { + OURS.0.get().copied().ok_or("this pass failed before the loader took its own ESP") +} + /// The GPT partition GUID of the volume firmware loaded this image from. -pub(crate) fn our_partition(handle: Handle, system_table: &SystemTable) -> Option<[u8; 16]> { +fn our_partition(handle: Handle, system_table: &SystemTable) -> Option<[u8; 16]> { let bs = system_table.boot_services(); let image = bs.open_protocol_exclusive::(handle).ok()?; let device = image.device()?; diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index 8c0b403e2eb..2f562805ed1 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -874,11 +874,13 @@ fn panic(info: &core::panic::PanicInfo) -> ! { println!("[PANIC]: {info}"); let system_table = uefi_services::system_table(); let rt = system_table.runtime_services(); - let ours = bootnext::our_partition(system_table.boot_services().image_handle(), &system_table); - let fell = bootvars::after_this_one(rt, ours.as_ref()).and_then(|(current, next)| { - let next = next.ok_or_else(|| alloc::format!("BootOrder holds no entry after Boot{current:04X}"))?; - bootvars::boot_next(rt, next).map(|()| (current, next)) - }); + let fell = bootnext::ours() + .map_err(alloc::string::String::from) + .and_then(|ours| bootvars::after_this_one(rt, ours.as_ref())) + .and_then(|(current, next)| { + let next = next.ok_or_else(|| alloc::format!("BootOrder holds no entry after Boot{current:04X}"))?; + bootvars::boot_next(rt, next).map(|()| (current, next)) + }); let reset = match fell { Ok((current, next)) => { println!("{} this pass failed, so BootNext=Boot{next:04X}, the entry after Boot{current:04X} in BootOrder", bootvars::HEAD); @@ -902,6 +904,8 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { // boot manager is a pass whose image the boot manager then unloads. See // `end_this_pass`. let exit_event = uefi_services::init(&mut system_table).unwrap(); + // Before anything else can panic: the panic handler falls past this ESP. + let ours = bootnext::take_ours(handle, &system_table); // First, because it covers everything below it: firmware starts a // five-minute countdown when it loads an image and resets the machine if // the image neither exits boot services nor disables it, and a minute is @@ -1019,7 +1023,6 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { // Before either end of the chain below: a request the running system left // is due at the next pass, and a report pass is that pass as often as not. println!("{}", bootvars::state(system_table.runtime_services())); - let ours = bootnext::our_partition(handle, &system_table); let fired = request::firmware(handle, &system_table, ours.as_ref()); if retry { // **The hang, and the only bound there is on one.** The last boot of @@ -1149,7 +1152,7 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { // The page says a kernel is running, and `BootNext` says this loader gets the // machine again however that kernel ends. blackbox::arm(page, armed_at(&system_table), log_guid); - bootnext::point_at_us(handle, &system_table); + bootnext::point_at_us(ours.as_ref(), &system_table); // The last act before the jump, so the smallest possible span of this loader // is inside the bound: everything above it can still be reported, and a hang From 7761abb3a957a2c9cd9ddd13bd78af0ae785ebed Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 13:00:30 +0200 Subject: [PATCH 22/26] update --once refuses by name to drop another ESP's boot An install asked for once replaced a standing Next::Esp with its own slot, silently, while --boot-next refuses to drop a slot asked for once. Request::install is the one rule for what an install leaves asked: a slot asked for once is answered, an ESP's boot is kept, and --once over one is refused by the ESP's GUID before any byte is written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- toyos-update/src/slots.rs | 26 ++++++++++++++++++++++++++ userland/update/src/main.rs | 11 +++++------ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/toyos-update/src/slots.rs b/toyos-update/src/slots.rs index 5b0d175d4ad..01c2a446214 100644 --- a/toyos-update/src/slots.rs +++ b/toyos-update/src/slots.rs @@ -139,6 +139,20 @@ impl Request { Some(Next::Esp(_)) | None => Ok(Self { next: Some(Next::Esp(guid)), ..self }), } } + + /// This request once an image is installed into `idle`, booted `once` or + /// marked: a slot asked for once before is answered by the install, and a + /// boot of another ESP is kept, or refused by its GUID where `once` would + /// replace it unmade. + pub fn install(self, idle: Which, once: bool) -> Result { + let next = match (self.next, once) { + (Some(Next::Esp(guid)), true) => return Err(guid), + (Some(Next::Esp(guid)), false) => Some(Next::Esp(guid)), + (Some(Next::Slot(_) | Next::Trial(_)) | None, true) => Some(Next::Slot(idle)), + (Some(Next::Slot(_) | Next::Trial(_)) | None, false) => None, + }; + Ok(Self { next, ..self }) + } } /// One copy of the table. @@ -504,6 +518,18 @@ mod tests { } } + #[test] + fn an_install_answers_a_slot_asked_once_and_never_drops_an_esp() { + let asked = Request { next: Some(Next::Esp([1; 16])), first: true }; + assert_eq!(asked.install(Which::B, false), Ok(asked)); + assert_eq!(asked.install(Which::B, true), Err([1; 16])); + for next in [None, Some(Next::Slot(Which::A)), Some(Next::Trial(Which::B))] { + let asked = Request { next, first: true }; + assert_eq!(asked.install(Which::B, false), Ok(Request { next: None, first: true })); + assert_eq!(asked.install(Which::B, true), Ok(Request { next: Some(Next::Slot(Which::B)), first: true })); + } + } + /// The check value every CRC-32 is held to. #[test] fn the_checksum_is_crc32() { diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index 6eafe295367..95c60c9a267 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -49,7 +49,7 @@ use toyos::endow::Endowments; use toyos::PartitionDev; use toyos_abi::part::{Block, BLOCK_BYTES, MAX_BLOCKS_PER_CALL}; use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; -use toyos_update::slots::{self, Next, Table, Which}; +use toyos_update::slots::{self, Table, Which}; use toyos_fat32::BlockAccess as _; use toyos_update::{policy, sig}; @@ -134,6 +134,9 @@ fn run(asked: Asked, began: Instant) -> Result { .find(|&w| table.slot(w).is_some_and(|s| s.boot == boot_guid && s.root == root_info.unique_guid)) .ok_or("the partitions this process holds are no slot the table names")?; let running = table.slot(idle.other()).ok_or("the table carries no running slot")?; + let request = table.request.install(idle, once).map_err(|guid| { + format!("EFI system partition {} is asked for once, and --once would drop that boot unmade", toyos_gpt::Guid(guid)) + })?; let mut input = std::io::stdin().lock(); let mut signed = [0u8; SIGNED_BYTES]; @@ -175,15 +178,11 @@ fn run(asked: Asked, began: Instant) -> Result { root.sync().map_err(|e| format!("slot {}'s ROOT is not durable: {e:?}", idle.letter()))?; boot.sync().map_err(|e| format!("slot {}'s volume is not durable: {e:?}", idle.letter()))?; - let mut next = table; + let mut next = Table { request, ..table }; let mut slot = table.slot(idle).expect("the idle slot is in the table"); slot.version = header.version; next.slots[idle.index()] = Some(slot); - // A slot asked for once before this install is answered by it: the idle - // slot is marked now, or is the one asked for. - next.request.next = next.request.next.filter(|n| matches!(n, Next::Esp(_))); let when = if once { - next.request.next = Some(Next::Slot(idle)); format!("it boots once at the next reboot, and slot {} at every boot after", table.marked.letter()) } else { next.marked = idle; From 19a37c6fc9dbe3814efbe3b7c8363ff41fd17e93 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 13:00:40 +0200 Subject: [PATCH 23/26] A failed pass with nothing behind its own entry powers the machine off update_no_entry_powers_off stages the no-slot machine with every entry behind the stick's in BootOrder made inactive, and holds the pass to saying there is no entry to fall to, QEMU (which takes every reset here) exiting, and exactly one loader banner. A WARM reset in place of SHUTDOWN loops into the same failure and turns it red. Rig::bend_kernel is the one way the tests here bend a slot's kernel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- tests/common/update.rs | 76 +++++++++++++++++++++++++++++++++--------- tests/toyos.rs | 7 ++-- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/tests/common/update.rs b/tests/common/update.rs index 3ce91981d25..6b4d7b8a598 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -277,6 +277,17 @@ impl Rig { image::read_file_on(&mut file, slot.boot, toyos_update::slots::SIGNED_FILE) } + /// Flip a byte of slot `which`'s kernel, past its signed header: the + /// signature still verifies and the hash does not. + fn bend_kernel(&self, which: Which) -> Result<(), String> { + let mut file = std::fs::File::open(&self.image).map_err(|e| format!("{}: {e}", self.image.display()))?; + let slot = image::slot_table_of(&mut file)?.slot(which).ok_or_else(|| format!("no slot {}", which.letter()))?; + let mut kernel = image::read_file_on(&mut file, slot.boot, toyos_update::slots::KERNEL_FILE)?; + drop(file); + kernel[100] ^= 0x01; + image::overwrite_file_on(&self.image, slot.boot, toyos_update::slots::KERNEL_FILE, &kernel) + } + /// The name of the floor this machine's loader keeps. fn floor_name(&self) -> Result { let key = signing::key(); @@ -622,12 +633,7 @@ pub fn update_refused_pass_credits_no_image(_: &Path, _: &[(String, Vec)], _ } // A byte of slot A's kernel, and slot B holds no image: every slot is // refused, so the pass panics after its first write and before its second. - let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; - let a = image::slot_table_of(&mut file)?.slot(Which::A).ok_or("no slot A")?; - let mut kernel = image::read_file_on(&mut file, a.boot, toyos_update::slots::KERNEL_FILE)?; - drop(file); - kernel[100] ^= 0x01; - image::overwrite_file_on(&rig.image, a.boot, toyos_update::slots::KERNEL_FILE, &kernel)?; + rig.bend_kernel(Which::A)?; let refused = rig.launch("Slots: no slot verifies"); said(refused.boot_log(), &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slot B: REFUSED"])?; drop(refused); @@ -845,11 +851,8 @@ pub fn update_trial_writes_nothing_of_the_kept_slot(_: &Path, _: &[(String, Vec< eprintln!(" [update] the trial held no grant, slot A kept its image, and the boot after was slot A's"); // A trial the loader refuses: slot B's kernel bent, and asked for once. - let b = table.slot(Which::B).ok_or("no slot B")?; - let mut kernel = image::read_file_on(&mut file, b.boot, toyos_update::slots::KERNEL_FILE)?; drop(file); - kernel[100] ^= 0x01; - image::overwrite_file_on(&rig.image, b.boot, toyos_update::slots::KERNEL_FILE, &kernel)?; + rig.bend_kernel(Which::B)?; image::restage_table(&rig.image, |t| t.request.next = Some(toyos_update::slots::Next::Slot(Which::B)))?; let (guest, console) = rig.boot()?; loader_said(&guest, 0, "Slot B: REFUSED, its kernel is not the bytes its signed header names")?; @@ -931,12 +934,7 @@ pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)] planted_order.insert(1, planted); vars::plant_global(&rig.vars, "BootOrder", &planted_order.iter().flat_map(|n| n.to_le_bytes()).collect::>())?; - let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; - let a = image::slot_table_of(&mut file)?.slot(Which::A).ok_or("no slot A")?; - let mut kernel = image::read_file_on(&mut file, a.boot, toyos_update::slots::KERNEL_FILE)?; - drop(file); - kernel[100] ^= 0x01; - image::overwrite_file_on(&rig.image, a.boot, toyos_update::slots::KERNEL_FILE, &kernel)?; + rig.bend_kernel(Which::A)?; // The recovery stick's own loader, naming its log partition: the pass // that follows the fall. let theirs: &'static str = Box::leak(Rig::loader_of(rig.recovery()?)?.into_boxed_str()); @@ -962,6 +960,52 @@ pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)] Ok(()) } +/// What the loader's panic handler says of a failed pass with nothing behind +/// its own entry. +const NO_ENTRY: &str = "this pass failed, and there is no entry to fall to"; + +/// **A failed pass with no entry behind its own powers the machine off**, +/// never resetting into the same failure: slot A's kernel carries a flipped +/// byte and slot B holds no image, so no slot verifies, and every entry behind +/// the stick's in `BootOrder` is made inactive (`LOAD_OPTION_ACTIVE`, UEFI 2.10 +/// §3.1.3, cleared). The pass says so, and QEMU, which takes every reset here, +/// exits after that one pass. +pub fn update_no_entry_powers_off(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-no-entry")?; + // The firmware's own entries, which its first boot writes. + drop(rig.launch("Slots: the table marks")); + rig.powered_off_cleanly()?; + let order = vars::global(&rig.vars, "BootOrder")?; + let order: Vec = order.chunks(2).map(|w| u16::from_le_bytes([w[0], w[1]])).collect(); + let (current, behind) = order.split_first().ok_or("the firmware wrote an empty BootOrder")?; + for n in behind { + let name = format!("Boot{n:04X}"); + let mut option = vars::global(&rig.vars, &name)?; + option[0] &= !1; + vars::plant_global(&rig.vars, &name, &option)?; + } + rig.bend_kernel(Which::A)?; + let mut fell = rig.launch("Boot entries: this pass failed"); + let fall = fell.boot_log().lines().find(|l| l.contains("this pass failed")).unwrap_or_default().to_string(); + if !fall.contains(NO_ENTRY) { + let state = fell.boot_log().lines().find(|l| l.contains("this pass was booted as")).unwrap_or_default().to_string(); + return Err(format!( + "with Boot{current:04X}'s followers {behind:04X?} made inactive, the pass fell by {fall:?} ({state:?}), where {NO_ENTRY:?} is owed" + )); + } + let after = fell.await_exit(std::time::Duration::from_secs(30)).map_err(|why| format!("the machine did not power off after {fall:?}: {why}"))?; + let all = format!("{}{after}", fell.boot_log()); + said(&all, &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slots: no slot verifies"])?; + let passes = all.matches(bootlog::LOADER_FIRST_LINE).count(); + if passes != 1 { + return Err(format!("the loader ran {passes} passes, where one that powers the machine off is owed:\n{all}")); + } + eprintln!(" [update] {fall}"); + drop(fell); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + /// An active `EFI_LOAD_OPTION` for `HD()/ /// \EFI\BOOT\BOOTX64.EFI`, its bytes written from UEFI 2.10 §3.1.3 and §10.3.6 /// and the partition read out of the GPT entry array by §5.3.3's layout. diff --git a/tests/toyos.rs b/tests/toyos.rs index 1b09926dc14..fb9e574992d 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -830,11 +830,12 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("update_refused_pass_credits_no_image", Sched::Parallel, Tier::Nightly), // The loader writes the firmware's boot variables on the running system's // request: a boot of another stick once, its own entry first, and the - // recovery stick behind it where no slot verifies. Boots of two sticks, - // nightly like the rest of the machine's. + // recovery stick behind it where no slot verifies, or a power-off where + // nothing is behind it. Nightly like the rest of the machine's. ("update_boot_next_boots_the_entry_once", Sched::Parallel, Tier::Nightly), ("update_boot_first_puts_the_loader_first", Sched::Parallel, Tier::Nightly), ("update_no_slot_boots_the_recovery_stick", Sched::Parallel, Tier::Nightly), + ("update_no_entry_powers_off", Sched::Parallel, Tier::Nightly), ("update_trial_writes_nothing_of_the_kept_slot", Sched::Parallel, Tier::Nightly), // The bench: `toyos-metal` delivers a staged boot to a machine running // ToyOS alone with `update --once`, swaps its netd, hands it back and @@ -1630,6 +1631,7 @@ const CARRIES: &[(&str, &[&str])] = &[ ("update_boot_next_boots_the_entry_once", &[]), ("update_boot_first_puts_the_loader_first", &[]), ("update_no_slot_boots_the_recovery_stick", &[]), + ("update_no_entry_powers_off", &[]), ("update_trial_writes_nothing_of_the_kept_slot", &[]), // The boot it delivers is staged as the metal profile stages it, with the // swap rehearsal's hold job on it. @@ -15498,6 +15500,7 @@ fn run_machine_test( "update_no_slot_boots_the_recovery_stick" => { common::update::update_no_slot_boots_the_recovery_stick(test_config, c_bins, rust_bins) } + "update_no_entry_powers_off" => common::update::update_no_entry_powers_off(test_config, c_bins, rust_bins), "update_trial_writes_nothing_of_the_kept_slot" => { common::update::update_trial_writes_nothing_of_the_kept_slot(test_config, c_bins, rust_bins) } From c650fbc2e42e646786c07e5a38113c2d37b2d84b Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 13:00:41 +0200 Subject: [PATCH 24/26] The track owes the owner's last resort, and designs no tool for it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- .../boot-media/the-machine-updates-itself-without-ubuntu.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md index 06fad4feaec..2a2ba06757a 100644 --- a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md +++ b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md @@ -47,10 +47,7 @@ stdin — and the machine installs nothing the owner did not sign. file servers (#536), since the kernel owns the only NVMe controller today. `--via-ubuntu` goes with it. 3. **The owner's last resort**, where no slot and no entry behind it boots: a - stick written on the Mac by this tree's own tooling — `cargo run -- - --write-stick /dev/diskN`, which unmounts the stick's volumes through - DiskArbitration, refuses an internal disk or one of an unexpected size by - name, writes the image and reads it back. Never `dd` or `diskutil`. Not + stick written by this tree's own tooling. Not built; until it is, the last resort is the firmware's boot menu and the Ubuntu still on the NVMe. From 23834021e7aa12b28a10b11dc595e7db8e94d34c Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 15:29:43 +0200 Subject: [PATCH 25/26] update_no_entry_powers_off clears the followers each pass reads, until a pass reads none active The setup cleared the entries behind the stick's in the BootOrder the firmware's first boot wrote, a list fixed in advance. At the next boot the firmware wrote an active entry the setup never saw (Boot0003, in the pass's "BootOrder is 0001,0003,0000"), and the failed pass fell to it. The machine now boots good passes until one reads a BootOrder whose every entry behind its own is inactive: after each, the host parses the loader's own "this pass was booted as ...; BootOrder is ..." line, clears every active entry behind the pass's in that order, and boots again. A firmware that still puts an active entry there after four passes reds the test by name, with every pass's order and which of its followers were active. Only then is slot A's kernel bent for the failed pass, which is held, as before, to "there is no entry to fall to", QEMU's exit, and one loader banner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- tests/common/update.rs | 73 ++++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/tests/common/update.rs b/tests/common/update.rs index 6b4d7b8a598..f62446563f0 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -964,33 +964,72 @@ pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)] /// its own entry. const NO_ENTRY: &str = "this pass failed, and there is no entry to fall to"; +/// The loader's line naming the entry that booted its pass and the +/// `BootOrder` it read (`bootloader/src/bootvars.rs`). +const PASS_STATE: &str = "Boot entries: this pass was booted as Boot"; + +/// The most passes [`update_no_entry_powers_off`] boots to find every entry +/// behind the stick's inactive: a firmware still putting an active one there +/// after this many does so at every boot. +const SETTLE_PASSES: usize = 4; + +/// The entry that booted a pass, and the `BootOrder` it read, off `log`. +fn pass_state(log: &str) -> Result<(u16, Vec), String> { + let line = log.lines().find(|l| l.contains(PASS_STATE)).ok_or_else(|| format!("the loader never said {PASS_STATE:?}:\n{log}"))?; + let rest = &line[line.find(PASS_STATE).expect("found above") + PASS_STATE.len()..]; + let (current, order) = rest.split_once("; BootOrder is ").ok_or_else(|| format!("no BootOrder in {line:?}"))?; + let hex = |n: &str| u16::from_str_radix(n, 16).map_err(|e| format!("{n:?} in {line:?}: {e}")); + Ok((hex(current)?, order.split(',').map(hex).collect::>()?)) +} + /// **A failed pass with no entry behind its own powers the machine off**, /// never resetting into the same failure: slot A's kernel carries a flipped /// byte and slot B holds no image, so no slot verifies, and every entry behind -/// the stick's in `BootOrder` is made inactive (`LOAD_OPTION_ACTIVE`, UEFI 2.10 -/// §3.1.3, cleared). The pass says so, and QEMU, which takes every reset here, -/// exits after that one pass. +/// the stick's in the `BootOrder` a pass reads is inactive +/// (`LOAD_OPTION_ACTIVE`, UEFI 2.10 §3.1.3, cleared). The firmware writes +/// entries at any boot, so the machine boots until a pass finds every entry +/// behind its own inactive, clearing the active ones each pass found. The +/// failed pass says so, and QEMU, which takes every reset here, exits after +/// that one pass. pub fn update_no_entry_powers_off(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { let rig = Rig::stage("update-no-entry")?; - // The firmware's own entries, which its first boot writes. - drop(rig.launch("Slots: the table marks")); - rig.powered_off_cleanly()?; - let order = vars::global(&rig.vars, "BootOrder")?; - let order: Vec = order.chunks(2).map(|w| u16::from_le_bytes([w[0], w[1]])).collect(); - let (current, behind) = order.split_first().ok_or("the firmware wrote an empty BootOrder")?; - for n in behind { - let name = format!("Boot{n:04X}"); - let mut option = vars::global(&rig.vars, &name)?; - option[0] &= !1; - vars::plant_global(&rig.vars, &name, &option)?; - } + let mut passes = Vec::new(); + let (current, behind) = loop { + let pass = rig.launch("Slots: the table marks"); + let (current, order) = pass_state(pass.boot_log())?; + drop(pass); + rig.powered_off_cleanly()?; + let at = order + .iter() + .rposition(|&e| e == current) + .ok_or_else(|| format!("the pass was booted as Boot{current:04X}, which its BootOrder {order:04X?} does not hold"))?; + let behind: Vec = order[at + 1..].iter().copied().filter(|&e| e != current).collect(); + let mut active = Vec::new(); + for &n in &behind { + let name = format!("Boot{n:04X}"); + let mut option = vars::global(&rig.vars, &name)?; + if option[0] & 1 != 0 { + option[0] &= !1; + vars::plant_global(&rig.vars, &name, &option)?; + active.push(n); + } + } + passes.push(format!("Boot{current:04X} then {behind:04X?}, {active:04X?} of them active")); + if active.is_empty() { + break (current, behind); + } + if passes.len() == SETTLE_PASSES { + return Err(format!("the firmware put an active entry behind its stick's at each of {SETTLE_PASSES} passes: {passes:?}")); + } + }; + eprintln!(" [update] passes until every entry behind the stick's was inactive: {passes:?}"); rig.bend_kernel(Which::A)?; let mut fell = rig.launch("Boot entries: this pass failed"); let fall = fell.boot_log().lines().find(|l| l.contains("this pass failed")).unwrap_or_default().to_string(); if !fall.contains(NO_ENTRY) { - let state = fell.boot_log().lines().find(|l| l.contains("this pass was booted as")).unwrap_or_default().to_string(); + let state = fell.boot_log().lines().find(|l| l.contains(PASS_STATE)).unwrap_or_default().to_string(); return Err(format!( - "with Boot{current:04X}'s followers {behind:04X?} made inactive, the pass fell by {fall:?} ({state:?}), where {NO_ENTRY:?} is owed" + "with Boot{current:04X}'s followers {behind:04X?} inactive at the pass before, the pass fell by {fall:?} ({state:?}), where {NO_ENTRY:?} is owed" )); } let after = fell.await_exit(std::time::Duration::from_secs(30)).map_err(|why| format!("the machine did not power off after {fall:?}: {why}"))?; From b7299abb143e6ade0198e7e5d4167cf9af05c416 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 17:54:11 +0200 Subject: [PATCH 26/26] Delete update_no_entry_powers_off: the pinned OVMF always writes an active entry behind the stick's The test needed a pass with nothing active behind the stick's entry. Round 9 set this up by clearing, over four passes, every follower the pass had read. The orchestrator's run at 133d5dcd measured the result: at each of the 4 passes the firmware had put back an active entry behind the stick's, first Boot0002 and Boot0000, then Boot0003 and Boot0000, and so on. The SHUTDOWN->WARM control arm died on the same setup line, so it measured nothing. EDK2's source at the pinned OVMF's commit (edk2-gf0064ac3af, f0064ac3afa28e1aa3b6b9c22c6cf422a4bb8771) says why. The firmware writes two active entries behind every device at every boot: - The EFI Internal Shell (BdsPlatform.c:1712, LOAD_OPTION_ACTIVE). The firmware matches an existing option on its attributes too (BmLoadOption.c:558), so a Shell entry made inactive is replaced by a new active one. - The Boot Manager Menu, UiApp (BdsEntry.c:991, CATEGORY_APP|ACTIVE|HIDDEN, BmBoot.c:2533). The firmware registers it again whenever BootOrder lists none. SetBootOrderFromQemu then rebuilds BootOrder from the active options alone, the bootindex devices first. That makes Boot0000 UiApp, Boot0001 the stick, and Boot0002 and Boot0003 the Shell. The Shell takes those two numbers in turn because BmGetFreeOptionNumber treats as free a number BootOrder does not list. The firmware owns its entries. Without overriding the firmware's order at every boot, no disk and no variable setup reaches the loader's no-entry arm. So the test goes. The arm is filed as proven by nothing, with its exit. The firmware's order also shows a second finding: entry::after counts a CATEGORY_APP entry, which the firmware's own BootOrder walk skips. That is filed too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j --- ...the-firmware-never-boots-from-its-order.md | 30 +++++++ ...no-entry-behind-it-is-proven-by-nothing.md | 45 ++++++++++ tests/common/update.rs | 85 ------------------- tests/toyos.rs | 7 +- 4 files changed, 77 insertions(+), 90 deletions(-) create mode 100644 issues/boot-media/a-failed-pass-can-fall-to-an-entry-the-firmware-never-boots-from-its-order.md create mode 100644 issues/boot-media/the-loaders-power-off-with-no-entry-behind-it-is-proven-by-nothing.md diff --git a/issues/boot-media/a-failed-pass-can-fall-to-an-entry-the-firmware-never-boots-from-its-order.md b/issues/boot-media/a-failed-pass-can-fall-to-an-entry-the-firmware-never-boots-from-its-order.md new file mode 100644 index 00000000000..04a1b9ab515 --- /dev/null +++ b/issues/boot-media/a-failed-pass-can-fall-to-an-entry-the-firmware-never-boots-from-its-order.md @@ -0,0 +1,30 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# A failed pass can fall to an entry the firmware never boots from its order + +`toyos_update::entry::after` picks the entry a failed pass hands the machine to +as "the entry the firmware would have tried after this one". It skips inactive +entries and entries naming the loader's own ESP, but not an entry whose +category is not `LOAD_OPTION_CATEGORY_BOOT`. UEFI 2.10 §3.1.3 says a +`LOAD_OPTION_CATEGORY_APP` option is "not part of the normal boot processing". +EDK2's boot manager skips one in its `BootOrder` walk +(`MdeModulePkg/Universal/BdsDxe/BdsEntry.c:402-406` at `f0064ac3af`, the pinned +OVMF), but boots it when `BootNext` names it. + +OVMF writes such an entry at every boot: the Boot Manager Menu (UiApp, +`CATEGORY_APP | ACTIVE | HIDDEN`), behind the stick's entry and the Shell. In +QEMU the Shell comes first, so no test has fallen to it. On a firmware whose +first follower is an application (a setup, diagnostics or menu entry), the +loader would set `BootNext` to it. The machine would then boot into that +application, which the firmware's own order never reaches, and the fall would +end there. + +This is read from the specification and EDK2's source. It is not measured. + +**Exit**: `after` passes over an entry whose category is not +`LOAD_OPTION_CATEGORY_BOOT`. A host test holds that, with a mutation that +drops the check and reds it. diff --git a/issues/boot-media/the-loaders-power-off-with-no-entry-behind-it-is-proven-by-nothing.md b/issues/boot-media/the-loaders-power-off-with-no-entry-behind-it-is-proven-by-nothing.md new file mode 100644 index 00000000000..2f7dd44643f --- /dev/null +++ b/issues/boot-media/the-loaders-power-off-with-no-entry-behind-it-is-proven-by-nothing.md @@ -0,0 +1,45 @@ +--- +status: open +kind: tooling +opened: 2026-09-28 +--- + +# The loader's power-off with no entry behind it is proven by nothing + +The loader's panic handler (`bootloader/src/main.rs`, `fn panic`) sets +`BootNext` to `toyos_update::entry::after`'s entry and resets, or powers the +machine off where `after` finds none. `after`'s `None` is host-tested +(`the_entry_after_is_later_in_the_order_and_boots_something_else`). The arm +that turns `None` into `ResetType::SHUTDOWN` is held by nothing but reading: +mutating `SHUTDOWN` to `WARM` reds no test. + +**QEMU cannot reach the arm without overriding the firmware.** The pinned OVMF +(`edk2-gf0064ac3af`, commit `f0064ac3afa28e1aa3b6b9c22c6cf422a4bb8771`) puts +two active entries behind the boot stick's at every boot: + +- **EFI Internal Shell** (`CATEGORY_BOOT`). `PlatformRegisterFvBootOption` + (`OvmfPkg/Library/PlatformBootManagerLib/BdsPlatform.c:1712`) registers it + with `LOAD_OPTION_ACTIVE`. `EfiBootManagerFindLoadOption` + (`MdeModulePkg/Library/UefiBootManagerLib/BmLoadOption.c:558`) matches an + existing option on its attributes too, so a Shell entry made inactive never + matches, and a new active one is written. +- **The Boot Manager Menu, UiApp** (`CATEGORY_APP | ACTIVE | HIDDEN`, + `BmBoot.c:2533`). `EfiBootManagerGetBootManagerMenu` + (`MdeModulePkg/Universal/BdsDxe/BdsEntry.c:991`) registers it again whenever + `BootOrder` lists none. +- `SetBootOrderFromQemu` (`BdsPlatform.c:1719`) rebuilds `BootOrder` from the + active options alone (`CollectActiveOptions`). It puts the devices QEMU's + `bootindex` names first, and `BootOrderComplete` appends the firmware-volume + applications behind them. + +So the stick's entry always has the Shell and UiApp behind it. The orchestrator +measured this over four passes: each time the test cleared both, the firmware +wrote them back active. It numbered the Shell 0002 and 0003 in turn, because +`BmGetFreeOptionNumber` treats as free every number `BootOrder` does not list. + +**Exit**: either one of these, or the arm and its claim are deleted: + +- a test on a machine whose own firmware leaves nothing active behind the + stick's entry, judged by the power-off rather than by a reset; +- the handler's choice made a pure function that a host test holds, so that + only the `ResetType` wiring is left unproven. diff --git a/tests/common/update.rs b/tests/common/update.rs index f62446563f0..6a0d599be83 100644 --- a/tests/common/update.rs +++ b/tests/common/update.rs @@ -960,91 +960,6 @@ pub fn update_no_slot_boots_the_recovery_stick(_: &Path, _: &[(String, Vec)] Ok(()) } -/// What the loader's panic handler says of a failed pass with nothing behind -/// its own entry. -const NO_ENTRY: &str = "this pass failed, and there is no entry to fall to"; - -/// The loader's line naming the entry that booted its pass and the -/// `BootOrder` it read (`bootloader/src/bootvars.rs`). -const PASS_STATE: &str = "Boot entries: this pass was booted as Boot"; - -/// The most passes [`update_no_entry_powers_off`] boots to find every entry -/// behind the stick's inactive: a firmware still putting an active one there -/// after this many does so at every boot. -const SETTLE_PASSES: usize = 4; - -/// The entry that booted a pass, and the `BootOrder` it read, off `log`. -fn pass_state(log: &str) -> Result<(u16, Vec), String> { - let line = log.lines().find(|l| l.contains(PASS_STATE)).ok_or_else(|| format!("the loader never said {PASS_STATE:?}:\n{log}"))?; - let rest = &line[line.find(PASS_STATE).expect("found above") + PASS_STATE.len()..]; - let (current, order) = rest.split_once("; BootOrder is ").ok_or_else(|| format!("no BootOrder in {line:?}"))?; - let hex = |n: &str| u16::from_str_radix(n, 16).map_err(|e| format!("{n:?} in {line:?}: {e}")); - Ok((hex(current)?, order.split(',').map(hex).collect::>()?)) -} - -/// **A failed pass with no entry behind its own powers the machine off**, -/// never resetting into the same failure: slot A's kernel carries a flipped -/// byte and slot B holds no image, so no slot verifies, and every entry behind -/// the stick's in the `BootOrder` a pass reads is inactive -/// (`LOAD_OPTION_ACTIVE`, UEFI 2.10 §3.1.3, cleared). The firmware writes -/// entries at any boot, so the machine boots until a pass finds every entry -/// behind its own inactive, clearing the active ones each pass found. The -/// failed pass says so, and QEMU, which takes every reset here, exits after -/// that one pass. -pub fn update_no_entry_powers_off(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { - let rig = Rig::stage("update-no-entry")?; - let mut passes = Vec::new(); - let (current, behind) = loop { - let pass = rig.launch("Slots: the table marks"); - let (current, order) = pass_state(pass.boot_log())?; - drop(pass); - rig.powered_off_cleanly()?; - let at = order - .iter() - .rposition(|&e| e == current) - .ok_or_else(|| format!("the pass was booted as Boot{current:04X}, which its BootOrder {order:04X?} does not hold"))?; - let behind: Vec = order[at + 1..].iter().copied().filter(|&e| e != current).collect(); - let mut active = Vec::new(); - for &n in &behind { - let name = format!("Boot{n:04X}"); - let mut option = vars::global(&rig.vars, &name)?; - if option[0] & 1 != 0 { - option[0] &= !1; - vars::plant_global(&rig.vars, &name, &option)?; - active.push(n); - } - } - passes.push(format!("Boot{current:04X} then {behind:04X?}, {active:04X?} of them active")); - if active.is_empty() { - break (current, behind); - } - if passes.len() == SETTLE_PASSES { - return Err(format!("the firmware put an active entry behind its stick's at each of {SETTLE_PASSES} passes: {passes:?}")); - } - }; - eprintln!(" [update] passes until every entry behind the stick's was inactive: {passes:?}"); - rig.bend_kernel(Which::A)?; - let mut fell = rig.launch("Boot entries: this pass failed"); - let fall = fell.boot_log().lines().find(|l| l.contains("this pass failed")).unwrap_or_default().to_string(); - if !fall.contains(NO_ENTRY) { - let state = fell.boot_log().lines().find(|l| l.contains(PASS_STATE)).unwrap_or_default().to_string(); - return Err(format!( - "with Boot{current:04X}'s followers {behind:04X?} inactive at the pass before, the pass fell by {fall:?} ({state:?}), where {NO_ENTRY:?} is owed" - )); - } - let after = fell.await_exit(std::time::Duration::from_secs(30)).map_err(|why| format!("the machine did not power off after {fall:?}: {why}"))?; - let all = format!("{}{after}", fell.boot_log()); - said(&all, &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slots: no slot verifies"])?; - let passes = all.matches(bootlog::LOADER_FIRST_LINE).count(); - if passes != 1 { - return Err(format!("the loader ran {passes} passes, where one that powers the machine off is owed:\n{all}")); - } - eprintln!(" [update] {fall}"); - drop(fell); - let _ = std::fs::remove_dir_all(&rig.scratch); - Ok(()) -} - /// An active `EFI_LOAD_OPTION` for `HD()/ /// \EFI\BOOT\BOOTX64.EFI`, its bytes written from UEFI 2.10 §3.1.3 and §10.3.6 /// and the partition read out of the GPT entry array by §5.3.3's layout. diff --git a/tests/toyos.rs b/tests/toyos.rs index 8113f9a371b..5c258693c22 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -824,12 +824,11 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("update_refused_pass_credits_no_image", Sched::Parallel, Tier::Nightly), // The loader writes the firmware's boot variables on the running system's // request: a boot of another stick once, its own entry first, and the - // recovery stick behind it where no slot verifies, or a power-off where - // nothing is behind it. Nightly like the rest of the machine's. + // recovery stick behind it where no slot verifies. Nightly like the rest of + // the machine's. ("update_boot_next_boots_the_entry_once", Sched::Parallel, Tier::Nightly), ("update_boot_first_puts_the_loader_first", Sched::Parallel, Tier::Nightly), ("update_no_slot_boots_the_recovery_stick", Sched::Parallel, Tier::Nightly), - ("update_no_entry_powers_off", Sched::Parallel, Tier::Nightly), ("update_trial_writes_nothing_of_the_kept_slot", Sched::Parallel, Tier::Nightly), // The bench: `toyos-metal` delivers a staged boot to a machine running // ToyOS alone with `update --once`, swaps its netd, hands it back and @@ -1625,7 +1624,6 @@ const CARRIES: &[(&str, &[&str])] = &[ ("update_boot_next_boots_the_entry_once", &[]), ("update_boot_first_puts_the_loader_first", &[]), ("update_no_slot_boots_the_recovery_stick", &[]), - ("update_no_entry_powers_off", &[]), ("update_trial_writes_nothing_of_the_kept_slot", &[]), // The boot it delivers is staged as the metal profile stages it, with the // swap rehearsal's hold job on it. @@ -15494,7 +15492,6 @@ fn run_machine_test( "update_no_slot_boots_the_recovery_stick" => { common::update::update_no_slot_boots_the_recovery_stick(test_config, c_bins, rust_bins) } - "update_no_entry_powers_off" => common::update::update_no_entry_powers_off(test_config, c_bins, rust_bins), "update_trial_writes_nothing_of_the_kept_slot" => { common::update::update_trial_writes_nothing_of_the_kept_slot(test_config, c_bins, rust_bins) }