From 043b86e1d0f717a962cfbe04809a1beb479b662f Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 21:39:57 +0200 Subject: [PATCH 01/12] A package granted a folder of the home by `grants` starts in it and sees it Stage 1 of launch-time consent: grants by command. - `toyos_manifest::grants`: the store the supervisor alone writes (`/state/supervisor/grants`), keyed on the user, the package and the SHA-256 of the exact binary the supervisor starts, so an update holds no grant until it is granted again; the folder rules (a canonical directory inside the session's home, never the home, nor `Apps` or anything in it, within the 54-byte grant root and std's four-component capability depth), each refusal a whole sentence; `decide`, the stored grant for that binary at most the image's ceiling; `cwd`, the caller's own inside the folder and the folder's root otherwise; and the request codec of the `grants` port. - `[apps] folder = "read-write"` is the ceiling, and until signed package entries carry a package's own request, what every package asks for. It lives in `[apps]` and not beside each package because `pkg`, which writes a package's `manifest.toml`, is outside this change, gbae's release carries no request, and a per-package line in `system.toml` would be the image naming an app. The build refuses any other spelling, and `[apps]` now refuses an unknown key, so a misspelt `folder` is no silent ceiling. - The supervisor serves `grants`, minted per start with the holder's row and session like a launcher, endowed under a label of its own and never in `svc`; it answers a login session alone. `add` checks the folder rules and the ceiling, reads the package's manifest, checks the folder is a directory and not a link, and hashes the program, all on its file worker; the store is written beside itself and renamed over. A launch of a package hashes its binary on the worker with the launch's other files, and a grant for it appends the folder to the package's view and prepares the launch again in the folder; a granted folder no longer a directory refuses the launch. - `/system/bin/grants list | add [read-only] | revoke`: the one row the build lets receive `grants`; the shell and sshserver list it. - The fileserver already follows no symlink at a grant's root: DATA's flat namespace lists nothing through one and makes nothing under one. A host test holds that, and the resolver's escape suite runs under a granted folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- Cargo.lock | 8 + Cargo.toml | 1 + src/build.rs | 80 +++- system.toml | 16 +- tests/proctreecase/system.toml | 13 +- tests/toyos-rust-tests/src/bin/app_view.rs | 166 +++++++- tests/toyos.rs | 7 +- toyos-manifest/src/grants.rs | 462 +++++++++++++++++++++ toyos-manifest/src/lib.rs | 53 ++- userland/fileserver/src/data.rs | 23 + userland/fileserver/src/resolve.rs | 22 + userland/grants/Cargo.toml | 14 + userland/grants/src/main.rs | 81 ++++ userland/supervisor/src/main.rs | 286 +++++++++++-- 14 files changed, 1168 insertions(+), 64 deletions(-) create mode 100644 toyos-manifest/src/grants.rs create mode 100644 userland/grants/Cargo.toml create mode 100644 userland/grants/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 75516233810..ecf3c7b6304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2566,6 +2566,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "grants" +version = "0.1.0" +dependencies = [ + "toyos", + "toyos-manifest", +] + [[package]] name = "group" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index 983504f7503..5ecc2b88c96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ members = [ "userland/editor", "userland/filepicker", "userland/filepicker-api", + "userland/grants", "userland/files", "userland/fileserver", "userland/host", diff --git a/src/build.rs b/src/build.rs index b383792baab..6aae599de74 100644 --- a/src/build.rs +++ b/src/build.rs @@ -127,12 +127,31 @@ struct BootConfig { start: Vec, } +/// **Unknown fields refused**: a misspelled `folder` would be an image that +/// silently grants no package a folder. #[derive(Deserialize, Default)] -#[serde(default, rename_all = "kebab-case")] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] struct AppsConfig { /// Connectors, and no `devices` or `syscap` beside it: an installed package /// reaches servers and claims no hardware. receives: Vec, + /// The most of a folder of the session's home a package may be granted, + /// `read-only` or `read-write` (`toyos_manifest::grants`). It names a + /// right and never an app. + folder: Option, +} + +/// `[apps] folder`, spelled as `toyos_manifest::grants::Access` spells it. +fn apps_folder(config: &SystemConfig) -> Result, String> { + config + .apps + .folder + .as_deref() + .map(|word| { + toyos_manifest::grants::Access::parse(word) + .ok_or_else(|| format!("`[apps] folder` is {word:?}, and is `read-only` or `read-write`")) + }) + .transpose() } /// **Unknown fields refused**: a misspelled `starts` would be a row that @@ -565,33 +584,40 @@ const SUPERVISOR_PROGRAM: &str = "supervisor"; /// /// Not `launcher`: a row holds it by its `starts`, badged with its row, and no /// row receives it. -const SUPERVISOR_SERVED: &[&str] = &[toyos_swap::PORT, "power"]; +const SUPERVISOR_SERVED: &[&str] = &[toyos_swap::PORT, "power", toyos_manifest::grants::PORT]; + +/// The supervisor's ports that one row alone may receive, each with that row: +/// the swap port, whose holder replaces any service's binary, and the grants +/// port, whose holder writes which folder of the home a package sees. +const HELD_ALONE: &[(&str, &str)] = + &[(toyos_swap::PORT, toyos_swap::HOLDER), (toyos_manifest::grants::PORT, toyos_manifest::grants::HOLDER)]; -/// Who may hold the two authorities that change what the machine runs: -/// the swap port, [`toyos_swap::HOLDER`] and nothing else — no other -/// `[programs]` row and never `[apps]` — and the idle slot, +/// Who may hold the authorities that change what the machine runs or what a +/// package sees: each of [`HELD_ALONE`]'s ports, its holder and nothing else +/// — no other `[programs]` row and never `[apps]` — and the idle slot, /// [`toyos_update::slots::HOLDER`] alone. /// /// **Checked on every manifest rendered, not only on the committed configs**, -/// because the holder of the one can replace any service's binary and the -/// holder of the other writes the image the machine boots next. +/// because the swap port's holder can replace any service's binary, the +/// grants port's hands a package part of the home, and the idle slot's +/// holder writes the image the machine boots next. fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { for (name, program) in &config.programs { - if name != toyos_swap::HOLDER && program.receives.iter().any(|r| r == toyos_swap::PORT) { - return Err(format!( - "`{name}` receives `{}`, which only `{}` may hold", - toyos_swap::PORT, - toyos_swap::HOLDER - )); + for (port, holder) in HELD_ALONE { + if name != holder && program.receives.iter().any(|r| r == port) { + return Err(format!("`{name}` receives `{port}`, which only `{holder}` may hold")); + } } if name != toyos_update::slots::HOLDER && program.slots { return Err(format!("`{name}` asks for `slots`, which only `{}` may hold", toyos_update::slots::HOLDER)); } } - if config.apps.receives.iter().any(|r| r == toyos_swap::PORT) { - return Err(format!("`[apps] receives` names `{}`, which only `{}` may hold", toyos_swap::PORT, toyos_swap::HOLDER)); + for (port, holder) in HELD_ALONE { + if config.apps.receives.iter().any(|r| r == port) { + return Err(format!("`[apps] receives` names `{port}`, which only `{holder}` may hold")); + } } - Ok(()) + apps_folder(config).map(drop) } /// What a row may start through the launcher, and which rows open a login @@ -651,11 +677,13 @@ fn render_manifest(config: &SystemConfig) -> Vec { restart: cfg.restart, starts: cfg.starts.clone(), login: cfg.login, + folder: None, } }) .collect(), supervisor_serves: SUPERVISOR_SERVED.iter().map(|s| (*s).to_string()).collect(), apps: config.apps.receives.clone(), + folder: apps_folder(config).expect("the gate above read it"), start: config.boot.start.clone(), }; toyos_manifest::render(&manifest) @@ -2952,6 +2980,26 @@ mod tests { assert!(held_by_their_holders_alone(&apps).is_err()); let slots: SystemConfig = toml::from_str("[programs.shell]\nslots = true\n").unwrap(); assert!(held_by_their_holders_alone(&slots).is_err()); + let grants: SystemConfig = toml::from_str("[programs.grants]\nreceives = [\"grants\"]\n").unwrap(); + assert!(held_by_their_holders_alone(&grants).is_ok()); + let shell: SystemConfig = toml::from_str("[programs.shell]\nreceives = [\"grants\"]\n").unwrap(); + assert!(held_by_their_holders_alone(&shell).is_err()); + let apps: SystemConfig = toml::from_str("[apps]\nreceives = [\"grants\"]\n").unwrap(); + assert!(held_by_their_holders_alone(&apps).is_err()); + } + + /// `[apps] folder` is one of the two accesses, spelled as the manifest + /// spells it, and nothing else reaches a manifest. + #[test] + fn the_apps_folder_ceiling_is_one_of_two_spellings() { + let gate = |text: &str| held_by_their_holders_alone(&toml::from_str(text).unwrap()); + assert!(gate("[apps]\nfolder = \"read-write\"\n").is_ok()); + assert!(gate("[apps]\nfolder = \"read-only\"\n").is_ok()); + assert!(gate("[apps]\n").is_ok()); + for bad in ["rw", "read_write", "write", "Read-Write", ""] { + assert!(gate(&format!("[apps]\nfolder = {bad:?}\n")).is_err(), "{bad}"); + } + assert!(toml::from_str::("[apps]\nfolders = \"read-write\"\n").is_err()); } /// Every committed config passes, and each refusal has a config that diff --git a/system.toml b/system.toml index fdc2ef3c6c2..aca7da80c9d 100644 --- a/system.toml +++ b/system.toml @@ -28,8 +28,14 @@ start = ["logkeeper", "diskserver", "fileserver", "compositor", "soundserver", " # `devices` and `syscap` have no spelling here, so nothing installed claims # hardware or enters the RT band — and no directory: a package sees its own # `/apps/` read-only and its own `/home/toy/Apps/`, its `HOME`. +# +# `folder` is the most of a folder of the session's home a package may be +# granted (`/system/bin/grants`), and until a signed package entry carries a +# package's own request it is what every package asks for: read-write, since +# gbae saves beside the ROM it plays. [apps] receives = ["compositor", "soundserver", "filepicker"] +folder = "read-write" # `service = true` makes a row a system service: the supervisor starts it with `HOME` at # its own `/state/` and makes that directory first. Every other row runs @@ -92,7 +98,7 @@ devices = ["pci:1af4:1041"] [programs.sshserver] service = true receives = ["netstack"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "sshserver", "swap", "update", "/apps"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "sshserver", "swap", "update", "grants", "/apps"] login = true # `swap` is the authority to replace a running service's binary; the build @@ -108,6 +114,12 @@ receives = ["swap"] [programs.update] slots = true +# Which folder of the session's home an installed package is granted: the one +# row the build lets receive `grants`, which the supervisor answers from a login +# session alone. `grants add gbae /home/toy/Games` grants one from a shell. +[programs.grants] +receives = ["grants"] + # A name's addresses, asked of netstack's resolver: `netstack` is the whole of what it # holds. [programs.host] @@ -143,7 +155,7 @@ starts = ["shell"] [programs.shell] receives = ["surface"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "sshserver", "swap", "update", "/apps"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "sshserver", "swap", "update", "grants", "/apps"] [programs.doom] receives = ["compositor", "soundserver"] diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index 48779a41b24..c55ff8182cc 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -21,10 +21,16 @@ # And `app_view`: test-runner's row lists `/apps`, so a job launches the # package it installed under the image's `[apps]` row and the view an # installed package is minted, and is refused one named after the `shell` row. +# It grants that package a folder through `grants`, which test-runner's row +# lists, so it is refused in the machine's session, and the shell's, so the +# login session the shell opens is answered. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] +[apps] +folder = "read-write" + # `every_boot_config_runs_logkeeper` is what refuses a boot config without it: the # kernel keeps the record ring and writes no file, so such a boot's `/log` is # empty. It claims no device and serves no port. @@ -36,7 +42,7 @@ syscap = ["logread"] # because test-runner hands each binary a duplicate of its capability. [programs.test-runner] syscap = ["dup", "roster"] -starts = ["toybox", "shell", "swap", "update", "/apps"] +starts = ["toybox", "shell", "swap", "update", "grants", "/apps"] # `roster`, which a child the job spawns itself never holds: a child holding # it ran under this row, and `launch_toctou` asks which bytes that child was. @@ -45,9 +51,12 @@ syscap = ["roster"] starts = ["swap"] [programs.shell] -starts = ["toybox", "shell"] +starts = ["toybox", "shell", "grants"] login = true +[programs.grants] +receives = ["grants"] + [programs.swap] receives = ["swap"] diff --git a/tests/toyos-rust-tests/src/bin/app_view.rs b/tests/toyos-rust-tests/src/bin/app_view.rs index 2bdc76e619f..391bfc2a9b5 100644 --- a/tests/toyos-rust-tests/src/bin/app_view.rs +++ b/tests/toyos-rust-tests/src/bin/app_view.rs @@ -20,6 +20,16 @@ //! //! Every arm runs, so one run names each one that is red. The job then holds //! the package to what it installed. +//! +//! Then it grants the package a folder of the home, `/home/toy/Games` holding +//! a ROM, through `/system/bin/grants`: refused in this job's own session, +//! which is the machine's, and refused `Apps/appview` by name, from the login +//! session a shell opens. Granted it there, the package run as `game` browses +//! its working directory with gbae's own `list_directory` and saves beside +//! the ROM as gbae does: it starts in the folder, lists the ROM and writes +//! `test.sav`. The same package with one byte more is another binary and +//! holds no folder; restored, it holds it again; revoked, its next launch +//! lists no ROM and writes nothing. use std::fs; use std::io::ErrorKind; @@ -47,10 +57,18 @@ const OTHER_PACKAGE_FILE: &str = "/apps/other/kept"; const OTHER_FOLDER_FILE: &str = "/home/toy/Apps/other/Data/kept"; const KEPT: &[u8] = b"what the app wrote in its own folder"; const APP: &str = "app"; +const GAME: &str = "game"; +const GAMES: &str = "/home/toy/Games"; +const ROM: &str = "/home/toy/Games/test.gba"; +const SAVE: &str = "/home/toy/Games/test.sav"; +const SAVED: &[u8] = b"what gbae writes beside the ROM"; +/// What the package says it saw as `game`, on one line the job reads. +const SAW: &str = "GAME "; fn main() { match std::env::args().nth(1).as_deref() { Some(APP) => app(), + Some(GAME) => game(), _ => job(), } } @@ -113,7 +131,8 @@ fn job() { Ok(bytes) if bytes == KEPT => println!(" the app's write is in {HOME}/Data"), other => red.push(format!("what the app wrote is not in {HOME}/Data/kept: {other:?}")), } - for dir in [PACKAGE, ROW_PACKAGE, "/apps/other", "/home/toy/Apps/other", HOME] { + granted(&mut red); + for dir in [PACKAGE, ROW_PACKAGE, "/apps/other", "/home/toy/Apps/other", HOME, GAMES] { let _ = fs::remove_dir_all(dir); } if !red.is_empty() { @@ -231,3 +250,148 @@ fn app() { } println!(" every arm held"); } + +/// `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") +} + +/// 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 out = String::from_utf8_lossy(&ran.stdout).into_owned(); + print!("{out}"); + match out.lines().find_map(|l| l.strip_prefix(SAW)) { + Some(saw) if ran.status.code() == Some(0) => Some(saw.to_string()), + _ => { + red.push(format!("{when}: the package said nothing it saw: {ran:?}")); + None + } + } +} + +fn granted(red: &mut Vec) { + let _ = in_login("/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"); + + let outside = Command::new("/system/bin/grants").args(["add", "appview", GAMES]).output().expect("launch grants"); + match (outside.status.code(), String::from_utf8_lossy(&outside.stdout)) { + (Some(1), said) if said.contains("only a login session may ask for grants") => println!(" grants in the machine's session: refused"), + _ => red.push(format!("grants in the machine's session was answered {outside:?}")), + } + let apps = in_login(&format!("/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}")); + 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"); + if !String::from_utf8_lossy(&listed.stdout).contains("appview read-write /home/toy/Games\n") { + red.push(format!("grants list answered {listed:?}")); + } + + let rom = format!("cwd={GAMES} roms=[\"test.gba\"] saved=Ok"); + match played(red, "granted") { + Some(saw) if saw == rom => println!(" granted: {saw}"), + Some(saw) => red.push(format!("granted, it saw {saw}, not {rom}")), + None => {} + } + match fs::read(SAVE) { + Ok(bytes) if bytes == SAVED => println!(" its save is beside the ROM"), + other => red.push(format!("{SAVE} holds {other:?}")), + } + let _ = fs::remove_file(SAVE); + + // One byte more is another binary, which holds no grant. + let mut longer = fs::read(SELF).expect("read this binary"); + longer.push(0); + fs::write(PROGRAM, &longer).expect("install a binary one byte longer"); + let none = "cwd=/ roms=[] saved=Err"; + match played(red, "another binary") { + Some(saw) if saw == none => println!(" another binary: {saw}"), + Some(saw) => red.push(format!("another binary saw {saw}, not {none}")), + None => {} + } + fs::copy(SELF, PROGRAM).expect("install this binary again"); + match played(red, "the granted binary again") { + Some(saw) if saw == rom => println!(" the granted binary again: {saw}"), + Some(saw) => red.push(format!("the granted binary again saw {saw}, not {rom}")), + None => {} + } + let _ = fs::remove_file(SAVE); + + let revoked = in_login("/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:?}")); + } + match played(red, "revoked") { + Some(saw) if saw == none => println!(" revoked: {saw}"), + Some(saw) => red.push(format!("revoked, it saw {saw}, not {none}")), + None => {} + } + if fs::metadata(SAVE).is_ok() { + red.push(format!("revoked, {SAVE} was written")); + } +} + +/// As gbae, started with no ROM: its menu on the working directory, by its +/// own `list_directory`, and a save beside the first ROM it lists, where gbae +/// puts one, or where the granted folder's would be with none listed. +fn game() { + let cwd = std::env::current_dir().expect("a working directory"); + let entries = list_directory(&cwd); + let roms: Vec = entries.iter().filter(|e| !e.is_directory).map(|e| e.name.clone()).collect(); + let saved = match entries.iter().find(|e| !e.is_directory) { + Some(rom) => { + let rom = rom.path.canonicalize().unwrap_or_else(|_| rom.path.clone()); + fs::write(rom.with_extension("sav"), SAVED) + } + None => fs::write(SAVE, SAVED), + }; + let saved = if saved.is_ok() { "Ok" } else { "Err" }; + println!("{SAW}cwd={} roms={roms:?} saved={saved}", cwd.display()); +} + +struct FileEntry { + name: String, + path: std::path::PathBuf, + is_directory: bool, +} + +/// gbae's own, verbatim (`Japabu/gbae` at `bfe8dab`, `src/menu.rs`). +fn list_directory(directory: &std::path::Path) -> Vec { + let mut directories = Vec::new(); + let mut roms = Vec::new(); + for entry in std::fs::read_dir(directory).into_iter().flatten().flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + if path.is_dir() { + directories.push(FileEntry { + name: format!("{}/", name), + path, + is_directory: true, + }); + } else if path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("gba")) { + roms.push(FileEntry { name, path, is_directory: false }); + } + } + let by_name = |a: &FileEntry, b: &FileEntry| a.name.to_lowercase().cmp(&b.name.to_lowercase()); + directories.sort_by(by_name); + roms.sort_by(by_name); + let parent = directory.parent().map(|parent| FileEntry { + name: "../".to_string(), + path: parent.to_path_buf(), + is_directory: true, + }); + parent.into_iter().chain(directories).chain(roms).collect() +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 008c522eced..ff134ce8b13 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -3571,7 +3571,8 @@ fn launch_authority(back: &metal::Readback) -> Result<(), String> { /// `app_view` passed, every arm its package asks held, and the supervisor /// refused each of the two launches for its own reason: a package named after /// the shell's row, and one whose folder is a file. The guest sees only that -/// each was refused. +/// each was refused. And the supervisor wrote and took away the package's +/// grant, and refused the machine's session one. fn app_view(back: &metal::Readback) -> Result<(), String> { back.job_passed("test_rs_app_view")?; let log = back.log(); @@ -3582,6 +3583,10 @@ fn app_view(back: &metal::Readback) -> Result<(), String> { &named, "supervisor: launcher: appview was not started: /home/toy/Apps/appview could not be made: \ /home/toy/Apps/appview is no directory", + "supervisor: grants: refused grants in the machine's session: only a login session may ask for grants", + "supervisor: grants: appview is granted /home/toy/Games read-write", + " granted: cwd=/home/toy/Games roms=[\"test.gba\"] saved=Ok", + "supervisor: grants: appview's grant is revoked; its next launch holds no folder", ] { if !log.text().lines().any(|l| l.contains(said)) { return Err(format!("the log never said `{said}`\n{}", log.text())); diff --git a/toyos-manifest/src/grants.rs b/toyos-manifest/src/grants.rs new file mode 100644 index 00000000000..5ba8d3fbdd9 --- /dev/null +++ b/toyos-manifest/src/grants.rs @@ -0,0 +1,462 @@ +//! Which folder of the session user's home an installed package is granted, +//! and the store `/system/bin/supervisor` keeps those grants in. +//! +//! **The supervisor is the only writer of [`STORE`].** A grant reaches it as a +//! request on [`PORT`], which the build gives to [`HOLDER`] alone and the +//! supervisor honours only from a login session, by the caller's badge. +//! +//! **A grant is keyed on the exact binary**: the session's user, the package's +//! name and the SHA-256 of the program the supervisor starts. A package +//! replaced under `/apps`, an update included, holds none until it is granted +//! again. +//! +//! **A folder is a directory inside the session user's home** ([`folder`]): +//! never the home itself, nor `Apps` or anything in it, where every app keeps +//! its own folder. +//! +//! **What a launch gets** is [`decide`]'s: the stored grant for that exact +//! binary, at most the image's `[apps] folder` ceiling, or nothing. +//! +//! ```text +//! toyos-grants 1 +//! grant +//! ``` + +use std::fmt; + +use crate::package::{self, DIGEST_LEN}; +use crate::{session_home, MAX_PROGRAM_NAME}; + +/// The port the supervisor serves grants on, and the name its holder opens. +pub const PORT: &str = "grants"; + +/// The one row the build lets receive [`PORT`]. +pub const HOLDER: &str = "grants"; + +/// Where the supervisor keeps every grant, and the directory it makes for it. +pub const STORE: &str = "/state/supervisor/grants"; +pub const STORE_DIR: &str = "/state/supervisor"; + +const HEADER: &str = "toyos-grants 1"; + +/// How many grants the store holds. Policy on the primitive: one per package +/// a user has, and a list that fits one answer ([`MAX_LIST_BYTES`]). +pub const MAX_ENTRIES: usize = 64; + +/// The longest store the supervisor reads: every entry at its longest. +pub const MAX_STORE_BYTES: usize = HEADER.len() + 1 + MAX_ENTRIES * MAX_LINE; + +/// The longest root a folder's grant carries, without its leading `/`: what +/// `toyos::fs::MAX_GRANT_ROOT` leaves, which the supervisor asserts. +pub const MAX_ROOT: usize = 54; + +/// The deepest folder, in components: std finds a capability by probing at +/// most this many (`MAX_CAPABILITY_DEPTH` in `sdk/std/sys/fs.rs`). +pub const MAX_DEPTH: usize = 4; + +const MAX_LINE: usize = "grant ".len() + crate::USER.len() + 1 + MAX_PROGRAM_NAME + 1 + DIGEST_LEN + 1 + 11 + 1 + MAX_ROOT + 2; + +/// The longest answer to [`Request::List`]: one line per grant. +pub const MAX_LIST_BYTES: usize = MAX_ENTRIES * (MAX_PROGRAM_NAME + 1 + 10 + 1 + MAX_ROOT + 2); + +/// What a package may do in its folder. Ordered: a grant is at most the +/// image's ceiling, and a user may give less than was asked. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Access { + ReadOnly, + ReadWrite, +} + +impl Access { + pub fn word(self) -> &'static str { + match self { + Self::ReadOnly => "read-only", + Self::ReadWrite => "read-write", + } + } + + pub fn parse(word: &str) -> Option { + [Self::ReadOnly, Self::ReadWrite].into_iter().find(|a| a.word() == word) + } +} + +impl fmt::Display for Access { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.word()) + } +} + +/// A folder a launch is granted: a whole path [`folder`] admits. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Folder { + pub path: String, + pub access: Access, +} + +/// One stored grant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub user: String, + pub package: String, + /// The SHA-256 of the program it was granted to, lowercase hex. + pub binary: String, + pub folder: Folder, +} + +/// Why `path` may not be a package's folder, as a whole sentence; `Ok` for +/// 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) { + return Err(format!("{path:?} is not a canonical path")); + } + if path == home { + return Err(format!("{home} is the home itself, and a package is granted a folder inside it")); + } + let Some(inside) = path.strip_prefix(&home).and_then(|rest| rest.strip_prefix('/')) else { + return Err(format!("{path} is not inside {home}")); + }; + if inside.split('/').next() == Some("Apps") { + return Err(format!("{path} is {home}/Apps or inside it, where every app keeps its own folder")); + } + if path.len() - 1 > MAX_ROOT { + return Err(format!("{path} is longer than the {MAX_ROOT} bytes a grant's root carries")); + } + if path.split('/').skip(1).count() > MAX_DEPTH { + return Err(format!("{path} is deeper than the {MAX_DEPTH} components a program's std reaches")); + } + Ok(()) +} + +/// The folder a launch of `binary` is granted: the stored grant for that exact +/// binary, at most `ceiling`, the image's `[apps] folder`. `None` with no +/// ceiling, no grant, or a grant to another binary. +pub fn decide(stored: Option<&Entry>, binary: &str, ceiling: Option) -> Option { + let ceiling = ceiling?; + let entry = stored.filter(|entry| entry.binary == binary)?; + Some(Folder { path: entry.folder.path.clone(), access: entry.folder.access.min(ceiling) }) +} + +/// The working directory a launch granted `folder` starts in: the caller's +/// 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) { + true => caller.to_string(), + false => folder.to_string(), + } +} + +/// Every grant, as [`STORE`] holds them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Store { + entries: Vec, +} + +impl Store { + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + pub fn find(&self, user: &str, package: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.user == user && e.package == package) + } + + /// Store `entry` in place of any grant to the same user's same package. + pub fn put(&mut self, entry: Entry) -> Result<(), String> { + check(&entry)?; + match self.entries.iter().position(|e| e.user == entry.user && e.package == entry.package) { + Some(at) => self.entries[at] = entry, + None if self.entries.len() >= MAX_ENTRIES => { + return Err(format!("the store already holds {MAX_ENTRIES} grants")); + } + None => self.entries.push(entry), + } + Ok(()) + } + + /// Take away the user's grant to `package`; whether there was one. + pub fn revoke(&mut self, user: &str, package: &str) -> bool { + let before = self.entries.len(); + self.entries.retain(|e| !(e.user == user && e.package == package)); + self.entries.len() != before + } + + pub fn render(&self) -> String { + let mut out = format!("{HEADER}\n"); + for e in &self.entries { + out.push_str(&format!("grant {} {} {} {} {}\n", e.user, e.package, e.binary, e.folder.access, e.folder.path)); + } + out + } + + /// Read back what [`render`](Self::render) wrote, refusing by name + /// anything it could not have: any declared row can write `/state`. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_STORE_BYTES { + return Err(format!("{STORE} is {} bytes, past the {MAX_STORE_BYTES} its grants fill", text.len())); + } + let mut lines = text.split_terminator('\n'); + if lines.next() != Some(HEADER) { + return Err(format!("{STORE} does not begin `{HEADER}`")); + } + let mut store = Store::default(); + for line in lines { + let mut words = line.splitn(6, ' '); + let [Some("grant"), Some(user), Some(package), Some(binary), Some(access), Some(path)] = + [(); 6].map(|()| words.next()) + else { + return Err(format!("{STORE}: {line:?} is not a grant")); + }; + let access = Access::parse(access).ok_or_else(|| format!("{STORE}: {access:?} is no access"))?; + let entry = Entry { + user: user.to_string(), + package: package.to_string(), + binary: binary.to_string(), + folder: Folder { path: path.to_string(), access }, + }; + if store.find(user, package).is_some() { + return Err(format!("{STORE}: {user}'s {package} is granted twice")); + } + store.put(entry).map_err(|why| format!("{STORE}: {why}"))?; + } + Ok(store) + } +} + +/// What an entry has to be for [`Store::render`] to write a line that reads +/// back as it. +fn check(e: &Entry) -> Result<(), String> { + if e.user != crate::USER { + return Err(format!("{:?} is not this image's user", e.user)); + } + if package::package_of(&format!("{}/{}/x", package::DIR, e.package)) != Some(e.package.as_str()) { + return Err(format!("{:?} is not a package name", e.package)); + } + if e.binary.len() != DIGEST_LEN || !e.binary.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return Err(format!("{:?} is not a SHA-256 in lowercase hex", e.binary)); + } + folder(&e.folder.path) +} + +/// A request on [`PORT`]: the message type is the verb. +pub const MSG_LIST: u32 = 1; +pub const MSG_ADD: u32 = 2; +pub const MSG_REVOKE: u32 = 3; +/// The supervisor's answers, each carrying its text. +pub const MSG_DONE: u32 = 1; +pub const MSG_REFUSED: u32 = 2; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Request { + List, + /// Grant `package` the folder at `path`, to `access`. + Add { package: String, folder: Folder }, + Revoke { package: String }, +} + +impl Request { + /// The message type and its payload: an add's package, access and path, + /// each ended by a NUL but the path. + pub fn encode(&self) -> (u32, Vec) { + match self { + Self::List => (MSG_LIST, Vec::new()), + Self::Add { package, folder } => { + (MSG_ADD, format!("{package}\0{}\0{}", folder.access, folder.path).into_bytes()) + } + Self::Revoke { package } => (MSG_REVOKE, package.as_bytes().to_vec()), + } + } + + /// `None` for a frame [`encode`](Self::encode) cannot have written. + pub fn decode(msg_type: u32, payload: &[u8]) -> Option { + let text = std::str::from_utf8(payload).ok()?; + match msg_type { + MSG_LIST if text.is_empty() => Some(Self::List), + MSG_ADD => { + let mut parts = text.splitn(3, '\0'); + let (package, access, path) = (parts.next()?, parts.next()?, parts.next()?); + let folder = Folder { path: path.to_string(), access: Access::parse(access)? }; + Some(Self::Add { package: package.to_string(), folder }) + } + MSG_REVOKE if !text.is_empty() && !text.contains('\0') => Some(Self::Revoke { package: text.to_string() }), + _ => None, + } + } +} + +/// One line of a [`Request::List`] answer. +pub fn listed(entry: &Entry) -> String { + format!("{} {} {}\n", entry.package, entry.folder.access, entry.folder.path) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BIN: &str = "99fcd8a700000000000000000000000000000000000000000000000000000000"; + + fn entry(package: &str, path: &str, access: Access) -> Entry { + Entry { + user: "toy".into(), + package: package.into(), + binary: BIN.into(), + folder: Folder { path: path.into(), access }, + } + } + + /// The deepest and longest folder a grant carries, at the longest package + /// name, and a store filled to its bound, read back as written. + #[test] + fn the_store_reads_back_as_written_at_every_bound() { + let longest = format!("/home/toy/{}", "g".repeat(MAX_ROOT - "home/toy/".len())); + let deepest = "/home/toy/a/b"; + assert_eq!(folder(&longest), Ok(())); + assert_eq!(folder(deepest), Ok(())); + let mut store = Store::default(); + store.put(entry(&"n".repeat(MAX_PROGRAM_NAME), &longest, Access::ReadWrite)).unwrap(); + store.put(entry("gbae", deepest, Access::ReadOnly)).unwrap(); + store.put(entry("spaced", "/home/toy/My Games", Access::ReadWrite)).unwrap(); + for i in store.entries().len()..MAX_ENTRIES { + store.put(entry(&format!("p{i}"), &longest, Access::ReadWrite)).unwrap(); + } + let text = store.render(); + assert!(text.len() <= MAX_STORE_BYTES, "{} bytes", text.len()); + assert_eq!(Store::parse(&text), Ok(store.clone())); + let list: String = store.entries().iter().map(listed).collect(); + assert!(list.len() <= MAX_LIST_BYTES, "{} bytes", list.len()); + assert_eq!(store.put(entry("one-more", deepest, Access::ReadOnly)), Err(format!("the store already holds {MAX_ENTRIES} grants"))); + // A grant to a package already granted replaces it, past the bound too. + store.put(entry("gbae", "/home/toy/Games", Access::ReadWrite)).unwrap(); + assert_eq!(store.find("toy", "gbae").unwrap().folder.path, "/home/toy/Games"); + assert_eq!(store.entries().len(), MAX_ENTRIES); + } + + /// Every folder a package may not be granted, each refused by name. + #[test] + fn a_folder_outside_the_home_or_of_the_apps_is_refused_by_name() { + let too_long = format!("/home/toy/{}", "g".repeat(MAX_ROOT - "home/toy/".len() + 1)); + for (path, said) in [ + ("/home/toy", "is the home itself"), + ("/home/toy/Apps", "where every app keeps its own folder"), + ("/home/toy/Apps/gbae", "where every app keeps its own folder"), + ("/home/toy/Apps/other/Data", "where every app keeps its own folder"), + ("/home", "is not inside /home/toy"), + ("/", "is not a canonical path"), + ("/home/toyx/Games", "is not inside /home/toy"), + ("/home/other/Games", "is not inside /home/toy"), + ("/state/supervisor", "is not inside /home/toy"), + ("/home/toy/Games/../Apps", "is not a canonical path"), + ("/home/toy/./Games", "is not a canonical path"), + ("/home/toy//Games", "is not a canonical path"), + ("/home/toy/Games/", "is not a canonical path"), + ("home/toy/Games", "is not a canonical path"), + ("/home/toy/Ga\nmes", "is not a canonical path"), + (&too_long, "longer than the 54 bytes"), + ("/home/toy/a/b/c", "deeper than the 4 components"), + ] { + match folder(path) { + Err(why) if why.contains(said) => {} + other => panic!("{path:?}: {other:?}, not {said:?}"), + } + } + // `Apps` is a component, not a prefix. + assert_eq!(folder("/home/toy/Appsy"), Ok(())); + } + + /// Bytes `render` cannot have written are refused, each by name. + #[test] + fn a_store_render_cannot_have_written_is_refused() { + let good = format!("{HEADER}\ngrant toy gbae {BIN} read-write /home/toy/Games\n"); + assert!(Store::parse(&good).is_ok()); + for (text, said) in [ + (String::new(), "does not begin"), + ("toyos-grants 2\n".to_string(), "does not begin"), + (format!("{HEADER}\nallow toy gbae {BIN} read-write /home/toy/Games\n"), "is not a grant"), + (format!("{HEADER}\ngrant toy gbae {BIN} read-write\n"), "is not a grant"), + (format!("{HEADER}\ngrant toy gbae {BIN} write /home/toy/Games\n"), "is no access"), + (format!("{HEADER}\ngrant root gbae {BIN} read-write /home/toy/Games\n"), "not this image's user"), + (format!("{HEADER}\ngrant toy ../x {BIN} read-write /home/toy/Games\n"), "not a package name"), + (format!("{HEADER}\ngrant toy gbae {} read-write /home/toy/Games\n", BIN.to_uppercase()), "lowercase hex"), + (format!("{HEADER}\ngrant toy gbae abc read-write /home/toy/Games\n"), "lowercase hex"), + (format!("{HEADER}\ngrant toy gbae {BIN} read-write /home/toy/Apps/gbae\n"), "keeps its own folder"), + (format!("{good}grant toy gbae {BIN} read-only /home/toy/Music\n"), "granted twice"), + (format!("{HEADER}\n{}", "x".repeat(MAX_STORE_BYTES)), "past the"), + ] { + match Store::parse(&text) { + Err(why) if why.contains(said) => {} + other => panic!("{text:?}: {other:?}, not {said:?}"), + } + } + } + + /// A revoke takes exactly the one grant it names. + #[test] + fn a_revoke_takes_one_grant_and_says_whether_it_did() { + let mut store = Store::default(); + store.put(entry("gbae", "/home/toy/Games", Access::ReadWrite)).unwrap(); + store.put(entry("paint", "/home/toy/Pictures", Access::ReadOnly)).unwrap(); + assert!(store.revoke("toy", "gbae")); + assert!(!store.revoke("toy", "gbae")); + assert_eq!(store.entries(), [entry("paint", "/home/toy/Pictures", Access::ReadOnly)]); + } + + /// The decision, as a table: the stored grant for the exact binary, at + /// most the ceiling, and nothing otherwise. + #[test] + fn a_launch_gets_the_grant_for_its_exact_binary_at_most_the_ceiling() { + let (rw, ro) = (entry("gbae", "/home/toy/Games", Access::ReadWrite), entry("gbae", "/home/toy/Games", Access::ReadOnly)); + let other = "0".repeat(DIGEST_LEN); + let games = |access| Some(Folder { path: "/home/toy/Games".into(), access }); + use Access::*; + #[rustfmt::skip] + let table: [(Option<&Entry>, &str, Option, Option); 8] = [ + (Some(&rw), BIN, Some(ReadWrite), games(ReadWrite)), + (Some(&ro), BIN, Some(ReadWrite), games(ReadOnly)), + (Some(&rw), BIN, Some(ReadOnly), games(ReadOnly)), + (Some(&rw), BIN, None, None), + (Some(&rw), &other, Some(ReadWrite), None), + (None, BIN, Some(ReadWrite), None), + (None, BIN, None, None), + (Some(&ro), &other, Some(ReadOnly), None), + ]; + for (i, (stored, binary, ceiling, want)) in table.into_iter().enumerate() { + assert_eq!(decide(stored, binary, ceiling), want, "row {i}"); + } + } + + /// The caller's own working directory where it is inside the folder, and + /// the folder's root otherwise. + #[test] + fn a_launch_starts_in_the_callers_directory_inside_the_folder_or_at_its_root() { + let games = "/home/toy/Games"; + assert_eq!(cwd(games, "/home/toy/Games/GBA"), "/home/toy/Games/GBA"); + assert_eq!(cwd(games, games), games); + for outside in ["/", "/home/toy", "/home/toy/GamesX", "/home/toy/Games/../Apps", "/home/toy/Games/./x", "/tmp"] { + assert_eq!(cwd(games, outside), games, "{outside}"); + } + } + + #[test] + fn a_request_reads_back_as_written_and_nothing_else_reads() { + for request in [ + Request::List, + Request::Add { package: "gbae".into(), folder: Folder { path: "/home/toy/My Games".into(), access: Access::ReadOnly } }, + Request::Revoke { package: "gbae".into() }, + ] { + let (msg, payload) = request.encode(); + assert_eq!(Request::decode(msg, &payload), Some(request)); + } + for (msg, payload) in [ + (MSG_LIST, &b"x"[..]), + (MSG_ADD, b"gbae\0read-write"), + (MSG_ADD, b"gbae\0write\0/home/toy/Games"), + (MSG_REVOKE, b""), + (MSG_REVOKE, b"a\0b"), + (MSG_REVOKE, &[0xff]), + (9, b""), + ] { + assert_eq!(Request::decode(msg, payload), None, "{msg} {payload:?}"); + } + } +} diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index 13ffce90806..122081d2b7d 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -27,14 +27,17 @@ //! supervisor-serve a name the supervisor serves itself //! start the supervisor starts this program at boot //! app-receive a connector every program launched from /apps holds +//! app-folder the most of a folder of the home a package may be granted //! starts a row this program may start through the launcher, or `/apps` //! login a launch it makes from the machine's session opens a login session //! ``` //! //! [`package`] is the other half: what an installed package says about itself, //! which is which of its own binaries a launch starts and never what it holds. -//! [`launch`] is who may start what. +//! [`launch`] is who may start what, and [`grants`] which folder of the home a +//! package is granted. +pub mod grants; pub mod launch; pub mod package; @@ -221,7 +224,7 @@ pub fn syscap_rights(names: &[String]) -> Result { Ok(rights) } -#[derive(Default, Debug, PartialEq, Eq)] +#[derive(Clone, Default, Debug, PartialEq, Eq)] pub struct Program { pub name: String, pub path: String, @@ -257,6 +260,10 @@ pub struct Program { /// The supervisor starts it again when it ends, on the same ports, for as long as /// it does not end faster than [`RESTARTS`] allows. pub restart: bool, + /// The folder of the session's home a package's launch was granted + /// ([`grants::decide`]): set by the supervisor on the row it synthesizes + /// for that launch, and never on a declared row. + pub folder: Option, /// The rows it may start through the supervisor's launcher: program keys, /// and [`launch::APPS`] for any installed package. Non-empty is what /// endows it a launcher at all. @@ -290,14 +297,18 @@ impl Program { } /// The directories this row's program is endowed. **An installed package - /// sees its own directory read-only and its own folder of the home - /// read-write, and nothing else any role serves**: no other package, no - /// other part of the home, no `/config`, `/state`, `/log` or `/boot`. + /// sees its own directory read-only, its own folder of the home + /// read-write and the folder its launch was granted, and nothing else any + /// role serves**: no other package, no other part of the home, no + /// `/config`, `/state`, `/log` or `/boot`. /// Every other row sees the whole tree, until each declares its own /// (`issues/every-program-sees-only-the-files-it-was-given.md`, stage 2). pub fn view(&self) -> Vec { match self.package() { - Some(name) => vec![data_dir(package::Package::dir(name), false), data_dir(app_home(name), true)], + Some(name) => { + let granted = self.folder.iter().map(|f| data_dir(f.path.clone(), f.access == grants::Access::ReadWrite)); + [data_dir(package::Package::dir(name), false), data_dir(app_home(name), true)].into_iter().chain(granted).collect() + } None => whole_tree(), } } @@ -317,6 +328,10 @@ pub struct Manifest { /// which is why a device class and a `syscap` right have no spelling on /// the package side at all. pub apps: Vec, + /// The most of a folder of the session's home a package may be granted + /// (`[apps] folder`), and, until signed package entries carry their own + /// request, what every package asks for: `None` grants none. + pub folder: Option, /// Program names, in the order `[boot] start` gave them — which orders /// nothing, because every port exists before any server runs. pub start: Vec, @@ -445,6 +460,9 @@ pub fn render(manifest: &Manifest) -> Result, RenderError> { check("supervisor", "apps", name)?; out.push_str(&format!("app-receive {name}\n")); } + if let Some(access) = manifest.folder { + out.push_str(&format!("app-folder {access}\n")); + } for name in &manifest.start { check("supervisor", "start", name)?; out.push_str(&format!("start {name}\n")); @@ -504,6 +522,10 @@ pub fn parse(text: &str) -> Manifest { } "supervisor-serve" => manifest.supervisor_serves.push(rest.to_string()), "app-receive" => manifest.apps.push(rest.to_string()), + "app-folder" => { + let access = grants::Access::parse(rest).unwrap_or_else(|| panic!("manifest: `{rest}` is no access")); + assert!(manifest.folder.replace(access).is_none(), "manifest: `app-folder` twice"); + } "start" => manifest.start.push(rest.to_string()), "" => {} _ => { @@ -584,6 +606,7 @@ mod tests { ], supervisor_serves: vec!["swap".into()], apps: vec!["compositor".into(), "soundserver".into()], + folder: Some(grants::Access::ReadWrite), start: vec!["compositor".into(), "soundserver".into()], } } @@ -687,6 +710,24 @@ mod tests { ); } + /// **A package's launch granted a folder sees it too, at the access it + /// was granted**, and nothing else is added. Spelled out whole. + #[test] + fn a_package_granted_a_folder_sees_it_beside_its_own() { + for (access, write) in [(grants::Access::ReadWrite, true), (grants::Access::ReadOnly, false)] { + let mut row = sample().app_row("gbae", "/apps/gbae/gbae").unwrap(); + row.folder = Some(grants::Folder { path: "/home/toy/Games".into(), access }); + assert_eq!( + views(&row), + [ + ("data", "/apps/gbae".into(), "apps/gbae".into(), false), + ("data", "/home/toy/Apps/gbae".into(), "home/toy/Apps/gbae".into(), true), + ("data", "/home/toy/Games".into(), "home/toy/Games".into(), write), + ] + ); + } + } + /// Every row the image declares sees the whole tree read-write, the /// installer and the shell included: neither has authority over `/apps` /// the other lacks. diff --git a/userland/fileserver/src/data.rs b/userland/fileserver/src/data.rs index 1fdbf82260c..5bf12f508b1 100644 --- a/userland/fileserver/src/data.rs +++ b/userland/fileserver/src/data.rs @@ -802,6 +802,29 @@ mod tests { } } + /// **A symlink where a grant's root is, is no directory**, so a connection + /// granted that root reaches nothing through it: the folder it named is + /// listed by nobody and nothing is made under it, and the directory the + /// link names is untouched. The supervisor checks a folder before it + /// grants it; this is what holds once a declared row replaces it after. + #[test] + fn a_symlink_at_a_grant_s_root_is_followed_by_nothing() { + let mut v = vol(); + v.mkdir("home/toy/Apps/other").unwrap(); + let kept = v.open("home/toy/Apps/other/kept", CREATE).unwrap(); + v.close(kept).unwrap(); + v.symlink("home/toy/Games", "Apps/other").unwrap(); + let root = "home/toy/Games"; + assert_eq!(v.lstat(root).unwrap().kind, Kind::Symlink); + assert_eq!(v.list(root).map(drop), Err(SyscallError::InvalidArgument)); + assert_eq!(v.lstat("home/toy/Games/kept"), Err(SyscallError::NotFound)); + assert_eq!(v.open("home/toy/Games/kept", PLAIN).map(drop), Err(SyscallError::NotFound)); + assert_eq!(v.open("home/toy/Games/made", CREATE).map(drop), Err(SyscallError::NotFound)); + assert_eq!(v.mkdir("home/toy/Games/sub"), Err(SyscallError::NotFound)); + let listed: Vec = v.list("home/toy/Apps/other").unwrap().into_iter().map(|(name, _)| name).collect(); + assert_eq!(listed, ["kept"]); + } + #[test] fn a_file_reads_back_what_was_written_across_pages_and_holes() { let mut v = vol(); diff --git a/userland/fileserver/src/resolve.rs b/userland/fileserver/src/resolve.rs index 3f92b98178a..bdce11a8a5d 100644 --- a/userland/fileserver/src/resolve.rs +++ b/userland/fileserver/src/resolve.rs @@ -160,6 +160,28 @@ mod tests { assert_eq!(run(&vol(&[("x", "..")]), "", "x", true), Err(Refusal::Escape)); } + /// The same suite under a folder of the home a package is granted: no + /// link inside it reaches the home around it, another app's folder or + /// the volume's other directories, and one to an absolute path is the + /// package's own table's to resolve, where none of those is. + #[test] + fn no_link_climbs_out_of_a_granted_folder() { + let root = "home/toy/Games"; + let v = vol(&[ + ("home/toy/Games/up", ".."), + ("home/toy/Games/home", "../.."), + ("home/toy/Games/other", "../Apps/other/Data"), + ("home/toy/Games/GBA/state", "../../../../state/supervisor"), + ("home/toy/Games/GBA/self", "../../Games/GBA"), + ]); + for rel in ["up", "up/Documents", "home", "other", "other/kept", "GBA/state/grants", "GBA/self"] { + assert_eq!(run(&v, root, rel, true), Err(Refusal::Escape), "{rel}"); + } + let abs = vol(&[("home/toy/Games/abs", "/home/toy/Apps/other")]); + assert_eq!(run(&abs, root, "abs/kept", true), Ok(Resolved::Absolute("/home/toy/Apps/other/kept".to_string()))); + assert_eq!(run(&vol(&[("home/toy/Games/GBA/back", "../ROMs")]), root, "GBA/back/a.gba", true), path("home/toy/Games/ROMs/a.gba")); + } + #[test] fn a_link_that_stays_inside_by_going_up_and_back_resolves() { let v = vol(&[("home/toy/a/back", "../b")]); diff --git a/userland/grants/Cargo.toml b/userland/grants/Cargo.toml new file mode 100644 index 00000000000..fef5e9ada12 --- /dev/null +++ b/userland/grants/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "grants" +description = "grants list | add [read-only] | revoke : which folder of the home an installed package is granted, asked of the supervisor." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[dependencies] +toyos = { path = "../../toyos" } +# The request's codec and the folder rules, read by the supervisor too. +toyos-manifest = { path = "../../toyos-manifest" } + +[package.metadata.toyos.host] +exempt.manages = "ToyOS's grants of a folder of the home to an installed package, through the supervisor's `grants` port" diff --git a/userland/grants/src/main.rs b/userland/grants/src/main.rs new file mode 100644 index 00000000000..1c5bd679391 --- /dev/null +++ b/userland/grants/src/main.rs @@ -0,0 +1,81 @@ +//! `/system/bin/grants`: which folder of the session's home an installed +//! package is granted, asked of `/system/bin/supervisor`, which alone keeps +//! them (`toyos_manifest::grants`). +//! +//! - `grants list`: every grant, one line each: package, access, folder. +//! - `grants add [read-only]`: grant the package that +//! folder, read-write unless asked for less; its next launch starts in it. +//! - `grants revoke `: take it away; its next launch holds no folder, +//! and one running keeps it until it ends. +//! +//! It holds the one `grants` connector the build lets a program hold, minted +//! with its row and session, so what it can do is ask: the supervisor answers +//! a login session alone, and judges every folder itself. The answer is one +//! line on standard output, the supervisor's text, with exit status 0 where it +//! was done and 1 where it was refused. + +use std::io::Write; + +use toyos::endow::Endowments; +use toyos::namespace::Namespace; +use toyos_manifest::grants::{self, Access, Folder, Request}; + +const USAGE: &str = "usage: grants list | add [read-only] | revoke "; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let (done, text) = match request(&args) { + Ok(request) => ask(&request), + Err(why) => (false, why.to_string()), + }; + print!("{text}"); + if !text.ends_with('\n') && !text.is_empty() { + println!(); + } + std::io::stdout().flush().expect("the answer reaches standard output"); + std::process::exit(if done { 0 } else { 1 }); +} + +fn request(args: &[String]) -> Result { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let add = |package: &str, path: &str, access| Request::Add { + package: package.to_string(), + folder: Folder { path: path.to_string(), access }, + }; + match args[..] { + ["list"] => Ok(Request::List), + ["add", package, path] => Ok(add(package, path, Access::ReadWrite)), + ["add", package, path, "read-only"] => Ok(add(package, path, Access::ReadOnly)), + ["revoke", package] => Ok(Request::Revoke { package: package.to_string() }), + _ => Err(USAGE), + } +} + +/// The supervisor's answer: whether it was done, and its text. +fn ask(request: &Request) -> (bool, String) { + let Some(held) = Endowments::get().take::(grants::PORT) else { + return (false, "this program holds no grants connector".to_string()); + }; + let supervisor = match held.open(grants::PORT) { + Ok(conn) => conn, + Err(e) => return (false, format!("the supervisor's grants port did not answer: {e:?}")), + }; + let (msg_type, payload) = request.encode(); + if let Err(e) = supervisor.send_bytes(msg_type, &payload) { + return (false, format!("the supervisor did not take the request: {e:?}")); + } + let answer = match supervisor.recv_header() { + Ok(answer) => answer, + Err(e) => return (false, format!("the supervisor did not answer: {e:?}")), + }; + let mut text = vec![0u8; answer.len() as usize]; + let said = match supervisor.recv_bytes(&answer, &mut text) { + Ok(n) => String::from_utf8_lossy(&text[..n]).into_owned(), + Err(e) => return (false, format!("the supervisor's answer did not arrive whole: {e:?}")), + }; + match answer.msg_type { + grants::MSG_DONE => (true, said), + grants::MSG_REFUSED => (false, said), + other => (false, format!("the supervisor answered message {other}, which is no grants answer")), + } +} diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 62fcba2c79c..1b12d09c827 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -54,6 +54,11 @@ //! A launch of a program no row names is answered with the session's, which //! the caller's direct spawn carries in place of its own. //! +//! **An installed package's launch also holds the folder of the home it was +//! granted** ([`toyos_manifest::grants`]): the grant stored for that exact +//! binary, which the supervisor alone writes, at the request of the one row +//! holding the `grants` port from a login session, and starts in it. +//! //! **A launch starts only what its caller's row lists** ([`toyos_manifest::launch`]). //! Every row whose `starts` lists anything is endowed a `launcher` the supervisor //! minted with its row and session as the badge, under a label of its own so @@ -75,6 +80,7 @@ use std::time::{Duration, Instant}; use toyos_swap::{Refusal, Request as SwapRequest, Word}; +use toyos_manifest::grants::{self, Store}; use toyos_manifest::launch::{self as authority, Authority, Session, Sessions, Target}; use toyos_manifest::package::{self, Package}; use toyos_manifest::{Manifest, Program, View}; @@ -152,11 +158,21 @@ struct Caller { enum Port { /// Who asks, off the connection's badge. Launcher(Caller), + /// Who asks to read or change the grants, off the connection's badge. + Grants(Caller), Swap, Power, } -/// The poll tokens for the `launcher`, `swap` and `power` acceptors, and for the +/// The ports the supervisor mints a row's badge on, its row and session: the +/// launcher every row whose `starts` lists anything holds, and +/// [`grants::PORT`], which its one holder asks on. +struct Minted { + launcher: Acceptor, + grants: Acceptor, +} + +/// The poll tokens for the `launcher`, `swap`, `power` and `grants` acceptors, and for the /// one connection whose swap has been answered and whose hang-up is awaited. A /// pending connection's token is [`TOKEN_PENDING_BASE`] plus its handle, which /// is unique among the connections the supervisor holds at once. @@ -165,7 +181,8 @@ const TOKEN_SWAP_ACCEPTOR: u64 = 1; const TOKEN_HANGUP: u64 = 2; const TOKEN_POWER_ACCEPTOR: u64 = 3; const TOKEN_WAKE: u64 = 4; -const TOKEN_PENDING_BASE: u64 = 5; +const TOKEN_GRANTS_ACCEPTOR: u64 = 5; +const TOKEN_PENDING_BASE: u64 = 6; /// How long a stop waits for `logkeeper` to say the log is whole. /// @@ -413,10 +430,14 @@ fn main() { // Its connector goes at once: every launcher a program holds is one minted // with that program's row. let (launcher, _) = port::create().expect("supervisor: no port for `launcher`"); + let grants_port = acceptors + .remove(grants::PORT) + .expect("supervisor: the manifest declares the supervisor serves `grants`"); let (wake_read, wake_write) = toyos::pipe_pair().expect("supervisor: no pipe to hear a service end"); let mut supervisor = Supervisor { system, - launcher, + minted: Minted { launcher, grants: grants_port }, + store: Store::default(), syscap: &syscap, acceptors, connectors, @@ -517,8 +538,11 @@ fn is_storage(program: &Program) -> bool { /// Everything the supervisor's loop acts on, for the machine's life. struct Supervisor<'a> { system: &'static Manifest, - /// What every launcher is minted on and every launch accepted from. - launcher: Acceptor, + /// What every launcher and every grants connector is minted on, and every + /// launch and grants request accepted from. + minted: Minted, + /// Every grant of a folder of the home, as [`grants::STORE`] holds them. + store: Store, syscap: &'a SysCap, /// The `serves` acceptors nobody has been started with yet, which a launch /// takes by move. @@ -623,7 +647,7 @@ impl<'a> Service<'a> { syscap: &SysCap, connectors: &BTreeMap<&str, Connector>, grants: &Grants<'_>, - launcher: &Acceptor, + minted: &Minted, log: &mut Log, ) -> Result { // **Checked again at every start, not only on arrival**: the installed @@ -655,7 +679,7 @@ impl<'a> Service<'a> { grants, &[], storage, - (launcher, self.session), + (minted, self.session), Output::Boot(log), )?; kept.generation += 1; @@ -811,7 +835,7 @@ impl<'a> Supervisor<'a> { self.grants.roles.push((role, Arc::clone(&service.kept))); } let started = - service.spawn(&program.path, &[], system, self.syscap, &self.connectors, &self.grants, &self.launcher, &mut self.log); + service.spawn(&program.path, &[], system, self.syscap, &self.connectors, &self.grants, &self.minted, &mut self.log); match started { Ok(_) => {} Err(StartError::Partition(why)) => { @@ -826,6 +850,83 @@ impl<'a> Supervisor<'a> { if !homes_made { self.make_session_home(); } + self.load_grants(); + } + + /// [`grants::STORE`], read on the worker once DATA's server runs. A store + /// refused, which any declared row can write, is said and the boot starts + /// with no grant; the next grant written replaces it. + fn load_grants(&mut self) { + let read = self.files("the grants", || -> Result, String> { + match std::fs::metadata(grants::STORE) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_string()), + Ok(meta) if meta.len() > grants::MAX_STORE_BYTES as u64 => { + return Err(format!("it is {} bytes, past the {} its grants fill", meta.len(), grants::MAX_STORE_BYTES)); + } + Ok(_) => {} + } + std::fs::read_to_string(grants::STORE).map(Some).map_err(|e| e.to_string()) + }); + let store = read.and_then(|read| read).and_then(|text| text.map_or(Ok(Store::default()), |t| Store::parse(&t))); + match store { + Ok(store) => self.store = store, + Err(why) => say!("supervisor: grants: {} is refused ({why}); this boot starts with no grant", grants::STORE), + } + } + + /// One request on [`grants::PORT`], answered with its text: the grants + /// listed, one written or one taken away. + fn serve_grants(&mut self, conn: &Connection, caller: &Caller, msg_type: u32, payload: &[u8]) { + let (msg, text) = match self.grant(caller, msg_type, payload) { + Ok(text) => (grants::MSG_DONE, text), + Err(why) => { + say!("supervisor: grants: refused {} in {}: {why}", caller.row.name, caller.session); + (grants::MSG_REFUSED, why) + } + }; + let _ = conn.try_send_bytes(msg, text.as_bytes()); + } + + /// **Only a login session reads or changes the grants**: one is a user's, + /// and the machine's session is every service's. + fn grant(&mut self, caller: &Caller, msg_type: u32, payload: &[u8]) -> Result { + let request = grants::Request::decode(msg_type, payload).ok_or("the request is not one this port reads")?; + if !matches!(caller.session, Session::Login(_)) { + return Err("only a login session may ask for grants".to_string()); + } + let user = toyos_manifest::USER; + let mut store = self.store.clone(); + let done = match request { + grants::Request::List => return Ok(store.entries().iter().map(grants::listed).collect()), + grants::Request::Revoke { package } => { + if !store.revoke(user, &package) { + return Err(format!("{package} holds no grant")); + } + format!("{package}'s grant is revoked; its next launch holds no folder") + } + grants::Request::Add { package, folder } => { + grants::folder(&folder.path)?; + let ceiling = self.system.folder.ok_or("this image grants no package a folder")?; + if folder.access > ceiling { + return Err(format!("{} is past this image's {ceiling}", folder.access)); + } + let (system, name, path) = (self.system, package.clone(), folder.path.clone()); + let binary = self.files("a grant's files", move || -> Result { + let program = installed(system, &name)?; + is_folder(&path)?; + binary_digest(&program) + })??; + let said = format!("{package} is granted {} {}", folder.path, folder.access); + store.put(grants::Entry { user: user.to_string(), package, binary, folder })?; + said + } + }; + let text = store.render(); + self.files("the grants", move || write_store(&text))??; + say!("supervisor: grants: {done}"); + self.store = store; + Ok(done) } fn make_session_home(&mut self) { @@ -931,12 +1032,13 @@ impl<'a> Supervisor<'a> { /// service it has just killed to finish ending, which is the kernel's /// teardown and no client's. fn serve_forever(&mut self, swap: &Acceptor, power: &Acceptor) -> ! { - let poller = Poller::new(5 + MAX_PENDING_LAUNCHES as u32); + let poller = Poller::new(6 + MAX_PENDING_LAUNCHES as u32); let mut pending: Vec = Vec::new(); let mut flight: Option = None; let mut ready: Vec = Vec::new(); loop { - poller.watch(&self.launcher, READABLE, TOKEN_ACCEPTOR); + poller.watch(&self.minted.launcher, READABLE, TOKEN_ACCEPTOR); + poller.watch(&self.minted.grants, READABLE, TOKEN_GRANTS_ACCEPTOR); poller.watch(swap, READABLE, TOKEN_SWAP_ACCEPTOR); poller.watch(power, READABLE, TOKEN_POWER_ACCEPTOR); poller.watch(&self.wake, READABLE, TOKEN_WAKE); @@ -970,7 +1072,12 @@ impl<'a> Supervisor<'a> { // Accept and the request are two events. Nothing is read here. for (token, acceptor, port) in [ - (TOKEN_ACCEPTOR, &self.launcher, (|s, c| s.caller(c).map(Port::Launcher)) as fn(&Self, &Connection) -> _), + ( + TOKEN_ACCEPTOR, + &self.minted.launcher, + (|s, c| s.caller(&s.minted.launcher, c).map(Port::Launcher)) as fn(&Self, &Connection) -> _, + ), + (TOKEN_GRANTS_ACCEPTOR, &self.minted.grants, |s, c| s.caller(&s.minted.grants, c).map(Port::Grants)), (TOKEN_SWAP_ACCEPTOR, swap, |_, _| Ok(Port::Swap)), (TOKEN_POWER_ACCEPTOR, power, |_, _| Ok(Port::Power)), ] { @@ -1034,6 +1141,7 @@ impl<'a> Supervisor<'a> { Port::Launcher(caller) => { self.serve_launch(&p.conn, &caller, msg_type, p.rx.payload(payload_len)) } + Port::Grants(caller) => self.serve_grants(&p.conn, &caller, msg_type, p.rx.payload(payload_len)), Port::Power => self.stop(&p.conn, msg_type), Port::Swap => { let payload = p.rx.payload(payload_len).to_vec(); @@ -1070,12 +1178,12 @@ impl<'a> Supervisor<'a> { } } - /// The row and session a launcher connection's badge names. Only this - /// supervisor mints on its launcher, so a refusal here is its own bug, said - /// and the connection dropped. - fn caller(&self, conn: &Connection) -> Result { + /// The row and session the badge of a connection on `acceptor`, one of + /// [`Minted`]'s, names. Only this supervisor mints on them, so a refusal + /// here is its own bug, said and the connection dropped. + fn caller(&self, acceptor: &Acceptor, conn: &Connection) -> Result { let mut badge = [0u8; MAX_BADGE]; - let badge = self.launcher.badge(conn, &mut badge).map_err(|e| format!("its connection has no badge ({e:?})"))?; + let badge = acceptor.badge(conn, &mut badge).map_err(|e| format!("its connection has no badge ({e:?})"))?; let Some(Authority { row, session }) = Authority::decode(badge) else { return Err(format!("its badge is not one this supervisor writes: {badge:?}")); }; @@ -1212,7 +1320,7 @@ impl<'a> Supervisor<'a> { let _ = old.wait(); } let started = - service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.grants, &self.launcher, &mut self.log); + service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.grants, &self.minted, &mut self.log); match started { Ok(pid) => { swapped( @@ -1383,7 +1491,7 @@ impl<'a> Supervisor<'a> { let (path, owed, program) = (service.path.clone(), service.devices.clone(), service.program); self.make_home(program); let service = &mut self.services[index]; - match service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.grants, &self.launcher, &mut self.log) { + match service.spawn(&path, &owed, self.system, self.syscap, &self.connectors, &self.grants, &self.minted, &mut self.log) { Ok(new) => say!("supervisor: {label} (pid {pid}) ended; started again as pid {new}"), Err(e) => { let mut kept = service.kept.lock().expect("supervisor: a service's state is poisoned"); @@ -1403,7 +1511,7 @@ impl<'a> Supervisor<'a> { self.make_home(program); let service = &mut self.services[index]; let name = service.program.name.clone(); - match service.spawn(previous, owed, self.system, self.syscap, &self.connectors, &self.grants, &self.launcher, &mut self.log) { + match service.spawn(previous, owed, self.system, self.syscap, &self.connectors, &self.grants, &self.minted, &mut self.log) { Ok(pid) => { service.kept.lock().expect("supervisor: a service's state is poisoned").swapping = false; swapped(&name, Word::Restored, &format!("{previous} as pid {pid}")); @@ -1422,6 +1530,44 @@ impl<'a> Supervisor<'a> { } } +/// The program installed package `name` launches, as its manifest names it +/// and the image would start it: a package named after a declared row is none. +fn installed(system: &Manifest, name: &str) -> Result { + let file = Package::path(name); + let text = std::fs::read_to_string(&file).map_err(|e| format!("{file} cannot be read: {e}"))?; + let package = Package::parse(&text)?; + if package.name != name { + return Err(format!("{file} calls itself {:?}", package.name)); + } + system.app_row(name, &package.program)?; + Ok(package.program) +} + +/// `Ok` where `path` is a directory, and not a link to one. +fn is_folder(path: &str) -> Result<(), String> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => Ok(()), + Ok(_) => Err(format!("{path} is no directory")), + Err(e) => Err(format!("{path} is no directory: {e}")), + } +} + +/// The SHA-256 of the binary at `path`, which a grant is keyed on. +fn binary_digest(path: &str) -> Result { + let bytes = read_binary(path).map_err(|why| why.to_string())?; + Ok(toyos_swap::hex(&toyos_swap::digest(&bytes))) +} + +/// [`grants::STORE`] replaced whole: a name beside it, then one rename, so the +/// store is never a partial one. +fn write_store(text: &str) -> Result<(), String> { + let partial = format!("{}.partial", grants::STORE); + make_dir(grants::STORE_DIR) + .and_then(|()| std::fs::write(&partial, text)) + .and_then(|()| std::fs::rename(&partial, grants::STORE)) + .map_err(|e| format!("{} could not be written: {e}", grants::STORE)) +} + /// A staged or installed binary's bytes, refused past [`toyos_swap::MAX_BINARY_BYTES`] /// before any of it is read. Made from the loop: a swap's files are under /// [`toyos_swap::STAGING`], which the kernel serves. @@ -1565,20 +1711,21 @@ impl Supervisor<'_> { } } - let installed; // On the worker, every file the launch reads: a path under `/apps` is // read off its package, and the program and its working directory may // be a file server's, which the supervisor supervises. Whether the // caller may start it is asked between the two, so a refused launch - // reads no image and judges no directory. + // reads no image and judges no directory. A package's binary is + // hashed there too: its grant is keyed on it. let (system, path) = (self.system, request.program.to_string()); let (row, session) = (caller.row, caller.session); let found = self.files("a launch's files", move || { resolve(system, &path, |target| -> Result<_, authority::Refusal> { let starts = authority::may_start(row, session, target)?; let (Target::Row(program) | Target::Package(program)) = target; + let binary = matches!(target, Target::Package(_)).then(|| binary_digest(&program.path)); let prepared = command.image_from(Path::new(&program.path)).prepare().map(drop); - Ok((starts, prepared.map(|()| command))) + Ok((starts, binary, prepared.map(|()| command))) }) }); let resolved = match found { @@ -1589,12 +1736,9 @@ impl Supervisor<'_> { return; } }; - let (program, verdict) = match resolved { - Resolved::Row(row, verdict) => (row, verdict), - Resolved::Package(row, verdict) => { - installed = row; - (&installed, verdict) - } + let (mut program, verdict) = match resolved { + Resolved::Row(row, verdict) => (row.clone(), verdict), + Resolved::Package(row, verdict) => (row, verdict), 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 @@ -1612,7 +1756,7 @@ impl Supervisor<'_> { }; // **`MSG_REFUSED`, never `MSG_NOT_DECLARED`**: the latter is std's cue to // spawn the program itself. - let (starts, prepared) = match verdict { + let (starts, binary, prepared) = match verdict { Ok(judged) => judged, Err(why) => { say!("{}", authority::refused(&caller.row.name, caller.session, &program.name, why)); @@ -1620,7 +1764,7 @@ impl Supervisor<'_> { return; } }; - let command = match prepared { + let mut command = match prepared { Ok(command) => command, Err(e) => { say!("supervisor: launcher: cannot start {}: {e}", program.name); @@ -1628,6 +1772,20 @@ impl Supervisor<'_> { return; } }; + if let Some(binary) = binary { + match self.granted(&program.name, binary, command, request.cwd) { + Ok((granted, prepared)) => { + program.folder = granted; + command = prepared; + } + Err(why) => { + say!("supervisor: launcher: {} was not started: {why}", program.name); + let _ = conn.try_signal(launch::MSG_REFUSED); + return; + } + } + } + let program = &program; let session = starts.session(&mut self.sessions); let caller_slots: Vec<(u32, toyos::RawHandle)> = request.slot_numbers().zip(slots.0.iter().copied()).collect(); @@ -1652,7 +1810,7 @@ impl Supervisor<'_> { &self.grants, &extras, Storage::default(), - (&self.launcher, session), + (&self.minted, session), Output::Launch { log: &mut self.log, slots: &caller_slots }, ); match started { @@ -1683,6 +1841,35 @@ impl Supervisor<'_> { } } +impl Supervisor<'_> { + /// The folder package `name`'s launch of `binary` is granted + /// ([`grants::decide`]), and `command` prepared again to start in it + /// ([`grants::cwd`]) once it is still a directory. `Err` refuses the + /// launch: a grant whose folder is gone names nothing. + fn granted( + &mut self, + name: &str, + binary: Result, + mut command: Command, + cwd: &str, + ) -> Result<(Option, Command), String> { + let binary = binary?; + let Some(folder) = grants::decide(self.store.find(toyos_manifest::USER, name), &binary, self.system.folder) else { + if self.system.folder.is_some() { + say!("supervisor: launcher: {name} holds no folder; `grants add {name} ` grants it one"); + } + return Ok((None, command)); + }; + let (path, cwd) = (folder.path.clone(), grants::cwd(&folder.path, cwd)); + let prepared = self.files("a granted folder", move || -> Result { + is_folder(&path)?; + command.current_dir(&cwd).prepare().map_err(|e| format!("{cwd} is no working directory: {e}"))?; + Ok(command) + })??; + Ok((Some(folder), prepared)) + } +} + /// Handles a launch moved into the supervisor, released on every path out of it. /// /// A `Drop` and not a close at each `return`: a client picks which way out of @@ -1921,7 +2108,7 @@ fn start<'a>( grants: &Grants<'_>, extras: &[(&str, Connector)], storage: Storage, - launcher: (&Acceptor, Session), + launcher: (&Minted, Session), output: Output<'_>, ) -> std::io::Result<(Child, Vec)> { // A storage row's own arguments first: a file server's role leads its argv. @@ -1965,6 +2152,11 @@ fn start<'a>( command.endow(LAUNCHER, raw.0); held.0.push(raw); } + if let Some(ns) = grants_namespace(program, launcher) { + let raw = ns.into_raw(); + command.endow(grants::PORT, raw.0); + held.0.push(raw); + } // The namespace answers with connections, so a child holding `surface` // only there could never hand `surface` to a child of its own — which is @@ -2246,9 +2438,10 @@ fn build_namespace( view: Vec<(String, Connector)>, extras: &[(&str, Connector)], ) -> std::io::Result> { - // Never the swap port: [`swap_namespace`] says why. + // Never the swap port nor the grants port: [`swap_namespace`] and + // [`grants_namespace`] say why. let receives: Vec<&String> = - program.receives.iter().filter(|name| *name != toyos_swap::PORT).collect(); + program.receives.iter().filter(|name| *name != toyos_swap::PORT && *name != grants::PORT).collect(); if receives.is_empty() && extras.is_empty() && view.is_empty() { return Ok(None); } @@ -2362,12 +2555,13 @@ fn swap_namespace(program: &Program, connectors: &BTreeMap<&str, Connector>) -> /// A `launcher` minted with `program`'s row and the session it runs in, in a /// namespace of its own, for a row whose `starts` lists anything; endowed under /// [`LAUNCHER`] and never an entry of `svc`, for [`swap_namespace`]'s reason. -fn launcher_namespace(program: &Program, (launcher, session): (&Acceptor, Session)) -> Option { +fn launcher_namespace(program: &Program, (minted, session): (&Minted, Session)) -> Option { if program.starts.is_empty() { return None; } let badge = Authority { row: program.name.clone(), session }.encode(); - let connector = launcher + let connector = minted + .launcher .mint(&badge) .unwrap_or_else(|e| panic!("supervisor: no launcher for {}: {e:?}", program.name)); let ns = namespace::build() @@ -2377,6 +2571,26 @@ fn launcher_namespace(program: &Program, (launcher, session): (&Acceptor, Sessio Some(ns) } +/// [`grants::PORT`] minted with `program`'s row and the session it runs in, in +/// a namespace of its own, for the row whose `receives` names it; endowed under +/// that name and never an entry of `svc`, for [`swap_namespace`]'s reason. +/// The badge is who asks: the supervisor honours a login session's alone. +fn grants_namespace(program: &Program, (minted, session): (&Minted, Session)) -> Option { + if !program.receives.iter().any(|name| name == grants::PORT) { + return None; + } + let badge = Authority { row: program.name.clone(), session }.encode(); + let connector = minted + .grants + .mint(&badge) + .unwrap_or_else(|e| panic!("supervisor: no grants connector for {}: {e:?}", program.name)); + let ns = namespace::build() + .add(grants::PORT, &connector) + .finish() + .unwrap_or_else(|e| panic!("supervisor: no grants namespace for {}: {e:?}", program.name)); + Some(ns) +} + /// One of the supervisor's words on a swap: its line in the log. fn swapped(service: &str, word: Word, detail: &str) { say!("{}", toyos_swap::said(service, word, detail)); From 04750cc1b020ca15714fbcb74cff2541f0b7ac14 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 21:48:44 +0200 Subject: [PATCH 02/12] The decide table names its row type, which clippy asks of a tuple that wide Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- toyos-manifest/src/grants.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toyos-manifest/src/grants.rs b/toyos-manifest/src/grants.rs index 5ba8d3fbdd9..337b2c3cb00 100644 --- a/toyos-manifest/src/grants.rs +++ b/toyos-manifest/src/grants.rs @@ -409,8 +409,9 @@ mod tests { let other = "0".repeat(DIGEST_LEN); let games = |access| Some(Folder { path: "/home/toy/Games".into(), access }); use Access::*; + type Row<'a> = (Option<&'a Entry>, &'a str, Option, Option); #[rustfmt::skip] - let table: [(Option<&Entry>, &str, Option, Option); 8] = [ + let table: [Row; 8] = [ (Some(&rw), BIN, Some(ReadWrite), games(ReadWrite)), (Some(&ro), BIN, Some(ReadWrite), games(ReadOnly)), (Some(&rw), BIN, Some(ReadOnly), games(ReadOnly)), From 89c4c7e3b3fe33a16f69c37083f8620dcb20ce24 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 21:50:56 +0200 Subject: [PATCH 03/12] The supervisor asserts a granted folder fits a grant and the grant list one answer Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- userland/supervisor/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 1b12d09c827..1269b9dda07 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -2517,6 +2517,12 @@ const _: () = assert!( <= toyos::fs::MAX_GRANT_ROOT ); +/// A granted folder's root is one a grant carries, and every grant listed is +/// one answer. +const _: () = assert!( + grants::MAX_ROOT <= toyos::fs::MAX_GRANT_ROOT && grants::MAX_LIST_BYTES <= ipc::MAX_FRAME_LEN as usize +); + /// A grant on `acceptor`, `dir`'s role's port, naming `share`, by the /// namespace name a program opens it under. fn mint(acceptor: &Acceptor, share: u64, dir: &View) -> (String, Connector) { From c190ac79bb0e411633853739880ed8d29d2f2be4 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 21:54:09 +0200 Subject: [PATCH 04/12] app_view names a missing grants program as red rather than panicking The negative control, the whole change reverted, has no /system/bin/grants; its launch failing is now one named arm and the others still run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- tests/toyos-rust-tests/src/bin/app_view.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/app_view.rs b/tests/toyos-rust-tests/src/bin/app_view.rs index 391bfc2a9b5..aa009ed97f1 100644 --- a/tests/toyos-rust-tests/src/bin/app_view.rs +++ b/tests/toyos-rust-tests/src/bin/app_view.rs @@ -278,10 +278,13 @@ fn granted(red: &mut Vec) { 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"); - let outside = Command::new("/system/bin/grants").args(["add", "appview", GAMES]).output().expect("launch grants"); - match (outside.status.code(), String::from_utf8_lossy(&outside.stdout)) { - (Some(1), said) if said.contains("only a login session may ask for grants") => println!(" grants in the machine's session: refused"), - _ => red.push(format!("grants in the machine's session was answered {outside:?}")), + match Command::new("/system/bin/grants").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") => + { + println!(" grants in the machine's session: refused") + } + other => red.push(format!("grants in the machine's session was answered {other:?}")), } let apps = in_login(&format!("/system/bin/grants add appview {HOME}")); match String::from_utf8_lossy(&apps.stdout) { From d9c6ec19fe855208c57b841968779c404d3970fc Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 08:42:37 +0200 Subject: [PATCH 05/12] A package launched from the desktop asks the person at the screen for a folder Stage 2 of launch-time consent. A package's launch in a session that holds the screen, with no stored answer for its exact binary, is parked in the supervisor while filepicker asks the question in the compositor's prompt layer: Allow once, Always, Deny, or Escape to skip. Always and Deny are kept until `grants revoke`; Once and Skip are not. ssh's sessions never ask. - toyos-manifest: a login session holds the screen when the row serving `prompt` opened it, and a launch by any other `login` row drops it (`launch::Screen`); `grants::decide` answers Ask at the screen; the store keeps `deny`; `consent` is the protocol, its bounds and its plain words. - supervisor: the launch is decided, then started or parked (`Launch`, `Parked`), one question at a time, ended by its caller's hang-up, the answer, or the consent server's end, never by a clock. The digest is of the image `prepare` read, and that image is what starts. - std: `CommandExt::prepared_image`, and a prepare after a working directory keeps the program it read, so the bytes hashed are the bytes started. - compositor: launches go off the loop; a window made through `prompt` is a level above topmost and the taskbar, has every key, takes only presses made after its first frame was on the panel (`PromptGate`), and owns the releases of what it was given after it goes. - filepicker: serves `consent`; a chooser bounded by the folder rules. - build: `prompt` held by filepicker alone, `consent` received by no row. - tests: host tables for each rule; `consent_prompt` drives the desktop on a new `Profile::Desktop` through QMP keys and the tablet, over a hostile fullscreen topmost window. The grants store's writability by every declared row is filed as issues/any-declared-row-can-write-the-grants-store.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- Cargo.lock | 2 + Cargo.toml | 3 + ...declared-row-can-write-the-grants-store.md | 35 ++ sdk/std/os/process.rs | 14 +- sdk/std/sys/process.rs | 38 +- src/build.rs | 60 ++- system.toml | 13 +- tests/common/qemu.rs | 71 ++- tests/consentcase/system.toml | 61 +++ tests/metalcase/system.toml | 2 +- tests/panelcase/system.toml | 2 +- tests/toyos-rust-tests/Cargo.lock | 8 + tests/toyos-rust-tests/Cargo.toml | 2 + tests/toyos-rust-tests/src/bin/consent.rs | 293 +++++++++++++ tests/toyos.rs | 292 +++++++++++++ toyos-manifest/src/consent.rs | 205 +++++++++ toyos-manifest/src/grants.rs | 171 +++++--- toyos-manifest/src/launch.rs | 109 +++-- toyos-manifest/src/lib.rs | 5 +- toyos/src/process.rs | 5 + userland/compositor/desktop/src/hit.rs | 48 ++- userland/compositor/desktop/src/input.rs | 166 ++++++- userland/compositor/desktop/src/lib.rs | 11 +- userland/compositor/desktop/src/plan.rs | 38 +- userland/compositor/desktop/src/stack.rs | 86 +++- userland/compositor/desktop/src/window.rs | 22 +- userland/compositor/src/client.rs | 6 + userland/compositor/src/main.rs | 8 +- userland/compositor/src/render.rs | 29 +- userland/compositor/src/session.rs | 162 ++++++- userland/filepicker/Cargo.toml | 3 +- userland/filepicker/src/consent.rs | 407 ++++++++++++++++++ userland/filepicker/src/main.rs | 35 +- userland/supervisor/src/main.rs | 333 +++++++++++--- userland/toyos-window/src/lib.rs | 19 +- 35 files changed, 2503 insertions(+), 261 deletions(-) create mode 100644 issues/any-declared-row-can-write-the-grants-store.md create mode 100644 tests/consentcase/system.toml create mode 100644 tests/toyos-rust-tests/src/bin/consent.rs create mode 100644 toyos-manifest/src/consent.rs create mode 100644 userland/filepicker/src/consent.rs diff --git a/Cargo.lock b/Cargo.lock index ecf3c7b6304..e0962f5720d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1441,6 +1441,7 @@ dependencies = [ "filepicker-api", "toyos", "toyos-font", + "toyos-manifest", "toyos-window", ] @@ -5778,6 +5779,7 @@ dependencies = [ "toml", "toyos-abi", "toyos-blackbox", + "toyos-desktop", "toyos-elf", "toyos-fat32", "toyos-fat32-check", diff --git a/Cargo.toml b/Cargo.toml index 5ecc2b88c96..15e2886d3b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -233,6 +233,9 @@ toyos-xhci = { path = "toyos-xhci" } # The ACPI server's count line, so the judge reads the words the server writes # and the job that waits for them matches. acpiserver-api = { path = "userland/acpiserver-api" } +# The desktop's geometry and the prompt's colour, so `consent_prompt` clicks +# where the compositor draws and finds the prompt by what the compositor paints. +toyos-desktop = { path = "userland/compositor/desktop" } [patch.crates-io] loom = { git = "https://github.com/ToyOSOrg/loom", branch = "toyos" } diff --git a/issues/any-declared-row-can-write-the-grants-store.md b/issues/any-declared-row-can-write-the-grants-store.md new file mode 100644 index 00000000000..9c81b1413c7 --- /dev/null +++ b/issues/any-declared-row-can-write-the-grants-store.md @@ -0,0 +1,35 @@ +--- +status: open +kind: defect +opened: 2026-10-10 +--- + +# Any declared row can write the grants store + +`/system/bin/supervisor` keeps every answer about a folder of the home in +`/state/supervisor/grants` (`toyos_manifest::grants::STORE`): which folder a +package's exact binary is granted, and which binaries were denied. The +supervisor is the only program meant to write it, and it reads it back at boot +refusing by name anything its own renderer cannot have written. + +But every row the image declares sees the whole tree read-write +(`toyos_manifest::whole_tree`), `/state` included. So any declared program, a +shell and everything a shell starts among them, can write a line granting a +package any folder the store's parser accepts, or delete a Deny, and the next +boot honours it without asking the person at the screen. Such a row already +reaches the whole tree itself, so it gains no file it lacks; what it gains is +a package's access, without the consent the prompt exists to ask for. + +The store is read once at boot, so a rewrite made while the machine runs takes +effect at the next boot. + +## Owner + +`issues/every-program-sees-only-the-files-it-was-given.md`, stage 2: every +row declares its files, and nothing still sees the global tree. + +## Exit condition + +No row but the supervisor's own view holds `/state/supervisor` writable, and a +guest test whose job writes a grant line into the store from a shell is +refused by the file server. diff --git a/sdk/std/os/process.rs b/sdk/std/os/process.rs index 3eb0354132c..f52f7e7e0c4 100644 --- a/sdk/std/os/process.rs +++ b/sdk/std/os/process.rs @@ -50,10 +50,18 @@ pub trait CommandExt { /// for a caller whose spawning thread may not wait on one, as /// `/system/bin/supervisor`'s loop may not wait on the file servers it starts /// again. A later [`current_dir`](crate::process::Command::current_dir) - /// undoes it. + /// undoes it but for the program read, which a prepare after it keeps and + /// a later [`image_from`](CommandExt::image_from) does not. #[stable(feature = "toyos_ext", since = "1.0.0")] fn prepare(&mut self) -> crate::io::Result<&mut Self>; + /// The bytes a prepared spawn starts, as [`prepare`](CommandExt::prepare) + /// read them off a file server: what a caller holds a program to before it + /// runs, with no second read between the two. `None` before a prepare and + /// for a program the kernel opens itself. + #[stable(feature = "toyos_ext", since = "1.0.0")] + fn prepared_image(&self) -> Option<&[u8]>; + /// Run the program at `path` rather than the one the command names, which /// 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]`. @@ -109,6 +117,10 @@ impl CommandExt for crate::process::Command { Ok(self) } + fn prepared_image(&self) -> Option<&[u8]> { + self.as_inner().prepared_image() + } + fn image_from(&mut self, path: &crate::path::Path) -> &mut Self { self.as_inner_mut().image_from(path.as_os_str()); self diff --git a/sdk/std/sys/process.rs b/sdk/std/sys/process.rs index eab4e112298..ce482abfd78 100644 --- a/sdk/std/sys/process.rs +++ b/sdk/std/sys/process.rs @@ -29,6 +29,10 @@ pub struct Command { /// The program the child runs, when it is not the one `argv[0]` names. image_path: Option, prepared: Option, + /// The program [`Command::prepare`] read off a file server, and the path + /// it read: kept across a later working directory, so a spawn starts the + /// bytes the caller saw ([`Command::prepared_image`]). + image: Option<(OsString, toyos::process::Image)>, } /// Whom a child is placed under, whose end takes it down. @@ -46,8 +50,6 @@ enum Parent { struct Prepared { program: OsString, cwd: String, - /// The program, when it is on a file server. - image: Option, } /// Where a spawn goes once the launcher has been asked, or could not be. @@ -98,6 +100,7 @@ impl Command { parent: Parent::Caller, image_path: None, prepared: None, + image: None, } } @@ -110,6 +113,7 @@ impl Command { pub fn image_from(&mut self, path: &OsStr) { self.image_path = Some(path.to_owned()); self.prepared = None; + self.image = None; } pub fn env_mut(&mut self) -> &mut CommandEnv { @@ -122,19 +126,32 @@ impl Command { } /// Find the program, judge the working directory and read a program on a - /// file server, so that [`Self::spawn`] calls no file server for them. + /// file server, so that [`Self::spawn`] calls no file server for them. A + /// program already read from the same path is kept, not read again. pub fn prepare(&mut self) -> io::Result<()> { let program = self.resolve_program()?; let cwd = self.child_cwd()?; - let source = Path::new(self.image_path.as_deref().unwrap_or(&program)); - let image = match crate::sys::fs::is_served(source) { - true => Some(crate::sys::fs::read_image(source)?), - false => None, - }; - self.prepared = Some(Prepared { program, cwd, image }); + let source = self.image_path.clone().unwrap_or_else(|| program.clone()); + let kept = self.image.as_ref().is_some_and(|(read, _)| *read == source); + if !kept { + self.image = None; + if crate::sys::fs::is_served(Path::new(&source)) { + let image = crate::sys::fs::read_image(Path::new(&source))?; + self.image = Some((source, image)); + } + } + self.prepared = Some(Prepared { program, cwd }); Ok(()) } + /// The program a prepared spawn starts, as [`Self::prepare`] read it off a + /// file server: `None` before a prepare, and for a program the kernel + /// opens itself. + pub fn prepared_image(&self) -> Option<&[u8]> { + self.prepared.as_ref()?; + self.image.as_ref().map(|(_, image)| image.bytes()) + } + pub fn stdin(&mut self, stdin: Stdio) { self.stdin = Some(stdin); } @@ -284,8 +301,9 @@ impl Command { )); } let prepared = self.prepared.take(); + let image = self.image.take().map(|(_, image)| image); let (resolved, cwd, prepared_image) = match prepared { - Some(Prepared { program, cwd, image }) => (program, cwd, image), + Some(Prepared { program, cwd }) => (program, cwd, image), None => (self.resolve_program()?, self.child_cwd()?, None), }; let mut argv_buf = Vec::new(); diff --git a/src/build.rs b/src/build.rs index 6aae599de74..982075facef 100644 --- a/src/build.rs +++ b/src/build.rs @@ -586,28 +586,41 @@ const SUPERVISOR_PROGRAM: &str = "supervisor"; /// row receives it. const SUPERVISOR_SERVED: &[&str] = &[toyos_swap::PORT, "power", toyos_manifest::grants::PORT]; -/// The supervisor's ports that one row alone may receive, each with that row: -/// the swap port, whose holder replaces any service's binary, and the grants -/// port, whose holder writes which folder of the home a package sees. -const HELD_ALONE: &[(&str, &str)] = - &[(toyos_swap::PORT, toyos_swap::HOLDER), (toyos_manifest::grants::PORT, toyos_manifest::grants::HOLDER)]; +/// The ports that one row alone may receive, each with that row: the swap +/// port, whose holder replaces any service's binary, the grants port, whose +/// holder writes which folder of the home a package sees, and the prompt +/// port, whose holder draws above every window and has every key while it does. +const HELD_ALONE: &[(&str, &str)] = &[ + (toyos_swap::PORT, toyos_swap::HOLDER), + (toyos_manifest::grants::PORT, toyos_manifest::grants::HOLDER), + (toyos_manifest::consent::PROMPT, toyos_manifest::consent::SERVER), +]; /// Who may hold the authorities that change what the machine runs or what a /// package sees: each of [`HELD_ALONE`]'s ports, its holder and nothing else -/// — no other `[programs]` row and never `[apps]` — and the idle slot, -/// [`toyos_update::slots::HOLDER`] alone. +/// — no other `[programs]` row and never `[apps]` — the idle slot, +/// [`toyos_update::slots::HOLDER`] alone, and the consent port, which no row +/// receives and its server alone serves. /// /// **Checked on every manifest rendered, not only on the committed configs**, /// because the swap port's holder can replace any service's binary, the -/// grants port's hands a package part of the home, and the idle slot's -/// holder writes the image the machine boots next. +/// grants port's hands a package part of the home, the prompt port's and the +/// consent port's ask the person at the screen and answer for them, and the +/// idle slot's holder writes the image the machine boots next. fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { + use toyos_manifest::consent; for (name, program) in &config.programs { for (port, holder) in HELD_ALONE { if name != holder && program.receives.iter().any(|r| r == port) { return Err(format!("`{name}` receives `{port}`, which only `{holder}` may hold")); } } + if program.receives.iter().any(|r| r == consent::PORT) { + return Err(format!("`{name}` receives `{}`, which only the supervisor may hold", consent::PORT)); + } + if name != consent::SERVER && program.serves.iter().any(|s| s == consent::PORT) { + return Err(format!("`{name}` serves `{}`, which only `{}` may serve", consent::PORT, consent::SERVER)); + } if name != toyos_update::slots::HOLDER && program.slots { return Err(format!("`{name}` asks for `slots`, which only `{}` may hold", toyos_update::slots::HOLDER)); } @@ -617,6 +630,9 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { return Err(format!("`[apps] receives` names `{port}`, which only `{holder}` may hold")); } } + if config.apps.receives.iter().any(|r| r == consent::PORT) { + return Err(format!("`[apps] receives` names `{}`, which only the supervisor may hold", consent::PORT)); + } apps_folder(config).map(drop) } @@ -2908,6 +2924,7 @@ mod tests { "diag/system.toml", "console/system.toml", "tests/acpicase/system.toml", + "tests/consentcase/system.toml", "tests/jobcase/system.toml", "tests/latencycase/system.toml", "tests/logstallcase/system.toml", @@ -2988,6 +3005,31 @@ mod tests { assert!(held_by_their_holders_alone(&apps).is_err()); } + /// The prompt port reaches its server alone, and the consent port no row, + /// nor `[apps]`, and is served by its server alone. + #[test] + fn only_the_consent_server_holds_the_prompt_and_serves_consent() { + let gate = |text: &str| held_by_their_holders_alone(&toml::from_str(text).unwrap()); + assert_eq!( + gate("[programs.filepicker]\nserves = [\"filepicker\", \"consent\"]\nreceives = [\"prompt\"]\n\ + [programs.compositor]\nserves = [\"prompt\"]\n"), + Ok(()) + ); + for (text, said) in [ + ("[programs.paint]\nreceives = [\"prompt\"]\n", "`paint` receives `prompt`, which only `filepicker` may hold"), + ("[apps]\nreceives = [\"prompt\"]\n", "`[apps] receives` names `prompt`"), + ("[programs.filepicker]\nreceives = [\"consent\"]\n", "`filepicker` receives `consent`, which only the supervisor"), + ("[programs.shell]\nreceives = [\"consent\"]\n", "`shell` receives `consent`"), + ("[apps]\nreceives = [\"consent\"]\n", "`[apps] receives` names `consent`"), + ("[programs.paint]\nserves = [\"consent\"]\n", "`paint` serves `consent`, which only `filepicker` may serve"), + ] { + match gate(text) { + Err(why) if why.contains(said) => {} + other => panic!("{text:?}: {other:?}, not {said:?}"), + } + } + } + /// `[apps] folder` is one of the two accesses, spelled as the manifest /// spells it, and nothing else reaches a manifest. #[test] diff --git a/system.toml b/system.toml index aca7da80c9d..ed62d1ab3db 100644 --- a/system.toml +++ b/system.toml @@ -68,10 +68,12 @@ receives = ["power"] # `starts` is what the desktop's taskbar and launcher start. `login = true` # makes the local desktop a login session, so `swap` and `update` run from its -# shell. +# shell. `prompt` is the layer above every window, which `filepicker` alone +# reaches: serving it makes the sessions its launches open the screen's, whose +# packages may ask the person at it for a folder. [programs.compositor] service = true -serves = ["compositor"] +serves = ["compositor", "prompt"] receives = ["soundserver", "filepicker"] devices = ["framebuffer", "keyboard", "mouse"] starts = ["terminal", "files", "/apps"] @@ -143,10 +145,13 @@ syscap = ["trace"] # it reads the files a record names and holds nothing else. [programs.symbolize] +# The file picker, and the consent server: `consent` is the supervisor's +# question before a package's launch, which no row receives, and `prompt` the +# layer it asks it in, which no other row may hold (`src/build.rs`). [programs.filepicker] service = true -serves = ["filepicker"] -receives = ["compositor"] +serves = ["filepicker", "consent"] +receives = ["compositor", "prompt"] [programs.terminal] provides = ["surface"] diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 758ee8281a2..efe39b385b7 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -697,6 +697,13 @@ pub enum Profile { /// ===TEST_START=== protocol like any other. [`BootOptions::mute`] takes /// it away for the one test that certifies the T14's literal shape. Metal, + /// [`Profile::Metal`]'s machine with a USB keyboard and an absolute USB + /// tablet beside its i8042, on a smaller panel: the desktop driven as a + /// person at QEMU's window drives it, so a test injects keys and a pointer + /// position through the compositor's own devices and reads the panel back. + /// The i8042 stays; QEMU activates one input handler per device class, so + /// every injected keystroke and position goes to the USB device. + Desktop, /// QEMU `virt` on AArch64 (GICv3, AAVMF): a GOP from `ramfb`, the boot /// stick on an xHCI, the PL011, a virtio-rng for firmware's /// `EFI_RNG_PROTOCOL`, and nothing else — no NIC, NVMe or IOMMU. The @@ -732,7 +739,8 @@ impl Profile { Self::Headless | Self::HeadlessNoIommu | Self::HeadlessNoUsb - | Self::Metal => Arch::X86_64, + | Self::Metal + | Self::Desktop => Arch::X86_64, } } @@ -936,6 +944,11 @@ impl Profile { iommu: Some(IOMMU_DEFAULT), rng: false, }, + Self::Desktop => Shape { + panel: Some((1280, 800)), + usb: &["usb-kbd,bus=xhci.0", "usb-tablet,bus=xhci.0"], + ..Self::Metal.shape() + }, Self::HeadlessNoIommu => Shape { iommu: None, ..Self::Headless.shape() }, Self::HeadlessNoUsb => Shape { xhci: &[], usb: &[], ..Self::Headless.shape() }, } @@ -1400,7 +1413,6 @@ impl QemuInstance { pub fn screendump(&mut self) -> super::screen::Ppm { let socket = self.sockets.qmp.clone().expect("screendump needs BootOptions { qmp: true }"); let out = self.screendump.clone(); - let _ = fs::remove_file(&out); // A guest that triple-faults exits QEMU (`-no-reboot`), and the // socket then refuses every connect. Without this the retry loop @@ -1415,13 +1427,13 @@ impl QemuInstance { panic!("[qemu] QEMU died before the screendump (status: {status})"); } }); - qmp.execute(&format!( - "{{\"execute\":\"screendump\",\"arguments\":{{\"filename\":\"{}\"}}}}", - out.display() - )); + dump(&mut qmp, &out) + } - let bytes = fs::read(&out).expect("screendump: QEMU wrote no file"); - super::screen::Ppm::parse(&bytes) + /// Where [`Self::screendump`] has QEMU write the scanout, for a + /// [`QmpInput::screendump`] made from inside a paced step. + pub fn screendump_file(&self) -> PathBuf { + self.screendump.clone() } /// Screendump until the decoded screen carries `needle`, or the timeout. @@ -1953,6 +1965,17 @@ impl QmpMonitor { } } +/// The scanout, written by QEMU to `out` and read back. +fn dump(qmp: &mut Qmp, out: &Path) -> super::screen::Ppm { + let _ = fs::remove_file(out); + qmp.execute(&format!( + "{{\"execute\":\"screendump\",\"arguments\":{{\"filename\":\"{}\"}}}}", + out.display() + )); + let bytes = fs::read(out).expect("screendump: QEMU wrote no file"); + super::screen::Ppm::parse(&bytes) +} + /// An open QMP connection for injecting input. /// /// One connection rather than one per event, because QEMU delivers each @@ -1989,8 +2012,40 @@ impl QmpInput { .collect(); self.send(&body); } + + /// The pointer to pixel `at` of a `screen`-sized panel, by an absolute + /// tablet position, which the compositor maps back to the pixel + /// (`toyos_desktop::cursor_from_abs`): the least position at or past it. + pub fn point(&mut self, at: (i32, i32), screen: (usize, usize)) { + let abs = |pixel: i32, size: usize| { + let size = size as i64; + ((i64::from(pixel) * ABS_RANGE + size - 1) / size).min(ABS_RANGE - 1) + }; + let (x, y) = (abs(at.0, screen.0), abs(at.1, screen.1)); + self.send(&[ + format!("{{\"type\":\"abs\",\"data\":{{\"axis\":\"x\",\"value\":{x}}}}}"), + format!("{{\"type\":\"abs\",\"data\":{{\"axis\":\"y\",\"value\":{y}}}}}"), + ]); + } + + /// The left button pressed at `at`, then released, each its own report. + pub fn click(&mut self, at: (i32, i32), screen: (usize, usize)) { + self.point(at, screen); + for down in [true, false] { + self.send(&[format!("{{\"type\":\"btn\",\"data\":{{\"down\":{down},\"button\":\"left\"}}}}")]); + } + } + + /// [`QemuInstance::screendump`] on this connection, into `out`, for a + /// step that holds no instance. + pub fn screendump(&mut self, out: &Path) -> super::screen::Ppm { + dump(&mut self.0, out) + } } +/// The span of QEMU's absolute axes, which the USB tablet reports unscaled. +const ABS_RANGE: i64 = 32768; + /// The argv `options` would launch QEMU with, built against placeholder /// paths. A profile's claim about which devices exist is a claim about this /// list and nothing else — no screendump can see a device that is present but diff --git a/tests/consentcase/system.toml b/tests/consentcase/system.toml new file mode 100644 index 00000000000..a31fa840c5a --- /dev/null +++ b/tests/consentcase/system.toml @@ -0,0 +1,61 @@ +# The boot `consent_prompt` drives through the compositor's own keyboard and +# tablet on `Profile::Desktop`'s machine: a package launched from the +# launcher asks the person at the screen for a folder in the prompt layer, +# which `filepicker` alone reaches, over a hostile fullscreen topmost window +# the job itself opens. +# +# test-runner receives `compositor` so a job's own window is a client like any, +# and lists `shell`, whose `login` row opens a login session that is not the +# screen's: the session `grants` is answered in, and a launch that never asks. + +assets = ["assets"] + +[boot] +start = ["logkeeper", "diskserver", "fileserver", "compositor", "filepicker", "test-runner"] + +[apps] +receives = ["compositor"] +folder = "read-write" + +# `every_boot_config_runs_logkeeper` is what refuses a boot config without it. +[programs.logkeeper] +service = true +syscap = ["logread"] + +[programs.compositor] +service = true +serves = ["compositor", "prompt"] +devices = ["framebuffer", "keyboard", "mouse"] +starts = ["/apps"] +login = true + +[programs.filepicker] +service = true +serves = ["filepicker", "consent"] +receives = ["compositor", "prompt"] + +# `dup` because test-runner hands each binary a duplicate of its capability. +[programs.test-runner] +syscap = ["dup"] +receives = ["compositor"] +starts = ["shell", "grants", "/apps"] + +[programs.shell] +starts = ["grants", "/apps"] +login = true + +[programs.grants] +receives = ["grants"] + +# The block service and the file servers, as `tests/proctreecase` has them: +# the package, the home and the store are DATA's. +[programs.diskserver] +service = true +restart = true +serves = ["block"] +devices = ["pci:1b36:0010"] + +[programs.fileserver] +restart = true +roles = ["data", "log", "boot"] +receives = ["block"] diff --git a/tests/metalcase/system.toml b/tests/metalcase/system.toml index 49bbffaaa84..fcdbf94dd5b 100644 --- a/tests/metalcase/system.toml +++ b/tests/metalcase/system.toml @@ -24,7 +24,7 @@ syscap = ["logread"] [programs.compositor] service = true -serves = ["compositor"] +serves = ["compositor", "prompt"] receives = ["soundserver"] devices = ["framebuffer", "keyboard", "mouse"] diff --git a/tests/panelcase/system.toml b/tests/panelcase/system.toml index d13bb762223..a41105fc6e0 100644 --- a/tests/panelcase/system.toml +++ b/tests/panelcase/system.toml @@ -16,5 +16,5 @@ syscap = ["logread"] [programs.compositor] service = true -serves = ["compositor"] +serves = ["compositor", "prompt"] devices = ["framebuffer", "keyboard", "mouse"] diff --git a/tests/toyos-rust-tests/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index d7afd1b6254..d3b133ca98d 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -693,6 +693,13 @@ dependencies = [ name = "toyos-abi" version = "0.16.0" +[[package]] +name = "toyos-desktop" +version = "0.1.0" +dependencies = [ + "toyos-abi", +] + [[package]] name = "toyos-font" version = "0.2.0" @@ -727,6 +734,7 @@ dependencies = [ "memmap2", "toyos", "toyos-abi", + "toyos-desktop", "toyos-inspect", "toyos-logstream", "toyos-tco", diff --git a/tests/toyos-rust-tests/Cargo.toml b/tests/toyos-rust-tests/Cargo.toml index c4e1f7285d4..27a573c249a 100644 --- a/tests/toyos-rust-tests/Cargo.toml +++ b/tests/toyos-rust-tests/Cargo.toml @@ -9,6 +9,8 @@ license = "MIT OR Apache-2.0" toyos-abi = { path = "../../toyos-abi" } toyos = { path = "../../toyos" } toyos-window = { path = "../../userland/toyos-window" } +# The desktop's furniture, for `consent`'s fullscreen window. +toyos-desktop = { path = "../../userland/compositor/desktop" } toyos-tco = { path = "../../toyos-tco" } toyos-inspect = { path = "../../toyos-inspect" } # The log as logkeeper serves it, and the asking for it, for `counters_metal`'s diff --git a/tests/toyos-rust-tests/src/bin/consent.rs b/tests/toyos-rust-tests/src/bin/consent.rs new file mode 100644 index 00000000000..56b6c9c4b72 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/consent.rs @@ -0,0 +1,293 @@ +//! A package's launch from the desktop asks the person at the screen for a +//! folder, in the prompt layer, and what they answer is what it holds. +//! +//! Run by `consent_prompt` on `tests/consentcase`, whose host side drives the +//! desktop through QEMU's keyboard and tablet: each step here says +//! `CONSENT ` on the console, the host clicks the package in the +//! launcher and answers the prompt with keys, and taps F12 once the package +//! has said what it saw (`CONSENTEE ...`). The tap reaches the job through +//! the one window it holds: +//! +//! - **a hostile client**, this binary as `hostile`: a fullscreen topmost +//! window that says every key and press the compositor gives it. It may be +//! given F12 and nothing else: every other key the host types, and the press +//! it makes outside the prompt, are the prompt's or nobody's. +//! +//! The steps, each held on the host to what the package saw and to the +//! store `/state/supervisor/grants`, which the job prints: +//! +//! - `always`: Games, Always. Stored; it lists the ROM and saves beside it. +//! - `again`: no question; the same folder. +//! - `deny`, after a revoke: Deny. Stored; no folder. +//! - `denied`: no question; no folder. +//! - `skip`, after a revoke: Escape. Nothing stored; no folder. +//! - `once`: asked again; Games, Allow once. Nothing stored; the folder. +//! - `ssh`: the package started from a login session no screen's row opened +//! asks nothing and holds no folder. +//! +//! First, std's half of the hash being of what runs: a prepared spawn starts +//! the bytes its prepare read, past a later working directory and a rewrite +//! of the file ([`prepared`]). +//! +//! The package is this binary installed as `consentee`, which, started under +//! that name, does what `app_view`'s `game` does: list the ROMs in its +//! working directory and save beside the first. + +use std::fs; +use std::io::{BufRead, BufReader, Lines, Write}; +use std::process::{ChildStdout, Command, Stdio}; + +use toyos_desktop::Chrome; +use window::{Color, Event, ResolutionInfo, Window, MSG_GET_RESOLUTION, MSG_RESOLUTION_CHANGED}; + +const SELF: &str = "/system/bin/test_rs_consent"; +const PACKAGE: &str = "/apps/consentee"; +const PROGRAM: &str = "/apps/consentee/consentee"; +const MANIFEST: &[u8] = b"name = \"consentee\"\nversion = \"1\"\n\ + digest = \"0000000000000000000000000000000000000000000000000000000000000000\"\n\ + program = \"/apps/consentee/consentee\"\n"; +const HOME: &str = "/home/toy"; +const GAMES: &str = "/home/toy/Games"; +const ROM: &str = "/home/toy/Games/test.gba"; +const SAVE: &str = "/home/toy/Games/test.sav"; +const STORE: &str = "/state/supervisor/grants"; +/// The hostile window's colour, which the host reads the panel for. +const HOSTILE: Color = Color { r: 0xff, g: 0x00, b: 0xff }; +/// The key the host taps once a step is done: HID usage F12. +const DONE: u8 = 0x45; +/// What this binary says run as `bytes`. +const RAN: &str = "BYTES ran"; + +fn main() { + let named = std::env::args().next().unwrap_or_default(); + if named.ends_with("/consentee") { + return game(); + } + match std::env::args().nth(1).as_deref() { + Some("hostile") => hostile(), + Some("bytes") => println!("{RAN}"), + _ => job(), + } +} + +fn job() { + let _ = fs::remove_dir_all(PACKAGE); + let _ = fs::remove_dir_all(GAMES); + fs::create_dir_all(PACKAGE).expect("make the package's directory"); + fs::copy(SELF, PROGRAM).expect("install this binary as the package's program"); + fs::write(format!("{PACKAGE}/manifest.toml"), MANIFEST).expect("write the package's manifest"); + 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"); + let _ = in_login("/system/bin/grants revoke consentee"); + + let mut red = Vec::new(); + prepared(&mut red); + let mut hostile = Command::new(SELF) + .arg("hostile") + .stdout(Stdio::piped()) + .spawn() + .expect("start the hostile window"); + let mut said = BufReader::new(hostile.stdout.take().expect("its stdout")).lines(); + until(&mut said, &mut red, "HOSTILE up"); + + // Where Games is in the prompt's list: the home's folders a package may be + // granted, sorted as the prompt sorts them. + let mut folders: Vec = fs::read_dir(HOME) + .expect("list the home") + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| name != "Apps") + .collect(); + folders.sort_by_key(|name| name.to_lowercase()); + let games = folders.iter().position(|name| name == "Games").expect("Games is in the home"); + + step(&mut said, &mut red, &format!("always down={games}")); + holds("always"); + match fs::read(SAVE) { + Ok(_) => println!(" always: its save is beside the ROM"), + Err(e) => red.push(format!("always: {SAVE}: {e}")), + } + let _ = fs::remove_file(SAVE); + step(&mut said, &mut red, "again"); + let _ = fs::remove_file(SAVE); + + revoke(&mut red); + step(&mut said, &mut red, "deny"); + holds("deny"); + step(&mut said, &mut red, "denied"); + + revoke(&mut red); + step(&mut said, &mut red, "skip"); + holds("skip"); + step(&mut said, &mut red, &format!("once down={games}")); + holds("once"); + let _ = fs::remove_file(SAVE); + + println!("CONSENT ssh"); + let ran = in_login(PROGRAM); + let out = String::from_utf8_lossy(&ran.stdout); + let none = "CONSENTEE cwd=/ roms=[] saved=Err"; + match out.lines().find(|l| l.starts_with("CONSENTEE ")) { + Some(saw) if saw == none => println!(" ssh: it saw {}", &saw["CONSENTEE ".len()..]), + other => red.push(format!("from a login session off the screen it said {other:?}, not {none:?}: {ran:?}")), + } + println!("CONSENT ssh done"); + // Its own sentinel: the host taps once it has read the panel again. + until_done(&mut said, &mut red, "ssh"); + + let _ = hostile.kill(); + let _ = hostile.wait(); + let _ = in_login("/system/bin/grants revoke consentee"); + let _ = fs::remove_dir_all(PACKAGE); + let _ = fs::remove_dir_all(GAMES); + if !red.is_empty() { + panic!("consent: {red:#?}"); + } + println!("consent: every answer was the person's, and the package held what it was given"); +} + +/// **The bytes a prepared spawn starts are the ones its prepare read**, which +/// is what lets the supervisor hold a package to the hash of what it starts: +/// a working directory set after the prepare keeps them, and the file +/// rewritten after it, here into no program at all, is not read again. +fn prepared(red: &mut Vec) { + use std::os::toyos::process::CommandExt; + let copy = format!("{GAMES}/prepared"); + let ours = fs::read(SELF).expect("read this binary"); + fs::write(©, &ours).expect("copy this binary onto DATA"); + let mut command = Command::new(©); + command.arg("bytes"); + if let Err(e) = command.prepare() { + red.push(format!("{copy} would not prepare: {e}")); + return; + } + let read = command.prepared_image().map(<[u8]>::to_vec); + fs::write(©, b"no program").expect("rewrite the copy"); + if let Err(e) = command.current_dir(HOME).prepare() { + red.push(format!("{copy} would not prepare again in {HOME}: {e}")); + return; + } + let kept = command.prepared_image().map(<[u8]>::to_vec); + match (read.as_deref() == Some(&ours[..]), kept == read, command.output()) { + (true, true, Ok(ran)) if String::from_utf8_lossy(&ran.stdout).contains(RAN) => { + println!(" a prepared spawn started the bytes it read, past a rewrite") + } + (read, kept, ran) => red.push(format!( + "a prepared spawn: the bytes read were this binary: {read}; kept across the directory: {kept}; it ran {ran:?}" + )), + } + let _ = fs::remove_file(©); +} + +/// Ask the host for `step`, and wait for its F12. +fn step(said: &mut Lines>, red: &mut Vec, step: &str) { + println!("CONSENT {step}"); + let _ = std::io::stdout().flush(); + until_done(said, red, step); +} + +/// Every line the hostile window says until the F12 the host taps, each held +/// to: F12 alone, and no press. +fn until_done(said: &mut Lines>, red: &mut Vec, step: &str) { + let done = format!("HOSTILE key {DONE:#x} down"); + loop { + let Some(Ok(line)) = said.next() else { + red.push(format!("{step}: the hostile window ended")); + return; + }; + if line == done { + return; + } + let allowed = line.starts_with(&format!("HOSTILE key {DONE:#x} ")) + || line.starts_with(&format!("HOSTILE mouse {} ", window::MOUSE_RELEASE)) + || line.starts_with(&format!("HOSTILE mouse {} ", window::MOUSE_MOVE)); + if !allowed { + red.push(format!("{step}: the hostile window was given `{line}`")); + } + } +} + +fn until(said: &mut Lines>, red: &mut Vec, want: &str) { + match said.next() { + Some(Ok(line)) if line == want => {} + other => red.push(format!("the hostile window said {other:?}, not {want:?}")), + } +} + +/// The store's line for the package after `step`, for the host to hold to +/// the SHA-256 of the binary it put on ROOT, which nothing in the guest +/// computed. +fn holds(step: &str) { + let store = fs::read_to_string(STORE).unwrap_or_default(); + let line = store.lines().find(|l| l.contains(" consentee ")).unwrap_or(""); + println!("STORE {step}: {line}"); +} + +fn revoke(red: &mut Vec) { + let out = in_login("/system/bin/grants revoke consentee"); + if !String::from_utf8_lossy(&out.stdout).contains("consentee's grant is revoked") { + red.push(format!("the revoke was answered {out:?}")); + } +} + +/// `line` run by a shell this job launches, whose `login` row opens a login +/// session that is not the screen's. +fn in_login(line: &str) -> std::process::Output { + Command::new("/system/bin/shell").args(["-c", line]).current_dir("/").output().expect("launch a shell") +} + +/// A fullscreen window that stays on top and says everything it is given. +fn hostile() { + let conn = toyos::endow::service("compositor").expect("a compositor"); + conn.signal(MSG_GET_RESOLUTION).expect("ask the compositor its resolution"); + let (kind, screen) = conn.recv::().expect("its resolution"); + assert_eq!(kind, MSG_RESOLUTION_CHANGED); + let chrome = Chrome::DEFAULT; + let (w, h) = ( + screen.width - chrome.chrome_w() as u32, + screen.height - chrome.taskbar as u32 - chrome.chrome_h() as u32, + ); + let mut window = Window::create_topmost(w, h, "hostile").expect("a topmost window"); + let fb = window.framebuffer(); + fb.clear(HOSTILE); + window.present(); + loop { + match window.recv_event() { + Event::Frame => break, + Event::Close => return, + _ => {} + } + } + println!("HOSTILE up"); + loop { + match window.recv_event() { + Event::KeyInput(key) => { + println!("HOSTILE key {:#x} {}", key.keycode, if key.pressed() { "down" } else { "up" }) + } + Event::MouseInput(mouse) => println!("HOSTILE mouse {} {} {}", mouse.event_type, mouse.x, mouse.y), + Event::Close => return, + _ => {} + } + } +} + +/// As gbae, started with no ROM: the ROMs in the working directory, and a +/// save beside the first, or where the granted folder's would be. +fn game() { + let cwd = std::env::current_dir().expect("a working directory"); + let mut roms: Vec = fs::read_dir(&cwd) + .into_iter() + .flatten() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| name.ends_with(".gba")) + .collect(); + roms.sort(); + let save = match roms.first() { + Some(rom) => cwd.join(rom).with_extension("sav"), + None => SAVE.into(), + }; + let saved = if fs::write(save, b"a save").is_ok() { "Ok" } else { "Err" }; + println!("CONSENTEE cwd={} roms={roms:?} saved={saved}", cwd.display()); +} diff --git a/tests/toyos.rs b/tests/toyos.rs index ff134ce8b13..8f2c3c6e78a 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -129,6 +129,9 @@ const RUST_SKIP: &[&str] = &[ // Needs a launcher whose row lists `/apps`, which `tests/testcases` does // not give: the `app_view` metal row runs it on tests/proctreecase. "app_view", + // Needs a desktop driven by a person's keys and pointer: `consent_prompt` + // runs it on tests/consentcase. + "consent", // Needs a launcher whose row lists a shell, and a shell whose row opens a // login session and lists a shell, so that a login session and its // launches ask DATA's server while this job's share holds all it may: the @@ -272,6 +275,13 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[ ("virt_jobs_at_el2", qemu::Profile::VirtEl2), ("virt_random_differs", qemu::Profile::Virt), ("virt_no_seed_refused", qemu::Profile::VirtNoRng), + // A package's launch from the desktop asks the person at the screen, in + // the prompt layer, over a hostile fullscreen window: the supervisor + // parking a launch, filepicker drawing through `prompt` and the compositor + // giving it every key, run together. Driven through the desktop's own + // keyboard and tablet and read off the panel, neither of which the T14's + // loop can do; each rule alone is a host test. + ("consent_prompt", qemu::Profile::Desktop), ]; /// The tests whose machine shape *is* the test, each on a boot of its own. @@ -2734,6 +2744,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re "virt_reboot" => virt_reboot(profile), "virt_off_names_the_cpus_left_on" => virt_off_names_the_cpus_left_on(profile), "virt_reboot_refused_without_psci" => virt_reboot_refused_without_psci(profile), + "consent_prompt" => consent_prompt(profile), "screen_fatal_behind_a_painter" => { // The fatal halt with a painter holding the panel's latch and // never giving it back — which is what a painter is when the halt @@ -3239,6 +3250,287 @@ fn served_by_diskserver(qemu: &mut QemuInstance, console: &mut String) -> Result Ok(()) } +/// `consent`'s steps, driven through the desktop's own keyboard and tablet +/// (`tests/toyos-rust-tests/src/bin/consent.rs`), and each held to what the +/// panel, the supervisor and the package said. +/// +/// **Every step is the guest's line and the host's answer to it**: the job +/// says `CONSENT `, and the host clicks the package in the launcher; +/// the compositor says its prompt is on the panel, and the host reads the +/// panel and answers with keys; the package says what it saw, and the host +/// taps F12, which the job waits on through its hostile window. Nothing here +/// waits on a clock but the run's own ceiling. +fn consent_prompt(profile: qemu::Profile) -> Result<(), String> { + use sha2::Digest as _; + let case = compile::repo_root().join("tests/consentcase"); + let (_, job) = suite_bin(profile.arch(), "consent"); + let digest: String = sha2::Sha256::digest(&job).iter().map(|b| format!("{b:02x}")).collect(); + let options = BootOptions { profile, qmp: true, ..Default::default() }; + let mut qemu = QemuInstance::boot_with_options(&case, &[], &[("consent".to_string(), job)], options); + let mut drive = Consent { digest, out: qemu.screendump_file(), ..Consent::default() }; + let result = qemu.run_test_paced("test_rs_consent", Duration::from_secs(300), |qmp, line| { + drive.line(qmp.expect("consent_prompt boots with QMP"), line) + }); + if let Some(why) = &result.error { + drive.red.push(format!("the job did not finish: {why}")); + } else if result.exit_code != Some(0) { + drive.red.push(format!("the job ended {:?}", result.exit_code)); + } + let all = ["always", "again", "deny", "denied", "skip", "once", "ssh"]; + if drive.done != all { + drive.red.push(format!("the steps finished were {:?}, not {all:?}", drive.done)); + } + match drive.red.is_empty() { + true => Ok(()), + false => Err(format!("{:#?}\nthe job said:\n{}", drive.red, result.stdout)), + } +} + +/// [`consent_prompt`]'s side of the conversation. +#[derive(Default)] +struct Consent { + /// The SHA-256 of the binary the job installs as the package, computed + /// here, which an answer the supervisor keeps is keyed on. + digest: String, + out: std::path::PathBuf, + /// The panel's size, from the first dump. + screen: Option<(usize, usize)>, + step: Option, + done: Vec, + /// The panel above the taskbar before the launch off the screen. + before: Option>, + red: Vec, +} + +#[derive(Default)] +struct ConsentStep { + name: String, + /// Where Games is in the prompt's list, from the job. + games: usize, + /// The supervisor's word on the answer. + answer: Option, + /// What the package saw. + saw: Option, + /// The prompt came up, and went. + up: bool, + down: bool, +} + +/// What the hostile window paints. +const HOSTILE_RGB: [u8; 3] = [0xff, 0x00, 0xff]; +const ROM_SEEN: &str = "cwd=/home/toy/Games roms=[\"test.gba\"] saved=Ok"; +const NOTHING_SEEN: &str = "cwd=/ roms=[] saved=Err"; + +impl ConsentStep { + /// Whether this step asks, the keys that answer it, the supervisor's word + /// on the answer, and what the package sees. + fn expected(&self) -> (bool, Vec<&'static str>, Option<&'static str>, &'static str) { + let down = || std::iter::repeat_n("down", self.games); + let tabs = |n| std::iter::repeat_n("tab", n); + match self.name.as_str() { + "always" => ( + true, + down().chain(tabs(3)).chain(["ret"]).collect(), + Some("always, /home/toy/Games read-write"), + ROM_SEEN, + ), + "again" => (false, Vec::new(), None, ROM_SEEN), + "deny" => (true, tabs(4).chain(["ret"]).collect(), Some("denied, until `grants revoke`"), NOTHING_SEEN), + "denied" => (false, Vec::new(), None, NOTHING_SEEN), + "skip" => (true, vec!["esc"], Some("skipped, so its next launch asks again"), NOTHING_SEEN), + "once" => ( + true, + down().chain(tabs(2)).chain(["ret"]).collect(), + Some("once, /home/toy/Games read-write"), + ROM_SEEN, + ), + other => panic!("consent_prompt: the job asked for a step `{other}` this side has no answer for"), + } + } +} + +impl Consent { + fn line(&mut self, qmp: &Path, line: &str) { + let after = |marker: &str| line.find(marker).map(|at| line[at + marker.len()..].trim().to_string()); + if let Some(step) = after("CONSENT ") { + self.begin(qmp, &step); + } else if line.contains("compositor: the prompt is on the panel") { + self.prompt_up(qmp); + } else if line.contains("compositor: the prompt is down") { + match &mut self.step { + Some(step) => step.down = true, + None => self.red.push("the prompt went down outside any step".to_string()), + } + } else if let Some(answer) = after("supervisor: consent: consentee: ") { + match &mut self.step { + Some(step) => step.answer = Some(answer), + None => self.red.push(format!("the supervisor took an answer outside any step: {answer}")), + } + } else if let Some(saw) = after("CONSENTEE ") { + match &mut self.step { + Some(step) => step.saw = Some(saw), + None => self.red.push(format!("the package ran outside any step and saw {saw}")), + } + } else if let Some(stored) = after("STORE ") { + self.stored(&stored); + } + self.finish(qmp); + } + + fn dump(&mut self, qmp: &Path) -> screen::Ppm { + let dump = qemu::QmpInput::open(qmp).screendump(&self.out); + self.screen.get_or_insert((dump.width, dump.height)); + dump + } + + fn begin(&mut self, qmp: &Path, said: &str) { + let mut words = said.split_whitespace(); + let name = words.next().unwrap_or_default().to_string(); + let games = words.find_map(|w| w.strip_prefix("down=")).map_or(0, |n| n.parse().unwrap_or(0)); + match name.as_str() { + // Off the screen: the panel above the taskbar is unchanged across + // it, and no prompt comes up (`prompt_up` reds one outside a step). + "ssh" if said == "ssh" => self.before = Some(self.above_taskbar(qmp)), + "ssh" => { + let after = self.above_taskbar(qmp); + if self.before.take().is_none_or(|before| before != after) { + self.red.push("the panel changed across the launch off the screen".to_string()); + } + self.done.push(name); + Self::tap_done(qmp); + } + _ => { + if self.screen.is_none() { + self.dump(qmp); + } + let (w, h) = self.screen.expect("the first dump measured the panel"); + // The `+`, then the launcher's last row, which is the one + // package installed: built-in rows come first. + let desk = toyos_desktop::Desk { + chrome: toyos_desktop::Chrome::DEFAULT, + screen: toyos_desktop::Rect::new(0, 0, w as i32, h as i32), + font_w: 8, + apps: 1, + }; + let bar = desk.taskbar(1); + let centre = |r: toyos_desktop::Rect| (i32::midpoint(r.x0, r.x1), i32::midpoint(r.y0, r.y1)); + let mut input = qemu::QmpInput::open(qmp); + input.click(centre(bar.new_button()), (w, h)); + input.click(centre(bar.launcher_item(0)), (w, h)); + self.step = Some(ConsentStep { name, games, ..ConsentStep::default() }); + } + } + } + + fn above_taskbar(&mut self, qmp: &Path) -> Vec<[u8; 3]> { + let dump = self.dump(qmp); + let rows = dump.height - toyos_desktop::Chrome::DEFAULT.taskbar as usize; + dump.pixels[..rows * dump.width].to_vec() + } + + /// The prompt is on the panel: it is drawn over the hostile window, and a + /// press outside it and the keys that answer it go in. + fn prompt_up(&mut self, qmp: &Path) { + let asks = match &self.step { + Some(step) => step.expected().0, + None => false, + }; + if !asks { + let name = self.step.as_ref().map_or("none", |s| s.name.as_str()).to_string(); + self.red.push(format!("a prompt came up in step {name}, which asks nothing")); + return; + } + let dump = self.dump(qmp); + let framed: Vec<(usize, usize)> = (0..dump.height) + .flat_map(|y| (0..dump.width).map(move |x| (x, y))) + .filter(|&(x, y)| dump.pixels[y * dump.width + x] == toyos_desktop::PROMPT_FRAME) + .collect(); + let at = |x: usize, y: usize| dump.pixels[y * dump.width + x]; + let (x0, x1) = match (framed.iter().map(|p| p.0).min(), framed.iter().map(|p| p.0).max()) { + (Some(x0), Some(x1)) if x1 - x0 >= 600 => (x0, x1), + other => { + self.red.push(format!("no prompt's frame on the panel: {} pixels, across {other:?}", framed.len())); + return; + } + }; + let y0 = framed.iter().map(|p| p.1).min().unwrap_or(0); + let y1 = framed.iter().map(|p| p.1).max().unwrap_or(0); + if at(usize::midpoint(x0, x1), usize::midpoint(y0, y1)) == HOSTILE_RGB || at(x0, y0 + 2) != toyos_desktop::PROMPT_FRAME { + self.red.push(format!("the prompt at ({x0}, {y0})-({x1}, {y1}) is under the hostile window")); + } + if at(8, 40) != HOSTILE_RGB { + self.red.push(format!("the hostile window is not on the panel beside the prompt: {:?}", at(8, 40))); + } + let screen = (dump.width, dump.height); + let prompt = |dump: &screen::Ppm| -> Vec<[u8; 3]> { + (y0..=y1).flat_map(|y| dump.pixels[y * dump.width + x0..=y * dump.width + x1].to_vec()).collect() + }; + let mut shown = prompt(&dump); + let mut input = qemu::QmpInput::open(qmp); + // A press on the hostile window around the prompt, which nothing takes. + input.click((8, 40), screen); + // **Each key is paced against the prompt it changes.** QEMU's keyboard + // loses a press or a release sent behind another before the guest has + // read it, so the next key goes only once the panel shows the last. + let keys = self.step.as_ref().map(|s| s.expected().1).unwrap_or_default(); + for key in keys { + input.keys(&[(key, true), (key, false)]); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let now = prompt(&input.screendump(&self.out)); + if now != shown { + shown = now; + break; + } + if Instant::now() >= deadline { + self.red.push(format!("`{key}` changed nothing on the prompt in 20 s")); + return; + } + thread::sleep(Duration::from_millis(50)); + } + } + if let Some(step) = &mut self.step { + step.up = true; + } + } + + /// A step that has said all it will: held to what it should have said, + /// and its F12 tapped. + fn finish(&mut self, qmp: &Path) { + let Some(step) = &self.step else { return }; + let (asks, _, answer, seen) = step.expected(); + if step.saw.is_none() || (asks && !(step.up && step.down)) { + return; + } + let step = self.step.take().expect("a step is under way"); + if step.answer.as_deref() != answer { + self.red.push(format!("{}: the supervisor said {:?}, not {answer:?}", step.name, step.answer)); + } + if step.saw.as_deref() != Some(seen) { + self.red.push(format!("{}: the package saw {:?}, not {seen:?}", step.name, step.saw)); + } + self.done.push(step.name); + Self::tap_done(qmp); + } + + /// The store's line after a step, held to the binary's own digest. + fn stored(&mut self, said: &str) { + let (step, line) = said.split_once(':').unwrap_or((said, "")); + let want = match step { + "always" => format!("grant toy consentee {} read-write /home/toy/Games", self.digest), + "deny" => format!("deny toy consentee {}", self.digest), + _ => String::new(), + }; + if line.trim() != want { + self.red.push(format!("{step}: the store holds {:?}, not {want:?}", line.trim())); + } + } + + fn tap_done(qmp: &Path) { + qemu::QmpInput::open(qmp).keys(&[("f12", true), ("f12", false)]); + } +} + /// Run `command` as a job of the boot, to exit 0: what it said. fn job_said(qemu: &mut QemuInstance, command: &str) -> Result { let result = qemu.run_test(command, Duration::from_secs(60)); diff --git a/toyos-manifest/src/consent.rs b/toyos-manifest/src/consent.rs new file mode 100644 index 00000000000..6a121402e18 --- /dev/null +++ b/toyos-manifest/src/consent.rs @@ -0,0 +1,205 @@ +//! The question `/system/bin/supervisor` asks the person at the screen before +//! an installed package's launch, and the answer it honours. +//! +//! **Only the supervisor asks, and only its question grants anything.** It +//! asks over [`PORT`], which [`SERVER`] serves and no row receives, so a +//! connection on it is the supervisor's; the answer comes back on that +//! connection and nowhere else, so a program that paints the same question in +//! a window of its own grants nothing. [`SERVER`] draws the question through +//! [`PROMPT`], which it alone receives: the compositor puts a window made +//! there above every other and gives it every key and press while it is up. +//! +//! **One connection per question**: the supervisor's [`Ask`], then the +//! server's [`Reply`]. The supervisor closing it withdraws the question, its +//! caller having hung up; the server closing it is [`Reply::Skip`]. + +use crate::grants::{Access, Answer, Folder}; + +/// The port the question is asked on, which no row receives. +pub const PORT: &str = "consent"; + +/// The one row that serves [`PORT`] and receives [`PROMPT`]. +pub const SERVER: &str = "filepicker"; + +/// The compositor's port for a window in the layer above every other. The row +/// that serves it is the screen's ([`crate::launch::Screen`]). +pub const PROMPT: &str = "prompt"; + +/// Supervisor → server: an [`Ask`]. +pub const MSG_ASK: u32 = 1; +/// Server → supervisor: a [`Reply`]. +pub const MSG_REPLY: u32 = 2; + +/// The longest [`Ask`]: the longer access, a NUL and the longest name. +pub const MAX_ASK: usize = LONGER_ACCESS + 1 + crate::MAX_PROGRAM_NAME; +/// The longest [`Reply`] whose folder [`crate::grants::folder`] admits: its +/// kind, the longer access, a NUL, and the longest folder, `/` and all. A +/// receiver keeps a byte more, so a longer one is refused rather than read as +/// the prefix kept. +pub const MAX_REPLY: usize = 1 + LONGER_ACCESS + 1 + 1 + crate::grants::MAX_ROOT; +const LONGER_ACCESS: usize = "read-write".len(); + +/// The question: which package asks for a folder, and what it asks to do there. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ask { + pub package: String, + pub access: Access, +} + +impl Ask { + /// The access, a NUL, then the package's name. + pub fn encode(&self) -> Vec { + format!("{}\0{}", self.access, self.package).into_bytes() + } + + /// `None` for bytes [`encode`](Self::encode) cannot have written. + pub fn decode(payload: &[u8]) -> Option { + let (access, package) = std::str::from_utf8(payload).ok()?.split_once('\0')?; + let path = format!("{}/{package}/x", crate::package::DIR); + let access = Access::parse(access)?; + (crate::package::package_of(&path) == Some(package)).then(|| Self { package: package.to_string(), access }) + } + + /// The question in plain words, as the prompt says it. + pub fn words(&self) -> String { + match self.access { + Access::ReadWrite => format!("{} asks to open and change the files in one folder you choose.", self.package), + Access::ReadOnly => format!("{} asks to open the files in one folder you choose.", self.package), + } + } +} + +/// What the person at the screen answered. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Reply { + /// This launch holds the folder; nothing is kept. + Once(Folder), + /// This launch and every one of the same binary until it is revoked. + Always(Folder), + /// No folder, kept until it is revoked. + Deny, + /// No folder this launch, and the next launch asks again. + Skip, +} + +const ONCE: u8 = 1; +const ALWAYS: u8 = 2; +const DENY: u8 = 3; +const SKIP: u8 = 4; + +impl Reply { + /// Its kind byte, then, for a folder, the access, a NUL and the path. + pub fn encode(&self) -> Vec { + let (kind, folder) = match self { + Self::Once(folder) => (ONCE, Some(folder)), + Self::Always(folder) => (ALWAYS, Some(folder)), + Self::Deny => (DENY, None), + Self::Skip => (SKIP, None), + }; + let mut out = vec![kind]; + if let Some(folder) = folder { + out.extend_from_slice(format!("{}\0{}", folder.access, folder.path).as_bytes()); + } + out + } + + /// `None` for bytes [`encode`](Self::encode) cannot have written. The path + /// is not judged here: the supervisor holds it to [`crate::grants::folder`]. + pub fn decode(payload: &[u8]) -> Option { + let (&kind, rest) = payload.split_first()?; + let folder = || { + let (access, path) = std::str::from_utf8(rest).ok()?.split_once('\0')?; + Some(Folder { path: path.to_string(), access: Access::parse(access)? }) + }; + match kind { + ONCE => folder().map(Self::Once), + ALWAYS => folder().map(Self::Always), + DENY if rest.is_empty() => Some(Self::Deny), + SKIP if rest.is_empty() => Some(Self::Skip), + _ => None, + } + } + + /// The folder this launch starts holding. + pub fn folder(&self) -> Option<&Folder> { + match self { + Self::Once(folder) | Self::Always(folder) => Some(folder), + Self::Deny | Self::Skip => None, + } + } + + /// What the store keeps of it: Always's folder and Deny, never Once or Skip. + pub fn kept(&self) -> Option { + match self { + Self::Always(folder) => Some(Answer::Granted(folder.clone())), + Self::Deny => Some(Answer::Denied), + Self::Once(_) | Self::Skip => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn games(access: Access) -> Folder { + Folder { path: "/home/toy/My Games".into(), access } + } + + #[test] + fn an_ask_and_a_reply_read_back_as_written_and_nothing_else_reads() { + for ask in [ + Ask { package: "gbae".into(), access: Access::ReadWrite }, + Ask { package: "n".repeat(crate::MAX_PROGRAM_NAME), access: Access::ReadOnly }, + Ask { package: "n".repeat(crate::MAX_PROGRAM_NAME), access: Access::ReadWrite }, + ] { + assert!(ask.encode().len() <= MAX_ASK, "{ask:?}"); + assert_eq!(Ask::decode(&ask.encode()), Some(ask)); + } + let longest = format!("/home/toy/{}", "g".repeat(crate::grants::MAX_ROOT - "home/toy/".len())); + assert_eq!(crate::grants::folder(&longest), Ok(())); + for access in [Access::ReadOnly, Access::ReadWrite] { + let reply = Reply::Always(Folder { path: longest.clone(), access }); + assert!(reply.encode().len() <= MAX_REPLY, "{reply:?}"); + } + assert_eq!(Reply::Always(Folder { path: longest, access: Access::ReadWrite }).encode().len(), MAX_REPLY); + for bad in [&b""[..], b"read-write", b"write\0gbae", b"read-write\0", b"read-write\0../x", b"read-write\0a/b", &[0xff, 0]] { + assert_eq!(Ask::decode(bad), None, "{bad:?}"); + } + for reply in [ + Reply::Once(games(Access::ReadWrite)), + Reply::Always(games(Access::ReadOnly)), + Reply::Deny, + Reply::Skip, + ] { + assert_eq!(Reply::decode(&reply.encode()), Some(reply)); + } + for bad in [&b""[..], &[0], &[5], &[ONCE], b"\x01read-write", b"\x02write\0/home/toy/Games", b"\x03x", b"\x04\0"] { + assert_eq!(Reply::decode(bad), None, "{bad:?}"); + } + } + + /// **Once and Skip are never kept**; Always keeps its folder and Deny is + /// kept as Deny. A launch holds the folder of Once and Always alone. + #[test] + fn only_always_and_deny_are_kept() { + let folder = games(Access::ReadWrite); + let table = [ + (Reply::Once(folder.clone()), None, Some(&folder)), + (Reply::Always(folder.clone()), Some(Answer::Granted(folder.clone())), Some(&folder)), + (Reply::Deny, Some(Answer::Denied), None), + (Reply::Skip, None, None), + ]; + for (reply, kept, holds) in table { + assert_eq!(reply.kept(), kept, "{reply:?}"); + assert_eq!(reply.folder(), holds, "{reply:?}"); + } + } + + #[test] + fn the_question_says_what_the_package_may_do_in_plain_words() { + let ask = |access| Ask { package: "gbae".into(), access }.words(); + assert_eq!(ask(Access::ReadWrite), "gbae asks to open and change the files in one folder you choose."); + assert_eq!(ask(Access::ReadOnly), "gbae asks to open the files in one folder you choose."); + } +} diff --git a/toyos-manifest/src/grants.rs b/toyos-manifest/src/grants.rs index 337b2c3cb00..1c13591e588 100644 --- a/toyos-manifest/src/grants.rs +++ b/toyos-manifest/src/grants.rs @@ -3,23 +3,29 @@ //! //! **The supervisor is the only writer of [`STORE`].** A grant reaches it as a //! request on [`PORT`], which the build gives to [`HOLDER`] alone and the -//! supervisor honours only from a login session, by the caller's badge. +//! supervisor honours only from a login session, by the caller's badge, or as +//! the person at the screen's answer to a launch's question +//! ([`crate::consent`]). //! -//! **A grant is keyed on the exact binary**: the session's user, the package's -//! name and the SHA-256 of the program the supervisor starts. A package -//! replaced under `/apps`, an update included, holds none until it is granted -//! again. +//! **An answer is keyed on the exact binary**: the session's user, the +//! package's name and the SHA-256 of the program the supervisor starts. A +//! package replaced under `/apps`, an update included, holds none until it is +//! answered again. A stored answer is a folder granted, or Deny, which is kept +//! until it is revoked. //! //! **A folder is a directory inside the session user's home** ([`folder`]): //! never the home itself, nor `Apps` or anything in it, where every app keeps //! its own folder. //! -//! **What a launch gets** is [`decide`]'s: the stored grant for that exact -//! binary, at most the image's `[apps] folder` ceiling, or nothing. +//! **What a launch gets** is [`decide`]'s: the stored answer for that exact +//! binary, a granted folder at most the image's `[apps] folder` ceiling; with +//! none, a question where the launch's session holds the screen, and nothing +//! otherwise. //! //! ```text //! toyos-grants 1 //! grant +//! deny //! ``` use std::fmt; @@ -93,14 +99,21 @@ pub struct Folder { pub access: Access, } -/// One stored grant. +/// One stored answer. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Entry { pub user: String, pub package: String, - /// The SHA-256 of the program it was granted to, lowercase hex. + /// The SHA-256 of the program it was given to, lowercase hex. pub binary: String, - pub folder: Folder, + pub answer: Answer, +} + +/// What the user answered for a package, kept until it is revoked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Answer { + Granted(Folder), + Denied, } /// Why `path` may not be a package's folder, as a whole sentence; `Ok` for @@ -128,13 +141,29 @@ pub fn folder(path: &str) -> Result<(), String> { Ok(()) } -/// The folder a launch of `binary` is granted: the stored grant for that exact -/// binary, at most `ceiling`, the image's `[apps] folder`. `None` with no -/// ceiling, no grant, or a grant to another binary. -pub fn decide(stored: Option<&Entry>, binary: &str, ceiling: Option) -> Option { - let ceiling = ceiling?; - let entry = stored.filter(|entry| entry.binary == binary)?; - Some(Folder { path: entry.folder.path.clone(), access: entry.folder.access.min(ceiling) }) +/// What a launch is given before it starts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Decision { + /// It starts, holding this folder or none. + Start(Option), + /// The person at the screen is asked first. + Ask, +} + +/// What a launch of `binary` is given: the stored answer for that exact +/// binary, a folder at most `ceiling`, the image's `[apps] folder`; with +/// none, the question where the launch's session holds the screen. No +/// ceiling is no folder and no question. +pub fn decide(stored: Option<&Entry>, binary: &str, ceiling: Option, at_screen: bool) -> Decision { + let Some(ceiling) = ceiling else { return Decision::Start(None) }; + match stored.filter(|entry| entry.binary == binary).map(|entry| &entry.answer) { + Some(Answer::Granted(folder)) => { + Decision::Start(Some(Folder { path: folder.path.clone(), access: folder.access.min(ceiling) })) + } + Some(Answer::Denied) => Decision::Start(None), + None if at_screen => Decision::Ask, + None => Decision::Start(None), + } } /// The working directory a launch granted `folder` starts in: the caller's @@ -185,7 +214,12 @@ impl Store { pub fn render(&self) -> String { let mut out = format!("{HEADER}\n"); for e in &self.entries { - out.push_str(&format!("grant {} {} {} {} {}\n", e.user, e.package, e.binary, e.folder.access, e.folder.path)); + out.push_str(&match &e.answer { + Answer::Granted(folder) => { + format!("grant {} {} {} {} {}\n", e.user, e.package, e.binary, folder.access, folder.path) + } + Answer::Denied => format!("deny {} {} {}\n", e.user, e.package, e.binary), + }); } out } @@ -203,18 +237,18 @@ impl Store { let mut store = Store::default(); for line in lines { let mut words = line.splitn(6, ' '); - let [Some("grant"), Some(user), Some(package), Some(binary), Some(access), Some(path)] = - [(); 6].map(|()| words.next()) - else { - return Err(format!("{STORE}: {line:?} is not a grant")); - }; - let access = Access::parse(access).ok_or_else(|| format!("{STORE}: {access:?} is no access"))?; - let entry = Entry { - user: user.to_string(), - package: package.to_string(), - binary: binary.to_string(), - folder: Folder { path: path.to_string(), access }, + let (user, package, binary, answer) = match [(); 6].map(|()| words.next()) { + [Some("grant"), Some(user), Some(package), Some(binary), Some(access), Some(path)] => { + let access = Access::parse(access).ok_or_else(|| format!("{STORE}: {access:?} is no access"))?; + (user, package, binary, Answer::Granted(Folder { path: path.to_string(), access })) + } + [Some("deny"), Some(user), Some(package), Some(binary), None, None] => { + (user, package, binary, Answer::Denied) + } + _ => return Err(format!("{STORE}: {line:?} is not a grant")), }; + let entry = + Entry { user: user.to_string(), package: package.to_string(), binary: binary.to_string(), answer }; if store.find(user, package).is_some() { return Err(format!("{STORE}: {user}'s {package} is granted twice")); } @@ -236,7 +270,10 @@ fn check(e: &Entry) -> Result<(), String> { if e.binary.len() != DIGEST_LEN || !e.binary.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { return Err(format!("{:?} is not a SHA-256 in lowercase hex", e.binary)); } - folder(&e.folder.path) + match &e.answer { + Answer::Granted(granted) => folder(&granted.path), + Answer::Denied => Ok(()), + } } /// A request on [`PORT`]: the message type is the verb. @@ -287,7 +324,10 @@ impl Request { /// One line of a [`Request::List`] answer. pub fn listed(entry: &Entry) -> String { - format!("{} {} {}\n", entry.package, entry.folder.access, entry.folder.path) + match &entry.answer { + Answer::Granted(folder) => format!("{} {} {}\n", entry.package, folder.access, folder.path), + Answer::Denied => format!("{} denied\n", entry.package), + } } #[cfg(test)] @@ -301,10 +341,14 @@ mod tests { user: "toy".into(), package: package.into(), binary: BIN.into(), - folder: Folder { path: path.into(), access }, + answer: Answer::Granted(Folder { path: path.into(), access }), } } + fn denied(package: &str) -> Entry { + Entry { user: "toy".into(), package: package.into(), binary: BIN.into(), answer: Answer::Denied } + } + /// The deepest and longest folder a grant carries, at the longest package /// name, and a store filled to its bound, read back as written. #[test] @@ -317,6 +361,7 @@ mod tests { store.put(entry(&"n".repeat(MAX_PROGRAM_NAME), &longest, Access::ReadWrite)).unwrap(); store.put(entry("gbae", deepest, Access::ReadOnly)).unwrap(); store.put(entry("spaced", "/home/toy/My Games", Access::ReadWrite)).unwrap(); + store.put(denied("refused")).unwrap(); for i in store.entries().len()..MAX_ENTRIES { store.put(entry(&format!("p{i}"), &longest, Access::ReadWrite)).unwrap(); } @@ -328,7 +373,10 @@ mod tests { assert_eq!(store.put(entry("one-more", deepest, Access::ReadOnly)), Err(format!("the store already holds {MAX_ENTRIES} grants"))); // A grant to a package already granted replaces it, past the bound too. store.put(entry("gbae", "/home/toy/Games", Access::ReadWrite)).unwrap(); - assert_eq!(store.find("toy", "gbae").unwrap().folder.path, "/home/toy/Games"); + assert_eq!( + store.find("toy", "gbae").unwrap().answer, + Answer::Granted(Folder { path: "/home/toy/Games".into(), access: Access::ReadWrite }) + ); assert_eq!(store.entries().len(), MAX_ENTRIES); } @@ -381,6 +429,11 @@ mod tests { (format!("{HEADER}\ngrant toy gbae abc read-write /home/toy/Games\n"), "lowercase hex"), (format!("{HEADER}\ngrant toy gbae {BIN} read-write /home/toy/Apps/gbae\n"), "keeps its own folder"), (format!("{good}grant toy gbae {BIN} read-only /home/toy/Music\n"), "granted twice"), + (format!("{good}deny toy gbae {BIN}\n"), "granted twice"), + (format!("{HEADER}\ndeny toy gbae {BIN} /home/toy/Games\n"), "is not a grant"), + (format!("{HEADER}\ndeny toy gbae\n"), "is not a grant"), + (format!("{HEADER}\ndeny toy gbae abc\n"), "lowercase hex"), + (format!("{HEADER}\ndeny root gbae {BIN}\n"), "not this image's user"), (format!("{HEADER}\n{}", "x".repeat(MAX_STORE_BYTES)), "past the"), ] { match Store::parse(&text) { @@ -396,33 +449,51 @@ mod tests { let mut store = Store::default(); store.put(entry("gbae", "/home/toy/Games", Access::ReadWrite)).unwrap(); store.put(entry("paint", "/home/toy/Pictures", Access::ReadOnly)).unwrap(); + store.put(denied("snake")).unwrap(); assert!(store.revoke("toy", "gbae")); assert!(!store.revoke("toy", "gbae")); + assert!(store.revoke("toy", "snake"), "a Deny is revoked as a grant is"); assert_eq!(store.entries(), [entry("paint", "/home/toy/Pictures", Access::ReadOnly)]); } - /// The decision, as a table: the stored grant for the exact binary, at - /// most the ceiling, and nothing otherwise. + /// The decision, as a table: the stored answer for the exact binary, a + /// folder at most the ceiling; with none, the question at the screen + /// alone; no ceiling, never a folder nor a question. #[test] - fn a_launch_gets_the_grant_for_its_exact_binary_at_most_the_ceiling() { - let (rw, ro) = (entry("gbae", "/home/toy/Games", Access::ReadWrite), entry("gbae", "/home/toy/Games", Access::ReadOnly)); + fn a_launch_gets_the_answer_for_its_exact_binary_and_is_asked_only_at_the_screen() { + let (rw, ro, deny) = ( + entry("gbae", "/home/toy/Games", Access::ReadWrite), + entry("gbae", "/home/toy/Games", Access::ReadOnly), + denied("gbae"), + ); let other = "0".repeat(DIGEST_LEN); - let games = |access| Some(Folder { path: "/home/toy/Games".into(), access }); + let games = |access| Decision::Start(Some(Folder { path: "/home/toy/Games".into(), access })); + let (none, ask) = (Decision::Start(None), Decision::Ask); use Access::*; - type Row<'a> = (Option<&'a Entry>, &'a str, Option, Option); + type Row<'a> = (Option<&'a Entry>, &'a str, Option, bool, Decision); #[rustfmt::skip] - let table: [Row; 8] = [ - (Some(&rw), BIN, Some(ReadWrite), games(ReadWrite)), - (Some(&ro), BIN, Some(ReadWrite), games(ReadOnly)), - (Some(&rw), BIN, Some(ReadOnly), games(ReadOnly)), - (Some(&rw), BIN, None, None), - (Some(&rw), &other, Some(ReadWrite), None), - (None, BIN, Some(ReadWrite), None), - (None, BIN, None, None), - (Some(&ro), &other, Some(ReadOnly), None), + let table: [Row; 18] = [ + (Some(&rw), BIN, Some(ReadWrite), false, games(ReadWrite)), + (Some(&rw), BIN, Some(ReadWrite), true, games(ReadWrite)), + (Some(&ro), BIN, Some(ReadWrite), true, games(ReadOnly)), + (Some(&rw), BIN, Some(ReadOnly), true, games(ReadOnly)), + (Some(&rw), BIN, None, true, none.clone()), + (Some(&rw), &other, Some(ReadWrite), false, none.clone()), + (Some(&rw), &other, Some(ReadWrite), true, ask.clone()), + (None, BIN, Some(ReadWrite), false, none.clone()), + (None, BIN, Some(ReadWrite), true, ask.clone()), + (None, BIN, Some(ReadOnly), true, ask.clone()), + (None, BIN, None, false, none.clone()), + (None, BIN, None, true, none.clone()), + (Some(&ro), &other, Some(ReadOnly), false, none.clone()), + (Some(&deny), BIN, Some(ReadWrite), true, none.clone()), + (Some(&deny), BIN, Some(ReadWrite), false, none.clone()), + (Some(&deny), BIN, None, true, none.clone()), + (Some(&deny), &other, Some(ReadWrite), true, ask), + (Some(&deny), &other, Some(ReadWrite), false, none), ]; - for (i, (stored, binary, ceiling, want)) in table.into_iter().enumerate() { - assert_eq!(decide(stored, binary, ceiling), want, "row {i}"); + for (i, (stored, binary, ceiling, at_screen, want)) in table.into_iter().enumerate() { + assert_eq!(decide(stored, binary, ceiling, at_screen), want, "row {i}"); } } diff --git a/toyos-manifest/src/launch.rs b/toyos-manifest/src/launch.rs index 870b372e0e7..09f8c55375a 100644 --- a/toyos-manifest/src/launch.rs +++ b/toyos-manifest/src/launch.rs @@ -15,6 +15,13 @@ //! its rows' `starts` reach, gives it more of a server: only the machine's //! session opens one. //! +//! **A login session holds the screen only where the screen's row opened it** +//! ([`Screen`]): the row serving [`consent::PROMPT`], the one whose launches a +//! person at the screen made. Every launch in that session holds it, but for +//! one made by another `login` row, whose launches are never that person's: +//! sshserver's shells, even one a desktop shell started, never hold it. Only a +//! launch in a session holding it may ask the person at the screen anything. +//! //! **The caller is the badge on its connection, not its word** ([`Authority`]): //! the supervisor mints it on the launcher it endows a row, the kernel stamps it //! on every connection made through that launcher, and a holder passes the @@ -27,7 +34,7 @@ use std::fmt; use toyos_abi::syscall::MAX_BADGE; -use crate::{package, Program, MAX_PROGRAM_NAME}; +use crate::{consent, package, Program, MAX_PROGRAM_NAME}; /// The `starts` entry naming every installed package. A program key has no /// `/`, so it names no key. @@ -41,7 +48,26 @@ pub enum Session { Machine(Share), /// One a `login` row's launch from the machine's session opened, and every /// launch made in it. - Login(Share), + Login(Share, Screen), +} + +/// Whether a login session's launches were made by the person at the screen, +/// and may ask them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Screen { + Held, + Not, +} + +impl Screen { + /// What a session `opener`'s launch opens holds: the screen where `opener` + /// serves the prompt layer. + pub fn of(opener: &Program) -> Self { + match opener.serves.iter().any(|name| name == consent::PROMPT) { + true => Self::Held, + false => Self::Not, + } + } } /// Which share of each file server's bounds: a number only [`Sessions`] makes @@ -58,16 +84,21 @@ impl Session { /// The number a file grant names this session's share of a server by. pub fn share(self) -> u64 { match self { - Self::Machine(Share(n)) | Self::Login(Share(n)) => n, + Self::Machine(Share(n)) | Self::Login(Share(n), _) => n, } } + + /// Whether a launch in this session may ask the person at the screen. + pub fn at_screen(self) -> bool { + matches!(self, Self::Login(_, Screen::Held)) + } } impl fmt::Display for Session { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Machine(_) => f.write_str("the machine's session"), - Self::Login(_) => f.write_str("a login session"), + Self::Login(..) => f.write_str("a login session"), } } } @@ -91,8 +122,8 @@ impl Sessions { } /// A login session none before it was. - pub fn open(&mut self) -> Session { - Session::Login(self.share()) + pub fn open(&mut self, screen: Screen) -> Session { + Session::Login(self.share(), screen) } fn share(&mut self) -> Share { @@ -112,6 +143,7 @@ pub struct Authority { const MACHINE: u8 = 0; const LOGIN: u8 = 1; +const LOGIN_AT_SCREEN: u8 = 2; const _: () = assert!(1 + 8 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority, a kind byte, a share and a row, must fit one badge"); @@ -121,7 +153,8 @@ impl Authority { let mut out = Vec::with_capacity(1 + 8 + self.row.len()); out.push(match self.session { Session::Machine(_) => MACHINE, - Session::Login(_) => LOGIN, + Session::Login(_, Screen::Not) => LOGIN, + Session::Login(_, Screen::Held) => LOGIN_AT_SCREEN, }); out.extend_from_slice(&self.session.share().to_le_bytes()); out.extend_from_slice(self.row.as_bytes()); @@ -138,7 +171,8 @@ impl Authority { }; let session = match kind { MACHINE => Session::Machine(share), - LOGIN => Session::Login(share), + LOGIN => Session::Login(share, Screen::Not), + LOGIN_AT_SCREEN => Session::Login(share, Screen::Held), _ => return None, }; if row.is_empty() || row.len() > MAX_PROGRAM_NAME { @@ -177,8 +211,8 @@ impl fmt::Display for Refusal { pub enum Starts { /// Its caller's. In(Session), - /// A login session it opens. - Opening, + /// A login session it opens, holding the screen or not. + Opening(Screen), } impl Starts { @@ -186,7 +220,7 @@ impl Starts { pub fn session(self, sessions: &mut Sessions) -> Session { match self { Self::In(session) => session, - Self::Opening => sessions.open(), + Self::Opening(screen) => sessions.open(screen), } } } @@ -202,9 +236,14 @@ pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Resu return Err(Refusal::NotListed); } // Only from the machine's session: a login session reaching a `login` row - // through `starts` would otherwise hold a share per launch of it. + // through `starts` would otherwise hold a share per launch of it. A + // `login` row's launch in one keeps its share and holds the screen only + // where the row is the screen's. let starts = match (caller.login, session) { - (true, Session::Machine(_)) => Starts::Opening, + (true, Session::Machine(_)) => Starts::Opening(Screen::of(caller)), + (true, Session::Login(share, Screen::Held)) if Screen::of(caller) == Screen::Not => { + Starts::In(Session::Login(share, Screen::Not)) + } _ => Starts::In(session), }; if program.login_only() && matches!(starts, Starts::In(Session::Machine(_))) { @@ -250,7 +289,7 @@ mod tests { let (ordinary, unlisted, own, package) = (row("ordinary"), row("unlisted"), caller(false), row("gbae")); let (swap, update) = (swap(), update()); let mut sessions = Sessions::default(); - let (machine, login) = (sessions.machine(), sessions.open()); + let (machine, login) = (sessions.machine(), sessions.open(Screen::Not)); use Refusal::*; use Starts::*; #[rustfmt::skip] @@ -267,17 +306,17 @@ mod tests { (false, login, Target::Row(&swap), Ok(In(login))), (false, machine, Target::Row(&update), Err(OutsideLogin)), (false, login, Target::Row(&update), Ok(In(login))), - (true, machine, Target::Row(&ordinary), Ok(Opening)), + (true, machine, Target::Row(&ordinary), Ok(Opening(Screen::Not))), (true, login, Target::Row(&ordinary), Ok(In(login))), (true, machine, Target::Row(&unlisted), Err(NotListed)), (true, login, Target::Row(&unlisted), Err(NotListed)), - (true, machine, Target::Row(&own), Ok(Opening)), + (true, machine, Target::Row(&own), Ok(Opening(Screen::Not))), (true, login, Target::Row(&own), Ok(In(login))), - (true, machine, Target::Package(&package), Ok(Opening)), + (true, machine, Target::Package(&package), Ok(Opening(Screen::Not))), (true, login, Target::Package(&package), Ok(In(login))), - (true, machine, Target::Row(&swap), Ok(Opening)), + (true, machine, Target::Row(&swap), Ok(Opening(Screen::Not))), (true, login, Target::Row(&swap), Ok(In(login))), - (true, machine, Target::Row(&update), Ok(Opening)), + (true, machine, Target::Row(&update), Ok(Opening(Screen::Not))), (true, login, Target::Row(&update), Ok(In(login))), ]; for (i, (opens, session, target, want)) in table.into_iter().enumerate() { @@ -295,11 +334,18 @@ mod tests { /// on each launch; every launch in that session spends its share, down a /// chain of shells and through sshserver, a `login` row it reaches, and /// the shell sshserver launches. No two of those shares are one, nor any - /// the supervisor's. + /// the supervisor's. The session the compositor, the screen's row, opens + /// holds the screen down the chain of shells, and sshserver's launches in + /// it never do. #[test] fn a_sessions_launches_spend_its_one_share() { let runner = Program { starts: vec!["toybox".into()], ..row("test-runner") }; - let compositor = Program { starts: vec!["terminal".into()], login: true, ..row("compositor") }; + let compositor = Program { + starts: vec!["terminal".into()], + login: true, + serves: vec![consent::PROMPT.into()], + ..row("compositor") + }; let terminal = Program { starts: vec!["shell".into()], ..row("terminal") }; let shell = Program { starts: vec!["shell".into(), "sshserver".into()], ..row("shell") }; let sshserver = Program { starts: vec!["shell".into()], login: true, ..row("sshserver") }; @@ -309,17 +355,26 @@ mod tests { let mut shares = vec![SUPERVISOR_SHARE, desktop.share(), tests.share()]; for _ in 0..2 { let opened = launch(&mut sessions, &compositor, desktop, &terminal); + assert!(opened.at_screen(), "the compositor's launch opened {opened:?}"); let mut session = launch(&mut sessions, &terminal, opened, &shell); for _ in 0..8 { session = launch(&mut sessions, &shell, session, &shell); } + assert_eq!(session, opened, "a login session's launches left it"); session = launch(&mut sessions, &shell, session, &sshserver); + assert_eq!(session, opened, "sshserver was not started in the desktop's session"); session = launch(&mut sessions, &sshserver, session, &shell); - assert_eq!(session, opened, "a login session's launches left it"); + assert_eq!(session.share(), opened.share(), "sshserver's launch left the session's share"); + assert!(!session.at_screen(), "sshserver's launch holds the screen: {session:?}"); + assert!(!launch(&mut sessions, &shell, session, &shell).at_screen(), "a shell regained the screen"); shares.push(opened.share()); } let distinct: BTreeSet = shares.iter().copied().collect(); assert_eq!(distinct.len(), shares.len(), "two shares are one: {shares:?}"); + // sshserver started by the machine, and any `login` row but the + // screen's, opens a session that never holds it. + let ssh = launch(&mut sessions, &sshserver, tests, &shell); + assert!(matches!(ssh, Session::Login(_, Screen::Not)), "{ssh:?}"); } /// A row that lists nothing starts nothing, a package included, and `/apps` @@ -342,8 +397,8 @@ mod tests { let mut sessions = Sessions::default(); for authority in [ Authority { row: "terminal".into(), session: sessions.machine() }, - Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login(Share(u64::MAX)) }, - Authority { row: "s".into(), session: sessions.open() }, + Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login(Share(u64::MAX), Screen::Held) }, + Authority { row: "s".into(), session: sessions.open(Screen::Not) }, Authority { row: "m".into(), session: Session::Machine(Share(u64::MAX)) }, ] { let bytes = authority.encode(); @@ -356,15 +411,15 @@ mod tests { /// authority reads back as it. #[test] fn what_encode_cannot_have_written_is_refused() { - let whole = Authority { row: "shell".into(), session: Sessions::default().open() }; + let whole = Authority { row: "shell".into(), session: Sessions::default().open(Screen::Held) }; let bytes = whole.encode(); for end in 0..bytes.len() { assert_ne!(Authority::decode(&bytes[..end]).as_ref(), Some(&whole), "a prefix of {end} bytes"); } let with = |kind: u8, share: u64, row: &[u8]| [&[kind][..], &share.to_le_bytes(), row].concat(); assert_eq!(Authority::decode(&[]), None); - assert_eq!(Authority::decode(&with(2, FIRST_SHARE, b"s")), None); - for kind in [MACHINE, LOGIN] { + assert_eq!(Authority::decode(&with(3, FIRST_SHARE, b"s")), None); + for kind in [MACHINE, LOGIN, LOGIN_AT_SCREEN] { assert_eq!(Authority::decode(&[kind]), None); // Its share cut short. assert_eq!(Authority::decode(&[kind, 1, 0, 0, 0, 0, 0, 0]), None); diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index 122081d2b7d..88aaf60dd7a 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -34,9 +34,10 @@ //! //! [`package`] is the other half: what an installed package says about itself, //! which is which of its own binaries a launch starts and never what it holds. -//! [`launch`] is who may start what, and [`grants`] which folder of the home a -//! package is granted. +//! [`launch`] is who may start what, [`grants`] which folder of the home a +//! package is granted, and [`consent`] how the person at the screen is asked. +pub mod consent; pub mod grants; pub mod launch; pub mod package; diff --git a/toyos/src/process.rs b/toyos/src/process.rs index b5b9eb1ea19..199b0c9efec 100644 --- a/toyos/src/process.rs +++ b/toyos/src/process.rs @@ -115,6 +115,11 @@ impl Image { Ok(Self { object, len }) } + /// The program's bytes, as they were read. + pub fn bytes(&self) -> &[u8] { + &self.object.as_slice()[..self.len as usize] + } + /// What `SpawnArgs::image` and `SpawnArgs::image_len` carry. The object is /// this process's until the spawn returns; the child keeps its own. pub fn spawn_words(&self) -> (u64, u64) { diff --git a/userland/compositor/desktop/src/hit.rs b/userland/compositor/desktop/src/hit.rs index ed576eff6fa..3b95e49245f 100644 --- a/userland/compositor/desktop/src/hit.rs +++ b/userland/compositor/desktop/src/hit.rs @@ -10,6 +10,8 @@ use crate::window::WindowMode; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Hit { Desktop, + /// A prompt is up and the pointer is not over its content: nothing is hit. + Blocked, TitleBar(usize), MinimizeButton(usize), MaximizeButton(usize), @@ -29,6 +31,15 @@ pub enum Hit { /// the hit test used an open-coded half-open half-unbounded expression, which /// made the frame's border pixel beside the button close the window too. pub fn hit_test(desk: &Desk, stack: &Stack, p: Point, launcher_open: bool) -> Hit { + // While a prompt is up its content is the one thing the pointer reaches: + // not the taskbar, the launcher, another window, nor its own chrome, so it + // is not moved, minimized or closed but by its own answer. + if let Some(prompt) = stack.prompt() { + return match stack[prompt].content.contains_point(p) { + true => Hit::Content(prompt), + false => Hit::Blocked, + }; + } let bar = desk.taskbar(stack.len()); if launcher_open && bar.launcher().contains_point(p) { @@ -90,7 +101,7 @@ mod tests { use crate::input::CursorStyle; use crate::layout::Chrome; use crate::rect::Rect; - use crate::window::Window; + use crate::window::{Level, Window}; use alloc::string::ToString; const DESK: Desk = Desk { @@ -106,7 +117,7 @@ mod tests { fn one_window(content: Rect) -> Stack { let mut s = Stack::default(); - s.insert(Window::new(0, content, "w".to_string(), false, CursorStyle::Default)); + s.insert(Window::new(0, content, "w".to_string(), Level::Ordinary, CursorStyle::Default)); s } @@ -150,7 +161,7 @@ mod tests { #[test] fn the_front_window_wins_an_overlap_and_a_minimized_one_never_does() { let mut s = one_window(Rect::new(100, 100, 400, 300)); - s.insert(Window::new(1, Rect::new(150, 150, 400, 300), "b".to_string(), false, CursorStyle::Default)); + s.insert(Window::new(1, Rect::new(150, 150, 400, 300), "b".to_string(), Level::Ordinary, CursorStyle::Default)); let p = at(200, 200); assert_eq!(hit_test(&DESK, &s, p, false), Hit::Content(1)); s[1].minimized = true; @@ -192,4 +203,35 @@ mod tests { assert_eq!(hit_test(&DESK, &s, p, false), Hit::Content(0)); assert_eq!(hit_test(&DESK, &s, p, true), Hit::LauncherItem(0)); } + + /// **While a prompt is up a press reaches its content and nothing else**: + /// not a fullscreen topmost window around it, the taskbar, the open + /// launcher, nor the prompt's own close button or title bar. + #[test] + fn a_hit_outside_the_prompt_s_content_is_swallowed() { + let mut s: Stack = Stack::default(); + s.insert(Window::new(0, DESK.work_area(), "hostile".to_string(), Level::Topmost, CursorStyle::Default)); + let ask = Rect::new(700, 400, 400, 200); + s.insert(Window::new(1, ask, "ask".to_string(), Level::Prompt, CursorStyle::Default)); + let bar = DESK.taskbar(s.len()); + let frame = DESK.chrome.frame(ask); + let [close, maximize, _] = DESK.chrome.buttons(frame); + for p in [ + at(10, 10), + at(bar.new_button().x0 + 2, bar.new_button().y0 + 2), + at(bar.tab(0).x0 + 2, bar.tab(0).y0 + 2), + at(bar.launcher_item(0).x0 + 4, bar.launcher_item(0).y0 + 4), + at(close.x0 + 2, close.y0 + 2), + at(maximize.x0 + 2, maximize.y0 + 2), + at(frame.x0 + 40, frame.y0 + 4), + at(ask.x1, ask.y1), + ] { + for launcher in [false, true] { + assert_eq!(hit_test(&DESK, &s, p, launcher), Hit::Blocked, "{p:?}, launcher open {launcher}"); + } + } + for p in [at(ask.x0, ask.y0), at(ask.x1 - 1, ask.y1 - 1), at(900, 500)] { + assert_eq!(hit_test(&DESK, &s, p, true), Hit::Content(1), "{p:?}"); + } + } } diff --git a/userland/compositor/desktop/src/input.rs b/userland/compositor/desktop/src/input.rs index cfd94f325cf..392853ec0b4 100644 --- a/userland/compositor/desktop/src/input.rs +++ b/userland/compositor/desktop/src/input.rs @@ -55,8 +55,14 @@ pub enum KeyAction { /// Releases are forwarded whatever they are. A chord is a press; a client that /// saw the press of a key it is holding needs the release of it too, and /// swallowing that leaves the window believing the key is still down. -pub fn key_action(ev: RawKeyEvent, focused: Option, launcher_open: bool) -> KeyAction { - if !ev.pressed() { +/// +/// **While a prompt is up every transition is the prompt's**, the focused +/// window's, and no chord is the desktop's: nothing closes, cycles, +/// minimizes or opens a window under it, and nothing is pasted into it, the +/// clipboard being any client's to write. [`PromptGate`] says which of them +/// reach it. +pub fn key_action(ev: RawKeyEvent, focused: Option, launcher_open: bool, prompt: bool) -> KeyAction { + if prompt || !ev.pressed() { return KeyAction::Forward; } if launcher_open && ev.keycode == usage::ESCAPE { @@ -395,11 +401,68 @@ pub fn cursor_style( } } +/// Which input a prompt is given: **only a transition whose press arrived +/// after the prompt's first frame was on the panel**, and the release of that +/// press. +/// +/// A key typed, or a button pressed, before the question could be read never +/// answers it: a press made before the first frame is swallowed with its +/// release, and so is the release of a key or button already down when the +/// prompt came up. The kernel delivers transitions and no repeats, so a key +/// held across the prompt's arrival never presses again until it is let go. +/// One per prompt, made when it is. +#[derive(Clone, Copy, Debug, Default)] +pub struct PromptGate { + presented: bool, + /// Usages whose press the prompt was given, by bit. + keys: [u64; 4], + /// The left button's press the prompt was given. + button: bool, +} + +impl PromptGate { + /// The prompt's first frame is on the panel: presses from now on reach it. + pub fn presented(&mut self) { + self.presented = true; + } + + /// Whether `ev` reaches the prompt. + pub fn key(&mut self, ev: RawKeyEvent) -> bool { + let (word, bit) = (usize::from(ev.keycode / 64), 1u64 << (ev.keycode % 64)); + if ev.pressed() { + if self.presented { + self.keys[word] |= bit; + } + return self.presented; + } + let given = self.keys[word] & bit != 0; + self.keys[word] &= !bit; + given + } + + /// Whether a press of the left button reaches the prompt. + pub fn press(&mut self) -> bool { + self.button = self.presented; + self.button + } + + /// Whether a release of the left button reaches the prompt. + pub fn release(&mut self) -> bool { + core::mem::take(&mut self.button) + } + + /// Once the prompt is gone: whether `ev` is the release of a key it was + /// given, which reaches no other window either. + pub fn owed(&mut self, ev: RawKeyEvent) -> bool { + !ev.pressed() && self.key(ev) + } +} + #[cfg(test)] mod tests { use super::*; use crate::layout::Chrome; - use crate::window::Window; + use crate::window::{Level, Window}; use alloc::string::ToString; use toyos_abi::input::{MOD_ALT, MOD_CTRL, MOD_GUI, MOD_RELEASED}; @@ -421,7 +484,7 @@ mod tests { i, Rect::new(100 + 20 * i as i32, 100 + 20 * i as i32, 400, 300), "w".to_string(), - false, + Level::Ordinary, CursorStyle::Default, )); } @@ -432,15 +495,15 @@ mod tests { fn a_release_is_always_the_window_s() { for code in [usage::Q, usage::LEFT, usage::TAB, usage::ESCAPE] { let ev = key(code, MOD_GUI | MOD_ALT | MOD_RELEASED); - assert_eq!(key_action(ev, Some(WindowMode::Normal), true), KeyAction::Forward); + assert_eq!(key_action(ev, Some(WindowMode::Normal), true, false), KeyAction::Forward); } } #[test] fn escape_closes_the_launcher_only_while_it_is_open() { let esc = key(usage::ESCAPE, 0); - assert_eq!(key_action(esc, None, true), KeyAction::CloseLauncher); - assert_eq!(key_action(esc, Some(WindowMode::Normal), false), KeyAction::Forward); + assert_eq!(key_action(esc, None, true, false), KeyAction::CloseLauncher); + assert_eq!(key_action(esc, Some(WindowMode::Normal), false, false), KeyAction::Forward); } #[test] @@ -457,13 +520,13 @@ mod tests { ]; for (code, from, want) in cases { assert_eq!( - key_action(key(code, MOD_GUI), Some(from), false), + key_action(key(code, MOD_GUI), Some(from), false, false), KeyAction::SetMode(want), "{code:#x} from {from:?}" ); } assert_eq!( - key_action(key(usage::DOWN, MOD_GUI), Some(WindowMode::Normal), false), + key_action(key(usage::DOWN, MOD_GUI), Some(WindowMode::Normal), false, false), KeyAction::Minimize ); } @@ -471,28 +534,101 @@ mod tests { #[test] fn no_window_means_no_super_chord_does_anything() { for code in [usage::LEFT, usage::RIGHT, usage::UP, usage::DOWN, usage::Q, usage::V] { - assert_eq!(key_action(key(code, MOD_GUI), None, false), KeyAction::Ignore); + assert_eq!(key_action(key(code, MOD_GUI), None, false, false), KeyAction::Ignore); } } #[test] fn an_unclaimed_super_chord_reaches_the_window() { assert_eq!( - key_action(key(0x04, MOD_GUI), Some(WindowMode::Normal), false), + key_action(key(0x04, MOD_GUI), Some(WindowMode::Normal), false, false), KeyAction::Forward ); } #[test] fn alt_tab_cycles_even_with_nothing_focused_and_ctrl_n_spawns() { - assert_eq!(key_action(key(usage::TAB, MOD_ALT), None, false), KeyAction::CycleFocus); - assert_eq!(key_action(key(usage::N, MOD_CTRL), None, false), KeyAction::SpawnTerminal); + assert_eq!(key_action(key(usage::TAB, MOD_ALT), None, false, false), KeyAction::CycleFocus); + assert_eq!(key_action(key(usage::N, MOD_CTRL), None, false, false), KeyAction::SpawnTerminal); // Without the modifier both are the window's. assert_eq!( - key_action(key(usage::TAB, 0), Some(WindowMode::Normal), false), + key_action(key(usage::TAB, 0), Some(WindowMode::Normal), false, false), KeyAction::Forward ); - assert_eq!(key_action(key(usage::N, 0), Some(WindowMode::Normal), false), KeyAction::Forward); + assert_eq!(key_action(key(usage::N, 0), Some(WindowMode::Normal), false, false), KeyAction::Forward); + } + + /// **While a prompt is up no chord is the desktop's**: closing, cycling, + /// snapping, minimizing, a terminal, the launcher's Escape and paste are + /// all the prompt's keys, pressed or released. + #[test] + fn a_prompt_gets_every_key_and_no_chord_or_paste_acts() { + let chords = [ + (usage::Q, MOD_GUI), + (usage::V, MOD_GUI), + (usage::DOWN, MOD_GUI), + (usage::UP, MOD_GUI), + (usage::LEFT, MOD_GUI), + (usage::TAB, MOD_ALT), + (usage::N, MOD_CTRL), + (usage::ESCAPE, 0), + (0x04, 0), + ]; + for (code, mods) in chords { + for released in [0, MOD_RELEASED] { + for launcher in [false, true] { + let ev = key(code, mods | released); + assert_eq!( + key_action(ev, Some(WindowMode::Normal), launcher, true), + KeyAction::Forward, + "{code:#x} {mods:#x} {released:#x}" + ); + } + } + } + } + + /// A key or button pressed before the prompt's first frame never reaches + /// it, press or release; one pressed after reaches it with its release. + #[test] + fn a_press_before_the_prompt_was_presented_never_reaches_it() { + let (enter, tab) = (0x28, usage::TAB); + let mut gate = PromptGate::default(); + // Held when the prompt came up, and typed before its first frame. + assert!(!gate.key(key(tab, 0)), "a press before the first frame reached the prompt"); + assert!(!gate.press(), "a click before the first frame reached the prompt"); + gate.presented(); + assert!(!gate.key(key(tab, MOD_RELEASED)), "the release of an early press reached the prompt"); + assert!(!gate.key(key(enter, MOD_RELEASED)), "the release of a key held from before reached it"); + assert!(!gate.release(), "the release of an early click reached the prompt"); + // Pressed after: the press and its release, once. + assert!(gate.key(key(enter, 0))); + assert!(gate.key(key(enter, MOD_RELEASED))); + assert!(!gate.key(key(enter, MOD_RELEASED)), "a release reached it twice"); + assert!(gate.press()); + assert!(gate.release()); + assert!(!gate.release()); + // Every usage is its own bit. + for code in [0u8, 63, 64, 127, 128, 255] { + assert!(gate.key(key(code, 0))); + } + for code in [0u8, 63, 64, 127, 128, 255] { + assert!(gate.key(key(code, MOD_RELEASED)), "{code}"); + } + } + + /// Once the prompt is gone, the release of a key it was given is owed to + /// nobody: not the window under it, which never saw the press. + #[test] + fn the_release_of_a_key_that_answered_the_prompt_reaches_no_window() { + let (escape, a) = (usage::ESCAPE, 0x04); + let mut gate = PromptGate::default(); + gate.presented(); + assert!(gate.key(key(escape, 0))); + assert!(!gate.owed(key(a, MOD_RELEASED)), "a key the prompt never had was taken"); + assert!(!gate.owed(key(escape, 0)), "a press after the prompt was taken"); + assert!(gate.owed(key(escape, MOD_RELEASED))); + assert!(!gate.owed(key(escape, MOD_RELEASED)), "a release was taken twice"); } fn report(buttons: u8, scroll: i8, x: u16, y: u16) -> [u8; 6] { diff --git a/userland/compositor/desktop/src/lib.rs b/userland/compositor/desktop/src/lib.rs index 82f0b6630b3..b93c35ebeaa 100644 --- a/userland/compositor/desktop/src/lib.rs +++ b/userland/compositor/desktop/src/lib.rs @@ -37,11 +37,18 @@ pub use damage::{Damage, MAX_DAMAGE_RECTS}; pub use hit::{hit_test, Hit}; pub use input::{ cursor_from_abs, cursor_style, edge_snap, fold_mouse, key_action, tab_action, CursorStyle, Grab, - Held, KeyAction, MouseSample, Released, TabAction, DRAG_THRESHOLD, MOUSE_EVENT_LEN, + Held, KeyAction, MouseSample, PromptGate, Released, TabAction, DRAG_THRESHOLD, MOUSE_EVENT_LEN, }; pub use layout::{set_mode, Chrome, Desk}; pub use plan::{compose, content_blit, Blit, Layer}; pub use rect::{Point, Rect}; pub use stack::Stack; pub use taskbar::{Taskbar, MAX_STATUS_CHARS, STATUS_MARGIN}; -pub use window::{Window, WindowId, WindowMode}; +pub use window::{Level, Window, WindowId, WindowMode}; + +/// The colour of the frame the compositor draws around a prompt and around +/// nothing else it draws: a cue to the person at the screen, and what a +/// screendump finds the prompt by. A client can paint it too; what an answer +/// grants is decided by who asked, not by how it looked +/// (`toyos_manifest::consent`). +pub const PROMPT_FRAME: [u8; 3] = [0xf5, 0xb0, 0x2e]; diff --git a/userland/compositor/desktop/src/plan.rs b/userland/compositor/desktop/src/plan.rs index 3a357fa8109..da87a5c65be 100644 --- a/userland/compositor/desktop/src/plan.rs +++ b/userland/compositor/desktop/src/plan.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; use crate::layout::Desk; use crate::rect::Rect; use crate::stack::Stack; -use crate::window::Window; +use crate::window::{Level, Window}; /// One thing to draw into the back buffer, in the order it must be drawn. /// @@ -36,8 +36,9 @@ pub fn compose(desk: &Desk, stack: &Stack, region: Rect, launcher_open: bo out.push(Layer::Wallpaper(uncovered)); } + let shown = |win: &Window| !win.minimized && region.overlaps(win.frame(&desk.chrome)); for (index, win) in stack.iter().enumerate() { - if !win.minimized && region.overlaps(win.frame(&desk.chrome)) { + if win.level != Level::Prompt && shown(win) { out.push(Layer::Window { index, clip: region }); } } @@ -46,7 +47,7 @@ pub fn compose(desk: &Desk, stack: &Stack, region: Rect, launcher_open: bo out.push(Layer::Taskbar { clip: region }); } - // Last, so it is over every window: it is a menu and not a window. + // Over every window a client asked for: it is a menu and not a window. if launcher_open { let l = bar.launcher(); if region.overlaps(l) { @@ -54,6 +55,12 @@ pub fn compose(desk: &Desk, stack: &Stack, region: Rect, launcher_open: bo } } + // Over the taskbar and the launcher too: nothing a client draws or opens + // covers the question. + if let Some(index) = stack.prompt().filter(|&index| shown(&stack[index])) { + out.push(Layer::Window { index, clip: region }); + } + out } @@ -97,7 +104,7 @@ mod tests { fn stack_of(contents: &[Rect]) -> Stack { let mut s = Stack::default(); for (i, c) in contents.iter().enumerate() { - s.insert(Window::new(i, *c, "w".to_string(), false, CursorStyle::Default)); + s.insert(Window::new(i, *c, "w".to_string(), Level::Ordinary, CursorStyle::Default)); } s } @@ -184,7 +191,7 @@ mod tests { #[test] fn a_blit_never_reads_outside_the_buffer() { let mut w: Window<()> = - Window::new((), Rect::new(100, 100, 400, 300), "w".to_string(), false, CursorStyle::Default); + Window::new((), Rect::new(100, 100, 400, 300), "w".to_string(), Level::Ordinary, CursorStyle::Default); // Dragged 200 pixels wider than the memory behind it. w.content = Rect::new(100, 100, 600, 300); let b = content_blit(&w, Rect::new(0, 0, 1920, 1080)).unwrap(); @@ -201,7 +208,7 @@ mod tests { #[test] fn a_clip_that_misses_the_window_blits_nothing() { let w: Window<()> = - Window::new((), Rect::new(100, 100, 400, 300), "w".to_string(), false, CursorStyle::Default); + Window::new((), Rect::new(100, 100, 400, 300), "w".to_string(), Level::Ordinary, CursorStyle::Default); assert!(content_blit(&w, Rect::new(0, 0, 50, 50)).is_none()); assert!(content_blit(&w, Rect::EMPTY).is_none()); } @@ -222,4 +229,23 @@ mod tests { assert!(!plan.is_empty(), "{region:?}"); } } + + /// **The prompt is drawn last**: over a fullscreen topmost window, the + /// taskbar and the open launcher, wherever its frame meets the region. + #[test] + fn a_prompt_is_drawn_over_the_taskbar_the_launcher_and_every_window() { + let mut s: Stack = Stack::default(); + s.insert(Window::new(0, DESK.screen, "hostile".to_string(), Level::Topmost, CursorStyle::Default)); + let ask = Rect::new(20, DESK.work_area().y1 - 100, 400, 300); + s.insert(Window::new(1, ask, "ask".to_string(), Level::Prompt, CursorStyle::Default)); + s.insert(Window::new(2, Rect::new(0, 0, 50, 50), "late".to_string(), Level::Topmost, CursorStyle::Default)); + let plan = compose(&DESK, &s, DESK.screen, true); + let last = plan.last().copied(); + assert_eq!(last, Some(Layer::Window { index: 2, clip: DESK.screen }), "{plan:?}"); + assert_eq!(s.prompt(), Some(2)); + let taskbar = plan.iter().position(|l| matches!(l, Layer::Taskbar { .. })).expect("the bar is drawn"); + let launcher = plan.iter().position(|l| matches!(l, Layer::Launcher(_))).expect("the launcher is drawn"); + assert!(taskbar < plan.len() - 1 && launcher < plan.len() - 1, "{plan:?}"); + assert_eq!(plan.iter().filter(|l| matches!(l, Layer::Window { index: 2, .. })).count(), 1); + } } diff --git a/userland/compositor/desktop/src/stack.rs b/userland/compositor/desktop/src/stack.rs index d5ce4938494..b6599c068db 100644 --- a/userland/compositor/desktop/src/stack.rs +++ b/userland/compositor/desktop/src/stack.rs @@ -1,15 +1,15 @@ use alloc::vec::Vec; use core::ops::{Index, IndexMut}; -use crate::window::{Window, WindowId}; +use crate::window::{Level, Window, WindowId}; /// The windows, bottom to top. /// /// The order *is* the Z-order, and one invariant holds over it: **every -/// topmost window sits above every ordinary one**. A window that asked to stay -/// on top is a file picker over the application that opened it, and an +/// window sits above every window of a lower [`Level`]**. A window that asked +/// to stay on top is a file picker over the application that opened it, and an /// ordinary window appearing above it is that dialog lost behind its own -/// parent. [`is_ordered`](Self::is_ordered) states it; [`insert`](Self::insert) +/// parent; and nothing a client asks for appears above a prompt. [`is_ordered`](Self::is_ordered) states it; [`insert`](Self::insert) /// and [`raise`](Self::raise) are the only ways in, and both maintain it. /// /// The `Vec` is private for that reason: `push` was the way in until this @@ -67,7 +67,7 @@ impl Stack { self.windows.iter().position(|w| w.id == id) } - /// Put `w` as high as its topmost flag entitles it to be, and say where it + /// Put `w` as high as its level entitles it to be, and say where it /// landed. /// /// A window with [`WindowId::UNASSIGNED`](crate::window::WindowId) — one @@ -80,11 +80,7 @@ impl Stack { w.id = WindowId(self.next_id); self.next_id += 1; } - let at = if w.topmost { - self.windows.len() - } else { - self.windows.iter().position(|o| o.topmost).unwrap_or(self.windows.len()) - }; + let at = self.windows.iter().position(|o| o.level > w.level).unwrap_or(self.windows.len()); self.windows.insert(at, w); at } @@ -104,10 +100,20 @@ impl Stack { self.insert(w) } - /// The window that has the keyboard: the topmost one that is not - /// minimized. + /// The window that has the keyboard: the prompt while one is up, and the + /// topmost one that is not minimized otherwise. pub fn focused(&self) -> Option { - self.windows.iter().rposition(|w| !w.minimized) + self.prompt().or_else(|| self.windows.iter().rposition(|w| !w.minimized)) + } + + /// The prompt, while one is up. + pub fn prompt(&self) -> Option { + self.windows.iter().position(|w| w.level == Level::Prompt) + } + + /// Whether a window of `level` may be made: a second prompt may not. + pub fn admits(&self, level: Level) -> bool { + level != Level::Prompt || self.prompt().is_none() } /// Send the top ordinary window to the bottom and reveal the next. @@ -117,7 +123,7 @@ impl Stack { /// screen changed. Topmost windows do not take part: cycling through one /// would either move it below an ordinary window or do nothing visible. pub fn cycle(&mut self) -> bool { - let ordinary = self.windows.iter().position(|w| w.topmost).unwrap_or(self.windows.len()); + let ordinary = self.windows.iter().position(|w| w.level > Level::Ordinary).unwrap_or(self.windows.len()); if ordinary < 2 { return false; } @@ -127,11 +133,10 @@ impl Stack { true } - /// Whether the topmost windows form a suffix — the invariant this type - /// exists to keep. + /// Whether the levels never fall from bottom to top — the invariant this + /// type exists to keep. pub fn is_ordered(&self) -> bool { - let first = self.windows.iter().position(|w| w.topmost).unwrap_or(self.windows.len()); - self.windows[first..].iter().all(|w| w.topmost) + self.windows.windows(2).all(|pair| pair[0].level <= pair[1].level) } } @@ -159,7 +164,7 @@ mod tests { let mut s = Stack::default(); for (name, topmost) in spec { let name: &'static str = alloc::boxed::Box::leak(name.to_string().into_boxed_str()); - s.insert(Window::new(name, Rect::new(0, 0, 100, 100), name.to_string(), *topmost, CursorStyle::Default)); + s.insert(Window::new(name, Rect::new(0, 0, 100, 100), name.to_string(), if *topmost { Level::Topmost } else { Level::Ordinary }, CursorStyle::Default)); } s } @@ -239,4 +244,47 @@ mod tests { assert_eq!(names(&s), ["b", "a", "p"]); assert!(s.is_ordered()); } + + fn with_prompt(spec: &[(&str, bool)]) -> Stack<&'static str> { + let mut s = stack(spec); + assert!(s.admits(Level::Prompt)); + s.insert(Window::new("ask", Rect::new(0, 0, 100, 100), "ask".to_string(), Level::Prompt, CursorStyle::Default)); + s + } + + /// **The prompt is above every window a client can ask for**, a topmost + /// one opened after it and one raised included, and has the keyboard. + #[test] + fn a_prompt_sits_above_every_topmost_window_and_has_the_keyboard() { + let mut s = with_prompt(&[("a", false), ("picker", true)]); + assert_eq!(names(&s), ["a", "picker", "ask"]); + s.insert(Window::new("hostile", Rect::new(0, 0, 100, 100), "h".to_string(), Level::Topmost, CursorStyle::Default)); + assert_eq!(names(&s), ["a", "picker", "hostile", "ask"]); + let raised = s.raise(2); + assert_eq!(names(&s)[raised], "hostile"); + assert_eq!(names(&s), ["a", "picker", "hostile", "ask"]); + assert!(s.is_ordered()); + assert_eq!(s.prompt(), Some(3)); + assert_eq!(s.focused(), Some(3)); + } + + /// One prompt at a time: a second is refused while the first is up, and + /// admitted once it is gone. + #[test] + fn a_second_prompt_is_refused_while_one_is_up() { + let mut s = with_prompt(&[("a", false)]); + assert!(!s.admits(Level::Prompt)); + assert!(s.admits(Level::Topmost) && s.admits(Level::Ordinary)); + s.remove(1); + assert!(s.admits(Level::Prompt)); + } + + /// Cycling moves ordinary windows alone and leaves the prompt on top. + #[test] + fn cycling_never_moves_a_prompt() { + let mut s = with_prompt(&[("a", false), ("b", false), ("p", true)]); + assert!(s.cycle()); + assert_eq!(names(&s), ["b", "a", "p", "ask"]); + assert_eq!(s.focused(), Some(3)); + } } diff --git a/userland/compositor/desktop/src/window.rs b/userland/compositor/desktop/src/window.rs index f185059ebcb..0f2d0c59ccd 100644 --- a/userland/compositor/desktop/src/window.rs +++ b/userland/compositor/desktop/src/window.rs @@ -4,6 +4,20 @@ use crate::input::CursorStyle; use crate::layout::Chrome; use crate::rect::Rect; +/// Which layer of the stack a window sits in: every window of a higher level +/// is above every window of a lower one. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum Level { + Ordinary, + /// Its client asked to stay on top: a dialog over the application that + /// opened it. + Topmost, + /// Made through the prompt port, which one program alone holds: above the + /// taskbar and every other window, at most one at a time, and the one + /// window with any input while it is up. + Prompt, +} + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum WindowMode { Normal, @@ -51,7 +65,7 @@ pub struct Window { pub buf_h: i32, pub title: String, pub minimized: bool, - pub topmost: bool, + pub level: Level, pub mode: WindowMode, /// Where a maximized or snapped window goes back to. pub saved: Rect, @@ -65,7 +79,7 @@ impl Window { client: C, content: Rect, title: String, - topmost: bool, + level: Level, cursor_style: CursorStyle, ) -> Self { Self { @@ -76,7 +90,7 @@ impl Window { buf_h: content.h(), title, minimized: false, - topmost, + level, mode: WindowMode::Normal, saved: Rect::EMPTY, presented: false, @@ -134,7 +148,7 @@ mod tests { use alloc::string::ToString; fn win(content: Rect) -> Window<()> { - Window::new((), content, "t".to_string(), false, CursorStyle::Default) + Window::new((), content, "t".to_string(), Level::Ordinary, CursorStyle::Default) } #[test] diff --git a/userland/compositor/src/client.rs b/userland/compositor/src/client.rs index b76fcda3de7..9eeb6e96f5a 100644 --- a/userland/compositor/src/client.rs +++ b/userland/compositor/src/client.rs @@ -82,6 +82,8 @@ pub struct ClientFrame { pub conn: Option, /// The connection's [`PendingConn::copy`]. pub copy: Option, + /// The connection's [`PendingConn::prompt`]. + pub prompt: bool, } impl ClientFrame { @@ -93,6 +95,7 @@ impl ClientFrame { payload_len: 0, conn: None, copy: None, + prompt: false, } } @@ -115,6 +118,9 @@ pub struct PendingConn { pub conn: Connection, pub rx: ClientRx, pub since: Instant, + /// It came in on the prompt port, whose one holder's window goes above every + /// other: its first frame may only ask for that window. + pub prompt: bool, /// The region a `MSG_COPY_BEGIN` was answered with. A connection holding /// one may send `MSG_COPY_COMMIT` and nothing else, within /// [`HANDSHAKE_TIMEOUT`] of `since`. diff --git a/userland/compositor/src/main.rs b/userland/compositor/src/main.rs index e0c199c0078..2099385bd57 100644 --- a/userland/compositor/src/main.rs +++ b/userland/compositor/src/main.rs @@ -41,13 +41,13 @@ pub const DRAIN_BUDGET: Duration = FRAME_INTERVAL; pub const FLAG_HARDWARE_CURSOR: u32 = 1 << 0; -/// Handles the compositor watches that are not windows: keyboard, mouse, -/// listener. -pub const FIXED_POLL_HANDLES: u32 = 3; +/// Handles the compositor watches that are not windows: keyboard, mouse, and +/// the two listeners, `compositor` and `prompt`. +pub const FIXED_POLL_HANDLES: u32 = 4; /// Hard ceiling on live windows, from the poller rather than from memory. /// -/// Every window's handle is registered in the same batch as the three fixed +/// Every window's handle is registered in the same batch as the four fixed /// ones /// and the pending connections, and [`Poller::MAX_HANDLES`] is the widest set /// one poller can carry. Unlike the memory budget this does not move when the diff --git a/userland/compositor/src/render.rs b/userland/compositor/src/render.rs index 468977a1067..194d369847a 100644 --- a/userland/compositor/src/render.rs +++ b/userland/compositor/src/render.rs @@ -10,7 +10,7 @@ //! to what, which pixels of a client's buffer reach the screen — is //! `toyos_desktop`'s and host-tested there. What is left here is the drawing. -use toyos_desktop::{content_blit, plan, Desk, Layer, Rect, Stack}; +use toyos_desktop::{content_blit, plan, Desk, Layer, Level, Rect, Stack, PROMPT_FRAME}; use window::{Color, Framebuffer}; use crate::client::Client; @@ -32,6 +32,10 @@ pub const TASKBAR_MINIMIZED_COLOR: Color = Color { r: 0x20, g: 0x20, b: 0x30 }; pub const TASKBAR_MINIMIZED_TEXT: Color = Color { r: 0x50, g: 0x50, b: 0x60 }; pub const LAUNCHER_BG: Color = Color { r: 0x20, g: 0x20, b: 0x30 }; pub const LAUNCHER_TEXT: Color = Color { r: 0xe0, g: 0xe0, b: 0xe8 }; +pub const PROMPT_TITLE_TEXT: Color = Color { r: 0x1e, g: 0x1e, b: 0x2e }; + +/// What a prompt's title bar says, whatever its client asked for. +const PROMPT_TITLE: &str = "ToyOS is asking"; /// Glyph cell height of the prebuilt font, which every vertical centring uses. const GLYPH_H: i32 = 16; @@ -127,9 +131,15 @@ fn draw_window( clip: Rect, ) { let chrome = &desk.chrome; - let border_color = if focused { FOCUSED_BORDER_COLOR } else { UNFOCUSED_BORDER_COLOR }; - let title_color = if focused { FOCUSED_TITLE_COLOR } else { UNFOCUSED_TITLE_COLOR }; - let text_color = if focused { FOCUSED_TITLE_TEXT } else { UNFOCUSED_TITLE_TEXT }; + // A prompt's frame is the compositor's own: its colour, its title and no + // buttons, since nothing but its answer takes it down. + let prompt = win.level == Level::Prompt; + let [r, g, b] = PROMPT_FRAME; + let (border_color, title_color, text_color) = match (prompt, focused) { + (true, _) => (Color { r, g, b }, Color { r, g, b }, PROMPT_TITLE_TEXT), + (false, true) => (FOCUSED_BORDER_COLOR, FOCUSED_TITLE_COLOR, FOCUSED_TITLE_TEXT), + (false, false) => (UNFOCUSED_BORDER_COLOR, UNFOCUSED_TITLE_COLOR, UNFOCUSED_TITLE_TEXT), + }; let frame = win.frame(chrome); let strip = chrome.title_strip(frame); @@ -146,7 +156,11 @@ fn draw_window( ); fill(surface, bar, title_color); - let title = if win.title.is_empty() { "Window" } else { &win.title }; + let title = match (prompt, win.title.is_empty()) { + (true, _) => PROMPT_TITLE, + (false, true) => "Window", + (false, false) => &win.title, + }; assets.font.draw_string( surface, (bar.x0 + 8) as usize, @@ -158,11 +172,12 @@ fn draw_window( let [close, maximize, minimize] = chrome.buttons(frame); let close_bg = if focused { CLOSE_BUTTON_BG } else { title_color }; - for (rect, bg, icon) in [ + let buttons = [ (close, close_bg, &assets.icons.close), (maximize, title_color, &assets.icons.maximize), (minimize, title_color, &assets.icons.minimize), - ] { + ]; + for (rect, bg, icon) in buttons.into_iter().filter(|_| !prompt) { fill(surface, rect, bg); draw_icon_centered(surface, icon, rect); } diff --git a/userland/compositor/src/session.rs b/userland/compositor/src/session.rs index ba31a622ca8..c7b268930e0 100644 --- a/userland/compositor/src/session.rs +++ b/userland/compositor/src/session.rs @@ -8,6 +8,7 @@ //! to the panel. use std::process::Command; +use std::sync::mpsc; use std::time::{Duration, Instant}; use toyos::endow; @@ -20,8 +21,8 @@ use toyos_abi::syscall::DeviceType; use toyos_abi::RawHandle; use toyos_desktop::{ cursor_from_abs, cursor_style, fold_mouse, hit_test, key_action, set_mode, tab_action, Chrome, - CursorStyle, Damage, Desk, Grab, Held, Hit, KeyAction, Point, Rect, Released, Stack, TabAction, - Verdict, Window, WindowMode, + CursorStyle, Damage, Desk, Grab, Held, Hit, KeyAction, Level, Point, PromptGate, Rect, Released, + Stack, TabAction, Verdict, Window, WindowId, WindowMode, }; use window::Screen; @@ -82,6 +83,18 @@ impl Wallpaper { pub struct Session { acceptor: Acceptor, + /// The prompt layer's port ([`toyos_manifest::consent::PROMPT`]), which + /// one program alone holds: a window made on it goes above every other. + prompts: Acceptor, + /// The prompt while one is up, and which of the input reaches it. + prompt: Option<(WindowId, PromptGate)>, + /// The last prompt's gate once it is gone: the releases of what it was + /// given, which no other window saw the press of. + owed: PromptGate, + /// Where a launch is made: off the loop, since the supervisor answers one + /// whose package asks a question only once the question is answered, + /// through this loop. + launcher: mpsc::Sender, kb: Keyboard, mouse: Mouse, poller: Poller, @@ -147,6 +160,8 @@ impl Session { // take. let acceptor = endow::acceptor("compositor") .expect("the manifest declares this program serves `compositor`"); + let prompts = endow::acceptor(toyos_manifest::consent::PROMPT) + .expect("the manifest declares this program serves `prompt`"); let kb: Keyboard = endow::device(DeviceType::Keyboard) .expect("the manifest gives this program the keyboard"); let mouse: Mouse = endow::device(DeviceType::Mouse) @@ -222,7 +237,7 @@ impl Session { ); // Sized for the slot ceiling rather than for `max_windows`: the batch - // between two `wait` calls is the three fixed registrations, one per + // between two `wait` calls is the four fixed registrations, one per // live window // and one per pending connection, and `MSG_SET_RESOLUTION` can raise // `max_windows` mid-run. @@ -230,6 +245,7 @@ impl Session { poller.watch(&kb, READABLE, kb.as_handle().0 as u64); poller.watch(&mouse, READABLE, mouse.as_handle().0 as u64); poller.watch(&acceptor, READABLE, acceptor.as_handle().0 as u64); + poller.watch(&prompts, READABLE, prompts.as_handle().0 as u64); let cursor = Point { x: desk.screen.w() / 2, y: desk.screen.h() / 2 }; if hw_cursor { @@ -239,6 +255,7 @@ impl Session { let mut damage = Damage::default(); damage.add(desk.screen); + let launcher = launcher(); println!("compositor: ready"); let now = Instant::now(); @@ -246,6 +263,10 @@ impl Session { reported_traffic: screen.traffic(), reported_composed: back.surface.traffic(), acceptor, + prompts, + prompt: None, + owed: PromptGate::default(), + launcher, kb, mouse, poller, @@ -311,6 +332,7 @@ impl Session { } self.tick_taskbar(); self.present(); + self.prompt_gone(); } /// One turn of the drain, or `false` when nothing was ready. @@ -323,6 +345,7 @@ impl Session { let kb_ready = self.is_ready(self.kb.as_handle()); let mouse_ready = self.is_ready(self.mouse.as_handle()); let accept_ready = self.is_ready(self.acceptor.as_handle()); + let prompt_ready = self.is_ready(self.prompts.as_handle()); let client_ready = self.stack.iter().any(|w| self.is_ready(w.client.conn.as_handle())) || self.pending.iter().any(|p| self.is_ready(p.conn.as_handle())); @@ -340,7 +363,7 @@ impl Session { } self.pending.retain(|p| now.duration_since(p.since) < HANDSHAKE_TIMEOUT); - if !kb_ready && !mouse_ready && !accept_ready && !client_ready { + if !kb_ready && !mouse_ready && !accept_ready && !prompt_ready && !client_ready { return false; } @@ -352,12 +375,16 @@ impl Session { self.pointer(); } if accept_ready { - self.accept(); + self.accept(false); + } + if prompt_ready { + self.accept(true); } let frames = self.take_frames(); self.dispatch(frames); self.reap(); - self.rearm(kb_ready, mouse_ready, accept_ready); + self.prompt_gone(); + self.rearm(kb_ready, mouse_ready, accept_ready, prompt_ready); true } @@ -383,11 +410,24 @@ impl Session { let event = ipc::decode_payload::(raw) .expect("a chunk is exactly one event long"); let focused = self.stack.focused(); - let action = - key_action(event.into(), focused.map(|i| self.stack[i].mode), self.launcher_open); + let action = key_action( + event.into(), + focused.map(|i| self.stack[i].mode), + self.launcher_open, + self.prompt.is_some(), + ); match action { KeyAction::Ignore => {} KeyAction::Forward => { + // While a prompt is up it is the focused window, and is + // given what its gate lets through and nothing else is. + let given = match &mut self.prompt { + Some((_, gate)) => gate.key(event.into()), + None => !self.owed.owed(event.into()), + }; + if !given { + continue; + } if let Some(i) = focused { deliver(&mut self.dead, &self.stack[i], window::MSG_KEY_INPUT, &event); } @@ -401,9 +441,7 @@ impl Session { self.damage_all(); } } - KeyAction::SpawnTerminal => { - Command::new("/system/bin/terminal").spawn().ok(); - } + KeyAction::SpawnTerminal => self.launch("/system/bin/terminal"), KeyAction::Paste => { if let Some(i) = focused { self.paste(i); @@ -540,6 +578,13 @@ impl Session { self.damage_all(); } Hit::Content(idx) => { + // The one content a press reaches while a prompt is up is the + // prompt's, and only a press its gate lets through. + if let Some((_, gate)) = &mut self.prompt { + if !gate.press() { + return; + } + } if self.launcher_open { self.launcher_open = false; self.damage.add(self.launcher_rect()); @@ -579,11 +624,13 @@ impl Session { } Hit::LauncherItem(idx) => { if let Some((_, program)) = self.apps.get(idx) { - Command::new(program).spawn().ok(); + let program = program.clone(); + self.launch(&program); } self.launcher_open = false; self.damage.add(self.launcher_rect()); } + Hit::Blocked => {} Hit::Desktop => { if self.launcher_open { self.launcher_open = false; @@ -594,7 +641,11 @@ impl Session { } fn release(&mut self, buttons: u8) { - if let Some(i) = self.stack.focused() { + let given = match &mut self.prompt { + Some((_, gate)) => gate.release(), + None => !self.owed.release(), + }; + if let (Some(i), true) = (self.stack.focused(), given) { let ev = mouse_event(&self.stack[i], self.cursor, buttons, window::MOUSE_RELEASE, 1, 0); deliver(&mut self.dead, &self.stack[i], window::MSG_MOUSE_INPUT, &ev); @@ -636,11 +687,13 @@ impl Session { } } - fn accept(&mut self) { + /// One connection off `compositor`, or off `prompt` where `prompt`. + fn accept(&mut self, prompt: bool) { // `accept` installs a descriptor, so it answers `ResourceExhausted` on // a full handle table — and clients drive that table, one handle per // connection. The connection is lost either way; the desktop is not. - match self.acceptor.accept() { + let acceptor = if prompt { &self.prompts } else { &self.acceptor }; + match acceptor.accept() { Err(e) => eprintln!("compositor: a connection could not be accepted ({e:?})"), Ok(conn) if self.pending.len() >= MAX_PENDING_CONNS as usize => { eprintln!( @@ -656,6 +709,7 @@ impl Session { rx: ClientRx::new(), since: Instant::now(), copy: None, + prompt, }); } } @@ -699,6 +753,7 @@ impl Session { let p = self.pending.remove(i); frame.conn = Some(p.conn); frame.copy = p.copy; + frame.prompt = p.prompt; out.push(frame); break; } @@ -755,6 +810,11 @@ impl Session { } (None, _) => {} } + // A connection on the prompt port is for its window and nothing else. + if frame.prompt && frame.msg_type != window::MSG_CREATE_WINDOW { + mark_dead(&mut self.dead, handle, DropReason::OutOfProtocol); + continue; + } match frame.msg_type { window::MSG_CREATE_WINDOW => self.create_window(frame), window::MSG_COPY_BEGIN => self.copy_begin(frame), @@ -846,6 +906,19 @@ impl Session { return; }; + let level = match (frame.prompt, req.flags & window::WINDOW_FLAG_TOPMOST != 0) { + (true, _) => Level::Prompt, + (false, true) => Level::Topmost, + (false, false) => Level::Ordinary, + }; + // One prompt at a time: the prompt layer is full. + if !self.stack.admits(level) { + eprintln!("compositor: refusing a prompt from client {} — one is already up", handle.0); + let reason = window::REFUSED_AT_CAPACITY; + let _ = ipc::try_send(handle, window::MSG_WINDOW_REFUSED, &window::WindowRefused { reason }); + return; + } + // Every refusal below is an answer to untrusted input, so none of them // is a panic and none is a silent shrink of what was asked for. let refusal = match toyos_desktop::create_verdict( @@ -926,10 +999,20 @@ impl Session { Client { conn, shm, rx: ClientRx::new(), presents: 0, frames: 0 }, content, title, - req.flags & window::WINDOW_FLAG_TOPMOST != 0, + level, CursorStyle::Default, )); note_opened(handle, content, self.stack.len()); + if level == Level::Prompt { + // Whatever the pointer was doing ends here, and the launcher + // closes: nothing under the prompt moves on input it is given. + self.prompt = Some((self.stack[at].id, PromptGate::default())); + self.grab = Grab::None; + if self.launcher_open { + self.launcher_open = false; + self.damage.add(self.launcher_rect()); + } + } self.poller.watch(&self.stack[at].client.conn, READABLE, handle.0 as u64); let pixel_format = self.pixel_format(); @@ -1001,6 +1084,7 @@ impl Session { rx: ClientRx::new(), since: Instant::now(), copy: Some(CopyRegion::new(region)), + prompt: false, }); } @@ -1123,7 +1207,10 @@ impl Session { } /// Re-arm the one-shot poll registrations for every handle that fired. - fn rearm(&mut self, kb: bool, mouse: bool, acceptor: bool) { + fn rearm(&mut self, kb: bool, mouse: bool, acceptor: bool, prompts: bool) { + if prompts { + self.poller.watch(&self.prompts, READABLE, self.prompts.as_handle().0 as u64); + } if kb { self.poller.watch(&self.kb, READABLE, self.kb.as_handle().0 as u64); } @@ -1284,6 +1371,14 @@ impl Session { deliver_signal(&mut dead, &self.stack[i], window::MSG_FRAME); self.stack[i].client.frames += 1; self.stack[i].presented = false; + // The prompt's own first frame is on the panel: from here a + // press can be an answer. + if let Some((id, gate)) = &mut self.prompt { + if *id == self.stack[i].id && self.stack[i].client.frames == 1 { + gate.presented(); + println!("compositor: the prompt is on the panel"); + } + } } } announce(&dead); @@ -1320,6 +1415,22 @@ impl Session { self.desk.taskbar(self.stack.len()).launcher() } + /// Say so once the prompt is gone, however it went. + fn prompt_gone(&mut self) { + if let Some((id, gate)) = self.prompt { + if self.stack.position(id).is_none() { + self.prompt = None; + self.owed = gate; + println!("compositor: the prompt is down"); + } + } + } + + /// Start `program` through the launcher, off the loop. + fn launch(&self, program: &str) { + self.launcher.send(program.to_string()).expect("compositor: its launcher thread has ended"); + } + fn retarget(&mut self, idx: usize, mode: WindowMode) { let pf = self.pixel_format(); set_mode(&self.desk, &mut self.stack, idx, mode); @@ -1379,6 +1490,23 @@ impl Session { } } +/// The thread every launch is made on, and the channel to it: one at a time, +/// in order, since a launch waits as long as its question does. +fn launcher() -> mpsc::Sender { + let (launches, queue) = mpsc::channel::(); + std::thread::Builder::new() + .name("launcher".into()) + .spawn(move || { + for program in queue { + if let Err(e) = Command::new(&program).spawn() { + eprintln!("compositor: {program} did not start: {e}"); + } + } + }) + .expect("compositor: no thread to launch on"); + launches +} + fn desk_of(screen: &Screen, font: &font::Font, apps: usize) -> Desk { Desk { chrome: Chrome::DEFAULT, diff --git a/userland/filepicker/Cargo.toml b/userland/filepicker/Cargo.toml index 3ded9fefbd7..526e9304504 100644 --- a/userland/filepicker/Cargo.toml +++ b/userland/filepicker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filepicker" -description = "The file picker: a window that answers one program's open or save request with a path, served on the filepicker port." +description = "The file picker: a window that answers one program's open or save request with a path, served on the filepicker port; and the consent server, which asks the person at the screen the supervisor's question before a package's launch, in the compositor's prompt layer." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" @@ -9,4 +9,5 @@ license = "MIT OR Apache-2.0" filepicker-api = { path = "../filepicker-api" } toyos = { path = "../../toyos" } toyos-font = { path = "../toyos-font" } +toyos-manifest = { path = "../../toyos-manifest" } toyos-window = { path = "../toyos-window" } diff --git a/userland/filepicker/src/consent.rs b/userland/filepicker/src/consent.rs new file mode 100644 index 00000000000..3e3cd9825cd --- /dev/null +++ b/userland/filepicker/src/consent.rs @@ -0,0 +1,407 @@ +//! The question `/system/bin/supervisor` asks before an installed package's +//! launch, drawn in the compositor's prompt layer ([`toyos_manifest::consent`]). +//! +//! **A chooser bounded by the folder rules**: it starts at the session user's +//! home and lists only folders a package may be granted +//! ([`grants::folder`]), never `Apps`, a link, or a folder past a grant's +//! bounds, so nothing the person can pick is refused for its shape. The +//! supervisor holds the answer to the same rules regardless. +//! +//! **Nothing answers but the person, and only once they could read it**: the +//! compositor gives the prompt only what was pressed after its first frame was +//! on the panel, and a button answers only on a release over the button its +//! press began on. The keyboard starts on the folder list, where Enter opens a +//! folder and answers nothing; an answer is a button reached with Tab. Escape +//! and the window closing are Skip, and the supervisor closing the connection +//! takes the question down unanswered. + +use std::fs; +use std::path::{Path, PathBuf}; + +use font::Font; +use toyos::ipc::{self, RxStep}; +use toyos::poller::{Poller, READABLE}; +use toyos::Connection; +use toyos_manifest::consent::{self, Ask, Reply}; +use toyos_manifest::grants::{self, Access, Folder}; +use window::{Color, Event, Framebuffer, KeyPress, MouseEvent, Window}; + +use crate::{ + ACCENT_BG, ACCENT_FG, BG, BUTTON_BG, BUTTON_FG, DIM_FG, DIR_FG, KEY_BACKSPACE, KEY_DOWN, KEY_ENTER, KEY_ESCAPE, + KEY_LEFT, KEY_RIGHT, KEY_SPACE, KEY_TAB, KEY_UP, PATH_BG, SEL_BG, TEXT_FG, +}; + +const WIDTH: u32 = 600; +const HEIGHT: u32 = 400; + +/// What has the keyboard, in Tab's order. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Focus { + List, + ReadOnly, + Button(Choice), +} + +/// The three answers a button gives. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Choice { + Once, + Always, + Deny, +} + +const CHOICES: [(Choice, &str); 3] = + [(Choice::Once, " Allow once "), (Choice::Always, " Always allow "), (Choice::Deny, " Deny ")]; + +/// One question being asked. +struct Question { + ask: Ask, + home: PathBuf, + dir: PathBuf, + /// The folders of `dir` a package may be granted, sorted. + folders: Vec, + selected: usize, + scroll: usize, + focus: Focus, + /// The person grants less than was asked: open, and not change. + read_only: bool, + /// What a press of the left button began on, which only a release over it + /// answers. + pressed: Option, +} + +impl Question { + fn new(ask: Ask) -> Self { + let home = PathBuf::from(toyos_manifest::session_home()); + let mut question = Self { + ask, + dir: home.clone(), + home, + folders: Vec::new(), + selected: 0, + scroll: 0, + focus: Focus::List, + read_only: false, + pressed: None, + }; + question.list(); + question + } + + fn list(&mut self) { + self.folders = grantable(&self.dir); + self.selected = 0; + self.scroll = 0; + } + + /// The folder an Allow grants: the one selected. + fn chosen(&self) -> Option { + let name = self.folders.get(self.selected)?; + let path = self.dir.join(name).to_str()?.to_string(); + let access = if self.read_only { Access::ReadOnly } else { self.ask.access }; + Some(Folder { path, access }) + } + + /// Tab's stops, in order: the read-only box only where the package asked + /// to change its folder. + fn stops(&self) -> Vec { + let mut stops = vec![Focus::List]; + if self.ask.access == Access::ReadWrite { + stops.push(Focus::ReadOnly); + } + stops.extend(CHOICES.iter().map(|(choice, _)| Focus::Button(*choice))); + stops + } + + fn tab(&mut self, back: bool) { + let stops = self.stops(); + let at = stops.iter().position(|s| *s == self.focus).unwrap_or(0); + let next = if back { (at + stops.len() - 1) % stops.len() } else { (at + 1) % stops.len() }; + self.focus = stops[next]; + } + + /// The answer `focus` gives when it is activated, or `None` where it only + /// moves the chooser. + fn activate(&mut self, focus: Focus) -> Option { + match focus { + Focus::List => { + self.enter(); + None + } + Focus::ReadOnly => { + self.read_only = !self.read_only; + None + } + Focus::Button(Choice::Deny) => Some(Reply::Deny), + Focus::Button(Choice::Once) => self.chosen().map(Reply::Once), + Focus::Button(Choice::Always) => self.chosen().map(Reply::Always), + } + } + + /// Into the selected folder. + fn enter(&mut self) { + if let Some(name) = self.folders.get(self.selected) { + self.dir.push(name); + self.list(); + } + } + + /// Back toward the home, never above it. + fn up(&mut self) { + if self.dir != self.home { + self.dir.pop(); + self.list(); + } + } + + fn key(&mut self, key: &KeyPress) -> Option { + if !key.pressed() { + return None; + } + match key.keycode { + KEY_ESCAPE => return Some(Reply::Skip), + KEY_TAB => self.tab(key.shift()), + KEY_ENTER => return self.activate(self.focus), + KEY_SPACE if self.focus != Focus::List => return self.activate(self.focus), + KEY_UP if self.focus == Focus::List => { + self.selected = self.selected.saturating_sub(1); + self.scroll = self.scroll.min(self.selected); + } + KEY_DOWN if self.focus == Focus::List => { + if self.selected + 1 < self.folders.len() { + self.selected += 1; + } + if self.selected >= self.scroll + ROWS { + self.scroll = self.selected + 1 - ROWS; + } + } + KEY_RIGHT if self.focus == Focus::List => self.enter(), + KEY_LEFT | KEY_BACKSPACE if self.focus == Focus::List => self.up(), + _ => {} + } + None + } + + fn mouse(&mut self, mouse: &MouseEvent, font: &Font) -> Option { + let at = target(self, font, mouse.x as usize, mouse.y as usize); + match mouse.event_type { + window::MOUSE_PRESS if mouse.changed == 1 => { + self.pressed = at; + if let Some(Focus::List) = at { + if let Some(row) = row_at(font, mouse.y as usize) { + if self.scroll + row < self.folders.len() { + self.selected = self.scroll + row; + } + } + } + if let Some(focus) = at { + self.focus = focus; + } + None + } + window::MOUSE_RELEASE if mouse.changed == 1 => match self.pressed.take() { + Some(pressed) if Some(pressed) == at && pressed != Focus::List => self.activate(pressed), + _ => None, + }, + _ => None, + } + } +} + +/// The folders of `dir` a package may be granted: directories, not links, +/// each one [`grants::folder`] admits. +fn grantable(dir: &Path) -> Vec { + let mut folders: Vec = fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().to_str().map(str::to_string)) + .filter(|name| dir.join(name).to_str().is_some_and(|path| grants::folder(path).is_ok())) + .collect(); + folders.sort_by_key(|name| name.to_lowercase()); + folders +} + +/// The rows the folder list shows at once. +const ROWS: usize = 12; + +/// Where the folder list's first row is. +fn list_y(font: &Font) -> usize { + (font.height() + 8) * 3 +} + +fn row_at(font: &Font, y: usize) -> Option { + let top = list_y(font); + (y >= top && y < top + ROWS * font.height()).then(|| (y - top) / font.height()) +} + +/// The read-only box's row. +fn toggle_y(font: &Font) -> usize { + list_y(font) + ROWS * font.height() + 8 +} + +fn buttons_y() -> usize { + HEIGHT as usize - 36 +} + +/// Each button's left edge and width, right to left from the window's edge. +fn button_rects(font: &Font) -> [(Choice, usize, usize); 3] { + let mut x = WIDTH as usize - 8; + let mut out = [(Choice::Deny, 0, 0); 3]; + for (i, (choice, label)) in CHOICES.iter().enumerate().rev() { + let w = label.len() * font.width(); + x -= w; + out[i] = (*choice, x, w); + x -= 8; + } + out +} + +/// What is at `(x, y)`. +fn target(question: &Question, font: &Font, x: usize, y: usize) -> Option { + if row_at(font, y).is_some() { + return Some(Focus::List); + } + let toggle = toggle_y(font); + if question.ask.access == Access::ReadWrite && (toggle..toggle + font.height()).contains(&y) { + return Some(Focus::ReadOnly); + } + let top = buttons_y(); + if !(top..top + font.height() + 8).contains(&y) { + return None; + } + button_rects(font) + .into_iter() + .find(|(_, left, w)| (*left..left + w).contains(&x)) + .map(|(choice, _, _)| Focus::Button(choice)) +} + +fn render(fb: &Framebuffer, font: &Font, question: &Question) { + let (w, fh) = (fb.width(), font.height()); + fb.clear(BG); + font.draw_string(fb, 8, 8, &question.ask.words(), TEXT_FG, BG); + let shown = question.dir.to_string_lossy(); + fb.fill_rect(0, fh + 16, w, fh + 8, PATH_BG); + font.draw_string(fb, 8, fh + 20, &format!("Choose the folder, in {shown}"), TEXT_FG, PATH_BG); + + let top = list_y(font); + if question.folders.is_empty() { + font.draw_string(fb, 8, top, " (no folder here can be given; Left goes back)", DIM_FG, BG); + } + for (row, name) in question.folders.iter().enumerate().skip(question.scroll).take(ROWS) { + let y = top + (row - question.scroll) * fh; + let selected = row == question.selected; + let bg = match (selected, question.focus == Focus::List) { + (true, true) => SEL_BG, + (true, false) => PATH_BG, + (false, _) => BG, + }; + fb.fill_rect(0, y, w, fh, bg); + font.draw_string(fb, 8, y, &format!(" {name}/"), DIR_FG, bg); + } + + if question.ask.access == Access::ReadWrite { + let y = toggle_y(font); + let bg = if question.focus == Focus::ReadOnly { SEL_BG } else { BG }; + let mark = if question.read_only { "[x]" } else { "[ ]" }; + let line = format!("{mark} Only let it open the files, not change them"); + fb.fill_rect(0, y, w, fh, bg); + font.draw_string(fb, 8, y, &line, TEXT_FG, bg); + } + + let y = buttons_y(); + fb.fill_rect(0, y - 4, w, fh + 16, PATH_BG); + font.draw_string(fb, 8, y + 4, "Esc: not now", DIM_FG, PATH_BG); + for (choice, left, width) in button_rects(font) { + let label = CHOICES.iter().find(|(c, _)| *c == choice).map_or("", |(_, l)| l); + let (bg, fg): (Color, Color) = match question.focus == Focus::Button(choice) { + true => (ACCENT_BG, ACCENT_FG), + false => (BUTTON_BG, BUTTON_FG), + }; + fb.fill_rect(left, y, width, fh + 8, bg); + font.draw_string(fb, left, y + 4, label, fg, bg); + } +} + +/// Ask `ask` of the person at the screen and answer the supervisor on `conn`, +/// unless the supervisor withdraws it first. +pub fn ask(ask: Ask, conn: &Connection, font: &Font) { + let package = ask.package.clone(); + let mut window = match Window::create_on(consent::PROMPT, WIDTH, HEIGHT, "") { + Ok(window) => window, + Err(e) => { + // The supervisor starts the package with no folder and asks again + // at its next launch. + eprintln!("filepicker: no prompt for {package}'s question ({e}); it is skipped"); + reply(conn, &package, Reply::Skip); + return; + } + }; + let mut fb = window.framebuffer(); + let mut question = Question::new(ask); + render(&fb, font, &question); + window.present(); + println!("filepicker: asking about {package}"); + + const WINDOW: u64 = 0; + const SUPERVISOR: u64 = 1; + let poller = Poller::new(2); + let mut withdrawn = ipc::FrameRx::<0>::new(); + loop { + poller.watch_raw(window.handle(), READABLE, WINDOW); + poller.watch(conn, READABLE, SUPERVISOR); + let (mut supervisor, mut shown) = (false, false); + poller.wait(1, u64::MAX, |token| match token { + SUPERVISOR => supervisor = true, + _ => shown = true, + }); + // Anything on the supervisor's connection after its question is the + // question taken back. + if supervisor && !matches!(withdrawn.pump(conn), RxStep::Idle) { + println!("filepicker: the question about {package} was withdrawn"); + return; + } + // One event per wake, read off the readiness this poller saw: a second + // poller on the window's handle (`Window::poll_event`) would take the + // next readiness from this one. A handle with more to read is ready + // again at the next watch. + if !shown { + continue; + } + let answer = match window.recv_event() { + Event::Close => Some(Reply::Skip), + Event::Resized => { + fb = window.framebuffer(); + None + } + Event::KeyInput(key) => question.key(&window.press(key)), + Event::MouseInput(mouse) => question.mouse(&mouse, font), + _ => continue, + }; + if let Some(answer) = answer { + reply(conn, &package, answer); + return; + } + render(&fb, font, &question); + window.present(); + } +} + +fn reply(conn: &Connection, package: &str, answer: Reply) { + if let Err(e) = conn.try_send_bytes(consent::MSG_REPLY, &answer.encode()) { + eprintln!("filepicker: the answer about {package} did not reach the supervisor ({e:?})"); + } +} + +/// The first frame on a connection off `consent`, read whole: the question, +/// or why it is none. +pub fn question(payload: &[u8], msg_type: u32) -> Result { + if msg_type != consent::MSG_ASK { + return Err(format!("message {msg_type} is no question")); + } + if payload.len() > consent::MAX_ASK { + return Err(format!("it is longer than the {} bytes any question is", consent::MAX_ASK)); + } + Ask::decode(payload).ok_or_else(|| "it is not one the protocol has".to_string()) +} diff --git a/userland/filepicker/src/main.rs b/userland/filepicker/src/main.rs index 69ff25bd2a7..d558749baa6 100644 --- a/userland/filepicker/src/main.rs +++ b/userland/filepicker/src/main.rs @@ -1,3 +1,5 @@ +mod consent; + use filepicker_api::{ PickerMode, MAX_REQUEST_BYTES, MSG_FILEPICKER_REQUEST, MSG_FILEPICKER_RESULT, }; @@ -37,6 +39,7 @@ const KEY_BACKSPACE: u8 = 0x2A; const KEY_ENTER: u8 = 0x28; const KEY_TAB: u8 = 0x2B; const KEY_ESCAPE: u8 = 0x29; +const KEY_SPACE: u8 = 0x2C; // --- Directory entry --- @@ -483,12 +486,16 @@ struct Pending { conn: Connection, rx: RequestRx, since: Instant, + /// It came in on `consent`, so it is the supervisor's question. + consent: bool, } const TOKEN_ACCEPTOR: u64 = 0; -const TOKEN_PENDING_BASE: u64 = 1; +const TOKEN_CONSENT_ACCEPTOR: u64 = 1; +const TOKEN_PENDING_BASE: u64 = 2; -/// Serve `filepicker` for the rest of the machine's life. +/// Serve `filepicker`, and the supervisor's questions on `consent` +/// ([`consent`]), for the rest of the machine's life. /// /// **A server never blocks on a client.** Accept and the request frame are two /// events and a frame is buffered until whole before anything acts on it, so a @@ -502,12 +509,17 @@ fn main() { // instruction. let acceptor = endow::acceptor("filepicker") .expect("the manifest declares this program serves `filepicker`"); + let questions = endow::acceptor(toyos_manifest::consent::PORT) + .expect("the manifest declares this program serves `consent`"); + let font_data = fs::read("/system/share/fonts/JetBrainsMono-Regular-8x16.font").expect("Failed to load font"); + let font = Font::from_prebuilt(&font_data); - let poller = Poller::new(1 + MAX_PENDING_REQUESTS as u32); + let poller = Poller::new(2 + MAX_PENDING_REQUESTS as u32); let mut pending: Vec = Vec::new(); let mut ready: Vec = Vec::new(); loop { poller.watch(&acceptor, READABLE, TOKEN_ACCEPTOR); + poller.watch(&questions, READABLE, TOKEN_CONSENT_ACCEPTOR); for p in &pending { poller.watch(&p.conn, READABLE, TOKEN_PENDING_BASE + p.conn.as_handle().0 as u64); } @@ -531,7 +543,11 @@ fn main() { } pending.retain(|p| now.duration_since(p.since) < HANDSHAKE_TIMEOUT); - if ready.contains(&TOKEN_ACCEPTOR) { + for (token, question) in [(TOKEN_ACCEPTOR, false), (TOKEN_CONSENT_ACCEPTOR, true)] { + if !ready.contains(&token) { + continue; + } + let acceptor = if question { &questions } else { &acceptor }; // An accept that fails costs one connection and never the picker. match acceptor.accept() { Err(e) => eprintln!("filepicker: a connection could not be accepted ({e:?})"), @@ -540,9 +556,7 @@ fn main() { already waiting to say what they want", conn.as_handle().0 ), - Ok(conn) => { - pending.push(Pending { conn, rx: RequestRx::new(), since: Instant::now() }) - } + Ok(conn) => pending.push(Pending { conn, rx: RequestRx::new(), since: Instant::now(), consent: question }), } } @@ -576,7 +590,12 @@ fn main() { } RxStep::Frame { msg_type, payload_len } => { let p = pending.remove(i); - if msg_type == MSG_FILEPICKER_REQUEST { + if p.consent { + match consent::question(p.rx.payload(payload_len), msg_type) { + Ok(ask) => consent::ask(ask, &p.conn, &font), + Err(why) => eprintln!("filepicker: dropping the supervisor's question — {why}"), + } + } else if msg_type == MSG_FILEPICKER_REQUEST { let data = p.rx.payload(payload_len); let mode = if data.first() == Some(&(PickerMode::Save as u8)) { PickerMode::Save diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 1269b9dda07..857589bc2d6 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -80,7 +80,8 @@ use std::time::{Duration, Instant}; use toyos_swap::{Refusal, Request as SwapRequest, Word}; -use toyos_manifest::grants::{self, Store}; +use toyos_manifest::consent; +use toyos_manifest::grants::{self, Answer, Decision, Store}; use toyos_manifest::launch::{self as authority, Authority, Session, Sessions, Target}; use toyos_manifest::package::{self, Package}; use toyos_manifest::{Manifest, Program, View}; @@ -172,8 +173,9 @@ struct Minted { grants: Acceptor, } -/// The poll tokens for the `launcher`, `swap`, `power` and `grants` acceptors, and for the -/// one connection whose swap has been answered and whose hang-up is awaited. A +/// The poll tokens for the `launcher`, `swap`, `power` and `grants` acceptors, for the +/// one connection whose swap has been answered and whose hang-up is awaited, and +/// for the two of the launch waiting on an answer. A /// pending connection's token is [`TOKEN_PENDING_BASE`] plus its handle, which /// is unique among the connections the supervisor holds at once. const TOKEN_ACCEPTOR: u64 = 0; @@ -182,7 +184,10 @@ const TOKEN_HANGUP: u64 = 2; const TOKEN_POWER_ACCEPTOR: u64 = 3; const TOKEN_WAKE: u64 = 4; const TOKEN_GRANTS_ACCEPTOR: u64 = 5; -const TOKEN_PENDING_BASE: u64 = 6; +/// The parked launch's caller, and its question's connection ([`Parked`]). +const TOKEN_PARKED_CALLER: u64 = 6; +const TOKEN_CONSENT: u64 = 7; +const TOKEN_PENDING_BASE: u64 = 8; /// How long a stop waits for `logkeeper` to say the log is whole. /// @@ -433,11 +438,20 @@ fn main() { let grants_port = acceptors .remove(grants::PORT) .expect("supervisor: the manifest declares the supervisor serves `grants`"); + // The supervisor alone holds a connector to it, so a connection on it is + // the supervisor's question and nobody else's. + let consent = connectors.remove(consent::PORT).map(|connector| { + namespace::build() + .add(consent::PORT, &connector) + .finish() + .expect("supervisor: no namespace for the consent port") + }); let (wake_read, wake_write) = toyos::pipe_pair().expect("supervisor: no pipe to hear a service end"); let mut supervisor = Supervisor { system, minted: Minted { launcher, grants: grants_port }, store: Store::default(), + consent, syscap: &syscap, acceptors, connectors, @@ -541,8 +555,11 @@ struct Supervisor<'a> { /// What every launcher and every grants connector is minted on, and every /// launch and grants request accepted from. minted: Minted, - /// Every grant of a folder of the home, as [`grants::STORE`] holds them. + /// Every answer about a folder of the home, as [`grants::STORE`] holds them. store: Store, + /// [`consent::PORT`], where its server runs: what a launch asks the person at + /// the screen on. + consent: Option, syscap: &'a SysCap, /// The `serves` acceptors nobody has been started with yet, which a launch /// takes by move. @@ -892,7 +909,7 @@ impl<'a> Supervisor<'a> { /// and the machine's session is every service's. fn grant(&mut self, caller: &Caller, msg_type: u32, payload: &[u8]) -> Result { let request = grants::Request::decode(msg_type, payload).ok_or("the request is not one this port reads")?; - if !matches!(caller.session, Session::Login(_)) { + if !matches!(caller.session, Session::Login(..)) { return Err("only a login session may ask for grants".to_string()); } let user = toyos_manifest::USER; @@ -918,7 +935,7 @@ impl<'a> Supervisor<'a> { binary_digest(&program) })??; let said = format!("{package} is granted {} {}", folder.path, folder.access); - store.put(grants::Entry { user: user.to_string(), package, binary, folder })?; + store.put(grants::Entry { user: user.to_string(), package, binary, answer: Answer::Granted(folder) })?; said } }; @@ -1032,9 +1049,10 @@ impl<'a> Supervisor<'a> { /// service it has just killed to finish ending, which is the kernel's /// teardown and no client's. fn serve_forever(&mut self, swap: &Acceptor, power: &Acceptor) -> ! { - let poller = Poller::new(6 + MAX_PENDING_LAUNCHES as u32); + let poller = Poller::new(8 + MAX_PENDING_LAUNCHES as u32); let mut pending: Vec = Vec::new(); let mut flight: Option = None; + let mut parked: Option = None; let mut ready: Vec = Vec::new(); loop { poller.watch(&self.minted.launcher, READABLE, TOKEN_ACCEPTOR); @@ -1045,6 +1063,10 @@ impl<'a> Supervisor<'a> { if let Some(Flight { phase: Phase::Answered { conn, .. }, .. }) = &flight { poller.watch(conn, READABLE, TOKEN_HANGUP); } + if let Some(asked) = &parked { + poller.watch(&asked.launch.conn, READABLE, TOKEN_PARKED_CALLER); + poller.watch(&asked.consent, READABLE, TOKEN_CONSENT); + } for p in &pending { poller.watch(&p.conn, READABLE, TOKEN_PENDING_BASE + p.conn.as_handle().0 as u64); } @@ -1139,7 +1161,8 @@ impl<'a> Supervisor<'a> { let p = pending.remove(i); match p.port { Port::Launcher(caller) => { - self.serve_launch(&p.conn, &caller, msg_type, p.rx.payload(payload_len)) + let payload = p.rx.payload(payload_len).to_vec(); + self.serve_launch(p.conn, &caller, msg_type, &payload, &mut parked) } Port::Grants(caller) => self.serve_grants(&p.conn, &caller, msg_type, p.rx.payload(payload_len)), Port::Power => self.stop(&p.conn, msg_type), @@ -1169,6 +1192,8 @@ impl<'a> Supervisor<'a> { pending.retain(|p| now.duration_since(p.since) < HANDSHAKE_TIMEOUT); flight = flight.and_then(|f| self.advance(f, ready.contains(&TOKEN_HANGUP))); + let (caller_ready, consent_ready) = (ready.contains(&TOKEN_PARKED_CALLER), ready.contains(&TOKEN_CONSENT)); + parked = parked.and_then(|asked| self.advance_parked(asked, caller_ready, consent_ready)); if ready.contains(&TOKEN_WAKE) { let mut sink = [0u8; 64]; @@ -1603,7 +1628,8 @@ fn forget(path: &str) { } impl Supervisor<'_> { - /// One `MSG_LAUNCH`, from the frame to the `Process` handle that answers it. + /// One `MSG_LAUNCH`, from the frame to the `Process` handle that answers it, + /// or to the question an installed package's launch waits on ([`Parked`]). /// /// **Everything in the request is a client's claim about itself; `caller` is /// not.** A program nothing declares is refused by name; a frame that does @@ -1613,7 +1639,14 @@ impl Supervisor<'_> { /// connectors the caller transferred, and the caller could only transfer /// what it already had — so a launch confers exactly the manifest row and /// nothing beyond it. - fn serve_launch(&mut self, conn: &Connection, caller: &Caller, msg_type: u32, payload: &[u8]) { + fn serve_launch( + &mut self, + conn: Connection, + caller: &Caller, + msg_type: u32, + payload: &[u8], + parked: &mut Option, + ) { if msg_type != launch::MSG_LAUNCH { return; } @@ -1648,17 +1681,18 @@ impl Supervisor<'_> { let (extra_handles, place_handle) = rest.split_at(request.extra_count); let slots = Moved(slot_handles.to_vec()); let place = Moved(place_handle.to_vec()); - // Owned, so they close when this call returns: `SYS_NAMESPACE_BUILD` copies - // a connector into the namespace and leaves the caller's handle, and the supervisor's - // copy of a client's connector has no life beyond this launch. - let extras: Vec<(&str, Connector)> = names + // Owned, so they close when the launch is done with: `SYS_NAMESPACE_BUILD` + // copies a connector into the namespace and leaves the caller's handle, + // and the supervisor's copy of a client's connector has no life beyond + // this launch. + let extras: Vec<(String, Connector)> = names .into_iter() .zip(extra_handles.iter().copied()) // SAFETY: the kernel moved these into the supervisor's table with the frame, and // nothing else answers for them. **Not a claim about the type** — a // client sends what it likes, and everything below treats a wrong one // as a refused launch rather than as the supervisor's own bug. - .map(|(name, handle)| (name, unsafe { Connector::from_raw(handle) })) + .map(|(name, handle)| (name.to_string(), unsafe { Connector::from_raw(handle) })) .collect(); // std joins a relative `current_dir` onto the supervisor's own cwd, so passing one @@ -1715,16 +1749,22 @@ impl Supervisor<'_> { // read off its package, and the program and its working directory may // be a file server's, which the supervisor supervises. Whether the // caller may start it is asked between the two, so a refused launch - // reads no image and judges no directory. A package's binary is - // hashed there too: its grant is keyed on it. + // reads no image and judges no directory. **A package's grant is keyed + // on the bytes its spawn starts**: the image `prepare` read, hashed + // where it lies, with no second read between the hash and the start. let (system, path) = (self.system, request.program.to_string()); let (row, session) = (caller.row, caller.session); let found = self.files("a launch's files", move || { resolve(system, &path, |target| -> Result<_, authority::Refusal> { let starts = authority::may_start(row, session, target)?; let (Target::Row(program) | Target::Package(program)) = target; - let binary = matches!(target, Target::Package(_)).then(|| binary_digest(&program.path)); let prepared = command.image_from(Path::new(&program.path)).prepare().map(drop); + let binary = matches!(target, Target::Package(_)).then(|| { + command + .prepared_image() + .map(|bytes| toyos_swap::hex(&toyos_swap::digest(bytes))) + .ok_or_else(|| format!("{} was not read before its start, so no grant can name it", program.path)) + }); Ok((starts, binary, prepared.map(|()| command))) }) }); @@ -1736,7 +1776,7 @@ impl Supervisor<'_> { return; } }; - let (mut program, verdict) = match resolved { + let (program, verdict) = match resolved { Resolved::Row(row, verdict) => (row.clone(), verdict), Resolved::Package(row, verdict) => (row, verdict), Resolved::NotDeclared => { @@ -1764,7 +1804,7 @@ impl Supervisor<'_> { return; } }; - let mut command = match prepared { + let command = match prepared { Ok(command) => command, Err(e) => { say!("supervisor: launcher: cannot start {}: {e}", program.name); @@ -1772,23 +1812,177 @@ impl Supervisor<'_> { return; } }; - if let Some(binary) = binary { - match self.granted(&program.name, binary, command, request.cwd) { - Ok((granted, prepared)) => { - program.folder = granted; - command = prepared; + let binary = match binary.transpose() { + Ok(binary) => binary, + Err(why) => { + say!("supervisor: launcher: {} was not started: {why}", program.name); + let _ = conn.try_signal(launch::MSG_REFUSED); + return; + } + }; + let session = starts.session(&mut self.sessions); + let caller_slots: Vec<(u32, toyos::RawHandle)> = + request.slot_numbers().zip(slots.0.iter().copied()).collect(); + let launch = Launch { + conn, + program, + command, + session, + slots, + caller_slots, + _place: place, + placed: under.is_some(), + extras, + cwd: request.cwd.to_string(), + }; + let Some(binary) = binary else { + return self.start_launch(launch, None); + }; + let stored = self.store.find(toyos_manifest::USER, &launch.program.name); + match grants::decide(stored, &binary, self.system.folder, session.at_screen()) { + Decision::Start(folder) => { + if folder.is_none() && self.system.folder.is_some() { + let name = &launch.program.name; + match stored.filter(|entry| entry.binary == binary).map(|entry| &entry.answer) { + Some(Answer::Denied) => { + say!("supervisor: launcher: {name} is denied a folder; `grants revoke {name}` asks again") + } + _ => say!("supervisor: launcher: {name} holds no folder; `grants add {name} ` grants it one"), + } } + self.start_launch(launch, folder); + } + Decision::Ask => self.ask(launch, binary, parked), + } + } + + /// Ask the person at the screen about `launch`, and park it until they + /// answer ([`Parked`]). One question is up at a time: a launch that would + /// ask a second is refused by name. A machine with no consent server to + /// ask starts it with no folder. + fn ask(&mut self, launch: Launch, binary: String, parked: &mut Option) { + let name = launch.program.name.clone(); + if let Some(up) = parked { + say!("supervisor: consent: {name} was not started: the question about {} is up", up.launch.program.name); + let _ = launch.conn.try_signal(launch::MSG_REFUSED); + return; + } + let access = self.system.folder.expect("a launch is asked only under a ceiling"); + let asked = self + .consent + .as_ref() + .ok_or_else(|| format!("this image serves no `{}`", consent::PORT)) + .and_then(|names| names.open(consent::PORT).map_err(|e| format!("its server is gone ({e:?})"))) + .and_then(|conn| { + let ask = consent::Ask { package: name.clone(), access }; + conn.try_send_bytes(consent::MSG_ASK, &ask.encode()) + .map(|()| conn) + .map_err(|e| format!("the question could not be sent ({e:?})")) + }); + match asked { + Ok(consent) => { + say!("supervisor: consent: {name} asks for a folder {access}; the question is up"); + *parked = Some(Parked { launch, binary, consent, rx: ipc::FrameRx::new(), hangup: ipc::FrameRx::new() }); + } + Err(why) => { + say!("supervisor: consent: {name} starts with no folder: nobody was asked, {why}"); + self.start_launch(launch, None); + } + } + } + + /// The parked launch moved on by what woke the loop: withdrawn when its + /// caller hung up, started on the answer, or started with no folder when + /// the consent server ended or said something no answer is. + fn advance_parked(&mut self, parked: Parked, caller_ready: bool, consent_ready: bool) -> Option { + let Parked { launch, binary, consent, mut rx, mut hangup } = parked; + let name = launch.program.name.clone(); + // The caller first: a launch nobody waits for any more is not started. + if caller_ready && !matches!(hangup.pump(&launch.conn), RxStep::Idle) { + say!("supervisor: consent: the question about {name} is withdrawn: its caller hung up"); + return None; + } + if !consent_ready { + return Some(Parked { launch, binary, consent, rx, hangup }); + } + let reply = match rx.pump(&consent) { + RxStep::Idle => return Some(Parked { launch, binary, consent, rx, hangup }), + RxStep::Frame { msg_type: consent::MSG_REPLY, payload_len } if payload_len > consent::MAX_REPLY => { + Err(format!("its answer is longer than the {} bytes any answer is", consent::MAX_REPLY)) + } + RxStep::Frame { msg_type: consent::MSG_REPLY, payload_len } => consent::Reply::decode(rx.payload(payload_len)) + .ok_or_else(|| "its answer is not one the protocol has".to_string()), + RxStep::Frame { msg_type, .. } => Err(format!("it sent message {msg_type}, which is no answer")), + RxStep::Eof => Err("the consent server ended before it answered".to_string()), + RxStep::Malformed => Err("it sent a frame the protocol cannot describe".to_string()), + }; + drop(consent); + let reply = reply.and_then(|reply| self.judged(reply)); + let reply = match reply { + Ok(reply) => reply, + Err(why) => { + say!("supervisor: consent: {name} starts with no folder: {why}"); + consent::Reply::Skip + } + }; + say!("supervisor: consent: {name}: {}", said(&reply)); + if let Some(answer) = reply.kept() { + if let Err(why) = self.keep(&name, binary, answer) { + say!("supervisor: consent: {name}'s answer is not kept, so its next launch asks again: {why}"); + } + } + self.start_launch(launch, reply.folder().cloned()); + None + } + + /// `reply`, its folder held to the rules a stored grant is ([`grants::folder`]) + /// and to the image's ceiling: the consent server is a program like any. + fn judged(&self, reply: consent::Reply) -> Result { + if let Some(folder) = reply.folder() { + grants::folder(&folder.path)?; + let ceiling = self.system.folder.expect("a launch is asked only under a ceiling"); + if folder.access > ceiling { + return Err(format!("{} is past this image's {ceiling}", folder.access)); + } + } + Ok(reply) + } + + /// `answer` stored for the session user's `package` at `binary`, in place + /// of any other. + fn keep(&mut self, package: &str, binary: String, answer: Answer) -> Result<(), String> { + let mut store = self.store.clone(); + store.put(grants::Entry { user: toyos_manifest::USER.to_string(), package: package.to_string(), binary, answer })?; + let text = store.render(); + self.files("the grants", move || write_store(&text))??; + self.store = store; + Ok(()) + } + + /// Start `launch`, holding `folder` and in it ([`grants::cwd`]) where it + /// holds one, and answer its caller. + fn start_launch(&mut self, launch: Launch, folder: Option) { + let Launch { conn, mut program, mut command, session, slots, caller_slots, _place, placed, extras, cwd } = launch; + if let Some(folder) = folder { + // The image `prepare` read is kept: only the working directory is + // judged again. + let (path, cwd) = (folder.path.clone(), grants::cwd(&folder.path, &cwd)); + let prepared = self.files("a granted folder", move || -> Result { + is_folder(&path)?; + command.current_dir(&cwd).prepare().map_err(|e| format!("{cwd} is no working directory: {e}"))?; + Ok(command) + }); + match prepared.and_then(|prepared| prepared) { + Ok(prepared) => command = prepared, Err(why) => { say!("supervisor: launcher: {} was not started: {why}", program.name); let _ = conn.try_signal(launch::MSG_REFUSED); return; } } + program.folder = Some(folder); } let program = &program; - let session = starts.session(&mut self.sessions); - let caller_slots: Vec<(u32, toyos::RawHandle)> = - request.slot_numbers().zip(slots.0.iter().copied()).collect(); // `inherit_handle` duplicates into the child, so the supervisor's own copies go with // `slots` when this returns. @@ -1813,6 +2007,7 @@ impl Supervisor<'_> { (&self.minted, session), Output::Launch { log: &mut self.log, slots: &caller_slots }, ); + drop(slots); match started { Ok((child, _)) => { // SAFETY: `into_raw_handle` gave up the child's one handle in this table. @@ -1829,7 +2024,7 @@ impl Supervisor<'_> { // here answers it: the command is prepared, so its spawn calls no // file server, and every refusal `start` makes before the spawn is // `Other`. - Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe && under.is_some() => { + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe && placed => { say!("supervisor: launcher: {} was not started: the place it names is ending", program.name); let _ = conn.try_signal(launch::MSG_GONE); } @@ -1841,32 +2036,54 @@ impl Supervisor<'_> { } } -impl Supervisor<'_> { - /// The folder package `name`'s launch of `binary` is granted - /// ([`grants::decide`]), and `command` prepared again to start in it - /// ([`grants::cwd`]) once it is still a directory. `Err` refuses the - /// launch: a grant whose folder is gone names nothing. - fn granted( - &mut self, - name: &str, - binary: Result, - mut command: Command, - cwd: &str, - ) -> Result<(Option, Command), String> { - let binary = binary?; - let Some(folder) = grants::decide(self.store.find(toyos_manifest::USER, name), &binary, self.system.folder) else { - if self.system.folder.is_some() { - say!("supervisor: launcher: {name} holds no folder; `grants add {name} ` grants it one"); - } - return Ok((None, command)); - }; - let (path, cwd) = (folder.path.clone(), grants::cwd(&folder.path, cwd)); - let prepared = self.files("a granted folder", move || -> Result { - is_folder(&path)?; - command.current_dir(&cwd).prepare().map_err(|e| format!("{cwd} is no working directory: {e}"))?; - Ok(command) - })??; - Ok((Some(folder), prepared)) +/// A launch decided up to whether it asks: everything its start needs, owned, +/// so it can wait for an answer ([`Parked`]) as well as start at once. +struct Launch { + /// The caller's, answered with the process or the refusal. + conn: Connection, + program: Program, + /// Prepared: its image read and hashed, its working directory judged. + command: Command, + session: Session, + /// The caller's stdio and extra slots, owned until the spawn duplicates them. + slots: Moved, + caller_slots: Vec<(u32, toyos::RawHandle)>, + /// The place the child is spawned under, owned until then. + _place: Moved, + /// Whether it names a place, or the supervisor. + placed: bool, + extras: Vec<(String, Connector)>, + /// The caller's working directory, which a granted folder's launch keeps + /// where it lies inside the folder. + cwd: String, +} + +/// A launch waiting on the person at the screen ([`toyos_manifest::consent`]). +/// +/// **Ended by events and never by a clock**: the person may take minutes. The +/// caller hanging up withdraws it and closes the question's connection, which +/// takes the prompt down; the consent server's answer starts it; the consent +/// server ending, or saying anything else, starts it with no folder. +struct Parked { + launch: Launch, + /// The SHA-256 of the image it starts, which a kept answer is keyed on. + binary: String, + /// The question's connection, which the supervisor alone holds. + consent: Connection, + /// One byte past the longest answer, so a longer one is refused and never + /// read as the prefix kept. + rx: ipc::FrameRx<{ consent::MAX_REPLY + 1 }>, + /// The caller's connection after its frame: anything on it is its hang-up. + hangup: ipc::FrameRx<8>, +} + +/// The supervisor's word for an answer, in its line in the log. +fn said(reply: &consent::Reply) -> String { + match reply { + consent::Reply::Once(folder) => format!("once, {} {}", folder.path, folder.access), + consent::Reply::Always(folder) => format!("always, {} {}", folder.path, folder.access), + consent::Reply::Deny => "denied, until `grants revoke`".to_string(), + consent::Reply::Skip => "skipped, so its next launch asks again".to_string(), } } @@ -2106,7 +2323,7 @@ fn start<'a>( served: Served<'_, 'a>, connectors: &BTreeMap<&str, Connector>, grants: &Grants<'_>, - extras: &[(&str, Connector)], + extras: &[(String, Connector)], storage: Storage, launcher: (&Minted, Session), output: Output<'_>, @@ -2436,7 +2653,7 @@ fn build_namespace( system: &Manifest, connectors: &BTreeMap<&str, Connector>, view: Vec<(String, Connector)>, - extras: &[(&str, Connector)], + extras: &[(String, Connector)], ) -> std::io::Result> { // Never the swap port nor the grants port: [`swap_namespace`] and // [`grants_namespace`] say why. diff --git a/userland/toyos-window/src/lib.rs b/userland/toyos-window/src/lib.rs index 393343c75d3..2365c69d3ef 100644 --- a/userland/toyos-window/src/lib.rs +++ b/userland/toyos-window/src/lib.rs @@ -23,6 +23,9 @@ pub use toyos_keymap::{Emit, Mods, Translator}; // Window flags pub const WINDOW_FLAG_TOPMOST: u8 = 1; +/// The compositor's port every window but a prompt is asked for on. +const COMPOSITOR: &str = "compositor"; + /// The largest payload a client sends inline; past it a region moves instead. /// /// One number both ends read: `ipc::FrameRx` discards whatever a frame @@ -451,7 +454,7 @@ pub fn clipboard_set(text: &str) -> Result<(), CopyError> { /// [`clipboard_set`]'s exchange, for text the clipboard holds. fn copy(bytes: &[u8]) -> Result<(), CreateError> { - let conn = endow::service("compositor")?; + let conn = endow::service(COMPOSITOR)?; if bytes.len() <= MAX_INLINE_PAYLOAD { return Ok(conn.send_bytes(MSG_CLIPBOARD_SET, bytes)?); } @@ -492,20 +495,28 @@ impl Window { } pub fn create_with_title(width: u32, height: u32, title: &str) -> Result { - Self::create_with_flags(width, height, title, 0) + Self::create_with_flags(COMPOSITOR, width, height, title, 0) } pub fn create_topmost(width: u32, height: u32, title: &str) -> Result { - Self::create_with_flags(width, height, title, WINDOW_FLAG_TOPMOST) + Self::create_with_flags(COMPOSITOR, width, height, title, WINDOW_FLAG_TOPMOST) + } + + /// A window asked for on the compositor's port named `port` in this + /// program's namespace rather than on `compositor`: which port a window + /// came in on is what the compositor places it by. + pub fn create_on(port: &str, width: u32, height: u32, title: &str) -> Result { + Self::create_with_flags(port, width, height, title, 0) } fn create_with_flags( + port: &str, width: u32, height: u32, title: &str, flags: u8, ) -> Result { - let conn = endow::service("compositor")?; + let conn = endow::service(port)?; let mut req = CreateWindowRequest { width, From b4bb07e787a3b041383902c86b206b4c4c6361f3 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 10:15:31 +0200 Subject: [PATCH 06/12] consent_prompt clicks the launcher row once the launcher is on the panel Under a load average near 57 the two clicks, `+` then the row, were read by the compositor at once and folded into one at the row's place, on a closed launcher, so no launch was made and the run timed out (mutation arm G7 at d9c6ec19f). The fold is filed as issues/two-clicks-the-compositor-reads-at-once-are-one-click.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...-compositor-reads-at-once-are-one-click.md | 26 +++++++++++++++++++ tests/toyos.rs | 20 +++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 issues/two-clicks-the-compositor-reads-at-once-are-one-click.md diff --git a/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md b/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md new file mode 100644 index 00000000000..df918fbd612 --- /dev/null +++ b/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-10-10 +--- + +# Two clicks the compositor reads at once are one click + +`toyos_desktop::fold_mouse` folds a whole read of the mouse claim into one +sample: whether the left button went down and came up somewhere in it, and the +last position. Two clicks in one read, at two places, are therefore one press +and one release, both at the second click's position, and the first click is +lost. A person clicking `+` and then a launcher row quickly enough for one +read to carry both, which a loaded machine makes likelier, has the row's +click land on a closed launcher. + +`consent_prompt` (`tests/toyos.rs`) met it on a loaded host (load average +57 when its batch of runs ended): it clicked `+` and then the launcher row +with nothing between them, and no launch was made, which these two clicks +folded into one on the row of a launcher not yet open accounts for. The test +now waits for the launcher on the panel before its second click. + +## Exit condition + +A host test of `fold_mouse` (or what replaces it) in which one read carrying +two clicks at two positions yields both, each at its own position. diff --git a/tests/toyos.rs b/tests/toyos.rs index 8f2c3c6e78a..f9ee901ae48 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -3414,9 +3414,27 @@ impl Consent { }; let bar = desk.taskbar(1); let centre = |r: toyos_desktop::Rect| (i32::midpoint(r.x0, r.x1), i32::midpoint(r.y0, r.y1)); + // **The row is clicked once the launcher is on the panel**: the + // compositor folds two clicks it reads at once into one at + // the second's place (issues/two-clicks-the-compositor-reads-at-once-are-one-click.md). + let row = bar.launcher_item(0); + let shown = |dump: &screen::Ppm| -> Vec<[u8; 3]> { + (row.y0 as usize..row.y1 as usize) + .flat_map(|y| dump.pixels[y * dump.width + row.x0 as usize..y * dump.width + row.x1 as usize].to_vec()) + .collect() + }; let mut input = qemu::QmpInput::open(qmp); + let closed = shown(&input.screendump(&self.out)); input.click(centre(bar.new_button()), (w, h)); - input.click(centre(bar.launcher_item(0)), (w, h)); + let deadline = Instant::now() + Duration::from_secs(20); + while shown(&input.screendump(&self.out)) == closed { + if Instant::now() >= deadline { + self.red.push(format!("{name}: the launcher did not open in 20 s")); + return; + } + thread::sleep(Duration::from_millis(50)); + } + input.click(centre(row), (w, h)); self.step = Some(ConsentStep { name, games, ..ConsentStep::default() }); } } From 1a1e8dc999ceff0cf0dbe92d4f05980b7c5386da Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 11:52:02 +0200 Subject: [PATCH 07/12] The supervisor's header says a package's launch may wait for the person at the screen Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- userland/supervisor/src/main.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 857589bc2d6..ae13a454b89 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -55,9 +55,12 @@ //! the caller's direct spawn carries in place of its own. //! //! **An installed package's launch also holds the folder of the home it was -//! granted** ([`toyos_manifest::grants`]): the grant stored for that exact -//! binary, which the supervisor alone writes, at the request of the one row -//! holding the `grants` port from a login session, and starts in it. +//! granted** ([`toyos_manifest::grants`]): the answer stored for the exact +//! bytes it starts, which the supervisor alone writes, at the request of the +//! one row holding the `grants` port from a login session or as the person at +//! the screen's answer, and starts in it. A launch with no stored answer in a +//! session that holds the screen waits for that answer ([`Parked`]), asked on +//! [`toyos_manifest::consent::PORT`], which only the supervisor connects to. //! //! **A launch starts only what its caller's row lists** ([`toyos_manifest::launch`]). //! Every row whose `starts` lists anything is endowed a `launcher` the supervisor From 40fa6c9955c292c6d8377c075994925ce47f14ff Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 12:31:50 +0200 Subject: [PATCH 08/12] One judgement of a consent answer and one path to the grants store, host-tested Review round 3 of #819. - consent::judge decides what the supervisor honours of the consent server's frame: its type, its length past MAX_REPLY, its decoding, and its folder under grants::admit. grants::admit is the folder rules and the image's ceiling, the one rule both a command's grant and the person's answer are held to. The supervisor's private `judged` and its length arm go; a host test covers a past-ceiling folder, an Apps folder, a non-canonical one, one outside the home, the home itself, an over-long reply and another message. - consent::settle decides a parked launch's next step from what its caller and the server said: a hang-up withdraws it before any answer. The supervisor maps its two FrameRx steps to consent::Heard and acts. - Supervisor::keep is the one way an answer reaches the store: it admits a granted folder, checks on the worker that it is a directory and not a link, then puts and commits. `grants add` and the person's Always both call it, so an Always whose folder was replaced is not stored, as a command's grant of it never was. - `installed` returns the package's row and `resolve` calls it, in place of its own copy of the package half. - load_grants and read_binary read through their bound with take: a file grown between the size check and the read is refused, never read whole. - The guest job checks that `grants add` of a file in the home is refused by name and nothing is stored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- tests/toyos-rust-tests/src/bin/consent.rs | 21 ++- toyos-manifest/src/consent.rs | 127 ++++++++++++++- toyos-manifest/src/grants.rs | 13 ++ userland/supervisor/src/main.rs | 185 +++++++++++----------- 4 files changed, 249 insertions(+), 97 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/consent.rs b/tests/toyos-rust-tests/src/bin/consent.rs index 56b6c9c4b72..1bc07797850 100644 --- a/tests/toyos-rust-tests/src/bin/consent.rs +++ b/tests/toyos-rust-tests/src/bin/consent.rs @@ -27,7 +27,8 @@ //! //! First, std's half of the hash being of what runs: a prepared spawn starts //! the bytes its prepare read, past a later working directory and a rewrite -//! of the file ([`prepared`]). +//! of the file ([`prepared`]); and a grant of a file is refused and never +//! kept ([`not_a_folder`]). //! //! The package is this binary installed as `consentee`, which, started under //! that name, does what `app_view`'s `game` does: list the ROMs in its @@ -82,6 +83,7 @@ fn job() { let mut red = Vec::new(); prepared(&mut red); + not_a_folder(&mut red); let mut hostile = Command::new(SELF) .arg("hostile") .stdout(Stdio::piped()) @@ -180,6 +182,23 @@ fn prepared(red: &mut Vec) { let _ = fs::remove_file(©); } +/// **A grant is kept only of a folder**: one of a file the folder rules +/// admit is refused by name and nothing is stored. The supervisor holds the +/// person's Always to the same check, in the one function that stores both. +fn not_a_folder(red: &mut Vec) { + let note = format!("{HOME}/Note"); + fs::write(¬e, b"a file, not a folder").expect("write a file in the home"); + let out = in_login(&format!("/system/bin/grants add consentee {note}")); + let said = String::from_utf8_lossy(&out.stdout); + let refused = format!("{note} is no directory"); + let kept = fs::read_to_string(STORE).unwrap_or_default().lines().any(|l| l.contains(" consentee ")); + match (said.contains(&refused), kept) { + (true, false) => println!(" a grant of a file was refused and nothing was kept"), + _ => red.push(format!("a grant of {note}: the supervisor said {said:?}, and the store holds a line for it: {kept}")), + } + let _ = fs::remove_file(¬e); +} + /// Ask the host for `step`, and wait for its F12. fn step(said: &mut Lines>, red: &mut Vec, step: &str) { println!("CONSENT {step}"); diff --git a/toyos-manifest/src/consent.rs b/toyos-manifest/src/consent.rs index 6a121402e18..781ad4cd342 100644 --- a/toyos-manifest/src/consent.rs +++ b/toyos-manifest/src/consent.rs @@ -104,7 +104,7 @@ impl Reply { } /// `None` for bytes [`encode`](Self::encode) cannot have written. The path - /// is not judged here: the supervisor holds it to [`crate::grants::folder`]. + /// is not judged here: [`judge`] holds it to [`crate::grants::admit`]. pub fn decode(payload: &[u8]) -> Option { let (&kind, rest) = payload.split_first()?; let folder = || { @@ -138,6 +138,63 @@ impl Reply { } } +/// What one end of a parked question said since it was last read. +#[derive(Clone, Copy, Debug)] +pub enum Heard<'a> { + Nothing, + /// A whole frame: its type, and its payload as the receiver kept it. + Frame(u32, &'a [u8]), + /// It hung up, or sent what no frame is: why, in words. + Ended(&'static str), +} + +/// Where a parked launch goes on what its `caller` and the consent `server` +/// said. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Settled { + /// Neither said anything: it stays parked. + Waiting, + /// Its caller hung up: it is not started, whatever was answered. + Withdrawn, + /// It starts, holding what the answer gives, or no folder for why it is + /// none ([`judge`]). + Answered(Result), +} + +/// A parked launch's next step. **The caller first**: anything on its +/// connection after its launch is its hang-up, and a launch nobody waits for +/// any more is not started, even on an answer already waiting. +pub fn settle(caller: Heard<'_>, server: Heard<'_>, ceiling: Option) -> Settled { + if !matches!(caller, Heard::Nothing) { + return Settled::Withdrawn; + } + match server { + Heard::Nothing => Settled::Waiting, + Heard::Frame(msg_type, payload) => Settled::Answered(judge(msg_type, payload, ceiling)), + Heard::Ended(why) => Settled::Answered(Err(why.to_string())), + } +} + +/// The server's frame on a question's connection, as the supervisor honours +/// it: a [`Reply`] whose folder [`crate::grants::admit`] gives under the +/// image's `ceiling`, or why it is none. `payload` is what the receiver kept, +/// a byte past [`MAX_REPLY`] at most, so a longer frame is refused rather than +/// read as the prefix kept. **The server is a program like any**: nothing it +/// says is trusted past this. +pub fn judge(msg_type: u32, payload: &[u8], ceiling: Option) -> Result { + if msg_type != MSG_REPLY { + return Err(format!("it sent message {msg_type}, which is no answer")); + } + if payload.len() > MAX_REPLY { + return Err(format!("its answer is longer than the {MAX_REPLY} bytes any answer is")); + } + let reply = Reply::decode(payload).ok_or("its answer is not one the protocol has")?; + if let Some(folder) = reply.folder() { + crate::grants::admit(folder, ceiling)?; + } + Ok(reply) +} + #[cfg(test)] mod tests { use super::*; @@ -196,6 +253,74 @@ mod tests { } } + /// **An answer is honoured only as a grant the image could give**: a + /// folder inside the home and outside `Apps`, canonical, at most the + /// ceiling, under a ceiling at all; never a frame longer than any answer, + /// nor another message. Each refusal is by name. + #[test] + fn an_answer_is_judged_as_a_grant_the_image_could_give() { + let rw = Some(Access::ReadWrite); + let ro = Some(Access::ReadOnly); + let judged = |reply: &Reply, ceiling| judge(MSG_REPLY, &reply.encode(), ceiling); + for (reply, ceiling) in [ + (Reply::Always(games(Access::ReadWrite)), rw), + (Reply::Once(games(Access::ReadOnly)), rw), + (Reply::Always(games(Access::ReadOnly)), ro), + (Reply::Deny, rw), + (Reply::Skip, None), + ] { + assert_eq!(judged(&reply, ceiling), Ok(reply.clone()), "{reply:?} under {ceiling:?}"); + } + let at = |path: &str| Folder { path: path.into(), access: Access::ReadWrite }; + for (reply, ceiling, said) in [ + (Reply::Always(games(Access::ReadWrite)), ro, "read-write is past this image's read-only"), + (Reply::Once(games(Access::ReadWrite)), ro, "read-write is past this image's read-only"), + (Reply::Once(games(Access::ReadOnly)), None, "this image grants no package a folder"), + (Reply::Always(at("/home/toy/Apps")), rw, "where every app keeps its own folder"), + (Reply::Once(at("/home/toy/Apps/gbae")), rw, "where every app keeps its own folder"), + (Reply::Always(at("/home/toy/Games/../Apps")), rw, "is not a canonical path"), + (Reply::Once(at("/home/toy//Games")), rw, "is not a canonical path"), + (Reply::Always(at("/state/supervisor")), rw, "is not inside /home/toy"), + (Reply::Once(at("/home/toy")), rw, "is the home itself"), + ] { + match judged(&reply, ceiling) { + Err(why) if why.contains(said) => {} + other => panic!("{reply:?} under {ceiling:?}: {other:?}, not {said:?}"), + } + } + // A frame past the longest answer, the receiver keeping a byte more: + // refused for its length, and not read as the answer it begins with. + let mut long = Reply::Deny.encode(); + long.resize(MAX_REPLY + 1, 0); + assert_eq!(judge(MSG_REPLY, &long, rw), Err(format!("its answer is longer than the {MAX_REPLY} bytes any answer is"))); + let mut kept = Reply::Always(games(Access::ReadWrite)).encode(); + kept.resize(MAX_REPLY + 1, b'x'); + assert_eq!(judge(MSG_REPLY, &kept, rw), Err(format!("its answer is longer than the {MAX_REPLY} bytes any answer is"))); + assert_eq!(judge(MSG_ASK, &Reply::Deny.encode(), rw), Err(format!("it sent message {MSG_ASK}, which is no answer"))); + assert_eq!(judge(MSG_REPLY, b"\x03x", rw), Err("its answer is not one the protocol has".to_string())); + } + + /// **A launch whose caller hung up is withdrawn**, an answer waiting + /// beside the hang-up included; with the caller still there, the server's + /// word decides, and silence waits. + #[test] + fn a_hang_up_withdraws_the_launch_before_any_answer() { + let rw = Some(Access::ReadWrite); + let always = Reply::Always(games(Access::ReadWrite)).encode(); + let answer = Heard::Frame(MSG_REPLY, &always); + for caller in [Heard::Ended("its caller hung up"), Heard::Frame(9, b"x")] { + for server in [Heard::Nothing, answer, Heard::Ended("the server ended")] { + assert_eq!(settle(caller, server, rw), Settled::Withdrawn, "{caller:?}, {server:?}"); + } + } + assert_eq!(settle(Heard::Nothing, Heard::Nothing, rw), Settled::Waiting); + assert_eq!(settle(Heard::Nothing, answer, rw), Settled::Answered(Ok(Reply::Always(games(Access::ReadWrite))))); + assert_eq!( + settle(Heard::Nothing, Heard::Ended("the server ended"), rw), + Settled::Answered(Err("the server ended".to_string())) + ); + } + #[test] fn the_question_says_what_the_package_may_do_in_plain_words() { let ask = |access| Ask { package: "gbae".into(), access }.words(); diff --git a/toyos-manifest/src/grants.rs b/toyos-manifest/src/grants.rs index 1c13591e588..ef3c77f29c9 100644 --- a/toyos-manifest/src/grants.rs +++ b/toyos-manifest/src/grants.rs @@ -141,6 +141,19 @@ pub fn folder(path: &str) -> Result<(), String> { Ok(()) } +/// Why `granted` may not be given under the image's `ceiling`, its +/// `[apps] folder`, as a whole sentence: a folder [`folder`] refuses, one past +/// the ceiling, or any under none. Every grant the supervisor gives or keeps, +/// by command or by the person's answer, is held to it. +pub fn admit(granted: &Folder, ceiling: Option) -> Result<(), String> { + let ceiling = ceiling.ok_or("this image grants no package a folder")?; + folder(&granted.path)?; + if granted.access > ceiling { + return Err(format!("{} is past this image's {ceiling}", granted.access)); + } + Ok(()) +} + /// What a launch is given before it starts. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Decision { diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index ae13a454b89..14d9cc040dc 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -875,18 +875,21 @@ impl<'a> Supervisor<'a> { /// [`grants::STORE`], read on the worker once DATA's server runs. A store /// refused, which any declared row can write, is said and the boot starts - /// with no grant; the next grant written replaces it. + /// with no grant; the next grant written replaces it. **Read through its + /// bound, never sized first**: a writer can grow it between the two. fn load_grants(&mut self) { let read = self.files("the grants", || -> Result, String> { - match std::fs::metadata(grants::STORE) { + use std::io::Read as _; + let file = match std::fs::File::open(grants::STORE) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(e.to_string()), - Ok(meta) if meta.len() > grants::MAX_STORE_BYTES as u64 => { - return Err(format!("it is {} bytes, past the {} its grants fill", meta.len(), grants::MAX_STORE_BYTES)); - } - Ok(_) => {} + opened => opened.map_err(|e| e.to_string())?, + }; + let mut text = String::new(); + file.take(grants::MAX_STORE_BYTES as u64 + 1).read_to_string(&mut text).map_err(|e| e.to_string())?; + match text.len() > grants::MAX_STORE_BYTES { + true => Err(format!("it is longer than the {} bytes its grants fill", grants::MAX_STORE_BYTES)), + false => Ok(Some(text)), } - std::fs::read_to_string(grants::STORE).map(Some).map_err(|e| e.to_string()) }); let store = read.and_then(|read| read).and_then(|text| text.map_or(Ok(Store::default()), |t| Store::parse(&t))); match store { @@ -915,38 +918,52 @@ impl<'a> Supervisor<'a> { if !matches!(caller.session, Session::Login(..)) { return Err("only a login session may ask for grants".to_string()); } - let user = toyos_manifest::USER; - let mut store = self.store.clone(); let done = match request { - grants::Request::List => return Ok(store.entries().iter().map(grants::listed).collect()), + grants::Request::List => return Ok(self.store.entries().iter().map(grants::listed).collect()), grants::Request::Revoke { package } => { - if !store.revoke(user, &package) { + let mut store = self.store.clone(); + if !store.revoke(toyos_manifest::USER, &package) { return Err(format!("{package} holds no grant")); } + self.commit(store)?; format!("{package}'s grant is revoked; its next launch holds no folder") } grants::Request::Add { package, folder } => { - grants::folder(&folder.path)?; - let ceiling = self.system.folder.ok_or("this image grants no package a folder")?; - if folder.access > ceiling { - return Err(format!("{} is past this image's {ceiling}", folder.access)); - } - let (system, name, path) = (self.system, package.clone(), folder.path.clone()); + let (system, name) = (self.system, package.clone()); let binary = self.files("a grant's files", move || -> Result { - let program = installed(system, &name)?; - is_folder(&path)?; - binary_digest(&program) + binary_digest(&installed(system, &name)?.path) })??; let said = format!("{package} is granted {} {}", folder.path, folder.access); - store.put(grants::Entry { user: user.to_string(), package, binary, answer: Answer::Granted(folder) })?; + self.keep(&package, binary, Answer::Granted(folder))?; said } }; + say!("supervisor: grants: {done}"); + Ok(done) + } + + /// `answer` stored for the session user's `package` at `binary`, in place + /// of any other: **the one way an answer reaches [`grants::STORE`]**, a + /// command's and the person's alike. A folder is held to + /// [`grants::admit`], and is a directory and not a link when it is + /// stored, so nothing kept names what no launch could hold. + fn keep(&mut self, package: &str, binary: String, answer: Answer) -> Result<(), String> { + if let Answer::Granted(folder) = &answer { + grants::admit(folder, self.system.folder)?; + let path = folder.path.clone(); + self.files("a granted folder", move || is_folder(&path))??; + } + let mut store = self.store.clone(); + store.put(grants::Entry { user: toyos_manifest::USER.to_string(), package: package.to_string(), binary, answer })?; + self.commit(store) + } + + /// `store` written whole over [`grants::STORE`], and then held. + fn commit(&mut self, store: Store) -> Result<(), String> { let text = store.render(); self.files("the grants", move || write_store(&text))??; - say!("supervisor: grants: {done}"); self.store = store; - Ok(done) + Ok(()) } fn make_session_home(&mut self) { @@ -1558,17 +1575,16 @@ impl<'a> Supervisor<'a> { } } -/// The program installed package `name` launches, as its manifest names it -/// and the image would start it: a package named after a declared row is none. -fn installed(system: &Manifest, name: &str) -> Result { +/// Installed package `name`'s row, its program as its manifest names it: a +/// package named after a declared row is none. +fn installed(system: &Manifest, name: &str) -> Result { let file = Package::path(name); let text = std::fs::read_to_string(&file).map_err(|e| format!("{file} cannot be read: {e}"))?; let package = Package::parse(&text)?; if package.name != name { return Err(format!("{file} calls itself {:?}", package.name)); } - system.app_row(name, &package.program)?; - Ok(package.program) + system.app_row(name, &package.program) } /// `Ok` where `path` is a directory, and not a link to one. @@ -1597,15 +1613,26 @@ fn write_store(text: &str) -> Result<(), String> { } /// A staged or installed binary's bytes, refused past [`toyos_swap::MAX_BINARY_BYTES`] -/// before any of it is read. Made from the loop: a swap's files are under -/// [`toyos_swap::STAGING`], which the kernel serves. +/// before any of it is read, and read through that bound: a file grown +/// between the two is refused, never read whole. Made from the loop: a swap's +/// files are under [`toyos_swap::STAGING`], which the kernel serves. fn read_binary(path: &str) -> Result, Refusal> { + use std::io::Read as _; let unreadable = |e: std::io::Error| Refusal::Unreadable { path: path.to_string(), why: e.to_string() }; - let len = std::fs::metadata(path).map_err(unreadable)?.len(); + let file = std::fs::File::open(path).map_err(unreadable)?; + let len = file.metadata().map_err(unreadable)?.len(); if len > toyos_swap::MAX_BINARY_BYTES { return Err(Refusal::TooLarge(len)); } - std::fs::read(path).map_err(unreadable) + let mut bytes = Vec::new(); + file.take(toyos_swap::MAX_BINARY_BYTES + 1).read_to_end(&mut bytes).map_err(unreadable)?; + match bytes.len() as u64 > toyos_swap::MAX_BINARY_BYTES { + true => Err(Refusal::Unreadable { + path: path.to_string(), + why: format!("it grew past {} bytes while it was read", toyos_swap::MAX_BINARY_BYTES), + }), + false => Ok(bytes), + } } /// Write verified bytes where a service is started from: a temporary name @@ -1900,27 +1927,23 @@ impl Supervisor<'_> { fn advance_parked(&mut self, parked: Parked, caller_ready: bool, consent_ready: bool) -> Option { let Parked { launch, binary, consent, mut rx, mut hangup } = parked; let name = launch.program.name.clone(); - // The caller first: a launch nobody waits for any more is not started. - if caller_ready && !matches!(hangup.pump(&launch.conn), RxStep::Idle) { - say!("supervisor: consent: the question about {name} is withdrawn: its caller hung up"); - return None; - } - if !consent_ready { - return Some(Parked { launch, binary, consent, rx, hangup }); - } - let reply = match rx.pump(&consent) { - RxStep::Idle => return Some(Parked { launch, binary, consent, rx, hangup }), - RxStep::Frame { msg_type: consent::MSG_REPLY, payload_len } if payload_len > consent::MAX_REPLY => { - Err(format!("its answer is longer than the {} bytes any answer is", consent::MAX_REPLY)) - } - RxStep::Frame { msg_type: consent::MSG_REPLY, payload_len } => consent::Reply::decode(rx.payload(payload_len)) - .ok_or_else(|| "its answer is not one the protocol has".to_string()), - RxStep::Frame { msg_type, .. } => Err(format!("it sent message {msg_type}, which is no answer")), - RxStep::Eof => Err("the consent server ended before it answered".to_string()), - RxStep::Malformed => Err("it sent a frame the protocol cannot describe".to_string()), + let caller = match caller_ready { + true => heard(hangup.pump(&launch.conn), &hangup, "its caller hung up"), + false => consent::Heard::Nothing, + }; + let server = match consent_ready { + true => heard(rx.pump(&consent), &rx, "the consent server ended before it answered"), + false => consent::Heard::Nothing, + }; + let reply = match consent::settle(caller, server, self.system.folder) { + consent::Settled::Waiting => return Some(Parked { launch, binary, consent, rx, hangup }), + consent::Settled::Withdrawn => { + say!("supervisor: consent: the question about {name} is withdrawn: its caller hung up"); + return None; + } + consent::Settled::Answered(reply) => reply, }; drop(consent); - let reply = reply.and_then(|reply| self.judged(reply)); let reply = match reply { Ok(reply) => reply, Err(why) => { @@ -1938,30 +1961,6 @@ impl Supervisor<'_> { None } - /// `reply`, its folder held to the rules a stored grant is ([`grants::folder`]) - /// and to the image's ceiling: the consent server is a program like any. - fn judged(&self, reply: consent::Reply) -> Result { - if let Some(folder) = reply.folder() { - grants::folder(&folder.path)?; - let ceiling = self.system.folder.expect("a launch is asked only under a ceiling"); - if folder.access > ceiling { - return Err(format!("{} is past this image's {ceiling}", folder.access)); - } - } - Ok(reply) - } - - /// `answer` stored for the session user's `package` at `binary`, in place - /// of any other. - fn keep(&mut self, package: &str, binary: String, answer: Answer) -> Result<(), String> { - let mut store = self.store.clone(); - store.put(grants::Entry { user: toyos_manifest::USER.to_string(), package: package.to_string(), binary, answer })?; - let text = store.render(); - self.files("the grants", move || write_store(&text))??; - self.store = store; - Ok(()) - } - /// Start `launch`, holding `folder` and in it ([`grants::cwd`]) where it /// holds one, and answer its caller. fn start_launch(&mut self, launch: Launch, folder: Option) { @@ -2080,6 +2079,17 @@ struct Parked { hangup: ipc::FrameRx<8>, } +/// What `rx`'s `step` heard, in [`consent::settle`]'s words: `ended` where +/// its peer hung up. +fn heard<'a, const KEEP: usize>(step: RxStep, rx: &'a ipc::FrameRx, ended: &'static str) -> consent::Heard<'a> { + match step { + RxStep::Idle => consent::Heard::Nothing, + RxStep::Frame { msg_type, payload_len } => consent::Heard::Frame(msg_type, rx.payload(payload_len)), + RxStep::Eof => consent::Heard::Ended(ended), + RxStep::Malformed => consent::Heard::Ended("it sent a frame the protocol cannot describe"), + } +} + /// The supervisor's word for an answer, in its line in the log. fn said(reply: &consent::Reply) -> String { match reply { @@ -2166,28 +2176,13 @@ fn resolve<'a, V>(system: &'a Manifest, path: &str, judge: impl FnOnce(Target<'_ None => Resolved::NotDeclared, }; }; - let file = Package::path(name); - let text = match std::fs::read_to_string(&file) { - Ok(text) => text, - Err(e) => return Resolved::Refused(format!("{file} cannot be read: {e}")), - }; - let installed = match Package::parse(&text) { - Ok(installed) => installed, - Err(why) => return Resolved::Refused(why), - }; - if installed.name != name { - return Resolved::Refused(format!("{file} calls itself {:?}", installed.name)); - } - if installed.program != path { - return Resolved::Refused(format!( - "{file} launches {:?} and this asks for {path:?}", - installed.program - )); - } - let row = match system.app_row(name, path) { + let row = match installed(system, name) { Ok(row) => row, Err(why) => return Resolved::Refused(why), }; + if row.path != path { + return Resolved::Refused(format!("{} launches {:?} and this asks for {path:?}", Package::path(name), row.path)); + } let verdict = judge(Target::Package(&row)); Resolved::Package(row, verdict) } From c5d744ee67e348c21a31eb4ec0a9377d4eeb85c3 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 12:31:50 +0200 Subject: [PATCH 09/12] The compositor admits the prompt past every client ceiling A client that filled the compositor's pending-connection table or its window budget made the prompt's create_on fail; filepicker then answered Skip and the launch started with no folder. Closed, but any app could deny the person the question. - toyos_desktop::pending_admits: a connection off `prompt` waits beside the clients' in a slot of its own; the clients share MAX_PENDING_CONNS. - toyos_desktop::create_verdict takes the window's level: the prompt is held to the one its layer admits, the clients (Stack::clients) to max_windows. One window over the memory budget, as its floor is. - The poller reserves the prompt's two handles (PROMPT_POLL_HANDLES), taken from MAX_WINDOW_SLOTS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- userland/compositor/desktop/src/budget.rs | 51 +++++++++++++++++++---- userland/compositor/desktop/src/lib.rs | 2 +- userland/compositor/desktop/src/stack.rs | 6 +++ userland/compositor/src/main.rs | 12 ++++-- userland/compositor/src/session.rs | 34 +++++++++------ 5 files changed, 81 insertions(+), 24 deletions(-) diff --git a/userland/compositor/desktop/src/budget.rs b/userland/compositor/desktop/src/budget.rs index 911848c83ae..18d02810eed 100644 --- a/userland/compositor/desktop/src/budget.rs +++ b/userland/compositor/desktop/src/budget.rs @@ -1,4 +1,5 @@ use crate::rect::Rect; +use crate::window::Level; /// Share of physical memory the compositor will hold in window buffers. /// @@ -62,8 +63,14 @@ pub enum Verdict { /// /// `requested` is `(0, 0)` for a client that did not name a size, which is a /// request for whatever the desktop gives it and can never be too large. -pub fn create_verdict(requested: (u32, u32), screen: Rect, live: usize, max: usize) -> Verdict { - if live >= max { +/// +/// **The prompt's window is outside the budget**: `clients`, the windows +/// below the prompt layer, are held to `max`, and the prompt to the one its +/// layer admits ([`crate::Stack::admits`]), so no client filling the desktop +/// keeps the person at the screen from being asked. One window past the +/// memory budget, as [`max_windows`]'s floor is. +pub fn create_verdict(requested: (u32, u32), screen: Rect, level: Level, clients: usize, max: usize) -> Verdict { + if level != Level::Prompt && clients >= max { return Verdict::AtCapacity; } let (w, h) = requested; @@ -73,6 +80,19 @@ pub fn create_verdict(requested: (u32, u32), screen: Rect, live: usize, max: usi Verdict::Allow } +/// Whether a connection just accepted may wait for its first frame, off +/// `prompt` or off the clients' port, beside the ones already `waiting` (each +/// `true` for one off `prompt`): **one off `prompt` has a slot of its own**, +/// and the clients' share `max`, so no client filling the table keeps the +/// prompt from being made. +pub fn pending_admits(prompt: bool, waiting: impl IntoIterator, max: usize) -> bool { + let mut waiting = waiting.into_iter(); + match prompt { + true => !waiting.any(|p| p), + false => waiting.filter(|p| !p).count() < max, + } +} + #[cfg(test)] mod tests { use super::*; @@ -107,20 +127,37 @@ mod tests { #[test] fn a_window_larger_than_the_screen_is_refused_by_name() { - assert_eq!(create_verdict((1921, 1080), HD, 0, 10), Verdict::TooLarge); - assert_eq!(create_verdict((1920, 1081), HD, 0, 10), Verdict::TooLarge); - assert_eq!(create_verdict((1920, 1080), HD, 0, 10), Verdict::Allow); + assert_eq!(create_verdict((1921, 1080), HD, Level::Ordinary, 0, 10), Verdict::TooLarge); + assert_eq!(create_verdict((1920, 1081), HD, Level::Ordinary, 0, 10), Verdict::TooLarge); + assert_eq!(create_verdict((1920, 1080), HD, Level::Ordinary, 0, 10), Verdict::Allow); } #[test] fn capacity_is_checked_before_size() { // Both would refuse; the one that names the compositor's own budget // wins, because it is the one a client can wait out. - assert_eq!(create_verdict((9999, 9999), HD, 10, 10), Verdict::AtCapacity); + assert_eq!(create_verdict((9999, 9999), HD, Level::Ordinary, 10, 10), Verdict::AtCapacity); } #[test] fn a_client_that_names_no_size_is_never_too_large() { - assert_eq!(create_verdict((0, 0), Rect::new(0, 0, 4, 4), 0, 10), Verdict::Allow); + assert_eq!(create_verdict((0, 0), Rect::new(0, 0, 4, 4), Level::Ordinary, 0, 10), Verdict::Allow); + } + + /// **A desktop full of clients still lets the prompt in**: every client + /// window and every waiting client connection is spent, and the prompt's + /// window and its connection are each admitted; a second of either is not. + #[test] + fn the_prompt_is_admitted_past_every_client_ceiling() { + for level in [Level::Ordinary, Level::Topmost] { + assert_eq!(create_verdict((600, 400), HD, level, 10, 10), Verdict::AtCapacity, "{level:?}"); + } + assert_eq!(create_verdict((600, 400), HD, Level::Prompt, 10, 10), Verdict::Allow); + assert_eq!(create_verdict((1921, 400), HD, Level::Prompt, 0, 10), Verdict::TooLarge); + let full = [false; 32]; + assert!(!pending_admits(false, full, 32)); + assert!(pending_admits(true, full, 32)); + assert!(!pending_admits(true, full.iter().copied().chain([true]), 32)); + assert!(pending_admits(false, [true; 1].into_iter().chain([false; 31]), 32)); } } diff --git a/userland/compositor/desktop/src/lib.rs b/userland/compositor/desktop/src/lib.rs index b93c35ebeaa..0874ce50676 100644 --- a/userland/compositor/desktop/src/lib.rs +++ b/userland/compositor/desktop/src/lib.rs @@ -32,7 +32,7 @@ pub mod stack; pub mod taskbar; pub mod window; -pub use budget::{create_verdict, max_windows, window_bytes, Verdict}; +pub use budget::{create_verdict, max_windows, pending_admits, window_bytes, Verdict}; pub use damage::{Damage, MAX_DAMAGE_RECTS}; pub use hit::{hit_test, Hit}; pub use input::{ diff --git a/userland/compositor/desktop/src/stack.rs b/userland/compositor/desktop/src/stack.rs index b6599c068db..4e0b0f11549 100644 --- a/userland/compositor/desktop/src/stack.rs +++ b/userland/compositor/desktop/src/stack.rs @@ -40,6 +40,11 @@ impl Stack { self.windows.is_empty() } + /// The windows below the prompt layer: what the window budget counts. + pub fn clients(&self) -> usize { + self.windows.iter().filter(|w| w.level != Level::Prompt).count() + } + pub fn as_slice(&self) -> &[Window] { &self.windows } @@ -275,6 +280,7 @@ mod tests { let mut s = with_prompt(&[("a", false)]); assert!(!s.admits(Level::Prompt)); assert!(s.admits(Level::Topmost) && s.admits(Level::Ordinary)); + assert_eq!((s.len(), s.clients()), (2, 1)); s.remove(1); assert!(s.admits(Level::Prompt)); } diff --git a/userland/compositor/src/main.rs b/userland/compositor/src/main.rs index 2099385bd57..0aec27e9eb3 100644 --- a/userland/compositor/src/main.rs +++ b/userland/compositor/src/main.rs @@ -45,18 +45,22 @@ pub const FLAG_HARDWARE_CURSOR: u32 = 1 << 0; /// the two listeners, `compositor` and `prompt`. pub const FIXED_POLL_HANDLES: u32 = 4; -/// Hard ceiling on live windows, from the poller rather than from memory. +/// Hard ceiling on live client windows, from the poller rather than from memory. /// /// Every window's handle is registered in the same batch as the four fixed -/// ones -/// and the pending connections, and [`Poller::MAX_HANDLES`] is the widest set +/// ones, the pending connections and the prompt's two, and [`Poller::MAX_HANDLES`] is the widest set /// one poller can carry. Unlike the memory budget this does not move when the /// resolution does, which is why the poller is sized from it: /// `MSG_SET_RESOLUTION` can make windows cheaper mid-run, and a poller sized /// for the old screen would then be too small. At any resolution this machine /// can actually scan out, the memory budget is far below this and is what /// binds. -pub const MAX_WINDOW_SLOTS: u32 = Poller::MAX_HANDLES - FIXED_POLL_HANDLES - MAX_PENDING_CONNS; +pub const MAX_WINDOW_SLOTS: u32 = Poller::MAX_HANDLES - FIXED_POLL_HANDLES - MAX_PENDING_CONNS - PROMPT_POLL_HANDLES; + +/// The prompt's own: its connection waiting for its first frame, and its +/// window, which no client's ceiling counts (`toyos_desktop::pending_admits`, +/// `toyos_desktop::create_verdict`). +pub const PROMPT_POLL_HANDLES: u32 = 2; /// Edge of the cursor sprites, in pixels — the size they are rasterized at and /// the size the software cursor damages. diff --git a/userland/compositor/src/session.rs b/userland/compositor/src/session.rs index c7b268930e0..698f6249567 100644 --- a/userland/compositor/src/session.rs +++ b/userland/compositor/src/session.rs @@ -35,7 +35,7 @@ use crate::render::{self, Assets, BackBuffer, SystemStats, TitleBarIcons}; use crate::stats::{FrameStats, FrameTotals}; use crate::{ launcher_apps, CURSOR_PX, DOUBLE_CLICK_TIME, DRAIN_BUDGET, FIXED_POLL_HANDLES, - FLAG_HARDWARE_CURSOR, FRAME_INTERVAL, MAX_WINDOW_SLOTS, STATS_INTERVAL, + FLAG_HARDWARE_CURSOR, FRAME_INTERVAL, MAX_WINDOW_SLOTS, PROMPT_POLL_HANDLES, STATS_INTERVAL, }; const _: () = assert!( @@ -239,9 +239,9 @@ impl Session { // Sized for the slot ceiling rather than for `max_windows`: the batch // between two `wait` calls is the four fixed registrations, one per // live window - // and one per pending connection, and `MSG_SET_RESOLUTION` can raise - // `max_windows` mid-run. - let poller = Poller::new(FIXED_POLL_HANDLES + MAX_WINDOW_SLOTS + MAX_PENDING_CONNS); + // and one per pending connection, the prompt's two beside them, and + // `MSG_SET_RESOLUTION` can raise `max_windows` mid-run. + let poller = Poller::new(FIXED_POLL_HANDLES + MAX_WINDOW_SLOTS + MAX_PENDING_CONNS + PROMPT_POLL_HANDLES); poller.watch(&kb, READABLE, kb.as_handle().0 as u64); poller.watch(&mouse, READABLE, mouse.as_handle().0 as u64); poller.watch(&acceptor, READABLE, acceptor.as_handle().0 as u64); @@ -695,12 +695,21 @@ impl Session { let acceptor = if prompt { &self.prompts } else { &self.acceptor }; match acceptor.accept() { Err(e) => eprintln!("compositor: a connection could not be accepted ({e:?})"), - Ok(conn) if self.pending.len() >= MAX_PENDING_CONNS as usize => { - eprintln!( - "compositor: refusing client {} — {MAX_PENDING_CONNS} connections are already \ - waiting to say what they want", - conn.as_handle().0 - ); + Ok(conn) + if !toyos_desktop::pending_admits( + prompt, + self.pending.iter().map(|p| p.prompt), + MAX_PENDING_CONNS as usize, + ) => + { + match prompt { + true => eprintln!("compositor: refusing a prompt's connection — another is waiting"), + false => eprintln!( + "compositor: refusing client {} — {MAX_PENDING_CONNS} connections are already \ + waiting to say what they want", + conn.as_handle().0 + ), + } } Ok(conn) => { self.poller.watch(&conn, READABLE, conn.as_handle().0 as u64); @@ -924,7 +933,8 @@ impl Session { let refusal = match toyos_desktop::create_verdict( (req.width, req.height), self.desk.screen, - self.stack.len(), + level, + self.stack.clients(), self.max_windows, ) { Verdict::Allow => None, @@ -938,7 +948,7 @@ impl Session { req.width, req.height, handle.0, - self.stack.len(), + self.stack.clients(), self.max_windows ); let _ = From a207c4f0b7c4c2dc9a2702e2c1f2cf4bfaad8de1 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 12:31:51 +0200 Subject: [PATCH 10/12] File the picker holding the question and the second poller, and give the two-clicks issue its owner - issues/a-question-waits-unseen-while-a-file-is-picked.md: a picking session holds filepicker's loop, so a question waits unseen and the compositor's one launcher thread, every desktop launch with it. - issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md: the variant of the prompt's loop that drained with Window::poll_event(0) stalled, and the kernel-side cause was not found. - issues/two-clicks-the-compositor-reads-at-once-are-one-click.md names its owner, the compositor's input decisions in the desktop track. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...ion-waits-unseen-while-a-file-is-picked.md | 37 +++++++++++++++++++ ...-on-a-windows-handle-stalled-the-prompt.md | 37 +++++++++++++++++++ ...-compositor-reads-at-once-are-one-click.md | 5 +++ 3 files changed, 79 insertions(+) create mode 100644 issues/a-question-waits-unseen-while-a-file-is-picked.md create mode 100644 issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md diff --git a/issues/a-question-waits-unseen-while-a-file-is-picked.md b/issues/a-question-waits-unseen-while-a-file-is-picked.md new file mode 100644 index 00000000000..d69288f9cf5 --- /dev/null +++ b/issues/a-question-waits-unseen-while-a-file-is-picked.md @@ -0,0 +1,37 @@ +--- +status: open +kind: defect +opened: 2026-10-10 +--- + +# A question waits unseen while a file is picked, and every desktop launch waits with it + +`/system/bin/filepicker` serves both a program's file-picking session +(`filepicker`) and the supervisor's question before a package's launch +(`consent`), from one loop. A picking session is `run_picker`, called inside +that loop, which returns only when the session ends. A question asked during +one stays queued on the `consent` acceptor, unseen, until the session ends. + +The supervisor holds that launch parked until the question is answered, which +is right. But the compositor makes every launch on one thread, one at a time +(`userland/compositor/src/session.rs`, `launcher()`), because a launch that +asks waits as long as its question. So while the question waits unseen, no +launch from the desktop is made at all, the terminal's chord included, until +the person closes the picker, whose window gives no sign of why. + +**Evidence**: read from the code at the branch that made the question +(`userland/filepicker/src/main.rs`, the `run_picker` call in `main`'s loop; +the compositor's `launcher`). Not run: no test opens a picker and launches a +package while it is open. + +## Owner + +`userland/filepicker` and the compositor's launch path, in the desktop track +`issues/toyos-has-a-desktop.md`. + +## Exit condition + +A guest test opens a file-picking session, launches a package that asks from +the desktop, and sees the prompt on the panel while the picker is still open; +and a launch from the desktop that asks nothing starts while another's +question is up. diff --git a/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md b/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md new file mode 100644 index 00000000000..3104d0140ca --- /dev/null +++ b/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md @@ -0,0 +1,37 @@ +--- +status: open +kind: defect +opened: 2026-10-10 +--- + +# A second poller on a window's handle stalled the prompt, and why is unknown + +Filepicker's prompt (`userland/filepicker/src/consent.rs`, `ask`) waits on +one `Poller` watching both its window's connection and the supervisor's. +While the branch that made it was built, a version that also drained the +window with `Window::poll_event(0)` after each wake, which watches the same +handle through the window's own second `Poller`, stalled the prompt in +`consent_prompt` (`tests/toyos.rs`) after its first keys. Reading one event per wake off the first poller +alone, as `ask` does now, does not stall. + +The kernel-side cause was not found. Nothing says the shape is the prompt's +alone: a program that waits on a `Window` beside another handle through its +own poller and drains with `poll_event(0)` watches one handle from two +pollers in the same way. `rg poll_event` finds those callers. + +**Evidence**: the stalling variant was not committed and its runs' logs were +not kept; what is recorded is the shape above and that removing the second +poller ended the stall. Related, and not shown to be the cause: +`issues/a-close-of-one-handle-ends-every-rings-poll-on-its-object.md` and +`issues/toyos-poller-is-sync-and-a-watch-moves-the-tail-in-two-steps.md`. + +## Owner + +`toyos/src/poller.rs` and the kernel's watch it rides, which nobody holds. + +## Exit condition + +A guest test in which one thread watches a connection from two pollers, one +waiting and one polling with no wait, and is handed every frame its peer +sends, red on the shape above or green with the cause named and fixed; or +`Window::poll_event` no longer owns a poller of its own. diff --git a/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md b/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md index df918fbd612..c0a64c50519 100644 --- a/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md +++ b/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md @@ -20,6 +20,11 @@ with nothing between them, and no launch was made, which these two clicks folded into one on the row of a launcher not yet open accounts for. The test now waits for the launcher on the panel before its second click. +## Owner + +The compositor's input decisions, `userland/compositor/desktop/src/input.rs`, +in the desktop track `issues/toyos-has-a-desktop.md`. + ## Exit condition A host test of `fold_mouse` (or what replaces it) in which one read carrying From 35a679f5edda6c5ea79f85d473a43d1d20254ca3 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 13:05:16 +0200 Subject: [PATCH 11/12] consent's job ends at once on a red before its steps Every step asks of an empty store and waits on the host's F12, so a red in `prepared` or `not_a_folder` left the job waiting on a step that never came, and the run ended only at the harness's far backstop. It panics there instead, and the host reds on the steps not finished. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- tests/toyos-rust-tests/src/bin/consent.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/toyos-rust-tests/src/bin/consent.rs b/tests/toyos-rust-tests/src/bin/consent.rs index 1bc07797850..38bc738c3c6 100644 --- a/tests/toyos-rust-tests/src/bin/consent.rs +++ b/tests/toyos-rust-tests/src/bin/consent.rs @@ -84,6 +84,10 @@ fn job() { let mut red = Vec::new(); prepared(&mut red); not_a_folder(&mut red); + // Every step below asks of an empty store, and waits on the host. + if !red.is_empty() { + panic!("consent: {red:#?}"); + } let mut hostile = Command::new(SELF) .arg("hostile") .stdout(Stdio::piped()) From 64a81c519c7a58e24ab333d2d4592a0fe9ce4a06 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 10 Oct 2026 13:25:52 +0200 Subject: [PATCH 12/12] File the parked launch's untested hang-up wiring, and give the second-poller issue the desktop track as owner The review of round 3 found the supervisor's wiring that feeds consent::settle a hang-up reached by no test, a claim of the pull request's body with no test. It is filed under the supervisor track's stage 2, which keeps the loop in the binary, with the two attempts that failed as its evidence and a guest test of a withdrawn launch as its exit. The second-poller issue named code nobody holds as its owner. Its exit is Window::poll_event owning no poller, which is toyos-window's, so the desktop track owns it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...ches-settle-through-wiring-no-test-runs.md | 39 +++++++++++++++++++ ...-on-a-windows-handle-stalled-the-prompt.md | 3 +- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 issues/a-parked-launchs-hang-up-reaches-settle-through-wiring-no-test-runs.md diff --git a/issues/a-parked-launchs-hang-up-reaches-settle-through-wiring-no-test-runs.md b/issues/a-parked-launchs-hang-up-reaches-settle-through-wiring-no-test-runs.md new file mode 100644 index 00000000000..a6a4477c1fe --- /dev/null +++ b/issues/a-parked-launchs-hang-up-reaches-settle-through-wiring-no-test-runs.md @@ -0,0 +1,39 @@ +--- +status: open +kind: defect +opened: 2026-10-10 +--- + +# A parked launch's hang-up reaches `settle` through wiring no test runs + +A launch that asks the person at the screen is parked in the supervisor until +its question is answered, and `toyos_manifest::consent::settle` decides its +next step: a caller that hung up withdraws it before any answer. That decision +is host-tested. What feeds it is not: in `userland/supervisor/src/main.rs`, the +loop's watch of the parked caller's connection (`TOKEN_PARKED_CALLER`), the +ready test that reads it, `Supervisor::advance_parked`'s two arms, and `heard`, +which turns a `FrameRx` step into a `consent::Heard`. A defect there starts a +launch whose caller has gone, with the folder the person answered; it grants +nothing the person did not give. + +**Evidence**: no guest test can hang up a caller while its question is up. +It needs a caller in a session that holds the screen which ends while asking, +and each candidate fails: + +- the compositor's launcher thread never hangs up; +- a shell's own spawn waits on the answer, and the prompt takes every key; +- a program a shell spawns directly holds no launcher, and its helper panicked + for want of one when tried; +- a test binary cannot be an image row (`build_programs` builds rows from + `userland/`), and the build refused one when tried. + +## Owner + +Stage 2 of `issues/the-supervisor-is-host-tested-and-owns-the-stop.md`, which +keeps handles, spawns and the loop in `userland/supervisor`. + +## Exit condition + +A guest test in which a caller in a session that holds the screen ends while +its question is up: the supervisor says `withdrawn`, the package never starts, +and the prompt leaves the panel. diff --git a/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md b/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md index 3104d0140ca..3ed9c775fc4 100644 --- a/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md +++ b/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md @@ -27,7 +27,8 @@ poller ended the stall. Related, and not shown to be the cause: ## Owner -`toyos/src/poller.rs` and the kernel's watch it rides, which nobody holds. +`toyos-window`'s `Window::poll_event` and the windows that drain through it, +in the desktop track `issues/toyos-has-a-desktop.md`. ## Exit condition