From bb091478ffad6957dc7e0b610364c6eaeda2a26a Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 16:35:14 +0200 Subject: [PATCH 1/4] An installed app sees its own package read-only and its own folder as HOME, and nothing else of /apps or /home A file grant carries its access: `toyos::fs::Grant` gains `Access` (read-only or read-write), and the badge format goes to version 3, with the access byte between the share and the root. fileserver refuses `PermissionDenied`, before the volume sees it, every request that would change what a read-only connection holds: an open to write, append, truncate, create or create anew, WRITE, TRUNCATE, MKDIR, RMDIR, UNLINK, RENAME, SYMLINK and STREAM. The list is `fileserver::rights::changes`, closed by default, so a request the wire gains is refused on a read-only connection until it is named as a read. HELLO's RIGHT_WRITE answers the grant's access and the volume's together. A row's view is `toyos_manifest::Program::view`. A package launched from /apps is minted two grants on DATA: `apps/` read-only, and `home/toy/Apps/` read-write; nothing of /config, /state, /log or /boot, no other package and no other part of the home. Its HOME is that folder (`Program::home`), which the supervisor makes with Config, Data, Cache and State before every launch. `[apps]` keeps no directory of its own: it is connectors only. Every row the image declares, pkg and the shell among them, keeps the whole tree read-write, as the package track rules the installer holds nothing a shell does not. A grant the package's name puts past the badge is a refused launch, never a supervisor panic. A running swap of fileserver across this change is refused in effect: a server of one side reads the other's grants as no grant and lets every connection go by name. The change lands by image. The `app_view` machine test boots tests/proctreecase, whose test-runner row now lists /apps, installs itself as a package and launches it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...der-apps-and-the-installer-is-a-program.md | 7 +- ...rogram-sees-only-the-files-it-was-given.md | 7 + issues/where-everything-lives.md | 4 + system.toml | 3 +- tests/proctreecase/system.toml | 6 +- tests/toyos-rust-tests/src/bin/app_view.rs | 200 ++++++++++++++++++ tests/toyos.rs | 23 ++ toyos-manifest/src/lib.rs | 140 +++++++++++- toyos-manifest/src/package.rs | 4 +- toyos/src/fs.rs | 84 +++++--- userland/fileserver/src/lib.rs | 4 +- userland/fileserver/src/main.rs | 17 +- userland/fileserver/src/rights.rs | 64 ++++++ userland/supervisor/src/main.rs | 99 +++++---- 14 files changed, 570 insertions(+), 92 deletions(-) create mode 100644 tests/toyos-rust-tests/src/bin/app_view.rs create mode 100644 userland/fileserver/src/rights.rs 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..79848126c74 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 @@ -93,9 +93,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/every-program-sees-only-the-files-it-was-given.md b/issues/every-program-sees-only-the-files-it-was-given.md index daca0b4949f..c23bc09664d 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,13 @@ 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 (`toyos_manifest::Program::view`). 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. 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..49da6bb54bc 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` guest test). 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/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..da1983d5d34 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`, under QEMU: 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. [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..a0439158c3d --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/app_view.rs @@ -0,0 +1,200 @@ +//! 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`. 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"; +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() { + let _ = fs::remove_dir_all(PACKAGE); + 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"); + 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 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)); + let mut red = Vec::new(); + 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, "/apps/other", "/home/toy/Apps/other"] { + 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 fee49f300f0..69a5b7ef389 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` machine test 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 @@ -316,6 +319,11 @@ const MACHINE_TESTS: &[&str] = &[ // reads it have no host build, and the T14 boots from a stick beside an // NVMe disk that is another system's. "nvme_disk_keeps_log_and_home", + // What an installed package's view holds, and that its own directory is + // read-only: the supervisor's grants and the file server's refusals, + // neither of which has a host build. Under QEMU and not as a metal row, + // so the merge queue's guest suite holds the boundary on every change. + "app_view", ]; /// **The metal profile**: which registrations run on the ThinkPad T14, what @@ -3077,6 +3085,7 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { "bar_map_again" => bar_map_again(test_config), "console_image_boots" => console_image_boots(), "nvme_disk_keeps_log_and_home" => nvme_disk_keeps_log_and_home(test_config), + "app_view" => app_view(), other => Err(format!("unknown machine test {other}")), } } @@ -3116,6 +3125,20 @@ fn served_by_diskserver(qemu: &mut QemuInstance, console: &mut String) -> Result Ok(()) } +/// Boot `tests/proctreecase`, whose test-runner holds a launcher listing +/// `/apps`, and run `app_view` there: it installs itself as a package, +/// launches it, and the package judges its own view. +fn app_view() -> Result<(), String> { + const JOB: &str = "test_rs_app_view"; + let case = compile::repo_root().join("tests/proctreecase"); + let (_, job) = suite_bin(qemu::SUITE_ARCH, "app_view"); + let mut qemu = + QemuInstance::boot_with_options(&case, &[], &[("app_view".to_string(), job)], BootOptions::default()); + let said = job_said(&mut qemu, JOB)?; + eprintln!("{said}"); + Ok(()) +} + /// Run `command` as a job of the boot, to exit 0: what it said. fn job_said(qemu: &mut QemuInstance, command: &str) -> Result { let result = qemu.run_test(command, Duration::from_secs(60)); diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index 00ef5335115..136603f472c 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -64,6 +64,16 @@ 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()) +} + +/// 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 +106,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 +267,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 +305,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. @@ -550,10 +617,67 @@ 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"); + assert_eq!(row.package(), Some("gbae")); + assert_eq!(row.home(), "/home/toy/Apps/gbae"); + assert_eq!(APP_FOLDERS, ["Config", "Data", "Cache", "State"]); + assert_eq!(sample().program("compositor").unwrap().package(), None); + } + + 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"); + 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}")); + 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..528372ac7b0 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}; @@ -138,6 +140,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 +459,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 +587,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,9 +618,7 @@ 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() { + if rights::changes(op, r.flags) && !self.clients[&id].writes { return Err(SyscallError::PermissionDenied); } match op { diff --git a/userland/fileserver/src/rights.rs b/userland/fileserver/src/rights.rs new file mode 100644 index 00000000000..036a43ac8aa --- /dev/null +++ b/userland/fileserver/src/rights.rs @@ -0,0 +1,64 @@ +//! 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 as one that +//! only reads is one that changes, so a request added to the wire is refused on +//! a read-only connection until it is named here. + +use toyos::fs::*; + +/// Whether request `op`, with an open's `flags`, may change what the directory +/// holds. +pub fn changes(op: u32, flags: u64) -> bool { + match op { + OPEN => flags & (O_WRITE | O_APPEND | O_CREATE | O_TRUNCATE | O_CREATE_NEW) != 0, + HELLO | CLOSE | READ | STAT | LSTAT | FSTAT | FSYNC | SYNC | READDIR | READLINK => false, + _ => true, + } +} + +#[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!(changes(op, 0), "request {op} writes"); + } + for op in READS { + assert!(!changes(op, 0), "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!(!changes(OPEN, O_READ)); + assert!(!changes(OPEN, 0)); + for flag in [O_WRITE, O_APPEND, O_CREATE, O_TRUNCATE, O_CREATE_NEW] { + assert!(changes(OPEN, flag), "flag {flag}"); + assert!(changes(OPEN, flag | O_READ), "flag {flag} with a read"); + } + } + + /// A request number the wire does not have is refused on a read-only + /// connection, never served as a read. + #[test] + fn a_request_the_wire_does_not_have_changes_the_directory() { + for op in [0, SYNC + 1, REPLY, LINK, u32::MAX] { + assert!(changes(op, 0), "request {op}"); + } + } +} diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 888a981816a..c5e50e840a8 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,14 @@ 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) + .unwrap_or_else(|why| panic!("supervisor: no grant of its own: {why}")) + }) .collect(); let build = || { let mut builder = namespace::build(); @@ -825,14 +837,22 @@ impl<'a> Supervisor<'a> { } } - /// A service's own `HOME`, made before it first runs. + /// A service's own `HOME`, or an installed package's and its + /// [`toyos_manifest::APP_FOLDERS`], made before it runs. fn make_home(&mut self, program: &Program) { - if !program.service || is_storage(program) { - return; - } + let folders: &'static [&str] = match (program.service, program.package()) { + (true, _) if is_storage(program) => return, + (true, _) => &[], + (false, Some(_)) => &toyos_manifest::APP_FOLDERS, + (false, None) => return, + }; let home = program.home(); let asked = home.clone(); - match self.files("a service's home", move || make_dir(&asked)) { + let made = self.files("a program's home", move || { + make_dir(&asked)?; + folders.iter().try_for_each(|folder| make_dir(&format!("{asked}/{folder}"))) + }); + match made { Ok(Ok(())) => {} Ok(Err(e)) => say!("supervisor: {}: {home} could not be made: {e}", program.name), Err(why) => say!("supervisor: {}: {home} was not made: {why}", program.name), @@ -1907,7 +1927,7 @@ fn start<'a>( command.endow(&label, raw.0); held.0.push(raw); } - if let Some(ns) = build_namespace(program, system, connectors, grants.view(program, launcher.1), extras)? { + if let Some(ns) = build_namespace(program, system, connectors, grants.view(program, launcher.1)?, extras)? { let raw = ns.into_raw(); command.endow(SVC_LABEL, raw.0); held.0.push(raw); @@ -2241,7 +2261,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 +2273,40 @@ 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. - fn view(&self, program: &Program, session: Session) -> Vec<(String, Connector)> { + /// `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) -> std::io::Result> { if is_storage(program) { - return Vec::new(); + return Ok(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).map_err(std::io::Error::other)?); } } - view + Ok(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() +/// A grant on `acceptor`, `dir`'s role's port, naming `share`, by the +/// namespace name a program opens it under. A package's name is part of its +/// directories', so one no grant carries is refused, by name. +fn mint(acceptor: &Acceptor, share: u64, dir: &View) -> Result<(String, Connector), String> { + 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) + .ok_or_else(|| format!("{}'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)); + Ok((format!("{CAPABILITY_PREFIX}{}", dir.dir), connector)) } /// [`toyos_swap::PORT`] in a namespace of its own, for a program whose row From 1c8490e4ead054300bdb767d57145c55be411c07 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 17:30:58 +0200 Subject: [PATCH 2/4] A package named after a declared row, or whose folder is not made, is refused, and app_view is a proctreecase metal row Review of #807, round 1: - A package's folder is `/home/toy/Apps/`, and the shell keeps its history in `Apps/shell`. `Manifest::app_row` now refuses a package named after any row the image declares, by `row_named`, and the supervisor says it as the launch's refusal. - A package's launch is refused (`MSG_REFUSED`) when its folder or one of `APP_FOLDERS` is not a directory after the supervisor made it, the file worker's being busy included. A service's home keeps its old, logged path. - `app_view` moves from a QEMU machine test to a metal row on `tests/proctreecase`, whose judge reads the job's pass, `every arm held`, its closing line, and the supervisor's two refusals; the job gains both refusal arms. - fileserver recognises a request before it judges the grant: a number the wire does not have drops the client on every connection. - `Grants::view` and `mint` cannot fail: a compile-time assertion holds the longest package folder within `MAX_GRANT_ROOT`. - The tautological `APP_FOLDERS` assertion goes; the isolation track says what a package still reaches (`/tmp`, `/system`). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...rogram-sees-only-the-files-it-was-given.md | 9 +- issues/where-everything-lives.md | 2 +- issues/whether-pkg-alone-writes-apps.md | 33 +++++++ tests/proctreecase/system.toml | 6 +- tests/toyos-rust-tests/src/bin/app_view.rs | 36 +++++++- tests/toyos.rs | 56 +++++++----- toyos-manifest/src/lib.rs | 43 ++++++++-- userland/fileserver/src/main.rs | 12 ++- userland/fileserver/src/rights.rs | 37 ++++---- userland/supervisor/src/main.rs | 85 +++++++++++++------ 10 files changed, 227 insertions(+), 92 deletions(-) create mode 100644 issues/whether-pkg-alone-writes-apps.md 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 c23bc09664d..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 @@ -98,9 +98,12 @@ nameable by every program and make confused-deputy bugs structural. 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 (`toyos_manifest::Program::view`). 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. + 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 49da6bb54bc..448bd09329b 100644 --- a/issues/where-everything-lives.md +++ b/issues/where-everything-lives.md @@ -82,7 +82,7 @@ until that lands nothing verifies it. 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` guest test). Each desktop app in the image still + 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 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/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index da1983d5d34..48779a41b24 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -18,9 +18,9 @@ # 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`, under QEMU: 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 `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"] diff --git a/tests/toyos-rust-tests/src/bin/app_view.rs b/tests/toyos-rust-tests/src/bin/app_view.rs index a0439158c3d..e299fbfb9d5 100644 --- a/tests/toyos-rust-tests/src/bin/app_view.rs +++ b/tests/toyos-rust-tests/src/bin/app_view.rs @@ -3,7 +3,10 @@ //! //! 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`. Run as the app (`app`), it asks: +//! 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`; @@ -34,6 +37,12 @@ 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"; @@ -47,20 +56,39 @@ fn main() { } fn job() { - let _ = fs::remove_dir_all(PACKAGE); + 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:?}")), + } + fs::write(HOME, b"no folder").expect("plant a file where the app's folder goes"); + 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)); - let mut red = Vec::new(); if ran.status.code() != Some(0) { red.push(format!("the app ended {:?}", ran.status)); } @@ -80,7 +108,7 @@ 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, "/apps/other", "/home/toy/Apps/other"] { + for dir in [PACKAGE, ROW_PACKAGE, "/apps/other", "/home/toy/Apps/other", HOME] { let _ = fs::remove_dir_all(dir); } if !red.is_empty() { diff --git a/tests/toyos.rs b/tests/toyos.rs index c8f87108648..008c522eced 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -127,7 +127,7 @@ const RUST_SKIP: &[&str] = &[ // `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` machine test runs it on tests/proctreecase. + // 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 @@ -334,11 +334,6 @@ const MACHINE_TESTS: &[&str] = &[ // reads it have no host build, and the T14 boots from a stick beside an // NVMe disk that is another system's. "nvme_disk_keeps_log_and_home", - // What an installed package's view holds, and that its own directory is - // read-only: the supervisor's grants and the file server's refusals, - // neither of which has a host build. Under QEMU and not as a metal row, - // so the merge queue's guest suite holds the boundary on every change. - "app_view", ]; /// **The metal profile**: which registrations run on the ThinkPad T14, what @@ -752,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 @@ -1010,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", @@ -1022,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", ], )]; @@ -3197,7 +3200,6 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { "bar_map_again" => bar_map_again(test_config), "console_image_boots" => console_image_boots(), "nvme_disk_keeps_log_and_home" => nvme_disk_keeps_log_and_home(test_config), - "app_view" => app_view(), other => Err(format!("unknown machine test {other}")), } } @@ -3237,20 +3239,6 @@ fn served_by_diskserver(qemu: &mut QemuInstance, console: &mut String) -> Result Ok(()) } -/// Boot `tests/proctreecase`, whose test-runner holds a launcher listing -/// `/apps`, and run `app_view` there: it installs itself as a package, -/// launches it, and the package judges its own view. -fn app_view() -> Result<(), String> { - const JOB: &str = "test_rs_app_view"; - let case = compile::repo_root().join("tests/proctreecase"); - let (_, job) = suite_bin(qemu::SUITE_ARCH, "app_view"); - let mut qemu = - QemuInstance::boot_with_options(&case, &[], &[("app_view".to_string(), job)], BootOptions::default()); - let said = job_said(&mut qemu, JOB)?; - eprintln!("{said}"); - Ok(()) -} - /// 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)); @@ -3580,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 136603f472c..13ffce90806 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -70,6 +70,12 @@ 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"]; @@ -322,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`] @@ -581,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()); @@ -629,13 +640,27 @@ mod tests { /// 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"); + 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!(APP_FOLDERS, ["Config", "Data", "Cache", "State"]); 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() } @@ -645,7 +670,7 @@ mod tests { /// 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"); + let row = sample().app_row("gbae", "/apps/gbae/gbae").unwrap(); assert_eq!( views(&row), [ @@ -655,7 +680,7 @@ mod tests { ); // 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}")); + 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)] diff --git a/userland/fileserver/src/main.rs b/userland/fileserver/src/main.rs index 528372ac7b0..662115a1631 100644 --- a/userland/fileserver/src/main.rs +++ b/userland/fileserver/src/main.rs @@ -92,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; @@ -618,8 +622,10 @@ impl Server { } fn serve_one(&mut self, id: u64, op: u32, r: Request) -> Result { - if rights::changes(op, r.flags) && !self.clients[&id].writes { - 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 => { @@ -829,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 index 036a43ac8aa..474f8e3565c 100644 --- a/userland/fileserver/src/rights.rs +++ b/userland/fileserver/src/rights.rs @@ -2,19 +2,20 @@ //! `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 as one that -//! only reads is one that changes, so a request added to the wire is refused on -//! a read-only connection until it is named here. +//! **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. -pub fn changes(op: u32, flags: u64) -> bool { +/// holds: `None` for an operation the wire does not have. +pub fn changes(op: u32, flags: u64) -> Option { match op { - OPEN => flags & (O_WRITE | O_APPEND | O_CREATE | O_TRUNCATE | O_CREATE_NEW) != 0, - HELLO | CLOSE | READ | STAT | LSTAT | FSTAT | FSYNC | SYNC | READDIR | READLINK => false, - _ => true, + 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, } } @@ -30,10 +31,10 @@ mod tests { #[test] fn every_request_that_writes_changes_the_directory_and_none_that_reads_does() { for op in WRITES { - assert!(changes(op, 0), "request {op} writes"); + assert_eq!(changes(op, 0), Some(true), "request {op} writes"); } for op in READS { - assert!(!changes(op, 0), "request {op} only 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(); @@ -45,20 +46,20 @@ mod tests { /// truncates, alone or with a read. #[test] fn an_open_changes_the_directory_by_every_flag_but_read() { - assert!(!changes(OPEN, O_READ)); - assert!(!changes(OPEN, 0)); + 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!(changes(OPEN, flag), "flag {flag}"); - assert!(changes(OPEN, flag | O_READ), "flag {flag} with a read"); + 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 refused on a read-only - /// connection, never served as 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_changes_the_directory() { + fn a_request_the_wire_does_not_have_is_neither() { for op in [0, SYNC + 1, REPLY, LINK, u32::MAX] { - assert!(changes(op, 0), "request {op}"); + assert_eq!(changes(op, 0), None, "request {op}"); } } } diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index c5e50e840a8..62fcba2c79c 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -394,10 +394,7 @@ fn main() { let own: Vec<(String, Connector)> = whole .iter() .filter_map(|view| Some((role_acceptors.get(view.role)?, view))) - .map(|(acceptor, view)| { - mint(acceptor, authority::SUPERVISOR_SHARE, view) - .unwrap_or_else(|why| panic!("supervisor: no grant of its own: {why}")) - }) + .map(|(acceptor, view)| mint(acceptor, authority::SUPERVISOR_SHARE, view)) .collect(); let build = || { let mut builder = namespace::build(); @@ -837,28 +834,44 @@ impl<'a> Supervisor<'a> { } } - /// A service's own `HOME`, or an installed package's and its - /// [`toyos_manifest::APP_FOLDERS`], made before it runs. + /// A service's own `HOME`, made before it first runs. fn make_home(&mut self, program: &Program) { - let folders: &'static [&str] = match (program.service, program.package()) { - (true, _) if is_storage(program) => return, - (true, _) => &[], - (false, Some(_)) => &toyos_manifest::APP_FOLDERS, - (false, None) => return, - }; + if !program.service || is_storage(program) { + return; + } let home = program.home(); let asked = home.clone(); - let made = self.files("a program's home", move || { - make_dir(&asked)?; - folders.iter().try_for_each(|folder| make_dir(&format!("{asked}/{folder}"))) - }); - match made { + match self.files("a service's home", move || make_dir(&asked)) { Ok(Ok(())) => {} Ok(Err(e)) => say!("supervisor: {}: {home} could not be made: {e}", program.name), Err(why) => say!("supervisor: {}: {home} was not made: {why}", program.name), } } + /// 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. @@ -1622,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, @@ -1757,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) } @@ -1927,7 +1950,7 @@ fn start<'a>( command.endow(&label, raw.0); held.0.push(raw); } - if let Some(ns) = build_namespace(program, system, connectors, grants.view(program, launcher.1)?, extras)? { + if let Some(ns) = build_namespace(program, system, connectors, grants.view(program, launcher.1), extras)? { let raw = ns.into_raw(); command.endow(SVC_LABEL, raw.0); held.0.push(raw); @@ -2277,9 +2300,9 @@ impl Grants<'_> { /// 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) -> std::io::Result> { + fn view(&self, program: &Program, session: Session) -> Vec<(String, Connector)> { if is_storage(program) { - return Ok(Vec::new()); + return Vec::new(); } let wanted = program.view(); let mut view = Vec::new(); @@ -2287,26 +2310,32 @@ impl Grants<'_> { let kept = kept.lock().expect("supervisor: a service's state is poisoned"); 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).map_err(std::io::Error::other)?); + view.push(mint(acceptor, session.share(), dir)); } } - Ok(view) + view } } +/// 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. A package's name is part of its -/// directories', so one no grant carries is refused, by name. -fn mint(acceptor: &Acceptor, share: u64, dir: &View) -> Result<(String, Connector), String> { +/// 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) - .ok_or_else(|| format!("{}'s root {:?} is no grant's", dir.dir, dir.root))?; + .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)); - Ok((format!("{CAPABILITY_PREFIX}{}", dir.dir), connector)) + (format!("{CAPABILITY_PREFIX}{}", dir.dir), connector) } /// [`toyos_swap::PORT`] in a namespace of its own, for a program whose row From 578cb70ba136ab6f5ed781790ed816b8dd66ebd2 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 17:33:02 +0200 Subject: [PATCH 3/4] gbae browses to no ROM under a package's view, measured and recorded as a defect gbae bfe8dabf8, started with no ROM, opens its file menu on its cwd. Run under a package's view in a QEMU guest (its `list_directory` verbatim, a temporary arm of app_view, posted on #807), the menu reaches no ROM from `/` or `/home/toy`; a ROM given by path loads only from its own folder; its config is kept in its own folder. The exit is the file picker of the package track's stage 6, and the track's running line now points at the defect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...der-apps-and-the-installer-is-a-program.md | 4 +- issues/an-installed-gbae-browses-to-no-rom.md | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 issues/an-installed-gbae-browses-to-no-rom.md 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 79848126c74..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 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. From 0ad87a5931e38e007237ece3c58b057367465d16 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 17:39:34 +0200 Subject: [PATCH 4/4] app_view names a folder it cannot plant a file over as red rather than panicking With the whole change reverted, the launch of the row-named package goes ahead and leaves the app's folder behind, so the plant failed and the job panicked before it named any hole. The negative control is now red by name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- tests/toyos-rust-tests/src/bin/app_view.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/app_view.rs b/tests/toyos-rust-tests/src/bin/app_view.rs index e299fbfb9d5..2bdc76e619f 100644 --- a/tests/toyos-rust-tests/src/bin/app_view.rs +++ b/tests/toyos-rust-tests/src/bin/app_view.rs @@ -79,12 +79,17 @@ fn job() { 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:?}")), } - fs::write(HOME, b"no folder").expect("plant a file where the app's folder goes"); - 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:?}")), + // 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"); + } } - 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));