From b06314c5e778d35b1a00dc768c81f28d436071cd Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 01:11:28 +0200 Subject: [PATCH 1/6] shm: a mapping does not keep its dead process's address space A shared-memory object kept a strong reference to the page tables of every process that mapped it, released only when the object's last handle went. A process that ended while another still held the region (a child mapping its parent's log ring, a client's lent window) kept its whole address space alive: its tables, its PCID and every backing its regions held. With every /system program spawned from an image object, that was the image too: QEMU, tests/images/testcases.toml, 2 MiB more in use for every spawn of toybox. The list now holds each address space weakly. A dead entry is passed over by the zero-handle teardown, by unmap_from and by map_into, which also drops dead entries first, so a reused pid no longer finds the mapping of the process it replaced. Co-Authored-By: Claude Opus 5.5 --- kernel/src/object/shm.rs | 45 ++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/kernel/src/object/shm.rs b/kernel/src/object/shm.rs index 4e3b1e47e0..418b24c387 100644 --- a/kernel/src/object/shm.rs +++ b/kernel/src/object/shm.rs @@ -7,8 +7,13 @@ //! a handle holds an `Arc`. An `Arc` a syscall cloned out of its table can //! outlive the last handle, so a map through one finds no list and is refused //! under the lock the teardown took it with. +//! +//! **A mapping does not keep its process's address space**: the list holds it +//! weakly, so a process that ended while another still holds the region has +//! its space, and everything the space holds, freed with it; its entry is dead +//! and is passed over. -use alloc::sync::Arc; +use alloc::sync::{Arc, Weak}; use alloc::vec::Vec; use toyos_abi::syscall::SyscallError; @@ -17,6 +22,7 @@ use crate::mm::policy::{CachePolicy, Prot}; use crate::mm::paging::Detached; use crate::mm::{align_2m_checked, pmm, Unmapped, PAGE_2M, PAGE_SIZE}; use crate::process::{PageTables, Pid}; +use crate::sync::Lock; use crate::{DirectMap, UserAddr}; use super::{Held, KObjectVariant, ObjectCore, ZeroHandles}; @@ -46,7 +52,20 @@ pub struct SharedMemObject { pub(super) core: ObjectCore, region: Region, /// Where this region is mapped, per process; released by the zero-handle hook. - mapped_in: Held>, + mapped_in: Held>, +} + +/// One process's mapping of a region: dead once its address space is gone. +struct Mapping { + pid: Pid, + space: Weak>, + at: UserAddr, +} + +impl Mapping { + fn live(&self) -> Option { + self.space.upgrade() + } } impl SharedMemObject { @@ -122,15 +141,17 @@ impl SharedMemObject { } fn mapped_nowhere(&self) -> bool { - self.mapped_in.with(|mapped| mapped.is_empty()).unwrap_or(true) + self.mapped_in.with(|mapped| mapped.iter().all(|m| m.space.strong_count() == 0)).unwrap_or(true) } /// Map into `pt`, or answer the address it is already mapped at; /// idempotent per process. `Gone` once the last handle has gone. pub fn map_into(&self, pid: Pid, pt: &PageTables) -> Result { - let map = |mapped: &mut Vec<(Pid, PageTables, UserAddr)>| { - if let Some((_, _, vaddr)) = mapped.iter().find(|(p, _, _)| *p == pid) { - return Ok(vaddr.raw()); + let map = |mapped: &mut Vec| { + // A dead entry's pid may be this process's, reused. + mapped.retain(|m| m.space.strong_count() > 0); + if let Some(m) = mapped.iter().find(|m| m.space.as_ptr() == Arc::as_ptr(pt)) { + return Ok(m.at.raw()); } let (addr, _) = pt .lock() @@ -148,7 +169,7 @@ impl SharedMemObject { pid ); } - mapped.push((pid, Arc::clone(pt), addr)); + mapped.push(Mapping { pid, space: Arc::downgrade(pt), at: addr }); Ok(addr.raw()) }; self.mapped_in.with_mut(map).unwrap_or(Err(SyscallError::Gone)) @@ -159,9 +180,10 @@ impl SharedMemObject { #[must_use = "the caller owes a shootdown before the address can be reissued"] pub fn unmap_from(&self, pid: Pid) -> Option>> { self.mapped_in.with_mut(|mapped| { - let pos = mapped.iter().position(|(p, _, _)| *p == pid)?; - let (_, pt, vaddr) = mapped.swap_remove(pos); - let detached = pt.lock().free_and_unmap(vaddr); + mapped.retain(|m| m.space.strong_count() > 0); + let pos = mapped.iter().position(|m| m.pid == pid)?; + let m = mapped.swap_remove(pos); + let detached = m.live().and_then(|pt| pt.lock().free_and_unmap(m.at)); Some(Unmapped::new(detached)) })? } @@ -176,7 +198,8 @@ impl ZeroHandles for SharedMemObject { if mapped.is_empty() { return; } - let detached: Vec<_> = mapped.iter().filter_map(|(_, pt, vaddr)| pt.lock().free_and_unmap(*vaddr)).collect(); + let detached: Vec<_> = + mapped.iter().filter_map(|m| m.live().and_then(|pt| pt.lock().free_and_unmap(m.at))).collect(); drop(Unmapped::new((mapped, detached))); } } From 9a72d9ad4b6e817f17f8666dea2893df88ffce4d Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 01:11:29 +0200 Subject: [PATCH 2/6] boot: the kernel hands the supervisor its images and parses no filesystem The kernel mounted ROOT with bcachefs and started the supervisor by path, and the supervisor read the manifest through the kernel's VFS. Owner ruling 2026-10-21 ("Removing the boot filesystem exception") removes that exception: the supervisor and the file server are signed boot sections beside the kernel and ROOT, the kernel starts the supervisor from its image and hands it the other two, and the file server serves /system out of ROOT's memory before anything reads a file. - Signed header format 2: kernel, cmdline, supervisor, fileserver, root, each bound by role, length and hash under the one version. The two programs sit beside kernel.elf on the slot's FAT volume, and ROOT no longer carries them. The update image and installer carry them too. The cmdline no longer names ROOT's filesystem. - The loader reads both programs into page-aligned LoaderData pages and holds them to their hashes; a refusal names the section and happens before ExitBootServices, with the usual fallback to the other slot. - KernelArgs carries three typed BootImage extents. The kernel refuses by name an empty, unaligned or unheld image and any two reserved regions that overlap, keeps the pages out of its allocator for the machine's life, and makes each a shared-memory object every mapping of which is read-only (Memory::Boot, FileType::ImmutableMemory). - Deleted from the kernel: rootfs.rs, bcachefs_adapter.rs, the ROOT file backing, the /system mount and its access flag, SUPERVISOR_PATH and the bcachefs dependency. The kernel VFS serves /tmp alone. - The supervisor makes every role's port and its own grants before it reads a file, starts the file server's SYSTEM role from its boot image with ROOT's image, then reads os-release and the manifest over the file protocol. A bootstrap failure is said on the console, since no log ring is read yet. SYSTEM restarts from the kept images; every fileserver role starts from the boot image; a role no row serves is closed before anything asks it. - fileserver's RootVolume serves ROOT read-only from the mapped bytes, bounds-checking every block; every view holds /system read-only. - std's CommandExt::boot_image spawns from an immutable region; toyos::shm::Immutable and Image::boot hold one. - The harness reads the file server's ROOT line where it read the kernel's root=; a QEMU test and a metal row boot an image whose marked slot's supervisor is bent and judge the refusal and the fallback. - Raw-syscall guest tests read their programs into images and open copies in /tmp, since the kernel opens no file of /system. - A QEMU test holds that eight spawns from an image leave memory in use as it was, which the previous commit's lifetime fix is what keeps. - Memory: 54 MiB more in use after a testcases boot on QEMU (330 to 384 MiB), the running services' images and the SYSTEM server; recorded in issues/root-in-memory-costs-its-whole-size.md. - Issues: the storage, small-kernel, parsing, isolation and update tracks and the ROOT memory finding are reconciled; the loader's own bcachefs read for microcode is recorded. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- build/bootlog.rs | 2 +- build/build.rs | 121 ++++-- build/image.rs | 279 +++++++------ build/signing.rs | 5 +- images/diag.toml | 2 +- images/system.toml | 2 +- ...rogram-sees-only-the-files-it-was-given.md | 7 +- issues/root-in-memory-costs-its-whole-size.md | 25 +- ...ge-is-layers-and-a-role-is-a-filesystem.md | 39 +- ...-small-interrupts-post-and-threads-wait.md | 41 +- ...ernel-still-parses-what-userland-writes.md | 5 +- ...rses-roots-filesystem-to-find-microcode.md | 24 ++ ...e-machine-updates-itself-without-ubuntu.md | 6 +- kernel/Cargo.toml | 1 - kernel/src/actuator.rs | 4 +- kernel/src/bcachefs_adapter.rs | 181 -------- kernel/src/bootimage.rs | 103 +++++ kernel/src/drivers/gop.rs | 2 +- kernel/src/file_backing.rs | 87 +--- kernel/src/loaded.rs | 6 +- kernel/src/loader/mod.rs | 35 +- kernel/src/log/mod.rs | 2 +- kernel/src/main.rs | 47 +-- kernel/src/object/ops.rs | 8 +- kernel/src/object/shm.rs | 45 +- kernel/src/object/syscap.rs | 2 +- kernel/src/params.rs | 4 +- kernel/src/pcidev/mod.rs | 2 +- kernel/src/process.rs | 2 +- kernel/src/rootfs.rs | 138 ------- kernel/src/syscall/machine.rs | 4 +- kernel/src/vfs.rs | 17 +- lib/bcachefs/src/fs.rs | 2 +- lib/bcachefs/src/superblock.rs | 2 +- lib/logstream/src/lib.rs | 2 +- lib/manifest/Cargo.toml | 2 +- lib/manifest/src/consent.rs | 2 +- lib/manifest/src/grants.rs | 2 +- lib/manifest/src/launch.rs | 2 +- lib/manifest/src/lib.rs | 81 ++-- lib/quiesce/src/lib.rs | 2 +- lib/rootimage/src/handoff.rs | 18 +- lib/rootimage/src/lib.rs | 9 +- lib/rootimage/tests/handoff.rs | 15 +- lib/swap/src/lib.rs | 2 +- lib/update/src/image.rs | 154 +++++-- lib/update/src/lib.rs | 6 +- lib/update/src/slots.rs | 8 +- .../fixtures/ssh-keygen-signed-header.sig | 8 +- .../tests/fixtures/ssh-keygen-signer.pub | 2 +- lib/update/tests/sshsig_oracle.rs | 12 +- loader/src/main.rs | 27 +- loader/src/microcode.rs | 4 +- loader/src/payload.rs | 63 +++ loader/src/rootimage.rs | 70 +--- loader/src/slot.rs | 68 ++- loader/src/watchdog.rs | 2 +- sdk/abi/src/boot.rs | 101 ++--- sdk/abi/src/syscall.rs | 14 +- sdk/osrelease/src/lib.rs | 9 +- sdk/std/os/process.rs | 14 +- sdk/std/sys/fs.rs | 4 +- sdk/std/sys/process.rs | 36 +- sdk/toyos/src/lib.rs | 2 +- sdk/toyos/src/log/mod.rs | 2 +- sdk/toyos/src/log/region.rs | 2 +- sdk/toyos/src/log/stdio.rs | 2 +- sdk/toyos/src/power.rs | 2 +- sdk/toyos/src/process.rs | 37 +- sdk/toyos/src/shm.rs | 43 ++ system/CLAUDE.md | 2 +- system/acpiserver/src/host.rs | 2 +- system/console/src/main.rs | 2 +- system/filepicker/src/consent.rs | 2 +- system/fileserver/Cargo.toml | 3 +- system/fileserver/src/data.rs | 6 +- system/fileserver/src/lib.rs | 6 +- system/fileserver/src/main.rs | 44 +- system/fileserver/src/root.rs | 387 ++++++++++++++++++ system/grants/src/main.rs | 2 +- system/logkeeper/src/main.rs | 2 +- system/netstack/i219/src/lib.rs | 2 +- system/netstack/src/main.rs | 2 +- system/supervisor/lifecycle/src/lib.rs | 2 +- system/supervisor/src/main.rs | 274 +++++++++---- system/swap/src/main.rs | 2 +- system/toybox/src/ps.rs | 2 +- system/toybox/src/reboot.rs | 2 +- system/toybox/src/shutdown.rs | 2 +- system/update/copies/src/installing.rs | 31 +- system/update/src/install/mod.rs | 23 +- tests/CLAUDE.md | 2 +- tests/common/metal.rs | 31 +- tests/common/mod.rs | 1 + tests/common/qemu.rs | 39 +- tests/common/slots.rs | 71 ++++ tests/corpus/tinycc/210_stat_identity.c | 6 +- .../rust-tests/src/bin/abuse_handle_table.rs | 22 +- .../src/bin/abuse_listener_hijack.rs | 2 +- .../rust-tests/src/bin/abuse_page_straddle.rs | 12 +- .../src/bin/abuse_readonly_copyout.rs | 10 +- .../rust-tests/src/bin/endowment_denied.rs | 2 +- .../rust-tests/src/bin/hierarchy_paths.rs | 4 +- .../rust-tests/src/bin/pipe_other_end_gone.rs | 6 +- .../guest/rust-tests/src/bin/process_tree.rs | 9 +- .../src/bin/spawn_child_ends_first.rs | 9 +- .../rust-tests/src/bin/spawn_lands_claimed.rs | 9 +- tests/guest/rust-tests/src/image.rs | 28 ++ tests/toyos.rs | 68 ++- 110 files changed, 2093 insertions(+), 1121 deletions(-) create mode 100644 issues/the-loader-parses-roots-filesystem-to-find-microcode.md delete mode 100644 kernel/src/bcachefs_adapter.rs create mode 100644 kernel/src/bootimage.rs delete mode 100644 kernel/src/rootfs.rs create mode 100644 loader/src/payload.rs create mode 100644 system/fileserver/src/root.rs create mode 100644 tests/common/slots.rs create mode 100644 tests/guest/rust-tests/src/image.rs diff --git a/Cargo.lock b/Cargo.lock index e7225f92df..6e9fe25b4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,6 +1306,7 @@ dependencies = [ "toyos-fat32-check", "toyos-gpt", "toyos-manifest", + "toyos-osrelease", ] [[package]] @@ -1955,7 +1956,6 @@ dependencies = [ name = "kernel" version = "0.1.0" dependencies = [ - "bcachefs", "dlmalloc", "hashbrown 0.16.1", "toyos-abi", diff --git a/build/bootlog.rs b/build/bootlog.rs index 00a1e5d14d..d5910cbb65 100644 --- a/build/bootlog.rs +++ b/build/bootlog.rs @@ -16,7 +16,7 @@ use std::fmt; /// running to write it there. pub const REBOOTING: &str = "Rebooting."; -/// What `/system/bin/supervisor` says as it asks `logkeeper` to make the log whole, before +/// What the supervisor says as it asks `logkeeper` to make the log whole, before /// it stops the machine: the last line a passing boot's log is owed, because /// nothing after it waits for the file. pub const STOPPING: &str = toyos_logstream::STOPPING; diff --git a/build/build.rs b/build/build.rs index c8395e68a9..4ea1d4626f 100644 --- a/build/build.rs +++ b/build/build.rs @@ -109,7 +109,7 @@ struct SystemConfig { symlinks: BTreeMap, #[serde(default)] assets: Vec, - /// What `/system/bin/supervisor` starts at boot. Program *keys*, never paths — a path + /// What the supervisor starts at boot. Program *keys*, never paths — a path /// here is a second spelling of a `[programs]` key and is what let a boot /// list smuggle an argument through. Arguments live on the program entry's /// `args` instead. @@ -350,12 +350,16 @@ fn config_crates(root: &Path, config: &SystemConfig) -> Vec { features: Features::Default, }); } - crates.push(ConfigCrate { - name: SUPERVISOR_PROGRAM.to_string(), - dir: program_dir(root, SUPERVISOR_PROGRAM), - built: Built::Member, - features: Features::Default, - }); + for name in BOOT_PROGRAMS { + if !config.programs.contains_key(name) { + crates.push(ConfigCrate { + name: name.to_string(), + dir: program_dir(root, name), + built: Built::Member, + features: Features::Default, + }); + } + } crates } @@ -598,13 +602,35 @@ fn assert_overflow_checked(what: &str, image: &[u8]) { // --- Shared root-image assembly --- -/// Build all programs from a config and assemble the ROOT image. /// The one program the kernel starts, in **every** image whatever `[programs]` -/// says. It reads the manifest below and starts what that names, so a ROOT image +/// says. It reads the manifest below and starts what that names, so an image /// without it is a machine with a kernel and no userland at all. const SUPERVISOR_PROGRAM: &str = "supervisor"; -/// Names `/system/bin/supervisor` serves itself. +/// The file server, which the supervisor serves ROOT with before it reads the +/// manifest, so in every image too. +const FILESERVER_PROGRAM: &str = "fileserver"; + +/// The programs a slot carries beside the kernel, as signed sections of their +/// own, and ROOT does not (`toyos_update::image`). +const BOOT_PROGRAMS: [&str; 2] = [SUPERVISOR_PROGRAM, FILESERVER_PROGRAM]; + +/// What a config's userland build makes: ROOT, and the two programs a slot +/// carries beside it. +pub struct Userland { + pub root: Vec, + pub supervisor: Vec, + pub fileserver: Vec, +} + +impl Userland { + /// The image of this userland and `kernel`. + pub fn release<'a>(&'a self, kernel: &'a [u8]) -> image::Release<'a> { + image::Release { kernel, supervisor: &self.supervisor, fileserver: &self.fileserver, root: &self.root } + } +} + +/// Names the supervisor serves itself. /// /// The supervisor is in every image and is no `[programs]` key, so these have no /// declaration to come from. They travel in the manifest so the supervisor creates @@ -741,7 +767,7 @@ fn receives_reach_a_running_server(config: &SystemConfig) -> Result<(), String> } } -/// The resolved config as the records `/system/bin/supervisor` reads. +/// The resolved config as the records the supervisor reads. /// /// The format, the renderer and the parser are `lib/manifest/`, whose /// round-trip test is what makes "what the build writes is what the supervisor reads" a @@ -761,7 +787,10 @@ fn render_manifest(config: &SystemConfig) -> Vec { let cfg = &config.programs[*name]; toyos_manifest::Program { name: (*name).clone(), - path: format!("/system/bin/{name}"), + path: match name.as_str() { + FILESERVER_PROGRAM => toyos_abi::syscall::FILESERVER_IMAGE_LABEL.to_string(), + _ => format!("/system/bin/{name}"), + }, args: cfg.args.clone(), serves: cfg.serves.clone(), provides: cfg.provides.clone(), @@ -858,13 +887,18 @@ fn build_and_assemble( quiet: bool, arch: Arch, machines: crate::microcode::Machines, -) -> Vec { +) -> Userland { // Before anything is built, so a file edited during the build is not // credited to the state it was read in. let release = release(root, env.sysroot.identity.of_target(arch.userland()), arch); let mut root_files: Vec<(String, Vec)> = vec![(toyos_osrelease::PATH.to_string(), release.to_string().into_bytes())]; build_programs(root, config, env, quiet, arch, &mut root_files); + let mut take = |name: &str| { + let at = root_files.iter().position(|(file, _)| *file == format!("bin/{name}")).expect("every image builds its boot programs"); + root_files.remove(at).1 + }; + let (supervisor, fileserver) = (take(SUPERVISOR_PROGRAM), take(FILESERVER_PROGRAM)); root_files.push((toyos_manifest::PATH.to_string(), render_manifest(config))); let programs: BTreeSet<&str> = config.programs.keys().map(String::as_str).collect(); @@ -905,10 +939,10 @@ fn build_and_assemble( panic!("{why}"); } - image::create_root_image(&root_files, &symlinks, quiet) + Userland { root: image::create_root_image(&root_files, &symlinks, quiet), supervisor, fileserver } } -/// Build `config`'s programs and the supervisor for `arch`, and add each to `root_files`. +/// Build `config`'s programs and [`BOOT_PROGRAMS`] for `arch`, and add each to `root_files`. fn build_programs( root: &Path, config: &SystemConfig, @@ -970,7 +1004,7 @@ fn symlink_target_name(to: &str) -> &str { } /// The converse of the crate assertion above: a `bin/` entry no name reaches. -/// A name buys authority — `/system/bin/supervisor` builds a `[programs]` row's namespace and +/// A name buys authority — the supervisor builds a `[programs]` row's namespace and /// device claims — and a harness binary has none, holding only what its spawner /// moved in, so it is legal exactly when the config starts something that could /// spawn it. **An inventory over the `bin/` namespace, not a reachability @@ -982,7 +1016,7 @@ fn unnamed_program( ) -> Result<(), String> { for name in names { let Some(program) = name.strip_prefix("bin/") else { continue }; - if program == SUPERVISOR_PROGRAM || programs.contains(program) { + if programs.contains(program) { continue; } if HARNESS_PREFIXES.iter().any(|p| name.starts_with(p)) { @@ -997,7 +1031,7 @@ fn unnamed_program( continue; } return Err(format!( - "the image carries {name} and no `[programs]` row names it, so `/system/bin/supervisor` can build \ + "the image carries {name} and no `[programs]` row names it, so the supervisor can build \ it no namespace and no device claim; add a row or take the file out" )); } @@ -1809,17 +1843,16 @@ pub fn build(root: &Path, boot: Boot, plan: &Plan) -> PathBuf { return image_path; } - let (kernel_bytes, bl_bytes, root_bytes) = shipped_parts(root, &boot, plan); + let (kernel_bytes, bl_bytes, userland) = shipped_parts(root, &boot, plan); let key = said_key(plan); // A machine this image is flashed onto updates itself, so it carries // the second slot an update is written to, with room for a ROOT twice // this one's size. - let second = image::SecondSlot { root_bytes: 2 * root_bytes.len() as u64 }; + let second = image::SecondSlot { root_bytes: 2 * userland.root.len() as u64 }; let disk_bytes = image::create_boot_image( plan.arch, - &kernel_bytes, &bl_bytes, - &root_bytes, + userland.release(&kernel_bytes), &plan.params.join(","), image::Signing { key, version: plan.version }, Some(second), @@ -1840,11 +1873,10 @@ pub fn build(root: &Path, boot: Boot, plan: &Plan) -> PathBuf { /// slot, signed with this run's key at the plan's version. pub fn build_update(root: &Path, boot: &Boot, plan: &Plan, out: &Path) { assert!(!boot.case, "an update image is built from a mode's config, and a case's image is a test's"); - let (kernel_bytes, _, root_bytes) = shipped_parts(root, boot, plan); + let (kernel_bytes, _, userland) = shipped_parts(root, boot, plan); let key = said_key(plan); let bytes = image::update_image( - &kernel_bytes, - &root_bytes, + userland.release(&kernel_bytes), &plan.params.join(","), image::Signing { key, version: plan.version }, ); @@ -1866,8 +1898,8 @@ fn said_key(plan: &Plan) -> &'static crate::signing::Key { key } -/// The kernel, the loader and ROOT a mode's image is made of. -fn shipped_parts(root: &Path, boot: &Boot, plan: &Plan) -> (Vec, Vec, Vec) { +/// The kernel, the loader and the userland a mode's image is made of. +fn shipped_parts(root: &Path, boot: &Boot, plan: &Plan) -> (Vec, Vec, Userland) { let kernel_features = plan.features.join(","); let arch = plan.arch; @@ -1894,10 +1926,10 @@ fn shipped_parts(root: &Path, boot: &Boot, plan: &Plan) -> (Vec, Vec, Ve ) }; - let root_bytes = build_and_assemble(root, &config, &env, &[], false, arch, plan.machines); + let userland = build_and_assemble(root, &config, &env, &[], false, arch, plan.machines); let bl_bytes = fs::read(&bl_art).expect("Failed to read staged bootloader"); - (kernel_bytes, bl_bytes, root_bytes) + (kernel_bytes, bl_bytes, userland) } /// Create an empty disk image the guest sees at full size and the host pays @@ -1955,21 +1987,21 @@ pub fn designate_for_format(path: &Path, len: u64) { /// What it does not see is a source edit that lands mid-run. A run is a /// measurement of one tree, so that is the behaviour wanted either way; a run /// that *starts* after a kernel edit still rebuilds every variant it uses. -struct Memo(std::sync::Mutex>>>); +struct Memo>(std::sync::Mutex>>); -impl Memo { +impl Memo { const fn new() -> Self { Self(std::sync::Mutex::new(BTreeMap::new())) } - fn get(&self, key: u64) -> Option>> { + fn get(&self, key: u64) -> Option> { self.0.lock().expect("a build panicked holding the artifact memo").get(&key).cloned() } /// The lock is deliberately not held across `make`: a build that panics /// under it would poison the memo, and every later boot would then fail on /// the poison instead of on whatever went wrong with it. - fn get_or_build(&self, key: u64, make: impl FnOnce() -> Vec) -> Arc> { + fn get_or_build(&self, key: u64, make: impl FnOnce() -> T) -> Arc { if let Some(hit) = self.get(key) { return hit; } @@ -1984,7 +2016,7 @@ impl Memo { static KERNEL: Memo = Memo::new(); static BOOTLOADER: Memo = Memo::new(); -static ROOT_IMAGE: Memo = Memo::new(); +static USERLAND: Memo = Memo::new(); /// What the ROOT image is a function of: the config naming the programs, the /// architecture and whether they are built at all, the key its @@ -2020,9 +2052,8 @@ pub fn build_test_image( let parts = build_test_parts(root, plan, quiet, extra_files); image::create_boot_image( plan.arch, - &parts.kernel, &parts.bootloader, - &parts.root, + parts.userland.release(&parts.kernel), &plan.params.join(","), image::Signing { key: crate::signing::key(), version: plan.version }, None, @@ -2033,7 +2064,7 @@ pub fn build_test_image( pub struct Parts { pub kernel: Arc>, pub bootloader: Arc>, - pub root: Arc>, + pub userland: Arc, } /// [`Parts`] for a plan: built, or this process's memo of them. @@ -2067,9 +2098,9 @@ pub fn build_test_parts( // Nothing left to build, so nothing for the lock, the toolchain check or the // staleness sweep to protect. - let memo = (KERNEL.get(kernel_key), BOOTLOADER.get(bl_key), ROOT_IMAGE.get(root_image_key)); - if let (Some(kernel), Some(bootloader), Some(root)) = memo { - return Parts { kernel, bootloader, root }; + let memo = (KERNEL.get(kernel_key), BOOTLOADER.get(bl_key), USERLAND.get(root_image_key)); + if let (Some(kernel), Some(bootloader), Some(userland)) = memo { + return Parts { kernel, bootloader, userland }; } // A cache miss is shared setup, not a property of whichever test happened @@ -2113,13 +2144,13 @@ pub fn build_test_parts( (kernel, bl) }; - let root_bytes = ROOT_IMAGE.get_or_build(root_image_key, || { + let userland = USERLAND.get_or_build(root_image_key, || { build_and_assemble(root, &config, &env, extra_files, quiet, arch, plan.machines) }); drop(building); - Parts { kernel: kernel_bytes, bootloader: bl_bytes, root: root_bytes } + Parts { kernel: kernel_bytes, bootloader: bl_bytes, userland } } /// Build all binaries in a multi-binary crate. Returns vec of (binary_name, bytes). @@ -3364,7 +3395,7 @@ mod tests { } /// The diagnostic image's whole reason for existing: nothing in it can claim - /// the framebuffer, so the kernel's boot log stays on the panel. `/system/bin/supervisor` + /// the framebuffer, so the kernel's boot log stays on the panel. The supervisor /// is in every image and could reach a device, so the property becomes "the /// config declares no `devices`" — checkable here for the first time. #[test] @@ -3379,7 +3410,7 @@ mod tests { } /// `[boot] start` names program keys, so a typo is a build error rather than - /// a refusal `/system/bin/supervisor` reports at boot. + /// a refusal the supervisor reports at boot. fn started_programs_are_declared(cfg: &SystemConfig) -> Result<(), String> { for name in &cfg.boot.start { if !cfg.programs.contains_key(name) { @@ -3408,7 +3439,7 @@ mod tests { let programs = declared(&["shell"]); let started = ["shell".to_string()]; assert!(unnamed_program( - &["bin/supervisor", "bin/shell", "lib/libtls_lib.so", "etc/system.manifest"], + &["bin/shell", "lib/libtls_lib.so", "etc/system.manifest"], &programs, &started, ) diff --git a/build/image.rs b/build/image.rs index 9b4eab87db..5bab30e226 100644 --- a/build/image.rs +++ b/build/image.rs @@ -9,8 +9,8 @@ use toyos_fat32::{BlockAccess, Fat32, FatTime, IoError}; use toyos_gpt::Guid; /// The image that goes on the ROOT partition, named by a UUID **derived, never -/// drawn**: two builds of one tree have to agree on the name the kernel -/// argument carries. +/// drawn**: two builds of one tree have to agree on the name the file server +/// serving it says it by (`toyos_osrelease::ROOT_SAID`). /// /// **A set, not a sequence.** Both lists are sorted by name here, so the /// volume's bytes and the UUID over them are a function of what the caller @@ -153,9 +153,8 @@ fn root_uuid(files: &[&(String, Vec)], symlinks: &[&(String, String)]) -> Fs FsUuid(uuid) } -/// The name the ROOT image `bytes` carries, read back out of its superblock, so -/// the kernel argument says what the image says rather than what whoever -/// assembled it meant to stamp. +/// The name the ROOT image `bytes` carries, read back out of its superblock: +/// what the image says rather than what whoever assembled it meant to stamp. pub fn root_uuid_of(bytes: &[u8]) -> FsUuid { superblock_of(bytes).uuid } @@ -190,28 +189,45 @@ pub struct SecondSlot { pub root_bytes: u64, } -/// The sections of one image, as a slot carries them and an update sends them. -struct Sections { - kernel: Vec, +/// What one signed image is made of but its boot parameter: the kernel, the +/// two programs the kernel starts the machine with — the supervisor, and the +/// file server that serves ROOT to it — and ROOT, which carries neither +/// program (`toyos_update::image`). +#[derive(Clone, Copy)] +pub struct Release<'a> { + pub kernel: &'a [u8], + pub supervisor: &'a [u8], + pub fileserver: &'a [u8], + pub root: &'a [u8], +} + +/// The signed header of one image and the boot parameter it names. +struct Signed { cmdline: Vec, signed: [u8; toyos_update::image::SIGNED_BYTES], } -fn sections(kernel: &[u8], root: &[u8], params: &str, signing: Signing<'_>) -> Sections { - let cmdline = cmdline_with_root(root_uuid_of(root), params).into_bytes(); - let header = toyos_update::image::Header::of(signing.version, kernel, &cmdline, root); - Sections { kernel: kernel.to_vec(), signed: signing.key.sign(&header), cmdline } +fn signed(release: Release<'_>, params: &str, signing: Signing<'_>) -> Signed { + let cmdline = params.as_bytes().to_vec(); + let header = toyos_update::image::Header::of(signing.version, §ions(release, &cmdline)); + Signed { signed: signing.key.sign(&header), cmdline } +} + +/// `release` and `cmdline` as the signed header names them. +fn sections<'a>(release: Release<'a>, cmdline: &'a [u8]) -> toyos_update::image::Sections<'a> { + let Release { kernel, supervisor, fileserver, root } = release; + toyos_update::image::Sections { kernel, cmdline, supervisor, fileserver, root } } /// The image `ssh update` takes on its standard input: the signed -/// header, then the kernel, the boot parameter and ROOT, in the order the -/// header names them (`toyos_update::image`). -pub fn update_image(kernel: &[u8], root: &[u8], params: &str, signing: Signing<'_>) -> Vec { - let s = sections(kernel, root, params, signing); +/// header, then every section in the order the header names them +/// (`toyos_update::image`), ROOT last. +pub fn update_image(release: Release<'_>, params: &str, signing: Signing<'_>) -> Vec { + let s = signed(release, params, signing); let mut out = s.signed.to_vec(); - out.extend_from_slice(&s.kernel); - out.extend_from_slice(&s.cmdline); - out.extend_from_slice(root); + for section in sections(release, &s.cmdline).all() { + out.extend_from_slice(section); + } out } @@ -224,9 +240,8 @@ pub fn update_image(kernel: &[u8], root: &[u8], params: &str, signing: Signing<' /// slot A's FAT and ROOT, marked, and slot B's where `second` asks for one. pub fn create_boot_image( arch: Arch, - kernel_bytes: &[u8], bl_bytes: &[u8], - root_bytes: &[u8], + release: Release<'_>, params: &str, signing: Signing<'_>, second: Option, @@ -240,11 +255,12 @@ pub fn create_boot_image( let a = (crate::gptwrite::random_guid(), crate::gptwrite::random_guid()); let b = second.map(|room| (crate::gptwrite::random_guid(), crate::gptwrite::random_guid(), room)); - let s = sections(kernel_bytes, root_bytes, params, signing); - // Room for a kernel twice this one's size beside it, so an update can - // write a new kernel into a slot's volume before the old one is gone. - let slot_bytes = round_up_sectors(((kernel_bytes.len() * 2 + ESP_FREE_BYTES) * 64 / 63).max(FAT32_MIN_BYTES)); - let a_boot = create_slot_volume(Some(&s), slot_bytes); + let s = signed(release, params, signing); + // Room for the slot's files twice over, so an update can write new ones + // into a slot's volume before the old ones are gone. + let files = release.kernel.len() + release.supervisor.len() + release.fileserver.len(); + let slot_bytes = round_up_sectors(((files * 2 + ESP_FREE_BYTES) * 64 / 63).max(FAT32_MIN_BYTES)); + let a_boot = create_slot_volume(Some(SlotFiles { release, cmdline: &s.cmdline, signed: &s.signed }), slot_bytes); let slot = |(boot, root): (Guid, Guid), version| toyos_update::slots::Slot { boot: boot.0, root: root.0, version }; let table = toyos_update::slots::Table { sequence: 1, @@ -264,7 +280,7 @@ pub fn create_boot_image( // Finder, in Windows and in Linux on plug-in, with nothing configured. Part::full("log partition", "ToyOS log", Guid::MICROSOFT_BASIC, log_guid, create_log_volume(), Some(Volume::Fat32)), Part::full("slot A's volume", "ToyOS slot A", Guid::TOYOS_BOOT, a.0, a_boot, Some(Volume::Fat32)), - Part::full("slot A's ROOT", "ToyOS root A", Guid::TOYOS_ROOT, a.1, root_bytes.to_vec(), Some(Volume::Root)), + Part::full("slot A's ROOT", "ToyOS root A", Guid::TOYOS_ROOT, a.1, release.root.to_vec(), Some(Volume::Root)), ]; if let Some((boot, root, room)) = b { parts.push(Part::full("slot B's volume", "ToyOS slot B", Guid::TOYOS_BOOT, boot, create_slot_volume(None, slot_bytes), Some(Volume::Fat32))); @@ -274,23 +290,10 @@ pub fn create_boot_image( create_gpt_disk(&parts) } -/// The boot parameter as a slot carries it: ROOT's name, then `params`. -fn cmdline_with_root(root: FsUuid, params: &str) -> String { - if params.is_empty() { - format!("root={root}") - } else { - format!("root={root},{params}") - } -} - /// The actuator list an image is armed with, read back off the image. -/// -/// `root=` is not an actuator and is on every image: this answers what a boot -/// would *arm*, which is the other reading of the same string -/// (`toyos_abi::boot::actuators`). pub fn params_of(path: &Path) -> Result, String> { let text = cmdline_of(path)?; - Ok(toyos_abi::boot::actuators(&text).map(str::to_string).collect()) + Ok(toyos_abi::boot::tokens(&text).map(str::to_string).collect()) } /// The whole boot parameter the image's marked slot carries, read back off @@ -628,22 +631,32 @@ fn create_esp_volume(arch: Arch, bootloader: &[u8], log_guid: Guid) -> Vec { volume } -/// A slot's FAT volume of `bytes`: the kernel, its boot parameter and the -/// signed header naming both and ROOT (`toyos_update::slots`), or nothing for -/// a slot no image has been installed in yet. +/// What a slot's volume carries of one image. +struct SlotFiles<'a> { + release: Release<'a>, + cmdline: &'a [u8], + signed: &'a [u8], +} + +/// A slot's FAT volume of `bytes`: the kernel, its boot parameter, the two +/// boot programs and the signed header naming them and ROOT +/// (`toyos_update::slots`), or nothing for a slot no image has been installed +/// in yet. /// -/// The parameter is ROOT's name and then the actuators this boot arms, -/// comma-separated: handed to the kernel in `KernelArgs`, because the earliest -/// actuator fires before `mm::init` and there is nowhere later to fetch it -/// from. [`params_of`] is how the host asks a finished image which of them a -/// guest booting it would arm. -fn create_slot_volume(image: Option<&Sections>, bytes: usize) -> Vec { +/// The parameter is the actuators this boot arms, comma-separated: handed to +/// the kernel in `KernelArgs`, because the earliest actuator fires before +/// `mm::init` and there is nowhere later to fetch it from. [`params_of`] is +/// how the host asks a finished image which of them a guest booting it would +/// arm. +fn create_slot_volume(image: Option>, bytes: usize) -> Vec { let mut volume = format_fat32(bytes, "TOYOS-SLOT"); let files: Vec<(&str, &[u8])> = match image { Some(s) => vec![ - (toyos_update::slots::KERNEL_FILE, &s.kernel), - (toyos_update::slots::CMDLINE_FILE, &s.cmdline), - (toyos_update::slots::SIGNED_FILE, &s.signed), + (toyos_update::slots::KERNEL_FILE, s.release.kernel), + (toyos_update::slots::CMDLINE_FILE, s.cmdline), + (toyos_update::slots::SUPERVISOR_FILE, s.release.supervisor), + (toyos_update::slots::FILESERVER_FILE, s.release.fileserver), + (toyos_update::slots::SIGNED_FILE, s.signed), ], None => Vec::new(), }; @@ -962,7 +975,17 @@ mod tests { /// A ROOT image with one file in it, for the tests that need a real one /// rather than a placeholder: the assembler reads its superblock. fn tiny_root() -> Vec { - create_root_image(&[("bin/supervisor".to_string(), b"supervisor".to_vec())], &[], true) + create_root_image(&[("etc/os-release".to_string(), b"release".to_vec())], &[], true) + } + + /// Placeholder programs: what these tests judge is where each goes. + fn release<'a>(kernel: &'a [u8], root: &'a [u8]) -> Release<'a> { + Release { kernel, supervisor: b"\x7fELF supervisor", fileserver: b"\x7fELF fileserver", root } + } + + /// What a slot's volume carries of `release`. + fn slot_files<'a>(release: Release<'a>, s: &'a Signed) -> SlotFiles<'a> { + SlotFiles { release, cmdline: &s.cmdline, signed: &s.signed } } /// A fixed key: what these tests judge is the image, not whose it is. @@ -1019,10 +1042,11 @@ mod tests { #[test] fn the_volumes_this_build_writes_break_no_format_rule() { let key = key(); - let s = sections(b"kernel", &tiny_root(), "", signing(&key)); + let root = tiny_root(); + let s = signed(release(b"kernel", &root), "", signing(&key)); for (what, volume) in [ ("ESP", create_esp_volume(Arch::X86_64, b"bootloader", crate::gptwrite::random_guid())), - ("slot volume", create_slot_volume(Some(&s), FAT32_MIN_BYTES)), + ("slot volume", create_slot_volume(Some(slot_files(release(b"kernel", &root), &s)), FAT32_MIN_BYTES)), ("empty slot volume", create_slot_volume(None, FAT32_MIN_BYTES)), ("log volume", create_log_volume()), ] { @@ -1047,7 +1071,7 @@ mod tests { fn a_damaged_image_is_refused_by_the_reader_that_caught_it() { let root_image = tiny_root(); let key = key(); - let disk = create_boot_image(Arch::X86_64, b"kernel", b"bootloader", &root_image, "", signing(&key), None); + let disk = create_boot_image(Arch::X86_64, b"bootloader", release(b"kernel", &root_image), "", signing(&key), None); let log = only(&disk, toyos_gpt::Guid::MICROSOFT_BASIC); let root = root_partition_guid_of(&disk); let parts = @@ -1112,27 +1136,35 @@ mod tests { ["EFI/BOOT/BOOTx64.EFI", "toyos/log.guid"] ); let key = key(); - let s = sections(b"kernel", &tiny_root(), "", signing(&key)); + let root = tiny_root(); + let s = signed(release(b"kernel", &root), "", signing(&key)); assert_eq!( - files_of(create_slot_volume(Some(&s), FAT32_MIN_BYTES)), - [toyos_update::slots::CMDLINE_FILE, toyos_update::slots::SIGNED_FILE, toyos_update::slots::KERNEL_FILE] + files_of(create_slot_volume(Some(slot_files(release(b"kernel", &root), &s)), FAT32_MIN_BYTES)), + [ + toyos_update::slots::CMDLINE_FILE, + toyos_update::slots::FILESERVER_FILE, + toyos_update::slots::SIGNED_FILE, + toyos_update::slots::KERNEL_FILE, + toyos_update::slots::SUPERVISOR_FILE, + ] ); assert!(files_of(create_slot_volume(None, FAT32_MIN_BYTES)).is_empty()); } /// **The host's own reading of what the loader checks**: the marked slot's /// signed header verifies under the key the image was signed with and names - /// the kernel, the boot parameter and ROOT the image carries, byte for - /// byte; the table names the slot's partitions; and an update image for the - /// same parts splits into the same header and sections. A second slot is - /// present, empty, and not marked. + /// the kernel, the boot parameter, the two programs and ROOT the image + /// carries, byte for byte, and ROOT carries neither program; the table names + /// the slot's partitions; and an update image for the same parts splits into + /// the same header and sections. A second slot is present, empty, and not + /// marked. #[test] fn an_image_s_marked_slot_is_signed_over_exactly_what_it_carries() { let key = key(); let root = tiny_root(); let dir = toyos_tmpdir::TempDir::new("image-slot"); let path = dir.join("slotted.img"); - let disk = create_boot_image(Arch::X86_64, b"\x7fELF kernel", b"bootloader", &root, "sched-fast-health", signing(&key), Some(SecondSlot { root_bytes: 8 << 20 })); + let disk = create_boot_image(Arch::X86_64, b"bootloader", release(b"\x7fELF kernel", &root), "sched-fast-health", signing(&key), Some(SecondSlot { root_bytes: 8 << 20 })); std::fs::write(&path, &disk).expect("write the image"); let mut file = std::fs::File::open(&path).expect("open the image"); let table = slot_table_of(&mut file).expect("the slot table"); @@ -1150,18 +1182,24 @@ mod tests { toyos_update::sig::verify(&key.public(), &bytes, &toyos_update::image::signature_of(&signed)).expect("the signature"); let kernel = read_file_on(&mut file, a.boot, toyos_update::slots::KERNEL_FILE).expect("the kernel"); let cmdline = read_file_on(&mut file, a.boot, toyos_update::slots::CMDLINE_FILE).expect("the cmdline"); + let supervisor = read_file_on(&mut file, a.boot, toyos_update::slots::SUPERVISOR_FILE).expect("the supervisor"); + let fileserver = read_file_on(&mut file, a.boot, toyos_update::slots::FILESERVER_FILE).expect("the file server"); let (at, _) = partition_extent(&mut file, a.root).expect("slot A's ROOT"); let on_disk = &disk[at as usize..at as usize + header.root().len as usize]; assert_eq!(kernel, b"\x7fELF kernel"); + assert_eq!(cmdline, b"sched-fast-health", "the boot parameter is the image's parameters and names no filesystem"); + let placeholder = release(b"", b""); + assert_eq!((&supervisor[..], &fileserver[..]), (placeholder.supervisor, placeholder.fileserver)); assert_eq!(on_disk, &root[..]); - assert_eq!(header, toyos_update::image::Header::of(7, &kernel, &cmdline, on_disk)); + let carried = toyos_update::image::Sections { kernel: &kernel, cmdline: &cmdline, supervisor: &supervisor, fileserver: &fileserver, root: on_disk }; + assert_eq!(header, toyos_update::image::Header::of(7, &carried)); drop(file); drop(dir); - let update = update_image(b"\x7fELF kernel", &root, "sched-fast-health", signing(&key)); + let update = update_image(release(b"\x7fELF kernel", &root), "sched-fast-health", signing(&key)); let parts = toyos_update::image::Parts::split(&update).expect("an update image splits"); assert_eq!(parts.header, header); - assert_eq!(toyos_update::image::Header::of(7, parts.kernel, parts.cmdline, parts.root), header); + assert_eq!(parts.sections, carried); assert_eq!(parts.signed, &signed); } @@ -1198,7 +1236,7 @@ mod tests { fn an_install_makes_its_disk_and_refuses_one_that_exists() { const DATA: u64 = 4 << 20; let key = key(); - let image = create_boot_image(Arch::X86_64, b"kernel", b"bootloader", &tiny_root(), "", signing(&key), None); + let image = create_boot_image(Arch::X86_64, b"bootloader", release(b"kernel", &tiny_root()), "", signing(&key), None); let dir = toyos_tmpdir::TempDir::new("image-install"); let path = dir.join("disk.img"); @@ -1226,16 +1264,14 @@ mod tests { assert_eq!(std::fs::read(&theirs).expect("read it back"), b"somebody's"); } - /// **One ordering of one set is one image.** The judge below compares - /// `root=` against the superblock the same build stamped, so it is blind to - /// this: a `root_uuid` returning a constant satisfies it. This is the arm - /// that is not — the same files handed over backwards have to come out as - /// one UUID and one byte string, or two builds of one tree are two images - /// and the kernel argument names a filesystem the next build has not got. + /// **One ordering of one set is one image.** The same files handed over + /// backwards have to come out as one UUID and one byte string, or two + /// builds of one tree are two images, and the name the file server says + /// ROOT by names a filesystem the next build has not got. #[test] fn one_ordering_of_one_set_is_one_image() { let files: Vec<(String, Vec)> = vec![ - ("bin/supervisor".to_string(), b"supervisor-binary".to_vec()), + ("bin/shell".to_string(), b"shell-binary".to_vec()), ("bin/toybox".to_string(), (0..40_000u32).map(|i| (i ^ 0x5A) as u8).collect()), ("etc/system.manifest".to_string(), b"[start]\nsupervisor\n".to_vec()), ("share/empty".to_string(), Vec::new()), @@ -1279,18 +1315,16 @@ mod tests { /// **The independent oracle for ROOT**, asked of the finished image by /// readers that did not write it: `toyos-gpt` finds the partition by type - /// where the table placed it, `bcachefs`'s mount-and-read path lists - /// what its format-and-write path put there, and `toyos-fat32` reads the - /// boot parameter off the ESP. + /// where the table placed it, and `bcachefs`'s mount-and-read path lists + /// what its format-and-write path put there. /// /// Every name, size, content hash and symlink target against the list - /// handed to [`create_root_image`] — a listing that merely parsed would - /// pass a far weaker claim — and `root=` against the superblock UUID the - /// reader found, which is the whole of how a boot picks its ROOT. + /// handed to [`create_root_image`]: a listing that merely parsed would + /// pass a far weaker claim. #[test] fn the_root_partition_reads_back_as_the_files_the_build_put_in_it() { let files: Vec<(String, Vec)> = vec![ - ("bin/supervisor".to_string(), b"supervisor-binary".to_vec()), + ("bin/shell".to_string(), b"shell-binary".to_vec()), // Multi-block, so an extent list that stopped after the first is // caught by the hash rather than by the size. ("bin/toybox".to_string(), (0..40_000u32).map(|i| (i ^ 0x5A) as u8).collect()), @@ -1301,7 +1335,7 @@ mod tests { let root_image = create_root_image(&files, &symlinks, true); let key = key(); - let disk = create_boot_image(Arch::X86_64, b"kernel", b"bootloader", &root_image, "", signing(&key), None); + let disk = create_boot_image(Arch::X86_64, b"bootloader", release(b"kernel", &root_image), "", signing(&key), None); // Located by *type*, through the parser the kernel uses, at the offset // the table gives — never at the one the writer computed. @@ -1341,32 +1375,15 @@ mod tests { .unwrap_or_else(|e| panic!("read_link {name}: {e:?}")); assert_eq!(read.as_deref(), Some(target.as_str())); } - - // And the kernel argument names *this* filesystem: the parameter comes - // off the ESP through the FAT driver, the UUID out of the superblock - // the mount above read. - let scratch = toyos_tmpdir::TempDir::new("root-oracle"); - let path = scratch.join("root-oracle.img"); - std::fs::write(&path, &disk).expect("stage the image"); - let cmdline = cmdline_of(&path).expect("the ESP carries a boot parameter"); - assert_eq!( - toyos_abi::boot::root_uuid(&cmdline), - Some(fs.uuid().to_string().as_str()), - "the boot parameter {cmdline:?} does not name the ROOT this image carries" - ); } - /// **The independent oracle for the hierarchy.** ROOT mounts at `/system`, so - /// every absolute path the shipped image declares is `/system/` plus a name - /// ROOT carries, the two the kernel opens by hard-coded path included. Asked - /// of a finished image by readers that did not write it: `toyos-gpt` finds - /// ROOT by type where the table says, `bcachefs` mounts it, and - /// `toyos-manifest` parses the records out of the volume. - /// - /// `SUPERVISOR_PATH` comes from the kernel source, and that is a **text scan of - /// one spelling** — the `pub const SUPERVISOR_PATH: &str = "…";` line — so a - /// kernel computing its spawn path otherwise is walked past. Not finding - /// the line fails here rather than skipping. + /// **The independent oracle for the hierarchy.** ROOT is served at + /// `/system`, so every absolute path the shipped image declares is + /// `/system/` plus a name ROOT carries, but the file server's, whose image + /// is the slot's and is named by the label it is handed under. Asked of a + /// finished image by readers that did not write it: `toyos-gpt` finds ROOT + /// by type where the table says, `bcachefs` mounts it, and `toyos-manifest` + /// parses the records out of the volume. #[test] fn every_declared_path_resolves_inside_the_root_partition() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -1374,26 +1391,22 @@ mod tests { let declared = toyos_manifest::parse( std::str::from_utf8(&manifest).expect("the manifest this build renders is text"), ); + let on_root: Vec<&toyos_manifest::Program> = + declared.programs.iter().filter(|p| p.path != toyos_abi::syscall::FILESERVER_IMAGE_LABEL).collect(); + assert_eq!( + declared.programs.len() - on_root.len(), + 1, + "the shipped image declares one file server, named by its image's label" + ); // Placeholders: what this judges is the path scheme, and the bytes of a // real binary say nothing about it. - let mut files: Vec<(String, Vec)> = declared - .programs - .iter() - .map(|p| (under_system(&p.path).to_string(), b"placeholder".to_vec())) - .collect(); - files.push(("bin/supervisor".to_string(), b"supervisor".to_vec())); + let mut files: Vec<(String, Vec)> = + on_root.iter().map(|p| (under_system(&p.path).to_string(), b"placeholder".to_vec())).collect(); files.push((toyos_manifest::PATH.to_string(), manifest.clone())); - let disk = create_boot_image( - Arch::X86_64, - b"kernel", - b"bootloader", - &create_root_image(&files, &symlinks, true), - "", - signing(&key()), - None, - ); + let root_image = create_root_image(&files, &symlinks, true); + let disk = create_boot_image(Arch::X86_64, b"bootloader", release(b"kernel", &root_image), "", signing(&key()), None); let part = only_partition(&mut ImageSectors(&disk), toyos_gpt::Guid::TOYOS_ROOT) .expect("a boot image carries exactly one TOYOS-ROOT partition"); @@ -1405,45 +1418,31 @@ mod tests { .expect("the ROOT partition mounts"); // As the volume holds it, not as the renderer returned it. - let on_root = fs + let on_volume = fs .read_file(toyos_manifest::PATH) .expect("ROOT carries the manifest where the guest path names it"); - assert_eq!(on_root, manifest, "the manifest on ROOT is not the one the build rendered"); + assert_eq!(on_volume, manifest, "the manifest on ROOT is not the one the build rendered"); assert_eq!( toyos_manifest::GUEST_PATH, format!("/system/{}", toyos_manifest::PATH), "the path a process opens is not where ROOT carries the manifest" ); - let supervisor = supervisor_path_of_the_kernel(root); let names: std::collections::BTreeSet = fs .list(usize::MAX, &|_| true) .expect("list the ROOT partition") .into_iter() .map(|(name, _)| name) .collect(); - for path in std::iter::once(&supervisor).chain(declared.programs.iter().map(|p| &p.path)) { + for path in on_root.iter().map(|p| &p.path) { let name = under_system(path); assert_ne!(name, path.as_str(), "{path} is not under the mount ROOT gets"); assert!(names.contains(name), "ROOT has no {name} for the declared path {path}"); } - assert!(names.contains("bin/supervisor"), "ROOT carries {names:?} and no bin/supervisor"); } /// A declared absolute path as ROOT carries it; unchanged off that mount. fn under_system(path: &str) -> &str { path.strip_prefix("/system/").unwrap_or(path) } - - /// The literal in `kernel/src/loader/mod.rs`'s `SUPERVISOR_PATH`. - fn supervisor_path_of_the_kernel(root: &Path) -> String { - const ITEM: &str = "pub const SUPERVISOR_PATH: &str = \""; - let source = std::fs::read_to_string(root.join("kernel/src/loader/mod.rs")) - .expect("kernel/src/loader/mod.rs"); - source - .lines() - .find_map(|line| line.trim_start().strip_prefix(ITEM)?.split('"').next()) - .expect("no `pub const SUPERVISOR_PATH: &str = \"…\";` in kernel/src/loader/mod.rs") - .to_string() - } } diff --git a/build/signing.rs b/build/signing.rs index 0fd2b9c442..91a9316fd2 100644 --- a/build/signing.rs +++ b/build/signing.rs @@ -331,7 +331,10 @@ mod tests { let key = Key::mint(); let text = openssh_text(&key.seed); assert_eq!(toyos_ssh::hostkey::openssh_seed(&text), Ok(key.seed)); - let header = Header::of(3, b"k", b"c", &[0; 4096]); + let header = Header::of( + 3, + &toyos_update::image::Sections { kernel: b"k", cmdline: b"c", supervisor: b"s", fileserver: b"f", root: &[0; 4096] }, + ); let signed = key.sign(&header); let sig: [u8; 64] = signed[HEADER_BYTES..].try_into().unwrap(); let bytes: [u8; HEADER_BYTES] = signed[..HEADER_BYTES].try_into().unwrap(); diff --git a/images/diag.toml b/images/diag.toml index 05f9a36a35..77c5c79520 100644 --- a/images/diag.toml +++ b/images/diag.toml @@ -7,7 +7,7 @@ # claimed it tens of milliseconds after the last checkpoint, so every line the # kernel logged was on screen for about a tenth of a second and then gone. # -# `/system/bin/supervisor` is in every image and holds the one `SysCap`, so the +# The supervisor is in every image and holds the one `SysCap`, so the # guarantee is that this config declares no `devices`, which # `no_diag_program_claims_the_screen` checks. [boot] diff --git a/images/system.toml b/images/system.toml index 9b192900b0..7936a240b1 100644 --- a/images/system.toml +++ b/images/system.toml @@ -11,7 +11,7 @@ # day delete it. assets = ["assets"] -# What `/system/bin/supervisor` starts at boot. Program keys, not paths, and the order means +# What the supervisor starts at boot. Program keys, not paths, and the order means # nothing: every port exists before any server runs. [boot] # `filepicker` is a `serves` daemon like the three beside it: a machine-wide diff --git a/issues/every-program-sees-only-the-files-it-was-given.md b/issues/every-program-sees-only-the-files-it-was-given.md index fa324aa290..323a29cb35 100644 --- a/issues/every-program-sees-only-the-files-it-was-given.md +++ b/issues/every-program-sees-only-the-files-it-was-given.md @@ -98,9 +98,10 @@ nameable by every program and make confused-deputy bugs structural. access that the file server enforces on every request that would change what it holds, and a package launched from `/apps` is minted its own directory read-only and its own folder of the session's home read-write, - and nothing else a file server serves (`toyos_manifest::Program::view`). - It still reaches `/tmp` and `/system`, which the kernel serves to every - program. Every row the image declares still sees the whole tree + `/system` read-only, and nothing else a file server serves + (`toyos_manifest::Program::view`). It still reaches `/tmp`, which the + kernel serves to every program. Every row the image declares still sees + the whole tree, `/system` read-only and the rest read-write, `/apps` included, so a shell and everything it starts can rewrite an installed package; whether the installer alone writes `/apps` is `issues/whether-pkg-alone-writes-apps.md`. diff --git a/issues/root-in-memory-costs-its-whole-size.md b/issues/root-in-memory-costs-its-whole-size.md index d7ce767fa3..80390d6757 100644 --- a/issues/root-in-memory-costs-its-whole-size.md +++ b/issues/root-in-memory-costs-its-whole-size.md @@ -8,7 +8,8 @@ opened: 2026-09-25 The loader reads ROOT's filesystem whole, the extent its superblock states (`loader/src/rootimage.rs`), and the kernel keeps it for the machine's -life. `Superblock::check` holds that extent equal to the partition, and +life, read-only, for the supervisor's SYSTEM file server to serve +(`kernel/src/bootimage.rs`, `system/fileserver/src/root.rs`). `Superblock::check` holds that extent equal to the partition, and `build/image.rs` builds the volume at the blocks its contents use, rounded up to the 1 MiB partition alignment, so what is read and kept is what ROOT carries. The cost is the payload. @@ -21,10 +22,18 @@ in turn, each carrying at most `SHARED_BOOT_BYTES` (64 MiB) of test binaries; carries `tests/images/testcases.toml`'s own programs, 13 MiB (3328 blocks), where every test boot carried the whole 619 MiB catalogue before. -**Every resident `/system` page is in RAM twice.** `ReadOnlyBacking::read_page` -(`kernel/src/file_backing.rs`) copies each page a program touches out of the -image into a file-cache page, so the image's copy and the cache's are both -held. +**A running `/system` program's pages are in RAM three times.** Its spawn +reads the whole binary through SYSTEM into a memory object of the spawner's +(`toyos::process::Image`, rounded up to 2 MiB), which lives as long as the +child pages from it, and `SharedImage::read_page` +(`kernel/src/file_backing.rs`) copies each page the child touches out of that +object: ROOT's copy, the image's and the page's are all held. Before ROOT +left the kernel, the kernel paged a program from ROOT and the image's copy was +not there. Measured on QEMU's x86-64 machine booting +`tests/images/testcases.toml`, toybox's `free` after the boot: 330 MiB in use +at `b9e4c5862`, 384 MiB once ROOT left the kernel, one boot each, the same +session; the 54 MiB are the running services' images, each rounded up to +2 MiB, and the SYSTEM file server. **On the dev host the fast tier's peak host memory is now below `main`'s.** An alternating A/B of `cargo test` against `origin/main` at `b0adc600`, three arms @@ -46,6 +55,6 @@ complete K ms`; a test boot on QEMU reads 1390, 108 (14) and 344. Not measured: the T14. What that run owes is that line off the stick and off NVMe, against the base's `Boot: complete`. -Exit: either measured and accepted by the owner as the price, or the file -cache serving the image's own page rather than a copy, before the self-update's signature check makes the whole -read a fixed cost. +Exit: either measured and accepted by the owner as the price, or a program's +pages mapped from ROOT's own read-only pages rather than copied, before the +self-update's signature check makes the whole read a fixed cost. diff --git a/issues/storage-is-layers-and-a-role-is-a-filesystem.md b/issues/storage-is-layers-and-a-role-is-a-filesystem.md index 8d58c06fba..b3d3f1c45b 100644 --- a/issues/storage-is-layers-and-a-role-is-a-filesystem.md +++ b/issues/storage-is-layers-and-a-role-is-a-filesystem.md @@ -10,8 +10,10 @@ What the installed product's disks look like, from the block device up to a path. **Where each layer runs is the small-kernel track's** (`issues/the-kernel-is-small-interrupts-post-and-threads-wait.md`, stages 3 and 4, owner ruling 2026-09-26): block services and file servers are -userland programs, the VFS is a client library, and the kernel keeps ROOT's -in-memory read path and nothing else of storage. The plan this track carried +userland programs and the VFS is a client library. The kernel keeps no +filesystem of ROOT: owner ruling 2026-10-21, which superseded the in-memory read +path the 2026-09-26 ruling kept, hands ROOT's bytes to the supervisor and its +file server serves them (Filesystem, below). The plan this track carried before that ruling — a kernel VFS forwarding to userland servers through a mount protocol — is superseded, and nothing here builds it. @@ -45,7 +47,7 @@ volume, handed to whichever server recognises its superblock. | partition | filesystem | mounts as | writable | |---|---|---|---| | ESP | FAT32, firmware's rule | `/boot` | kernel only | -| ROOT | bcachefs image the build writes | `/system` | no; versioned per release | +| ROOT | bcachefs image the build writes | `/system` | no; signed per release | | DATA | bcachefs, formatted on first boot | `/apps`, `/config`, `/home`, `/state` | yes | | LOG | FAT32 while a Mac has to read the dev stick | `/log` | yes | @@ -55,18 +57,20 @@ Roles come from partitions, so both shapes are the same code. ## Filesystem -**The kernel reads ROOT and nothing more.** The loader reads the selected -slot's ROOT into memory and the kernel mounts it read-only with bcachefs's read -half; no code that changes a disk is in the kernel. `/system` is immutable by -design: the installer and the updater write it offline, checkpoint it and mark -the journal clean; a kernel that finds a dirty journal under `/system` refuses -by name, because replay is a write-path job and a system image needing one is a -broken install. - -**Every other filesystem is a file server per role** — LOG, DATA, BOOT — over -its block service's sessions, linking the whole crate it serves: a server that -crashes loses its own role and nothing else, and no foreign parser runs in the -kernel on bytes another OS wrote. +**ROOT is opaque to the kernel.** The loader reads the selected slot's ROOT, +the supervisor and the file server into memory, each a section of the slot's +signed header, and the kernel keeps their pages read-only, starts the +supervisor from its image and hands it the other two. The supervisor starts the +file server's SYSTEM role from its image, serving ROOT read-only out of that +memory, before it reads a file. `/system` is immutable by design: the installer +and the updater write it offline, checkpoint it and mark the journal clean; a +server that finds a dirty journal under `/system` refuses by name, because +replay is a write-path job and a system image needing one is a broken install. + +**Every filesystem is a file server per role** — SYSTEM, LOG, DATA, BOOT — +SYSTEM over ROOT's bytes in memory and every other over its block service's +sessions, linking the whole crate it serves: a server that crashes loses its +own role and nothing else, and no filesystem parser runs in the kernel. **Multi-device is a filesystem property, not a block-layer subsystem.** Pooling across disks, replicas per subvolume, tiering with a fast foreground device, @@ -124,8 +128,9 @@ what is left here is built on them. 5. Multi-device, replicas, tiering, snapshots, as the bcachefs crate grows into them. 6. A full secure boot chain, the end state of `/system`'s immutability: - firmware verifies the bootloader, the bootloader the kernel, the kernel the - ROOT image it mounts, and a link that fails is refused by name. + firmware verifies the bootloader, the bootloader every section of the slot + it boots (built: the signed header binds the kernel, the two boot programs + and ROOT), and a link that fails is refused by name. 7. NTFS read-only, postponed here by the owner. LOG stays its own FAT32 partition through the dev phase, because a Mac has to diff --git a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md index b5642a446a..d02d34e6ab 100644 --- a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md +++ b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md @@ -19,7 +19,8 @@ kernel's part of a restart, and named module tests hold each program's 1. **Step 9's file half** (stage 4). libc is a file server's client through `toyos::files`, as std is. 2. **Step 8's exit** (stage 4). Its empty view needs step 9's file half: - until then the kernel serves `/system` and `/tmp` to every process. + until then the kernel serves `/tmp` to every process, and every view holds + `/system` read-only. 3. **P1 of the Wi-Fi track** (`issues/there-is-no-wifi.md`): a BAR stays where firmware put it, handed over in 4 KiB pages with its MSI-X table's page, under the owner's Q2 ruling. The orchestrator ruled on #900 that @@ -41,8 +42,9 @@ Beside that order, each waiting on something outside it: 1. **The system image is loaded into memory by the loader.** The UEFI loader reads the selected slot's ROOT through the firmware's own block I/O, checks its signature on the bytes in memory (the self-update's signing, so one - check covers both), and hands the kernel its address and length. The kernel - mounts ROOT from memory and needs no storage driver to boot. + check covers both), and hands the kernel its address and length. No storage + driver is needed to boot: the supervisor's file server serves ROOT out of + that memory (owner ruling 2026-10-21, step 9). **Exit**: the machine boots in QEMU and on the T14 with the kernel's NVMe and USB storage paths never touched before the supervisor runs. The boot-time cost on the stick and on NVMe is measured. @@ -83,8 +85,9 @@ Beside that order, each waiting on something outside it: file server per role (LOG, DATA, BOOT), so one crashing cannot take another down. A file-server crash is survivable, not invisible: data survives on disk, clients reopen, and the few states that cannot be recovered answer - `Gone`; invisible restart can come later. The kernel keeps ROOT's - in-memory read path and exec from it. USB storage stays in the kernel until + `Gone`; invisible restart can come later. The kernel keeps no filesystem + of ROOT (owner ruling 2026-10-21, step 9, which replaced this ruling's + in-memory read path and exec from it). USB storage stays in the kernel until stage 5 moves the whole xHCI out, because its one IOMMU domain is shared with the keyboard, and the panic console never depends on a userland USB program. Ctrl+Alt+D is removed (owner, 2026-10-10: "i dont want it. i want @@ -151,22 +154,28 @@ Beside that order, each waiting on something outside it: one port, and `fs_escape` is refused a symlink that climbs out, a `..` on the wire and a directory it was not given. **Left**: the rest of the escape suite, a rename racing a lookup among it, shown red on a - global-tree resolver; and the empty view, which names `/system` and - `/tmp` while the kernel serves them (step 9's file half). + global-tree resolver; and the empty view, which names `/tmp` while the + kernel serves it (step 9's file half). 9. **Delete the kernel storage stack.** **Partly done** (#536). Two halves are left, and they land apart: - - **The file half**: the VFS down to ROOT's resolver, tmpfs (`tmpfs.rs`, - `fs_rename.rs`), the page cache (`file_cache.rs`) and the file - syscalls. `/tmp` leaves the kernel per session, as the owner ruled - ("Per session", 2026-10-03, in the isolation track), and `/system` is - read over the file protocol, which std and libc both reach through - `toyos::files`. **Exit**: `tmpfs.rs`, - `fs_rename.rs` and `file_cache.rs` are gone, `vfs.rs` resolves ROOT - for exec and `SYS_DLOPEN` alone, and `sdk/abi` has no `SYS_OPEN` (9), + - **The file half**: the VFS, tmpfs (`tmpfs.rs`, `fs_rename.rs`), the + page cache (`file_cache.rs`) and the file syscalls. `/tmp` leaves the + kernel per session, as the owner ruled ("Per session", 2026-10-03, in + the isolation track). **`/system` is read over the file protocol**, + which std and libc both reach through `toyos::files` (owner ruling + 2026-10-21, "Removing the boot filesystem exception"): + the loader hands the kernel the supervisor's and the file server's + executables beside ROOT, each a section of the slot's signed header; + the kernel keeps their pages read-only, starts the supervisor from its + image and hands it the other two, and the supervisor starts the file + server's SYSTEM role from its image, serving ROOT out of that memory, + before it reads the manifest. The kernel's ROOT mount, its bcachefs + adapter and its spawn by `/system` path are deleted. **Exit**: + `tmpfs.rs`, `fs_rename.rs`, `file_cache.rs` and `vfs.rs` are gone, and + `sdk/abi` has no `SYS_OPEN` (9), `SYS_STAT` (16), `SYS_READDIR` (17), `SYS_DELETE` (18), `SYS_CHDIR` (20), `SYS_GETCWD` (21), `SYS_RENAME` (52), `SYS_MKDIR` (53), `SYS_RMDIR` (54), `SYS_SYMLINK` (81) or `SYS_READLINK` (82). - `SYS_DLOPEN` (55) is step 5's. - **The block half**, with step 10: the block layer (`block.rs`), GPT (`gpt.rs`), USB storage (`drivers/usb_storage.rs`), the refusal chain and `SYS_PARTITION_READ/WRITE`. **Exit**: `git grep -n -e diff --git a/issues/the-kernel-still-parses-what-userland-writes.md b/issues/the-kernel-still-parses-what-userland-writes.md index f95d7df37b..b67e042543 100644 --- a/issues/the-kernel-still-parses-what-userland-writes.md +++ b/issues/the-kernel-still-parses-what-userland-writes.md @@ -53,8 +53,9 @@ before that day. Independent of everything else; may run as soon as a slot frees **Move 2 — filesystem daemons**, sequenced after the completion architecture. A crafted image attacks the kernel rather than a sandboxed daemon. The FS daemon needs the blocking story to be efficient, and the boot path needs its story told -first: the kernel mounts ROOT itself, but what mounts `/boot` and `/home`, and -with what authority, is the open question. +first. Told (owner ruling 2026-10-21): the kernel parses no filesystem of +ROOT; the supervisor's file server serves it out of the memory the loader +verified, and every other role is a file server too. **Move 3 — the panic-time symbol resolver**, small and independent. Ruled (owner, 2026-10-03, `issues/toyos-explains-itself.md`): he chose diff --git a/issues/the-loader-parses-roots-filesystem-to-find-microcode.md b/issues/the-loader-parses-roots-filesystem-to-find-microcode.md new file mode 100644 index 0000000000..06c723e9d6 --- /dev/null +++ b/issues/the-loader-parses-roots-filesystem-to-find-microcode.md @@ -0,0 +1,24 @@ +--- +status: open +kind: finding +opened: 2026-10-22 +--- + +# The loader parses ROOT's filesystem to find the microcode patch + +The kernel parses no filesystem of ROOT: ROOT is an opaque signed section the +supervisor's file server serves. The loader still does. `loader/src/microcode.rs` +mounts ROOT read-only with the `bcachefs` crate after the slot's header has +verified it, and reads the patch `toyos_microcode` names for this CPU out of +`/system/share/microcode/`, so a filesystem reader of ROOT's whole format runs +in the loader on every x86-64 boot. It is outside the kernel scope of the +owner's 2026-10-21 ruling and was left as it is. + +Owner: the orchestrator. + +The candidate exit: the image's patches as a signed boot section of their own +beside the kernel and the two boot programs (`toyos_update::image`), which the +loader picks this CPU's patch out of without reading ROOT, so ROOT is opaque to +every boot stage. It has to be weighed against +`issues/a-new-microcode-patch-reaches-a-machine-only-in-a-new-image.md`, whose +exit delivers a new patch as a file on ROOT. diff --git a/issues/the-machine-updates-itself-without-ubuntu.md b/issues/the-machine-updates-itself-without-ubuntu.md index aebe610eb4..541ad817d3 100644 --- a/issues/the-machine-updates-itself-without-ubuntu.md +++ b/issues/the-machine-updates-itself-without-ubuntu.md @@ -15,11 +15,13 @@ stdin — and the machine installs nothing the owner did not sign. ## Stage 1 — slots, signatures and `update`, proven in QEMU (done) - **A signed image** (`toyos-update`): a header naming a monotonic version and - the SHA-256 of the kernel, its boot parameter and ROOT, signed with Ed25519 + the SHA-256 of the kernel, its boot parameter, the supervisor, the file server + and ROOT, each by role and length, signed with Ed25519 over OpenSSH's SSHSIG message in the `toyos-image` namespace — so `ssh-keygen -Y sign` signs one and `ssh-keygen -Y verify` checks ours. The signature covers hashes, never bytes. -- **Two slots**, each a FAT volume (kernel, parameter, signed header) and a +- **Two slots**, each a FAT volume (kernel, parameter, the two boot programs, + signed header) and a ROOT, named by a slot table (two copies, a sequence, the writer writes the copy that is not current). Every image carries slot A; an image built for a machine carries an empty slot B. diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml index 214b26d1c5..0b5e894190 100644 --- a/kernel/Cargo.toml +++ b/kernel/Cargo.toml @@ -499,7 +499,6 @@ toyos-abi = { path = "../sdk/abi" } # The binary's alone, so a host build of the library compiles none of them. [target.'cfg(target_os = "none")'.dependencies] -bcachefs = { path = "../lib/bcachefs", default-features = false } toyos-acpi = { path = "../lib/acpi" } toyos-amdvi = { path = "amdvi" } toyos-dma = { path = "dma" } diff --git a/kernel/src/actuator.rs b/kernel/src/actuator.rs index a6c2324be7..c4c79a0b14 100644 --- a/kernel/src/actuator.rs +++ b/kernel/src/actuator.rs @@ -218,7 +218,7 @@ static ARMED: [AtomicU64; ARM_WORDS] = [const { AtomicU64::new(0) }; ARM_WORDS]; #[cfg(feature = "boot-actuators")] pub fn init(cmdline: &str) { let mut armed = [0u64; ARM_WORDS]; - for token in toyos_abi::boot::actuators(cmdline).filter(|t| !crate::params::claims(t)) { + for token in toyos_abi::boot::tokens(cmdline).filter(|t| !crate::params::claims(t)) { arm(&mut armed, token); } for (name, implied) in IMPLIES { @@ -257,7 +257,7 @@ const fn at(index: usize) -> (usize, u64) { /// Refuses any actuator: a kernel with none must not boot looking like one that was given none. #[cfg(not(feature = "boot-actuators"))] pub fn init(cmdline: &str) { - let mut named = toyos_abi::boot::actuators(cmdline).filter(|t| !crate::params::claims(t)); + let mut named = toyos_abi::boot::tokens(cmdline).filter(|t| !crate::params::claims(t)); assert!( named.next().is_none(), "this kernel carries no actuators and was handed the boot parameter {cmdline:?}" diff --git a/kernel/src/bcachefs_adapter.rs b/kernel/src/bcachefs_adapter.rs deleted file mode 100644 index 48d1ce3017..0000000000 --- a/kernel/src/bcachefs_adapter.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! ROOT as the VFS reads it: the signed image, a read-only bcachefs volume in -//! memory. The only filesystem this kernel mounts; every writable one is a -//! file server's (`/system/bin/fileserver`). - -use alloc::collections::BTreeMap; -use alloc::string::String; -use alloc::sync::Arc; -use alloc::vec::Vec; - -use bcachefs::{FsError, Mounted, ReadOnly}; -use crate::file_backing::{FileBacking, ReadOnlyBacking}; -use crate::file_cache::{self, FileId}; -use crate::rootfs::MemoryImage; -use toyos_abi::syscall::SyscallError; - -use crate::vfs::FileSystem; - -/// Exhaustive match: corruption maps to `Io`, never `NotFound` — a btree that won't decode isn't "not there". -fn as_syscall_error(err: &FsError) -> SyscallError { - match err { - FsError::NotFound => SyscallError::NotFound, - FsError::NoSpace { .. } | FsError::EntryTooLarge { .. } | FsError::ListTooLong { .. } => { - SyscallError::ResourceExhausted - } - FsError::NameTooLong { .. } => SyscallError::InvalidArgument, - FsError::DeviceRead(..) - | FsError::DeviceWrite(..) - | FsError::DeviceSync(_) - | FsError::BadMagic { .. } - | FsError::UnsupportedVersion(_) - | FsError::ChecksumMismatch { .. } - | FsError::CorruptedKey(_) - | FsError::CorruptedNode(_) - | FsError::BlockOffDevice { .. } - | FsError::NotEnoughBlocks { .. } - | FsError::TreeTooDeep(_) - | FsError::BadSuperblock { .. } - | FsError::NodeOverfull { .. } - | FsError::TargetTooLong { .. } => SyscallError::Io, - } -} - -/// Ceiling on the one allocation `read_link` materialises from a disk-declared -/// size: the kernel's heap ceiling, which `bcachefs` cannot know and the -/// volume bound does not reach. -const MAX_LINK_TARGET: u64 = crate::user_ptr::MAX_USER_STR; -const _: () = assert!(MAX_LINK_TARGET <= crate::mm::MAX_HEAP_ALLOC as u64); - -/// Logs the error's detail, then maps it to the `SyscallError` a caller can act on. -fn mapped(op: &str, name: &str, result: Result) -> Result { - result.map_err(|err| { - log!("bcachefs: {} of '{}' failed: {:?}", op, name, err); - as_syscall_error(&err) - }) -} - -/// The same, for a `bcachefs` call whose `Ok(None)` means the name is absent. -fn present(op: &str, name: &str, result: Result, FsError>) -> Result { - mapped(op, name, result)?.ok_or(SyscallError::NotFound) -} - - -/// What a name on ROOT is numbered by, once asked. -#[derive(Clone, Copy)] -enum Node { - /// A file: the `FileId` every open of it answers. - File(FileId), - /// A symlink: a number `file_cache::draw_node` drew, which no file holds. - Symlink(u64), -} - -/// VFS adapter for ROOT, a read-only bcachefs volume in memory. -/// -/// Every backing it hands out reads the image the mount was opened over, so a -/// file cannot be served off bytes its filesystem does not occupy. -pub struct ReadOnlyBcacheFsAdapter { - fs: Mounted, - /// Every name's node once asked. The volume never changes, so a name is - /// one entry for the boot. - nodes: BTreeMap, -} - -impl ReadOnlyBcacheFsAdapter { - pub fn new(fs: Mounted) -> Self { - Self { fs, nodes: BTreeMap::new() } - } - - /// The `FileId` of the file `name`, `size` bytes long, made with no - /// reference held when this is its first ask; `Io` for a name the volume - /// answered as a symlink before and as a file now, which only a corrupt - /// image can. - fn file_id(&mut self, name: &str, size: u64) -> Result { - match self.nodes.get(name) { - Some(&Node::File(file_id)) => return Ok(file_id), - Some(&Node::Symlink(_)) => { - log!("bcachefs: '{}' has extents and was a symlink", name); - return Err(SyscallError::Io); - } - None => {} - } - // Its mtime is the volume's (`file_mtime`), never the cache's. - let file_id = file_cache::create_file(true, 0); - file_cache::set_size(file_id, size); - file_cache::release(file_id); - self.nodes.insert(String::from(name), Node::File(file_id)); - Ok(file_id) - } -} - -impl FileSystem for ReadOnlyBcacheFsAdapter { - fn list(&mut self, dir: &str, limit: usize) -> Result, SyscallError> { - let listed = mapped("list", dir, self.fs.list(limit, &|name| crate::vfs::under_directory(name, dir)))?; - Ok(listed.into_iter().map(|(name, _)| name).collect()) - } - - fn node(&mut self, name: &str) -> Result { - match self.nodes.get(name) { - Some(&(Node::File(node) | Node::Symlink(node))) => return Ok(node), - None => {} - } - if mapped("is_symlink", name, self.fs.is_symlink(name))? { - let node = file_cache::draw_node(); - self.nodes.insert(String::from(name), Node::Symlink(node)); - return Ok(node); - } - let (_, size) = present("node", name, self.fs.file_extents(name))?; - self.file_id(name, size) - } - - fn is_dir(&mut self, dir: &str) -> Result { - mapped("is_dir", dir, self.fs.is_dir(dir)) - } - - fn file_mtime(&mut self, name: &str) -> Result { - present("file_mtime", name, self.fs.file_mtime(name)) - } - - fn read_link(&mut self, name: &str) -> Result, SyscallError> { - mapped("read_link", name, self.fs.read_link(name, MAX_LINK_TARGET)) - } - - fn open_file(&mut self, name: &str) -> Result<(FileId, Option>), SyscallError> { - let (extents, size) = present("open", name, self.fs.file_extents(name))?; - let file_id = self.file_id(name, size)?; - file_cache::open(file_id).commit(); - let backing = Arc::new(ReadOnlyBacking::new(*self.fs.io(), extents, size)); - Ok((file_id, Some(backing))) - } - - fn create(&mut self, _name: &str, _mtime: u64) -> Result { - Err(SyscallError::PermissionDenied) - } - - fn delete(&mut self, _name: &str) -> Result<(), SyscallError> { - Err(SyscallError::PermissionDenied) - } - - fn rename(&mut self, _old: &str, _new: &str) -> Result<(), SyscallError> { - Err(SyscallError::PermissionDenied) - } - - // `NotSupported`, not a refusal: no directory representation here, so the - // VFS carries created directories itself. - fn create_dir(&mut self, _name: &str) -> Result<(), SyscallError> { - Err(SyscallError::NotSupported) - } - - fn remove_dir(&mut self, _name: &str) -> Result<(), SyscallError> { - Err(SyscallError::NotSupported) - } - - fn create_symlink(&mut self, _name: &str, _target: &str) -> Result<(), SyscallError> { - Err(SyscallError::PermissionDenied) - } - - fn open_backing(&mut self, name: &str) -> Result, SyscallError> { - let (extents, size) = present("open_backing", name, self.fs.file_extents(name))?; - Ok(Arc::new(ReadOnlyBacking::new(*self.fs.io(), extents, size))) - } -} - diff --git a/kernel/src/bootimage.rs b/kernel/src/bootimage.rs new file mode 100644 index 0000000000..6044571000 --- /dev/null +++ b/kernel/src/bootimage.rs @@ -0,0 +1,103 @@ +//! The payloads the loader hands the kernel beside its own ELF: the +//! supervisor's executable, the file server's and ROOT's bytes +//! (`toyos_abi::boot::BootImage`). The kernel parses none of them but the +//! supervisor's program headers, which it spawns the supervisor from; the +//! other two it hands the supervisor, whose file server serves `/system` out +//! of ROOT. +//! +//! [`init`] runs before `mm::init` and refuses the boot by name unless each +//! image is non-empty, page-aligned and whole pages inside one `LoaderData` +//! descriptor; `kernel_main` then refuses any two reserved regions that share +//! a byte. The loader is trusted and verified each image's hash, so a refusal +//! here is its bug, not an input to survive. The regions it returns are kept +//! out of the allocator and charged to no process, for the machine's life. +//! +//! [`objects`] makes each one a [`SharedMemObject`] over its pages +//! ([`Memory::Boot`]), which every mapping maps read-only, so no holder writes +//! it whatever rights its handle carries. + +use alloc::sync::Arc; + +use toyos_abi::boot::{BootImage, KernelArgs, MemoryMapEntry}; +use toyos_rootimage::handoff::{held, Descriptor}; + +use crate::mm::policy::CachePolicy; +use crate::mm::{DirectMap, Region, PAGE_SIZE}; +use crate::object::shm::{Memory, SharedMemObject}; +use crate::sync::Lock; + +/// The three images, as the loader handed them: kept from [`init`] to +/// [`objects`]. +#[derive(Clone, Copy)] +struct Handed { + supervisor: BootImage, + fileserver: BootImage, + root: BootImage, +} + +static HANDED: Lock> = Lock::new(None); + +/// Every image in the order [`init`] returns their regions, by name. +fn named(handed: &Handed) -> [(&'static str, BootImage); 3] { + [("supervisor", handed.supervisor), ("fileserver", handed.fileserver), ("ROOT", handed.root)] +} + +/// The pages `image` occupies. +fn pages(image: BootImage) -> Region { + Region { start: image.addr, end: image.addr + image.len.next_multiple_of(PAGE_SIZE) } +} + +/// Take the images out of the handoff, refusing the boot by name on one the +/// kernel may not keep, and answer their regions for `mm::init` to withhold. +/// Runs before `mm::init`, so it neither allocates nor logs. +pub fn init(args: &KernelArgs, map: &[MemoryMapEntry]) -> [Region; 3] { + let handed = Handed { supervisor: args.supervisor_image, fileserver: args.fileserver_image, root: args.root_image }; + for (what, image) in named(&handed) { + assert!(image.len != 0, "boot: the loader handed no {what} image"); + let rounded = image.len.checked_next_multiple_of(PAGE_SIZE); + let kept = rounded.and_then(|len| { + held( + map.iter().map(|e| Descriptor { ty: e.uefi_type, start: e.start, end: e.end }), + toyos_bootmap::EFI_LOADER_DATA, + image.addr, + len, + PAGE_SIZE, + ) + }); + assert!( + kept.is_some(), + "boot: the {what} image at {:#x}+{:#x} is not whole {PAGE_SIZE}-byte pages inside one LoaderData \ + descriptor", + image.addr, + image.len + ); + } + *HANDED.lock() = Some(handed); + named(&handed).map(|(_, image)| pages(image)) +} + +/// The objects the supervisor is spawned from and handed. +pub struct Objects { + pub supervisor: Arc, + pub fileserver: Arc, + pub root: Arc, +} + +/// One object over each image, once `mm::init` has run: called once, by the +/// supervisor's spawn. +pub fn objects() -> Objects { + let handed = HANDED.lock().take().expect("boot: the images are taken once, after `init`"); + let object = |image: BootImage| { + let region = pages(image); + SharedMemObject::over(crate::object::shm::Region { + phys: DirectMap::from_phys(region.start), + size: region.end - region.start, + cache: CachePolicy::Normal, + memory: Memory::Boot { len: image.len }, + }) + }; + for (what, image) in named(&handed) { + log!("boot: {what} image {:#x}+{:#x}, kept read-only for the machine's life", image.addr, image.len); + } + Objects { supervisor: object(handed.supervisor), fileserver: object(handed.fileserver), root: object(handed.root) } +} diff --git a/kernel/src/drivers/gop.rs b/kernel/src/drivers/gop.rs index a733cff43c..7a7a383a2a 100644 --- a/kernel/src/drivers/gop.rs +++ b/kernel/src/drivers/gop.rs @@ -42,7 +42,7 @@ pub fn init( phys: DirectMap::from_phys(addr), size: aligned_size, cache: CachePolicy::WriteCombining, - pages: None, + memory: crate::object::shm::Memory::Foreign, }; log!("GOP: {}x{} stride={} format={} at {:#x}", width, height, stride, pixel_format, addr); diff --git a/kernel/src/file_backing.rs b/kernel/src/file_backing.rs index c6926bb081..8dad2a7fc4 100644 --- a/kernel/src/file_backing.rs +++ b/kernel/src/file_backing.rs @@ -1,14 +1,10 @@ use alloc::sync::Arc; -use alloc::vec::Vec; -use bcachefs::Extent; use crate::object::shm::SharedMemObject; -use crate::rootfs::MemoryImage; use toyos_abi::syscall::SyscallError; /// `mm::PAGE_SIZE`: `usize` for buffer sizing, `u64` for file offsets. const BLOCK_SIZE: usize = crate::mm::PAGE_SIZE as usize; -const BLOCK_SIZE_U64: u64 = crate::mm::PAGE_SIZE; /// Why a page a backing holds could not be read. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -17,17 +13,10 @@ pub enum BlockError { Device, } -/// Every failure here was attempted: the bcachefs crate retries none. -impl bcachefs::TransferError for BlockError { - fn refused_before_attempt(&self) -> bool { - false - } -} - pub type BlockResult = Result<(), BlockError>; -/// Backing store for a memory-mapped file: ROOT's image, an image a caller -/// handed over, or a tmpfs file; callers do not know which. +/// Backing store for a memory-mapped file: an image a caller handed over, or a +/// tmpfs file; callers do not know which. pub trait FileBacking: Send + Sync { /// Reads one page of file data at `file_offset` into `buf`, zero-filling past EOF. #[must_use = "a failed read left the buffer zeroed; it does not hold the file's bytes"] @@ -37,68 +26,10 @@ pub trait FileBacking: Send + Sync { fn file_size(&self) -> u64; } -/// The block holding `file_offset`, if the extents reach that far. -fn offset_to_block(extents: &[Extent], file_offset: u64) -> Option { - let block_idx = file_offset / BLOCK_SIZE_U64; - let mut cursor = 0u64; - for ext in extents { - let count = ext.block_count as u64; - if block_idx < cursor + count { - return Some(ext.start_block + (block_idx - cursor)); - } - cursor += count; - } - None -} - -/// File on ROOT, backed by a fixed extent list over the image in memory. -/// -/// No revocation cell: nothing can delete or truncate a -/// file here, so the blocks a backing was opened over stay that file's for as -/// long as it lives. A block outside the image is refused by -/// [`MemoryImage::read`], which is where every bound on a number the image -/// chose belongs. -pub struct ReadOnlyBacking { - image: MemoryImage, - extents: Vec, - size: u64, -} - -impl ReadOnlyBacking { - pub fn new(image: MemoryImage, extents: Vec, size: u64) -> Self { - Self { image, extents, size } - } -} - -impl FileBacking for ReadOnlyBacking { - fn read_page(&self, file_offset: u64, buf: &mut [u8; BLOCK_SIZE]) -> BlockResult { - buf.fill(0); - if file_offset >= self.size { - return Ok(()); - } - // Past the extent list: a hole, and zeros are the file's own bytes. - let Some(block) = offset_to_block(&self.extents, file_offset) else { - return Ok(()); - }; - let mut raw = [0u8; BLOCK_SIZE]; - // `buf` is already zeroed, so a failed read returns a hole, not stale data. - if let Err(e) = self.image.read(block, &mut raw) { - log!("root: read of block {block} failed"); - return Err(e); - } - let valid = BLOCK_SIZE.min((self.size - file_offset) as usize); - buf[..valid].copy_from_slice(&raw[..valid]); - Ok(()) - } - - fn file_size(&self) -> u64 { - self.size - } -} - -/// An executable a caller read into a shared memory object of its -/// own and handed over by handle: what a program in `/apps` is spawned and -/// paged from, since no file server's volume is the kernel's. +/// An executable in a shared memory object handed over by handle: one a +/// caller read into an object of its own, which is what every program a file +/// server holds is spawned and paged from, since no file server's volume is +/// the kernel's; or a boot image the loader verified. /// /// **Nothing is copied at the call, and every page is copied once when it is /// read.** The object is the caller's memory, charged to it and alive while @@ -114,10 +45,10 @@ pub struct SharedImage { impl SharedImage { /// The first `len` bytes of `object`. Refused unless they are ordinary - /// memory the kernel allocated — a device aperture is no program, and a - /// read of one is a device access — and the object holds them all. + /// memory — a device aperture is no program, and a read of one is a device + /// access — and the object holds them all. pub fn over(object: Arc, len: u64) -> Result { - if len > object.size() || object.ram().is_none() { + if len > object.size() || !object.is_ordinary() { return Err(SyscallError::InvalidArgument); } Ok(Self { object, size: len }) diff --git a/kernel/src/loaded.rs b/kernel/src/loaded.rs index 2cc6a4ec82..fccc75dc25 100644 --- a/kernel/src/loaded.rs +++ b/kernel/src/loaded.rs @@ -1,8 +1,8 @@ //! The partitions the loader named: the ROOT it read, the booted slot's //! volume, the log's, and the ESP firmware loaded it from (`Role::Boot`). -//! This kernel reads none of them but ROOT, which is in memory (`rootfs`); it -//! answers their GUIDs in the inventory, so the log and boot file servers find -//! theirs and the supervisor knows the running slot, whoever drives the disk. +//! This kernel reads none of them; it answers their GUIDs in the inventory, so +//! the log and boot file servers find theirs and the supervisor knows the +//! running slot, whoever drives the disk. use alloc::vec::Vec; diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index e139a9b447..fcfd905679 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -434,16 +434,17 @@ pub fn spawn( Ok((pid, held)) } -/// The one program the kernel starts. `build/build.rs` puts this binary in every -/// image, so a missing one is a bad build, not a different boot. -pub const SUPERVISOR_PATH: &str = "/system/bin/supervisor"; +/// The name the supervisor runs under: no file, since it runs from its boot image. +const SUPERVISOR: &str = "supervisor"; -/// Start `/system/bin/supervisor`, holding the machine's one full-rights `SysCap`. +/// Start the supervisor from its boot image, holding the machine's one +/// full-rights `SysCap` and the file server's image and ROOT's +/// (`toyos_abi::syscall::FILESERVER_IMAGE_LABEL`, `ROOT_IMAGE_LABEL`). /// /// Nothing else can construct one: what the supervisor endows is the entire set of /// processes that can ever claim a device, enter the RT band, or power off. /// Panics on failure: a boot that cannot start the supervisor has nowhere to report to. -pub fn spawn_supervisor() -> Pid { +pub fn spawn_supervisor(images: crate::bootimage::Objects) -> Pid { let mut handles = HandleTable::new(); let mut entries = Vec::new(); let mut labels = Vec::new(); @@ -486,12 +487,34 @@ pub fn spawn_supervisor() -> Pid { _pad: 0, }); labels.extend_from_slice(label.as_bytes()); + for (label, object) in [ + (toyos_abi::syscall::FILESERVER_IMAGE_LABEL, &images.fileserver), + (toyos_abi::syscall::ROOT_IMAGE_LABEL, &images.root), + ] { + let object = KObjectRef::SharedMem(Arc::clone(object)); + let rights = ops::initial_rights(&object); + let handle = handles + .install(crate::object::HandleEntry::new(object, rights)) + .expect("spawn_supervisor: a table of five handles refused a boot image"); + entries.push(toyos_abi::syscall::EndowEntry { + label_off: labels.len() as u32, + label_len: label.len() as u32, + handle, + _pad: 0, + }); + labels.extend_from_slice(label.as_bytes()); + } + let len = images.supervisor.immutable().expect("a boot image is immutable"); + let image: Arc = Arc::new( + crate::file_backing::SharedImage::over(images.supervisor, len) + .expect("spawn_supervisor: a boot image is ordinary memory, its length inside it"), + ); // Built by the kernel and owing nobody anything: no table but the supervisor's own holds it. let commit = |own: start::Own| { own.endow(&mut handles, &mut entries, &mut labels); Ok((handles, Endowments::new(entries, labels), crate::object::job::new_job(), ())) }; - match spawn(SUPERVISOR_PATH, &[SUPERVISOR_PATH], commit, String::from("/"), Vec::new(), None, Parent::Root) { + match spawn(SUPERVISOR, &[SUPERVISOR], commit, String::from("/"), Vec::new(), Some(image), Parent::Root) { Ok((pid, ())) => pid, Err(crate::object::Refusal::Error(e)) => panic!("spawn_supervisor: failed to spawn: {e:?}"), Err(crate::object::Refusal::Handle(e)) => panic!("spawn_supervisor: {e}"), diff --git a/kernel/src/log/mod.rs b/kernel/src/log/mod.rs index 291b2d3a7a..fecbfb3d0b 100644 --- a/kernel/src/log/mod.rs +++ b/kernel/src/log/mod.rs @@ -74,7 +74,7 @@ const TAIL_HEAD: &str = "log: this boot's newest records follow, newest first"; /// records a stop writes is the machine's to decide, by its CPUs and its disks. /// /// **The kernel does not wait for `/system/bin/logkeeper`, so it does not know what -/// reached `/log`.** `/system/bin/supervisor` has `logkeeper` flush before it asks for the +/// reached `/log`.** The supervisor has `logkeeper` flush before it asks for the /// stop; everything committed after that — the stop's own record, the last /// word — is on the console, and here, where the next /// loader pass prints it into `loader.log`. diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 625f448714..f437c8d0ae 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -45,14 +45,13 @@ mod power; mod inventory; mod loaded; mod rollback; -mod rootfs; +mod bootimage; mod file_cache; #[cfg(feature = "boot-actuators")] #[cfg(feature = "boot-actuators")] mod revoke_selftest; mod tmpfs; mod file_backing; -mod bcachefs_adapter; mod fs_rename; mod vfs; mod elf; @@ -265,7 +264,7 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { params::init(cmdline); deadline::claim(cmdline); actuator::init(cmdline); - let root_image = rootfs::init(cmdline, &kernel_args, maps); + let [supervisor_image, fileserver_image, root_image] = bootimage::init(&kernel_args, maps); arch::boot::after_console(&kernel_args, maps); @@ -310,13 +309,6 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { "boot: cmdline {:#x}+{}", kernel_args.cmdline_addr, kernel_args.cmdline_len ); - // ROOT's name hashes every file it carries, `/system/etc/os-release` among - // them: the build, named before anything is mounted. - match toyos_abi::boot::root_uuid(cmdline).map(bcachefs::FsUuid::parse) { - Some(Some(root)) => log!("boot: root={root}"), - Some(None) => log!("boot: root= names no filesystem this kernel can parse"), - None => log!("boot: the cmdline carries no root="), - } // Before `mm::init`, which may hand the parameter's memory out. This record // is how a slot that died or was refused reaches the next boot's `/log`. match params::slot(cmdline) { @@ -351,8 +343,9 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { // memory the allocator would otherwise hand out. Empty on a boot whose // parameter line names none. blackbox::reserved_region(), - // ROOT's image, `LoaderData` like the black box's page. Empty on a - // boot the loader handed none. + // The boot images (`bootimage`), `LoaderData` like the black box's page. + supervisor_image, + fileserver_image, root_image, // Firmware's memory map as the loader copied it, which `mm` keeps // (`mm::firmware_map`): `LoaderData` too. @@ -382,8 +375,17 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { // name, rather than indexing it, means a region added to `loader` fails // to compile here instead of compiling and being silently dropped from // what `mm::init` withholds. - let [image, elf, black_box, root, map, microcode] = loader; - let reserved = [image, elf, black_box, root, map, microcode, arch::boot::reserved()]; + let [image, elf, black_box, supervisor, fileserver, root, map, microcode] = loader; + let reserved = [image, elf, black_box, supervisor, fileserver, root, map, microcode, arch::boot::reserved()]; + // A region another one shares is two allocations the loader made of one + // byte, and whichever writes it second corrupts the first. + let extents = reserved.map(|r| r.start..r.end); + if let Some((a, b)) = toyos_rootimage::handoff::overlapping(&extents) { + panic!( + "boot: the reserved regions {:#x}..{:#x} and {:#x}..{:#x} share memory", + reserved[a].start, reserved[a].end, reserved[b].start, reserved[b].end + ); + } let layout = toyos_elf::Layout::parse(kernel_elf, arch::ELF_MACHINE) .unwrap_or_else(|e| panic!("boot: the loader's kernel.elf: {e}")); let image = mm::Image::new(&layout, image); @@ -446,22 +448,15 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { scheduler::init(); pipe::init(); - // ROOT is the loader's image in memory, so nothing from here to the supervisor's - // spawn asks a disk for anything: every storage driver comes up after it. - use vfs::UserAccess; - let root_fs = rootfs::mount(); - vfs::lock().mount( - &["system"], - Box::new(bcachefs_adapter::ReadOnlyBcacheFsAdapter::new(root_fs)), - UserAccess::KernelOnly, - ); - vfs::lock().mount(&["tmp"], Box::new(crate::tmpfs::TmpFs::new()), UserAccess::ReadWrite); + vfs::lock().mount(&["tmp"], Box::new(crate::tmpfs::TmpFs::new())); boot_phase!("subsystems ready", t_subsys); + // Off the loader's images, so nothing from here to the supervisor's + // spawn asks a disk for anything: every storage driver comes up after it. // The supervisor reads /system/etc/system.manifest itself; the boot config never names the program it starts. - let pid = process::spawn_supervisor(); - log!("spawned {} pid={pid}", process::SUPERVISOR_PATH); + let pid = process::spawn_supervisor(bootimage::objects()); + log!("spawned the supervisor pid={pid}"); #[cfg(feature = "boot-actuators")] if actuator::revoked_backing_selftest() { diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs index 1e5ff32d03..ca3eee5229 100644 --- a/kernel/src/object/ops.rs +++ b/kernel/src/object/ops.rs @@ -677,8 +677,12 @@ pub fn fstat(object: &KObjectRef) -> Stat { KObjectRef::Acceptor(_) => plain(FileType::Pipe), KObjectRef::SharedMem(m) => Stat { // The verdict `pcidev::dma_map` lends a device by: a receiver judges a peer's region on it before mapping. - file_type: if m.ram().is_some() { FileType::SharedMemory } else { FileType::DeviceMemory } as u64, - size: m.size(), + file_type: match (m.ram(), m.immutable()) { + (Some(_), _) => FileType::SharedMemory, + (None, Some(_)) => FileType::ImmutableMemory, + (None, None) => FileType::DeviceMemory, + } as u64, + size: m.immutable().unwrap_or(m.size()), mtime: 0, volume: 0, node: 0, diff --git a/kernel/src/object/shm.rs b/kernel/src/object/shm.rs index 418b24c387..344b0e883a 100644 --- a/kernel/src/object/shm.rs +++ b/kernel/src/object/shm.rs @@ -43,9 +43,21 @@ pub struct Region { pub phys: DirectMap, pub size: u64, pub cache: CachePolicy, - /// The pages, when the kernel owns them; `None` for firmware's - /// framebuffer or an MMIO aperture it does not own. - pub pages: Option>, + pub memory: Memory, +} + +/// Whose memory a region is. +#[derive(Clone)] +pub enum Memory { + /// Pages the kernel allocated, freed with the last `Arc`, which the + /// field only keeps alive. + Owned { _pages: Arc }, + /// A payload the loader verified and handed over, `len` bytes of the + /// region's pages: kept for the machine's life and written by nobody, so + /// every mapping of it is read-only (`crate::bootimage`). + Boot { len: u64 }, + /// Firmware's framebuffer or an MMIO aperture the kernel does not own. + Foreign, } pub struct SharedMemObject { @@ -103,7 +115,7 @@ impl SharedMemObject { phys, size: aligned, cache: CachePolicy::Normal, - pages: Some(Arc::new(Pages(pages))), + memory: Memory::Owned { _pages: Arc::new(Pages(pages)) }, })) } @@ -117,10 +129,25 @@ impl SharedMemObject { /// a second device's domain would be one device reaching another's /// registers. pub fn ram(&self) -> Option<(u64, u64)> { - (self.region.pages.is_some() && self.region.cache == CachePolicy::Normal) + (matches!(self.region.memory, Memory::Owned { .. }) && self.region.cache == CachePolicy::Normal) .then(|| (self.region.phys.phys(), self.region.size)) } + /// The payload's length, for a region the loader handed over: memory + /// nobody writes. + pub fn immutable(&self) -> Option { + match self.region.memory { + Memory::Boot { len } => Some(len), + Memory::Owned { .. } | Memory::Foreign => None, + } + } + + /// Whether a program may be paged from it: ordinary memory, never a + /// device's, whose read is a device access. + pub fn is_ordinary(&self) -> bool { + self.ram().is_some() || self.immutable().is_some() + } + /// The kernel's own view of the pages, through the direct map; a mapped /// region is memory two writers can share — see `inbox.rs`. pub fn phys(&self) -> DirectMap { @@ -153,9 +180,13 @@ impl SharedMemObject { if let Some(m) = mapped.iter().find(|m| m.space.as_ptr() == Arc::as_ptr(pt)) { return Ok(m.at.raw()); } + let prot = match self.region.memory { + Memory::Boot { .. } => Prot::Read, + Memory::Owned { .. } | Memory::Foreign => Prot::ReadWrite, + }; let (addr, _) = pt .lock() - .alloc_and_map(self.region.phys.phys(), self.region.size, Prot::ReadWrite, self.region.cache) + .alloc_and_map(self.region.phys.phys(), self.region.size, prot, self.region.cache) .ok_or(SyscallError::ResourceExhausted)?; // Logged only for a non-default policy: this process is the one // paying for it. Read back the installed policy, not the request, @@ -191,7 +222,7 @@ impl SharedMemObject { /// Every mapping goes, flushed here, and with the list gone no later map can /// add one; the pages do not, since a handle holds an `Arc` and -/// `Region::pages` frees them only when the last one drops. +/// [`Memory::Owned`] frees them only when the last one drops. impl ZeroHandles for SharedMemObject { fn on_zero_handles(&self) { let mapped = self.mapped_in.take().expect("the zero-handle hook runs once"); diff --git a/kernel/src/object/syscap.rs b/kernel/src/object/syscap.rs index 3a2f736e00..2965eb18ba 100644 --- a/kernel/src/object/syscap.rs +++ b/kernel/src/object/syscap.rs @@ -3,7 +3,7 @@ //! Minting a device claim, entering the RT band, and turning a pid into a //! process handle are each reachable only through one bit on a handle to //! this. The kernel creates exactly one full-rights `SysCap`, at boot, for -//! `/system/bin/supervisor`; nothing else can construct one, so the set of processes +//! the supervisor; nothing else can construct one, so the set of processes //! that can ever do the three is exactly what the supervisor endowed. use alloc::sync::Arc; diff --git a/kernel/src/params.rs b/kernel/src/params.rs index 8c98cd8fee..3877be0618 100644 --- a/kernel/src/params.rs +++ b/kernel/src/params.rs @@ -18,7 +18,7 @@ static EARLY_PANEL_NAMED: AtomicBool = AtomicBool::new(false); static PARSED: AtomicBool = AtomicBool::new(false); pub fn init(cmdline: &str) { - for token in toyos_abi::boot::actuators(cmdline) { + for token in toyos_abi::boot::tokens(cmdline) { if let Some((_, named)) = PARAMS.iter().find(|(name, _)| *name == token) { named.store(true, Ordering::Relaxed); } @@ -49,7 +49,7 @@ pub const SLOT_RECORD: &str = "boot: slot"; /// loader wrote them: the last of each, because the loader appends its words /// after the slot's own parameter and so has the last one. pub fn slot(cmdline: &str) -> (Option<&str>, Option<&str>) { - let word = |prefix: &str| toyos_abi::boot::actuators(cmdline).filter_map(|t| t.strip_prefix(prefix)).last(); + let word = |prefix: &str| toyos_abi::boot::tokens(cmdline).filter_map(|t| t.strip_prefix(prefix)).last(); (word(toyos_abi::boot::SLOT_PARAM), word(toyos_abi::boot::SLOT_REFUSED_PARAM)) } diff --git a/kernel/src/pcidev/mod.rs b/kernel/src/pcidev/mod.rs index 028d0be056..0dfd7b1035 100644 --- a/kernel/src/pcidev/mod.rs +++ b/kernel/src/pcidev/mod.rs @@ -1360,7 +1360,7 @@ pub fn bar_object(binding: &Binding, index: u64) -> Result, // Registers, and the memory type is not firmware's to decide. cache: CachePolicy::Uncacheable, // The kernel owns no pages here: this is a device's aperture. - pages: None, + memory: crate::object::shm::Memory::Foreign, })) }) } diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 9c113af12b..8ee82da6e0 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -53,7 +53,7 @@ use kernel::proclife::{join, reap, spawn as proclife_spawn, teardown as proclife /// One `EndowEntry` on the wire; `loader::start` and [`Endowments::encode`] both index by it. pub const ENDOW_ENTRY_LEN: usize = core::mem::size_of::(); -pub use crate::loader::{build_child_handles, spawn, spawn_supervisor, SUPERVISOR_PATH}; +pub use crate::loader::{build_child_handles, spawn, spawn_supervisor}; /// Page tables shared between a process and all its threads. pub type PageTables = Arc>; diff --git a/kernel/src/rootfs.rs b/kernel/src/rootfs.rs deleted file mode 100644 index 28c1bce039..0000000000 --- a/kernel/src/rootfs.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! ROOT: the filesystem the boot parameter names, mounted read-only at -//! `/system` from the image the loader read into memory. -//! -//! The loader picks the partition and reads its filesystem whole; this kernel -//! reads ROOT from nowhere else. [`init`] takes the image out of the handoff -//! and keeps it only when `toyos_rootimage::handoff` finds it whole blocks -//! inside one `LoaderData` descriptor of firmware's map, returning the region -//! `mm::init` keeps out of the allocator. [`mount`] refuses the boot by name on -//! a handoff with no image, and on an image whose superblock is not the one -//! `root=` names. The image's bytes crossed a -//! trust boundary like any disk's, so every read of it is bounds-checked and a -//! block outside it is a refused read, never a panic. - -use bcachefs::{BlockBuf, BlockIO, BlockNum, DeviceError, FsUuid, Mounted, ReadOnly}; -use toyos_abi::boot::{KernelArgs, MemoryMapEntry}; -use toyos_rootimage::handoff::{held, Descriptor}; - -use crate::file_backing::BlockError; -use crate::mm::{DirectMap, Region}; -use crate::sync::Lock; - -/// The unit ROOT's filesystem is written in, which is the page. -const BLOCK: usize = crate::mm::PAGE_SIZE as usize; - -/// The record a ROOT mounted off the loader's image is reported on. -pub const MOUNTED_FROM_MEMORY: &str = "root: mounted read-only from memory at"; - -/// What the handoff said about ROOT, decided before `mm::init` may hand the -/// loader's pool memory back: nothing may hold a borrow of it this far into a boot. -#[derive(Clone, Copy)] -enum Handed { - /// The loader handed no image. - Nothing, - /// An extent that is not whole blocks inside one `LoaderData` descriptor. - Unheld { at: u64, len: u64 }, - Image(MemoryImage), -} - -#[derive(Clone, Copy)] -struct Boot { - named: Option, - handed: Handed, -} - -static BOOT: Lock = Lock::new(Boot { named: None, handed: Handed::Nothing }); - -/// ROOT's bytes: whole blocks, read-only, in memory the kernel never frees. -#[derive(Clone, Copy)] -pub struct MemoryImage(&'static [u8]); - -impl MemoryImage { - /// Where the image is, as the physical address and length the loader handed. - pub fn extent(&self) -> (u64, u64) { - (DirectMap::phys_of(self.0.as_ptr()), self.0.len() as u64) - } - - /// Copy block `block` into `into`; a block outside the image is a refused read. - pub fn read(&self, block: u64, into: &mut [u8; BLOCK]) -> Result<(), BlockError> { - let bytes = usize::try_from(block) - .ok() - .and_then(|block| block.checked_mul(BLOCK)) - .and_then(|at| self.0.get(at..at.checked_add(BLOCK)?)) - .ok_or(BlockError::Device)?; - into.copy_from_slice(bytes); - Ok(()) - } -} - -impl BlockIO for MemoryImage { - fn read_block(&self, block: BlockNum, buf: &mut BlockBuf) -> Result<(), DeviceError> { - self.read(block.raw(), &mut buf.0).map_err(|e| DeviceError::classify(&e)) - } - - /// A `ReadOnly` mount has no path to this; reaching it is a kernel bug. - fn write_block(&self, block: BlockNum, _buf: &BlockBuf) -> Result<(), DeviceError> { - panic!("rootfs: a read-only mount wrote block {} of the in-memory ROOT", block.raw()); - } - - fn block_count(&self) -> u64 { - (self.0.len() / BLOCK) as u64 - } -} - -/// Take ROOT's name out of the boot parameter and its image out of the -/// handoff, returning the region `mm::init` must keep: the image, or an empty -/// one where there is none to keep. Runs before `mm::init`, so it neither -/// allocates nor logs. -pub fn init(cmdline: &str, args: &KernelArgs, map: &[MemoryMapEntry]) -> Region { - let (at, len) = (args.root_image_addr, args.root_image_len); - let descriptors = map.iter().map(|entry| Descriptor { ty: entry.uefi_type, start: entry.start, end: entry.end }); - let none = Region { start: 0, end: 0 }; - let (handed, region) = match held(descriptors, toyos_bootmap::EFI_LOADER_DATA, at, len, BLOCK as u64) { - _ if len == 0 => (Handed::Nothing, none), - None => (Handed::Unheld { at, len }, none), - Some(extent) => ( - // SAFETY: the extent is whole pages of `LoaderData` the loader - // allocated and wrote before the jump, and the region returned here - // keeps `mm::init`'s allocator off it, so it is live, never written, - // and mapped at PHYS_OFFSET for the kernel's life. - Handed::Image(MemoryImage(unsafe { - core::slice::from_raw_parts(DirectMap::from_phys(at).as_ptr::(), len as usize) - })), - Region { start: extent.start, end: extent.end }, - ), - }; - *BOOT.lock() = Boot { - named: toyos_abi::boot::root_uuid(cmdline).and_then(FsUuid::parse), - handed, - }; - region -} - -/// Mount the filesystem the boot parameter named, off the image the loader -/// handed. Panics when there is no image, or it is not that filesystem. -pub fn mount() -> Mounted { - let Boot { named, handed, .. } = *BOOT.lock(); - let Some(named) = named else { - panic!("boot: the kernel argument names no root filesystem this kernel can parse"); - }; - let image = match handed { - Handed::Nothing => panic!( - "boot: the loader handed no ROOT image, and this kernel reads ROOT from memory and nowhere else" - ), - Handed::Unheld { at, len } => panic!( - "boot: the ROOT image at {at:#x}+{len:#x} is not whole {BLOCK}-byte blocks inside one \ - LoaderData descriptor" - ), - Handed::Image(image) => image, - }; - let fs = Mounted::<_, ReadOnly>::open(image) - .unwrap_or_else(|e| panic!("boot: the ROOT image holds no filesystem this kernel can mount: {e:?}")); - if fs.uuid() != named { - panic!("boot: root={named} and the ROOT image the loader handed holds {}", fs.uuid()); - } - let (at, len) = image.extent(); - log!("{MOUNTED_FROM_MEMORY} {at:#x}+{len:#x}, filesystem {named}, {} blocks", image.block_count()); - fs -} diff --git a/kernel/src/syscall/machine.rs b/kernel/src/syscall/machine.rs index 0512ac6884..b5701c74a5 100644 --- a/kernel/src/syscall/machine.rs +++ b/kernel/src/syscall/machine.rs @@ -2,7 +2,7 @@ //! //! [`sys_log_read`], [`sys_trace_read`], the roster half of [`sys_sysinfo`], and both of //! [`sys_shutdown`] and [`sys_reboot`] each require a `SysCap` bit from -//! `/system/bin/supervisor`'s `system.toml`; `SYS_SYSINFO`'s header is ambient. +//! the supervisor's `system.toml`; `SYS_SYSINFO`'s header is ambient. use alloc::vec::Vec; @@ -114,7 +114,7 @@ fn quiesce(last: &str) -> Result<(crate::quiesce::Stopping, StopWire), SyscallEr // First: what follows outlasts a feed cadence, and no pass runs to feed again. crate::arch::watchdog::disarm(); // Every userland thread stops here, the log's writer with the rest: - // `/system/bin/supervisor` had it flush before it asked for this stop. + // the supervisor had it flush before it asked for this stop. let (stopped, stopping) = crate::quiesce::stop(); // From here on nothing carries a record to a file: the seal below takes // every one stamped after this one, the newest the stop found. diff --git a/kernel/src/vfs.rs b/kernel/src/vfs.rs index 6d0e24fa00..2109c52091 100644 --- a/kernel/src/vfs.rs +++ b/kernel/src/vfs.rs @@ -47,7 +47,7 @@ pub trait FileSystem: Send { /// Whether `dir` (`""` is the mount root) is a directory: a working /// directory is judged by this, on every spawn and `SYS_CHDIR`, under the /// VFS lock. Nothing beneath it is materialised and nothing caps it; what it costs is - /// the mount's — a bcachefs answer of no reads the whole tree. + /// the mount's. fn is_dir(&mut self, dir: &str) -> Result; /// When `name` was last written, as `toyos_abi::syscall::Stat::mtime` says, @@ -80,14 +80,6 @@ pub trait FileSystem: Send { } -/// Whether userland may modify a mount; stated at every `mount`, defaulted nowhere. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum UserAccess { - ReadWrite, - /// Readable, and every userland syscall that would change it is refused. - KernelOnly, -} - /// The entries `/` has, in listing order. `/` is synthesized rather than /// mounted: it is no filesystem, and nothing outside this set can be mounted. pub const ROOT_ENTRIES: [&str; 9] = @@ -95,7 +87,6 @@ pub const ROOT_ENTRIES: [&str; 9] = struct Mount { fs: Box, - access: UserAccess, } /// A name at `/`, and where in its filesystem that name begins. @@ -217,9 +208,9 @@ impl Vfs { /// Mount one filesystem under `names`, each a [`ROOT_ENTRIES`] name. Several /// names put each under its own directory of it. - pub fn mount(&mut self, names: &[&'static str], fs: Box, access: UserAccess) { + pub fn mount(&mut self, names: &[&'static str], fs: Box) { let index = self.mounts.len(); - self.mounts.push(Mount { fs, access }); + self.mounts.push(Mount { fs }); for name in names { let prefix = if names.len() == 1 { "" } else { *name }; let slot = Self::entry(name).expect("a mount name is one of the root's entries"); @@ -240,7 +231,7 @@ impl Vfs { /// and a bare mount point, answer no: the read-only refusal, not a missing name. pub fn user_may_modify(&self, path: &str) -> bool { let (mount, _) = self.resolve_path("/", path); - self.point(&mount).is_some_and(|p| self.mounts[p.fs].access == UserAccess::ReadWrite) + self.point(&mount).is_some() } fn resolve_fs(&mut self, mount: &str, file: &str) -> Option<(&mut dyn FileSystem, String)> { diff --git a/lib/bcachefs/src/fs.rs b/lib/bcachefs/src/fs.rs index 418cee4118..662615095b 100644 --- a/lib/bcachefs/src/fs.rs +++ b/lib/bcachefs/src/fs.rs @@ -552,7 +552,7 @@ impl Formatted { Ok(Self(Mounted { io, sb, alloc, _mode: PhantomData })) } - /// Name this filesystem, so a role's kernel argument can select it. + /// Name this filesystem, so whoever serves it can say which one it is. /// /// A separate act from formatting: a volume nothing names is legal, and /// nothing here invents a name for one. Persisted by [`Self::into_io`]. diff --git a/lib/bcachefs/src/superblock.rs b/lib/bcachefs/src/superblock.rs index f02c0853a0..ace3abef5b 100644 --- a/lib/bcachefs/src/superblock.rs +++ b/lib/bcachefs/src/superblock.rs @@ -37,7 +37,7 @@ const _: () = assert!( ); /// A filesystem's name: sixteen bytes in its superblock, printed as 32 hex -/// digits, and what a role's kernel argument names it by. +/// digits. /// /// Not a partition GUID and never mixed with one — a partition is where a /// filesystem's bytes are, and one filesystem may occupy several. diff --git a/lib/logstream/src/lib.rs b/lib/logstream/src/lib.rs index a3add819e1..622c71a900 100644 --- a/lib/logstream/src/lib.rs +++ b/lib/logstream/src/lib.rs @@ -1,5 +1,5 @@ //! A boot's log as `logkeeper` writes it and serves it: the form a program's line -//! takes beside the kernel's records, the frame `/system/bin/supervisor` hands `logkeeper` +//! takes beside the kernel's records, the frame the supervisor hands `logkeeper` //! a program's output on, the replay a reader who connects late is served //! from, and the one reader of a line's head ([`parse`]). //! diff --git a/lib/manifest/Cargo.toml b/lib/manifest/Cargo.toml index d64fea3082..7126e4555b 100644 --- a/lib/manifest/Cargo.toml +++ b/lib/manifest/Cargo.toml @@ -7,6 +7,6 @@ license = "MIT OR Apache-2.0" # The one place a `devices` class name and a `syscap` right name are decoded. # Both are ABI vocabulary, and a manifest crate holding its own copy of either -# would be a config the build system accepts and `/system/bin/supervisor` cannot act on. +# would be a config the build system accepts and the supervisor cannot act on. [dependencies] toyos-abi = { path = "../../sdk/abi" } diff --git a/lib/manifest/src/consent.rs b/lib/manifest/src/consent.rs index 3da41be035..0ffe28ecbb 100644 --- a/lib/manifest/src/consent.rs +++ b/lib/manifest/src/consent.rs @@ -1,4 +1,4 @@ -//! The question `/system/bin/supervisor` asks the person at the screen before +//! The question the supervisor asks the person at the screen before //! an installed package's launch, and the answer it honours. //! //! **Only the supervisor asks, and only its question grants anything.** It diff --git a/lib/manifest/src/grants.rs b/lib/manifest/src/grants.rs index 9b4a7e6892..0f96168f25 100644 --- a/lib/manifest/src/grants.rs +++ b/lib/manifest/src/grants.rs @@ -1,5 +1,5 @@ //! Which folder of the session user's home an installed package is granted, -//! and the store `/system/bin/supervisor` keeps those grants in. +//! and the store the supervisor keeps those grants in. //! //! **The supervisor is the only writer of [`STORE`].** A grant reaches it as a //! request on [`PORT`], which the build gives to [`HOLDER`] alone and the diff --git a/lib/manifest/src/launch.rs b/lib/manifest/src/launch.rs index 03645af006..75699feb03 100644 --- a/lib/manifest/src/launch.rs +++ b/lib/manifest/src/launch.rs @@ -1,4 +1,4 @@ -//! Who may start what through `/system/bin/supervisor`'s launcher. +//! Who may start what through the supervisor's launcher. //! //! **A caller starts only the rows its own row's `starts` lists**: program //! keys, and [`APPS`] for any installed package. **A row that replaces what the diff --git a/lib/manifest/src/lib.rs b/lib/manifest/src/lib.rs index ffb9c97ab2..ea39afba71 100644 --- a/lib/manifest/src/lib.rs +++ b/lib/manifest/src/lib.rs @@ -1,5 +1,5 @@ //! What every program in an image is allowed to hold, written by the build -//! system and read by `/system/bin/supervisor`. +//! system and read by the supervisor. //! //! **One definition of the format, used by both halves.** `build/build.rs` //! resolves `system.toml` into a [`Manifest`] and [`render`]s it into the @@ -52,7 +52,7 @@ pub mod package; /// spelling. [`GUEST_PATH`] is what a process opens. pub const PATH: &str = "etc/system.manifest"; -/// The path `/system/bin/supervisor` opens. +/// The path the supervisor opens. pub const GUEST_PATH: &str = "/system/etc/system.manifest"; /// A program key may be this long. Policy on the primitive: the launcher @@ -60,7 +60,7 @@ pub const GUEST_PATH: &str = "/system/etc/system.manifest"; /// truncated into some other program's. pub const MAX_PROGRAM_NAME: usize = 32; -/// The port `/system/bin/supervisor` serves swaps on: its one holder replaces a +/// The port the supervisor serves swaps on: its one holder replaces a /// running service's binary, so a row receiving it starts only in a login /// session ([`launch`]). pub const SWAP_PORT: &str = "swap"; @@ -100,18 +100,25 @@ pub struct RoleDir { /// What every program's namespace calls it, after `fs:`: `/home`. pub dir: &'static str, /// Where it is on the role's volume, which a grant on it names - /// (`toyos::fs::Grant`): DATA keeps its directories side by side, and LOG - /// and BOOT are each served whole. + /// (`toyos::fs::Grant`): DATA keeps its directories side by side, and + /// SYSTEM, LOG and BOOT are each served whole. pub root: &'static str, + /// Whether a grant on it may change it: SYSTEM's ROOT is the signed + /// image, which nothing does. + pub write: bool, } const fn dir(dir: &'static str, root: &'static str) -> RoleDir { - RoleDir { dir, root } + RoleDir { dir, root, write: true } } +/// The role serving ROOT, which the supervisor starts before it reads a file. +pub const SYSTEM: &str = "system"; + /// The file-server roles, and the directories each serves: one port per role, /// and one grant on it per directory. -pub const ROLES: [(&str, &[RoleDir]); 3] = [ +pub const ROLES: [(&str, &[RoleDir]); 4] = [ + (SYSTEM, &[RoleDir { dir: "/system", root: "", write: false }]), ("data", &[dir("/apps", "apps"), dir("/config", "config"), dir("/home", "home"), dir("/state", "state")]), ("log", &[dir("/log", "")]), ("boot", &[dir("/boot", "")]), @@ -135,17 +142,23 @@ pub struct View { pub write: bool, } -/// Every directory every role serves, each read-write: the view of a program -/// no narrower one is declared for. +/// Every directory every role serves, each read-write but `/system`: the view +/// of a program no narrower one is declared for. pub fn whole_tree() -> Vec { ROLES .iter() .flat_map(|(role, dirs)| { - dirs.iter().map(|d| View { role, dir: d.dir.to_string(), root: d.root.to_string(), write: true }) + dirs.iter().map(|d| View { role, dir: d.dir.to_string(), root: d.root.to_string(), write: d.write }) }) .collect() } +/// `/system`, read-only: in every view. +fn system() -> View { + let [only] = role_dirs(SYSTEM).expect("SYSTEM is a role") else { panic!("SYSTEM serves one directory") }; + View { role: SYSTEM, dir: only.dir.to_string(), root: only.root.to_string(), write: false } +} + /// The DATA directory `dir`, beneath one DATA serves. fn data_dir(dir: String, write: bool) -> View { let role = "data"; @@ -307,9 +320,9 @@ impl Program { } /// The directories this row's program is endowed. **An installed package - /// sees its own directory read-only, its own folder of the home - /// read-write and the folder its launch was granted, and nothing else any - /// role serves**: no other package, no other part of the home, no + /// sees `/system` and its own directory read-only, its own folder of the + /// home read-write and the folder its launch was granted, and nothing else + /// any role serves**: no other package, no other part of the home, no /// `/config`, `/state`, `/log` or `/boot`. /// Every other row sees the whole tree, until each declares its own /// (`issues/every-program-sees-only-the-files-it-was-given.md`, stage 2). @@ -317,7 +330,10 @@ impl Program { match self.package() { Some(name) => { let granted = self.folder.iter().map(|f| data_dir(f.path.clone(), f.access == grants::Access::ReadWrite)); - [data_dir(package::Package::dir(name), false), data_dir(app_home(name), true)].into_iter().chain(granted).collect() + [system(), data_dir(package::Package::dir(name), false), data_dir(app_home(name), true)] + .into_iter() + .chain(granted) + .collect() } None => whole_tree(), } @@ -651,7 +667,7 @@ mod tests { }, Program { name: "fileserver".into(), - path: "/system/bin/fileserver".into(), + path: toyos_abi::syscall::FILESERVER_IMAGE_LABEL.into(), receives: vec!["block".into()], roles: vec!["data".into(), "log".into()], restart: true, @@ -757,15 +773,22 @@ mod tests { row.view().into_iter().map(|v| (v.role, v.dir, v.root, v.write)).collect() } - /// **An installed package sees its own directory read-only and its own - /// folder read-write, and nothing else any role serves.** Spelled out - /// whole, so a third directory, a wider root or a writable package is red. + /// `/system`, read-only, as every view holds it. + fn system() -> (&'static str, String, String, bool) { + (SYSTEM, "/system".into(), String::new(), false) + } + + /// **An installed package sees `/system` and its own directory read-only + /// and its own folder read-write, and nothing else any role serves.** + /// Spelled out whole, so a fourth directory, a wider root or a writable + /// package is red. #[test] fn a_package_s_view_is_its_own_directory_read_only_and_its_own_folder() { let row = sample().app_row("gbae", "/apps/gbae/gbae").unwrap(); assert_eq!( views(&row), [ + system(), ("data", "/apps/gbae".into(), "apps/gbae".into(), false), ("data", "/home/toy/Apps/gbae".into(), "home/toy/Apps/gbae".into(), true), ] @@ -774,7 +797,7 @@ mod tests { let longest = "n".repeat(MAX_PROGRAM_NAME); let row = sample().app_row(&longest, &format!("/apps/{longest}/{longest}")).unwrap(); assert_eq!( - views(&row).into_iter().map(|(_, _, root, write)| (root, write)).collect::>(), + views(&row).into_iter().skip(1).map(|(_, _, root, write)| (root, write)).collect::>(), [(format!("apps/{longest}"), false), (format!("home/toy/Apps/{longest}"), true)] ); } @@ -789,6 +812,7 @@ mod tests { assert_eq!( views(&row), [ + system(), ("data", "/apps/gbae".into(), "apps/gbae".into(), false), ("data", "/home/toy/Apps/gbae".into(), "home/toy/Apps/gbae".into(), true), ("data", "/home/toy/Games".into(), "home/toy/Games".into(), write), @@ -797,17 +821,20 @@ mod tests { } } - /// Every row the image declares sees the whole tree read-write, the - /// installer and the shell included: neither has authority over `/apps` - /// the other lacks. + /// Every row the image declares sees the whole tree read-write but + /// `/system`, the installer and the shell included: neither has authority + /// over `/apps` the other lacks, and nobody writes the signed image. #[test] fn every_declared_row_sees_the_whole_tree_read_write() { let m = parse("program pkg /system/bin/pkg\nprogram shell /system/bin/shell\n"); - let whole: Vec<_> = ["/apps", "/config", "/home", "/state", "/log", "/boot"] - .iter() - .zip(["apps", "config", "home", "state", "", ""]) - .zip(["data", "data", "data", "data", "log", "boot"]) - .map(|((dir, root), role)| (role, dir.to_string(), root.to_string(), true)) + let whole: Vec<_> = std::iter::once(system()) + .chain( + ["/apps", "/config", "/home", "/state", "/log", "/boot"] + .iter() + .zip(["apps", "config", "home", "state", "", ""]) + .zip(["data", "data", "data", "data", "log", "boot"]) + .map(|((dir, root), role)| (role, dir.to_string(), root.to_string(), true)), + ) .collect(); let s = sample(); for row in [m.program("pkg").unwrap(), m.program("shell").unwrap(), s.program("compositor").unwrap()] { diff --git a/lib/quiesce/src/lib.rs b/lib/quiesce/src/lib.rs index 20ac4949bb..1e83dd2528 100644 --- a/lib/quiesce/src/lib.rs +++ b/lib/quiesce/src/lib.rs @@ -7,7 +7,7 @@ //! here; `kernel/src/quiesce.rs` marks the threads and spends the time. //! //! **Nothing is carved out.** The log's writer stops with every other thread: -//! `/system/bin/supervisor` has it flush before it asks for the stop, and what the +//! the supervisor has it flush before it asks for the stop, and what the //! kernel says after that goes to its console and its black box, never to a //! process it would have to keep running. //! diff --git a/lib/rootimage/src/handoff.rs b/lib/rootimage/src/handoff.rs index 0b0c327084..306be2e5df 100644 --- a/lib/rootimage/src/handoff.rs +++ b/lib/rootimage/src/handoff.rs @@ -1,7 +1,8 @@ -//! Whether the extent the loader names as ROOT's image is memory the kernel -//! may keep: whole blocks, every byte of it inside one descriptor of the type -//! the loader allocated it as. The kernel then keeps it out of its allocator -//! by address, as it keeps the black box's page. +//! Whether an extent the loader hands the kernel is memory the kernel may +//! keep: whole blocks, every byte of it inside one descriptor of the type the +//! loader allocated it as, and no byte of it another handed extent's. The +//! kernel then keeps it out of its allocator by address, as it keeps the black +//! box's page. /// `[start, end)` of UEFI memory type `ty`, as a memory map describes it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -32,3 +33,12 @@ pub fn held( .any(|entry| entry.ty == ty && entry.start <= at && end <= entry.end) .then_some(at..end) } + +/// The first two of `extents` that share a byte, by index; `None` when no two +/// do. An empty extent shares nothing. +pub fn overlapping(extents: &[core::ops::Range]) -> Option<(usize, usize)> { + let shares = |a: &core::ops::Range, b: &core::ops::Range| { + !a.is_empty() && !b.is_empty() && a.start < b.end && b.start < a.end + }; + (0..extents.len()).find_map(|i| (i + 1..extents.len()).find(|&j| shares(&extents[i], &extents[j])).map(|j| (i, j))) +} diff --git a/lib/rootimage/src/lib.rs b/lib/rootimage/src/lib.rs index d5daa0f561..ef50816e39 100644 --- a/lib/rootimage/src/lib.rs +++ b/lib/rootimage/src/lib.rs @@ -1,7 +1,8 @@ -//! ROOT in memory: every decision the loader and the kernel make about the -//! image, pure. The loader reads the partition its slot names in bounded -//! [`chunk`]s; the kernel keeps the extent it is handed only once [`handoff`] -//! finds it inside memory the loader allocated. +//! ROOT and the boot programs in memory: every decision the loader and the +//! kernel make about the images, pure. The loader reads the partition its slot +//! names in bounded [`chunk`]s; the kernel keeps each extent it is handed only +//! once [`handoff`] finds it inside memory the loader allocated and no other +//! extent's. //! //! No `alloc`, no firmware, no `unsafe`: the loader and the kernel supply the //! device, the candidates and the map. diff --git a/lib/rootimage/tests/handoff.rs b/lib/rootimage/tests/handoff.rs index 774a572a55..e489151bbd 100644 --- a/lib/rootimage/tests/handoff.rs +++ b/lib/rootimage/tests/handoff.rs @@ -1,7 +1,7 @@ //! The handoff's bounds: an extent is kept only when it is whole blocks inside //! one descriptor of the loader's type, and each edge of that is its own test. -use toyos_rootimage::handoff::{held, Descriptor}; +use toyos_rootimage::handoff::{held, overlapping, Descriptor}; const PAGE: u64 = 4096; const LOADER_DATA: u32 = 2; @@ -63,3 +63,16 @@ fn a_start_that_is_not_block_aligned_is_refused() { fn an_extent_inside_a_descriptor_of_another_type_is_refused() { assert_eq!(held(map(), LOADER_DATA, 2 * PAGE, 4 * PAGE, PAGE), None); } + +/// Two handed extents that share a byte are named by index, whichever end +/// overlaps; ones that only touch, or are empty, share nothing. +#[test] +fn extents_that_share_a_byte_are_named() { + let (a, b, c) = (16 * PAGE..20 * PAGE, 20 * PAGE..24 * PAGE, 30 * PAGE..32 * PAGE); + assert_eq!(overlapping(&[a.clone(), b.clone(), c.clone()]), None, "touching extents share nothing"); + assert_eq!(overlapping(&[a.clone(), b, 23 * PAGE..31 * PAGE]), Some((1, 2))); + assert_eq!(overlapping(&[c, a.clone(), 31 * PAGE..33 * PAGE]), Some((0, 2))); + assert_eq!(overlapping(&[a.clone(), 17 * PAGE..18 * PAGE]), Some((0, 1)), "one inside another"); + assert_eq!(overlapping(&[a.clone(), a.clone()]), Some((0, 1)), "the same extent twice"); + assert_eq!(overlapping(&[a, 18 * PAGE..18 * PAGE]), None, "an empty extent"); +} diff --git a/lib/swap/src/lib.rs b/lib/swap/src/lib.rs index 3b92967f7f..bb57a62640 100644 --- a/lib/swap/src/lib.rs +++ b/lib/swap/src/lib.rs @@ -1,5 +1,5 @@ //! Replacing a running service's binary: every word `/system/bin/swap` and -//! `/system/bin/supervisor` say about it, and every decision the supervisor makes +//! the supervisor say about it, and every decision the supervisor makes //! about one. Pure. //! //! **A swap is asked by `/system/bin/swap` and done by the supervisor, and nothing else diff --git a/lib/update/src/image.rs b/lib/update/src/image.rs index 14b0433019..321531cb55 100644 --- a/lib/update/src/image.rs +++ b/lib/update/src/image.rs @@ -10,9 +10,11 @@ //! Little-endian throughout. **The sections are exactly [`SECTIONS`], in that //! order**, and a header naming anything else is refused rather than read //! around: a loader that skipped a section it did not know would boot bytes -//! nothing vouched for. The header is the whole of what is signed, so a -//! section's bytes are judged by its hash and never by being signed -//! themselves — which is what lets a later format fetch them as +//! nothing vouched for. A section's name is its role, so one version binds the +//! kernel, its parameter, the two programs the kernel starts the machine with +//! and ROOT, each by role, length and hash. The header is the whole of what is +//! signed, so a section's bytes are judged by its hash and never by being +//! signed themselves — which is what lets a later format fetch them as //! content-addressed chunks under this same header. use crate::Digest; @@ -21,17 +23,22 @@ use crate::Digest; pub const MAGIC: [u8; 8] = *b"TOYOSIMG"; /// This layout. A header of any other format is refused by name. -pub const FORMAT: u32 = 1; +pub const FORMAT: u32 = 2; /// The kernel ELF. pub const KERNEL: &str = "kernel"; /// The boot parameter the loader hands the kernel. pub const CMDLINE: &str = "cmdline"; +/// The supervisor, the one program the kernel starts. +pub const SUPERVISOR: &str = "supervisor"; +/// The file server the supervisor serves ROOT with before it reads a file. +pub const FILESERVER: &str = "fileserver"; /// ROOT's filesystem, whole. pub const ROOT: &str = "root"; -/// Every section an image carries, in the order it carries them. -pub const SECTIONS: [&str; 3] = [KERNEL, CMDLINE, ROOT]; +/// Every section an image carries, in the order it carries them. ROOT is last, +/// so an update streams it after every other section is held. +pub const SECTIONS: [&str; 5] = [KERNEL, CMDLINE, SUPERVISOR, FILESERVER, ROOT]; /// Bytes a section's name takes, NUL-padded. const NAME_BYTES: usize = 16; @@ -50,8 +57,9 @@ pub const SIGNED_BYTES: usize = HEADER_BYTES + SIGNATURE_BYTES; /// The largest section of each kind an image may carry, so a header is a /// bound on what a reader allocates before a single hash is checked. The /// kernel's is the loader's own bound on an ESP file; ROOT's is well above -/// any ROOT this tree builds and below what a slot is. -pub const MAX_BYTES: [u64; 3] = [1 << 30, 4096, 4 << 30]; +/// any ROOT this tree builds and below what a slot is; each program's is far +/// above what this tree builds (2 MiB) and a bound on the pages the kernel keeps. +pub const MAX_BYTES: [u64; 5] = [1 << 30, 4096, 64 << 20, 64 << 20, 4 << 30]; /// ROOT is whole 4 KiB blocks: it is written to a partition and read back in them. pub const ROOT_BLOCK: u64 = 4096; @@ -69,7 +77,7 @@ pub struct Section { pub struct Header { pub version: u64, /// In [`SECTIONS`] order. - pub sections: [Section; 3], + pub sections: [Section; 5], } /// Why bytes are not an image. @@ -84,6 +92,8 @@ pub enum Malformed { SectionName(usize), /// The section at this index is longer than [`MAX_BYTES`] allows. TooLong(usize, u64), + /// The program section at this index is empty. + Empty(usize), /// ROOT is not whole [`ROOT_BLOCK`]s, or is empty. RootNotBlocks(u64), /// The image's bytes do not add up to the header's sections. @@ -99,6 +109,7 @@ impl core::fmt::Display for Malformed { Self::SectionCount(n) => write!(f, "the header names {n} sections, and an image has {}", SECTIONS.len()), Self::SectionName(i) => write!(f, "section {i} is not `{}`", SECTIONS[i]), Self::TooLong(i, len) => write!(f, "the `{}` section is {len} bytes, past its bound of {}", SECTIONS[i], MAX_BYTES[i]), + Self::Empty(i) => write!(f, "the `{}` section is empty", SECTIONS[i]), Self::RootNotBlocks(len) => write!(f, "ROOT is {len} bytes, which is not whole {ROOT_BLOCK}-byte blocks"), Self::Length { have, want } => write!(f, "the image is {have} bytes and its header adds up to {want}"), } @@ -139,7 +150,7 @@ impl Header { return Err(Malformed::SectionCount(count)); } let version = u64_at(bytes, 16); - let mut sections = [Section { len: 0, sha256: [0; 32] }; 3]; + let mut sections = [Section { len: 0, sha256: [0; 32] }; 5]; for (i, section) in sections.iter_mut().enumerate() { let at = FIXED_BYTES + i * ENTRY_BYTES; if bytes[at..at + NAME_BYTES] != padded(SECTIONS[i]) { @@ -152,7 +163,12 @@ impl Header { section.len = len; section.sha256.copy_from_slice(&bytes[at + NAME_BYTES + 8..at + ENTRY_BYTES]); } - let root = sections[2].len; + for program in [2, 3] { + if sections[program].len == 0 { + return Err(Malformed::Empty(program)); + } + } + let root = sections[4].len; if root == 0 || !root.is_multiple_of(ROOT_BLOCK) { return Err(Malformed::RootNotBlocks(root)); } @@ -176,9 +192,8 @@ impl Header { } /// The header naming exactly these sections' bytes. - pub fn of(version: u64, kernel: &[u8], cmdline: &[u8], root: &[u8]) -> Self { - let section = |bytes: &[u8]| Section { len: bytes.len() as u64, sha256: crate::sha256(bytes) }; - Self { version, sections: [section(kernel), section(cmdline), section(root)] } + pub fn of(version: u64, sections: &Sections<'_>) -> Self { + Self { version, sections: sections.all().map(|bytes| Section { len: bytes.len() as u64, sha256: crate::sha256(bytes) }) } } pub fn kernel(&self) -> Section { @@ -189,10 +204,18 @@ impl Header { self.sections[1] } - pub fn root(&self) -> Section { + pub fn supervisor(&self) -> Section { self.sections[2] } + pub fn fileserver(&self) -> Section { + self.sections[3] + } + + pub fn root(&self) -> Section { + self.sections[4] + } + /// The bytes the sections take after the signed header. pub fn payload_len(&self) -> u64 { self.sections.iter().map(|s| s.len).sum() @@ -204,15 +227,30 @@ pub fn signature_of(signed: &[u8; SIGNED_BYTES]) -> [u8; SIGNATURE_BYTES] { signed[HEADER_BYTES..].try_into().expect("a signed header ends in a signature") } +/// Each section's bytes, by role. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sections<'a> { + pub kernel: &'a [u8], + pub cmdline: &'a [u8], + pub supervisor: &'a [u8], + pub fileserver: &'a [u8], + pub root: &'a [u8], +} + +impl<'a> Sections<'a> { + /// In [`SECTIONS`] order. + pub fn all(&self) -> [&'a [u8]; 5] { + [self.kernel, self.cmdline, self.supervisor, self.fileserver, self.root] + } +} + /// An image whose layout adds up, split into its parts. Nothing here is /// vouched for until the header's signature ([`crate::sig::verify`]) and /// each section's hash against its header entry are checked. pub struct Parts<'a> { pub header: Header, pub signed: &'a [u8; SIGNED_BYTES], - pub kernel: &'a [u8], - pub cmdline: &'a [u8], - pub root: &'a [u8], + pub sections: Sections<'a>, } impl<'a> Parts<'a> { @@ -224,9 +262,14 @@ impl<'a> Parts<'a> { return Err(Malformed::Length { have: bytes.len() as u64, want }); } let signed: &[u8; SIGNED_BYTES] = bytes[..SIGNED_BYTES].try_into().expect("checked above"); - let (k, c) = (header.kernel().len as usize, header.cmdline().len as usize); - let rest = &bytes[SIGNED_BYTES..]; - Ok(Self { header, signed, kernel: &rest[..k], cmdline: &rest[k..k + c], root: &rest[k + c..] }) + let mut rest = &bytes[SIGNED_BYTES..]; + let mut take = |section: Section| { + let (bytes, after) = rest.split_at(section.len as usize); + rest = after; + bytes + }; + let [kernel, cmdline, supervisor, fileserver, root] = header.sections.map(&mut take); + Ok(Self { header, signed, sections: Sections { kernel, cmdline, supervisor, fileserver, root } }) } } @@ -234,8 +277,20 @@ impl<'a> Parts<'a> { mod tests { use super::*; + const ROOT_BYTES: [u8; 8192] = [0x5A; 8192]; + + fn sections() -> Sections<'static> { + Sections { + kernel: b"\x7fELF kernel", + cmdline: b"slot=A", + supervisor: b"\x7fELF supervisor", + fileserver: b"\x7fELF fileserver", + root: &ROOT_BYTES, + } + } + fn header() -> Header { - Header::of(7, b"\x7fELF kernel", b"root=x", &[0x5A; 8192]) + Header::of(7, §ions()) } fn signed(header: &Header) -> [u8; SIGNED_BYTES] { @@ -244,11 +299,18 @@ mod tests { out } + /// The entry of section `i`: where its name, then its length, begin. + fn entry(i: usize) -> usize { + FIXED_BYTES + i * ENTRY_BYTES + } + #[test] fn a_header_reads_back_as_it_was_written() { let h = header(); assert_eq!(Header::parse(&signed(&h)), Ok(h)); - assert_eq!(HEADER_BYTES, 192); + assert_eq!(HEADER_BYTES, 304); + assert_eq!(h.supervisor().sha256, crate::sha256(b"\x7fELF supervisor")); + assert_eq!(h.fileserver().len, 15); } /// Every field a reader decides on, bent one at a time, is refused by the @@ -263,28 +325,44 @@ mod tests { }; assert_eq!(Header::parse(&good[..SIGNED_BYTES - 1]), Err(Malformed::Short(SIGNED_BYTES - 1))); assert_eq!(bend(0, b"TOYOSIMH"), Err(Malformed::Magic)); - assert_eq!(bend(8, &2u32.to_le_bytes()), Err(Malformed::Format(2))); - assert_eq!(bend(12, &4u32.to_le_bytes()), Err(Malformed::SectionCount(4))); - assert_eq!(bend(FIXED_BYTES, b"kernal"), Err(Malformed::SectionName(0))); + assert_eq!(bend(8, &1u32.to_le_bytes()), Err(Malformed::Format(1))); + assert_eq!(bend(12, &3u32.to_le_bytes()), Err(Malformed::SectionCount(3))); + assert_eq!(bend(entry(0), b"kernal"), Err(Malformed::SectionName(0))); // The name is the whole padded field, so a longer one is not a prefix match. - assert_eq!(bend(FIXED_BYTES + ENTRY_BYTES + 7, b"x"), Err(Malformed::SectionName(1))); - let at = FIXED_BYTES + ENTRY_BYTES + NAME_BYTES; - assert_eq!(bend(at, &4097u64.to_le_bytes()), Err(Malformed::TooLong(1, 4097))); - let root = FIXED_BYTES + 2 * ENTRY_BYTES + NAME_BYTES; - assert_eq!(bend(root, &4095u64.to_le_bytes()), Err(Malformed::RootNotBlocks(4095))); - assert_eq!(bend(root, &0u64.to_le_bytes()), Err(Malformed::RootNotBlocks(0))); + assert_eq!(bend(entry(1) + 7, b"x"), Err(Malformed::SectionName(1))); + assert_eq!(bend(entry(1) + NAME_BYTES, &4097u64.to_le_bytes()), Err(Malformed::TooLong(1, 4097))); + let len = |i| entry(i) + NAME_BYTES; + assert_eq!(bend(len(4), &4095u64.to_le_bytes()), Err(Malformed::RootNotBlocks(4095))); + assert_eq!(bend(len(4), &0u64.to_le_bytes()), Err(Malformed::RootNotBlocks(0))); + for program in [2, 3] { + assert_eq!(bend(len(program), &0u64.to_le_bytes()), Err(Malformed::Empty(program))); + let past = MAX_BYTES[program] + 1; + assert_eq!(bend(len(program), &past.to_le_bytes()), Err(Malformed::TooLong(program, past))); + } + } + + /// **A role is its place**: the two programs' entries swapped are refused + /// by name, so a header cannot hand the kernel the file server as the + /// supervisor. + #[test] + fn two_programs_swapped_are_refused() { + let good = signed(&header()); + let mut swapped = good; + swapped[entry(2)..entry(3)].copy_from_slice(&good[entry(3)..entry(4)]); + swapped[entry(3)..entry(4)].copy_from_slice(&good[entry(2)..entry(3)]); + assert_eq!(Header::parse(&swapped), Err(Malformed::SectionName(2))); } #[test] fn an_image_splits_into_exactly_its_sections() { - let (kernel, cmdline, root) = (b"\x7fELF kernel".to_vec(), b"root=x".to_vec(), vec![0x5A; 8192]); - let h = Header::of(7, &kernel, &cmdline, &root); + let s = sections(); + let h = Header::of(7, &s); let mut bytes = signed(&h).to_vec(); - bytes.extend_from_slice(&kernel); - bytes.extend_from_slice(&cmdline); - bytes.extend_from_slice(&root); + for section in s.all() { + bytes.extend_from_slice(section); + } let parts = Parts::split(&bytes).expect("an image"); - assert_eq!((parts.kernel, parts.cmdline, parts.root), (&kernel[..], &cmdline[..], &root[..])); + assert_eq!(parts.sections, s); let want = bytes.len() as u64; bytes.push(0); assert_eq!(Parts::split(&bytes).err(), Some(Malformed::Length { have: want + 1, want })); diff --git a/lib/update/src/lib.rs b/lib/update/src/lib.rs index 3ede32a18f..f9c304975c 100644 --- a/lib/update/src/lib.rs +++ b/lib/update/src/lib.rs @@ -4,8 +4,8 @@ //! about it. Pure: no firmware, no device, and no allocation but [`repo`]'s. //! //! **The contract.** An [`image`] is a header naming a monotonic version and -//! the SHA-256 of each of its sections — the kernel, its boot parameter and -//! ROOT — followed by an Ed25519 signature over that header, and then the +//! the SHA-256 of each of its sections — the kernel, its boot parameter, the +//! supervisor, the file server that serves ROOT, and ROOT — followed by an Ed25519 signature over that header, and then the //! sections. The signature covers the hashes and never the bytes, so the //! bytes may arrive by any path and in any order: an `ssh … update < image` //! today, a pull of content-addressed chunks tomorrow, the same signature over @@ -14,7 +14,7 @@ //! ordinary Ed25519 key and a second implementation can sign or check an image. //! //! A machine carries two [`slots`], each a FAT partition holding the kernel, -//! its boot parameter and the signed header, and a ROOT partition holding +//! its boot parameter, the two programs and the signed header, and a ROOT partition holding //! ROOT; the slot table names both slots and marks one. The loader boots the //! marked slot, and falls back to the other where the marked one is refused or //! died on its last boot ([`record`]); the updater writes only the slot the diff --git a/lib/update/src/slots.rs b/lib/update/src/slots.rs index 97ffeed861..07a45754bb 100644 --- a/lib/update/src/slots.rs +++ b/lib/update/src/slots.rs @@ -31,10 +31,12 @@ const BODY_BYTES: usize = 8 + 4 + 4 + 8 + 2 * SLOT_BYTES; /// A copy's bytes, checksum included; the rest of its block is zero. pub const TABLE_BYTES: usize = BODY_BYTES + 4; -/// Where each slot's kernel, boot parameter and signed header are on its FAT -/// partition, as paths from the volume's root. +/// Where each slot's kernel, boot parameter, two programs and signed header +/// are on its FAT partition, as paths from the volume's root. pub const KERNEL_FILE: &str = "toyos/kernel.elf"; pub const CMDLINE_FILE: &str = "toyos/cmdline"; +pub const SUPERVISOR_FILE: &str = "toyos/supervisor.elf"; +pub const FILESERVER_FILE: &str = "toyos/fileserver.elf"; pub const SIGNED_FILE: &str = "toyos/image.sig"; /// A slot's two partitions, by their unique GUIDs as a GPT entry stores them. @@ -217,7 +219,7 @@ pub fn next_write(current: (Table, usize), mut next: Table) -> (usize, [u8; BLOC (1 - current.1, next.encode()) } -/// The labels `/system/bin/supervisor` endows a `slots` grant under, and +/// The labels the 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. pub const TABLE_LABEL: &str = "slots:table"; diff --git a/lib/update/tests/fixtures/ssh-keygen-signed-header.sig b/lib/update/tests/fixtures/ssh-keygen-signed-header.sig index 28f128ee2a..4b4fee3383 100644 --- a/lib/update/tests/fixtures/ssh-keygen-signed-header.sig +++ b/lib/update/tests/fixtures/ssh-keygen-signed-header.sig @@ -1,6 +1,6 @@ -----BEGIN SSH SIGNATURE----- -U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgWDeuq6ibeR5EIpzG6ISSbfZh1g -6o8enc39xZTEnS/7IAAAALdG95b3MtaW1hZ2UAAAAAAAAABnNoYTUxMgAAAFMAAAALc3No -LWVkMjU1MTkAAABAscB9wiZe8D62StbkOswkgTxduwAmOj2jSGN3IAtZddlG8sBiLjtQ0E -SIarUv0tfed85GrCQitXdLzQ0a4f58Bg== +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgkB4U+wgKrR3HDyW75PXkzvD/rC +Zuo/RqJaq8IjoNP68AAAALdG95b3MtaW1hZ2UAAAAAAAAABnNoYTUxMgAAAFMAAAALc3No +LWVkMjU1MTkAAABAMPXcS8H94CSimDN3du917D2kZsQW5RWZqVwEly0162x6En7sclRkhu +geXFOW1vmQucXblPGXWM1NDBb5yHEbAg== -----END SSH SIGNATURE----- diff --git a/lib/update/tests/fixtures/ssh-keygen-signer.pub b/lib/update/tests/fixtures/ssh-keygen-signer.pub index ab01549eb0..9f8816db1f 100644 --- a/lib/update/tests/fixtures/ssh-keygen-signer.pub +++ b/lib/update/tests/fixtures/ssh-keygen-signer.pub @@ -1 +1 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFg3rquom3keRCKcxuiEkm32YdYOqPHp3N/cWUxJ0v+y toyos-image-oracle +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJAeFPsICq0dxw8lu+T15M7w/6wmbqP0aiWqvCI6DT+v toyos-image-oracle diff --git a/lib/update/tests/sshsig_oracle.rs b/lib/update/tests/sshsig_oracle.rs index 369e942c48..35701cb815 100644 --- a/lib/update/tests/sshsig_oracle.rs +++ b/lib/update/tests/sshsig_oracle.rs @@ -9,7 +9,7 @@ //! the fixture is a signature over bytes this crate writes, not over a file it //! only carries. Nothing runs `ssh-keygen` at test time. -use toyos_update::image::{Header, HEADER_BYTES}; +use toyos_update::image::{Header, Sections, HEADER_BYTES}; const SIGNATURE: &str = include_str!("fixtures/ssh-keygen-signed-header.sig"); const PUBLIC: &str = include_str!("fixtures/ssh-keygen-signer.pub"); @@ -18,9 +18,13 @@ const PUBLIC: &str = include_str!("fixtures/ssh-keygen-signer.pub"); fn oracle_header() -> [u8; HEADER_BYTES] { Header::of( 20260926, - b"\x7fELF oracle kernel", - b"root=00000000-0000-0000-0000-000000000000", - &[0x5A; 4096], + &Sections { + kernel: b"\x7fELF oracle kernel", + cmdline: b"slot=A", + supervisor: b"\x7fELF oracle supervisor", + fileserver: b"\x7fELF oracle fileserver", + root: &[0x5A; 4096], + }, ) .encode() } diff --git a/loader/src/main.rs b/loader/src/main.rs index ed4e7a7671..bfb5eee8bf 100644 --- a/loader/src/main.rs +++ b/loader/src/main.rs @@ -36,6 +36,7 @@ mod floor; mod gcd; mod loaderlog; mod microcode; +mod payload; mod rootbridge; mod rootimage; mod seed; @@ -532,8 +533,15 @@ fn report_reach(what: &str, at: u64, len: u64) { ); } +/// What the chosen slot hands the kernel beside its ELF and parameter. +struct Handed { + root: rootimage::RootImage, + supervisor: payload::Payload, + fileserver: payload::Payload, +} + #[allow(clippy::too_many_arguments)] -fn start_kernel(kernel: LoadedKernel, kernel_elf_bytes: vec::Vec, cmdline: vec::Vec, rsdp_addr: u64, gop: Option, boot_part: Option, log_partition_guid: [u8; 16], root_image: rootimage::RootImage, slot_volume_guid: [u8; 16], microcode: Option<&'static [u8]>, entry_counter: u64, wall_clock: Option<(toyos_wallclock::Civil, u64)>, system_table: SystemTable) -> ! { +fn start_kernel(kernel: LoadedKernel, kernel_elf_bytes: vec::Vec, cmdline: vec::Vec, rsdp_addr: u64, gop: Option, boot_part: Option, log_partition_guid: [u8; 16], handed: Handed, slot_volume_guid: [u8; 16], microcode: Option<&'static [u8]>, entry_counter: u64, wall_clock: Option<(toyos_wallclock::Civil, u64)>, system_table: SystemTable) -> ! { // Said before it is refused, for `report_reach`'s reason. match arch::cpu_as_entered() { Ok(None) => {} @@ -628,7 +636,9 @@ fn start_kernel(kernel: LoadedKernel, kernel_elf_bytes: vec::Vec, cmdline: v None => ([0u8; 16], 0, 0, 0), }; - let (root_image_addr, root_image_len, root_partition_guid, root_read_ticks) = root_image.handoff(); + let root_image = handed.root.pages.handoff(); + let (root_partition_guid, root_read_ticks) = (handed.root.partition, handed.root.ticks); + let (supervisor_image, fileserver_image) = (handed.supervisor.handoff(), handed.fileserver.handoff()); // The BSP loads it before `mm::init`, through the boot map. let (microcode_addr, microcode_len) = microcode.map_or((0, 0), |patch| (patch.as_ptr() as u64, patch.len() as u64)); @@ -667,8 +677,7 @@ fn start_kernel(kernel: LoadedKernel, kernel_elf_bytes: vec::Vec, cmdline: v cmdline_len: cmdline.len() as u64, root_bridge_window_count, root_bridge_windows, - root_image_addr, - root_image_len, + root_image, root_partition_guid, loader_entry_counter: entry_counter, loader_handoff_counter: 0, @@ -684,6 +693,8 @@ fn start_kernel(kernel: LoadedKernel, kernel_elf_bytes: vec::Vec, cmdline: v microcode_addr, microcode_len, slot_volume_guid, + supervisor_image, + fileserver_image, }; if let Some(edid) = gop.as_ref().and_then(|g| g.edid) { kernel_args.gop_edid = edid; @@ -944,8 +955,8 @@ fn main(handle: Handle, system_table: SystemTable) -> Status { println!("Log partition: signature {:02x?}", log_guid); - // Every byte the kernel is handed below — its ELF, its parameter and ROOT — - // is one the chosen slot's signed header names. ROOT is read before the + // Every byte the kernel is handed below — its ELF, its parameter, the two + // programs and ROOT — is one the chosen slot's signed header names. ROOT is read before the // kernel is loaded and not after: it is the allocation the image pages come // from, and a slot whose ROOT is refused has no use for the kernel's. let chosen = slot::choose(handle, &system_table, image_floor.value, &record) @@ -985,7 +996,7 @@ fn main(handle: Handle, system_table: SystemTable) -> Status { println!("Loading kernel elf..."); let loaded_kernel = load_kernel_elf(&kernel_bytes); - let microcode = microcode::find(system_table.boot_services(), &chosen.root); + let microcode = microcode::find(system_table.boot_services(), &chosen.root.pages); // Query UEFI GOP before exiting boot services let gop = query_gop(&system_table); @@ -1014,5 +1025,5 @@ fn main(handle: Handle, system_table: SystemTable) -> Status { watchdog::arm(&system_table, rsdp_addr, params); println!("Starting kernel..."); - start_kernel(loaded_kernel, kernel_bytes, cmdline, rsdp_addr, gop, boot_part, log_guid, chosen.root, chosen.volume, microcode, entry_counter, wall_clock, system_table); + start_kernel(loaded_kernel, kernel_bytes, cmdline, rsdp_addr, gop, boot_part, log_guid, Handed { root: chosen.root, supervisor: chosen.supervisor, fileserver: chosen.fileserver }, chosen.volume, microcode, entry_counter, wall_clock, system_table); } diff --git a/loader/src/microcode.rs b/loader/src/microcode.rs index f4d76b56d8..1a6373381c 100644 --- a/loader/src/microcode.rs +++ b/loader/src/microcode.rs @@ -13,14 +13,14 @@ use bcachefs::{BlockBuf, BlockIO, BlockNum, DeviceError, FsError, Mounted, ReadO use toyos_bootmap::BOOT_MAP_BYTES; use crate::efi::{AllocateType, BootServices, PAGE_SIZE}; -use crate::rootimage::RootImage; +use crate::payload::Payload; /// The line every answer here is said on. pub const SAID: &str = "Microcode:"; /// The patch for this CPU, or `None` where ROOT holds none for it or its /// architecture loads none; either way said on [`SAID`]'s line. -pub fn find(bs: &BootServices, root: &RootImage) -> Option<&'static [u8]> { +pub fn find(bs: &BootServices, root: &Payload) -> Option<&'static [u8]> { let cpu = match crate::arch::microcode_cpu()? { Ok(cpu) => cpu, Err(why) => { diff --git a/loader/src/payload.rs b/loader/src/payload.rs new file mode 100644 index 0000000000..e685135c82 --- /dev/null +++ b/loader/src/payload.rs @@ -0,0 +1,63 @@ +//! A section of the slot's signed image in pages of its own, which the kernel +//! keeps out of its allocator by the address it is handed +//! (`toyos_abi::boot::BootImage`). +//! +//! **The contract with the kernel**: the pages are `LoaderData`, whole and +//! page-aligned, the bytes past `len` in the last one are zero, and nothing +//! writes them between the read the slot's hash is checked over and the jump. +//! A check over [`Payload::bytes`] is therefore a check over exactly what the +//! kernel is handed. + +use toyos_abi::boot::BootImage; + +use crate::efi::{AllocateType, BootServices, PAGE_SIZE}; + +/// `len` bytes at the physical address `at`, identity-mapped while boot +/// services live. +pub struct Payload { + at: u64, + len: u64, +} + +impl Payload { + /// Zeroed pages for `len` bytes, `len` non-zero. + pub fn allocate(bs: &BootServices, len: u64) -> Result { + assert!(len != 0, "a payload of no bytes has no pages to hand over"); + let pages = usize::try_from(len.div_ceil(PAGE_SIZE as u64)) + .map_err(|_| alloc::format!("{len} bytes are more pages than this loader can count"))?; + let at = bs + .allocate_pages(AllocateType::AnyPages, pages) + .map_err(|e| alloc::format!("firmware would not give {len} bytes: {e:?}"))?; + // SAFETY: the `pages` pages at `at` were just allocated to this loader, + // are identity-mapped while boot services live, and nothing else holds them. + unsafe { core::ptr::write_bytes(at as *mut u8, 0, pages * PAGE_SIZE) }; + Ok(Self { at, len }) + } + + pub fn bytes(&self) -> &[u8] { + // SAFETY: `at` and `len` are the pages `allocate` took, identity-mapped + // while boot services live, and released only by `free`, which takes + // `self`. + unsafe { core::slice::from_raw_parts(self.at as *const u8, self.len as usize) } + } + + pub fn bytes_mut(&mut self) -> &mut [u8] { + // SAFETY: as `bytes`, and `&mut self` is the one borrow of them. + unsafe { core::slice::from_raw_parts_mut(self.at as *mut u8, self.len as usize) } + } + + /// Give the pages back: a payload whose slot was refused is not the kernel's. + pub fn free(self, bs: &BootServices) { + let pages = self.len.div_ceil(PAGE_SIZE as u64) as usize; + // SAFETY: the pages are this payload's own allocation, and `self` is + // consumed, so nothing reads them after this. + if let Err(e) = unsafe { bs.free_pages(self.at, pages) } { + println!("Payload: firmware would not take back the {} bytes at {:#x} ({e:?})", self.len, self.at); + } + } + + /// Where the kernel is told the payload is. + pub fn handoff(&self) -> BootImage { + BootImage { addr: self.at, len: self.len } + } +} diff --git a/loader/src/rootimage.rs b/loader/src/rootimage.rs index dac9adddfb..e5a593494a 100644 --- a/loader/src/rootimage.rs +++ b/loader/src/rootimage.rs @@ -1,6 +1,7 @@ //! The boot disk as the loader reads it: its slot table, and a slot's ROOT -//! read whole into memory the kernel keeps, so the kernel mounts `/system` -//! from these bytes and needs no storage driver to boot. +//! read whole into memory the kernel keeps and hands the supervisor, whose +//! file server serves `/system` from these bytes, so no storage driver is +//! needed to boot. //! //! **Which disk** is the one firmware loaded this image from and no other: a //! slot is a pair of partitions on one disk, and a slot table on another disk @@ -10,21 +11,19 @@ //! length, and never a superblock's word: the header's SHA-256 is what those //! bytes are held to (`crate::slot`). //! -//! **The contract with the kernel**: [`RootImage`] is made only by -//! [`Disk::read_root`], its pages are `LoaderData`, which the kernel keeps out -//! of its allocator by the address it is handed, as it keeps the black box's -//! page, and nothing writes them between the read and the jump. A check over -//! the image between the read and [`RootImage::handoff`] is therefore a check -//! over exactly what the kernel mounts. +//! [`RootImage`] is made only by [`Disk::read_root`], and its pages are a +//! [`Payload`], whose contract with the kernel its module states. use alloc::alloc::Layout; use alloc::string::String; use core::num::NonZeroU64; +use crate::payload::Payload; + use toyos_gpt::{Guid, Located, Sectors}; use toyos_rootimage::chunk; use toyos_update::slots::{self, Table}; -use crate::efi::{AllocateType, BlockIo, BootServices, DevicePath, Handle, HardDrive, LoadedImage, Scoped, Status}; +use crate::efi::{BlockIo, BootServices, DevicePath, Handle, HardDrive, LoadedImage, Scoped, Status}; /// The unit ROOT's filesystem is written in, and the alignment every buffer /// here is allocated at. @@ -43,41 +42,13 @@ const CHUNK_BOUND: usize = 1 << 20; /// The line a read ROOT is reported on, with where it went and what it cost. pub const READ_AT: &str = "ROOT: read into memory at"; -/// ROOT in memory: `len` bytes at the physical address `at`, identity-mapped -/// while boot services live. +/// ROOT in memory, and where it was read from. pub struct RootImage { - at: u64, - len: u64, + pub pages: Payload, /// The partition it was read from, raw as in its GPT entry. - partition: [u8; 16], + pub partition: [u8; 16], /// The counter ticks the read took. - ticks: u64, -} - -impl RootImage { - /// Where the kernel is told the image is, which partition it came from, and - /// the counter ticks reading it took. - pub fn handoff(&self) -> (u64, u64, [u8; 16], u64) { - (self.at, self.len, self.partition, self.ticks) - } - - /// The image's bytes, for the hash its slot's header names. - pub fn bytes(&self) -> &[u8] { - // SAFETY: `at` and `len` are the pages `Disk::read_root` allocated and - // filled, identity-mapped while boot services live, written by nothing - // after that read, and released only by `free`, which takes `self`. - unsafe { core::slice::from_raw_parts(self.at as *const u8, self.len as usize) } - } - - /// Give the pages back: a ROOT whose slot was refused is not the kernel's. - pub fn free(self, bs: &BootServices) { - // SAFETY: the pages are this image's own allocation, and `self` is - // consumed, so nothing reads them after this. - let freed = unsafe { bs.free_pages(self.at, (self.len as usize).div_ceil(BLOCK)) }; - if let Err(e) = freed { - println!("ROOT: firmware would not take back the {} bytes at {:#x} ({e:?})", self.len, self.at); - } - } + pub ticks: u64, } /// The whole-disk block device carrying the partition firmware loaded this @@ -199,15 +170,8 @@ impl<'a> Disk<'a> { if len > capacity || !len.is_multiple_of(BLOCK as u64) { return Err(alloc::format!("{len} bytes of ROOT do not fit whole in its {capacity}-byte partition")); } - let pages = (len / BLOCK as u64) as usize; - // `LoaderData`, as the black box's page is, for the reason its module - // header gives. - let at = bs - .allocate_pages(AllocateType::AnyPages, pages) - .map_err(|e| alloc::format!("firmware would not give {len} bytes for ROOT: {e:?}"))?; - // SAFETY: the `pages` pages at `at` were just allocated to this loader, - // are identity-mapped while boot services live, and nothing else holds them. - let into = unsafe { core::slice::from_raw_parts_mut(at as *mut u8, len as usize) }; + let mut pages = Payload::allocate(bs, len).map_err(|why| alloc::format!("ROOT: {why}"))?; + let at = pages.handoff().addr; let granularity = self.granularity_lbas(); // Chunks are whole `BLOCK`s from a page-aligned buffer, so each one @@ -215,8 +179,8 @@ impl<'a> Disk<'a> { let chunk = chunk::chunk_bytes(CHUNK_BOUND, BLOCK, self.lba_bytes, granularity.unwrap_or(0)); let began = crate::arch::counter(); let mut device = Firmware { io: &self.io, media_id: self.media_id }; - let read = chunk::read(&mut device, part.first_lba(), self.lba_bytes, chunk, into); - let image = RootImage { at, len, partition: part.unique_guid().0, ticks: crate::arch::counter().wrapping_sub(began) }; + let read = chunk::read(&mut device, part.first_lba(), self.lba_bytes, chunk, pages.bytes_mut()); + let image = RootImage { pages, partition: part.unique_guid().0, ticks: crate::arch::counter().wrapping_sub(began) }; if let Err(failed) = read { let why = alloc::format!( "the read of {} blocks at LBA {} failed: {:?}, after {} of {len} bytes read", @@ -225,7 +189,7 @@ impl<'a> Disk<'a> { failed.error, failed.read ); - image.free(bs); + image.pages.free(bs); return Err(why); } println!( diff --git a/loader/src/slot.rs b/loader/src/slot.rs index 9e026020ee..1f39677321 100644 --- a/loader/src/slot.rs +++ b/loader/src/slot.rs @@ -6,7 +6,8 @@ //! booted only once, in this order: its signed header is on its FAT //! partition, the signature is [`KEY`]'s, its image did not die on its last //! boot, its version is at or above the floor a boot has proven, and its -//! kernel, boot parameter and ROOT each hash to what the header names. Each +//! kernel, boot parameter, supervisor, file server and ROOT each hash to what +//! the header names. Each //! refusal is said by name, and the boot that falls back tells the kernel //! which slot it refused and why, so the next boot's log carries it. //! @@ -22,13 +23,14 @@ use alloc::string::String; use alloc::vec::Vec; -use toyos_update::image::{Header, SIGNATURE_BYTES, SIGNED_BYTES}; +use toyos_update::image::{Header, Section, SIGNATURE_BYTES, SIGNED_BYTES}; use toyos_update::policy::{self, Refusal}; use toyos_update::record::{self, Booted, Record}; use toyos_update::slots::{self, Slot, Which}; use toyos_update::{sig, Digest}; use crate::efi::{BootServices, CString16, Handle, Mode, SimpleFileSystem, Status, SystemTable}; +use crate::payload::Payload; use crate::rootimage::{Disk, RootImage}; /// The owner's public key, embedded at build time: the only key this loader @@ -46,6 +48,8 @@ pub struct Chosen { pub digest: Digest, pub kernel: Vec, pub cmdline: Vec, + pub supervisor: Payload, + pub fileserver: Payload, pub root: RootImage, /// The unique GUID of its volume, which the kernel and its parameter were read from. pub volume: [u8; 16], @@ -144,20 +148,64 @@ fn verify( held(&kernel, header.kernel().sha256, "kernel")?; let cmdline = read_section(bs, &slot.boot, slots::CMDLINE_FILE, header.cmdline().len, "cmdline")?; held(&cmdline, header.cmdline().sha256, "cmdline")?; + let supervisor = program(bs, &slot.boot, slots::SUPERVISOR_FILE, header.supervisor(), "supervisor")?; + let fileserver = match program(bs, &slot.boot, slots::FILESERVER_FILE, header.fileserver(), "fileserver") { + Ok(fileserver) => fileserver, + Err(why) => { + supervisor.free(bs); + return Err(why); + } + }; - let root = disk.read_root(bs, &part, header.root().len).map_err(|why| { - println!("{HEAD} {letter}: ROOT: {why}"); - Refusal::Unreadable("root") - })?; + let root = match disk.read_root(bs, &part, header.root().len) { + Ok(root) => root, + Err(why) => { + println!("{HEAD} {letter}: ROOT: {why}"); + supervisor.free(bs); + fileserver.free(bs); + return Err(Refusal::Unreadable("root")); + } + }; let began = crate::arch::counter(); - let root_hash = toyos_update::sha256(root.bytes()); + let root_hash = toyos_update::sha256(root.pages.bytes()); println!("{HEAD} {letter}: ROOT hashed in {} counter ticks", crate::arch::counter().wrapping_sub(began)); if root_hash != header.root().sha256 { - root.free(bs); + root.pages.free(bs); + supervisor.free(bs); + fileserver.free(bs); return Err(Refusal::Hash("root")); } - println!("{HEAD} {letter}: kernel, cmdline and ROOT are the bytes the signed header names"); - Ok(Chosen { which, version: header.version, digest, kernel, cmdline, root, volume: slot.boot, refused: None }) + println!("{HEAD} {letter}: kernel, cmdline, supervisor, fileserver and ROOT are the bytes the signed header names"); + Ok(Chosen { + which, + version: header.version, + digest, + kernel, + cmdline, + supervisor, + fileserver, + root, + volume: slot.boot, + refused: None, + }) +} + +/// The program section at `path`, in pages of its own and held to `section`'s +/// hash. +fn program(bs: &BootServices, guid: &[u8; 16], path: &str, section: Section, name: &'static str) -> Result { + let bytes = read_section(bs, guid, path, section.len, name)?; + let mut pages = Payload::allocate(bs, section.len).map_err(|why| { + println!("{HEAD}: {path}: {why}"); + Refusal::Unreadable(name) + })?; + pages.bytes_mut().copy_from_slice(&bytes); + drop(bytes); + // Over the pages the kernel is handed, not over the buffer they came from. + if let Err(why) = held(pages.bytes(), section.sha256, name) { + pages.free(bs); + return Err(why); + } + Ok(pages) } /// `slot`'s signed header, held to [`KEY`], and its SHA-256. diff --git a/loader/src/watchdog.rs b/loader/src/watchdog.rs index 2214bd6ed3..6e07a688b6 100644 --- a/loader/src/watchdog.rs +++ b/loader/src/watchdog.rs @@ -48,7 +48,7 @@ impl Phys for Identity { /// Arm the watchdog when `cmdline` names it, and say on the console what was /// armed or why nothing was. pub fn arm(system_table: &SystemTable, rsdp_addr: u64, cmdline: &str) { - if !toyos_abi::boot::actuators(cmdline).any(|token| token == toyos_tco::PARAM) { + if !toyos_abi::boot::tokens(cmdline).any(|token| token == toyos_tco::PARAM) { return; } if !crate::arch::pio::EXISTS { diff --git a/sdk/abi/src/boot.rs b/sdk/abi/src/boot.rs index 4190b97681..d2fd0f8f05 100644 --- a/sdk/abi/src/boot.rs +++ b/sdk/abi/src/boot.rs @@ -63,10 +63,10 @@ pub struct KernelArgs { pub layout: u32, /// The boot parameter, as ASCII with no terminator: comma-separated tokens /// read out of `\toyos\cmdline` on the volume the bootloader loaded itself - /// from. [`root_uuid`] and [`actuators`] are the two readings of it. + /// from, read by [`tokens`]. /// - /// Every shipping image carries exactly `root=`, and a kernel that - /// carries no actuators refuses any other token rather than ignoring it + /// Every shipping image carries none, and a kernel that + /// carries no actuators refuses any token rather than ignoring it /// (`kernel/src/actuator.rs`). It is in this struct rather than anywhere the /// kernel could go and fetch it because the earliest actuator panics before /// `mm::init` and another acts at AP bring-up: a parameter that is not here @@ -88,16 +88,10 @@ pub struct KernelArgs { /// land inside, and there is no deriving it from the memory map — the map /// says what firmware *used*, not what the bridge would decode. pub root_bridge_windows: [RootBridgeWindow; MAX_ROOT_BRIDGE_WINDOWS], - /// Where the loader put ROOT: its filesystem, read through the firmware's - /// block I/O into `LoaderData` pages, which the kernel mounts from memory and - /// keeps out of its allocator by this address, as it keeps the black box's. - /// - /// These are the bytes a signature check over ROOT has to cover: the loader - /// writes them once, and nothing writes them between that read and the - /// kernel's mount. Zero length is a loader that handed no image, and the - /// kernel refuses to boot on it by name; it never reads ROOT anywhere else. - pub root_image_addr: u64, - pub root_image_len: u64, + /// ROOT's bytes, opaque to the kernel: the slot's ROOT partition read + /// through the firmware's block I/O. [`Self::supervisor_image`] says what + /// every [`BootImage`] is. + pub root_image: BootImage, /// The unique GUID of the partition the image was read from, raw as in the /// GPT entry like [`Self::boot_partition_guid`]; zero with no image. The /// kernel holds that partition so no claim writes the slot it is running. @@ -145,6 +139,27 @@ pub struct KernelArgs { /// read the kernel and its parameter from, raw as in the GPT entry like /// [`Self::root_partition_guid`]; zero with no image. pub slot_volume_guid: [u8; 16], + /// The supervisor's executable and the file server's, which the kernel + /// hands the supervisor with [`Self::root_image`]: the programs that serve + /// ROOT, so the kernel opens no file to start the machine. + /// + /// **Every `BootImage` is a section of the slot's signed header**, read + /// into page-aligned `LoaderData` pages and held to its hash before the + /// loader exits boot services; nothing writes it after. The kernel keeps + /// each out of its allocator for the machine's life, and refuses by name a + /// boot whose image is empty, unaligned, outside one `LoaderData` + /// descriptor or overlapping another region the loader handed. + pub supervisor_image: BootImage, + pub fileserver_image: BootImage, +} + +/// One payload the loader hands the kernel: `len` bytes at the physical +/// address `addr`. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BootImage { + pub addr: u64, + pub len: u64, } /// The bytes of [`KernelArgs::gop_edid`]: one EDID base block. @@ -163,10 +178,6 @@ pub const LAYOUT: u32 = 0x5459_0000 | core::mem::size_of::() as u32; /// negative control on the kernel's refusal, and read by both of them. pub const WRITE_NO_LAYOUT_PARAM: &str = "loader-writes-no-layout"; -/// The boot parameter on which the loader hands the kernel no ROOT image: the -/// negative control on the kernel's refusal, and read by both of them. -pub const WITHHOLD_ROOT_PARAM: &str = "loader-withholds-root"; - /// The word the loader appends naming the slot it booted, `A` or `B`: the /// signed boot parameter is the slot's own, and which slot carried it is a /// fact only the loader has. @@ -211,23 +222,9 @@ impl RootBridgeWindow { } } -/// The token naming the filesystem the kernel mounts as root. -const ROOT_PARAM: &str = "root="; - -/// What the boot parameter names ROOT, or `None` on a parameter that names none. -/// -/// `bcachefs::FsUuid`'s text, compared against each candidate partition's -/// *superblock* and never against a partition GUID: a role names a filesystem, -/// and a filesystem may have members on more than one disk. -pub fn root_uuid(cmdline: &str) -> Option<&str> { - cmdline.split(',').find_map(|token| token.strip_prefix(ROOT_PARAM)) -} - -/// Every token of the boot parameter that is not [`root_uuid`]'s. A token this -/// yields is one the actuator table has to declare, so a `root=` left in would -/// panic a kernel that boots. -pub fn actuators(cmdline: &str) -> impl Iterator { - cmdline.split(',').filter(|t| !t.is_empty() && !t.starts_with(ROOT_PARAM)) +/// Every token of the boot parameter: each one a parameter of the kernel's (`params`) or an actuator its table declares. +pub fn tokens(cmdline: &str) -> impl Iterator { + cmdline.split(',').filter(|t| !t.is_empty()) } impl KernelArgs { @@ -262,8 +259,7 @@ const _: () = { assert!(offset_of!(KernelArgs, cmdline_len) == 176); assert!(offset_of!(KernelArgs, root_bridge_window_count) == 184); assert!(offset_of!(KernelArgs, root_bridge_windows) == 192); - assert!(offset_of!(KernelArgs, root_image_addr) == 1216); - assert!(offset_of!(KernelArgs, root_image_len) == 1224); + assert!(offset_of!(KernelArgs, root_image) == 1216); assert!(offset_of!(KernelArgs, root_partition_guid) == 1232); assert!(offset_of!(KernelArgs, loader_entry_counter) == 1248); assert!(offset_of!(KernelArgs, loader_handoff_counter) == 1256); @@ -278,7 +274,10 @@ const _: () = { assert!(offset_of!(KernelArgs, microcode_addr) == 1472); assert!(offset_of!(KernelArgs, microcode_len) == 1480); assert!(offset_of!(KernelArgs, slot_volume_guid) == 1488); - assert!(size_of::() == 1504); + assert!(offset_of!(KernelArgs, supervisor_image) == 1504); + assert!(offset_of!(KernelArgs, fileserver_image) == 1520); + assert!(size_of::() == 1536); + assert!(size_of::() == 16); assert!(LAYOUT as i32 > 1440 || (LAYOUT as i32) < -1440); assert!(align_of::() == 8); assert!(size_of::() == 16); @@ -296,26 +295,11 @@ pub struct MemoryMapEntry { mod tests { use super::*; - /// Every token is one reading's or the other's, and neither sees the - /// other's: a `root=` reaching the actuator table panics a kernel. #[test] - fn the_two_readings_of_a_boot_parameter_partition_it() { - const ROOT: &str = "0123456789abcdef0123456789abcdef"; - const SHIPPING: &str = "root=0123456789abcdef0123456789abcdef"; - const ARMED: &str = - "root=0123456789abcdef0123456789abcdef,usb-flush-fails,fat-boot-reads-fail"; - - assert_eq!(root_uuid(SHIPPING), Some(ROOT)); - assert_eq!(actuators(SHIPPING).count(), 0); - - assert_eq!(root_uuid(ARMED), Some(ROOT)); - assert!(actuators(ARMED).eq(["usb-flush-fails", "fat-boot-reads-fail"])); - - // No root filesystem, and no parameter at all: neither reading invents. - assert_eq!(root_uuid("usb-flush-fails"), None); - assert!(actuators("usb-flush-fails").eq(["usb-flush-fails"])); - assert_eq!(root_uuid(""), None); - assert_eq!(actuators("").count(), 0); + fn a_boot_parameter_is_its_non_empty_tokens() { + assert!(tokens("usb-flush-fails,fat-boot-reads-fail").eq(["usb-flush-fails", "fat-boot-reads-fail"])); + assert!(tokens("boot-slot=A,,x").eq(["boot-slot=A", "x"])); + assert_eq!(tokens("").count(), 0); } #[test] @@ -367,8 +351,7 @@ mod tests { root_bridge_window_count: 0, root_bridge_windows: [RootBridgeWindow { base: 0, length: 0 }; MAX_ROOT_BRIDGE_WINDOWS], - root_image_addr: 0, - root_image_len: 0, + root_image: BootImage { addr: 0, len: 0 }, root_partition_guid: [0; 16], loader_entry_counter: 0, loader_handoff_counter: 0, @@ -383,6 +366,8 @@ mod tests { microcode_addr: 0, microcode_len: 0, slot_volume_guid: [0; 16], + supervisor_image: BootImage { addr: 0, len: 0 }, + fileserver_image: BootImage { addr: 0, len: 0 }, }; #[test] diff --git a/sdk/abi/src/syscall.rs b/sdk/abi/src/syscall.rs index 627a0f9312..3210ca3fc3 100644 --- a/sdk/abi/src/syscall.rs +++ b/sdk/abi/src/syscall.rs @@ -341,7 +341,7 @@ crate::user_safe! { pub labels_ptr: u64, pub labels_len: u64, /// The child's working directory, absolute, or the spawn is refused - /// `InvalidArgument`. Under a name the kernel serves (`/system`, `/tmp`) it + /// `InvalidArgument`. Under the name the kernel serves (`/tmp`) it /// must be a directory there or the spawn is `NotFound`; under any other /// name it is a file server's directory, which the caller's client asked /// that server about and the kernel cannot. **Always the caller's @@ -418,6 +418,13 @@ const _: () = assert!(core::mem::size_of::() == 16); pub const SYSCAP_LABEL: &str = "syscap"; /// The label for a program's namespace — what its manifest `receives` becomes. pub const SVC_LABEL: &str = "svc"; +/// The labels the kernel puts on the immutable memory objects it hands the +/// supervisor beside the system capability: the file server's executable and +/// ROOT's bytes, both as the loader verified them (`boot::KernelArgs`). The +/// file server's is also the path its manifest row names, which is where its +/// image comes from. +pub const FILESERVER_IMAGE_LABEL: &str = "boot:fileserver"; +pub const ROOT_IMAGE_LABEL: &str = "boot:root"; /// The label every process starts holding a handle to itself under, carrying /// `WRITE`, `DUP` and `TRANSFER`: what it hands on for a child to be placed /// under it ([`SpawnArgs::place`]). The kernel puts it in every table. @@ -604,6 +611,10 @@ pub enum FileType { DeviceMemory = 13, /// A [null](null) handle. Null = 14, + /// A region nobody can write, which every mapping of is read-only: a + /// payload the loader verified (`boot::BootImage`); `size` is the + /// payload's length, within the region's pages. + ImmutableMemory = 15, } impl FileType { @@ -621,6 +632,7 @@ impl FileType { 12 => Some(Self::Symlink), 13 => Some(Self::DeviceMemory), 14 => Some(Self::Null), + 15 => Some(Self::ImmutableMemory), _ => None, } } diff --git a/sdk/osrelease/src/lib.rs b/sdk/osrelease/src/lib.rs index c738b6a261..8cb7f6545a 100644 --- a/sdk/osrelease/src/lib.rs +++ b/sdk/osrelease/src/lib.rs @@ -1,5 +1,5 @@ //! `/system/etc/os-release`: which build an image is, written by the build -//! system into ROOT and read by `/system/bin/supervisor`, libc's `uname` and +//! system into ROOT and read by the supervisor, libc's `uname` and //! the sysinfo fork. //! //! The freedesktop os-release format (`os-release(5)`), ToyOS's own fields @@ -50,9 +50,14 @@ pub const NAME: &str = "ToyOS"; /// `ID`, the operating system's identifier. pub const ID: &str = "toyos"; -/// What `/system/bin/supervisor` says a release under, followed by its commit. +/// What the supervisor says a release under, followed by its commit. pub const SAID: &str = "supervisor: build "; +/// What the file server serving `/system` says ROOT under, followed by its +/// filesystem's UUID: a name derived from every file ROOT carries, so the +/// build again, said by the server that serves it. +pub const ROOT_SAID: &str = "fileserver: ROOT is filesystem "; + /// The architecture an image is built for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Arch { diff --git a/sdk/std/os/process.rs b/sdk/std/os/process.rs index 285421723e..8d772bce6e 100644 --- a/sdk/std/os/process.rs +++ b/sdk/std/os/process.rs @@ -46,7 +46,7 @@ pub trait CommandExt { /// find the program, judge the working directory, and read a program on a /// file server into memory. The spawn then calls no file server for them — /// for a caller whose spawning thread may not wait on one, as - /// `/system/bin/supervisor`'s loop may not wait on the file servers it starts + /// the supervisor's loop may not wait on the file servers it starts /// again. A later [`current_dir`](crate::process::Command::current_dir) /// undoes it but for the program read, which a prepare after it keeps and /// a later [`image_from`](CommandExt::image_from) does not. @@ -70,6 +70,13 @@ pub trait CommandExt { #[stable(feature = "toyos_ext", since = "1.0.0")] fn image_from(&mut self, path: &crate::path::Path) -> &mut Self; + /// Run the boot image `image` names, an immutable region this process + /// holds and keeps, rather than any file: the command's program is the + /// child's `argv[0]` and nothing opens it. What the supervisor starts the + /// file server with before any file can be read. + #[stable(feature = "toyos_ext", since = "1.0.0")] + fn boot_image(&mut self, image: u32) -> &mut Self; + /// Place the child under the process `place` names rather than under this /// one, so that its end, not this process's, takes the child down. /// @@ -124,6 +131,11 @@ impl CommandExt for crate::process::Command { self } + fn boot_image(&mut self, image: u32) -> &mut Self { + self.as_inner_mut().boot_image(image); + self + } + fn under(&mut self, place: u32) -> &mut Self { self.as_inner_mut().under(place); self diff --git a/sdk/std/sys/fs.rs b/sdk/std/sys/fs.rs index ffecb3da6c..a42f8bf1c5 100644 --- a/sdk/std/sys/fs.rs +++ b/sdk/std/sys/fs.rs @@ -1,5 +1,5 @@ -//! Files on ToyOS: a path is served by the kernel (`/system`, `/tmp`) or by a -//! file server behind one of the process's directory capabilities, and +//! Files on ToyOS: a path is served by the kernel (`/tmp`) or by a file +//! server behind one of the process's directory capabilities, and //! `toyos::files` routes it, follows its links and keeps what it opened. A //! `File` is one descriptor of the process's table either way. diff --git a/sdk/std/sys/process.rs b/sdk/std/sys/process.rs index a60be02f03..dedf7f4f9b 100644 --- a/sdk/std/sys/process.rs +++ b/sdk/std/sys/process.rs @@ -34,6 +34,9 @@ pub struct Command { /// it read: kept across a later working directory, so a spawn starts the /// bytes the caller saw ([`Command::prepared_image`]). image: Option<(OsString, toyos::process::Image)>, + /// A boot image this process holds, which the child runs instead of any + /// file ([`Command::boot_image`]). + boot_image: Option, } /// The file reads a spawn needs, made ahead of it ([`Command::prepare`]). @@ -83,6 +86,7 @@ impl Command { image_path: None, prepared: None, image: None, + boot_image: None, } } @@ -96,6 +100,17 @@ impl Command { self.image_path = Some(path.to_owned()); self.prepared = None; self.image = None; + self.boot_image = None; + } + + /// Run the immutable region `handle` names, which this process keeps, + /// rather than any file: `argv[0]` is the program the command names, and + /// nothing opens it. + pub fn boot_image(&mut self, handle: u32) { + self.boot_image = Some(handle); + self.image_path = None; + self.prepared = None; + self.image = None; } pub fn env_mut(&mut self) -> &mut CommandEnv { @@ -117,7 +132,7 @@ impl Command { let kept = self.image.as_ref().is_some_and(|(read, _)| *read == source); if !kept { self.image = None; - if crate::sys::fs::is_served(Path::new(&source)) { + if self.boot_image.is_none() && crate::sys::fs::is_served(Path::new(&source)) { let image = crate::sys::fs::read_image(Path::new(&source))?; self.image = Some((source, image)); } @@ -208,7 +223,7 @@ impl Command { fn resolve_program(&self) -> io::Result { let prog = self.program.to_str().unwrap_or(""); - if prog.contains('/') { + if prog.contains('/') || self.boot_image.is_some() { return Ok(self.program.clone()); } // Search PATH for the executable @@ -323,10 +338,19 @@ impl Command { // this process's own that the kernel pages the child from: the kernel // opens only what it serves itself. let source = Path::new(image_path.unwrap_or(program)); - let image = match prepared_image { - Some(image) => Some(image), - None if crate::sys::fs::is_served(source) => Some(crate::sys::fs::read_image(source)?), - None => None, + let image = match (prepared_image, self.boot_image) { + (Some(image), _) => Some(image), + (None, Some(handle)) => { + let copy = toyos_abi::syscall::dup(toyos_abi::RawHandle(handle)).map_err(crate::sys::to_io_error)?; + // SAFETY: `dup` answered a fresh handle nothing else answers for. + let copy = unsafe { toyos::OwnedHandle::from_raw(copy) }; + let region = toyos::shm::Immutable::adopt(copy).map_err(|why| { + io::Error::new(io::ErrorKind::InvalidInput, format!("the boot image is no immutable region: {why}")) + })?; + Some(toyos::process::Image::boot(region)) + } + (None, None) if crate::sys::fs::is_served(source) => Some(crate::sys::fs::read_image(source)?), + (None, None) => None, }; let spawned = request.direct(&direct_env, image.as_ref()); // The child keeps the object alive; this process's handle, and with it diff --git a/sdk/toyos/src/lib.rs b/sdk/toyos/src/lib.rs index 7e65d15ff7..b3621c4a72 100644 --- a/sdk/toyos/src/lib.rs +++ b/sdk/toyos/src/lib.rs @@ -211,7 +211,7 @@ impl From for OwnedHandle { fn from(pipe: Pipe) -> Self { pipe.0 } } -/// A console handle: the one `/system/bin/supervisor` endows `/system/bin/logkeeper` with, +/// A console handle: the one the supervisor endows `/system/bin/logkeeper` with, /// the only one in the machine that may write. /// /// **A write takes whole lines and says how many bytes that was.** The diff --git a/sdk/toyos/src/log/mod.rs b/sdk/toyos/src/log/mod.rs index c8f5b403c2..b60ccaab22 100644 --- a/sdk/toyos/src/log/mod.rs +++ b/sdk/toyos/src/log/mod.rs @@ -5,7 +5,7 @@ //! [`ring`]): its stdout and stderr, and what it says with [`say!`](crate::say), //! [`warn!`](crate::warn) and [`error!`](crate::error), each stamped with its //! time, severity and thread as it is written ([`stdio`]). Whose lines they are is decided where the ring was made: -//! `/system/bin/supervisor` names each ring to `/system/bin/logkeeper`, and nothing a +//! the supervisor names each ring to `/system/bin/logkeeper`, and nothing a //! program writes can change the name. //! //! **The kernel's records** are read with [`LogTail`]. The kernel keeps no diff --git a/sdk/toyos/src/log/region.rs b/sdk/toyos/src/log/region.rs index f9363459f5..b7ec111d8d 100644 --- a/sdk/toyos/src/log/region.rs +++ b/sdk/toyos/src/log/region.rs @@ -2,7 +2,7 @@ //! header, [`LANES`] lanes of [`LANE_SLOTS`] slots, and [`SHARED_SLOTS`] slots //! of shared ring — each slot a protocol word and a [`Body`]. //! -//! `/system/bin/supervisor` creates one per program it starts, lays it out before +//! The supervisor creates one per program it starts, lays it out before //! any other process can map it ([`Ring::lay_out`]), gives the program //! duplicates as its stdout and stderr, and hands `/system/bin/logkeeper` the //! region with the program's name. The program writes, `logkeeper` reads, and diff --git a/sdk/toyos/src/log/stdio.rs b/sdk/toyos/src/log/stdio.rs index 25b3db8186..f8e57a5752 100644 --- a/sdk/toyos/src/log/stdio.rs +++ b/sdk/toyos/src/log/stdio.rs @@ -2,7 +2,7 @@ //! when the stream is one, bytes into its descriptor when it is anything else. //! //! **Where a stream goes is what its descriptor names** (`crate::fd`). -//! `/system/bin/supervisor` gives a program its log ring as descriptors 1 and +//! The supervisor gives a program its log ring as descriptors 1 and //! 2, so a daemon's output is records; a program a terminal started holds that //! terminal's pipes there, so its output is bytes on the terminal. The first //! write to a stream asks the descriptor once ([`FileType::SharedMemory`] is a diff --git a/sdk/toyos/src/power.rs b/sdk/toyos/src/power.rs index 56f07a154f..5ecbb525f4 100644 --- a/sdk/toyos/src/power.rs +++ b/sdk/toyos/src/power.rs @@ -1,4 +1,4 @@ -//! Asking `/system/bin/supervisor` to stop the machine. +//! Asking the supervisor to stop the machine. //! //! **The stop is the supervisor's to sequence, because the log is a process.** The //! kernel stops every userland thread at once and waits for none of them, so diff --git a/sdk/toyos/src/process.rs b/sdk/toyos/src/process.rs index bd75ed15f7..84085baa5a 100644 --- a/sdk/toyos/src/process.rs +++ b/sdk/toyos/src/process.rs @@ -11,7 +11,7 @@ use toyos_abi::handle::Rights; use toyos_abi::syscall::{self, ProcessStats, SyscallError}; use crate::endow::FromHandle; -use crate::shm::SharedMemory; +use crate::shm::{Immutable, SharedMemory}; use crate::{AsHandle, OwnedHandle, RawHandle}; /// The `` number POSIX names an end by, as libc's `WTERMSIG` @@ -101,14 +101,21 @@ impl FromHandle for Process { } } -/// A program's bytes in a memory object of this process's own, which the -/// kernel pages a child from: how a program the kernel does not serve is -/// spawned (`SpawnArgs::image`). +/// A program's bytes in a memory object this process holds, which the kernel +/// pages a child from: how a program the kernel does not serve is spawned +/// (`SpawnArgs::image`). pub struct Image { - object: SharedMemory, + object: Object, len: u64, } +enum Object { + /// Read into memory of this process's own. + Read(SharedMemory), + /// A boot image the kernel handed over. + Boot(Immutable), +} + /// Why [`Image::read`] made no image. #[derive(Debug)] pub enum ImageRefused { @@ -141,18 +148,32 @@ impl Image { n => done += n, } } - Ok(Self { object, len }) + Ok(Self { object: Object::Read(object), len }) + } + + /// The program a boot image is, whole. + pub fn boot(image: Immutable) -> Self { + let len = image.as_slice().len() as u64; + Self { object: Object::Boot(image), len } } /// The program's bytes, as they were read. pub fn bytes(&self) -> &[u8] { - &self.object.as_slice()[..self.len as usize] + let bytes = match &self.object { + Object::Read(object) => object.as_slice(), + Object::Boot(image) => image.as_slice(), + }; + &bytes[..self.len as usize] } /// What `SpawnArgs::image` and `SpawnArgs::image_len` carry. The object is /// this process's until the spawn returns; the child keeps its own. pub fn spawn_words(&self) -> (u64, u64) { - (u64::from(self.object.as_handle().0), self.len) + let handle = match &self.object { + Object::Read(object) => object.as_handle(), + Object::Boot(image) => image.as_handle(), + }; + (u64::from(handle.0), self.len) } } diff --git a/sdk/toyos/src/shm.rs b/sdk/toyos/src/shm.rs index 8a69f13083..7c8ad344c5 100644 --- a/sdk/toyos/src/shm.rs +++ b/sdk/toyos/src/shm.rs @@ -16,6 +16,10 @@ //! length the protocol says, is refused before it is mapped, since mapping a //! handle that names no region ends this process. [`SharedMemory::adopt`] is //! for a region the kernel itself handed this process. +//! +//! **A region nobody can write is an [`Immutable`]**, taken only once the +//! kernel answers it is one: every mapping of it is read-only, so it has no +//! mutable view to offer. use core::fmt; use core::sync::atomic::AtomicU8; @@ -152,6 +156,45 @@ impl AsHandle for SharedMemory { } } +/// A region nobody can write, mapped read-only: a payload the loader verified, +/// as the kernel hands it out (`toyos_abi::boot::BootImage`). +pub struct Immutable { + handle: OwnedHandle, + ptr: *const u8, + len: usize, +} + +// SAFETY: the mapping is read-only and nobody writes the region. +unsafe impl Send for Immutable {} +// SAFETY: as for `Send`: there is nothing to race with. +unsafe impl Sync for Immutable {} + +impl Immutable { + /// Map `handle`, refused and never mapped unless the kernel answers it is + /// a region nobody can write. Its length is the kernel's answer. The handle + /// is consumed either way. + pub fn adopt(handle: OwnedHandle) -> Result { + let stat = syscall::fstat(handle.raw()).map_err(Lent::Kernel)?; + if stat.file_type != FileType::ImmutableMemory { + return Err(Lent::NotRam); + } + // A region the kernel maps is within the address space, so its length is a `usize`. + Ok(Self { ptr: map(&handle).map_err(Lent::Kernel)?, handle, len: stat.size as usize }) + } + + pub fn as_slice(&self) -> &[u8] { + // SAFETY: the mapping is `len` bytes, lives as long as `self`, and is + // written by nobody, so a shared reference over it is sound. + unsafe { core::slice::from_raw_parts(self.ptr, self.len) } + } +} + +impl AsHandle for Immutable { + fn as_handle(&self) -> RawHandle { + self.handle.raw() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/system/CLAUDE.md b/system/CLAUDE.md index 8fa936c3a7..1f43e64ad2 100644 --- a/system/CLAUDE.md +++ b/system/CLAUDE.md @@ -1,6 +1,6 @@ # System programs -The module header at the site owns its subject — surfaces, translators and the channel in `sdk/toyos/`'s surface modules, soundserver whole under `system/soundserver/`, what a process holds and how it got it at `kernel/src/object/` and `/system/bin/supervisor`. The compositor's decisions are `system/compositor/desktop/`, pure and host-tested; `system/compositor/` is devices, handles, shared memory and the panel. +The module header at the site owns its subject — surfaces, translators and the channel in `sdk/toyos/`'s surface modules, soundserver whole under `system/soundserver/`, what a process holds and how it got it at `kernel/src/object/` and `system/supervisor/`. The compositor's decisions are `system/compositor/desktop/`, pure and host-tested; `system/compositor/` is devices, handles, shared memory and the panel. **A server never blocks on a client** — the doctrine no single site owns. Accept and the first frame are two events; a frame is buffered until whole before anything acts on it; a write is one `try_send` whose refusal drops the peer by name; a blocking read or write of a pipe the client owns is the same bug. The supervisor, the compositor, netstack, soundserver and every surface host use `ipc::FrameRx`. diff --git a/system/acpiserver/src/host.rs b/system/acpiserver/src/host.rs index f7b9dcca43..9a610ca6d2 100644 --- a/system/acpiserver/src/host.rs +++ b/system/acpiserver/src/host.rs @@ -157,7 +157,7 @@ pub trait Kernel { fn latched(&self) -> Option; /// Acknowledge the SCI, whose status this server has cleared. fn ack(&self); - /// Ask `/system/bin/supervisor` to power the machine off + /// Ask the supervisor to power the machine off /// (`toyos::power::stop`), which answers only where it did not. fn power_off(&self) -> toyos::power::Refused; } diff --git a/system/console/src/main.rs b/system/console/src/main.rs index 0a897822d3..6700fc8a98 100644 --- a/system/console/src/main.rs +++ b/system/console/src/main.rs @@ -62,7 +62,7 @@ const SOURCE_FRAMES: usize = 64; /// apply. const SEED_MAX_BYTES: usize = 64 * 1024; -/// The name `/system/bin/supervisor` starts this program under, which its own lines +/// The name the supervisor starts this program under, which its own lines /// come back from `logkeeper` tagged with. const OWN_TAG: &str = "console"; diff --git a/system/filepicker/src/consent.rs b/system/filepicker/src/consent.rs index 2c0febbcca..60ab92f006 100644 --- a/system/filepicker/src/consent.rs +++ b/system/filepicker/src/consent.rs @@ -1,4 +1,4 @@ -//! The question `/system/bin/supervisor` asks before an installed package's +//! The question the supervisor asks before an installed package's //! launch, drawn in the compositor's prompt layer ([`toyos_manifest::consent`]). //! //! **A chooser bounded by the folder rules**: it starts at the session user's diff --git a/system/fileserver/Cargo.toml b/system/fileserver/Cargo.toml index e5d5ad66fc..b3b28830be 100644 --- a/system/fileserver/Cargo.toml +++ b/system/fileserver/Cargo.toml @@ -2,7 +2,7 @@ name = "fileserver" edition = "2024" license = "MIT OR Apache-2.0" -description = "ToyOS's file server: one process per storage role — DATA, the log or the boot volume — serves that role's paths." +description = "ToyOS's file server: one process per storage role — ROOT, DATA, the log or the boot volume — serves that role's paths." # The library is the server's decisions — the block cache, the volumes and the # resolver — so the host can test them; the service is `src/main.rs`. @@ -18,6 +18,7 @@ path = "src/main.rs" toyos-abi = { path = "../../sdk/abi" } toyos = { path = "../../sdk/toyos" } toyos-manifest = { path = "../../lib/manifest" } +toyos-osrelease = { path = "../../sdk/osrelease" } toyos-blockring = { path = "../../lib/blockring" } toyos-gpt = { path = "../../lib/gpt" } toyos-fat32 = { path = "../../lib/fat32" } diff --git a/system/fileserver/src/data.rs b/system/fileserver/src/data.rs index e587f82842..bc9f024c85 100644 --- a/system/fileserver/src/data.rs +++ b/system/fileserver/src/data.rs @@ -107,7 +107,7 @@ pub fn find(served: Result<&[[u8; 16]], String>) -> Located { } } -fn refusal(e: &FsError) -> Error { +pub(crate) fn refusal(e: &FsError) -> Error { match e { FsError::NotFound => Error::NotFound, FsError::NoSpace { .. } => Error::NoSpace, @@ -131,7 +131,7 @@ fn refusal(e: &FsError) -> Error { } /// Logs the format's own account, answers the word a client can act on. -fn mapped(what: &str, path: &str, r: Result) -> Result { +pub(crate) fn mapped(what: &str, path: &str, r: Result) -> Result { r.map_err(|e| { println!("fileserver: {what} of '{path}' failed: {e:?}"); refusal(&e) @@ -148,7 +148,7 @@ fn disk_word(e: DiskError) -> Error { } /// The block holding page `page`, if the extents reach it. -fn block_for(extents: &[Extent], page: u64) -> Option { +pub(crate) fn block_for(extents: &[Extent], page: u64) -> Option { let mut at = 0u64; for e in extents { let end = at.checked_add(e.block_count as u64)?; diff --git a/system/fileserver/src/lib.rs b/system/fileserver/src/lib.rs index 6876c9fa14..40b266a0d2 100644 --- a/system/fileserver/src/lib.rs +++ b/system/fileserver/src/lib.rs @@ -1,8 +1,9 @@ //! 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 +//! can serve ([`data`] for the bcachefs DATA role, [`root`] for the SYSTEM +//! role's ROOT in memory, [`fat`] for FAT32's LOG and BOOT, [`absent`] for a +//! role with no volume this boot), and the resolver that //! keeps every path inside the directory a connection was given ([`resolve`]), //! which requests a read-only one is refused ([`rights`]), what a client //! may take of the server and name in a request ([`bounds`]), and the open @@ -25,5 +26,6 @@ pub mod fat; pub mod ram; pub mod resolve; pub mod rights; +pub mod root; pub mod volume; pub mod writeback; diff --git a/system/fileserver/src/main.rs b/system/fileserver/src/main.rs index b3d4a20fad..4c9a2bc932 100644 --- a/system/fileserver/src/main.rs +++ b/system/fileserver/src/main.rs @@ -1,14 +1,19 @@ -//! fileserver: one role's file server — DATA, LOG or BOOT — serving the directories -//! of that role as capabilities. +//! fileserver: one role's file server — SYSTEM, DATA, LOG or BOOT — serving the +//! directories of that role as capabilities. //! //! **What it holds**: the acceptor of its role's one port, endowed by the -//! supervisor under `serve:fs:`; for its volume, `block` in its -//! namespace, a connector to the block service 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. Two DATA -//! partitions 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. +//! supervisor under `serve:fs:`; for its volume, SYSTEM's ROOT image +//! (`ROOT_IMAGE_LABEL`), which needs no disk, or `block` in its namespace, a +//! connector to the block service 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. Two DATA partitions 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. +//! +//! **SYSTEM is the first program the supervisor starts**, from its boot image +//! and before any file can be read, so it needs no file and no library to +//! serve `/system`. //! //! **A connection is what its grant says** (`toyos::fs::Grant`): the badge //! the supervisor minted its connector with, which the kernel stamped on it and @@ -54,6 +59,7 @@ use fileserver::ram::Ram; use fileserver::fat::FatVolume; use fileserver::resolve::{self, Found, Refusal as Escape, Resolved}; use fileserver::rights; +use fileserver::root::RootVolume; use fileserver::volume::{Kind, Meta, Node, OpenHow, Out, Volume}; use fileserver::writeback::WriteBack; use toyos::endow::{self, Endowments}; @@ -105,6 +111,7 @@ const TOKEN_STREAM: u64 = 2 << 32; #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Role { + System, Data, Log, Boot, @@ -113,6 +120,7 @@ enum Role { impl Role { fn parse(text: &str) -> Option { match text { + "system" => Some(Self::System), "data" => Some(Self::Data), "log" => Some(Self::Log), "boot" => Some(Self::Boot), @@ -122,6 +130,7 @@ impl Role { fn name(self) -> &'static str { match self { + Self::System => "system", Self::Data => "data", Self::Log => "log", Self::Boot => "boot", @@ -203,7 +212,7 @@ impl Exports { fn main() { let args: Vec = std::env::args().collect(); let role = args.get(1).and_then(|r| Role::parse(r)).unwrap_or_else(|| { - panic!("fileserver: started with {args:?}; the first argument is a role: data, log or boot") + panic!("fileserver: started with {args:?}; the first argument is a role: system, data, log or boot") }); let mut guid = None; for arg in args.iter().skip(2) { @@ -269,6 +278,7 @@ fn data_partitions() -> Result, String> { fn open_volume(role: Role, guid: Option<&str>, roots: &[&str]) -> Box { match role { + Role::System => Box::new(root()), Role::Data => match fileserver::data::find(data_partitions().as_deref().map_err(String::clone)) { Located::Nowhere => ram(roots, "this machine has no DATA partition"), Located::Served(guid) => match served(guid) { @@ -296,6 +306,20 @@ fn open_volume(role: Role, guid: Option<&str>, roots: &[&str]) -> Box RootVolume { + let label = toyos_abi::syscall::ROOT_IMAGE_LABEL; + let handle = Endowments::get() + .take::(label) + .unwrap_or_else(|| panic!("fileserver: the system role was started without `{label}`")); + let image = toyos::shm::Immutable::adopt(handle) + .unwrap_or_else(|why| panic!("fileserver: `{label}` is no image nobody writes: {why}")); + let bytes: &'static [u8] = Box::leak(Box::new(image)).as_slice(); + RootVolume::mount(bytes).unwrap_or_else(|why| panic!("fileserver: {why}")) +} + fn absent(roots: &[&str], why: String) -> Box { println!("fileserver: {why}; DATA is absent this boot"); Box::new(Absent::new(roots, why)) diff --git a/system/fileserver/src/root.rs b/system/fileserver/src/root.rs new file mode 100644 index 0000000000..8b54d7c68f --- /dev/null +++ b/system/fileserver/src/root.rs @@ -0,0 +1,387 @@ +//! The SYSTEM role's volume: ROOT, the bcachefs volume of the slot's signed +//! image, served read-only out of the memory the kernel handed the supervisor +//! (`toyos_abi::syscall::ROOT_IMAGE_LABEL`). No disk and no cache: the bytes +//! are already in memory, and nobody writes them. +//! +//! **ROOT crossed the loader's hash and nothing else.** Every block the format +//! names is bounds-checked against the bytes ([`Bytes`]), and every refusal of +//! the format is a client's `Io`, never a panic of this server. +//! +//! Every name is indexed at mount, as DATA's are ([`crate::data`]): ROOT is +//! written once by the build and never changes, so a directory is a prefix of +//! names and nothing more. + +use std::collections::BTreeMap; + +use bcachefs::{BlockBuf, BlockIO, BlockNum, DeviceError, Extent, Mounted, ReadOnly, TransferError}; +use toyos::fs::Error; + +use crate::data::{block_for, mapped}; +use crate::volume::{join, Identities, Kind, Meta, Node, OpenHow, Out, Volume}; + +/// The longest symlink target read back: the wire's path bound. +const MAX_LINK: u64 = toyos::fs::MAX_PATH as u64; + +/// ROOT's unit, which is the format's block. +const BLOCK: usize = 4096; + +/// ROOT's bytes, as the format reads them: whole blocks of them, and none past. +pub struct Bytes(&'static [u8]); + +impl Bytes { + /// Block `block`, or `None` past the bytes. + fn block(&self, block: u64) -> Option<&[u8]> { + let at = usize::try_from(block).ok()?.checked_mul(BLOCK)?; + self.0.get(at..at.checked_add(BLOCK)?) + } +} + +/// A block outside the bytes, or a write to them. +struct Refused; + +impl TransferError for Refused { + fn refused_before_attempt(&self) -> bool { + false + } +} + +impl BlockIO for Bytes { + fn read_block(&self, block: BlockNum, buf: &mut BlockBuf) -> Result<(), DeviceError> { + buf.0.copy_from_slice(self.block(block.raw()).ok_or(DeviceError::classify(&Refused))?); + Ok(()) + } + + /// A `ReadOnly` mount has no path here. + fn write_block(&self, _: BlockNum, _: &BlockBuf) -> Result<(), DeviceError> { + Err(DeviceError::classify(&Refused)) + } + + fn block_count(&self) -> u64 { + (self.0.len() / BLOCK) as u64 + } +} + +/// One file open, while any client holds it. +struct Open { + extents: Vec, + size: u64, + mtime: u64, + holders: u32, +} + +pub struct RootVolume { + fs: Mounted, + names: BTreeMap, + open: BTreeMap, + by_path: BTreeMap, + next: Node, + ids: Identities, +} + +impl RootVolume { + /// The volume `bytes` hold, every name indexed; why not, by the format's word. + pub fn mount(bytes: &'static [u8]) -> Result { + let fs = Mounted::<_, ReadOnly>::open(Bytes(bytes)).map_err(|e| format!("ROOT does not mount: {e:?}"))?; + let listed = fs.list(usize::MAX, &|_| true).map_err(|e| format!("ROOT would not list: {e:?}"))?; + let mut names = BTreeMap::new(); + for (name, _) in listed { + let kind = match name.strip_suffix('/') { + Some(dir) => { + names.insert(dir.to_string(), Kind::Dir); + continue; + } + None if fs.is_symlink(&name).map_err(|e| format!("ROOT: {name}: {e:?}"))? => Kind::Symlink, + None => Kind::File, + }; + names.insert(name, kind); + } + Ok(Self { fs, names, open: BTreeMap::new(), by_path: BTreeMap::new(), next: 1, ids: Identities::new() }) + } + + /// The filesystem's name, which the build derives from every file ROOT carries. + pub fn uuid(&self) -> String { + self.fs.uuid().to_string() + } + + fn has_children(&self, dir: &str) -> bool { + let prefix = format!("{dir}/"); + self.names.range(prefix.clone()..).next().is_some_and(|(name, _)| name.starts_with(&prefix)) + } + + fn is_dir(&self, path: &str) -> bool { + path.is_empty() + || self.names.get(path) == Some(&Kind::Dir) + || (!self.names.contains_key(path) && self.has_children(path)) + } + + fn entry(&self, node: Node) -> Result<&Open, Error> { + self.open.get(&node).ok_or(Error::Stale) + } +} + +impl Volume for RootVolume { + fn writable(&self) -> bool { + false + } + + fn lstat(&mut self, path: &str) -> Result { + match self.names.get(path).copied() { + Some(Kind::Dir) => Ok(Meta { kind: Kind::Dir, size: 0, mtime: 0 }), + Some(kind) => { + let (_, size) = mapped("a lookup", path, self.fs.file_extents(path))?.ok_or(Error::NotFound)?; + let mtime = mapped("a lookup", path, self.fs.file_mtime(path))?.ok_or(Error::NotFound)?; + Ok(Meta { kind, size, mtime }) + } + None if self.is_dir(path) => Ok(Meta { kind: Kind::Dir, size: 0, mtime: 0 }), + None => Err(Error::NotFound), + } + } + + fn read_link(&mut self, path: &str) -> Result { + if self.names.get(path) != Some(&Kind::Symlink) { + return Err(if self.names.contains_key(path) || self.is_dir(path) { Error::NotALink } else { Error::NotFound }); + } + mapped("readlink", path, self.fs.read_link(path, MAX_LINK))?.ok_or(Error::Io) + } + + fn list(&mut self, dir: &str) -> Result, Error> { + if !self.is_dir(dir) { + return Err(if self.names.contains_key(dir) { Error::NotADirectory } else { Error::NotFound }); + } + let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") }; + let mut children: BTreeMap = BTreeMap::new(); + for (name, kind) in self.names.range(prefix.clone()..) { + let Some(rest) = name.strip_prefix(&prefix) else { break }; + match rest.split_once('/') { + Some((child, _)) => { + children.insert(child.to_string(), Kind::Dir); + } + None => { + children.entry(rest.to_string()).or_insert(*kind); + } + } + } + let mut out = Vec::with_capacity(children.len()); + for (name, kind) in children { + let meta = match kind { + Kind::Dir => Meta { kind, size: 0, mtime: 0 }, + _ => self.lstat(&join(dir, &name))?, + }; + out.push((name, meta)); + } + Ok(out) + } + + fn open(&mut self, path: &str, how: OpenHow) -> Result { + if self.is_dir(path) { + return Err(Error::IsADirectory); + } + match self.names.get(path) { + Some(_) if how.create_new => return Err(Error::AlreadyExists), + None if how.create || how.create_new => return Err(Error::ReadOnly), + None => return Err(Error::NotFound), + // A link the resolver did not follow is one with nothing behind it. + Some(Kind::Symlink | Kind::Dir) => return Err(Error::NotFound), + Some(Kind::File) if how.truncate => return Err(Error::ReadOnly), + Some(Kind::File) => {} + } + let node = match self.by_path.get(path).copied() { + Some(node) => node, + None => { + let (extents, size) = mapped("open", path, self.fs.file_extents(path))?.ok_or(Error::NotFound)?; + let mtime = mapped("open", path, self.fs.file_mtime(path))?.ok_or(Error::NotFound)?; + let node = self.next; + self.next += 1; + self.open.insert(node, Open { extents, size, mtime, holders: 0 }); + self.by_path.insert(path.to_string(), node); + node + } + }; + self.open.get_mut(&node).expect("just found or made").holders += 1; + Ok(node) + } + + fn close(&mut self, node: Node) -> Result<(), Error> { + let Some(open) = self.open.get_mut(&node) else { return Ok(()) }; + open.holders -= 1; + if open.holders == 0 { + self.open.remove(&node); + self.by_path.retain(|_, n| *n != node); + } + Ok(()) + } + + fn node_meta(&mut self, node: Node) -> Result { + let open = self.entry(node)?; + Ok(Meta { kind: Kind::File, size: open.size, mtime: open.mtime }) + } + + fn identity(&mut self, path: &str) -> Result { + if !self.names.contains_key(path) && !self.is_dir(path) { + return Err(Error::NotFound); + } + Ok(self.ids.of(path.to_string())) + } + + fn read(&mut self, node: Node, offset: u64, out: &mut dyn Out) -> Result { + let open = self.entry(node)?; + if offset >= open.size { + return Ok(0); + } + let n = out.len().min((open.size - offset) as usize); + let mut done = 0; + while done < n { + let at = offset + done as u64; + let within = (at % BLOCK as u64) as usize; + let take = (BLOCK - within).min(n - done); + match block_for(&open.extents, at / BLOCK as u64) { + Some(block) => { + let Some(bytes) = self.fs.io().block(block) else { + println!("fileserver: a file on ROOT names block {block}, past ROOT's end"); + return Err(Error::Io); + }; + out.put(done, &bytes[within..within + take]); + } + // Past the extents: a hole, whose bytes are zeros. + None => out.zero(done, take), + } + done += take; + } + Ok(n) + } + + fn write(&mut self, _: Node, _: u64, _: &[u8]) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn truncate(&mut self, _: Node, _: u64) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn mkdir(&mut self, _: &str) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn rmdir(&mut self, _: &str) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn unlink(&mut self, _: &str) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn rename(&mut self, _: &str, _: &str) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn symlink(&mut self, _: &str, _: &str) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn sync(&mut self) -> Result, Error> { + Ok(Vec::new()) + } + + fn describe(&self) -> String { + format!("{}{}, {} blocks read-only from memory", toyos_osrelease::ROOT_SAID, self.uuid(), self.fs.io().block_count()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::volume::Buf; + + /// A ROOT the format writes, as the build writes one: two files, one of + /// them several blocks, a symlink and a nested directory. + fn root() -> RootVolume { + let mut fs = bcachefs::Formatted::format(bcachefs::VecBlockIO::new(256)).expect("format"); + fs.create("etc/system.manifest", b"program shell /system/bin/shell\n", 7).unwrap(); + fs.create("bin/toybox", &big(), 9).unwrap(); + fs.create_symlink("bin/ls", "/system/bin/toybox", 0).unwrap(); + fs.create("share/fonts/a.font", b"font", 0).unwrap(); + let bytes = fs.into_io().expect("write").into_vec(); + RootVolume::mount(Box::leak(bytes.into_boxed_slice())).expect("mount") + } + + fn big() -> Vec { + (0..3 * BLOCK as u32 + 17).map(|i| (i * 7) as u8).collect() + } + + const READ: OpenHow = OpenHow { create: false, create_new: false, truncate: false }; + + fn read_all(v: &mut RootVolume, path: &str) -> Vec { + let node = v.open(path, READ).expect(path); + let size = v.node_meta(node).unwrap().size as usize; + let mut out = vec![0; size + 5]; + let n = v.read(node, 0, &mut Buf(&mut out)).unwrap(); + v.close(node).unwrap(); + out.truncate(n); + out + } + + #[test] + fn root_reads_back_what_the_build_put_in_it() { + let mut v = root(); + assert_eq!(read_all(&mut v, "etc/system.manifest"), b"program shell /system/bin/shell\n"); + assert_eq!(read_all(&mut v, "bin/toybox"), big()); + let node = v.open("bin/toybox", READ).unwrap(); + let mut part = [0u8; 10]; + assert_eq!(v.read(node, BLOCK as u64 - 3, &mut Buf(&mut part)).unwrap(), 10); + assert_eq!(part[..], big()[BLOCK - 3..BLOCK + 7]); + assert_eq!(v.lstat("bin/toybox").unwrap().mtime, 9); + assert_eq!(v.read_link("bin/ls").unwrap(), "/system/bin/toybox"); + assert_eq!(v.read_link("bin/toybox"), Err(Error::NotALink)); + let listed: Vec<(String, Kind)> = v.list("").unwrap().into_iter().map(|(n, m)| (n, m.kind)).collect(); + assert_eq!(listed, [("bin".into(), Kind::Dir), ("etc".into(), Kind::Dir), ("share".into(), Kind::Dir)]); + let bin: Vec = v.list("bin").unwrap().into_iter().map(|(n, _)| n).collect(); + assert_eq!(bin, ["ls", "toybox"]); + assert_eq!(v.lstat("share/fonts").unwrap().kind, Kind::Dir); + assert_eq!(v.lstat("nothing"), Err(Error::NotFound)); + assert!(v.describe().starts_with(toyos_osrelease::ROOT_SAID)); + } + + /// Every request that would change ROOT is refused read-only, and an open + /// that would make or empty a file is refused before anything is opened. + #[test] + fn every_change_is_refused() { + let mut v = root(); + assert!(!v.writable()); + let create = OpenHow { create: true, ..READ }; + assert_eq!(v.open("etc/new", create), Err(Error::ReadOnly)); + assert_eq!(v.open("bin/toybox", OpenHow { truncate: true, ..READ }), Err(Error::ReadOnly)); + assert_eq!(v.open("bin/toybox", OpenHow { create_new: true, ..READ }), Err(Error::AlreadyExists)); + let node = v.open("bin/toybox", READ).unwrap(); + assert_eq!(v.write(node, 0, b"x"), Err(Error::ReadOnly)); + assert_eq!(v.truncate(node, 0), Err(Error::ReadOnly)); + assert_eq!(v.mkdir("d"), Err(Error::ReadOnly)); + assert_eq!(v.rmdir("bin"), Err(Error::ReadOnly)); + assert_eq!(v.unlink("bin/ls"), Err(Error::ReadOnly)); + assert_eq!(v.rename("bin/ls", "bin/dir"), Err(Error::ReadOnly)); + assert_eq!(v.symlink("x", "y"), Err(Error::ReadOnly)); + assert_eq!(read_all(&mut v, "bin/toybox"), big(), "a refused write changed nothing"); + } + + /// Bytes that are no volume, and a block past them, are refusals and never + /// a panic of this server. + #[test] + fn bytes_that_are_no_volume_or_too_few_are_refused() { + assert!(RootVolume::mount(Box::leak(vec![0u8; 8 * BLOCK].into_boxed_slice())).is_err()); + let bytes = Bytes(Box::leak(vec![1u8; 2 * BLOCK].into_boxed_slice())); + let mut buf = BlockBuf::zeroed(); + assert!(bytes.read_block(BlockNum::new(1), &mut buf).is_ok()); + assert!(bytes.read_block(BlockNum::new(2), &mut buf).is_err()); + assert!(bytes.read_block(BlockNum::new(u64::MAX), &mut buf).is_err()); + assert!(bytes.write_block(BlockNum::new(0), &buf).is_err()); + } + + /// A node closed by its last holder is gone; a second open is a fresh one. + #[test] + fn a_closed_node_is_stale() { + let mut v = root(); + let node = v.open("bin/toybox", READ).unwrap(); + v.close(node).unwrap(); + assert_eq!(v.node_meta(node), Err(Error::Stale)); + assert!(v.open("bin/toybox", READ).is_ok()); + } +} diff --git a/system/grants/src/main.rs b/system/grants/src/main.rs index 1c5bd67939..90e4ddb059 100644 --- a/system/grants/src/main.rs +++ b/system/grants/src/main.rs @@ -1,5 +1,5 @@ //! `/system/bin/grants`: which folder of the session's home an installed -//! package is granted, asked of `/system/bin/supervisor`, which alone keeps +//! package is granted, asked of the supervisor, which alone keeps //! them (`toyos_manifest::grants`). //! //! - `grants list`: every grant, one line each: package, access, folder. diff --git a/system/logkeeper/src/main.rs b/system/logkeeper/src/main.rs index 9d0245fbaa..ed0b6c453f 100644 --- a/system/logkeeper/src/main.rs +++ b/system/logkeeper/src/main.rs @@ -12,7 +12,7 @@ //! (`toyos_logstream`'s header is the form): //! //! - **the kernel's records**, read off its ring with `SYS_LOG_READ`; -//! - **every program's records**, read off the log ring `/system/bin/supervisor` made +//! - **every program's records**, read off the log ring the supervisor made //! for it and sent here with the manifest's name for the program and its pid, //! on a connection only the supervisor holds ([`toyos_logstream::ORIGINS`]). A record is //! that program's because it came out of that ring ([`origin`]). This diff --git a/system/netstack/i219/src/lib.rs b/system/netstack/i219/src/lib.rs index 113b366195..1b3ec10487 100644 --- a/system/netstack/i219/src/lib.rs +++ b/system/netstack/i219/src/lib.rs @@ -758,7 +758,7 @@ fn phy_configured(regs: &R, clock: &C, since: u64) -> Op /// Which part the claim is on. /// -/// **The parent's answer and never a probe**: `/system/bin/supervisor` moved a claim +/// **The parent's answer and never a probe**: the supervisor moved a claim /// on a declared vendor and device into this process, and a driver that read /// the register file to work out which part it was on would be guessing at the /// registers it does not yet trust. diff --git a/system/netstack/src/main.rs b/system/netstack/src/main.rs index f73f265d04..6e0233996c 100644 --- a/system/netstack/src/main.rs +++ b/system/netstack/src/main.rs @@ -60,7 +60,7 @@ use client::{Client, ClientRx, HANDSHAKE_TIMEOUT, MAX_PENDING_CONNS, PendingConn /// identifies the card rather than by the slot firmware put it in, and each /// with the driver that opens it. The manifest row spells the same matcher and /// the claim arrives under its text, so which of these exists is -/// `/system/bin/supervisor`'s answer and not this program's — at most one is +/// the supervisor's answer and not this program's — at most one is /// ever endowed, and a machine with none is a machine netstack leaves. /// /// `1af4:1041` is virtio's transitional device id `1000 + 1` for a network diff --git a/system/supervisor/lifecycle/src/lib.rs b/system/supervisor/lifecycle/src/lib.rs index ffe204e5b3..0af9d7e203 100644 --- a/system/supervisor/lifecycle/src/lib.rs +++ b/system/supervisor/lifecycle/src/lib.rs @@ -1,4 +1,4 @@ -//! The supervisor's lifecycle decisions, pure: `/system/bin/supervisor` holds +//! The supervisor's lifecycle decisions, pure: the supervisor holds //! the handles, makes the spawns and runs the loop, and asks here what to do. //! //! [`restart`] is when a process that ended is started again, [`claims`] which diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index 3693fe1d3e..ccc1fdceb9 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -125,8 +125,8 @@ use toyos_logstream::{ }; use toyos_abi::inventory::{Arrival, Pci, PciAddr, Record}; use toyos_abi::syscall::{ - DeviceRequest, FileType, SyscallError, DEV_PREFIX, JOB_LABEL, MAX_BADGE, PROVIDE_PREFIX, SERVE_PREFIX, - SVC_LABEL, SYSCAP_LABEL, + DeviceRequest, FileType, SyscallError, DEV_PREFIX, FILESERVER_IMAGE_LABEL, JOB_LABEL, MAX_BADGE, PROVIDE_PREFIX, + ROOT_IMAGE_LABEL, SERVE_PREFIX, SVC_LABEL, SYSCAP_LABEL, }; /// netstack's port, whose every connector the supervisor mints for one row and @@ -403,47 +403,22 @@ fn make_dir(path: &str) -> std::io::Result<()> { fn main() { // Before anything is started, and before the supervisor says anything: from here // the supervisor's own lines are records in its ring. - let log = Log::open(); - say_release(); + let mut log = Log::open(); let syscap: SysCap = Endowments::get() .take(SYSCAP_LABEL) .expect("supervisor: the kernel spawns this program holding the system capability"); + let boot: &'static Boot = Box::leak(Box::new(Boot::take())); - let bytes = read_root(toyos_manifest::GUEST_PATH) - .unwrap_or_else(|e| panic!("supervisor: cannot read {}: {e:?}", toyos_manifest::GUEST_PATH)); - let text = String::from_utf8(bytes) - .unwrap_or_else(|_| panic!("supervisor: {} is not text", toyos_manifest::GUEST_PATH)); - // For the machine's life, and `'static` so a launch's path can be resolved - // against it on the supervisor's file worker. - let system: &'static Manifest = Box::leak(Box::new(toyos_manifest::parse(&text))); - - // Before anything is spawned, and for every `serves` name in the manifest - // rather than only the ones `[boot] start` names: the filepicker is - // launched by the compositor, and an editor holding its connector must be - // able to ask for a file before the picker has run an instruction. - let mut acceptors = BTreeMap::new(); - let mut connectors: BTreeMap<&str, Connector> = BTreeMap::new(); - let names = system - .served_names() - .into_iter() - .chain(system.supervisor_serves.iter().map(String::as_str)); - for name in names { - let (acceptor, connector) = - port::create().unwrap_or_else(|e| panic!("supervisor: no port for `{name}`: {e:?}")); - acceptors.insert(name, acceptor); - connectors.insert(name, connector); - } - // Its connector goes at once: every `netstack` a program holds is one - // minted with that program's row and session (`Grants::netstack`). - drop(connectors.remove(NETSTACK)); - - // One port per file-server role, made here for the same reason: a + // **Everything the supervisor's own files and the SYSTEM server need is + // made before a file is read**, since the manifest is a file of `/system` + // and SYSTEM is what serves it. One port per file-server role, then: a // program's first open works whether or not its server runs yet. Its // connector goes at once: every connector a program holds to it is a - // grant minted for that program's share (`Grants`). + // grant minted for that program's share (`Grants`). A role no row serves + // has its port closed once the manifest says so. let mut role_acceptors: BTreeMap<&str, Acceptor> = BTreeMap::new(); - for role in system.programs.iter().flat_map(|p| p.roles.iter()) { + for (role, _) in toyos_manifest::ROLES { let (acceptor, _) = port::create().unwrap_or_else(|e| panic!("supervisor: no port for the `{role}` role: {e:?}")); role_acceptors.insert(role, acceptor); } @@ -455,7 +430,7 @@ fn main() { let whole = toyos_manifest::whole_tree(); let own: Vec<(String, Connector)> = whole .iter() - .filter_map(|view| Some((role_acceptors.get(view.role)?, view))) + .map(|view| (&role_acceptors[view.role], view)) .map(|(acceptor, view)| mint(acceptor, authority::SUPERVISOR_SHARE, view)) .collect(); let build = || { @@ -471,13 +446,77 @@ fn main() { } Box::leak(Box::new(build())) }; - // Its connector goes at once: every launcher a program holds is one minted // with that program's row. let (launcher, _) = port::create().expect("supervisor: no port for `launcher`"); - let grants_port = acceptors - .remove(grants::PORT) - .expect("supervisor: the manifest declares the supervisor serves `grants`"); + let (grants_port, grants_connector) = port::create().expect("supervisor: no port for `grants`"); + let minted = Minted { launcher, grants: grants_port }; + let mut sessions = Sessions::default(); + + // SYSTEM, from the file server's boot image: what serves `/system`. No + // manifest row holds it, since it serves the manifest. + let system_row: &'static Program = Box::leak(Box::new(system_row())); + let acceptor = role_acceptors.remove(toyos_manifest::SYSTEM).expect("SYSTEM is a role"); + let mut system_service = Service::new( + system_row, + Some(toyos_manifest::SYSTEM), + vec![(format!("{CAPABILITY_PREFIX}{}", toyos_manifest::SYSTEM), acceptor)], + sessions.machine(), + ); + let no_grants = Grants { roles: Vec::new(), block: None, netstack: None }; + let unread = Manifest::default(); + let started = system_service.spawn( + &system_row.path, + &[], + Vec::new(), + &unread, + &syscap, + &BTreeMap::new(), + &no_grants, + &minted, + boot, + &mut log, + ); + if let Err(e) = started { + bootstrap_fails(format!("the file server serving /system did not start: {e}")); + } + + say_release(&system_service); + let text = read_system(toyos_manifest::GUEST_PATH, &system_service); + // For the machine's life, and `'static` so a launch's path can be resolved + // against it on the supervisor's file worker. + let system: &'static Manifest = Box::leak(Box::new(toyos_manifest::parse(&text))); + // A role no row serves: closed before anything asks it, so a connect to it + // answers `ServerGone` rather than waiting in a queue nothing will ever + // accept from. + role_acceptors.retain(|role, _| system.programs.iter().any(|p| p.roles.iter().any(|r| r == role))); + + // Before anything is spawned, and for every `serves` name in the manifest + // rather than only the ones `[boot] start` names: the filepicker is + // launched by the compositor, and an editor holding its connector must be + // able to ask for a file before the picker has run an instruction. + // `grants` was made above, before the manifest said the supervisor serves it. + assert!( + system.supervisor_serves.iter().any(|name| name == grants::PORT), + "supervisor: the manifest declares the supervisor serves `grants`" + ); + let mut acceptors = BTreeMap::new(); + let mut connectors: BTreeMap<&str, Connector> = BTreeMap::from([(grants::PORT, grants_connector)]); + let names = system + .served_names() + .into_iter() + .chain(system.supervisor_serves.iter().map(String::as_str)) + .filter(|name| *name != grants::PORT); + for name in names { + let (acceptor, connector) = + port::create().unwrap_or_else(|e| panic!("supervisor: no port for `{name}`: {e:?}")); + acceptors.insert(name, acceptor); + connectors.insert(name, connector); + } + // Its connector goes at once: every `netstack` a program holds is one + // minted with that program's row and session (`Grants::netstack`). + drop(connectors.remove(NETSTACK)); + // The supervisor alone holds a connector to it, so a connection on it is // the supervisor's question and nobody else's. let consent = connectors.remove(consent::PORT).map(|connector| { @@ -486,18 +525,21 @@ fn main() { .finish() .expect("supervisor: no namespace for the consent port") }); + let mut grants = no_grants; + grants.roles.push((toyos_manifest::SYSTEM, Rc::clone(&system_service.ports))); let mut supervisor = Supervisor { system, - minted: Minted { launcher, grants: grants_port }, + minted, store: Store::default(), consent, syscap: &syscap, + boot, acceptors, connectors, - grants: Grants { roles: Vec::new(), block: None, netstack: None }, - sessions: Sessions::default(), + grants, + sessions, files, - services: Vec::new(), + services: vec![system_service], drivers: drives::matchers(system), drives: Drives::default(), instances: Vec::new(), @@ -613,6 +655,8 @@ struct Supervisor<'a> { /// the screen on. consent: Option, syscap: &'a SysCap, + /// The images the kernel handed the supervisor. + boot: &'static Boot, /// The `serves` acceptors nobody has been started with yet, which a launch /// takes by move. acceptors: BTreeMap<&'a str, Acceptor>, @@ -711,6 +755,7 @@ impl<'a> Service<'a> { connectors: &BTreeMap<&str, Connector>, grants: &Grants<'_>, minted: &Minted, + boot: &Boot, log: &mut Log, ) -> Result { // **Checked again at every start, not only on arrival**: the installed @@ -724,14 +769,19 @@ impl<'a> Service<'a> { std::io::Error::other(format!("{path} no longer holds what was installed: {why}")) })?; } - let mut storage = storage_endowment(self.role, syscap, grants)?; + let mut storage = storage_endowment(self.role, syscap, grants, boot)?; if is_block(self.program) { storage.args.extend(block); } let ports = self.ports.borrow(); let served = Served::Keep { acceptors: &ports, owed }; + let mut command = Command::new(path); + if path == FILESERVER_IMAGE_LABEL { + command = Command::new(&self.program.name); + command.boot_image(boot.fileserver.as_handle().0); + } let (child, devices) = start( - Command::new(path), + command, self.program, system, syscap, @@ -895,6 +945,10 @@ impl<'a> Supervisor<'a> { self.make_home(program); let block = self.block_args(); let service = &mut self.services[at]; + // SYSTEM was started before the manifest was read. + if service.child.is_some() { + continue; + } let started = service.spawn( &program.path, &[], @@ -904,6 +958,7 @@ impl<'a> Supervisor<'a> { &self.connectors, &self.grants, &self.minted, + self.boot, &mut self.log, ); match started { @@ -1554,6 +1609,7 @@ impl<'a> Supervisor<'a> { &self.connectors, &self.grants, &self.minted, + self.boot, &mut self.log, ); match started { @@ -1775,7 +1831,7 @@ impl<'a> Supervisor<'a> { let block = self.block_args(); let service = &mut self.services[index]; let (system, syscap, connectors, grants) = (self.system, self.syscap, &self.connectors, &self.grants); - match service.spawn(&path, &owed, block, system, syscap, connectors, grants, &self.minted, &mut self.log) { + match service.spawn(&path, &owed, block, system, syscap, connectors, grants, &self.minted, self.boot, &mut self.log) { Ok(new) => say!("supervisor: {label} (pid {pid}) ended; started again as pid {new}"), Err(e) => { service.ports.borrow_mut().clear(); @@ -1810,7 +1866,7 @@ impl<'a> Supervisor<'a> { let block = self.block_args(); let service = &mut self.services[index]; let name = service.program.name.clone(); - match service.spawn(previous, owed, block, self.system, self.syscap, &self.connectors, &self.grants, &self.minted, &mut self.log) { + match service.spawn(previous, owed, block, self.system, self.syscap, &self.connectors, &self.grants, &self.minted, self.boot, &mut self.log) { Ok(pid) => { service.swapping = false; swapped(&name, Word::Restored, &format!("{previous} as pid {pid}")); @@ -2605,7 +2661,7 @@ fn start<'a>( launcher: (&Minted, Session), output: Output<'_>, ) -> Result<(Child, Vec), StartError> { - let Extra { args, claims, ports, stop } = extra; + let Extra { args, claims, root, ports, stop } = extra; // A storage row's own arguments first: a file server's role leads its argv. command.args(&args); command.args(&program.args); @@ -2648,6 +2704,10 @@ fn start<'a>( command.endow(toyos_quiesce::DRIVER_STOP, raw.0); held.0.push(raw); } + if let Some(root) = root { + command.endow(ROOT_IMAGE_LABEL, root.0); + held.0.push(root); + } let mut view = grants.view(program, launcher.1); view.extend(ports); view.extend(grants.netstack(program, launcher.1)); @@ -3152,6 +3212,8 @@ fn swapped(service: &str, word: Word, detail: &str) { struct Extra { args: Vec, claims: Vec<(String, toyos::Device)>, + /// SYSTEM's ROOT image: a duplicate the start owns until the spawn moves it. + root: Option, ports: Vec<(String, Connector)>, /// A disk driver's end of its [`toyos_quiesce::DRIVER_STOP`] pipe. stop: Option, @@ -3191,15 +3253,15 @@ fn guid_text(guid: [u8; 16]) -> String { toyos_gpt::Guid(guid).to_string() } -/// A file server's arguments and block connector for one start. +/// A file server's arguments and the volume it is endowed for one start. /// -/// A file server is told its role, and for LOG and BOOT the partition's GUID +/// A file server is told its role. SYSTEM is handed ROOT's image. For LOG and BOOT, the partition's GUID /// the loader named, with `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. A log or boot role the loader /// named no partition for, and a block port closed for good, each refuse the /// start: the role is then absent, never served from memory. -fn storage_endowment(role: Option<&str>, syscap: &SysCap, grants: &Grants<'_>) -> Result { +fn storage_endowment(role: Option<&str>, syscap: &SysCap, grants: &Grants<'_>, boot: &Boot) -> Result { use toyos_abi::inventory::Role; let mut storage = Extra::default(); let Some(role) = role else { @@ -3207,6 +3269,12 @@ fn storage_endowment(role: Option<&str>, syscap: &SysCap, grants: &Grants<'_>) - }; storage.args.push(role.to_string()); let served = match role { + toyos_manifest::SYSTEM => { + let root = toyos_abi::syscall::dup(boot.root.as_handle()) + .map_err(|e| std::io::Error::other(format!("ROOT's image did not duplicate: {e:?}")))?; + storage.root = Some(root); + return Ok(storage); + } "data" => BlockGrant { scope: Scope::Kind(toyos_gpt::Guid::TOYOS_DATA.0), writes: true }, "log" | "boot" => { let records = inventory(syscap).map_err(|why| StartError::Other(std::io::Error::other(why)))?; @@ -3225,34 +3293,12 @@ fn storage_endowment(role: Option<&str>, syscap: &SysCap, grants: &Grants<'_>) - Ok(storage) } -/// A file of ROOT, through the kernel's own `open`. -/// -/// **Not through std**: std resolves a path through this process's namespace, -/// and the first path it resolves fixes that namespace for the process's life. -/// The supervisor builds its namespace out of the manifest, so ROOT's files are read -/// before std is asked anything. -fn read_root(path: &str) -> Result, SyscallError> { - use toyos_abi::syscall::{self, OpenFlags}; - let handle = syscall::open(path.as_bytes(), OpenFlags::READ)?; - let mut bytes = Vec::new(); - let mut buf = [0u8; 4096]; - let read = loop { - match syscall::read(handle, &mut buf) { - Ok(0) => break Ok(()), - Ok(n) => bytes.extend_from_slice(&buf[..n]), - Err(e) => break Err(e), - } - }; - syscall::close(handle); - read.map(|()| bytes) -} - -/// Say which build this machine runs: the first thing it says, so a boot -/// that dies past here names its build in its log. -fn say_release() { +/// Say which build this machine runs: the first file the supervisor reads, +/// so a boot that dies past here names its build in its log. +fn say_release(server: &Service<'_>) { let path = toyos_osrelease::GUEST_PATH; - let bytes = read_root(path).unwrap_or_else(|e| panic!("supervisor: cannot read {path}: {e:?}")); - let release = toyos_osrelease::parse(&bytes) + let text = read_system(path, server); + let release = toyos_osrelease::parse(text.as_bytes()) .unwrap_or_else(|e| panic!("supervisor: {path} is not one the build wrote: {e:?}")); say!( "{}{} {}, committed {} UTC, toolchain {}, {}", @@ -3264,3 +3310,73 @@ fn say_release() { release.arch.machine() ); } + +/// The file `path` of `/system`, read before the loop exists to start SYSTEM +/// again: a server that ended or will not answer within [`FILES_BOUND`] is +/// this boot's end, said by name. +fn read_system(path: &'static str, server: &Service<'_>) -> String { + let (answer, answered) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("bootstrap".into()) + .spawn(move || { + // The receiver is gone once the bound has passed. + let _ = answer.send(std::fs::read_to_string(path)); + }) + .expect("supervisor: no thread to read /system on"); + match answered.recv_timeout(FILES_BOUND) { + Ok(Ok(text)) => text, + Ok(Err(e)) => bootstrap_fails(format!("cannot read {path}: {e}")), + Err(_) => bootstrap_fails(format!( + "{path} was not read in {} s; {} {}", + FILES_BOUND.as_secs(), + server.label(), + match server.child.as_ref().map(exit_of) { + Some(Some(exit)) => format!("ended, {exit}"), + _ => "is running and did not answer".to_string(), + } + )), + } +} + +/// The boot's end before `logkeeper` runs, which no ring reaches yet: said on +/// the console the kernel gave the supervisor, then a panic like any other. +fn bootstrap_fails(why: String) -> ! { + let line = format!("supervisor: {why}\n"); + let _ = toyos::fd::with(0, |console| toyos_abi::syscall::write(console.handle, line.as_bytes())); + panic!("supervisor: {why}"); +} + +/// The images the kernel hands the supervisor beside the system capability: +/// the file server's executable and ROOT's bytes, which nobody writes +/// (`toyos_abi::boot::BootImage`). Kept for the machine's life, since SYSTEM +/// is started again from them. +struct Boot { + fileserver: toyos::shm::Immutable, + root: toyos::shm::Immutable, +} + +impl Boot { + fn take() -> Self { + let image = |label: &str| { + let handle: toyos::OwnedHandle = Endowments::get() + .take(label) + .unwrap_or_else(|| panic!("supervisor: the kernel spawns this program holding `{label}`")); + toyos::shm::Immutable::adopt(handle) + .unwrap_or_else(|why| panic!("supervisor: `{label}` is no image nobody writes: {why}")) + }; + Self { fileserver: image(FILESERVER_IMAGE_LABEL), root: image(ROOT_IMAGE_LABEL) } + } +} + +/// The row SYSTEM is started under, which no manifest declares since it +/// serves the manifest: the file server's boot image, serving ROOT, started +/// again when it ends, and holding nothing but its role's port and ROOT. +fn system_row() -> Program { + Program { + name: "fileserver".to_string(), + path: FILESERVER_IMAGE_LABEL.to_string(), + roles: vec![toyos_manifest::SYSTEM.to_string()], + restart: true, + ..Program::default() + } +} diff --git a/system/swap/src/main.rs b/system/swap/src/main.rs index a164ddf02f..b2910e4b89 100644 --- a/system/swap/src/main.rs +++ b/system/swap/src/main.rs @@ -1,5 +1,5 @@ //! `/system/bin/swap `: a running service's binary -//! replaced by the one on standard input, handed to `/system/bin/supervisor`, which +//! replaced by the one on standard input, handed to the supervisor, which //! alone can swap it. //! //! An ordinary program: `ssh swap netstack < netstack` diff --git a/system/toybox/src/ps.rs b/system/toybox/src/ps.rs index 8e1e07cc6c..7d3841408d 100644 --- a/system/toybox/src/ps.rs +++ b/system/toybox/src/ps.rs @@ -5,7 +5,7 @@ //! the image's `[programs.toybox]` row declares — a config that does not name //! `roster` there builds an image whose `ps` says it cannot and changes nothing //! else. Nothing here asks for the capability: it is either in the endowment -//! table `/system/bin/supervisor` filled at spawn or it does not exist for this process. +//! table the supervisor filled at spawn or it does not exist for this process. //! What the roster says is [`crate::roster`]'s to read. //! //! `free` is the other half of the same syscall and needs none of this: the diff --git a/system/toybox/src/reboot.rs b/system/toybox/src/reboot.rs index db15510b95..7cb34074f6 100644 --- a/system/toybox/src/reboot.rs +++ b/system/toybox/src/reboot.rs @@ -1,4 +1,4 @@ -//! Return the machine to firmware, by asking `/system/bin/supervisor`, which has the +//! Return the machine to firmware, by asking the supervisor, which has the //! log made whole first ([`toyos::power`]). **The `power` connector is the //! whole of the authority**: this is `/system/bin/toybox` under another name, //! holding what `[programs.toybox]` declares. diff --git a/system/toybox/src/shutdown.rs b/system/toybox/src/shutdown.rs index 63e48da3bd..23c351e595 100644 --- a/system/toybox/src/shutdown.rs +++ b/system/toybox/src/shutdown.rs @@ -1,4 +1,4 @@ -//! Power the machine off, by asking `/system/bin/supervisor`, which has the log made +//! Power the machine off, by asking the supervisor, which has the log made //! whole first ([`toyos::power`]). //! //! **The `power` connector is the whole of the authority.** `/system/bin/shutdown` diff --git a/system/update/copies/src/installing.rs b/system/update/copies/src/installing.rs index 35812eb1ca..b6c73b2539 100644 --- a/system/update/copies/src/installing.rs +++ b/system/update/copies/src/installing.rs @@ -5,7 +5,7 @@ use toyos_disk::DiskError; use toyos_update::slots::{Slot, Table}; use toyos_disk::{Disk, BLOCK}; -use toyos_update::image::{Header, SIGNED_BYTES}; +use toyos_update::image::{Header, Sections, SIGNED_BYTES}; use toyos_update::slots::{self, Which}; use toyos_update::sig; @@ -47,7 +47,9 @@ const SLOT_A: Slot = Slot { boot: [1; 16], root: [2; 16], version: 100 }; const SLOT_B: Slot = Slot { boot: [3; 16], root: [4; 16], version: 0 }; const KERNEL: &[u8] = b"an update's kernel, whose bytes are no ELF"; -const CMDLINE: &[u8] = b"root=update-test"; +const CMDLINE: &[u8] = b"update-test"; +const SUPERVISOR: &[u8] = b"an update's supervisor"; +const FILESERVER: &[u8] = b"an update's file server"; /// A machine running slot A, its B empty: the table's partition, and B's FAT /// volume and ROOT. @@ -66,12 +68,13 @@ fn root_bytes() -> Vec { (0..3 * BLOCK).map(|i| if i % BLOCK == 0 { (i / BLOCK) as u8 } else { (i * 7) as u8 }).collect() } -/// The image `ssh update` takes: the signed header, then the kernel, -/// the boot parameter and ROOT. +/// The image `ssh update` takes: the signed header, then every +/// section, ROOT last. fn image(seed: &[u8; 32], version: u64) -> Vec { let root = root_bytes(); - let header = Header::of(version, KERNEL, CMDLINE, &root).encode(); - [&header[..], &sig::sign(seed, &header), KERNEL, CMDLINE, &root].concat() + let sections = Sections { kernel: KERNEL, cmdline: CMDLINE, supervisor: SUPERVISOR, fileserver: FILESERVER, root: &root }; + let header = Header::of(version, §ions).encode(); + [&header[..], &sig::sign(seed, &header), KERNEL, CMDLINE, SUPERVISOR, FILESERVER, &root].concat() } fn install_on(table: &mut Mem, boot: &mut Mem, root: &mut Mem, image: &[u8]) -> Result { @@ -117,9 +120,25 @@ fn a_signed_image_is_written_into_the_idle_slot_and_marked() { assert!(complaints.is_empty(), "{}", toyos_fat32_check::describe(&complaints)); assert_eq!(file_on(&mut boot, slots::KERNEL_FILE), KERNEL); assert_eq!(file_on(&mut boot, slots::CMDLINE_FILE), CMDLINE); + assert_eq!(file_on(&mut boot, slots::SUPERVISOR_FILE), SUPERVISOR); + assert_eq!(file_on(&mut boot, slots::FILESERVER_FILE), FILESERVER); assert_eq!(file_on(&mut boot, slots::SIGNED_FILE), image[..SIGNED_BYTES]); } +/// A signed image whose supervisor is not the bytes its header names is +/// refused by that section's name before its slot's volume or table is +/// written, so the machine boots what it booted. +#[test] +fn an_image_whose_supervisor_was_bent_is_refused_and_marks_nothing() { + let (mut table, mut boot, mut root) = machine(); + let (table_before, boot_before) = (table.0.clone(), boot.0.clone()); + let mut bent = image(&SEED, 200); + bent[SIGNED_BYTES + KERNEL.len() + CMDLINE.len()] ^= 1; + let refused = install_on(&mut table, &mut boot, &mut root, &bent).unwrap_err(); + assert!(refused.contains("supervisor is not the bytes its signed header names"), "{refused}"); + assert!(table.0 == table_before && boot.0 == boot_before, "a refused image wrote the table or the volume"); +} + /// An image another key signed is refused before anything is written: every /// partition reads as it did, so the machine boots what it booted. #[test] diff --git a/system/update/src/install/mod.rs b/system/update/src/install/mod.rs index 319ac3d0be..d38f6f1547 100644 --- a/system/update/src/install/mod.rs +++ b/system/update/src/install/mod.rs @@ -89,6 +89,8 @@ pub fn install( let kernel = take(input, header.kernel().len, header.kernel().sha256, "kernel")?; let cmdline = take(input, header.cmdline().len, header.cmdline().sha256, "cmdline")?; + let supervisor = take(input, header.supervisor().len, header.supervisor().sha256, "supervisor")?; + let fileserver = take(input, header.fileserver().len, header.fileserver().sha256, "fileserver")?; let streamed = Instant::now(); stream_root(input, &mut *root.disk, header.root().len, header.root().sha256)?; let root_ms = streamed.elapsed().as_millis(); @@ -99,7 +101,14 @@ pub fn install( Err(e) => return Err(format!("the input's end would not read: {e}")), } - write_volume(&mut *boot.disk, &kernel, &cmdline, &signed, now)?; + let files = [ + (slots::SIGNED_FILE, &signed[..]), + (slots::KERNEL_FILE, &kernel), + (slots::CMDLINE_FILE, &cmdline), + (slots::SUPERVISOR_FILE, &supervisor), + (slots::FILESERVER_FILE, &fileserver), + ]; + write_volume(&mut *boot.disk, &files, now)?; 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()))?; @@ -148,18 +157,14 @@ fn stream_root(input: &mut impl Read, root: &mut dyn Disk, len: u64, sha256: toy Ok(()) } -/// The kernel, its boot parameter and the signed header onto the idle slot's -/// FAT volume, replacing whatever was there. -fn write_volume(boot: &mut dyn Disk, kernel: &[u8], cmdline: &[u8], signed: &[u8], now: u64) -> Result<(), String> { +/// Every file a slot carries onto the idle slot's FAT volume, replacing +/// whatever was there. +fn write_volume(boot: &mut dyn Disk, files: &[(&str, &[u8])], now: u64) -> 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 time = toyos_fat32::FatTime::from_unix_secs(now); fs.create_dir_all("toyos", time).map_err(|e| format!("toyos/ on the idle volume: {e:?}"))?; - for (path, bytes) in [ - (slots::SIGNED_FILE, signed), - (slots::KERNEL_FILE, kernel), - (slots::CMDLINE_FILE, cmdline), - ] { + for &(path, bytes) in files { if fs.exists(path).map_err(|e| format!("{path}: {e:?}"))? { fs.remove(path).map_err(|e| format!("removing the old {path}: {e:?}"))?; } diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index f30ba3011d..0746f958b6 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -7,7 +7,7 @@ The mechanics live where the work is: profiles and shapes in `tests/common/`, re - **A host that sleeps mid-run is reported, not measured** — a run whose wall clock jumped against the monotonic one reports `INVL` per test and exits 2. A wild outlier *not* marked that way is a real finding. - **A machine-wide kernel panic reds whichever test was running** — that red's name is the workload, never the cause. `QEMU died before ===READY=== (status 0)` is the same thing said silently: a guest that reset itself is a kernel death, and its evidence is the boot log. - **A machine-wide death during boot is reproduced by boots, not by suites** — parallel `bootable.img` guests, each waited on its completion marker and never on a fixed timer; the baseline is measured in the same session as the arm; a death counts whether or not a marker printed, so run guests with `-action reboot=shutdown -action shutdown=pause` and read a silent one's registers over QMP; a defect whose rate is set by interrupts per unit of guest work is measured on the *slowest* instrument — TCG can be the stronger oracle. -- **`/system/bin/supervisor` speaks in every program's name before that program runs** — a predicate keyed on a `: ` prefix is satisfied by the wrong speaker; wait for the whole line, in the constant the assertion also reads. +- **The supervisor speaks in every program's name before that program runs** — a predicate keyed on a `: ` prefix is satisfied by the wrong speaker; wait for the whole line, in the constant the assertion also reads. - **A guest binary cannot ask what a handle it does not hold does** — the probe ends its caller with a handle fault, so it runs in a child, one fault per child. - **A boot's capture has two pieces** — `boot_log()` ends at the ready marker, `run_test`'s capture begins at `===TEST_START===`, and a job's capture is every program's lines of its window, a server's late boot lines among them. - **A guest of an architecture the host is not runs under TCG** (`Arch::accel`) — TCG prices an uncontended atomic read-modify-write unlike hardware, and an x86-64 guest there boots `qemu64`, which has no PCID: every `INVPCID` path is dead, so a change gated on a CPUID feature is unverified by a green TCG suite. diff --git a/tests/common/metal.rs b/tests/common/metal.rs index f314fd0046..4e0f56822f 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -66,6 +66,10 @@ pub struct Arm { /// fit the bound every boot has, and a measurement's own span for one that /// runs longer on purpose. Two arms on one boot add both. pub adds_ms: u64, + /// A slot file bent in the marked slot, with the other slot carrying the + /// image whole (`super::slots`): a boot whose subject is the loader's + /// refusal and fallback. `None` for the one-slot image every other boot is. + pub bent: Option<&'static str>, } /// The ordinary arm: one boot, and the fields a caller must still say. @@ -79,7 +83,7 @@ pub const fn once( params: &'static [&'static str], jobs: &'static [&'static str], ) -> Arm { - Arm { boot, config, params, jobs, features: &[], last: None, adds_ms: 0 } + Arm { boot, config, params, jobs, features: &[], last: None, adds_ms: 0, bent: None } } /// One boot carrying members that are **discovered rather than registered**. @@ -560,6 +564,7 @@ pub struct Batch { config: &'static str, params: Vec<&'static str>, features: &'static [&'static str], + bent: Option<&'static str>, /// What the runner spawns, in order. pub jobs: Vec, /// What its arms and the members riding it add to the bound of its list. @@ -600,6 +605,7 @@ impl Batch { /// The batch of the boot `boot`, which `who` rides as `(config, params, /// features)`: a new one, or the one an earlier rider made where the two agree. +#[allow(clippy::too_many_arguments)] fn ride<'a>( out: &'a mut BTreeMap, who: &str, @@ -607,22 +613,24 @@ fn ride<'a>( config: &'static str, params: &'static [&'static str], features: &'static [&'static str], + bent: Option<&'static str>, ) -> Result<&'a mut Batch, String> { let batch = out.entry(boot.to_string()).or_insert_with(|| Batch { config, params: params.to_vec(), features, + bent, jobs: Vec::new(), adds_ms: 0, last: None, files: Vec::new(), links: Vec::new(), }); - if batch.config != config || batch.params != params || batch.features != features { + if batch.config != config || batch.params != params || batch.features != features || batch.bent != bent { return Err(format!( - "{who} rides the boot {boot:?} as ({config}, {params:?}, {features:?}) and another rides \ - it as ({}, {:?}, {:?}); one boot is one image", - batch.config, batch.params, batch.features + "{who} rides the boot {boot:?} as ({config}, {params:?}, {features:?}, bent {bent:?}) and another \ + rides it as ({}, {:?}, {:?}, bent {:?}); one boot is one image", + batch.config, batch.params, batch.features, batch.bent )); } Ok(batch) @@ -654,7 +662,7 @@ pub fn batches( for (name, decl) in tests { let Metal { arms, .. } = decl; for arm in *arms { - let batch = ride(&mut out, name, arm.boot, arm.config, arm.params, arm.features)?; + let batch = ride(&mut out, name, arm.boot, arm.config, arm.params, arm.features, arm.bent)?; batch.add(arm.jobs.iter().map(|j| (*j).to_string())); batch.adds_ms += arm.adds_ms; match (batch.last, arm.last) { @@ -682,7 +690,7 @@ pub fn batches( return Err(format!("two shared boots are both named {:?}", boot.boot)); } let who = format!("the shared boot {:?}", boot.boot); - let batch = ride(&mut out, &who, &boot.boot, boot.config, boot.params, boot.features)?; + let batch = ride(&mut out, &who, &boot.boot, boot.config, boot.params, boot.features, None)?; batch.add(boot.members.iter().map(|member| member.job.clone())); batch.adds_ms += boot.members_ms(); batch.files.extend(boot.files.iter().cloned()); @@ -837,7 +845,14 @@ fn build( params.push(&deadline); let mut plan = toyos_build::build::Plan::new(toyos_build::arch::Arch::X86_64, &config, features, ¶ms); plan.machines = toyos_build::microcode::Machines::Benches; - let bytes = toyos_build::build::build_test_image(root, &plan, quiet, &extra); + let bytes = match batch.bent { + None => toyos_build::build::build_test_image(root, &plan, quiet, &extra), + Some(file) => { + let parts = toyos_build::build::build_test_parts(root, &plan, quiet, &extra); + let signing = toyos_build::image::Signing { key: toyos_build::signing::key(), version: plan.version }; + super::slots::bent_fallback(plan.arch, &parts, &plan.params.join(","), signing, file) + } + }; let image = home.join("image.img"); std::fs::write(&image, &bytes).map_err(|e| format!("{}: {e}", image.display()))?; Ok(image) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index de03db65db..fdc7276f9b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -21,5 +21,6 @@ pub mod ps2server; pub mod qemu; pub mod screen; pub mod serial; +pub mod slots; /// A VNC client, as far as resizing a host window takes one. pub mod vnc; diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index ba2940e518..22dcdab6e0 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -1185,6 +1185,9 @@ pub struct BootOptions { /// The instant the machine's RTC starts at, as QEMU's `-rtc base=` takes /// it, and the host's own UTC with `None`. pub rtc_base: Option<&'static str>, + /// A slot file (`toyos_update::slots::*_FILE`) bent in the marked slot, + /// with the other slot carrying the image whole ([`super::slots`]). + pub bent: Option<&'static str>, } impl BootOptions { @@ -1225,6 +1228,7 @@ impl Default for BootOptions { data_bytes: None, psci_trace: None, rtc_base: None, + bent: None, } } } @@ -1261,6 +1265,9 @@ pub struct QemuInstance { boot_image: PathBuf, /// The variable store this boot copied for itself. vars: PathBuf, + /// The 16550's file, where a machine whose console is virtio-serial keeps + /// what the loader said. + uart_log: PathBuf, boot_log: String, /// This guest's vCPU count, kept so its liveness ceilings can be widened by /// its own oversubscription on a host with fewer cores than vCPUs — see @@ -1291,7 +1298,7 @@ fn kernel_of(options: &BootOptions) -> Vec<&'static str> { toyos_build::build::TEST_KERNEL.to_vec() } -// Eight, because an image is its architecture as much as its files and its kernel. +// Nine, because an image is its architecture and its slots as much as its files and its kernel. #[allow(clippy::too_many_arguments)] fn build_boot_image_with( arch: Arch, @@ -1302,6 +1309,7 @@ fn build_boot_image_with( kernel_features: &[&str], kernel_params: &[&str], debug_wait: bool, + bent: Option<&str>, ) -> 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 @@ -1368,7 +1376,15 @@ fn build_boot_image_with( let quiet = !VERBOSE.load(Ordering::Relaxed); let plan = toyos_build::build::Plan::new(arch, config, kernel_features, kernel_params); - toyos_build::build::build_test_image(&compile::repo_root(), &plan, quiet, &extra_files) + let root = compile::repo_root(); + match bent { + None => toyos_build::build::build_test_image(&root, &plan, quiet, &extra_files), + Some(file) => { + let parts = toyos_build::build::build_test_parts(&root, &plan, quiet, &extra_files); + let signing = toyos_build::image::Signing { key: toyos_build::signing::key(), version: plan.version }; + super::slots::bent_fallback(arch, &parts, &plan.params.join(","), signing, file) + } + } } /// Build all binaries in a test crate. @@ -1455,6 +1471,7 @@ impl QemuInstance { &features, ¶ms, options.debug_wait, + options.bent, ); let storage = match (options.profile.shape().storage, options.data_bytes) { (storage, None) => storage, @@ -1650,6 +1667,14 @@ impl QemuInstance { &self.boot_log } + /// What the loader said: the 16550's file so far on a machine whose + /// console is virtio-serial, and [`Self::boot_log`] on one whose console + /// is the serial port the loader writes too. + pub fn loader_log(&self) -> String { + let uart = fs::read_to_string(&self.uart_log).unwrap_or_default(); + format!("{uart}\n{}", self.boot_log) + } + /// The disk image this boot runs from: the stick under a stick profile. /// What the guest wrote to it is whole once [`Self::await_exit`] answers. pub fn boot_image(&self) -> &Path { @@ -2620,15 +2645,16 @@ fn spawn_and_wait_ready(mut qemu: Command, options: BootOptions, files: Files) - wait_for_ready(&mut child, &rx, &options, &uart_log) }; // The ready marker is test-runner's, which the supervisor starts after its - // first line, so a boot that reached it has said its build, and the kernel - // named the ROOT that carries it before that. Both are read off the image - // this boot was handed, never off the checkout, which may have moved since. + // first line, so a boot that reached it has said its build, and the file + // server serving `/system` named the ROOT that carries it before that. + // Both are read off the image this boot was handed, never off the + // checkout, which may have moved since. if options.ready_marker == DEFAULT_READY && !options.mute { let (root, bytes) = toyos_build::image::root_file_on(&boot_image, toyos_osrelease::PATH) .unwrap_or_else(|why| panic!("[qemu] {why}")); let release = toyos_osrelease::parse(&bytes) .unwrap_or_else(|why| panic!("[qemu] {} on {} is not the build's: {why:?}", toyos_osrelease::PATH, boot_image.display())); - let named = format!("boot: root={root}"); + let named = format!("{}{root}", toyos_osrelease::ROOT_SAID); let said = format!( "{}{} {}, committed {} UTC, toolchain {}, {}", toyos_osrelease::SAID, @@ -2657,6 +2683,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: BootOptions, files: Files) - screendump, boot_image, vars, + uart_log, boot_log, smp: options.smp, carried, diff --git a/tests/common/slots.rs b/tests/common/slots.rs new file mode 100644 index 0000000000..fbf3d14570 --- /dev/null +++ b/tests/common/slots.rs @@ -0,0 +1,71 @@ +//! An image whose marked slot's `file` carries one bent byte and whose other +//! slot carries the same image whole: the loader refuses the marked slot by +//! the section that file is, before it exits boot services, and boots the +//! other. A boot that falls back, rather than one with nothing to boot, is the +//! only refusal a metal bench comes back from, since a loader with no slot +//! powers the machine off. + +use std::io::{Seek, SeekFrom, Write}; + +use toyos_build::build::Parts; +use toyos_build::image::{self, SecondSlot, Signing}; + +/// The bytes of `parts` as such an image, its slot A's `file` (a +/// `toyos_update::slots::*_FILE`) bent. +pub fn bent_fallback( + arch: toyos_build::arch::Arch, + parts: &Parts, + params: &str, + signing: Signing<'_>, + file: &str, +) -> Vec { + let userland = &parts.userland; + let room = SecondSlot { root_bytes: userland.root.len() as u64 }; + let disk = image::create_boot_image(arch, &parts.bootloader, userland.release(&parts.kernel), params, signing, Some(room)); + let scratch = toyos_tmpdir::TempDir::new("bent-slot"); + let path = scratch.join("disk.img"); + std::fs::write(&path, &disk).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut f = std::fs::OpenOptions::new().read(true).write(true).open(&path).expect("the image just written"); + let table = image::slot_table_of(&mut f).expect("the image's slot table"); + let (a, b) = (table.slots[0].expect("slot A"), table.slots[1].expect("slot B")); + let extent = |f: &mut std::fs::File, guid| image::partition_extent(f, guid).expect("a slot's partition"); + let (a_boot, b_boot) = (extent(&mut f, a.boot), extent(&mut f, b.boot)); + let (a_root, b_root) = (extent(&mut f, a.root), extent(&mut f, b.root)); + assert_eq!(a_boot.1, b_boot.1, "the two slots' volumes are one size"); + assert!(b_root.1 >= userland.root.len() as u64, "slot B's ROOT holds slot A's"); + + // Slot B: slot A's volume and ROOT, byte for byte, so it verifies under + // the one signed header both carry. + let mut copy = |from: (u64, u64), to: u64| { + let mut bytes = vec![0u8; from.1 as usize]; + std::io::Read::read_exact(&mut (&disk[from.0 as usize..]), &mut bytes).expect("inside the image"); + f.seek(SeekFrom::Start(to)).and_then(|_| f.write_all(&bytes)).expect("write slot B"); + }; + copy(a_boot, b_boot.0); + copy((a_root.0, userland.root.len() as u64), b_root.0); + + // Slot A: one byte of the file bent where it sits on the volume, found by + // a run of its own bytes that occurs once on that volume. + let carried = image::read_file_on(&mut f, a.boot, file).expect("slot A carries the file"); + let volume = &disk[a_boot.0 as usize..(a_boot.0 + a_boot.1) as usize]; + let once = |probe: &[u8]| { + let mut hits = volume.windows(probe.len()).enumerate().filter(|(_, w)| *w == probe).map(|(i, _)| i); + match (hits.next(), hits.next()) { + (Some(at), None) => Some(at), + _ => None, + } + }; + let at = (1..16) + .map(|sixteenth| carried.len() * sixteenth / 16) + .find_map(|from| once(&carried[from..from + 64])) + .unwrap_or_else(|| panic!("no run of {file}'s bytes occurs once on slot A's volume")); + let byte = a_boot.0 + at as u64; + f.seek(SeekFrom::Start(byte)).and_then(|_| f.write_all(&[volume[at] ^ 1])).expect("bend slot A's file"); + drop(f); + + let bent = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut reread = std::fs::File::open(&path).expect("the bent image"); + assert_ne!(image::read_file_on(&mut reread, a.boot, file).expect("still a file"), carried, "{file} was not bent"); + assert_eq!(image::read_file_on(&mut reread, b.boot, file).expect("slot B's copy"), carried, "slot B's copy was bent"); + bent +} diff --git a/tests/corpus/tinycc/210_stat_identity.c b/tests/corpus/tinycc/210_stat_identity.c index 9899e9e364..3b021e2445 100644 --- a/tests/corpus/tinycc/210_stat_identity.c +++ b/tests/corpus/tinycc/210_stat_identity.c @@ -62,7 +62,7 @@ static void judge(const char *what, int is, int want) { } int main(void) { - struct stat a, b, a2, opened, moved, toybox, cat, supervisor; + struct stat a, b, a2, opened, moved, toybox, cat, release; struct stat bin, bin2, etc, sys, tmpdir, catlink, cplink; int fd; @@ -90,10 +90,10 @@ int main(void) { close(fd); if (stat_of("/system/bin/toybox", &toybox) || stat_of("/system/bin/cat", &cat) || - stat_of("/system/bin/supervisor", &supervisor)) + stat_of("/system/etc/os-release", &release)) return 1; judge("a link is the file it names", same(&toybox, &cat), 1); - judge("two files on /system are two", same(&toybox, &supervisor), 0); + judge("two files on /system are two", same(&toybox, &release), 0); judge("/system and /tmp are two volumes", toybox.st_dev == a.st_dev, 0); if (mkdir("/tmp/210_d", 0755) != 0 || stat_of("/system/bin", &bin) || stat_of("/system/etc/../bin", &bin2) || diff --git a/tests/guest/rust-tests/src/bin/abuse_handle_table.rs b/tests/guest/rust-tests/src/bin/abuse_handle_table.rs index d33ff284f5..5ea1bd48bc 100644 --- a/tests/guest/rust-tests/src/bin/abuse_handle_table.rs +++ b/tests/guest/rust-tests/src/bin/abuse_handle_table.rs @@ -28,6 +28,9 @@ //! **A pipe and a port are two inserts answered as one**: at the cap, and one //! slot short of fitting, each is refused with the table as it was. +#[path = "../image.rs"] +mod image; + use toyos_abi::syscall::{ self, EndowEntry, MmapFlags, MmapProt, SpawnArgs, SyscallError, MAX_SPAWN_ENDOWMENTS, QUIT_LABEL, SELF_LABEL, }; @@ -52,10 +55,18 @@ const REGION: usize = 4 * 1024 * 1024; /// be in range or the refusal would be the label check rather than the repeat. const LABELS: &[u8] = b"twice"; +/// This binary as an image, read before the table is filled: a full table has +/// no slot for the object a read makes. +fn own() -> &'static toyos::process::Image { + static OWN: std::sync::OnceLock = std::sync::OnceLock::new(); + OWN.get_or_init(|| image::image_of(SELF)) +} + fn main() { if std::env::args().nth(1).as_deref() == Some(CHILD) { return; } + own(); let region = unsafe { syscall::mmap( core::ptr::null_mut(), @@ -70,6 +81,8 @@ fn main() { const ARGV0: &[u8] = b"/system/bin/echo\0"; unsafe { core::ptr::copy_nonoverlapping(ARGV0.as_ptr(), argv, ARGV0.len()) }; + let echo = image::image_of("/system/bin/echo"); + let (image, image_len) = echo.spawn_words(); let spawn_with = |endow_count: u64| { let args = SpawnArgs { path_ptr: argv as u64, @@ -84,8 +97,8 @@ fn main() { labels_len: LABELS.len() as u64, cwd_ptr: CWD.as_ptr() as u64, cwd_len: CWD.len() as u64, - image: 0, - image_len: 0, + image, + image_len, place: u64::from(toyos_abi::HANDLE_INVALID.0), }; unsafe { syscall::spawn(&args) } @@ -179,6 +192,7 @@ fn a_pair_is_refused_whole(filled: &mut Vec) { /// `entries` into the blob `labels`. fn spawn_endowed(entries: &[EndowEntry], labels: &[u8]) -> Result { let argv = format!("{SELF}\0{CHILD}"); + let (image, image_len) = own().spawn_words(); // SAFETY: every pointer names a live local for the whole call. unsafe { syscall::spawn(&SpawnArgs { @@ -194,8 +208,8 @@ fn spawn_endowed(entries: &[EndowEntry], labels: &[u8]) -> Result [u8; LEN] { out } -/// A fresh handle on this binary at offset 0, so every `read` has bytes to give. +/// A fresh handle on this binary at offset 0, so every `read` has bytes to give: +/// its copy in the kernel's `/tmp`, made once. fn open_self() -> RawHandle { - syscall::open(SELF_PATH.as_bytes(), OpenFlags::READ).expect("open self") + static OWN: std::sync::OnceLock = std::sync::OnceLock::new(); + let own = OWN.get_or_init(|| image::in_tmp(SELF_PATH)); + syscall::open(own.as_bytes(), OpenFlags::READ).expect("open self") } /// `len` bytes at `addr`, as the buffer a `read` is asked to fill. diff --git a/tests/guest/rust-tests/src/bin/endowment_denied.rs b/tests/guest/rust-tests/src/bin/endowment_denied.rs index 1340c67893..f8ba99bee9 100644 --- a/tests/guest/rust-tests/src/bin/endowment_denied.rs +++ b/tests/guest/rust-tests/src/bin/endowment_denied.rs @@ -21,7 +21,7 @@ //! reading the roster of every process in the machine, and powering the machine //! off //! — and each is one bit on a handle to a `SysCap` the kernel mints exactly once, -//! for `/system/bin/supervisor`. A handle that carries the wrong *bit* is refused with a word, +//! for the supervisor. A handle that carries the wrong *bit* is refused with a word, //! because probing what an attenuated capability can still do is what //! attenuation is for; a handle that is **no handle at all** ends the caller. //! diff --git a/tests/guest/rust-tests/src/bin/hierarchy_paths.rs b/tests/guest/rust-tests/src/bin/hierarchy_paths.rs index a03a3990be..715e305420 100644 --- a/tests/guest/rust-tests/src/bin/hierarchy_paths.rs +++ b/tests/guest/rust-tests/src/bin/hierarchy_paths.rs @@ -66,7 +66,7 @@ fn root_refuses_every_way_of_changing_it() { "rmdir of a mount point was permitted" ); assert_eq!( - syscall::symlink(b"/system/bin/supervisor", b"/intruder"), + syscall::symlink(b"/system/etc/os-release", b"/intruder"), Err(SyscallError::PermissionDenied), "a symlink at / was permitted" ); @@ -74,7 +74,7 @@ fn root_refuses_every_way_of_changing_it() { // The machine is still here, and / still holds what it held. let after = names("/"); assert_eq!(after, ROOT_ENTRIES, "a refused operation changed / to {after:?}"); - assert!(fs::metadata("/system/bin/supervisor").is_ok(), "/system is unreadable after the refusals"); + assert!(fs::metadata("/system/etc/os-release").is_ok(), "/system is unreadable after the refusals"); println!(" PASS create, unlink, rename, mkdir, rmdir and symlink are all refused at /"); } diff --git a/tests/guest/rust-tests/src/bin/pipe_other_end_gone.rs b/tests/guest/rust-tests/src/bin/pipe_other_end_gone.rs index b413c16b16..88eb5d945f 100644 --- a/tests/guest/rust-tests/src/bin/pipe_other_end_gone.rs +++ b/tests/guest/rust-tests/src/bin/pipe_other_end_gone.rs @@ -21,6 +21,9 @@ //! it moved to a child that still lives: the watch stays armed through each, //! and is answered once that holder goes. +#[path = "../image.rs"] +mod image; + use std::io::Read; use std::os::toyos::process::CommandExt; use std::process::{Command, Stdio}; @@ -74,7 +77,8 @@ fn test() { let (client, server) = connection(); refused(client.as_handle(), "a connection"); - let file = syscall::open(SELF_PATH.as_bytes(), OpenFlags::READ).expect("this binary's own file"); + let own = image::in_tmp(SELF_PATH); + let file = syscall::open(own.as_bytes(), OpenFlags::READ).expect("this binary's own file"); refused(file, "a file"); syscall::close(file); println!(" a connection and a file were refused the question"); diff --git a/tests/guest/rust-tests/src/bin/process_tree.rs b/tests/guest/rust-tests/src/bin/process_tree.rs index 963a39dfa1..b86719d967 100644 --- a/tests/guest/rust-tests/src/bin/process_tree.rs +++ b/tests/guest/rust-tests/src/bin/process_tree.rs @@ -24,6 +24,9 @@ //! //! Every wait is unbounded: the harness ceiling is the only clock. +#[path = "../image.rs"] +mod image; + use std::io::{BufRead, BufReader}; use std::os::toyos::process::{ChildExt, CommandExt}; use std::process::{Child, ChildStdin, Command, Stdio}; @@ -224,6 +227,8 @@ fn launcher_copy() -> RawHandle { /// `SYS_SPAWN` of this binary as a C under `place`, killed again if it starts. fn spawn_under(place: RawHandle) -> Result { let argv = format!("{SELF_PATH}\0c"); + let own = image::image_of(SELF_PATH); + let (image, image_len) = own.spawn_words(); let args = SpawnArgs { path_ptr: SELF_PATH.as_ptr() as u64, path_len: SELF_PATH.len() as u64, @@ -237,8 +242,8 @@ fn spawn_under(place: RawHandle) -> Result { labels_len: 0, cwd_ptr: "/".as_ptr() as u64, cwd_len: 1, - image: 0, - image_len: 0, + image, + image_len, place: u64::from(place.0), }; // SAFETY: every pointer names a buffer of this frame that outlives the call. diff --git a/tests/guest/rust-tests/src/bin/spawn_child_ends_first.rs b/tests/guest/rust-tests/src/bin/spawn_child_ends_first.rs index da9aaf1fdf..cf35e7ff93 100644 --- a/tests/guest/rust-tests/src/bin/spawn_child_ends_first.rs +++ b/tests/guest/rust-tests/src/bin/spawn_child_ends_first.rs @@ -14,6 +14,9 @@ //! //! Every wait is unbounded: the runner's deadline is the only clock. +#[path = "../image.rs"] +mod image; + use toyos_abi::syscall::{self, debug_action, EndowEntry, SpawnArgs, SyscallError}; use toyos_abi::RawHandle; use toyos_abi::end::End; @@ -89,6 +92,8 @@ fn spawn(role: &str, stdout: Option) -> Result = stdout.into_iter().map(|handle| EndowEntry { label_off: 0, label_len: LABEL.len() as u32, handle, _pad: 0 }).collect(); + let own = image::image_of(SELF_PATH); + let (image, image_len) = own.spawn_words(); let args = SpawnArgs { path_ptr: SELF_PATH.as_ptr() as u64, path_len: SELF_PATH.len() as u64, @@ -102,8 +107,8 @@ fn spawn(role: &str, stdout: Option) -> Result Result { let argv = format!("{SELF_PATH}\0child"); + let own = image::image_of(SELF_PATH); + let (image, image_len) = own.spawn_words(); let args = SpawnArgs { path_ptr: SELF_PATH.as_ptr() as u64, path_len: SELF_PATH.len() as u64, @@ -89,8 +94,8 @@ fn spawn_under(place: RawHandle) -> Result { labels_len: 0, cwd_ptr: "/".as_ptr() as u64, cwd_len: 1, - image: 0, - image_len: 0, + image, + image_len, place: u64::from(place.0), }; // SAFETY: every pointer names a buffer of this frame that outlives the call. diff --git a/tests/guest/rust-tests/src/image.rs b/tests/guest/rust-tests/src/image.rs new file mode 100644 index 0000000000..7a6d9a368e --- /dev/null +++ b/tests/guest/rust-tests/src/image.rs @@ -0,0 +1,28 @@ +//! What a raw `SYS_SPAWN` names as its program and what a raw `SYS_OPEN` +//! opens, now that the kernel opens no file of `/system`: a program read whole +//! into a memory object of this process's own, and a file copied to the +//! kernel's `/tmp`. + +// Each binary takes the half it needs. +#![allow(dead_code)] + +use std::io::Read as _; + +use toyos::process::Image; + +/// `path`, read whole into a memory object of this process's own, which a +/// `SpawnArgs` names by [`Image::spawn_words`]. +pub fn image_of(path: &str) -> Image { + let mut file = std::fs::File::open(path).unwrap_or_else(|e| panic!("open {path}: {e}")); + let len = file.metadata().unwrap_or_else(|e| panic!("stat {path}: {e}")).len(); + Image::read(len, |buf| file.read(buf)).unwrap_or_else(|why| panic!("read {path} into an image: {why:?}")) +} + +/// `path` copied to `/tmp` under its own name, which the kernel opens: the +/// path to open. +pub fn in_tmp(path: &str) -> String { + let name = path.rsplit('/').next().expect("a path names a file"); + let copy = format!("/tmp/{name}"); + std::fs::copy(path, ©).unwrap_or_else(|e| panic!("copy {path} to {copy}: {e}")); + copy +} diff --git a/tests/toyos.rs b/tests/toyos.rs index d8bb58a8ae..4cc2209bfc 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -512,6 +512,17 @@ const MACHINE_TESTS: &[&str] = &[ // `images/console.toml`'s image, which runs no job and hands no machine // back: a metal boot of it ends with a hand on the power button. "console_image_boots", + // The UEFI loader's check of a boot program, which no host test reaches: + // a slot whose supervisor is bent is refused before boot services end, + // and the other slot boots. On both architectures' QEMU machines; the + // `bent_supervisor_falls_back` metal row runs it on a bench. + "a_bent_supervisor_is_refused_and_the_other_slot_boots", + // Memory the kernel frees only when a process's address space goes: a + // child spawned from an image (every program of `/system` is) holds the + // image until then, and a region its parent still maps must not keep + // that space alive. The kernel's object lifetimes, which no host test + // holds; one boot of QEMU's x86-64 machine. + "a_spawn_from_an_image_frees_it_when_the_child_ends", // The boot's log written through usbd onto the stick it booted from, and // still there once the machine has stopped: the path every metal boot's // `/log` takes, which no other QEMU row reads, because their console is @@ -609,6 +620,10 @@ const MACHINE_TESTS: &[&str] = &[ /// than derived from its config and parameters, because sharing is not always /// safe and only the author knows. const METAL: &[(&str, metal::Metal)] = &[ + ( + "bent_supervisor_falls_back", + metal::Metal { arms: BENT_SUPERVISOR, judge: |b| bent_supervisor_fell_back(&b[0].loader(), &b[0].kernel()) }, + ), // ---- one image: tests/images/testcases.toml, no parameters, one job list ---- ( "blackbox_unclaimed_page", @@ -2463,6 +2478,47 @@ fn console_image_boots() -> Result<(), String> { said.must_be_clean() } +/// Megabytes in use, by `free`'s line, after `free` itself has ended. +fn used_mib(qemu: &mut QemuInstance) -> Result { + let said = qemu.run_test("free", Duration::from_secs(30)).stdout; + let line = said.lines().find(|l| l.trim_end().ends_with('M')).ok_or_else(|| format!("free said no figures:\n{said}"))?; + let used = line.split_whitespace().nth(1).and_then(|w| w.strip_suffix('M')?.parse().ok()); + used.ok_or_else(|| format!("free's line {line:?} names no used megabytes")) +} + +/// Eight spawns of a program read from `/system` into an image, each of a +/// child that maps the log ring its parent keeps: what is in use after them is +/// what was before, give or take one 2 MiB image. A child's ended address +/// space kept alive by its parent's ring holds the image, 2 MiB a spawn. +fn a_spawn_from_an_image_frees_it_when_the_child_ends(test_config: &Path) -> Result<(), String> { + let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], BootOptions::default()); + let before = used_mib(&mut qemu)?; + for _ in 0..8 { + used_mib(&mut qemu)?; + } + let after = used_mib(&mut qemu)?; + match after <= before + 2 { + true => Ok(()), + false => Err(format!("{} MiB more is in use after eight spawns than before them: {before} MiB, then {after}", after - before)), + } +} + +/// What a boot whose marked slot's supervisor is bent says: the loader refused +/// that slot by the section, and the kernel was booted from the other. +fn bent_supervisor_fell_back(loader: &serial::Serial, kernel: &serial::Serial) -> Result<(), String> { + let refused = toyos_update::policy::Refusal::Hash(toyos_update::image::SUPERVISOR); + loader.must_say(&format!("Slot A: REFUSED, {refused}"))?; + // `kernel/src/params.rs`'s `SLOT_RECORD`, which the kernel's binary keeps. + kernel.must_say(&format!("boot: slot B, because the marked slot A was refused: {}", refused.word()))?; + Ok(()) +} + +/// [`bent_supervisor_fell_back`] on a bench, whose loader keeps its own log. +const BENT_SUPERVISOR: &[metal::Arm] = &[metal::Arm { + bent: Some(toyos_update::slots::SUPERVISOR_FILE), + ..metal::once("bentsupervisor", "tests/images/testcases.toml", &[], &[]) +}]; + /// Boot `tests/images/testcases.toml` from a USB stick, as a metal boot does, /// write the stick through usbd ([`metal::STICK_LOAD_JOB`]), press the power /// button while it writes, and read the stick's log volume back with the metal @@ -3782,7 +3838,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re "percpu: BSP cpu_id=0", "GIC: v", "clock: the generic timer counts at", - "spawned /system/bin/supervisor pid=", + "spawned the supervisor pid=", SPAWNED, ] { if !serial.contains(want) { @@ -4121,6 +4177,16 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { "bar_map_behind_a_bridge" => bar_map_behind_a_bridge(test_config), "lent_device_memory_refused" => lent_device_memory_refused(test_config), "console_image_boots" => console_image_boots(), + "a_spawn_from_an_image_frees_it_when_the_child_ends" => a_spawn_from_an_image_frees_it_when_the_child_ends(test_config), + "a_bent_supervisor_is_refused_and_the_other_slot_boots" => { + [qemu::Profile::Headless, qemu::Profile::Virt].into_iter().try_for_each(|profile| { + let options = BootOptions { profile, bent: Some(toyos_update::slots::SUPERVISOR_FILE), ..Default::default() }; + let qemu = QemuInstance::boot_with_options(test_config, &[], &[], options); + let loader = serial::Serial::named("loader's serial", qemu.loader_log()); + let kernel = serial::Serial::named("boot's console", qemu.boot_log().to_string()); + bent_supervisor_fell_back(&loader, &kernel) + }) + } "the_log_lands_on_the_boot_stick" => the_log_lands_on_the_boot_stick(test_config), "a_usb_key_reaches_the_console" => a_usb_key_reaches_the_console(), "desktop_without_x2apic" => desktop_without_x2apic(), From e21f72ede852f6aabc0327ea25b0d6534753ad5c Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 04:47:12 +0200 Subject: [PATCH 3/6] boot: answer review round 1 - A manifest row's source is typed (toyos_manifest::Source): a path, or the file server's boot image, rendered `boot-program `. The kernel ABI's label is the endowment's alone, and its doc no longer speaks for the manifest. A swap never targets SYSTEM, which runs the signed image. - SharedImage's SAFETY comment states the precondition boot images meet: ordinary memory alive while the object lives. - The supervisor joins each bootstrap reader once it has answered, and keeps the boot images as handles it checked are immutable rather than mapping ROOT it never reads; an os-release the build did not write is said on the console too. - KernelArgs' cmdline contract names the loader's words and the kernel's parameters beside the actuators; bootimage::init hands the images back rather than through a static; Immutable::adopt refuses with its own word. - toyos-metal admits an image by its one slot table, so a two-slot image (the bent-supervisor row) is flashable. - Issue records that cited deleted code are restated: the unused loader words, the guest suite's ROOT rows, the supervisor's console record, the libc EINVAL record and the SMP record. Co-Authored-By: Claude Opus 5.5 --- build/build.rs | 6 +- build/image.rs | 15 ++- ...ore-logkeeper-starts-reaches-no-console.md | 22 ++-- ...einval-for-every-error-it-does-not-name.md | 4 +- ...-reserve-window-red-under-a-loaded-host.md | 2 +- ...and-two-loader-words-that-nothing-calls.md | 3 +- ...-runs-only-what-no-cheaper-tier-reaches.md | 8 +- kernel/src/bootimage.rs | 24 ++--- kernel/src/file_backing.rs | 8 +- kernel/src/main.rs | 4 +- lib/manifest/src/launch.rs | 2 +- lib/manifest/src/lib.rs | 83 +++++++++++--- sdk/abi/src/boot.rs | 16 +-- sdk/abi/src/syscall.rs | 4 +- sdk/toyos/src/files.rs | 2 +- sdk/toyos/src/shm.rs | 26 ++++- system/supervisor/src/main.rs | 102 ++++++++++-------- tests/metal/src/lib.rs | 28 +++-- 18 files changed, 225 insertions(+), 134 deletions(-) diff --git a/build/build.rs b/build/build.rs index 4ea1d4626f..8c57267ccb 100644 --- a/build/build.rs +++ b/build/build.rs @@ -787,9 +787,9 @@ fn render_manifest(config: &SystemConfig) -> Vec { let cfg = &config.programs[*name]; toyos_manifest::Program { name: (*name).clone(), - path: match name.as_str() { - FILESERVER_PROGRAM => toyos_abi::syscall::FILESERVER_IMAGE_LABEL.to_string(), - _ => format!("/system/bin/{name}"), + source: match name.as_str() { + FILESERVER_PROGRAM => toyos_manifest::Source::BootFileServer, + _ => toyos_manifest::Source::Path(format!("/system/bin/{name}")), }, args: cfg.args.clone(), serves: cfg.serves.clone(), diff --git a/build/image.rs b/build/image.rs index 5bab30e226..e6d563fef7 100644 --- a/build/image.rs +++ b/build/image.rs @@ -1379,8 +1379,8 @@ mod tests { /// **The independent oracle for the hierarchy.** ROOT is served at /// `/system`, so every absolute path the shipped image declares is - /// `/system/` plus a name ROOT carries, but the file server's, whose image - /// is the slot's and is named by the label it is handed under. Asked of a + /// `/system/` plus a name ROOT carries; the file server's row names no + /// path, its image being the slot's. Asked of a /// finished image by readers that did not write it: `toyos-gpt` finds ROOT /// by type where the table says, `bcachefs` mounts it, and `toyos-manifest` /// parses the records out of the volume. @@ -1391,18 +1391,17 @@ mod tests { let declared = toyos_manifest::parse( std::str::from_utf8(&manifest).expect("the manifest this build renders is text"), ); - let on_root: Vec<&toyos_manifest::Program> = - declared.programs.iter().filter(|p| p.path != toyos_abi::syscall::FILESERVER_IMAGE_LABEL).collect(); + let on_root: Vec<&str> = declared.programs.iter().filter_map(|p| p.source.path()).collect(); assert_eq!( declared.programs.len() - on_root.len(), 1, - "the shipped image declares one file server, named by its image's label" + "the shipped image declares one program run from a boot image, the file server" ); // Placeholders: what this judges is the path scheme, and the bytes of a // real binary say nothing about it. let mut files: Vec<(String, Vec)> = - on_root.iter().map(|p| (under_system(&p.path).to_string(), b"placeholder".to_vec())).collect(); + on_root.iter().map(|path| (under_system(path).to_string(), b"placeholder".to_vec())).collect(); files.push((toyos_manifest::PATH.to_string(), manifest.clone())); let root_image = create_root_image(&files, &symlinks, true); @@ -1434,9 +1433,9 @@ mod tests { .into_iter() .map(|(name, _)| name) .collect(); - for path in on_root.iter().map(|p| &p.path) { + for &path in &on_root { let name = under_system(path); - assert_ne!(name, path.as_str(), "{path} is not under the mount ROOT gets"); + assert_ne!(name, path, "{path} is not under the mount ROOT gets"); assert!(names.contains(name), "ROOT has no {name} for the declared path {path}"); } } diff --git a/issues/a-supervisor-panic-before-logkeeper-starts-reaches-no-console.md b/issues/a-supervisor-panic-before-logkeeper-starts-reaches-no-console.md index c83910d878..1b292cc6f8 100644 --- a/issues/a-supervisor-panic-before-logkeeper-starts-reaches-no-console.md +++ b/issues/a-supervisor-panic-before-logkeeper-starts-reaches-no-console.md @@ -6,17 +6,23 @@ opened: 2026-10-04 # A supervisor panic before logkeeper starts reaches no console -`/system/bin/supervisor` makes its own lines records in its log ring first thing +The supervisor makes its own lines records in its log ring first thing (`Log::open` in `system/supervisor/src/main.rs`), and only `logkeeper` drains a ring. So a panic before `logkeeper` runs leaves its message in a -ring nobody reads. The refusals of a ROOT without -`/system/etc/os-release`, or without a readable `/system/etc/system.manifest`, -are such panics. +ring nobody reads. -Measured with ROOT carrying no os-release (`machine_shutdown`, x86-64, QEMU): -the console's last kernel record is `exit: supervisor pid=0 code=101`, the -panic's words appear nowhere, and the harness waits out its 63 s boot ceiling -for `===READY===`. It does not treat the supervisor's end as a death. +The bootstrap's own refusals are said on the console first +(`bootstrap_fails`): the SYSTEM file server not starting, `/system/etc/os-release` +or `/system/etc/system.manifest` not read, and an os-release the build did not +write. What is left: a manifest `toyos_manifest::parse` panics on (a build +whose two halves disagree), and every other panic between `Log::open` and +`logkeeper`'s start. + +Measured before `bootstrap_fails`, with ROOT carrying no os-release +(`machine_shutdown`, x86-64, QEMU): the console's last kernel record is +`exit: supervisor pid=0 code=101`, the panic's words appear nowhere, and the +harness waits out its 63 s boot ceiling for `===READY===`. It does not treat +the supervisor's end as a death. **Exit**: a supervisor panic before `logkeeper` serves puts its message on the console, and the harness ends a boot whose supervisor exited, naming the exit. diff --git a/issues/libc-answers-einval-for-every-error-it-does-not-name.md b/issues/libc-answers-einval-for-every-error-it-does-not-name.md index d4290ff0f2..eb018bfe1b 100644 --- a/issues/libc-answers-einval-for-every-error-it-does-not-name.md +++ b/issues/libc-answers-einval-for-every-error-it-does-not-name.md @@ -14,9 +14,7 @@ sends `Unknown`, `BadAddress`, `ResourceExhausted` and `NotSupported` through full (`fd_errno`, `sdk/libc/src/posix_io.rs`), but a full kernel handle table (`kernel/src/object/handle.rs`) refuses the `open` or `pipe` beneath it first with `ResourceExhausted`, which reaches the caller as `EINVAL` where POSIX has -`EMFILE`; a full -bcachefs volume (`kernel/src/bcachefs_adapter.rs`) `write` `EINVAL` where it -has `ENOSPC`, and an operation a filesystem lacks `EINVAL` where it has +`EMFILE`; and an operation a filesystem lacks `EINVAL` where it has `ENOTSUP`. A refusal that is truly `EINVAL` reads the same as each of them, so a case asserting `EINVAL` passes whichever happened. diff --git a/issues/smp-ap-hole-and-log-reserve-window-red-under-a-loaded-host.md b/issues/smp-ap-hole-and-log-reserve-window-red-under-a-loaded-host.md index 4d9997039a..44bad8b3e9 100644 --- a/issues/smp-ap-hole-and-log-reserve-window-red-under-a-loaded-host.md +++ b/issues/smp-ap-hole-and-log-reserve-window-red-under-a-loaded-host.md @@ -22,7 +22,7 @@ still panics on any refusal. What answered `WouldBlock` was never found. The same run red `log_reserve_window` on a ROOT read that missed its 2000 ms budget in the kernel's usb-storage driver. That half is gone: the kernel reads -ROOT only from the image the loader read into memory (`kernel/src/rootfs.rs`) +no ROOT, which the supervisor's file server serves from the loader's image, and drives no USB (`13ed7b4f4`). **Both tests are deleted**, as flaky tests are: `aedcf17dc` took diff --git a/issues/sys-debug-actions-and-two-loader-words-that-nothing-calls.md b/issues/sys-debug-actions-and-two-loader-words-that-nothing-calls.md index 5571eef96a..6add4546a1 100644 --- a/issues/sys-debug-actions-and-two-loader-words-that-nothing-calls.md +++ b/issues/sys-debug-actions-and-two-loader-words-that-nothing-calls.md @@ -20,8 +20,7 @@ finds no caller of: `heap_ceiling`'s, for stage C's `heap_ceiling_bounds`; - `LOCK_ACROSS_SWITCH` (2), `test_panic_child`'s. Its stage C item is a type (`lock_across_switch_halts`), which calls no `SYS_DEBUG`, so no stage brings a caller back; -- `boot::WRITE_NO_LAYOUT_PARAM` and `boot::WITHHOLD_ROOT_PARAM`, for stage A's - `kernel_args_layout_refused` and `root_withheld_refused`. +- `boot::WRITE_NO_LAYOUT_PARAM`, for stage A's `kernel_args_layout_refused`. `LOG_PATTERNED` (21) has one caller, `panic_halts_first`, which wants a record and reads none of the pattern `kernel/src/log/storm.rs` writes into it: the checksum and payload had test-runner's diff --git a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md index 66cb185866..f9f00eefee 100644 --- a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md +++ b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md @@ -34,9 +34,11 @@ name is a `#[test]` in the named crate or a `METAL` row, and reverting the behav - host `broken_data_volume_is_absent`, `data_candidate_with_bad_geometry_is_absent`: a bad DATA candidate is absent, not formatted. - host `block_duplicate_id`: two disks with one identity are refused. -- metal `root_from_memory`: ROOT mounts from the loader's image with no storage command before init. -- metal `root_withheld_refused`: a handoff with no ROOT image is refused by name; brings back - `loader-withholds-root` in the loader as well as the kernel. +- metal `root_from_memory`: the supervisor's SYSTEM file server serves ROOT out of the loader's + image, with no storage command before the supervisor reads the manifest. +- metal `boot_image_withheld_refused`: a handoff whose supervisor, file server or ROOT image is + empty or outside the loader's memory is refused by name before `mm::init`; brings in a loader + word that withholds one, read by the loader and the kernel. - metal `kernel_args_layout_refused`: a loader of another `KernelArgs` layout is refused first; brings back `loader-writes-no-layout` in the loader as well as the kernel. - metal `boot_from_power_on`: the power-on spans are the loader's counts at the kernel's rate. diff --git a/kernel/src/bootimage.rs b/kernel/src/bootimage.rs index 6044571000..2d44fa43fc 100644 --- a/kernel/src/bootimage.rs +++ b/kernel/src/bootimage.rs @@ -24,19 +24,15 @@ use toyos_rootimage::handoff::{held, Descriptor}; use crate::mm::policy::CachePolicy; use crate::mm::{DirectMap, Region, PAGE_SIZE}; use crate::object::shm::{Memory, SharedMemObject}; -use crate::sync::Lock; -/// The three images, as the loader handed them: kept from [`init`] to -/// [`objects`]. +/// The three images, as the loader handed them and [`init`] judged them. #[derive(Clone, Copy)] -struct Handed { +pub struct Handed { supervisor: BootImage, fileserver: BootImage, root: BootImage, } -static HANDED: Lock> = Lock::new(None); - /// Every image in the order [`init`] returns their regions, by name. fn named(handed: &Handed) -> [(&'static str, BootImage); 3] { [("supervisor", handed.supervisor), ("fileserver", handed.fileserver), ("ROOT", handed.root)] @@ -48,9 +44,9 @@ fn pages(image: BootImage) -> Region { } /// Take the images out of the handoff, refusing the boot by name on one the -/// kernel may not keep, and answer their regions for `mm::init` to withhold. -/// Runs before `mm::init`, so it neither allocates nor logs. -pub fn init(args: &KernelArgs, map: &[MemoryMapEntry]) -> [Region; 3] { +/// kernel may not keep, and answer them with their regions for `mm::init` to +/// withhold. Runs before `mm::init`, so it neither allocates nor logs. +pub fn init(args: &KernelArgs, map: &[MemoryMapEntry]) -> (Handed, [Region; 3]) { let handed = Handed { supervisor: args.supervisor_image, fileserver: args.fileserver_image, root: args.root_image }; for (what, image) in named(&handed) { assert!(image.len != 0, "boot: the loader handed no {what} image"); @@ -72,8 +68,7 @@ pub fn init(args: &KernelArgs, map: &[MemoryMapEntry]) -> [Region; 3] { image.len ); } - *HANDED.lock() = Some(handed); - named(&handed).map(|(_, image)| pages(image)) + (handed, named(&handed).map(|(_, image)| pages(image))) } /// The objects the supervisor is spawned from and handed. @@ -83,10 +78,9 @@ pub struct Objects { pub root: Arc, } -/// One object over each image, once `mm::init` has run: called once, by the -/// supervisor's spawn. -pub fn objects() -> Objects { - let handed = HANDED.lock().take().expect("boot: the images are taken once, after `init`"); +/// One object over each image, once `mm::init` has run, for the supervisor's +/// spawn. +pub fn objects(handed: Handed) -> Objects { let object = |image: BootImage| { let region = pages(image); SharedMemObject::over(crate::object::shm::Region { diff --git a/kernel/src/file_backing.rs b/kernel/src/file_backing.rs index 8dad2a7fc4..e175efeb0b 100644 --- a/kernel/src/file_backing.rs +++ b/kernel/src/file_backing.rs @@ -63,9 +63,11 @@ impl FileBacking for SharedImage { } let valid = BLOCK_SIZE.min((self.size - file_offset) as usize); // SAFETY: `over` held `size` inside the object, which is one physically - // contiguous run the kernel allocated (`ram`) and keeps while `object` - // lives, so `file_offset + valid <= size` bytes from its direct-map - // address are mapped; `buf` is the reader's own and never the object. + // contiguous run of ordinary memory (`is_ordinary`: pages the kernel + // allocated, or a boot image it keeps for the machine's life) alive + // while `object` lives, so `file_offset + valid <= size` bytes from its + // direct-map address are mapped; `buf` is the reader's own and never + // the object. // No reference is formed over the object's bytes, which its holders // may be writing: this is the one fetch of each. unsafe { diff --git a/kernel/src/main.rs b/kernel/src/main.rs index f437c8d0ae..baca858726 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -264,7 +264,7 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { params::init(cmdline); deadline::claim(cmdline); actuator::init(cmdline); - let [supervisor_image, fileserver_image, root_image] = bootimage::init(&kernel_args, maps); + let (boot_images, [supervisor_image, fileserver_image, root_image]) = bootimage::init(&kernel_args, maps); arch::boot::after_console(&kernel_args, maps); @@ -455,7 +455,7 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { // Off the loader's images, so nothing from here to the supervisor's // spawn asks a disk for anything: every storage driver comes up after it. // The supervisor reads /system/etc/system.manifest itself; the boot config never names the program it starts. - let pid = process::spawn_supervisor(bootimage::objects()); + let pid = process::spawn_supervisor(bootimage::objects(boot_images)); log!("spawned the supervisor pid={pid}"); #[cfg(feature = "boot-actuators")] diff --git a/lib/manifest/src/launch.rs b/lib/manifest/src/launch.rs index 75699feb03..2c573c70e1 100644 --- a/lib/manifest/src/launch.rs +++ b/lib/manifest/src/launch.rs @@ -259,7 +259,7 @@ mod tests { use super::*; fn row(name: &str) -> Program { - Program { name: name.into(), path: format!("/system/bin/{name}"), ..Program::default() } + Program { name: name.into(), source: crate::Source::Path(format!("/system/bin/{name}")), ..Program::default() } } fn swap() -> Program { diff --git a/lib/manifest/src/lib.rs b/lib/manifest/src/lib.rs index ea39afba71..2db3b6eb0f 100644 --- a/lib/manifest/src/lib.rs +++ b/lib/manifest/src/lib.rs @@ -13,7 +13,8 @@ //! job already done. //! //! ```text -//! program starts a program's records +//! program starts a program's records: the executable at +//! boot-program starts one whose process runs the file server's boot image //! arg argv after argv[0] //! serve the supervisor makes one machine-wide port and endows the acceptor //! provide this program makes its own port, once per instance @@ -242,10 +243,46 @@ pub fn syscap_rights(names: &[String]) -> Result { Ok(rights) } +/// What a row's process runs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Source { + /// The executable at this path: a file of `/system`, or an installed + /// package's. + Path(String), + /// The file server's boot image, which the kernel hands the supervisor + /// (`toyos_abi::boot::BootImage`): what serves `/system` runs from no file. + BootFileServer, +} + +impl Default for Source { + fn default() -> Self { + Self::Path(String::new()) + } +} + +impl core::fmt::Display for Source { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Path(path) => f.write_str(path), + Self::BootFileServer => f.write_str("the file server's boot image"), + } + } +} + +impl Source { + /// The executable's path; `None` for a boot image. + pub fn path(&self) -> Option<&str> { + match self { + Self::Path(path) => Some(path), + Self::BootFileServer => None, + } + } +} + #[derive(Clone, Default, Debug, PartialEq, Eq)] pub struct Program { pub name: String, - pub path: String, + pub source: Source, pub args: Vec, /// Machine-wide ports the supervisor creates and endows the **acceptor** of. pub serves: Vec, @@ -306,7 +343,7 @@ impl Program { /// The installed package this row launches: a row [`Manifest::app_row`] /// made. pub fn package(&self) -> Option<&str> { - package::package_of(&self.path) + package::package_of(self.source.path()?) } /// The `HOME` the supervisor starts this row with. A location grants nothing: what @@ -378,7 +415,7 @@ impl Manifest { } Ok(Program { name: name.to_string(), - path: program.to_string(), + source: Source::Path(program.to_string()), receives: self.apps.clone(), ..Program::default() }) @@ -439,11 +476,16 @@ pub fn render(manifest: &Manifest) -> Result, RenderError> { return Err(RenderError::NameTooLong(program.name.clone())); } check(&program.name, "name", &program.name)?; - check(&program.name, "path", &program.path)?; if !program.serves.is_empty() && !program.service { return Err(RenderError::ServesWithoutService(program.name.clone())); } - out.push_str(&format!("program {} {}\n", program.name, program.path)); + match &program.source { + Source::Path(path) => { + check(&program.name, "path", path)?; + out.push_str(&format!("program {} {path}\n", program.name)); + } + Source::BootFileServer => out.push_str(&format!("boot-program {}\n", program.name)), + } for arg in &program.args { reject_newline(&program.name, "args", arg)?; out.push_str(&format!("arg {arg}\n")); @@ -593,7 +635,18 @@ pub fn parse(text: &str) -> Manifest { ); manifest.programs.push(Program { name: name.to_string(), - path: path.to_string(), + source: Source::Path(path.to_string()), + ..Program::default() + }); + } + "boot-program" => { + assert!( + rest.len() <= MAX_PROGRAM_NAME, + "manifest: program name longer than {MAX_PROGRAM_NAME}: {rest}" + ); + manifest.programs.push(Program { + name: rest.to_string(), + source: Source::BootFileServer, ..Program::default() }); } @@ -641,7 +694,7 @@ mod tests { programs: vec![ Program { name: "compositor".into(), - path: "/system/bin/compositor".into(), + source: Source::Path("/system/bin/compositor".into()), serves: vec!["compositor".into()], receives: vec!["soundserver".into()], devices: vec!["framebuffer".into(), "acpi".into()], @@ -652,7 +705,7 @@ mod tests { }, Program { name: "soundserver".into(), - path: "/system/bin/soundserver".into(), + source: Source::Path("/system/bin/soundserver".into()), serves: vec!["soundserver".into()], devices: vec!["pci:04:01:00/1af4:1059".into()], syscap: vec!["rt".into()], @@ -661,13 +714,13 @@ mod tests { }, Program { name: "update".into(), - path: "/system/bin/update".into(), + source: Source::Path("/system/bin/update".into()), slots: true, ..Program::default() }, Program { name: "fileserver".into(), - path: toyos_abi::syscall::FILESERVER_IMAGE_LABEL.into(), + source: Source::BootFileServer, receives: vec!["block".into()], roles: vec!["data".into(), "log".into()], restart: true, @@ -675,14 +728,14 @@ mod tests { }, Program { name: "vdagent".into(), - path: "/system/bin/vdagent".into(), + source: Source::Path("/system/bin/vdagent".into()), receives: vec!["clipboard".into()], drives: vec!["pci:07:80:00/1af4:1043".into()], ..Program::default() }, Program { name: "terminal".into(), - path: "/system/bin/terminal".into(), + source: Source::Path("/system/bin/terminal".into()), args: vec!["--login shell".into()], provides: vec!["surface".into()], receives: vec!["compositor".into()], @@ -706,7 +759,7 @@ mod tests { assert!(row.syscap.is_empty()); assert!(row.serves.is_empty()); assert!(row.provides.is_empty()); - assert_eq!(row.path, "/apps/gbae/gbae"); + assert_eq!(row.source.path(), Some("/apps/gbae/gbae")); } /// The one property both halves depend on, and the reason they live here. @@ -960,7 +1013,7 @@ mod tests { .iter() .map(|(name, devices, drives)| Program { name: name.to_string(), - path: format!("/system/bin/{name}"), + source: Source::Path(format!("/system/bin/{name}")), devices: devices.iter().map(|d| d.to_string()).collect(), drives: drives.iter().map(|d| d.to_string()).collect(), ..Program::default() diff --git a/sdk/abi/src/boot.rs b/sdk/abi/src/boot.rs index d2fd0f8f05..153e852f7b 100644 --- a/sdk/abi/src/boot.rs +++ b/sdk/abi/src/boot.rs @@ -62,11 +62,12 @@ pub struct KernelArgs { /// layout. pub layout: u32, /// The boot parameter, as ASCII with no terminator: comma-separated tokens - /// read out of `\toyos\cmdline` on the volume the bootloader loaded itself - /// from, read by [`tokens`]. + /// read out of `\toyos\cmdline` on the slot's volume, with the words the + /// loader appends ([`SLOT_PARAM`] among them), read by [`tokens`]. /// - /// Every shipping image carries none, and a kernel that - /// carries no actuators refuses any token rather than ignoring it + /// Every token is a parameter the kernel claims (`kernel/src/params.rs`) + /// or an actuator its table declares, and a kernel that carries no + /// actuators refuses any other rather than ignoring it /// (`kernel/src/actuator.rs`). It is in this struct rather than anywhere the /// kernel could go and fetch it because the earliest actuator panics before /// `mm::init` and another acts at AP bring-up: a parameter that is not here @@ -295,13 +296,6 @@ pub struct MemoryMapEntry { mod tests { use super::*; - #[test] - fn a_boot_parameter_is_its_non_empty_tokens() { - assert!(tokens("usb-flush-fails,fat-boot-reads-fail").eq(["usb-flush-fails", "fat-boot-reads-fail"])); - assert!(tokens("boot-slot=A,,x").eq(["boot-slot=A", "x"])); - assert_eq!(tokens("").count(), 0); - } - #[test] fn a_window_holds_an_extent_and_not_merely_its_first_address() { let window = RootBridgeWindow { base: 0xa080_0000, length: 0x1f80_0000 }; diff --git a/sdk/abi/src/syscall.rs b/sdk/abi/src/syscall.rs index 3210ca3fc3..ccaa2bb408 100644 --- a/sdk/abi/src/syscall.rs +++ b/sdk/abi/src/syscall.rs @@ -420,9 +420,7 @@ pub const SYSCAP_LABEL: &str = "syscap"; pub const SVC_LABEL: &str = "svc"; /// The labels the kernel puts on the immutable memory objects it hands the /// supervisor beside the system capability: the file server's executable and -/// ROOT's bytes, both as the loader verified them (`boot::KernelArgs`). The -/// file server's is also the path its manifest row names, which is where its -/// image comes from. +/// ROOT's bytes, both as the loader verified them (`boot::KernelArgs`). pub const FILESERVER_IMAGE_LABEL: &str = "boot:fileserver"; pub const ROOT_IMAGE_LABEL: &str = "boot:root"; /// The label every process starts holding a handle to itself under, carrying diff --git a/sdk/toyos/src/files.rs b/sdk/toyos/src/files.rs index cc6d0e5dd4..926c979176 100644 --- a/sdk/toyos/src/files.rs +++ b/sdk/toyos/src/files.rs @@ -1,5 +1,5 @@ //! Files by path, and the descriptors that name open ones, wherever they are -//! served: the kernel serves `/system` and `/tmp`, and every other directory a +//! served: the kernel serves `/tmp`, and every other directory a //! process may reach is a capability in its namespace, `fs:`, //! served by a file server ([`crate::fs`]). A program or a library is read //! here into an image the kernel pages it from ([`read_image`]). diff --git a/sdk/toyos/src/shm.rs b/sdk/toyos/src/shm.rs index 7c8ad344c5..c7c75f2dd8 100644 --- a/sdk/toyos/src/shm.rs +++ b/sdk/toyos/src/shm.rs @@ -173,13 +173,13 @@ impl Immutable { /// Map `handle`, refused and never mapped unless the kernel answers it is /// a region nobody can write. Its length is the kernel's answer. The handle /// is consumed either way. - pub fn adopt(handle: OwnedHandle) -> Result { - let stat = syscall::fstat(handle.raw()).map_err(Lent::Kernel)?; + pub fn adopt(handle: OwnedHandle) -> Result { + let stat = syscall::fstat(handle.raw()).map_err(NotImmutable::Kernel)?; if stat.file_type != FileType::ImmutableMemory { - return Err(Lent::NotRam); + return Err(NotImmutable::Writable); } // A region the kernel maps is within the address space, so its length is a `usize`. - Ok(Self { ptr: map(&handle).map_err(Lent::Kernel)?, handle, len: stat.size as usize }) + Ok(Self { ptr: map(&handle).map_err(NotImmutable::Kernel)?, handle, len: stat.size as usize }) } pub fn as_slice(&self) -> &[u8] { @@ -189,6 +189,24 @@ impl Immutable { } } +/// Why a handle was not taken as an [`Immutable`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum NotImmutable { + /// The kernel would not describe or map it. + Kernel(SyscallError), + /// It names something other than a region nobody can write. + Writable, +} + +impl fmt::Display for NotImmutable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Kernel(e) => write!(f, "the kernel would not describe or map it ({e:?})"), + Self::Writable => write!(f, "it is not a region nobody can write"), + } + } +} + impl AsHandle for Immutable { fn as_handle(&self) -> RawHandle { self.handle.raw() diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index ccc1fdceb9..49fa1ecba7 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -101,7 +101,7 @@ use toyos_manifest::grants::{self, Answer, Decision, Store}; use toyos_manifest::launch::{self as authority, Authority, Session, Sessions, Target}; use toyos_manifest::package::{self, Package}; use toyos_manifest::device::{Entry, PciMatch}; -use toyos_manifest::{Manifest, Program, View}; +use toyos_manifest::{Manifest, Program, Source, View}; use toyos_supervisor::claims::{self, Missing, Unresolved}; use toyos_supervisor::drives::{self, Act, Drives}; use toyos_supervisor::restart::{self, AfterEnd, Budget}; @@ -466,7 +466,7 @@ fn main() { let no_grants = Grants { roles: Vec::new(), block: None, netstack: None }; let unread = Manifest::default(); let started = system_service.spawn( - &system_row.path, + &system_row.source, &[], Vec::new(), &unread, @@ -707,9 +707,9 @@ struct Service<'a> { program: &'a Program, /// The file-server role this process serves, for a row with `roles`. role: Option<&'a str>, - /// The binary the running process was started from: the row's path, or a - /// swap's installed one. - path: String, + /// What the running process was started from: the row's source, or a + /// swap's installed binary. + source: Source, /// `None` once neither binary would start. child: Option, /// The row's devices the running process was endowed: what a process @@ -731,7 +731,7 @@ impl<'a> Service<'a> { Self { program, role, - path: program.path.clone(), + source: program.source.clone(), child: None, devices: Vec::new(), ports: Rc::new(RefCell::new(acceptors)), @@ -741,13 +741,13 @@ impl<'a> Service<'a> { } } - /// Start `path` holding this service's manifest row, and answer its pid. + /// Start `source` holding this service's manifest row, and answer its pid. /// /// `owed` is what the process this start follows held: a start refused any /// of it is no start, so a service never runs on without a device it had. fn spawn( &mut self, - path: &str, + source: &Source, owed: &[String], block: Vec, system: &Manifest, @@ -763,7 +763,7 @@ impl<'a> Service<'a> { // wrote it is not a claim about what is there now. This narrows the // window to the spawn itself and does not close it // (`issues/a-swapped-binary-lives-where-any-process-can-rewrite-it.md`). - if let Some(digest) = toyos_swap::installed_digest(path) { + if let Some((path, digest)) = source.path().and_then(|path| Some((path, toyos_swap::installed_digest(path)?))) { let bytes = read_binary(path).map_err(|why| std::io::Error::other(why.to_string()))?; toyos_swap::verify(&bytes, &digest).map_err(|why| { std::io::Error::other(format!("{path} no longer holds what was installed: {why}")) @@ -775,11 +775,14 @@ impl<'a> Service<'a> { } let ports = self.ports.borrow(); let served = Served::Keep { acceptors: &ports, owed }; - let mut command = Command::new(path); - if path == FILESERVER_IMAGE_LABEL { - command = Command::new(&self.program.name); - command.boot_image(boot.fileserver.as_handle().0); - } + let command = match source { + Source::Path(path) => Command::new(path), + Source::BootFileServer => { + let mut command = Command::new(&self.program.name); + command.boot_image(boot.fileserver.as_handle().0); + command + } + }; let (child, devices) = start( command, self.program, @@ -796,7 +799,7 @@ impl<'a> Service<'a> { drop(ports); let pid = child.id(); self.child = Some(child); - self.path = path.to_string(); + self.source = source.clone(); self.devices = devices; Ok(pid) } @@ -854,7 +857,7 @@ enum Phase { Answered { conn: Connection, rx: LaunchRx, since: Instant }, /// The new binary runs; it is the service if it is still running at /// `until`, and `previous` is started again if it is not. - Probation { until: Instant, previous: String, owed: Vec }, + Probation { until: Instant, previous: Source, owed: Vec }, } impl Flight { @@ -950,7 +953,7 @@ impl<'a> Supervisor<'a> { continue; } let started = service.spawn( - &program.path, + &program.source, &[], block, system, @@ -1055,7 +1058,8 @@ impl<'a> Supervisor<'a> { let launcher = (&self.minted, self.sessions.machine()); let served = Served::Keep { acceptors: &[], owed: &[] }; let output = Output::Boot(&mut self.log); - start(Command::new(&row.path), row, self.system, self.syscap, served, &self.connectors, &self.grants, &[], extra, launcher, output) + let path = row.source.path().ok_or_else(|| format!("{} runs {}, and a `drives` row runs a file", row.name, row.source))?; + start(Command::new(path), row, self.system, self.syscap, served, &self.connectors, &self.grants, &[], extra, launcher, output) .map(|(child, _)| (child, stop)) .map_err(|e| e.to_string()) } @@ -1118,7 +1122,8 @@ impl<'a> Supervisor<'a> { grants::Request::Add { package, folder } => { let (system, name) = (self.system, package.clone()); let binary = self.files("a grant's files", move || -> Result { - binary_digest(&installed(system, &name)?.path) + let row = installed(system, &name)?; + binary_digest(row.source.path().ok_or_else(|| format!("{name} runs {}", row.source))?) })??; let said = format!("{package} is granted {} {}", folder.path, folder.access); self.keep(&package, binary, Answer::Granted(folder))?; @@ -1501,7 +1506,9 @@ impl<'a> Supervisor<'a> { // request leaves nothing staged behind it. let bytes = read_binary(&request.staged); let _ = std::fs::remove_file(&request.staged); - let Some(index) = self.services.iter().position(|s| s.program.name == name) else { + // Not SYSTEM, which no manifest row declares: it runs the signed boot + // image whatever is installed. + let Some(index) = self.services.iter().position(|s| s.program.name == name && s.role != Some(toyos_manifest::SYSTEM)) else { return refuse(name, Refusal::NotAService(name.to_string())); }; if let Some(busy) = flight { @@ -1525,7 +1532,7 @@ impl<'a> Supervisor<'a> { return refuse(name, why); } let installed = toyos_swap::installed_path(name, &request.digest); - if installed == service.path { + if service.source.path() == Some(installed.as_str()) { return refuse(name, Refusal::AlreadyRuns(installed)); } if let Err(why) = install(&installed, &request.digest, &bytes) { @@ -1538,7 +1545,7 @@ impl<'a> Supervisor<'a> { "{installed} ({} bytes, sha256 {}) replaces {} (pid {})", bytes.len(), toyos_swap::hex(&request.digest), - service.path, + service.source, service.pid().map_or(0, |p| p) ), ); @@ -1578,7 +1585,7 @@ impl<'a> Supervisor<'a> { /// Stop the service and start the installed binary in its place. fn cut_over(&mut self, index: usize, path: String) -> Option { let name = self.services[index].program.name.clone(); - let previous = self.services[index].path.clone(); + let previous = self.services[index].source.clone(); // What the process being stopped holds, which its replacement and the // binary a failed swap starts again are each owed. let owed = self.services[index].devices.clone(); @@ -1601,7 +1608,7 @@ impl<'a> Supervisor<'a> { let _ = old.wait(); } let started = service.spawn( - &path, + &Source::Path(path.clone()), &owed, block, self.system, @@ -1636,7 +1643,7 @@ impl<'a> Supervisor<'a> { /// Probation is over: the new binary is the service, or the one it /// replaced is started again. - fn end_probation(&mut self, index: usize, path: &str, previous: &str, owed: &[String]) { + fn end_probation(&mut self, index: usize, path: &str, previous: &Source, owed: &[String]) { let service = &mut self.services[index]; let name = service.program.name.clone(); // The loop watches its end from here, and an end before this answer @@ -1652,7 +1659,7 @@ impl<'a> Supervisor<'a> { Word::InService, &format!("{path} as pid {}", service.pid().map_or(0, |p| p)), ); - if previous != path { + if let Some(previous) = previous.path().filter(|&previous| previous != path) { forget(previous); } } @@ -1826,12 +1833,12 @@ impl<'a> Supervisor<'a> { ); } AfterEnd::Restart => { - let (path, owed, program) = (service.path.clone(), service.devices.clone(), service.program); + let (source, owed, program) = (service.source.clone(), service.devices.clone(), service.program); self.make_home(program); let block = self.block_args(); let service = &mut self.services[index]; let (system, syscap, connectors, grants) = (self.system, self.syscap, &self.connectors, &self.grants); - match service.spawn(&path, &owed, block, system, syscap, connectors, grants, &self.minted, self.boot, &mut self.log) { + match service.spawn(&source, &owed, block, system, syscap, connectors, grants, &self.minted, self.boot, &mut self.log) { Ok(new) => say!("supervisor: {label} (pid {pid}) ended; started again as pid {new}"), Err(e) => { service.ports.borrow_mut().clear(); @@ -1860,7 +1867,7 @@ impl<'a> Supervisor<'a> { /// Start the binary a failed swap replaced, or close the service's ports /// when that will not start either. - fn restore(&mut self, index: usize, previous: &str, owed: &[String]) { + fn restore(&mut self, index: usize, previous: &Source, owed: &[String]) { let program = self.services[index].program; self.make_home(program); let block = self.block_args(); @@ -2099,12 +2106,15 @@ impl Supervisor<'_> { resolve(system, &path, |target| -> Result<_, authority::Refusal> { let starts = authority::may_start(row, session, target)?; let (Target::Row(program) | Target::Package(program)) = target; - let prepared = command.image_from(Path::new(&program.path)).prepare().map(drop); + let prepared = match program.source.path() { + Some(path) => command.image_from(Path::new(path)).prepare().map(drop), + None => Err(std::io::Error::other(format!("{} runs {}, which no launch starts", program.name, program.source))), + }; let binary = matches!(target, Target::Package(_)).then(|| { command .prepared_image() .map(|bytes| toyos_swap::hex(&toyos_swap::digest(bytes))) - .ok_or_else(|| format!("{} was not read before its start, so no grant can name it", program.path)) + .ok_or_else(|| format!("{} was not read before its start, so no grant can name it", program.source)) }); Ok((starts, binary, prepared.map(|()| command))) }) @@ -2445,7 +2455,7 @@ impl Drop for Moved { /// named that row's binary — directly, or through a link that lands on it. fn declared<'a>(system: &'a Manifest, path: &str) -> Option<&'a Program> { let key = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string(); - let row = |p: &str| system.program(&key(p)).filter(|program| program.path == p); + let row = |p: &str| system.program(&key(p)).filter(|program| program.source.path() == Some(p)); if let Some(program) = row(path) { return Some(program); } @@ -2491,8 +2501,8 @@ fn resolve<'a, V>(system: &'a Manifest, path: &str, judge: impl FnOnce(Target<'_ Ok(row) => row, Err(why) => return Resolved::Refused(why), }; - if row.path != path { - return Resolved::Refused(format!("{} launches {:?} and this asks for {path:?}", Package::path(name), row.path)); + if row.source.path() != Some(path) { + return Resolved::Refused(format!("{} launches {} and this asks for {path:?}", Package::path(name), row.source)); } let verdict = judge(Target::Package(&row)); Resolved::Package(row, verdict) @@ -3299,7 +3309,7 @@ fn say_release(server: &Service<'_>) { let path = toyos_osrelease::GUEST_PATH; let text = read_system(path, server); let release = toyos_osrelease::parse(text.as_bytes()) - .unwrap_or_else(|e| panic!("supervisor: {path} is not one the build wrote: {e:?}")); + .unwrap_or_else(|e| bootstrap_fails(format!("{path} is not one the build wrote: {e:?}"))); say!( "{}{} {}, committed {} UTC, toolchain {}, {}", toyos_osrelease::SAID, @@ -3316,14 +3326,19 @@ fn say_release(server: &Service<'_>) { /// this boot's end, said by name. fn read_system(path: &'static str, server: &Service<'_>) -> String { let (answer, answered) = std::sync::mpsc::channel(); - std::thread::Builder::new() + let reader = std::thread::Builder::new() .name("bootstrap".into()) .spawn(move || { // The receiver is gone once the bound has passed. let _ = answer.send(std::fs::read_to_string(path)); }) .expect("supervisor: no thread to read /system on"); - match answered.recv_timeout(FILES_BOUND) { + let read = answered.recv_timeout(FILES_BOUND); + // Answered, so it is on its way out. + if read.is_ok() { + reader.join().expect("supervisor: the bootstrap reader panicked"); + } + match read { Ok(Ok(text)) => text, Ok(Err(e)) => bootstrap_fails(format!("cannot read {path}: {e}")), Err(_) => bootstrap_fails(format!( @@ -3350,9 +3365,11 @@ fn bootstrap_fails(why: String) -> ! { /// the file server's executable and ROOT's bytes, which nobody writes /// (`toyos_abi::boot::BootImage`). Kept for the machine's life, since SYSTEM /// is started again from them. +/// +/// Handles and not mappings: the supervisor reads neither, it hands them on. struct Boot { - fileserver: toyos::shm::Immutable, - root: toyos::shm::Immutable, + fileserver: toyos::OwnedHandle, + root: toyos::OwnedHandle, } impl Boot { @@ -3361,8 +3378,9 @@ impl Boot { let handle: toyos::OwnedHandle = Endowments::get() .take(label) .unwrap_or_else(|| panic!("supervisor: the kernel spawns this program holding `{label}`")); - toyos::shm::Immutable::adopt(handle) - .unwrap_or_else(|why| panic!("supervisor: `{label}` is no image nobody writes: {why}")) + let kind = toyos_abi::syscall::fstat(handle.as_handle()).map(|stat| stat.file_type); + assert_eq!(kind, Ok(FileType::ImmutableMemory), "supervisor: `{label}` is no image nobody writes"); + handle }; Self { fileserver: image(FILESERVER_IMAGE_LABEL), root: image(ROOT_IMAGE_LABEL) } } @@ -3374,7 +3392,7 @@ impl Boot { fn system_row() -> Program { Program { name: "fileserver".to_string(), - path: FILESERVER_IMAGE_LABEL.to_string(), + source: Source::BootFileServer, roles: vec![toyos_manifest::SYSTEM.to_string()], restart: true, ..Program::default() diff --git a/tests/metal/src/lib.rs b/tests/metal/src/lib.rs index 220fdbda60..654bc3570c 100644 --- a/tests/metal/src/lib.rs +++ b/tests/metal/src/lib.rs @@ -899,13 +899,14 @@ pub fn judge_arms(armed: &[String]) -> Result { } } -/// [`whole_disk`], and exactly one partition of each of the three types at the -/// numbers the installed rule names. +/// [`whole_disk`]: exactly one ESP and one TOYOS-LOG at the numbers the +/// installed rule names, and one slot table, which names the slots the loader +/// boots — one or two ROOTs. fn admit(path: &Path, target: &Target) -> Result { let (mut file, bytes) = whole_disk(path).map_err(Refusal::Disk)?; let esp = one_partition(&mut file, toyos_gpt::Guid::EFI_SYSTEM, "ESP")?; let log = one_partition(&mut file, toyos_gpt::Guid::MICROSOFT_BASIC, "TOYOS-LOG")?; - one_partition(&mut file, toyos_gpt::Guid::TOYOS_ROOT, "TOYOS-ROOT")?; + one_partition(&mut file, toyos_gpt::Guid::TOYOS_SLOTS, "TOYOS-SLOTS")?; for (what, got, want) in [("the ESP", esp.index, target.esp_part), ("TOYOS-LOG", log.index, target.log_part)] { @@ -3062,7 +3063,8 @@ mod tests { std::fs::write(&at, bytes).expect("write"); at }; - let (esp, basic, root) = (toyos_gpt::Guid::EFI_SYSTEM, toyos_gpt::Guid::MICROSOFT_BASIC, toyos_gpt::Guid::TOYOS_ROOT); + let (esp, basic, slots, root) = + (toyos_gpt::Guid::EFI_SYSTEM, toyos_gpt::Guid::MICROSOFT_BASIC, toyos_gpt::Guid::TOYOS_SLOTS, toyos_gpt::Guid::TOYOS_ROOT); assert_eq!( admit(&write("ragged.img", &vec![0u8; 1000]), &t), @@ -3082,24 +3084,32 @@ mod tests { Err(Refusal::Disk(Unfit::BackupHeader { .. })) )); - let two = disk(&[("a", esp), ("b", esp), ("log", basic), ("root", root)]); + let two = disk(&[("a", esp), ("b", esp), ("log", basic), ("slots", slots)]); assert_eq!( admit(&write("two-esps.img", &two), &t), Err(Refusal::Partitions { what: "ESP", matched: 2 }) ); - let no_log = disk(&[("esp", esp), ("root", root)]); + let no_log = disk(&[("esp", esp), ("slots", slots)]); assert_eq!( admit(&write("no-log.img", &no_log), &t), Err(Refusal::Partitions { what: "TOYOS-LOG", matched: 0 }) ); // The log where the installed rule does not name it: p2, not p3. - let moved = disk(&[("esp", esp), ("log", basic), ("root", root)]); + let moved = disk(&[("esp", esp), ("log", basic), ("slots", slots)]); assert_eq!( admit(&write("moved-log.img", &moved), &t), Err(Refusal::PartitionIndex { what: "TOYOS-LOG", want: 3, got: 2 }) ); - // And the shape `build/image.rs` writes, which is the one admitted. - let built = disk(&[("esp", esp), ("root", root), ("log", basic)]); + let no_table = disk(&[("esp", esp), ("root", root), ("log", basic)]); + assert_eq!( + admit(&write("no-table.img", &no_table), &t), + Err(Refusal::Partitions { what: "TOYOS-SLOTS", matched: 0 }) + ); + // And the shapes `build/image.rs` writes, one slot or two, which are + // the ones admitted. + let two_slots = disk(&[("esp", esp), ("slots", slots), ("log", basic), ("a", root), ("b", root)]); + assert!(admit(&write("two-slots.img", &two_slots), &t).is_ok(), "a two-slot image is refused"); + let built = disk(&[("esp", esp), ("slots", slots), ("log", basic), ("root", root)]); let ok = admit(&write("built.img", &built), &t).expect("the built shape is admitted"); assert_eq!((ok.esp.index, ok.log.index), (1, 3)); assert_eq!(ok.bytes % u64::from(LBA), 0); From 4a133b79d33c2da0d0ea63cff8944b454ab6fd4f Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 05:04:09 +0200 Subject: [PATCH 4/6] boot: answer review round 2 - toyos-metal admits an image carrying one or two TOYOS-ROOTs beside its slot table, and refuses zero or three by name (Refusal::Roots), so the gate's doc says what it checks. - issues: an ended detached thread is reaped only at its process's next spawn, with the ff9b4b1c5 ps evidence and a scratch run at a head carrying #987 that shows the later spawns reaping the readers. - issues/root-in-memory-costs-its-whole-size.md: the measured cost per held child, 14.2 MiB at the base and 16.2 MiB with the spawn's image, which takes spawn_children_cap's 255 children past a 4 GiB guest; its exit is the memory design's stages 2, 4 and 6, as the orchestrator ruled. Co-Authored-By: Claude Opus 5.5 --- ...-reaped-only-at-its-processs-next-spawn.md | 44 +++++++++++++++++++ issues/root-in-memory-costs-its-whole-size.md | 34 ++++++++++++-- tests/metal/src/lib.rs | 20 ++++++++- 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md diff --git a/issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md b/issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md new file mode 100644 index 0000000000..9e3388ee13 --- /dev/null +++ b/issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md @@ -0,0 +1,44 @@ +--- +status: open +kind: defect +opened: 2026-10-22 +--- + +# An ended detached thread is reaped only at its process's next spawn + +The kernel releases a thread only when another thread joins it +(`SYS_THREAD_JOIN`). Nothing can be told that no thread ever will. A thread std +detaches can't free the stack it runs on, so as it ends it pushes its block +onto `REAPED` (`sdk/std/sys/thread.rs`). The next `Thread::new` in the same +process joins it, which frees its 2 MiB stack and its `MAX_THREADS` place. + +Until that spawn, the ended thread stays in its process's table. `ps` shows it +as `Z`, and its stack stays allocated. A process that detaches threads and then +never spawns another keeps all of them for its whole life. That covers a +service whose last thread start is a detached worker: the supervisor detaches +`sync-{role}` (`system/supervisor/src/main.rs`), and anything that drops a +`JoinHandle` does the same. The cost is bounded by the threads detached since +the last spawn, not by the number of spawns. #987 made it bounded; before #987 +nothing reaped these threads at all. + +Measured on QEMU's x86-64 machine booting `tests/images/testcases.toml`, with +toybox `ps` after the boot: + +- At `ff9b4b1c5`, which has neither #987 nor a join, the supervisor's two + `bootstrap` reader threads, which the supervisor had detached, show `Z` for + the rest of the boot. +- At `25d787482`, which has #987 but not that commit's join (a scratch revert), + no `bootstrap` thread is listed. The supervisor's later thread spawns had + reaped them. + +The case this record is about, a process that never spawns again, was not +measured. It follows from `Thread::new` being the only reaper. + +*Exit:* the kernel releases an ended thread that nobody will join. That means +a detach call, or a flag at thread creation, so the thread's place goes when +it ends. std's detach then frees the stack at once: by the kernel unmapping it +as part of the thread's end, or by std freeing it from a thread that is still +alive. `REAPED` is deleted. libc's `pthread_detach` (`sdk/libc/src/pthread.rs`) +uses the same pattern and moves with it. + +Owner: the orchestrator. diff --git a/issues/root-in-memory-costs-its-whole-size.md b/issues/root-in-memory-costs-its-whole-size.md index 80390d6757..7c767fefa1 100644 --- a/issues/root-in-memory-costs-its-whole-size.md +++ b/issues/root-in-memory-costs-its-whole-size.md @@ -35,6 +35,29 @@ at `b9e4c5862`, 384 MiB once ROOT left the kernel, one boot each, the same session; the 54 MiB are the running services' images, each rounded up to 2 MiB, and the SYSTEM file server. +**Per held child, the image is 2 MiB of a 16 MiB cost, and it is what puts +255 children past a 4 GiB guest.** `libc_children`'s `spawn_children_cap` +holds 255 unwaited children of one `/system` program. Measured on QEMU's x86-64 +machine, `tests/images/libccase.toml`, 4 GiB, toybox's `free` every 50 held +children, one boot each in the same session (a scratch patch to the case, not +committed): + +| held | used at `6ae79ccaa` (kernel pages from ROOT) | used at `25d787482` (spawn reads an image) | +|---|---|---| +| 0 | 126 MiB | 178 MiB | +| 50 | 836 MiB | 988 MiB | +| 100 | 1546 MiB | 1798 MiB | +| 150 | 2254 MiB | 2606 MiB | +| 200 | 2964 MiB | 3416 MiB | +| 250 | 3674 MiB | refused at 241 (`ENOMEM`) | + +That is 14.2 MiB a child at the base and 16.2 MiB with the image, the +difference one image rounded up to 2 MiB. Each child's exit record says +`peak=2MB allocs=2` in both: `peak` counts what a process allocated, and neither +its image nor what the kernel holds for it. So the image costs the case its +last 14 children, and the 14.2 MiB beside it are 2 MiB pages, which +`process-memory-is-2-mib-pages-and-that-caps-the-process-count.md` removes. + **On the dev host the fast tier's peak host memory is now below `main`'s.** An alternating A/B of `cargo test` against `origin/main` at `b0adc600`, three arms each in one session, summing the RSS of the run's QEMU processes every 2 s: @@ -55,6 +78,11 @@ complete K ms`; a test boot on QEMU reads 1390, 108 (14) and 344. Not measured: the T14. What that run owes is that line off the stick and off NVMe, against the base's `Boot: complete`. -Exit: either measured and accepted by the owner as the price, or a program's -pages mapped from ROOT's own read-only pages rather than copied, before the -self-update's signature check makes the whole read a fixed cost. +Exit: a program's pages mapped from ROOT's own read-only pages rather than +copied, before the self-update's signature check makes the whole read a fixed +cost. The orchestrator ruled the mechanism (2026-10-22): the memory design's +stages 2, 4 and 6, namely 4 KiB pages, so an image is a program's size and not +2 MiB; memory-object subranges; and immutable file objects shared across +spawns, so that SYSTEM's object for a ROOT file is a subrange of the boot +image. No dedicated call over part of a boot image is to be built. +`spawn_children_cap` holds all 255 children again once that has landed. diff --git a/tests/metal/src/lib.rs b/tests/metal/src/lib.rs index 654bc3570c..df858e9ad6 100644 --- a/tests/metal/src/lib.rs +++ b/tests/metal/src/lib.rs @@ -135,6 +135,9 @@ pub enum Refusal { Table(String), /// The table does not hold exactly one partition of a type the loop needs. Partitions { what: &'static str, matched: u32 }, + /// The table holds neither one nor two ROOTs, which are the slots the + /// loader boots. + Roots { matched: u32 }, /// The image is armed with a parameter [`FLASHABLE`] does not clear. Armed { name: String }, /// **The image carries no bound on its own boot.** Every metal image is @@ -230,6 +233,9 @@ impl fmt::Display for Refusal { Self::Partitions { what, matched } => { write!(f, "the image carries {matched} {what} partitions and this loop needs one") } + Self::Roots { matched } => { + write!(f, "the image carries {matched} TOYOS-ROOT partitions and this loop needs one or two") + } Self::NoBound { staged_a_wedge } => write!( f, "the image carries no `{}` on its parameter line{}. Every image the \ @@ -900,8 +906,8 @@ pub fn judge_arms(armed: &[String]) -> Result { } /// [`whole_disk`]: exactly one ESP and one TOYOS-LOG at the numbers the -/// installed rule names, and one slot table, which names the slots the loader -/// boots — one or two ROOTs. +/// installed rule names, one slot table, and one or two ROOTs, the slots the +/// loader boots. fn admit(path: &Path, target: &Target) -> Result { let (mut file, bytes) = whole_disk(path).map_err(Refusal::Disk)?; let esp = one_partition(&mut file, toyos_gpt::Guid::EFI_SYSTEM, "ESP")?; @@ -914,6 +920,12 @@ fn admit(path: &Path, target: &Target) -> Result { return Err(Refusal::PartitionIndex { what, want, got }); } } + let roots = toyos_gpt::locate_type(&mut toyos_build::image::FileSectors(&mut file), toyos_gpt::Guid::TOYOS_ROOT, &mut []) + .map_err(|e| Refusal::Table(format!("{e:?}")))? + .matched; + if !(1..=2).contains(&roots) { + return Err(Refusal::Roots { matched: roots }); + } Ok(Flashable { path: path.to_path_buf(), bytes, esp, log }) } @@ -3100,6 +3112,10 @@ mod tests { admit(&write("moved-log.img", &moved), &t), Err(Refusal::PartitionIndex { what: "TOYOS-LOG", want: 3, got: 2 }) ); + let rootless = disk(&[("esp", esp), ("slots", slots), ("log", basic)]); + assert_eq!(admit(&write("rootless.img", &rootless), &t), Err(Refusal::Roots { matched: 0 })); + let three = disk(&[("esp", esp), ("slots", slots), ("log", basic), ("a", root), ("b", root), ("c", root)]); + assert_eq!(admit(&write("three-roots.img", &three), &t), Err(Refusal::Roots { matched: 3 })); let no_table = disk(&[("esp", esp), ("root", root), ("log", basic)]); assert_eq!( admit(&write("no-table.img", &no_table), &t), From e229dfbe67d5877f4638ad128eb4077e9aaad7bb Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 10:07:25 +0200 Subject: [PATCH 5/6] issues: no supervisor thread is an example of a kept detached thread The sync threads are joined once they answer; drop the example. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- ...ched-thread-is-reaped-only-at-its-processs-next-spawn.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md b/issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md index 9e3388ee13..5d325bcb14 100644 --- a/issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md +++ b/issues/an-ended-detached-thread-is-reaped-only-at-its-processs-next-spawn.md @@ -14,10 +14,8 @@ process joins it, which frees its 2 MiB stack and its `MAX_THREADS` place. Until that spawn, the ended thread stays in its process's table. `ps` shows it as `Z`, and its stack stays allocated. A process that detaches threads and then -never spawns another keeps all of them for its whole life. That covers a -service whose last thread start is a detached worker: the supervisor detaches -`sync-{role}` (`system/supervisor/src/main.rs`), and anything that drops a -`JoinHandle` does the same. The cost is bounded by the threads detached since +never spawns another keeps all of them for its whole life: any program that +drops a `JoinHandle` and starts no thread after it. The cost is bounded by the threads detached since the last spawn, not by the number of spawns. #987 made it bounded; before #987 nothing reaped these threads at all. From aa499fde155e67feb550c7be8831898596f2d71c Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 10:35:55 +0200 Subject: [PATCH 6/6] fileserver: ROOT answers room, none #970 added Volume::room. ROOT is read-only, so it has no room, as the read-only FAT and absent volumes answer, in bcachefs blocks. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- system/fileserver/src/root.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/system/fileserver/src/root.rs b/system/fileserver/src/root.rs index 8b54d7c68f..282d9b293f 100644 --- a/system/fileserver/src/root.rs +++ b/system/fileserver/src/root.rs @@ -282,6 +282,11 @@ impl Volume for RootVolume { Ok(Vec::new()) } + /// None: ROOT is read-only. + fn room(&mut self) -> Result { + Ok(toyos::fs::Room { free: 0, unit: BLOCK as u64 }) + } + fn describe(&self) -> String { format!("{}{}, {} blocks read-only from memory", toyos_osrelease::ROOT_SAID, self.uuid(), self.fs.io().block_count()) }