diff --git a/issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md b/issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md index 52b029bfcb1..1ca5168a35e 100644 --- a/issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md +++ b/issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md @@ -66,7 +66,9 @@ the binary in a ToyOS guest is this track's harness's job, not gbae's. - **Running is the desktop's.** gbae opens a window through winit and softbuffer and plays through cpal, all three on the forks the SDK release branches carry. It lists a directory itself and reads the ROM the user picks - out of it. The first run is the milestone's end. + out of it. The first run is the milestone's end. Under stage 5's view that + listing reaches no ROM outside its own folder + (`issues/an-installed-gbae-browses-to-no-rom.md`). ## Stages, in order @@ -93,9 +95,10 @@ The storage track's users and mount-protocol stages do not block this one. project. 5. The users track's per-user `/home` (`issues/a-user-is-a-home-tree-and-a-login-row.md`) decides - where a package's own data goes. Until then nothing says where: a - committed `/apps/` is written by nothing, and that directory is where - a package wrote before the stage-then-commit ruling. + where a package's own data goes. Until then it goes in its own folder of + the session user's home, `/home/toy/Apps/`, which is its `HOME` and + the one part of the home it sees; its own `/apps/` is read-only to + it (`toyos_manifest::Program::view`). 6. **An app's rights are its request ∩ the user's grant ∩ the image's ceiling** (owner ruling, 2026-09-24; the ceiling's shape, 2026-09-26). The package's manifest *requests* rights; the user *grants* them per user diff --git a/issues/an-installed-gbae-browses-to-no-rom.md b/issues/an-installed-gbae-browses-to-no-rom.md new file mode 100644 index 00000000000..fdc3e79edc7 --- /dev/null +++ b/issues/an-installed-gbae-browses-to-no-rom.md @@ -0,0 +1,39 @@ +--- +status: open +kind: defect +opened: 2026-10-09 +--- + +# An installed gbae browses to no ROM + +gbae (`Japabu/gbae` at `bfe8dabf8`), started with no ROM, opens its own file +menu on `std::env::current_dir()` (`src/main.rs:471`) and walks it with +`std::fs::read_dir` (`src/menu.rs:308`). A package's view is its own +`/apps/` read-only and its own `/home/toy/Apps/` +(`toyos_manifest::Program::view`), and nothing else a file server serves, so +that menu reaches no ROM anywhere. The package track records the opposite: +"It lists a directory itself and reads the ROM the user picks out of it" +(`issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md`). + +Evidence, in a QEMU guest on `tests/proctreecase`, a package launched from +`/apps` running gbae's `list_directory` verbatim and its loads, with ROMs at +`/home/toy/Downloads` and in the package's own `Data` folder: + +- From cwd `/`, the compositor's own, which its launch carries (read from + the code, not measured), the menu lists `/`'s nine + mount points; `/system` lists `bin/` and `etc/`, which hold no ROM, and + every other one lists only `../`, `/home` and `/home/toy` included: no ROM + is browsable. +- From cwd `/home/toy`: the same. +- A ROM given by path loads from the package's own folder and not from + `/home/toy/Downloads` (`NotFound`). +- Its config, `$HOME/.config/gbae/config`, is written and read back in its + own folder. + +**Exit**: an installed gbae, started from the desktop, loads a ROM the user +picked from outside its own folder. The designed answer is the file picker, +which hands an app the one file the user chose: the package track's stage 6 +(an app's rights are its request, the user's grant and the image's ceiling), +and the isolation track's "Sharing is granted, never reached" +(`issues/every-program-sees-only-the-files-it-was-given.md`). Until then gbae +plays only a ROM placed in its own folder and given on its command line. diff --git a/issues/every-program-sees-only-the-files-it-was-given.md b/issues/every-program-sees-only-the-files-it-was-given.md index daca0b4949f..b4385e2a23a 100644 --- a/issues/every-program-sees-only-the-files-it-was-given.md +++ b/issues/every-program-sees-only-the-files-it-was-given.md @@ -94,6 +94,16 @@ nameable by every program and make confused-deputy bugs structural. terminal get the session's view, doom its `/apps` directory, and daemons their own state. **Exit**: the machine boots with every program in a declared view, and nothing still sees the global tree. + The `/apps` slice is built: a grant carries a read-only or read-write + access that the file server enforces on every request that would change + what it holds, and a package launched from `/apps` is minted its own + directory read-only and its own folder of the session's home read-write, + and nothing else a file server serves (`toyos_manifest::Program::view`). + It still reaches `/tmp` and `/system`, which the kernel serves to every + program. Every row the image declares still sees the whole tree + read-write, `/apps` included, so a shell and everything it starts can + rewrite an installed package; whether the installer alone writes `/apps` + is `issues/whether-pkg-alone-writes-apps.md`. 3. **Sessions and users.** `issues/a-user-is-a-home-tree-and-a-login-row.md` on top of views: a login authority (sshd, and later a local greeter) holds a `login` right and asks init's `launcher` for a session, and init builds diff --git a/issues/where-everything-lives.md b/issues/where-everything-lives.md index 39e9b3edbde..448bd09329b 100644 --- a/issues/where-everything-lives.md +++ b/issues/where-everything-lives.md @@ -81,6 +81,10 @@ until that lands nothing verifies it. **Exit**: a launched app's `home_dir()` is its folder, it cannot name another app's folder, and the shell's history is still `/home//Apps/shell/State/history` (`OWN_FOLDER` in `userland/shell`). + Built for a package launched from `/apps` (`toyos_manifest::Program::view`, + judged by the `app_view` metal row). Each desktop app in the image still + runs with the session's `HOME` and the whole tree: its row declares no view + until that isolation stage gives every row one. 3. **Users.** The users track creates `/home/` and its folders from a login row, and `toy` stops being a constant in `toyos-manifest`. **Exit**: init names no user. diff --git a/issues/whether-pkg-alone-writes-apps.md b/issues/whether-pkg-alone-writes-apps.md new file mode 100644 index 00000000000..8f442509735 --- /dev/null +++ b/issues/whether-pkg-alone-writes-apps.md @@ -0,0 +1,33 @@ +--- +status: owner +kind: question +opened: 2026-10-09 +--- + +# Whether pkg alone writes /apps + +Two of the owner's rulings in +`issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md` +pull against each other once a package's own directory is read-only to it: + +- **"The installer is an ordinary program … with no authority a shell does + not have"**: `pkg` writes under `/apps` because `/apps` is writable to it. +- **"`/apps/` is immutable by stage-then-commit … nothing writes a + committed package"** (2026-10-02). + +Today every row the image declares, `pkg` and the shell among them, holds +`/apps` read-write (`toyos_manifest::whole_tree`), so a shell and everything +it starts can rewrite an installed package. An installed package itself +holds its own directory read-only (`toyos_manifest::Program::view`). + +## The question + +Does `pkg` alone write `/apps`, which gives the installer an authority a +shell lacks, or does every row that may start `pkg` keep `/apps` writable, +which leaves a committed package writable by the shell? + +## Exit condition + +The owner's answer. If `pkg` alone writes `/apps`, every other declared row's +view holds `/apps` read-only, which needs the per-row views of +`issues/every-program-sees-only-the-files-it-was-given.md` stage 2. diff --git a/system.toml b/system.toml index b5430d2c924..fdc2ef3c6c2 100644 --- a/system.toml +++ b/system.toml @@ -26,7 +26,8 @@ start = ["logkeeper", "diskserver", "fileserver", "compositor", "soundserver", " # a row read out of one would be a directory deciding what the machine hands # out; this is the image's answer, one for all of them. Connectors only — # `devices` and `syscap` have no spelling here, so nothing installed claims -# hardware or enters the RT band. +# 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`. [apps] receives = ["compositor", "soundserver", "filepicker"] diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index 4c53742d095..48779a41b24 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -17,6 +17,10 @@ # test-runner's share of DATA's server; a shell that shell launches is in a # login session its row opens, which has a share of its own, and so is every # shell launched down from it, whose `login` row opens none there. +# +# 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. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] @@ -32,7 +36,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"] +starts = ["toybox", "shell", "swap", "update", "/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. diff --git a/tests/toyos-rust-tests/src/bin/app_view.rs b/tests/toyos-rust-tests/src/bin/app_view.rs new file mode 100644 index 00000000000..2bdc76e619f --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/app_view.rs @@ -0,0 +1,233 @@ +//! An installed app sees its own package read-only and its own folder as +//! `HOME`, and nothing else of `/apps` or `/home`. +//! +//! The job installs this binary as the package `appview` beside another +//! package and another app's folder, and launches it through test-runner's +//! launcher, whose row lists `/apps`. Two launches are refused first: the +//! same binary installed as `shell`, a row the image declares, whose folder +//! of the home is the shell's; and `appview` while a file stands where its +//! folder goes. Run as the app (`app`), it asks: +//! +//! - its `HOME` is `/home/toy/Apps/appview`, holding `Config Data Cache State`, +//! and a file it writes there lands in that folder of `/home`; +//! - its own package reads, and every request that would change it — an open +//! to write, append, truncate, create or create anew, a `mkdir`, `rmdir`, +//! `unlink`, `rename` and `symlink` — is refused `PermissionDenied` by the +//! server, past std, and std's own write is too; +//! - it holds no other directory: not `/apps`, another package, `/home`, the +//! session's home, another app's folder, `/config`, `/state`, `/log` or +//! `/boot`, and none of their files is there by path. +//! +//! Every arm runs, so one run names each one that is red. The job then holds +//! the package to what it installed. + +use std::fs; +use std::io::ErrorKind; +use std::process::Command; + +use toyos::fs::{Dir, Refused, O_APPEND, O_CREATE, O_CREATE_NEW, O_READ, O_TRUNCATE, O_WRITE}; +use toyos_abi::syscall::SyscallError; + +const SELF: &str = "/system/bin/test_rs_app_view"; +const PACKAGE: &str = "/apps/appview"; +const PROGRAM: &str = "/apps/appview/appview"; +/// The layout's `/home//Apps/`, spelled here and not asked of the +/// manifest crate the supervisor reads. +const HOME: &str = "/home/toy/Apps/appview"; +const MANIFEST: &[u8] = b"name = \"appview\"\nversion = \"1\"\n\ + digest = \"0000000000000000000000000000000000000000000000000000000000000000\"\n\ + program = \"/apps/appview/appview\"\n"; +/// A package named after the shell's row, whose `Apps/shell` is the shell's. +const ROW_PACKAGE: &str = "/apps/shell"; +const ROW_PROGRAM: &str = "/apps/shell/shell"; +const ROW_MANIFEST: &[u8] = b"name = \"shell\"\nversion = \"1\"\n\ + digest = \"0000000000000000000000000000000000000000000000000000000000000000\"\n\ + program = \"/apps/shell/shell\"\n"; +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"; + +fn main() { + match std::env::args().nth(1).as_deref() { + Some(APP) => app(), + _ => job(), + } +} + +fn job() { + for dir in [PACKAGE, ROW_PACKAGE] { + let _ = fs::remove_dir_all(dir); + } + let _ = fs::remove_dir_all(HOME); + let _ = fs::remove_file(HOME); + fs::create_dir_all(format!("{PACKAGE}/sub")).expect("make the package's directories"); + 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(ROW_PACKAGE).expect("make the row-named package's directory"); + fs::copy(SELF, ROW_PROGRAM).expect("install this binary as the row-named package's program"); + fs::write(format!("{ROW_PACKAGE}/manifest.toml"), ROW_MANIFEST).expect("write the row-named package's manifest"); + for file in [OTHER_PACKAGE_FILE, OTHER_FOLDER_FILE] { + let dir = file.rsplit_once('/').expect("a file in a directory").0; + fs::create_dir_all(dir).unwrap_or_else(|e| panic!("make {dir}: {e}")); + fs::write(file, b"not the app's").unwrap_or_else(|e| panic!("write {file}: {e}")); + } + let mut red = Vec::new(); + + // Refused, and the supervisor says why (the metal row's judge reads it). + match Command::new(ROW_PROGRAM).arg(APP).output() { + Err(e) => println!(" a package named after the shell's row: refused ({e})"), + Ok(ran) => red.push(format!("a package named after the shell's row ran: {ran:?}")), + } + // A launch that went ahead above may have left a folder there. + match fs::write(HOME, b"no folder") { + Err(e) => red.push(format!("no file could be planted where the app's folder goes: {e}")), + Ok(()) => { + match Command::new(PROGRAM).arg(APP).output() { + Err(e) => println!(" a package whose folder is a file: refused ({e})"), + Ok(ran) => red.push(format!("a package whose folder is a file ran: {ran:?}")), + } + fs::remove_file(HOME).expect("take the planted file away"); + } + } + + let ran = Command::new(PROGRAM).arg(APP).output().expect("launch the package through the launcher"); + print!("{}", String::from_utf8_lossy(&ran.stdout)); + print!("{}", String::from_utf8_lossy(&ran.stderr)); + if ran.status.code() != Some(0) { + red.push(format!("the app ended {:?}", ran.status)); + } + match fs::read(format!("{PACKAGE}/manifest.toml")) { + Ok(bytes) if bytes == MANIFEST => {} + other => red.push(format!("the package's manifest is not what was installed: {other:?}")), + } + let mut listed: Vec = fs::read_dir(PACKAGE) + .expect("list the package") + .map(|e| e.expect("an entry").file_name().to_string_lossy().into_owned()) + .collect(); + listed.sort(); + if listed != ["appview", "manifest.toml", "sub"] { + red.push(format!("the package holds {listed:?}, not what was installed")); + } + match fs::read(format!("{HOME}/Data/kept")) { + 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] { + let _ = fs::remove_dir_all(dir); + } + if !red.is_empty() { + panic!("app_view: {red:#?}"); + } + println!("app_view: the app saw its own package read-only and its own folder as HOME, and nothing else"); +} + +/// Every arm, as the package `appview`: `red` names each one that failed. +fn app() { + let mut red: Vec = Vec::new(); + + let home = std::env::home_dir(); + if home.as_deref() != Some(std::path::Path::new(HOME)) { + red.push(format!("home_dir() is {home:?}, not {HOME}")); + } + for folder in ["Config", "Data", "Cache", "State"] { + if !fs::metadata(format!("{HOME}/{folder}")).is_ok_and(|m| m.is_dir()) { + red.push(format!("{HOME}/{folder} is not a directory")); + } + } + if let Err(e) = fs::write(format!("{HOME}/Data/kept"), KEPT) { + red.push(format!("a write in its own folder was refused: {e}")); + } + + match fs::read(format!("{PACKAGE}/manifest.toml")) { + Ok(bytes) if bytes == MANIFEST => println!(" its own package reads"), + other => red.push(format!("its own manifest did not read back: {other:?}")), + } + match fs::write(format!("{PACKAGE}/made"), b"x") { + Err(e) if e.kind() == ErrorKind::PermissionDenied => println!(" std's write into its package: refused"), + other => red.push(format!("std's write into its own package was answered {other:?}")), + } + + let names = toyos::endow::namespace().expect("an app is endowed a namespace"); + match Dir::connect(names, &format!("fs:{PACKAGE}")) { + Err(e) => red.push(format!("fs:{PACKAGE} would not connect: {e:?}")), + Ok(mut dir) => { + if dir.writable() { + red.push(format!("fs:{PACKAGE} says it is writable")); + } + match dir.open("manifest.toml", O_READ) { + Ok(opened) => dir.close(opened.fid, opened.generation), + Err(e) => red.push(format!("an open to read its manifest was refused: {e:?}")), + } + let denied = Refused::Error(SyscallError::PermissionDenied); + let mut each = |what: &str, answer: Result<(), Refused>| match answer { + Err(e) if e == denied => println!(" {what}: refused"), + other => red.push(format!("{what} in its own package was answered {other:?}")), + }; + for (flags, what) in [ + (O_WRITE, "an open to write"), + (O_READ | O_APPEND, "an open to append"), + (O_READ | O_TRUNCATE, "an open to truncate"), + ] { + each(what, dir.open("manifest.toml", flags).map(|o| dir.close(o.fid, o.generation))); + } + each("an open to create", dir.open("made", O_WRITE | O_CREATE).map(|o| dir.close(o.fid, o.generation))); + each( + "an open to create anew", + dir.open("made", O_WRITE | O_CREATE_NEW).map(|o| dir.close(o.fid, o.generation)), + ); + each("mkdir", dir.mkdir("made")); + each("rmdir", dir.rmdir("sub")); + each("unlink", dir.unlink("manifest.toml")); + each("rename", dir.rename("manifest.toml", "moved")); + each("symlink", dir.symlink("manifest.toml", "link")); + } + } + match Dir::connect(names, &format!("fs:{HOME}")) { + Ok(dir) if dir.writable() => {} + Ok(_) => red.push(format!("fs:{HOME} says it is read-only")), + Err(e) => red.push(format!("fs:{HOME} would not connect: {e:?}")), + } + + for held in [ + "fs:/apps", + "fs:/apps/other", + "fs:/home", + "fs:/home/toy", + "fs:/home/toy/Apps", + "fs:/home/toy/Apps/other", + "fs:/config", + "fs:/state", + "fs:/log", + "fs:/boot", + ] { + match names.open(held) { + Err(_) => {} + Ok(_) => red.push(format!("it holds {held}")), + } + } + for file in [OTHER_PACKAGE_FILE, OTHER_FOLDER_FILE] { + match fs::read(file) { + Err(_) => {} + Ok(bytes) => red.push(format!("{file} read {} bytes", bytes.len())), + } + } + // A directory no capability names is a mount point of the kernel's, and + // lists nothing of what the file server holds under it. + for dir in ["/apps", "/home", "/home/toy", "/home/toy/Apps", "/config", "/state", "/log"] { + if let Ok(listing) = fs::read_dir(dir) { + let names: Vec<_> = listing.filter_map(Result::ok).map(|e| e.file_name()).collect(); + if !names.is_empty() { + red.push(format!("{dir} lists {names:?}")); + } + } + } + + if !red.is_empty() { + for line in &red { + println!(" RED {line}"); + } + std::process::exit(1); + } + println!(" every arm held"); +} diff --git a/tests/toyos.rs b/tests/toyos.rs index fc6d31ef4fb..008c522eced 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -126,6 +126,9 @@ const RUST_SKIP: &[&str] = &[ // A kernel primitive with no use for any one boot's devices: the // `port_badge` metal row runs it on tests/proctreecase. "port_badge", + // 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 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 @@ -744,6 +747,12 @@ const METAL: &[(&str, metal::Metal)] = &[ "fs_share", metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_fs_share") }, ), + ( + // An installed package sees its own directory read-only and its own + // folder as `HOME`, and nothing else of `/apps` or `/home`. + "app_view", + metal::Metal { arms: PROCTREECASE, judge: |b| app_view(b[0]) }, + ), // ---- one image: tests/metalcase, which runs no job ---- // // The rows after the first read what any boot that hands the machine back @@ -1002,8 +1011,9 @@ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/metalcase", & /// A launcher and a declared `cat` and shell, which `process_tree`'s subtree /// launches, a `toybox` row holding `roster`, which `launch_toctou` races, the -/// rows `launch_authority` is refused and started, and the shells `fs_share` -/// asks DATA's server through, under its share and in a login session. +/// rows `launch_authority` is refused and started, the shells `fs_share` +/// asks DATA's server through, under its share and in a login session, and +/// the `/apps` `app_view` launches its package from. const PROCTREECASE: &[metal::Arm] = &[metal::once( "proctreecase", "tests/proctreecase", @@ -1014,6 +1024,7 @@ const PROCTREECASE: &[metal::Arm] = &[metal::once( "test_rs_launch_authority", "test_rs_port_badge", "test_rs_fs_share", + "test_rs_app_view", ], )]; @@ -3557,6 +3568,28 @@ fn launch_authority(back: &metal::Readback) -> Result<(), String> { Ok(()) } +/// `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. +fn app_view(back: &metal::Readback) -> Result<(), String> { + back.job_passed("test_rs_app_view")?; + let log = back.log(); + let named = format!("supervisor: launcher: {}", toyos_manifest::row_named("shell")); + for said in [ + " every arm held", + "app_view: the app saw its own package read-only and its own folder as HOME, and nothing else", + &named, + "supervisor: launcher: appview was not started: /home/toy/Apps/appview could not be made: \ + /home/toy/Apps/appview is no directory", + ] { + if !log.text().lines().any(|l| l.contains(said)) { + return Err(format!("the log never said `{said}`\n{}", log.text())); + } + } + Ok(()) +} + /// A backing read after deletion is refused on both writable mounts, and a page-cache slot whose fill the device refused is unbound. fn read_fault_probes(log: &str) -> Result<(), String> { let probe = "revoke-selftest: /tmp/revoke_probe"; diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index 00ef5335115..13ffce90806 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -64,6 +64,22 @@ pub fn session_home() -> String { format!("/home/{USER}") } +/// An installed package's own folder in the session user's home: its `HOME`, +/// and the one directory of the home its view holds. +pub fn app_home(name: &str) -> String { + format!("{}/Apps/{name}", session_home()) +} + +/// Why a launch of the package `name` is refused when a row the image declares +/// has that name: [`app_home`] is a folder that row keeps its own in. +pub fn row_named(name: &str) -> String { + format!("the package {name} is named after a row the image declares, and {} is that row's folder", app_home(name)) +} + +/// What an app's own folder holds, made with it: where it keeps its config, +/// data, cache and state. English on disk, as every home folder is. +pub const APP_FOLDERS: [&str; 4] = ["Config", "Data", "Cache", "State"]; + /// Where each system service keeps its own persistent data, one directory per /// program key. pub const STATE: &str = "/state"; @@ -96,6 +112,42 @@ pub fn role_dirs(role: &str) -> Option<&'static [RoleDir]> { ROLES.iter().find(|(name, _)| *name == role).map(|(_, dirs)| *dirs) } +/// One directory capability in a program's view: the grant the supervisor +/// mints on `role`'s port (`toyos::fs::Grant`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct View { + pub role: &'static str, + /// What the program's namespace calls it, after `fs:`. + pub dir: String, + /// Where it is on the role's volume. + pub root: String, + /// Whether a request that changes what it holds is served. + pub write: bool, +} + +/// Every directory every role serves, each read-write: the view of a program +/// no narrower one is declared for. +pub fn whole_tree() -> Vec { + ROLES + .iter() + .flat_map(|(role, dirs)| { + dirs.iter().map(|d| View { role, dir: d.dir.to_string(), root: d.root.to_string(), write: true }) + }) + .collect() +} + +/// The DATA directory `dir`, beneath one DATA serves. +fn data_dir(dir: String, write: bool) -> View { + let role = "data"; + let parent = role_dirs(role) + .expect("DATA is a role") + .iter() + .find(|d| dir.strip_prefix(d.dir).is_some_and(|rest| rest.starts_with('/'))) + .unwrap_or_else(|| panic!("manifest: {dir} is beneath no directory DATA serves")); + let root = format!("{}{}", parent.root, &dir[parent.dir.len()..]); + View { role, dir, root, write } +} + /// How often a `restart` row is started again before the supervisor gives up on it: at /// most this many ends inside [`RESTART_WINDOW_SECS`]. Past it the row's ports /// close, and a client's next connection is answered `Gone`. @@ -221,12 +273,32 @@ impl Program { self.slots || self.receives.iter().any(|r| r == SWAP_PORT) } + /// The installed package this row launches: a row [`Manifest::app_row`] + /// made. + pub fn package(&self) -> Option<&str> { + package::package_of(&self.path) + } + /// The `HOME` the supervisor starts this row with. A location grants nothing: what /// the program can reach is its view's business, never this string's. pub fn home(&self) -> String { - match self.service { - true => format!("{STATE}/{}", self.name), - false => session_home(), + match (self.service, self.package()) { + (true, _) => format!("{STATE}/{}", self.name), + (false, Some(name)) => app_home(name), + (false, None) => session_home(), + } + } + + /// 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`. + /// 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)], + None => whole_tree(), } } } @@ -239,10 +311,11 @@ pub struct Manifest { /// Names the supervisor serves itself. The supervisor is in every image and is no `[programs]` /// key, so these have no declaration to come from. pub supervisor_serves: Vec, - /// The namespace every program launched from `/apps` is given: connectors, - /// and nothing else. A package directory is writable, so this row is the - /// image's rather than the package's — which is why a device class and a - /// `syscap` right have no spelling on the package side at all. + /// The connectors every program launched from `/apps` is given beside its + /// view ([`Program::view`]), and nothing else. `/apps` is writable to + /// the installer, so this row is the image's rather than the package's — + /// which is why a device class and a `syscap` right have no spelling on + /// the package side at all. pub apps: Vec, /// Program names, in the order `[boot] start` gave them — which orders /// nothing, because every port exists before any server runs. @@ -255,14 +328,19 @@ impl Manifest { } /// The row a launch of an installed package is built from: synthesized, - /// because a package has no `[programs]` key to hold one. - pub fn app_row(&self, name: &str, program: &str) -> Program { - Program { + /// because a package has no `[programs]` key to hold one. **A package + /// named after a row the image declares is refused** ([`row_named`]): its + /// folder of the home would be that row's. + pub fn app_row(&self, name: &str, program: &str) -> Result { + if self.program(name).is_some() { + return Err(row_named(name)); + } + Ok(Program { name: name.to_string(), path: program.to_string(), receives: self.apps.clone(), ..Program::default() - } + }) } /// Every `serves` name in the whole manifest, not only the ones [`start`] @@ -514,7 +592,7 @@ mod tests { /// row's construction rather than by a check. #[test] fn a_package_row_is_connectors_and_nothing_else() { - let row = sample().app_row("gbae", "/apps/gbae/gbae"); + let row = sample().app_row("gbae", "/apps/gbae/gbae").unwrap(); assert_eq!(row.receives, ["compositor", "soundserver"]); assert!(row.devices.is_empty()); assert!(row.syscap.is_empty()); @@ -550,10 +628,81 @@ mod tests { let m = sample(); assert_eq!(m.program("soundserver").unwrap().home(), "/state/soundserver"); assert_eq!(m.program("terminal").unwrap().home(), "/home/toy"); - assert_eq!(m.app_row("gbae", "/apps/gbae/gbae").home(), "/home/toy"); let m = parse("program sshserver /system/bin/sshserver\nservice\nprogram shell /system/bin/shell\n"); assert!(m.program("sshserver").unwrap().service); assert!(!m.program("shell").unwrap().service); + // The shell keeps its history under the session's home, in its own + // `Apps/shell` (`OWN_FOLDER` in `userland/shell`). + assert_eq!(m.program("shell").unwrap().home(), "/home/toy"); + } + + /// **An installed package's `HOME` is its own folder** of the session + /// user's home, the layout's `/home//Apps/`. + #[test] + fn a_package_s_home_is_its_own_folder() { + let row = sample().app_row("gbae", "/apps/gbae/gbae").unwrap(); + assert_eq!(row.package(), Some("gbae")); + assert_eq!(row.home(), "/home/toy/Apps/gbae"); + assert_eq!(sample().program("compositor").unwrap().package(), None); + } + + /// **A package named after a declared row is refused**, by name: the + /// shell keeps its history in `Apps/shell`, which would be the package's + /// folder. + #[test] + fn a_package_named_after_a_declared_row_is_refused() { + let m = parse("program shell /system/bin/shell\n"); + assert_eq!(m.app_row("shell", "/apps/shell/shell"), Err(row_named("shell"))); + assert!(row_named("shell").contains("/home/toy/Apps/shell")); + let s = sample(); + for row in &s.programs { + assert_eq!(s.app_row(&row.name, &format!("/apps/{0}/{0}", row.name)), Err(row_named(&row.name))); + } + assert!(m.app_row("gbae", "/apps/gbae/gbae").is_ok()); + } + + fn views(row: &Program) -> Vec<(&'static str, String, String, bool)> { + row.view().into_iter().map(|v| (v.role, v.dir, v.root, v.write)).collect() + } + + /// **An installed package sees its own directory read-only and its own + /// folder read-write, and nothing else any role serves.** Spelled out + /// whole, so a third directory, a wider root or a writable package is red. + #[test] + fn a_package_s_view_is_its_own_directory_read_only_and_its_own_folder() { + let row = sample().app_row("gbae", "/apps/gbae/gbae").unwrap(); + assert_eq!( + views(&row), + [ + ("data", "/apps/gbae".into(), "apps/gbae".into(), false), + ("data", "/home/toy/Apps/gbae".into(), "home/toy/Apps/gbae".into(), true), + ] + ); + // The longest name a package has is still beneath its own directories. + let longest = "n".repeat(MAX_PROGRAM_NAME); + let row = sample().app_row(&longest, &format!("/apps/{longest}/{longest}")).unwrap(); + assert_eq!( + views(&row).into_iter().map(|(_, _, root, write)| (root, write)).collect::>(), + [(format!("apps/{longest}"), false), (format!("home/toy/Apps/{longest}"), true)] + ); + } + + /// 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. + #[test] + fn every_declared_row_sees_the_whole_tree_read_write() { + let m = parse("program pkg /system/bin/pkg\nprogram shell /system/bin/shell\n"); + let whole: Vec<_> = ["/apps", "/config", "/home", "/state", "/log", "/boot"] + .iter() + .zip(["apps", "config", "home", "state", "", ""]) + .zip(["data", "data", "data", "data", "log", "boot"]) + .map(|((dir, root), role)| (role, dir.to_string(), root.to_string(), true)) + .collect(); + let s = sample(); + for row in [m.program("pkg").unwrap(), m.program("shell").unwrap(), s.program("compositor").unwrap()] { + assert_eq!(views(row), whole, "{}", row.name); + } } #[test] diff --git a/toyos-manifest/src/package.rs b/toyos-manifest/src/package.rs index 08e3182d514..b616fc62b24 100644 --- a/toyos-manifest/src/package.rs +++ b/toyos-manifest/src/package.rs @@ -4,8 +4,8 @@ //! to resolve a launch, so the format lives beside [`crate::Manifest`] for the //! same reason: one renderer, one parser, one round-trip test. //! -//! **Nothing here is a grant.** `/apps` is writable to every program that can -//! name it, so a manifest is a peer's claim about itself: it says which binary +//! **Nothing here is a grant.** `/apps` is writable to every row the image +//! declares, so a manifest is a peer's claim about itself: it says which binary //! *of its own directory* a launch starts. A device, a right and another //! package's binary have no spelling in this file at all. //! diff --git a/toyos/src/fs.rs b/toyos/src/fs.rs index 5c6b96cb7fa..303fc2e9361 100644 --- a/toyos/src/fs.rs +++ b/toyos/src/fs.rs @@ -4,8 +4,8 @@ //! **A directory capability is a connector in the program's namespace**, named //! [`CAPABILITY_PREFIX`] and the absolute directory it serves (`fs:/home`). //! Each is a connector to its role's one port, which the supervisor minted with -//! a [`Grant`]: the directory, and whose share of the server it spends. The -//! kernel stamps that on every connection made through it and answers it to the +//! a [`Grant`]: the directory, whether it may be changed, and whose share of +//! the server it spends. The kernel stamps that on every connection made through it and answers it to the //! port's acceptor alone, so the server reads what was granted off the //! connection and nothing the client says. A program names a file only under a //! directory it holds, and the kernel's part is who holds which connector. @@ -152,44 +152,64 @@ pub struct Grant<'a> { /// one service it starts itself or one login session, and for every /// launch made from it that opens no session. pub share: u64, + /// Whether a request that changes what the directory holds is served. + pub access: Access, /// The directory, as a path on the role's volume, every path on the /// connection is resolved beneath: `home`, or the empty path for a volume /// served whole. [`canonical`], and at most [`MAX_GRANT_ROOT`] bytes. pub root: &'a str, } +/// What a [`Grant`] lets its holder do to the directory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + /// Read, list and stat; every request that would change what the + /// directory holds is refused `PermissionDenied`. + ReadOnly, + ReadWrite, +} + /// The format [`Grant::encode`] writes. Carried because a swap replaces a file /// server and not the supervisor, so one server reads grants another build /// minted, and an older one is refused by name rather than read as this one. -const GRANT_VERSION: u8 = 2; +const GRANT_VERSION: u8 = 3; -/// The longest root a grant carries: what one badge holds past the version and -/// the share. -pub const MAX_GRANT_ROOT: usize = MAX_BADGE - 1 - 8; +/// The longest root a grant carries: what one badge holds past the version, +/// the share and the access. +pub const MAX_GRANT_ROOT: usize = MAX_BADGE - 1 - 8 - 1; impl<'a> Grant<'a> { - /// The version, the share, then the root: `None` for a root no grant - /// can carry. + /// The version, the share, the access, then the root: `None` for a root + /// no grant can carry. pub fn encode<'b>(&self, out: &'b mut [u8; MAX_BADGE]) -> Option<&'b [u8]> { if self.root.len() > MAX_GRANT_ROOT || !canonical(self.root) { return None; } out[0] = GRANT_VERSION; out[1..9].copy_from_slice(&self.share.to_le_bytes()); - let end = 9 + self.root.len(); - out[9..end].copy_from_slice(self.root.as_bytes()); + out[9] = match self.access { + Access::ReadOnly => 0, + Access::ReadWrite => 1, + }; + let end = 10 + self.root.len(); + out[10..end].copy_from_slice(self.root.as_bytes()); Some(&out[..end]) } /// `None` for bytes [`Self::encode`] cannot have written. pub fn decode(bytes: &'a [u8]) -> Option { let (&version, rest) = bytes.split_first()?; - if version != GRANT_VERSION || rest.len() < 8 || rest.len() - 8 > MAX_GRANT_ROOT { + if version != GRANT_VERSION || rest.len() < 9 || rest.len() - 9 > MAX_GRANT_ROOT { return None; } let share = u64::from_le_bytes(rest[..8].try_into().expect("eight bytes")); - let root = core::str::from_utf8(&rest[8..]).ok()?; - canonical(root).then_some(Self { share, root }) + let access = match rest[8] { + 0 => Access::ReadOnly, + 1 => Access::ReadWrite, + _ => return None, + }; + let root = core::str::from_utf8(&rest[9..]).ok()?; + canonical(root).then_some(Self { share, access, root }) } } @@ -625,10 +645,12 @@ mod tests { fn a_grant_round_trips_at_every_bound() { let longest = "r".repeat(MAX_GRANT_ROOT); for (share, root) in [(0, ""), (1, "home"), (u64::MAX, "home/toy/Documents"), (7, longest.as_str())] { - let grant = Grant { share, root }; - let mut out = [0u8; MAX_BADGE]; - let bytes = grant.encode(&mut out).expect("a root a grant carries"); - assert_eq!(Grant::decode(bytes), Some(grant), "{root:?}"); + for access in [Access::ReadOnly, Access::ReadWrite] { + let grant = Grant { share, access, root }; + let mut out = [0u8; MAX_BADGE]; + let bytes = grant.encode(&mut out).expect("a root a grant carries"); + assert_eq!(Grant::decode(bytes), Some(grant), "{root:?} {access:?}"); + } } } @@ -637,30 +659,38 @@ mod tests { let mut out = [0u8; MAX_BADGE]; let past = "r".repeat(MAX_GRANT_ROOT + 1); for root in ["/home", "home/", "a//b", ".", "a/../b", past.as_str()] { - assert_eq!(Grant { share: 1, root }.encode(&mut out), None, "{root:?}"); + assert_eq!(Grant { share: 1, access: Access::ReadWrite, root }.encode(&mut out), None, "{root:?}"); } } #[test] fn bytes_no_grant_was_encoded_as_are_refused() { let mut out = [0u8; MAX_BADGE]; - let good = Grant { share: 3, root: "home" }.encode(&mut out).unwrap().to_vec(); - // Shorter than a version and a share. - for n in 0..9 { + let good = Grant { share: 3, access: Access::ReadOnly, root: "home" }.encode(&mut out).unwrap().to_vec(); + // Shorter than a version, a share and an access. + for n in 0..10 { assert_eq!(Grant::decode(&good[..n]), None, "{n} bytes"); } - // Another version. - let mut other = good.clone(); - other[0] = GRANT_VERSION + 1; - assert_eq!(Grant::decode(&other), None); + // Another version, and the one before this. + for version in [GRANT_VERSION - 1, GRANT_VERSION + 1] { + let mut other = good.clone(); + other[0] = version; + assert_eq!(Grant::decode(&other), None, "version {version}"); + } + // An access byte that is neither, which is never read as either. + for access in [2, 0x80, 0xff] { + let mut other = good.clone(); + other[9] = access; + assert_eq!(Grant::decode(&other), None, "access {access}"); + } // A root the wire refuses, or not UTF-8. for root in [&b"/home"[..], b"a/../b", b"a//b", b"\xff"] { - let mut bad = good[..9].to_vec(); + let mut bad = good[..10].to_vec(); bad.extend_from_slice(root); assert_eq!(Grant::decode(&bad), None, "{root:?}"); } // One byte past the longest root. - let mut long = good[..9].to_vec(); + let mut long = good[..10].to_vec(); long.extend(core::iter::repeat_n(b'r', MAX_GRANT_ROOT + 1)); assert_eq!(Grant::decode(&long), None); } diff --git a/userland/fileserver/src/lib.rs b/userland/fileserver/src/lib.rs index 1dc8bb7b990..0c29712db4d 100644 --- a/userland/fileserver/src/lib.rs +++ b/userland/fileserver/src/lib.rs @@ -2,7 +2,8 @@ //! cache every byte of its volume passes through ([`cache`]), the volumes it //! can serve ([`data`] for the bcachefs DATA role, [`fat`] for FAT32's LOG and //! BOOT, [`absent`] for a role with no volume this boot), and the resolver that -//! keeps every path inside the directory a connection was given ([`resolve`]). +//! keeps every path inside the directory a connection was given ([`resolve`]), +//! and which requests a read-only one is refused ([`rights`]). //! //! **This is the page cache.** A block of the volume — a btree node, a FAT //! sector, a file's data — is read into [`cache::Cache`] once and served from @@ -18,5 +19,6 @@ pub mod data; pub mod disk; pub mod fat; pub mod resolve; +pub mod rights; pub mod volume; pub mod writeback; diff --git a/userland/fileserver/src/main.rs b/userland/fileserver/src/main.rs index a5032365ea6..662115a1631 100644 --- a/userland/fileserver/src/main.rs +++ b/userland/fileserver/src/main.rs @@ -13,8 +13,9 @@ //! **A connection is what its grant says** (`toyos::fs::Grant`): the badge //! the supervisor minted its connector with, which the kernel stamped on it and //! answers this port's acceptor alone. Every path on it is resolved beneath -//! the grant's root (`fileserver::resolve`); a write on a read-only volume is -//! refused before the volume sees it. +//! the grant's root (`fileserver::resolve`); a request that would change what +//! it holds, on a read-only grant or a read-only volume, is refused before the +//! volume sees it (`fileserver::rights`). //! //! **One share cannot take the server.** Beneath each machine-wide bound — //! connections waiting on their hello, connections served, streams — each @@ -44,6 +45,7 @@ use fileserver::data::{DataVolume, Located, Probed}; use fileserver::disk::{Claimed, Disk, Ram, Served}; use fileserver::fat::FatVolume; use fileserver::resolve::{self, Found, Refusal as Escape, Resolved}; +use fileserver::rights; use fileserver::volume::{Kind, Meta, Node, OpenHow, Out, Volume}; use fileserver::writeback::WriteBack; use toyos::endow::{self, Endowments}; @@ -90,6 +92,10 @@ const _: () = assert!(1 + MAX_SERVED + MAX_HANDSHAKES + MAX_STREAMS <= Poller::M /// How long an accepted connection may take to lend its window. const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2); +/// Why a client asking for a request number the wire does not have is let go, +/// whatever its grant. +const NO_SUCH_OPERATION: &str = "it asked for an operation this protocol does not have"; + /// The volume memory stands in for when DATA has no partition: 1 GiB of /// blocks, of which only what is written costs anything. const RAM_BLOCKS: u64 = 1 << 18; @@ -138,6 +144,8 @@ struct Client { root: String, /// Its grant's share, which it spends. share: u64, + /// Its grant is read-write and the volume is: what it may change. + writes: bool, window: Option, fids: BTreeMap, next_fid: u64, @@ -455,6 +463,7 @@ impl Server { rx: ipc::FrameRx::new(), root: grant.root.to_string(), share: grant.share, + writes: grant.access == Access::ReadWrite && self.volume.writable(), window: None, fids: BTreeMap::new(), next_fid: 1, @@ -582,8 +591,8 @@ impl Server { Ok(window) => client.window = Some(window), Err(_) => return Answer::Drop("its window would not map"), } - let rights = if self.volume.writable() { RIGHT_WRITE } else { 0 }; - return Answer::Reply(Reply { value: rights, ..Reply::ok() }); + let granted = if client.writes { RIGHT_WRITE } else { 0 }; + return Answer::Reply(Reply { value: granted, ..Reply::ok() }); } if self.clients[&id].window.is_none() { return Answer::Drop("it asked before it lent a window"); @@ -613,10 +622,10 @@ impl Server { } fn serve_one(&mut self, id: u64, op: u32, r: Request) -> Result { - let changes = matches!(op, WRITE | TRUNCATE | MKDIR | RMDIR | UNLINK | RENAME | SYMLINK | STREAM) - || (op == OPEN && r.flags & (O_WRITE | O_APPEND | O_CREATE | O_TRUNCATE | O_CREATE_NEW) != 0); - if changes && !self.volume.writable() { - return Err(SyscallError::PermissionDenied); + match rights::changes(op, r.flags) { + None => return Ok(Answer::Drop(NO_SUCH_OPERATION)), + Some(true) if !self.clients[&id].writes => return Err(SyscallError::PermissionDenied), + Some(_) => {} } match op { OPEN => { @@ -826,7 +835,7 @@ impl Server { self.dirtied(); Ok(Answer::Reply(Reply::ok())) } - _ => Ok(Answer::Drop("it asked for an operation this protocol does not have")), + _ => Ok(Answer::Drop(NO_SUCH_OPERATION)), } } diff --git a/userland/fileserver/src/rights.rs b/userland/fileserver/src/rights.rs new file mode 100644 index 00000000000..474f8e3565c --- /dev/null +++ b/userland/fileserver/src/rights.rs @@ -0,0 +1,65 @@ +//! Which requests change what a connection's directory holds: each is refused +//! `PermissionDenied` before the volume sees it, on a connection whose grant is +//! read-only (`toyos::fs::Access`) or whose volume is. +//! +//! **Closed by default**: an operation this list does not name is one the +//! wire does not have, and its client is let go on every connection, so a +//! request added to the wire is served nowhere until it is named here. + +use toyos::fs::*; + +/// Whether request `op`, with an open's `flags`, may change what the directory +/// holds: `None` for an operation the wire does not have. +pub fn changes(op: u32, flags: u64) -> Option { + match op { + OPEN => Some(flags & (O_WRITE | O_APPEND | O_CREATE | O_TRUNCATE | O_CREATE_NEW) != 0), + HELLO | CLOSE | READ | STAT | LSTAT | FSTAT | FSYNC | SYNC | READDIR | READLINK => Some(false), + WRITE | TRUNCATE | MKDIR | RMDIR | UNLINK | RENAME | SYMLINK | STREAM => Some(true), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every request the wire has, by what it does to the directory: the + /// independent spelling `changes` is held to. + const READS: [u32; 10] = [HELLO, CLOSE, READ, STAT, LSTAT, FSTAT, FSYNC, SYNC, READDIR, READLINK]; + const WRITES: [u32; 8] = [WRITE, TRUNCATE, MKDIR, RMDIR, UNLINK, RENAME, SYMLINK, STREAM]; + + #[test] + fn every_request_that_writes_changes_the_directory_and_none_that_reads_does() { + for op in WRITES { + assert_eq!(changes(op, 0), Some(true), "request {op} writes"); + } + for op in READS { + assert_eq!(changes(op, 0), Some(false), "request {op} only reads"); + } + // Every request number the wire has is one of the two, or `OPEN`. + let mut named: Vec = READS.iter().chain(&WRITES).copied().chain([OPEN]).collect(); + named.sort_unstable(); + assert_eq!(named, (HELLO..=SYNC).collect::>()); + } + + /// An open changes the directory by any one flag that writes, creates or + /// truncates, alone or with a read. + #[test] + fn an_open_changes_the_directory_by_every_flag_but_read() { + assert_eq!(changes(OPEN, O_READ), Some(false)); + assert_eq!(changes(OPEN, 0), Some(false)); + for flag in [O_WRITE, O_APPEND, O_CREATE, O_TRUNCATE, O_CREATE_NEW] { + assert_eq!(changes(OPEN, flag), Some(true), "flag {flag}"); + assert_eq!(changes(OPEN, flag | O_READ), Some(true), "flag {flag} with a read"); + } + } + + /// A request number the wire does not have is named neither, so its + /// client is let go whatever its grant. + #[test] + fn a_request_the_wire_does_not_have_is_neither() { + for op in [0, SYNC + 1, REPLY, LINK, u32::MAX] { + assert_eq!(changes(op, 0), None, "request {op}"); + } + } +} diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 888a981816a..62fcba2c79c 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -42,8 +42,15 @@ //! ([`FILES_BOUND`]). //! //! **Every program it starts gets `HOME` from its row** (`Program::home`), over -//! anything a launching caller carried: a service its own `/state/`, made -//! before it runs, and everything else the session user's home, made at boot. +//! anything a launching caller carried: a service its own `/state/` and +//! an installed package its own `Apps/` folder of the session user's +//! home, each made before it runs, and everything else the session user's +//! home, made at boot. +//! +//! **Every program it starts holds the directories its row's view names** +//! (`Program::view`), each a grant minted for that start ([`Grants`]): an +//! installed package its own directory read-only and its own folder +//! read-write, and every other row the whole tree. //! 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. //! @@ -70,9 +77,9 @@ use toyos_swap::{Refusal, Request as SwapRequest, Word}; use toyos_manifest::launch::{self as authority, Authority, Session, Sessions, Target}; use toyos_manifest::package::{self, Package}; -use toyos_manifest::{Manifest, Program}; +use toyos_manifest::{Manifest, Program, View}; use toyos::endow::Endowments; -use toyos::fs::{Grant, CAPABILITY_PREFIX}; +use toyos::fs::{Access, Grant, CAPABILITY_PREFIX}; use toyos::ipc::{self, Connection, RxStep}; use toyos::launch::{self, Parent, Request, LAUNCHER}; use toyos::namespace::{self, Namespace}; @@ -383,9 +390,11 @@ fn main() { // process nobody endows a namespace: std resolves through this one, and // the stop's syncs through the second. let files: &'static Namespace = { - let own: Vec<(String, Connector)> = role_acceptors + let whole = toyos_manifest::whole_tree(); + let own: Vec<(String, Connector)> = whole .iter() - .flat_map(|(role, acceptor)| mint_grants(role, acceptor, authority::SUPERVISOR_SHARE)) + .filter_map(|view| Some((role_acceptors.get(view.role)?, view))) + .map(|(acceptor, view)| mint(acceptor, authority::SUPERVISOR_SHARE, view)) .collect(); let build = || { let mut builder = namespace::build(); @@ -839,6 +848,30 @@ impl<'a> Supervisor<'a> { } } + /// An installed package's `HOME`, its folder of the session's home, and + /// its [`toyos_manifest::APP_FOLDERS`], made before it runs. `Err` is why + /// one is not a directory, and the launch is refused: its grant would + /// name nothing, or something else than a folder. + fn make_app_home(&mut self, program: &Program) -> Result<(), String> { + let home = program.home(); + let asked = home.clone(); + let made = self.files("an app's home", move || { + let folders = toyos_manifest::APP_FOLDERS.iter().map(|folder| format!("{asked}/{folder}")); + std::iter::once(asked.clone()).chain(folders).try_for_each(|dir| { + make_dir(&dir)?; + match std::fs::symlink_metadata(&dir)?.is_dir() { + true => Ok(()), + false => Err(std::io::Error::other(format!("{dir} is no directory"))), + } + }) + }); + match made { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(format!("{home} could not be made: {e}")), + Err(why) => Err(format!("{home} was not made: {why}")), + } + } + /// `work`, a call into the file servers, made on [`Worker`], and its /// answer — with every server that ends meanwhile started again, so a call /// its end left waiting in the port's queue goes on to the new process. @@ -1602,6 +1635,13 @@ impl Supervisor<'_> { // `inherit_handle` duplicates into the child, so the supervisor's own copies go with // `slots` when this returns. self.make_home(program); + if program.package().is_some() { + if let Err(why) = self.make_app_home(program) { + say!("supervisor: launcher: {} was not started: {why}", program.name); + let _ = conn.try_signal(launch::MSG_REFUSED); + return; + } + } let started = start( command, program, @@ -1737,7 +1777,10 @@ fn resolve<'a, V>(system: &'a Manifest, path: &str, judge: impl FnOnce(Target<'_ installed.program )); } - let row = system.app_row(name, path); + let row = match system.app_row(name, path) { + Ok(row) => row, + Err(why) => return Resolved::Refused(why), + }; let verdict = judge(Target::Package(&row)); Resolved::Package(row, verdict) } @@ -2241,7 +2284,7 @@ fn build_namespace( /// file-server role's port. /// /// **Each start is minted grants naming its session's share** (`toyos::fs::Grant`, -/// [`Session::share`]), one per directory of every role: a service has a +/// [`Session::share`]), one per directory of its row's view: a service has a /// share of its own through every start of it, so the servers count it, every /// child it spawns directly and every launch made from it that opens no /// session against one share, and a login session's processes against one @@ -2253,45 +2296,46 @@ struct Grants<'a> { impl Grants<'_> { /// The directory capabilities `program` is endowed for one start in - /// `session`, by namespace name. - /// - /// **Every program sees the whole tree the file servers serve**, which is - /// the kernel's old view kept whole until each row declares its own - /// (`issues/every-program-sees-only-the-files-it-was-given.md`, stage 2), - /// with one exception: a storage row sees none, since a file server - /// resolving a path of its own through itself waits for ever. Asked before - /// anything is locked, since a storage row's start holds its own kept state. + /// `session`, by namespace name: its row's view (`Program::view`), but + /// that a storage row sees none, since a file server resolving a path of + /// its own through itself waits for ever. Asked before anything is locked, + /// since a storage row's start holds its own kept state. fn view(&self, program: &Program, session: Session) -> Vec<(String, Connector)> { if is_storage(program) { return Vec::new(); } + let wanted = program.view(); let mut view = Vec::new(); for (role, kept) in &self.roles { let kept = kept.lock().expect("supervisor: a service's state is poisoned"); - if let Some((_, acceptor)) = kept.acceptors.first() { - view.extend(mint_grants(role, acceptor, session.share())); + let Some((_, acceptor)) = kept.acceptors.first() else { continue }; + for dir in wanted.iter().filter(|dir| dir.role == *role) { + view.push(mint(acceptor, session.share(), dir)); } } view } } -/// A grant on `acceptor`, the `role`'s port, for each of its directories, -/// naming `share`: each by the namespace name a program opens it under. -fn mint_grants(role: &str, acceptor: &Acceptor, share: u64) -> Vec<(String, Connector)> { - let dirs = toyos_manifest::role_dirs(role).expect("supervisor: the build refuses a role it does not know"); - dirs.iter() - .map(|dir| { - let mut badge = [0u8; MAX_BADGE]; - let badge = Grant { share, root: dir.root } - .encode(&mut badge) - .unwrap_or_else(|| panic!("supervisor: {}'s root {:?} is no grant's", dir.dir, dir.root)); - let connector = acceptor - .mint(badge) - .unwrap_or_else(|e| panic!("supervisor: no grant on {} for share {share}: {e:?}", dir.dir)); - (format!("{CAPABILITY_PREFIX}{}", dir.dir), connector) - }) - .collect() +/// The longest root a view names, a package's folder of the home at the +/// longest name a package has, is one a grant carries. +const _: () = assert!( + "home/".len() + toyos_manifest::USER.len() + "/Apps/".len() + toyos_manifest::MAX_PROGRAM_NAME + <= toyos::fs::MAX_GRANT_ROOT +); + +/// 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) { + let access = if dir.write { Access::ReadWrite } else { Access::ReadOnly }; + let mut badge = [0u8; MAX_BADGE]; + let badge = Grant { share, access, root: &dir.root } + .encode(&mut badge) + .unwrap_or_else(|| panic!("supervisor: {}'s root {:?} is no grant's, past the bound asserted above", dir.dir, dir.root)); + let connector = acceptor + .mint(badge) + .unwrap_or_else(|e| panic!("supervisor: no grant on {} for share {share}: {e:?}", dir.dir)); + (format!("{CAPABILITY_PREFIX}{}", dir.dir), connector) } /// [`toyos_swap::PORT`] in a namespace of its own, for a program whose row