From bfc9b979317ea3ffaf4bfabe3393af640c4f89ed Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 05:24:05 +0200 Subject: [PATCH] spawn: a plain spawn is a helper, and a launch is asked for std's Command and libc's posix_spawn routed every plain spawn to the launcher when the process held one, and spawned it directly when no row declared the program, with a HOME the supervisor answered. Which authority a child got therefore depended on whether the image happened to declare it. A spawn is now one of two kinds, decided once by `toyos::spawn::Spawn::start`: - a helper, by default: spawned here, holding this process's namespace, its environment as given and exactly the descriptors and endowments it names, never this process's launcher; - an independent launch, asked with `CommandExt::independent()`: the program's own row through the launcher this process holds, refused PermissionDenied with the reason where it cannot be made, never spawned as a helper instead. `detached()` replaces `under_supervisor()` and places the child under the launcher's owner. What a launch alone carries (a provided connector, a detached parent) is refused a helper, and what a helper alone carries (an endowment past the job, a descriptor past stdio, a named image) is refused a launch. `Start::Helper` and `Start::Launch` hold the one way to start each kind. The launcher answers an undeclared program, one its package does not answer for, or one the caller's row may not start `MSG_NOT_PERMITTED`; `MSG_NOT_DECLARED`, its HOME answer, `direct_env` and libc's start-up HOME tracking go. The launch path rule (`is_canonical`, `program_path`) moves to `toyos::launch`, where std makes an independent program absolute by it. Callers decide their kind: the terminal and the console launch their shell with its surface; the compositor's apps are launched detached; sshserver launches the shell for a session and runs an exec request as `shell -c`, so its splitter goes; the shell's bare commands are helpers, `launch ` is an independent launch with the shell's provided connectors and `detach ` a detached one; and `ps`, `shutdown`, `reboot`, `screen`, `inspect`, `swap` and `update` say "run it with `launch `" when they hold no connector. Also: - A launch carrying an image the caller holds, a boot image or one `prepare` read, is refused as one naming an image is (`image_held`). - The userland test gate counts a `cfg` only in a file that holds a test or declares a module, where alone it can hide one; sshserver's ToyOS launch is the leaf `src/spawn.rs`. - process_tree's missing program and depth chain run from /tmp, which the kernel opens itself, so the loader's refusal and the depth refusal are reached; a file server's refused connection, read as the kernel's path, is recorded. - A bare `ls` typed at the console is the shell's helper, spawned by its path, which the usb-key test waits for. - Issues: the oversized-launch and provided-name records go; the relative program left is a helper's; the process track's stage 3 says helper. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- Cargo.lock | 2 +- build/userlandhost.rs | 49 +- ...nt-and-a-parent-takes-its-children-down.md | 3 +- ...d-connection-reads-as-a-missing-program.md | 29 ++ ...ge-for-one-frame-becomes-a-direct-spawn.md | 32 -- ...d-name-cannot-reach-an-undeclared-child.md | 56 -- .../a-relative-program-is-refused-a-launch.md | 23 - ...ned-as-a-helper-is-passed-on-unresolved.md | 25 + ...-not-resolve-the-command-its-user-typed.md | 48 -- ...-runs-only-what-no-cheaper-tier-reaches.md | 6 +- ...h-server-is-toyos-own-and-russh-is-gone.md | 2 +- lib/manifest/Cargo.toml | 2 + lib/manifest/src/grants.rs | 6 +- lib/manifest/src/package.rs | 73 +-- sdk/libc/src/env.rs | 14 +- sdk/libc/src/spawn.rs | 22 +- sdk/std/os/process.rs | 67 ++- sdk/std/sys/process.rs | 40 +- sdk/toyos/src/launch.rs | 136 +++-- sdk/toyos/src/spawn.rs | 485 +++++++----------- system/compositor/src/session.rs | 2 +- system/console/src/main.rs | 1 + system/inspect/src/lib.rs | 2 +- system/inspect/src/main.rs | 2 +- system/shell/Cargo.toml | 2 - system/shell/src/main.rs | 98 ++-- system/sshserver/Cargo.toml | 3 + system/sshserver/src/command.rs | 182 ------- system/sshserver/src/connection.rs | 27 +- system/sshserver/src/main.rs | 6 +- system/sshserver/src/spawn.rs | 19 + system/supervisor/src/main.rs | 30 +- system/swap/src/main.rs | 2 +- system/terminal/src/main.rs | 1 + system/toybox/src/ps.rs | 2 +- system/toybox/src/reboot.rs | 5 +- system/toybox/src/screen.rs | 1 + system/toybox/src/shutdown.rs | 5 +- system/update/src/main.rs | 2 +- tests/guest/rust-tests/src/bin/app_view.rs | 23 +- .../guest/rust-tests/src/bin/launch_toctou.rs | 39 +- .../rust-tests/src/bin/launch_unsent_stdio.rs | 24 +- .../guest/rust-tests/src/bin/process_tree.rs | 26 +- .../guest/rust-tests/src/bin/scale_window.rs | 2 + tests/toyos.rs | 7 +- 45 files changed, 667 insertions(+), 966 deletions(-) create mode 100644 issues/a-file-servers-refused-connection-reads-as-a-missing-program.md delete mode 100644 issues/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md delete mode 100644 issues/a-provided-name-cannot-reach-an-undeclared-child.md delete mode 100644 issues/a-relative-program-is-refused-a-launch.md create mode 100644 issues/a-relative-program-spawned-as-a-helper-is-passed-on-unresolved.md delete mode 100644 issues/stats-does-not-resolve-the-command-its-user-typed.md delete mode 100644 system/sshserver/src/command.rs create mode 100644 system/sshserver/src/spawn.rs diff --git a/Cargo.lock b/Cargo.lock index 3032a1ba181..c6dae6f85d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2943,7 +2943,6 @@ version = "0.1.0" dependencies = [ "toyos", "toyos-abi", - "toyos-manifest", ] [[package]] @@ -3708,6 +3707,7 @@ dependencies = [ name = "toyos-manifest" version = "0.1.0" dependencies = [ + "toyos", "toyos-abi", ] diff --git a/build/userlandhost.rs b/build/userlandhost.rs index ae89e9865b1..af816b1bc05 100644 --- a/build/userlandhost.rs +++ b/build/userlandhost.rs @@ -42,9 +42,10 @@ //! host build most userland crates cannot make. So the reading is made to fail //! loudly: a test is gated only in the one shape a default `cargo test` in a //! crate is known to run. That is an attribute naming `test` or `test_case`, in -//! a file under the `src/` or `tests/` of the crate nearest it, in a crate whose -//! only `cfg` is `cfg(test)`, which ignores no test, and whose manifest switches -//! off no target's tests. +//! a file under the `src/` or `tests/` of the crate nearest it, in a crate that +//! ignores no test, whose manifest switches off no target's tests, and whose +//! only `cfg` in a file holding a test or declaring a module is `cfg(test)`. A +//! `cfg` in any other file has no test to compile out. //! //! Doc-tests are not read: a library crate that leaves `doctest` on is refused, //! because the `toyos` toolchain builds no rustdoc. @@ -106,8 +107,10 @@ pub fn survey(userland: &Path) -> Result { } let crate_name = rel(userland, owner); crates.insert(crate_name.clone()); - for why in &scan.conditions { - conditional.push((crate_name.clone(), file.clone(), *why)); + if scan.test || scan.module { + for why in &scan.conditions { + conditional.push((crate_name.clone(), file.clone(), *why)); + } } if !scan.test { continue; @@ -366,8 +369,10 @@ fn switched_off(doc: &toml::Value) -> Vec { struct Scan { /// An attribute names `test` or `test_case` as a word. test: bool, + /// A line declares a module, whose tests a condition on it could hide. + module: bool, /// Why a default `cargo test` might skip this crate's tests; binding only - /// if the crate is gated. + /// if the crate is gated and this file holds a test or declares a module. conditions: Vec<&'static str>, } @@ -426,6 +431,9 @@ fn scan(text: &str) -> Result { judge_attribute(&body, &mut out); rest = rest[end..].trim_start(); } + if attribute.is_none() && declares_module(rest) { + out.module = true; + } } match attribute { Some(_) => Err("an attribute that never closes, which hides the rest of the file"), @@ -433,6 +441,19 @@ fn scan(text: &str) -> Result { } } +/// Whether `code`, a line past its attributes, starts a module item: +/// `mod m;`, `pub(crate) mod m {`. +fn declares_module(code: &str) -> bool { + let mut code = code; + if let Some(after) = code.strip_prefix("pub") { + code = match after.trim_start().strip_prefix('(') { + Some(scoped) => scoped.split_once(')').map_or("", |(_, rest)| rest), + None => after, + }; + } + code.trim_start().strip_prefix("mod").is_some_and(|rest| rest.starts_with(char::is_whitespace)) +} + /// One attribute, whitespace and string contents removed: `#[cfg(test)]`, /// `#![doc=""]`. fn judge_attribute(body: &str, out: &mut Scan) { @@ -503,6 +524,8 @@ mod tests { put("gated/Cargo.toml", manifest); put("gated/src/lib.rs", test); put("gated/src/slow.rs", "#[test]\n#[ignore]\nfn slow() {}\n"); + put("gated/src/leaf.rs", "pub fn f() {\n #[cfg(target_os = \"toyos\")]\n g();\n}\n"); + put("gated/src/hides.rs", "#[cfg(target_os = \"toyos\")]\npub(crate) mod hidden;\n"); put("gated/build.rs", &format!("fn main() {{}}\n{escape}")); put("gated/examples/x.rs", &format!("fn main() {{}}\n{escape}")); put("gated/sub/Cargo.toml", manifest); @@ -534,6 +557,9 @@ mod tests { "gated/examples/x.rs: a test outside gated's src/ and tests/, which cargo \ test does not run" .into(), + "gated/src/hides.rs: a cfg other than cfg(test) can compile a test out of \ + the host run" + .into(), "gated/src/slow.rs: an ignored test runs nowhere".into(), "loose.rs: a test in no crate".into(), "switched/Cargo.toml: [lib] test = false leaves its tests unrun".into(), @@ -656,6 +682,17 @@ mod tests { assert!(scan("#[cfg(test)\nmod tests {}\n#[test]\nfn f() {}").is_err()); } + #[test] + fn a_module_item_is_a_module_and_a_word_like_it_is_not() { + let module = |text| scan(text).expect("closes").module; + for item in ["mod m;", "pub mod m;", "pub(crate) mod m {", "pub(in crate::a) mod m;", "#[cfg(x)] mod m;"] { + assert!(module(item), "{item}"); + } + for not in ["let model = 1;", "// mod m;", "fn modulo() {}", "pub fn f() {}", "#[cfg(x)]\nfn f() {}"] { + assert!(!module(not), "{not}"); + } + } + #[test] fn a_manifest_that_switches_tests_off_is_named() { let keys = |text: &str| switched_off(&text.parse().expect("TOML")).len(); diff --git a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index a417779fcb7..bad14fa26c4 100644 --- a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md +++ b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md @@ -103,8 +103,7 @@ an explicit force issues it, and never a timer's. Oracle: POSIX ``'s classes. 3. **Done**: **libc starts and waits for children**. `posix_spawn` and `posix_spawnp` with file actions and attributes start a child by - `toyos::spawn`, std's launch-or-spawn rule, which std's `Command` now uses - too. A C child starts with descriptors 0–2 and exactly what its file + `toyos::spawn`, as a helper, as std's `Command` does by default. A C child starts with descriptors 0–2 and exactly what its file actions name, a stated departure from POSIX, each endowed as `fd:`, which the child's descriptor table installs at its number (`toyos::fd`). A descriptor a process was started without is no diff --git a/issues/a-file-servers-refused-connection-reads-as-a-missing-program.md b/issues/a-file-servers-refused-connection-reads-as-a-missing-program.md new file mode 100644 index 00000000000..3a2885bc96d --- /dev/null +++ b/issues/a-file-servers-refused-connection-reads-as-a-missing-program.md @@ -0,0 +1,29 @@ +--- +status: open +kind: defect +opened: 2026-10-23 +--- + +# A file server's refused connection reads as a missing program + +`toyos::files::is_served` (`sdk/toyos/src/files.rs`) answers `false` for any +path whose route fails, a refusal included: `toyos_fs_is_served` +(`sdk/toyos/src/files/owner.rs`) answers the route's error, and `is_served` +reads every answer but `Ok(1)` as "the kernel's". So when a process's first +connection to the file server serving a path is refused, std's spawn takes +the kernel's path for a program on that server, and the kernel, which serves +no `/system`, answers `NotFound`. + +Seen on the T14 at `db6be8df1` and on `main` `35e930136`, `proctreecase`: +`process_tree`'s depth chain, each link a helper of the one before in the +job's share, stopped at link 31 with "its next link was refused: entity not +found", and the kernel logged `spawn: /system/bin/test_rs_process_tree: not +found`. SYSTEM's server serves one share at most `SERVED_SHARE` (32) clients +(`system/fileserver/src/bounds.rs`), and the 32nd process of that share to +connect is refused. The chain now runs from a copy on `/tmp`; the defect +stands for any program a share's 33rd process spawns off a file server. + +Exit: a refused connection reaches the spawn's caller as the server's +refusal, never as the kernel's `NotFound`; a guest arm spawns a `/system` +program from a process whose share is at the server's bound and reads that +refusal. diff --git a/issues/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md b/issues/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md deleted file mode 100644 index 70958ed9cb0..00000000000 --- a/issues/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-02 ---- - -# A launch too large for one frame becomes a direct spawn - -`toyos::launch::launch` (`sdk/toyos/src/launch.rs`) answers -`LaunchError::NotSent` when `Launch::encode` refuses the request, and -`Spawn::launch` (`sdk/toyos/src/spawn.rs`, `unmade`), which std's `Command` -spawn takes (`sdk/std/sys/process.rs`), answers `NotSent` with the direct spawn. -`encode` refuses a request whose header, program, argv, environment, working -directory and connector names do not fit `MAX_FRAME_LEN` -(`sdk/toyos/src/ipc.rs`). Its other refusal, too many connectors or slots, std -never reaches: it refuses the connectors itself, and a launch carries stdio -alone. - -So a declared program started with an argv and environment past one frame is -not launched. Its caller spawns it: it holds its caller's namespace and not its -manifest row, a connector the caller `provide`d does not reach it, and it -carries no `HOME` unless its caller named one. Neither side is told. A child -asked of the supervisor (`under_supervisor`) is refused `PermissionDenied` -instead. - -Evidence: by reading. No test sends a launch past one frame. - -Exit: a launch `encode` refuses answers its caller an error and starts nothing, -or the wire carries every argv and environment `SYS_SPAWN` takes; a test -launching a declared program with an environment past one frame reads that -error, or finds the child holding its row. Owner: the std lane, with -`sdk/toyos/src/launch.rs`. diff --git a/issues/a-provided-name-cannot-reach-an-undeclared-child.md b/issues/a-provided-name-cannot-reach-an-undeclared-child.md deleted file mode 100644 index a485e4b47b2..00000000000 --- a/issues/a-provided-name-cannot-reach-an-undeclared-child.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-08-10 ---- - -# A transferred connector cannot be merged into an inherited namespace - -`Command::provide(name, connector)` says "give the child this connector under -this name, on top of whatever else it holds". The launcher can do that: the -supervisor builds the child's namespace itself, from the manifest row plus the -extras the caller transferred (`system/supervisor/src/main.rs`, -`build_namespace`). - -The **direct** path still cannot, and the reason is no longer the ABI. - -**The ABI half is closed.** `NamespaceBuild::flags` carries -`NAMESPACE_KEEP_ALL` (`sdk/abi/src/syscall.rs`), `Builder::keep_all` is its -SDK spelling (`sdk/toyos/src/namespace.rs`), `sys_namespace_build` carries the -base's whole entry set when the bit is set and refuses a bit it does not define -(`kernel/src/syscall/ipc.rs`), and `endowment_denied`'s -`the_base_plus_one_more_name` asserts both halves in a guest. - -**What is left is the SDK's direct spawn.** std's `Command` spawn -(`sdk/std/sys/process.rs`) goes through `toyos::spawn::Spawn`, whose `direct` -(`sdk/toyos/src/spawn.rs`) endows the parent's namespace handle unchanged — it -duplicates it and pushes it under `SVC_LABEL` — and carries none of -`provided`, so a caller that transferred a connector to a program the -manifest does **not** declare, the one case where the launcher answers -`MSG_NOT_DECLARED` and the SDK falls back to the direct path, still hands the -child a name it cannot resolve. Neither side is told. - -It is unreached today only by coincidence: every extras-carrying caller in the -tree (the terminal and the console, both transferring `surface` to -`/system/bin/shell`) happens to go through the launcher, and the shell's own -children inherit `surface` because the supervisor merged it in. That is the kind -of safety that ends the moment somebody adds a caller. - -**And every shell child is past it, masked rather than absent.** -`build_command` (`system/shell/src/main.rs`) calls `Command::provide` for -*every* child the shell starts, so a child no `[programs]` row declares takes -the direct path and its provided `surface` connector is dropped. Nothing reds: -the inherited namespace already carries `surface`, so the child resolves the -name by the other route and the loss is invisible. - -## What closing it takes - -`Spawn::direct` building `keep_all(parent) + add(extras)` and endowing the -result as `svc`, in place of the bare duplicate: one function in -`sdk/toyos/src/spawn.rs`. - -The instrument is a guest arm: a binary the manifest does not declare, spawned -with a `provide`d connector by a caller that holds a `launcher` connector, must -resolve both that name and everything it inherited — and the same spawn without -the extra must still resolve the inherited set alone, so the arm is not passing -because the child was given a fresh namespace. diff --git a/issues/a-relative-program-is-refused-a-launch.md b/issues/a-relative-program-is-refused-a-launch.md deleted file mode 100644 index 6029b374d37..00000000000 --- a/issues/a-relative-program-is-refused-a-launch.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-20 ---- - -# A relative program path is refused a launch - -`toyos::spawn` asks the launcher with the program's path as the caller holds -it, and the supervisor refuses any path that is not canonical and absolute -(`lib/manifest/src/package.rs`'s `is_canonical`), answering `MSG_REFUSED`. So -under a parent that holds a launcher, `Command::new("./tool")` and -`posix_spawn` of `"./tool"` fail, where the same path spawned directly would -start. The shell makes what its user types absolute itself -(`system/shell/src/main.rs`, with `package::launch_path`, which sits beside -the gate's `is_canonical` in `lib/manifest/src/package.rs`); std and libc do -not. Read from the code, not -reproduced: no guest test spawns a relative path under a launcher. - -*Exit*: a relative program spawned under a parent that holds a launcher -starts, by std and by libc, made absolute against the caller's working -directory by one rule beside the supervisor's gate (`is_canonical`), and the -shell's own resolution goes. diff --git a/issues/a-relative-program-spawned-as-a-helper-is-passed-on-unresolved.md b/issues/a-relative-program-spawned-as-a-helper-is-passed-on-unresolved.md new file mode 100644 index 00000000000..c3b6db6797a --- /dev/null +++ b/issues/a-relative-program-spawned-as-a-helper-is-passed-on-unresolved.md @@ -0,0 +1,25 @@ +--- +status: open +kind: defect +opened: 2026-10-23 +--- + +# A relative program spawned as a helper is passed on unresolved + +std's `Command` makes an independent launch's program absolute against the +child's working directory (`toyos::launch::program_path`), but a helper's +program it passes on as the caller wrote it: `resolve_program` +(`sdk/std/sys/process.rs`) returns any path holding a `/` unchanged, and that +path is what the image is read from on a file server and what the kernel's +loader opens. libc's `posix_spawn` and toybox's `stats` pass theirs the same +way. The shell is the one caller that makes what its user typed absolute +itself (`build_command`, `system/shell/src/main.rs`), for every command. + +So `stats ./foo`, and a C or Rust program spawning `./foo`, may be refused +where the shell's `./foo` starts. Read from the code, not reproduced: no +guest test spawns a relative path as a helper. + +Exit: a helper's relative program is made absolute against the child's +working directory by `toyos::spawn`, the one rule std and libc share, and the +shell's own call goes; a guest probe spawns `./foo` from a working directory by +std, by libc and through `stats`, and reads each child's output back. diff --git a/issues/stats-does-not-resolve-the-command-its-user-typed.md b/issues/stats-does-not-resolve-the-command-its-user-typed.md deleted file mode 100644 index 882d3973ff0..00000000000 --- a/issues/stats-does-not-resolve-the-command-its-user-typed.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-05 ---- - -# `stats` does not resolve the command its user typed, so a relative one is refused - -`system/toybox/src/stats.rs:12` spawns what it was given, unchanged: - -``` - let mut child = Command::new(&args[0]) - .args(&args[1..]) -``` - -`args[0]` is a path its user typed, and `/system/bin/init`'s launcher refuses a -launch path that is not already canonical. So `stats ./foo`, `stats sub/foo` and -`stats ../bin/foo` answer - - stats: failed to spawn ./foo: ... - -with `init: launcher: "./foo" is not a canonical path` in the log, while -`stats /system/bin/foo` and `stats foo` both work — the second because a bare -name goes through std's `PATH` walk, which hands init an absolute path. - -It is the one program left that takes a path from a user and spawns it without -resolving: `/system/bin/shell` is the other, and does. - -## The fix - -`toyos_manifest::package::launch_path(&args[0], &cwd)`, the same call -`system/shell/src/main.rs` makes, with `cwd` from `env::current_dir()` — a -launch carries a working directory and init applies it, so `stats` has the one -its caller gave it. `toybox` would take `toyos-manifest` as a dependency, which -`shell` and `init` already do. - -## Reproduction - -From a shell, in a directory holding a runnable `foo`: `stats ./foo`. - -The refusal's wording is measured — `pkg_install_gbae`'s symlink probe reads -that line back for four spellings — and that `stats` passes `args[0]` through is -read from the source above; the two have not been put together in a run. - -## Exit condition - -`stats ./foo` runs `./foo`, and a guest probe spawns `stats` with a -`current_dir` and a relative command and reads the child's output back. diff --git a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md index f9f00eefeed..c3531cf3443 100644 --- a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md +++ b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md @@ -199,10 +199,8 @@ Host: `toyos-net-tcp`, `toyos-dns`, `toyos-mdns`, `toyos-swap`, `toyos-inspect`, - metal `sshd_exec`, the arms `lan_talk`'s one command did not reach, each a step of that row's exchange red when its arm in `userland/sshd/src/main.rs` is reverted; the row and its exchange are deleted, and this waits on `issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`: - - a program that is not there ends 127, `cannot run /system/bin/` on stderr and nothing - on stdout; - - an unquotable line ends 127 before anything runs; `command::split`'s own tests already hold - the refusal it names; + - a program that is not there ends 127, the shell saying `: not found`; + - an unquotable line is the shell's to refuse, its `-c` line read as the shell reads any; - stdout and stderr stay apart: `cat` of a file and of a missing path; - the channel's input is the program's stdin; - an `env` request is answered with a failure, never left unanswered; diff --git a/issues/the-ssh-server-is-toyos-own-and-russh-is-gone.md b/issues/the-ssh-server-is-toyos-own-and-russh-is-gone.md index d7260208a54..d0e38fe7350 100644 --- a/issues/the-ssh-server-is-toyos-own-and-russh-is-gone.md +++ b/issues/the-ssh-server-is-toyos-own-and-russh-is-gone.md @@ -42,7 +42,7 @@ Compromises recorded on their own: `Server` or the connection layer until stage 2. 2. **The switch and the deletion.** `system/sshserver` is rewritten on `toyos-ssh` with `std` threads: a shell over pipes, `subsystem sftp` over - `sftp.rs`, `exec` through `command.rs`, an `authorized_keys` reader in + `sftp.rs`, `exec` as `shell -c` (#1024), an `authorized_keys` reader in `toyos-ssh`, several channels on one connection, a login deadline and a cap on unauthenticated connections. The recorded shell, sftp and two-channel sessions replay against `toyos-ssh`'s scripted driver, whose SFTP server is diff --git a/lib/manifest/Cargo.toml b/lib/manifest/Cargo.toml index 7126e4555b4..13315eee1c3 100644 --- a/lib/manifest/Cargo.toml +++ b/lib/manifest/Cargo.toml @@ -10,3 +10,5 @@ license = "MIT OR Apache-2.0" # would be a config the build system accepts and the supervisor cannot act on. [dependencies] toyos-abi = { path = "../../sdk/abi" } +# `toyos::launch::is_canonical`, the one rule a launch path is held to. +toyos = { path = "../../sdk/toyos" } diff --git a/lib/manifest/src/grants.rs b/lib/manifest/src/grants.rs index 0f96168f25f..eeadc1569cb 100644 --- a/lib/manifest/src/grants.rs +++ b/lib/manifest/src/grants.rs @@ -30,6 +30,8 @@ use std::fmt; +use toyos::launch::is_canonical; + use crate::package::{self, DIGEST_LEN}; use crate::{session_home, MAX_PROGRAM_NAME}; @@ -120,7 +122,7 @@ pub enum Answer { /// one it may be. pub fn folder(path: &str) -> Result<(), String> { let home = session_home(); - if path.contains(char::is_control) || !package::is_canonical(path) { + if path.contains(char::is_control) || !is_canonical(path) { return Err(format!("{path:?} is not a canonical path")); } if path == home { @@ -183,7 +185,7 @@ pub fn decide(stored: Option<&Entry>, binary: &str, ceiling: Option, at_ /// own where it lies inside the folder, and the folder's root otherwise. pub fn cwd(folder: &str, caller: &str) -> String { let inside = caller == folder || caller.strip_prefix(folder).is_some_and(|rest| rest.starts_with('/')); - match inside && package::is_canonical(caller) { + match inside && is_canonical(caller) { true => caller.to_string(), false => folder.to_string(), } diff --git a/lib/manifest/src/package.rs b/lib/manifest/src/package.rs index 039fc092c7f..278b6543b9c 100644 --- a/lib/manifest/src/package.rs +++ b/lib/manifest/src/package.rs @@ -134,41 +134,6 @@ pub fn listed(entries: &[(String, String)]) -> Vec { out } -/// Whether `path` is already the string every path syscall will act on. -/// -/// The kernel normalizes before it opens anything (`kernel/src/vfs.rs`'s -/// `normalize` drops an empty component and `.`, and pops on `..`), so a path -/// meaning something else after that is one nothing can both classify and -/// spawn: `/apps/./x` is outside `/apps` to [`package_of`] and inside it to -/// every syscall. -pub fn is_canonical(path: &str) -> bool { - path.starts_with('/') - && !path.split('/').skip(1).any(|part| part.is_empty() || part == "." || part == "..") -} - -/// What a shell hands the launcher for a program its user typed: a bare name -/// left alone for `PATH`, anything with a `/` made absolute against `cwd` and -/// normalized the way the kernel does. It lives beside [`is_canonical`] because -/// a resolver that drifted from the gate would refuse every relative command. -pub fn launch_path(program: &str, cwd: &str) -> String { - if !program.contains('/') { - return program.to_string(); - } - let joined = - if program.starts_with('/') { program.to_string() } else { format!("{cwd}/{program}") }; - let mut parts: Vec<&str> = Vec::new(); - for part in joined.split('/') { - match part { - "" | "." => {} - ".." => { - parts.pop(); - } - other => parts.push(other), - } - } - if parts.is_empty() { String::from("/") } else { format!("/{}", parts.join("/")) } -} - /// The package a launch path belongs to, or `None` for a path outside `/apps`. /// The one segment after `/apps`, so `..` and an empty segment answer `None` /// rather than resolving to some other package's row. @@ -225,6 +190,8 @@ fn value(key: &'static str, text: &str) -> Result<(), String> { #[cfg(test)] mod tests { + use toyos::launch::is_canonical; + use super::*; fn sample() -> Package { @@ -280,43 +247,13 @@ mod tests { assert!(check_digest(&upper).is_err()); } - /// Checked against the gate itself, not a shape written down twice. - #[test] - fn what_a_shell_resolves_is_what_the_launcher_accepts() { - let cwd = "/home/toy/reltest"; - for (typed, want) in [ - ("./foo", "/home/toy/reltest/foo"), - ("../bin/foo", "/home/toy/bin/foo"), - ("./a/./b/../c", "/home/toy/reltest/a/c"), - ("sub/foo", "/home/toy/reltest/sub/foo"), - ("/system/bin/toybox", "/system/bin/toybox"), - ("/system//bin/./toybox", "/system/bin/toybox"), - ("/../system/bin/toybox", "/system/bin/toybox"), - ] { - let got = launch_path(typed, cwd); - assert_eq!(got, want, "{typed:?} from {cwd:?}"); - assert!(is_canonical(&got), "{typed:?} resolved to {got:?}, which the supervisor refuses"); - } - // A bare name is `PATH`'s, and that rule is left where it was. - for bare in ["foo", "echo", "toybox"] { - assert_eq!(launch_path(bare, cwd), bare); - } - } - /// The four `package_of` answers `None` for while every path syscall lands - /// them inside `/apps` — the hole the gate above closes. + /// them inside `/apps`: the hole `is_canonical` closes. #[test] - fn a_path_the_normalizer_would_change_is_not_canonical_and_classifies_as_nothing() { - assert!(is_canonical("/apps/toy/echo")); - assert!(is_canonical("/system/bin/toybox")); - for bad in - ["/apps/./toy/echo", "/apps//toy/echo", "apps/toy/echo", "/tmp/../apps/toy/echo", - "/apps/toy/echo/", "./echo", "", "/"] - { - assert!(!is_canonical(bad), "{bad:?} passed as canonical"); - } + fn a_path_the_normalizer_would_change_classifies_as_nothing() { for e in ["/apps/./toy/echo", "/apps//toy/echo", "apps/toy/echo", "/tmp/../apps/toy/echo"] { + assert!(!is_canonical(e), "{e:?} passed as canonical"); assert_eq!(package_of(e), None, "{e:?} classified as a package"); } } diff --git a/sdk/libc/src/env.rs b/sdk/libc/src/env.rs index b7c8bca1783..2dd0cba0f14 100644 --- a/sdk/libc/src/env.rs +++ b/sdk/libc/src/env.rs @@ -17,9 +17,6 @@ struct Env { strings: Vec<*mut u8>, /// `strings` and a null, which `environ` points at. array: Vec<*mut u8>, - /// The `HOME` entry this process started with, `KEY=VALUE` without the - /// `NUL`, which no child spawned here is handed (`toyos::spawn::direct_env`). - home: Option>, } // SAFETY: the pointers are this module's own strings, read and changed under the lock. @@ -63,9 +60,7 @@ impl Env { block.resize(need, 0); } let strings: Vec<*mut u8> = block.split(|&b| b == 0).filter(|e| !e.is_empty()).map(leak).collect(); - // SAFETY: every one is a string just made. - let home = strings.iter().map(|&s| unsafe { entry(s) }).find(|e| is(e, b"HOME")).map(Box::from); - let mut env = Env { strings, array: Vec::new(), home }; + let mut env = Env { strings, array: Vec::new() }; env.publish(); env } @@ -169,13 +164,6 @@ pub(crate) fn blob() -> Vec { }) } -/// Whether the blob `env` names its `HOME`: a `HOME` other than the one this -/// process started with, or none where it started with one. -pub(crate) fn names_home(env: &[u8]) -> bool { - let home = env.split(|&b| b == 0).find(|e| is(e, b"HOME")); - with(|this| home != this.home.as_deref()) -} - /// The first file named `name` under a directory of `PATH`, with its `NUL`: /// `/system/bin` where there is no `PATH`. pub(crate) fn find_in_path(name: &[u8]) -> Option> { diff --git a/sdk/libc/src/spawn.rs b/sdk/libc/src/spawn.rs index eff45edab3c..7bbe9339100 100644 --- a/sdk/libc/src/spawn.rs +++ b/sdk/libc/src/spawn.rs @@ -5,8 +5,7 @@ //! **A child is the `Process` handle its spawn answered**, which this library //! keeps under its pid until it is waited for; a pid names nothing else here, //! and `kill` and `waitpid` reach only this process's own children. A child -//! starts by `toyos::spawn`'s rule, as std's do: launched where the -//! supervisor declares it, spawned here otherwise. It starts with descriptors +//! is a helper (`toyos::spawn`), as a plain std spawn is. It starts with descriptors //! 0–2 and exactly what its file actions name (`spawnreq`). A child's group is //! a number this library keeps, which `kill(-pgid, …)` and `waitpid(-pgid, //! …)` read; the kernel has none. @@ -25,7 +24,7 @@ use core::sync::atomic::{AtomicUsize, Ordering}; use toyos::fd::Kind; use toyos::process::{End, Process}; -use toyos::spawn::{self, Launched, Parent, Refused, Spawn}; +use toyos::spawn::{Parent, Refused, Spawn, Start}; use toyos_abi::syscall; use toyos_abi::RawHandle; @@ -322,9 +321,8 @@ fn spawn_counted(path: &str, argv: &[u8], env: &[u8], actions: &[Action Result { let mut cwd = alloc::vec![0u8; syscall::MAX_PATH]; let n = syscall::getcwd(&mut cwd); @@ -332,6 +330,7 @@ fn start(path: &str, argv: &[u8], env: &[u8], fds: &[(u32, RawHandle, Kind)]) -> let request = Spawn { program: path, image: None, + image_held: false, argv, env, cwd, @@ -339,21 +338,18 @@ fn start(path: &str, argv: &[u8], env: &[u8], fds: &[(u32, RawHandle, Kind)]) -> endow: &[], provided: &[], parent: Parent::Caller, + independent: false, }; - let mut answer = alloc::vec![0u8; toyos::ipc::MAX_FRAME_LEN as usize]; - let home = match request.launch(&mut answer).map_err(errno_of)? { - Launched::Started(process) => return Ok(process), - Launched::Direct { home } => home, + let Start::Helper(helper) = request.start().map_err(errno_of)? else { + unreachable!("a spawn that is not independent is a helper or refused") }; - let mut direct = Vec::new(); - spawn::direct_env(env, crate::env::names_home(env), home, &mut direct); // A program on a file server is read here into an image the kernel pages // the child from: the kernel opens only what it serves itself. let image = match toyos::files::is_served(path.as_bytes()) { true => Some(toyos::files::read_image(path.as_bytes()).map_err(crate::errcode::of_image)?), false => None, }; - request.direct(&direct, image.as_ref()).map_err(errno_of) + helper.spawn(image.as_ref()).map_err(errno_of) } fn errno_of(refused: Refused) -> i32 { diff --git a/sdk/std/os/process.rs b/sdk/std/os/process.rs index fdf9f612526..8d0901cbcf4 100644 --- a/sdk/std/os/process.rs +++ b/sdk/std/os/process.rs @@ -35,18 +35,16 @@ pub trait CommandExt { #[stable(feature = "toyos_ext", since = "1.0.0")] fn endow(&mut self, label: &str, handle: u32) -> &mut Self; - /// Put `connector` in the child's namespace under `name`, on top of what - /// the manifest says the child holds. + /// Put `connector` in an [`independent`](CommandExt::independent) + /// child's namespace under `name`, on top of what its manifest row holds. /// - /// **This is a launch, not a spawn.** A terminal's `surface` port exists - /// once per terminal, so the supervisor cannot know it and the manifest cannot - /// name it — but the shell's own `[programs]` row is what should decide the - /// rest of what a shell holds. So the caller supplies this one connector, - /// the supervisor supplies the row, and the child's namespace is the union. + /// A terminal's `surface` port exists once per terminal, so no manifest + /// can name it, but the shell's own row is what should decide the rest of + /// what a shell holds. So the caller supplies this one connector, the + /// launcher supplies the row, and the child's namespace is the union. /// - /// The connector is **moved**, like [`endow`](CommandExt::endow), and the - /// spawn fails if this process holds no `launcher` connector: there is - /// nowhere else the manifest row can come from. + /// The connector is **moved**, like [`endow`](CommandExt::endow). A + /// command that is not independent is refused `InvalidInput`. #[stable(feature = "toyos_ext", since = "1.0.0")] fn provide(&mut self, name: &str, connector: u32) -> &mut Self; @@ -72,9 +70,9 @@ pub trait CommandExt { /// stays the child's `argv[0]`: the kernel opens `path`, or the spawn reads /// it when a file server serves it, and nothing opens `argv[0]`. /// - /// **A spawn, never a launch.** The launcher runs the program its manifest - /// row names, so with [`under_supervisor`](CommandExt::under_supervisor) or - /// [`provide`](CommandExt::provide) the spawn answers `PermissionDenied`. + /// **A helper's alone.** The launcher runs the program its manifest row + /// names, so an [`independent`](CommandExt::independent) command that + /// names an image is refused `InvalidInput`. #[stable(feature = "toyos_ext", since = "1.0.0")] fn image_from(&mut self, path: &crate::path::Path) -> &mut Self; @@ -90,22 +88,34 @@ pub trait CommandExt { /// /// `place` is a handle carrying `WRITE` to that process: a copy of the /// handle it holds to itself, which it handed on. The spawn is refused - /// `PermissionDenied` for a handle without `WRITE`, or without `DUP` for a - /// program the supervisor launches, which carries a copy, and `BrokenPipe` for a + /// `PermissionDenied` for a handle without `WRITE`, or without `DUP` for an + /// independent launch, which carries a copy, and `BrokenPipe` for a /// process whose end has begun. #[stable(feature = "toyos_ext", since = "1.0.0")] fn under(&mut self, place: u32) -> &mut Self; - /// Ask the supervisor to be the child's parent: the one way for a child to outlive - /// this process. + /// Start the child as an **independent launch** through the session + /// launcher this process holds: the program its manifest row declares, + /// holding that row's authority, its own identity and the connectors + /// [`provide`](CommandExt::provide)d. Without it a spawn is a helper, + /// which inherits this process's namespace, environment and the + /// descriptors its spawn names. /// - /// **A launch or nothing.** The supervisor starts a program its manifest declares, - /// holding what its row says, so with no `launcher` connector, for a - /// program no row declares, or for a command that endows a handle or names - /// a descriptor beyond stdio, the spawn answers `PermissionDenied` and starts - /// nothing. + /// **A launch or nothing.** It is refused `PermissionDenied`, with the + /// reason, where this process holds no launcher, the launcher will not + /// open, or no row declares the program or this one may not start it; and + /// `InvalidInput` for an endowment other than a job, a descriptor past + /// stdio or an [`image_from`](CommandExt::image_from). It is never + /// started as a helper instead. A relative program is made absolute + /// against the child's working directory. #[stable(feature = "toyos_ext", since = "1.0.0")] - fn under_supervisor(&mut self) -> &mut Self; + fn independent(&mut self) -> &mut Self; + + /// Place an [`independent`](CommandExt::independent) child under the + /// launcher's owner, so that it outlives this process. A command that is + /// not independent is refused `InvalidInput`. + #[stable(feature = "toyos_ext", since = "1.0.0")] + fn detached(&mut self) -> &mut Self; } #[stable(feature = "toyos_ext", since = "1.0.0")] @@ -154,8 +164,13 @@ impl CommandExt for crate::process::Command { self } - fn under_supervisor(&mut self) -> &mut Self { - self.as_inner_mut().under_supervisor(); + fn independent(&mut self) -> &mut Self { + self.as_inner_mut().independent(); + self + } + + fn detached(&mut self) -> &mut Self { + self.as_inner_mut().detached(); self } } @@ -192,7 +207,7 @@ pub trait ChildExt { /// Give up this process's handle, for one about to be sent or endowed. /// /// After this the parent no longer holds the child: it cannot wait for it, - /// kill it or read its accounting. The supervisor's launcher is the caller — + /// kill it or read its accounting. The launcher is the caller — /// it answers with the handle and keeps none, because a process that could /// ask it to start `/bin/true` in a loop would otherwise exhaust the one /// handle table the whole machine depends on. diff --git a/sdk/std/sys/process.rs b/sdk/std/sys/process.rs index b895af72b8d..6c3bc26a6e8 100644 --- a/sdk/std/sys/process.rs +++ b/sdk/std/sys/process.rs @@ -27,6 +27,8 @@ pub struct Command { endowments: Vec<(String, u32)>, provided: Vec<(String, u32)>, parent: toyos::spawn::Parent, + /// An independent launch, where a plain spawn is a helper. + independent: bool, /// The program the child runs, when it is not the one `argv[0]` names. image_path: Option, prepared: Option, @@ -83,6 +85,7 @@ impl Command { endowments: Vec::new(), provided: Vec::new(), parent: toyos::spawn::Parent::Caller, + independent: false, image_path: None, prepared: None, image: None, @@ -214,10 +217,8 @@ impl Command { self.endowments.push((label.to_owned(), handle)); } - /// Give the child `connector` under `name`, on top of its manifest row. - /// - /// Routes the spawn through the launcher, which is the only thing that can - /// build a child's authority out of the child's own declaration. + /// Give the child `connector` under `name`, on top of its manifest row: + /// an independent launch's alone. pub fn provide(&mut self, name: &str, connector: u32) { self.provided.push((name.to_owned(), connector)); } @@ -228,9 +229,14 @@ impl Command { self.parent = toyos::spawn::Parent::Place(toyos_abi::RawHandle(place)); } - /// Ask the supervisor to be the child's parent, through the launcher or not at all. - pub fn under_supervisor(&mut self) { - self.parent = toyos::spawn::Parent::Supervisor; + /// Start the child as an independent launch, through the launcher. + pub fn independent(&mut self) { + self.independent = true; + } + + /// Place a launched child under the launcher's owner, so it outlives this process. + pub fn detached(&mut self) { + self.parent = toyos::spawn::Parent::Detached; } fn resolve_program(&self) -> io::Result { @@ -331,9 +337,12 @@ impl Command { self.endowments.iter().map(|(label, h)| (label.as_str(), toyos_abi::RawHandle(*h))).collect(); let provided: Vec<(&str, toyos_abi::RawHandle)> = self.provided.iter().map(|(name, h)| (name.as_str(), toyos_abi::RawHandle(*h))).collect(); + // A launch names its program absolute, as the launcher resolves it. + let launched = self.independent.then(|| toyos::launch::program_path(program, &cwd)); let request = toyos::spawn::Spawn { - program, + program: launched.as_deref().unwrap_or(program), image: image_path, + image_held: prepared_image.is_some() || self.boot_image.is_some(), argv: &argv_buf, env: &env_buf, cwd: &cwd, @@ -341,19 +350,16 @@ impl Command { endow: &endow, provided: &provided, parent: self.parent, + independent: self.independent, }; let pipes = |stdin, stdout, stderr| StdioPipes { stdin, stdout, stderr }; - let mut answer = crate::vec![0u8; toyos::ipc::MAX_FRAME_LEN as usize]; - let home = match request.launch(&mut answer).map_err(refused)? { - toyos::spawn::Launched::Started(handle) => { + let helper = match request.start().map_err(refused)? { + toyos::spawn::Start::Launch(launch) => { + let handle = launch.launch().map_err(refused)?; return Ok((Process { handle }, pipes(stdin_pipe, stdout_pipe, stderr_pipe))); } - toyos::spawn::Launched::Direct { home } => home, + toyos::spawn::Start::Helper(helper) => helper, }; - // A caller that named `HOME`, or removed it, decided it. - let named = self.env.iter().any(|(key, _)| key == OsStr::new("HOME")); - let mut direct_env = Vec::new(); - toyos::spawn::direct_env(&env_buf, named, home, &mut direct_env); // A program on a file server is read here, into a memory object of // this process's own that the kernel pages the child from: the kernel // opens only what it serves itself. @@ -372,7 +378,7 @@ impl Command { (None, None) if crate::sys::fs::is_served(source) => Some(crate::sys::fs::read_image(source)?), (None, None) => None, }; - let spawned = request.direct(&direct_env, image.as_ref()); + let spawned = helper.spawn(image.as_ref()); // The child keeps the object alive; this process's handle, and with it // its own mapping, goes. drop(image); diff --git a/sdk/toyos/src/launch.rs b/sdk/toyos/src/launch.rs index 9bc5afe583c..f57b7c07ee3 100644 --- a/sdk/toyos/src/launch.rs +++ b/sdk/toyos/src/launch.rs @@ -1,4 +1,5 @@ -//! Asking the supervisor to start a declared program. +//! Asking the session launcher to start a declared program: an independent +//! launch. //! //! **`SYS_SPAWN` can only give a child what the caller holds, and that is not //! enough.** doom is started from a shell and doom is to hold sound; the shell @@ -20,12 +21,15 @@ //! //! **A launch names its parent**, by one of two words: a place, which is a //! handle to a process carrying `WRITE` — the caller's own `self`, duplicated — -//! that the supervisor spawns the program under, so the caller's end takes it down; or -//! the supervisor, the one way for a program to outlive whoever started it. +//! that the program is spawned under, so the caller's end takes it down; or +//! detached, under the launcher's owner, the one way for a program to outlive +//! whoever started it. //! //! The wire is a single frame plus one handle batch, and this module is both //! halves of it — std's `Command` encodes and the supervisor decodes. +use alloc::string::String; + use toyos_abi::syscall::SyscallError; use crate::fd::Kind; @@ -43,12 +47,10 @@ pub const LAUNCHER: &str = "launcher"; pub const MSG_LAUNCH: u32 = 1; /// It started. One `Process` handle travels with this. pub const MSG_LAUNCHED: u32 = 2; -/// No `[programs]` row names that program, so the supervisor has nothing to build its -/// authority from. The caller spawns it directly instead, which gets -/// inheritance — the right answer for a binary the image did not declare. -/// The payload is the `HOME` the supervisor decides for it, which that spawn carries in -/// place of the caller's own: a location is the supervisor's to hand out, not a parent's. -pub const MSG_NOT_DECLARED: u32 = 3; +/// Not permitted: no row declares the program, its package does not answer +/// for it, or the caller's row may not start it. The reason is in the +/// launcher's log. +pub const MSG_NOT_PERMITTED: u32 = 3; /// It is declared and did not start: no such file, a full table, a refused /// endowment. The reason is in the supervisor's log, not in this frame — a caller can do /// nothing differently about any of them. @@ -95,8 +97,8 @@ const fn kind_of(word: u32) -> Option { /// The header's last word for a launch that names a place. const PARENT_PLACE: u32 = 1; -/// The header's last word for a launch that asks the supervisor to be the parent. -const PARENT_SUPERVISOR: u32 = 2; +/// The header's last word for a detached launch. +const PARENT_DETACHED: u32 = 2; /// Whom a launch asks the supervisor to place the program under. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -104,8 +106,8 @@ pub enum Parent { /// The process a handle carrying `WRITE` names, moved as the batch's last /// handle. Place(H), - /// The supervisor itself. - Supervisor, + /// The launcher's owner. + Detached, } /// What a caller asks the supervisor to start. @@ -177,7 +179,7 @@ impl Launch<'_> { let mut sendable = true; let place = match self.parent { Parent::Place(place) => Some(place), - Parent::Supervisor => None, + Parent::Detached => None, }; let fds = self.fds.iter().map(|(_, h, _)| h); let extras = self.extras.iter().map(|(_, h)| h); @@ -196,7 +198,7 @@ impl Launch<'_> { pub fn encode(&self, buf: &mut [u8]) -> Result { let (place, word) = match self.parent { Parent::Place(_) => (1, PARENT_PLACE), - Parent::Supervisor => (0, PARENT_SUPERVISOR), + Parent::Detached => (0, PARENT_DETACHED), }; if self.extras.len() + place + 1 > MAX_LAUNCH_EXTRAS || self.fds.len() > MAX_LAUNCH_FDS { return Err(EncodeError::TooMany); @@ -316,7 +318,7 @@ impl<'a> Request<'a> { pub fn parent(&self) -> Option> { match self.parent { PARENT_PLACE => Some(Parent::Place(())), - PARENT_SUPERVISOR => Some(Parent::Supervisor), + PARENT_DETACHED => Some(Parent::Detached), _ => None, } } @@ -348,6 +350,30 @@ impl<'a> Request<'a> { } } +/// Whether `path` is already the string every path syscall will act on. +/// +/// The kernel normalizes before it opens anything (`kernel/src/vfs.rs`'s +/// `normalize` drops an empty component and `.`, and pops on `..`), so a path +/// meaning something else after that is one nothing can both classify and +/// spawn: `/apps/./x` is outside `/apps` to the supervisor's package +/// classification and inside it to every syscall. +pub fn is_canonical(path: &str) -> bool { + path.starts_with('/') + && !path.split('/').skip(1).any(|part| part.is_empty() || part == "." || part == "..") +} + +/// What a launch names for a program its caller typed: a bare name left +/// alone for `PATH`, anything with a `/` made absolute against `cwd` and +/// normalized the way the kernel does. It lives beside [`is_canonical`] +/// because a resolver that drifted from the gate would refuse every relative +/// command. +pub fn program_path(program: &str, cwd: &str) -> String { + match program.contains('/') { + true => crate::files::absolute(cwd, program), + false => String::from(program), + } +} + /// Why a launch did not answer, and — the part that matters — whether the /// handles it was going to carry are still the caller's. /// @@ -366,28 +392,22 @@ pub enum LaunchError { } /// What a launch answered. -pub enum Outcome<'a> { +pub enum Outcome { /// The handle the caller now holds for the child. Started(RawHandle), - /// Not a `[programs]` key. The caller spawns it directly, with `home` as - /// its `HOME`. - NotDeclared { home: &'a str }, + /// Not permitted ([`MSG_NOT_PERMITTED`]). + NotPermitted, /// Declared, and it did not start. Refused, /// The place it named has begun to end. Gone, } -/// Send one launch and read its answer, a `HOME` in `answer`. +/// Send one launch and read its answer. /// /// The handles travel in the launch frame itself, and the `Process` handle -/// comes back in the answer's. A `HOME` longer than `answer` or not UTF-8 is a -/// malformed answer, never a shortened one. -pub fn launch<'a>( - conn: &Connection, - request: &Launch<'_>, - answer: &'a mut [u8], -) -> Result, LaunchError> { +/// comes back in the answer's. +pub fn launch(conn: &Connection, request: &Launch<'_>) -> Result { let mut buf = [0u8; crate::ipc::MAX_FRAME_LEN as usize]; let len = request .encode(&mut buf) @@ -409,15 +429,7 @@ pub fn launch<'a>( Some([process]) => Ok(Outcome::Started(process)), None => Err(LaunchError::Sent(IpcError::Malformed)), }, - MSG_NOT_DECLARED => { - if header.len() as usize > answer.len() { - return Err(LaunchError::Sent(IpcError::Malformed)); - } - let len = conn.recv_bytes(&header, answer).map_err(LaunchError::Sent)?; - let home = core::str::from_utf8(&answer[..len]) - .map_err(|_| LaunchError::Sent(IpcError::Malformed))?; - Ok(Outcome::NotDeclared { home }) - } + MSG_NOT_PERMITTED => Ok(Outcome::NotPermitted), MSG_REFUSED => Ok(Outcome::Refused), MSG_GONE => Ok(Outcome::Gone), _ => Err(LaunchError::Sent(IpcError::Malformed)), @@ -428,7 +440,39 @@ pub fn launch<'a>( mod tests { use super::*; - /// The header's last word names the parent: a place, the supervisor, or — any other + /// Checked against the gate itself, not a shape written down twice. + #[test] + fn what_a_shell_resolves_is_what_the_launcher_accepts() { + let cwd = "/home/toy/reltest"; + for (typed, want) in [ + ("./foo", "/home/toy/reltest/foo"), + ("../bin/foo", "/home/toy/bin/foo"), + ("./a/./b/../c", "/home/toy/reltest/a/c"), + ("sub/foo", "/home/toy/reltest/sub/foo"), + ("/system/bin/toybox", "/system/bin/toybox"), + ("/system//bin/./toybox", "/system/bin/toybox"), + ("/../system/bin/toybox", "/system/bin/toybox"), + ] { + let got = program_path(typed, cwd); + assert_eq!(got, want, "{typed:?} from {cwd:?}"); + assert!(is_canonical(&got), "{typed:?} resolved to {got:?}, which the supervisor refuses"); + } + // A bare name is `PATH`'s, and that rule is left where it was. + for bare in ["foo", "echo", "toybox"] { + assert_eq!(program_path(bare, cwd), bare); + } + } + + #[test] + fn a_path_the_normalizer_would_change_is_not_canonical() { + assert!(is_canonical("/apps/toy/echo")); + assert!(is_canonical("/system/bin/toybox")); + for bad in ["/apps/./toy/echo", "/apps//toy/echo", "apps/toy/echo", "/tmp/../apps/toy/echo", "/apps/toy/echo/", "./echo", "", "/"] { + assert!(!is_canonical(bad), "{bad:?} passed as canonical"); + } + } + + /// The header's last word names the parent: a place, detached, or — any other /// word — neither, which the supervisor refuses. #[test] fn a_request_names_its_parent_by_one_word() { @@ -443,11 +487,11 @@ mod tests { parent, job: RawHandle(10), }; - for (parent, named) in [(Parent::Place(RawHandle(9)), Parent::Place(())), (Parent::Supervisor, Parent::Supervisor)] { + for (parent, named) in [(Parent::Place(RawHandle(9)), Parent::Place(())), (Parent::Detached, Parent::Detached)] { let len = request(parent).encode(&mut buf).expect("encode a launch"); assert_eq!(Request::decode(&buf[..len]).expect("decode it").parent(), Some(named)); } - let len = request(Parent::Supervisor).encode(&mut buf).expect("encode a launch"); + let len = request(Parent::Detached).encode(&mut buf).expect("encode a launch"); for word in [0, 3, u32::MAX] { buf[HEADER - 4..HEADER].copy_from_slice(&word.to_le_bytes()); assert_eq!(Request::decode(&buf[..len]).expect("decode it").parent(), None, "word {word}"); @@ -467,7 +511,7 @@ mod tests { cwd: "/", extras: &[], fds: &fds, - parent: Parent::Supervisor, + parent: Parent::Detached, job: RawHandle(10), }; let len = request.encode(&mut buf).expect("encode a launch"); @@ -505,10 +549,10 @@ mod tests { batch(&[(0, h(5), Kind::Handle), (1, h(6), Kind::Handle)], &[("a", h(7)), ("b", h(8))], Parent::Place(h(9))), Ok(vec![h(5), h(6), h(7), h(8), h(9), h(10)]), ); - assert_eq!(batch(&[], &[("a", h(7)), ("b", h(7))], Parent::Supervisor), Err(vec![h(7), h(10)]), "two extras"); - assert_eq!(batch(&[(0, h(5), Kind::Handle), (1, h(5), Kind::Handle)], &[], Parent::Supervisor), Err(vec![h(5), h(10)]), "two descriptors"); + assert_eq!(batch(&[], &[("a", h(7)), ("b", h(7))], Parent::Detached), Err(vec![h(7), h(10)]), "two extras"); + assert_eq!(batch(&[(0, h(5), Kind::Handle), (1, h(5), Kind::Handle)], &[], Parent::Detached), Err(vec![h(5), h(10)]), "two descriptors"); assert_eq!( - batch(&[(0, h(7), Kind::Handle), (1, h(6), Kind::Handle)], &[("a", h(7))], Parent::Supervisor), + batch(&[(0, h(7), Kind::Handle), (1, h(6), Kind::Handle)], &[("a", h(7))], Parent::Detached), Err(vec![h(7), h(6), h(10)]), "a descriptor and an extra", ); @@ -518,7 +562,7 @@ mod tests { "an extra and the place", ); assert_eq!( - batch(&[(0, h(5), Kind::Handle)], &[("a", CONN), ("b", h(7))], Parent::Supervisor), + batch(&[(0, h(5), Kind::Handle)], &[("a", CONN), ("b", h(7))], Parent::Detached), Err(vec![h(5), h(7), h(10)]), "the connection as an extra", ); @@ -527,6 +571,6 @@ mod tests { Err(vec![h(7), h(10)]), "the connection as the place", ); - assert_eq!(batch(&[(0, h(10), Kind::Handle)], &[], Parent::Supervisor), Err(vec![h(10)]), "a descriptor and the job"); + assert_eq!(batch(&[(0, h(10), Kind::Handle)], &[], Parent::Detached), Err(vec![h(10)]), "a descriptor and the job"); } } diff --git a/sdk/toyos/src/spawn.rs b/sdk/toyos/src/spawn.rs index b50cd0e9bde..ad330af5acf 100644 --- a/sdk/toyos/src/spawn.rs +++ b/sdk/toyos/src/spawn.rs @@ -1,17 +1,19 @@ -//! Starting a child: asked of the supervisor's launcher, or spawned by this -//! process. std's `Command` and libc's `posix_spawn` both start one here, in -//! two steps, so neither carries a rule of its own: [`Spawn::launch`], which -//! decides where the child goes and asks the launcher when it is to be asked, -//! and, when that answers [`Launched::Direct`], [`Spawn::direct`] with the -//! environment [`direct_env`] makes. +//! Starting a child: a helper this process spawns, or an independent launch +//! asked of the session launcher it holds. std's `Command` and libc's +//! `posix_spawn` both start one here, so neither carries a rule of its own: +//! [`Spawn::start`] says which the spawn is, or refuses it, and what it +//! answers is the one way to start that kind. //! -//! **The supervisor starts a child only by a launch**, and a launch runs its -//! row's own program with no authority but its row's and the connectors -//! provided beside it. So a spawn asked of the supervisor that carries an -//! endowment or a descriptor past stdio, or names its image, is refused, as is one that -//! names its image and provides a connector; one that decided what its child -//! holds or runs is spawned directly; and one whose launch was not made is -//! spawned directly only where it asked nothing a launch alone gives. +//! **A helper inherits.** It holds this process's namespace under `svc`, its +//! environment as given, and exactly the descriptors and endowments its spawn +//! names; never this process's launcher, which only an endowment the caller +//! names hands on. +//! +//! **An independent launch runs its row.** The launcher starts the program a +//! manifest row declares, holding that row's authority and the connectors +//! provided beside it, so a launch carries no endowment, no descriptor past +//! stdio and no image of the caller's choosing. A launch this process cannot +//! make is refused, never spawned as a helper instead. //! //! **The job the child joins is no endowment of that kind**: a launch carries //! it too. The one endowed under [`JOB_LABEL`], or else this process's own. @@ -21,7 +23,7 @@ use toyos_abi::syscall::{ SVC_LABEL, }; -use crate::fd::Kind; +use crate::fd::Kind as FdKind; use crate::launch::{self, Launch, LaunchError, Outcome, MAX_LAUNCH_EXTRAS, MAX_LAUNCH_FDS}; use crate::process::{Image, Process}; use crate::{AsHandle, RawHandle}; @@ -33,8 +35,9 @@ pub enum Parent { Caller, /// The process a handle carrying `WRITE` names. Place(RawHandle), - /// The supervisor, asked through the launcher. - Supervisor, + /// The launcher's owner, so that the child outlives this process. Only a + /// launch asks it. + Detached, } /// What a spawn asks for. Every handle in it is the caller's and stays the @@ -42,30 +45,36 @@ pub enum Parent { /// child moves. pub struct Spawn<'a> { /// The program, as the caller resolved it: what a launch names and what a - /// direct spawn runs when no `image` is named. + /// helper runs when no `image` is named. pub program: &'a str, - /// The program a direct spawn runs in place of `program`. + /// The program a helper runs in place of `program`. pub image: Option<&'a str>, + /// The caller holds the image the child runs, a boot image or one it read + /// itself, which only a helper takes. + pub image_held: bool, /// NUL-separated, `argv[0]` first. pub argv: &'a [u8], - /// `KEY=VALUE\0` repeated: what a launch carries, and what [`direct_env`] - /// makes a direct spawn's of. + /// `KEY=VALUE\0` repeated: the child's environment. pub env: &'a [u8], /// Absolute: the kernel starts a child only where its spawn says. pub cwd: &'a str, /// `(child descriptor, caller's handle, its kind)`, each a number once: /// the child starts with these and no other descriptors. The caller keeps /// its handles, which a spawn duplicates; a stream's is an end - /// `net::stream::export` made for the child. A launch carries 0 to 2; a - /// descriptor past them only a direct spawn gives. - pub fds: &'a [(u32, RawHandle, Kind)], + /// `net::stream::export` made for the child. A launch carries 0 to 2 + /// alone. + pub fds: &'a [(u32, RawHandle, FdKind)], /// `(label, handle)` the child is endowed, each moved. One under - /// [`JOB_LABEL`] is the job the child joins, which a launch carries too. + /// [`JOB_LABEL`] is the job the child joins, which a launch carries too; + /// any other only a helper takes. pub endow: &'a [(&'a str, RawHandle)], /// `(name, connector)` put in a launched child's namespace beside its /// row's, each moved. pub provided: &'a [(&'a str, RawHandle)], pub parent: Parent, + /// An independent launch through this process's launcher, where a helper + /// is not. + pub independent: bool, } /// Why nothing was started. @@ -73,55 +82,107 @@ pub struct Spawn<'a> { pub enum Refused { /// Not permitted, for this reason. Permission(&'static str), - /// The spawn names more than it can carry, for this reason. + /// The spawn names more than it can carry, or asks what its kind does + /// not give, for this reason. Invalid(&'static str), /// It would put a stream's own handle in another process, where only an /// exported end of it goes (`crate::net::stream::export`). Stream(RawHandle), /// netstack made no holder of a stream the child was to be given. Export(crate::net::NetError), - /// The supervisor did not start a program it declares, or its answer was lost. + /// The launcher did not start a program it permits, or its answer was lost. NotStarted, /// The kernel refused. Syscall(SyscallError), } -/// Where [`Spawn::launch`] left the child. -pub enum Launched<'h> { - /// The supervisor started it. - Started(Process), - /// This process spawns it ([`Spawn::direct`]), with the `HOME` the - /// supervisor answered for it if it answered one. - Direct { home: Option<&'h str> }, +/// What [`Spawn::start`] found a spawn to be, holding the one way to start it. +pub enum Start<'s, 'a> { + Helper(Helper<'s, 'a>), + Launch(Independent<'s, 'a>), +} + +/// A spawn this process makes: [`Helper::spawn`]. +pub struct Helper<'s, 'a>(&'s Spawn<'a>); + +/// A spawn the launcher makes: [`Independent::launch`]. +pub struct Independent<'s, 'a>(&'s Spawn<'a>); + +/// What [`kind`] judges of a spawn. +#[derive(Clone, Copy, Default, Debug)] +struct Shape { + independent: bool, + detached: bool, + endowed: bool, + extra_fds: bool, + image_named: bool, + provided: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Helper, + Launch, +} + +/// Which spawn this is, or why it is none: what a launch alone gives asked +/// of a helper, or what a helper alone carries asked of a launch. +fn kind(shape: Shape) -> Result { + if !shape.independent { + return match (shape.detached, shape.provided) { + (true, _) => Err("only an independent launch is detached"), + (_, true) => Err("only an independent launch carries a provided connector"), + _ => Ok(Kind::Helper), + }; + } + match (shape.endowed, shape.extra_fds, shape.image_named) { + (true, _, _) => Err("a launch carries no endowment but its job"), + (_, true, _) => Err("a launch carries descriptors 0 to 2 alone"), + (_, _, true) => Err("a launch runs its row's own program, so it takes no image the caller names"), + _ => Ok(Kind::Launch), + } } -impl Spawn<'_> { - /// Where the child goes: refused, launched, or to be spawned directly. - /// `answer` holds the `HOME` an undeclared program's launch answers, and - /// is at least `ipc::MAX_FRAME_LEN` long to take any. - pub fn launch<'h>(&self, answer: &'h mut [u8]) -> Result, Refused> { +impl<'a> Spawn<'a> { + /// Which spawn this is, refused before anything moves where it asks what + /// its kind does not give or would put a stream in another process. + pub fn start(&self) -> Result, Refused> { self.refuse_streams()?; - let asked = Asked { - supervisor: self.parent == Parent::Supervisor, + let shape = Shape { + independent: self.independent, + detached: self.parent == Parent::Detached, endowed: self.endow.iter().any(|(label, _)| *label != JOB_LABEL), extra_fds: self.fds.iter().any(|&(fd, _, _)| fd > 2), - image_named: self.image.is_some(), + image_named: self.image.is_some() || self.image_held, provided: !self.provided.is_empty(), }; - match route(asked) { - Route::Refused(why) => Err(Refused::Permission(why)), - Route::Direct => Ok(Launched::Direct { home: None }), - Route::Launch => self.ask_launcher(answer), + match kind(shape).map_err(Refused::Invalid)? { + Kind::Helper => Ok(Start::Helper(Helper(self))), + Kind::Launch => Ok(Start::Launch(Independent(self))), } } - /// Spawn the child here, carrying `env` and running `image` when one is - /// given, which the kernel pages the child from. It inherits this - /// process's namespace under `svc` unless the spawn endows one. - pub fn direct(&self, env: &[u8], image: Option<&Image>) -> Result { - let path = image_and_name(self.program, self.image).0; - let names_svc = self.endow.iter().any(|(label, _)| *label == SVC_LABEL); - if self.endow.len() + self.fds.len() + usize::from(!names_svc) > MAX_SPAWN_ENDOWMENTS { + /// Refuses, before anything moves, a spawn that would put a stream's own + /// handle in another process: a holder stays in its process, and what + /// goes is an end of a new one, which the caller exports. + fn refuse_streams(&self) -> Result<(), Refused> { + let fds = self.fds.iter().map(|&(_, handle, _)| handle); + let given = self.endow.iter().chain(self.provided).map(|&(_, handle)| handle); + match fds.chain(given).find(|h| crate::net::stream::is_stream(*h)) { + Some(stream) => Err(Refused::Stream(stream)), + None => Ok(()), + } + } +} + +impl Helper<'_, '_> { + /// Spawn the child here, carrying the spawn's environment and running + /// `image` when one is given, which the kernel pages the child from. + pub fn spawn(self, image: Option<&Image>) -> Result { + let s = self.0; + let path = image_and_name(s.program, s.image).0; + let names_svc = s.endow.iter().any(|(label, _)| *label == SVC_LABEL); + if s.endow.len() + s.fds.len() + usize::from(!names_svc) > MAX_SPAWN_ENDOWMENTS { return Err(Refused::Invalid("more endowments and descriptors than a spawn carries")); } let mut entries = [EndowEntry { label_off: 0, label_len: 0, handle: RawHandle(0), _pad: 0 }; MAX_SPAWN_ENDOWMENTS]; @@ -136,11 +197,11 @@ impl Spawn<'_> { copies.iter().for_each(|&h| syscall::close(h)); e }; - let svc = match (names_svc, crate::endow::namespace()) { - (false, Some(ns)) => Some(syscall::dup(ns.as_handle()).map_err(Refused::Syscall)?), - _ => None, + let svc = match crate::endow::namespace().filter(|_| !names_svc) { + Some(ns) => Some((SVC_LABEL, syscall::dup(ns.as_handle()).map_err(Refused::Syscall)?)), + None => None, }; - if let Some(svc) = svc { + if let Some((_, svc)) = svc { copies[copied] = svc; copied += 1; } @@ -157,13 +218,13 @@ impl Spawn<'_> { (*count, *used) = (*count + 1, *used + len); Ok(()) }; - for &(label, handle) in self.endow { + for &(label, handle) in s.endow { put(&[label.as_bytes()], handle, &mut count, &mut used).map_err(|e| release(&copies[..copied], e))?; } - if let Some(svc) = svc { - put(&[SVC_LABEL.as_bytes()], svc, &mut count, &mut used).map_err(|e| release(&copies[..copied], e))?; + if let Some((label, svc)) = svc { + put(&[label.as_bytes()], svc, &mut count, &mut used).map_err(|e| release(&copies[..copied], e))?; } - for &(fd, handle, kind) in self.fds { + for &(fd, handle, kind) in s.fds { let copy = match syscall::dup(handle) { Ok(copy) => copy, Err(e) => return Err(release(&copies[..copied], Refused::Syscall(e))), @@ -178,22 +239,22 @@ impl Spawn<'_> { let args = SpawnArgs { path_ptr: path.as_ptr() as u64, path_len: path.len() as u64, - argv_ptr: self.argv.as_ptr() as u64, - argv_len: self.argv.len() as u64, - env_ptr: env.as_ptr() as u64, - env_len: env.len() as u64, + argv_ptr: s.argv.as_ptr() as u64, + argv_len: s.argv.len() as u64, + env_ptr: s.env.as_ptr() as u64, + env_len: s.env.len() as u64, endow_ptr: entries.as_ptr() as u64, endow_count: count as u64, labels_ptr: labels.as_ptr() as u64, labels_len: used as u64, - cwd_ptr: self.cwd.as_ptr() as u64, - cwd_len: self.cwd.len() as u64, + cwd_ptr: s.cwd.as_ptr() as u64, + cwd_len: s.cwd.len() as u64, image, image_len, - place: match self.parent { + place: match s.parent { Parent::Caller => u64::from(toyos_abi::HANDLE_INVALID.0), Parent::Place(place) => u64::from(place.0), - Parent::Supervisor => unreachable!("a child asked of the supervisor is launched or refused"), + Parent::Detached => unreachable!("a detached child is refused a helper's spawn"), }, }; // SAFETY: every pointer is to a buffer above that outlives the call. @@ -205,79 +266,56 @@ impl Spawn<'_> { Err(e) => Err(release(&copies[..copied], Refused::Syscall(e))), } } +} - /// Refuses, before anything moves, a spawn that would put a stream's own - /// handle in another process: a holder stays in its process, and what - /// goes is an end of a new one, which the caller exports. - fn refuse_streams(&self) -> Result<(), Refused> { - let fds = self.fds.iter().map(|&(_, handle, _)| handle); - let given = self.endow.iter().chain(self.provided).map(|&(_, handle)| handle); - match fds.chain(given).find(|h| crate::net::stream::is_stream(*h)) { - Some(stream) => Err(Refused::Stream(stream)), - None => Ok(()), - } - } - - /// Ask the supervisor to start this program, or answer - /// [`Launched::Direct`] for a caller that cannot or a program the manifest - /// does not declare — but a child asked of the supervisor is launched or - /// refused, never spawned here. +impl Independent<'_, '_> { + /// Ask this process's launcher to start the program. Refused + /// [`Refused::Permission`], with nothing spawned in its place, where this + /// process holds no launcher, the launcher will not open, or no row + /// declares the program or the launcher's row may not start it. /// /// The stdio handles and the place are **duplicated** before they go, and /// a handle to this process's own job made: a launch moves what it /// carries. A job the spawn endowed goes itself, moved as an endowment is. - fn ask_launcher<'h>(&self, answer: &'h mut [u8]) -> Result, Refused> { - let for_supervisor = self.parent == Parent::Supervisor; - let unmade = |why, home| match unmade(why, for_supervisor) { - Unlaunched::Direct => Ok(Launched::Direct { home }), - Unlaunched::Refused(why) => Err(Refused::Permission(why)), - Unlaunched::Failed(e) => Err(Refused::Syscall(e)), - }; - let Some(conn) = crate::endow::launcher().and_then(|held| held.open(launch::LAUNCHER).ok()) else { - return unmade(Unmade::NoLauncher { provided: !self.provided.is_empty() }, None); + pub fn launch(self) -> Result { + let s = self.0; + let held = crate::endow::launcher().ok_or(Refused::Permission("this process holds no launcher"))?; + let conn = held.open(launch::LAUNCHER).map_err(|_| Refused::Permission("the launcher this process holds will not open"))?; + let place = match s.parent { + Parent::Detached => None, + Parent::Place(place) => Some(place), + Parent::Caller => Some(crate::endow::this_process().as_handle()), }; - // The place and the job are two of the batch's extras. Refused rather - // than spawned directly: that child would hold this process's - // namespace, not its row. - if self.provided.len() + usize::from(!for_supervisor) + 1 > MAX_LAUNCH_EXTRAS { + if s.provided.len() + usize::from(place.is_some()) + 1 > MAX_LAUNCH_EXTRAS { return Err(Refused::Invalid("more connectors provided than a launch carries beside its place and job")); } - let stdio = self.fds; - if stdio.len() > MAX_LAUNCH_FDS || stdio.iter().any(|&(fd, _, _)| fd > 2) { + if s.fds.len() > MAX_LAUNCH_FDS { return Err(Refused::Invalid("a launch carries descriptors 0 to 2 alone")); } - let place = match self.parent { - Parent::Supervisor => None, - Parent::Place(place) => Some(place), - Parent::Caller => Some(crate::endow::this_process().as_handle()), - }; - let endowed_job = self.endow.iter().find(|(label, _)| *label == JOB_LABEL).map(|&(_, job)| job); + let endowed_job = s.endow.iter().find(|(label, _)| *label == JOB_LABEL).map(|&(_, job)| job); let job_copy = match endowed_job { Some(_) => None, - None => match crate::job::own() { - Ok(own) => Some(own.into_raw()), - Err(e) => return unmade(Unmade::NotCopied(e), None), - }, + None => Some(crate::job::own().map_err(Refused::Syscall)?.into_raw()), }; let parent = match place { - None => launch::Parent::Supervisor, + None => launch::Parent::Detached, Some(place) => match syscall::dup(place) { Ok(copy) => launch::Parent::Place(copy), Err(e) => { job_copy.into_iter().for_each(syscall::close); - return unmade(Unmade::NotCopied(e), None); + return Err(Refused::Syscall(e)); } }, }; - let mut fds = [(0u32, RawHandle(0), Kind::Handle); MAX_LAUNCH_FDS]; - let release = |fds: &[(u32, RawHandle, Kind)]| { + let mut fds = [(0u32, RawHandle(0), FdKind::Handle); MAX_LAUNCH_FDS]; + let release = |fds: &[(u32, RawHandle, FdKind)]| { fds.iter().for_each(|(_, h, _)| syscall::close(*h)); if let launch::Parent::Place(copy) = parent { syscall::close(copy); } job_copy.into_iter().for_each(syscall::close); }; - for (i, &(child, handle, kind)) in stdio.iter().enumerate() { + for (i, &(child, handle, kind)) in s.fds.iter().enumerate() { match syscall::dup(handle) { Ok(copy) => fds[i] = (child, copy, kind), Err(e) => { @@ -286,32 +324,32 @@ impl Spawn<'_> { } } } - let fds = &fds[..stdio.len()]; - let mut extras = [("", RawHandle(0)); MAX_LAUNCH_EXTRAS]; - extras[..self.provided.len()].copy_from_slice(self.provided); + let fds = &fds[..s.fds.len()]; let request = Launch { - program: self.program, - argv: self.argv, - env: self.env, - cwd: self.cwd, - extras: &extras[..self.provided.len()], + program: s.program, + argv: s.argv, + env: s.env, + cwd: s.cwd, + extras: s.provided, fds, parent, job: job_copy.or(endowed_job).expect("a job endowed or copied"), }; // **The send moved them.** Every arm but `NotSent` is past the point // where the duplicates left this table, so closing them would be this - // process naming a handle it does not hold — which the kernel answers + // process naming a handle it does not hold, which the kernel answers // by ending it. The launcher releases what it took. - match launch::launch(&conn, &request, answer) { - // SAFETY: the supervisor moved this handle into this table and holds none. - Ok(Outcome::Started(handle)) => Ok(Launched::Started(unsafe { Process::from_raw(handle) })), - Ok(Outcome::NotDeclared { home }) => unmade(Unmade::NotDeclared, Some(home)), + match launch::launch(&conn, &request) { + // SAFETY: the launcher moved this handle into this table and holds none. + Ok(Outcome::Started(handle)) => Ok(unsafe { Process::from_raw(handle) }), + Ok(Outcome::NotPermitted) => { + Err(Refused::Permission("no row declares the program, or the launcher's row may not start it")) + } Ok(Outcome::Refused) | Err(LaunchError::Sent(_)) => Err(Refused::NotStarted), Ok(Outcome::Gone) => Err(Refused::Syscall(SyscallError::Gone)), Err(LaunchError::NotSent(_)) => { release(fds); - unmade(Unmade::NotSent, None) + Err(Refused::Invalid("the launch does not fit one frame")) } } } @@ -330,98 +368,8 @@ fn decimal(mut n: u32, buf: &mut [u8; 10]) -> &[u8] { } } -/// The environment a direct spawn carries: `env`, except `HOME`. -/// -/// **A direct child's `HOME` is one its caller named or one the supervisor -/// answered for it, never the one this process was started with.** The -/// supervisor decides every program's `HOME` from its row, and a service's is -/// its own `/state/`: a child the supervisor never saw would otherwise -/// carry a location decided for its parent alone. `named` says the caller named -/// `env`'s `HOME`, or named that there is none. -pub fn direct_env(env: &[u8], named: bool, from_supervisor: Option<&str>, out: &mut impl Extend) { - let entries = env.split(|&b| b == 0).filter(|e| !e.is_empty()); - for entry in entries.filter(|e| named || !e.starts_with(b"HOME=")) { - out.extend(entry.iter().copied().chain([0])); - } - if let (false, Some(home)) = (named, from_supervisor) { - out.extend(b"HOME=".iter().chain(home.as_bytes()).copied().chain([0])); - } -} - -/// What a spawn asked for before it was routed. -#[derive(Clone, Copy, Default)] -struct Asked { - supervisor: bool, - endowed: bool, - extra_fds: bool, - image_named: bool, - provided: bool, -} - -/// Where a spawn goes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Route { - Direct, - /// Asked of the launcher, where this process holds one. - Launch, - Refused(&'static str), -} - -fn route(asked: Asked) -> Route { - if asked.supervisor && (asked.endowed || asked.extra_fds) { - return Route::Refused("the supervisor starts only a launch, which carries no endowment and no descriptor past stdio"); - } - if asked.image_named && (asked.supervisor || asked.provided) { - return Route::Refused("a launch runs its row's own program, so it takes no image the caller names"); - } - match asked.endowed || asked.extra_fds || asked.image_named { - true => Route::Direct, - false => Route::Launch, - } -} - -/// Why a launch was not made. -enum Unmade { - /// This process holds no launcher; `provided` is a spawn that provided a - /// connector, which only a launch carries. - NoLauncher { provided: bool }, - /// No row declares the program. - NotDeclared, - /// The launch never left this process. - NotSent, - /// The copy of its place or of this process's job a launch carries could - /// not be made. - NotCopied(SyscallError), -} - -/// What a spawn whose launch was not made gets. -#[derive(Debug, PartialEq, Eq)] -enum Unlaunched { - Direct, - Refused(&'static str), - /// Refused as the copy of its place was. - Failed(SyscallError), -} - -/// A launch not made is spawned directly only where the spawn asked nothing a -/// launch alone gives: not the supervisor, not a connector beside its row, and -/// not a place the launch was to carry, under which a direct child would hold -/// this process's namespace and not its row's. -fn unmade(why: Unmade, supervisor: bool) -> Unlaunched { - match why { - Unmade::NotCopied(e) => Unlaunched::Failed(e), - Unmade::NoLauncher { provided: true } => { - Unlaunched::Refused("only a launch carries a provided connector, and this process holds no launcher") - } - Unmade::NoLauncher { provided: false } | Unmade::NotDeclared | Unmade::NotSent => match supervisor { - true => Unlaunched::Refused("the supervisor did not launch it"), - false => Unlaunched::Direct, - }, - } -} - -/// What a direct spawn runs, and the name it runs it under: the image named, -/// else the program; `argv[0]` is the program's either way. +/// What a helper runs, and the name it runs it under: the image named, else +/// the program; `argv[0]` is the program's either way. pub fn image_and_name<'a>(program: &'a str, image: Option<&'a str>) -> (&'a str, &'a str) { (image.unwrap_or(program), program) } @@ -430,62 +378,36 @@ pub fn image_and_name<'a>(program: &'a str, image: Option<&'a str>) -> (&'a str, mod tests { use super::*; - const SUPERVISOR: Asked = Asked { supervisor: true, endowed: false, extra_fds: false, image_named: false, provided: false }; - - /// What the supervisor would not start is refused here, and nothing is - /// spawned in its place. + /// A plain spawn is a helper, whatever it endows or runs, and is never + /// asked of the launcher. #[test] - fn the_supervisor_is_asked_only_by_a_launch_of_its_row_s_own_program() { - for asked in [ - Asked { endowed: true, ..SUPERVISOR }, - Asked { extra_fds: true, ..SUPERVISOR }, - Asked { image_named: true, ..SUPERVISOR }, - Asked { image_named: true, provided: true, ..Asked::default() }, + fn a_plain_spawn_is_a_helper() { + for shape in [ + Shape::default(), + Shape { endowed: true, ..Shape::default() }, + Shape { extra_fds: true, ..Shape::default() }, + Shape { image_named: true, ..Shape::default() }, ] { - assert!(matches!(route(asked), Route::Refused(_))); - } - assert_eq!(route(SUPERVISOR), Route::Launch); - for why in [Unmade::NoLauncher { provided: false }, Unmade::NotDeclared, Unmade::NotSent] { - assert_eq!(unmade(why, true), Unlaunched::Refused("the supervisor did not launch it")); - } - } - - /// A spawn that decided what its child holds or runs is spawned directly, - /// launcher or not; one that decided nothing is asked of the launcher, and - /// spawned directly where it is not launched. - #[test] - fn a_spawn_that_decided_its_child_is_spawned_directly() { - for asked in [ - Asked { endowed: true, ..Asked::default() }, - Asked { extra_fds: true, ..Asked::default() }, - Asked { image_named: true, ..Asked::default() }, - ] { - assert_eq!(route(asked), Route::Direct); - } - assert_eq!(route(Asked::default()), Route::Launch); - for why in [Unmade::NoLauncher { provided: false }, Unmade::NotDeclared, Unmade::NotSent] { - assert_eq!(unmade(why, false), Unlaunched::Direct); + assert_eq!(kind(shape), Ok(Kind::Helper), "{shape:?}"); } } - /// A launch carries a copy of its place and of this process's job: one - /// this process cannot copy refuses the launch as the copy was refused, - /// and nothing is spawned directly in its place, whoever the spawn asked - /// for. + /// What only a launch gives is refused a helper, and what only a helper + /// carries is refused a launch: neither becomes the other. #[test] - fn a_launch_whose_place_or_job_cannot_be_copied_is_refused_and_not_spawned_directly() { - for supervisor in [false, true] { - let e = SyscallError::PermissionDenied; - assert_eq!(unmade(Unmade::NotCopied(e), supervisor), Unlaunched::Failed(e)); + fn each_kind_refuses_what_the_other_alone_takes() { + const LAUNCH: Shape = Shape { independent: true, detached: false, endowed: false, extra_fds: false, image_named: false, provided: false }; + assert_eq!(kind(LAUNCH), Ok(Kind::Launch)); + assert_eq!(kind(Shape { detached: true, provided: true, ..LAUNCH }), Ok(Kind::Launch)); + for shape in [Shape { detached: true, ..Shape::default() }, Shape { provided: true, ..Shape::default() }] { + assert!(kind(shape).is_err(), "{shape:?}"); } - } - - /// Only a launch carries a provided connector: with no launcher to carry - /// it, the spawn is refused rather than started without it. - #[test] - fn a_provided_connector_with_no_launcher_is_refused() { - for supervisor in [false, true] { - assert!(matches!(unmade(Unmade::NoLauncher { provided: true }, supervisor), Unlaunched::Refused(_))); + for shape in [ + Shape { endowed: true, ..LAUNCH }, + Shape { extra_fds: true, ..LAUNCH }, + Shape { image_named: true, ..LAUNCH }, + ] { + assert!(kind(shape).is_err(), "{shape:?}"); } } @@ -496,29 +418,4 @@ mod tests { assert_eq!(image_and_name("/bin/cat", Some("/tmp/other")), ("/tmp/other", "/bin/cat")); assert_eq!(image_and_name("/bin/cat", None), ("/bin/cat", "/bin/cat")); } - - fn direct(env: &[u8], named: bool, home: Option<&str>) -> Vec { - let mut out = Vec::new(); - direct_env(env, named, home, &mut out); - out - } - - /// The start-up `HOME` is never handed on: the supervisor's answer takes - /// its place, or nothing does. - #[test] - fn a_direct_child_carries_the_supervisor_s_home_and_not_this_process_s() { - let env = b"PATH=/bin\0HOME=/state/svc\0TERM=dumb\0"; - assert_eq!(direct(env, false, Some("/home/u")), b"PATH=/bin\0TERM=dumb\0HOME=/home/u\0"); - assert_eq!(direct(env, false, None), b"PATH=/bin\0TERM=dumb\0"); - } - - /// A `HOME` the caller named, or named away, is the child's, whatever the - /// supervisor answered; a `HOMEDIR` is no `HOME`. - #[test] - fn a_home_the_caller_named_is_the_child_s() { - let env = b"HOME=/x\0HOMEDIR=/y\0"; - assert_eq!(direct(env, true, Some("/home/u")), b"HOME=/x\0HOMEDIR=/y\0"); - assert_eq!(direct(b"HOMEDIR=/y\0", true, Some("/home/u")), b"HOMEDIR=/y\0"); - assert_eq!(direct(env, false, Some("/home/u")), b"HOMEDIR=/y\0HOME=/home/u\0"); - } } diff --git a/system/compositor/src/session.rs b/system/compositor/src/session.rs index c55b97a8396..8ea4e6c9bd9 100644 --- a/system/compositor/src/session.rs +++ b/system/compositor/src/session.rs @@ -2163,7 +2163,7 @@ fn launcher() -> mpsc::Sender { continue; } }; - if let Err(e) = Command::new(&program).endow(JOB_LABEL, job.into_raw().0).spawn() { + if let Err(e) = Command::new(&program).independent().detached().endow(JOB_LABEL, job.into_raw().0).spawn() { eprintln!("compositor: {program} did not start: {e}"); } } diff --git a/system/console/src/main.rs b/system/console/src/main.rs index 088259dde84..cd320af67c9 100644 --- a/system/console/src/main.rs +++ b/system/console/src/main.rs @@ -497,6 +497,7 @@ impl Shell { .duplicate() .expect("console: the kernel refused a duplicate of its own surface connector"); let mut child = Command::new("/system/bin/shell") + .independent() .provide(surface::SERVICE, surface_copy.into_raw().0) .stdin(process::tty_piped()) .stdout(process::tty_piped()) diff --git a/system/inspect/src/lib.rs b/system/inspect/src/lib.rs index 634c1eb85ba..dbdf1019212 100644 --- a/system/inspect/src/lib.rs +++ b/system/inspect/src/lib.rs @@ -25,7 +25,7 @@ const _: () = assert!( pub fn ask(owner: Owner) -> Result, String> { let conn = endow::service(owner.port).map_err(|e| match e { EndowError::NotEndowed => format!( - "this program holds no `{}` connector, so {} is not its to read", + "this program holds no `{}` connector, so {} is not its to read; run it with `launch inspect`", owner.port, owner.root ), EndowError::ServerGone => format!("`{}` is not running: its port is closed", owner.port), diff --git a/system/inspect/src/main.rs b/system/inspect/src/main.rs index 05002f9cb23..155f935c461 100644 --- a/system/inspect/src/main.rs +++ b/system/inspect/src/main.rs @@ -59,7 +59,7 @@ fn main() { [(toyos_inspect::dev::ROOT, inventory), (toyos_inspect::kernel::ROOT, cpu_counters)]; for (root, ask) in kernel_roots.into_iter().filter(|(root, _)| run.selector.reaches(root)) { let answer = cap.as_ref().ok_or_else(|| { - "this program holds no system capability, so the kernel's answer is not its to read" + "this program holds no system capability, so the kernel's answer is not its to read; run it with `launch inspect`" .to_string() }); match answer.and_then(ask) { diff --git a/system/shell/Cargo.toml b/system/shell/Cargo.toml index b7aaf0ef28b..0c8721c432a 100644 --- a/system/shell/Cargo.toml +++ b/system/shell/Cargo.toml @@ -9,8 +9,6 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../sdk/toyos" } # The quit a `-c` shell asks of a child spawned past its own interrupt. toyos-abi = { path = "../../sdk/abi" } -# `launch_path`, which is the other half of the gate the supervisor refuses a launch on. -toyos-manifest = { path = "../../lib/manifest" } [package.metadata.toyos.host] fails = ["linux", "macos", "windows"] diff --git a/system/shell/src/main.rs b/system/shell/src/main.rs index 1d1872b9911..c01dd462964 100644 --- a/system/shell/src/main.rs +++ b/system/shell/src/main.rs @@ -642,8 +642,7 @@ fn execute_pipeline(pipeline: &[SimpleCommand]) -> bool { for (i, cmd) in pipeline.iter().enumerate() { let is_first = i == 0; let is_last = i == pipeline.len() - 1; - let Some(mut command) = build_command(cmd) else { - println!("{}: not found", cmd.args[0]); + let Ok(mut command) = build_command(cmd) else { return false; }; @@ -681,8 +680,8 @@ fn execute_pipeline(pipeline: &[SimpleCommand]) -> bool { } children.push(child); } - Err(_) => { - println!("{}: not found", cmd.args[0]); + Err(e) => { + unstarted(cmd, &e); return false; } } @@ -733,10 +732,12 @@ fn execute_simple(cmd: &SimpleCommand, piped_stdin: Option {} } - let Some(mut command) = build_command(cmd) else { - println!("{}: not found", cmd.args[0]); - set_status_code(127); - return false; + let mut command = match build_command(cmd) { + Ok(command) => command, + Err(code) => { + set_status_code(code); + return false; + } }; if let Some(stdin) = piped_stdin { @@ -771,8 +772,8 @@ fn execute_simple(cmd: &SimpleCommand, piped_stdin: Option { - println!("{}: not found", cmd.args[0]); + Err(e) => { + unstarted(cmd, &e); set_status_code(127); return false; } @@ -781,8 +782,8 @@ fn execute_simple(cmd: &SimpleCommand, piped_stdin: Option child, - Err(_) => { - println!("{}: not found", cmd.args[0]); + Err(e) => { + unstarted(cmd, &e); set_status_code(127); return false; } @@ -820,19 +821,22 @@ fn provided() -> &'static [(&'static str, Connector)] { }) } -/// `detach [args...]`: start it with the supervisor as its parent — the one way -/// for a program to outlive this shell — and do not wait for it. Nothing this -/// shell was provided goes with it: that belongs to the session it outlives. +/// `detach [args...]`: launch it under the launcher's owner, so it +/// outlives this shell, and do not wait for it. Nothing this shell was +/// provided goes with it: that belongs to the session it outlives. fn detach(cmd: &SimpleCommand) -> bool { let Some(program) = cmd.args.get(1) else { println!("detach: which program?"); set_status_code(2); return false; }; - let cwd = - env::current_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| String::from("/")); - let mut command = Command::new(toyos_manifest::package::launch_path(program, &cwd)); - command.args(&cmd.args[2..]).under_supervisor(); + if toyos::endow::launcher().is_none() { + println!("detach: this shell holds no launcher"); + set_status_code(126); + return false; + } + let mut command = Command::new(program); + command.args(&cmd.args[2..]).independent().detached(); apply_redirect(&mut command, cmd); match command.spawn() { Ok(_) => { @@ -847,22 +851,47 @@ fn detach(cmd: &SimpleCommand) -> bool { } } -fn build_command(cmd: &SimpleCommand) -> Option { - if cmd.args.is_empty() { return None; } - // The launcher refuses a path it would have to normalize, and the shell is - // the half that knows the cwd. +/// The program a simple command starts: a helper, which inherits this shell's +/// namespace and so the surface it was provided; or after `launch`, an +/// independent launch of the program's own row, provided what this shell was. +/// The status, said, for a `launch` this shell cannot make, as `detach`'s. +fn build_command(cmd: &SimpleCommand) -> Result { + let launched = cmd.args.first().is_some_and(|word| word == LAUNCH); + let args = &cmd.args[usize::from(launched)..]; + let Some(program) = args.first() else { + println!("launch: which program?"); + return Err(2); + }; + if launched && toyos::endow::launcher().is_none() { + println!("launch: this shell holds no launcher"); + return Err(126); + } let cwd = env::current_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| String::from("/")); - let mut command = - Command::new(toyos_manifest::package::launch_path(&cmd.args[0], &cwd)); - command.args(&cmd.args[1..]); - for (name, connector) in provided() { - // A duplicate per child: this shell keeps its own for the next one. - if let Ok(handed) = connector.duplicate() { - command.provide(name, handed.into_raw().0); + let mut command = Command::new(toyos::launch::program_path(program, &cwd)); + command.args(&args[1..]); + if launched { + command.independent(); + for (name, connector) in provided() { + // A duplicate per child: this shell keeps its own for the next one. + if let Ok(handed) = connector.duplicate() { + command.provide(name, handed.into_raw().0); + } } } - Some(command) + Ok(command) +} + +/// The word that makes a simple command an independent launch. +const LAUNCH: &str = "launch"; + +/// Say why `cmd` did not start: a helper's program is not found, and a +/// launch's refusal is the launcher's to explain. +fn unstarted(cmd: &SimpleCommand, e: &std::io::Error) { + match cmd.args.first().is_some_and(|word| word == LAUNCH) { + true => println!("launch: {}: {e}", cmd.args.get(1).map_or("", String::as_str)), + false => println!("{}: not found", cmd.args[0]), + } } fn apply_redirect(command: &mut Command, cmd: &SimpleCommand) { @@ -929,8 +958,9 @@ fn set_status_code(code: i32) { } fn print_help() { - println!("Builtins: cd, clear, detach, exit, export, help"); - println!("detach : start it under the supervisor, so it outlives this shell"); + println!("Builtins: cd, clear, detach, exit, export, help, launch"); + println!("launch : start it as itself, with its own row's authority"); + println!("detach : launch it so it outlives this shell"); println!("Operators: | (pipe), && (and), || (or), ; (sequence)"); println!("Redirects: > (truncate), >> (append)"); println!("Variables: $VAR, ${{VAR}}, $? (exit status)"); @@ -1156,7 +1186,7 @@ fn replace_line(line: &mut String, cursor: &mut usize, new_content: &str) { *cursor = new_chars; } -const BUILTINS: &[&str] = &["cd", "clear", "exit", "export", "help", "true", "false"]; +const BUILTINS: &[&str] = &["cd", "clear", "detach", "exit", "export", "help", "launch", "true", "false"]; fn handle_tab(line: &mut String, cursor: &mut usize) { // Find the word being completed diff --git a/system/sshserver/Cargo.toml b/system/sshserver/Cargo.toml index 7e1b5c01bdc..04aac1f96bc 100644 --- a/system/sshserver/Cargo.toml +++ b/system/sshserver/Cargo.toml @@ -15,3 +15,6 @@ getrandom = "0.3" [dev-dependencies] # Test scratch that is gone on every way out; `--ci host` reds on any left behind. toyos-tmpdir = { path = "../../lib/tmpdir" } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("toyos"))'] } diff --git a/system/sshserver/src/command.rs b/system/sshserver/src/command.rs deleted file mode 100644 index c9d8f106ae9..00000000000 --- a/system/sshserver/src/command.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! What an `exec` channel request means: a program and its arguments, and -//! nothing else. -//! -//! **SSH has no argument vector.** `SSH_MSG_CHANNEL_REQUEST "exec"` carries one -//! string, and every client builds it by pasting words together — the remote -//! side is expected to be a shell. This daemon is not one, so the split is -//! spelled here and its whole grammar is quoting: single quotes, double quotes -//! and backslash. A pipe, a redirection, a glob, a `$` and a `;` are ordinary -//! bytes of an argument. - -/// Split an `exec` request line into a program and its arguments. -/// -/// `Err` is the whole diagnostic: it names what is wrong with the line, and -/// the caller sends it to the client rather than running anything. -pub fn split(line: &str) -> Result, String> { - let mut words = Vec::new(); - let mut word = String::new(); - // A word exists as soon as a quote opens, so `''` is an empty argument and - // not a missing one. - let mut started = false; - let mut chars = line.chars(); - - while let Some(c) = chars.next() { - match c { - c if c.is_whitespace() => { - if started { - words.push(std::mem::take(&mut word)); - started = false; - } - } - '\'' => { - started = true; - loop { - match chars.next() { - Some('\'') => break, - Some(c) => word.push(c), - None => return Err("unterminated ' quote".to_string()), - } - } - } - '"' => { - started = true; - loop { - match chars.next() { - Some('"') => break, - // Inside double quotes a backslash escapes only the - // two characters that could not otherwise be written; - // before anything else it is a backslash, which is - // what every shell does here. - Some('\\') => match chars.next() { - Some(c @ ('"' | '\\')) => word.push(c), - Some(c) => { - word.push('\\'); - word.push(c); - } - None => return Err("a line may not end in a backslash".to_string()), - }, - Some(c) => word.push(c), - None => return Err("unterminated \" quote".to_string()), - } - } - } - '\\' => { - started = true; - match chars.next() { - Some(c) => word.push(c), - None => return Err("a line may not end in a backslash".to_string()), - } - } - c => { - started = true; - word.push(c); - } - } - } - if started { - words.push(word); - } - // An empty *first* word is a quoted nothing, not a program: `''` would - // otherwise resolve to the program directory and be spawned as it. - if words.first().is_none_or(String::is_empty) { - return Err("no program named".to_string()); - } - Ok(words) -} - -/// Where a program name resolves to. A bare name is `/system/bin/`, -/// which is what the shell's `PATH` holds and the only directory of programs -/// this system has; anything absolute is taken as written. -/// -/// A name with a `/` in it that is *not* absolute is refused rather than -/// resolved against a working directory this daemon does not have. -pub fn resolve(name: &str) -> Result { - if name.starts_with('/') { - return Ok(name.to_string()); - } - if name.contains('/') { - return Err(format!( - "{name:?} is neither an absolute path nor a bare program name, and this daemon has \ - no working directory to resolve it against" - )); - } - Ok(format!("/system/bin/{name}")) -} - -/// The grammar, against lines a client actually sends. -#[cfg(test)] -mod tests { - use super::*; - - fn words(line: &str) -> Vec { - split(line).expect("a line that splits") - } - - #[test] - fn a_bare_command_is_its_words() { - assert_eq!(words("echo hello world"), ["echo", "hello", "world"]); - assert_eq!(words(" echo hello "), ["echo", "hello"]); - assert_eq!(words("echo"), ["echo"]); - } - - #[test] - fn quotes_hold_a_word_together() { - assert_eq!(words("echo 'hello world'"), ["echo", "hello world"]); - assert_eq!(words("echo \"hello world\""), ["echo", "hello world"]); - assert_eq!(words("echo a' 'b"), ["echo", "a b"]); - assert_eq!(words("echo ''"), ["echo", ""]); - } - - /// The one construct that has to survive intact, because it is how a client - /// reaches the shell this daemon refuses to be. - #[test] - fn a_shell_command_reaches_the_shell_whole() { - assert_eq!( - words("shell -c 'ls /system/bin | grep sshserver'"), - ["shell", "-c", "ls /system/bin | grep sshserver"] - ); - } - - /// A shell metacharacter is a byte of an argument and nothing else. The - /// refusal is that it is *not* interpreted, so it must arrive whole. - #[test] - fn metacharacters_are_ordinary_bytes() { - assert_eq!(words("echo a|b"), ["echo", "a|b"]); - assert_eq!(words("echo >out"), ["echo", ">out"]); - assert_eq!(words("echo $HOME"), ["echo", "$HOME"]); - assert_eq!(words("echo a;b"), ["echo", "a;b"]); - assert_eq!(words("echo *"), ["echo", "*"]); - } - - #[test] - fn backslash_escapes_one_character() { - assert_eq!(words("echo a\\ b"), ["echo", "a b"]); - assert_eq!(words("echo \"a\\\"b\""), ["echo", "a\"b"]); - // Inside double quotes, a backslash before anything else stays. - assert_eq!(words("echo \"a\\nb\""), ["echo", "a\\nb"]); - } - - /// A quoted nothing names no program either: `''` is a word that exists and - /// is empty, and resolving it would spawn the program directory itself. - #[test] - fn a_line_that_names_nothing_is_refused() { - for line in ["", " ", "\t\n", "''", "\"\"", " '' arg", "\"\"''"] { - assert_eq!(split(line), Err("no program named".to_string()), "{line:?}"); - } - } - - #[test] - fn an_unfinished_quote_is_refused() { - assert_eq!(split("echo 'x"), Err("unterminated ' quote".to_string())); - assert_eq!(split("echo \"x"), Err("unterminated \" quote".to_string())); - assert!(split("echo x\\").is_err()); - } - - #[test] - fn a_bare_name_resolves_under_system_bin() { - assert_eq!(resolve("echo").as_deref(), Ok("/system/bin/echo")); - assert_eq!(resolve("/system/bin/echo").as_deref(), Ok("/system/bin/echo")); - assert!(resolve("../etc/passwd").is_err()); - assert!(resolve("bin/echo").is_err()); - } -} diff --git a/system/sshserver/src/connection.rs b/system/sshserver/src/connection.rs index 2bc9c1c8bde..eb85605892f 100644 --- a/system/sshserver/src/connection.rs +++ b/system/sshserver/src/connection.rs @@ -24,7 +24,7 @@ use std::collections::{BTreeMap, VecDeque}; use std::io::{Read, Write}; use std::net::{Shutdown, TcpStream}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::sync::mpsc::{self, Receiver, Sender, SyncSender}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -32,7 +32,7 @@ use std::time::{Duration, Instant}; use toyos_ssh::{Authorizer, ChannelId, Event, HostKey, Server}; use crate::program::Program; -use crate::{command, sftp, OsRandom}; +use crate::{sftp, OsRandom}; /// This daemon would not run what was asked: the shell's own convention for /// a command that could not be executed, and the reason is on stderr. @@ -289,17 +289,13 @@ impl Connection { const SHELL: &str = "/system/bin/shell"; impl Connection { - /// Run the named program. **Not through a shell**: `command::split` is the - /// whole grammar the line is read with, and a client that wants a pipe asks - /// for `shell -c` by name. + /// Run the request line as `shell -c` does, as OpenSSH runs it under the + /// user's `$SHELL -c`: SSH has no argument vector, and every client pastes + /// its words into one line for a shell to read. Its quoting is the shell's. fn exec(&mut self, channel: ChannelId, line: &[u8]) { - let argv = std::str::from_utf8(line) - .map_err(|_| "the command is not UTF-8".to_string()) - .and_then(command::split) - .and_then(|argv| Ok(std::iter::once(command::resolve(&argv[0])?).chain(argv.into_iter().skip(1)).collect())); - match argv { - Ok(argv) => self.start(channel, argv, false), - Err(why) => self.refuse(channel, &why), + match std::str::from_utf8(line) { + Ok(line) => self.start(channel, vec![SHELL.to_string(), "-c".to_string(), line.to_string()], false), + Err(_) => self.refuse(channel, "the command is not UTF-8"), } } @@ -311,7 +307,12 @@ impl Connection { } fn start(&mut self, channel: ChannelId, argv: Vec, crlf: bool) { - let spawned = Command::new(&argv[0]).args(&argv[1..]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn(); + let spawned = crate::spawn::own_row(&argv[0]) + .args(&argv[1..]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); let mut child = match spawned { Ok(child) => child, Err(e) => return self.refuse(channel, &format!("cannot run {}: {e}", argv[0])), diff --git a/system/sshserver/src/main.rs b/system/sshserver/src/main.rs index 7b0151f29db..25d163b07a0 100644 --- a/system/sshserver/src/main.rs +++ b/system/sshserver/src/main.rs @@ -1,15 +1,15 @@ //! The machine's SSH server: a shell, a command, and files both ways. A -//! program run over `exec` is an ordinary one — `update` and `swap` are two — -//! and holds its own manifest row, not this daemon's. +//! command run over `exec` is a line for `shell -c`, which the shell, launched +//! in its own manifest row and not this daemon's, runs. //! //! The protocol is `toyos-ssh`'s. This daemon drives it: a thread for each //! connection ([`connection`]), the host key, who may log in, and the bounds //! on clients that have not logged in. -mod command; mod connection; mod program; mod sftp; +mod spawn; use std::fs; use std::net::TcpListener; diff --git a/system/sshserver/src/spawn.rs b/system/sshserver/src/spawn.rs new file mode 100644 index 00000000000..634b6727bb9 --- /dev/null +++ b/system/sshserver/src/spawn.rs @@ -0,0 +1,19 @@ +//! How this daemon starts what a client asks for. + +use std::process::Command; + +/// A command for `program` that runs in that program's own row, not this +/// daemon's: it holds no host key and no network right of sshserver's. +#[cfg(target_os = "toyos")] +pub fn own_row(program: &str) -> Command { + use std::os::toyos::process::CommandExt; + let mut command = Command::new(program); + command.independent(); + command +} + +/// Elsewhere a program has no row, and runs as this daemon's child. +#[cfg(not(target_os = "toyos"))] +pub fn own_row(program: &str) -> Command { + Command::new(program) +} diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index 2cfcf877ddf..6686c4e9e89 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -2051,11 +2051,11 @@ impl Supervisor<'_> { } // **A launch names its parent**: the place it carries, spawned under, or - // the supervisor. One that names neither is refused rather than given to the supervisor, - // which would let it outlive a caller that never asked it to. + // detached, under the supervisor. One that names neither is refused rather than + // detached, which would let it outlive a caller that never asked it to. let under = match request.parent() { Some(Parent::Place(())) => Some(place.0[0]), - Some(Parent::Supervisor) => None, + Some(Parent::Detached) => None, None => { say!("supervisor: launcher: refused a launch that names no parent"); let _ = conn.try_signal(launch::MSG_REFUSED); @@ -2129,28 +2129,25 @@ impl Supervisor<'_> { let (program, verdict) = match resolved { Resolved::Row(row, verdict) => (row.clone(), verdict), Resolved::Package(row, verdict) => (row, verdict), + // **`try_signal` and not `signal`.** A blocking write is the other + // half of the rule that made the read side an event loop: a client + // that never drains its end decides when the supervisor runs again. Resolved::NotDeclared => { - // **`try_send_bytes` and not `send`.** A blocking write is the - // other half of the rule that made the read side an event loop: a - // client that never drains its end decides when the supervisor runs again. - // The `HOME` is the session's: a program no row names is no service. - let home = toyos_manifest::session_home(); - let _ = conn.try_send_bytes(launch::MSG_NOT_DECLARED, home.as_bytes()); + say!("supervisor: launcher: no row declares {}", request.program); + let _ = conn.try_signal(launch::MSG_NOT_PERMITTED); return; } Resolved::Refused(why) => { say!("supervisor: launcher: {why}"); - let _ = conn.try_signal(launch::MSG_REFUSED); + let _ = conn.try_signal(launch::MSG_NOT_PERMITTED); return; } }; - // **`MSG_REFUSED`, never `MSG_NOT_DECLARED`**: the latter is std's cue to - // spawn the program itself. let (starts, binary, prepared) = match verdict { Ok(judged) => judged, Err(why) => { say!("supervisor: launcher: {} in {} may not start {}: {why}", caller.row.name, caller.session, program.name); - let _ = conn.try_signal(launch::MSG_REFUSED); + let _ = conn.try_signal(launch::MSG_NOT_PERMITTED); return; } }; @@ -2475,7 +2472,7 @@ enum Resolved<'a, V> { Row(&'a Program, V), /// An installed package, whose row is the image's `[apps]` list. Package(Program, V), - /// Nothing in the image declares it, and the caller spawns it itself. + /// Nothing in the image declares it. NotDeclared, /// A path under `/apps` whose package does not answer for it. Refused(String), @@ -2491,11 +2488,8 @@ enum Resolved<'a, V> { /// classifies as one thing and opens as another, and `start` spawns the string /// the client sent, so the two need not even name one file. /// -/// 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, V>(system: &'a Manifest, path: &str, judge: impl FnOnce(Target<'_>) -> V) -> Resolved<'a, V> { - if !package::is_canonical(path) { + if !toyos::launch::is_canonical(path) { return Resolved::Refused(format!("{path:?} is not a canonical path")); } let Some(name) = package::package_of(path) else { diff --git a/system/swap/src/main.rs b/system/swap/src/main.rs index b2910e4b896..bc07efa7edd 100644 --- a/system/swap/src/main.rs +++ b/system/swap/src/main.rs @@ -101,7 +101,7 @@ fn take(args: &[String]) -> Result { fn ask(asked: &Asked) -> (Answer, Option) { let unasked = |why: String| (Answer::Unasked(why), None); let Some(held) = Endowments::get().take::(toyos_swap::LABEL) else { - return unasked(Refusal::NoAuthority.to_string()); + return unasked(format!("{}; run it with `launch swap`", Refusal::NoAuthority)); }; let supervisor = match held.open(toyos_swap::PORT) { Ok(conn) => conn, diff --git a/system/terminal/src/main.rs b/system/terminal/src/main.rs index 3a484bc76d7..775f987b5e9 100644 --- a/system/terminal/src/main.rs +++ b/system/terminal/src/main.rs @@ -284,6 +284,7 @@ fn shell(surface: &toyos::port::Connector) -> std::process::Child { .duplicate() .expect("terminal: the kernel refused a duplicate of its own surface connector"); Command::new("/system/bin/shell") + .independent() .provide(surface::SERVICE, handed.into_raw().0) .stdin(process::tty_piped()) .stdout(process::tty_piped()) diff --git a/system/toybox/src/ps.rs b/system/toybox/src/ps.rs index 7d3841408d3..c1ab5cb96c9 100644 --- a/system/toybox/src/ps.rs +++ b/system/toybox/src/ps.rs @@ -64,7 +64,7 @@ fn state_str(s: u8) -> &'static str { pub fn main(_args: Vec) { let Some(cap) = Endowments::get().take::(SYSCAP_LABEL) else { - eprintln!("ps: this program was endowed no system capability"); + eprintln!("ps: this program was endowed no system capability; run it with `launch ps`"); std::process::exit(1); }; let mut buf = vec![0u8; HEADER + ENTRY * 128]; diff --git a/system/toybox/src/reboot.rs b/system/toybox/src/reboot.rs index 7cb34074f6a..dbe699d9435 100644 --- a/system/toybox/src/reboot.rs +++ b/system/toybox/src/reboot.rs @@ -8,6 +8,9 @@ use toyos::power::{self, Stop}; pub fn main(_args: Vec) { // Comes back only refused: on the other path the machine is already at its firmware. let refused = power::stop(Stop::Reboot); - eprintln!("reboot: refused ({refused:?})"); + match refused { + power::Refused::NotEndowed => eprintln!("reboot: this process holds no `power` connector; run it with `launch reboot`"), + refused => eprintln!("reboot: refused ({refused:?})"), + } std::process::exit(1); } diff --git a/system/toybox/src/screen.rs b/system/toybox/src/screen.rs index 7c996d8cb05..1067e4401ed 100644 --- a/system/toybox/src/screen.rs +++ b/system/toybox/src/screen.rs @@ -30,6 +30,7 @@ pub fn main(args: Vec) { }; match shown { Ok(shown) => say(shown), + Err(e @ display::Error::NotEndowed) => fail(&format!("{e}; run it with `launch screen`")), Err(e) => fail(&e.to_string()), } } diff --git a/system/toybox/src/shutdown.rs b/system/toybox/src/shutdown.rs index 23c351e595f..ceb798b3edc 100644 --- a/system/toybox/src/shutdown.rs +++ b/system/toybox/src/shutdown.rs @@ -12,6 +12,9 @@ use toyos::power::{self, Stop}; pub fn main(_args: Vec) { // Comes back only refused: on the other path the power is already off. let refused = power::stop(Stop::Shutdown); - eprintln!("shutdown: refused ({refused:?})"); + match refused { + power::Refused::NotEndowed => eprintln!("shutdown: this process holds no `power` connector; run it with `launch shutdown`"), + refused => eprintln!("shutdown: refused ({refused:?})"), + } std::process::exit(1); } diff --git a/system/update/src/main.rs b/system/update/src/main.rs index 2c3d7ef1b81..8a0aca33489 100644 --- a/system/update/src/main.rs +++ b/system/update/src/main.rs @@ -49,7 +49,7 @@ struct Held { /// The partition endowed under `label`: a session through the connector of /// that name, which reaches one partition and lists it. fn held(label: &str) -> Result { - let none = || format!("this process holds no `{label}`: the supervisor grants the idle slot to one update at a time, and says why where it grants none"); + let none = || format!("this process holds no `{label}`: the supervisor grants the idle slot to one update at a time, and says why where it grants none; run it with `launch update`"); let names = endow::namespace().ok_or_else(none)?; let own = toyos::namespace::build().keep(names, &[label]).finish().map_err(|_| none())?; let listed = diskserver::list(&own, label).map_err(|why| format!("`{label}` would not list its partition: {why:?}"))?; diff --git a/tests/guest/rust-tests/src/bin/app_view.rs b/tests/guest/rust-tests/src/bin/app_view.rs index 2cc9fd9c654..af966a34a27 100644 --- a/tests/guest/rust-tests/src/bin/app_view.rs +++ b/tests/guest/rust-tests/src/bin/app_view.rs @@ -33,6 +33,7 @@ use std::fs; use std::io::ErrorKind; +use std::os::toyos::process::CommandExt; use std::process::Command; use toyos::fs::{Dir, Error, Refused, O_APPEND, O_CREATE, O_CREATE_NEW, O_READ, O_TRUNCATE, O_WRITE}; @@ -92,7 +93,7 @@ fn job() { let mut red = Vec::new(); // Refused, and the supervisor says why (the metal row's judge reads it). - match Command::new(ROW_PROGRAM).arg(APP).output() { + match Command::new(ROW_PROGRAM).independent().arg(APP).output() { Err(e) => println!(" a package named after the shell's row: refused ({e})"), Ok(ran) => red.push(format!("a package named after the shell's row ran: {ran:?}")), } @@ -100,7 +101,7 @@ fn job() { match fs::write(HOME, b"no folder") { Err(e) => red.push(format!("no file could be planted where the app's folder goes: {e}")), Ok(()) => { - match Command::new(PROGRAM).arg(APP).output() { + match Command::new(PROGRAM).independent().arg(APP).output() { Err(e) => println!(" a package whose folder is a file: refused ({e})"), Ok(ran) => red.push(format!("a package whose folder is a file ran: {ran:?}")), } @@ -108,7 +109,7 @@ fn job() { } } - let ran = Command::new(PROGRAM).arg(APP).output().expect("launch the package through the launcher"); + let ran = Command::new(PROGRAM).independent().arg(APP).output().expect("launch the package through the launcher"); print!("{}", String::from_utf8_lossy(&ran.stdout)); print!("{}", String::from_utf8_lossy(&ran.stderr)); if ran.status.code() != Some(0) { @@ -253,13 +254,13 @@ fn app() { /// `line` run by a shell this job launches, whose `login` row opens a login /// session for what it launches: the session `grants` is answered in. fn in_login(line: &str) -> std::process::Output { - Command::new("/system/bin/shell").args(["-c", line]).current_dir("/").output().expect("launch a shell") + Command::new("/system/bin/shell").independent().args(["-c", line]).current_dir("/").output().expect("launch a shell") } /// What the package saw as `game`: its working directory, the ROMs gbae's /// menu lists there, and whether its save beside the first was written. fn played(red: &mut Vec, when: &str) -> Option { - let ran = Command::new(PROGRAM).arg(GAME).current_dir("/").output().expect("launch the package through the launcher"); + let ran = Command::new(PROGRAM).independent().arg(GAME).current_dir("/").output().expect("launch the package through the launcher"); let out = String::from_utf8_lossy(&ran.stdout).into_owned(); print!("{out}"); match out.lines().find_map(|l| l.strip_prefix(SAW)) { @@ -272,12 +273,12 @@ fn played(red: &mut Vec, when: &str) -> Option { } fn granted(red: &mut Vec) { - let _ = in_login("/system/bin/grants revoke appview"); + let _ = in_login("launch /system/bin/grants revoke appview"); let _ = fs::remove_dir_all(GAMES); fs::create_dir_all(GAMES).expect("make the folder the package is granted"); fs::write(ROM, b"a ROM's bytes").expect("put a ROM in it"); - match Command::new("/system/bin/grants").args(["add", "appview", GAMES]).output() { + match Command::new("/system/bin/grants").independent().args(["add", "appview", GAMES]).output() { Ok(out) if out.status.code() == Some(1) && String::from_utf8_lossy(&out.stdout).contains("only a login session may ask for grants") => { @@ -285,16 +286,16 @@ fn granted(red: &mut Vec) { } other => red.push(format!("grants in the machine's session was answered {other:?}")), } - let apps = in_login(&format!("/system/bin/grants add appview {HOME}")); + let apps = in_login(&format!("launch /system/bin/grants add appview {HOME}")); match String::from_utf8_lossy(&apps.stdout) { said if said.contains("where every app keeps its own folder") => println!(" {HOME} as a grant: refused"), _ => red.push(format!("{HOME} as a grant was answered {apps:?}")), } - let added = in_login(&format!("/system/bin/grants add appview {GAMES}")); + let added = in_login(&format!("launch /system/bin/grants add appview {GAMES}")); if !String::from_utf8_lossy(&added.stdout).contains("appview is granted /home/toy/Games read-write") { red.push(format!("the grant was answered {added:?}")); } - let listed = in_login("/system/bin/grants list"); + let listed = in_login("launch /system/bin/grants list"); if !String::from_utf8_lossy(&listed.stdout).contains("appview read-write /home/toy/Games\n") { red.push(format!("grants list answered {listed:?}")); } @@ -329,7 +330,7 @@ fn granted(red: &mut Vec) { } let _ = fs::remove_file(SAVE); - let revoked = in_login("/system/bin/grants revoke appview"); + let revoked = in_login("launch /system/bin/grants revoke appview"); if !String::from_utf8_lossy(&revoked.stdout).contains("appview's grant is revoked") { red.push(format!("the revoke was answered {revoked:?}")); } diff --git a/tests/guest/rust-tests/src/bin/launch_toctou.rs b/tests/guest/rust-tests/src/bin/launch_toctou.rs index 5b8be7597c1..e2859c1cedc 100644 --- a/tests/guest/rust-tests/src/bin/launch_toctou.rs +++ b/tests/guest/rust-tests/src/bin/launch_toctou.rs @@ -10,17 +10,17 @@ //! //! - toybox's `ps` exits 0 only holding a `SysCap` with `ROSTER`, which only the //! row gives: the row's own program under the row; -//! - `ps` without one exits 1: the link named this binary when the launcher -//! asked, so the caller spawned it directly, and it named toybox again when the -//! kernel opened it; -//! - this binary as [`ROLE`] exits [`PLAIN`] without a `SysCap` and [`EXPLOIT`] -//! with one — the caller's bytes under the row's claims, the defect. +//! - a launch refused `PermissionDenied`: the link named this binary, which no +//! row declares, when the launcher asked; +//! - this binary as [`ROLE`] exits [`EXPLOIT`] holding a `SysCap`: the caller's +//! bytes under the row's claims, the defect. //! -//! Every launch must start and be waited, and both of the first two must happen: -//! one shows a launch of the row runs, the other that the link really moved -//! while launches were made. +//! Each launch must start and be waited or be refused, and both of the first +//! two must happen: one shows a launch of the row runs, the other that the +//! link really moved while launches were made. use std::os::toyos::fs::symlink; +use std::os::toyos::process::CommandExt; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -44,8 +44,6 @@ const EXPLOIT: i32 = 123; const PLAIN: i32 = 77; /// Toybox's `ps`, having read the roster. const PS_UNDER_ROW: i32 = 0; -/// Toybox's `ps`, endowed no `SysCap`. -const PS_DIRECT: i32 = 1; const ATTEMPTS: u32 = 100; @@ -78,18 +76,19 @@ fn attacker() { } }); - let (mut under_row, mut direct) = (0u32, 0u32); + let (mut under_row, mut refused) = (0u32, 0u32); for attempt in 1..=ATTEMPTS { - // Captured rather than null: a direct spawn holds no slot it was not - // given, and `ps` says why it refused on stderr. - let output = Command::new(LINK) - .arg(ROLE) - .stdin(Stdio::null()) - .output() - .unwrap_or_else(|e| panic!("launch {attempt} of {LINK} was refused or not waited: {e}")); + // Captured rather than null: `ps` says why it refused on stderr. + let output = match Command::new(LINK).independent().arg(ROLE).stdin(Stdio::null()).output() { + Ok(output) => output, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + refused += 1; + continue; + } + Err(e) => panic!("launch {attempt} of {LINK} was not waited: {e}"), + }; match output.status.code() { Some(PS_UNDER_ROW) => under_row += 1, - Some(PS_DIRECT) | Some(PLAIN) => direct += 1, Some(EXPLOIT) => panic!( "launch_toctou: launch {attempt} ran the caller's bytes holding the `toybox` \ row's capability: the program a launched row runs was opened from the \ @@ -102,6 +101,6 @@ fn attacker() { stop.store(true, Ordering::Relaxed); flipper.join().expect("the re-point thread failed"); assert!(under_row > 0, "none of {ATTEMPTS} launches ran the `toybox` row's program under its row"); - assert!(direct > 0, "the link never named this binary when the launcher asked in {ATTEMPTS} launches"); + assert!(refused > 0, "the link never named this binary when the launcher asked in {ATTEMPTS} launches"); println!("launch_toctou: PASS ({under_row} of {ATTEMPTS} launches ran the row's program under it, none the caller's)"); } diff --git a/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs b/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs index d347dd2f3a3..20b939aa41e 100644 --- a/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs +++ b/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs @@ -12,17 +12,19 @@ //! (`tests/images/launchcase.toml`), whose test-runner holds a launcher. use toyos::AsHandle; -use toyos::spawn::{Launched, Parent, Spawn}; +use toyos::spawn::{Parent, Spawn, Start}; +use toyos::fd::Kind; use toyos_abi::RawHandle; const UNSENT: &str = "nothing-sent-said-this"; const SENT: &str = "slot-sent-said-this"; /// `program` with `argv` launched with exactly `fds`, and waited for. -fn launched(program: &str, argv: &[u8], fds: &[(u32, RawHandle)]) { +fn launched(program: &str, argv: &[u8], fds: &[(u32, RawHandle, Kind)]) { let request = Spawn { program, image: None, + image_held: false, argv, env: b"", cwd: "/", @@ -30,12 +32,14 @@ fn launched(program: &str, argv: &[u8], fds: &[(u32, RawHandle)]) { endow: &[], provided: &[], parent: Parent::Caller, + independent: true, }; - let mut answer = vec![0u8; toyos::ipc::MAX_FRAME_LEN as usize]; - let child = match request.launch(&mut answer) { - Ok(Launched::Started(child)) => child, - Ok(Launched::Direct { .. }) => panic!("{program} was not launched: this boot's runner holds a launcher"), - Err(refused) => panic!("the launch of {program} was refused: {refused:?}"), + let child = match request.start().map(|start| match start { + Start::Launch(launch) => launch.launch(), + Start::Helper(_) => panic!("an independent spawn of {program} was judged a helper"), + }) { + Ok(Ok(child)) => child, + Ok(Err(refused)) | Err(refused) => panic!("the launch of {program} was refused: {refused:?}"), }; let end = child.wait().unwrap_or_else(|e| panic!("wait for {program}: {e:?}")); assert!(end.success(), "{program} launched with descriptors {fds:?} ended {end:?}"); @@ -43,14 +47,14 @@ fn launched(program: &str, argv: &[u8], fds: &[(u32, RawHandle)]) { fn main() { let (read, write) = toyos::pipe_pair().expect("a pipe"); - launched("/system/bin/cat", b"/system/bin/cat", &[(1, write.as_handle())]); + launched("/system/bin/cat", b"/system/bin/cat", &[(1, write.as_handle(), Kind::Handle)]); drop(write); let mut buf = [0u8; 64]; let n = read.read(&mut buf).expect("read what cat wrote"); assert_eq!(n, 0, "a launched cat sent no stdin wrote {:?}", String::from_utf8_lossy(&buf[..n])); launched("/system/bin/echo", format!("/system/bin/echo\0{UNSENT}").as_bytes(), &[]); - let out = toyos::fd::copy(1).expect("this job's stdout").expect("this job holds a stdout"); - launched("/system/bin/echo", format!("/system/bin/echo\0{SENT}").as_bytes(), &[(1, out.as_handle())]); + let (out, kind) = toyos::fd::copy(1).expect("this job's stdout").expect("this job holds a stdout"); + launched("/system/bin/echo", format!("/system/bin/echo\0{SENT}").as_bytes(), &[(1, out.as_handle(), kind)]); eprintln!("launch_unsent_stdio: a launched cat sent no stdin read end of file"); } diff --git a/tests/guest/rust-tests/src/bin/process_tree.rs b/tests/guest/rust-tests/src/bin/process_tree.rs index b86719d967f..340097db406 100644 --- a/tests/guest/rust-tests/src/bin/process_tree.rs +++ b/tests/guest/rust-tests/src/bin/process_tree.rs @@ -44,6 +44,11 @@ use std::os::toyos::process::ExitStatusExt; use toyos_abi::end::{End, Fault}; const SELF_PATH: &str = "/system/bin/test_rs_process_tree"; +/// This program copied to `/tmp`, which the kernel opens itself, under the +/// same name: a chain's links spawn it with no file server's connection +/// each, so a file server's bound on one share's clients is not what stops +/// the chain before its depth does. +const LINK_PATH: &str = "/tmp/test_rs_process_tree"; /// `/system/bin/toybox` under the name of an applet that reads its input until /// it ends: a declared program, so a launch, and one that runs until killed /// while A holds its input open. @@ -201,8 +206,7 @@ fn under_b_is_gone(grown: &Grown) { parent: Parent::Place(place), job: toyos::job::own().expect("a handle to this process's job").into_raw(), }; - let mut home = [0u8; 256]; - match launch::launch(&conn, &request, &mut home) { + match launch::launch(&conn, &request) { Ok(Outcome::Gone) => {} Ok(Outcome::Started(child)) => panic!("a launch under B {when} started {child:?}"), Ok(_) => panic!("a launch under B {when} was answered as something other than gone"), @@ -286,8 +290,7 @@ fn a_pipe_is_no_place() { parent: Parent::Place(place), job: toyos::job::own().expect("a handle to this process's job").into_raw(), }; - let mut home = [0u8; 256]; - match launch::launch(&conn, &request, &mut home) { + match launch::launch(&conn, &request) { Ok(Outcome::Refused) => {} Ok(Outcome::Started(child)) => panic!("a launch whose place is a pipe started {child:?}"), Ok(_) => panic!("a launch whose place is a pipe was answered as something other than refused"), @@ -300,7 +303,8 @@ fn a_pipe_is_no_place() { /// the link 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) + std::fs::copy(SELF_PATH, LINK_PATH).expect("copy this program to /tmp for the chain"); + let mut first = Command::new(LINK_PATH) .args(["link", "1"]) .stdout(Stdio::piped()) .spawn() @@ -337,7 +341,7 @@ fn link(k: u32) -> ! { println!("unbounded at link {k}"); park(); } - let next = Command::new(SELF_PATH).args(["link", &(k + 1).to_string()]).spawn(); + let next = Command::new(LINK_PATH).args(["link", &(k + 1).to_string()]).spawn(); if let Err(e) = next { println!("link {k}: its next link was refused: {e}"); } @@ -348,6 +352,7 @@ fn link(k: u32) -> ! { /// end is published: it reads the input this process holds open. fn a_detached_program_outlives_its_shell() { let mut shell = Command::new(SHELL) + .independent() .args(["-c", &format!("detach {HELD}")]) .stdin(Stdio::piped()) .spawn() @@ -391,11 +396,12 @@ fn park() -> ! { fn b() -> ! { let conn = endow::service(BACK).expect("A endowed a port back"); - // Admitted under B, then refused by the loader: its hold on B goes. - assert!(Command::new("/system/bin/no_such_program").spawn().is_err(), "a program that is not there started"); + // Admitted under B, then refused by the loader: its hold on B goes. On + // `/tmp`, which the kernel opens itself, so its loader is what refuses it. + assert!(Command::new("/tmp/no_such_program").spawn().is_err(), "a program that is not there started"); let c = Command::new(SELF_PATH).arg("c").stdin(Stdio::null()).spawn().expect("B spawns C"); - let d = Command::new(HELD).spawn().expect("B launches D"); - let e = Command::new(HELD).under_supervisor().spawn().expect("B asks the supervisor for E"); + let d = Command::new(HELD).independent().spawn().expect("B launches D"); + let e = Command::new(HELD).independent().detached().spawn().expect("B launches E detached"); let own = endow::this_process(); let handles = [ syscall::dup(RawHandle(c.as_raw_handle())).expect("a copy of C"), diff --git a/tests/guest/rust-tests/src/bin/scale_window.rs b/tests/guest/rust-tests/src/bin/scale_window.rs index eadf8a5519d..d0a4a62e04f 100644 --- a/tests/guest/rust-tests/src/bin/scale_window.rs +++ b/tests/guest/rust-tests/src/bin/scale_window.rs @@ -10,6 +10,7 @@ //! window, its new fill on the panel, then Enter again. The harness presses //! Enter only once it has read the panel, so neither step outruns it. +use std::os::toyos::process::CommandExt; use std::process::Command; use window::{Color, Event, Size, Window}; @@ -37,6 +38,7 @@ fn main() { } enter(&mut window); let status = Command::new("/system/bin/toybox") + .independent() .args(["screen", "scale", "1"]) .status() .unwrap_or_else(|e| panic!("scale_window: `screen scale 1` was not launched: {e}")); diff --git a/tests/toyos.rs b/tests/toyos.rs index d9d0ca4e0d2..6586797dfee 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -2751,7 +2751,8 @@ fn a_usb_key_reaches_the_console() -> Result<(), String> { } } await_guest(&mut qemu, &mut console, "the shell to run what was typed", |c| { - c.lines().any(|l| l.contains("spawn: ") && l.contains("toybox")) + // A bare `ls` is the shell's helper, spawned by the name it was typed as. + c.lines().any(|l| l.contains("spawn: /system/bin/ls ")) })?; serial::Serial::named("the typed-on console boot", console).must_be_clean() } @@ -5487,10 +5488,10 @@ fn process_tree(back: &metal::Readback) -> Result<(), String> { back.job_passed("test_rs_process_tree")?; let kernel = back.kernel(); let log = kernel.text(); - let unloaded = log.lines().filter(|l| l.contains("spawn: /system/bin/no_such_program: ")).count(); + let unloaded = log.lines().filter(|l| l.contains("spawn: /tmp/no_such_program: ")).count(); if unloaded != 2 { return Err(format!( - "the loader refused /system/bin/no_such_program {unloaded} times, not once per B" + "the loader refused /tmp/no_such_program {unloaded} times, not once per B" )); } let refused: Vec<&str> = log.lines().filter(|l| l.contains("spawn: refused under pid ")).collect();