diff --git a/bootloader/Cargo.toml b/bootloader/Cargo.toml index 7e57aadd824..2cea4853ed8 100644 --- a/bootloader/Cargo.toml +++ b/bootloader/Cargo.toml @@ -37,7 +37,8 @@ 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 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 # unoptimised guest mismeasures everything under TCG; 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..9c9841fa990 100644 --- a/bootloader/src/bootnext.rs +++ b/bootloader/src/bootnext.rs @@ -2,11 +2,9 @@ //! //! **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. +//! 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 @@ -15,178 +13,75 @@ //! 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::media::PartitionSignature; -use uefi::proto::device_path::{DevicePath, DeviceSubType, DeviceType}; +use uefi::proto::device_path::DevicePath; use uefi::proto::loaded_image::LoadedImage; -use uefi::table::runtime::{VariableAttributes, VariableVendor}; -use uefi::CStr16; + +use crate::bootvars; /// 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 /// 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 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"), } } -/// The GPT partition GUID of the volume firmware loaded this image from. -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()?; - let path = bs.open_protocol_exclusive::(device).ok()?; - hard_drive_guid(path.node_iter()) -} +/// 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>); -/// 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 -} +// SAFETY: [`Ours`]'s own contract; nothing else in this crate names the type. +unsafe impl Sync for Ours {} -/// The number of the `Boot####` entry whose device path names `ours`. -/// -/// Every entry is read rather than only those in `BootOrder`: an entry the owner -/// has moved out of the order is still ours and still the one to come back to. -fn entry_for(system_table: &SystemTable, 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(name) else { continue }; - let Ok((bytes, _)) = rt.get_variable_boxed(name, &key.vendor) else { continue }; - if !load_option_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 -} +static OURS: Ours = Ours(OnceCell::new()); -/// `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) +/// 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 } -/// 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 +/// 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") } -/// 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() +/// The GPT partition GUID of the volume firmware loaded this image from. +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()?; + let path = bs.open_protocol_exclusive::(device).ok()?; + toyos_update::entry::partition(path.as_bytes()).ok().map(|(_, part)| part.guid) } diff --git a/bootloader/src/bootvars.rs b/bootloader/src/bootvars.rs new file mode 100644 index 00000000000..101f5724ade --- /dev/null +++ b/bootloader/src/bootvars.rs @@ -0,0 +1,202 @@ +//! 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::DevicePath; +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; + +/// 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:?})"))?; + 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 (_, 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")); + } + 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"); + 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. +type Entry = (u16, alloc::boxed::Box<[u8]>); + +/// 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 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`: 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) = 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 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); + 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.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), + 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]; + 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})"))?; + 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. +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")?; + Ok((current, entry::after(&order, current, &entries(rt)?, ours))) +} + +/// 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..cd2a3c8effb 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,63 @@ 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"); + +/// 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) { + // 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() { + 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 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})")), + }; + let Some(mut kept) = kept.into_regular_file() else { + return refused_previous(format_args!("{PREVIOUS} is a directory")); + }; + 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})")); + } +} + +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 +220,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 cfdc293def4..2f562805ed1 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}, @@ -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,42 @@ fn load_file_bytes(handle: Handle, system_table: &SystemTable, path: &CStr bytes } +/// 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"; + +/// 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. +/// 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. /// @@ -189,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 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 + } } - 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. @@ -830,9 +847,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(); @@ -845,6 +861,40 @@ fn end_this_pass(system_table: &SystemTable, exit_event: Option) -> system_table.runtime_services().reset(ResetType::WARM, Status::SUCCESS, None) } +/// 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); + 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 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); + 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] fn main(handle: Handle, mut system_table: SystemTable) -> Status { // First: the TSC counts from reset, so this is what firmware took. @@ -854,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 @@ -952,6 +1004,26 @@ 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 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 @@ -977,6 +1049,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" @@ -1011,9 +1088,15 @@ 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) + let once = request::once(handle, &system_table); + 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, digest: chosen.digest }); + 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!( @@ -1038,12 +1121,15 @@ 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(), 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); @@ -1066,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 diff --git a/bootloader/src/request.rs b/bootloader/src/request.rs new file mode 100644 index 00000000000..a18720779e8 --- /dev/null +++ b/bootloader/src/request.rs @@ -0,0 +1,148 @@ +//! 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"); + true + } + Err(why) => { + println!("{HEAD} {what} stands, and is not acted 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::Esp(_))) }; + 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(|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" }; + 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(|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 + } + 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; 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 (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, + }; + 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/rootimage.rs b/bootloader/src/rootimage.rs index 3609e088bab..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[..] { @@ -126,7 +122,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 +141,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>, @@ -185,6 +188,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 mut found = [None; 2]; let scan = toyos_gpt::locate_type(self, Guid::TOYOS_SLOTS, &mut found) .map_err(|e| alloc::format!("the boot disk's partition table: {e:?}"))?; @@ -209,10 +218,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..2159409773a 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,13 +80,24 @@ 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() + ); + } + 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"); match verify(bs, &mut disk, which, slot, floor, Some(record)) { - 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 { @@ -96,12 +113,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()), } } @@ -153,7 +165,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-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/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..8cb9f9b3052 --- /dev/null +++ b/issues/boot-media/a-loader-change-reaches-a-machine-only-by-writing-its-stick.md @@ -0,0 +1,27 @@ +--- +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. + +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 +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-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. 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-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/issues/boot-media/the-machine-updates-itself-without-ubuntu.md b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md index 404ebff7983..2a2ba06757a 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,20 @@ 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: +**Owed:** -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. +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 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. **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 ed36ca62a09..eea032ccb50 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -336,6 +336,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, 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/src/bootlog.rs b/src/bootlog.rs index 14aa18ad1e4..e5a5fd0a668 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -119,6 +119,23 @@ 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 removable-media file on this ESP hashes to"; + +/// 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. 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"; @@ -620,6 +637,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/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 064f521e52e..793087e9b73 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2006,6 +2006,57 @@ 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: 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 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`: 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!( + "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" + )); + } + Ok(format!("{}\n", lines.join("\n"))) +} + /// 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 { @@ -3057,6 +3108,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", @@ -3712,4 +3765,15 @@ 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(())); } + + #[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/flags.rs b/src/flags.rs index 99b5156ef68..494e4efa136 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -88,6 +88,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 43d25814e9f..f8e2220569d 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()); @@ -385,6 +386,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 @@ -409,37 +477,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; @@ -551,6 +599,39 @@ 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())) } +/// 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 (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)) + .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}"))?; + 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: {e}")) +} + /// 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/lib.rs b/src/lib.rs index 3bce5ecd05b..f94fe33529e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,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 4aa5b32c73b..923d9e11056 100644 --- a/src/main.rs +++ b/src/main.rs @@ -267,6 +267,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 dab449ddb95..5edc208512a 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -1,8 +1,13 @@ -//! `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: 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 //! 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 +32,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 +76,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. /// @@ -149,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 }, @@ -187,6 +193,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 +218,7 @@ impl Refusal { | Self::Talk(_) | Self::Swap(_) | Self::Wedge { .. } + | Self::NotThisBoot(_) ) } } @@ -299,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 \ @@ -345,6 +363,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 +569,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 +587,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 +721,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 +729,7 @@ struct Part { } #[derive(Debug, Clone, PartialEq, Eq)] -struct Flashable { +pub(crate) struct Flashable { path: PathBuf, bytes: u64, esp: Part, @@ -876,7 +896,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 +936,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()))?; @@ -1094,7 +1114,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") })?; @@ -1121,7 +1141,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<()>, @@ -1129,7 +1149,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)); @@ -1166,7 +1186,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(); @@ -1174,7 +1194,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") @@ -1424,7 +1444,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: @@ -1537,17 +1557,25 @@ 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 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 +/// swap is made by the same loop once the bench has gone down +/// ([`crate::metalbench::run`]). +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)] @@ -1558,9 +1586,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 @@ -1573,11 +1601,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. @@ -1586,27 +1614,48 @@ 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`]). + 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()) { @@ -1638,6 +1687,9 @@ 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(), }; if let Some(host) = value(&HOST) { let (user, machine) = host.split_once('@').ok_or_else(|| { @@ -1713,16 +1765,85 @@ 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.nic.is_some() || out.fat32_check) { + return Err(Refusal::Usage( + "--resident hands the machine to a bench image and judges no boot, so --readback, \ + --nic and --fat32-check describe a boot it will not judge" + .to_string(), + )); + } Ok(out) } } +/// 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. @@ -1730,12 +1851,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 @@ -1745,7 +1866,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< @@ -1770,25 +1891,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) @@ -1810,19 +1930,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) @@ -1831,7 +1970,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, @@ -1850,12 +1989,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)? { @@ -1944,7 +2084,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 }; @@ -1956,6 +2096,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 @@ -1981,7 +2134,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, }; @@ -2095,6 +2248,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. @@ -2108,7 +2271,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 @@ -2118,21 +2281,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 = toyos_tmpdir::TempDir::new("bench"); + 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"; @@ -2140,7 +2334,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> { @@ -2153,7 +2347,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> { @@ -2183,7 +2377,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", @@ -2285,8 +2479,14 @@ 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, +]; /// Empty a readback directory, before this run can leave any of it standing. /// @@ -2313,7 +2513,7 @@ pub fn clear_readback(dir: &Path) -> Result<(), Refusal> { Ok(()) } -fn write_readback( +pub(crate) fn write_readback( dir: &Path, loader: &str, log: &str, @@ -2540,7 +2740,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()); @@ -2553,8 +2753,9 @@ mod tests { vec!["--wait-secs", "60"], vec!["--nic", "0000:00:1f.6"], vec!["--talk", "/tmp/k"], + vec!["--resident"], ] { - 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(); @@ -2566,7 +2767,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()); } @@ -2582,10 +2783,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() { @@ -2594,17 +2795,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"], - 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(); 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 @@ -2615,6 +2815,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); @@ -2627,6 +2832,44 @@ 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"], 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(); + 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. @@ -3132,7 +3375,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()); @@ -3188,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(); @@ -3198,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..5f9aca37084 --- /dev/null +++ b/src/metalbench.rs @@ -0,0 +1,576 @@ +//! `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. +//! +//! **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); + +/// Where the machine's files are, on the machine. +const LOG_DIR: &str = "/log"; + +/// 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, 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 + /// 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(); + let mut last = None; + while began.elapsed().as_secs() < 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 }) + } + + /// 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(); + let mut last = None; + while began.elapsed().as_secs() < secs { + 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, last }) + } +} + +/// **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"))); + }; + // 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() + .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 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)?; + 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, 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(); + // A name /log held before the delivery is an earlier boot's. + 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; + } + 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, + } +} + +/// 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) +} + +/// 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 before_loader = before.read(bootlog::LOADER_LOG)?; + 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, + }; + + 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) { + 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, &before_loader); + 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, &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. + 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"); + 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()?; + 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"), &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)?; + 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 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")), + ("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, 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 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"); + } + + #[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"); + } + + #[test] + 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); + 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}"); + 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 + /// 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 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)); + 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"); + } +} 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 58d0fa8c519..6795849b77d 100644 --- a/src/testargs.rs +++ b/src/testargs.rs @@ -162,6 +162,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. diff --git a/tests/benchcase/system.toml b/tests/benchcase/system.toml new file mode 100644 index 00000000000..3b44f05fa9e --- /dev/null +++ b/tests/benchcase/system.toml @@ -0,0 +1,41 @@ +# 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. +# +# `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; `date` is its clock. +[programs.toybox] +receives = ["power"] + +[symlinks] +"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..2942d792fb3 --- /dev/null +++ b/tests/benchvirtiocase/system.toml @@ -0,0 +1,40 @@ +# 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; `date` is its clock. +[programs.test-runner] +syscap = ["logread"] + +[programs.toybox] +receives = ["power"] + +[symlinks] +"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..3dd0130e37c --- /dev/null +++ b/tests/common/bench.rs @@ -0,0 +1,199 @@ +//! 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 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( + _: &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 = 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)?; + + 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()))?; + // 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::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)?; + 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::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"); + 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; + 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 clock the loop reads a `--nic` boot's cable against**, from the + // 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 +%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 + // through the forwards. + 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))), + 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(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(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", + "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:?}")); + } + } + 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:?}")); + } + 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")?..]; + 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/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 346cb502d5c..b80dfa7f698 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, @@ -558,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) { @@ -611,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 @@ -795,7 +812,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); @@ -816,14 +833,25 @@ fn fingerprint(text: &str) -> u64 { h.finish() } -/// 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. +/// 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. +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())); + build(&root, dir, arm.boot, &batch, rust_bins, &[], true) +} + /// 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) -> 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(), @@ -833,12 +861,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 aaeea0cc903..ccdf5e41144 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -42,6 +42,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 09e7bf0eeb9..9931fab902b 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -1689,6 +1689,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"; @@ -2352,6 +2357,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 @@ -2395,6 +2406,8 @@ 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. + pub stick_readonly: bool, /// What the emulated RTC reads when the machine starts, as /// `YYYY-MM-DDTHH:MM:SS`. /// @@ -2516,12 +2529,14 @@ 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, 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, @@ -4436,8 +4451,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", _ => "", } )); @@ -4533,6 +4549,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", @@ -5067,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 { @@ -5132,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!( 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 5ce91a9d5e5..6a0d599be83 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` @@ -81,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 { @@ -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() }; @@ -203,6 +263,31 @@ impl Rig { Ok((from, uart)) } + /// 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()? }; + 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) + } + + /// 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(); @@ -548,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); @@ -646,6 +726,269 @@ 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"; + +/// 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 +/// 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 (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()?; + + // 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())), + stick_readonly: true, + firmware_vars: Some(rig.vars.clone()), + recovery_stick: rig.recovery.clone(), + ready_marker: STANDS, + ..Default::default() + }; + 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") { + 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"); + + 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 + // 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 { + 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(()) +} + +/// 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())?; + 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))?; + + // 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 { + 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:?}")); + } + 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()?; + 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. + drop(file); + 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")?; + 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"; + +/// **`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")?; + 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}"))?; + // 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:?}")); + } + 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 — 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::>())?; + + 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()); + 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", "this pass failed, so BootNext=Boot"], + )?; + 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/ @@ -671,6 +1014,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, @@ -690,6 +1037,8 @@ mod vars { /// One variable header in the store. struct Found { + /// Where its header begins. + at: usize, state: u8, vendor: [u8; 16], var: Var, @@ -711,7 +1060,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)) @@ -728,9 +1077,74 @@ 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() + .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}")) + } + + /// 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> { + 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)?; @@ -741,7 +1155,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/tests/ssh-client-host/src/main.rs b/tests/ssh-client-host/src/main.rs index 67d9ebe02d8..e54fd42bf17 100644 --- a/tests/ssh-client-host/src/main.rs +++ b/tests/ssh-client-host/src/main.rs @@ -22,9 +22,12 @@ //! toyos_ssh fire //! → accepted | refused | closed | silent //! | exited +//! 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 @@ -63,6 +66,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"; @@ -73,10 +95,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())), } } @@ -103,9 +126,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 } @@ -200,6 +225,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 @@ -482,6 +518,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 4cb11680e3a..5c258693c22 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -822,6 +822,18 @@ 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. 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_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. + ("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), @@ -1609,6 +1621,13 @@ 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", &[]), + ("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"]), ("blocking_read_window", &["test_rs_blocking_read_stress"]), ("user_copy_races_munmap", &["test_rs_copy_out_races_munmap"]), ("tls_rebase_window", &["test_rs_tls_dtv_race"]), @@ -15464,6 +15483,21 @@ 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) + } + "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) + } "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), @@ -20104,6 +20138,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); } @@ -20194,6 +20229,7 @@ fn main() { run.exit( match metal::run( mode, + metal_reach, &dir, &selected, &{ diff --git a/toyos-abi/src/boot.rs b/toyos-abi/src/boot.rs index 1c1c40a5050..83ba23acb39 100644 --- a/toyos-abi/src/boot.rs +++ b/toyos-abi/src/boot.rs @@ -148,6 +148,10 @@ pub const SLOT_PARAM: &str = "boot-slot="; /// log. pub const SLOT_REFUSED_PARAM: &str = "slot-refused="; +/// `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. pub const MAX_ROOT_BRIDGE_WINDOWS: usize = 64; diff --git a/toyos-gpt/src/guid.rs b/toyos-gpt/src/guid.rs index 0564ef80de6..a7cad6ba8d6 100644 --- a/toyos-gpt/src/guid.rs +++ b/toyos-gpt/src/guid.rs @@ -102,6 +102,26 @@ impl Guid { pub const fn is_zero(&self) -> bool { u128::from_ne_bytes(self.0) == 0 } + + /// 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(); + 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)| char::from(b).to_digit(16)); + 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()?; + } + 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 { @@ -157,6 +177,22 @@ mod tests { assert_ne!(Guid::TOYOS_ROOT, Guid::TOYOS_DATA); } + #[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/src/entry.rs b/toyos-update/src/entry.rs new file mode 100644 index 00000000000..39eed1d8805 --- /dev/null +++ b/toyos-update/src/entry.rs @@ -0,0 +1,385 @@ +//! 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), 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)] +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], +} + +/// `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 +} + +/// 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]) -> usize { + out[..4].copy_from_slice(&LOAD_OPTION_ACTIVE.to_le_bytes()); + let path_at = ucs2(description, out, LOAD_OPTION_HEAD); + + 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(&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; + + 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_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 +} + +/// 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 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 = 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 = 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((&path[..path.len() - rest.len()], this)), + _ => {} + } + rest = &rest[len..]; + } + 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"); + } + 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")); + 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)) +} + +/// Whether an option is active: the boot manager skips one that is not. +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` 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. +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)) { + *out.get_mut(n)? = entry; + n += 1; + } + Some(n) +} + +/// 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..], + None => order, + }; + 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)) +} + +/// 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 (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)) { + 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 + } + + /// 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 = [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])); + } + + /// **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"); + } + + /// **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")); + } + + /// **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))]; + 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).expect("fits"); + assert_eq!(&out[..n], &[7, 3, 1]); + 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).expect("fits"); + assert_eq!(&out[..n], &[2]); + 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. + #[test] + 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]; + 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"); + 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_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"), None, "lowercase is no load option"); + assert_eq!(number("BootOrder"), None); + assert_eq!(number("BootNext"), None); + assert_eq!(number("Boot00031"), None); + 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/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..c28f4322823 100644 --- a/toyos-update/src/policy.rs +++ b/toyos-update/src/policy.rs @@ -90,11 +90,32 @@ 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)] +} + +/// 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. @@ -145,10 +166,29 @@ 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"); + } + + /// **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. 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..01c2a446214 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, 3 a slot on trial) | 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,65 @@ 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 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`, + /// 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 + } + + /// 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 }), + } + } + + /// 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. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Table { @@ -91,6 +162,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 +173,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 +185,16 @@ 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; +const NEXT_TRIAL: u32 = 3; + impl Table { pub fn slot(&self, which: Which) -> Option { self.slots[which.index()] @@ -134,6 +215,17 @@ impl Table { out[at + 40..at + 48].copy_from_slice(&slot.version.to_le_bytes()); } } + let at = REQUEST_AT; + 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()); out @@ -168,7 +260,40 @@ 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 | 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)), + _ => 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 }) } } @@ -210,6 +335,8 @@ pub enum NoIdle { NotThisBoot, /// The idle slot names a partition that is no idle slot's. Stray { part: &'static str, why: Stray }, + /// The running slot is on trial, and the idle one is the slot kept. + Trial { running: Which, kept: Which }, } /// Why a partition the idle slot names is not one a grant may claim. @@ -232,6 +359,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", @@ -271,6 +405,10 @@ pub struct Kinds { /// 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 }); + } 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); @@ -321,7 +459,75 @@ 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: Some(Next::Trial(Which::B)), first: true }, + 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] = 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")); + 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")); + } + + #[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)); + } + } + + #[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. @@ -413,5 +619,8 @@ 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"); + 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/Cargo.lock b/userland/Cargo.lock index 30245ea0bae..0ec2a5d2ee4 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -4274,6 +4274,7 @@ dependencies = [ "toyos", "toyos-abi", "toyos-fat32", + "toyos-gpt", "toyos-update", ] diff --git a/userland/toybox/src/date.rs b/userland/toybox/src/date.rs new file mode 100644 index 00000000000..a3a1d60baef --- /dev/null +++ b/userland/toybox/src/date.rs @@ -0,0 +1,18 @@ +//! `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(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) => { + 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/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 3898e2adac9..95c60c9a267 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. Neither ask is signed. use std::io::Read; use std::time::Instant; @@ -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_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), \ + `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,29 @@ 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 = 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", + toyos_gpt::Guid(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] @@ -75,6 +134,9 @@ fn run(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]; @@ -116,19 +178,20 @@ fn run(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; - next.marked = idle; + 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); - 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:?}"))?; + let when = if once { + 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 +199,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];