Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<name>` 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/<name>`, which is its `HOME` and
the one part of the home it sees; its own `/apps/<name>` 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
Expand Down
39 changes: 39 additions & 0 deletions issues/an-installed-gbae-browses-to-no-rom.md
Original file line number Diff line number Diff line change
@@ -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/<name>` read-only and its own `/home/toy/Apps/<name>`
(`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.
10 changes: 10 additions & 0 deletions issues/every-program-sees-only-the-files-it-was-given.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions issues/where-everything-lives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user>/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/<user>` and its folders from a
login row, and `toy` stops being a constant in `toyos-manifest`.
**Exit**: init names no user.
Expand Down
33 changes: 33 additions & 0 deletions issues/whether-pkg-alone-writes-apps.md
Original file line number Diff line number Diff line change
@@ -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/<name>` 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.
3 changes: 2 additions & 1 deletion system.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` read-only and its own `/home/toy/Apps/<name>`, its `HOME`.
[apps]
receives = ["compositor", "soundserver", "filepicker"]

Expand Down
6 changes: 5 additions & 1 deletion tests/proctreecase/system.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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.
Expand Down
233 changes: 233 additions & 0 deletions tests/toyos-rust-tests/src/bin/app_view.rs
Original file line number Diff line number Diff line change
@@ -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/<user>/Apps/<name>`, 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<String> = 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<String> = 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");
}
Loading
Loading