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
6 changes: 4 additions & 2 deletions console/system.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ serves = ["log"]

[programs.console]
provides = ["surface"]
receives = ["launcher", "log"]
receives = ["log"]
starts = ["shell"]
devices = ["framebuffer", "keyboard", "mouse"]

[programs.shell]
receives = ["surface", "launcher"]
receives = ["surface"]
starts = ["toybox", "shell"]

# `power` because `bin/shutdown` below is this binary, and on the machine this
# image exists for — no serial port, a prompt on the panel — there is no other
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
status: open
kind: defect
opened: 2026-10-04
---

# A held launcher that will not open is a direct spawn

std's ToyOS `launch` (`library/std/src/sys/process/toyos.rs` in the `rust/`
fork) reads `toyos::endow::launcher().and_then(|held| held.open(LAUNCHER).ok())`:
a process that holds a launcher and whose open of it fails is treated as one
that holds none, and its spawn of a declared program goes on as a direct spawn,
without that program's row and with no error. The shape is the one `main` had
before launch authority, where `endow::service("launcher")`'s `Err` did the
same.

Holding none and holding one that refuses are different facts: the first is a
direct spawn by design, the second is a failure the caller is never told of,
and the program it starts runs without what its row holds.

**Exit**: a spawn by a process holding a launcher whose open fails returns
that failure, and a test that makes the open fail reds when the spawn
starts the program.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ was handed, so isolating people follows from isolating programs.
- **A child's view is at most its parent's.** Nothing widens a view after
spawn, and nothing in it is inherited wider than the parent held it. The
swap port leaking to whatever sshd spawns (PR #484's review) is the same
shape of defect in the service namespace.
shape of defect in the service namespace. **A launch is the exception, and
its caller's row is the approval** (owner, 2026-10-03, "Restrict": each
program may start only the programs its configuration row lists; swap and
update of system programs only from the login session): the launched
program holds its own row, the supervisor refuses a row the caller's
`starts` does not list, and a direct spawn never inherits a launcher.
- **Resolution cannot leave the view.** `..` at a view's root, a symlink,
a rename racing a lookup, or a held directory handle never resolves outside
the view. This is the classic way a restricted root is escaped, so it is
Expand All @@ -50,6 +55,16 @@ was handed, so isolating people follows from isolating programs.
`issues/filesystem/where-everything-lives.md` that `/tmp` is private per
program.

**Ruled** (owner, 2026-10-04), **the local desktop, "Desktop counts"**: the
local desktop is a login session before a greeter authenticates anybody, so
`swap` and `update` run from its shell. His words: "You can run swap/update
from the desktop shell, as today. After #709, untrusted desktop programs no
longer hold the launch right anyway, so excluding the desktop would mostly
just block you." That premise holds while only the compositor, terminal,
shell, toybox and sshserver rows list `starts` and no `/apps` row does. The
compositor's `login = true` in `system.toml` is the whole of it. Whether the
desktop stays one once stage 3's local greeter exists is not ruled.

Unix permission bits and numeric user ids are rejected: they leave every path
nameable by every program and make confused-deputy bugs structural.

Expand Down Expand Up @@ -94,6 +109,14 @@ nameable by every program and make confused-deputy bugs structural.
the stage exits. **Exit**: a hostile session can neither reach another
session's connections nor deny it memory or processes.

## Known weaknesses

- **A login session never ends.** A program launched in one keeps the session
in its launcher's badge after the login that opened it is gone, so a
detached program can start `swap` after logout. The exit is a session's end
ending its launches, which the per-session `/tmp`'s wait on the session's
first process provides.

## Ordering

