Skip to content

The loader boots the marked slot or nothing - #1201

Merged
Japabu merged 2 commits into
mainfrom
fix-forward-loader
Nov 10, 2026
Merged

Japabu merged 2 commits into
mainfrom
fix-forward-loader

Conversation

@Japabu

@Japabu Japabu commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Updates fix forward (owner ruling, 2026-11-05): the loader boots the slot the table marks and never the other one. #1196 removed the death-driven fallback. The verification fallback was still there: policy::order named the other slot after the marked one, and slot::choose booted it when the marked slot failed its signature, a hash or the anti-rollback floor, telling the kernel through slot-refused=.

Resulting behavior

  • A pass tries and boots the marked slot and no other. The other slot is read only where the proof record names it: slot::proven verifies its signed header to raise the floor, and nothing of it boots.
  • A refused marked slot is named, on the console and in loader.log:
    Slot A: REFUSED, its supervisor is not the bytes its signed header names, then
    Slots: the marked slot A is refused, and no other slot is booted; the remedy is a newer image.
  • The pass then ends through the loader's existing panic path, unchanged: the machine powers off, or resets where a BootNext bought this boot and the firmware's default entry is another (the development bench, whose default is its own system). No kernel is handed the machine. The design, now in the manual by the orchestrator's ruling on this PR, is power off and reset into nothing else; the reset that remains is recorded in the update track with the loader track's stage 4 as its exit.
  • The kernel is told only the slot it was booted from; its record of a refused marked slot is gone.

Removed

  • toyos_update::policy::order and its test; Refusal::word and its test (its only reader was slot-refused=); Refusal::Absent, which nothing constructed.
  • Chosen::refused and the loop in loader/src/slot.rs; slot::choose is slot::marked.
  • toyos_abi::boot::SLOT_REFUSED_PARAM, the kernel's claim on it, the build's VALUED_PARAMS row and the kernel's two "because the marked slot was refused" records. This is a removal from toyos-abi; its consumers are the loader, the kernel and the build, all changed here. No fork names it.
  • The metal row bent_supervisor_falls_back, with metal::Arm::bent and the metal profile's bent-image build, which only that row used.
  • The free-on-refusal cleanup (Payload::free, BootServices::free_pages and their call sites): it gave pages back so the next slot could be tried, and every refusal now ends the pass.

