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/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/every-program-sees-only-the-files-it-was-given.md b/issues/isolation/every-program-sees-only-the-files-it-was-given.md index 7ace38ef042..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 @@ -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 @@ -50,6 +55,16 @@ 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 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. @@ -94,6 +109,14 @@ 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. +## 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/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/issues/kernel/nothing-charges-kernel-memory-to-a-process.md b/issues/kernel/nothing-charges-kernel-memory-to-a-process.md index 77b4d34a871..b46fff99fc2 100644 --- a/issues/kernel/nothing-charges-kernel-memory-to-a-process.md +++ b/issues/kernel/nothing-charges-kernel-memory-to-a-process.md @@ -40,3 +40,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..739ee6aa2b3 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 { @@ -81,6 +84,21 @@ impl ConnectionEnd { 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 +107,7 @@ impl ConnectionEnd { inbox, outbox, reference: Held::new((rx, tx)), + stamp, }) } @@ -97,6 +116,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 d2f3ef0c66a..0e09231f779 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_counters, sys_device_inventory, sys_log_read, sys_reboot, sys_sched_info, sys_shutdown, sys_sysinfo, @@ -492,6 +492,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/rust b/rust index 724238524f4..f293615ad7a 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 724238524f4dcddc202ffa570cf034c512fe3e1d +Subproject commit f293615ad7aa162200b40426c159c505c449e182 diff --git a/src/build.rs b/src/build.rs index 65c9c8f7db5..c1c164badf8 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,40 @@ 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. +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")); + } + } + } + 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 +728,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 +3207,22 @@ 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!(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 ee8411d2bf3..ee83ec8e92e 100644 --- a/system.toml +++ b/system.toml @@ -53,11 +53,16 @@ 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. [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 +79,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. [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 @@ -113,10 +120,12 @@ receives = ["compositor"] [programs.terminal] provides = ["surface"] -receives = ["compositor", "launcher"] +receives = ["compositor"] +starts = ["shell"] [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"] @@ -151,6 +160,7 @@ receives = ["compositor", "filepicker"] [programs.toybox] receives = ["compositor", "soundserver", "surface", "power"] syscap = ["roster"] +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. 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..57bbdf480be 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -1,10 +1,17 @@ # 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; `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"] @@ -19,16 +26,26 @@ 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. [programs.toybox] syscap = ["roster"] +starts = ["swap"] [programs.shell] -receives = ["launcher"] +starts = ["toybox"] +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..3e10361eb16 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/launch_authority.rs @@ -0,0 +1,90 @@ +//! 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; +//! - `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. + +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 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; +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")); + } + } + } + + let from_machine = Command::new("/system/bin/toybox") + .args(["stats", "/system/bin/swap"]) + .output() + .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"); + 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..4f95dd06e55 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/port_badge.rs @@ -0,0 +1,123 @@ +//! 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, +//! 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. +//! - 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 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"; + +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" + ); + 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, through either accept, 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"); +} + +/// 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"); + 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 7fac3b67117..dc6c0e08b0e 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -110,6 +110,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", @@ -631,6 +638,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", @@ -821,12 +840,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 @@ -2692,6 +2712,28 @@ 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, 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 (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())); + } + } + 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 d63e214f3c3..746a73311cb 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -267,6 +267,15 @@ pub const SYS_DEVICE_DMA_UNMAP: u64 = 123; /// [`Rights::TRACE`]: crate::handle::Rights::TRACE pub const SYS_COUNTERS: u64 = 124; +/// 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 = 125; + +/// Read the badge a connection accepted from the caller's port was stamped +/// with. See [`port_badge`]. +pub const SYS_PORT_BADGE: u64 = 126; + /// 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. /// @@ -279,7 +288,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_COUNTERS < SYSCALL_PROFILE_OTHER as u64); +const _: () = assert!(SYS_PORT_BADGE < SYSCALL_PROFILE_OTHER as u64); pub const WNOHANG: u64 = 1; @@ -1837,6 +1846,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/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 new file mode 100644 index 00000000000..c93347b1cbb --- /dev/null +++ b/toyos-manifest/src/launch.rs @@ -0,0 +1,244 @@ +//! 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, 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 => f.write_str("a login session"), + } + } +} + +/// 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!(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. + pub fn encode(&self) -> Vec { + 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 + } + + /// `None` for bytes [`Self::encode`] cannot have written. + pub fn decode(bytes: &[u8]) -> Option { + let (&kind, rest) = bytes.split_first()?; + 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; + } + 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. +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), + }; + if !caller.starts.iter().any(|key| key == listed) { + return Err(Refusal::NotListed); + } + let session = match caller.login { + true => Session::Login, + false => session, + }; + if program.login_only() && 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 (machine, login) = (Session::Machine, Session::Login); + use Refusal::*; + #[rustfmt::skip] + let table: [(bool, Session, Target, Result); 24] = [ + (false, machine, Target::Row(&ordinary), Ok(machine)), + (false, login, Target::Row(&ordinary), Ok(login)), + (false, machine, Target::Row(&unlisted), Err(NotListed)), + (false, login, Target::Row(&unlisted), Err(NotListed)), + (false, machine, Target::Row(&own), Ok(machine)), + (false, login, Target::Row(&own), Ok(login)), + (false, machine, Target::Package(&package), Ok(machine)), + (false, login, Target::Package(&package), Ok(login)), + (false, machine, Target::Row(&swap), Err(OutsideLogin)), + (false, login, Target::Row(&swap), Ok(login)), + (false, machine, Target::Row(&update), Err(OutsideLogin)), + (false, login, Target::Row(&update), Ok(login)), + (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, 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() { + assert_eq!(may_start(&caller(opens), session, target), want, "row {i}"); + } + } + + /// 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), Err(Refusal::NotListed)); + } + let apps_only = Program { starts: vec![APPS.into()], ..row("apps") }; + 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 }, + Authority { row: "s".into(), session: Session::Login }, + ] { + 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 }; + 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(&[2, b's']), None); + assert_eq!(Authority::decode(&[MACHINE]), 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)); + assert_eq!(Authority::decode(&long), None); + } +} diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index c27a6241616..ccd3af63c12 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"; @@ -181,9 +190,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 { @@ -314,6 +335,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)?; @@ -400,6 +428,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}`"), } } @@ -419,9 +449,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 { @@ -456,7 +488,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..ce31322f152 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] = @@ -129,14 +132,20 @@ 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, } -#[derive(Clone, Copy, PartialEq, Eq)] +/// A launch's caller: the row and session the badge on its connection names. +struct Caller { + row: &'static Program, + session: Session, +} + enum Port { - Launcher, + /// Who asks, off the connection's badge. + Launcher(Caller), Swap, Power, } @@ -396,9 +405,13 @@ 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, syscap: &syscap, acceptors, connectors, @@ -415,10 +428,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 +436,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 +511,8 @@ 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, syscap: &'a SysCap, /// The `serves` acceptors nobody has been started with yet, which a launch /// takes by move. @@ -600,6 +611,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 +643,7 @@ impl<'a> Service<'a> { dirs, &[], storage, + (launcher, Session::Machine), Output::Boot(log), )?; kept.generation += 1; @@ -788,7 +801,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 +896,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,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, 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; @@ -940,9 +953,16 @@ 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 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 }); } // `remove` rather than `swap_remove`: the entries after `i` shift @@ -977,7 +997,9 @@ 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(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 +1036,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 +1180,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 +1354,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 +1374,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 +1431,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 +1540,20 @@ 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) = (caller.row, caller.session); 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(()), - }; - (resolved, prepared.map(|()| command)) + 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, prepared) = match found { + let resolved = match found { Ok(found) => found, Err(why) => { say!("supervisor: launcher: {} was not resolved: {why}", request.program); @@ -1523,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 @@ -1544,6 +1582,16 @@ impl Supervisor<'_> { return; } }; + // **`MSG_REFUSED`, never `MSG_NOT_DECLARED`**: the latter is std's cue to + // spawn the program itself. + 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; + } + }; let command = match prepared { Ok(command) => command, Err(e) => { @@ -1568,6 +1616,7 @@ impl Supervisor<'_> { &self.dirs, &extras, Storage::default(), + (&self.launcher, session), Output::Launch { log: &mut self.log, slots: &caller_slots }, ); match started { @@ -1647,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. @@ -1671,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, }; }; @@ -1699,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 @@ -1838,6 +1889,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 +1928,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 +2281,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());