From a81a401980c978bf2206c3f7727f2378477c6d56 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 09:42:35 +0200 Subject: [PATCH 1/4] A block connector opens only the partitions its badge grants; update and the slot table reach diskserver's disk through sessions The supervisor mints every connector to the block port with a `toyos_blockring::wire::Grant` as its badge (`SYS_PORT_MINT`): one partition by unique GUID, or every partition of one type, writing or not. diskserver reads the badge the kernel stamped on each connection and lists only what it admits, refuses an open of anything else `NotGranted` (whether or not the table carries it), answers a write on a session whose grant does not write `Invalid` before the device sees it, and reaches nothing through the port's own unbadged connector, which no program is handed any more. A file server on a served disk gets `block` minted for its role: the loader's LOG partition writing, the loader's BOOT volume read-only, every TOYOS-DATA partition writing. The block service starts before the file servers its grants are minted on. The slot grant takes the running ROOT from the loader's `Loaded(Root)` record instead of `PartState::Kernel`. Where the inventory lists that partition, the kernel drives its disk and the grant is claims as before; otherwise the supervisor mints read-only grants for the slot table's, the volumes' and the ROOTs' types, lists them and reads the table through a session on its file worker (a call into a service it restarts), checks the idle slot with `toyos_update::slots::grant`, and hands `update` a namespace of three connectors, each minted for one partition. `update` writes through `diskserver::disk::Disk`, which moves out of fileserver into diskserver's client library so both share one; fileserver keeps `Ram`. Closes the-block-port-opens-every-partition-of-the-disk and an-image-on-a-disk-diskserver-drives-cannot-write-its-slots; a-file-server-can-open-every-partition-diskserver-serves narrows to its claim half, the-boot-volumes-server-holds-a-claim-that-writes: a partition claim has no DUP to narrow, so the boot server's claim on the stick still writes until usbd serves it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- Cargo.lock | 3 +- ...-open-every-partition-diskserver-serves.md | 27 -- ...iskserver-drives-cannot-write-its-slots.md | 34 -- ...-port-opens-every-partition-of-the-disk.md | 26 -- ...olumes-server-holds-a-claim-that-writes.md | 25 ++ src/build.rs | 2 + tests/blockgrantcase/system.toml | 20 ++ tests/common/qemu.rs | 16 +- tests/slotscase/system.toml | 28 ++ tests/toyos-rust-tests/Cargo.lock | 41 +++ tests/toyos-rust-tests/Cargo.toml | 5 + .../src/bin/partition_grant.rs | 119 ++++++ .../src/bin/update_idle_slot.rs | 22 ++ tests/toyos.rs | 132 +++++++ toyos-blockring/src/client.rs | 3 +- toyos-blockring/src/entry.rs | 3 +- toyos-blockring/src/lib.rs | 4 +- toyos-blockring/src/model.rs | 2 +- toyos-blockring/src/server.rs | 39 +- toyos-blockring/src/wire.rs | 119 +++++- .../{fileserver => diskserver}/src/disk.rs | 63 +--- userland/diskserver/src/lib.rs | 6 +- userland/diskserver/src/main.rs | 189 ++++++++-- userland/fileserver/src/cache.rs | 17 +- userland/fileserver/src/data.rs | 4 +- userland/fileserver/src/fat.rs | 5 +- userland/fileserver/src/lib.rs | 5 +- userland/fileserver/src/main.rs | 9 +- userland/fileserver/src/ram.rs | 50 +++ userland/supervisor/Cargo.toml | 5 +- userland/supervisor/src/main.rs | 339 ++++++++++++------ userland/update/Cargo.toml | 6 +- userland/update/src/main.rs | 111 +++--- userland/update/src/volume.rs | 53 ++- 34 files changed, 1138 insertions(+), 394 deletions(-) delete mode 100644 issues/a-file-server-can-open-every-partition-diskserver-serves.md delete mode 100644 issues/an-image-on-a-disk-diskserver-drives-cannot-write-its-slots.md delete mode 100644 issues/the-block-port-opens-every-partition-of-the-disk.md create mode 100644 issues/the-boot-volumes-server-holds-a-claim-that-writes.md create mode 100644 tests/blockgrantcase/system.toml create mode 100644 tests/slotscase/system.toml create mode 100644 tests/toyos-rust-tests/src/bin/partition_grant.rs create mode 100644 tests/toyos-rust-tests/src/bin/update_idle_slot.rs rename userland/{fileserver => diskserver}/src/disk.rs (78%) create mode 100644 userland/fileserver/src/ram.rs diff --git a/Cargo.lock b/Cargo.lock index 4e974e0480f..6c443e7631b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5380,6 +5380,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" name = "supervisor" version = "0.1.0" dependencies = [ + "diskserver", "toyos", "toyos-abi", "toyos-blockring", @@ -6326,9 +6327,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" name = "update" version = "0.1.0" dependencies = [ + "diskserver", "sha2 0.10.9", "toyos", - "toyos-abi", "toyos-fat32", "toyos-update", ] diff --git a/issues/a-file-server-can-open-every-partition-diskserver-serves.md b/issues/a-file-server-can-open-every-partition-diskserver-serves.md deleted file mode 100644 index 16be5d4c25e..00000000000 --- a/issues/a-file-server-can-open-every-partition-diskserver-serves.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-27 ---- - -# A file server can open every partition blockd serves - -init gives each `fsd` role the `block` connector (`receives = ["block"]`), and a -session on it is opened by unique GUID (`toyos_blockring::wire::MSG_OPEN`) for -any partition blockd serves but ROOT. So the log's server can open DATA, and -the boot volume's server — whose volume is read-only — can open a partition to -write. Nothing in the protocol ties a session to the role that asked for it; -what keeps each server on its own partition is the argument init passes it, -which is not authority. - -The partition claim init mints for a role on a disk the kernel drives -(`storage_endowment` in `userland/init/src/main.rs`) is the same: the boot -volume's server is endowed a claim that writes, on the ESP the firmware loads -the loader from. Before the file servers the kernel refused a `/boot` write -itself; now only that server's promise to mount its volume read-only does. - -**Exit**: init hands each file server a capability to its own partition's -session and nothing else — a port blockd serves per partition, or a session -init opens and moves — and the boot volume's server a claim that cannot -write, with negative controls: the log's server asking for DATA is refused, -and a write through the boot server's claim is refused by the kernel. diff --git a/issues/an-image-on-a-disk-diskserver-drives-cannot-write-its-slots.md b/issues/an-image-on-a-disk-diskserver-drives-cannot-write-its-slots.md deleted file mode 100644 index 0224d7d2dc7..00000000000 --- a/issues/an-image-on-a-disk-diskserver-drives-cannot-write-its-slots.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-27 ---- - -# An image on a disk diskserver drives cannot write its slots - -The updater writes the idle slot through a partition claim -(`SYS_DEVICE_CLAIM` with `DeviceType::Partition`, granted by `system.toml`'s -`slots` row), and the kernel answers a claim only on the disks it drives: the -USB disks. The kernel drives no NVMe controller: `diskserver` does -(`userland/diskserver`). So a machine booted from an image on its NVMe disk — -every guest whose profile's storage is `Storage::Disk` -(`tests/common/qemu.rs`), and the T14 once ToyOS is installed on its internal -disk — finds its slots nowhere a claim reaches, and an update is refused at -the claim: `slot_grant` (`userland/supervisor/src/main.rs`) asks the inventory -for the ROOT partition the kernel holds, and the kernel lists no partition of -a disk it does not drive. Read off that function, and not run: the tree holds -no test that runs an update on any machine, which is why nothing fails. - -The launcher (`src/qemu.rs`) boots every machine it starts off a USB stick -for this reason: a machine that updates itself keeps its image where a claim -reaches its slots, and moves onto its NVMe disk only once this issue's exit -is met. - -**Exit**: the slots of an image on a disk `diskserver` drives are written -through a session the supervisor grants the updater on `diskserver`'s port, -judged by an update test that boots a profile whose storage is -`Storage::Disk`. - -## Owner - -`userland/supervisor` and `userland/diskserver`; unheld. diff --git a/issues/the-block-port-opens-every-partition-of-the-disk.md b/issues/the-block-port-opens-every-partition-of-the-disk.md deleted file mode 100644 index 29f8de3ccda..00000000000 --- a/issues/the-block-port-opens-every-partition-of-the-disk.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-26 ---- - -# The block port opens every partition of the disk - -A holder of blockd's `block` connector (`toyos_blockring::PORT`) may open a -session on any partition blockd serves, named by nothing but its unique GUID -(`userland/blockd/src/main.rs`, `Service::open`). The kernel's partition claims -are minted one partition at a time from a manifest row, so a program the -manifest gives one partition reaches that one; a program given blockd's port -reaches the whole disk. - -Not fixed with blockd's first landing because nothing yet hands the port to a -program: no manifest row starts blockd, and its only client is the test that -supervises it. Scoping it means the authority to open a partition has to be -something init mints per row — a connector per partition, or a session -capability blockd issues and init hands on — which is the manifest wiring the -small-kernel track's steps 6 and 7 build -(`issues/the-kernel-is-small-interrupts-post-and-threads-wait.md`). - -**Exit condition.** What a program holds names the partitions its manifest row -gives it and no others, and a guest test holding one partition's authority is -refused another partition of the same disk by name. diff --git a/issues/the-boot-volumes-server-holds-a-claim-that-writes.md b/issues/the-boot-volumes-server-holds-a-claim-that-writes.md new file mode 100644 index 00000000000..62d07babdd4 --- /dev/null +++ b/issues/the-boot-volumes-server-holds-a-claim-that-writes.md @@ -0,0 +1,25 @@ +--- +status: open +kind: defect +opened: 2026-10-10 +--- + +# The boot volume's server holds a claim that writes + +On a disk the kernel drives — the boot stick, until usbd serves it — the +supervisor endows the boot volume's file server a partition claim on the +running slot's volume (`storage_endowment`, `userland/supervisor/src/main.rs`). +A claim carries `Rights::WRITE` and no `DUP` (`initial_rights`, +`kernel/src/object/ops.rs`), so nothing can hand that server a duplicate +narrowed to reading: the volume the loader reads the kernel from stays +unwritten only by that server's promise to mount it read-only. On a disk the +block service serves, the same server's grant does not write, and diskserver +answers a write through it `Invalid` (`block_grants_reach_their_partitions`). + +**Exit**: the boot volume's server on the boot stick holds a partition it +cannot write — a read-only session once usbd serves the stick, or a claim the +kernel mints without `WRITE` — and a test's write through it is refused. + +## Owner + +The usbd cutover of `issues/the-kernel-is-small-interrupts-post-and-threads-wait.md`; unheld. diff --git a/src/build.rs b/src/build.rs index b383792baab..e352ae3af93 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2880,6 +2880,7 @@ mod tests { "diag/system.toml", "console/system.toml", "tests/acpicase/system.toml", + "tests/blockgrantcase/system.toml", "tests/jobcase/system.toml", "tests/latencycase/system.toml", "tests/logstallcase/system.toml", @@ -2888,6 +2889,7 @@ mod tests { "tests/netcase/system.toml", "tests/panelcase/system.toml", "tests/proctreecase/system.toml", + "tests/slotscase/system.toml", "tests/testcases/system.toml", "tests/virtjobcase/system.toml", "tests/virtpaniccase/system.toml", diff --git a/tests/blockgrantcase/system.toml b/tests/blockgrantcase/system.toml new file mode 100644 index 00000000000..3711b5819a2 --- /dev/null +++ b/tests/blockgrantcase/system.toml @@ -0,0 +1,20 @@ +# A block service's grants, judged by `block_grants_reach_their_partitions`: +# diskserver is built into the image and started by nothing but the one job, +# `partition_grant`, which holds the claim it hands it and the acceptor every +# grant is minted on. No file server runs, so no partition of the disk the +# machine booted from is held but the one the job opens. +[boot] +start = ["logkeeper", "test-runner"] + +[programs.logkeeper] +service = true +syscap = ["logread"] + +# `device` because the job mints the controller's claim it hands diskserver, +# `dup` because test-runner hands a job its capability as a duplicate and +# hands none without it, and `inventory` because the job reads which +# partitions the loader named. +[programs.test-runner] +syscap = ["device", "dup", "inventory"] + +[programs.diskserver] diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 6df69de4ce2..85444bd0344 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -994,6 +994,10 @@ pub struct BootOptions { /// calling CPU's affinity: the firmware side's own account of what the /// kernel asked of it. pub psci_trace: Option, + /// A second slot beside the one the image boots, with room for an + /// update's ROOT of this many bytes: a machine that updates itself. + /// `None` for every guest whose subject is not the update. + pub second_slot: Option, } impl BootOptions { @@ -1031,6 +1035,7 @@ impl Default for BootOptions { ready_marker: DEFAULT_READY, extra_root_files: Vec::new(), psci_trace: None, + second_slot: None, } } } @@ -1109,6 +1114,7 @@ fn build_boot_image_with( kernel_features: &[&str], kernel_params: &[&str], debug_wait: bool, + second_slot: Option, ) -> Vec { // **The two fields have the same type, so swapping them compiles.** It // happened once, in this file's own conversion: the shared boot handed @@ -1179,7 +1185,8 @@ fn build_boot_image_with( ); let quiet = !VERBOSE.load(Ordering::Relaxed); - let plan = toyos_build::build::Plan::new(arch, &config_path, kernel_features, kernel_params); + let mut plan = toyos_build::build::Plan::new(arch, &config_path, kernel_features, kernel_params); + plan.second = second_slot.map(|root_bytes| toyos_build::image::SecondSlot { root_bytes }); toyos_build::build::build_test_image(&compile::repo_root(), &plan, quiet, &extra_files) } @@ -1261,6 +1268,7 @@ impl QemuInstance { &features, ¶ms, options.debug_wait, + options.second_slot, ); let storage = options.profile.shape().storage; match storage { @@ -1568,6 +1576,12 @@ impl QemuInstance { self.sockets.qmp.as_deref().expect("qmp_socket needs BootOptions { qmp: true }") } + /// The disk this guest booted from, which it writes: read back while the + /// guest runs, since the instance's end deletes it. + pub fn boot_image(&self) -> &Path { + &self.boot_image + } + pub fn run_test(&mut self, name: &str, timeout: Duration) -> TestResult { self.run_test_paced(name, timeout, |_, _| {}) } diff --git a/tests/slotscase/system.toml b/tests/slotscase/system.toml new file mode 100644 index 00000000000..0e85e895f0c --- /dev/null +++ b/tests/slotscase/system.toml @@ -0,0 +1,28 @@ +# The update, judged by `update_writes_the_idle_slot_through_the_block_service`: +# a machine booted off its NVMe disk, whose slots are diskserver's partitions, +# runs `update` as a login runs it. test-runner is a `login` row listing it, +# so the job it runs launches `update` in a login session of its own. +[boot] +start = ["logkeeper", "diskserver", "fileserver", "test-runner"] + +[programs.logkeeper] +service = true +syscap = ["logread"] + +[programs.test-runner] +login = true +starts = ["update"] + +[programs.update] +slots = true + +[programs.diskserver] +service = true +restart = true +serves = ["block"] +devices = ["pci:1b36:0010"] + +[programs.fileserver] +restart = true +roles = ["data", "log", "boot"] +receives = ["block"] diff --git a/tests/toyos-rust-tests/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index d7afd1b6254..ff854837b1c 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -128,6 +128,17 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "diskserver" +version = "0.0.0" +dependencies = [ + "toyos", + "toyos-abi", + "toyos-blockhold", + "toyos-blockring", + "toyos-gpt", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -693,10 +704,26 @@ dependencies = [ name = "toyos-abi" version = "0.16.0" +[[package]] +name = "toyos-blockhold" +version = "0.1.0" + +[[package]] +name = "toyos-blockring" +version = "0.1.0" +dependencies = [ + "toyos-blockhold", + "toyos-transport", +] + [[package]] name = "toyos-font" version = "0.2.0" +[[package]] +name = "toyos-gpt" +version = "0.1.0" + [[package]] name = "toyos-inspect" version = "0.1.0" @@ -721,12 +748,15 @@ version = "0.1.0" dependencies = [ "acpiserver-api", "cpal", + "diskserver", "inspect", "libloading", "logkeeper-api", "memmap2", "toyos", "toyos-abi", + "toyos-blockring", + "toyos-gpt", "toyos-inspect", "toyos-logstream", "toyos-tco", @@ -745,6 +775,17 @@ dependencies = [ "toyos-abi", ] +[[package]] +name = "toyos-transport" +version = "0.1.0" +dependencies = [ + "toyos-untrusted", +] + +[[package]] +name = "toyos-untrusted" +version = "0.1.0" + [[package]] name = "toyos-window" version = "0.20.0" diff --git a/tests/toyos-rust-tests/Cargo.toml b/tests/toyos-rust-tests/Cargo.toml index c4e1f7285d4..1c277eb63d5 100644 --- a/tests/toyos-rust-tests/Cargo.toml +++ b/tests/toyos-rust-tests/Cargo.toml @@ -17,6 +17,11 @@ toyos-logstream = { path = "../../toyos-logstream" } logkeeper-api = { path = "../../userland/logkeeper-api" } # The ACPI server's count line, for `acpi_hold`'s wait on it. acpiserver-api = { path = "../../userland/acpiserver-api" } +# A block service's session, its grants and the partition types, for +# `partition_grant` and `update_idle_slot`. +diskserver = { path = "../../userland/diskserver" } +toyos-blockring = { path = "../../toyos-blockring" } +toyos-gpt = { path = "../../toyos-gpt" } # The diary's decoder, for `trace_read` and `trace_flood`. toyos-trace = { path = "../../toyos-trace" } # The reader's asker, for `hda_client_stall`. diff --git a/tests/toyos-rust-tests/src/bin/partition_grant.rs b/tests/toyos-rust-tests/src/bin/partition_grant.rs new file mode 100644 index 00000000000..51eb1fc50b7 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/partition_grant.rs @@ -0,0 +1,119 @@ +//! A block service's grants, asked of diskserver serving the disk this +//! machine booted from: this job holds the controller's claim and the block +//! port's acceptor, starts diskserver with both as the supervisor starts it, +//! and mints every grant on the acceptor as the supervisor mints a file +//! server's. +//! +//! - One partition's grant lists that partition alone, opens it, and is +//! refused another partition of the same disk by name — one nothing holds, +//! so nothing but the grant stands in the way. +//! - A type's grant lists and opens its type's partition and is refused one +//! of another type. +//! - A session whose grant does not write is refused a write, and reads. +//! - The port's own connector, which carries no grant, lists and opens +//! nothing. +//! - The partition the machine runs from is held, whatever reaches it. + +use std::os::toyos::process::CommandExt; +use std::process::Command; + +use diskserver::{Error, Outcome, Session}; +use toyos::endow::{Endowments, SYSCAP_LABEL}; +use toyos::namespace::{self, Namespace}; +use toyos::port::{self, Connector}; +use toyos::syscap::SysCap; +use toyos::AsHandle; +use toyos_abi::inventory::{RawRecord, Record, Role}; +use toyos_abi::syscall::{DeviceRequest, DEV_PREFIX, SERVE_PREFIX}; +use toyos_blockring::wire::{Grant, Refusal, Scope}; +use toyos_blockring::PORT; + +/// The controller diskserver's row names, which this boot starts no row for. +const CONTROLLER: &str = "pci:1b36:0010"; + +fn names(connector: &Connector) -> Namespace { + namespace::build().add(PORT, connector).finish().expect("partition_grant: a namespace of one connector") +} + +fn listed(connector: &Connector) -> Result, Error> { + diskserver::list(&names(connector), PORT).map(|all| all.into_iter().map(|l| l.unique).collect()) +} + +fn open(connector: &Connector, guid: [u8; 16]) -> Result { + Session::open(names(connector), PORT, guid) +} + +fn main() { + let cap: SysCap = Endowments::get().take(SYSCAP_LABEL).expect("test-runner endows a device-minting capability"); + let records: Vec = cap.records(|n| vec![RawRecord::EMPTY; n]).expect("partition_grant: the inventory"); + let loaded = |role: Role| { + records + .iter() + .find_map(|r| match r { + Record::Loaded(l) if l.role == role => Some(l.unique_guid), + _ => None, + }) + .unwrap_or_else(|| panic!("partition_grant: the loader named no {role:?}")) + }; + let (log, boot, root) = (loaded(Role::Log), loaded(Role::Boot), loaded(Role::Root)); + + let Some(DeviceRequest::Pci(id)) = DeviceRequest::parse(CONTROLLER) else { unreachable!("a PCI request") }; + let claim: toyos::Device = cap.claim_pci(id).expect("partition_grant: the NVMe controller nothing else claims"); + let (acceptor, bare) = port::create().expect("partition_grant: the block port"); + let served = toyos_abi::syscall::dup(acceptor.as_handle()).expect("partition_grant: the acceptor for diskserver"); + let mut text = [0u8; toyos_abi::part::GUID_TEXT_LEN]; + let running = toyos_abi::part::PartGuid(root).write_text(&mut text).to_string(); + let mut server = Command::new("/system/bin/diskserver") + .args(["--running", &running]) + .endow(&format!("{DEV_PREFIX}{CONTROLLER}"), claim.into_raw().0) + .endow(&format!("{SERVE_PREFIX}{PORT}"), served.0) + .spawn() + .expect("partition_grant: diskserver"); + let mint = |scope: Scope, writes: bool| { + acceptor.mint(&Grant { scope, writes }.encode()).expect("partition_grant: a grant minted") + }; + + let logs = mint(Scope::Unique(log), true); + assert_eq!(listed(&logs), Ok(vec![log]), "the log's grant listed more than the log"); + let held = open(&logs, log).expect("partition_grant: the log's grant opens the log"); + assert_eq!( + open(&logs, boot).err(), + Some(Error::Refused(Refusal::NotGranted)), + "the log's grant opened the boot volume, which nothing holds" + ); + drop(held); + println!("partition_grant: the log's grant opened the log and was refused the boot volume NotGranted"); + + let data = mint(Scope::Kind(toyos_gpt::Guid::TOYOS_DATA.0), true); + let [data_part] = listed(&data).expect("partition_grant: DATA's grant lists")[..] else { + panic!("partition_grant: DATA's grant lists other than one partition") + }; + let held = open(&data, data_part).expect("partition_grant: DATA's grant opens DATA"); + assert_eq!(open(&data, log).err(), Some(Error::Refused(Refusal::NotGranted)), "DATA's grant opened the log"); + drop(held); + println!("partition_grant: DATA's grant opened DATA and was refused the log NotGranted"); + + let boots = mint(Scope::Unique(boot), false); + let mut volume = open(&boots, boot).expect("partition_grant: the boot volume's grant opens it"); + let (read, first) = volume.read(0, 1).expect("partition_grant: a read of the boot volume"); + let first = first.filter(|_| read == Outcome::Done).expect("partition_grant: the boot volume's first block"); + // The bytes it holds: a write let through changes nothing. + assert_eq!( + volume.write(0, &first), + Ok(Outcome::Invalid), + "a session whose grant does not write wrote the boot volume" + ); + drop(volume); + println!("partition_grant: the boot volume's read-only grant read it and was refused a write Invalid"); + + assert_eq!(listed(&bare), Err(Error::Refused(Refusal::NotGranted)), "the port's own connector listed"); + assert_eq!(open(&bare, log).err(), Some(Error::Refused(Refusal::NotGranted)), "the port's own connector opened"); + println!("partition_grant: the port's own connector was refused a listing and an open NotGranted"); + + let roots = mint(Scope::Unique(root), true); + assert_eq!(open(&roots, root).err(), Some(Error::Refused(Refusal::Held)), "a grant opened the running ROOT"); + println!("partition_grant: the running ROOT was refused Held to the grant naming it"); + + server.kill().expect("partition_grant: diskserver ends"); + server.wait().expect("partition_grant: diskserver reaped"); +} diff --git a/tests/toyos-rust-tests/src/bin/update_idle_slot.rs b/tests/toyos-rust-tests/src/bin/update_idle_slot.rs new file mode 100644 index 00000000000..1215995da8d --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/update_idle_slot.rs @@ -0,0 +1,22 @@ +//! `update` run as `ssh update < image` runs it, on the signed +//! image the harness put on ROOT: launched through this job's launcher, so +//! the supervisor grants it the idle slot, with the image as its standard +//! input. What it says is this job's; whether the slot holds the image is +//! the harness's to read off the disk. + +use std::process::{Command, Stdio}; + +/// Where the harness puts the image (`tests/slotscase`). +const IMAGE: &str = "/system/share/update-test.img"; + +fn main() { + let image = std::fs::File::open(IMAGE).expect("update_idle_slot: the image the harness staged"); + let out = Command::new("/system/bin/update") + .stdin(Stdio::from(image)) + .output() + .expect("update_idle_slot: update launched"); + for line in String::from_utf8_lossy(&out.stdout).lines() { + println!("update_idle_slot: update said: {line}"); + } + assert!(out.status.success(), "update_idle_slot: update ended {:?}", out.status); +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 562ca05ee8a..a14d57a9fae 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -351,6 +351,21 @@ const MACHINE_TESTS: &[&str] = &[ // reads it have no host build, and the T14 boots from a stick beside an // NVMe disk that is another system's. "nvme_disk_keeps_log_and_home", + // A badge the kernel stamps on a connection to diskserver, judged by + // diskserver on the disk it drives: the grant's decisions are host-tested + // in diskserver and toyos-blockring, but whether the badge diskserver + // reads is the one a connector was minted with, and whether a refused + // open stays refused against a partition nothing else holds, is the + // kernel's port and a claimed controller, which have no host build; and + // the T14 boots from a stick, so no disk diskserver drives holds a + // partition the loader named. + "block_grants_reach_their_partitions", + // The supervisor reading the slot table through a block session and + // minting `update` the idle slot's partitions, which `update` writes and + // marks through diskserver: the supervisor, the launcher and diskserver + // have no host build, and the T14's slots are on its stick, which only + // the kernel drives until usbd. + "update_writes_the_idle_slot_through_the_block_service", ]; /// **The metal profile**: which registrations run on the ThinkPad T14, what @@ -3409,6 +3424,8 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { "bar_map_again" => bar_map_again(test_config), "console_image_boots" => console_image_boots(), "nvme_disk_keeps_log_and_home" => nvme_disk_keeps_log_and_home(test_config), + "block_grants_reach_their_partitions" => block_grants_reach_their_partitions(), + "update_writes_the_idle_slot_through_the_block_service" => update_writes_the_idle_slot_through_the_block_service(), other => Err(format!("unknown machine test {other}")), } } @@ -3445,6 +3462,11 @@ fn served_by_diskserver(qemu: &mut QemuInstance, console: &mut String) -> Result "diskserver opened sessions on {sessions:?}, and DATA, the log and the slot's volume are three partitions\n{console}" )); } + // The boot volume's grant does not write, and only that one. + let read_only = said.text().lines().filter(|line| line.contains("diskserver: session ") && line.contains(", read-only)")).count(); + if read_only != 1 { + return Err(format!("diskserver opened {read_only} read-only sessions, and only the slot's volume is granted one\n{console}")); + } Ok(()) } @@ -3530,6 +3552,116 @@ fn nvme_disk_keeps_log_and_home(test_config: &Path) -> Result<(), String> { Ok(()) } +/// What `partition_grant` says, a line per grant it was refused through. +const GRANTS_SAID: [&str; 5] = [ + "partition_grant: the log's grant opened the log and was refused the boot volume NotGranted", + "partition_grant: DATA's grant opened DATA and was refused the log NotGranted", + "partition_grant: the boot volume's read-only grant read it and was refused a write Invalid", + "partition_grant: the port's own connector was refused a listing and an open NotGranted", + "partition_grant: the running ROOT was refused Held to the grant naming it", +]; + +/// A connection to diskserver opens only what the badge on its connector +/// grants, on the NVMe disk the machine booted from: `partition_grant` starts +/// diskserver itself and mints each grant, and diskserver names each refusal. +fn block_grants_reach_their_partitions() -> Result<(), String> { + const JOB: &str = "partition_grant"; + let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/toyos-rust-tests"), JOB); + let case = compile::repo_root().join("tests/blockgrantcase"); + let options = BootOptions { profile: qemu::Profile::HeadlessNoUsb, ..Default::default() }; + let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), bin)], options); + let result = qemu.run_test(&format!("test_rs_{JOB}"), Duration::from_secs(60)); + if let Some(why) = &result.error { + return Err(format!("{why}\nthe job said:\n{}", result.stdout)); + } + if result.exit_code != Some(0) { + return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout)); + } + let said = serial::Serial::named("the job", result.stdout); + for line in GRANTS_SAID { + eprintln!(" [grant] {}", said.must_say(line)?.trim()); + } + // diskserver's own word for each refusal, beside the job's. + for refused in ["refused: NotGranted", "refused: Held"] { + if !said.text().lines().any(|l| l.contains("diskserver: an open of ") && l.trim_end().ends_with(refused)) { + return Err(format!("diskserver never said an open was {refused}:\n{}", said.text())); + } + } + Ok(()) +} + +/// The update image `update_writes_the_idle_slot_through_the_block_service` +/// stages: a ROOT of one file, signed with this run's key at a version past +/// any a build signs; the ROOT's length, which the second slot is made to +/// hold exactly, so the host mounts the partition whole; and its file. +struct StagedUpdate { + image: Vec, + version: u64, + root_bytes: u64, + marker: Vec, +} + +/// Where the staged ROOT's one file is, and the path the guest reads the +/// update image at. +const UPDATE_MARKER: &str = "etc/update-test"; +const UPDATE_IMAGE: &str = "share/update-test.img"; + +fn staged_update() -> StagedUpdate { + let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).expect("this host's clock is past 1970"); + let marker = format!("update {} {}\n", std::process::id(), since.as_nanos()).into_bytes(); + let root = toyos_build::image::create_root_image(&[(UPDATE_MARKER.to_string(), marker.clone())], &[], true); + // Past every version a build signs, which is its Unix time. + let version = u64::from(u32::MAX) << 8; + let signing = toyos_build::image::Signing { key: toyos_build::signing::key(), version }; + let image = toyos_build::image::update_image(b"update test kernel", &root, "", signing); + StagedUpdate { image, version, root_bytes: root.len() as u64, marker } +} + +/// `update` on a machine booted off its NVMe disk: the supervisor reads the +/// slot table through a session on diskserver and grants `update` the idle +/// slot's partitions as connectors minted for each, and `update` writes the +/// signed image there and marks it — read back off the disk by the host's own +/// partition table and bcachefs readers, not by anything that wrote it. +fn update_writes_the_idle_slot_through_the_block_service() -> Result<(), String> { + const JOB: &str = "update_idle_slot"; + let staged = staged_update(); + let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &compile::repo_root().join("tests/toyos-rust-tests"), JOB); + let case = compile::repo_root().join("tests/slotscase"); + let options = BootOptions { + profile: qemu::Profile::HeadlessNoUsb, + extra_root_files: vec![(UPDATE_IMAGE.to_string(), staged.image.clone())], + second_slot: Some(staged.root_bytes), + ..Default::default() + }; + let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), bin)], options); + let result = qemu.run_test(&format!("test_rs_{JOB}"), Duration::from_secs(120)); + if let Some(why) = &result.error { + return Err(format!("{why}\nthe job said:\n{}", result.stdout)); + } + if result.exit_code != Some(0) { + return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout)); + } + let said = serial::Serial::named("the job", result.stdout); + eprintln!( + " [update] {}", + said.must_say("supervisor: the idle slot is B, granted with the slot table read through the block service")?.trim() + ); + eprintln!(" [update] {}", said.must_say(&format!("update: installed version {} in slot B", staged.version))?.trim()); + + let mut disk = fs::File::open(qemu.boot_image()).map_err(|e| format!("{}: {e}", qemu.boot_image().display()))?; + let table = toyos_build::image::slot_table_of(&mut disk)?; + let marked = table.slot(table.marked).ok_or("the slot table marks a slot it does not carry")?; + if table.marked.letter() != 'B' || marked.version != staged.version { + return Err(format!("after the update the slot table marks {} at version {}, not B at {}", table.marked.letter(), marked.version, staged.version)); + } + let (_, read_back) = toyos_build::image::root_file_on(qemu.boot_image(), UPDATE_MARKER)?; + if read_back != staged.marker { + return Err(format!("slot B's ROOT carries {:?} at {UPDATE_MARKER}, and the update carried {:?}", String::from_utf8_lossy(&read_back), String::from_utf8_lossy(&staged.marker))); + } + eprintln!(" [update] the disk's slot table marks B at version {}, and B's ROOT mounts carrying the update's {UPDATE_MARKER}", staged.version); + Ok(()) +} + /// A claim's memory BAR asked for again — while an earlier answer is held, and /// once its handle is gone, closed or never installed for want of room: each /// time the kernel answers a handle whose mapping reads the function's own diff --git a/toyos-blockring/src/client.rs b/toyos-blockring/src/client.rs index 191cf9c3004..a13f9778f12 100644 --- a/toyos-blockring/src/client.rs +++ b/toyos-blockring/src/client.rs @@ -58,7 +58,8 @@ pub enum Outcome { /// A flush: every write acknowledged before it was asked for is on the /// medium. Durable, - /// The server refused it as malformed. + /// The server refused it as malformed, or as a write its grant does not + /// make. Invalid, /// The device did not do it. A write answered this may or may not have /// reached the medium; a flush answered this left writes the device would diff --git a/toyos-blockring/src/entry.rs b/toyos-blockring/src/entry.rs index f39788b4c00..a842d1df208 100644 --- a/toyos-blockring/src/entry.rs +++ b/toyos-blockring/src/entry.rs @@ -98,7 +98,8 @@ pub enum Status { /// Done. For a flush, every write of its writer's acknowledged before it /// was submitted is on the medium. Ok, - /// Refused unread: the request was malformed ([`Refused`]). + /// Refused unread: the request was malformed ([`Refused`]), or is a write + /// on a session whose grant does not write. Invalid, /// The device did not do it, or was reset under it. A write answered this /// may or may not have reached the medium. diff --git a/toyos-blockring/src/lib.rs b/toyos-blockring/src/lib.rs index db67c0e94d0..42fb150ad31 100644 --- a/toyos-blockring/src/lib.rs +++ b/toyos-blockring/src/lib.rs @@ -50,6 +50,6 @@ pub use entry::{Completion, Op, Request, Status}; pub use toyos_transport::Run; pub use layout::{BLOCK_BYTES, DEPTH, MAX_REQUEST_BLOCKS, SESSION_BYTES}; -/// The name a block service is served under. A holder of its connector may -/// open any partition the service has. +/// The name a block service is served under. A connection opens only what its +/// connector's badge grants ([`wire::Grant`]). pub const PORT: &str = "block"; diff --git a/toyos-blockring/src/model.rs b/toyos-blockring/src/model.rs index 39c2a60dfaf..90d6a58b609 100644 --- a/toyos-blockring/src/model.rs +++ b/toyos-blockring/src/model.rs @@ -273,7 +273,7 @@ fn start(failures: Failures) -> World { fn connect(world: &mut World) { let mut holds = Holds::new(); holds.hold(0, BLOCKS as u64, 1).expect("a fresh server holds nothing"); - world.server = Some(Server { session: ServerSession::new(0, BLOCKS as u64), holds }); + world.server = Some(Server { session: ServerSession::new(0, BLOCKS as u64, true), holds }); world.alive = true; world.losses = 0; world.client.session_started(); diff --git a/toyos-blockring/src/server.rs b/toyos-blockring/src/server.rs index 24ca1703bf2..5c66442ab9d 100644 --- a/toyos-blockring/src/server.rs +++ b/toyos-blockring/src/server.rs @@ -31,6 +31,9 @@ pub struct ServerSession { blocks: u64, /// The span of the device this session holds, which is its writer. first: u64, + /// Its grant lets it write; a write on a session whose grant does not is + /// answered `Invalid` and never reaches the device. + writes: bool, /// Each request in flight, in the order it was taken: a tag is only ever /// compared for equality. inflight: Vec<(u32, Op)>, @@ -47,9 +50,9 @@ pub enum Taken { impl ServerSession { /// A session over the partition at device block `first`, `blocks` long, - /// which `holds` already holds for it. - pub fn new(first: u64, blocks: u64) -> Self { - Self { blocks, first, inflight: Vec::new() } + /// which `holds` already holds for it, taking writes if `writes`. + pub fn new(first: u64, blocks: u64, writes: bool) -> Self { + Self { blocks, first, writes, inflight: Vec::new() } } /// The writer this session's writes and flushes are accounted to. @@ -74,9 +77,10 @@ impl ServerSession { /// Decide what one entry the client published is. /// - /// A malformed entry, and a tag already in flight, are answered at once - /// and never reach the device: the second would make one tag two - /// requests, and the client could not tell which answer was whose. + /// A malformed entry, a write its grant does not let it make, and a tag + /// already in flight, are answered at once and never reach the device: + /// the last would make one tag two requests, and the client could not + /// tell which answer was whose. pub fn take(&mut self, words: [Untrusted; SQE_WORDS]) -> Taken { let request = match Request::decode(words, self.blocks) { Ok(request) => request, @@ -84,6 +88,9 @@ impl ServerSession { return Taken::Answer(Completion { tag, status: Status::Invalid }) } }; + if !self.writes && matches!(request.op, Op::Write { .. }) { + return Taken::Answer(Completion { tag: request.tag, status: Status::Invalid }); + } if self.inflight.iter().any(|&(tag, _)| tag == request.tag) { return Taken::Answer(Completion { tag: request.tag, status: Status::Invalid }); } @@ -145,7 +152,7 @@ mod tests { fn a_reset_answers_once_and_the_late_device_answer_is_dropped() { let mut holds: Holds = Holds::new(); holds.hold(10, 20, 1).unwrap(); - let mut session = ServerSession::new(10, 10); + let mut session = ServerSession::new(10, 10, true); assert!(matches!(session.take(write(1)), Taken::Issue(_))); assert_eq!(session.abort_all(), [Completion { tag: 1, status: Status::Device }]); assert_eq!(session.complete(1, true, &mut holds, 1), None); @@ -155,7 +162,7 @@ mod tests { /// tags' values: nothing orders a tag but its arrival. #[test] fn a_reset_answers_in_the_order_taken() { - let mut session = ServerSession::new(0, 10); + let mut session = ServerSession::new(0, 10, true); for tag in [9, 4] { assert!(matches!(session.take(write(tag)), Taken::Issue(_))); } @@ -165,7 +172,7 @@ mod tests { #[test] fn a_tag_in_flight_twice_is_refused_unissued() { - let mut session = ServerSession::new(0, 10); + let mut session = ServerSession::new(0, 10, true); assert!(matches!(session.take(write(4)), Taken::Issue(_))); assert_eq!(session.take(write(4)), Taken::Answer(Completion { tag: 4, status: Status::Invalid })); assert_eq!(session.inflight().len(), 1); @@ -177,7 +184,7 @@ mod tests { fn a_flush_after_a_loss_answers_lost_once() { let mut holds: Holds = Holds::new(); holds.hold(0, 10, 1).unwrap(); - let mut session = ServerSession::new(0, 10); + let mut session = ServerSession::new(0, 10, true); session.take(write(1)); assert_eq!(session.complete(1, true, &mut holds, 0).unwrap().status, Status::Ok); session.take(flush(2)); @@ -185,4 +192,16 @@ mod tests { session.take(flush(3)); assert_eq!(session.complete(3, true, &mut holds, 1).unwrap().status, Status::Ok); } + + /// A session whose grant does not write answers a write `Invalid` and + /// holds nothing for the device, and still reads and flushes. + #[test] + fn a_read_only_session_refuses_a_write_unissued() { + let mut session = ServerSession::new(0, 10, false); + assert_eq!(session.take(write(1)), Taken::Answer(Completion { tag: 1, status: Status::Invalid })); + assert_eq!(session.inflight().len(), 0); + let read = Request { op: Op::Read { run: ARENA.run(0, 1).unwrap(), lba: 0 }, tag: 2 }; + assert!(matches!(session.take(read.encode().map(Untrusted::new)), Taken::Issue(_))); + assert!(matches!(session.take(flush(3)), Taken::Issue(_))); + } } diff --git a/toyos-blockring/src/wire.rs b/toyos-blockring/src/wire.rs index 71236c917bf..2dacaf2b39d 100644 --- a/toyos-blockring/src/wire.rs +++ b/toyos-blockring/src/wire.rs @@ -6,6 +6,12 @@ //! [`Refusal`]. After `MSG_OPENED` the connection carries nothing but doorbell //! bytes, each way. //! +//! **What a connection may open is its [`Grant`]**, the badge the holder of +//! the service's acceptor minted its connector with, which the kernel stamps +//! on every connection through it: a listing names what the grant admits and +//! nothing else, an open of anything else is refused [`Refusal::NotGranted`], +//! and a connection with no grant reaches nothing. +//! //! **The answer comes with the server's ends of the page.** A client looks at //! the page the moment it hears, and one that opens the same region again //! sends the cursors its last server left on it. So [`Opened::over`] makes a @@ -17,14 +23,15 @@ use toyos_transport::Word; use crate::layout::{self, ServerRings, RING_WORDS}; /// Open the partition whose unique GUID is the payload, over the region sent -/// with it. Handles: the region. +/// with it, if the connection's [`Grant`] admits it. Handles: the region. pub const MSG_OPEN: u32 = 1; /// The session is open; the payload is [`Opened`]. pub const MSG_OPENED: u32 = 2; /// Refused; the payload is a [`Refusal`]'s word. pub const MSG_REFUSED: u32 = 3; -/// What partitions the service serves, for a client that finds its own by -/// type: no payload, no handles; answered [`MSG_LISTED`]. +/// What partitions the service serves that the connection's [`Grant`] +/// admits, for a client that finds its own by type or holds one partition's +/// grant: no payload, no handles; answered [`MSG_LISTED`]. pub const MSG_LIST: u32 = 4; /// The answer to [`MSG_LIST`]: one [`Listed`] after another. pub const MSG_LISTED: u32 = 5; @@ -91,10 +98,10 @@ impl Opened { } /// One partition of the table a service drives, as [`MSG_LISTED`] carries -/// it: its unique and type GUIDs as the table stores them. Every entry is -/// listed, one the service will not open among them, so a client that finds -/// its partition by type learns why from the open's refusal rather than -/// taking the partition for missing. +/// it: its unique and type GUIDs as the table stores them. Every entry the +/// grant admits is listed, one the service will not open among them, so a +/// client that finds its partition by type learns why from the open's refusal +/// rather than taking the partition for missing. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Listed { pub unique: [u8; GUID_BYTES], @@ -124,6 +131,67 @@ impl Listed { } } +/// What a connector to a block service reaches: the badge it was minted with +/// (`SYS_PORT_MINT`), whose bytes are [`Grant::encode`]'s. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Grant { + pub scope: Scope, + /// A session it opens takes writes; one that does not answers every + /// write `Invalid`, unissued. + pub writes: bool, +} + +/// Which partitions a [`Grant`] reaches. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// The one partition whose unique GUID this is. + Unique([u8; GUID_BYTES]), + /// Every partition whose type GUID this is: a role found by type. + Kind([u8; GUID_BYTES]), +} + +impl Grant { + pub const BYTES: usize = 2 + GUID_BYTES; + + pub fn encode(&self) -> [u8; Self::BYTES] { + let (tag, guid) = match self.scope { + Scope::Unique(guid) => (1, guid), + Scope::Kind(guid) => (2, guid), + }; + let mut out = [0u8; Self::BYTES]; + out[0] = tag; + out[1] = u8::from(self.writes); + out[2..].copy_from_slice(&guid); + out + } + + /// `None` for bytes no minter of this protocol stamps. + pub fn decode(bytes: &[u8]) -> Option { + let bytes: &[u8; Self::BYTES] = bytes.try_into().ok()?; + let guid: [u8; GUID_BYTES] = bytes[2..].try_into().ok()?; + let scope = match bytes[0] { + 1 => Scope::Unique(guid), + 2 => Scope::Kind(guid), + _ => return None, + }; + let writes = match bytes[1] { + 0 => false, + 1 => true, + _ => return None, + }; + Some(Self { scope, writes }) + } + + /// Whether it reaches the partition whose unique GUID is `unique` and + /// whose type GUID is `kind`. + pub fn admits(&self, unique: [u8; GUID_BYTES], kind: [u8; GUID_BYTES]) -> bool { + match self.scope { + Scope::Unique(guid) => guid == unique, + Scope::Kind(guid) => guid == kind, + } + } +} + /// Why an open was refused. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Refusal { @@ -144,6 +212,11 @@ pub enum Refusal { /// service its claim: nothing on it is served, and a listing is refused /// the same. ClaimRefused, + /// The connection's grant does not reach that partition, whether or not + /// the service has it. A connection with no grant, or with bytes no + /// minter of this protocol stamps, reaches none, and its listing is + /// refused the same. + NotGranted, } impl Refusal { @@ -155,6 +228,7 @@ impl Refusal { Self::Malformed => 4, Self::Exhausted => 5, Self::ClaimRefused => 6, + Self::NotGranted => 7, } } @@ -166,6 +240,7 @@ impl Refusal { 4 => Some(Self::Malformed), 5 => Some(Self::Exhausted), 6 => Some(Self::ClaimRefused), + 7 => Some(Self::NotGranted), _ => None, } } @@ -207,6 +282,7 @@ mod tests { Refusal::Malformed, Refusal::Exhausted, Refusal::ClaimRefused, + Refusal::NotGranted, ] { assert_eq!(Refusal::decode(&r.encode()), Some(r)); } @@ -214,4 +290,33 @@ mod tests { assert_eq!(Refusal::decode(&[1, 0, 0]), None); assert_eq!(guid(&[0; 15]), None); } + + /// A unique grant reaches its one partition and a kind grant every + /// partition of its type, never one that only shares the other GUID's + /// bytes; and a badge decodes only as a minter of this protocol stamps it. + #[test] + fn a_grant_reaches_its_own_partitions_and_decodes_only_whole() { + let (own, other, data) = ([1; GUID_BYTES], [2; GUID_BYTES], [9; GUID_BYTES]); + let unique = Grant { scope: Scope::Unique(own), writes: false }; + assert!(unique.admits(own, data)); + assert!(!unique.admits(other, data)); + assert!(!unique.admits(data, own), "a unique grant read as a type"); + let kind = Grant { scope: Scope::Kind(data), writes: true }; + assert!(kind.admits(own, data) && kind.admits(other, data)); + assert!(!kind.admits(data, own), "a kind grant read as a unique GUID"); + + for grant in [unique, kind] { + assert_eq!(Grant::decode(&grant.encode()), Some(grant)); + assert_eq!(Grant::decode(&grant.encode()[1..]), None); + let mut long = grant.encode().to_vec(); + long.push(0); + assert_eq!(Grant::decode(&long), None); + } + let mut bad = kind.encode(); + bad[0] = 3; + assert_eq!(Grant::decode(&bad), None); + bad = kind.encode(); + bad[1] = 2; + assert_eq!(Grant::decode(&bad), None); + } } diff --git a/userland/fileserver/src/disk.rs b/userland/diskserver/src/disk.rs similarity index 78% rename from userland/fileserver/src/disk.rs rename to userland/diskserver/src/disk.rs index c7c6386ac33..a605549ea59 100644 --- a/userland/fileserver/src/disk.rs +++ b/userland/diskserver/src/disk.rs @@ -1,5 +1,6 @@ -//! Where a volume's blocks come from: a partition diskserver serves, a partition -//! the kernel's own disk serves through a claim, or memory. +//! A partition as a program that reads and writes its blocks holds it: one a +//! block service serves ([`Served`]), or one the kernel's own disk serves +//! through a claim ([`Claimed`]), until usbd serves the stick. //! //! **A block is 4 KiB here, on every source.** A partition that is not whole //! 4 KiB blocks is refused before it is served (diskserver's table read, the @@ -12,9 +13,7 @@ //! because a block write names its whole destination and means the same thing //! twice. -use std::collections::BTreeMap; - -use diskserver::{Error as SessionError, Outcome, Session}; +use crate::{Error as SessionError, Outcome, Session}; use toyos::PartitionDev; use toyos_abi::part::{Block, MAX_BLOCKS_PER_CALL}; use toyos_blockring::MAX_REQUEST_BLOCKS; @@ -44,8 +43,10 @@ pub trait Disk { fn flush(&mut self) -> Result<(), DiskError>; } -fn span(first: u64, len: usize, blocks: u64) -> Result { - assert!(len % BLOCK == 0, "fileserver: a transfer of {len} bytes is no whole blocks"); +/// How many blocks `len` bytes from `first` are, or `Range` past the end of +/// a partition `blocks` long. +pub fn span(first: u64, len: usize, blocks: u64) -> Result { + assert!(len % BLOCK == 0, "a transfer of {len} bytes is no whole blocks"); let count = (len / BLOCK) as u64; match first.checked_add(count) { Some(end) if end <= blocks => Ok(count), @@ -53,50 +54,6 @@ fn span(first: u64, len: usize, blocks: u64) -> Result { } } -/// A volume in memory: the DATA role on a machine with no DATA partition, as -/// the kernel's tmpfs was. Sparse, so what nothing wrote costs nothing. -pub struct Ram { - blocks: u64, - written: BTreeMap>, -} - -impl Ram { - pub fn new(blocks: u64) -> Self { - Self { blocks, written: BTreeMap::new() } - } -} - -impl Disk for Ram { - fn blocks(&self) -> u64 { - self.blocks - } - - fn read(&mut self, first: u64, out: &mut [u8]) -> Result<(), DiskError> { - span(first, out.len(), self.blocks)?; - for (i, chunk) in out.chunks_exact_mut(BLOCK).enumerate() { - match self.written.get(&(first + i as u64)) { - Some(block) => chunk.copy_from_slice(&block[..]), - None => chunk.fill(0), - } - } - Ok(()) - } - - fn write(&mut self, first: u64, data: &[u8]) -> Result<(), DiskError> { - span(first, data.len(), self.blocks)?; - for (i, chunk) in data.chunks_exact(BLOCK).enumerate() { - let mut block = Box::new([0u8; BLOCK]); - block.copy_from_slice(chunk); - self.written.insert(first + i as u64, block); - } - Ok(()) - } - - fn flush(&mut self) -> Result<(), DiskError> { - Ok(()) - } -} - /// A partition the kernel's own disk serves: the stick, until usbd serves it. pub struct Claimed { claim: PartitionDev, @@ -173,9 +130,9 @@ impl Served { Ok(answer) => return Ok(answer), Err(SessionError::Ended) if reconnects < MAX_RECONNECTS => { reconnects += 1; - println!("fileserver: the block service ended; reconnecting ({reconnects} of {MAX_RECONNECTS})"); + println!("the block service ended; reconnecting ({reconnects} of {MAX_RECONNECTS})"); if let Err(why) = self.session.reconnect() { - println!("fileserver: the block service would not take the session back: {why:?}"); + println!("the block service would not take the session back: {why:?}"); return Err(DiskError::Gone); } } diff --git a/userland/diskserver/src/lib.rs b/userland/diskserver/src/lib.rs index 31b787ac9a5..e4d6c10ecd5 100644 --- a/userland/diskserver/src/lib.rs +++ b/userland/diskserver/src/lib.rs @@ -1,9 +1,11 @@ //! What a program that opens a partition through a block service links — the -//! session region as a process sees it ([`region`]) and the session itself -//! ([`session`]) — and the NVMe driver the service runs ([`nvme`]), which is a +//! session region as a process sees it ([`region`]), the session itself +//! ([`session`]), and a partition's blocks however it is held ([`disk`]) — +//! and the NVMe driver the service runs ([`nvme`]), which is a //! library so a test can aim the controller at an address itself. The service //! is `src/main.rs` beside this. +pub mod disk; pub mod nvme; pub mod region; pub mod session; diff --git a/userland/diskserver/src/main.rs b/userland/diskserver/src/main.rs index 818cab622ce..ca0ecd8aa82 100644 --- a/userland/diskserver/src/main.rs +++ b/userland/diskserver/src/main.rs @@ -32,6 +32,14 @@ //! starter names it (`--running `, the ROOT the loader read), because a //! writer there changes the image under the kernel that booted from it. //! +//! **A connection reaches what its grant says** ([`wire::Grant`]): the badge +//! the supervisor minted its connector with on this service's port, which the +//! kernel stamps on it and answers this acceptor alone. A listing names the +//! partitions the grant admits, an open of any other is refused `NotGranted` +//! whether or not the table carries it, a connection with no grant reaches +//! nothing, and a session whose grant does not write answers every write +//! `Invalid` before the device sees it. +//! //! **A client may ask what is served** ([`wire::MSG_LIST`]) before it opens //! anything, which is how a file server finds its role's partition by type. //! @@ -52,12 +60,12 @@ use toyos::ipc::{self, Connection, RxStep}; use toyos::poller::{Poller, READABLE}; use toyos::AsHandle; use toyos_abi::part::{PartGuid, GUID_TEXT_LEN}; -use toyos_abi::syscall::{DEV_PREFIX, SyscallError}; +use toyos_abi::syscall::{DEV_PREFIX, MAX_BADGE, SyscallError}; use toyos_blockhold::Holds; use toyos_blockring::entry::{Completion, Op}; use toyos_blockring::layout::{ServerRings, DEPTH}; use toyos_blockring::server::{ServerSession, Taken}; -use toyos_blockring::wire::{self, Opened, Refusal}; +use toyos_blockring::wire::{self, Grant, Opened, Refusal}; use toyos_blockring::{BLOCK_BYTES, PORT, SESSION_BYTES}; /// How long a command may go unanswered before the controller is reset to @@ -172,6 +180,40 @@ fn read_table(ctrl: &mut Controller) -> Vec { parts } +/// The partitions a listing through `grant` names: every one it admits. +fn listed(parts: &[Part], grant: Option) -> Result, Refusal> { + let grant = grant.ok_or(Refusal::NotGranted)?; + Ok(parts + .iter() + .filter(|p| grant.admits(p.unique, p.kind)) + .flat_map(|p| wire::Listed { unique: p.unique, kind: p.kind }.encode()) + .collect()) +} + +/// Where an open of `guid` through `grant` is served, before anything is +/// held for it; or its refusal. **The grant is asked first**, so a +/// connection learns nothing of a partition it does not reach, not even +/// whether the table carries it; then the table, then the partition the +/// machine runs from. +fn admitted(parts: &[Part], running: Option<[u8; 16]>, guid: [u8; 16], grant: Option) -> Result<(u64, u64), Refusal> { + let grant = grant.ok_or(Refusal::NotGranted)?; + let part = parts.iter().find(|p| p.unique == guid && guid != [0; 16]); + // A partition the table does not carry has no type: only a unique grant + // naming it reaches it, to be told `NotFound`. + if !grant.admits(guid, part.map_or([0; 16], |p| p.kind)) { + return Err(Refusal::NotGranted); + } + let span = match part.map(|p| &p.span) { + None => return Err(Refusal::NotFound), + Some(Err(_)) => return Err(Refusal::Unusable), + Some(Ok(span)) => *span, + }; + if running == Some(guid) { + return Err(Refusal::Held); + } + Ok(span) +} + /// A connection that has not opened a session yet. struct Pending { conn: Connection, @@ -251,6 +293,7 @@ struct Opening { opened: Opened, device_addr: u64, first: u64, + writes: bool, } impl Service { @@ -258,35 +301,29 @@ impl Service { Self { ctrl, holds: Holds::new(), losses: 0, sessions: BTreeMap::new(), next_id: 0, running } } - /// What a listing answers: every partition the table names. - fn listing(&self) -> Result, Refusal> { + /// What a listing through `grant` answers: every partition of the table + /// it admits. + fn listing(&self, grant: Option) -> Result, Refusal> { + grant.ok_or(Refusal::NotGranted)?; match &self.ctrl { - Drive::Up(_, parts) => { - Ok(parts.iter().flat_map(|p| wire::Listed { unique: p.unique, kind: p.kind }.encode()).collect()) - } - Drive::Absent => Ok(Vec::new()), + Drive::Up(_, parts) => listed(parts, grant), + Drive::Absent => listed(&[], grant), Drive::Unusable => Err(Refusal::Unusable), Drive::ClaimRefused => Err(Refusal::ClaimRefused), } } - /// The span an open of `guid` is served on, held for it; or its refusal. - fn place(&mut self, guid: [u8; 16]) -> Result<(u64, u64), Refusal> { + /// The span an open of `guid` through `grant` is served on, held for it; + /// or its refusal. + fn place(&mut self, guid: [u8; 16], grant: Option) -> Result<(u64, u64), Refusal> { + grant.ok_or(Refusal::NotGranted)?; let parts = match &self.ctrl { - Drive::Up(_, parts) => parts, - Drive::Absent => return Err(Refusal::NotFound), + Drive::Up(_, parts) => parts.as_slice(), + Drive::Absent => &[], Drive::Unusable => return Err(Refusal::Unusable), Drive::ClaimRefused => return Err(Refusal::ClaimRefused), }; - let part = parts.iter().find(|p| p.unique == guid && guid != [0; 16]); - let (first, blocks) = match part.map(|p| &p.span) { - None => return Err(Refusal::NotFound), - Some(Err(_)) => return Err(Refusal::Unusable), - Some(Ok(span)) => *span, - }; - if self.running == Some(guid) { - return Err(Refusal::Held); - } + let (first, blocks) = admitted(parts, self.running, guid, grant)?; if self.sessions.len() >= MAX_SESSIONS { return Err(Refusal::Exhausted); } @@ -298,13 +335,14 @@ impl Service { /// What an open answers: a session to admit, or its refusal. `region` is /// the client's and is consumed either way. - fn open(&mut self, guid: [u8; 16], region: toyos::RawHandle) -> Result { + fn open(&mut self, guid: [u8; 16], region: toyos::RawHandle, grant: Option) -> Result { let region = Region::adopt(region).map_err(|_| Refusal::Malformed)?; - let (first, blocks) = self.place(guid)?; + let (first, blocks) = self.place(guid, grant)?; + let writes = grant.expect("placed through a grant").writes; match self.ctrl.up().claim().dma_map(region.handle()) { Ok(mapping) if mapping.bytes == SESSION_BYTES as u64 => { let (rings, opened) = Opened::over(region.words(), blocks, guid); - Ok(Opening { region, rings, opened, device_addr: mapping.device_addr, first }) + Ok(Opening { region, rings, opened, device_addr: mapping.device_addr, first, writes }) } // A region longer than a session would spend the claim's bound on // the kernel's side for every other client: refused whole. @@ -336,7 +374,7 @@ impl Service { region: opening.region, device_addr: opening.device_addr, rings: opening.rings, - state: ServerSession::new(opening.first, opening.opened.blocks()), + state: ServerSession::new(opening.first, opening.opened.blocks(), opening.writes), unique: opening.opened.unique(), closing: false, requests: 0, @@ -648,7 +686,7 @@ fn serve(service: &mut Service, acceptor: &toyos::port::Acceptor) -> ! { } RxStep::Frame { msg_type, payload_len } => { let p = pending.remove(i); - handshake(service, p, msg_type, payload_len); + handshake(service, acceptor, p, msg_type, payload_len); } } } @@ -681,13 +719,25 @@ fn doorbells(conn: &Connection) -> bool { } } +/// The grant `conn`, accepted from `acceptor`, was minted with; `None` for a +/// connection through an unbadged connector or with bytes no minter stamps. +fn grant(acceptor: &toyos::port::Acceptor, conn: &Connection) -> Option { + let mut badge = [0u8; MAX_BADGE]; + match acceptor.badge(conn, &mut badge) { + Ok(bytes) => Grant::decode(bytes), + Err(SyscallError::NotFound) => None, + Err(why) => panic!("diskserver: its own acceptor would not say a connection's badge: {why:?}"), + } +} + /// Answer one connection's first frame: a listing, or an open. -fn handshake(service: &mut Service, p: Pending, msg_type: u32, payload_len: usize) { +fn handshake(service: &mut Service, acceptor: &toyos::port::Acceptor, p: Pending, msg_type: u32, payload_len: usize) { let refuse = |conn: &Connection, why: Refusal| { let _ = conn.try_send_bytes(wire::MSG_REFUSED, &why.encode()); }; + let grant = grant(acceptor, &p.conn); if msg_type == wire::MSG_LIST && payload_len == 0 { - match service.listing() { + match service.listing(grant) { Err(why) => refuse(&p.conn, why), // One frame, and the connection is done with: a table larger than // a frame is one this service lists no part of rather than half of. @@ -708,19 +758,20 @@ fn handshake(service: &mut Service, p: Pending, msg_type: u32, payload_len: usiz refuse(&p.conn, Refusal::Malformed); return; }; - match service.open(guid, region) { + match service.open(guid, region, grant) { Err(why) => { println!("diskserver: an open of {} refused: {why:?}", guid_text(guid)); refuse(&p.conn, why); } Ok(opening) => { - let opened = opening.opened; + let (opened, writes) = (opening.opened, opening.writes); if p.conn.try_send_bytes(wire::MSG_OPENED, &opened.encode()).is_err() { service.abandon(opening); return; } let id = service.admit(opening, p.conn); - println!("diskserver: session {id} opened {} ({} blocks)", guid_text(guid), opened.blocks()); + let access = if writes { "" } else { ", read-only" }; + println!("diskserver: session {id} opened {} ({} blocks{access})", guid_text(guid), opened.blocks()); } } } @@ -728,6 +779,7 @@ fn handshake(service: &mut Service, p: Pending, msg_type: u32, payload_len: usiz #[cfg(test)] mod tests { use super::*; + use toyos_blockring::wire::Scope; /// A controller this service cannot use, or one whose claim was refused, /// is a disk there and never a machine without one: its listing and its @@ -736,14 +788,77 @@ mod tests { #[test] fn an_unusable_or_unclaimed_controller_is_refused_and_an_absent_one_lists_nothing() { let guid = [7; 16]; + let grant = Some(Grant { scope: Scope::Unique(guid), writes: true }); let mut unusable = Service::new(Drive::Unusable, None); - assert_eq!(unusable.listing(), Err(Refusal::Unusable)); - assert_eq!(unusable.place(guid), Err(Refusal::Unusable)); + assert_eq!(unusable.listing(grant), Err(Refusal::Unusable)); + assert_eq!(unusable.place(guid, grant), Err(Refusal::Unusable)); let mut unclaimed = Service::new(Drive::ClaimRefused, None); - assert_eq!(unclaimed.listing(), Err(Refusal::ClaimRefused)); - assert_eq!(unclaimed.place(guid), Err(Refusal::ClaimRefused)); + assert_eq!(unclaimed.listing(grant), Err(Refusal::ClaimRefused)); + assert_eq!(unclaimed.place(guid, grant), Err(Refusal::ClaimRefused)); let mut absent = Service::new(Drive::Absent, None); - assert_eq!(absent.listing(), Ok(Vec::new())); - assert_eq!(absent.place(guid), Err(Refusal::NotFound)); + assert_eq!(absent.listing(grant), Ok(Vec::new())); + assert_eq!(absent.place(guid, grant), Err(Refusal::NotFound)); + for mut service in [unusable, unclaimed, absent] { + assert_eq!(service.listing(None), Err(Refusal::NotGranted)); + assert_eq!(service.place(guid, None), Err(Refusal::NotGranted)); + } + } + + const LOG: [u8; 16] = [1; 16]; + const BOOT: [u8; 16] = [2; 16]; + const DATA: [u8; 16] = [3; 16]; + const ROOT: [u8; 16] = [4; 16]; + const LOG_KIND: [u8; 16] = [0x10; 16]; + const BOOT_KIND: [u8; 16] = [0x20; 16]; + const DATA_KIND: [u8; 16] = [0x30; 16]; + const ROOT_KIND: [u8; 16] = [0x40; 16]; + + fn table() -> Vec { + [(LOG, LOG_KIND), (BOOT, BOOT_KIND), (DATA, DATA_KIND), (ROOT, ROOT_KIND)] + .into_iter() + .enumerate() + .map(|(i, (unique, kind))| Part { unique, kind, span: Ok((i as u64 * 100, 100)) }) + .collect() + } + + fn unique(guid: [u8; 16]) -> Option { + Some(Grant { scope: Scope::Unique(guid), writes: true }) + } + + /// A unique grant opens its one partition and is refused every other of + /// the same table by name, as is a connection with no grant; a kind grant + /// opens every partition of its type and no other; and the partition the + /// machine runs from is held whatever reaches it. + #[test] + fn an_open_reaches_only_what_its_grant_admits() { + let parts = table(); + assert_eq!(admitted(&parts, None, LOG, unique(LOG)), Ok((0, 100))); + for other in [BOOT, DATA, ROOT] { + assert_eq!(admitted(&parts, None, other, unique(LOG)), Err(Refusal::NotGranted)); + assert_eq!(admitted(&parts, None, other, None), Err(Refusal::NotGranted)); + } + let data = Some(Grant { scope: Scope::Kind(DATA_KIND), writes: true }); + assert_eq!(admitted(&parts, None, DATA, data), Ok((200, 100))); + assert_eq!(admitted(&parts, None, LOG, data), Err(Refusal::NotGranted)); + // Nothing of a GUID the table does not carry, but to the grant that names it. + assert_eq!(admitted(&parts, None, [9; 16], data), Err(Refusal::NotGranted)); + assert_eq!(admitted(&parts, None, [9; 16], unique([9; 16])), Err(Refusal::NotFound)); + assert_eq!(admitted(&parts, Some(ROOT), ROOT, unique(ROOT)), Err(Refusal::Held)); + assert_eq!(admitted(&parts, Some(ROOT), ROOT, unique(LOG)), Err(Refusal::NotGranted)); + } + + /// A listing names exactly what its grant admits, and nothing to a + /// connection with none. + #[test] + fn a_listing_names_only_what_its_grant_admits() { + let parts = table(); + let decoded = |bytes: Vec| -> Vec<[u8; 16]> { + wire::Listed::decode_all(&bytes).expect("whole entries").map(|l| l.unique).collect() + }; + assert_eq!(listed(&parts, unique(BOOT)).map(decoded), Ok(vec![BOOT])); + let roots = Some(Grant { scope: Scope::Kind(ROOT_KIND), writes: false }); + assert_eq!(listed(&parts, roots).map(decoded), Ok(vec![ROOT])); + assert_eq!(listed(&parts, unique([9; 16])).map(decoded), Ok(vec![])); + assert_eq!(listed(&parts, None), Err(Refusal::NotGranted)); } } diff --git a/userland/fileserver/src/cache.rs b/userland/fileserver/src/cache.rs index 6306c35fb78..021e2aef5e0 100644 --- a/userland/fileserver/src/cache.rs +++ b/userland/fileserver/src/cache.rs @@ -17,7 +17,7 @@ use std::cell::RefCell; use std::collections::{BTreeMap, VecDeque}; -use crate::disk::{Disk, DiskError, BLOCK}; +use diskserver::disk::{Disk, DiskError, BLOCK}; /// Clean blocks kept: 64 MiB. pub const CLEAN_LIMIT: usize = 16 * 1024; @@ -232,11 +232,11 @@ impl Clone for Shared { impl bcachefs::BlockIO for Shared { fn read_block(&self, block: bcachefs::BlockNum, buf: &mut bcachefs::BlockBuf) -> Result<(), bcachefs::DeviceError> { - self.0.read(block.raw(), buf.as_bytes_mut()).map_err(|e| bcachefs::DeviceError::classify(&e)) + self.0.read(block.raw(), buf.as_bytes_mut()).map_err(|_| bcachefs::DeviceError::classify(&Attempted)) } fn write_block(&self, block: bcachefs::BlockNum, buf: &bcachefs::BlockBuf) -> Result<(), bcachefs::DeviceError> { - self.0.write(block.raw(), buf.as_bytes()).map_err(|e| bcachefs::DeviceError::classify(&e)) + self.0.write(block.raw(), buf.as_bytes()).map_err(|_| bcachefs::DeviceError::classify(&Attempted)) } fn block_count(&self) -> u64 { @@ -244,12 +244,15 @@ impl bcachefs::BlockIO for Shared { } fn sync(&self) -> Result<(), bcachefs::DeviceError> { - self.0.flush().map_err(|e| bcachefs::DeviceError::classify(&e)) + self.0.flush().map_err(|_| bcachefs::DeviceError::classify(&Attempted)) } } -/// Every refusal here was attempted: nothing in this server refuses on a clock. -impl bcachefs::TransferError for DiskError { +/// What every [`DiskError`] is to bcachefs: attempted, since nothing in this +/// server refuses on a clock. +struct Attempted; + +impl bcachefs::TransferError for Attempted { fn refused_before_attempt(&self) -> bool { false } @@ -258,7 +261,7 @@ impl bcachefs::TransferError for DiskError { #[cfg(test)] mod tests { use super::*; - use crate::disk::Ram; + use crate::ram::Ram; /// A disk that counts what reaches it. struct Counting { diff --git a/userland/fileserver/src/data.rs b/userland/fileserver/src/data.rs index 1fdbf82260c..fd4c57a27cc 100644 --- a/userland/fileserver/src/data.rs +++ b/userland/fileserver/src/data.rs @@ -36,7 +36,7 @@ use bcachefs::{Extent, Formatted, FsError, Mounted, ReadWrite}; use toyos_abi::syscall::SyscallError; use crate::cache::{Cache, Shared}; -use crate::disk::{Disk, DiskError, BLOCK}; +use diskserver::disk::{Disk, DiskError, BLOCK}; use crate::volume::{join, parent, Kind, Meta, Node, OpenHow, Out, Volume}; /// The longest symlink target read back: the wire's path bound. @@ -767,7 +767,7 @@ mod tests { use std::collections::HashMap; use super::*; - use crate::disk::Ram; + use crate::ram::Ram; use crate::volume::Buf; fn clock() -> u64 { diff --git a/userland/fileserver/src/fat.rs b/userland/fileserver/src/fat.rs index 49d695ff466..6ba7537dd5a 100644 --- a/userland/fileserver/src/fat.rs +++ b/userland/fileserver/src/fat.rs @@ -24,7 +24,7 @@ use toyos_abi::syscall::SyscallError; use toyos_fat32::{BlockAccess, Error, Fat32, FatTime, IoError}; use crate::cache::Cache; -use crate::disk::{Disk, BLOCK}; +use diskserver::disk::{Disk, BLOCK}; use crate::volume::{parent, Kind, Meta, Node, OpenHow, Out, Volume, NANOS_PER_SEC}; /// The most entries one directory listing materialises. @@ -438,7 +438,8 @@ mod tests { use super::*; use crate::cache::CLEAN_LIMIT; - use crate::disk::{DiskError, Ram}; + use crate::ram::Ram; + use diskserver::disk::DiskError; /// A disk that refuses every read of one block, after the fixture's own /// bytes are on it. diff --git a/userland/fileserver/src/lib.rs b/userland/fileserver/src/lib.rs index 0c29712db4d..c8eb2f0d62b 100644 --- a/userland/fileserver/src/lib.rs +++ b/userland/fileserver/src/lib.rs @@ -1,4 +1,5 @@ -//! A file server's decisions: where its blocks come from ([`disk`]), the one +//! A file server's decisions: the blocks memory stands in for a partition +//! with ([`ram`]) — a partition's own are `diskserver::disk`'s — the one //! cache every byte of its volume passes through ([`cache`]), the volumes it //! can serve ([`data`] for the bcachefs DATA role, [`fat`] for FAT32's LOG and //! BOOT, [`absent`] for a role with no volume this boot), and the resolver that @@ -16,8 +17,8 @@ pub mod absent; pub mod cache; pub mod data; -pub mod disk; pub mod fat; +pub mod ram; pub mod resolve; pub mod rights; pub mod volume; diff --git a/userland/fileserver/src/main.rs b/userland/fileserver/src/main.rs index 662115a1631..19c0453f413 100644 --- a/userland/fileserver/src/main.rs +++ b/userland/fileserver/src/main.rs @@ -3,8 +3,10 @@ //! //! **What it holds**: the acceptor of its role's one port, endowed by the //! supervisor under `serve:fs:`; for its volume, a claim on each partition -//! of its role a disk the kernel drives carries, and diskserver's `block` -//! connector in its namespace; and nothing else of the machine. DATA is one +//! of its role a disk the kernel drives carries, and `block` in its namespace, +//! a connector to diskserver minted for its role's partition alone — every +//! DATA partition, or the one the loader named, read-only for the boot +//! volume; and nothing else of the machine. DATA is one //! partition counted over both, and two are refused by name, never guessed //! between (`fileserver::data::find`). Argv is the role, and for LOG and BOOT the //! unique GUID of the partition the loader named for it when no claim on it @@ -42,7 +44,8 @@ use std::time::{Duration, Instant}; use fileserver::absent::Absent; use fileserver::data::{DataVolume, Located, Probed}; -use fileserver::disk::{Claimed, Disk, Ram, Served}; +use diskserver::disk::{Claimed, Disk, Served}; +use fileserver::ram::Ram; use fileserver::fat::FatVolume; use fileserver::resolve::{self, Found, Refusal as Escape, Resolved}; use fileserver::rights; diff --git a/userland/fileserver/src/ram.rs b/userland/fileserver/src/ram.rs new file mode 100644 index 00000000000..f146f74a619 --- /dev/null +++ b/userland/fileserver/src/ram.rs @@ -0,0 +1,50 @@ +//! The blocks memory stands in for a partition with. + +use std::collections::BTreeMap; + +use diskserver::disk::{span, Disk, DiskError, BLOCK}; + +/// A volume in memory: the DATA role on a machine with no DATA partition, as +/// the kernel's tmpfs was. Sparse, so what nothing wrote costs nothing. +pub struct Ram { + blocks: u64, + written: BTreeMap>, +} + +impl Ram { + pub fn new(blocks: u64) -> Self { + Self { blocks, written: BTreeMap::new() } + } +} + +impl Disk for Ram { + fn blocks(&self) -> u64 { + self.blocks + } + + fn read(&mut self, first: u64, out: &mut [u8]) -> Result<(), DiskError> { + span(first, out.len(), self.blocks)?; + for (i, chunk) in out.chunks_exact_mut(BLOCK).enumerate() { + match self.written.get(&(first + i as u64)) { + Some(block) => chunk.copy_from_slice(&block[..]), + None => chunk.fill(0), + } + } + Ok(()) + } + + fn write(&mut self, first: u64, data: &[u8]) -> Result<(), DiskError> { + span(first, data.len(), self.blocks)?; + for (i, chunk) in data.chunks_exact(BLOCK).enumerate() { + let mut block = Box::new([0u8; BLOCK]); + block.copy_from_slice(chunk); + self.written.insert(first + i as u64, block); + } + Ok(()) + } + + fn flush(&mut self) -> Result<(), DiskError> { + Ok(()) + } +} + diff --git a/userland/supervisor/Cargo.toml b/userland/supervisor/Cargo.toml index 710f43ca183..c912c2b8cae 100644 --- a/userland/supervisor/Cargo.toml +++ b/userland/supervisor/Cargo.toml @@ -17,8 +17,11 @@ toyos-swap = { path = "../../toyos-swap" } toyos-update = { path = "../../toyos-update" } # The partition types that name the running ROOT and the slot table. toyos-gpt = { path = "../../toyos-gpt" } -# The block service's port name, which marks a storage row. +# The block service's port name, which marks a storage row, and the grant +# every connector to it is minted with. toyos-blockring = { path = "../../toyos-blockring" } +# The slot table, read through a session where the block service serves it. +diskserver = { path = "../diskserver" } # How long a stop is prepared before the kernel is asked for it. toyos-quiesce = { path = "../../toyos-quiesce" } diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 62fcba2c79c..2e5169fc4f0 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -92,6 +92,7 @@ use toyos::shm::SharedMemory; use toyos::syscap::SysCap; use toyos::{AsHandle, Pipe}; use toyos_abi::Rights; +use toyos_blockring::wire::{Grant as BlockGrant, Scope}; use toyos_logstream::{ Registration, Tag, ALIVE, CONSOLE, FLUSH, FLUSHED, LOGKEEPER, MAX_TAG, ORIGINS, REGISTER, RESUME, STOPPING, }; @@ -420,7 +421,7 @@ fn main() { syscap: &syscap, acceptors, connectors, - grants: Grants { roles: Vec::new() }, + grants: Grants { roles: Vec::new(), block: None }, sessions: Sessions::default(), files, services: Vec::new(), @@ -511,7 +512,12 @@ impl Worker { /// file server. Started before every other row, and never made a home, which /// would be a directory on the volume it serves. fn is_storage(program: &Program) -> bool { - !program.roles.is_empty() || program.serves.iter().any(|s| s == toyos_blockring::PORT) + !program.roles.is_empty() || is_block(program) +} + +/// A row serving the block port. +fn is_block(program: &Program) -> bool { + program.serves.iter().any(|s| s == toyos_blockring::PORT) } /// Everything the supervisor's loop acts on, for the machine's life. @@ -644,7 +650,7 @@ impl<'a> Service<'a> { 0 => Served::Keep(&kept.acceptors), _ => Served::Restart { acceptors: &kept.acceptors, owed }, }; - let storage = storage_endowment(self.program, self.role, syscap)?; + let storage = storage_endowment(self.program, self.role, syscap, grants)?; let (child, devices) = start( Command::new(path), self.program, @@ -763,16 +769,19 @@ impl Flight { impl<'a> Supervisor<'a> { /// Start `[boot] start`, kept for the machine's life: the supervisor is the only /// thing that can kill a daemon, and there is no other way back to a - /// process it started. The storage rows go first — every other start may - /// make a directory, and a directory is a file server's — and the + /// process it started. The storage rows go first, the block service + /// before the file servers its grants are minted on — every other start + /// may make a directory, and a directory is a file server's — and the /// session's home is made before the first row that is not one. fn boot(&mut self, role_acceptors: &mut BTreeMap<&str, Acceptor>, wake: toyos::RawHandle) { let system = self.system; - let storage_first = system - .start - .iter() - .filter(|n| system.program(n).is_some_and(is_storage)) - .chain(system.start.iter().filter(|n| system.program(n).is_none_or(|p| !is_storage(p)))); + let rank = |name: &&String| match system.program(name) { + Some(p) if is_block(p) => 0, + Some(p) if is_storage(p) => 1, + _ => 2, + }; + let mut storage_first: Vec<&String> = system.start.iter().collect(); + storage_first.sort_by_key(rank); let mut homes_made = false; for name in storage_first { let program = system @@ -810,6 +819,10 @@ impl<'a> Supervisor<'a> { if let Some(role) = role { self.grants.roles.push((role, Arc::clone(&service.kept))); } + if is_block(program) { + assert!(self.grants.block.is_none(), "supervisor: two rows start a block service"); + self.grants.block = Some(Arc::clone(&service.kept)); + } let started = service.spawn(&program.path, &[], system, self.syscap, &self.connectors, &self.grants, &self.launcher, &mut self.log); match started { @@ -872,9 +885,70 @@ impl<'a> Supervisor<'a> { } } - /// `work`, a call into the file servers, made on [`Worker`], and its - /// answer — with every server that ends meanwhile started again, so a call - /// its end left waiting in the port's queue goes on to the new process. + /// The idle slot, for the one program whose row asks for it + /// (`toyos_update::slots`): minted here, so the slot this boot runs is + /// never among what is endowed. A machine with none says so and the + /// program starts holding nothing, which it refuses by name. + fn slot_storage(&mut self, program: &Program) -> Storage { + if !program.slots { + return Storage::default(); + } + self.slot_grant().unwrap_or_else(|why| { + say!("supervisor: {}: no slot to grant: {why}", program.name); + Storage::default() + }) + } + + /// **Which slot is idle is the loader's word, not the table's**: the + /// running ROOT is the one the loader read, the table is the one on that + /// ROOT's disk, and the idle slot is the one whose ROOT is not it — and + /// its two partitions are reached only as `toyos_update::slots::grant` + /// admits them: claimed on a disk the kernel drives, and on one the block + /// service serves, through connectors minted for each. + fn slot_grant(&mut self) -> Result { + use toyos_abi::inventory::{Record, Role}; + use toyos_update::slots::{BOOT_LABEL, ROOT_LABEL, TABLE_LABEL}; + let records = inventory(self.syscap)?; + let running = loaded(&records, Role::Root).ok_or("the loader named no ROOT")?; + let parts: Vec<_> = records + .iter() + .filter_map(|r| match r { + Record::Partition(p) => Some(*p), + _ => None, + }) + .collect(); + if let Some(root) = parts.iter().find(|p| p.unique_guid == running) { + return claimed_slots(self.syscap, &parts, *root); + } + let mint = |grants: &Grants<'_>, grant: BlockGrant| { + grants + .partitions(grant)? + .ok_or_else(|| "the ROOT this boot runs is on no disk the kernel drives, and no block service runs".to_string()) + }; + let kind = |guid: toyos_gpt::Guid| BlockGrant { scope: Scope::Kind(guid.0), writes: false }; + let tables = mint(&self.grants, kind(toyos_gpt::Guid::TOYOS_SLOTS))?; + let boots = mint(&self.grants, kind(toyos_gpt::Guid::TOYOS_BOOT))?; + let roots = mint(&self.grants, kind(toyos_gpt::Guid::TOYOS_ROOT))?; + let (table, table_guid, listed) = self.files("the slot table", move || served_slots(tables, boots, roots))??; + let Some(runs) = listed.iter().find(|p| p.unique_guid == running && p.type_guid == SLOT_KINDS.root) else { + return Err("the ROOT this boot runs is on no disk the kernel or the block service serves".into()); + }; + let (idle, slot) = toyos_update::slots::grant(&table, runs, &listed, SLOT_KINDS).map_err(|why| why.to_string())?; + let mut ports = Vec::new(); + for (label, guid) in [(TABLE_LABEL, table_guid), (BOOT_LABEL, slot.boot), (ROOT_LABEL, slot.root)] { + ports.push((label.to_string(), mint(&self.grants, BlockGrant { scope: Scope::Unique(guid), writes: true })?)); + } + say!( + "supervisor: the idle slot is {}, granted with the slot table read through the block service", + idle.letter() + ); + Ok(Storage { ports, ..Storage::default() }) + } + + /// `work`, a call into the file servers or the block service, made on + /// [`Worker`], and its answer — with every server that ends meanwhile + /// started again, so a call its end left waiting in the port's queue goes + /// on to the new process. /// `Err` is the call still unanswered at [`FILES_BOUND`], which the worker /// goes on waiting for alone, or the worker still waiting on an earlier /// one. @@ -1642,6 +1716,7 @@ impl Supervisor<'_> { return; } } + let storage = self.slot_storage(program); let started = start( command, program, @@ -1651,7 +1726,7 @@ impl Supervisor<'_> { &self.connectors, &self.grants, &extras, - Storage::default(), + storage, (&self.launcher, session), Output::Launch { log: &mut self.log, slots: &caller_slots }, ); @@ -1785,41 +1860,22 @@ fn resolve<'a, V>(system: &'a Manifest, path: &str, judge: impl FnOnce(Target<'_ Resolved::Package(row, verdict) } -/// The slot table's partition and the idle slot's two, claimed: the grant a -/// `slots` row is endowed (`toyos_update::slots`). -/// -/// **Which slot is idle is the kernel's word, not the table's**: the running -/// ROOT is the TOYOS-ROOT partition the kernel holds, the table is the one on -/// that ROOT's disk, and the idle slot is the one whose ROOT is not it — and -/// its two partitions are claimed only as `toyos_update::slots::grant` admits -/// them. -fn slot_grant(syscap: &SysCap) -> Result<[(&'static str, toyos::Device); 3], String> { - use toyos_abi::inventory::{PartState, Partition, Record}; - let parts: Vec = inventory(syscap)? - .into_iter() - .filter_map(|r| match r { - Record::Partition(p) => Some(p), - _ => None, - }) +/// The slot table's partition and the idle slot's two, claimed on a disk the +/// kernel drives: the grant a `slots` row is endowed (`toyos_update::slots`), +/// where the ROOT this boot runs, `running`, is one of `parts`. +fn claimed_slots( + syscap: &SysCap, + parts: &[toyos_abi::inventory::Partition], + running: toyos_abi::inventory::Partition, +) -> Result { + use toyos_abi::inventory::Partition; + let tables: Vec<&Partition> = parts + .iter() + .filter(|p| p.device == running.device && p.type_guid == toyos_gpt::Guid::TOYOS_SLOTS.0) .collect(); - let one = |what: &str, found: Vec<&Partition>| match found[..] { - [p] => Ok(*p), - _ => Err(format!("the machine has {} {what}, and a grant needs one", found.len())), + let [table_part] = tables[..] else { + return Err(format!("the machine has {} slot tables on the running ROOT's disk, and a grant needs one", tables.len())); }; - let running = one( - "ROOT partitions the kernel holds", - parts - .iter() - .filter(|p| p.type_guid == toyos_gpt::Guid::TOYOS_ROOT.0 && p.state == PartState::Kernel) - .collect(), - )?; - let table_part = one( - "slot tables on the running ROOT's disk", - parts - .iter() - .filter(|p| p.device == running.device && p.type_guid == toyos_gpt::Guid::TOYOS_SLOTS.0) - .collect(), - )?; let claim = |guid: [u8; 16], what: &str| { syscap .claim_partition::(toyos_abi::part::PartGuid(guid)) @@ -1831,25 +1887,74 @@ fn slot_grant(syscap: &SysCap) -> Result<[(&'static str, toyos::Device); 3], Str .map_err(|e| format!("the slot table would not read: {e:?}"))?; let (table, _) = toyos_update::slots::current([&copies[0], &copies[1]]) .map_err(|why| format!("the slot table's partition holds {why}"))?; - // The table is the grantee's to write, so what it names is held to the - // inventory before anything is claimed. let listed = |p: &Partition| toyos_update::slots::Listed { device: p.device, type_guid: p.type_guid, unique_guid: p.unique_guid, }; - let kinds = toyos_update::slots::Kinds { boot: toyos_gpt::Guid::TOYOS_BOOT.0, root: toyos_gpt::Guid::TOYOS_ROOT.0 }; let all: Vec<_> = parts.iter().map(listed).collect(); - let (idle, slot) = - toyos_update::slots::grant(&table, &listed(&running), &all, kinds).map_err(|why| why.to_string())?; + let (idle, slot) = toyos_update::slots::grant(&table, &listed(&running), &all, SLOT_KINDS).map_err(|why| why.to_string())?; let boot = claim(slot.boot, "idle slot's volume")?; let root = claim(slot.root, "idle slot's ROOT")?; say!("supervisor: the idle slot is {}, granted with the slot table", idle.letter()); - Ok([ - (toyos_update::slots::TABLE_LABEL, table_claim), - (toyos_update::slots::BOOT_LABEL, boot), - (toyos_update::slots::ROOT_LABEL, root), - ]) + let claims = vec![ + (toyos_update::slots::TABLE_LABEL.to_string(), table_claim), + (toyos_update::slots::BOOT_LABEL.to_string(), boot), + (toyos_update::slots::ROOT_LABEL.to_string(), root), + ]; + Ok(Storage { claims, ..Storage::default() }) +} + +/// The types a slot's two partitions carry. +const SLOT_KINDS: toyos_update::slots::Kinds = + toyos_update::slots::Kinds { boot: toyos_gpt::Guid::TOYOS_BOOT.0, root: toyos_gpt::Guid::TOYOS_ROOT.0 }; + +/// What the block service serves of the slots: the slot table, read through +/// a session on `tables`, its unique GUID, and every partition `tables`, +/// `boots` and `roots` list — each a connector minted for one type, read-only. +/// +/// **Made on the supervisor's file worker** ([`Supervisor::files`]): a call into a +/// service the supervisor restarts, from its loop alone, would wait in the +/// port's queue for a process only the loop can start. +fn served_slots( + tables: Connector, + boots: Connector, + roots: Connector, +) -> Result<(toyos_update::slots::Table, [u8; 16], Vec), String> { + let names = |connector: &Connector| { + namespace::build() + .add(toyos_blockring::PORT, connector) + .finish() + .map_err(|e| format!("no namespace for the block service: {e:?}")) + }; + let list = |connector: &Connector| { + diskserver::list(&names(connector)?, toyos_blockring::PORT) + .map_err(|why| format!("the block service would not list the slots' partitions: {why:?}")) + }; + let table_parts = list(&tables)?; + let [table_part] = table_parts[..] else { + return Err(format!("the block service serves {} slot tables, and a grant needs one", table_parts.len())); + }; + let mut session = diskserver::Session::open(names(&tables)?, toyos_blockring::PORT, table_part.unique) + .map_err(|why| format!("the slot table would not open: {why:?}"))?; + let read = session.read(0, toyos_update::slots::COPIES as u32); + let data = match read { + Ok((diskserver::Outcome::Done, Some(data))) => data, + other => return Err(format!("the slot table would not read: {other:?}")), + }; + let (first, second) = data.split_at(toyos_update::slots::BLOCK); + let copies = [first, second].map(|copy| <&[u8; toyos_update::slots::BLOCK]>::try_from(copy).expect("one block each")); + let (table, _) = + toyos_update::slots::current(copies).map_err(|why| format!("the slot table's partition holds {why}"))?; + let mut listed = table_parts; + listed.extend(list(&boots)?); + listed.extend(list(&roots)?); + // One block service is one disk. + let listed = listed + .into_iter() + .map(|p| toyos_update::slots::Listed { device: 0, type_guid: p.kind, unique_guid: p.unique }) + .collect(); + Ok((table, table_part.unique, listed)) } /// What the supervisor says about a device it could not mint a claim for. @@ -1924,8 +2029,9 @@ fn start<'a>( launcher: (&Acceptor, Session), output: Output<'_>, ) -> std::io::Result<(Child, Vec)> { + let Storage { args, claims, ports } = storage; // A storage row's own arguments first: a file server's role leads its argv. - command.args(&storage.args); + command.args(&args); command.args(&program.args); let booting = matches!(output, Output::Boot(_)); @@ -1945,12 +2051,14 @@ fn start<'a>( // acceptor is gone can never be served again. let mut taken: Vec<(&'a str, Acceptor)> = Vec::new(); - for (label, claim) in storage.claims { + for (label, claim) in claims { let raw = claim.into_raw(); command.endow(&label, raw.0); held.0.push(raw); } - if let Some(ns) = build_namespace(program, system, connectors, grants.view(program, launcher.1), extras)? { + let mut view = grants.view(program, launcher.1); + view.extend(ports); + if let Some(ns) = build_namespace(program, system, connectors, view, extras)? { let raw = ns.into_raw(); command.endow(SVC_LABEL, raw.0); held.0.push(raw); @@ -2089,28 +2197,12 @@ fn start<'a>( say!("supervisor: {}: {}", program.name, refused(name, e)); // A block service is told, so the partitions on a controller // the machine has are refused and never taken for none. - if e != SyscallError::NotFound && program.serves.iter().any(|s| s == toyos_blockring::PORT) { + if e != SyscallError::NotFound && is_block(program) { command.args(["--claim-refused", name.as_str()]); } } } } - // The idle slot, for the one program whose row asks for it: minted here, - // against the ROOT the kernel holds, so the slot this boot runs is never - // among what is endowed. A machine with none says so and the program - // starts holding nothing, which it refuses by name. - if program.slots { - match slot_grant(syscap) { - Ok(claims) => { - for (label, claim) in claims { - let raw = claim.into_raw(); - command.endow(label, raw.0); - held.0.push(raw); - } - } - Err(why) => say!("supervisor: {}: no slot to grant: {why}", program.name), - } - } // Nothing was spawned, so everything minted goes back with `held`. if let Some(unpaid) = unpaid { return Err(unpaid); @@ -2230,7 +2322,8 @@ enum Output<'l> { Launch { log: &'l mut Log, slots: &'l [(u32, toyos::RawHandle)] }, } -/// The namespace this program's `receives` names, [`toyos_swap::PORT`] apart. +/// The namespace this program's `receives` names, [`toyos_swap::PORT`] and +/// [`toyos_blockring::PORT`] apart, with `view`'s. /// /// A name some program *provides* rather than serves is not the supervisor's to give: it /// is one port per instance, made by whoever spawns the holder, and it reaches @@ -2246,9 +2339,14 @@ fn build_namespace( view: Vec<(String, Connector)>, extras: &[(&str, Connector)], ) -> std::io::Result> { - // Never the swap port: [`swap_namespace`] says why. - let receives: Vec<&String> = - program.receives.iter().filter(|name| *name != toyos_swap::PORT).collect(); + // Never the swap port: [`swap_namespace`] says why. Never the block + // port's own connector, which reaches nothing: a holder of `block` holds + // one minted for what it reaches, in `view`. + let receives: Vec<&String> = program + .receives + .iter() + .filter(|name| *name != toyos_swap::PORT && *name != toyos_blockring::PORT) + .collect(); if receives.is_empty() && extras.is_empty() && view.is_empty() { return Ok(None); } @@ -2281,7 +2379,7 @@ fn build_namespace( } /// What each program's directory capabilities are minted from: each -/// file-server role's port. +/// file-server role's port; and what its partitions are, the block service's. /// /// **Each start is minted grants naming its session's share** (`toyos::fs::Grant`, /// [`Session::share`]), one per directory of its row's view: a service has a @@ -2289,12 +2387,34 @@ fn build_namespace( /// child it spawns directly and every launch made from it that opens no /// session against one share, and a login session's processes against one /// more. A role whose ports closed for good is minted nothing. +/// +/// **A partition is reached through a connector minted for it** +/// ([`toyos_blockring::wire::Grant`]), never through the block port's own, +/// which no program is handed. struct Grants<'a> { /// Each role's service, whose kept acceptor is the role's port. roles: Vec<(&'a str, Arc>)>, + /// The block service the supervisor started, whose kept acceptor is + /// [`toyos_blockring::PORT`]. + block: Option>>, } impl Grants<'_> { + /// A connector to the block service reaching what `grant` admits; `None` + /// on a machine whose boot starts no block service, and `Err` once its + /// port has closed for good. + fn partitions(&self, grant: BlockGrant) -> Result, String> { + let Some(kept) = &self.block else { return Ok(None) }; + let kept = kept.lock().expect("supervisor: a service's state is poisoned"); + let Some((_, acceptor)) = kept.acceptors.iter().find(|(name, _)| name == toyos_blockring::PORT) else { + return Err("the block service's port has closed for good".into()); + }; + acceptor + .mint(&grant.encode()) + .map(Some) + .map_err(|e| format!("no connector to the block service could be minted: {e:?}")) + } + /// The directory capabilities `program` is endowed for one start in /// `session`, by namespace name: its row's view (`Program::view`), but /// that a storage row sees none, since a file server resolving a path of @@ -2382,13 +2502,15 @@ fn swapped(service: &str, word: Word, detail: &str) { say!("{}", toyos_swap::said(service, word, detail)); } -/// What a storage row's process is started with beside its row: its -/// arguments, and the claims on the partition a file server's role is on -/// where a disk the kernel drives carries it. +/// What a process is started with beside its row for the partitions it +/// reaches: its arguments, the claims endowed by label on the partitions a +/// disk the kernel drives carries, and the connectors in its namespace by +/// name to those the block service serves, each minted for what it reaches. #[derive(Default)] struct Storage { args: Vec, claims: Vec<(String, toyos::Device)>, + ports: Vec<(String, Connector)>, } /// Every record the kernel's inventory answers. @@ -2411,25 +2533,31 @@ fn guid_text(guid: [u8; 16]) -> String { toyos_abi::part::PartGuid(guid).write_text(&mut buf).to_string() } -/// A storage row's arguments and claims for one start. +/// A storage row's arguments, claims and block connector for one start. /// /// A block service is told the ROOT the machine runs from, which it serves no /// session on. A file server is told its role, and gets a claim on every /// partition of its role a disk the kernel drives carries — the stick, until -/// usbd serves it — and otherwise the partition's GUID, which it opens through -/// the block service; DATA it finds by type there itself, and counts with its -/// claims. A log or boot role the loader named no partition for, and a claim -/// the kernel refuses, each refuse the start: the role is then absent, never -/// served from memory. -fn storage_endowment(program: &Program, role: Option<&str>, syscap: &SysCap) -> Result { +/// usbd serves it — and otherwise the partition's GUID and `block` minted for +/// that partition alone, writing for the log and reading for the boot volume; +/// DATA it finds by type, through `block` minted for every DATA partition, +/// and counts with its claims. A log or boot role the loader named no +/// partition for, a claim the kernel refuses, and a block port closed for +/// good each refuse the start: the role is then absent, never served from +/// memory. +fn storage_endowment( + program: &Program, + role: Option<&str>, + syscap: &SysCap, + grants: &Grants<'_>, +) -> Result { use toyos_abi::inventory::{Record, Role}; let mut storage = Storage::default(); - let is_block = program.serves.iter().any(|s| s == toyos_blockring::PORT); - if !is_block && role.is_none() { + if !is_block(program) && role.is_none() { return Ok(storage); } let records = inventory(syscap).map_err(|why| StartError::Other(std::io::Error::other(why)))?; - if is_block { + if is_block(program) { if let Some(root) = loaded(&records, Role::Root) { storage.args.extend(["--running".to_string(), guid_text(root)]); } @@ -2446,14 +2574,18 @@ fn storage_endowment(program: &Program, role: Option<&str>, syscap: &SysCap) -> }) .collect() }; - let (kernel, named) = match role { - "data" => (on_kernel_disk(&|p| p.type_guid == toyos_gpt::Guid::TOYOS_DATA.0), None), + let (kernel, named, served) = match role { + "data" => { + let kind = toyos_gpt::Guid::TOYOS_DATA.0; + (on_kernel_disk(&|p| p.type_guid == kind), None, BlockGrant { scope: Scope::Kind(kind), writes: true }) + } "log" | "boot" => { let which = if role == "log" { Role::Log } else { Role::Boot }; let Some(guid) = loaded(&records, which) else { return Err(StartError::Partition(format!("the loader named no `{role}` partition"))); }; - (on_kernel_disk(&|p| p.unique_guid == guid), Some(guid)) + let grant = BlockGrant { scope: Scope::Unique(guid), writes: role == "log" }; + (on_kernel_disk(&|p| p.unique_guid == guid), Some(guid), grant) } other => panic!("supervisor: `{other}` is no role; the build refuses it"), }; @@ -2466,8 +2598,15 @@ fn storage_endowment(program: &Program, role: Option<&str>, syscap: &SysCap) -> Err(e) => return Err(StartError::Partition(refused(&name, e))), } } - if let (Some(guid), []) = (named, kernel.as_slice()) { - storage.args.push(guid_text(guid)); + // DATA is counted over both; a named partition no claim reached is the + // block service's. + if named.is_none() || kernel.is_empty() { + if let Some(guid) = named { + storage.args.push(guid_text(guid)); + } + if let Some(port) = grants.partitions(served).map_err(StartError::Partition)? { + storage.ports.push((toyos_blockring::PORT.to_string(), port)); + } } Ok(storage) } diff --git a/userland/update/Cargo.toml b/userland/update/Cargo.toml index f03341a6c06..d675b4e6356 100644 --- a/userland/update/Cargo.toml +++ b/userland/update/Cargo.toml @@ -7,7 +7,9 @@ license = "MIT OR Apache-2.0" [dependencies] toyos = { path = "../../toyos" } -toyos-abi = { path = "../../toyos-abi" } +# A slot's partitions, claimed on a disk the kernel drives or opened through +# the block service. +diskserver = { path = "../diskserver" } # The format, the verifier and the slot table the loader reads the same way. toyos-update = { path = "../../toyos-update" } # A slot's volume is FAT, written with the driver the kernel mounts FAT with. @@ -16,4 +18,4 @@ toyos-fat32 = { path = "../../toyos-fat32" } sha2 = { version = "0.10", default-features = false } [package.metadata.toyos.host] -exempt.manages = "ToyOS's boot slots: it writes the idle one, onto the partitions the supervisor claims for it" +exempt.manages = "ToyOS's boot slots: it writes the idle one, onto the partitions the supervisor grants it" diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index d8b65789929..c3763a32317 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -7,9 +7,12 @@ //! signature over them is the whole of its authority to install anything. //! //! **What it holds is the whole of what it can write** (`slots` in its -//! `system.toml` row): the supervisor claims the slot table's partition and the idle -//! slot's FAT volume and ROOT and endows them, and the slot this boot runs is -//! never among them (`toyos_update::slots::idle`). So it writes, in order: +//! `system.toml` row): the slot table's partition and the idle slot's FAT +//! volume and ROOT, as claims the supervisor endows under their labels on a +//! disk the kernel drives, or as connectors in its namespace under the same +//! names, each minted for its one partition on the block service's port; the +//! slot this boot runs is never among them (`toyos_update::slots::idle`). So +//! it writes, in order: //! //! 1. nothing, until the signed header's signature is this machine's key's and //! its version is newer than the running image and no older than the idle @@ -18,7 +21,7 @@ //! the header's hash once whole; //! 3. the kernel, its boot parameter and the signed header onto the idle FAT //! volume, each held to its hash before it is written; -//! 4. an fsync of each claim, which answers for these writes and no other +//! 4. a flush of each partition, 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. @@ -30,9 +33,9 @@ use std::io::Read; use std::time::Instant; -use toyos::endow::Endowments; +use diskserver::disk::{Claimed, Disk, Served, BLOCK}; +use toyos::endow::{self, Endowments}; use toyos::PartitionDev; -use toyos_abi::part::{Block, BLOCK_BYTES, MAX_BLOCKS_PER_CALL}; use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; use toyos_update::slots::{self, Table, Which}; use toyos_fat32::BlockAccess as _; @@ -55,24 +58,38 @@ fn main() { } } -/// The three claims the supervisor endowed, or why this process holds none. -fn grant() -> Result<(PartitionDev, PartitionDev, PartitionDev), String> { - let take = |label: &str| { - Endowments::get() - .take::(label) - .ok_or_else(|| format!("this process holds no `{label}`: the supervisor grants the idle slot to one update at a time, and says why where it grants none")) +/// One partition of the grant: its blocks, and its unique GUID. +struct Held { + disk: Box, + unique: [u8; 16], +} + +/// The partition endowed under `label`: a claim, or a session through the +/// connector of that name, which reaches one partition and lists it. +fn held(label: &str) -> Result { + let none = || format!("this process holds no `{label}`: the supervisor grants the idle slot to one update at a time, and says why where it grants none"); + if let Some(claim) = Endowments::get().take::(label) { + let unique = claim.describe().map_err(|e| format!("the `{label}` claim: {e:?}"))?.unique_guid; + let disk = Claimed::new(claim).map_err(|e| format!("the `{label}` claim: {e:?}"))?; + return Ok(Held { disk: Box::new(disk), unique }); + } + let names = endow::namespace().ok_or_else(none)?; + let own = toyos::namespace::build().keep(names, &[label]).finish().map_err(|_| none())?; + let listed = diskserver::list(&own, label).map_err(|why| format!("`{label}` would not list its partition: {why:?}"))?; + let [one] = listed[..] else { + return Err(format!("`{label}` lists {} partitions, and is minted for one", listed.len())); }; - Ok((take(slots::TABLE_LABEL)?, take(slots::BOOT_LABEL)?, take(slots::ROOT_LABEL)?)) + let session = diskserver::Session::open(own, label, one.unique) + .map_err(|why| format!("`{label}`'s partition would not open: {why:?}"))?; + Ok(Held { disk: Box::new(Served::new(session)), unique: one.unique }) } fn run(began: Instant) -> Result { - let (table_claim, boot, root) = grant()?; - let (table, current) = read_table(&table_claim)?; - 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 (mut table_part, mut boot, mut root) = (held(slots::TABLE_LABEL)?, held(slots::BOOT_LABEL)?, held(slots::ROOT_LABEL)?); + let (table, current) = read_table(&mut *table_part.disk)?; let idle = [Which::A, Which::B] .into_iter() - .find(|&w| table.slot(w).is_some_and(|s| s.boot == boot_guid && s.root == root_info.unique_guid)) + .find(|&w| table.slot(w).is_some_and(|s| s.boot == boot.unique && s.root == root.unique)) .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")?; @@ -84,12 +101,12 @@ fn run(began: Instant) -> Result { sig::verify(&KEY, header_bytes, &toyos_update::image::signature_of(&signed)).map_err(|why| why.to_string())?; let idle_version = table.slot(idle).map(|s| s.version).filter(|&v| v != 0); policy::installable(header.version, running.version, idle_version).map_err(|why| why.to_string())?; - if header.root().len > root_info.blocks * BLOCK_BYTES as u64 { + if header.root().len > root.disk.blocks() * BLOCK as u64 { return Err(format!( "ROOT is {} bytes and slot {}'s ROOT partition holds {}", header.root().len, idle.letter(), - root_info.blocks * BLOCK_BYTES as u64 + root.disk.blocks() * BLOCK as u64 )); } println!( @@ -103,7 +120,7 @@ fn run(began: Instant) -> Result { let kernel = take(&mut input, header.kernel().len, header.kernel().sha256, "kernel")?; let cmdline = take(&mut input, header.cmdline().len, header.cmdline().sha256, "cmdline")?; let streamed = Instant::now(); - stream_root(&mut input, &root, header.root().len, header.root().sha256)?; + stream_root(&mut input, &mut *root.disk, header.root().len, header.root().sha256)?; let root_ms = streamed.elapsed().as_millis(); let mut rest = [0u8; 1]; match input.read(&mut rest) { @@ -112,9 +129,9 @@ fn run(began: Instant) -> Result { Err(e) => return Err(format!("the input's end would not read: {e}")), } - write_volume(&boot, &kernel, &cmdline, &signed)?; - 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()))?; + write_volume(&mut *boot.disk, &kernel, &cmdline, &signed)?; + root.disk.flush().map_err(|e| format!("slot {}'s ROOT is not durable: {e:?}", idle.letter()))?; + boot.disk.flush().map_err(|e| format!("slot {}'s volume is not durable: {e:?}", idle.letter()))?; let mut next = table; next.marked = idle; @@ -122,10 +139,11 @@ fn run(began: Instant) -> Result { 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]) + table_part + .disk + .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:?}"))?; + table_part.disk.flush().map_err(|e| format!("the slot table is not durable: {e:?}"))?; Ok(format!( "{INSTALLED} version {} in slot {} ({} bytes of ROOT in {root_ms} ms, {} ms in all); it boots at the next reboot", @@ -137,10 +155,12 @@ fn run(began: Instant) -> Result { } /// 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]; - claim.read(0, &mut copies).map_err(|e| format!("the slot table would not read: {e:?}"))?; - slots::current([&copies[0], &copies[1]]).map_err(|why| format!("the slot table's partition holds {why}")) +fn read_table(disk: &mut dyn Disk) -> Result<(Table, usize), String> { + let mut copies = [0u8; 2 * BLOCK]; + disk.read(0, &mut copies).map_err(|e| format!("the slot table would not read: {e:?}"))?; + let (first, second) = copies.split_at(BLOCK); + let copies = [first, second].map(|copy| <&[u8; BLOCK]>::try_from(copy).expect("one block each")); + slots::current(copies).map_err(|why| format!("the slot table's partition holds {why}")) } /// Exactly `len` bytes of the input, held to `sha256`. @@ -153,21 +173,23 @@ fn take(input: &mut impl Read, len: u64, sha256: toyos_update::Digest, section: Ok(bytes) } -/// ROOT onto the idle ROOT partition as it arrives, a call's worth of blocks -/// at a time, and held to `sha256` once whole. -fn stream_root(input: &mut impl Read, root: &PartitionDev, len: u64, sha256: toyos_update::Digest) -> Result<(), String> { +/// The blocks of ROOT [`stream_root`] reads and writes at a time. +const STREAM_BLOCKS: usize = 32; + +/// ROOT onto the idle ROOT partition as it arrives, [`STREAM_BLOCKS`] at a +/// time, and held to `sha256` once whole. +fn stream_root(input: &mut impl Read, root: &mut dyn Disk, len: u64, sha256: toyos_update::Digest) -> Result<(), String> { use sha2::Digest as _; let mut hasher = sha2::Sha256::new(); - let mut run: Vec = vec![[0; BLOCK_BYTES]; MAX_BLOCKS_PER_CALL]; - let blocks = len / BLOCK_BYTES as u64; + let mut run = vec![0u8; STREAM_BLOCKS * BLOCK]; + let blocks = len / BLOCK as u64; let mut at = 0u64; while at < blocks { - let n = (blocks - at).min(MAX_BLOCKS_PER_CALL as u64) as usize; - for block in &mut run[..n] { - input.read_exact(block).map_err(|e| format!("the input ended inside ROOT, at block {at}: {e}"))?; - hasher.update(&block[..]); - } - root.write(at, &run[..n]).map_err(|e| format!("ROOT's blocks from {at} would not write: {e:?}"))?; + let n = (blocks - at).min(STREAM_BLOCKS as u64) as usize; + let bytes = &mut run[..n * BLOCK]; + input.read_exact(bytes).map_err(|e| format!("the input ended inside ROOT, at block {at}: {e}"))?; + hasher.update(&*bytes); + root.write(at, bytes).map_err(|e| format!("ROOT's blocks from {at} would not write: {e:?}"))?; at += n as u64; } if <[u8; 32]>::from(hasher.finalize()) != sha256 { @@ -178,9 +200,8 @@ fn stream_root(input: &mut impl Read, root: &PartitionDev, len: u64, sha256: toy /// The kernel, its boot parameter and the signed header onto the idle slot's /// FAT volume, replacing whatever was there. -fn write_volume(boot: &PartitionDev, kernel: &[u8], cmdline: &[u8], signed: &[u8]) -> Result<(), String> { - let blocks = boot.describe().map_err(|e| format!("the idle volume's claim: {e:?}"))?.blocks; - let mut fs = toyos_fat32::Fat32::mount(volume::Cached::new(boot, blocks)) +fn write_volume(boot: &mut dyn Disk, kernel: &[u8], cmdline: &[u8], signed: &[u8]) -> Result<(), String> { + let mut fs = toyos_fat32::Fat32::mount(volume::Cached::new(boot)) .map_err(|e| format!("the idle slot's volume does not mount: {e:?}"))?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/userland/update/src/volume.rs b/userland/update/src/volume.rs index 67cad4703dc..f8274c57942 100644 --- a/userland/update/src/volume.rs +++ b/userland/update/src/volume.rs @@ -1,39 +1,37 @@ -//! A partition claim as `toyos-fat32` reads one: byte-addressed, over 4 KiB +//! A partition as `toyos-fat32` reads one: byte-addressed, over 4 KiB //! blocks, with every write held until [`Cached::flush`] writes the blocks it -//! touched in runs of whole calls. +//! touched in runs. //! //! **Held, because FAT writes a cluster at a time**, and a slot volume's //! clusters are smaller than a block: written through, a kernel image would be //! a read and a write of one block per 512 bytes. Held, the volume's writes are -//! as many calls as the blocks it touched need, and the partition's own fsync -//! is what makes them durable — which is the caller's, after this. +//! as many requests as the blocks it touched need, and the partition's own +//! flush is what makes them durable — which is the caller's, after this. use std::collections::BTreeMap; -use toyos::PartitionDev; -use toyos_abi::part::{Block, BLOCK_BYTES, MAX_BLOCKS_PER_CALL}; +use diskserver::disk::{Disk, BLOCK}; use toyos_fat32::{BlockAccess, IoError}; pub struct Cached<'a> { - claim: &'a PartitionDev, - blocks: u64, + disk: &'a mut dyn Disk, /// Every block written since the last flush, by block number. - dirty: BTreeMap>, + dirty: BTreeMap>, } impl<'a> Cached<'a> { - pub fn new(claim: &'a PartitionDev, blocks: u64) -> Self { - Self { claim, blocks, dirty: BTreeMap::new() } + pub fn new(disk: &'a mut dyn Disk) -> Self { + Self { disk, dirty: BTreeMap::new() } } /// Block `n` as the volume now reads: the held write, or the device's. - fn block(&self, n: u64) -> Result { + fn block(&mut self, n: u64) -> Result<[u8; BLOCK], IoError> { if let Some(held) = self.dirty.get(&n) { return Ok(**held); } - let mut one = [[0u8; BLOCK_BYTES]]; - self.claim.read(n, &mut one).map_err(|_| IoError::Device)?; - Ok(one[0]) + let mut one = [0u8; BLOCK]; + self.disk.read(n, &mut one).map_err(|_| IoError::Device)?; + Ok(one) } /// Each block `[offset, offset + len)` touches, with the part of it that @@ -46,9 +44,9 @@ impl<'a> Cached<'a> { let mut out = Vec::new(); let mut at = offset; while at < end { - let n = at / BLOCK_BYTES as u64; - let from = (at % BLOCK_BYTES as u64) as usize; - let to = BLOCK_BYTES.min(from + (end - at) as usize); + let n = at / BLOCK as u64; + let from = (at % BLOCK as u64) as usize; + let to = BLOCK.min(from + (end - at) as usize); out.push((n, from..to, (at - offset) as usize)); at += (to - from) as u64; } @@ -58,7 +56,7 @@ impl<'a> Cached<'a> { impl BlockAccess for Cached<'_> { fn capacity(&self) -> u64 { - self.blocks * BLOCK_BYTES as u64 + self.disk.blocks() * BLOCK as u64 } fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<(), IoError> { @@ -72,31 +70,32 @@ impl BlockAccess for Cached<'_> { fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), IoError> { for (n, range, from) in self.spans(offset, buf.len())? { // A whole block's write needs nothing read under it. - let mut block = if range.len() == BLOCK_BYTES { [0u8; BLOCK_BYTES] } else { self.block(n)? }; + let mut block = if range.len() == BLOCK { [0u8; BLOCK] } else { self.block(n)? }; block[range.clone()].copy_from_slice(&buf[from..from + range.len()]); self.dirty.insert(n, Box::new(block)); } Ok(()) } - /// Write every held block, a run of consecutive ones per call. Durable is - /// the claim's fsync, which the caller asks once every volume is written. + /// Write every held block, a run of consecutive ones per request. Durable + /// is the partition's flush, which the caller asks once every volume is + /// written. fn flush(&mut self) -> Result<(), IoError> { let held = std::mem::take(&mut self.dirty); - let mut run: Vec = Vec::with_capacity(MAX_BLOCKS_PER_CALL); + let mut run: Vec = Vec::new(); let mut first = 0u64; for (n, block) in held { - if !run.is_empty() && (n != first + run.len() as u64 || run.len() == MAX_BLOCKS_PER_CALL) { - self.claim.write(first, &run).map_err(|_| IoError::Device)?; + if !run.is_empty() && n != first + (run.len() / BLOCK) as u64 { + self.disk.write(first, &run).map_err(|_| IoError::Device)?; run.clear(); } if run.is_empty() { first = n; } - run.push(*block); + run.extend_from_slice(&block[..]); } if !run.is_empty() { - self.claim.write(first, &run).map_err(|_| IoError::Device)?; + self.disk.write(first, &run).map_err(|_| IoError::Device)?; } Ok(()) } From fa1bcdd5559aa5c5eeda795043b8001f72e3daaf Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 10:38:49 +0200 Subject: [PATCH 2/4] A write a grant does not make is refused ReadOnly; the update's slot volume is read back and checked; the two new jobs stay off the shared boot Review round 1 of #826, and the T14 reading at a81a40198. - `partition_grant` and `update_idle_slot` are on `RUST_SKIP`: each needs its machine test's boot (a controller nothing claims and the inventory; a staged image and a second slot), and the T14's testcases list ran both and reddened. Their machine tests now name them literally, so `suite_split` sees them driven and reds if either leaves the list. - blockring gains `Status::ReadOnly` and `Outcome::ReadOnly` (word 4), and `Invalid` means malformed again: a refusal of authority no longer reaches a client as a malformed request, nor `Served::write`'s caller as a device failure (`DiskError::ReadOnly`, which fileserver answers `PermissionDenied`). The word on a read, a flush or a reissued write is a server that does not know what it answered, and a violation. - The update test reads slot B's FAT volume off the disk: toyos-fat32-check over the whole volume, then the kernel, the boot parameter and the signed header byte for byte against the staged image's sections. - diskserver reads every hang-up its clients already made before it judges an open, so `update`'s open of the slot table is never refused `Held` for the supervisor's session, which ended before `update` started. - `listed` and `admitted` take the drive's table or its refusal, so the grant is asked once, first, in the pure functions. - The two-copy slot table read is `toyos_update::slots::read`, which the supervisor's two branches and `update` call with their own reader. - `held_by_their_holders_alone` refuses a `slots` row in `[boot] start`: the supervisor grants the slots to a launch alone. - Prose this branch made false is deleted from toyos-manifest, src/build.rs and toyos-update's slots. - The launcher's move off its stick, which the closed issue recorded as waiting on this exit, is filed: issues/the-launcher-boots-off-a-stick-though-an-image-on-nvme-can-update-itself.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...olumes-server-holds-a-claim-that-writes.md | 2 +- ...ough-an-image-on-nvme-can-update-itself.md | 23 +++++ src/build.rs | 10 ++- .../src/bin/partition_grant.rs | 4 +- tests/toyos.rs | 56 ++++++++++-- toyos-blockring/src/client.rs | 36 ++++++-- toyos-blockring/src/entry.rs | 15 ++-- toyos-blockring/src/server.rs | 20 ++--- toyos-blockring/src/wire.rs | 2 +- toyos-manifest/src/lib.rs | 7 +- toyos-update/src/slots.rs | 35 +++++++- userland/diskserver/src/disk.rs | 17 ++-- userland/diskserver/src/main.rs | 90 +++++++++++-------- userland/fileserver/src/data.rs | 1 + userland/supervisor/src/main.rs | 23 ++--- userland/update/src/main.rs | 14 +-- 16 files changed, 245 insertions(+), 110 deletions(-) create mode 100644 issues/the-launcher-boots-off-a-stick-though-an-image-on-nvme-can-update-itself.md diff --git a/issues/the-boot-volumes-server-holds-a-claim-that-writes.md b/issues/the-boot-volumes-server-holds-a-claim-that-writes.md index 62d07babdd4..a1809fb4560 100644 --- a/issues/the-boot-volumes-server-holds-a-claim-that-writes.md +++ b/issues/the-boot-volumes-server-holds-a-claim-that-writes.md @@ -14,7 +14,7 @@ A claim carries `Rights::WRITE` and no `DUP` (`initial_rights`, narrowed to reading: the volume the loader reads the kernel from stays unwritten only by that server's promise to mount it read-only. On a disk the block service serves, the same server's grant does not write, and diskserver -answers a write through it `Invalid` (`block_grants_reach_their_partitions`). +answers a write through it `ReadOnly` (`block_grants_reach_their_partitions`). **Exit**: the boot volume's server on the boot stick holds a partition it cannot write — a read-only session once usbd serves the stick, or a claim the diff --git a/issues/the-launcher-boots-off-a-stick-though-an-image-on-nvme-can-update-itself.md b/issues/the-launcher-boots-off-a-stick-though-an-image-on-nvme-can-update-itself.md new file mode 100644 index 00000000000..f8c7429eff5 --- /dev/null +++ b/issues/the-launcher-boots-off-a-stick-though-an-image-on-nvme-can-update-itself.md @@ -0,0 +1,23 @@ +--- +status: open +kind: tooling +opened: 2026-10-10 +--- + +# The launcher boots off a stick though an image on NVMe can update itself + +`cargo run`'s machine (`src/qemu.rs`) boots its image off a USB stick beside +an NVMe disk, `target/nvme.img`. It did so because only a disk the kernel +drives could have its slots written; `update` now writes the idle slot through +diskserver's sessions, and `update_writes_the_idle_slot_through_the_block_service` +installs into a second slot on a guest booted off its NVMe disk. So the +development machine still boots through the kernel's USB storage path, which +usbd is to replace, and not through diskserver, which the T14 runs on once +ToyOS is installed on its internal disk. + +**Exit**: `cargo run`'s machine boots its image off its NVMe disk, and +`update` run on it installs into its idle slot. + +## Owner + +The launcher, `src/qemu.rs`; unheld. diff --git a/src/build.rs b/src/build.rs index b43a35847dc..7d0f9f08e71 100644 --- a/src/build.rs +++ b/src/build.rs @@ -157,7 +157,7 @@ struct ProgramConfig { /// `toyos_manifest::syscap_rights` takes. A handful of rows in the whole /// tree declare one. syscap: Vec, - /// The idle slot, granted as partition claims (`toyos_manifest::Program::slots`). + /// The idle slot (`toyos_manifest::Program::slots`). slots: bool, /// A system service: the supervisor starts it with `HOME` at its own `/state/` /// and makes that directory, where every other row gets the session's. @@ -593,6 +593,11 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { if name != toyos_update::slots::HOLDER && program.slots { return Err(format!("`{name}` asks for `slots`, which only `{}` may hold", toyos_update::slots::HOLDER)); } + // The supervisor grants the idle slot to a launch alone, so a row it + // starts at boot, or again, would run holding nothing. + if program.slots && config.boot.start.contains(name) { + return Err(format!("`{name}` asks for `slots` and is in `[boot] start`, and the slots are granted to a launch alone")); + } } if config.apps.receives.iter().any(|r| r == toyos_swap::PORT) { return Err(format!("`[apps] receives` names `{}`, which only `{}` may hold", toyos_swap::PORT, toyos_swap::HOLDER)); @@ -2981,6 +2986,9 @@ mod tests { assert!(held_by_their_holders_alone(&apps).is_err()); let slots: SystemConfig = toml::from_str("[programs.shell]\nslots = true\n").unwrap(); assert!(held_by_their_holders_alone(&slots).is_err()); + let booted: SystemConfig = + toml::from_str("[boot]\nstart = [\"update\"]\n[programs.update]\nslots = true\n").unwrap(); + assert!(held_by_their_holders_alone(&booted).is_err()); } /// Every committed config passes, and each refusal has a config that diff --git a/tests/toyos-rust-tests/src/bin/partition_grant.rs b/tests/toyos-rust-tests/src/bin/partition_grant.rs index 51eb1fc50b7..7dcee56b5a1 100644 --- a/tests/toyos-rust-tests/src/bin/partition_grant.rs +++ b/tests/toyos-rust-tests/src/bin/partition_grant.rs @@ -100,11 +100,11 @@ fn main() { // The bytes it holds: a write let through changes nothing. assert_eq!( volume.write(0, &first), - Ok(Outcome::Invalid), + Ok(Outcome::ReadOnly), "a session whose grant does not write wrote the boot volume" ); drop(volume); - println!("partition_grant: the boot volume's read-only grant read it and was refused a write Invalid"); + println!("partition_grant: the boot volume's read-only grant read it and was refused a write ReadOnly"); assert_eq!(listed(&bare), Err(Error::Refused(Refusal::NotGranted)), "the port's own connector listed"); assert_eq!(open(&bare, log).err(), Some(Error::Refused(Refusal::NotGranted)), "the port's own connector opened"); diff --git a/tests/toyos.rs b/tests/toyos.rs index 8338e4a73c5..11a328bddeb 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -189,6 +189,14 @@ const RUST_SKIP: &[&str] = &[ // It claims QEMU's virtio NIC, which the T14 has none of: `bar_map_again` // runs it. "bar_map_again", + // It claims the NVMe controller a boot off that disk starts no server + // for, and reads the inventory: `block_grants_reach_their_partitions` + // runs it on tests/blockgrantcase. + "partition_grant", + // It installs the image its machine test staged into a second slot on + // the disk diskserver drives: `update_writes_the_idle_slot_through_the_block_service` + // runs it on tests/slotscase. + "update_idle_slot", ]; /// The shared boot's last members, in this order: each fills a bound of its @@ -3659,7 +3667,7 @@ fn nvme_disk_keeps_log_and_home(test_config: &Path) -> Result<(), String> { const GRANTS_SAID: [&str; 5] = [ "partition_grant: the log's grant opened the log and was refused the boot volume NotGranted", "partition_grant: DATA's grant opened DATA and was refused the log NotGranted", - "partition_grant: the boot volume's read-only grant read it and was refused a write Invalid", + "partition_grant: the boot volume's read-only grant read it and was refused a write ReadOnly", "partition_grant: the port's own connector was refused a listing and an open NotGranted", "partition_grant: the running ROOT was refused Held to the grant naming it", ]; @@ -3673,7 +3681,7 @@ fn block_grants_reach_their_partitions() -> Result<(), String> { let case = compile::repo_root().join("tests/blockgrantcase"); let options = BootOptions { profile: qemu::Profile::HeadlessNoUsb, ..Default::default() }; let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), bin)], options); - let result = qemu.run_test(&format!("test_rs_{JOB}"), Duration::from_secs(60)); + let result = qemu.run_test("test_rs_partition_grant", Duration::from_secs(60)); if let Some(why) = &result.error { return Err(format!("{why}\nthe job said:\n{}", result.stdout)); } @@ -3696,12 +3704,14 @@ fn block_grants_reach_their_partitions() -> Result<(), String> { /// The update image `update_writes_the_idle_slot_through_the_block_service` /// stages: a ROOT of one file, signed with this run's key at a version past /// any a build signs; the ROOT's length, which the second slot is made to -/// hold exactly, so the host mounts the partition whole; and its file. +/// hold exactly, so the host mounts the partition whole; its file; and the +/// three files the slot's FAT volume is to carry, each with its bytes. struct StagedUpdate { image: Vec, version: u64, root_bytes: u64, marker: Vec, + volume: [(&'static str, Vec); 3], } /// Where the staged ROOT's one file is, and the path the guest reads the @@ -3716,15 +3726,24 @@ fn staged_update() -> StagedUpdate { // Past every version a build signs, which is its Unix time. let version = u64::from(u32::MAX) << 8; let signing = toyos_build::image::Signing { key: toyos_build::signing::key(), version }; - let image = toyos_build::image::update_image(b"update test kernel", &root, "", signing); - StagedUpdate { image, version, root_bytes: root.len() as u64, marker } + let kernel = b"update test kernel"; + let image = toyos_build::image::update_image(kernel, &root, "", signing); + // The image is the signed header, the kernel, the boot parameter and + // ROOT, end to end (`toyos_update::image`). + let signed = toyos_update::image::SIGNED_BYTES; + let volume = [ + (toyos_update::slots::SIGNED_FILE, image[..signed].to_vec()), + (toyos_update::slots::KERNEL_FILE, kernel.to_vec()), + (toyos_update::slots::CMDLINE_FILE, image[signed + kernel.len()..image.len() - root.len()].to_vec()), + ]; + StagedUpdate { image, version, root_bytes: root.len() as u64, marker, volume } } /// `update` on a machine booted off its NVMe disk: the supervisor reads the /// slot table through a session on diskserver and grants `update` the idle /// slot's partitions as connectors minted for each, and `update` writes the -/// signed image there and marks it — read back off the disk by the host's own -/// partition table and bcachefs readers, not by anything that wrote it. +/// signed image there and marks it — read back off the disk by the host, and +/// its FAT volume judged by toyos-fat32-check, which shares no code with it. fn update_writes_the_idle_slot_through_the_block_service() -> Result<(), String> { const JOB: &str = "update_idle_slot"; let staged = staged_update(); @@ -3737,7 +3756,7 @@ fn update_writes_the_idle_slot_through_the_block_service() -> Result<(), String> ..Default::default() }; let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), bin)], options); - let result = qemu.run_test(&format!("test_rs_{JOB}"), Duration::from_secs(120)); + let result = qemu.run_test("test_rs_update_idle_slot", Duration::from_secs(120)); if let Some(why) = &result.error { return Err(format!("{why}\nthe job said:\n{}", result.stdout)); } @@ -3762,6 +3781,27 @@ fn update_writes_the_idle_slot_through_the_block_service() -> Result<(), String> return Err(format!("slot B's ROOT carries {:?} at {UPDATE_MARKER}, and the update carried {:?}", String::from_utf8_lossy(&read_back), String::from_utf8_lossy(&staged.marker))); } eprintln!(" [update] the disk's slot table marks B at version {}, and B's ROOT mounts carrying the update's {UPDATE_MARKER}", staged.version); + + use std::io::{Read as _, Seek as _}; + // B's FAT volume: judged whole by the checker that is no part of the + // driver `update` wrote it with, then each file read back byte for byte. + let (at, len) = toyos_build::image::partition_extent(&mut disk, marked.boot)?; + let mut volume = vec![0u8; usize::try_from(len).map_err(|_| format!("a {len}-byte volume"))?]; + disk.seek(std::io::SeekFrom::Start(at)) + .and_then(|_| disk.read_exact(&mut volume)) + .map_err(|e| format!("slot B's volume at byte {at}: {e}"))?; + let complaints = toyos_fat32_check::check(&volume); + if !complaints.is_empty() { + return Err(format!("toyos-fat32-check refuses slot B's volume:\n{}", toyos_fat32_check::describe(&complaints))); + } + for (name, staged) in &staged.volume { + let read = toyos_build::image::read_file_on(&mut disk, marked.boot, name) + .map_err(|why| format!("slot B's volume: {why}"))?; + if read != *staged { + return Err(format!("slot B's {name} is {} bytes that are not the {} the update carried", read.len(), staged.len())); + } + } + eprintln!(" [update] slot B's volume checks out, carrying the update's {} byte for byte", staged.volume.iter().map(|(name, _)| *name).collect::>().join(", ")); Ok(()) } diff --git a/toyos-blockring/src/client.rs b/toyos-blockring/src/client.rs index a13f9778f12..7f69ca411d1 100644 --- a/toyos-blockring/src/client.rs +++ b/toyos-blockring/src/client.rs @@ -58,9 +58,10 @@ pub enum Outcome { /// A flush: every write acknowledged before it was asked for is on the /// medium. Durable, - /// The server refused it as malformed, or as a write its grant does not - /// make. + /// The server refused it as malformed. Invalid, + /// A write the session's grant does not make, refused unissued. + ReadOnly, /// The device did not do it. A write answered this may or may not have /// reached the medium; a flush answered this left writes the device would /// not keep. @@ -225,12 +226,18 @@ impl Client { let outcome = match status { Status::Invalid => Outcome::Invalid, Status::Device => Outcome::Device, - // A read or a write answered `Lost` is a server that does - // not know which op it was. - Status::Lost | Status::Ok => return Err(Violation::Entry), + Status::ReadOnly if matches!(q.op, Op::Write { .. }) => Outcome::ReadOnly, + // A read or a write answered `Lost`, or a read answered + // `ReadOnly`, is a server that does not know which op it + // was. + Status::ReadOnly | Status::Lost | Status::Ok => return Err(Violation::Entry), }; self.answers.push_back((ticket, outcome)); } + // A write the session took once, refused by the same grant on its + // way again, or a flush refused as a write: a server that does + // not know what it granted. + (Kind::Reissue(_) | Kind::Flush(_), Status::ReadOnly) => return Err(Violation::Entry), (Kind::Reissue(mut acked), Status::Ok) => { acked.seq = self.bump(); // A loss since it went out: an earlier write it overlaps may @@ -522,6 +529,25 @@ mod tests { assert_eq!(client.take_answers().collect::>(), [(3, Outcome::Device)]); } + /// A write refused by its grant is the caller's `ReadOnly` and gives its + /// arena blocks back; the word on a read or a flush is a server that does + /// not know which op it answered. + #[test] + fn read_only_answers_a_write_alone() { + let mut client: Client = Client::new(); + client.session_started(); + client.submit(1, Op::Write { run: run(0, 1), lba: 0 }); + let w = client.next_request().unwrap(); + answer(&mut client, w, Status::ReadOnly); + assert_eq!(client.take_answers().collect::>(), [(1, Outcome::ReadOnly)]); + assert_eq!(client.take_released().collect::>(), [run(0, 1)]); + for op in [Op::Read { run: run(1, 1), lba: 0 }, Op::Flush] { + client.submit(2, op); + let r = client.next_request().unwrap(); + assert_eq!(client.complete(Completion { tag: r.tag, status: Status::ReadOnly }), Err(Violation::Entry)); + } + } + #[test] fn what_was_on_the_wire_at_the_end_is_refused_and_what_was_not_waits() { let mut client: Client = Client::new(); diff --git a/toyos-blockring/src/entry.rs b/toyos-blockring/src/entry.rs index a842d1df208..a6fcf280543 100644 --- a/toyos-blockring/src/entry.rs +++ b/toyos-blockring/src/entry.rs @@ -98,9 +98,10 @@ pub enum Status { /// Done. For a flush, every write of its writer's acknowledged before it /// was submitted is on the medium. Ok, - /// Refused unread: the request was malformed ([`Refused`]), or is a write - /// on a session whose grant does not write. + /// Refused unread: the request was malformed ([`Refused`]). Invalid, + /// Refused unissued: a write on a session whose grant does not write. + ReadOnly, /// The device did not do it, or was reset under it. A write answered this /// may or may not have reached the medium. Device, @@ -111,12 +112,15 @@ pub enum Status { } impl Status { + const ALL: [Self; 5] = [Self::Ok, Self::Invalid, Self::Device, Self::Lost, Self::ReadOnly]; + const fn word(self) -> u32 { match self { Self::Ok => 0, Self::Invalid => 1, Self::Device => 2, Self::Lost => 3, + Self::ReadOnly => 4, } } } @@ -140,8 +144,7 @@ impl Completion { if !reserved.iter().all(|word| word.is(0)) { return None; } - let status = - [Status::Ok, Status::Invalid, Status::Device, Status::Lost].into_iter().find(|s| status.is(s.word()))?; + let status = Status::ALL.into_iter().find(|s| status.is(s.word()))?; Some(Self { tag: opaque(tag), status }) } } @@ -208,11 +211,11 @@ mod tests { #[test] fn a_completion_survives_its_words_and_refuses_what_it_does_not_define() { - for status in [Status::Ok, Status::Invalid, Status::Device, Status::Lost] { + for status in Status::ALL { let c = Completion { tag: 9, status }; assert_eq!(Completion::decode(peer(c.encode())), Some(c)); } - assert_eq!(Completion::decode(peer([1, 4, 0, 0])), None); + assert_eq!(Completion::decode(peer([1, 5, 0, 0])), None); assert_eq!(Completion::decode(peer([1, 0, 1, 0])), None); } } diff --git a/toyos-blockring/src/server.rs b/toyos-blockring/src/server.rs index 5c66442ab9d..ef94f3e3737 100644 --- a/toyos-blockring/src/server.rs +++ b/toyos-blockring/src/server.rs @@ -32,7 +32,7 @@ pub struct ServerSession { /// The span of the device this session holds, which is its writer. first: u64, /// Its grant lets it write; a write on a session whose grant does not is - /// answered `Invalid` and never reaches the device. + /// answered `ReadOnly` and never reaches the device. writes: bool, /// Each request in flight, in the order it was taken: a tag is only ever /// compared for equality. @@ -77,10 +77,10 @@ impl ServerSession { /// Decide what one entry the client published is. /// - /// A malformed entry, a write its grant does not let it make, and a tag - /// already in flight, are answered at once and never reach the device: - /// the last would make one tag two requests, and the client could not - /// tell which answer was whose. + /// A malformed entry, a tag already in flight, and a write its grant does + /// not let it make, are answered at once and never reach the device: the + /// second would make one tag two requests, and the client could not tell + /// which answer was whose. pub fn take(&mut self, words: [Untrusted; SQE_WORDS]) -> Taken { let request = match Request::decode(words, self.blocks) { Ok(request) => request, @@ -88,12 +88,12 @@ impl ServerSession { return Taken::Answer(Completion { tag, status: Status::Invalid }) } }; - if !self.writes && matches!(request.op, Op::Write { .. }) { - return Taken::Answer(Completion { tag: request.tag, status: Status::Invalid }); - } if self.inflight.iter().any(|&(tag, _)| tag == request.tag) { return Taken::Answer(Completion { tag: request.tag, status: Status::Invalid }); } + if !self.writes && matches!(request.op, Op::Write { .. }) { + return Taken::Answer(Completion { tag: request.tag, status: Status::ReadOnly }); + } self.inflight.push((request.tag, request.op)); Taken::Issue(request) } @@ -193,12 +193,12 @@ mod tests { assert_eq!(session.complete(3, true, &mut holds, 1).unwrap().status, Status::Ok); } - /// A session whose grant does not write answers a write `Invalid` and + /// A session whose grant does not write answers a write `ReadOnly` and /// holds nothing for the device, and still reads and flushes. #[test] fn a_read_only_session_refuses_a_write_unissued() { let mut session = ServerSession::new(0, 10, false); - assert_eq!(session.take(write(1)), Taken::Answer(Completion { tag: 1, status: Status::Invalid })); + assert_eq!(session.take(write(1)), Taken::Answer(Completion { tag: 1, status: Status::ReadOnly })); assert_eq!(session.inflight().len(), 0); let read = Request { op: Op::Read { run: ARENA.run(0, 1).unwrap(), lba: 0 }, tag: 2 }; assert!(matches!(session.take(read.encode().map(Untrusted::new)), Taken::Issue(_))); diff --git a/toyos-blockring/src/wire.rs b/toyos-blockring/src/wire.rs index 2dacaf2b39d..0092c1c02f1 100644 --- a/toyos-blockring/src/wire.rs +++ b/toyos-blockring/src/wire.rs @@ -137,7 +137,7 @@ impl Listed { pub struct Grant { pub scope: Scope, /// A session it opens takes writes; one that does not answers every - /// write `Invalid`, unissued. + /// write `ReadOnly`, unissued. pub writes: bool, } diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index 13ffce90806..e8b5c0c45ad 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -20,7 +20,7 @@ //! receive a connector in this program's namespace //! device a claim the supervisor mints and endows //! syscap a right on the SysCap dup the supervisor endows -//! slots the idle slot's partitions and the slot table, claimed by the supervisor +//! slots the idle slot's partitions and the slot table //! service a system service: its `HOME` is `/state/`, not the session's //! role a file server for ``: one process of it per role //! restart the supervisor starts it again when it ends @@ -239,9 +239,8 @@ pub struct Program { /// the system may enter the RT band, mint a device claim, read the machine /// log, list every process in the machine, or power the machine off. pub syscap: Vec, - /// The machine's idle slot, granted as claims: the slot table's partition - /// and the idle slot's FAT volume and ROOT, which the supervisor resolves against - /// the ROOT the kernel holds and mints (`toyos_update::slots`). The + /// The machine's idle slot: the slot table's partition and the idle + /// slot's FAT volume and ROOT (`toyos_update::slots`). The /// authority to write the next image and nothing else: the slot a boot /// runs is never among them. One program holds it — `src/build.rs` gates /// which. diff --git a/toyos-update/src/slots.rs b/toyos-update/src/slots.rs index 77cc13f8a5e..a2245996ffd 100644 --- a/toyos-update/src/slots.rs +++ b/toyos-update/src/slots.rs @@ -183,6 +183,33 @@ pub fn current(copies: [&[u8; BLOCK]; 2]) -> Result<(Table, usize), Unreadable> } } +/// [`current`] of a slot table's partition whose first [`COPIES`] blocks +/// `fill` reads, whatever reaches the partition. +pub fn read( + fill: impl FnOnce(&mut [[u8; BLOCK]; COPIES as usize]) -> Result<(), E>, +) -> Result<(Table, usize), NotRead> { + let mut copies = [[0; BLOCK]; COPIES as usize]; + fill(&mut copies).map_err(NotRead::Read)?; + current([&copies[0], &copies[1]]).map_err(NotRead::Unreadable) +} + +/// Why [`read`] has no table. +#[derive(Debug)] +pub enum NotRead { + /// The blocks would not read, in the reader's word. + Read(E), + Unreadable(Unreadable), +} + +impl core::fmt::Display for NotRead { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Read(why) => write!(f, "the slot table would not read: {why:?}"), + Self::Unreadable(why) => write!(f, "the slot table's partition holds {why}"), + } + } +} + /// What a writer puts where, to make `next` the table: the copy that is not /// `current`'s, one sequence past it. pub fn next_write(current: (Table, usize), mut next: Table) -> (usize, [u8; BLOCK]) { @@ -192,7 +219,7 @@ pub fn next_write(current: (Table, usize), mut next: Table) -> (usize, [u8; BLOC /// The labels `/system/bin/supervisor` endows a `slots` grant under, and /// `/system/bin/update` takes it by: the slot table's partition, and the idle -/// slot's FAT volume and ROOT, each a partition claim. +/// slot's FAT volume and ROOT. pub const TABLE_LABEL: &str = "slots:table"; pub const BOOT_LABEL: &str = "slots:boot"; pub const ROOT_LABEL: &str = "slots:root"; @@ -205,7 +232,7 @@ pub const HOLDER: &str = "update"; pub enum NoIdle { /// The table carries one slot, and that is the one running. OneSlot, - /// Neither slot's ROOT is the one the kernel holds: this table is not + /// Neither slot's ROOT is the one this boot runs: this table is not /// the one this boot came from. NotThisBoot, /// The idle slot names a partition that is no idle slot's. @@ -262,7 +289,7 @@ pub struct Kinds { pub root: [u8; 16], } -/// The idle slot a grant may claim, given the ROOT the kernel holds +/// The idle slot a grant may claim, given the ROOT this boot runs /// (`running`) and every partition the machine lists. /// /// **The table is the grantee's to write**, so nothing it names is taken on @@ -290,7 +317,7 @@ pub fn grant(table: &Table, running: &Listed, listed: &[Listed], kinds: Kinds) - Ok((idle, slot)) } -/// The slot the machine is not running, given the ROOT the kernel holds — +/// The slot the machine is not running, given the ROOT this boot runs — /// **what makes the running slot unwritable by construction**: the grant is /// only ever the other one. pub fn idle(table: &Table, running_root: &[u8; 16]) -> Result<(Which, Slot), NoIdle> { diff --git a/userland/diskserver/src/disk.rs b/userland/diskserver/src/disk.rs index a605549ea59..e3979fe8760 100644 --- a/userland/diskserver/src/disk.rs +++ b/userland/diskserver/src/disk.rs @@ -30,6 +30,8 @@ pub enum DiskError { Gone, /// Past the end of the partition. Range, + /// A write the block service's grant to this holder does not make. + ReadOnly, } /// A partition, a block at a time or several. @@ -168,14 +170,15 @@ impl Disk for Served { let per = MAX_REQUEST_BLOCKS as usize; for (i, chunk) in data.chunks(BLOCK * per).enumerate() { let at = first + (i * per) as u64; - match self.asked(|s| s.write(at, chunk))? { + let mut outcome = self.asked(|s| s.write(at, chunk))?; + // On the wire when the session ended: the write names its whole + // destination, so it is written again rather than guessed at. + if outcome == Outcome::Refused { + outcome = self.asked(|s| s.write(at, chunk))?; + } + match outcome { Outcome::Done => {} - // On the wire when the session ended: the write names its whole - // destination, so it is written again rather than guessed at. - Outcome::Refused => match self.asked(|s| s.write(at, chunk))? { - Outcome::Done => {} - _ => return Err(DiskError::Device), - }, + Outcome::ReadOnly => return Err(DiskError::ReadOnly), _ => return Err(DiskError::Device), } } diff --git a/userland/diskserver/src/main.rs b/userland/diskserver/src/main.rs index ca0ecd8aa82..9e7a0d9a05b 100644 --- a/userland/diskserver/src/main.rs +++ b/userland/diskserver/src/main.rs @@ -38,7 +38,7 @@ //! partitions the grant admits, an open of any other is refused `NotGranted` //! whether or not the table carries it, a connection with no grant reaches //! nothing, and a session whose grant does not write answers every write -//! `Invalid` before the device sees it. +//! `ReadOnly` before the device sees it. //! //! **A client may ask what is served** ([`wire::MSG_LIST`]) before it opens //! anything, which is how a file server finds its role's partition by type. @@ -180,10 +180,12 @@ fn read_table(ctrl: &mut Controller) -> Vec { parts } -/// The partitions a listing through `grant` names: every one it admits. -fn listed(parts: &[Part], grant: Option) -> Result, Refusal> { +/// The partitions a listing through `grant` names: every one it admits, of +/// `parts`, the table of a drive or why it has none. **The grant is asked +/// first**, so a connection with none learns nothing of the drive. +fn listed(parts: Result<&[Part], Refusal>, grant: Option) -> Result, Refusal> { let grant = grant.ok_or(Refusal::NotGranted)?; - Ok(parts + Ok(parts? .iter() .filter(|p| grant.admits(p.unique, p.kind)) .flat_map(|p| wire::Listed { unique: p.unique, kind: p.kind }.encode()) @@ -193,11 +195,16 @@ fn listed(parts: &[Part], grant: Option) -> Result, Refusal> { /// Where an open of `guid` through `grant` is served, before anything is /// held for it; or its refusal. **The grant is asked first**, so a /// connection learns nothing of a partition it does not reach, not even -/// whether the table carries it; then the table, then the partition the -/// machine runs from. -fn admitted(parts: &[Part], running: Option<[u8; 16]>, guid: [u8; 16], grant: Option) -> Result<(u64, u64), Refusal> { +/// whether the table carries it; then the drive, then the table, then the +/// partition the machine runs from. +fn admitted( + parts: Result<&[Part], Refusal>, + running: Option<[u8; 16]>, + guid: [u8; 16], + grant: Option, +) -> Result<(u64, u64), Refusal> { let grant = grant.ok_or(Refusal::NotGranted)?; - let part = parts.iter().find(|p| p.unique == guid && guid != [0; 16]); + let part = parts?.iter().find(|p| p.unique == guid && guid != [0; 16]); // A partition the table does not carry has no type: only a unique grant // naming it reaches it, to be told `NotFound`. if !grant.admits(guid, part.map_or([0; 16], |p| p.kind)) { @@ -265,6 +272,18 @@ impl Drive { } } + /// The table a listing and an open are judged against: none on a drive + /// that is absent, and a drive this service cannot use, or was refused, + /// refused by that word. + fn parts(&self) -> Result<&[Part], Refusal> { + match self { + Drive::Up(_, parts) => Ok(parts), + Drive::Absent => Ok(&[]), + Drive::Unusable => Err(Refusal::Unusable), + Drive::ClaimRefused => Err(Refusal::ClaimRefused), + } + } + fn oldest(&self) -> Option { match self { Drive::Up(ctrl, _) => ctrl.oldest(), @@ -304,26 +323,25 @@ impl Service { /// What a listing through `grant` answers: every partition of the table /// it admits. fn listing(&self, grant: Option) -> Result, Refusal> { - grant.ok_or(Refusal::NotGranted)?; - match &self.ctrl { - Drive::Up(_, parts) => listed(parts, grant), - Drive::Absent => listed(&[], grant), - Drive::Unusable => Err(Refusal::Unusable), - Drive::ClaimRefused => Err(Refusal::ClaimRefused), - } + listed(self.ctrl.parts(), grant) } /// The span an open of `guid` through `grant` is served on, held for it; /// or its refusal. + /// + /// **A client that hung up holds nothing**: every session's end its client + /// has already made is read before the open is judged, so an open made + /// after another holder closed its connection is never refused `Held` for + /// a holder that is gone — only for one whose commands are still on the + /// device. fn place(&mut self, guid: [u8; 16], grant: Option) -> Result<(u64, u64), Refusal> { - grant.ok_or(Refusal::NotGranted)?; - let parts = match &self.ctrl { - Drive::Up(_, parts) => parts.as_slice(), - Drive::Absent => &[], - Drive::Unusable => return Err(Refusal::Unusable), - Drive::ClaimRefused => return Err(Refusal::ClaimRefused), - }; - let (first, blocks) = admitted(parts, self.running, guid, grant)?; + let (first, blocks) = admitted(self.ctrl.parts(), self.running, guid, grant)?; + for s in self.sessions.values_mut() { + if !s.closing && !doorbells(&s.conn) { + s.closing = true; + } + } + self.retire(); if self.sessions.len() >= MAX_SESSIONS { return Err(Refusal::Exhausted); } @@ -832,19 +850,19 @@ mod tests { #[test] fn an_open_reaches_only_what_its_grant_admits() { let parts = table(); - assert_eq!(admitted(&parts, None, LOG, unique(LOG)), Ok((0, 100))); + assert_eq!(admitted(Ok(&parts), None, LOG, unique(LOG)), Ok((0, 100))); for other in [BOOT, DATA, ROOT] { - assert_eq!(admitted(&parts, None, other, unique(LOG)), Err(Refusal::NotGranted)); - assert_eq!(admitted(&parts, None, other, None), Err(Refusal::NotGranted)); + assert_eq!(admitted(Ok(&parts), None, other, unique(LOG)), Err(Refusal::NotGranted)); + assert_eq!(admitted(Ok(&parts), None, other, None), Err(Refusal::NotGranted)); } let data = Some(Grant { scope: Scope::Kind(DATA_KIND), writes: true }); - assert_eq!(admitted(&parts, None, DATA, data), Ok((200, 100))); - assert_eq!(admitted(&parts, None, LOG, data), Err(Refusal::NotGranted)); + assert_eq!(admitted(Ok(&parts), None, DATA, data), Ok((200, 100))); + assert_eq!(admitted(Ok(&parts), None, LOG, data), Err(Refusal::NotGranted)); // Nothing of a GUID the table does not carry, but to the grant that names it. - assert_eq!(admitted(&parts, None, [9; 16], data), Err(Refusal::NotGranted)); - assert_eq!(admitted(&parts, None, [9; 16], unique([9; 16])), Err(Refusal::NotFound)); - assert_eq!(admitted(&parts, Some(ROOT), ROOT, unique(ROOT)), Err(Refusal::Held)); - assert_eq!(admitted(&parts, Some(ROOT), ROOT, unique(LOG)), Err(Refusal::NotGranted)); + assert_eq!(admitted(Ok(&parts), None, [9; 16], data), Err(Refusal::NotGranted)); + assert_eq!(admitted(Ok(&parts), None, [9; 16], unique([9; 16])), Err(Refusal::NotFound)); + assert_eq!(admitted(Ok(&parts), Some(ROOT), ROOT, unique(ROOT)), Err(Refusal::Held)); + assert_eq!(admitted(Ok(&parts), Some(ROOT), ROOT, unique(LOG)), Err(Refusal::NotGranted)); } /// A listing names exactly what its grant admits, and nothing to a @@ -855,10 +873,10 @@ mod tests { let decoded = |bytes: Vec| -> Vec<[u8; 16]> { wire::Listed::decode_all(&bytes).expect("whole entries").map(|l| l.unique).collect() }; - assert_eq!(listed(&parts, unique(BOOT)).map(decoded), Ok(vec![BOOT])); + assert_eq!(listed(Ok(&parts), unique(BOOT)).map(decoded), Ok(vec![BOOT])); let roots = Some(Grant { scope: Scope::Kind(ROOT_KIND), writes: false }); - assert_eq!(listed(&parts, roots).map(decoded), Ok(vec![ROOT])); - assert_eq!(listed(&parts, unique([9; 16])).map(decoded), Ok(vec![])); - assert_eq!(listed(&parts, None), Err(Refusal::NotGranted)); + assert_eq!(listed(Ok(&parts), roots).map(decoded), Ok(vec![ROOT])); + assert_eq!(listed(Ok(&parts), unique([9; 16])).map(decoded), Ok(vec![])); + assert_eq!(listed(Ok(&parts), None), Err(Refusal::NotGranted)); } } diff --git a/userland/fileserver/src/data.rs b/userland/fileserver/src/data.rs index d8d7d3d8ed1..76901f44288 100644 --- a/userland/fileserver/src/data.rs +++ b/userland/fileserver/src/data.rs @@ -150,6 +150,7 @@ fn disk_word(e: DiskError) -> SyscallError { match e { DiskError::Device | DiskError::Range => SyscallError::Io, DiskError::Gone => SyscallError::Gone, + DiskError::ReadOnly => SyscallError::PermissionDenied, } } diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 2e5169fc4f0..55658eb162f 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -1882,11 +1882,9 @@ fn claimed_slots( .map_err(|e| refused(&format!("the {what}"), e)) }; let table_claim = claim(table_part.unique_guid, "slot table")?; - let mut copies: [toyos_abi::part::Block; 2] = [[0; toyos_abi::part::BLOCK_BYTES]; 2]; - toyos_abi::syscall::partition_read(table_claim.as_handle(), 0, &mut copies) - .map_err(|e| format!("the slot table would not read: {e:?}"))?; - let (table, _) = toyos_update::slots::current([&copies[0], &copies[1]]) - .map_err(|why| format!("the slot table's partition holds {why}"))?; + let (table, _) = + toyos_update::slots::read(|copies| toyos_abi::syscall::partition_read(table_claim.as_handle(), 0, copies)) + .map_err(|why| why.to_string())?; let listed = |p: &Partition| toyos_update::slots::Listed { device: p.device, type_guid: p.type_guid, @@ -1912,6 +1910,8 @@ const SLOT_KINDS: toyos_update::slots::Kinds = /// What the block service serves of the slots: the slot table, read through /// a session on `tables`, its unique GUID, and every partition `tables`, /// `boots` and `roots` list — each a connector minted for one type, read-only. +/// Its session on the table ends when this returns, and diskserver reads +/// that end before it judges `update`'s open of the same partition. /// /// **Made on the supervisor's file worker** ([`Supervisor::files`]): a call into a /// service the supervisor restarts, from its loop alone, would wait in the @@ -1921,6 +1921,7 @@ fn served_slots( boots: Connector, roots: Connector, ) -> Result<(toyos_update::slots::Table, [u8; 16], Vec), String> { + use diskserver::disk::Disk as _; let names = |connector: &Connector| { namespace::build() .add(toyos_blockring::PORT, connector) @@ -1935,17 +1936,11 @@ fn served_slots( let [table_part] = table_parts[..] else { return Err(format!("the block service serves {} slot tables, and a grant needs one", table_parts.len())); }; - let mut session = diskserver::Session::open(names(&tables)?, toyos_blockring::PORT, table_part.unique) + let session = diskserver::Session::open(names(&tables)?, toyos_blockring::PORT, table_part.unique) .map_err(|why| format!("the slot table would not open: {why:?}"))?; - let read = session.read(0, toyos_update::slots::COPIES as u32); - let data = match read { - Ok((diskserver::Outcome::Done, Some(data))) => data, - other => return Err(format!("the slot table would not read: {other:?}")), - }; - let (first, second) = data.split_at(toyos_update::slots::BLOCK); - let copies = [first, second].map(|copy| <&[u8; toyos_update::slots::BLOCK]>::try_from(copy).expect("one block each")); + let mut disk = diskserver::disk::Served::new(session); let (table, _) = - toyos_update::slots::current(copies).map_err(|why| format!("the slot table's partition holds {why}"))?; + toyos_update::slots::read(|copies| disk.read(0, copies.as_flattened_mut())).map_err(|why| why.to_string())?; let mut listed = table_parts; listed.extend(list(&boots)?); listed.extend(list(&roots)?); diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index c3763a32317..747744fabd1 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -37,7 +37,7 @@ use diskserver::disk::{Claimed, Disk, Served, BLOCK}; use toyos::endow::{self, Endowments}; use toyos::PartitionDev; use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; -use toyos_update::slots::{self, Table, Which}; +use toyos_update::slots::{self, Which}; use toyos_fat32::BlockAccess as _; use toyos_update::{policy, sig}; @@ -86,7 +86,8 @@ fn held(label: &str) -> Result { fn run(began: Instant) -> Result { let (mut table_part, mut boot, mut root) = (held(slots::TABLE_LABEL)?, held(slots::BOOT_LABEL)?, held(slots::ROOT_LABEL)?); - let (table, current) = read_table(&mut *table_part.disk)?; + let (table, current) = + slots::read(|copies| table_part.disk.read(0, copies.as_flattened_mut())).map_err(|why| why.to_string())?; let idle = [Which::A, Which::B] .into_iter() .find(|&w| table.slot(w).is_some_and(|s| s.boot == boot.unique && s.root == root.unique)) @@ -154,15 +155,6 @@ fn run(began: Instant) -> Result { )) } -/// The slot table and which copy of it is current. -fn read_table(disk: &mut dyn Disk) -> Result<(Table, usize), String> { - let mut copies = [0u8; 2 * BLOCK]; - disk.read(0, &mut copies).map_err(|e| format!("the slot table would not read: {e:?}"))?; - let (first, second) = copies.split_at(BLOCK); - let copies = [first, second].map(|copy| <&[u8; BLOCK]>::try_from(copy).expect("one block each")); - slots::current(copies).map_err(|why| format!("the slot table's partition holds {why}")) -} - /// Exactly `len` bytes of the input, held to `sha256`. fn take(input: &mut impl Read, len: u64, sha256: toyos_update::Digest, section: &str) -> Result, String> { let mut bytes = vec![0u8; len as usize]; From 4b4991b550229995a4efdd36edfee2af4f99d14e Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 10:51:21 +0200 Subject: [PATCH 3/4] update's volume ends at its sync, which already writes every block it holds `Fat32::sync` flushes its device, and `Cached::flush` is that flush: the `into_device().flush()` after it found nothing held, which is why the review's mutation of it (m5, applied at fa1bcdd55) left `update_writes_the_idle_slot_through_the_block_service` green, exit 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- userland/update/src/main.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs index 747744fabd1..b20fb5963a2 100644 --- a/userland/update/src/main.rs +++ b/userland/update/src/main.rs @@ -38,7 +38,6 @@ use toyos::endow::{self, Endowments}; use toyos::PartitionDev; use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; use toyos_update::slots::{self, Which}; -use toyos_fat32::BlockAccess as _; use toyos_update::{policy, sig}; /// The key an image must be signed with: the same the loader embeds. @@ -212,8 +211,8 @@ fn write_volume(boot: &mut dyn Disk, kernel: &[u8], cmdline: &[u8], signed: &[u8 fs.write(&mut file, 0, bytes).map_err(|e| format!("writing {path}: {e:?}"))?; fs.flush_meta(&mut file, time).map_err(|e| format!("recording {path}: {e:?}"))?; } - fs.sync().map_err(|e| format!("the idle volume's metadata: {e:?}"))?; - fs.into_device().flush().map_err(|e| format!("the idle volume's blocks: {e:?}")) + // Writes every block the volume holds (`Cached::flush`). + fs.sync().map_err(|e| format!("the idle volume's blocks: {e:?}")) } mod volume; From 5093967103898278ae104f4dc7ac89e202bc5ed1 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 11:20:28 +0200 Subject: [PATCH 4/4] The boot volume's server says why the device's ReadOnly never reaches it, and diskserver reads a hang-up in one place The review of round 2 asked the BOOT server to carry DiskError::ReadOnly through Bytes or to say why it cannot arrive. Carrying it is not small: toyos-fat32 has one device word because its repair contract reads every refused write as of unknown outcome, and a second word would reach into that crate's queued-repair machinery. It cannot arrive: the one read-only grant is the BOOT server's, whose volume is mounted unwritable, whose clients are refused every changing operation before the volume (rights::changes), and whose close and sync write nothing. The reason sits at the impl, and a_read_only_volume_never_writes_its_disk holds the last clause on a disk that panics on a write or a flush: m9, which drops sync's unwritable return, is red with "a read-only volume flushed its disk". diskserver's two reads of a session's doorbells, in place and in the loop, are one Service::hear. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- userland/diskserver/src/main.rs | 40 ++++++++++++++-------------- userland/fileserver/src/fat.rs | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/userland/diskserver/src/main.rs b/userland/diskserver/src/main.rs index 9e7a0d9a05b..31e78b53685 100644 --- a/userland/diskserver/src/main.rs +++ b/userland/diskserver/src/main.rs @@ -336,10 +336,9 @@ impl Service { /// device. fn place(&mut self, guid: [u8; 16], grant: Option) -> Result<(u64, u64), Refusal> { let (first, blocks) = admitted(self.ctrl.parts(), self.running, guid, grant)?; - for s in self.sessions.values_mut() { - if !s.closing && !doorbells(&s.conn) { - s.closing = true; - } + let ids: Vec = self.sessions.keys().copied().collect(); + for id in ids { + self.hear(id); } self.retire(); if self.sessions.len() >= MAX_SESSIONS { @@ -510,6 +509,21 @@ impl Service { } } + /// Consume session `id`'s doorbell bytes; once its client has hung up, + /// the session is closing. + fn hear(&mut self, id: u64) { + let s = self.sessions.get_mut(&id).expect("a session"); + let mut sink = [0u8; 64]; + while !s.closing { + match s.conn.read_nonblock(&mut sink) { + Ok(0) => s.closing = true, + Ok(_) => {} + Err(SyscallError::WouldBlock) => return, + Err(_) => s.closing = true, + } + } + } + /// End every session that is closing and has nothing on the device: its /// region leaves the controller's domain, and its partition is free. fn retire(&mut self) { @@ -712,10 +726,7 @@ fn serve(service: &mut Service, acceptor: &toyos::port::Acceptor) -> ! { let ids: Vec = service.sessions.keys().copied().collect(); for id in ids { if ready.contains(&(TOKEN_SESSION + id)) { - let s = service.sessions.get_mut(&id).expect("listed"); - if !doorbells(&s.conn) { - s.closing = true; - } + service.hear(id); } service.pull(id); } @@ -724,19 +735,6 @@ fn serve(service: &mut Service, acceptor: &toyos::port::Acceptor) -> ! { } } -/// Consume a session's doorbell bytes; `false` once its client has hung up. -fn doorbells(conn: &Connection) -> bool { - let mut sink = [0u8; 64]; - loop { - match conn.read_nonblock(&mut sink) { - Ok(0) => return false, - Ok(_) => continue, - Err(SyscallError::WouldBlock) => return true, - Err(_) => return false, - } - } -} - /// The grant `conn`, accepted from `acceptor`, was minted with; `None` for a /// connection through an unbadged connector or with bytes no minter stamps. fn grant(acceptor: &toyos::port::Acceptor, conn: &Connection) -> Option { diff --git a/userland/fileserver/src/fat.rs b/userland/fileserver/src/fat.rs index 6ba7537dd5a..1b827260db8 100644 --- a/userland/fileserver/src/fat.rs +++ b/userland/fileserver/src/fat.rs @@ -37,6 +37,11 @@ pub struct Bytes { len: u64, } +// The disk's `ReadOnly` is `Device` here because it never arrives: the one +// read-only grant is the BOOT server's, whose volume is mounted unwritable, +// whose clients are refused every changing operation before it, and whose +// close and sync write nothing. `toyos-fat32` has the one device word since it +// reads every refused write as of unknown outcome. impl BlockAccess for Bytes { fn capacity(&self) -> u64 { self.len @@ -580,6 +585,47 @@ mod tests { assert_eq!(v.node_meta(a), Err(SyscallError::Io)); } + /// A disk whose grant does not write: every write and flush is refused. + struct Granted(Ram); + + impl Disk for Granted { + fn blocks(&self) -> u64 { + self.0.blocks() + } + fn read(&mut self, first: u64, out: &mut [u8]) -> Result<(), DiskError> { + self.0.read(first, out) + } + fn write(&mut self, _: u64, _: &[u8]) -> Result<(), DiskError> { + panic!("a read-only volume wrote to its disk") + } + fn flush(&mut self) -> Result<(), DiskError> { + panic!("a read-only volume flushed its disk") + } + } + + /// A volume mounted unwritable never asks its disk to write or flush, so + /// the read-only grant's refusal never reaches [`Bytes`]. + #[test] + fn a_read_only_volume_never_writes_its_disk() { + const OPEN: OpenHow = OpenHow { create: false, create_new: false, truncate: false }; + let spec = spec_volume::fixture(); + let blocks = spec.bytes.len().div_ceil(BLOCK); + let mut ram = Ram::new(blocks as u64); + let mut image = spec.bytes.clone(); + image.resize(blocks * BLOCK, 0); + ram.write(0, &image).unwrap(); + let mut v = FatVolume::mount(Granted(ram), false, || 1_717_245_296 * NANOS_PER_SEC).unwrap(); + + assert_eq!(v.lstat("short.txt").unwrap().size, 100); + v.list("sub").unwrap(); + let n = v.open("short.txt", OPEN).unwrap(); + let mut out = vec![0u8; 100]; + assert_eq!(v.read(n, 0, &mut crate::volume::Buf(&mut out)), Ok(100)); + assert!(out == spec.bytes[spec_volume::cluster_offset(spec.at("short.txt").first)..][..100], "the file reads as the fixture wrote it"); + v.close(n).unwrap(); + assert_eq!(v.sync(), Ok(Vec::new())); + } + /// What the driver says of a volume that stopped answering is `Io`, which /// no caller takes for a name that is not there. #[test]