update --once is not in the tree (it is the loader track's stage 5), so there was no request semantics to keep. The bench's loader-binary trial is untouched.

Decisions worth a reviewer's eye

  • No new host test of the policy. With order gone, toyos-update's policy has no slot decision left: the loader reads table.marked, and Table::decode already refuses a table marking a slot it does not carry (a_table_reads_back_and_a_bent_one_is_refused). What remains, that a refused marked slot boots nothing, is in the UEFI loader and is held by the guest row; the pure pass::decide that would make it host-testable is the loader track's stage 5. The one host addition is the_loader_writes_the_lines_the_host_reads holding the test's marker to the loader's constant.
  • The refusal ends as a panic does, as the floor refusal beside it already does, rather than through a second stop mechanism. The loader track's stage 4 owns the handler (power off only, written to loader.log).
  • The refused-slot row has no metal counterpart. The metal loop judges a boot by the kernel's own record, which a refused pass never writes, so a refused slot cannot be judged without changing the harness's contract. The update track's stage 2 exit asked for the metal row to be converted; the record now says why it is deleted instead, and that a refused slot on the T14 is the bench stage's exit (loader track stage 6).
  • The manual gains one clause, by the orchestrator's ruling on this PR: after a refusal the machine reports it and powers off; it does not reset into anything else.
  • Filed: issues/an-unreadable-newest-slot-table-copy-boots-the-previous-mark-unreported.md, the one path left by which a pass can boot the previous slot, found in review and older than this branch.

Tests

a_bent_supervisor_is_refused_and_nothing_boots replaces a_bent_supervisor_is_refused_and_the_other_slot_boots. It needs a guest because the check runs in the UEFI loader before ExitBootServices. Slot A's supervisor is bent beside a whole slot B; it runs on HeadlessNoConsolePort (x86-64) and Virt (AArch64), the machines whose console is the serial port the loader writes. It waits for the loader's refusal line, then for QEMU to exit, and holds that the loader verified nothing of slot B and that no kernel said a line. Its boot has no proof record, so nothing of slot B is read at all.

Checks

Commands, exit codes and logs are in the evidence. Head is 3231f4125; round one was dd736982b.

  • Guest rows at 3231f4125 (guest runner, run 42919063497): a_bent_supervisor_is_refused_and_nothing_boots (x86-64 and AArch64), a_clean_stop_proves_its_image and virt_first_entry: 3 passed. log. The same three passed at dd736982b (run 42910883434).
  • Negative control at dd736982b (run 42910889983): with the other slot put back into the pass (control.diff), the image builds and the row fails: the loader refuses slot A, verifies slot B and boots it. log. Not repeated: round two did not touch slot::marked.
  • T14, dd736982b: one boot of the testcases image, run by the orchestrator. The loop's verdict passed, and control_regs and memory_overwrite_requested pass on its readback. judge log. The boot is of round one's loader; round two only removes calls on refusal paths.
  • Host at dd736982b's tree: cargo run -- --ci host exits 1 with 1 of 110 steps red: llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key, which fails on every macOS host (issues/the-llvm-environment-test-breaks-git-on-macos.md) and is in code this branch does not touch. log
  • Host at 3231f4125's tree: cargo run -- --clippy exits 0, and cargo test -p toyos-update -p toyos-abi exits 0. The whole host run was not repeated.

Risks

  • A machine whose marked slot is refused now needs a newer image from outside it (installation media); nothing on the disk brings it back. That is the ruling.
  • No metal row holds the firmware's ResetSystem after a refusal; none did before.

🤖 Generated with Claude Code

Updates fix forward (owner ruling, 2026-11-05). #1196 took the death-driven
fallback out; this takes out the verification fallback, the last one.

A pass verifies the slot the table marks and no other. Where it is refused
for its signature, a hash, its version against the floor or anything else,
the loader names the refusal, says that no other slot is booted and that the
remedy is a newer image, on the console and in loader.log, and ends as its
panic does: the machine powers off, or resets where a BootNext bought this
boot and the firmware's default entry is another. The other slot is not read.

Gone with it: policy::order and its test; Refusal::word, its test and
Refusal::Absent, which nothing constructed; Chosen::refused; the
slot-refused= boot parameter in toyos-abi, the kernel's claim on it and the
kernel's record of a refused marked slot; the metal row
bent_supervisor_falls_back with the metal profile's bent arms.

a_bent_supervisor_is_refused_and_the_other_slot_boots becomes
a_bent_supervisor_is_refused_and_nothing_boots: slot A's supervisor bent
beside a whole slot B, on a machine of each architecture whose console is the
loader's serial port. It waits for the loader's line, then for QEMU to exit,
and holds that the loader read nothing of slot B and no kernel said a line.
It has no metal row: the metal loop refuses a boot that left no stop mark, so
a refused slot on the T14 stays the bench stage's exit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1201 at dd736982b (merge base f9b5d7f12)

Read: the commit, the whole diff, each changed file, loader/src/{main,slot,rootimage,proof,loaderlog,payload,bootorder}.rs, loader/src/efi/mod.rs' panic handler, lib/update/src/{policy,slots,record}.rs, the manual's boot and installation pages, both tracks, tests/metal/src/lib.rs' verdict, and the evidence at bfc4c29c. No test or build was run.

Architecture

The change is the right shape: one owner of the decision (table.marked), no second mechanism, and the compensating parts go with the loop (policy::order, Refusal::word/Absent, Chosen::refused, slot-refused= in the ABI, the kernel and the build, the metal profile's bent arm). The owner's ruling (9289022: fix forward, no automatic fallback) is kept; nothing here changes an owner decision. Production is about +44/-106, tests about +51/-88, records +19/-37.

The remaining path. slot::marked verifies only table.marked. verify is unchanged: ROOT's partition located and typed, the signed header's signature under KEY, the floor, then kernel, cmdline, supervisor, file server and ROOT each read to the header's length and hashed, the two programs and ROOT over the pages the kernel is handed. main uses only Chosen. Nothing of the slot runs before that. A refusal ends at loader/src/main.rs:798; no proof record is written and no watchdog is armed first.

No marked slot, or a corrupt table. Table::decode refuses a copy whose mark names no slot it carries (held by a_table_reads_back_and_a_bent_one_is_refused), so the expect at loader/src/slot.rs:70 is an invariant, not input. Both copies unreadable, no TOYOS-SLOTS partition or two of them, and a boot disk that does not resolve all return Err and end the same way, now written to loader.log as well as the console. One copy unreadable: the other decides, see the note at the end.

The end state. By inspection of loader/src/efi/mod.rs:560-567 and bootorder.rs: ResetSystem(SHUTDOWN), or WARM where BootCurrent is not BootOrder's first entry. The reset hands the machine to the firmware's default entry, never to a ToyOS slot, so it is not the fallback the ruling removed. The QEMU row cannot tell the two apart (-no-reboot ends QEMU on either) and does not claim to.

BLOCKER

  1. issues/the-machine-updates-itself-without-ubuntu.md:34-36 — "a pass verifies the marked slot alone" and "reads nothing of the other slot" are false. slot::proven (loader/src/slot.rs:201, called at loader/src/main.rs:748, before slot::marked) reads and verifies the signed header of the slot the proof record names. On the pass after an update whose previous boot stopped cleanly, that is the unmarked slot, which is exactly the pass where a new marked slot would be refused. It is harmless (held to KEY and the record's digest, used only to raise the floor, nothing of it boots), but the record states a security property the code does not have. What is true: no other slot is tried or booted. The row's own comments are true of its boot, which has no proof record. The same sentence is in the PR body ("reads nothing of the other") and the commit message; the body is the orchestrator's to correct.

  2. sdk/abi/src/boot.rs:65-66 — "with the words the loader appends ([SLOT_PARAM] among them)" is stale. This change removed the only other word; the loader now appends exactly one. It is the ABI's statement of what KernelArgs' boot parameter carries. A comment-only edit there changes no identity.

Neither fix can invalidate the guest or host evidence.

SUGGESTION

  • loader/src/slot.rs:106-131,156-158, loader/src/rootimage.rs:193, loader/src/payload.rs:49-57, loader/src/efi/boot.rs:287 — the free-on-refusal paths are the fallback's cleanup: they returned pages so the next slot could be tried and so "a payload whose slot was refused is not the kernel's". Every caller now ends the pass on Err, so Payload::free, BootServices::free_pages and the eight call sites have no observable effect, and verify could use ?. Delete here, or say that stage 5's verify rewrite takes them.
  • issues/the-loader-does-only-what-must-precede-the-handover.md:152-153 — stage 4 names only the refused-floor site as writing its reason before its panic!. loader/src/main.rs:798-801 is now a second one; name it, or stage 4 leaves the refusal twice in loader.log.
  • tests/toyos.rs:654, issues/the-machine-updates-itself-without-ubuntu.md:42-43 — "the metal loop judges only a boot that stopped cleanly" names the wrong requirement: a deadline-armed boot is judged without a clean stop. What both arms of boot_verdict need is the kernel's own record (tests/metal/src/lib.rs:1888-1906), which a refused pass never writes.
  • tests/toyos.rs:3279 — "Every line the loader says of a slot it reads opens Slot <letter>:" is not so (read_section and program say Slot: <path> ...). The assertion is sound because a slot that verifies says Slot B: signed header ... first; say that.

The four departures

  • No new host test of policy: acceptable. With order gone the decision is a field read, and a function kept to be tested would be a test of what inspection establishes. "A dead image is still booted" has nothing left to test since Memory overwrite on every boot; the black box goes, and a clean stop proves an image #1196 removed Refusal::Died. The host test of the refusal itself stays owed by issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md (update_refusals_are_named) and arrives with stage 5's pure verify/decide.
  • Metal row deleted, not converted: acceptable. Verified: boot_verdict reds any boot with no kernel record before a row's judge runs, so a conversion needs a third verdict mode in a loop stage 7 deletes. Loader stage 6's exit already names a refused slot on the T14. What is given up until then is the real firmware's ResetSystem after a refusal, which no metal row held before either.
  • The refusal ends as a panic: acceptable for this stage. Returning a status would hand the firmware its next boot option; a second stop mechanism would be deleted by loader stage 4. The track's earlier "powers the machine off" was the plan's wording, not the ruling, and the record now says what the code does.
  • The manual unchanged: no rule is broken, since the PR changes no decision and docs/src/architecture/boot.md:41-43 is accurate as far as it goes. It should say what the machine does next, in one clause, and that is the owner's to word. It is the observable consequence of the page's central rule and a real choice among powering off, resetting to the firmware's default entry and holding the message. The tracks' design is power off and never reset (loader stage 4); today a machine whose default entry is another resets. One fact for that decision, by inspection: the handler prints and calls ResetSystem at once, so on a machine with no serial console "reported" means loader.log, not a panel a person can read.

Evidence

  • Guest run 42910883434: headSha dd736982b, conclusion success, 3 passed; the log builds the row's image for x86_64 and aarch64.
  • Control 42910889983: 5635e5496 = head plus control.diff, builds, fails at the wait for the refusal line after Slot A: REFUSED, Slot B: ... verifies and boot: slot B. It exposes the defect the row exists for.
  • Host: one red, llvm::tests::the_caller_s_environment_reaches_neither_the_build_nor_the_key, in build/llvm.rs, which the branch does not touch; the_loader_writes_the_lines_the_host_reads passed. Two caveats, both disclosed in the README: the run was on the working tree before the commit, not on dd736982b, and loader/src/slot.rs held the control's mutation during the kernel-model steps. The log's step times agree that the steps reading loader/ had ended by then. PR CI skipped the draft, so the first host run at the exact head will be the merge queue's.
  • No fork clone or cargo checkout names SLOT_REFUSED_PARAM or slot-refused. No reference to a removed name remains in the tree outside the loader track's list of In QEMU, the loader writes boot variables on the running system's request and a failed pass falls to the entry behind its own or powers off; toyos-metal drives a boot through a machine running ToyOS alone #539's pieces.

Not this PR's

slots::current takes the other table copy when one does not decode and the loader says nothing of the unreadable one (loader/src/rootimage.rs:154-156). For a torn mark move that is the designed atomicity. For a newest copy that stops decoding after the mark moved, the pass boots what the older copy marks, unreported. It predates this branch and the table is unauthenticated, so it is no security change, but it is the one path left by which a pass can boot the previous slot. Worth a finding in issues/.

SEND BACK

…ords say what a pass reads

A refused slot ends the pass, so nothing is given back to try another:
Payload::free, BootServices::free_pages and their call sites go, and
verify uses `?`.

The update track no longer says a pass reads nothing of the other slot:
slot::proven verifies the signed header of the slot the proof record names,
to raise the floor. What holds is that no other slot is tried or booted.

The manual's boot page says what follows a refusal, by the owner's ruling on
this PR: the machine reports it and powers off, and resets into nothing else.
The track records that today's handler still resets where the firmware's
default entry is another, with the loader track's stage 4 as the exit, and
that stage names the refused-slot site beside the refused-floor one.

Filed: an unreadable newest slot-table copy boots the previous mark,
unreported.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1201 at 3231f4125 (dd736982b..3231f4125)

Read: the commit and the round's whole diff; loader/src/{slot,payload,rootimage}.rs in full; loader/src/efi/boot.rs' table and its offset asserts; main.rs' pass from the firmware watchdog to the handover; the panic handler and bootorder.rs; slots::current; the manual's boot page; both tracks; the new record against issues/README.md; the evidence at b1b0248c. No test or build was run.

r1's blockers

  1. Resolved. issues/the-machine-updates-itself-without-ubuntu.md:34-39 now says a pass tries and boots the marked slot and no other, and that the other slot is read only where the proof record names it, its signed header verified to raise the floor. That is what slot::proven (loader/src/slot.rs:184, called at loader/src/main.rs:748) does and all it does. The PR body says the same. No other copy of the false sentence is in the tree.
  2. Resolved. sdk/abi/src/boot.rs:65-66 names the one word the loader appends, which is what loader/src/main.rs:825 appends.

The deletion

It is a pure deletion. Loader +11/-52 this round.

  • Every caller ends the pass. verify is called only by slot::marked, and marked only at loader/src/main.rs:798, which panics on Err. program and Disk::read_root are called only by verify. slot::proven allocates no payload.
  • No refused payload reaches a kernel. The kernel's addresses come from handoff() at loader/src/main.rs:542-544, over Handed, which is built only from an Ok(Chosen). Payload has no Drop and no other holder of its address, so a refused one's pages stay LoaderData in a firmware memory map no kernel is given.
  • Order unchanged. main.rs, efi/mod.rs, proof.rs and watchdog.rs are not in the round's diff: the firmware watchdog is armed at entry, the proof record is written only after marked returns Ok, and the chipset watchdog is armed last before the jump.
  • The refusal says the same lines. The ROOT line moved into the map_err closure verbatim; the other arms lost only their free calls.
  • The firmware table keeps its layout. free_pages becomes Unused, pointer-sized like the table's other unused entries, and offset_of!(BootServices, free_pages) == 48 and its neighbours still hold it at build time.
  • The one new condition is that the panic handler runs with the refused pages still allocated. A booting pass does far more with the same pages held, so that is no new exposure.
  • loader/src/payload.rs:38-39 ("never released") is now true. loader/src/rootimage.rs:166 rightly drops "the attempt count the next pass reads": nothing in the loader counts attempts since Memory overwrite on every boot; the black box goes, and a clean stop proves an image #1196.

The manual, the tracks, the test comments

  • docs/src/architecture/boot.md:43-45: the clause names no code and contradicts no other page. It states what the loader track's stage 4 already designs (a handler that powers off and never resets), so it moves an existing design into its home and changes no approved decision.
  • issues/the-machine-updates-itself-without-ubuntu.md:41-45 records the gap truthfully: efi/mod.rs:565 resets WARM where bootorder::default_is_another holds, and the exit it names, stage 4, does remove that. The T14's own loader.log line in the evidence ("a panic in this loader resets the machine") shows the gap is real on the bench today.
  • issues/the-loader-does-only-what-must-precede-the-handover.md:152-154 names both sites that write their reason before their panic! (main.rs:716-723 and 798-801). Accurate.
  • tests/toyos.rs:654-655 and :3280 now say what boot_verdict and signed_header do. Both changes to tests/ are comments only.

The new record

issues/an-unreadable-newest-slot-table-copy-boots-the-previous-mark-unreported.md is well formed (open, finding, no severity, slug matches the heading) and its mechanism is right: slots::current takes whichever copy decodes, and Disk::slot_table drops which one that was. It labels itself as found by inspection with no boot showing it, names its owner and has a checkable exit.

Evidence

  • Guest run 42919063497: headSha 3231f4125, conclusion success, 3 passed; the log builds the refusal row's image for x86_64 and aarch64. That row takes the program arm that lost its free, so the changed refusal path ran at the head on both architectures.
  • r2/clippy.log: exit 0, 26 invocations, bootloader checked for x86_64-unknown-uefi and aarch64-unknown-uefi. r2/units.log: exit 0. Both ran on the working tree seconds before it was committed as 3231f4125, as the README says.
  • The T14 boot of dd736982b still covers the head. It was a booting pass (slot A verified, slot B absent, boot: slot A). On that path the two revisions run the same calls in the same order: every removed line is in an Err arm, and the retyped table entry keeps its size and offset. The judges ran with the tree at 3231f4125, whose tests/ differs from dd736982b's in comments only. What it never covered, at either revision, is a refusal on real firmware; the PR says so.
  • Not repeating the negative control is sound: slot::marked is untouched, and the row it guards passed at the head.
  • The PR is a draft and its checks are skipped, so the merge queue's is the first whole host run at the head. The round adds nothing a host step reads beyond clippy; the new record carries no identifying shape for the source gate.

For the orchestrator

The PR body (twice) and 3231f4125's message say the manual's clause is "by the owner's ruling on this PR". The thread holds no statement from the owner, and the brief for this review calls it an orchestrator ruling. The body becomes main's commit message. Say whose ruling it is. The clause itself needs no new owner decision, for the reason above.

SUGGESTION

  • issues/an-unreadable-newest-slot-table-copy-boots-the-previous-mark-unreported.md:13-14 — "a block gone bad" is this record's case only on media that return wrong bytes. Where the firmware reports the failed read, loader/src/rootimage.rs:149-151 ends the pass with "the slot table's copy N would not read", for either copy, the older one included: reported, and nothing boots. "Reads but no longer decodes" is the condition; the read-failure behaviour is a fact whoever takes the exit will want in the same record.

LAND

@Japabu
Japabu marked this pull request as ready for review November 10, 2026 23:11
@Japabu
Japabu added this pull request to the merge queue Nov 10, 2026
Merged via the queue into main with commit 9349705 Nov 10, 2026
11 checks passed
@Japabu
Japabu deleted the fix-forward-loader branch November 10, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant