diff --git a/Cargo.lock b/Cargo.lock index e86620557da..efbb4264903 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1246,6 +1246,7 @@ dependencies = [ "filepicker-api", "toyos", "toyos-font", + "toyos-manifest", "toyos-window", ] @@ -1543,6 +1544,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "grants" +version = "0.1.0" +dependencies = [ + "toyos", + "toyos-manifest", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -4272,6 +4281,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 ae1e1c90e28..c50c027b55e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ members = [ "userland/editor", "userland/filepicker", "userland/filepicker-api", + "userland/grants", "userland/files", "userland/fileserver", "userland/host", @@ -249,6 +250,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" } # `common::https`'s server, the peer a guest's HTTPS client is judged # against, and the authority and certificates it presents, made per run. rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } 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-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..3ed9c775fc4 --- /dev/null +++ b/issues/a-second-poller-on-a-windows-handle-stalled-the-prompt.md @@ -0,0 +1,38 @@ +--- +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-window`'s `Window::poll_event` and the windows that drain through it, +in the desktop track `issues/toyos-has-a-desktop.md`. + +## 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/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/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..c0a64c50519 --- /dev/null +++ b/issues/two-clicks-the-compositor-reads-at-once-are-one-click.md @@ -0,0 +1,31 @@ +--- +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. + +## 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 +two clicks at two positions yields both, each at its own position. 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 3524e9b40e1..984b49a4d48 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 @@ -571,24 +590,42 @@ 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 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 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, -/// [`toyos_update::slots::HOLDER`] alone. +/// 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]` — 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 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, 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 { - 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 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)); @@ -599,10 +636,15 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { return Err(format!("`{name}` asks for `slots` and is in `[boot] start`, and the slots are granted to a launch alone")); } } - 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")); + } + } + if config.apps.receives.iter().any(|r| r == consent::PORT) { + return Err(format!("`[apps] receives` names `{}`, which only the supervisor may hold", consent::PORT)); } - Ok(()) + apps_folder(config).map(drop) } /// What a row may start through the launcher, and which rows open a login @@ -662,11 +704,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) @@ -2909,6 +2953,7 @@ mod tests { "console/system.toml", "tests/acpicase/system.toml", "tests/blockgrantcase/system.toml", + "tests/consentcase/system.toml", "tests/jobcase/system.toml", "tests/latencycase/system.toml", "tests/logstallcase/system.toml", @@ -2985,6 +3030,51 @@ mod tests { let booted: SystemConfig = toml::from_str("[boot]\nstart = [\"update\"]\n[programs.update]\nslots = true\n").unwrap(); assert!(held_by_their_holders_alone(&booted).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()); + } + + /// 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] + 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 faf117187c2..38dc73d470e 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 @@ -62,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"] @@ -92,7 +100,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", "toyfetch", "update", "/apps"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "sshserver", "swap", "toyfetch", "update", "grants", "/apps"] login = true # `swap` is the authority to replace a running service's binary; the build @@ -108,6 +116,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] @@ -131,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"] @@ -143,7 +160,7 @@ starts = ["shell"] [programs.shell] receives = ["surface"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "sshserver", "swap", "toyfetch", "update", "/apps"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "proctest", "sshserver", "swap", "toyfetch", "update", "grants", "/apps"] [programs.doom] receives = ["compositor", "soundserver"] diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 5c7e8ff2689..f369f67f8df 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -712,6 +712,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, /// [`Profile::Metal`] on a CPU whose CPUID offers no x2APIC, emulated /// whatever the host, so its local APICs run in xAPIC mode: the machine /// an AMD laptop without x2APIC is. @@ -776,6 +783,7 @@ impl Profile { | Self::HeadlessVirtioGpu | Self::HeadlessUsbSpare | Self::Metal + | Self::Desktop | Self::MetalNoX2apic | Self::MetalAmdVi => Arch::X86_64, } @@ -1036,6 +1044,11 @@ impl Profile { rng: false, virtio_gpu: false, }, + Self::Desktop => Shape { + panel: Some((1280, 800)), + usb: &["usb-kbd,bus=xhci.0", "usb-tablet,bus=xhci.0"], + ..Self::Metal.shape() + }, Self::MetalNoX2apic => Self::Metal.shape(), Self::MetalAmdVi => Shape { iommu: Some(Unit::AmdVi), ..Self::Metal.shape() }, Self::HeadlessNoIommu => Shape { iommu: None, ..Self::Headless.shape() }, @@ -1529,7 +1542,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 @@ -1544,13 +1556,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. @@ -2091,6 +2103,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 @@ -2127,8 +2150,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 6bbfc4c6056..bc04f3dc996 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/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/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index 2878efca2ff..8144777142f 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -837,6 +837,13 @@ dependencies = [ "toyos-transport", ] +[[package]] +name = "toyos-desktop" +version = "0.1.0" +dependencies = [ + "toyos-abi", +] + [[package]] name = "toyos-font" version = "0.2.0" @@ -879,6 +886,7 @@ dependencies = [ "toyos", "toyos-abi", "toyos-blockring", + "toyos-desktop", "toyos-gpt", "toyos-inspect", "toyos-logstream", diff --git a/tests/toyos-rust-tests/Cargo.toml b/tests/toyos-rust-tests/Cargo.toml index 6bf957ba47a..f9bb05e1928 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/app_view.rs b/tests/toyos-rust-tests/src/bin/app_view.rs index 2bdc76e619f..aa009ed97f1 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,151 @@ 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"); + + 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) { + 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-rust-tests/src/bin/consent.rs b/tests/toyos-rust-tests/src/bin/consent.rs new file mode 100644 index 00000000000..38bc738c3c6 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/consent.rs @@ -0,0 +1,316 @@ +//! 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`]); 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 +//! 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); + 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()) + .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(©); +} + +/// **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}"); + 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 8cedebc6c1e..2f75410204b 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -134,6 +134,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 @@ -305,6 +308,13 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[ ("virt_random_differs", qemu::Profile::Virt), ("virt_smmu", qemu::Profile::VirtSmmu), ("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), ("virt_wall_clock_utc", qemu::Profile::Virt), ("virt_low_ecam", qemu::Profile::VirtLowEcam), ]; @@ -3145,6 +3155,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re "virt_reboot_wire_kept" => virt_reboot(profile, power::wire_staged(true)), "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 @@ -3935,6 +3946,304 @@ 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> { + let case = compile::repo_root().join("tests/consentcase"); + let (_, job) = suite_bin(profile.arch(), "consent"); + let digest: String = toyos_sha2_hw::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)); + // **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)); + 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() }); + } + } + } + + 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)); @@ -4639,7 +4948,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(); @@ -4650,6 +4960,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/consent.rs b/toyos-manifest/src/consent.rs new file mode 100644 index 00000000000..781ad4cd342 --- /dev/null +++ b/toyos-manifest/src/consent.rs @@ -0,0 +1,330 @@ +//! 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: [`judge`] holds it to [`crate::grants::admit`]. + 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, + } + } +} + +/// 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::*; + + 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:?}"); + } + } + + /// **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(); + 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 new file mode 100644 index 00000000000..ef3c77f29c9 --- /dev/null +++ b/toyos-manifest/src/grants.rs @@ -0,0 +1,547 @@ +//! 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, or as +//! the person at the screen's answer to a launch's question +//! ([`crate::consent`]). +//! +//! **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 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; + +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 answer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub user: String, + pub package: String, + /// The SHA-256 of the program it was given to, lowercase hex. + pub binary: String, + 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 +/// 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(()) +} + +/// 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 { + /// 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 +/// 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(&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 + } + + /// 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 (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")); + } + 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)); + } + match &e.answer { + Answer::Granted(granted) => folder(&granted.path), + Answer::Denied => Ok(()), + } +} + +/// 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 { + match &entry.answer { + Answer::Granted(folder) => format!("{} {} {}\n", entry.package, folder.access, folder.path), + Answer::Denied => format!("{} denied\n", entry.package), + } +} + +#[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(), + 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] + 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(); + store.put(denied("refused")).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().answer, + Answer::Granted(Folder { path: "/home/toy/Games".into(), access: Access::ReadWrite }) + ); + 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!("{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) { + 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(); + 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 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_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| 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, bool, Decision); + #[rustfmt::skip] + 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, at_screen, want)) in table.into_iter().enumerate() { + assert_eq!(decide(stored, binary, ceiling, at_screen), 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/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 6d1745736ba..4bd1465ffdb 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -27,14 +27,18 @@ //! 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, [`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; @@ -221,7 +225,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, @@ -256,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. @@ -289,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(), } } @@ -316,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, @@ -444,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")); @@ -503,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()), "" => {} _ => { @@ -583,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()], } } @@ -686,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/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/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/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..0874ce50676 100644 --- a/userland/compositor/desktop/src/lib.rs +++ b/userland/compositor/desktop/src/lib.rs @@ -32,16 +32,23 @@ 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::{ 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..4e0b0f11549 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 @@ -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 } @@ -67,7 +72,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 +85,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 +105,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 +128,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 +138,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 +169,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 +249,48 @@ 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)); + assert_eq!((s.len(), s.clients()), (2, 1)); + 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 42c1e4f8806..0645e25575b 100644 --- a/userland/compositor/src/main.rs +++ b/userland/compositor/src/main.rs @@ -41,22 +41,26 @@ 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. +/// 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 three fixed -/// ones -/// and the pending connections, and [`Poller::MAX_HANDLES`] is the widest set +/// Every window's handle is registered in the same batch as the four fixed +/// 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; /// What the launcher offers before `/apps` is read: the label it shows and the /// program it starts. 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 8cd669da173..2fef74f043a 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 sprite::Sprite; @@ -21,8 +22,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; @@ -35,7 +36,7 @@ use crate::render::{self, Assets, BackBuffer, SystemStats, TitleBarIcons}; use crate::stats::{FrameStats, FrameTotals}; use crate::{ launcher_apps, 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!( @@ -91,6 +92,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, @@ -156,6 +169,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) @@ -227,14 +242,15 @@ 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. - 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); + 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 { @@ -244,6 +260,7 @@ impl Session { let mut damage = Damage::default(); damage.add(desk.screen); + let launcher = launcher(); println!("compositor: ready"); let now = Instant::now(); @@ -251,6 +268,10 @@ impl Session { reported_traffic: screen.traffic(), reported_composed: back.surface.traffic(), acceptor, + prompts, + prompt: None, + owed: PromptGate::default(), + launcher, kb, mouse, poller, @@ -316,6 +337,7 @@ impl Session { } self.tick_taskbar(); self.present(); + self.prompt_gone(); } /// One turn of the drain, or `false` when nothing was ready. @@ -328,6 +350,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())); @@ -345,7 +368,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; } @@ -357,12 +380,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 } @@ -388,11 +415,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); } @@ -406,9 +446,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); @@ -545,6 +583,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()); @@ -584,11 +629,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; @@ -599,7 +646,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); @@ -641,18 +692,29 @@ 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!( - "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); @@ -661,6 +723,7 @@ impl Session { rx: ClientRx::new(), since: Instant::now(), copy: None, + prompt, }); } } @@ -704,6 +767,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; } @@ -760,6 +824,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), @@ -851,12 +920,26 @@ 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( (req.width, req.height), self.desk.screen, - self.stack.len(), + level, + self.stack.clients(), self.max_windows, ) { Verdict::Allow => None, @@ -870,7 +953,7 @@ impl Session { req.width, req.height, handle.0, - self.stack.len(), + self.stack.clients(), self.max_windows ); let _ = @@ -931,10 +1014,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(); @@ -1006,6 +1099,7 @@ impl Session { rx: ClientRx::new(), since: Instant::now(), copy: Some(CopyRegion::new(region)), + prompt: false, }); } @@ -1128,7 +1222,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); } @@ -1289,6 +1386,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); @@ -1325,6 +1430,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); @@ -1384,6 +1505,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/fileserver/src/data.rs b/userland/fileserver/src/data.rs index 697f7b71d31..bbe4affe619 100644 --- a/userland/fileserver/src/data.rs +++ b/userland/fileserver/src/data.rs @@ -842,6 +842,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 3349ee8e944..3bcec5fa71b 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -54,6 +54,14 @@ //! 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 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 //! minted with its row and session as the badge, under a label of its own so @@ -75,6 +83,8 @@ use std::time::{Duration, Instant}; use toyos_swap::{Refusal, Request as SwapRequest, Word}; +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}; @@ -153,12 +163,23 @@ 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 -/// one connection whose swap has been answered and whose hang-up is awaited. A +/// 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, 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; @@ -166,7 +187,11 @@ 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; +/// 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. /// @@ -414,10 +439,23 @@ 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`"); + // 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, - launcher, + minted: Minted { launcher, grants: grants_port }, + store: Store::default(), + consent, syscap: &syscap, acceptors, connectors, @@ -523,8 +561,14 @@ fn is_block(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 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. @@ -629,7 +673,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 @@ -661,7 +705,7 @@ impl<'a> Service<'a> { grants, &[], storage, - (launcher, self.session), + (minted, self.session), Output::Boot(log), )?; kept.generation += 1; @@ -824,7 +868,7 @@ impl<'a> Supervisor<'a> { self.grants.block = Some(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)) => { @@ -839,6 +883,100 @@ 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. **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> { + 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), + 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)), + } + }); + 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 done = match request { + grants::Request::List => return Ok(self.store.entries().iter().map(grants::listed).collect()), + grants::Request::Revoke { 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 } => { + let (system, name) = (self.system, package.clone()); + let binary = self.files("a grant's files", move || -> Result { + binary_digest(&installed(system, &name)?.path) + })??; + let said = format!("{package} is granted {} {}", folder.path, folder.access); + 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))??; + self.store = store; + Ok(()) } fn make_session_home(&mut self) { @@ -1005,18 +1143,24 @@ 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(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.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); 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); } @@ -1044,7 +1188,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)), ] { @@ -1106,8 +1255,10 @@ 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), Port::Swap => { let payload = p.rx.payload(payload_len).to_vec(); @@ -1135,6 +1286,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]; @@ -1144,12 +1297,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:?}")); }; @@ -1286,7 +1439,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( @@ -1457,7 +1610,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"); @@ -1477,7 +1630,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}")); @@ -1496,16 +1649,64 @@ impl<'a> Supervisor<'a> { } } +/// 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` 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. +/// 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 @@ -1531,7 +1732,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 @@ -1541,7 +1743,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; } @@ -1576,17 +1785,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 @@ -1639,12 +1849,13 @@ 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 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 || { @@ -1652,7 +1863,13 @@ impl Supervisor<'_> { let starts = authority::may_start(row, session, target)?; let (Target::Row(program) | Target::Package(program)) = target; let prepared = command.image_from(Path::new(&program.path)).prepare().map(drop); - Ok((starts, prepared.map(|()| command))) + 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))) }) }); let resolved = match found { @@ -1664,11 +1881,8 @@ impl Supervisor<'_> { } }; let (program, verdict) = match resolved { - Resolved::Row(row, verdict) => (row, verdict), - Resolved::Package(row, verdict) => { - installed = row; - (&installed, verdict) - } + 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 @@ -1686,7 +1900,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)); @@ -1702,9 +1916,149 @@ impl Supervisor<'_> { return; } }; + 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(); + 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 = 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 + } + + /// 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; // `inherit_handle` duplicates into the child, so the supervisor's own copies go with // `slots` when this returns. @@ -1727,9 +2081,10 @@ impl Supervisor<'_> { &self.grants, &extras, storage, - (&self.launcher, session), + (&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. @@ -1746,7 +2101,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); } @@ -1758,6 +2113,68 @@ impl Supervisor<'_> { } } +/// 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>, +} + +/// 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 { + 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(), + } +} + /// 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 @@ -1834,28 +2251,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) } @@ -2019,9 +2421,9 @@ fn start<'a>( served: Served<'_, 'a>, connectors: &BTreeMap<&str, Connector>, grants: &Grants<'_>, - extras: &[(&str, Connector)], + extras: &[(String, Connector)], storage: Storage, - launcher: (&Acceptor, Session), + launcher: (&Minted, Session), output: Output<'_>, ) -> std::io::Result<(Child, Vec)> { let Storage { args, claims, ports } = storage; @@ -2068,6 +2470,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 @@ -2330,15 +2737,16 @@ 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: [`swap_namespace`] says why. Never the block - // port's own connector, which reaches nothing: a holder of `block` holds - // one minted for what it reaches, in `view`. + // Never the swap port nor the grants port: [`swap_namespace`] and + // [`grants_namespace`] say why. Never the block port's own connector, + // which reaches nothing: a holder of `block` holds one minted for what it + // reaches, in `view`. let receives: Vec<&String> = program .receives .iter() - .filter(|name| *name != toyos_swap::PORT && *name != toyos_blockring::PORT) + .filter(|name| *name != toyos_swap::PORT && *name != grants::PORT && *name != toyos_blockring::PORT) .collect(); if receives.is_empty() && extras.is_empty() && view.is_empty() { return Ok(None); @@ -2437,6 +2845,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) { @@ -2475,12 +2889,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() @@ -2490,6 +2905,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)); 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,