From 4e2048034e3ffba0ef0b0ab104b1ac423c756bff Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 04:18:54 +0200 Subject: [PATCH 1/7] A launch starts only what the caller's row lists; swap and update only in a login session The owner's ruling of 2026-10-03, "Restrict": each program may start only the programs its configuration row lists; swap and update of system programs only from the login session. And "Adopt": unforgeable minter-written badges on connections, the kernel vouching for what was granted, never who connects. - Kernel: `SYS_PORT_MINT` mints a connector to the caller's own port carrying 1..=MAX_BADGE (64) bytes, gated by READ on the acceptor; every connection made through it is stamped with the bytes and the port's never-repeating id, and `SYS_PORT_BADGE` answers the stamp only to that port's acceptor. - toyos-manifest: `starts` and `login` per row; `launch::may_start`, pure, with `Session`, `Authority` (the badge's codec) and `Refusal`. - Supervisor: one launcher port, its unbadged connector closed at once; every row whose `starts` lists anything is endowed a launcher minted with its row and session under the `launcher` label, never in `svc`; a launch reads the caller off its connection's badge and is refused before any file of the target is read when the caller's row does not list it or it changes what the machine runs outside a login session. - Build: refuses a `starts` naming nothing declared or a row that serves ports, a `login` row that starts nothing, and an unknown row field. Closes issues/isolation/the-launcher-starts-any-row-for-any-holder.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- console/system.toml | 6 +- ...rogram-sees-only-the-files-it-was-given.md | 26 +- ...-launcher-starts-any-row-for-any-holder.md | 49 ---- ...hing-charges-kernel-memory-to-a-process.md | 9 + kernel/src/inbox/mod.rs | 7 +- kernel/src/object/port.rs | 39 ++- kernel/src/object/service.rs | 24 ++ kernel/src/syscall/dispatch.rs | 21 +- kernel/src/syscall/ipc.rs | 48 +++- src/build.rs | 65 ++++- system.toml | 24 +- tests/lantalkcase/system.toml | 4 +- tests/metalcase/system.toml | 4 +- tests/proctreecase/system.toml | 24 +- .../src/bin/launch_authority.rs | 84 ++++++ tests/toyos-rust-tests/src/bin/port_badge.rs | 76 +++++ .../toyos-rust-tests/src/bin/process_tree.rs | 40 ++- tests/toyos.rs | 40 ++- toyos-abi/src/syscall.rs | 43 ++- toyos-manifest/src/launch.rs | 263 ++++++++++++++++++ toyos-manifest/src/lib.rs | 36 ++- toyos-swap/src/lib.rs | 7 +- toyos/src/endow.rs | 16 ++ toyos/src/launch.rs | 6 + toyos/src/port.rs | 17 +- userland/supervisor/src/main.rs | 166 ++++++++--- userland/test-runner/src/main.rs | 14 + 27 files changed, 1016 insertions(+), 142 deletions(-) delete mode 100644 issues/isolation/the-launcher-starts-any-row-for-any-holder.md create mode 100644 tests/toyos-rust-tests/src/bin/launch_authority.rs create mode 100644 tests/toyos-rust-tests/src/bin/port_badge.rs create mode 100644 toyos-manifest/src/launch.rs diff --git a/console/system.toml b/console/system.toml index f6f992dee83..65be9a1a62b 100644 --- a/console/system.toml +++ b/console/system.toml @@ -33,11 +33,13 @@ serves = ["log"] [programs.console] provides = ["surface"] -receives = ["launcher", "log"] +receives = ["log"] +starts = ["shell"] devices = ["framebuffer", "keyboard", "mouse"] [programs.shell] -receives = ["surface", "launcher"] +receives = ["surface"] +starts = ["toybox", "shell"] # `power` because `bin/shutdown` below is this binary, and on the machine this # image exists for — no serial port, a prompt on the panel — there is no other diff --git a/issues/isolation/every-program-sees-only-the-files-it-was-given.md b/issues/isolation/every-program-sees-only-the-files-it-was-given.md index 25a65f939bd..7176814a471 100644 --- a/issues/isolation/every-program-sees-only-the-files-it-was-given.md +++ b/issues/isolation/every-program-sees-only-the-files-it-was-given.md @@ -25,7 +25,12 @@ was handed, so isolating people follows from isolating programs. - **A child's view is at most its parent's.** Nothing widens a view after spawn, and nothing in it is inherited wider than the parent held it. The swap port leaking to whatever sshd spawns (PR #484's review) is the same - shape of defect in the service namespace. + shape of defect in the service namespace. **A launch is the exception, and + its caller's row is the approval** (owner, 2026-10-03, "Restrict": each + program may start only the programs its configuration row lists; swap and + update of system programs only from the login session): the launched + program holds its own row, the supervisor refuses a row the caller's + `starts` does not list, and a direct spawn never inherits a launcher. - **Resolution cannot leave the view.** `..` at a view's root, a symlink, a rename racing a lookup, or a held directory handle never resolves outside the view. This is the classic way a restricted root is escaped, so it is @@ -81,6 +86,25 @@ nameable by every program and make confused-deputy bugs structural. the stage exits. **Exit**: a hostile session can neither reach another session's connections nor deny it memory or processes. +## Open with the owner + +- **Whether the local desktop is a login session before a greeter + authenticates anybody.** Today it is: the compositor's `login = true` in + `system.toml` is the whole of it, so `swap` and `update` run from the + desktop's shell. Removing it confines both to SSH, which the development loop + already uses; the cost is that the shipping image's sshserver is itself + started from the desktop's shell, and until a session's view excludes + sshserver's key list any desktop program can log itself in over the network + regardless. + +## Known weaknesses + +- **A login session never ends.** A program launched in one keeps the session + in its launcher's badge after the login that opened it is gone, so a + detached program can start `swap` after logout. The exit is a session's end + ending its launches, which the per-session `/tmp`'s wait on the session's + first process provides. + ## Ordering After self-update and partition claims, and before the browser: rendering diff --git a/issues/isolation/the-launcher-starts-any-row-for-any-holder.md b/issues/isolation/the-launcher-starts-any-row-for-any-holder.md deleted file mode 100644 index b0a18d188a7..00000000000 --- a/issues/isolation/the-launcher-starts-any-row-for-any-holder.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-03 ---- - -# The launcher starts any declared row for any holder of it - -A holder of a `launcher` connector can start **any** `[programs]` row and is -handed that row's whole authority. Three facts compose: - -- **`launcher` is inherited, not granted per child.** A direct spawn copies the - caller's `svc` namespace into the child (`inherited_namespace`, - `rust/library/std/src/sys/process/toyos.rs`), and std routes every declared - program through the launcher when the caller holds one and endowed nothing - itself (the routing rule in `Command::spawn`/`Command::launch`, same file). So - every program in a shell-started subtree holds the shell's `launcher`. -- **The supervisor never checks which rows a caller may start.** `serve_launch` - (`userland/supervisor/src/main.rs`) resolves `request.program` to a declared - row and starts it holding that row's `receives`, `devices`, `syscap` and - `slots` — for any caller that reached the port. A plain app launched from the - shell can therefore launch `/system/bin/swap` and receive the swap connector, - or `/system/bin/update` and receive the idle slot's partition claims. -- **`accept_swap` checks the requester's own digest, not its identity.** It - verifies the service exists, is a running `[boot] start` service, and that the - staged bytes match the requester's `digest` — never which program asked - (`accept_swap`, same file). The swap connector is gated to `/system/bin/swap` - by the build (`held_by_their_holders_alone`, `src/build.rs`), but fact one and - fact two reach that row anyway. So any program can rewrite any boot service's - binary. - -The per-program-views track names the same shape in the service namespace ("the -swap port leaking to whatever sshd spawns", -`issues/isolation/every-program-sees-only-the-files-it-was-given.md`), and it is -distinct from where a swapped binary lives -(`issues/isolation/a-swapped-binary-lives-where-any-process-can-rewrite-it.md`): -that one is which bytes a declared swap runs, this one is who may invoke a -declared row at all. - -**The owner's ruling:** each program may start only the rows its own row lists; -swap and update of system programs only from the login session. - -**Owner.** The orchestrator. - -**Exit condition.** A guest test that is red until both halves of the ruling -hold: a launch of a row the caller's own row does not list is refused — -`/system/bin/swap` and `/system/bin/update` through an inherited `launcher` -among them — and a swap and an update of a system program asked from outside -the login session are refused. diff --git a/issues/kernel/nothing-charges-kernel-memory-to-a-process.md b/issues/kernel/nothing-charges-kernel-memory-to-a-process.md index 7a003252238..089075a87a8 100644 --- a/issues/kernel/nothing-charges-kernel-memory-to-a-process.md +++ b/issues/kernel/nothing-charges-kernel-memory-to-a-process.md @@ -38,3 +38,12 @@ both cost a day to discover: kernel allocation panics with the size, the layer and the call site named, and every kernel panic halts, so a grower userland can drive is refused at its bound or it ends the machine. + +**A port badge is kernel memory nobody is charged for.** Any process can make +a port and mint on it (`SYS_PORT_MINT`), at most `MAX_BADGE` = 64 bytes per +connector. Derived from the bounds, not measured: a namespace holds 64 +connectors and a table 4,096 handles, so one process pins 16 MiB of badge +bytes in its table, and up to 2 GiB more in the handle queues of the 2,048 +connections it can hold both ends of (2 directions × 16 batches × 8 handles × +64 connectors × 64 bytes each), the same queues that already hold its +unbadged connectors uncharged. diff --git a/kernel/src/inbox/mod.rs b/kernel/src/inbox/mod.rs index 124f06812dc..dc385159942 100644 --- a/kernel/src/inbox/mod.rs +++ b/kernel/src/inbox/mod.rs @@ -648,12 +648,7 @@ fn process_accept(inbox: &Arc, submission: &Submission) { let installed = process::with_process_data(|data| { ops::install( &mut data.handles, - KObjectRef::Connection(crate::object::service::ConnectionEnd::new( - conn.rx, - conn.tx, - conn.inbox, - conn.outbox, - )), + KObjectRef::Connection(crate::object::service::ConnectionEnd::accepted(conn)), ) }); match installed { diff --git a/kernel/src/object/port.rs b/kernel/src/object/port.rs index 6f81e3e30fe..a0bd6b47e30 100644 --- a/kernel/src/object/port.rs +++ b/kernel/src/object/port.rs @@ -2,9 +2,15 @@ //! connection queue. Both ends are created together before either process //! runs, so a client's first connect always has something to reach — never a //! name that is not yet bound, so nothing to retry and no timeout. +//! +//! **The kernel vouches for what the acceptor's holder granted, never for who +//! connected.** The holder mints a connector carrying bytes it chose +//! ([`Acceptor::mint`]); a connection made through it is stamped with them and +//! the port, and only that port's acceptor reads the stamp back. use alloc::collections::VecDeque; use alloc::sync::Arc; +use core::sync::atomic::{AtomicU64, Ordering}; use crate::pipe::{PipeReader, PipeWriter}; use crate::sync::Lock; @@ -22,8 +28,21 @@ pub struct PendingConnection { pub tx: PipeWriter, pub inbox: Arc, pub outbox: Arc, + /// [`Connector::stamp`] of the connector the client connected through. + pub stamp: Stamp, } +/// What an accepted connection keeps of the connector it was made through. +#[derive(Clone)] +pub struct Stamp { + /// The port's never-repeating id, which the acceptor asking for the badge must match. + pub port: u64, + pub badge: Option>, +} + +/// Never repeats, so a stamp outlives its port without ever naming another one. +static NEXT_PORT: AtomicU64 = AtomicU64::new(1); + /// `closed` and `pending` share one lock: checking `closed` and pushing must not /// interleave, or a connection queues after nothing will ever drain it again. struct PortQueue { @@ -33,6 +52,7 @@ struct PortQueue { /// Everything the two ends share; neither end holds the other, so no `Arc` cycle exists. pub struct PortShared { + id: u64, queue: Lock, /// Lives on the port, not either end: a client's connect must complete a /// poll the server registered on the `Acceptor`. An `Arc` so a poll @@ -48,6 +68,8 @@ pub struct Acceptor { pub struct Connector { pub(super) core: ObjectCore, shared: Arc, + /// Immutable for the object's life: a duplicate is another handle to this object. + badge: Option>, } /// Why a connection was not queued. @@ -59,12 +81,13 @@ pub enum PushError { pub fn create() -> (Arc, Arc) { let shared = Arc::new(PortShared { + id: NEXT_PORT.fetch_add(1, Ordering::Relaxed), queue: Lock::new(PortQueue { closed: false, pending: VecDeque::new() }), watch: Arc::new(Watch::new()), }); ( Arc::new(Acceptor { core: Acceptor::new_core(), shared: shared.clone() }), - Arc::new(Connector { core: Connector::new_core(), shared }), + Arc::new(Connector { core: Connector::new_core(), shared, badge: None }), ) } @@ -99,6 +122,15 @@ impl Acceptor { pub fn watch(&self) -> &Arc { self.shared.watch() } + + pub fn port_id(&self) -> u64 { + self.shared.id + } + + /// A connector to this port whose every connection is stamped with `badge`. + pub fn mint(&self, badge: Arc<[u8]>) -> Arc { + Arc::new(Connector { core: Connector::new_core(), shared: self.shared.clone(), badge: Some(badge) }) + } } impl Connector { @@ -122,6 +154,11 @@ impl Connector { pub fn port(&self) -> Arc { self.shared.clone() } + + /// What a connection made through this connector is stamped with. + pub fn stamp(&self) -> Stamp { + Stamp { port: self.shared.id, badge: self.badge.clone() } + } } /// Wakes every thread parked in `accept` and drops each queued connection's diff --git a/kernel/src/object/service.rs b/kernel/src/object/service.rs index 1e128cf2a6c..c4f554cc89b 100644 --- a/kernel/src/object/service.rs +++ b/kernel/src/object/service.rs @@ -10,6 +10,7 @@ use crate::pipe::{PipeId, PipeReader, PipeWriter}; use crate::sync::Lock; use super::handle::HandleEntry; +use super::port::{PendingConnection, Stamp}; use super::{Held, KObjectVariant, ObjectCore, ZeroHandles}; /// Handles in flight in one direction of a connection; `None` once the reader is gone. @@ -68,6 +69,8 @@ pub struct ConnectionEnd { inbox: Arc, outbox: Arc, reference: Held<(PipeReader, PipeWriter)>, + /// The server's end of a port's connection; `None` on a client's end and a joined one. + stamp: Option, } impl ConnectionEnd { @@ -76,11 +79,27 @@ impl ConnectionEnd { (HandleQueue::open(), HandleQueue::open()) } + /// A client's end. pub fn new( rx: PipeReader, tx: PipeWriter, inbox: Arc, outbox: Arc, + ) -> Arc { + Self::build(rx, tx, inbox, outbox, None) + } + + /// The server's end of a queued connection, keeping its stamp. + pub fn accepted(conn: PendingConnection) -> Arc { + Self::build(conn.rx, conn.tx, conn.inbox, conn.outbox, Some(conn.stamp)) + } + + fn build( + rx: PipeReader, + tx: PipeWriter, + inbox: Arc, + outbox: Arc, + stamp: Option, ) -> Arc { Arc::new(Self { core: Self::new_core(), @@ -89,6 +108,7 @@ impl ConnectionEnd { inbox, outbox, reference: Held::new((rx, tx)), + stamp, }) } @@ -97,6 +117,10 @@ impl ConnectionEnd { Self::new(rx, tx, HandleQueue::dead(), HandleQueue::dead()) } + pub fn stamp(&self) -> Option<&Stamp> { + self.stamp.as_ref() + } + pub fn rx(&self) -> PipeId { self.rx } diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index 3764111a12c..be7e47fd2d8 100644 --- a/kernel/src/syscall/dispatch.rs +++ b/kernel/src/syscall/dispatch.rs @@ -38,7 +38,7 @@ use super::io::{ use super::ipc::{ sys_accept, sys_connection_join, sys_handle_recv, sys_handle_send, sys_inbox_setup, sys_inbox_submit, sys_namespace_build, sys_namespace_open, sys_pipe, sys_pipe_map, - sys_port_create, sys_shm_create, sys_shm_map, + sys_port_badge, sys_port_create, sys_port_mint, sys_shm_create, sys_shm_map, }; use super::machine::{ sys_device_inventory, sys_log_read, sys_reboot, sys_sched_info, sys_shutdown, sys_sysinfo, @@ -482,6 +482,25 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> SYS_SHM_CREATE => sys_shm_create(a1), SYS_SHM_MAP => sys_shm_map(RawHandle(a1 as u32)), SYS_PORT_CREATE => sys_port_create(), + SYS_PORT_MINT => { + // Copied in before any handle is looked up: the kernel holds the badge, never the caller's page. + let Ok(len) = Untrusted::new(a3).at_most(MAX_BADGE as u64) else { + return SyscallError::InvalidArgument.to_u64(); + }; + if len == 0 { + return SyscallError::InvalidArgument.to_u64(); + } + let Some(bytes) = ctx.user_bytes(UserAddr::new(a2), len) else { return bad_addr }; + let mut badge = [0u8; MAX_BADGE]; + bytes.read_at(0, &mut badge[..len as usize]); + sys_port_mint(RawHandle(a1 as u32), &badge[..len as usize]) + } + SYS_PORT_BADGE => { + let Some(mut out) = ctx.user_bytes_mut(UserAddr::new(a3), MAX_BADGE as u64) else { + return bad_addr; + }; + sys_port_badge(RawHandle(a1 as u32), RawHandle(a2 as u32), &mut out) + } SYS_NAMESPACE_BUILD => { let Ok(args) = ctx.copy_in::(UserAddr::new(a1)) else { return bad_addr; diff --git a/kernel/src/syscall/ipc.rs b/kernel/src/syscall/ipc.rs index bb7431320a1..6b62d9b6d42 100644 --- a/kernel/src/syscall/ipc.rs +++ b/kernel/src/syscall/ipc.rs @@ -257,6 +257,7 @@ fn connect_through(connector: &port::Connector) -> u64 { tx: sc_writer, // server writes to server→client inbox: to_server, outbox: to_client, + stamp: connector.stamp(), }); if let Err(e) = queued { process::with_process_data(|data| { @@ -284,14 +285,7 @@ pub(super) fn sys_accept(h: RawHandle) -> u64 { loop { if let Some(conn) = acceptor.pop() { // PipeReader/PipeWriter move from queue into connection: ownership transfers, no refcount change. - let object = KObjectRef::Connection( - crate::object::service::ConnectionEnd::new( - conn.rx, - conn.tx, - conn.inbox, - conn.outbox, - ), - ); + let object = KObjectRef::Connection(crate::object::service::ConnectionEnd::accepted(conn)); let installed = process::with_process_data(|data| { ops::install(&mut data.handles, object) }); @@ -317,6 +311,44 @@ pub(super) fn sys_accept(h: RawHandle) -> u64 { } } +/// A connector to the caller's own port stamping every connection with `badge`, which the dispatch has copied in and bounded. `READ` on the acceptor, the right accepting takes, is the authority: whoever answers every connection on a port grants nothing by labelling them. +pub(super) fn sys_port_mint(h: RawHandle, badge: &[u8]) -> u64 { + let acceptor = match process::with_process_data(|data| { + data.handles.get::(h, Rights::READ) + }) { + Ok(a) => a, + Err(e) => return e.refuse(), + }; + let connector = KObjectRef::Connector(acceptor.mint(alloc::sync::Arc::from(badge))); + process::with_process_data(|data| handle_result(ops::install(&mut data.handles, connector))) +} + +/// The badge a connection accepted from `acceptor_h`'s port was stamped with, written to `out`; answers its length. +/// +/// **The port check is the whole boundary**: any process can make a port and mint any bytes on it, so a stamp is answered only to the acceptor of the port it was made on. A connection with no stamp (a client's end, a joined one) is `InvalidArgument`, one from another port `PermissionDenied`, and one made through an unbadged connector `NotFound`, never an empty badge. +pub(super) fn sys_port_badge( + acceptor_h: RawHandle, + conn_h: RawHandle, + out: &mut crate::user_ptr::UserBytesMut, +) -> u64 { + let found = process::with_process_data(|data| { + let acceptor = data.handles.get::(acceptor_h, Rights::READ)?; + let conn = data.handles.get::(conn_h, Rights::READ)?; + Ok::<_, crate::object::HandleError>((acceptor.port_id(), conn.stamp().cloned())) + }); + let (port, stamp) = match found { + Ok(found) => found, + Err(e) => return e.refuse(), + }; + let Some(stamp) = stamp else { return SyscallError::InvalidArgument.to_u64() }; + if stamp.port != port { + return SyscallError::PermissionDenied.to_u64(); + } + let Some(badge) = stamp.badge else { return SyscallError::NotFound.to_u64() }; + out.write_at(0, &badge); + badge.len() as u64 +} + /// Make a shared region and hand back the one handle to it, carrying MAP, DUP and TRANSFER — naming it is what authorizes mapping it. pub(super) fn sys_shm_create(size: u64) -> u64 { let object = match crate::object::shm::SharedMemObject::create(size) { diff --git a/src/build.rs b/src/build.rs index 65c9c8f7db5..4e4f60acc99 100644 --- a/src/build.rs +++ b/src/build.rs @@ -137,8 +137,10 @@ struct AppsConfig { receives: Vec, } +/// **Unknown fields refused**: a misspelled `starts` would be a row that +/// silently holds no launcher. #[derive(Deserialize, Default)] -#[serde(default, rename_all = "kebab-case")] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] struct ProgramConfig { path: Option, no_default_features: bool, @@ -169,6 +171,10 @@ struct ProgramConfig { roles: Vec, /// The supervisor starts it again when it ends (`toyos_manifest::Program::restart`). restart: bool, + /// The rows it may start through the launcher (`toyos_manifest::Program::starts`). + starts: Vec, + /// A launch it makes opens a login session (`toyos_manifest::Program::login`). + login: bool, } impl ProgramConfig { @@ -635,7 +641,10 @@ const SUPERVISOR_PROGRAM: &str = "supervisor"; /// declaration to come from. They travel in the manifest so the supervisor creates /// exactly the ports the build-time gate counted as provided — one producer, /// rather than a constant here and a string in the supervisor. -const SUPERVISOR_SERVED: &[&str] = &["launcher", toyos_swap::PORT, "power"]; +/// +/// Not `launcher`: a row holds it by its `starts`, badged with its row, and no +/// row receives it. +const SUPERVISOR_SERVED: &[&str] = &[toyos_swap::PORT, "power"]; /// Who may hold the two authorities that change what the machine runs: /// the swap port, [`toyos_swap::HOLDER`] and nothing else — no other @@ -664,14 +673,43 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { Ok(()) } +/// What a row may start through the launcher, and which rows open a login +/// session (`toyos_manifest::launch`). +/// +/// **Checked on every manifest rendered**: a `starts` entry is `/apps` or a +/// declared row the supervisor can start more than once, so not one that +/// serves a port or a file-server role, whose acceptors a launch would take and +/// a second one find gone; and a `login` row starts something. +fn starts_name_what_a_launch_can_start(config: &SystemConfig) -> Result<(), String> { + for (name, program) in &config.programs { + for key in &program.starts { + if key == toyos_manifest::launch::APPS { + continue; + } + let Some(target) = config.programs.get(key) else { + return Err(format!("`{name}` starts `{key}`, which is not declared")); + }; + if !target.serves.is_empty() || !target.roles.is_empty() { + return Err(format!("`{name}` starts `{key}`, which serves ports a launch would take for good")); + } + } + if program.login && program.starts.is_empty() { + return Err(format!("`{name}` opens a login session and starts nothing")); + } + } + Ok(()) +} + /// The resolved config as the records `/system/bin/supervisor` reads. /// /// The format, the renderer and the parser are `toyos-manifest/`, whose /// round-trip test is what makes "what the build writes is what the supervisor reads" a /// fact rather than two hand-matched implementations. fn render_manifest(config: &SystemConfig) -> Vec { - if let Err(why) = held_by_their_holders_alone(config) { - panic!("system.toml cannot be rendered as a manifest: {why}"); + for gate in [held_by_their_holders_alone, starts_name_what_a_launch_can_start] { + if let Err(why) = gate(config) { + panic!("system.toml cannot be rendered as a manifest: {why}"); + } } let mut names: Vec<&String> = config.programs.keys().collect(); names.sort(); @@ -693,6 +731,8 @@ fn render_manifest(config: &SystemConfig) -> Vec { service: cfg.service, roles: cfg.roles.clone(), restart: cfg.restart, + starts: cfg.starts.clone(), + login: cfg.login, } }) .collect(), @@ -3170,6 +3210,23 @@ mod tests { assert!(held_by_their_holders_alone(&slots).is_err()); } + /// Every committed config passes, and each refusal has a config that + /// takes it and nothing else. + #[test] + fn a_row_starts_only_what_a_launch_can_start() { + for cfg in ALL_CONFIGS { + starts_name_what_a_launch_can_start(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}")); + } + let gate = |toml: &str| starts_name_what_a_launch_can_start(&toml::from_str(toml).unwrap()); + assert!(gate("[programs.shell]\nstarts = [\"toybox\", \"/apps\"]\nlogin = true\n[programs.toybox]\n").is_ok()); + assert!(gate("[programs.shell]\nstarts = [\"ghost\"]\n").is_err()); + assert!(gate("[programs.shell]\nstarts = [\"apps\"]\n").is_err()); + assert!(gate("[programs.shell]\nstarts = [\"compositor\"]\n[programs.compositor]\nserves = [\"compositor\"]\n").is_err()); + assert!(gate("[programs.shell]\nstarts = [\"fileserver\"]\n[programs.fileserver]\nroles = [\"data\"]\n").is_err()); + assert!(gate("[programs.sshserver]\nlogin = true\n").is_err()); + assert!(toml::from_str::("[programs.shell]\nstart = [\"toybox\"]\n").is_err()); + } + /// `[apps] receives` is narrower than a program's: a `provides` name is one /// port per instance and nobody makes one for a package, so naming one here /// is a namespace the supervisor cannot build. diff --git a/system.toml b/system.toml index 7ad1913158f..2211f83a38c 100644 --- a/system.toml +++ b/system.toml @@ -53,11 +53,18 @@ service = true syscap = ["logread"] serves = ["log"] +# `starts` is what the desktop's taskbar and launcher start. `login = true` +# makes the local desktop a login session, so `swap` and `update` run from its +# shell: whether it is one before a greeter authenticates anybody is the +# owner's open decision, and this line is the whole of it +# (`issues/isolation/every-program-sees-only-the-files-it-was-given.md`). [programs.compositor] service = true serves = ["compositor"] -receives = ["soundserver", "filepicker", "launcher"] +receives = ["soundserver", "filepicker"] devices = ["framebuffer", "keyboard", "mouse"] +starts = ["terminal", "files", "/apps"] +login = true [programs.soundserver] service = true @@ -74,11 +81,13 @@ service = true serves = ["netstack"] devices = ["pci:1af4:1041"] -# `launcher` is how a login's program is started holding its own row: `update` -# and `swap` below are two such programs. +# Every launch it makes opens a login session, the one `update` and `swap` +# below start in. It runs whatever command a login names, as the shell does. [programs.sshserver] service = true -receives = ["netstack", "launcher"] +receives = ["netstack"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] +login = true # `swap` is the authority to replace a running service's binary; the build # refuses it to any other program (`src/build.rs`). `ssh swap netstack @@ -112,10 +121,13 @@ receives = ["compositor"] [programs.terminal] provides = ["surface"] -receives = ["compositor", "launcher"] +receives = ["compositor"] +starts = ["shell"] +# Whatever a user names at its prompt. [programs.shell] -receives = ["surface", "launcher"] +receives = ["surface"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] [programs.doom] receives = ["compositor", "soundserver"] diff --git a/tests/lantalkcase/system.toml b/tests/lantalkcase/system.toml index cad1cb0849d..e110809833a 100644 --- a/tests/lantalkcase/system.toml +++ b/tests/lantalkcase/system.toml @@ -23,7 +23,9 @@ devices = ["pci:8086:15fc"] [programs.sshserver] service = true -receives = ["netstack", "launcher"] +receives = ["netstack"] +starts = ["toybox", "swap"] +login = true # The host replaces netstack on this boot without rebooting it. [programs.swap] diff --git a/tests/metalcase/system.toml b/tests/metalcase/system.toml index 468d75b2133..1a5d2559a90 100644 --- a/tests/metalcase/system.toml +++ b/tests/metalcase/system.toml @@ -28,7 +28,7 @@ syscap = ["logread"] [programs.compositor] service = true serves = ["compositor"] -receives = ["soundserver", "launcher"] +receives = ["soundserver"] devices = ["framebuffer", "keyboard", "mouse"] [programs.soundserver] @@ -46,7 +46,7 @@ devices = ["pci:1af4:1041"] [programs.sshserver] service = true -receives = ["netstack", "launcher"] +receives = ["netstack"] # The union its guest binaries need on this machine shape. [programs.test-runner] diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index 55a90801fc0..d28312479d5 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -1,10 +1,15 @@ # The boot `process_tree` runs on: a parent's end takes its children down. # -# A `launcher`, because the subtree under test has children the supervisor launches; a -# declared `cat`, which runs until its input ends; and a declared shell, which a -# depth chain launches and which detaches a `cat`. +# A launcher for test-runner, which it endows every job, because the subtree +# under test has children the supervisor launches; a declared `cat`, which runs +# until its input ends; and a declared shell, which a depth chain launches and +# which detaches a `cat`. # # `launch_toctou` rides it too: a launch of `toybox` through a link it re-points. +# +# And `launch_authority`: test-runner's row lists `swap` and `update`, which a +# job is refused outside a login session, and not `proctest`, which it is +# refused outright; the shell opens a login session, in which it starts `swap`. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] @@ -19,8 +24,8 @@ syscap = ["logread"] # `roster` because the guest asks which of its tree still runs, and `dup` # because test-runner hands each binary a duplicate of its capability. [programs.test-runner] -receives = ["launcher"] syscap = ["dup", "roster"] +starts = ["toybox", "shell", "swap", "update"] # `roster`, which a child the job spawns itself never holds: a child holding # it ran under this row, and `launch_toctou` asks which bytes that child was. @@ -28,7 +33,16 @@ syscap = ["dup", "roster"] syscap = ["roster"] [programs.shell] -receives = ["launcher"] +starts = ["toybox", "swap"] +login = true + +[programs.swap] +receives = ["swap"] + +[programs.update] +slots = true + +[programs.proctest] [symlinks] "bin/cat" = "/system/bin/toybox" diff --git a/tests/toyos-rust-tests/src/bin/launch_authority.rs b/tests/toyos-rust-tests/src/bin/launch_authority.rs new file mode 100644 index 00000000000..88631326720 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/launch_authority.rs @@ -0,0 +1,84 @@ +//! A launch starts only what the caller's row lists, and `swap` and `update` +//! only in a login session. +//! +//! This job holds test-runner's launcher, in the machine's session, and asks +//! the supervisor for: +//! +//! - `toybox` as `ps`, which test-runner's row lists: it runs under its row, +//! which alone gives it the roster `ps` exits 0 with; +//! - `proctest`, which the row does not list, and `swap` and `update`, which +//! it lists and which start only in a login session: each is refused, and +//! nothing is spawned in its place; +//! - a shell, whose row opens a login session, that detaches `swap`: it +//! starts there. +//! +//! And a child it spawns directly holds no launcher, under its label or in +//! the namespace it inherits: the chain where any program a shell spawned +//! could ask for `swap` is gone. +//! +//! Written against what the base it replaces had too, so the change reverted +//! reds here and does not fail to build. Which refusal each refused launch +//! got is in the supervisor's lines, which the metal judge reads. + +use std::process::{Command, Stdio}; + +use toyos::endow::{self, EndowError, Endowments}; + +const SELF_PATH: &str = "/system/bin/test_rs_launch_authority"; +const LAUNCHER: &str = "launcher"; +const CHILD: &str = "child"; +/// What the child exits with holding no launcher, and holding one. +const HOLDS_NONE: i32 = 0; +const HOLDS_ONE: i32 = 1; + +fn main() { + match std::env::args().nth(1).as_deref() { + Some(CHILD) => child(), + _ => test(), + } +} + +fn test() { + // Every arm runs, so one run says each one that is red. + let mut red = Vec::new(); + + let ps = Command::new("/system/bin/toybox").arg("ps").output().expect("launch toybox as ps"); + if ps.status.code() != Some(0) { + red.push(format!("a listed row did not run under its row: {ps:?}")); + } + + for program in ["/system/bin/proctest", "/system/bin/swap", "/system/bin/update"] { + match Command::new(program).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + Err(_) => println!(" {program}: refused"), + Ok(mut started) => { + let _ = started.kill(); + let _ = started.wait(); + red.push(format!("{program} started for a caller in the machine's session")); + } + } + } + + // Piped, so the shell holds the input it hands what it detaches. + let detached = Command::new("/system/bin/shell") + .args(["-c", "detach /system/bin/swap"]) + .stdin(Stdio::piped()) + .output() + .expect("launch a shell"); + if !detached.status.success() { + red.push(format!("swap did not start in a login session: {detached:?}")); + } + + let child = Command::new(SELF_PATH).arg(CHILD).status().expect("spawn this binary as a child"); + if child.code() != Some(HOLDS_NONE) { + red.push(format!("a child spawned directly holds a launcher ({child:?})")); + } + + assert!(red.is_empty(), "launch_authority:\n {}", red.join("\n ")); + println!("launch_authority: PASS"); +} + +fn child() -> ! { + let labelled = Endowments::get().labels().any(|label| label == LAUNCHER); + let inherited = !matches!(endow::service(LAUNCHER), Err(EndowError::NotEndowed)); + std::process::exit(if labelled || inherited { HOLDS_ONE } else { HOLDS_NONE }) +} diff --git a/tests/toyos-rust-tests/src/bin/port_badge.rs b/tests/toyos-rust-tests/src/bin/port_badge.rs new file mode 100644 index 00000000000..08d1e206637 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/port_badge.rs @@ -0,0 +1,76 @@ +//! A badge is what the acceptor's holder granted, read back only by that +//! port's acceptor. +//! +//! - A connection made through a minted connector carries its bytes, exactly, +//! and a connector [`MAX_BADGE`] long is the bound, not past it. +//! - A connection accepted on one port and asked of another port's acceptor is +//! refused: any process can make a port and mint any bytes on it, so this is +//! what a forged server end handed to a server meets. +//! - An unbadged connection answers `NotFound`, never an empty badge. +//! - A badge of none or more than [`MAX_BADGE`] bytes is never minted. +//! - A client's end is no accepted connection. +//! - Minting takes `READ` on the acceptor, the right accepting takes. + +use toyos::ipc::Connection; +use toyos::namespace; +use toyos::port::{self, Acceptor, Connector}; +use toyos::AsHandle; +use toyos_abi::handle::Rights; +use toyos_abi::syscall::{self, SyscallError, MAX_BADGE}; + +const NAME: &str = "port"; + +fn main() { + let (acceptor, _) = port::create().expect("a port"); + let (other, _) = port::create().expect("a second port"); + let mut out = [0u8; MAX_BADGE]; + + for badge in [&b"terminal"[..], &[0xa5; MAX_BADGE][..]] { + let minted = acceptor.mint(badge).expect("mint a badge"); + let (client, server) = connect(&acceptor, &minted); + assert_eq!(acceptor.badge(&server, &mut out), Ok(badge), "the stamp is not the minted bytes"); + assert_eq!( + acceptor.badge(&client, &mut out), + Err(SyscallError::InvalidArgument), + "a client's end answered a badge" + ); + assert_eq!( + other.badge(&server, &mut out), + Err(SyscallError::PermissionDenied), + "another port's acceptor read this port's stamp" + ); + } + println!(" a minted badge comes back exact on its own port, and on no other"); + + let (unbadged_acceptor, unbadged) = port::create().expect("a third port"); + let (_client, server) = connect(&unbadged_acceptor, &unbadged); + assert_eq!(unbadged_acceptor.badge(&server, &mut out), Err(SyscallError::NotFound)); + println!(" an unbadged connection answers NotFound"); + + for len in [0, MAX_BADGE + 1] { + let badge = vec![1u8; len]; + assert_eq!( + syscall::port_mint(acceptor.as_handle(), &badge).err(), + Some(SyscallError::InvalidArgument), + "a badge of {len} bytes was minted" + ); + } + println!(" a badge of 0 or {} bytes is refused", MAX_BADGE + 1); + + let narrowed = syscall::dup_narrowed(acceptor.as_handle(), Rights::DUP.union(Rights::TRANSFER)) + .expect("the acceptor without READ"); + // SAFETY: made by the call above, and owned here alone. + let narrowed = unsafe { Acceptor::from_raw(narrowed) }; + assert_eq!(narrowed.mint(b"x").err(), Some(SyscallError::PermissionDenied)); + println!(" minting takes READ on the acceptor"); + + println!("port_badge: PASS"); +} + +/// A connection through `connector`, and its end accepted on `acceptor`. +fn connect(acceptor: &Acceptor, connector: &Connector) -> (Connection, Connection) { + let names = namespace::build().add(NAME, connector).finish().expect("a namespace"); + let client = names.open(NAME).expect("connect"); + let server = acceptor.accept().expect("accept"); + (client, server) +} diff --git a/tests/toyos-rust-tests/src/bin/process_tree.rs b/tests/toyos-rust-tests/src/bin/process_tree.rs index 50812b81799..a393f0873f7 100644 --- a/tests/toyos-rust-tests/src/bin/process_tree.rs +++ b/tests/toyos-rust-tests/src/bin/process_tree.rs @@ -21,11 +21,15 @@ //! declares and for a command carrying an extra slot; and a launch runs its row's //! own program, so std refuses a command naming its image under the supervisor or //! with a connector provided, and spawns one naming its image directly, under -//! its program's name, launcher or not. A chain alternating spawn -//! and launch — each link launches a shell, and the shell spawns the next link -//! — stops where the kernel refuses a process more than `MAX_DEPTH` below -//! the supervisor, and dies whole with its first link. And a `cat` a shell `detach`es -//! outlives the shell. +//! its program's name, launcher or not. A chain — each link starts a shell, +//! and the shell spawns the next link — stops where the kernel refuses a +//! process more than `MAX_DEPTH` below the supervisor, and dies whole with its +//! first link: that link is endowed a launcher and launches its shell, and +//! every later one is a direct spawn, holds none, and spawns its own. And a +//! `cat` a shell `detach`es outlives the shell. +//! +//! This process holds the launcher test-runner endows every job, and hands a +//! duplicate to each child that launches: a direct spawn inherits none. //! //! Every wait is unbounded: the harness ceiling is the only clock. @@ -36,7 +40,7 @@ use std::process::{Child, ChildStdin, Command, Stdio}; use toyos::endow::{self, Endowments, SVC_LABEL, SYSCAP_LABEL}; use toyos::ipc::Connection; -use toyos::launch::{self, Launch, Outcome, Parent}; +use toyos::launch::{self, Launch, Outcome, Parent, LAUNCHER}; use toyos::process::Process; use toyos::syscap::SysCap; use toyos::{namespace, port, AsHandle}; @@ -140,7 +144,7 @@ struct Grown { /// take what it sends once its subtree is grown. fn grow() -> Grown { let (acceptor, connector) = port::create().expect("a port of our own"); - let own = endow::namespace().expect("test-runner endows its namespace, `launcher` in it"); + let own = endow::namespace().expect("test-runner endows its namespace"); let ns = namespace::build() .keep_all(own) .add(BACK, &connector) @@ -149,6 +153,7 @@ fn grow() -> Grown { let mut b = Command::new(SELF_PATH) .arg("b") .endow(SVC_LABEL, ns.into_raw().0) + .endow(LAUNCHER, launcher_copy().0) .stdin(Stdio::piped()) .spawn() .expect("spawn B"); @@ -203,7 +208,7 @@ fn under_b_is_gone(grown: &Grown) { Ok(_) => panic!("a spawn under B {when} started"), } let place = syscall::dup(grown.b_self).expect("a copy of B's self to send"); - let conn = endow::service("launcher").expect("this process holds a launcher connector"); + let conn = launcher(); let request = Launch { program: HELD, argv: b"/system/bin/cat", @@ -222,6 +227,20 @@ fn under_b_is_gone(grown: &Grown) { } } +/// A connection to the launcher test-runner endowed this process. +fn launcher() -> Connection { + endow::launcher() + .expect("test-runner endows every job its launcher") + .open(LAUNCHER) + .expect("the launcher answers") +} + +/// A duplicate of this process's launcher, for a child that launches. +fn launcher_copy() -> RawHandle { + let held = endow::launcher().expect("test-runner endows every job its launcher"); + syscall::dup(held.as_handle()).expect("a copy of the launcher") +} + /// `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"); @@ -273,7 +292,7 @@ fn a_manage_only_handle_is_no_place() { fn a_pipe_is_no_place() { let (_read, write) = toyos::pipe_pair().expect("a pipe of our own"); let place = syscall::dup(write.as_handle()).expect("a duplicate to send"); - let conn = endow::service("launcher").expect("this process holds a launcher connector"); + let conn = launcher(); let request = Launch { program: HELD, argv: b"/system/bin/cat", @@ -356,12 +375,13 @@ fn refused(spawned: std::io::Result, what: &str) { } } -/// Each link launches a shell, and the shell spawns the next link: until a +/// Each link starts a shell, and the shell spawns the next link: until a /// start is refused for its depth, which the link or the shell says on the /// chain's one output. Killing the first link then ends every one. fn a_chain_stops_at_max_depth_and_dies_whole() { let mut first = Command::new(SELF_PATH) .args(["link", "1"]) + .endow(LAUNCHER, launcher_copy().0) .stdout(Stdio::piped()) .spawn() .expect("spawn the chain's first link"); diff --git a/tests/toyos.rs b/tests/toyos.rs index f76c849b38e..35a7454afda 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -102,6 +102,13 @@ const RUST_SKIP: &[&str] = &[ // `tests/testcases` does not give: the `launch_toctou` metal row runs it on // tests/proctreecase. "launch_toctou", + // Needs a launcher whose row lists `swap` and `update` and not `proctest`, + // and a shell that opens a login session: the `launch_authority` metal row + // runs it on tests/proctreecase. + "launch_authority", + // A kernel primitive with no use for any one boot's devices: the + // `port_badge` metal row runs it on tests/proctreecase. + "port_badge", // It asserts nothing at all: it holds a `tests/lanleasecase` boot open for // twenty seconds. On a shared boot it would be twenty seconds of nothing. "lan_hold", @@ -592,6 +599,18 @@ const METAL: &[(&str, metal::Metal)] = &[ "launch_toctou", metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_launch_toctou") }, ), + ( + // A launch starts only what the caller's row lists, and `swap` and + // `update` only in a login session. + "launch_authority", + metal::Metal { arms: PROCTREECASE, judge: |b| launch_authority(b[0]) }, + ), + ( + // A connection's badge is the minted bytes, read back by its own + // port's acceptor alone. + "port_badge", + metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_port_badge") }, + ), // ---- one image: tests/metalcase ---- ( "metal_sim_scanout_wc", @@ -766,12 +785,13 @@ const USB_RESET_BOOTS: &[metal::Arm] = &[ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/metalcase", &[], &[])]; /// A launcher and a declared `cat` and shell, which `process_tree`'s subtree -/// launches, and a `toybox` row holding `roster`, which `launch_toctou` races. +/// launches, a `toybox` row holding `roster`, which `launch_toctou` races, and +/// the rows `launch_authority` is refused and started. const PROCTREECASE: &[metal::Arm] = &[metal::once( "proctreecase", "tests/proctreecase", &[], - &["test_rs_process_tree", "test_rs_launch_toctou"], + &["test_rs_process_tree", "test_rs_launch_toctou", "test_rs_launch_authority", "test_rs_port_badge"], )]; /// netstack in front of the T14's I219 with its lease probe armed: netstack's exit code @@ -2620,6 +2640,22 @@ fn process_tree(back: &metal::Readback) -> Result<(), String> { Ok(()) } +/// `launch_authority` passed, and the supervisor refused each launch for its +/// own reason: `proctest` as unlisted, `swap` and `update` as outside a login +/// session. The guest sees only that each was refused. +fn launch_authority(back: &metal::Readback) -> Result<(), String> { + use toyos_manifest::launch::{refused, Refusal, Session}; + back.job_passed("test_rs_launch_authority")?; + let log = back.log(); + for (target, why) in [("proctest", Refusal::NotListed), ("swap", Refusal::OutsideLogin), ("update", Refusal::OutsideLogin)] { + let line = refused("test-runner", Session::Machine, target, why); + if !log.text().lines().any(|l| l.contains(&line)) { + return Err(format!("the supervisor never said `{line}`\n{}", log.text())); + } + } + Ok(()) +} + /// A backing read after deletion is refused on both writable mounts, and a page-cache slot whose fill the device refused is unbound. fn read_fault_probes(log: &str) -> Result<(), String> { let probe = "revoke-selftest: /tmp/revoke_probe"; diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 493b2833bde..533f7b4bac4 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -259,6 +259,15 @@ pub const SYS_DEVICE_DMA_MAP: u64 = 122; /// [`device_dma_unmap`]. pub const SYS_DEVICE_DMA_UNMAP: u64 = 123; +/// Make a connector to a port the caller accepts on, every connection through +/// which is stamped with the badge it carries. Gated by `READ` on the +/// acceptor, the right accepting takes. See [`port_mint`]. +pub const SYS_PORT_MINT: u64 = 124; + +/// Read the badge a connection accepted from the caller's port was stamped +/// with. See [`port_badge`]. +pub const SYS_PORT_BADGE: u64 = 125; + /// Bins in the per-process syscall profile — one for every number this ABI /// issues, and one at the end for every number it does not. /// @@ -271,7 +280,7 @@ pub const SYSCALL_PROFILE_BINS: usize = 128; /// a reader can see in the line; dropping is one nobody can. pub const SYSCALL_PROFILE_OTHER: usize = SYSCALL_PROFILE_BINS - 1; -const _: () = assert!(SYS_DEVICE_DMA_UNMAP < SYSCALL_PROFILE_OTHER as u64); +const _: () = assert!(SYS_PORT_BADGE < SYSCALL_PROFILE_OTHER as u64); pub const WNOHANG: u64 = 1; @@ -1818,6 +1827,38 @@ pub fn namespace_open(ns: RawHandle, name: &str) -> Result Result { + check(syscall(SYS_PORT_MINT, acceptor.0 as u64, badge.as_ptr() as u64, badge.len() as u64, 0)) + .map(|v| RawHandle(v as u32)) +} + +/// The badge `connection`, accepted from `acceptor`'s port, was stamped with, +/// written to `out`; answers its length. +/// +/// `NotFound` is a connection made through an unbadged connector, +/// `PermissionDenied` one accepted from another port, and `InvalidArgument` a +/// handle that is no port's accepted end. +pub fn port_badge( + acceptor: RawHandle, + connection: RawHandle, + out: &mut [u8; MAX_BADGE], +) -> Result { + check(syscall(SYS_PORT_BADGE, acceptor.0 as u64, connection.0 as u64, out.as_mut_ptr() as u64, 0)) + .map(|v| v as usize) +} + /// Accept a queued connection. Blocks until there is one. /// /// **It answers with the connection and nothing else.** Who connected is not diff --git a/toyos-manifest/src/launch.rs b/toyos-manifest/src/launch.rs new file mode 100644 index 00000000000..fc69d555c28 --- /dev/null +++ b/toyos-manifest/src/launch.rs @@ -0,0 +1,263 @@ +//! Who may start what through `/system/bin/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 +//! machine runs starts only in a login session** ([`Program::login_only`]). Both +//! are the owner's ruling. A launch by a row marked `login` opens a login +//! session; every other launch is in its caller's session, and a row the +//! supervisor starts at boot is in the machine's. +//! +//! **The caller is the badge on its connection, not its word** ([`Authority`]): +//! the supervisor mints it on the launcher it endows a row, the kernel stamps it +//! on every connection made through that launcher, and a holder passes the +//! launcher on only by endowing it. +//! +//! A launch is the one way a child holds more than its parent, and the caller's +//! row listing the target is the approval for that: the child holds its own row. + +use std::fmt; + +use toyos_abi::syscall::MAX_BADGE; + +use crate::{package, Program, MAX_PROGRAM_NAME}; + +/// The `starts` entry naming every installed package. A program key has no +/// `/`, so it names no key. +pub const APPS: &str = package::DIR; + +/// The session a launched program runs in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Session { + /// A row the supervisor started at boot, and every launch made in it. + Machine, + /// One a `login` row opened, by a never-repeating id. + Login(u64), +} + +impl fmt::Display for Session { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Machine => f.write_str("the machine's session"), + Self::Login(id) => write!(f, "login session {id}"), + } + } +} + +/// What the supervisor mints on the launcher it endows `row`: the row a +/// connection's caller holds, and the session it runs in. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Authority { + pub row: String, + pub session: Session, +} + +const MACHINE: u8 = 0; +const LOGIN: u8 = 1; + +const _: () = assert!(1 + 8 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority must fit one badge"); + +impl Authority { + /// The session's kind, a login session's id, then the row's key. + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(1 + 8 + self.row.len()); + match self.session { + Session::Machine => out.push(MACHINE), + Session::Login(id) => { + out.push(LOGIN); + out.extend_from_slice(&id.to_le_bytes()); + } + } + out.extend_from_slice(self.row.as_bytes()); + out + } + + /// `None` for bytes [`Self::encode`] cannot have written. + pub fn decode(bytes: &[u8]) -> Option { + let (&kind, rest) = bytes.split_first()?; + let (session, row) = match kind { + MACHINE => (Session::Machine, rest), + LOGIN => { + let (id, row) = rest.split_first_chunk::<8>()?; + (Session::Login(u64::from_le_bytes(*id)), row) + } + _ => return None, + }; + if row.is_empty() || row.len() > MAX_PROGRAM_NAME { + return None; + } + Some(Self { row: std::str::from_utf8(row).ok()?.to_string(), session }) + } +} + +/// What a launch names, once resolved. +#[derive(Clone, Copy)] +pub enum Target<'a> { + Row(&'a Program), + /// An installed package's synthesized row ([`crate::Manifest::app_row`]). + Package(&'a Program), +} + +/// Why a launch was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + NotListed, + OutsideLogin, +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::NotListed => "its caller's row does not list it", + Self::OutsideLogin => "it starts only in a login session", + }) + } +} + +/// The session `target` starts in when `caller`, running in `session`, asks +/// for it, or why it does not start. `open` names a new login session, and is +/// called only for a listed target of a `login` caller. +pub fn may_start( + caller: &Program, + session: Session, + target: Target<'_>, + open: impl FnOnce() -> u64, +) -> Result { + let (listed, program) = match target { + Target::Row(row) => (row.name.as_str(), row), + Target::Package(row) => (APPS, row), + }; + if !caller.starts.iter().any(|key| key == listed) { + return Err(Refusal::NotListed); + } + let session = match caller.login { + true => Session::Login(open()), + false => session, + }; + if program.login_only() && !matches!(session, Session::Login(_)) { + return Err(Refusal::OutsideLogin); + } + Ok(session) +} + +/// The supervisor's line for a refused launch, which the metal judge reads whole. +pub fn refused(caller: &str, session: Session, target: &str, why: Refusal) -> String { + format!("supervisor: launcher: {caller} in {session} may not start {target}: {why}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(name: &str) -> Program { + Program { name: name.into(), path: format!("/system/bin/{name}"), ..Program::default() } + } + + fn swap() -> Program { + Program { receives: vec![crate::SWAP_PORT.into()], ..row("swap") } + } + + fn update() -> Program { + Program { slots: true, ..row("update") } + } + + fn caller(login: bool) -> Program { + Program { + starts: ["ordinary", "swap", "update", "caller", APPS].map(String::from).to_vec(), + login, + ..row("caller") + } + } + + /// Every target kind against every session and both kinds of caller. + #[test] + fn a_caller_starts_what_its_row_lists_and_swap_and_update_only_in_a_login_session() { + let (ordinary, unlisted, own, package) = (row("ordinary"), row("unlisted"), caller(false), row("gbae")); + let (swap, update) = (swap(), update()); + let login = Session::Login(7); + let opened = Session::Login(9); + use Refusal::*; + #[rustfmt::skip] + let table: [(bool, Session, Target, Result); 24] = [ + (false, Session::Machine, Target::Row(&ordinary), Ok(Session::Machine)), + (false, login, Target::Row(&ordinary), Ok(login)), + (false, Session::Machine, Target::Row(&unlisted), Err(NotListed)), + (false, login, Target::Row(&unlisted), Err(NotListed)), + (false, Session::Machine, Target::Row(&own), Ok(Session::Machine)), + (false, login, Target::Row(&own), Ok(login)), + (false, Session::Machine, Target::Package(&package), Ok(Session::Machine)), + (false, login, Target::Package(&package), Ok(login)), + (false, Session::Machine, Target::Row(&swap), Err(OutsideLogin)), + (false, login, Target::Row(&swap), Ok(login)), + (false, Session::Machine, Target::Row(&update), Err(OutsideLogin)), + (false, login, Target::Row(&update), Ok(login)), + (true, Session::Machine, Target::Row(&ordinary), Ok(opened)), + (true, login, Target::Row(&ordinary), Ok(opened)), + (true, Session::Machine, Target::Row(&unlisted), Err(NotListed)), + (true, login, Target::Row(&unlisted), Err(NotListed)), + (true, Session::Machine, Target::Row(&own), Ok(opened)), + (true, login, Target::Row(&own), Ok(opened)), + (true, Session::Machine, Target::Package(&package), Ok(opened)), + (true, login, Target::Package(&package), Ok(opened)), + (true, Session::Machine, Target::Row(&swap), Ok(opened)), + (true, login, Target::Row(&swap), Ok(opened)), + (true, Session::Machine, Target::Row(&update), Ok(opened)), + (true, login, Target::Row(&update), Ok(opened)), + ]; + for (i, (opens, session, target, want)) in table.into_iter().enumerate() { + let mut asked = false; + let got = may_start(&caller(opens), session, target, || { + asked = true; + 9 + }); + assert_eq!(got, want, "row {i}"); + assert_eq!(asked, opens && got.is_ok(), "row {i}: a login session opened for nothing"); + } + } + + /// A row that lists nothing starts nothing, a package included, and `/apps` + /// lists no row: a key has no `/`. + #[test] + fn nothing_listed_is_nothing_started_and_apps_names_no_row() { + let none = row("none"); + let (ordinary, package) = (row("ordinary"), row("gbae")); + for target in [Target::Row(&ordinary), Target::Package(&package)] { + assert_eq!(may_start(&none, Session::Machine, target, || 1), Err(Refusal::NotListed)); + } + let apps_only = Program { starts: vec![APPS.into()], ..row("apps") }; + assert_eq!(may_start(&apps_only, Session::Machine, Target::Row(&ordinary), || 1), Err(Refusal::NotListed)); + assert_eq!(may_start(&apps_only, Session::Machine, Target::Package(&package), || 1), Ok(Session::Machine)); + } + + #[test] + fn an_authority_reads_back_as_written() { + for authority in [ + Authority { row: "terminal".into(), session: Session::Machine }, + Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login(u64::MAX) }, + Authority { row: "sshserver".into(), session: Session::Login(1) }, + ] { + let bytes = authority.encode(); + assert!(bytes.len() <= MAX_BADGE); + assert_eq!(Authority::decode(&bytes), Some(authority)); + } + } + + /// Bytes `encode` cannot have written are refused, and no prefix of an + /// authority reads back as it. + #[test] + fn what_encode_cannot_have_written_is_refused() { + let whole = Authority { row: "shell".into(), session: Session::Login(3) }; + let bytes = whole.encode(); + for end in 0..bytes.len() { + assert_ne!(Authority::decode(&bytes[..end]).as_ref(), Some(&whole), "a prefix of {end} bytes"); + } + assert_eq!(Authority::decode(&[]), None); + assert_eq!(Authority::decode(&[LOGIN, 1, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(Authority::decode(&[2, b's']), None); + assert_eq!(Authority::decode(&[MACHINE]), None); + assert_eq!(Authority::decode(&[LOGIN, 1, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(Authority::decode(&[MACHINE, 0xff]), None); + let mut long = vec![MACHINE]; + long.extend(std::iter::repeat_n(b'x', MAX_PROGRAM_NAME + 1)); + assert_eq!(Authority::decode(&long), None); + } +} diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index 0dff91193ca..4772d26e5b2 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -27,11 +27,15 @@ //! supervisor-serve a name the supervisor serves itself //! start the supervisor starts this program at boot //! app-receive a connector every program launched from /apps holds +//! starts a row this program may start through the launcher, or `/apps` +//! login a launch this program makes opens a login session //! ``` //! //! [`package`] is the other half: what an installed package says about itself, //! which is which of its own binaries a launch starts and never what it holds. +//! [`launch`] is who may start what. +pub mod launch; pub mod package; /// Where ROOT carries it, without a leading slash — that volume's own @@ -46,6 +50,11 @@ 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 +/// running service's binary, so a row receiving it starts only in a login +/// session ([`launch`]). +pub const SWAP_PORT: &str = "swap"; + /// The dev image's one user, until the users track gives the supervisor a login row. pub const USER: &str = "toy"; @@ -175,9 +184,21 @@ pub struct Program { /// The supervisor starts it again when it ends, on the same ports, for as long as /// it does not end faster than [`RESTARTS`] allows. pub restart: bool, + /// The rows it may start through the supervisor's launcher: program keys, + /// and [`launch::APPS`] for any installed package. Non-empty is what + /// endows it a launcher at all. + pub starts: Vec, + /// A launch it makes opens a login session ([`launch`]). + pub login: bool, } impl Program { + /// It replaces what the machine runs — the swap port's holder, or the idle + /// slot's — so it starts only in a login session. + pub fn login_only(&self) -> bool { + self.slots || self.receives.iter().any(|r| r == SWAP_PORT) + } + /// The `HOME` the supervisor starts this row with. A location grants nothing: what /// the program can reach is its view's business, never this string's. pub fn home(&self) -> String { @@ -308,6 +329,13 @@ pub fn render(manifest: &Manifest) -> Result, RenderError> { if program.restart { out.push_str("restart\n"); } + for key in &program.starts { + check(&program.name, "starts", key)?; + out.push_str(&format!("starts {key}\n")); + } + if program.login { + out.push_str("login\n"); + } } for name in &manifest.supervisor_serves { check("supervisor", "supervisor_serves", name)?; @@ -394,6 +422,8 @@ pub fn parse(text: &str) -> Manifest { "service" => program.service = true, "role" => program.roles.push(rest.to_string()), "restart" if rest.is_empty() => program.restart = true, + "starts" => program.starts.push(rest.to_string()), + "login" if rest.is_empty() => program.login = true, other => panic!("manifest: unknown record `{other}`"), } } @@ -413,9 +443,11 @@ mod tests { name: "compositor".into(), path: "/system/bin/compositor".into(), serves: vec!["compositor".into()], - receives: vec!["soundserver".into(), "launcher".into()], + receives: vec!["soundserver".into()], devices: vec!["framebuffer".into(), "keyboard".into()], service: true, + starts: vec!["terminal".into(), "/apps".into()], + login: true, ..Program::default() }, Program { @@ -450,7 +482,7 @@ mod tests { ..Program::default() }, ], - supervisor_serves: vec!["launcher".into()], + supervisor_serves: vec!["swap".into()], apps: vec!["compositor".into(), "soundserver".into()], start: vec!["compositor".into(), "soundserver".into()], } diff --git a/toyos-swap/src/lib.rs b/toyos-swap/src/lib.rs index 664342e049e..a0e623ba090 100644 --- a/toyos-swap/src/lib.rs +++ b/toyos-swap/src/lib.rs @@ -35,9 +35,10 @@ use sha2::{Digest as _, Sha256}; -/// The name the supervisor serves swap requests on — a `supervisor-serve` record like -/// `launcher`. -pub const PORT: &str = "swap"; +/// The name the supervisor serves swap requests on — a `supervisor-serve` +/// record — which the manifest names because its holder starts only in a login +/// session. +pub const PORT: &str = toyos_manifest::SWAP_PORT; /// The endowment label [`PORT`] reaches its holder under: a namespace holding /// that one name, and never an entry of the holder's `svc` namespace — which diff --git a/toyos/src/endow.rs b/toyos/src/endow.rs index caa6b91f816..d4adebed5a3 100644 --- a/toyos/src/endow.rs +++ b/toyos/src/endow.rs @@ -144,6 +144,14 @@ pub fn namespace() -> Option<&'static Namespace> { NAMESPACE.get().as_ref() } +/// The namespace holding the supervisor's `launcher` this process was endowed +/// under [`crate::launch::LAUNCHER`], or `None`. Borrowed, as [`namespace`] is: +/// std opens it for every launch, and a parent that hands it on endows a +/// duplicate. +pub fn launcher() -> Option<&'static Namespace> { + LAUNCHER.get().as_ref() +} + /// This process's handle to itself, which it hands on for a child to be /// placed under it: `WRITE`, `DUP` and `TRANSFER`, from the kernel, under /// [`SELF_LABEL`]. Borrowed rather than taken, as the namespace is: every @@ -299,10 +307,12 @@ impl Once { static TABLE: EndowTable = EndowTable(Once::new()); static NAMESPACE: NamespaceCell = NamespaceCell(Once::new()); +static LAUNCHER: LauncherCell = LauncherCell(Once::new()); static THIS_PROCESS: ProcessCell = ProcessCell(Once::new()); struct EndowTable(Once); struct NamespaceCell(Once>); +struct LauncherCell(Once>); struct ProcessCell(Once); impl EndowTable { @@ -317,6 +327,12 @@ impl NamespaceCell { } } +impl LauncherCell { + fn get(&'static self) -> &'static Option { + self.0.get_or_init(|| Endowments::get().take::(crate::launch::LAUNCHER)) + } +} + impl ProcessCell { fn get(&'static self) -> &'static Process { self.0.get_or_init(|| { diff --git a/toyos/src/launch.rs b/toyos/src/launch.rs index 51c6278bb2f..c1eba69e33a 100644 --- a/toyos/src/launch.rs +++ b/toyos/src/launch.rs @@ -24,6 +24,12 @@ use crate::ipc::{Connection, IpcError}; use crate::RawHandle; +/// The label a holder is endowed its launcher under, and the one name in that +/// namespace. **Never an entry of `svc`**, which std hands every direct spawn: +/// a launcher carries its holder's row and session as a badge, and a program +/// passes it on only by endowing it ([`crate::endow::launcher`]). +pub const LAUNCHER: &str = "launcher"; + /// Ask the supervisor to start a program. Carries the request blob below; the stdio /// handles and the extra connectors travel in the batch beside it. pub const MSG_LAUNCH: u32 = 1; diff --git a/toyos/src/port.rs b/toyos/src/port.rs index 6b4bb9094f1..285a37a53f1 100644 --- a/toyos/src/port.rs +++ b/toyos/src/port.rs @@ -10,7 +10,7 @@ //! reached `accept` or has even been spawned. There is nothing to retry and no //! timeout anywhere. -use toyos_abi::syscall::{self, SyscallError}; +use toyos_abi::syscall::{self, SyscallError, MAX_BADGE}; use crate::ipc::Connection; use crate::{AsHandle, OwnedHandle, RawHandle}; @@ -41,6 +41,21 @@ impl Acceptor { syscall::accept(self.0.raw()).map(|h| Connection(OwnedHandle(h))) } + /// A connector to this port whose every connection carries `badge`, which + /// [`Self::badge`] reads back: what this holder granted whoever holds the + /// connector, never who that is. + pub fn mint(&self, badge: &[u8]) -> Result { + syscall::port_mint(self.0.raw(), badge).map(|h| Connector(OwnedHandle(h))) + } + + /// The badge `conn`, accepted from this port, was made with. `NotFound` is + /// a connection through an unbadged connector; one accepted from another + /// port is `PermissionDenied`. + pub fn badge<'a>(&self, conn: &Connection, out: &'a mut [u8; MAX_BADGE]) -> Result<&'a [u8], SyscallError> { + let len = syscall::port_badge(self.0.raw(), conn.as_handle(), out)?; + Ok(&out[..len]) + } + /// Give up ownership, for a handle about to be endowed or transferred. pub fn into_raw(self) -> RawHandle { self.0.into_raw() diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 0af8366260f..0d1481e1679 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -47,6 +47,12 @@ //! A launch of a program no row names is answered with the session's, which //! the caller's direct spawn carries in place of its own. //! +//! **A launch starts only what its caller's row lists** ([`toyos_manifest::launch`]). +//! Every row whose `starts` lists anything is endowed a `launcher` the supervisor +//! minted with its row and session as the badge, under a label of its own so +//! no direct spawn inherits it; the badge on a launch's connection is who asks, +//! and nothing the caller says is. +//! //! **A launched program is spawned under the place its request names** — a //! copy of the caller's `self` — so the caller's end takes it down; a request //! that asks for the supervisor is the one way to outlive the caller, and one naming @@ -62,12 +68,13 @@ use std::time::{Duration, Instant}; use toyos_swap::{Refusal, Request as SwapRequest, Word}; +use toyos_manifest::launch::{self as authority, Authority, Session, Target}; use toyos_manifest::package::{self, Package}; use toyos_manifest::{Manifest, Program}; use toyos::endow::Endowments; use toyos::fs::CAPABILITY_PREFIX; use toyos::ipc::{self, Connection, RxStep}; -use toyos::launch::{self, Parent, Request}; +use toyos::launch::{self, Parent, Request, LAUNCHER}; use toyos::namespace::{self, Namespace}; use toyos::poller::{Poller, READABLE}; use toyos::port::{self, Acceptor, Connector}; @@ -83,14 +90,10 @@ use toyos_logstream::{ SWAP, SWAP_BACK, SWAP_LEAVING, }; use toyos_abi::syscall::{ - DeviceRequest, FileType, SyscallError, DEV_PREFIX, PROVIDE_PREFIX, SERVE_PREFIX, SVC_LABEL, - SYSCAP_LABEL, + DeviceRequest, FileType, SyscallError, DEV_PREFIX, MAX_BADGE, PROVIDE_PREFIX, SERVE_PREFIX, + SVC_LABEL, SYSCAP_LABEL, }; -/// The service the supervisor answers on. Its own, so it has no `[programs]` row and the -/// manifest carries it as a `supervisor-serve` record. -const LAUNCHER: &str = "launcher"; - /// What the supervisor makes in the session user's home before anything runs. English on /// disk in every locale: a translation is a label, never a rename. const HOME_FOLDERS: [&str; 8] = @@ -132,6 +135,14 @@ struct Pending { /// Which of the supervisor's two ports it came in on, which decides what its frame /// may ask for. port: Port, + /// Who asks, off the connection's badge: present exactly for [`Port::Launcher`]. + caller: Option, +} + +/// A launch's caller: the row and session the badge on its connection names. +struct Caller { + row: &'static Program, + session: Session, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -396,9 +407,14 @@ 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 (wake_read, wake_write) = toyos::pipe_pair().expect("supervisor: no pipe to hear a service end"); let mut supervisor = Supervisor { system, + launcher, + logins: 0, syscap: &syscap, acceptors, connectors, @@ -415,10 +431,6 @@ fn main() { // Nothing else holds a `serves` acceptor that has not been launched yet, so // the supervisor outliving its children is what keeps those ports open. It parks // here. - let launcher = supervisor - .acceptors - .remove(LAUNCHER) - .expect("supervisor: the manifest declares the supervisor serves `launcher`"); let swap = supervisor .acceptors .remove(toyos_swap::PORT) @@ -427,7 +439,7 @@ fn main() { .acceptors .remove(power::PORT) .expect("supervisor: the manifest declares the supervisor serves `power`"); - supervisor.serve_forever(&launcher, &swap, &power); + supervisor.serve_forever(&swap, &power); } /// How long the supervisor waits on one call into a file server that is alive and has @@ -502,6 +514,10 @@ fn is_storage(program: &Program) -> bool { /// Everything the supervisor's loop acts on, for the machine's life. struct Supervisor<'a> { system: &'static Manifest, + /// What every launcher is minted on and every launch accepted from. + launcher: Acceptor, + /// The login sessions opened so far: the last one's id. + logins: u64, syscap: &'a SysCap, /// The `serves` acceptors nobody has been started with yet, which a launch /// takes by move. @@ -600,6 +616,7 @@ impl<'a> Service<'a> { syscap: &SysCap, connectors: &BTreeMap<&str, Connector>, dirs: &BTreeMap, + launcher: &Acceptor, log: &mut Log, ) -> Result { // **Checked again at every start, not only on arrival**: the installed @@ -631,6 +648,7 @@ impl<'a> Service<'a> { dirs, &[], storage, + (launcher, Session::Machine), Output::Boot(log), )?; kept.generation += 1; @@ -788,7 +806,7 @@ impl<'a> Supervisor<'a> { self.make_home(program); let mut service = Service::new(program, role, kept, wake); let started = - service.spawn(&program.path, &[], system, self.syscap, &self.connectors, &self.dirs, &mut self.log); + service.spawn(&program.path, &[], system, self.syscap, &self.connectors, &self.dirs, &self.launcher, &mut self.log); match started { Ok(_) => {} Err(StartError::Partition(why)) => { @@ -883,13 +901,13 @@ impl<'a> Supervisor<'a> { /// the wait wakes for. The one wait the supervisor makes outside the loop is for a /// service it has just killed to finish ending, which is the kernel's /// teardown and no client's. - fn serve_forever(&mut self, launcher: &Acceptor, swap: &Acceptor, power: &Acceptor) -> ! { + fn serve_forever(&mut self, swap: &Acceptor, power: &Acceptor) -> ! { let poller = Poller::new(5 + MAX_PENDING_LAUNCHES as u32); let mut pending: Vec = Vec::new(); let mut flight: Option = None; let mut ready: Vec = Vec::new(); loop { - poller.watch(launcher, READABLE, TOKEN_ACCEPTOR); + poller.watch(&self.launcher, READABLE, TOKEN_ACCEPTOR); poller.watch(swap, READABLE, TOKEN_SWAP_ACCEPTOR); poller.watch(power, READABLE, TOKEN_POWER_ACCEPTOR); poller.watch(&self.wake, READABLE, TOKEN_WAKE); @@ -923,7 +941,7 @@ impl<'a> Supervisor<'a> { // Accept and the request are two events. Nothing is read here. for (token, acceptor, port) in [ - (TOKEN_ACCEPTOR, launcher, Port::Launcher), + (TOKEN_ACCEPTOR, &self.launcher, Port::Launcher), (TOKEN_SWAP_ACCEPTOR, swap, Port::Swap), (TOKEN_POWER_ACCEPTOR, power, Port::Power), ] { @@ -940,9 +958,19 @@ impl<'a> Supervisor<'a> { are already waiting to say what they want", conn.as_handle().0 ); - } else { - pending.push(Pending { conn, rx: LaunchRx::new(), since: Instant::now(), port }); + continue; } + let caller = match port { + Port::Launcher => match self.caller(&conn) { + Ok(caller) => Some(caller), + Err(why) => { + say!("supervisor: launcher: dropping client {} — {why}", conn.as_handle().0); + continue; + } + }, + Port::Swap | Port::Power => None, + }; + pending.push(Pending { conn, rx: LaunchRx::new(), since: Instant::now(), port, caller }); } // `remove` rather than `swap_remove`: the entries after `i` shift @@ -977,7 +1005,10 @@ impl<'a> Supervisor<'a> { RxStep::Frame { msg_type, payload_len } => { let p = pending.remove(i); match p.port { - Port::Launcher => self.serve_launch(&p.conn, msg_type, p.rx.payload(payload_len)), + Port::Launcher => { + let caller = p.caller.as_ref().expect("a launcher connection is kept only with its caller"); + self.serve_launch(&p.conn, caller, msg_type, p.rx.payload(payload_len)) + } Port::Power => self.stop(&p.conn, msg_type), Port::Swap => { let payload = p.rx.payload(payload_len).to_vec(); @@ -1014,6 +1045,19 @@ impl<'a> Supervisor<'a> { } } + /// The row and session a launcher connection's badge names. Only this + /// supervisor mints on its launcher, so a refusal here is its own bug, said + /// and the connection dropped. + fn caller(&self, conn: &Connection) -> Result { + let mut badge = [0u8; MAX_BADGE]; + let badge = self.launcher.badge(conn, &mut badge).map_err(|e| format!("its connection has no badge ({e:?})"))?; + let Some(Authority { row, session }) = Authority::decode(badge) else { + return Err(format!("its badge is not one this supervisor writes: {badge:?}")); + }; + let row = self.system.program(&row).ok_or_else(|| format!("its badge names `{row}`, which is no row"))?; + Ok(Caller { row, session }) + } + /// One swap request, answered: refused with the old service untouched, or /// verified, installed and accepted. /// @@ -1145,7 +1189,7 @@ impl<'a> Supervisor<'a> { let _ = old.wait(); } let started = - service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.dirs, &mut self.log); + service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.dirs, &self.launcher, &mut self.log); match started { Ok(pid) => { swapped( @@ -1319,7 +1363,7 @@ impl<'a> Supervisor<'a> { let (path, owed, program) = (service.path.clone(), service.devices.clone(), service.program); self.make_home(program); let service = &mut self.services[index]; - match service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.dirs, &mut self.log) { + match service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.dirs, &self.launcher, &mut self.log) { Ok(new) => say!("supervisor: {label} (pid {pid}) ended; started again as pid {new}"), Err(e) => { let mut kept = service.kept.lock().expect("supervisor: a service's state is poisoned"); @@ -1339,7 +1383,7 @@ impl<'a> Supervisor<'a> { self.make_home(program); let service = &mut self.services[index]; let name = service.program.name.clone(); - match service.spawn(previous, owed, self.system, self.syscap, &self.connectors, &self.dirs, &mut self.log) { + match service.spawn(previous, owed, self.system, self.syscap, &self.connectors, &self.dirs, &self.launcher, &mut self.log) { Ok(pid) => { service.kept.lock().expect("supervisor: a service's state is poisoned").swapping = false; swapped(&self.log, &name, Word::Restored, &format!("{previous} as pid {pid}")); @@ -1396,13 +1440,15 @@ fn forget(path: &str) { impl Supervisor<'_> { /// One `MSG_LAUNCH`, from the frame to the `Process` handle that answers it. /// - /// **Everything in the request is a client's claim about itself.** A program - /// nothing declares is refused by name; a frame that does not decode is a - /// dropped connection and nothing else. What the child ends up holding is the - /// manifest's row for it plus whatever connectors the caller transferred, and - /// the caller could only transfer what it already had — so a launch confers - /// exactly the manifest row and nothing beyond it. - fn serve_launch(&mut self, conn: &Connection, msg_type: u32, payload: &[u8]) { + /// **Everything in the request is a client's claim about itself; `caller` is + /// not.** A program nothing declares is refused by name; a frame that does + /// not decode is a dropped connection and nothing else; a row the caller's + /// row may not start is refused before any of its files is read. What the + /// child ends up holding is the manifest's row for it plus whatever + /// connectors the caller transferred, and the caller could only transfer + /// what it already had — so a launch confers exactly the manifest row and + /// nothing beyond it. + fn serve_launch(&mut self, conn: &Connection, caller: &Caller, msg_type: u32, payload: &[u8]) { if msg_type != launch::MSG_LAUNCH { return; } @@ -1503,19 +1549,27 @@ impl Supervisor<'_> { let installed; // On the worker, every file the launch reads: a path under `/apps` is // read off its package, and the program and its working directory may - // be a file server's, which the supervisor supervises. + // be a file server's, which the supervisor supervises. Whether the + // caller may start it is asked between the two, so a refused launch + // reads no image and judges no directory. let (system, path) = (self.system, request.program.to_string()); + let (row, session, opened) = (caller.row, caller.session, self.logins + 1); let found = self.files("a launch's files", move || { let resolved = resolve(system, &path); - let prepared = match &resolved { - Resolved::Row(Program { path, .. }) | Resolved::Package(Program { path, .. }) => { - command.image_from(Path::new(path)).prepare().map(drop) - } - Resolved::NotDeclared | Resolved::Refused(_) => Ok(()), + let (target, path) = match &resolved { + Resolved::Row(program) => (Target::Row(program), &program.path), + Resolved::Package(program) => (Target::Package(program), &program.path), + Resolved::NotDeclared | Resolved::Refused(_) => return (resolved, None, Ok(command)), }; - (resolved, prepared.map(|()| command)) + let verdict = authority::may_start(row, session, target, || opened); + let prepared = match verdict { + Ok(_) => command.image_from(Path::new(path)).prepare().map(drop), + Err(_) => Ok(()), + }; + let prepared = prepared.map(|()| command); + (resolved, Some(verdict), prepared) }); - let (resolved, prepared) = match found { + let (resolved, verdict, prepared) = match found { Ok(found) => found, Err(why) => { say!("supervisor: launcher: {} was not resolved: {why}", request.program); @@ -1544,6 +1598,19 @@ impl Supervisor<'_> { return; } }; + // **`MSG_REFUSED`, never `MSG_NOT_DECLARED`**: the latter is std's cue to + // spawn the program itself. + let session = match verdict.expect("a resolved row is judged") { + Ok(session) => session, + Err(why) => { + say!("{}", authority::refused(&caller.row.name, caller.session, &program.name, why)); + let _ = conn.try_signal(launch::MSG_REFUSED); + return; + } + }; + if session == Session::Login(opened) { + self.logins = opened; + } let command = match prepared { Ok(command) => command, Err(e) => { @@ -1568,6 +1635,7 @@ impl Supervisor<'_> { &self.dirs, &extras, Storage::default(), + (&self.launcher, session), Output::Launch { log: &mut self.log, slots: &caller_slots }, ); match started { @@ -1838,6 +1906,7 @@ fn start<'a>( dirs: &BTreeMap, extras: &[(&str, Connector)], storage: Storage, + launcher: (&Acceptor, Session), output: Output<'_>, ) -> std::io::Result<(Child, Vec)> { // A storage row's own arguments first: a file server's role leads its argv. @@ -1876,6 +1945,11 @@ fn start<'a>( command.endow(toyos_swap::LABEL, raw.0); held.0.push(raw); } + if let Some(ns) = launcher_namespace(program, launcher) { + let raw = ns.into_raw(); + command.endow(LAUNCHER, raw.0); + held.0.push(raw); + } // The namespace answers with connections, so a child holding `surface` // only there could never hand `surface` to a child of its own — which is @@ -2224,6 +2298,24 @@ fn swap_namespace(program: &Program, connectors: &BTreeMap<&str, Connector>) -> Some(ns) } +/// A `launcher` minted with `program`'s row and the session it runs in, in a +/// namespace of its own, for a row whose `starts` lists anything; endowed under +/// [`LAUNCHER`] and never an entry of `svc`, for [`swap_namespace`]'s reason. +fn launcher_namespace(program: &Program, (launcher, session): (&Acceptor, Session)) -> Option { + if program.starts.is_empty() { + return None; + } + let badge = Authority { row: program.name.clone(), session }.encode(); + let connector = launcher + .mint(&badge) + .unwrap_or_else(|e| panic!("supervisor: no launcher for {}: {e:?}", program.name)); + let ns = namespace::build() + .add(LAUNCHER, &connector) + .finish() + .unwrap_or_else(|e| panic!("supervisor: no launcher namespace for {}: {e:?}", program.name)); + Some(ns) +} + /// One of the supervisor's words on a swap: its line in the log, and — for the service /// `logkeeper`'s network readers are carried by — the frame `logkeeper` acts on. The /// frame and not the line: nothing a program writes is a word `logkeeper` obeys. diff --git a/userland/test-runner/src/main.rs b/userland/test-runner/src/main.rs index cd0b5cb3635..2efb1c5b9e8 100644 --- a/userland/test-runner/src/main.rs +++ b/userland/test-runner/src/main.rs @@ -256,6 +256,20 @@ fn run_one(name: &str, args: &[&str], cap: Option<&SysCap>) -> Ran { return Ran::No; } } + // Its launcher too, where its row starts anything: a job is a direct spawn, + // and a direct spawn inherits none. + if let Some(launcher) = toyos::endow::launcher() { + match toyos_abi::syscall::dup(toyos::AsHandle::as_handle(launcher)) { + Ok(dup) => { + command.endow(toyos::launch::LAUNCHER, dup.0); + } + Err(e) => { + println!("===TEST_END {name} error=the launcher would not duplicate: {e:?}==="); + let _ = io::stdout().flush(); + return Ran::No; + } + } + } let ran = match command.spawn() { Ok(mut child) => { drop(child.stdin.take()); From 9c3a4f2356c4e1c5e51a5ac2d1f85cfca58e6c8e Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 04:21:17 +0200 Subject: [PATCH 2/7] rust: std asks the launcher its caller was endowed (f293615ad7a) The fork's one call site opens `toyos::endow::launcher()` in place of a `launcher` entry in the inherited `svc` namespace, which no row holds any more. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- rust | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust b/rust index 724238524f4..f293615ad7a 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 724238524f4dcddc202ffa570cf034c512fe3e1d +Subproject commit f293615ad7aa162200b40426c159c505c449e182 From e19a8f0204d7493c640688bb8e9cbcad0eabfc12 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 06:05:45 +0200 Subject: [PATCH 3/7] Answer review round 1: a launched row's session is tested, both accepts read the stamp, one command list - launch_authority: toybox in proctreecase lists `swap` and opens no session. Launched by this job, in the machine's session, its `stats swap` is refused, and the judge reads the supervisor's refusal naming toybox. Launched by the `login` shell, its `stats swap` starts. These two arms test the session a launched program's badge carries. They replace `detach swap`, which a `login` row's own launch answered. - port_badge: a connection accepted through an inbox's OP_ACCEPT reads back its stamp. - The login id is gone. Session is {Machine, Login}, may_start opens nothing, and the badge is the kind byte and the row. No reader separated sessions by id. - Supervisor: the caller rides in Port::Launcher(Caller), and the verdict rides in Resolved's row variants. Neither has a state to expect away. - Build: `[commands] starts` is the one list a row marked `commands = true` starts. The shell, sshserver and toybox each carry the mark, toybox for `stats`, which runs the command it is given. The refusal of a `login` row that starts nothing is deleted, since a reader can see it changes nothing. - toyos-manifest's description names `launch`. The supervisor track's stage 2 names the module's move. The std fallback on a launcher that will not open is filed. - Deleted: the control-provenance comment, the private LAUNCHER copy, and the false "A client's end." doc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- ...er-that-will-not-open-is-a-direct-spawn.md | 23 ++++ ...rvisor-is-host-tested-and-owns-the-stop.md | 5 +- kernel/src/object/service.rs | 1 - src/build.rs | 31 +++-- system.toml | 15 ++- tests/proctreecase/system.toml | 7 +- .../src/bin/launch_authority.rs | 34 +++--- tests/toyos-rust-tests/src/bin/port_badge.rs | 51 ++++++++- tests/toyos.rs | 12 +- toyos-manifest/Cargo.toml | 2 +- toyos-manifest/src/launch.rs | 107 +++++++----------- userland/supervisor/src/main.rs | 93 +++++++-------- 12 files changed, 227 insertions(+), 154 deletions(-) create mode 100644 issues/isolation/a-held-launcher-that-will-not-open-is-a-direct-spawn.md diff --git a/issues/isolation/a-held-launcher-that-will-not-open-is-a-direct-spawn.md b/issues/isolation/a-held-launcher-that-will-not-open-is-a-direct-spawn.md new file mode 100644 index 00000000000..585e563bc5f --- /dev/null +++ b/issues/isolation/a-held-launcher-that-will-not-open-is-a-direct-spawn.md @@ -0,0 +1,23 @@ +--- +status: open +kind: defect +opened: 2026-10-04 +--- + +# A held launcher that will not open is a direct spawn + +std's ToyOS `launch` (`library/std/src/sys/process/toyos.rs` in the `rust/` +fork) reads `toyos::endow::launcher().and_then(|held| held.open(LAUNCHER).ok())`: +a process that holds a launcher and whose open of it fails is treated as one +that holds none, and its spawn of a declared program goes on as a direct spawn, +without that program's row and with no error. The shape is the one `main` had +before launch authority, where `endow::service("launcher")`'s `Err` did the +same. + +Holding none and holding one that refuses are different facts: the first is a +direct spawn by design, the second is a failure the caller is never told of, +and the program it starts runs without what its row holds. + +**Exit**: a spawn by a process holding a launcher whose open fails returns +that failure, and a test that makes the open fail reds when the spawn +starts the program. diff --git a/issues/isolation/the-supervisor-is-host-tested-and-owns-the-stop.md b/issues/isolation/the-supervisor-is-host-tested-and-owns-the-stop.md index d10b0f03766..0e9c81ba61d 100644 --- a/issues/isolation/the-supervisor-is-host-tested-and-owns-the-stop.md +++ b/issues/isolation/the-supervisor-is-host-tested-and-owns-the-stop.md @@ -61,8 +61,9 @@ nothing. manifest whose dependencies form an undeclared cycle; each refusal and each decision above has a mutation that reds a host test, named in the PR; and the stage deletes each decision from `userland/supervisor`, which calls - the crate for it, named per decision in the PR. No decision exists in two - places. + the crate for it, named per decision in the PR; `toyos_manifest::launch`, + who may start what and the launcher's badge codec, moves into the crate + and out of `toyos-manifest`. No decision exists in two places. 3. **The supervisor owns the stop.** It asks each service it started to finish by a quit with reason terminate (`issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md` diff --git a/kernel/src/object/service.rs b/kernel/src/object/service.rs index c4f554cc89b..739ee6aa2b3 100644 --- a/kernel/src/object/service.rs +++ b/kernel/src/object/service.rs @@ -79,7 +79,6 @@ impl ConnectionEnd { (HandleQueue::open(), HandleQueue::open()) } - /// A client's end. pub fn new( rx: PipeReader, tx: PipeWriter, diff --git a/src/build.rs b/src/build.rs index 4e4f60acc99..03245f34200 100644 --- a/src/build.rs +++ b/src/build.rs @@ -121,6 +121,24 @@ struct SystemConfig { /// them, because a package directory is writable. #[serde(default)] apps: AppsConfig, + /// What a command a user names may start, which every row marked + /// `commands` starts beside its own `starts`. + #[serde(default)] + commands: CommandsConfig, +} + +impl SystemConfig { + /// Every row `program` may start through the launcher. + fn starts<'a>(&'a self, program: &'a ProgramConfig) -> impl Iterator { + let commands = if program.commands { &self.commands.starts[..] } else { &[] }; + program.starts.iter().chain(commands) + } +} + +#[derive(Deserialize, Default)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +struct CommandsConfig { + starts: Vec, } #[derive(Deserialize, Default)] @@ -173,6 +191,8 @@ struct ProgramConfig { restart: bool, /// The rows it may start through the launcher (`toyos_manifest::Program::starts`). starts: Vec, + /// It runs whatever command a user names, so it starts `[commands]`'s rows too. + commands: bool, /// A launch it makes opens a login session (`toyos_manifest::Program::login`). login: bool, } @@ -679,10 +699,10 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { /// **Checked on every manifest rendered**: a `starts` entry is `/apps` or a /// declared row the supervisor can start more than once, so not one that /// serves a port or a file-server role, whose acceptors a launch would take and -/// a second one find gone; and a `login` row starts something. +/// a second one find gone. fn starts_name_what_a_launch_can_start(config: &SystemConfig) -> Result<(), String> { for (name, program) in &config.programs { - for key in &program.starts { + for key in config.starts(program) { if key == toyos_manifest::launch::APPS { continue; } @@ -693,9 +713,6 @@ fn starts_name_what_a_launch_can_start(config: &SystemConfig) -> Result<(), Stri return Err(format!("`{name}` starts `{key}`, which serves ports a launch would take for good")); } } - if program.login && program.starts.is_empty() { - return Err(format!("`{name}` opens a login session and starts nothing")); - } } Ok(()) } @@ -731,7 +748,7 @@ fn render_manifest(config: &SystemConfig) -> Vec { service: cfg.service, roles: cfg.roles.clone(), restart: cfg.restart, - starts: cfg.starts.clone(), + starts: config.starts(cfg).cloned().collect(), login: cfg.login, } }) @@ -3223,7 +3240,7 @@ mod tests { assert!(gate("[programs.shell]\nstarts = [\"apps\"]\n").is_err()); assert!(gate("[programs.shell]\nstarts = [\"compositor\"]\n[programs.compositor]\nserves = [\"compositor\"]\n").is_err()); assert!(gate("[programs.shell]\nstarts = [\"fileserver\"]\n[programs.fileserver]\nroles = [\"data\"]\n").is_err()); - assert!(gate("[programs.sshserver]\nlogin = true\n").is_err()); + assert!(gate("[commands]\nstarts = [\"ghost\"]\n[programs.shell]\ncommands = true\n").is_err()); assert!(toml::from_str::("[programs.shell]\nstart = [\"toybox\"]\n").is_err()); } diff --git a/system.toml b/system.toml index 2211f83a38c..51f23e122f7 100644 --- a/system.toml +++ b/system.toml @@ -32,6 +32,11 @@ start = ["logkeeper", "diskserver", "fileserver", "compositor", "soundserver", " [apps] receives = ["compositor", "soundserver", "filepicker"] +# What a command a user names may start: every row a launch can start. A row +# marked `commands = true` runs whatever one names, and starts these. +[commands] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] + # `service = true` makes a row a system service: the supervisor starts it with `HOME` at # its own `/state/` and makes that directory first. Every other row runs # with the session user's home. @@ -82,11 +87,11 @@ serves = ["netstack"] devices = ["pci:1af4:1041"] # Every launch it makes opens a login session, the one `update` and `swap` -# below start in. It runs whatever command a login names, as the shell does. +# below start in. [programs.sshserver] service = true receives = ["netstack"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] +commands = true login = true # `swap` is the authority to replace a running service's binary; the build @@ -124,10 +129,9 @@ provides = ["surface"] receives = ["compositor"] starts = ["shell"] -# Whatever a user names at its prompt. [programs.shell] receives = ["surface"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] +commands = true [programs.doom] receives = ["compositor", "soundserver"] @@ -159,9 +163,12 @@ receives = ["compositor", "filepicker"] # # `free` is the same syscall as `ps` and is named by neither: the machine header # is ambient and the process roster is not. +# +# `commands` for `stats`, which runs the command it is given. [programs.toybox] receives = ["compositor", "soundserver", "surface", "power"] syscap = ["roster"] +commands = true # Symlinks created on ROOT at build time. # Keys are ROOT-relative; targets are the guest paths they resolve to. diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index d28312479d5..57bbdf480be 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -9,7 +9,9 @@ # # And `launch_authority`: test-runner's row lists `swap` and `update`, which a # job is refused outside a login session, and not `proctest`, which it is -# refused outright; the shell opens a login session, in which it starts `swap`. +# refused outright; `toybox`, which opens no session, starts `swap` in the +# session it was launched in: refused in the machine's, started in the one the +# shell opens. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] @@ -31,9 +33,10 @@ starts = ["toybox", "shell", "swap", "update"] # it ran under this row, and `launch_toctou` asks which bytes that child was. [programs.toybox] syscap = ["roster"] +starts = ["swap"] [programs.shell] -starts = ["toybox", "swap"] +starts = ["toybox"] login = true [programs.swap] diff --git a/tests/toyos-rust-tests/src/bin/launch_authority.rs b/tests/toyos-rust-tests/src/bin/launch_authority.rs index 88631326720..3e10361eb16 100644 --- a/tests/toyos-rust-tests/src/bin/launch_authority.rs +++ b/tests/toyos-rust-tests/src/bin/launch_authority.rs @@ -9,23 +9,24 @@ //! - `proctest`, which the row does not list, and `swap` and `update`, which //! it lists and which start only in a login session: each is refused, and //! nothing is spawned in its place; -//! - a shell, whose row opens a login session, that detaches `swap`: it -//! starts there. +//! - `toybox stats swap`: toybox's row lists `swap` and opens no session, so +//! it asks in the session it was launched in, the machine's, and is refused; +//! - a shell, whose row opens a login session, running `toybox stats swap`: +//! toybox asks in that session, and `swap` starts. +//! +//! `stats` exits 1 when its command does not start, and 0 once it has run. //! //! And a child it spawns directly holds no launcher, under its label or in //! the namespace it inherits: the chain where any program a shell spawned //! could ask for `swap` is gone. -//! -//! Written against what the base it replaces had too, so the change reverted -//! reds here and does not fail to build. Which refusal each refused launch -//! got is in the supervisor's lines, which the metal judge reads. use std::process::{Command, Stdio}; use toyos::endow::{self, EndowError, Endowments}; +use toyos::launch::LAUNCHER; const SELF_PATH: &str = "/system/bin/test_rs_launch_authority"; -const LAUNCHER: &str = "launcher"; +const STATS_SWAP: &str = "/system/bin/toybox stats /system/bin/swap"; const CHILD: &str = "child"; /// What the child exits with holding no launcher, and holding one. const HOLDS_NONE: i32 = 0; @@ -58,14 +59,19 @@ fn test() { } } - // Piped, so the shell holds the input it hands what it detaches. - let detached = Command::new("/system/bin/shell") - .args(["-c", "detach /system/bin/swap"]) - .stdin(Stdio::piped()) + let from_machine = Command::new("/system/bin/toybox") + .args(["stats", "/system/bin/swap"]) .output() - .expect("launch a shell"); - if !detached.status.success() { - red.push(format!("swap did not start in a login session: {detached:?}")); + .expect("launch toybox as stats"); + match from_machine.status.code() { + Some(1) => println!(" toybox in the machine's session: swap refused"), + _ => red.push(format!("toybox launched in the machine's session started swap: {from_machine:?}")), + } + + let from_login = Command::new("/system/bin/shell").args(["-c", STATS_SWAP]).output().expect("launch a shell"); + match from_login.status.code() { + Some(0) => println!(" toybox in a login session: swap started"), + _ => red.push(format!("toybox launched in a login session did not start swap: {from_login:?}")), } let child = Command::new(SELF_PATH).arg(CHILD).status().expect("spawn this binary as a child"); diff --git a/tests/toyos-rust-tests/src/bin/port_badge.rs b/tests/toyos-rust-tests/src/bin/port_badge.rs index 08d1e206637..4f95dd06e55 100644 --- a/tests/toyos-rust-tests/src/bin/port_badge.rs +++ b/tests/toyos-rust-tests/src/bin/port_badge.rs @@ -2,7 +2,8 @@ //! port's acceptor. //! //! - A connection made through a minted connector carries its bytes, exactly, -//! and a connector [`MAX_BADGE`] long is the bound, not past it. +//! accepted by `SYS_ACCEPT` or by an inbox's `OP_ACCEPT`, and a connector +//! [`MAX_BADGE`] long is the bound, not past it. //! - A connection accepted on one port and asked of another port's acceptor is //! refused: any process can make a port and mint any bytes on it, so this is //! what a forged server end handed to a server meets. @@ -11,12 +12,18 @@ //! - A client's end is no accepted connection. //! - Minting takes `READ` on the acceptor, the right accepting takes. +use core::sync::atomic::Ordering; + use toyos::ipc::Connection; use toyos::namespace; use toyos::port::{self, Acceptor, Connector}; use toyos::AsHandle; use toyos_abi::handle::Rights; +use toyos_abi::inbox::{ + Completion, RingHeader, Submission, COMPLETION_RING_OFF, OP_ACCEPT, SUBMISSIONS_OFF, SUBMISSION_RING_OFF, +}; use toyos_abi::syscall::{self, SyscallError, MAX_BADGE}; +use toyos_abi::RawHandle; const NAME: &str = "port"; @@ -39,8 +46,14 @@ fn main() { Err(SyscallError::PermissionDenied), "another port's acceptor read this port's stamp" ); + let names = namespace::build().add(NAME, &minted).finish().expect("a namespace"); + let _client = names.open(NAME).expect("connect"); + let server = accept_through_inbox(&acceptor); + let len = syscall::port_badge(acceptor.as_handle(), server, &mut out); + assert_eq!(len.map(|len| &out[..len]), Ok(badge), "an inbox's accept lost the stamp"); + syscall::close(server); } - println!(" a minted badge comes back exact on its own port, and on no other"); + println!(" a minted badge comes back exact on its own port, through either accept, and on no other"); let (unbadged_acceptor, unbadged) = port::create().expect("a third port"); let (_client, server) = connect(&unbadged_acceptor, &unbadged); @@ -67,6 +80,40 @@ fn main() { println!("port_badge: PASS"); } +/// The server's end of the connection queued on `acceptor`, taken by an +/// inbox's `OP_ACCEPT`. +fn accept_through_inbox(acceptor: &Acceptor) -> RawHandle { + const DEPTH: u32 = 8; + // SAFETY: the rings are this process's own, mapped by the call and read + // only through the offsets the ABI defines. + let (inbox, base) = unsafe { syscall::inbox_setup(DEPTH) }.expect("inbox_setup"); + let ring = unsafe { &*(base.add(SUBMISSION_RING_OFF as usize) as *const RingHeader) }; + let head = ring.head.load(Ordering::Acquire); + let idx = (head & (DEPTH - 1)) as usize; + let submission = unsafe { + &mut *(base.add(SUBMISSIONS_OFF as usize + idx * core::mem::size_of::()) as *mut Submission) + }; + *submission = Submission { op: OP_ACCEPT, handle: acceptor.as_handle(), token: 0xACCE, ..Submission::default() }; + ring.tail.store(head.wrapping_add(1), Ordering::Release); + + assert_eq!(syscall::inbox_submit(inbox, 1, 1, 0), Ok(1), "the accept did not complete"); + let cq = unsafe { &*(base.add(COMPLETION_RING_OFF as usize) as *const RingHeader) }; + let ch = cq.head.load(Ordering::Acquire); + assert_ne!(ch, cq.tail.load(Ordering::Acquire), "no completion was posted"); + let cidx = (ch & (cq.ring_size - 1)) as usize; + let completion = unsafe { + &*(base.add( + COMPLETION_RING_OFF as usize + + core::mem::size_of::() + + cidx * core::mem::size_of::(), + ) as *const Completion) + }; + assert_eq!(completion.token, 0xACCE, "the completion is for another submission"); + let handle = u32::try_from(completion.result).unwrap_or_else(|_| panic!("OP_ACCEPT refused: {}", completion.result)); + syscall::close(inbox); + RawHandle(handle) +} + /// A connection through `connector`, and its end accepted on `acceptor`. fn connect(acceptor: &Acceptor, connector: &Connector) -> (Connection, Connection) { let names = namespace::build().add(NAME, connector).finish().expect("a namespace"); diff --git a/tests/toyos.rs b/tests/toyos.rs index 7958320bf22..ef953c56531 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -2677,13 +2677,19 @@ fn process_tree(back: &metal::Readback) -> Result<(), String> { /// `launch_authority` passed, and the supervisor refused each launch for its /// own reason: `proctest` as unlisted, `swap` and `update` as outside a login -/// session. The guest sees only that each was refused. +/// session, for test-runner and for the toybox it launched. The guest sees +/// only that each was refused. fn launch_authority(back: &metal::Readback) -> Result<(), String> { use toyos_manifest::launch::{refused, Refusal, Session}; back.job_passed("test_rs_launch_authority")?; let log = back.log(); - for (target, why) in [("proctest", Refusal::NotListed), ("swap", Refusal::OutsideLogin), ("update", Refusal::OutsideLogin)] { - let line = refused("test-runner", Session::Machine, target, why); + for (caller, target, why) in [ + ("test-runner", "proctest", Refusal::NotListed), + ("test-runner", "swap", Refusal::OutsideLogin), + ("test-runner", "update", Refusal::OutsideLogin), + ("toybox", "swap", Refusal::OutsideLogin), + ] { + let line = refused(caller, Session::Machine, target, why); if !log.text().lines().any(|l| l.contains(&line)) { return Err(format!("the supervisor never said `{line}`\n{}", log.text())); } diff --git a/toyos-manifest/Cargo.toml b/toyos-manifest/Cargo.toml index 8d9b4ddf6d6..413ca101e7b 100644 --- a/toyos-manifest/Cargo.toml +++ b/toyos-manifest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos-manifest" -description = "The one definition of /system/etc/system.manifest: what every program in an image may hold, written by the build and read by the supervisor." +description = "The one definition of /system/etc/system.manifest: what every program in an image may hold, written by the build and read by the supervisor; and who may start what through the supervisor's launcher, with the badge codec the supervisor alone writes and reads." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/toyos-manifest/src/launch.rs b/toyos-manifest/src/launch.rs index fc69d555c28..341a551b4ca 100644 --- a/toyos-manifest/src/launch.rs +++ b/toyos-manifest/src/launch.rs @@ -30,15 +30,15 @@ pub const APPS: &str = package::DIR; pub enum Session { /// A row the supervisor started at boot, and every launch made in it. Machine, - /// One a `login` row opened, by a never-repeating id. - Login(u64), + /// One a `login` row opened, and every launch made in it. + Login, } impl fmt::Display for Session { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Machine => f.write_str("the machine's session"), - Self::Login(id) => write!(f, "login session {id}"), + Self::Login => f.write_str("a login session"), } } } @@ -54,19 +54,16 @@ pub struct Authority { const MACHINE: u8 = 0; const LOGIN: u8 = 1; -const _: () = assert!(1 + 8 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority must fit one badge"); +const _: () = assert!(1 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority must fit one badge"); impl Authority { - /// The session's kind, a login session's id, then the row's key. + /// The session's kind, then the row's key. pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(1 + 8 + self.row.len()); - match self.session { - Session::Machine => out.push(MACHINE), - Session::Login(id) => { - out.push(LOGIN); - out.extend_from_slice(&id.to_le_bytes()); - } - } + let mut out = Vec::with_capacity(1 + self.row.len()); + out.push(match self.session { + Session::Machine => MACHINE, + Session::Login => LOGIN, + }); out.extend_from_slice(self.row.as_bytes()); out } @@ -74,14 +71,12 @@ impl Authority { /// `None` for bytes [`Self::encode`] cannot have written. pub fn decode(bytes: &[u8]) -> Option { let (&kind, rest) = bytes.split_first()?; - let (session, row) = match kind { - MACHINE => (Session::Machine, rest), - LOGIN => { - let (id, row) = rest.split_first_chunk::<8>()?; - (Session::Login(u64::from_le_bytes(*id)), row) - } + let session = match kind { + MACHINE => Session::Machine, + LOGIN => Session::Login, _ => return None, }; + let row = rest; if row.is_empty() || row.len() > MAX_PROGRAM_NAME { return None; } @@ -114,14 +109,8 @@ impl fmt::Display for Refusal { } /// The session `target` starts in when `caller`, running in `session`, asks -/// for it, or why it does not start. `open` names a new login session, and is -/// called only for a listed target of a `login` caller. -pub fn may_start( - caller: &Program, - session: Session, - target: Target<'_>, - open: impl FnOnce() -> u64, -) -> Result { +/// for it, or why it does not start. +pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Result { let (listed, program) = match target { Target::Row(row) => (row.name.as_str(), row), Target::Package(row) => (APPS, row), @@ -130,10 +119,10 @@ pub fn may_start( return Err(Refusal::NotListed); } let session = match caller.login { - true => Session::Login(open()), + true => Session::Login, false => session, }; - if program.login_only() && !matches!(session, Session::Login(_)) { + if program.login_only() && session != Session::Login { return Err(Refusal::OutsideLogin); } Ok(session) @@ -173,44 +162,37 @@ mod tests { fn a_caller_starts_what_its_row_lists_and_swap_and_update_only_in_a_login_session() { let (ordinary, unlisted, own, package) = (row("ordinary"), row("unlisted"), caller(false), row("gbae")); let (swap, update) = (swap(), update()); - let login = Session::Login(7); - let opened = Session::Login(9); + let (machine, login) = (Session::Machine, Session::Login); use Refusal::*; #[rustfmt::skip] let table: [(bool, Session, Target, Result); 24] = [ - (false, Session::Machine, Target::Row(&ordinary), Ok(Session::Machine)), + (false, machine, Target::Row(&ordinary), Ok(machine)), (false, login, Target::Row(&ordinary), Ok(login)), - (false, Session::Machine, Target::Row(&unlisted), Err(NotListed)), + (false, machine, Target::Row(&unlisted), Err(NotListed)), (false, login, Target::Row(&unlisted), Err(NotListed)), - (false, Session::Machine, Target::Row(&own), Ok(Session::Machine)), + (false, machine, Target::Row(&own), Ok(machine)), (false, login, Target::Row(&own), Ok(login)), - (false, Session::Machine, Target::Package(&package), Ok(Session::Machine)), + (false, machine, Target::Package(&package), Ok(machine)), (false, login, Target::Package(&package), Ok(login)), - (false, Session::Machine, Target::Row(&swap), Err(OutsideLogin)), + (false, machine, Target::Row(&swap), Err(OutsideLogin)), (false, login, Target::Row(&swap), Ok(login)), - (false, Session::Machine, Target::Row(&update), Err(OutsideLogin)), + (false, machine, Target::Row(&update), Err(OutsideLogin)), (false, login, Target::Row(&update), Ok(login)), - (true, Session::Machine, Target::Row(&ordinary), Ok(opened)), - (true, login, Target::Row(&ordinary), Ok(opened)), - (true, Session::Machine, Target::Row(&unlisted), Err(NotListed)), + (true, machine, Target::Row(&ordinary), Ok(login)), + (true, login, Target::Row(&ordinary), Ok(login)), + (true, machine, Target::Row(&unlisted), Err(NotListed)), (true, login, Target::Row(&unlisted), Err(NotListed)), - (true, Session::Machine, Target::Row(&own), Ok(opened)), - (true, login, Target::Row(&own), Ok(opened)), - (true, Session::Machine, Target::Package(&package), Ok(opened)), - (true, login, Target::Package(&package), Ok(opened)), - (true, Session::Machine, Target::Row(&swap), Ok(opened)), - (true, login, Target::Row(&swap), Ok(opened)), - (true, Session::Machine, Target::Row(&update), Ok(opened)), - (true, login, Target::Row(&update), Ok(opened)), + (true, machine, Target::Row(&own), Ok(login)), + (true, login, Target::Row(&own), Ok(login)), + (true, machine, Target::Package(&package), Ok(login)), + (true, login, Target::Package(&package), Ok(login)), + (true, machine, Target::Row(&swap), Ok(login)), + (true, login, Target::Row(&swap), Ok(login)), + (true, machine, Target::Row(&update), Ok(login)), + (true, login, Target::Row(&update), Ok(login)), ]; for (i, (opens, session, target, want)) in table.into_iter().enumerate() { - let mut asked = false; - let got = may_start(&caller(opens), session, target, || { - asked = true; - 9 - }); - assert_eq!(got, want, "row {i}"); - assert_eq!(asked, opens && got.is_ok(), "row {i}: a login session opened for nothing"); + assert_eq!(may_start(&caller(opens), session, target), want, "row {i}"); } } @@ -221,19 +203,19 @@ mod tests { let none = row("none"); let (ordinary, package) = (row("ordinary"), row("gbae")); for target in [Target::Row(&ordinary), Target::Package(&package)] { - assert_eq!(may_start(&none, Session::Machine, target, || 1), Err(Refusal::NotListed)); + assert_eq!(may_start(&none, Session::Machine, target), Err(Refusal::NotListed)); } let apps_only = Program { starts: vec![APPS.into()], ..row("apps") }; - assert_eq!(may_start(&apps_only, Session::Machine, Target::Row(&ordinary), || 1), Err(Refusal::NotListed)); - assert_eq!(may_start(&apps_only, Session::Machine, Target::Package(&package), || 1), Ok(Session::Machine)); + assert_eq!(may_start(&apps_only, Session::Machine, Target::Row(&ordinary)), Err(Refusal::NotListed)); + assert_eq!(may_start(&apps_only, Session::Machine, Target::Package(&package)), Ok(Session::Machine)); } #[test] fn an_authority_reads_back_as_written() { for authority in [ Authority { row: "terminal".into(), session: Session::Machine }, - Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login(u64::MAX) }, - Authority { row: "sshserver".into(), session: Session::Login(1) }, + Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login }, + Authority { row: "s".into(), session: Session::Login }, ] { let bytes = authority.encode(); assert!(bytes.len() <= MAX_BADGE); @@ -245,16 +227,15 @@ mod tests { /// authority reads back as it. #[test] fn what_encode_cannot_have_written_is_refused() { - let whole = Authority { row: "shell".into(), session: Session::Login(3) }; + let whole = Authority { row: "shell".into(), session: Session::Login }; let bytes = whole.encode(); for end in 0..bytes.len() { assert_ne!(Authority::decode(&bytes[..end]).as_ref(), Some(&whole), "a prefix of {end} bytes"); } assert_eq!(Authority::decode(&[]), None); - assert_eq!(Authority::decode(&[LOGIN, 1, 0, 0, 0, 0, 0, 0]), None); assert_eq!(Authority::decode(&[2, b's']), None); assert_eq!(Authority::decode(&[MACHINE]), None); - assert_eq!(Authority::decode(&[LOGIN, 1, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(Authority::decode(&[LOGIN]), None); assert_eq!(Authority::decode(&[MACHINE, 0xff]), None); let mut long = vec![MACHINE]; long.extend(std::iter::repeat_n(b'x', MAX_PROGRAM_NAME + 1)); diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 0d1481e1679..ce31322f152 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -132,11 +132,9 @@ struct Pending { conn: Connection, rx: LaunchRx, since: Instant, - /// Which of the supervisor's two ports it came in on, which decides what its frame + /// Which of the supervisor's ports it came in on, which decides what its frame /// may ask for. port: Port, - /// Who asks, off the connection's badge: present exactly for [`Port::Launcher`]. - caller: Option, } /// A launch's caller: the row and session the badge on its connection names. @@ -145,9 +143,9 @@ struct Caller { session: Session, } -#[derive(Clone, Copy, PartialEq, Eq)] enum Port { - Launcher, + /// Who asks, off the connection's badge. + Launcher(Caller), Swap, Power, } @@ -414,7 +412,6 @@ fn main() { let mut supervisor = Supervisor { system, launcher, - logins: 0, syscap: &syscap, acceptors, connectors, @@ -516,8 +513,6 @@ struct Supervisor<'a> { system: &'static Manifest, /// What every launcher is minted on and every launch accepted from. launcher: Acceptor, - /// The login sessions opened so far: the last one's id. - logins: u64, syscap: &'a SysCap, /// The `serves` acceptors nobody has been started with yet, which a launch /// takes by move. @@ -941,9 +936,9 @@ impl<'a> Supervisor<'a> { // Accept and the request are two events. Nothing is read here. for (token, acceptor, port) in [ - (TOKEN_ACCEPTOR, &self.launcher, Port::Launcher), - (TOKEN_SWAP_ACCEPTOR, swap, Port::Swap), - (TOKEN_POWER_ACCEPTOR, power, Port::Power), + (TOKEN_ACCEPTOR, &self.launcher, (|s, c| s.caller(c).map(Port::Launcher)) as fn(&Self, &Connection) -> _), + (TOKEN_SWAP_ACCEPTOR, swap, |_, _| Ok(Port::Swap)), + (TOKEN_POWER_ACCEPTOR, power, |_, _| Ok(Port::Power)), ] { if !ready.contains(&token) { continue; @@ -960,17 +955,14 @@ impl<'a> Supervisor<'a> { ); continue; } - let caller = match port { - Port::Launcher => match self.caller(&conn) { - Ok(caller) => Some(caller), - Err(why) => { - say!("supervisor: launcher: dropping client {} — {why}", conn.as_handle().0); - continue; - } - }, - Port::Swap | Port::Power => None, + let port = match port(self, &conn) { + Ok(port) => port, + Err(why) => { + say!("supervisor: launcher: dropping client {} — {why}", conn.as_handle().0); + continue; + } }; - pending.push(Pending { conn, rx: LaunchRx::new(), since: Instant::now(), port, caller }); + pending.push(Pending { conn, rx: LaunchRx::new(), since: Instant::now(), port }); } // `remove` rather than `swap_remove`: the entries after `i` shift @@ -1005,9 +997,8 @@ impl<'a> Supervisor<'a> { RxStep::Frame { msg_type, payload_len } => { let p = pending.remove(i); match p.port { - Port::Launcher => { - let caller = p.caller.as_ref().expect("a launcher connection is kept only with its caller"); - self.serve_launch(&p.conn, caller, msg_type, p.rx.payload(payload_len)) + Port::Launcher(caller) => { + self.serve_launch(&p.conn, &caller, msg_type, p.rx.payload(payload_len)) } Port::Power => self.stop(&p.conn, msg_type), Port::Swap => { @@ -1553,23 +1544,16 @@ impl Supervisor<'_> { // caller may start it is asked between the two, so a refused launch // reads no image and judges no directory. let (system, path) = (self.system, request.program.to_string()); - let (row, session, opened) = (caller.row, caller.session, self.logins + 1); + let (row, session) = (caller.row, caller.session); let found = self.files("a launch's files", move || { - let resolved = resolve(system, &path); - let (target, path) = match &resolved { - Resolved::Row(program) => (Target::Row(program), &program.path), - Resolved::Package(program) => (Target::Package(program), &program.path), - Resolved::NotDeclared | Resolved::Refused(_) => return (resolved, None, Ok(command)), - }; - let verdict = authority::may_start(row, session, target, || opened); - let prepared = match verdict { - Ok(_) => command.image_from(Path::new(path)).prepare().map(drop), - Err(_) => Ok(()), - }; - let prepared = prepared.map(|()| command); - (resolved, Some(verdict), prepared) + resolve(system, &path, |target| -> Result<_, authority::Refusal> { + let session = 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); + Ok((session, prepared.map(|()| command))) + }) }); - let (resolved, verdict, prepared) = match found { + let resolved = match found { Ok(found) => found, Err(why) => { say!("supervisor: launcher: {} was not resolved: {why}", request.program); @@ -1577,11 +1561,11 @@ impl Supervisor<'_> { return; } }; - let program = match resolved { - Resolved::Row(row) => row, - Resolved::Package(row) => { + let (program, verdict) = match resolved { + Resolved::Row(row, verdict) => (row, verdict), + Resolved::Package(row, verdict) => { installed = row; - &installed + (&installed, verdict) } Resolved::NotDeclared => { // **`try_send_bytes` and not `send`.** A blocking write is the @@ -1600,17 +1584,14 @@ impl Supervisor<'_> { }; // **`MSG_REFUSED`, never `MSG_NOT_DECLARED`**: the latter is std's cue to // spawn the program itself. - let session = match verdict.expect("a resolved row is judged") { - Ok(session) => session, + let (session, prepared) = match verdict { + Ok(judged) => judged, Err(why) => { say!("{}", authority::refused(&caller.row.name, caller.session, &program.name, why)); let _ = conn.try_signal(launch::MSG_REFUSED); return; } }; - if session == Session::Login(opened) { - self.logins = opened; - } let command = match prepared { Ok(command) => command, Err(e) => { @@ -1715,11 +1696,11 @@ fn declared<'a>(system: &'a Manifest, path: &str) -> Option<&'a Program> { row(target.to_str()?) } -/// What a launch's path resolves to. -enum Resolved<'a> { - Row(&'a Program), +/// What a launch's path resolves to, a row with `judge`'s verdict on it. +enum Resolved<'a, V> { + Row(&'a Program, V), /// An installed package, whose row is the image's `[apps]` list. - Package(Program), + Package(Program, V), /// Nothing in the image declares it, and the caller spawns it itself. NotDeclared, /// A path under `/apps` whose package does not answer for it. @@ -1739,13 +1720,13 @@ enum Resolved<'a> { /// A path under `/apps` that no manifest answers for is refused rather than /// answered undeclared, because the caller's fallback for undeclared is a /// direct spawn carrying the caller's own namespace. -fn resolve<'a>(system: &'a Manifest, path: &str) -> Resolved<'a> { +fn resolve<'a, V>(system: &'a Manifest, path: &str, judge: impl FnOnce(Target<'_>) -> V) -> Resolved<'a, V> { if !package::is_canonical(path) { return Resolved::Refused(format!("{path:?} is not a canonical path")); } let Some(name) = package::package_of(path) else { return match declared(system, path) { - Some(row) => Resolved::Row(row), + Some(row) => Resolved::Row(row, judge(Target::Row(row))), None => Resolved::NotDeclared, }; }; @@ -1767,7 +1748,9 @@ fn resolve<'a>(system: &'a Manifest, path: &str) -> Resolved<'a> { installed.program )); } - Resolved::Package(system.app_row(name, path)) + let row = system.app_row(name, path); + let verdict = judge(Target::Package(&row)); + Resolved::Package(row, verdict) } /// The slot table's partition and the idle slot's two, claimed: the grant a From 3105c9a2d99830af40153f079538c4bc2937ae5f Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 06:33:59 +0200 Subject: [PATCH 4/7] toyos-manifest: the badge bound reads as clippy's int_plus_one asks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- toyos-manifest/src/launch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toyos-manifest/src/launch.rs b/toyos-manifest/src/launch.rs index 341a551b4ca..c93347b1cbb 100644 --- a/toyos-manifest/src/launch.rs +++ b/toyos-manifest/src/launch.rs @@ -54,7 +54,7 @@ pub struct Authority { const MACHINE: u8 = 0; const LOGIN: u8 = 1; -const _: () = assert!(1 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority must fit one badge"); +const _: () = assert!(MAX_PROGRAM_NAME < MAX_BADGE, "an authority, a kind byte and a row, must fit one badge"); impl Authority { /// The session's kind, then the row's key. From 1e232df1b971b327d91f0d918455db94b6803074 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 07:27:57 +0200 Subject: [PATCH 5/7] Answer review round 2: the shell, sshserver and toybox each list their own starts The owner's "Restrict" is per row: a program starts only the programs its own row lists. `[commands]` pulled a list in from outside three rows, coupled them so none could be narrowed alone, and was a second spelling of a row's `starts`. Each of the three rows now carries the list itself, and `[commands]`, `CommandsConfig`, `SystemConfig::starts`, `ProgramConfig::commands` and the fixture reaching the gate through them go. The rendered manifest is unchanged: each row's `starts` is the list it saw before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- src/build.rs | 25 ++----------------------- system.toml | 13 +++---------- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/src/build.rs b/src/build.rs index 03245f34200..c1c164badf8 100644 --- a/src/build.rs +++ b/src/build.rs @@ -121,24 +121,6 @@ struct SystemConfig { /// them, because a package directory is writable. #[serde(default)] apps: AppsConfig, - /// What a command a user names may start, which every row marked - /// `commands` starts beside its own `starts`. - #[serde(default)] - commands: CommandsConfig, -} - -impl SystemConfig { - /// Every row `program` may start through the launcher. - fn starts<'a>(&'a self, program: &'a ProgramConfig) -> impl Iterator { - let commands = if program.commands { &self.commands.starts[..] } else { &[] }; - program.starts.iter().chain(commands) - } -} - -#[derive(Deserialize, Default)] -#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] -struct CommandsConfig { - starts: Vec, } #[derive(Deserialize, Default)] @@ -191,8 +173,6 @@ struct ProgramConfig { restart: bool, /// The rows it may start through the launcher (`toyos_manifest::Program::starts`). starts: Vec, - /// It runs whatever command a user names, so it starts `[commands]`'s rows too. - commands: bool, /// A launch it makes opens a login session (`toyos_manifest::Program::login`). login: bool, } @@ -702,7 +682,7 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { /// a second one find gone. fn starts_name_what_a_launch_can_start(config: &SystemConfig) -> Result<(), String> { for (name, program) in &config.programs { - for key in config.starts(program) { + for key in &program.starts { if key == toyos_manifest::launch::APPS { continue; } @@ -748,7 +728,7 @@ fn render_manifest(config: &SystemConfig) -> Vec { service: cfg.service, roles: cfg.roles.clone(), restart: cfg.restart, - starts: config.starts(cfg).cloned().collect(), + starts: cfg.starts.clone(), login: cfg.login, } }) @@ -3240,7 +3220,6 @@ mod tests { assert!(gate("[programs.shell]\nstarts = [\"apps\"]\n").is_err()); assert!(gate("[programs.shell]\nstarts = [\"compositor\"]\n[programs.compositor]\nserves = [\"compositor\"]\n").is_err()); assert!(gate("[programs.shell]\nstarts = [\"fileserver\"]\n[programs.fileserver]\nroles = [\"data\"]\n").is_err()); - assert!(gate("[commands]\nstarts = [\"ghost\"]\n[programs.shell]\ncommands = true\n").is_err()); assert!(toml::from_str::("[programs.shell]\nstart = [\"toybox\"]\n").is_err()); } diff --git a/system.toml b/system.toml index 60b3fcf7244..148a012ad36 100644 --- a/system.toml +++ b/system.toml @@ -32,11 +32,6 @@ start = ["logkeeper", "diskserver", "fileserver", "compositor", "soundserver", " [apps] receives = ["compositor", "soundserver", "filepicker"] -# What a command a user names may start: every row a launch can start. A row -# marked `commands = true` runs whatever one names, and starts these. -[commands] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] - # `service = true` makes a row a system service: the supervisor starts it with `HOME` at # its own `/state/` and makes that directory first. Every other row runs # with the session user's home. @@ -91,7 +86,7 @@ devices = ["pci:1af4:1041"] [programs.sshserver] service = true receives = ["netstack"] -commands = true +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] login = true # `swap` is the authority to replace a running service's binary; the build @@ -132,7 +127,7 @@ starts = ["shell"] [programs.shell] receives = ["surface"] -commands = true +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] [programs.doom] receives = ["compositor", "soundserver"] @@ -164,12 +159,10 @@ receives = ["compositor", "filepicker"] # # `free` is the same syscall as `ps` and is named by neither: the machine header # is ambient and the process roster is not. -# -# `commands` for `stats`, which runs the command it is given. [programs.toybox] receives = ["compositor", "soundserver", "surface", "power"] syscap = ["roster"] -commands = true +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "toyos-ld", "sshserver", "swap", "update", "/apps"] # Symlinks created on ROOT at build time. # Keys are ROOT-relative; targets are the guest paths they resolve to. From 2c88cb002059544a88090a5607344419b34c946b Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 07:33:33 +0200 Subject: [PATCH 6/7] The local desktop is a login session: the owner's ruling, recorded The owner ruled "Desktop counts" on 2026-10-04: the local desktop is a login session, so `swap` and `update` run from its shell. The track records it among its rulings, and the open question it answered goes from the track and from the compositor's comment. The compositor's `login = true` stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- ...ry-program-sees-only-the-files-it-was-given.md | 15 ++++----------- system.toml | 4 +--- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/issues/isolation/every-program-sees-only-the-files-it-was-given.md b/issues/isolation/every-program-sees-only-the-files-it-was-given.md index f755e1ebd43..fc7b87957b9 100644 --- a/issues/isolation/every-program-sees-only-the-files-it-was-given.md +++ b/issues/isolation/every-program-sees-only-the-files-it-was-given.md @@ -55,6 +55,10 @@ was handed, so isolating people follows from isolating programs. `issues/filesystem/where-everything-lives.md` that `/tmp` is private per program. +**Ruled** (owner, 2026-10-04), **the local desktop, "Desktop counts"**: the +local desktop is a login session, so `swap` and `update` run from its shell. +The compositor's `login = true` in `system.toml` is the whole of it. + Unix permission bits and numeric user ids are rejected: they leave every path nameable by every program and make confused-deputy bugs structural. @@ -99,17 +103,6 @@ nameable by every program and make confused-deputy bugs structural. the stage exits. **Exit**: a hostile session can neither reach another session's connections nor deny it memory or processes. -## Open with the owner - -- **Whether the local desktop is a login session before a greeter - authenticates anybody.** Today it is: the compositor's `login = true` in - `system.toml` is the whole of it, so `swap` and `update` run from the - desktop's shell. Removing it confines both to SSH, which the development loop - already uses; the cost is that the shipping image's sshserver is itself - started from the desktop's shell, and until a session's view excludes - sshserver's key list any desktop program can log itself in over the network - regardless. - ## Known weaknesses - **A login session never ends.** A program launched in one keeps the session diff --git a/system.toml b/system.toml index 148a012ad36..ee83ec8e92e 100644 --- a/system.toml +++ b/system.toml @@ -55,9 +55,7 @@ serves = ["log"] # `starts` is what the desktop's taskbar and launcher start. `login = true` # makes the local desktop a login session, so `swap` and `update` run from its -# shell: whether it is one before a greeter authenticates anybody is the -# owner's open decision, and this line is the whole of it -# (`issues/isolation/every-program-sees-only-the-files-it-was-given.md`). +# shell. [programs.compositor] service = true serves = ["compositor"] From 65ffb58666dc365dfe0a7a7a4736ae0b9fc5c314 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 09:13:18 +0200 Subject: [PATCH 7/7] Answer review round 3: the desktop ruling is scoped to before a greeter, in the owner's words The owner answered whether the local desktop is a login session before a greeter authenticates anybody; the track recorded his "Desktop counts" without that scope, which read as settling stage 3's local greeter too. It now states the scope, quotes his reason, and names the premise that reason rests on: only the compositor, terminal, shell, toybox and sshserver rows list `starts`, and no `/apps` row does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- .../every-program-sees-only-the-files-it-was-given.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/issues/isolation/every-program-sees-only-the-files-it-was-given.md b/issues/isolation/every-program-sees-only-the-files-it-was-given.md index fc7b87957b9..1fbfd60cdd7 100644 --- a/issues/isolation/every-program-sees-only-the-files-it-was-given.md +++ b/issues/isolation/every-program-sees-only-the-files-it-was-given.md @@ -56,8 +56,14 @@ was handed, so isolating people follows from isolating programs. program. **Ruled** (owner, 2026-10-04), **the local desktop, "Desktop counts"**: the -local desktop is a login session, so `swap` and `update` run from its shell. -The compositor's `login = true` in `system.toml` is the whole of it. +local desktop is a login session before a greeter authenticates anybody, so +`swap` and `update` run from its shell. His words: "You can run swap/update +from the desktop shell, as today. After #709, untrusted desktop programs no +longer hold the launch right anyway, so excluding the desktop would mostly +just block you." That premise holds while only the compositor, terminal, +shell, toybox and sshserver rows list `starts` and no `/apps` row does. The +compositor's `login = true` in `system.toml` is the whole of it. Whether the +desktop stays one once stage 3's local greeter exists is not ruled. Unix permission bits and numeric user ids are rejected: they leave every path nameable by every program and make confused-deputy bugs structural.