After self-update and partition claims, and before the browser: rendering
Expand Down
49 changes: 0 additions & 49 deletions issues/isolation/the-launcher-starts-any-row-for-any-holder.md

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ nothing.
manifest whose dependencies form an undeclared cycle; each refusal and
each decision above has a mutation that reds a host test, named in the PR;
and the stage deletes each decision from `userland/supervisor`, which calls
the crate for it, named per decision in the PR. No decision exists in two
places.
the crate for it, named per decision in the PR; `toyos_manifest::launch`,
who may start what and the launcher's badge codec, moves into the crate
and out of `toyos-manifest`. No decision exists in two places.
3. **The supervisor owns the stop.** It asks each service it started to
finish by a quit with reason terminate
(`issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md`
Expand Down
9 changes: 9 additions & 0 deletions issues/kernel/nothing-charges-kernel-memory-to-a-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,12 @@ both cost a day to discover:
kernel allocation panics with the size, the layer and the call site named, and
every kernel panic halts, so a grower userland can drive is refused at its bound
or it ends the machine.

**A port badge is kernel memory nobody is charged for.** Any process can make
a port and mint on it (`SYS_PORT_MINT`), at most `MAX_BADGE` = 64 bytes per
connector. Derived from the bounds, not measured: a namespace holds 64
connectors and a table 4,096 handles, so one process pins 16 MiB of badge
bytes in its table, and up to 2 GiB more in the handle queues of the 2,048
connections it can hold both ends of (2 directions × 16 batches × 8 handles ×
64 connectors × 64 bytes each), the same queues that already hold its
unbadged connectors uncharged.
7 changes: 1 addition & 6 deletions kernel/src/inbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,12 +648,7 @@ fn process_accept(inbox: &Arc<Inbox>, submission: &Submission) {
let installed = process::with_process_data(|data| {
ops::install(
&mut data.handles,
KObjectRef::Connection(crate::object::service::ConnectionEnd::new(
conn.rx,
conn.tx,
conn.inbox,
conn.outbox,
)),
KObjectRef::Connection(crate::object::service::ConnectionEnd::accepted(conn)),
)
});
match installed {
Expand Down
39 changes: 38 additions & 1 deletion kernel/src/object/port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
//! connection queue. Both ends are created together before either process
//! runs, so a client's first connect always has something to reach — never a
//! name that is not yet bound, so nothing to retry and no timeout.
//!
//! **The kernel vouches for what the acceptor's holder granted, never for who
//! connected.** The holder mints a connector carrying bytes it chose
//! ([`Acceptor::mint`]); a connection made through it is stamped with them and
//! the port, and only that port's acceptor reads the stamp back.

use alloc::collections::VecDeque;
use alloc::sync::Arc;
use core::sync::atomic::{AtomicU64, Ordering};

use crate::pipe::{PipeReader, PipeWriter};
use crate::sync::Lock;
Expand All @@ -22,8 +28,21 @@ pub struct PendingConnection {
pub tx: PipeWriter,
pub inbox: Arc<HandleQueue>,
pub outbox: Arc<HandleQueue>,
/// [`Connector::stamp`] of the connector the client connected through.
pub stamp: Stamp,
}

/// What an accepted connection keeps of the connector it was made through.
#[derive(Clone)]
pub struct Stamp {
/// The port's never-repeating id, which the acceptor asking for the badge must match.
pub port: u64,
pub badge: Option<Arc<[u8]>>,
}

/// Never repeats, so a stamp outlives its port without ever naming another one.
static NEXT_PORT: AtomicU64 = AtomicU64::new(1);

/// `closed` and `pending` share one lock: checking `closed` and pushing must not
/// interleave, or a connection queues after nothing will ever drain it again.
struct PortQueue {
Expand All @@ -33,6 +52,7 @@ struct PortQueue {

/// Everything the two ends share; neither end holds the other, so no `Arc` cycle exists.
pub struct PortShared {
id: u64,
queue: Lock<PortQueue>,
/// Lives on the port, not either end: a client's connect must complete a
/// poll the server registered on the `Acceptor`. An `Arc` so a poll
Expand All @@ -48,6 +68,8 @@ pub struct Acceptor {
pub struct Connector {
pub(super) core: ObjectCore,
shared: Arc<PortShared>,
/// Immutable for the object's life: a duplicate is another handle to this object.
badge: Option<Arc<[u8]>>,
}

/// Why a connection was not queued.
Expand All @@ -59,12 +81,13 @@ pub enum PushError {

pub fn create() -> (Arc<Acceptor>, Arc<Connector>) {
let shared = Arc::new(PortShared {
id: NEXT_PORT.fetch_add(1, Ordering::Relaxed),
queue: Lock::new(PortQueue { closed: false, pending: VecDeque::new() }),
watch: Arc::new(Watch::new()),
});
(
Arc::new(Acceptor { core: Acceptor::new_core(), shared: shared.clone() }),
Arc::new(Connector { core: Connector::new_core(), shared }),
Arc::new(Connector { core: Connector::new_core(), shared, badge: None }),
)
}

Expand Down Expand Up @@ -99,6 +122,15 @@ impl Acceptor {
pub fn watch(&self) -> &Arc<Watch> {
self.shared.watch()
}

pub fn port_id(&self) -> u64 {
self.shared.id
}

/// A connector to this port whose every connection is stamped with `badge`.
pub fn mint(&self, badge: Arc<[u8]>) -> Arc<Connector> {
Arc::new(Connector { core: Connector::new_core(), shared: self.shared.clone(), badge: Some(badge) })
}
}

impl Connector {
Expand All @@ -122,6 +154,11 @@ impl Connector {
pub fn port(&self) -> Arc<PortShared> {
self.shared.clone()
}

/// What a connection made through this connector is stamped with.
pub fn stamp(&self) -> Stamp {
Stamp { port: self.shared.id, badge: self.badge.clone() }
}
}

/// Wakes every thread parked in `accept` and drops each queued connection's
Expand Down
23 changes: 23 additions & 0 deletions kernel/src/object/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::pipe::{PipeId, PipeReader, PipeWriter};
use crate::sync::Lock;

use super::handle::HandleEntry;
use super::port::{PendingConnection, Stamp};
use super::{Held, KObjectVariant, ObjectCore, ZeroHandles};

/// Handles in flight in one direction of a connection; `None` once the reader is gone.
Expand Down Expand Up @@ -68,6 +69,8 @@ pub struct ConnectionEnd {
inbox: Arc<HandleQueue>,
outbox: Arc<HandleQueue>,
reference: Held<(PipeReader, PipeWriter)>,
/// The server's end of a port's connection; `None` on a client's end and a joined one.
stamp: Option<Stamp>,
}

impl ConnectionEnd {
Expand All @@ -81,6 +84,21 @@ impl ConnectionEnd {
tx: PipeWriter,
inbox: Arc<HandleQueue>,
outbox: Arc<HandleQueue>,
) -> Arc<Self> {
Self::build(rx, tx, inbox, outbox, None)
}

/// The server's end of a queued connection, keeping its stamp.
pub fn accepted(conn: PendingConnection) -> Arc<Self> {
Self::build(conn.rx, conn.tx, conn.inbox, conn.outbox, Some(conn.stamp))
}

fn build(
rx: PipeReader,
tx: PipeWriter,
inbox: Arc<HandleQueue>,
outbox: Arc<HandleQueue>,
stamp: Option<Stamp>,
) -> Arc<Self> {
Arc::new(Self {
core: Self::new_core(),
Expand All @@ -89,6 +107,7 @@ impl ConnectionEnd {
inbox,
outbox,
reference: Held::new((rx, tx)),
stamp,
})
}

Expand All @@ -97,6 +116,10 @@ impl ConnectionEnd {
Self::new(rx, tx, HandleQueue::dead(), HandleQueue::dead())
}

pub fn stamp(&self) -> Option<&Stamp> {
self.stamp.as_ref()
}

pub fn rx(&self) -> PipeId {
self.rx
}
Expand Down
21 changes: 20 additions & 1 deletion kernel/src/syscall/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ use super::io::{
use super::ipc::{
sys_accept, sys_connection_join, sys_handle_recv, sys_handle_send, sys_inbox_setup,
sys_inbox_submit, sys_namespace_build, sys_namespace_open, sys_pipe, sys_pipe_map,
sys_port_create, sys_shm_create, sys_shm_map,
sys_port_badge, sys_port_create, sys_port_mint, sys_shm_create, sys_shm_map,
};
use super::machine::{
sys_counters, sys_device_inventory, sys_log_read, sys_reboot, sys_sched_info, sys_shutdown, sys_sysinfo,
Expand Down Expand Up @@ -492,6 +492,25 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) ->
SYS_SHM_CREATE => sys_shm_create(a1),
SYS_SHM_MAP => sys_shm_map(RawHandle(a1 as u32)),
SYS_PORT_CREATE => sys_port_create(),
SYS_PORT_MINT => {
// Copied in before any handle is looked up: the kernel holds the badge, never the caller's page.
let Ok(len) = Untrusted::new(a3).at_most(MAX_BADGE as u64) else {
return SyscallError::InvalidArgument.to_u64();
};
if len == 0 {
return SyscallError::InvalidArgument.to_u64();
}
let Some(bytes) = ctx.user_bytes(UserAddr::new(a2), len) else { return bad_addr };
let mut badge = [0u8; MAX_BADGE];
bytes.read_at(0, &mut badge[..len as usize]);
sys_port_mint(RawHandle(a1 as u32), &badge[..len as usize])
}
SYS_PORT_BADGE => {
let Some(mut out) = ctx.user_bytes_mut(UserAddr::new(a3), MAX_BADGE as u64) else {
return bad_addr;
};
sys_port_badge(RawHandle(a1 as u32), RawHandle(a2 as u32), &mut out)
}
SYS_NAMESPACE_BUILD => {
let Ok(args) = ctx.copy_in::<NamespaceBuild>(UserAddr::new(a1)) else {
return bad_addr;
Expand Down
Loading
Loading