Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build/bootlog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ pub const LOADER_LOG: &str = "loader.log";
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";

/// How the loader's line ends that says the marked slot was refused, in
/// `loader/src/slot.rs`: the last it says before the pass ends.
pub const LOADER_BOOTS_NOTHING: &str = "is refused, and no other slot is booted; the remedy is a newer image";

/// The kernel's record for a process that ended, in `kernel/src/process.rs`.
///
/// **The one channel a guest binary's verdict crosses on a machine with no
Expand Down Expand Up @@ -445,6 +449,7 @@ mod tests {
("loader/src/loaderlog.rs", format!("cstr16!(\"{LOADER_LOG}\")")),
("loader/src/loaderlog.rs", format!("\"{LOADER_FIRST_LINE}\"")),
("loader/src/loaderlog.rs", format!("\"{LOADER_LAST_LINE}\"")),
("loader/src/slot.rs", format!("\"{LOADER_BOOTS_NOTHING}\"")),
];
for (file, rhs) in wanted {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(file);
Expand Down
3 changes: 1 addition & 2 deletions build/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1530,9 +1530,8 @@ fn check_params(root: &Path, params: &[String]) {
/// other — a prefix only one of them knows is an image the other refuses.
const VALUED_PARAMS: &[(&str, &str)] = &[
("toyos_tco::DEADLINE_PARAM", toyos_tco::DEADLINE_PARAM),
// The loader's words about the slot it booted.
// The loader's word naming the slot it booted.
("toyos_abi::boot::SLOT_PARAM", toyos_abi::boot::SLOT_PARAM),
("toyos_abi::boot::SLOT_REFUSED_PARAM", toyos_abi::boot::SLOT_REFUSED_PARAM),
];

/// The names in [`VALUED_PARAMS`], for a refusal that says what it would have
Expand Down
4 changes: 3 additions & 1 deletion docs/src/architecture/boot.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
table. An update is written to the slot the machine is not running.
- Updates fix forward. The loader boots the slot marked to boot next and no
other; a slot that is refused or fails is reported, never replaced by the
previous one automatically. The remedy is a newer image.
previous one automatically. After a refusal the machine reports it and
powers off; it does not reset into anything else. The remedy is a newer
image.
- Updating is an ordinary program: a signed image on standard input, written to
the idle slot and marked to boot next. The signature is its whole authority,
and how the bytes arrived does not matter.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
status: open
kind: finding
opened: 2026-11-11
---

# An unreadable newest slot-table copy boots the previous mark, unreported

`toyos_update::slots::current` takes the other copy of the slot table when one
does not decode, and the loader (`Disk::slot_table`, `loader/src/rootimage.rs`)
says nothing of the copy it could not read. For a mark move that tore, that is
the designed atomicity: the machine boots the old mark or the new one. For a
newest copy that stops decoding after the mark moved (a block gone bad, a
stray write), the pass boots the slot the older copy marks and no line says
so. Found by inspection in the review of #1201; no boot has shown it.

Updates fix forward, and this is the one path left by which a pass boots the
previous slot. The table is unauthenticated and the slot it boots is still
held to the signature and the floor, so it changes nothing about what may run.

Owner: the update track (`issues/the-machine-updates-itself-without-ubuntu.md`).

**Exit**: the loader says which copy it read and why the other was not, in
`loader.log` and to the kernel, or the design rules that an unreadable copy
refuses the pass; a host test of `slots::current` holds whichever it is.
14 changes: 7 additions & 7 deletions issues/the-loader-does-only-what-must-precede-the-handover.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ Each stage lands on its own, in this order.
From `EXITING` on (the first `ExitBootServices` call) it skips `loaderlog`
and goes straight to `ResetSystem`'s shutdown, since UEFI 2.10 §7.4.6
forbids the File protocol from that call on as it forbids the console.
The refused-floor site stops writing its reason to `loader.log` before
its `panic!`: the handler writes it.
The refused-floor site and the refused-slot site (`slot::marked`'s
caller) stop writing their reason to `loader.log` before their `panic!`:
the handler writes it.
- `alloc_kernel_memory` stops building a `Vec<u8>` over a 2 MiB-aligned
allocation. Both relocation unsafes go.
- The layout word and every field before it keep their offsets, each
Expand All @@ -159,7 +160,7 @@ Each stage lands on its own, in this order.
`repr(C)` sub-structs, and `#[repr(C, u32)]` enums for the optional parts
in place of `u32` presence flags and zero sentinels; a field the typing
changes moves after the word. The slot booted is a field, not
`boot-slot=` text; `slot-refused=` leaves with the fallback.
`boot-slot=` text.
`params.rs`' last-one-wins and kernel `main.rs`' branch for a format
never emitted go. `kernel_stack_addr` is renamed for the offset it
holds. x86's `_start` reads by `const offset_of!`.
Expand Down Expand Up @@ -196,10 +197,9 @@ Each stage lands on its own, in this order.
rises. It replaces the proof record (`loader/src/proof.rs`,
`lib/update/src/record.rs`), logkeeper's stop mark and `slot::proven` as
how an image is proven.
Which slot a pass boots is the marked one or none: updates fix forward, and
the loader's fallback leaves in stage 2 of
`issues/the-machine-updates-itself-without-ubuntu.md`, which this stage
follows. #1196 moved that proof from the black-box page to the stop mark.
Which slot a pass boots is the marked one or none: updates fix forward
(stage 2 of `issues/the-machine-updates-itself-without-ubuntu.md`, done).
#1196 moved that proof from the black-box page to the stop mark.
- An install writes its slot with the good flag clear, whatever the slot
held before.
- Any failure after `verify` in `start_kernel` that the image causes is a
Expand Down
51 changes: 20 additions & 31 deletions issues/the-machine-updates-itself-without-ubuntu.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ stdin — and the machine installs nothing the owner did not sign.
## Stage 1 — slots, signatures and `update`, proven in QEMU (done)

Done: the signed image (`toyos-update`), the two slots and their table, and
the loader's verification. The loader boots the marked slot and today the
other where the marked one is refused; the kernel logs which and why. That
fallback leaves in stage 2.
the loader's verification.

- **`update`** holds the slot table and the idle slot's two partitions, which
init claims against the ROOT the kernel holds, writes the image and moves the
Expand All @@ -28,38 +26,29 @@ fallback leaves in stage 2.
(`--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 — fix forward: the loader boots the marked slot or nothing
## Stage 2 — fix forward: the loader boots the marked slot or nothing (done)

Owner ruling, 2026-11-05, answering #1191's open decisions: updates fix
forward, with no automatic fallback to the previous slot.

The loader falls back today in three ways, all of which go:

- `policy::order` names the other slot after the marked one, and
`slot::choose` boots it when the marked one is refused;
- `Refusal::Died` refuses an image whose last boot the record says died or
hung, so the other slot boots instead;
- where no slot verifies but a dead one, `slot::choose` boots that one,
which may be the other slot.

A pass verifies the marked slot alone and boots it whether or not its last
boot died. Where it is refused, the loader names the refusal on the panel and
in `loader.log` and powers the machine off. `slot-refused=` and the kernel's
line that logs it go with the fallback. A one-shot `update --once` is a
request, not a fallback, and stays the loader track's stage 5.

With the fallback gone, marking an image dead in the boot record on the
black-box page no longer refuses anything, but the next boot still reports the
last boot's death; #1196 (the memory-overwrite request) decides what carries
that report if the page goes. This stage removes only the fallback's use of
the record.

**Exit**: `policy`'s host tests hold that a pass names only the marked slot
and that a dead image in it is still booted.
`a_bent_supervisor_is_refused_and_the_other_slot_boots` and its metal row
`bent_supervisor_falls_back` become a bent marked supervisor refused by name,
nothing booted and the machine powered off; putting the other slot back into
`policy::order` reds them.
Done: a pass tries and boots the marked slot and no other. Where it is
refused, the loader names the refusal on the console and in `loader.log` and
ends as its panic does. The other slot is read only where the proof record
names it: its signed header is verified to raise the floor, and nothing of it
boots. The kernel is told only the slot it was booted from. #1196 took the
death-driven fallback out with the black-box page.

Not yet the design: the machine is to power off after a refusal and reset
into nothing else. Today's handler powers off, but resets where a `BootNext`
bought this boot and the firmware's default entry is another. The exit is
stage 4 of `issues/the-loader-does-only-what-must-precede-the-handover.md`,
whose handler never resets.

`a_bent_supervisor_is_refused_and_nothing_boots` holds it on both
architectures' QEMU machines. It has no metal row: the metal loop judges a
boot by the kernel's own record, which a refused pass never writes, so a
refused slot on the T14 is stage 6's exit in that track. A one-shot
`update --once` is a request, not a fallback, and stays that track's stage 5.

## Stage 3 — the T14 installs ToyOS on its NVMe

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ Not consumers:
2.4192 GHz, about 105 µs, and 122,724 on the AMD bench, whose CPU states
no counter rate.
- An AArch64 QEMU loader boot verifies a signed image. Done:
`a_bent_supervisor_is_refused_and_the_other_slot_boots` boots QEMU's
`a_bent_supervisor_is_refused_and_nothing_boots` boots QEMU's
AArch64 machine too.
- `ed25519-dalek` and the loader's `sha2` leave `Cargo.lock`'s non-dev
packages. Done (#1019).
Expand Down
11 changes: 3 additions & 8 deletions kernel/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,10 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! {
"boot: cmdline {:#x}+{}",
kernel_args.cmdline_addr, kernel_args.cmdline_len
);
// Before `mm::init`, which may hand the parameter's memory out. This record
// is how a slot that died or was refused reaches the next boot's `/log`.
// Before `mm::init`, which may hand the parameter's memory out.
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, 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),
},
(None, _) => log!("{} none: the loader named no slot", params::SLOT_RECORD),
Some(slot) => log!("{} {slot}, the one the slot table marks", params::SLOT_RECORD),
None => log!("{} none: the loader named no slot", params::SLOT_RECORD),
}

// The signed kernel section the loader laid this kernel out of, kept for
Expand Down
11 changes: 4 additions & 7 deletions kernel/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,15 @@ pub fn claims(token: &str) -> bool {
PARAMS.iter().any(|(name, _)| *name == token)
|| token.starts_with(toyos_tco::DEADLINE_PARAM)
|| token.starts_with(toyos_abi::boot::SLOT_PARAM)
|| token.starts_with(toyos_abi::boot::SLOT_REFUSED_PARAM)
}

/// The head of the record naming the slot this boot came from.
pub const SLOT_RECORD: &str = "boot: slot";

/// The slot the loader booted, and the marked slot it refused and why, as the
/// loader wrote them: the last of each, because the loader appends its words
/// after the slot's own parameter and so has the last one.
pub fn slot(cmdline: &str) -> (Option<&str>, Option<&str>) {
let word = |prefix: &str| toyos_abi::boot::tokens(cmdline).filter_map(|t| t.strip_prefix(prefix)).last();
(word(toyos_abi::boot::SLOT_PARAM), word(toyos_abi::boot::SLOT_REFUSED_PARAM))
/// The slot the loader booted, as the loader wrote it: the last one, because
/// the loader appends its word after the slot's own parameter.
pub fn slot(cmdline: &str) -> Option<&str> {
toyos_abi::boot::tokens(cmdline).filter_map(|t| t.strip_prefix(toyos_abi::boot::SLOT_PARAM)).last()
}

pub fn watchdog() -> bool {
Expand Down
5 changes: 2 additions & 3 deletions lib/update/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@
//! A machine carries two [`slots`], each a FAT partition holding the kernel,
//! its boot parameter, the two programs and the signed header, and a ROOT partition holding
//! ROOT; the slot table names both slots and marks one. The loader boots the
//! marked slot, and falls back to the other where the marked one is refused;
//! the updater writes only the slot the machine is not running, and moves the
//! mark last.
//! marked slot or nothing; the updater writes only the slot the machine is not
//! running, and moves the mark last.

#![cfg_attr(not(test), no_std)]
#![forbid(unsafe_code)]
Expand Down
60 changes: 2 additions & 58 deletions lib/update/src/policy.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! Which slot a boot may take, which image an update may install, and every
//! reason one is refused, by the word the loader hands the kernel for it.
//! Whether a boot may take the marked slot's image, which image an update may
//! install, and every reason one is refused.
//!
//! **The anti-rollback floor** is the highest version a boot has proven by
//! stopping cleanly. The loader refuses an image below it, and raises it only
Expand All @@ -24,13 +24,9 @@
//! hand clearing its NVRAM, lowers the floor to nothing. Only a monotonic
//! counter the platform refuses to lower — a TPM's NV counter — closes that.

use crate::slots::{Table, Which};

/// Why a slot was not booted, or an image not installed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
/// The table carries no such slot.
Absent,
/// It carries no signed header.
Unsigned,
/// Its signed header is not one.
Expand All @@ -49,27 +45,9 @@ pub enum Refusal {
Type(&'static str),
}

impl Refusal {
/// The word the loader hands the kernel for this refusal: one token of the
/// boot parameter, so no comma and no space.
pub fn word(&self) -> &'static str {
match self {
Self::Absent => "absent",
Self::Unsigned => "unsigned",
Self::Malformed => "malformed",
Self::Signature => "signature",
Self::BelowFloor { .. } | Self::Older { .. } => "version",
Self::Hash(_) => "hash",
Self::Unreadable(_) => "unreadable",
Self::Type(_) => "type",
}
}
}

impl core::fmt::Display for Refusal {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match *self {
Self::Absent => write!(f, "the slot table carries no such slot"),
Self::Unsigned => write!(f, "it carries no signed header, so it is unsigned"),
Self::Malformed => write!(f, "its signed header is not one"),
Self::Signature => write!(f, "its signature is not this machine's key's"),
Expand All @@ -92,13 +70,6 @@ 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<Which>; 2] {
let other = table.marked.other();
[Some(table.marked), table.slot(other).map(|_| other)]
}

/// The floor after a boot proved `proven`: it only rises.
pub fn raised(floor: u64, proven: u64) -> u64 {
floor.max(proven)
Expand All @@ -124,7 +95,6 @@ pub fn installable(version: u64, running: u64, idle: Option<u64>) -> Result<(),
#[cfg(test)]
mod tests {
use super::*;
use crate::slots::Slot;

#[test]
fn the_floor_admits_its_own_version_and_above_and_only_rises() {
Expand All @@ -143,30 +113,4 @@ mod tests {
assert_eq!(installable(11, 10, Some(12)), Err(Refusal::Older { version: 11, than: 12 }));
assert_eq!(installable(12, 10, Some(12)), Ok(()));
}

#[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]);
}

/// Each word is one token of a comma-separated boot parameter.
#[test]
fn every_word_is_one_parameter_token() {
for r in [
Refusal::Absent,
Refusal::Unsigned,
Refusal::Malformed,
Refusal::Signature,
Refusal::BelowFloor { version: 1, floor: 2 },
Refusal::Hash("kernel"),
Refusal::Unreadable("root"),
Refusal::Type("root"),
] {
assert!(!r.word().is_empty() && !r.word().contains([',', ' ', ':', '=']), "{r:?}");
}
}
}
12 changes: 1 addition & 11 deletions loader/src/efi/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub struct BootServices {
raise_tpl: Unused,
restore_tpl: Unused,
allocate_pages: unsafe extern "efiapi" fn(kind: u32, memory: u32, pages: usize, at: *mut u64) -> Status,
free_pages: unsafe extern "efiapi" fn(at: u64, pages: usize) -> Status,
free_pages: Unused,
get_memory_map: unsafe extern "efiapi" fn(
size: *mut usize,
map: *mut u64,
Expand Down Expand Up @@ -279,16 +279,6 @@ impl BootServices {
unsafe { (self.allocate_pages)(kind, LOADER_DATA, pages, &mut at) }.ok().map(|()| at)
}

/// `FreePages` (§7.2.2).
///
/// # Safety
/// `at` and `pages` are an allocation `allocate_pages` made, and nothing
/// reads or writes it after.
pub unsafe fn free_pages(&self, at: u64, pages: usize) -> Result<(), Status> {
// SAFETY: the caller's contract.
unsafe { (self.free_pages)(at, pages) }.ok()
}

/// `AllocatePool` (§7.2.4), as `EfiLoaderData`: 8-byte aligned.
pub fn allocate_pool(&self, size: usize) -> Result<*mut u8, Status> {
let mut at = ptr::null_mut();
Expand Down
Loading
Loading