Reading this book
Redoubt is a headless, multi-user operating system for RISC-V, built so that hostile agents can run on it, do real work, and not get out. This book describes the whole system from its code: what each part does, which rule it keeps, which test attacks that rule, and what is still planned. The tenets outrank every other page.
Reading order
- The tenets: what Redoubt is, the threat model, the guarantees and the walls that hold them, the non-goals.
- The glossary: the one vocabulary every page uses. Skim it once; come back to it.
- The kernel: handles, IPC, memory, budgets, scheduling, the timer, processes, devices, boot, and the model that checks them.
- The servers: trust tiers and labels, then one page per server, from
initand the steward to the network path. - Userland: what a person, an agent and a developer see.
- The security register: every security property on one table, with the code that enforces it, the test that attacks it and its residual risks. An auditor starts here.
- The plan: one page per milestone, then the follow-ups in
todo/and the ideas past the plan inbeyond/. - The test bench: how every claim in this book is tested.
To build and run Redoubt, see GETTING-STARTED.md. How the work itself is organised, and in what order, is outside this book: AGENTS.md is the way in.
Written as if complete
Every page describes the system as it will be at the end of the plan. Whether a part exists today is said by the status line at the top of its section, never by the prose. A section that is part built and part planned is split in two.
| Status line | Meaning |
|---|---|
Status: built · tested: <tests> | The code exists, and each named test attacks the section’s claim. |
Status: built · partly tested: <gap> · tested: <tests> | The code exists; the gap says what no test attacks yet, and the tests (when named) cover the rest. |
Status: planned · M2 (usable shell) | Nothing of it is built; it arrives in the named milestone. The section ends with an Open: list of what is still undecided. |
A status with four or more tests folds them away, so the section opens on its claim and not on a
list: the status sits in a <summary> with the count of tests, and the tests follow as a bullet
list inside the <details>, one per line. The checker holds the count to the list.
<details><summary>Status: built · tested (3)</summary>
- bench:redoubt-ipc
- host:redoubt-sys::every_call_round_trips
- mutation:R2OneCursor
</details>
“Built” means the code exists and its named tests pass. A host test or a build case is not a boot: code built and host-tested is not thereby running in the integrated system, and running there is its own planned section.
A section without its own status line inherits its nearest parent’s. Purpose, Residual risks and Why sections carry none: they say why and what is left, not what is claimed. Status lines appear on the kernel, server and userland pages and on the test bench page; plan, follow-up and idea pages do not use them, and the security register has a status column instead.
A test is named by its kind:
| Form | What it is |
|---|---|
bench:<case> | a bench case, tests/<case>.toml: a real boot under QEMU, a host-test run or a source check (the test bench) |
host:<crate>::<test> | a Rust #[test] in that crate, run on the build machine; the model’s properties are host tests of redoubt-model |
mutation:<name> | a rule deliberately broken in the model, which its checks must catch (the model) |
fuzz:<crate>/<target> | a fuzz target in that crate’s fuzz/ directory |
The milestones
A milestone is always written with its name.
| Milestone | Goal |
|---|---|
| M1 (sessions over SSH, kept apart) | Alice and Bob log in over SSH and on the console into Elixir sessions, kept apart in fixed sub-budgets per label set, with files; a dead steward restarts without a reboot. |
| M2 (usable shell) | The Elixir shell is a working environment: commands, file operations, native programs and pipes, jobs, line editing, screens, the editor. |
| M3 (agents, approvals and the attack suite) | Alice’s agent runs contained under a lease; approvals come only from approve@box; declassification, crash blame, and the scripted hostile agent and user. |
| M4 (files in and out) | SFTP and SCP inside SSH, confined to the session’s capabilities and audited. |
| M5 (self-hosted development) | Redoubt is developed on Redoubt: compilers, git and a model provider through gateways, the agent harness, the audit log and the escape room. |
| M6 (persist, install, share) | The steward’s state survives reboots; signed packages, trust lists and shared projects; A/B updates; the supervisor; wall-clock time; log retention. |
Anything past M6 (persist, install, share) is beyond M6: an idea, not a goal, one page each under beyond/.
Rule IDs
Every security property has an ID, defined once by a heading on the page that owns it and cited everywhere else with its short name the first time on a page:
- Rules are one series,
Rand a number. The kernel’s come first, on the kernel pages, such as R3 (lends and abandoned calls); the server, network and policy rules take the numbers after them, on the server pages, such as R25 (the label check). A letter after the number refines a rule: R4a (open calls). - Invariants are
Iand a number, all on invariants, such as I9 (pages W^X, zeroed, lends unmapped). The model checks every one after every step. - Rule F (trusted verdicts) is the bench’s own: a verdict comes only from a party the attacker cannot impersonate (the test bench).
IDs are cited one at a time, never as ranges, and never reused: a withdrawn rule keeps its heading, marked withdrawn. The security register lists every ID.
Rendering
The pages are plain Markdown and read on GitHub as they are. mdBook renders them as a book with navigation and search:
mdbook build docs # output in target/book
mdbook serve docs # the same, served on localhost with live reload
Diagrams are text inside the pages: Mermaid for flows, sequences, state machines and the packet
diagrams that draw a record’s words and bits, which GitHub renders too; and memmap blocks for
address maps, which the book’s own preprocessor (tools/mdbook-memmap) draws to scale on a log
axis, one region per line with its first address, its name and a note per column, and which read
as a table on GitHub. Solid lines are built; dashed lines are planned. The book needs the
mdbook-mermaid preprocessor and cargo for its own.
The docs checker, redoubt-doccheck, holds every page to these rules
(the test bench).
The search index
The book’s search index (target/book/searchindex-<hash>.js) is held to a ceiling of
7,000,000 bytes, 30 % under the 10,000,000 at which mdbook warns and the build check fails;
the_book_builds fails over the ceiling naming the size and the three largest pages, so the
failure says what grew. The index is about three times the pages’ text (3.2× in October 2026: a
term’s postings for every section it appears in), and the three largest pages hold a fifth of
the text. The ceiling moves only by a stated, measured decision in the same change as the
measurement, and never past mdbook’s 10,000,000. Headroom is kept
by keeping the pages to their argument: measurements, evidence tables, transcripts and
reasoning beyond what a rule or a design needs stay outside the book, in the working notes or a
case’s comment, not on the page. A page leaving the book, or splitting, is the owner’s decision,
never a way to pass the check. The index needs an mdbook built with elasticlunr-rs 3.1.0 or later (an older indexer
doubles every field’s tokens into the next field; scripts/setup.sh installs the right one and
the same check reads the version from the binary).
A tour of Redoubt
Redoubt is a prison: a headless RISC-V operating system that the most capable and most malicious agents can run on, do real work on, and not get out of. This tour shows the whole in five pictures, each with a link to the page that gives the detail. In every figure solid lines and boxes are built; dashed ones are planned.
The prison walls
An agent sits innermost, and each wall around it holds only what the wall outside it gave. The one door in is SSH; the one way to move authority or data across a wall is an approval by a human, on a channel nothing inside can draw on. The host’s device emulation and the human’s judgment are walls the OS rests on but does not own.
flowchart TB
subgraph host["the host: virtio emulation"]
FW["firmware (RustSBI)"] --> LD["loader: verifies the bundle"] --> K["kernel"]
K -.-> INIT["init"]
K --> KD["keyd"]
K --> SH["shared servers: files, disk, network, console"]
K -.-> ST["the steward"]
K -.-> SSHD["sshd"]
ST -.-> SE["sessions: one Elixir VM each"]
SE -.-> AG["agents, each in a lease"]
end
P(["a person"]) -.->|"SSH, the one door"| SSHD
H(["the approving human"]) -.->|"approve@box"| ST
classDef planned stroke-dasharray: 5 5
class INIT,ST,SSHD,SE,AG,P,H planned
Figure: the prison walls. init’s manifest handling, the steward, sshd and sessions are
planned for M1 (sessions over SSH, kept apart), agents and approvals for
M3 (agents, approvals and the attack suite); the servers drawn solid are built and attacked in host
tests or on the kernel.
Detail: the walls
The layers
The kernel keeps five things: memory, threads, IPC, interrupt delivery and the timer. Everything else (drivers, file systems, the network, keys, logins and policy) is an unprivileged server that reaches hardware, other servers and the kernel only through the handles it was given. A person’s session is one more process above them: an Elixir VM that holds narrowed connections to the servers and nothing else.
flowchart TB
SESS["a session: an Elixir VM, beamlet"]
SRV["unprivileged servers: drivers, files, network, keys, logins, policy"]
KER["kernel: memory, threads, IPC, interrupts, the timer"]
HW["RISC-V hart and virtio devices"]
SESS -.->|"IPC through its handles"| SRV
SRV -->|"system calls"| KER
KER --> HW
classDef planned stroke-dasharray: 5 5
class SESS planned
Figure: the layers. Only the kernel and the firmware below it run privileged; a server holds no authority it was not handed.
Detail: the kernel, the servers, sessions and namespaces
One IPC call
A client calls a server through an endpoint handle, and may lend it pages for the length of the call. The kernel, not the client, tells the server who is calling: it stamps each message with the handle’s badge, the caller’s account and its labels, which the client cannot forge. The reply hands the lent pages back.
sequenceDiagram
participant C as Client
participant K as Kernel
participant S as Server
S->>K: receive
C->>K: call, lending pages
Note over K: checks labels,<br/>unmaps the lend from the client
K->>S: message stamped with badge, account, labels
S->>K: reply
K->>C: reply, lend returned
Figure: one IPC call. The stamp is the server’s only knowledge of its caller.
Detail: IPC
Capability delegation
Authority flows down a tree and narrows at every step. The steward mints each launch fresh, narrower connections: a person’s session gets its own, an agent gets a subset of its launcher’s under a lease, and a sub-agent a subset of that. A lease is a budget with a deadline; when it ends, everything below it ends too.
flowchart TB
ST["the steward: mints fresh, narrower connections"]
L["launcher: a person's session"]
A["agent, in a lease: a subset of the launcher's"]
SA["sub-agent: a subset of the agent's"]
LE["the lease ends: its deadline, or the sponsor"]
ST -.-> L
L -.->|"narrower"| A
A -.->|"narrower again"| SA
LE -.-x A
LE -.-x SA
classDef planned stroke-dasharray: 5 5
class ST,L,A,SA planned
Figure: capability delegation. Every step holds less than the one above it. The tree is planned; the kernel’s deadlines, which end a lease, are built.
Detail: agents, leases and labels
The road
The plan is six milestones, each a whole working system. M1 (sessions over SSH, kept apart) is in progress: the kernel, the drivers, the network server and the serving library are built and attack-tested, and the steward and SSH logins are next; the contained agent is M3 (agents, approvals and the attack suite)’s.
flowchart LR
m1["M1 (sessions over SSH, kept apart)<br/>in progress"]
m2["M2 (usable shell)"]
m3["M3 (agents, approvals and the attack suite)"]
m4["M4 (files in and out)"]
m5["M5 (self-hosted development)"]
m6["M6 (persist, install, share)"]
m1 -.-> m2 -.-> m3 -.-> m4 -.-> m5 -.-> m6
classDef planned stroke-dasharray: 5 5
class m2,m3,m4,m5,m6 planned
Figure: the road. M1 (sessions over SSH, kept apart) is part built (solid box); the rest are planned.
Detail: M1 (sessions over SSH, kept apart), the milestones
Tenets
This page outranks every other page in the book. When a change conflicts with a tenet, either the change loses or the tenet is amended here first, with its reason (changing a tenet). Other pages say how a mechanism works; this one says what the whole must achieve, against whom, and where its limits are.
What Redoubt is
Redoubt is a prison: a headless, multi-user operating system built so that the most capable and most malicious agents can run on it, do real work, and not get out.
- It runs on RISC-V softcores, nearly always over virtio (block, network, console). It never has a local display, keyboard or mouse. People reach it over SSH.
- Auditable by construction. Everything that runs on the machine is Rust, with assembly only where Rust cannot reach. No C and no shell script runs on the machine, userland included. The trusted computing base (the firmware interface, the loader and the kernel) is small enough to read like a textbook.
- Authority is capabilities. There is no ambient authority: no root, no user IDs that grant access, no global file tree. A child’s capabilities are its launcher’s or narrower. An agent never inherits network access.
- The network is closed by default. What a principal may reach is written as names, and an agent reaches the outside only through gateways that hold its keys and check its requests.
- Residual risks are stated, never hidden. Every guarantee below names the walls it rests on and the test that attacks each one. No page says “impossible”.
Every page is written as if M1 (sessions over SSH, kept apart) through M6 (persist, install, share) were complete; each section’s status line says what is built today (reading this book).
Threat model
The adversary has the full source, automates its exploit attempts, and has as much time as it likes. It controls every process it runs, including its whole Elixir VM (beamlet is outside the trusted computing base), and it may compromise a server it talks to. It may be an agent, a person logged in over SSH, or several of them colluding with a pre-arranged code. It has a perfect clock: secrets are protected by isolation, and by constant-time code where a rule claims it (side channels), never by hiding time.
Trusted: the firmware interface, the loader and the kernel; the servers that hold system
authority (init, the steward, keyd) and sshd, which carries each channel’s labels; a driver
that programs DMA, unless the hardware confines its DMA; the host that emulates the virtio
devices; and the approving human’s judgment, but never the channel the approval travels on (see
the walls).
A compromised shared server exposes what its clients entrusted to it, and no more: it holds only its own handles, and the kernel stamps every message with its sender (R14 (unforgeable sender)).
Out of scope:
- physical attacks and malicious hardware;
- microarchitectural side channels (caches, branch predictors, memory bandwidth);
- covert communication between co-located budgets through power, heat, electromagnetic emission, a shared clock or any other physical coupling. On one machine these couple any two domains, so no operating system can close or bound them; the only zero is placement, which is a deployment’s choice (the FPGA platform).
Not claimed: the boot bundle’s confidentiality. Verified boot proves the bundle’s integrity; it does not hide it. Seeds and anything else in the bundle are readable by whoever reads the bundle image (boot’s residual risks).
Software closes every intentional path: a message, a write, a read up, a sink, metadata, a counter or a shared cap. An intentional path across a label boundary is a design hole, whatever its bandwidth. Timing that software can reduce is reduced (limits are carved, never overcommitted; caps are counted per account and label set; no global counter is visible), and what remains is listed under side channels.
Adversarial agents and real attacks test the design; review alone cannot bound the damage a bug does.
Guarantees
Redoubt makes three guarantees. Each is a set of rules, defined on the page that enforces them and listed with their tests in the security register.
Capability closure
An agent’s reachable authority never exceeds its initial grants, plus what a steward action a human approved adds, plus what a holder already able to reach it passes to it inside its trust domain. Every delegation step through a launcher narrows.
- A handle names an object, a badge and a stamp; the kernel holds it, and a process can copy or close it but never forge or widen it. A minted handle keeps the stamp of the one it came from, so revoking the source revokes it (R9 (stamps)).
- A child budget’s limits are carved from its parent’s and never add up to more (R7 (carving)); destroying a budget revokes everything under it (R10 (destruction)).
- A launcher passes fresh, narrower connections, never its own; a grant is made in a revocation scope and dies with it (R41 (narrowing by revocation scope)).
- No server is given a
systembudget to hand out (R33 (no server holds a system budget)), a restart never widens what a service holds (R73 (a restart never widens)), and code a principal did not choose to trust gets no authority (R71 (no new authority without trust)).
Open: the closure is stated with its same-label qualification: a holder inside the agent’s trust domain may pass it a handle it already has, with no approval. Whether the guarantee should instead be stated per trust domain, or same-label delegation be recorded by the steward, is not decided.
Label non-interference
No intentional path crosses a label boundary: not a call or a send, a write, a read up, a
sink, metadata, a counter or a shared cap. The label set, not the capability set, is the
isolation unit. Two budgets with equal label sets are one trust domain, and a handle passed
between them crosses nothing. Two budgets with differing label sets have no path the OS carries,
except one item at a time through the steward, after the label’s owner approves it.
The residual is stated: there is no non-collusion guarantee within one label set. Two agents with different handle sets but the same label set are not kept from cooperating; the kernel carries messages between whoever holds each other’s handles. Agents that must not collude get different labels (labels and vaults).
- The kernel refuses a
callorsendbetween twouserbudgets whose label sets differ, and checks exit notices and usage reads the same way (R1 (flow)); servers insystembudgets check labels themselves. Labels only grow downward the budget tree (I6 (labels only grow downward)). - A shared server applies the same check to every object it serves (R25 (the label check)), and a sink refuses every labelled caller (R60 (a sink refuses labels)).
- Nothing a vault session does is visible to its principal’s unlabelled sessions (R37 (vault non-interference)): caps are counted per account and label set (R26 (admission fairness)), budgets are split into fixed sub-budgets per label set, and crash blame is kept per label set (R40 (blame by label set)).
- Data leaves a label only by declassification, and enters a confined domain only by a push: one approved item each, snapshotted, audited (R42 (one approved item)).
Confinement. In a confined deployment no two label sets share a server instance, volume,
endpoint, network instance or device, and no labelled domain reads a shared unlabelled volume;
a manifest that would place them so fails the boot
(R34 (confined placement)). R34 names the one exception,
the control plane: the steward and sshd, and only by three kinds of edge.
- the request and owner-approval path;
- per-item reader and writer budgets, each carrying exactly one label set and dying after one item (declassification and push);
- lease supervision: ending a lease, and learning that it ended.
No data server or device is exempt. init checks the declared graph at boot, and the
steward checks every budget and grant it creates later against the same rule. The residual is
stated: the named mediators are trusted across the labels they serve. A bug in the steward or
sshd reaches every label set it mediates.
Human control
Every increase in authority and every declassification is an out-of-band human approval, leases bound time, and the sponsor can always end a lease.
- An approval happens only where nothing the requester runs can draw, type or listen: on the approval channel the steward alone drives, like a second factor (R38 (out-of-band approval), R68 (only the steward on approve@box)).
- A lease is a budget with a deadline: when it passes, the kernel destroys the budget and everything in it, whether or not the steward is running (R39 (leases end)).
- The sponsor ends a lease sooner, and a request to do so is answered ahead of admission, so an agent flooding its sponsor’s share of a server cannot block it (R26). How fast the steward answers under load is a measured target, not a proven bound (scheduling).
The design protects the approval channel from the requester. It does not protect the human’s judgment: see the approval human.
The walls
The prison is layers, and each layer holds only what the one inside it was given. An agent sits innermost: in its own VM, in a lease, with narrowed connections and no socket.
flowchart TB
subgraph HOST["host: virtio emulation (a wall the OS does not own)"]
subgraph M["the machine"]
FW["RustSBI firmware (M-mode)"]
LD["loader: verifies the bundle"]
K["kernel: handles, IPC, budgets, W^X"]
subgraph SYS["system servers"]
INIT["init"]
ST["steward"]
KD["keyd"]
end
subgraph SH["shared servers"]
FS["bootfsd, littlefsd, consoled"]
DRV["blkd, netd (drivers)"]
IPD["ipd, resolver"]
GW["gatewayd"]
SSHD["sshd"]
end
subgraph SESS["sessions (user budgets)"]
S["a person's session"]
subgraph LEASE["a lease"]
AG["agent"]
end
end
end
end
HUMAN(["the approving human"])
NET(["the internet: allowed egress"])
FW --> LD --> K
K --> SYS
K --> SH
K --> SESS
S --> SSHD
AG -.-> GW
GW -.-> IPD
IPD --> DRV
DRV --> NET
SSHD --> HUMAN
HUMAN -.->|"approve@box"| ST
Figure: the prison walls. Solid lines are built, dashed lines planned. Each box holds only the handles the box around it gave it; four walls (the host’s emulation, the human, allowed egress and side channels) are not the OS’s own, and are named below.
Inside the machine each wall is a rule with a test. Four walls are not the OS’s own. They are named here, and every page that rests on one says so in its residual risks.
The approval human
An approval is only as good as the human’s judgment. The requester can persuade, repeat until the human tires, or phrase a request that reads one way and means another. The design shows the whole request on a channel the requester cannot draw on, keeps each declassified item small and printable (R42), and audits every approval; it does not decide for the human. The escape room attacks this wall with collusion through the human (agents).
Host virtio emulation
Redoubt’s devices are virtio devices emulated by the host (QEMU, or a softcore’s host side). Only
the drivers (blkd, netd, consoled) touch a device, each through its own device objects
(R18 (device authority)), and they treat everything a
device says as hostile: a disk’s lie is a failure, never corruption
(R52 (a lie is a failure, never corruption)),
and a bad frame is content (R55 (a bad frame is content, not a lie)).
But a bug in the host’s emulation that a driver’s requests can reach is outside the OS, and a
driver that programs DMA is trusted unless the hardware confines it. No agent or session holds a
device.
Allowed egress
Every allowed channel out of the box can carry data. A model API an agent may call, a git remote
it may push to, and the upstream DNS resolver’s shared cache (which serves everyone outside the
box, so its timing is visible to anyone) are paths out that the design narrows but cannot close.
gatewayd checks and meters every request against the capability it came through
(R65 (a request only within its capability))
and logs it; the resolver answers only allowed names
(R63 (only allowed names)). No labelled caller
reaches any egress (R60). What an unlabelled agent sends through an allowed channel is the
sponsor’s to allow.
Side channels
Timing, shared caches and shared hardware couple budgets in ways software can reduce but not close:
- the kernel and the scheduler, which every label set shares, confined or not, and server CPU spent on a caller’s behalf;
- CPU caches, the shared second-level cache and memory bandwidth; on QEMU and ordinary hardware none of these is partitioned;
- a shared server’s caches and the disk: a vault’s reads warm a cache an unlabelled session can time, unless the deployment is confined;
- a shared server’s service slot: if it crashes on its own, whose call it was serving depends on
what was queued ahead. A vault’s queued calls therefore change an unlabelled caller’s result
(
Dead, or a later reply) and the crash blame it can read, unless the deployment is confined (steward); - a server sized for fewer buckets than the label sets it serves, which refuses latecomers (serving).
A confined deployment removes the shared servers, endpoints and devices. What is left is the kernel’s and the hardware’s: every label set shares the one kernel and its cores, because the kernel is the trusted base and a core’s timing is no more partitioned than the caches around it. Hardware placement is beyond M6.
Constant time is claimed for one thing: keyd’s signing, whatever the key and the nonce
(R45 (constant-time signing)). That claim is partly
tested: its timing tests need an optimised build that no bench case runs. No other code that
handles a secret is claimed constant-time; such a secret rests on isolation alone, and its timing
is a side channel like the others above.
Network policy
The network is where a prison most easily leaks, so its rules are stated here once.
- Default deny. A principal reaches only what its grants allow; an agent inherits no network access at all.
- Allowlists are written as names. A name rule covers a domain or a suffix, matched only at a label boundary; a blocklist only subtracts from an allowlist and always wins.
- DNS is mediated. The resolver answers only names in the caller’s allowlist (R63), and a connection made by name is pinned to the address that was checked (R64 (connections by name are pinned)).
- Agents get gateway capabilities, not sockets.
gatewaydholds the keys, speaks TLS, checks each request against the grant and logs it; no credential ever reaches the agent (R66 (no credential leaves gatewayd)). - Only people get name-scoped TCP, through
ipdwith a resolver connection: a scope reaches only what it allows (R58 (a scope reaches only what it allows)), and a grant only narrows it (R61 (scopes only narrow)). - Always forbidden, whatever a grant says: the host’s and cloud metadata addresses, the box’s own services from inside (R59 (never the box’s own addresses)), and inbound traffic other than SSH (with SFTP and SCP inside it).
- Web serving, when it comes, terminates TCP, TLS and HTTP in separate trusted servers split by privilege; applications get parsed requests, and inbound access is only through SSH forwarding (the web stack).
The network path, from an agent through gatewayd and ipd to netd, is drawn on
the servers.
The tenets
1. Simple enough to audit in full
A competent reader can read the whole trusted computing base and hold it in their head. It reads like a textbook example of each mechanism.
- The kernel keeps memory, threads, IPC, interrupt delivery and the timer. Nothing else. Drivers, file systems and policy are unprivileged servers (the kernel).
- One obvious way to do each thing. A trick needs a comment that says why the obvious way does not work; if the comment is hard to write, the trick goes.
- Size is budgeted, not just observed. Growing the trusted computing base needs a stated reason.
- Deleting code beats adding configuration. A feature nobody uses on our targets is removed, not carried.
- Every design decision is written down where a newcomer can follow it: in this book.
2. Secure by construction
- No ambient authority. A process touches only what it was given: memory it mapped, connections it holds, devices it was granted. “First to ask gets it” is not a policy.
- Least privilege all the way down. The kernel does not trust processes, servers do not trust clients, the loader does not trust images, and nothing trusts input it has not checked.
- W^X everywhere, the kernel’s own mappings included: no page is ever writable and executable under any alias (R11 (memory), R19 (kernel W^X)).
- Verified boot. Every byte that runs in a privileged mode is authenticated before it runs (R15 (verified boot)).
- Fail closed and loudly. On a violated invariant the kernel stops; it never limps on. An insecure fallback (a guessable random seed, a missing signature) is a boot failure, not a warning (R17 (fail closed)).
unsafeis a budget. Everyunsafeblock states the invariant it relies on; unsafe code lives in few, small modules with safe interfaces; the count is tracked by the bench and goes up only with a reason (the unsafe budget).- The requester never influences the approval channel. Any convenience that puts approval inside a session must first amend this tenet.
- Non-interference across labels, by construction (label non-interference).
3. Rust, and assembly only where Rust cannot reach
- Everything that runs on the machine is Rust: the RustSBI firmware, the loader, the kernel, the servers and the applications, userland runtimes included. RustSBI is the only supported firmware and is always trusted.
- Assembly is limited to what the language cannot express (trap entry and exit, the context switch, the first instructions after reset) and is written inside Rust sources, never as prebuilt objects.
- No C, no C toolchain in the build, no binary blobs, and no bindings to C libraries. No C and no shell script runs on the machine. One exception, on the build host only: test oracles and fuzz drivers (the littlefs C reference, libFuzzer) may be C or C++, in crates outside the workspace build, never linked into anything that runs on the machine.
- This tenet governs what runs on the machine; the build host is outside it and may run shell scripts that drive the build, the bench and the tools. The reason: the tenet keeps what the adversary can reach auditable in one language, and a build-host script never runs on the machine; what it builds is signed and verified before it runs (R15).
- The build is one pinned toolchain (
rustcandcargo), and reproducible.
4. Open, auditable standards
- Hardware interface: ratified RISC-V specifications only (the privileged architecture, SBI, the PLIC, Sv32 and Sv39, Sstc). A board’s vendor extension sits behind a capability with a standards-based alternative.
- Machine description: the device tree. Devices: virtio.
- Formats and protocols: published ones with independent implementations (ELF, tar, 9P2000, SSH, TLS). A format is invented only when no open one fits, and then it is specified in this book.
- Everything needed to rebuild and audit the system is public: sources, specifications, toolchain.
5. Dependencies are part of the trusted computing base
A crate linked into the kernel or the loader is our code, as far as the adversary is concerned.
- Reuse beats writing our own when the crate is small,
no_std, pure Rust, maintained, and read. Otherwise we write the fifty lines. - Privileged code keeps a short, reviewed dependency list, pinned by the lockfile and vendored. A new dependency there needs a stated reason.
- Servers have more latitude than the kernel; applications more than servers.
- A dependency that panics on malformed input is a denial-of-service bug in us.
6. Tested to hell and back
If it is not tested, it does not work; we just have not found out yet. The harness is part of the system, held to the same standard as the kernel (the test bench).
- One simple harness, real boots. The bench boots the real kernel under QEMU with chosen programs and judges the console. No mock of the kernel and no special test build of it; a build of the same sources with debug assertions and overflow checks on is the kernel checked harder, not a special build.
- Every behaviour has a case. New kernel or loader behaviour lands with a test; a bug fix lands with the test that would have caught it.
- Attack tests, not just happy paths. Hostile images, hostile call arguments, hostile messages, exhaustion, malformed device trees. The expected outcome is a clean refusal, never a kernel panic and never silent corruption. A verdict comes only from a party the attacker cannot impersonate (rule F).
- The harness can fail. It is checked against known-bad runs, so a green result means something; the model’s checks are shown to catch deliberate breaks (the model).
- Fuzz what parses. Anything that parses untrusted bytes (ELF, tar, the device tree, call arguments, messages) gets a fuzz target.
- A flaky test is a bug, in the test or in the system, and is fixed rather than retried.
7. Devices speak virtio
Drivers are virtio, unless the device is trivial (a UART, a real-time clock: small, no DMA). A driver that does DMA is in the trusted computing base unless the hardware confines its DMA; the platform states which. On hardware too messy to drive, Redoubt writes no drivers: it takes reserved cores and lets Linux run the hardware and serve virtio to it, and there Linux is trusted (Linux on reserved cores).
Every dimension we claim
A configuration the bench does not boot is not supported.
- Widths. Every milestone, M1 (sessions over SSH, kept apart) to M6 (persist, install, share), requires rv64 boots and rv32 compilation. Most kernel cases boot on rv32 as well; a full rv32 system is beyond M6.
- Harts. The kernel runs user code on every hart, up to 8, under one big lock, with a budget on one hart at a time until one process’s threads may run on several. M2 (usable shell) brings several harts: the kernel runs user code on every hart, first under one big lock, and one process’s threads on several harts at once, so a beamlet VM’s schedulers run in parallel (several harts). The reason: the FPGA platform’s cores have two hardware threads each, and by the end of M1 (sessions over SSH, kept apart) the kernel’s rules are attacked on one hart, so a second hart multiplies what can go wrong only in rules already attacked.
- Firmware. RustSBI’s prototyper, built from source, on both widths.
- Machine. QEMU’s
virtmachine, with virtio block, network and console devices.
Non-goals
These are not missing features. Each is left out because it would widen what an attacker can reach or what an auditor must read.
- No display, keyboard, mouse or GUI. The box is headless; people use SSH.
- No Unix signals,
fork,setuidor root. A process is ended by destroying its budget; authority is only ever narrowed. - No POSIX shell. The shell is Elixir.
- No symlinks or hard links. Namespaces are built from binds, per process.
- No swap and no IPv6.
- No inbound services except SSH, with SFTP and SCP inside it.
Changing a tenet
A tenet changes before the code does. The change is made on this page, with the reason next to it, and every page that rests on the old wording is updated in the same change. A proposal that conflicts with a tenet and does not amend it is refused. Weakening a guarantee, or moving a wall from “attacked” to “assumed”, needs the owner’s explicit decision.
The security register
This is where an audit of Redoubt starts. Every security property the book claims is one row below: what it is, the rule that states it, the code that enforces it, the tests that attack it, whether it is built, and what it leaves open. Nothing else is claimed. The rules are defined on the pages that own them; this register is derived from those pages, and the docs checker holds it to them (the test bench). The guarantees the rules add up to, and the walls they rest on, are on the tenets.
How to audit
- Start from a guarantee on the tenets: capability closure, label non-interference, human control. Each names the rules it rests on.
- Find the rule’s row. The Rule column links the section that defines it; read that section whole. It states the rule, says why, and opens with its status line. For example, R1 (flow) is the kernel’s half of label non-interference, and I7 (every flow obeys R1) is the model’s check of it after every step.
- Read the enforcing code. The Enforced in column names the files where the rule is kept. The kernel’s files are small enough to read in full; each cites the rules it keeps.
- Read the tests that attack it. A
bench:test istests/<name>.toml: most are a boot under QEMU whose verdict comes from a party the attacker cannot impersonate (rule F), and the rest run a host check and judge its exit status (wire-host-tests, for one, runs host tests; the kinds); ahost:test is a Rust test in that crate; amutation:is the rule deliberately broken in the model, which its checks must catch (the model); afuzz:test is a fuzz target. - Read what is left open. “Built, partly tested” means the section’s status line names what no test attacks yet; the Residual risks column names what the rule does not stop, and links the page’s full list.
Status reads the same as on the pages (reading this book): built (every claim attacked by the tests listed), built, partly tested (the gap is on the page), or planned for a named milestone, with nothing built yet. Built means the code exists and its named tests pass. A host test or a build case is not a boot: a server whose rows are built from host tests is not thereby running in the integrated system, and running there is its own planned section on its page.
The walls
The rows fall into groups, one per wall of the prison. From outside in: the boot chain that decides what runs, the kernel that separates processes, the servers that separate principals, and the network that is the way out. Four walls are not the OS’s own: they are named, with what rests on them, below.
flowchart TB
subgraph HOST["host: virtio emulation"]
BOOT["Boot<br/>verified bundle, confined images, init's checks"]
subgraph K["Kernel"]
KI["IPC and objects"]
KM["memory"]
KB["budgets, scheduling and time"]
KP["processes and devices"]
KV["invariants (the model)"]
end
subgraph SV["Servers"]
SL["the serving library and wire formats"]
SS["the steward"]
SK["keys, files and devices"]
SP["packages and services"]
end
NET["Network<br/>netd, ipd, resolver, gatewayd, sshd"]
end
HUMAN(["the approval human"])
OUT(["allowed egress"])
BOOT --> K
K --> SV
SV --> NET
NET --> OUT
SS -.-> HUMAN
Figure: the register’s row groups as walls, outermost first. Solid lines are built, dashed lines planned. Side channels cross every box and are listed below.
The register
| Property | Rule | Enforced in | Tested by | Status | Residual risks |
|---|---|---|---|---|---|
| Kernel: IPC and objects | |||||
Information flows between two user budgets only if their label sets are equal; system budgets check labels themselves | R1 (flow) | kernel/src/message.rs, kernel/src/budget.rs, kernel/src/process.rs | bench:process-attack, bench:process-review, mutation:R1SkipLabelCheck, mutation:R1ChecksReceiverNotOwner, mutation:R1ExitNoticeIgnoresLabels, mutation:R1UsageIgnoresLabels | built, partly tested | System-class servers are trusted to check labels (more) |
| Blocked senders are served in turns per (account, label set), so one principal cannot crowd out another at an endpoint | R2 (fair waiting) | kernel/src/endpoint.rs, kernel/src/message.rs | bench:redoubt-ipc, bench:ipc-fair-label-sets, bench:walfsd-flood-turn, bench:vault-flood-turn, bench:blkd-fairness, host:redoubt-model::steward_noninterference, host:redoubt-model::one_cursor_scenario, mutation:R2FifoAcrossAccounts, mutation:R2NoWaitCap, mutation:R2KeyByAccountOnly, mutation:R2KeyByStampLabels, mutation:R2SystemCallersShareGroup, mutation:R2OneCursor | built, partly tested | Turns between groups are attacked on the kernel only as one label set’s order beside a vault’s turn and a served group going behind one already waiting (more) |
| A lend is the caller’s own writable RAM, unmapped from it while lent, and an abandoned call’s lend stays with the server until it replies | R3 (lends and abandoned calls) | kernel/src/message.rs, kernel/src/arch/riscv/mem.rs | bench:redoubt-revoke, bench:timeouts, bench:ipc-outcomes, bench:uaf-lent-page, bench:process-lifecycle, bench:endpoint-destroy-open-calls, mutation:R3UnmapAbandonedLend, mutation:R3ChargeStaysWithCaller, mutation:AbandonNoticeMissing, mutation:AbandonNoticeRepeated, mutation:BadRecordConsumesNotice, mutation:EndpointDestroyNoticeKept | built | A server pays for the calls it holds (more) |
| A message is delivered only if the receiver’s budget can pay for everything it brings | R4 (delivery) | kernel/src/message.rs, kernel/src/handle.rs | bench:redoubt-ipc, bench:redoubt-dead, bench:redoubt-tight, mutation:R4IgnoreMaxTransfer, mutation:R4OverdrawOnDelivery, mutation:IpcDropPartial | built | Refused and Busy are signals (more) |
| An open call costs the receiver a page and counts against its limit of open calls | R4a (open calls) | kernel/src/message.rs, kernel/src/budget.rs | bench:redoubt-ipc, bench:net-pinned-ssh, mutation:R4aOpenCallsPerThread, mutation:R4aFullTakesNothing | built | A server pays for the calls it holds (more) |
A server that dies holding calls gives each caller Dead and its lend back intact | R4b (a server dies) | kernel/src/message.rs, kernel/src/process.rs | bench:redoubt-dead, bench:process-lifecycle, bench:ending-pumps-once, bench:destroy-keeps-notices, bench:destroy-keeps-notices-creator, mutation:R4bDeadServerFakesReply | built | A server pays for the calls it holds until it replies or dies; delivery reads the endpoint’s own lists, never every thread (more) |
| Every call ends in exactly one outcome, and caller and server agree on it | R13 (one outcome per call) | kernel/src/message.rs, libs/sys/src/ret.rs | bench:ipc-outcomes, bench:elixir-oracles, mutation:IpcWrongLend, mutation:IpcFalseDelivery, mutation:IpcSkipOutputCheck, mutation:IpcLeakRollback | built, partly tested | Completion races between harts are argued, not attacked (more) |
| The badge, account and labels a receiver sees are the kernel’s, never the sender’s claim | R14 (unforgeable sender) | kernel/src/message.rs | bench:redoubt-ipc, bench:bench-attack-forgery, bench:pid-reuse-authority, mutation:MsgNoLabels, mutation:MsgAccountZero | built, partly tested | A non-zero account or a label set reaching the receiver unchanged is attacked only in the model (more) |
| Every handle carries a stamp, and destroying that budget or one above it revokes the handle everywhere | R9 (stamps) | kernel/src/handle.rs, kernel/src/budget.rs | bench:process-attack, bench:redoubt-revoke, bench:budget-deadline, mutation:R9ReceivedHandleRestamped, mutation:R9MintStampsCaller, mutation:R9MsgStampIsSenderBudget, mutation:R10KeepForeignHandles | built, partly tested | Revocation is by budget only; a budget handle cannot be narrowed (more) |
| Kernel: memory | |||||
No RAM page is writable and executable, every page is zeroed, the zeroes cannot be discarded from user mode, no physical address leaks, DMA pages are held until reset, and map_fixed never replaces | R11 (memory) | kernel/src/mem.rs, kernel/src/arch/riscv/mem.rs, kernel/src/arch/riscv/asm.rs | bench:wx, bench:write-only-attack, bench:map-fixed-attack, bench:device, bench:mem-attack, bench:process-attack, bench:return-lent-unmapped, bench:dma-rules, bench:dma-reset-reuse, bench:device-exec-refused, bench:cbo-user-fault, bench:smp-inflight-race, mutation:R11NoZeroing, mutation:R11SetFlagsAllowsWx, mutation:R11SetFlagsAllowsWriteOnly, mutation:R11LendStaysMapped, mutation:R11MapFixedSkipsOverlap, mutation:R11ExecOnDeviceMemory, mutation:R11ProcessMapSkipsFlags | built, partly tested | No case ties a reused page it read to the frame another process filled (more) |
| A freed frame is no process’s and not free until it is zeroed, by the hart that freed it or by an allocation, one hart at a time, after every hart that could store to it has flushed; every free frame is zero | R81 (frames in flight) | kernel/src/reclaim.rs, kernel/src/mem.rs, kernel/src/arch/riscv/irq.rs | bench:smp-inflight-race, mutation:R81InFlightAllocatable, mutation:R81CommitUnzeroed | built, partly tested | QEMU drops stale translations at a hart’s next satp write, so the order that keeps a stale store out is checked from the trace (more) |
| The kernel’s own mappings are never writable and executable | R19 (kernel W^X) | loader/src/paging.rs, kernel/src/arch/riscv/mem.rs, libs/paging/src/lib.rs | bench:kernel-wx | built, partly tested | The physmap maps every user frame writable for the kernel (more) |
| A range call costs what the page tables need and the budget can pay, never what the length asks | R22 (range cost) | kernel/src/mem.rs, kernel/src/arch/riscv/mem.rs | bench:map-fixed-attack, bench:map-anon-search-bound, host:redoubt-model::huge_len_is_refused_promptly | built | map_anon’s search is bounded by its area, not by what is mapped: about 9.6 ms at worst (more) |
The kernel keeps sstatus.SUM and sstatus.MXR clear | R24 (SUM and MXR clear) | kernel/src/arch/riscv/asm.rs, kernel/src/arch/riscv/mod.rs | bench:sum-clear | built, partly tested | No probe covers MXR (more) |
| Kernel: budgets, scheduling and time | |||||
| Every kernel object is charged to one budget, and a charge over the limit fails before anything changes | R6 (charging) | kernel/src/budget.rs | bench:budget, bench:budget-mem-churn, bench:budget-table-attack, bench:map-fixed-tables, bench:page-table-reclaim, bench:pages-exhaustion, bench:redoubt-tight, bench:process-attack, bench:pid-pinning-attack, bench:thread-limit, mutation:R6ChargeAncestors, mutation:R6OwnPageChargedToItself, mutation:R6EndpointsFree, mutation:R6PageTablesFree, mutation:R6EmptyTableKept, mutation:R6OpenCallsFree, mutation:R6ProcessObjectFree, mutation:R6ProcessObjectChargedToBudget, mutation:R6LendChargedOnce, mutation:R6RootPageUncounted, mutation:R6PidUncountedAtEnd | built, partly tested | An endpoint’s page charge is attacked only in the model (more) |
| A child’s limits come out of its parent’s free limits and never add up to more | R7 (carving) | kernel/src/budget.rs | bench:budget-carve-attack, bench:budget, bench:process-attack, bench:pid-pinning-attack, host:redoubt-model::budget_lifecycles, mutation:R7NoCarveCheck, mutation:R7CarveToZeroFree, mutation:ProcessInWeightlessBudget | built | A lost budget stays carved until its parent goes (more) |
| A budget’s account is inherited and cannot be changed by the budget’s holder | R8 (accounts) | kernel/src/budget.rs | bench:process-attack, mutation:R8AccountFromArgument | built | Usage reads are signals (more) |
| Destroying a budget destroys everything below it, in a fixed order, and returns its resources to its parent; a reap does so for one child and keeps the budget | R10 (destruction) | kernel/src/budget.rs, kernel/src/time.rs | bench:budget, bench:budget-destroy-attack, bench:budget-destroy-kills, bench:budget-destroy-growth, bench:budget-deadline, bench:deadline-flood-billed, bench:redoubt-revoke, bench:process-attack, bench:pid-pinning-attack, bench:endpoint-destroy-open-calls, bench:endpoint-destroy-full, bench:handle-chain-attack, bench:handle-chain-fault, bench:process-chain-fault, bench:sched-destroy-billing, bench:dma-reset-quarantine, bench:dma-destroy-quarantine, bench:budget-reap, host:redoubt-model::budget_lifecycles, host:redoubt-model::quarantine_charge_moves_to_a_parent_at_its_limit, host:redoubt-model::a_reap_destroys_one_child_and_keeps_the_budget, host:redoubt-model::a_destruction_delivers_at_its_end, mutation:R10KeepForeignHandles, mutation:R10KeepCarvedLimits, mutation:R10SpareDescendantProcesses, mutation:R10ExitNoticesOutlivePayer, mutation:R10RevokedMessageDelivered, mutation:R10RevokedCallAnswered, mutation:R10SweptHandlesDropped, mutation:R10CreatorDeathSparesProcess, mutation:R10HeldPidsDropped, mutation:R10ReapDestroysParent, mutation:R10ReapKeepsCarve, mutation:R10ReapSkipsGrandchildren, mutation:R10DeliveredMidDestruction, mutation:ExpireBudgetsFirst, mutation:BudgetDeadlineIgnored | built, partly tested | Destruction is uninterruptible kernel time; a process entering the kernel in a tight loop to put its deadline off is not attacked (the timer’s part, more) |
| A budget’s CPU follows its free weight, in one queue with no priority | R12 (scheduling) | kernel/src/sched.rs, libs/stride/src/lib.rs | bench:sched-share, bench:steward-latency, bench:steward-latency-n4, bench:steward-latency-n16, bench:sched-share-release, bench:sched-capped, bench:sched-capped-mttcg, bench:sched-capped-holds-floor, bench:sched-sleep-gaming, bench:sched-idle-gap, bench:sched-exit-churn, bench:sched-budget-churn, bench:sched-budget-churn-shell, bench:sched-carve-inflation, bench:sched-debt-lift, bench:sched-lift-delay, bench:sched-timer-flood, bench:sched-server-busy, bench:sched-large-weight, bench:sched-large-weight-release, bench:deadline-flood-billed, bench:deadline-flood-billed-traced, bench:deadline-flood-billed-traced-mttcg, bench:sched-exit-churn-mttcg, bench:deadline-placement, bench:sched-carve-return, bench:map-anon-search-bound, bench:scan-bounds, bench:call-bounds, bench:worst-walk, host:redoubt-stride::the_crate_and_the_model_agree, host:redoubt-stride::a_broken_model_disagrees, host:redoubt-stride::a_slice_end_reads_no_more_states_for_a_longer_queue, host:redoubt-stride::a_rank_out_of_step_with_its_frame_trips_the_audit, host:redoubt-model::scheduler_fairness, host:redoubt-model::scheduler_contracts_hold, mutation:R12PriorityById, mutation:R12IgnoreWeight, mutation:R12WakeBanksCredit, mutation:R12TieQueuedFirst, mutation:R12RequeueAhead, mutation:R12RequeueLifo, mutation:R12PreemptOnWake, mutation:R12TimeoutWakePreempts, mutation:R12NoFloorWhenIdle, mutation:R12ShortRunsFree, mutation:R12DropRemainder, mutation:R12ExitRunsFree, mutation:R12DestroyDropsDebt, mutation:R12CreateAtFloorOnly, mutation:R12LiftByMax, mutation:R12StrideWeightIsLimit, mutation:R12UnnormalizedLift, mutation:R12LiftCountsEntryWait, mutation:R12FoldAtNewWeight, mutation:R12NoMinimumCharge, mutation:R12DeadlineWorkUnbilled, mutation:R12RescaleOnlyOnReturn, mutation:R12SliceCountsExitWork, mutation:R12TimerWorkUnbilled, mutation:R12SwitchBilledToPrevious, mutation:R12CappedHoldsFloor, mutation:R12CapOnce, mutation:R12AllCappedHoldsFloor, mutation:R12UncapBanksCredit, mutation:R12UncapForfeitsWait, mutation:R12EmptyHoldsFloor, mutation:R12RequeueWaitsForCap, mutation:R12BlockLeavesAsRequeued, mutation:R12OneRunnerPerBudget, mutation:R12SpreadChargesOnce | built, partly tested | Server work is paid by the server’s weight; wakeup is prompt but not bounded, and the steward decision-wake target is missed on rv64 (the timer’s part, more) |
On several harts, a hart waits for kernel entry behind at most MAX_HARTS - 1 kernel sections: the kernel lock is FIFO, so no budget’s harts can starve another’s of the kernel | R78 (fair kernel entry) | kernel/src/cell.rs | bench:smp-boot, bench:smp-lock-wait, bench:sched-lock-contention, bench:sched-lock-contention-4, bench:sched-lock-contention-4-mttcg, bench:irq-boot-hart-only, host:testbench::lock_waits_take_the_lock_in_ticket_order | built | The bound is by hart count, not time; a long kernel section delays every waiting hart by its length, and at two harts a wake behind R12’s costliest call waits about two of its sections (more) |
| The production kernel carries no test-only diagnostic channel | R23 (no test channels) | kernel/src/sched.rs, kernel/Cargo.toml | bench:bench-no-trace, bench:bench-trace-scan-catches, bench:bench-test-only-refused | built | Test builds carry more (more) |
| Kernel: processes and devices | |||||
| A reused PID inherits nothing of the process that had it | R20 (PID reuse) | kernel/src/process.rs | bench:pid-reuse-authority, bench:process-attack, bench:process-lifecycle, host:redoubt-model::pid_reuse_only_after_notice_receipt | built | PIDs are one global pool held by untaken notices (more) |
| A crash blames the sender of the current call, or nobody | R21 (crash blame) | kernel/src/process.rs, kernel/src/message.rs | bench:process, bench:process-attack, mutation:BlameNobody, mutation:BlameNewestCall, mutation:ExitWithOpenCallsNotFaulted, mutation:CurrentNeverSet, mutation:ReceiveKeepsCurrent, mutation:ServeIgnored, mutation:ExitEndpointBadged | built, partly tested | Delayed corruption can misattribute blame; a crash with no current call blames nobody (more) |
| An interrupt is masked and flagged, and reaches only the holder of its IRQ object | R5 (interrupts) | kernel/src/device.rs, kernel/src/arch/riscv/irq.rs, kernel/src/arch/riscv/intc_plic.rs | bench:uart-irq, bench:sched-irq-wake, bench:irq-first-receive, bench:receive-bad-record, mutation:R5NoMaskOnFire, mutation:R5NoUnmaskOnReceive, mutation:R5BadRecordConsumesInterrupt | built, partly tested | Two parts are not attacked on QEMU: the mask is attacked in the model, the completion order argued; the billing is attacked by bench:sched-irq-wake (owner system, whoever holds the handle) (more) |
| A process reaches a device only through a device object it was given | R18 (device authority) | kernel/src/device.rs, loader/src/main.rs, kernel/src/main.rs | bench:irq-attack, bench:legacy-gone, bench:device-info-attack | built, partly tested | A DMA driver is trusted without an IOMMU; one early process holds all device authority; a co-holder keeps its mapping of a quarantined or revoked device; a device tree that hides a controller defeats the Ctrl check (more) |
| Kernel: invariants | |||||
| Handles name live objects: a process names objects only through its own handle table | I1 (handles name live objects) | kernel/src/handle.rs | bench:budget-forge-attack, bench:budget-destroy-attack, bench:process, host:redoubt-model::exited_object_handles_and_queued_copies_live_until_notice_receipt, host:redoubt-model::kernel_sequences | built | A stale handle stops the machine (more) |
| Revocation is complete: no handle below a destroyed budget can be used anywhere | I2 (revocation is complete) | kernel/src/budget.rs, kernel/src/handle.rs, kernel/src/message.rs | bench:budget-destroy-attack, bench:handle-chain-attack, bench:handle-chain-fault, bench:process-chain-fault, bench:redoubt-revoke, bench:budget-deadline, bench:process-attack, mutation:R10KeepForeignHandles, host:redoubt-rt::badges_are_never_reused_and_ids_are_never_zero | built | Revocation is by budget only (more) |
| Minted badges are non-zero, and a mint never widens its source’s stamp | I3 (minted badges are non-zero and narrow) | kernel/src/message.rs | bench:redoubt-ipc-attack, bench:redoubt-revoke, bench:redoubt-ipc, host:redoubt-sys::malformed_calls_are_refused, mutation:R9MintStampsCaller | built | The model checks its own abstraction (more) |
| Only badge-0 handles receive | I4 (only badge-0 handles receive) | kernel/src/message.rs | bench:redoubt-ipc-attack, bench:redoubt-ipc, bench:process-attack, mutation:ReceiveWithBadgedHandle, mutation:ExitEndpointBadged | built | The model checks its own abstraction (more) |
| Every budget’s usage is within its limits | I5 (usage within limits) | kernel/src/budget.rs, kernel/src/dma.rs | bench:budget, bench:budget-carve-attack, bench:redoubt-tight, host:redoubt-model::quarantine_charge_moves_to_a_parent_at_its_limit, mutation:R4OverdrawOnDelivery, mutation:R7NoCarveCheck | built | Quarantined DMA pages stay charged (more) |
| A budget’s labels never change, and a child carries all its parent’s | I6 (labels only grow downward) | kernel/src/budget.rs | bench:budget, bench:process-attack, mutation:LabelsAddedByParentClass | built | Adding a label needs a system-class caller (more) |
| Every flow the kernel carries obeys the flow rule | I7 | kernel/src/message.rs, kernel/src/budget.rs | bench:process-attack, bench:process-review, mutation:R1SkipLabelCheck, mutation:R1ChecksReceiverNotOwner, mutation:R1ExitNoticeIgnoresLabels, mutation:R1UsageIgnoresLabels, mutation:R1UsageExemptBySystemTarget, mutation:R1ExitExemptBySystemExiting | built, partly tested | System-class servers are trusted to keep it (more) |
| A child budget inherits its parent’s class and account | I8 (class and account inherited) | kernel/src/budget.rs | bench:process-attack, mutation:ClassNotInherited, mutation:R8AccountFromArgument | built | The model checks its own abstraction (more) |
| User pages are W^X, zeroed before reuse, and unmapped from the lender while lent | I9 (pages W^X, zeroed, lends unmapped) | kernel/src/mem.rs, kernel/src/arch/riscv/mem.rs | bench:wx, bench:write-only-attack, bench:mem-attack, bench:map-fixed-attack, bench:return-lent-unmapped, bench:lender-touches-lent, bench:uaf-lent-page, bench:device-exec-refused, bench:smp-inflight-race, mutation:R11NoZeroing, mutation:R11SetFlagsAllowsWx, mutation:R11SetFlagsAllowsWriteOnly, mutation:R11LendStaysMapped, mutation:R11ExecOnDeviceMemory | built, partly tested | No case ties a reused page it read to the frame another process filled (more) |
| Creating then destroying a budget leaves its parent unchanged, once notices are taken | I10 (create-destroy leaves the parent unchanged) | kernel/src/budget.rs, kernel/src/process.rs | bench:budget, bench:budget-deadline, bench:process-lifecycle, bench:process-attack, host:redoubt-model::budget_lifecycles, mutation:R10KeepCarvedLimits | built | The invariant waits for the exit notices (more) |
| Each group of blocked senders gets a turn within k receives | I11 (fair turns) | kernel/src/endpoint.rs, kernel/src/message.rs | host:redoubt-model::flood, host:redoubt-model::kernel_sequences, bench:ipc-fair-label-sets, mutation:R2FifoAcrossAccounts, mutation:R2OneCursor | built, partly tested | Turns between groups are attacked on the kernel only as one label set’s order beside a vault’s turn and a served group going behind one already waiting (more) |
| Object ids are never reused | I12 (ids never reused) | kernel/src/budget.rs | bench:redoubt-ipc, bench:redoubt-ipc-attack, mutation:MsgIdsGlobal | built, partly tested | Id reuse is attacked only in the model (more) |
| Every blocking call returns by its timeout | I13 (every blocking call returns by its timeout) | kernel/src/time.rs, kernel/src/message.rs | bench:timeouts, bench:timeouts-tcg, bench:sched-latency, bench:sched-wake-no-preempt, host:redoubt-model::kernel_sequences, mutation:TimeoutIgnoredWhileOthersRun | built, partly tested | The invariant bounds the commit, not the run; the timer’s own cases for it are on the timer (more) |
| No sequence of calls, with any arguments, panics the kernel | I14 (no call panics the kernel) | kernel/src/redoubt.rs | bench:budget-syscall-attack, bench:syscall-attack, bench:redoubt-tight, bench:pages-exhaustion, host:redoubt-sys::malformed_calls_are_refused, fuzz:redoubt-sys/decode, host:redoubt-model::kernel_sequences | built | A kernel bookkeeping bug is a stop (more) |
| Every abandoned call is reported exactly once and stays open until replied to | I15 (abandoned calls reported once) | kernel/src/message.rs | bench:redoubt-ipc, bench:timeouts, bench:budget-deadline, bench:process-lifecycle, bench:ending-pumps-once, bench:receive-bad-record, bench:endpoint-destroy-open-calls, mutation:AbandonNoticeMissing, mutation:AbandonNoticeRepeated, mutation:BadRecordConsumesNotice, mutation:EndpointDestroyNoticeKept | built | A server pays for the calls it holds, abandoned ones too, until it replies (more) |
| DMA pages return to the pool only after every device that could write them confirmed a reset | I16 (DMA pages reset before reuse) | kernel/src/dma.rs | bench:dma-reset-reuse, bench:dma-reset-quarantine, bench:dma-destroy-quarantine, bench:dma-rules, host:redoubt-model::reset_at_one_death_does_not_cover_a_co_holder, host:redoubt-model::deaf_device_quarantines_the_co_holder_too, host:redoubt-model::exit_pools_after_reset, mutation:DmaFreeBeforeReset, mutation:DmaQuarantinedSlotCountsAsReset, mutation:DmaResetClearsCoHolderReach, mutation:DmaUnmapFrees | built, partly tested | The reset covers reuse, not a live driver (more) |
| Boot | |||||
| No byte of the bundle is parsed or run before its signature checks | R15 (verified boot) | loader/src/verify.rs, libs/signing/src/lib.rs | bench:verified-boot-rejects-tamper, bench:verified-boot-rejects-bare-archive, bench:verified-boot-rejects-short-initrd, host:testbench::golden_signature_over_a_known_archive | built | The loader itself is unverified on QEMU; the development key is public; no rollback protection (more) |
| A module or application resource resolved by name, or a program launched from the userland disk, comes only from a verified volume (R76 (verified volumes)); a refused lookup never falls through to the code path | R75 (verified userland) | image/manifest.json, userland/otp/redoubt/src/userland.rs, userland/otp/redoubt/src/pack.rs, userland/otp/redoubt/src/lib.rs, userland/otp/redoubt/src/bin/beamlet.rs, userland/otp/vm/src/vm.rs, userland/otp/vm/src/bif/info.rs | bench:userland-bad-start, bench:userland-boot, bench:userland-read-only, bench:verity-flipped-tree, bench:verity-wrong-root, bench:pack-bad-truncated, bench:pack-bad-wrong-length, bench:pack-bad-wrong-name, host:beamlet-redoubt::a_pack_with_a_bad_entry_is_refused_whole, host:beamlet-vm::a_refused_system_module_never_touches_the_code_path, host:beamlet-vm::app_spec_uses_one_source_attempt_and_keeps_its_erlang_result, host:beamlet-redoubt::a_module_is_its_file_and_a_failed_read_is_refused, host:beamlet-redoubt::not_found_at_the_open_is_absent_and_every_other_error_is_refused_by_name, host:beamlet-redoubt::verified_module_lookup_propagates_found_absent_and_refused, host:beamlet-redoubt::verified_application_lookup_propagates_found_absent_and_refused | built, partly tested | Programs come from the volume when launching exists (BEAM4); code:load_binary/3 still loads a session’s own bytes, within its authority (more) |
| The loader confines every image to its own area and refuses a truncated one | R16 (image confinement) | loader/src/image.rs | bench:loader-rejects-kernel-address, bench:loader-rejects-kernel-entry, bench:loader-rejects-truncated-elf, bench:loader-rejects-segment-data-cut, bench:loader-rejects-wx-segment | built | The kernel trusts the loader’s handoff (more) |
| A bad signature, a short random seed or a missing timebase powers the machine off | R17 (fail closed) | loader/src/main.rs, loader/src/dt.rs, kernel/src/arch/riscv/timer_sbi.rs, kernel/src/platform/sbi/rand.rs, kernel/src/arch/riscv/process.rs | bench:verified-boot-rejects-tamper, bench:verified-boot-rejects-bare-archive, bench:verified-boot-rejects-short-initrd, bench:boot-rejects-no-seed, bench:boot-rejects-short-seed, bench:boot-rejects-no-timebase, bench:boot-rejects-irq-past-plic, host:redoubt-layout::ram_one_page_past_the_physmap_end_is_refused, host:loader::a_boot_hart_without_an_s_mode_context_is_refused, host:loader::booting_on_hart_1_takes_hart_1s_s_mode_context, host:loader::a_33rd_mmio_region_is_refused, host:loader::a_33rd_interrupt_is_refused, host:loader::thirty_two_devices_are_kept, host:loader::an_interrupt_two_devices_raise_takes_one_slot, host:paging::the_asid_width_decision | built, partly tested | The kernel’s refusal of a missing Seed tag stands behind the loader’s and is not reached by a boot; the device tree is not signed (more) |
| A program runs only with a startup block that passed every rule | R31 (startup block checked whole) | libs/rt/src/startup.rs | bench:rt-host-tests, bench:session-hostile-launch, host:redoubt-rt::hostile_blocks_are_refused, host:redoubt-rt::fields_hold_whole_entries, host:redoubt-rt::handle_names_follow_the_manifest_rule, host:redoubt-rt::image_round_trips_and_is_validated, host:redoubt-rt::handle_counts_are_what_process_start_can_install, host:redoubt-rt::random_bytes_never_panic, fuzz:redoubt-rt/startup | built | The startup block’s fuzz target runs outside the bench (more) |
| No launcher parses an ELF; a hostile image hurts only its own process | R32 (a hostile image hurts only its process) | stub/src/main.rs, stub/src/lib.rs | bench:stub-launch, bench:session-hostile-launch, host:stub::plan_refuses_a_segment_overlapping_an_excluded_range, host:stub::plan_refuses_writable_and_executable, host:stub::plan_refuses_two_segments_that_overlap_each_other, host:stub::plan_refuses_a_segment_touching_page_zero, host:stub::plan_refuses_a_segment_reaching_into_the_stub_region, host:stub::image_in_bounds_refuses_an_image_overlapping_the_stub, host:stub::read_image_refuses_an_image_len_over_the_cap, host:stub::plan_refuses_a_segment_over_its_own_image, host:stub::the_fuzz_corpus_still_passes | built, partly tested | On target, the kernel’s refusal hides the stub’s overlap checks (more) |
Only init and the steward ever hold a system-class budget | R33 (no server holds a system budget) | servers/init/src/check.rs; the policy is modelled in model/src/steward.rs | bench:init-refuses-budget-handle, host:redoubt-init::a_server_handed_a_budget_is_refused, bench:steward-boot | built | A system server that hands one across is at fault, not the kernel (more) |
| In a confined deployment no two label sets share a server, volume, endpoint, network or device, except the named control plane | R34 (confined placement) | servers/init/src/confine.rs, servers/steward/src/own.rs | bench:init-refuses-confined-server, bench:steward-confined-push, host:redoubt-init::confined_refuses_two_label_sets_on_one_endpoint, host:redoubt-init::confined_refuses_two_label_sets_on_one_volume, host:redoubt-init::confined_counts_a_home_for_the_principals_unlabelled_domain_alone, host:redoubt-init::the_steward_is_told_a_confined_manifest, host:redoubt-steward-server::a_confined_boot_leaves_a_labelled_sessions_home_unbound, host:redoubt-init::confined_gives_a_labelled_domain_no_network, host:redoubt-init::confined_refuses_two_label_sets_on_one_disk, host:redoubt-init::confined_refuses_a_server_instance_serving_two_label_sets, host:redoubt-init::confined_counts_only_a_shared_servers_own_label_set, host:redoubt-init::confined_does_not_count_an_exempt_server_handed_a_labelled_endpoint, host:redoubt-init::confined_lets_label_sets_that_share_nothing_share_the_cores, host:redoubt-init::confined_gives_each_label_set_its_own_userland_disk, host:redoubt-init::confined_gives_each_label_set_its_own_verifier | built | The named mediators are trusted across the labels they serve; a confined boot is checked once (more) |
keyd never holds a key that authenticates anyone to the box | R35 (key separation) | servers/init/src/check.rs, servers/init/src/bin/init.rs; the policy is modelled in model/src/steward.rs | bench:init-refuses-held-login-key, bench:init-refuses-held-bundle-key, bench:init-refuses-second-keyd, host:redoubt-init::every_login_and_approval_key_then_the_bundle_key_is_asked_about, host:redoubt-init::init_calls_one_of_each_server_it_calls | built | The seeds live in init’s memory and the bundle (more) |
| The loader refuses a system bundle older than the counter outside both slots | R72 (no rollback below the counter) | planned: the loader | — | planned · M6 (persist, install, share) | A resettable counter undoes rollback protection (more) |
| Servers: the serving library and wire formats | |||||
| A shared server lets data flow to a caller only from objects whose labels it holds, and into objects only with equal labels | R25 (the label check) | libs/rt/src/server/label.rs, libs/rt/src/server/ninep.rs | host:redoubt-rt::matches_the_set_definition, host:redoubt-rt::properties, host:redoubt-rt::labels_are_checked_on_every_request, host:redoubt-rt::every_write_needs_equal_labels, host:redoubt-rt::labelled_metadata_does_not_flow_down, host:redoubt-rt::an_unlabelled_caller_cannot_reach_labelled_data_to_destroy_or_probe_it, host:redoubt-rt::a_mint_at_its_own_root_reads_nothing, bench:net-attacks, bench:littlefsd-label-check, bench:steward-agent-escape | built | System servers are trusted to apply it (more) |
| One client cannot use up a shared server that serves others | R26 (admission fairness) | libs/rt/src/server/admit.rs, libs/rt/src/server/minted.rs | bench:walfsd-flood-turn, bench:vault-flood-turn, bench:walfsd-pools, bench:lease-code-load, bench:session-tcp-churn, host:redoubt-rt::the_key_is_the_account_and_the_label_set, host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share, host:redoubt-rt::a_root_fid_is_a_fixed_cost_outside_the_share, host:testbench::the_file_servers_fid_caps_leave_every_connection_its_root_and_working_share, host:redoubt-client::an_admission_refusal_of_the_moment_is_tried_again, host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share_and_its_lease_end, host:redoubt-rt::self_minting_does_not_multiply_the_share, host:redoubt-rt::a_connection_minted_for_another_account_is_admitted_there_from_its_first_request, host:redoubt-rt::a_first_request_on_a_connection_its_account_cannot_hold_is_refused_and_it_stays_pending, host:redoubt-rt::an_account_0_chain_holds_one_bucket, host:redoubt-rt::an_account_0_rooted_chain_holds_one_bucket, host:redoubt-rt::caps_are_big_enough_for_a_share_to_mean_anything, host:redoubt-rt::open_calls_leave_headroom, host:redoubt-rt::the_worst_order_never_passes_the_headroom, host:redoubt-rt::a_bucket_count_is_given_once_and_never_defaulted, host:redoubt-bootfsd::a_bad_public_list_stops_the_server, host:redoubt-consoled::a_console_with_no_device_does_not_start, host:redoubt-keyd::bad_key_arguments_stop_keyd_starting, host:redoubt-ipd::every_scope_and_the_milestone_parse, host:redoubt-ipd::a_heap_that_cannot_hold_every_socket_stops_ipd | built, partly tested | An undersized server is a channel (more) |
| A server never gives out one badge twice, across restarts too | R27 (badge allocation) | libs/rt/src/server/minted.rs | host:redoubt-rt::the_first_badge_is_random_and_leaves_room, host:redoubt-rt::badges_are_never_reused_and_ids_are_never_zero, host:redoubt-rt::the_badge_space_runs_out_cleanly, host:redoubt-rt::a_mint_that_fails_records_nothing | built | Two runs share a badge with probability 2^-62 (more) |
| A parked call holds exactly one admission slot of its caller until it is resumed, expires or is abandoned | R28 (parked-call accounting) | libs/rt/src/server/parked.rs | host:redoubt-rt::parking_is_admitted_per_bucket_and_share, host:redoubt-rt::parked_calls_are_served_abandoned_and_expired, host:redoubt-rt::a_waiting_write_is_parked_abandoned_and_expired, bench:net-pinned, bench:net-pinned-ssh | built, partly tested | A console read has no deadline (more) |
| A client holds no more of a server through a multiplexed connection than one share, however many requests it sends or completions it leaves uncollected | R77 (multiplexed requests) | libs/rt/src/server/ninep_mux.rs | host:redoubt-rt::a_never_polling_client_holds_only_its_share, host:redoubt-rt::a_sends_pages_count_once_and_go_back_with_its_last_request, host:redoubt-rt::death_with_requests_parked_frees_the_connection, host:redoubt-rt::a_death_between_completion_calls_is_found_at_the_session_bound, host:redoubt-rt::a_request_late_in_a_hold_leaves_the_session_its_whole_bound, host:redoubt-rt::a_flush_racing_a_completion_answers_once, host:redoubt-rt::a_flood_of_sends_at_wait_cap_never_blocks_the_server, host:redoubt-rt::a_reused_or_out_of_range_tag_ends_the_connection, host:redoubt-rt::a_second_completion_call_is_refused | built, partly tested | A console read has no deadline (more) |
| Decoding untrusted bytes never panics, never reads outside its input, and re-encodes exactly | R29 (strict decoding) | libs/wire/src/codec.rs, libs/wire/src/ninep.rs, libs/wire/src/json.rs | fuzz:redoubt-wire/ninep, fuzz:redoubt-wire/typed, fuzz:redoubt-wire/json, host:redoubt-wire::lengths_are_bounded_by_the_input, host:redoubt-wire::framing_is_strict, host:redoubt-wire::trailing_and_padding, host:redoubt-wire::words_must_fit_in_32_bits, host:redoubt-wire::buffer_length_is_bounded, host:redoubt-wire::heap_is_bounded, host:redoubt-wire::stack_is_bounded | built | The fuzz targets run outside the bench (more) |
| Each typed message has one layout, from one table, with generated codecs checked against it | R30 (one layout per message) | libs/wire/src/typed.rs, libs/wire/tables | host:redoubt-wire-gen::generated_files_are_current, host:redoubt-wire-gen::refuses_bad_tables, host:redoubt-wire-gen::every_row_is_read_or_refused, host:redoubt-wire-gen::malformed_is_code_one_everywhere, host:redoubt-wire-gen::ninep_marker_sets_the_opcode_floor, bench:wire-host-tests | built | Handle kinds are not checked on receipt (more) |
| Servers: the steward | |||||
| Every id the steward hands out is unpredictable | R36 (unpredictable ids) | libs/steward/src/machines.rs (the core draws every id from the random words), servers/steward/src/drive.rs (the server’s words, top bit set); modelled in model/src/steward.rs | bench:steward-session-ends, bench:vault-no-leak, host:redoubt-steward-server::a_login_runs_the_session_batch_and_answers_the_session | built | Ids are only as unpredictable as the generator’s key (more) |
| A vault session’s work changes nothing an unlabelled session observes | R37 (vault non-interference) | libs/steward/src/machines.rs (each session carved from its label set’s fixed sub-budget), servers/steward/src/lib.rs (the carve), servers/steward/src/bin/steward.rs (the binding table), libs/steward/src/guards.rs (sees: a session’s contexts are its domain’s); modelled in model/src/steward.rs | bench:steward-vault-session, bench:steward-sub-budget-flood, bench:vault-flood-turn, bench:vault-no-leak, bench:shell-contexts, host:redoubt-steward-server::a_vault_login_carves_from_the_vault_s_sub_budget_and_has_no_network, host:redoubt-steward::a_session_sees_and_ends_its_own_domain_s_contexts_only, host:redoubt-steward-server::a_session_lists_detaches_and_ends_its_own_domain_s_contexts | built | The mediators are trusted across labels; a shared server’s own crash shows whose call it was serving, unless confined (more) |
An approval counts only from approve@box, with the approver’s key, for the frozen request | R38 (out-of-band approval) | libs/steward/src/guards.rs (approval_key, owns_labels, rendered_here, hash_matches, pending_cap, fair_share), libs/steward/src/effects.rs (notify, render, drop_requests), libs/steward/src/edges.rs (approval), libs/steward/src/render.rs (screen, sanitize), servers/steward/src/protocol.rs (the principal from the key, the code), servers/steward/src/drive.rs (compose, hash_of_code), servers/steward/src/approval.rs (parse, Lines), servers/steward/src/bin/steward.rs (reader_thread, read_channel), servers/sshd/src/bin/sshd.rs (approve); modelled in model/src/steward.rs | bench:steward-approve-box, bench:steward-screen-inert, bench:steward-pending-cap, host:redoubt-steward-server::a_request_is_rendered_bound_to_its_screen_and_its_approval_starts_the_labelled_agent, host:redoubt-steward-server::an_approval_channel_opens_on_the_approval_key_alone, mutation:PolicyApproveIgnoresHash, mutation:PolicyApproveWithLoginKey, mutation:PolicyApproveOtherChannel, mutation:PolicyRenderNotWhitelisted, mutation:PolicyLabelledFreeTextShown, mutation:PolicyNotifyLabelledToAll, mutation:PolicyShowLabelledToAll, bench:steward-vault-note | built, partly tested | An approved text can carry a hidden message; approve@box shares sshd (more) |
| Every lease has a bounded deadline, sub-agents end with it, and the sponsor can always end it | R39 (leases end) | libs/steward/src/guards.rs (lease_bounded, sponsor_session, sponsor_of, sponsor_set), libs/steward/src/effects.rs (carve_lease, carve_session: a sponsored principal’s session under its sponsor’s with a deadline, notify_sponsor), libs/steward/src/edges.rs (end_lease, ahead of admission), servers/steward/src/drive.rs (the batches, the notice), servers/steward/src/bin/steward.rs (the lease’s launch, the notices endpoint); modelled in model/src/steward.rs | bench:steward-lease-ends, bench:steward-end-lease, bench:steward-sub-agent-dies-with-agent, bench:agent-login, host:redoubt-steward::a_sponsored_principals_session_is_a_lease_of_its_sponsors, mutation:PolicyUnboundedLease, mutation:PolicyEndLeaseFromVault, mutation:PolicySponsoredEndedByAnyone, mutation:PolicySponsoredVaultLogin, bench:steward-agent-escape, bench:steward-lease-cut | built | Steward work is paid by the steward (more) |
| Crash blame ends only the blamed (account, label set)’s sessions, and only after three crashes in ten minutes | R40 (blame by label set) | libs/steward/src/guards.rs (blame_window, not_locked), libs/steward/src/effects.rs (lock_out), servers/steward/src/protocol.rs (blame), servers/init/src/blame.rs, servers/init/src/reconnect.rs and servers/steward/src/drive.rs (reconnected, rebind: the restarted server’s sessions get their files back, so the count reaches three); modelled in model/src/steward.rs | bench:steward-blame-lockout, bench:steward-server-restart, host:redoubt-steward-server::three_blames_in_the_window_lock_the_domain_out_until_the_window_passes, host:redoubt-steward::blame_window_and_not_locked, host:redoubt-steward::a_leases_exit_is_its_end_and_no_blame, mutation:PolicyBlameNoWindow, mutation:PolicyNoLockout | built | Blame follows the current call; the vault arm and the window’s end are host tests; a lease’s connections at a restarted server stay dead (more) |
| A connection narrowed to a session’s life hangs on a revocation scope, never on a budget with processes | R41 (narrowing by revocation scope) | libs/rt/src/server/ninep.rs (new_connection’s and grant’s scope stamps the mint), libs/rt/src/client.rs, servers/ipd/src/server.rs (grant’s revocation scope), servers/steward/src/drive.rs (every Connect in the object’s scope, every shared server’s connection, 9P and the network alike; destroy, then disconnect), servers/init/src/bin/init.rs (the steward’s badges stamped users); modelled in model/src/steward.rs | host:redoubt-rt::a_connection_minted_in_a_scope_ends_when_the_scope_is_destroyed, host:redoubt-rt::a_connection_is_minted_in_the_scope_its_requester_sends, host:redoubt-ipd::a_granted_connection_in_a_scope_ends_when_the_scope_is_destroyed, host:redoubt-ipd::a_grant_is_minted_in_the_revocation_scope_it_brings, host:redoubt-steward-server::a_session_s_end_destroys_its_budget_before_it_releases_its_connections, host:redoubt-init::the_steward_s_entry_alone_is_given_the_manifest_lines, bench:steward-agent-escape, bench:steward-lease-cut | built, partly tested | none |
| Data crosses a label only as one owner-approved item, snapshotted and audited | R42 (one approved item) | libs/steward/src/effects.rs, libs/steward/src/guards.rs, servers/steward/src/drive.rs, servers/crossing/src | bench:steward-declassify, bench:steward-push, host:redoubt-steward-server::a_declassification_reads_through_a_crossing_and_its_approval_copies_the_snapshot_out, host:redoubt-steward-server::a_push_reads_the_source_unlabelled_and_its_approval_writes_through_a_crossing, host:redoubt-crossing::an_item_over_the_bound_is_cut_one_past_declassify_max, mutation:PolicyDeclassifyLive, mutation:PolicyDeclassifyWithoutReader, mutation:PolicyDeclassifyFromUnlabelled, mutation:PolicyDeclassifyUnfit, mutation:PolicyWriteUp | built, partly tested | An approved text can carry a hidden message; a push is one human action (more) |
| A domain holds at most one live session of a context’s name | R79 (one session per context) | libs/steward/src/guards.rs (the guard context_free), libs/steward/src/machines.rs (a login’s context checked as a name); modelled in model/src/policy.rs | bench:steward-context-login, host:redoubt-steward::context_free_holds_one_session_per_name, host:redoubt-steward-server::a_login_to_an_attached_context_takes_it_over, host:redoubt-model::steward_policy | built | none |
| A context’s console is attached to at most one channel, only after its login’s authentication, and only the steward ends a channel | R80 (one channel per context) | libs/steward/src/effects.rs (take_over, attach_relay, detach_relay), servers/sshd/src/console.rs (Consoles::may_end), servers/consrelay; modelled in model/src/policy.rs | bench:steward-context-login, bench:steward-session-ends, bench:steward-context-labels, bench:sshd-restart-detaches, host:redoubt-steward::a_context_is_attached_to_one_channel_at_a_time, host:redoubt-steward-server::a_login_to_an_attached_context_takes_it_over, host:redoubt-steward-server::closing_the_channel_detaches_the_context_and_a_login_reattaches, host:redoubt-steward-server::sshd_gone_detaches_every_attached_context, host:redoubt-sshd::a_login_s_two_consoles_and_only_the_steward_s_ends_the_channel, host:redoubt-model::steward_policy | built | none |
| A principal holds at most its cap of live contexts per label set; a login past it is refused after the key and before anything is carved, and nothing is evicted | R82 (the context cap) | libs/steward/src/guards.rs (the guard under_cap), servers/init/src/check.rs (holds, contexts_of); modelled in model/src/policy.rs | bench:steward-context-cap, bench:shell-contexts, host:redoubt-steward::the_cap_refuses_a_new_context_and_keeps_the_oldest, host:redoubt-steward-server::a_login_past_the_cap_is_refused_and_makes_nothing, host:redoubt-init::the_steward_s_sizes_fit_every_principal_s_smallest_share, host:redoubt-model::steward_policy | built | none |
| A detached context ends at its principal’s idle bound; an attached one never does; an attach clears the clock | R83 (idle contexts end) | libs/steward/src/guards.rs (the guard idle_due), libs/steward/src/effects.rs (attach_with, detach_relay), servers/steward/src/bin/steward.rs (the timer); modelled in model/src/policy.rs | bench:steward-context-idle, host:redoubt-steward::idle_ends_a_detached_context_only, host:redoubt-model::steward_policy | built | none |
| Servers: keys, files and devices | |||||
No keyd message returns a private key or changes the key set | R43 (no export) | servers/keyd/src/server.rs, servers/keyd/src/keys.rs | bench:r4-host-tests, host:redoubt-keyd::nothing_in_the_protocol_returns_a_key, host:redoubt-keyd::no_request_can_put_a_key_into_keyd, host:redoubt-keyd::random_requests_never_panic | built | The seeds live in init’s memory and the bundle (more) |
A badge signs with one key, for one purpose, over a digest keyd computes itself | R44 (one key, one purpose, keyd’s own digest) | servers/keyd/src/keys.rs, servers/keyd/src/ssh.rs | bench:r4-host-tests, host:redoubt-keyd::a_badge_signs_only_its_own_key_and_only_its_own_purpose, host:redoubt-keyd::a_relayed_ssh_user_auth_blob_is_never_what_gets_signed, host:redoubt-keyd::parts_cannot_be_slid_into_each_other, host:redoubt-keyd::the_hash_matches_an_independent_implementation, host:redoubt-keyd::a_granted_capability_names_the_same_key_and_dies_with_release | built | keyd sees the SSH shared secret; an ssh_host badge speaks as the box (more) |
| Signing takes the same time whatever the key | R45 (constant-time signing) | libs/sha256/src/lib.rs, servers/keyd/src/keys.rs, the Ed25519 crate keyd links, at the version pinned in Cargo.lock | — | built, partly tested | The timing tests are ignored in an ordinary test run (more) |
/boot shows exactly the public list’s entries, fixed at the seal | R46 (only the public list) | servers/bootfsd/src/server.rs | host:redoubt-bootfsd::a_session_reads_the_public_entries_and_sees_nothing_else, host:redoubt-bootfsd::a_walk_to_an_unpublished_name_is_the_same_as_to_one_that_never_existed, host:redoubt-bootfsd::setup_is_refused_after_seal_and_from_every_minted_connection, host:redoubt-bootfsd::nothing_is_visible_before_seal, host:redoubt-bootfsd::every_way_of_writing_is_refused, host:redoubt-bootfsd::a_client_cannot_publish_into_boot | built | Whatever is public is public to everyone (more) |
Each walfsd, littlefsd or erofsd instance serves one volume and holds only its block range | R47 (one volume per instance) | servers/init/src/check.rs, servers/init/src/bin/init.rs, servers/littlefsd/src/bin/littlefsd.rs, servers/erofsd/src/bin/erofsd.rs, servers/walfsd/src/bin/walfsd.rs | bench:littlefsd-one-volume, bench:walfsd-one-volume, host:redoubt-init::a_walfsd_entry_is_a_volume_server_as_a_littlefsd_one_is, host:redoubt-init::an_erofsd_entry_is_a_volume_server_as_a_littlefsd_one_is, host:redoubt-init::no_server_is_handed_a_badge_at_blkd | built | A shared littlefsd is shared state (more) |
| Every connection’s root has a byte quota carved from its granter’s | R48 (a quota per attach root) | libs/fileserver/src/quota.rs, servers/littlefsd/src/server.rs, servers/walfsd/src/server.rs | bench:littlefsd-quota, bench:walfsd-pools, bench:walfsd-quota, host:redoubt-littlefsd::a_root_with_quota_0_cannot_create_but_can_read_and_remove, host:redoubt-littlefsd::a_write_past_one_roots_quota_is_refused_while_another_still_writes, host:redoubt-walfsd::a_root_with_quota_0_cannot_create_but_can_read_and_remove, host:redoubt-walfsd::a_write_past_one_roots_quota_is_refused_while_another_still_writes | built | A refused rename or remove says a live root is there (more) |
| Whatever bytes the medium holds, littlefs, erofs and walfs refuse them as corrupt rather than crashing | R49 (a hostile medium is corrupt, not a crash) | libs/littlefs/src/mdir.rs, libs/littlefs/src/fs.rs, libs/erofs/src/read.rs, servers/erofsd/src/server.rs, libs/walfs/src/layout.rs, libs/walfs/src/fs.rs, servers/walfsd/src/server.rs | bench:erofs-corrupt, bench:walfsd-corrupt-volume, bench:walfsd-flipped-block, fuzz:erofs/image, fuzz:littlefs/image, fuzz:littlefs/mutate, fuzz:walfs/image, fuzz:walfs/mutate, host:littlefs::corrupted_bytes_never_panic, host:littlefs::forged_file_sizes_do_not_amplify_allocation, host:littlefs::noise_never_panics, host:littlefs::skip_list_pointing_at_itself_terminates, host:littlefs::stale_handle_after_pair_drop_does_not_erase_another_files_data, host:littlefs::tail_list_cycle_is_refused, host:redoubt-walfsd::a_flipped_bit_in_a_file_is_corrupt_for_that_file_alone, host:redoubt-walfsd::noise_is_never_formatted_and_never_mounted, host:walfs::every_flipped_bit_is_corrupt_where_it_is_read, host:walfs::noise_never_panics | built | littlefs’s data is not checksummed (more) |
| A power cut at any block write leaves every metadata change done or not done, and on walfs every operation | R50 (power loss leaves before or after) | libs/littlefs/src/fs.rs, libs/littlefs/src/mdir.rs, libs/walfs/src/fs.rs, servers/walfsd/src/volume.rs | bench:walfsd-power-loss, host:littlefs::crash_at_every_write_random_workloads, host:littlefs::crash_at_every_write_small_blocks, host:littlefs::crash_at_every_write_torn_erases, host:littlefs::crash_during_repair, host:redoubt-walfsd::a_cut_at_every_write_of_a_write_and_a_rename_leaves_before_or_after, host:walfs::crash_at_every_write_fixed_workload, host:walfs::crash_at_every_write_random_workloads, host:walfs::crash_during_recovery | built | Attributes and data are two commits (more) |
blkd points the device only at its own DMA region | R51 (DMA stays in its region) | servers/blkd/src/queue.rs, servers/blkd/src/virtio.rs | host:redoubt-blkd::a_broken_device_is_never_handed_a_clients_write_payload, host:redoubt-blkd::a_hundred_thousand_random_liars_never_panic_and_never_stray, host:redoubt-blkd::impossible_requests_never_reach_the_device | built | blkd is trusted without an IOMMU (more) |
A lying disk makes a request fail, never corrupts blkd or mixes clients’ bytes | R52 (a lie is a failure, never corruption) | servers/blkd/src/transport.rs, servers/blkd/src/queue.rs | fuzz:redoubt-blkd/device, host:redoubt-blkd::rewriting_the_rings_changes_nothing_the_driver_believes, host:redoubt-blkd::a_device_that_lies_about_a_completion_is_refused_and_never_spoken_to_again, host:redoubt-blkd::a_lying_device_becomes_failed_and_stays_failed, host:redoubt-blkd::a_hundred_thousand_random_liars_never_panic_and_never_stray, host:redoubt-blkd::one_clients_read_never_carries_anothers_bytes | built | Wrong bytes are not detected (more) |
| A client’s badge names one partition, and overlapping partition tables are refused | R53 (a filesystem sees only its partition) | servers/blkd/src/gpt.rs, servers/blkd/src/range.rs | fuzz:redoubt-blkd/gpt, host:redoubt-blkd::a_range_cannot_name_a_sector_outside_itself, host:redoubt-blkd::hostile_partition_entries_are_refused, host:redoubt-blkd::a_badge_that_names_no_range_is_refused, host:redoubt-blkd::a_gap_in_the_table_does_not_renumber_the_volumes_after_it | built | blkd is trusted without an IOMMU (more) |
A reader of a verified volume sees only blocks that hash, through the tree, to the root the signed manifest gives, or, for a signed volume, to the root its root block gives, signed under the manifest’s key at a version no lower than the manifest’s floor; otherwise a device failure, which littlefsd serves as corrupt | R76 (verified volumes) | libs/verity/src/lib.rs, libs/signing/src/lib.rs, servers/verityd/src/volume.rs, servers/verityd/src/server.rs | bench:userland-boot, bench:userland-bad-start, bench:verity-flipped-tree, bench:verity-wrong-root, bench:verity-bad-signature, bench:verity-rollback, host:redoubt-verity::a_flipped_bit_at_each_level_is_refused, host:redoubt-verityd::a_wrong_root_or_top_is_refused, host:redoubt-verityd::a_mismatch_is_failed_and_said_naming_the_block, host:redoubt-verityd::a_failed_block_is_never_kept, host:redoubt-verityd::arbitrary_media_never_panic, host:redoubt-verityd::a_root_block_that_does_not_verify_is_refused_naming_the_signature, host:redoubt-verityd::a_root_block_below_the_floor_is_refused_naming_the_version | built | Rollback is the bundle’s, a signed volume’s floor with it; writable volumes are unverified (more) |
| No labelled caller writes to the physical console | R69 (no write down onto the console) | servers/consoled/src/server.rs | host:redoubt-rt::labels_are_checked_on_every_request, host:redoubt-rt::every_write_needs_equal_labels | built, partly tested | Anyone with a connection reads the console (more) |
A stuck or lying UART never hangs consoled | R70 (a stuck UART never hangs the console) | servers/consoled/src/uart.rs, servers/consoled/src/server.rs | host:redoubt-consoled::a_device_stuck_on_data_ready_does_not_hang_the_server, host:redoubt-consoled::a_flood_of_input_keeps_what_was_typed_first | built | QEMU’s UART neither sticks nor lies: only a fake one on the host does (more) |
| Servers: packages and services | |||||
| Code runs with a principal’s grants only if a key on its trust list signed it | R71 (no new authority without trust) | planned: the steward and the pkg server | — | planned · M6 (persist, install, share) | A stolen trusted key; a hijacked agent runs code it wrote (more) |
| An untrusted archive never reaches the parser, and a trusted hostile one writes only its principal’s packages | R74 (a hostile package stays in its principal’s packages) | planned: the pkg server | — | planned · M6 (persist, install, share) | Every principal reaches the whole system-call interface (more) |
| A restarted service gets exactly its recorded grants, through fresh connections | R73 (a restart never widens) | planned: the supervisor | — | planned · M6 (persist, install, share) | A restart does not clean what the service wrote (more) |
| Network | |||||
netd points the device only at its two DMA regions | R54 (DMA stays in netd’s regions) | servers/netd/src/ring.rs, servers/netd/src/virtio.rs | host:redoubt-netd::scribbling_devices_change_nothing_netd_believes, host:redoubt-netd::randomized_hostile_devices, host:redoubt-netd::a_transmit_sends_exactly_its_header_and_frame | built | netd is trusted without an IOMMU (more) |
| A bad frame from the wire is dropped as content, never taken as a device lie | R55 (a bad frame is content, not a lie) | servers/netd/src/receiver.rs, servers/netd/src/rxq.rs | host:redoubt-netd::a_bad_frame_from_the_wire_is_dropped_never_a_lie, host:redoubt-netd::receive_lies_are_refused, fuzz:redoubt-netd/device | built | A frame flood costs netd’s CPU (more) |
| No byte of an earlier frame reaches a later one | R56 (no earlier frame leaks) | servers/netd/src/rxq.rs, servers/netd/src/txq.rs | host:redoubt-netd::a_transmit_sends_exactly_its_header_and_frame, host:redoubt-netd::an_inflated_length_reads_zeros_not_old_frames, host:redoubt-netd::frames_arrive_exactly_and_every_slot_comes_back | built | A reset stops the device for everyone (more) |
On every way out netd controls, the device is reset first | R57 (the device stops before netd does) | servers/netd/src/device.rs, servers/netd/src/bin/netd.rs | host:redoubt-netd::a_panic_resets_the_device, host:redoubt-netd::the_receive_loop_resets_and_reports_a_lie, host:redoubt-netd::the_receive_loop_resets_and_reports_when_the_interrupt_fails, host:redoubt-netd::only_the_receive_threads_report_breaks_the_device | built, partly tested | A kill from outside gives netd no chance to reset (more) |
| A connection reaches only the addresses and ports its scope allows | R58 (a scope reaches only what it allows) | servers/ipd/src/scope.rs, servers/ipd/src/stack.rs | bench:net-attacks, host:redoubt-ipd::a_scope_permits_exactly_what_its_rules_contain, host:redoubt-ipd::connects_outside_the_scope_or_to_the_box_are_refused_and_send_nothing, host:redoubt-ipd::numbers_are_per_connection_and_invisible_to_others | built | A NAT hairpin is refused only if listed (more) |
| No connection reaches the box’s own addresses, whatever its scope | R59 (never the box’s own addresses) | servers/ipd/src/scope.rs, servers/ipd/src/args.rs, servers/ipd/src/stack.rs | bench:net-attacks, bench:bench-net-self-unrefused, bench:session-tcp-loopback, host:redoubt-ipd::the_self_set | built | A NAT hairpin is refused only if listed (more) |
ipd refuses every labelled caller before reading its request | R60 (a sink refuses labels) | servers/ipd/src/server.rs | bench:net-attacks, host:redoubt-ipd::a_labelled_caller_gets_nothing_and_holds_no_bucket | built | Connection rates are observable (more) |
| A connection’s scope is always within the scope it came from | R61 (scopes only narrow) | servers/ipd/src/scope.rs, servers/ipd/src/server.rs | host:redoubt-ipd::a_grant_only_narrows, host:redoubt-ipd::a_grant_never_widens_or_adds_listen, host:redoubt-ipd::new_connection_keeps_the_scope | built | A shared ipd is shared state (more) |
| Initial sequence numbers come from the kernel’s random words | R62 (sequence numbers from the kernel) | servers/ipd/src/stack.rs | host:redoubt-ipd::each_active_open_takes_one_seed_and_its_isn_is_that_seeds, host:redoubt-ipd::each_passive_open_takes_one_seed_and_its_isn_is_that_seeds, host:redoubt-ipd::without_a_seed_nothing_opens | built | Blind injection within a guessed window (more) |
| The resolver queries and answers only allowed names | R63 (only allowed names) | planned: the resolver | — | planned · M5 (self-hosted development) | Upstream answers are believed; the upstream’s cache is shared (more) |
| A connection by name reaches one checked address for its whole life | R64 (connections by name are pinned) | planned: the resolver and ipd | — | planned · M5 (self-hosted development) | A name’s addresses are shared (more) |
gatewayd sends only requests it built and checked against the caller’s capability and meter | R65 (a request only within its capability) | planned: gatewayd | — | planned · M5 (self-hosted development) | The service sees what the agent sends; metering trusts the provider (more) |
No credential ever leaves gatewayd, except over TLS to its own service | R66 (no credential leaves gatewayd) | planned: gatewayd | — | planned · M5 (self-hosted development) | The keys sit beside the response parser and in the bundle (more) |
| A labelled session’s output reaches only its owner’s own pty channel | R67 (a channel keeps its labels) | servers/sshd/src/console.rs, servers/sshd/src/bin/sshd.rs | bench:sshd-loopback-r67, bench:steward-vault-session, bench:steward-vault-launch, host:redoubt-sshd::the_console_is_one_file_carrying_the_channel_s_labels | built | sshd is trusted across labels (more) |
On approve@box only the steward draws and only the steward hears | R68 (only the steward on approve@box) | servers/sshd/src/bin/sshd.rs (approve), servers/steward/src/bin/steward.rs (reader_thread, screen) | bench:steward-approve-box, bench:steward-screen-inert | built, partly tested | approve@ shares sshd with the most hostile input; a route back to the box the manifest does not list (more) |
Residual risks by wall
What each wall leaves open, gathered from the pages’ residual risks. The four walls that are not the OS’s own are described on the tenets.
Boot.
- On QEMU the loader itself is not verified, and whoever controls the host’s files chooses what boots; the development signing key is public; there is no rollback protection until the version counter (boot, packages).
- The device tree is not signed, and the firmware is trusted. A device tree that hides a
controller from the loader’s controller search and its device list defeats the kernel’s
Ctrlcheck, whose list of controllers comes from the same tree (boot, devices). - The boot bundle’s confidentiality is not claimed: seeds in it are readable by whoever reads the image (boot).
- A confined boot is checked once; a capability handed across label sets later is the handing server’s fault, not the kernel’s (init).
The kernel.
- System-class servers are trusted to check labels; the kernel checks only between
userbudgets (IPC). - Destruction is uninterruptible kernel time, which follows the dying subtree (budgets, scheduling).
- A driver that programs DMA is trusted: there is no IOMMU, and reset-before-reuse does not confine a live driver (devices).
- Only virtio-mmio devices are reset: any other DMA device is never reset, so every process end that reaches it quarantines, and such a platform gets no driver restart (devices).
- A co-holder keeps its mapping of a quarantined device, and of a device whose handle was revoked: the kernel does not unmap device ranges (devices).
- A crash can blame nobody: corruption left by an answered call can crash a thread later, when it has no current call (processes).
- PIDs are one global pool that untaken exit notices can hold (processes).
- The kernel runs on one hart; completion races and flushes across harts are argued, not attacked (the kernel), until M2 (usable shell) brings several harts (several harts).
The servers.
- The named mediators, the steward and
sshd, are trusted across the labels they serve (the steward, sshd). - There is no non-collusion guarantee within one label set: agents with the same label set and each other’s handles can cooperate (the tenets).
- A dead launcher leaks its children’s connections until its own connection is freed, against its own account and label set (serving).
- An account-0 client can spend every bucket of a 9P server, and a server sized for fewer buckets than the label sets it serves is a channel (serving).
- Server work is paid by the server’s weight, not the requester’s (the servers).
The session. A session is per-principal code: a bug in it reaches that principal’s own capabilities, which the kernel contains (trust tiers).
- beamlet’s own code is memory-safe, but crates it links hold
unsafeand parse hostile data (compressed streams, patterns, certificates) with the session’s authority, as any program’s libraries do. A memory bug there, reached by a file another principal wrote, an agent’s opened by its sponsor above all, has the sponsor’s session, whichever of its tools read it; the kernel keeps it to that principal, and keys and approvals out of its reach (beamlet, the shell). - The terminal guard stops hostile text, not the session’s own code, which can write to its own console; and it draws the bidirectional marks, which only settle weak and neutral characters (the shell).
- The terminal guard draws every character that is there, not its likeness: a name made of look-alike letters (Cyrillic а for a) can still look like another (the shell).
- Ed25519 is checked cofactored everywhere on the box, so signatures are malleable; nothing that holds a wall takes one as an identifier (beamlet, boot).
- Until M6 (persist, install, share) a session has no wall clock and counts from 1970 at boot: a check that a date has begun fails, but a check only that one has not passed, a token’s expiry, passes whatever the token, and times from two boots cannot be ordered (beamlet).
- A console that stops answering a write stops the session’s VM, until writes move to its I/O threads (beamlet).
- A session makes a fixed number of waiting calls at once, and one past it is refused; memory off the Erlang heap, a large binary, is bounded only by the session’s own page limit (beamlet, the shell).
The supply chain.
- Vendored crates are checked against checksums taken from the tree itself: that proves they have not changed since vendoring, not that they match their upstream release (vendored dependencies).
- The vendored crates’
unsafeis outside the ratchet; recorded Miri runs cover their own tests andipd’s (ipd under Miri). - The unsafe ratchet counts only what its budgets name; it fails when a
no_stdworkspace member has a source in no budget, but a member it lists as uncounted is trusted to be off the target (the unsafe budget).
The network.
- Upstream DNS answers are believed, and an allowed name’s address may host other services (the resolver).
- A shared
ipdis shared state; connection rates are observable (ipd). - The model service sees what an agent sends, and metering trusts the provider’s figures (gatewayd).
Walls the OS does not own.
- The approval human: the requester can persuade, repeat until the human tires, or phrase a request that reads one way and means another; an approved text can carry a hidden message, and a push is one human action (the tenets, the steward).
- Host virtio emulation: a bug in the host’s emulation reachable through a driver is outside the OS (the tenets).
- Allowed egress: every allowed channel out can carry data: a model API, a
gitremote, the upstream resolver’s shared cache (the tenets). - Side channels: scheduling is observable, and so is server CPU spent on a caller’s behalf; every process has a perfect clock; CPU caches, the shared second-level cache and memory bandwidth are not partitioned; a shared server’s caches and the disk are shared unless the deployment is confined (scheduling, timer, the tenets).
Glossary
One vocabulary, used the same way on every page. Each entry says what the term means here, the nearest Unix idea where there is one (and how it differs), and the page that defines it. When a page and this list disagree, the page that owns the term is right and this list is fixed.
A/B slots
Two places for the signed bundle. An update writes the new bundle to the inactive slot, and the loader verifies it on the next boot; a system that does not boot, or does not reach a healthy state before any session starts, falls back to the other slot. A version counter kept outside both slots stops a rollback below it. Unix: A/B partitions on a phone. Defined in packages.
abandoned call
An open call whose caller has gone: it died, timed out, or was failed by revocation after the server took the call. The server keeps the call, and any lend, until it replies; the reply reaches nobody. Unix: none. Defined in IPC.
account
A 64-bit number carried by a budget that says which principal the budget bills to; 0 means none. A new budget inherits its parent’s account unless the parent has none. Servers see the caller’s account on every message. Unix: a user ID, but it grants nothing by itself. Defined in budgets.
agent
A program that acts for a principal and is assumed hostile: an AI agent or any other automated worker. It is its own principal with a sponsor, runs under a lease, and holds only capabilities narrowed from its launcher’s. Unix: none. Defined in agents.
approval
A human’s out-of-band yes to a request that would add authority or move data across a label boundary. The requester cannot draw, type or listen on the channel where it happens. Unix: none (sudo’s prompt is in-band). Defined in the steward.
attach root
The directory a 9P connection is rooted at. Nothing above it can be named through that connection. Unix: a chroot, per connection. Defined in the wire protocol.
attack case
A bench case that tries to break a security property and passes only on a verdict the attacker cannot produce: the kernel’s own output, a victim’s, or a clean power-off. Defined in the test bench.
badge
A 64-bit number attached to an endpoint handle, chosen by the server that minted it. The kernel delivers it with every message sent through that handle, so the server knows which grant is in use. Badge 0 is the receive right. Unix: none; closest to a file descriptor’s open-file description as the server sees it. Defined in handles and objects.
bench case
One test the bench runs: a file tests/<name>.toml that boots the real kernel under QEMU with
chosen programs, or runs host tests or a source check. Defined in the test bench.
bind
Making a capability the process already holds appear at another path in its namespace. It
creates no authority. Unix: mount --bind, but per process and with no global mount table.
Defined in files and binds.
boot manifest
The one strict-JSON file in the signed bundle that tells init which servers to start, with
which devices, labels, weights, volumes and arguments, and which principals exist. Unix:
/etc/fstab, /etc/inittab, /etc/passwd and the service files, as one signed document.
Defined in init.
boot pack
One file on the verified system volume, boot.pack, holding again the modules and resources the
shell loads to its prompt, behind an index of name, offset and length. beamlet reads it whole
before its VM starts and takes those names from it, in place of one lookup each; any other name is
the volume’s file. It is per VM and never written at run time. Unix: none close; a little like an
initramfs that is only ever read whole. Defined in
beamlet on Redoubt.
budget
The kernel object that holds resources: page, process and CPU-weight limits, a class, a label set, an account and an optional deadline. Every process runs in one; every kernel object is charged to one; destroying one revokes everything under it. Budgets form a tree. Unix: a cgroup, a revocation list and a security label in one object. Defined in budgets.
bundle
The signed archive the loader boots: the kernel, init, the boot manifest, every other server’s
program and any data entries.
Defined in boot.
call
A message that waits for a reply. It may lend pages to the server for the length of the call. Defined in IPC.
capability
The right to use something, held as a handle. There is no other authority: no user IDs, no paths that grant access by name, no root. Unix: a file descriptor, if file descriptors were the only way to reach anything. Defined in handles and objects.
carve
Giving part of a budget’s limits to a new child budget. The children’s limits never add up to more than the parent’s. Unix: none. Defined in budgets.
class
system or user, fixed for a budget and inherited by its children. It decides trust (a flow
into a system budget is not label-checked by the kernel) and never scheduling order. Defined in
budgets.
commandlet
One of the shell’s commands (cat, cp, grep): an Elixir function declared once, with
defcommand, with typed parameters and its help, which it does not compile without. Imported at
the prompt and called as plain Elixir. Unix: a shell builtin, but an ordinary function with no
syntax of its own. Defined in the shell.
confined deployment
A boot in which init refuses any manifest that lets two different label sets share a server
instance, volume, endpoint, network instance or device, except the two named control-plane
mediators. The kernel and the cores stay shared. Defined in init.
connection
One client’s session with a server: an endpoint handle with its own badge, and the server state keyed to it (for 9P, its fid table). Copies of the handle share the connection; a launcher gives each child a fresh one. Unix: an open socket or file description. Defined in the servers.
context
A named session of one principal in one label set, chosen by the SSH user name:
ssh alice.work@box is Alice’s context work, ssh alice@box her default one. One session at a
time. Defined in sessions and namespaces.
crash blame
When a server crashes while working on a call, the exit notice names the account and labels of that call’s sender. The steward counts blame and ends sessions that keep crashing shared servers. Defined in processes.
current call
The open call a server thread is working on: the call its last receive took, or the one it
named with serve; none once it replies to it or receives anything else. A crash blames its
sender. Defined in IPC.
declassification
Moving one item of data out of a label to an unlabelled volume, done only by the steward after the label’s owner approves that exact snapshot out of band. Unix: none. Defined in the steward.
device object
The kernel’s record of one device resource: an MMIO range (possibly able to do DMA), an interrupt line, or the right to power off. A driver reaches a device only through a handle to one. Unix: a device node, but held as a capability rather than found by path. Defined in devices.
DMA
Direct memory access: a device reading and writing RAM itself. A driver that programs DMA is trusted unless hardware confines it. Defined in devices.
endpoint
The kernel object clients call and servers receive on. It holds no queue: its waiting messages are its blocked senders. Unix: a listening socket, with the badge in place of the peer address. Defined in IPC.
escape room
The standing game in which real agents, told to get out, attack a confined deployment while a referee decides from the record, never from an agent’s own output. It tests what nobody thought to write a bench case for. Unix: none; a capture-the-flag run continuously. Defined in agents.
exit notice
The message the kernel sends to a process’s exit endpoint when it exits, faults or is killed:
its PID, cause, code, and who is blamed. Unix: SIGCHLD and wait, as a message. Defined in
processes.
fid
A number a 9P client uses to name a file it has walked to, within one connection. Unix: a file descriptor, scoped to the connection. Defined in the wire protocol.
fixed sub-budget
One of the budgets the steward splits a principal’s top budget into at boot, one per label set the manifest names for that principal. Every session and lease of one (principal, label set) is carved from its own sub-budget, so a vault’s activity never shows in what the unlabelled side can carve. Unix: none. Defined in the steward.
founding handle
The root badge of a server that is filled once at setup and then only served: bootfsd’s is
the one init uses to add the public entries and seal them. No connection minted for a client
can fill it. Unix: none. Defined in bootfsd.
gateway
A server that does a network job for an agent so the agent never holds a socket or a key: TLS,
credentials, request checks and logging. gatewayd is the first. Defined in
gatewayd.
gateway capability
A connection to gatewayd whose badge names one grant (the model gateway, a git remote),
optionally narrowed to some of its hosts, with a meter. It is what an agent holds instead of a
socket. Unix: none; closest to a scoped API token the holder can use but never read. Defined in
gatewayd.
grant and release
The two operations of a typed protocol that mints a narrower capability for a client: a grant
replies with the new endpoint handle and a random id; a release frees it by that id, and only
for the client that received it. A 9P server’s pair is new_connection and disconnect. Unix:
open and close, where only the opener may close. Defined in
the wire protocol.
handle
An index into a process’s handle table, which the kernel holds. It names an object, a badge and a stamp. It cannot be forged; it can be copied and closed. Unix: a file descriptor. Defined in handles and objects.
hart
A RISC-V hardware thread: one core, or one thread of a multithreaded core. Unix: a CPU.
job
The native stages one shell command started, each running in a budget of its own carved from
the session’s. Killing a job destroys those budgets; there is no signal. Unix: a shell job,
killed by budget destruction rather than SIGKILL. Defined in
native programs.
label
A name owned by a principal, marking data that must not leave a set of budgets. The kernel sees it as a 64-bit number. Defined in the servers.
label set
The labels on a budget, fixed when it is created. It is the isolation unit: two budgets with different label sets have no path between them that the OS carries, except through the steward’s approved moves. Defined in IPC.
lease
A budget with a deadline, made by the steward for an agent or session. When the deadline passes, the kernel destroys the budget and everything in it; the sponsor can end it sooner. Unix: none. Defined in the steward.
lend
Pages a caller hands to a server for the length of one call. They leave the caller’s address space until the reply brings them back. Defined in IPC.
loader stub
A small program mapped into a new process that reads the program’s ELF image and maps its
segments, so the launcher never parses an ELF. Unix: the ELF loader in execve, moved out of
the kernel and the parent. Defined in init.
meter
The part of a gateway capability that says how much the holder may spend: tokens and money, carved from its principal’s own. Unix: a quota. Defined in gatewayd.
mint
Making a new endpoint handle with a chosen badge, and optionally a narrower stamp. Only the holder of the receive right can. Defined in handles and objects.
model
The executable model: the Rust crate redoubt-model, which states the kernel’s objects, calls,
rules and invariants as code, drives them with random call sequences and checks every invariant
after each step. Its traces replay on the real kernel. Unix: none. Defined in
the model.
mutation
A deliberate break of one rule planted in the executable model, to show the model’s checks catch it. Defined in the model.
name rules
What a resolver connection may have answered: an allowlist of domain names and suffixes, and a blocklist that subtracts from it and always wins. A suffix matches only at a label boundary. A grant only narrows them. Unix: none; closest to a filtering DNS proxy per client. Defined in the resolver.
namespace
A process’s own map from path prefixes to capabilities, built by its launcher. Nothing is inherited and there is no global file tree. Unix: a mount namespace with no root to fall back on. Defined in sessions and namespaces.
native program
A Rust program built for Redoubt and started from a session in a budget of its own, through the loader stub, with the namespace its launcher binds. Unix: an executable, but with no inherited descriptors or environment. Defined in native programs.
9P
The Plan 9 file protocol (9P2000), used for every service a person or program sees as files. Defined in the wire protocol.
open call
A call a server has taken with receive and not yet replied to. Each costs the server a page
and counts against its limit of open calls. Defined in IPC.
pass
A budget’s position in the stride scheduler: the lowest pass runs next, and running raises it in inverse proportion to the budget’s weight. Defined in scheduling.
person
A principal who is a human: logs in with a key over SSH and can approve. Only a person gets name-scoped TCP, and every chain of sponsors ends at one. Unix: a user. Defined in the steward.
physmap
The kernel’s mapping of all RAM at a fixed offset, so it can reach any frame. Supervisor-only and never executable. Defined in memory layout.
PID
A process’s number, 2 to 64 (1 is the kernel), which is also its hardware address-space id. It is drawn at random from the free ones and held until the process’s exit notice is taken or dropped. No authority is keyed by it. Unix: a PID, but it names; it grants nothing. Defined in processes.
pipe
A file that somebody serves over 9P, bound into two programs’ namespaces as one’s standard output
and the other’s standard input. There is no pipe object in the kernel and no inherited
descriptor. Unix: pipe(2), as a served file. Defined in
native programs.
powerbox
The steward’s service that grants authority a principal lacks, or confirms a principal’s own
high-stakes step, through an out-of-band approval, and if approved hands back a narrow
capability. Unix: none (sudo grants everything and asks in-band). Defined in
the steward.
principal
A named, accountable identity the steward knows: a person, an agent or a project. It has an authentication method, a set of capabilities and an audit identity, and its top budget carries its account. Unix: a user account, without root and without ambient rights. Defined in the steward.
profile
The package versions a principal runs, and so the /bin its sessions see. It is a steward
record, not a file in the principal’s space. Unix: a Nix profile. Defined in
packages.
project
A principal sponsored by several members, with its own budget, volume, package directory and profile, and optionally a label. Membership is capabilities minted into a revocation scope per member. Unix: a group, but with its own budget and no ambient rights. Defined in the steward.
purpose
The one message shape a keyd badge may have its key sign: ssh_host signs only an SSH key
exchange, audit only an audit record. No purpose signs a caller’s own bytes. Unix: none;
closest to a key usage extension. Defined in keyd.
push
Moving one item from an unlabelled volume into a labelled domain’s volume, triggered by the label’s owner out of band. The mirror of declassification, and the only way input enters a confined labelled domain. Unix: none. Defined in the steward.
quarantine
What happens to DMA pages and devices when a device in the reset set does not confirm its reset: the pages are mapped nowhere and never reused until the machine resets, still charged to their budget, and the device’s object is destroyed and none names it again until reboot. Unix: none. Defined in devices.
reader budget
A short-lived budget the steward creates carrying exactly an item’s labels, with a deadline, to read that item for it: the steward stays unlabelled and calls it. There is no standing reader. Defined in the steward.
receive right
An endpoint handle with badge 0. Only it can receive, and only its holder can mint.
Defined in IPC.
revocation scope
A budget with zero limits, made only to be a stamp and later destroyed, so a grant can be revoked without killing any process. Defined in budgets.
root badge
A badge below the minted range that a server’s setter-up gives meaning to (the manifest, through
init), rather than one the server minted for a client. It is how system callers get separate
shares and how setup-only operations are reserved. Defined in
serving.
scope
The IP prefixes and ports an ipd connection may reach. A grant only narrows it, and no scope
reaches the box’s own addresses. Unix: none; closest to a per-socket firewall rule. Defined in
ipd.
screen buffer
A grid of cells beamlet holds natively for one screen program, written with a few primitives and diffed into the cells that changed; the only way a screen’s text reaches the terminal. Unix: a curses window, but with no escape sequence in it. Defined in beamlet.
screen program
A full-screen program of the session (the pager, the editor, a chooser): an Erlang process that draws into a screen buffer and is sent its keys by the shell’s driver. Unix: a curses program, but in the session’s VM, not a process of its own. Defined in the shell.
seal
The message that ends a server’s setup: before it clients see nothing; after it the contents are
fixed for good. bootfsd is sealed once its public entries are added. Defined in
bootfsd.
self set
The addresses that are the box itself: its own address, its network and broadcast addresses,
loopback, “this host”, multicast, class E and the prefixes named as the host’s. ipd refuses
them before any scope is looked at. Defined in ipd.
send
A message that does not wait for a reply. It may transfer pages to the receiver for good. Defined in IPC.
service record
The steward’s record of one installed service: the package’s program, its owner, the grants the owner made for it, a budget carved from the owner’s and a restart policy. The supervisor starts services only from these. Unix: a systemd unit, holding grants rather than a user name. Defined in the supervisor.
session
A person’s or agent’s working environment on the box: an Elixir VM in its own budget, with a namespace and capabilities the steward built for it. Unix: a login session. Defined in sessions and namespaces.
sink
A server whose output leaves a principal or the machine (ipd, gatewayd, sshd). A sink is
cleared for no label and refuses every labelled caller, with one exception: sshd, on the
channel whose owner authenticated it. Defined in
the servers.
sponsor
The principal accountable for an agent: a person, or an agent with a person at the top of the chain. The agent’s budget sits under the sponsor’s, and the sponsor can always end its lease. Defined in agents.
stamp
The budget recorded in a handle: destroying that budget, or any budget above it, closes the handle everywhere. Defined in handles and objects.
startup block
The read-only page a launcher gives a new process: its namespace, named handles, arguments and
the location of its image. Unix: argv, envp and the inherited descriptors, in one checked
record. Defined in init.
steward
The trusted server that knows principals, authenticates them, builds sessions, grants leases, runs approvals and keeps the audit log. Defined in the steward.
TCB
The trusted computing base: the code whose failure can break the security guarantees. For Redoubt: the firmware interface, the loader and the kernel, plus any server that programs DMA without hardware confinement. Defined in the kernel.
TID
A thread’s number within its process, 1 to 31; the first thread is 1. Defined in processes.
transfer
Pages given away with a send: they leave the sender and become the receiver’s. Defined in
IPC.
trust domain
The budgets that share one label set. Within it, delegation is permitted; across it, only the steward moves data, one approved item at a time. Defined in the tenets.
trust list
The signing keys whose code a principal runs. The steward launches code on a principal’s behalf only if a key on its trust list signed it. Unix: an apt keyring, per principal. Defined in packages.
typed message
A message whose word 0 names an operation in a table, encoded by generated code, as opposed to a 9P message. Defined in the wire protocol.
vault session
A session carrying one of its principal’s labels, so it can read that label’s data and cannot
send it anywhere unlabelled. Opened with ssh alice+X@box. Defined in
sessions and namespaces.
verdict
The line a bench case passes or fails on. It must come from a party the attacker cannot impersonate. Defined in the test bench.
W^X
No RAM page is ever both writable and executable, under any mapping; the kernel’s own mappings keep the same rule. Device memory is a stated gap. Defined in memory.
weight
A budget’s CPU share. The scheduler uses its free weight: its limit less what its children carved. Defined in scheduling.
wire table
A Markdown table in libs/wire/tables/ that defines one typed protocol; the Rust and Elixir
codecs are generated from it. Defined in the wire protocol.
writer budget
A short-lived budget the steward creates carrying exactly a target label set, to write one pushed item into that domain, since a write needs equal labels. Defined in the steward.
XLEN
The register width: 32 bits (rv32) or 64 bits (rv64). Every milestone boots rv64 and compiles rv32.
The kernel
The Redoubt kernel is a small RISC-V microkernel in Rust. It keeps five things: memory, threads, IPC, interrupt delivery and the timer. Everything else (drivers, file systems, the network, policy) is an unprivileged server that reaches the kernel only through handles and the 26 system calls. The kernel runs in S-mode under RustSBI firmware, one binary for rv64 (Sv39) and rv32 (Sv32). This page says what the kernel keeps, names its objects, measures the trusted computing base, and maps each source file to the page that documents it.
Purpose
The kernel is the part of Redoubt every other guarantee rests on, so it has to be small enough to read in full (TENETS). A reader who wants to audit it needs three things first: what the kernel is responsible for and what it refuses to hold, the objects it hands out and how they refer to each other, and where the code for each rule lives. The pages below this one each take one mechanism; this page is the map.
What the kernel keeps
Status: built · partly tested: that no driver, file system or policy sits in the kernel is read from the source map, not attacked by a case · tested: bench:no-cruft, bench:legacy-gone
| Job | What the kernel holds | Calls | Page |
|---|---|---|---|
| Memory | who owns every RAM frame, each process’s page tables, the physmap | map_anon, unmap, set_flags, map_fixed, process_map, map_device, dma_alloc | memory, memory layout |
| Threads | processes, up to MAX_THREADS (255) threads each, and one stride queue over every runnable budget | process_create, process_start, thread_create, thread_exit, process_exit | processes, scheduling |
| IPC | endpoints and the messages of blocked senders | endpoint_create, mint, call, send, receive, reply, serve, handle_close | IPC, objects |
| Interrupt delivery | the interrupt controller (a PLIC), mapped for the kernel alone, and one IRQ device object per line | receive naming an IRQ handle | devices |
| The timer | one hardware timer, always armed for the earliest slice end, timeout or budget deadline | time_now, and the timeout of every blocking call | timer |
Budgets pay for all five: every object, page, process and share of the CPU is charged to one,
and destroying one revokes everything under it (budgets,
R10 (destruction)). The budget calls are budget_create,
budget_destroy, budget_reap (destroy one child, keep the budget) and budget_usage. Two
calls sit outside the five jobs. random returns 64 bits from the kernel’s ChaCha8 generator,
keyed from the loader’s Seed argument; the kernel also draws PIDs from it. system_reset powers the machine off or reboots it through the
firmware, and only a holder of the Reset device object can call it.
The kernel holds nothing else:
- No drivers. Its own console is the SBI debug console. Every real device (UART, virtio block, virtio net) belongs to a userspace server that holds its device object. The console’s UART is shared: its holder takes the console’s hold around each write, and the kernel’s lines wait, whole, for it (the console’s one writer).
- No names. The kernel knows handles, never paths, file names or server names. A process reaches only what its own handle table holds.
- No program loading. The loader places the kernel and
initat boot. After that a launcher builds a process withprocess_mapandprocess_start, and a loader stub inside the new process reads its ELF image (boot, processes). - No policy. The kernel enforces R1 (flow) on label sets and the budget
limits. Who gets which label, lease or capability is decided by
initand the steward.
There is one call table. A number in a0 outside it is InvalidArgument, whatever the other
registers hold (ABI). The kernel never calls into user code and
never switches straight from one process to another. User mode is entered two ways (resuming a
thread, or returning from a call) and left one way (a trap), so every instruction a process runs
is counted against the budget the scheduler picked
(R12 (scheduling)). The kernel runs on every hart, with
interrupts off while it runs, under one FIFO lock that a hart takes at its trap from user mode and
gives up at its return or idle. Each kernel global (the memory manager, the process table, the
timer, the generator) sits in a KernelCell of its own (kernel/src/cell.rs): a run-time-checked
borrow under that lock, so a nested borrow is a panic, not two live references. A checked build
asserts that the hart reaching it holds the lock; a release build does not check, and rests on
the trap paths taking it.
bench:no-cruft reads the sources and fails on a name from a list of call-interface
identifiers Redoubt does not have, on a silenced dead-code warning (allow(dead_code) or
allow(unused)) in the kernel, the loader, paging, redoubt-layout or the test programs, on
a Cargo feature no cfg(feature) reads, and on a second literal definition of PAGE_SIZE or
USER_AREA_END. Every exemption is an entry in the case with its reason, and an exemption that
covers nothing fails the case (test bench).
bench:legacy-gone makes the calls such an interface would take, from user mode, and checks
that each is refused and that nothing it asked for happened: no mapping, no callback run, no
switch.
The objects at a glance
Status: built · partly tested: an endpoint's page is attacked only in the model and a device object's page by no case ([objects](objects.md#what-objects-cost)), and that no id is ever reused is not visible to a process ([I12 (ids never reused)](invariants.md#i12-ids-never-reused)) · tested (5)
- bench:budget
- bench:redoubt-ipc
- bench:process
- bench:device
- bench:budget-forge-attack
A process holds handles: indices into a handle table that only the kernel writes. A handle names an object, a badge (a 64-bit number the minting server chose; 0 for the receive right) and a stamp (the budget whose destruction closes the handle everywhere, R9 (stamps)). Index 0 is never a handle. There are four kinds of object:
| Object | What it is | Made by | Charged | Ends when |
|---|---|---|---|---|
| budget | limits on pages, processes and CPU weight; a class, a label set, an account, an optional deadline | budget_create; root, system and users at boot | one page to its parent | budget_destroy, its deadline, budget_reap on its parent, or the destruction of a budget above it |
| endpoint | what clients call and servers receive on; it holds no queue | endpoint_create | one page to its owner, the creating process’s budget | its owner is destroyed |
| process | an address space, a handle table and threads; its object page holds the one exit notice | process_create | the object page to the creator’s budget; its header, page tables and memory to the budget it runs in | it exits, faults or is killed; the page stays until the notice is taken or dropped |
| device | an MMIO range (with a DMA flag), an IRQ line, or the Reset right | only at boot, from the loader’s device list | one page to its owner (system at boot) | its owner is destroyed, or its DMA reset is never confirmed |
Each object lives in a RAM frame of its own. Every lookup compares the id in the frame with the
id in the handle, and a mismatch stops the kernel rather than naming a reused frame
(I1 (handles name live objects)). Threads,
handle-table pages and open calls are not objects: no handle names them. A thread is named by
its process and its TID (1 to 255). Details, costs and mint are in objects.
flowchart LR
H["handle<br/>(object, badge, stamp)"]
P["process<br/>address space, handle table,<br/>exit slot"]
T["thread<br/>TID 1 to 255"]
E["endpoint"]
D["device object<br/>MMIO, IRQ or Reset"]
B["budget"]
P -- "holds, in its table" --> H
H -- names --> B
H -- names --> E
H -- names --> D
H -- names --> P
H -- "stamp: closed when destroyed" --> B
T -- "belongs to" --> P
T -- "blocked sender:<br/>a queued message" --> E
P -- "runs in, pays for<br/>threads and memory" --> B
P -- "object page charged<br/>to the creator's" --> B
P -- "exit endpoint" --> E
E -- "owner (R1, charged)" --> B
D -- "owner (charged)" --> B
B -- "parent, carved from" --> B
Figure: the kernel’s objects and what each refers to. An arrow points from the one that holds the reference to the one it names.
Containment
Status: built · tested: bench:kernel-containment
One boot, bench:kernel-containment, shows that the kernel’s primitives alone contain hostile
code before init, the steward or any server is built on them. Hostile code in a lease is
preempted, then ended at its deadline or revoked by hand while its messages and lends are in
flight, and its victims stay responsive the whole time. The boot is evidence for the kernel only.
Sessions have their own cases
(M1 (sessions over SSH, kept apart)), and the steward’s
leases, approvals and the network theirs
(M3 (agents, approvals and the attack suite)).
What runs. The loader starts one program. It holds root, system, users, the console
and the Reset right. It builds the budget tree at the weights init gives
(init) and starts copies of itself in the tree, as the latency workload does
(responsiveness):
- in
system: the workload’s driver stand-in and steward stand-in, and a victim server that takes the hostile agents’ calls and holds them open; - in
users: a bystander session, and a sessions budget the steward stand-in holds. The steward stand-in carves each lease from that budget and starts a hostile agent in it, as the steward will.
Each hostile agent runs in a lease the size of the workload’s leases, with room added for a full handle table and one sub-agent. It does all of these at once:
- some of its threads spin and never enter the kernel, and one enters the kernel in a tight loop;
- some threads call the victim server with lends, which the server takes and holds open, and
others are blocked in
sendto it. All of them go through handles minted into the lease’s revocation scope; - a sub-agent runs in a budget the agent carves from its lease. That budget has a later deadline of its own, and the sub-agent’s process churns threads and processes;
- the agent fills the rest of its lease with endpoints and its handle table up to
MAX_HANDLES, so the destruction walks as much as a lease of its size can make it walk.
The bystander also holds a message queued at the victim server that carries a handle stamped with
a live lease. The hostile leases come in pairs, one of each kind. One ends at its deadline while
the other lives, so two hostile leases are live at every deadline’s end. The steward stand-in then
ends the other with budget_destroy after a timeout, which is its decision; that lease is the only
one live at its end. Each pair is replaced when both have ended, until nine of each kind have
ended.
Verdicts. None of them comes from a hostile agent:
| What is judged | Rules | Judged by |
|---|---|---|
Every process in a lease, its sub-agent’s included, is killed at the lease’s deadline or at the decision, and each gets a killed notice that blames nobody. The tight loop does not put the deadline off | R10 (destruction), deadlines, R21 (crash blame) | the kernel’s exit notices, taken by the steward stand-in |
| Each call the victim server took from a lease is abandoned, with one notice. Its lend stays mapped in the server with the bytes the agent wrote, while the next lease reuses freed frames. The server’s reply is discarded with mask 0, and its usage returns to where it started | R3 (lends and abandoned calls), I15 (abandoned calls reported once), I9 (pages W^X, zeroed, lends unmapped) | the victim server |
| Nothing sent through a lease’s handles, its blocked sends’ included, is received after the lease ends: the victim finds the lease’s send endpoint empty. That each blocked send returns failed is seen only by the agent, so no verdict rests on it. The bystander’s queued message arrives with the stamped handle as 0 | R10, R9 (stamps), I2 (revocation is complete) | the victim server |
| Every handle the steward stand-in holds to a lease is closed. Once the lease’s notices are taken, the sessions budget’s usage is what it was before the lease was made. The sub-agent’s later deadline never fires | R10, I2, I10 (create-destroy leaves the parent unchanged) | the steward stand-in, from budget_usage and the results of its calls |
| The victims stay responsive: the driver wake, the steward’s timer and decision wakes, the deadline notice, R10’s kernel time and a lease’s end are all within the targets in responsiveness | R12 (scheduling), R10 | the RTC (driver), time_now (steward), the kernel’s trace (R10) and the bench’s post-check |
Every pick is in rank order, and the bystander keeps its share of the CPU the kernel charged under users under both slots’ leases (its weight’s at one hart, judged there; at two, a hart for its one thread, reported), net of the checked build’s audits; its own count is printed beside as its useful work | R12 | the scheduler oracle over the kernel’s trace; the program prints the count |
| The kernel does not panic, and no assertion of a checked build fails | I14 (no call panics the kernel) | the kernel |
The program’s lines are trusted (rule F) because it owns the console and holds the reset. Its children hold no device and have no path to the console, so no line on it can come from a hostile agent. Each victim reports through a handle the program badged for it, and the program never counts a hostile agent’s report as a verdict.
The run. The boot runs on rv64 and rv32, at the bench’s hart count, one and two in the gate,
in a checked build with the tracing kernel
(R23 (no test channels)). Its trace ring is 256 MiB
(sched-trace-large, at 576 MiB of RAM), not the usual 64: at the 1 ms slice the run writes about
ten records a slice, some four million at one hart and seven million at two, and a trace that
drops one fails the gate. It uses the latency workload’s virtual time and one pinned seed. Sweeps
of 16 seeds on both widths, at one hart and at two, set which seed the gate runs, and this page
records them. The targets are the ones in
responsiveness, and the gate adds none. If the gate misses one of
them, that is a finding against the kernel, not a reason to set a new target.
The sweep at one hart (with the 192 MiB ring then) ran all 16 seeds on both widths, and every run passed. The table gives each run’s net p99 of the deadline notice, R10’s p99 and a lease’s end (the decision wake’s net p99 plus R10’s), in µs, against targets of 40000, 30000 and 125000:
| Seed | rv32 notice | rv32 R10 | rv32 lease end | rv64 notice | rv64 R10 | rv64 lease end |
|---|---|---|---|---|---|---|
| 1 | 25924 | 22379 | 30708 | 25505 | 22251 | 30194 |
| 2 | 25590 | 21780 | 30138 | 25019 | 21713 | 29571 |
| 3 | 25343 | 21767 | 30147 | 24716 | 21613 | 29471 |
| 4 | 25939 | 22260 | 30530 | 25258 | 22029 | 29887 |
| 5 | 25502 | 22052 | 30428 | 24923 | 21742 | 29600 |
| 6 | 25346 | 21956 | 30334 | 24932 | 21552 | 29410 |
| 7 | 25349 | 22025 | 30382 | 24924 | 21884 | 29742 |
| 8 | 25938 | 22400 | 30745 | 25328 | 22076 | 29935 |
| 9 | 25735 | 22048 | 30416 | 25347 | 21898 | 29757 |
| 10 | 25905 | 22142 | 30414 | 25466 | 22002 | 29860 |
| 11 | 25574 | 22236 | 30506 | 25136 | 22082 | 29954 |
| 12 | 25838 | 22044 | 30396 | 25315 | 21893 | 29751 |
| 13 | 26197 | 22517 | 30889 | 25794 | 22380 | 30251 |
| 14 | 25947 | 22361 | 30737 | 25597 | 22217 | 30075 |
| 15 | 25750 | 22063 | 30407 | 25297 | 21907 | 29766 |
| 16 | 26167 | 22473 | 30743 | 25723 | 22385 | 30243 |
The deadline notice is the target closest to its limit, at 65% in the worst run, and every seed lands within 1.5 ms of the others. An earlier version of the gate showed a second group about 10 ms higher on some seeds. The steward stand-in then polled for its notices in 200 ms waits, and when one ran out just before a deadline, the stand-in was runnable rather than waiting when the notice came, so another party ran a slice first. It now waits in one receive bounded by the deadline. At one hart seed 13 is the worst on both widths for the deadline notice (26197 µs on rv32, 25794 µs on rv64) and a lease’s end, and on rv32 for R10; rv64’s worst R10 is seed 16, at 22385 µs.
At two harts every target is met on all 16 seeds on both widths; the deadline notice’s p99 is 4
to 7 ms above its one-hart value, and R10’s 1 to 1.5 ms. The
bystander’s share of the charges (its one thread is owed a hart, 500 of 1000) fell in two modes
by the hart it landed on: the one that waited longer for the lock and took the audits, or the
other, 445 or 543 to 546 on rv32. With a wait behind another hart’s audit billed to no one it
reads 464 on rv64 and 442 on rv32 on the default seed, so at two harts the share is still
reported, not judged (@1; scheduling). In µs, against the same
targets, before that change:
| Seed | rv32 notice | rv32 R10 | rv32 lease end | rv32 bystander | rv64 notice | rv64 R10 | rv64 lease end | rv64 bystander |
|---|---|---|---|---|---|---|---|---|
| 1 | 32181 | 23882 | 27863 | 445 | 30324 | 23219 | 26111 | 480 |
| 2 | 32343 | 23894 | 27608 | 445 | 30474 | 23220 | 27020 | 481 |
| 3 | 32587 | 23903 | 28242 | 445 | 30351 | 23219 | 26983 | 512 |
| 4 | 32584 | 23908 | 28252 | 445 | 31641 | 23221 | 26942 | 479 |
| 5 | 32180 | 23765 | 27721 | 544 | 30293 | 23219 | 26403 | 478 |
| 6 | 32573 | 23908 | 28043 | 445 | 30259 | 23217 | 26981 | 480 |
| 7 | 32240 | 23847 | 27972 | 445 | 30585 | 23100 | 26528 | 480 |
| 8 | 31836 | 23901 | 28194 | 445 | 30409 | 23101 | 26865 | 515 |
| 9 | 32371 | 23881 | 28156 | 445 | 31283 | 23197 | 27090 | 480 |
| 10 | 31827 | 23892 | 28013 | 445 | 30549 | 23219 | 26984 | 480 |
| 11 | 32574 | 23853 | 27552 | 545 | 30289 | 23219 | 27034 | 515 |
| 12 | 32053 | 23850 | 28174 | 445 | 30070 | 23096 | 26886 | 513 |
| 13 | 32294 | 23888 | 28179 | 546 | 30372 | 23217 | 26240 | 480 |
| 14 | 32557 | 23883 | 27833 | 445 | 30331 | 23218 | 26972 | 480 |
| 15 | 32123 | 23883 | 27563 | 445 | 30396 | 23100 | 26863 | 481 |
| 16 | 32282 | 23904 | 28212 | 543 | 30072 | 23216 | 26979 | 481 |
The case file pins one seed for both widths and both hart counts: the gate runs seed 4, the worst at two harts or within a millisecond of it on both widths: the deadline notice on rv64 (31641 µs; rv32’s worst is seed 3, 3 µs above), R10 on both (23221 and 23908 µs) and a lease’s end on rv32 (28252 µs; rv64’s is seed 9, 27090 µs). At one hart it is 25939 µs on rv32 and 25258 µs on rv64 for the deadline notice, within 0.6 ms of seed 13.
The gate replaces none of the focused cases. budget-deadline, redoubt-revoke,
uaf-lent-page, endpoint-destroy-open-calls and sched-latency each attack one clause alone,
and each fails with a narrower message. The gate shows that all of those clauses hold together,
under load, against one party that tries them all at once.
The TCB and its size
Status: built · partly tested: the inclusive line counts in the table are snapshots, not pinned by a case · tested: bench:unsafe-budget, bench:docs, bench:size-budget, host:testbench::actual_source_counts_still_enforce_the_budget, host:testbench::every_configured_root_must_contain_rust_source
The trusted computing base (TCB) is the code whose failure can break Redoubt’s guarantees: the
firmware interface, the loader and the kernel, with the libraries they link, plus any server that
programs DMA without hardware to confine it (devices). Lines are every line of the
.rs files under the path, comments and in-file tests included, as the ratchet walks them.
| Part | Where | Lines | unsafe (pinned) |
|---|---|---|---|
| RustSBI Prototyper, the M-mode firmware | bios/firmware/prototyper/src and its bios/library crates | 11,843 in the Prototyper | not counted: vendored at a pinned commit |
| loader | loader/src | 1,576 | 18 |
| kernel | kernel/src | 15,899 | 37 |
redoubt-sys, the call ABI both sides share | libs/sys/src | 2,464 (843 of them host tests) | 3 |
paging, the Sv32/Sv39 page-table types | libs/paging/src | 625 | 12 |
redoubt-layout, the kernel-half map and PIDs | libs/layout/src | 225 | 0 |
redoubt-stride, the scheduling rules | libs/stride/src | 2,143 (831 of them host tests) | 0 |
redoubt-signing, the bundle signature preimage | libs/signing/src | 149 | 0 |
blkd, the virtio-blk driver (DMA) | servers/blkd/src | 3,410 | 4 |
netd, the virtio-net driver (DMA) | servers/netd/src | 2,430 | 8 |
Without the firmware, the TCB Redoubt writes is 28,921 lines. The kernel’s unsafe is split
three ways in the ratchet:
| Ratchet budget | What its unsafe does | unsafe (pinned) |
|---|---|---|
| kernel: Sv39, SBI and PLIC backends | sfence.vma, the satp write and fence.i; the page-table walks through the physmap (a root table, a new child table, and an address space built from the loader’s); the PLIC; the timer and reschedule interrupt enables; the console; the physmap window | 11 |
| kernel: RISC-V arch (shared with rv32) | returning to user mode and kmain’s switch; the current process’s bookkeeping; the interrupt window and wfi; clearing a hart’s reschedule interrupt; the sum-probe load (test builds only) | 10 |
| kernel: core | the physmap word access in kframe.rs; what the loader handed over (the kernel’s entry, the argument block, the ownership tables and the process list); the free-frame bitmap; the lock; the console; DMA register access | 16 |
Trap entry and context restore are global_asm! in arch/riscv/asm.rs, which the ratchet lists
but which holds no unsafe keyword. redoubt-sys’s three are the ecall itself, the
unsafe trait Transport whose contract the runtime’s unsafe rests on, and Ecall’s
implementation of it. redoubt-layout, redoubt-stride and redoubt-signing say
#![forbid(unsafe_code)], so their budgets can only stay at 0.
The unsafe counts on this page are kept true by the docs checker: every count in a
`unsafe` (pinned) column must equal the ceilings in tests/unsafe-budget.toml, summed over
the budgets under a row’s paths or read by a row’s budget name, so a change to a ceiling fails
bench:docs until this page follows it (docs checker).
bench:unsafe-budget does not boot. It counts every use of the word unsafe outside a //
comment in each budget’s paths, and fails if a count is over its ceiling, or if any use lacks a
justification: a // SAFETY: comment within 6 lines above a block, or a # Safety doc section
within 16 lines above an unsafe fn, impl, extern or trait. Every ceiling in the TCB
equals its count and allows 0 undocumented uses, so one more unsafe anywhere in it fails the
case. A budget whose path holds no Rust source fails too, so a moved directory is not silently
uncounted (test bench).
Source map
Status: built · partly tested: the map is checked by reading the source, not by a case
Every file in kernel/src, and each library the kernel links, is documented on one page (or
two, where a file serves two mechanisms).
| Source | What it does | Page |
|---|---|---|
main.rs | init, the boot order; kmain’s loop: expire, pick, switch, idle | boot, scheduling |
args.rs | reads the loader’s argument block | boot |
redoubt.rs | the call dispatcher; checks and copies records | ABI |
handle.rs | handle tables | objects |
endpoint.rs | endpoints | objects, IPC |
message.rs | call, send, receive, reply, serve, mint | IPC, objects |
budget.rs | budgets, per-process accounts, the cost table | budgets, objects |
process.rs, ptable.rs | processes, threads, exit notices, the process table | processes |
mem.rs | frame ownership and the memory calls | memory |
kframe.rs | words in RAM frames, through the physmap | memory layout |
sched.rs | the stride queue, driven from the trap boundary | scheduling |
time.rs | the kernel’s timer: slices, timeouts, deadlines | timer |
device.rs | device objects, map_device, dma_alloc, system_reset | devices |
dma.rs | DMA device reset and frame quarantine | devices |
cell.rs | KernelCell, the checked cell each kernel global sits in | this page |
debug/, io.rs | the kernel console | boot |
platform/sbi/ | SBI start-up, console and reset; rand.rs, the ChaCha8 generator | boot, devices |
arch/riscv/asm.rs | kernel entry, trap entry, context restore | ABI |
arch/riscv/irq.rs, exception.rs | the trap handler: calls, interrupts, faults | ABI, devices, processes |
arch/riscv/syscall.rs | resuming user mode; kmain’s private switch | scheduling |
arch/riscv/intc_plic.rs | the PLIC backend | devices |
arch/riscv/timer_sbi.rs | the hart timer, through SBI TIME | timer |
arch/riscv/mem.rs, physmap.rs, mmu_flags.rs | page tables, the physmap, PTE flags, the kernel’s W^X check | memory, memory layout |
arch/riscv/process.rs | the process header, threads’ saved contexts, PID slots | processes, memory layout |
arch/riscv/hart.rs | each hart’s block, starting the other harts, the reschedule interrupt and the shootdown | memory |
arch/riscv/panic.rs | a kernel panic prints and powers off | boot |
libs/sys (redoubt-sys) | call numbers, registers, records, errors | ABI |
libs/paging (paging) | typed Sv32/Sv39 page tables, shared with the loader | memory layout |
libs/layout (redoubt-layout) | the kernel-half address map and PIDs, shared with the loader | memory layout |
libs/stride (redoubt-stride) | the stride rules the scheduler applies | scheduling |
libs/signing (redoubt-signing) | the signature preimage the loader checks | boot |
loader/ | verifies the bundle, places the kernel and init | boot |
model/ (redoubt-model) | the executable model, its invariants and mutations | model, invariants |
Residual risks
- RustSBI is TCB and outside the ratchet. The firmware runs in M-mode below the kernel. It is
vendored at a pinned commit and built with only QEMU virt’s drivers, but its
unsafeis not counted, and QEMU loads it outside the signed bundle, so verified boot does not cover it (boot). - Third-party crates in the TCB are outside the ratchet. The kernel links
riscv,sbi-rt,plicandrand_chacha;paginglinksbitflags; the loader linksfdt-rs,elf,tar-no-std,crc,sbi-rtand an Ed25519 verifier. They are pinned byCargo.lockand are TCB like the rest (TENETS), but no case counts theirunsafe. - The ratchet counts words, not soundness. It checks that a
SAFETY:comment is near eachunsafe, not that the comment is true, and it counts only the paths its budgets list: a TCB file no budget names is not counted, and the case cannot notice (test bench). - The table’s inclusive line counts are snapshots. The size budget has separate ceilings for shipped code lines, excluding comments and tests, and fails if a trusted crate grows past its ceiling (the size budget).
- Several harts, one lock. Every hart runs user code, one process’s threads on several harts
at once, under one kernel lock (
bench:smp-boot,bench:smp-shootdown); completion races between harts are not attacked by a case (IPC). Finer locking is M2 (usable shell)’s later step (several harts). - The containment gate is one workload. Containment runs hostile leases of one size, against stand-ins for the steward and a driver, at one hart and two and under one pinned seed. It shows that the kernel’s primitives hold together for that workload. It does not bound every workload, and it says nothing about the real steward. The steward reruns the responsiveness measures with its real servers (M3 (agents, approvals and the attack suite)).
- The kernel trusts the loader’s handoff. It reads the argument block through a pointer the loader passed and trusts the memory map in it. It checks the device list itself (no device may overlap RAM or an interrupt controller), and it checks its own mappings are W^X before the first process runs (R19 (kernel W^X)). The loader is TCB for the rest (boot).
- Test builds carry more. The kernel source has features only some bench cases turn on:
sched-trace,walk-trace,lock-trace,hold-trace,block-trace,dma-reset-deaf,sum-probe,panic-in-printandsmp, andsched-inject-tie-faultfor a recorded negative run. A production build leaves them off (R23 (no test channels)); a kernel built with them is not the kernel this page measures.
Why
- Five jobs, because each needs privilege. Page tables, trap entry and the context switch, the stamp on a message’s sender, the one interrupt controller and the one hardware timer can only be done safely by code no process controls. A driver, a file system or a policy can run unprivileged, so it does: a bug there reaches only what its server’s handles reach.
- One call table, no compatibility layer. Two call interfaces are two sets of argument checks
to audit and two ways into every object. Redoubt carries one, and
bench:no-cruftkeeps the tree from growing a second, or dead code, or a feature nothing reads. For the same reason each shared constant has one literal definition. - No callbacks, no direct switch. A kernel that calls into user code, or hands the CPU from one process straight to another, runs code on a budget the scheduler never picked, and a callback that never returns holds the CPU for ever. With two ways in and one way out, every instruction is charged and every slice ends.
- Count
unsafe, not just lines.unsafeis where Rust’s checks stop and a reader’s must start. A ceiling that only goes down, with a written reason beside each use, keeps that part small and said out loud. - Objects in frames of their own. Each object is one page charged to a budget, so there is no kernel table whose size one budget could exhaust for another, and destroying a budget can free everything it paid for.
Handles and objects
A handle is how a process holds authority: an index into a table the kernel keeps for that process, naming one kernel object together with a badge and a stamp. The kernel has four kinds of object (budgets, endpoints, devices and processes), each one page of RAM charged to one budget. Handles carry no rights bits: whoever holds one may use it in full. A server narrows a grant by minting a handle whose badge means less, and a grant is revoked by destroying the budget its handle is stamped with.
Purpose
Apart from its own memory and threads, the clock and random numbers, a process reaches everything through a handle: a server through an endpoint, resources through a budget, a child through a process handle, hardware through a device. So a process’s handle table is the whole of what it may do beyond itself, and three questions decide whether that is safe. Can a process name something it was not given? It names only indices into its own table, and only the kernel writes the table. Can a grant outlive the decision to revoke it? Every handle is stamped with a budget, and destroying that budget closes every copy. Can holding objects and handles use up memory someone else needs? Every object and every table page is charged to a budget. This page describes those three walls and the tests that attack them.
Interface
The four object kinds
Status: built · partly tested: that no two objects ever share an id is attacked only in the model for budgets and not at all for the other kinds, because a process cannot see an id · tested (6)
- bench:budget
- bench:redoubt-ipc
- bench:redoubt-dead
- bench:process
- bench:process-attack
- bench:device
| Kind | What it is | Made by | Ends when |
|---|---|---|---|
| budget | limits on pages, processes and CPU weight, with a class, labels, an account and an optional deadline (budgets) | budget_create; root, system and users by the kernel at boot | it or a budget above it is destroyed, by budget_destroy or a deadline |
| endpoint | what clients call and servers receive on; it holds no queue (IPC) | endpoint_create | its owner, the budget of the process that created it, is destroyed |
| device | an MMIO range with a DMA flag, an interrupt line, or the right to power off or reboot (devices) | the kernel at boot, from the loader’s device list; never later | its owner is destroyed, or a DMA device fails its reset |
| process | a program with its address space and threads, and afterwards its exit notice (processes) | process_create | its exit notice is received or dropped, or its creator’s budget is destroyed |
Nothing else is an object: no handle names a thread, a page or an open call. Threads are named by their TID within their process, pages by address, open calls by message id.
Each object lives in one RAM frame of its own. The frame’s first word marks its kind, and its second is the object’s id, drawn from one 64-bit counter that never repeats, so no two objects of any kind ever share an id (I12 (ids never reused)). That frame is the page the cost table charges. No call destroys an endpoint or a device: each dies with the budget it is charged to, and a DMA device also when its reset fails (Residual risks).
Handles
Status: built · tested (10)
- bench:budget
- bench:budget-table-attack
- bench:budget-forge-attack
- bench:budget-destroy-attack
- bench:handle-chain-attack
- bench:handle-chain-fault
- bench:process-chain-fault
- bench:redoubt-ipc
- bench:device
- host:redoubt-sys::malformed_calls_are_refused
A handle is an index into the calling process’s handle table. The slot at that index holds:
- the object: its kind, its frame and its id;
- the badge, a 64-bit number. For an endpoint, badge 0 is the receive right and any other
badge was chosen by whoever minted the handle (
mint). Handles to budgets, processes and devices carry badge 0; - the stamp, the budget whose destruction revokes the handle (R9).
Indices start at 1. Index 0 is never allocated: in a register or a record slot it means “no
handle”, for an optional argument left out or a message slot whose handle was revoked. An index
that is 0 where a handle is required, wider than 32 bits, or not in the caller’s table is
BadHandle; a handle of the wrong kind for the call is WrongObject. A process cannot read its
table: it learns nothing of a handle’s object, badge or stamp except by using it.
A handle is four 64-bit words. A slot adds two chain entries of two words each (below), so it
is eight words, 64 bytes, on both widths, and a 4 KiB table page holds HANDLES_PER_PAGE (64)
handles; a compile-time check in kernel/src/handle.rs confirms that 64 slots fill one page
exactly. Index i is slot (i - 1) mod 64 of table page (i - 1) / 64. A table has at most
MAX_HANDLES (4096: 64 pages) handles. A message’s copy of a handle is the four words alone.
---
config:
packet:
bitsPerRow: 64
---
packet-beta
title one slot: eight 64-bit words, 64 bytes, so 64 slots fill a 4 KiB table page
0-27: "object's frame"
28-55: "stamp's frame"
56-63: "kind"
64-127: "object's id"
128-191: "badge"
192-255: "stamp's budget id"
256-319: "object chain: previous link"
320-383: "object chain: next link"
384-447: "stamp chain: previous link"
448-511: "stamp chain: next link"
The kind is 0 for an empty slot, 1 a budget, 2 an endpoint, 3 a device, 4 a process. A link is 0 for none, the head’s frame with the top bit set, or the holder’s PID and the index. Figure: a handle as the kernel stores it. A compile-time check confirms every frame index fits its 28 bits.
A handle dies when the object it names goes or when the budget that stamped it does. The two
chain entries let a destruction find it without reading every table
(residual risks). When a handle arrives in a table, the kernel walks
up from the holder’s budget, at most MAX_DEPTH steps, and enters the slot:
- in the object chain of the budget its object is charged to, if the holder is outside that budget’s subtree (a budget handle’s object is the budget itself); a handle to a process object is entered in that object’s own chain wherever it is held, since the object can be freed while its creator lives;
- in the stamp chain of its stamp, if the holder is outside the stamp’s subtree.
A handle held inside dies with its holder’s table, which goes whole when the holder ends. Each chain is doubly linked, from a head word in the budget’s or process object’s frame, so adding or closing a handle costs a constant and using one costs nothing more. Beside its two handle chains’ heads a budget’s frame holds two more heads, of its charged chain and its counted chain of process objects; each process object carries a pair of links for each, beside the pair that puts it on its exit endpoint’s lists (what objects cost).
A new handle takes the lowest free index. A table page is a frame of its own, allocated and
charged one page to the process’s budget when its first handle arrives, and freed when its last
handle goes. Handles are never moved to fill holes, so an index stays valid until it is closed,
and a table with holes costs one page per table page in use. A table already holding
MAX_HANDLES handles refuses one more with TooLarge, which a caller can tell apart from its
budget being unable to pay for the next table page (OutOfMemory). At delivery, a handle that
would take the receiver past MAX_HANDLES is a cost it cannot pay, and the sender gets Refused
(R4 (delivery)).
flowchart LR
subgraph T["process P's handle table: index, object, badge, stamp"]
direction TB
h1["1: budget B, badge 0, stamp system"]
h2["2: endpoint E, badge 0, stamp system"]
h3["3: endpoint E, badge 5, stamp S"]
h4["4: process Q, badge 0, stamp system"]
h5["5 to 64: empty"]
end
subgraph O["kernel objects"]
direction TB
B["budget B"]
E["endpoint E"]
Q["process Q"]
end
h1 --> B
h2 --> E
h3 --> E
h4 --> Q
Table page 0 holds indices 1 to 64 in one frame, charged to P’s budget; table page 1 (65 to 128)
has no frame until a handle needs index 65; table page 63 ends at index 4096 (MAX_HANDLES).
Figure: a handle table and the objects it names. Handles 2 and 3 name the same endpoint: 2 is
its receive right, 3 a handle minted with badge 5 and stamped with S, a budget below system.
Every lookup checks the id in the handle against the id in the object’s frame, and the stamp’s id against its budget’s. A mismatch would mean a handle outlived its object. The chains of R10 (destruction) rule that out (I2 (revocation is complete)), and the kernel stops rather than use a frame that may hold something else (I1 (handles name live objects)).
Making, copying and closing handles
Status: built · tested (5)
- bench:budget
- bench:process
- bench:process-attack
- bench:redoubt-ipc
- bench:redoubt-revoke
| Call | Arguments -> result | The handle |
|---|---|---|
endpoint_create | -> h | the receive right (badge 0) to a new endpoint, owned by and charged to the caller’s budget |
budget_create | h(parent), spec -> h | a new child budget (budgets) |
process_create | h(budget), h(exit endpoint) -> h | a new process object (processes) |
mint | source, badge, h(budget) or none -> h | another handle to an existing endpoint, with a non-zero badge (below) |
handle_close | h | removed; the object is untouched |
Each call that makes a handle installs it at the lowest free index of the caller’s table,
stamped as R9 says. If the table cannot take it (TooLarge, or OutOfMemory for
a new table page), the call is undone and costs nothing.
Handles are copied, never moved:
- a message carries up to
MAX_MSG_HANDLES(4) handles, installed in the receiver’s table when it is delivered while the sender keeps its own (IPC); process_startcopies up toMAX_START_HANDLES(128) of the caller’s handles into the new process’s table, at indices 1 to n in the order given (processes).
A copy is the same object, badge and stamp at a new index in another table. Copies are equal: none is the original, and closing one leaves the others. There is no call that copies a handle within one table.
handle_close(h) removes the handle at index h, and frees its table page if it was the page’s
last; BadHandle if the caller holds no handle at h. It never destroys the object: a budget,
endpoint or device lives on under its own rules, and a process handle closed early leaves the
exit notice pending. The index is free again, and the process’s next new handle may take it.
When a process ends, every handle in its table closes. When a process object is freed, every
handle to it closes, in every table.
What objects cost
Status: built · partly tested: an endpoint's page is attacked only in the model, a device's page by no case; the boot code departs from R6 (charging) for `root`'s own page · tested (11)
- bench:budget
- bench:budget-table-attack
- bench:budget-mem-churn
- bench:process-attack
- bench:process-review
- bench:process-lifecycle
- bench:thread-limit
- mutation:R6EndpointsFree
- mutation:R6ProcessObjectChargedToBudget
- mutation:R6OwnPageChargedToItself
- mutation:R6OpenCallsFree
Everything the kernel stores is charged in whole pages to one budget (R6 (charging)):
| What | Pages | Charged to |
|---|---|---|
| budget | 1 | its parent; root’s own to root, a rule the boot code departs from (budgets) |
| endpoint | 1 | its owner, the budget of the process that created it |
| device | 1 | its owner: system, charged at boot |
| process object (it holds the exit notice) | 1 | the creator’s budget, the budget of process_create’s caller |
| process header | 1 | the budget the process runs in |
| thread IPC page | 1 per thread | the budget the process runs in |
| page tables | 1 per page-table page, the root table included | the budget the process runs in |
| handle table | 1 per table page holding a handle | the budget the process runs in |
| open call | 1 while open | the receiving process’s budget (R4a (open calls)) |
Pages a process maps, lends or is given are charged as memory and IPC
say. The header is the page at PROCESS_AREA
(memory layout), one on both widths; a thread’s saved
registers are the last bytes of its IPC page, so a thread costs one page on both widths too. The
header and each IPC page are counted once, and neither is the process object’s page.
A budget’s own page is its parent’s, so the whole of a budget’s page limit is usable and a
revocation scope (a budget with zero limits, made only to be destroyed) is no special case.
The process object is charged to its creator, not to the budget the process runs in, because it
holds the exit notice, and that notice must outlive the budget it ran in; the page was paid at
process_create, so delivering a notice never allocates. Everything the running process needs
(header, page tables, thread pages, handle table) is charged where it runs, and comes back when
it ends.
So a process object belongs to two budgets, and each heads a chain of them, doubly linked through
the objects’ own frames from a head word in the budget’s: the creator’s charged chain, of the
objects charged to it, and a budget’s counted chain, of the objects whose PIDs it counts, which
while a process lives is the budget it runs in. An object joins both when process_create makes it and leaves both when
it is freed. A destruction finds the processes it kills, the objects it frees and the PIDs it moves
from the dying budgets’ chains alone (R10).
Every charge comes back exactly, with one exception. Closing a handle, unmapping, replying, receiving an exit notice and destroying a budget each return what they freed; a refused call charges nothing. The exception is a DMA page quarantined because its device did not confirm a reset: it is never freed, so its charge stays, and moves to the parent when its budget is destroyed (devices). A table of n handles filled without closing any costs exactly ceil(n / 128) pages.
mint
Status: built · partly tested: `Dead` from a message source whose endpoint or stamp has gone is attacked by no case · tested (5)
- bench:redoubt-ipc
- bench:redoubt-ipc-attack
- bench:redoubt-revoke
- host:redoubt-sys::malformed_calls_are_refused
- mutation:MintFromUnservedMessage
mint(source, badge, budget?) -> h makes a new handle to an endpoint, with a badge the caller
chooses. The source is one of:
- a receive right the caller holds: the new handle is to that endpoint, and its default stamp is the receive right’s stamp;
- the message id of an open call held by the calling thread: the new handle is to the
endpoint the call arrived on, and its default stamp is the stamp of the handle the call came
through. A
send’s id, another thread’s call and an id the thread never received areInvalidArgument; if the call’s endpoint or its stamp has gone meanwhile,Dead.
The rules:
- The badge is never 0: 0 is the receive right, and
mintnever makes one. The decoder inredoubt-sysrefuses it, and the kernel checks again (I3 (minted badges are non-zero and narrow)). - Only a receive right mints: a source handle with any other badge is
NotPermitted. So only a holder of the receive right hands out badges for an endpoint (I4 (only badge-0 handles receive)). - With no budget handle, the new handle gets the default stamp. With one, that budget must be the
default stamp or below it in the budget tree, or the call is
NotPermitted: a budget handle only narrows. A 9P server mints a connection in the scope its client hands it (R41 (narrowing by revocation scope)). - The new handle is installed like any other: the lowest free index,
TooLargeorOutOfMemoryif the table cannot take it.
The badge means whatever the server says: a 9P root and its mode, one client’s session, one grant in a typed protocol (the servers). The kernel only guarantees it: every message sent through the handle carries it (R14 (unforgeable sender)). Minting from a call is how a server answers a request with a new capability: the handle it returns is stamped like the handle the request came through, so it dies with the client’s grant. The full order of checks is in the ABI reference.
flowchart TD
root[root] --> system[system]
root --> users[users]
system --> S["S: a revocation scope"]
RR["receive right on E<br/>badge 0, stamp system"]
RR -- "mint badge 5" --> H5["E, badge 5<br/>stamp system"]
RR -- "mint badge 6 into S" --> H6["E, badge 6<br/>stamp S"]
RR -- "mint badge 7 into root<br/>or into users" --> NP["NotPermitted:<br/>not system or below"]
Figure: narrowing. A receive right stamped with system mints handles stamped with system or a
budget below it, never above or beside it. Destroying S revokes the badge-6 handle and every copy
of it; the badge-5 handle lasts as long as system.
Because mint only narrows, a server can mint a handle into a user-class budget only from a
receive right whose stamp is that budget or an ancestor of it. A server’s own endpoints are stamped
with its own budget, so a system-class keeper — the steward, whose sessions and leases are carved
from users — cannot mint a session’s or lease’s handles from them: users is beside system,
not below it. The receive rights it mints from are therefore ones a process running at or above
users created: init, which runs in root, makes its servers’ endpoints stamped root (below
which everything sits) and mints the badged handles its manifest names
(init); a helper run in users itself stamps its
endpoints users and so can mint into anything under users. That helper must run in users
itself, not in a child of it (a child’s stamp would be the leases’ sibling, not their ancestor),
and it may exit at once, because an endpoint dies only with its owner budget, never with the
process that created it.
Authority
Status: built · tested (6)
- bench:redoubt-ipc-attack
- bench:budget-forge-attack
- bench:device
- bench:process-attack
- mutation:ReceiveWithBadgedHandle
- mutation:ExitEndpointBadged
What each handle lets its holder do:
| Handle | Its holder may |
|---|---|
| budget | carve a child from it (budget_create), destroy it and everything below it (budget_destroy), destroy its children one at a time and keep it (budget_reap), read its usage (budget_usage, subject to R1 (flow)), run a process in it (process_create), and name it to narrow a mint |
| endpoint, badge 0 (the receive right) | receive on it, mint handles to it, call and send through it, and name it as a new process’s exit endpoint |
| endpoint, any other badge | call and send through it; the server sees the badge |
| process | process_map into it and process_start it, both only before it starts |
| device, MMIO | map_device; dma_alloc too if it has the DMA flag |
| device, IRQ | receive on it |
| device, Reset | system_reset |
A handle of another kind gets WrongObject. A device handle is the only way to hardware
(R18 (device authority)).
- A process uses only its own table. An index is looked up in the caller’s table and nowhere
else, so no number a process passes names another process’s handle, and a forged index is
BadHandle(I1). - No rights bits. Whoever holds a copy of a handle may do everything in its row. A grant is narrowed by the server minting a badge that means less, or by giving a child budget in place of a budget, never by a flag on the handle.
- Authority grows only three ways: a creating call makes a new object paid from the caller’s
own budget;
mintmakes a new badge on an endpoint whose receive right the caller holds or whose call it took; a copy passes on a handle the sender already holds. None gives the caller authority over anything it did not already hold or pay for. - A budget handle is the right to end that budget. Any holder may destroy it and everything
below it, so a budget handle is given only to a party that may end it. A party that needs a
budget only to narrow a
mintis given a revocation scope, whose destruction ends no process (budgets).
Security properties
R9 (stamps)
Status: built · partly tested: a handle minted from a call taking that call's stamp is attacked only in the model · tested (7)
- bench:process-attack
- bench:redoubt-revoke
- bench:budget-deadline
- mutation:R9ReceivedHandleRestamped
- mutation:R9MintStampsCaller
- mutation:R9MsgStampIsSenderBudget
- mutation:R10KeepForeignHandles
Every handle has a stamp, a budget. Destroying that budget, or any budget above it, closes the handle and every copy of it, in every table; a copy in a message not yet received arrives as 0 (R10 (destruction), I2). Which budget the stamp is:
endpoint_create,budget_createandprocess_createstamp the new handle with the caller’s budget, the one its process runs in. A budget handle made by a process in budget A dies with A, even though the budget it names sits under another parent and outlives it.- A copy keeps its stamp. A handle delivered in a message or a reply, or placed by
process_start, is stamped as the sender’s was. No path restamps a handle. mintstamps with the source’s default stamp, or with a budget the caller names at or below it (mint). The default stamp of a call is the stamp of the handle the call came through, not the caller’s budget.- Handles the kernel places at boot, before any process runs, are stamped with
rootfor the budgets and devicesinitreceives (boot).
So a stamp only moves down the budget tree. A handle minted from another dies no later than its source, and a handle minted in answer to a call dies no later than the handle the call came through: a client whose grant is revoked loses what servers minted for it as well.
The stamp is not the object’s owner. An object dies with the budget it is charged to; a handle dies with its stamp; either ends the handle, because R10 closes every handle whose object or stamp is being destroyed.
Failure and restart
Status: built · tested (5)
- bench:budget-destroy-attack
- bench:budget-table-attack
- bench:process
- bench:process-attack
- bench:redoubt-revoke
- A process ends: every handle in its table closes and its table pages return to its budget. The objects they named are untouched: an endpoint it received on stays for a restarted server (R4b (a server dies)).
- A budget is destroyed: every handle naming it or a budget below it, naming an endpoint, device or process object charged to one of them, or stamped with one of them, closes in every table, and a copy in a message not yet received arrives as 0 (R10).
- A process object is freed, when its notice is received or dropped or its creator’s budget is destroyed: every handle to it closes.
- A creating call cannot install its handle: the object is freed and its charge returned, so a refused create costs nothing.
- A handle whose object has gone is a kernel bug, not an error a call returns. Every lookup
compares ids, and on a mismatch the kernel stops rather than use a frame that may hold something
else (I1). R10’s sweeps keep it from happening (I2), and no argument to any call reaches it
(I14 (no call panics the kernel));
budget-destroy-attackreuses frames and indices after a destruction to look for one.
Residual risks
- No call destroys an endpoint. An endpoint lives, and costs its owner a page, until the owner budget is destroyed; closing every handle to it frees nothing. The owner is always the creating process’s own budget, so a server that makes an endpoint per client pays for each until that budget goes, bounded by the budget’s page limit. Servers make one per service instead (Why).
- A device object is never remade. None is created after boot; one destroyed with its owner, or for failing a DMA reset, is gone until reboot (devices).
- Revocation is by budget only. A grant is revoked by destroying the budget it is stamped with, and with it everything else stamped there. A server that means to cut off one client alone must have minted that client’s handle into a revocation scope of its own. Otherwise it can only stop honouring the badge: the handle still reaches the endpoint, and the refusal is the server’s, not the kernel’s.
- A budget handle cannot be narrowed. Every holder may destroy the budget and everything
below it: a handle to
systemis the right to end every system server, and a handle torootthe right to end every process. - A stale handle stops the machine. The kernel’s answer to a broken I1 is to stop. A bug in R10’s chain walk would stop the kernel, and every process with it; it would not let a process use a freed object.
- Slot searches and one sweep scan. Destroying a budget closes the handles in its chains and
frees the dying tables whole; it walks the dying subtree, its owner lists and its process
chains, not the object
frames or the live tables (budgets). Destroying a quarantined
device still closes its handles in one pass over every table of every process (up to
MAX_PROCESS_COUNT(511) processes of 64 table pages), bounded by compile-time constants. Installing a handle searches for the lowest free slot of one table.
Why
- No rights bits. A read-only or non-transferable handle would not hold: its holder can proxy for anyone it talks to. So the kernel checks one thing, that the caller’s table holds the handle, and meaning lives where it is enforced: in the badge, which the server chose and checks (a 9P root, read-only), and in the stamp, which bounds how long the grant lasts.
- Budgets are the only revocation. There is no per-handle revoker and no derivation tree to walk. Every handle already names a budget in its stamp, and destroying a budget is already the one way resources come back, so revocation reuses it. A revocation scope, a budget with zero limits, makes any grant revocable on its own for one page. Budget ids are never reused, so a stale stamp never matches a later budget.
- Stamps only narrow. A server answering a call mints under the call’s stamp by default, so what it hands a client dies with the client’s grant, and the server never holds the client’s budget.
- An endpoint dies only with its owner. A server serves every client on one endpoint and tells them apart by badge (the servers), so it makes one endpoint per service, not per client, and no server needs to free one on its own. A call that did would add a destruction path to the kernel for a cost that already falls only on the owner’s own budget, bounded by its page limit.
- Stamp and owner are separate. Who pays for an object and who may revoke a handle to it are different questions. A process in budget A may create a budget under B for someone else; B’s child lives as long as B, while A’s own handle to it dies with A.
- Every object is a page of its own. There is no kernel table of fixed size that one budget could fill for everyone: creating an object fails only on the caller’s own budget (R7 (carving)). Handle tables work the same way, a page at a time.
- Lowest free index, never compacted. An index stays valid until it is closed, so a program can keep a number. A table with holes costs a page per page in use, which is what its memory costs, and the model charges the same.
TooLargeatMAX_HANDLES, so a caller can tell a full table from a budget out of pages; the limit also bounds every scan of a table.- 0 means none, so optional arguments and revoked message slots need no second encoding.
- The process object is its creator’s, because its exit notice must outlive the budget the process ran in; the page was paid at creation, so a notice never allocates.
IPC: calls, sends and replies
Processes talk only through the kernel’s IPC. A client calls an endpoint and blocks until a server replies, or sends one-way. The kernel carries a few words, a few handles and at most one run of pages, and it stamps every message with who sent it. There is no message queue: a queued message is a sender blocked on the endpoint.
Purpose
IPC is how authority is used. Holding an endpoint handle is the right to call the server behind it; the handle’s badge tells the server which grant is in use. Everything a server offers (a file, a socket, a key operation) is a call on an endpoint. So IPC has to do three things well: say truthfully who is calling, keep one caller from crowding out the others, and never let a page change hands in a way either side did not agree to.
Interface
Messages
Status: built · tested (9)
- bench:ipc
- bench:redoubt-ipc
- bench:all-together
- host:redoubt-sys::received_layout
- host:redoubt-rt::a_lent_page_is_refused_to_every_call_until_the_call_returns
- host:redoubt-rt::a_transferred_page_is_refused_to_its_sender_once_taken
- host:redoubt-rt::an_abandoned_lend_is_refused_to_its_caller_until_the_server_replies
- host:redoubt-rt::queued_pages_are_refused_until_the_message_times_out_and_gives_them_back
- host:redoubt-rt::a_destroyed_endpoint_abandons_a_taken_lend_to_the_server_until_it_replies
A message carries:
WORDS(4) machine words, widened to 64 bits in records so one layout serves rv32 and rv64;- up to
MAX_MSG_HANDLES(4) handles, copied: the sender keeps its own, and each copy keeps its stamp (the budget whose destruction revokes it; objects); - at most one buffer of whole pages: a lend on a
call, or a transfer on asend.
A message’s pages move by ownership: every page has one owner at a time, and only its owner touches it.
- A lend is up to
MAX_LEND_PAGES(16 pages, 64 KiB) of pages the caller owns and may write. From thecalluntil it returns, the caller has given them up: it may not read, write, lend, transfer, map or unmap them. A server that takes the call may read and write them, at an address the kernel picks, until it replies. The call’s return says who owns them:returned, as before the call;consumed, the server, whose reply frees them (R3). - A transfer is pages the sender owns, given away for good: the sender gives them up at the
send, and when a receiver takes the message they become the receiver’s, owner and payer both. Asendthat fails before a receiver takes it gives them back. A receiver takes a transfer only if itsreceivenamed amax_transferat least that large.
On this kernel giving pages up removes them from the giver’s address space, so a touch is a fault (I9 (pages W^X, zeroed, lends unmapped)), and taking them maps them in the taker’s (memory). A backend without an MMU may hand the same buffer over instead, as long as every rule above holds.
sequenceDiagram
participant C as caller
participant S as server
C->>S: call: four words, up to four handles, a lend of pages
Note over C,S: the caller gives the lent pages up until the reply;<br/>the server reads and writes them at an address the kernel chose
S-->>C: reply: four words, copies of the handles
Note over C,S: the pages are the caller's again
Figure: what a call carries. The lent pages have one owner at a time.
The kernel attaches three facts the sender cannot choose (R14): the
badge of the handle the message came through, the sender budget’s account (the principal
it bills to; 0 for none) and its label set (the information-flow labels,
containment). It also gives each message a message id,
non-zero and never reused within the receiving process, which reply and serve name.
The calls
Status: built · tested (4)
- bench:redoubt-ipc
- bench:redoubt-ipc-attack
- host:redoubt-sys::every_call_round_trips
- host:redoubt-sys::malformed_calls_are_refused
| Call | Arguments -> result | What it does |
|---|---|---|
call | endpoint, body record, lend or none, timeout -> status, lend disposition, reply disposition | Queue a message and block until the reply, an error or the timeout. The reply is written back into the body record. |
send | endpoint, body record, transfer or none, timeout | Queue a message and block until a receiver takes it, or it fails. No reply. |
receive | badge-0 endpoint, IRQ handle or none, timeout, max_transfer -> one record | Take the next message or notice. With no handle it sleeps until the timeout. |
reply | message id, body record -> delivered or discarded, installed-handle mask | Answer an open call and close it. |
serve | message id | Make an open call the thread’s current call, the one a crash blames. |
A server that holds a call without answering it yet holds it open: receive took it, and
nothing forces a prompt reply. The thread may keep receiving. Each call it holds is an open
call, and the one it is working on is its current call: receive sets it to the call just
taken (or to none), serve switches it, and replying to it clears it.
Every blocking call takes a timeout in microseconds; FOREVER never expires
(timer, I13 (every blocking call returns by its timeout)). Argument checks run in a
fixed order, the same in the kernel and the model; the full rows are in the
ABI reference.
What receive returns
Status: built · partly tested: a record made unwritable while its thread waits is attacked only for an exit notice (`process-attack`); for a message it is not attacked by a case · tested (4)
- bench:redoubt-ipc
- bench:timeouts
- bench:process-attack
- host:redoubt-sys::received_layout
One record layout for every result: (kind, msg_id, badge, account, labels, words, handles, buffer, pages). A field a kind does not use is 0.
| Kind | Meaning | Fields used |
|---|---|---|
call | a message that owes a reply; its buffer, if any, is a lend | all |
send | a message that owes nothing; its buffer, if any, is a transfer | all |
interrupt | the IRQ handle receive named has fired (devices) | kind |
exit | a process whose exit endpoint this is has ended (processes) | words 0-2 = pid, cause, code; account, labels = who is blamed |
abandoned | an open call this thread holds lost its caller (R3) | msg_id |
Timeout is an error, not a record. Notices come before messages: first an abandoned-call
notice for the receiving thread, then an exit notice, then the next message by
R2. Receivers waiting on one endpoint are served in the order they began to
wait; a receiver at MAX_OPEN_CALLS is passed over while no group has a send. An abandoned-call
notice goes to the holder that began waiting first, and exit notices go in the order their
processes ended. A handle revoked while its message was queued arrives as 0 in its slot, so slots
keep their positions.
The record is checked when receive starts, and again just before a message or exit notice is
delivered, because another thread of the process may have unmapped it meanwhile. If it can no
longer be written, the receiver gets InvalidArgument and the message or notice stays pending
for the next receive: nothing is taken that the receiver cannot be told about. Interrupts and
abandoned-call notices follow the same rule
(a bad record takes nothing).
flowchart TD
R[receive on endpoint E] --> A{abandoned call held<br/>by this thread on E?}
A -- yes --> NA[return abandoned notice]
A -- no --> X{exit notice<br/>pending on E?}
X -- yes --> NX[return exit notice]
X -- no --> P{"next sender by R2<br/>(calls skipped if the process<br/>holds MAX_OPEN_CALLS)"}
P -- none --> W[block until one arrives<br/>or the timeout]
P -- found --> C{"R4: can the receiving<br/>budget pay for it all?"}
C -- no --> RF[sender gets Refused,<br/>try the next sender]
RF --> P
C -- yes --> D[deliver: map buffer,<br/>install handles, return record]
Figure: what one receive on an endpoint returns, in order.
A bad record takes nothing
Status: built · tested (5)
- bench:receive-bad-record
- bench:process-attack
- host:redoubt-model::a_record_gone_bad_while_receiving_takes_nothing
- mutation:BadRecordConsumesNotice
- mutation:R5BadRecordConsumesInterrupt
An interrupt or an abandoned-call notice is kept like a message: the record is checked just
before either is delivered, and if it can no longer be written the receiver gets
InvalidArgument and the item stays pending, the interrupt still fired (and its source masked)
and the call’s notice still owed. The next receive that can write its record gets it, in the
order above. So the one report of I15 (abandoned calls reported once) is always received, and a
driver never loses an interrupt to its own thread’s unmap.
How a call completes
Status: built · partly tested: completion races between harts are not attacked by a case · tested: bench:ipc-outcomes, bench:timeouts, host:redoubt-sys::ipc_outcomes_round_trip_and_reject_impossible_combinations
A call returns three separate facts, and a caller must read all three
(R13):
- the status: success or an error;
- the lend disposition, who owns the lent pages now:
none(no lend),returned(as before the call) orconsumed(the server, until its reply frees them; never touch them again); - the reply disposition:
present(the whole reply record was written) orabsent(the record holds nothing; never decode it).
A successful reply tells the server delivered or discarded, and a mask of the reply’s
handle slots that were installed in the caller.
| What happened | Caller status | Lend | Reply | Server’s reply result |
|---|---|---|---|---|
| Refused, timed out or revoked while still queued | the error (Timeout, Dead, …) | returned | absent | (never taken) |
| Taken, then the caller timed out or was revoked | Timeout or Dead | consumed | absent | discarded, mask 0 |
| The server died holding it | Dead | returned | absent | (no reply) |
| Normal reply | success | returned | present | delivered, mask |
| Reply whose handles do not all fit the caller (R4) | OutOfMemory | returned | present | delivered, mask of those that fit |
| The caller’s record can no longer be written | InvalidArgument | returned | absent | discarded, mask 0 |
| The caller died after the server took it | (none) | consumed (R3) | (none) | discarded, mask 0 |
With no lend every row reports none. The dispositions come back in registers, outside user
memory, on every return including errors:
| Call | a0 | a1 | a2 |
|---|---|---|---|
call | 0 or the error code | lend: 0 none, 1 returned, 2 consumed | reply: 0 absent, 1 present |
reply (success) | 0 | 0 discarded, 1 delivered | installed-handle mask, bits 0-3 |
reply (error) | the error code | 0 | 0 |
Registers a3-a7 are 0. redoubt_sys::decode_result refuses any other combination: for
call, present with a status other than success or OutOfMemory, success without present,
present with a consumed lend, and consumed without Timeout or Dead; for reply, a mask
bit past the fourth handle, or any mask bit with discarded. The runtime then checks the mask
against the handles its reply actually supplied.
The Rust runtime (libs/rt/src/ipc.rs) makes the rules hard to break. Endpoint::call takes the
lend as an owned Buffer and returns a CallOutcome holding the status, the buffer only if it
was returned, and the reply only if it is present. Request::reply consumes the request, and hands it back with the error if the reply is refused, so the server can still answer it.
sequenceDiagram
participant C as Client thread
participant K as Kernel
participant S as Server thread
S->>K: receive(E, timeout, max_transfer)
Note over S,K: blocks: nothing queued
C->>K: call(E, words, handles, lend 4 pages, timeout)
Note over K: checks, R1 labels, R2 cap,<br/>client gives the lend up
K->>S: record: kind=call, msg_id, badge,<br/>account, labels, words, handles, lend at A
Note over K: open call page charged to server (R4a),<br/>lend charged to both sides (R3)
S->>S: read and write the lend at A
S->>K: reply(msg_id, words, handles)
Note over K: lend the client's again,<br/>reply record written
K-->>C: status 0, lend returned, reply present
K-->>S: delivered, mask
Figure: a call with a lend, from receive to reply.
Authority
Status: built · tested: bench:redoubt-ipc-attack, mutation:R1ChecksReceiverNotOwner
- An endpoint handle with badge 0 is the receive right. Only it may
receive, and only its holder canminthandles with other badges.endpoint_createreturns it. - Any other endpoint handle is the right to call or send, identified to the server by its badge. Handles carry no rights bits: whoever holds a copy may use it. A server narrows a grant by minting a new badge and meaning less by it.
- IPC can grant only what the sender already holds: a message copies the sender’s handles, each with its own stamp. It creates no authority.
- IPC never gives the receiver anything of the sender’s but the words, the copied handles and the buffer: not the sender’s other handles, budget, address or identity beyond the three attached facts. The raw budget id never travels.
- Handing a receive right to another budget is delegation of the whole endpoint, and it is never handed across label sets (I7 (every flow obeys R1)).
Security properties
R1 (flow)
Status: built · partly tested: a call or send between user budgets with different labels is attacked only in the model; on the target only a `budget_usage` read and an exit notice are · tested (6)
- bench:process-attack
- bench:process-review
- mutation:R1SkipLabelCheck
- mutation:R1ChecksReceiverNotOwner
- mutation:R1ExitNoticeIgnoresLabels
- mutation:R1UsageIgnoresLabels
Information flows from budget A to budget B only if B is class system or B’s labels include
all of A’s. A message is a flow from the sender’s budget to the endpoint’s owner (the budget
that created it), whoever takes it, so the check is made when the message is sent. Because every
call is answered or refused, it is also a flow back. So between two user-class budgets the
label sets must be equal, or the call gets LabelDenied. When either side is system
class the kernel does not check: system servers serve many label sets and check them
themselves (servers). An exit notice is a flow from the
exiting budget to the owner of the exit endpoint; one that fails the rule is dropped. A
budget_usage read is a flow from the budget read to the reader
(budgets).
R2 (fair waiting)
Status: built · partly tested: how groups are keyed (account, label set, and budget for account 0) is attacked only in the model, and turns between groups on the kernel only as one label set's order beside a vault's turn and a served group going behind one already waiting; the IPC case fills one group's cap, and three boots flood a real server and keep the victim's group its turn · tested (13)
- bench:redoubt-ipc
- bench:ipc-fair-label-sets
- bench:walfsd-flood-turn
- bench:vault-flood-turn
- bench:blkd-fairness
- host:redoubt-model::steward_noninterference
- host:redoubt-model::one_cursor_scenario
- mutation:R2FifoAcrossAccounts
- mutation:R2NoWaitCap
- mutation:R2KeyByAccountOnly
- mutation:R2KeyByStampLabels
- mutation:R2SystemCallersShareGroup
- mutation:R2OneCursor
Senders blocked on an endpoint are grouped by their budget’s account and label set, and, for
account 0 (no principal: the boot budgets, and any budget carved without one, of either class),
by budget as well. Each receive takes the oldest message of the group served least recently: a
group’s turn is due from when it was last served, or from when its oldest message arrived if it
has not been served since. Ties go to the lower group key. A group’s turn is read from one kernel
counter no process reads: its oldest queued message’s arrival, or the group’s last take if that came
later, kept with the group only while it has a message queued, so a group with nothing queued keeps
nothing. Each endpoint keeps its waiting groups in the order their turns fall due, and apart those
with a send queued in the order their oldest sends fall due, for a receiver at MAX_OPEN_CALLS
(R4a); a take moves its group behind the others on both, one write whatever the
group holds. A group’s place therefore depends only on its own history, so the order in which one
label set’s groups are served is the same whatever other label sets send. A single cursor over all
groups would not keep that: a vault session’s take would move the cursor, and with it which
unlabelled caller is served next, which crash blame then shows to unlabelled readers
(R37 (vault non-interference)). A group that
already has WAIT_CAP (32) messages queued on the endpoint gets Busy at once. Only queued
messages count; a taken call is bounded by R4a instead. Keying by label set keeps
a vault session and its owner’s ordinary session, which share an account, from sharing a turn or a
cap. Keying account-0 callers by budget keeps one busy system server from filling another’s cap.
With k groups waiting and the receiver below its open-call limit, each group’s oldest message is
taken within k receives (I11 (fair turns)).
R3 (lends and abandoned calls)
Status: built · tested (12)
- bench:redoubt-revoke
- bench:timeouts
- bench:ipc-outcomes
- bench:uaf-lent-page
- bench:process-lifecycle
- bench:endpoint-destroy-open-calls
- mutation:R3UnmapAbandonedLend
- mutation:R3ChargeStaysWithCaller
- mutation:AbandonNoticeMissing
- mutation:AbandonNoticeRepeated
- mutation:BadRecordConsumesNotice
- mutation:EndpointDestroyNoticeKept
A lend’s range must be the caller’s own writable RAM. Pages in it that were never touched are
backed first, charged to the caller like map_anon’s; a caller that cannot pay for them gets
InvalidArgument, since call has no OutOfMemory of its own. A lent page stays charged to
the caller. Taking the call charges it to the receiving process’s budget as well, until reply
gives it back. The page tables that map the lend in the receiver are charged to the receiver
too, and go at the reply that leaves them mapping nothing (memory).
A taken call is abandoned when its caller dies, times out, or is failed by revocation. Then:
- the caller’s charge ends and the lend becomes the server’s alone: it may still read and write it until its reply;
- the thread holding the call gets one abandoned-call notice, on the endpoint the call arrived on (I15 (abandoned calls reported once));
- the call stays open, and counts against the server’s limit, until the server replies; that reply reaches nobody, and replying frees the lend.
A thread owed several notices gets one per receive, each exactly once, in no promised order.
A reply that comes before the abandonment is delivered, and nothing is abandoned. If the
abandonment comes first and the server replies before it has received the notice, reply
returns discarded, mask 0, and no notice follows: the reply closed the call.
The destruction of the endpoint a call arrived on (budgets) also
abandons the calls taken through it, and fails their callers with Dead and their lends
consumed, but no notice follows: there is no endpoint left to receive one on. Dead from
receive reaches only a thread already blocked in receive on the endpoint; the sure sign is
the reply: a reply to one of those calls returns discarded, mask 0, and frees its lend, as for
any abandoned call.
stateDiagram-v2
[*] --> Queued: call
Queued --> [*]: Refused / Timeout / Dead<br/>(lend returned)
Queued --> Open: receive takes it
Open --> [*]: reply (delivered)<br/>lend returned
Open --> [*]: server dies<br/>caller gets Dead, lend returned
Open --> Abandoned: caller dies, times out,<br/>or is revoked
Abandoned --> [*]: server replies<br/>(discarded, lend freed)
Abandoned --> [*]: server dies<br/>(lend freed)
Figure: the life of a call. The lend has exactly one owner in every state.
R4 (delivery)
Status: built · tested (6)
- bench:redoubt-ipc
- bench:redoubt-dead
- bench:redoubt-tight
- mutation:R4IgnoreMaxTransfer
- mutation:R4OverdrawOnDelivery
- mutation:IpcDropPartial
A message is delivered only if the receiving process’s budget can pay for everything it
brings: the handle-table pages for its handles, a call’s open-call page, its lent or transferred
pages and the page tables to map them. A lend is charged to the receiver even when both sides
share a budget (R3). A transfer is charged to the receiver instead of the sender, so between two
processes of one budget it moves no charge and costs nothing. A transfer also needs a
max_transfer at least its size. Handles that would take the receiver past MAX_HANDLES
(4096) are a cost it cannot pay, and so is a buffer with no free run of pages for it in the
receiver’s message area. Otherwise the sender gets Refused and the kernel moves on to the
next sender. A receive never fails for want of pages.
A reply is never refused: its caller is blocked and has nowhere else to put the error.
Reply handles that do not fit the caller are dropped, each 0 in its slot. The words and the
other handles arrive, and the caller’s call returns OutOfMemory with the reply present.
R4a (open calls)
Status: built · tested: bench:redoubt-ipc, bench:net-pinned-ssh, mutation:R4aOpenCallsPerThread, mutation:R4aFullTakesNothing
Taking a call opens it and charges one page to the receiving process’s budget; reply closes
it and frees the page. A process holding MAX_OPEN_CALLS (256) open calls takes no more: calls
stay queued and R2’s turns skip them, while its receive still delivers sends, interrupts and
notices. A send is never an open call, so reply to a send’s id is InvalidArgument.
R4b (a server dies)
Status: built · tested (6)
- bench:redoubt-dead
- bench:process-lifecycle
- bench:ending-pumps-once
- bench:destroy-keeps-notices
- bench:destroy-keeps-notices-creator
- mutation:R4bDeadServerFakesReply
When a thread or process exits, faults or is killed holding open calls, each waiting caller
gets Dead and its lend back intact; the lend of an abandoned call is freed. Senders still
queued on the endpoint keep waiting: the endpoint outlives the server, and a restarted server
receives them (init). A thread of a
doomed process (one the destruction will kill: its budget is dying,
or its creator’s is) is offered nothing (no message, exit notice or abandoned-call notice):
nothing is delivered while the destruction runs, and the deliveries at its end find it gone.
What it would have taken stays for the next receiver outside the destruction.
R13 (one outcome per call)
Status: built · partly tested: completion races between harts are not attacked by a case · tested (6)
- bench:ipc-outcomes
- bench:elixir-oracles
- mutation:IpcWrongLend
- mutation:IpcFalseDelivery
- mutation:IpcSkipOutputCheck
- mutation:IpcLeakRollback
Every call ends in exactly one row of the completion table, and the caller and server agree
on it. present means the whole reply record was written, and only then may the caller decode
it. consumed means the lend’s mapping and charge have ended. The body record is checked
readable and writable before anything is delivered, and checked again at completion, because
another thread may have unmapped it while the call waited. If the reply cannot be written, every
handle this reply installed in the caller is closed again and its table pages released; the
caller gets InvalidArgument, its lend back and absent, and the server gets discarded.
Checking the record, copying, installing or rolling back handles and publishing the outcome are
one step: the kernel runs with interrupts off and holds the memory manager’s guard throughout,
so no unmap, remap or teardown can fall between them.
The clients keep it above the kernel: the client library’s typed call, and a session’s generated Elixir client, return a reply’s handles to the caller and keep none that an error reply, or one that does not decode, brought (wire).
R14 (unforgeable sender)
Status: built · partly tested: every case delivers account 0 and no labels, so a non-zero account or a label set reaching the receiver unchanged is attacked only in the model · tested (5)
- bench:redoubt-ipc
- bench:bench-attack-forgery
- bench:pid-reuse-authority
- mutation:MsgNoLabels
- mutation:MsgAccountZero
The badge, account and labels a receiver sees are the kernel’s: the badge of the handle used,
and the sender budget’s account and labels at the time of sending. No argument of call or
send can set them. Message ids are per receiving process, so they reveal nothing of anyone
else’s traffic, and a stale id cannot reach a later message (I12 (ids never reused)).
Failure and restart
Status: built · tested (4)
- bench:redoubt-dead
- bench:redoubt-revoke
- bench:budget-deadline
- bench:timeouts
- The server dies holding calls: callers get
Deadand their lends back (R4b). Queued senders wait for the restarted server on the same endpoint. - The caller dies, times out or is revoked after the server took the call: the call is abandoned (R3). The server keeps the lend until it replies and pays for it.
- A budget is destroyed: endpoints it owns are destroyed and everything waiting on them gets
Dead; queued messages sent through a handle it stamped fail withDead; a taken call sent through one is abandoned (R10 (destruction)). A taken call on a destroyed endpoint is abandoned with no notice (R3): a thread blocked inreceiveon it getsDead, and the reply’sdiscardedis the sure sign. - Crash blame: when a server process faults, or exits while it holds open calls, the exit
notice blames the account and labels of the sender of the ending thread’s current call, or
nobody if it has none (R21 (crash blame)). A
sendis never blamed. A server callsservebefore resuming a held call so that a crash blames the right caller. - No argument to any IPC call can make the kernel panic (I14 (no call panics the kernel)).
Residual risks
- System-class servers are trusted to check labels. R1 does not constrain a flow into or out
of a
systembudget. A system server that serves two label sets and mixes their data breaks label separation, and the kernel cannot see it (servers). RefusedandBusyare signals.Refusedtells a sender one bit about the receiver’s budget;Busytells it its own group is full. A sender with a clock learns as much from timing. Covert and timing channels are out of scope (TENETS).- A server pays for the calls it holds. An abandoned call’s lend stays charged to the server until it replies; a server that never replies keeps paying. That is the server’s own cost, never another budget’s.
- A
consumedlend is gone. A caller whose taken call times out loses those pages. Callers that cannot afford that must not lend them with a short timeout. - Completion races between harts are argued from the code, not attacked by a case. On one hart the kernel runs with interrupts off. On several (a build for more than one hart), each kernel global is guarded by its own lock, and the completion holds the memory manager’s for the whole step.
Why
- No queue, no allocation for the queue. A queued message is its blocked sender, and the
sender’s own thread page (already paid for) holds it. So queueing a
callorsendallocates no kernel memory and cannot make the kernel allocate on a receiver’s behalf. The only pages they may allocate are the sender’s own untouched buffer pages, backed and charged to the sender (R3). Finding a message’s parties costs a few links in pages already paid for: the endpoint’s frame heads its receivers and its groups, and the links are in the waiting threads’ own pages. - Lend, not copy. A 9P message is up to 64 KiB. Moving pages costs page-table updates, not copies, and unmapping them from the caller means neither side can see the other change them mid-call.
- The reply is never refused, because the caller is blocked and has no other way to learn the outcome; dropping the handles that do not fit, and saying so, is the only safe partial.
- An abandoned lend stays with the server so the server never has a page pulled from under it mid-request; it frees the page by replying.
- Owner, not receiver, for R1. The check is made at send time against the endpoint’s owner, so it cannot depend on which thread later receives, and a receive right cannot be passed across label sets to get around it.
- Groups, not senders, for fairness. One principal with many processes gets one turn, not
one per process, and a full group gets
Busyat once instead of blocking others.
Memory
A process gets memory only from the kernel, in whole 4 KiB pages, paid for by its budget. Five
calls change what a process has mapped: map_anon, map_fixed, unmap, set_flags and
process_map. The kernel zeroes every page before a process first sees it, never maps a RAM page
writable and executable, and chooses every address but the ones map_fixed and process_map
name. Lends and transfers move pages between processes by changing page-table entries, never
by copying.
Purpose
Memory is what every process consumes, and a page is the easiest thing to leak between two
processes: a frame freed by one and handed to the next still holds what the first wrote. So
the memory calls have four jobs. Hand out pages that hold nothing of anyone else’s. Keep every
RAM page W^X, so data a process writes cannot run as code. Keep a page’s owner and payer
straight as it moves by lend, transfer or process_map. And keep the cost of each call bounded
by what the caller has, because the kernel runs a call to its end with interrupts off.
Interface
The mapping calls
Status: built · tested (6)
- bench:device
- bench:write-only-attack
- bench:process-attack
- bench:map-fixed-attack
- bench:return-lent-unmapped
- bench:dma-rules
| Call | Arguments -> result | What it does |
|---|---|---|
map_anon | len, flags -> addr | Map len bytes of zeroed pages at an address the kernel chooses, each backed and charged to the caller’s budget at once. |
map_fixed | addr, len, flags | The same, at exactly addr. Never replaces a mapping (below). |
unmap | addr, len | Remove the caller’s own pages. A RAM frame goes back to the free pool and its charge to the budget; a device’s registers or a dma_alloc page only lose the mapping. |
set_flags | addr, len, flags | Give each of the caller’s own pages exactly these permissions. It may add a permission as well as drop one. |
process_map | h(process), src, dst, len, flags | Move the caller’s own RAM pages, contents and all, into a process that has not started, at dst, with flags. The child’s budget pays for them and for the page tables that map them (processes). |
A range is page-aligned and non-empty, and for every call but map_anon it lies below
USER_AREA_END (the end of user space: 0x8000_0000, 2 GiB, on rv32; 0x40_0000_0000, 256 GiB,
on rv64). Flags are READ, WRITE and EXECUTE: at least one, never WRITE with
EXECUTE, never WRITE without READ. “The caller’s own page” is a live user mapping that is
neither side of a loan and is credited to the caller in the kernel’s frame ownership table (or
is a dma_alloc page of its own, or device registers it mapped). Only the RAM among them can
be made executable (R11). A reservation not yet touched is not a mapping.
Each call checks the whole range before it changes any page, so an error leaves every mapping
as it was and charges nothing. process_map counts the untouched pages of its source among what
the call must pay for, and backs them only once nothing can refuse it.
The errors are InvalidArgument and OutOfMemory, and for process_map also BadHandle,
WrongObject and NotPermitted (the child has started). The order of the checks, the same in
the kernel and the model, is in the
ABI reference.
Backing and zeroing
Status: built · partly tested: that a frame freed with data in it comes back zero is attacked by `smp-inflight-race`, whose witness reads every page it is handed while the trace shows most frames handed out were given back since, but no case ties a page it read to the frame another process filled: `mem-attack` cannot tell which frames it was handed, and `dma-reset-reuse`, which proves reuse by physical address, never reads the reused frames; a frame free since boot is zeroed at its first allocation, which no case can tell from a frame QEMU left zero; QEMU models no cache, so `cbo-user-fault` shows that user cache-block operations trap, not that zeroes could be lost, and the firmware's flush is read from the code · tested (10)
- bench:device
- bench:mem-attack
- bench:map-fixed-attack
- bench:lend-untouched-page
- bench:touch-beyond-ram
- bench:cbo-user-fault
- bench:scan-bounds
- bench:smp-inflight-race
- mutation:R11NoZeroing
- mutation:R81CommitUnzeroed
map_anon and map_fixed back every page when they map it. Each takes a free frame, charges it
to the caller’s budget (R6 (charging)), and only then writes the entry
that maps it. Every free frame is already zero: a frame given up is zeroed before it is free again
(below), and a frame free since boot, which may hold whatever the loader or
the firmware left there, is zeroed through the
physmap the first time it is taken. So a page is zero
the first time its process can see it, whoever held the frame before, and backing a page writes
nothing to it. The page tables a mapping needs are allocated and charged as it goes, to the
budget of the process they map into. A free frame is a set bit in a bitmap the kernel keeps, with a summary bit
for every word of it, level above level, to a fixed depth set by the most RAM the kernel can map.
Taking the lowest free frame reads one word a level, and giving one back sets its bit and at most
one word a level, so neither searches RAM, however much of it is in use (bench:scan-bounds). A
checked build proves after each destruction that the bitmap is exactly the free frames and every
summary is exact.
The one exception is the stack of a program the loader starts: it is reserved, its entries
holding permissions but no frame. A page of it is backed with a zeroed, charged frame on first
touch: a load or store fault, or a call that lends, transfers or process_maps it. A process
started by process_start has no reservations: its parent gave it every page it has.
Running out is the caller’s error: map_anon of more than the budget or RAM can supply is
OutOfMemory, and every page and page table it had taken goes back.
The zeroes cannot be discarded from user mode. Until they are evicted they may live only in a
data cache, and a cbo.inval of the page would drop them and expose the frame’s old contents in
DRAM. So the kernel writes senvcfg 0 on every hart at entry, whatever the reset left, and every
cache-block operation traps in user mode; the boot stops unless it reads the 0 back. The firmware
turns a supervisor cbo.inval into a flush, so no mode below M can discard a line. The kernel
requires privileged architecture 1.12, where senvcfg first appears: on an older hart the write
traps and the boot stops.
A test kernel’s trace ring (sched-trace, never in a release build;
R23 (no test channels)) takes its frames at boot, the highest free ones, before
the budget tree counts what the kernel keeps, and holds each as its page number in 32 bits, so the
256 MiB ring’s table of 65,536 frames fits the kernel’s 1 MiB RAM region. Every frame the kernel
takes lies in the physmap, which ends at 128 GiB on
rv64 and below 4 GiB on rv32: page numbers to 2^25, where 32 bits reach 16 TiB of physical
address. A compile-time assertion in kernel/src/sched.rs holds the physmap’s last page number
within 32 bits.
Frames in flight
Status: built · partly tested: QEMU drops a hart's TLB at its next `satp` write, so no case lands a stale store in a frame being zeroed; the order that keeps one out is the trace's, and a stale store cannot be told apart from the recorded negative's · tested (4)
- bench:smp-inflight-race
- bench:smp-shootdown
- mutation:R81InFlightAllocatable
- mutation:R81CommitUnzeroed
A frame its owner gives up, by unmap, a process’s end, a page table emptied, an abandoned lend
or a kernel object freed, is not free at once. Under the kernel lock it goes in flight: the
ownership table names it as IN_FLIGHT’s, a reserved owner no process has, so every call that
checks who owns a frame refuses it, and its bit in the bitmap stays clear, so no allocation takes
it. Its budget is uncharged then, as before. It is zeroed later, outside the lock by the hart that
freed it when that hart idles, or under the lock by an allocation that finds the bitmap empty, and
the lock’s next holder, on any hart, gives it back to the bitmap (R81).
So zeroing, the one piece of a frame’s work long enough to matter, leaves the calls that free and
the calls that allocate whenever a hart has idle time to do it, and a call never pays for frames
it did not free or take.
A frame waits for its zeroing on one of two lists the freeing hart keeps:
- Staged, while another hart may still hold a translation to it: the call cleared its entry in a process’s space and has yet to shoot that process down. The list holds the frame’s number, never anything in the frame, since a store through a stale translation could still change it. The call’s shootdown (residual risks) moves the process’s staged frames on; a call that frees more than the 64 a hart stages shoots the process down early, once for every 64.
- Pending, once no hart can store to it: an ended process’s frames (its harts left its space before its frames were freed), a kernel object’s (never mapped), and the staged ones after their shootdown. The list is linked through one word of each frame, the word a destruction already links the object frames it frees through, so that chain joins the list whole.
Pending frames are zeroed in one of two places, and never in a third:
- Idle, when every hart is idle. A hart with nothing to run, when every started hart is idle
too, takes one frame off its pending list while it still holds the lock, gives the lock up,
zeroes it and pushes it onto its done list, the link still in that word. With more left it does
not halt, but goes back through
kmain’s interrupt-enabled top, where an interrupt that came meanwhile is taken and anything woken runs first, and then takes the next. So an idle hart answers an interrupt within one frame’s zeroing, about 30 µs undericount. One hart drains at a time: each frame costs its drainer a release and a take of the lock, and two drainers would find it held at nearly every take, each take a halt and each release the other’s interrupt. At rest after a login on launch’s four harts that was about 33,000 interrupts in two seconds, where alone a drainer finds the lock free. A hart that idles while another drains halts; the drainer, its list empty, wakes the next hart with frames pending once it gives the lock up, and so does a hart that idles last with none of its own, for a hart that halted with frames while another worked. A drainer woken to work drains no more until every hart is idle again, and gives the drain up at its next idle pass if one is not. While any hart is busy, an idle hart halts instead, as before, and a hart with nothing pending halts as it always did. So the system at rest, once drained, takes the interrupts it took before R81 (the machine at rest). - On demand. An allocation that finds the bitmap empty with frames in flight gives back the done frames first, then takes one frame off any hart’s pending list and zeroes it under the lock, as every allocation once did: one frame for each allocation, main’s cost. Only when every frame in flight is one an idle hart is zeroing does it wait, still holding the lock, and then for one frame at most: that hart needs no lock to finish it, and its push wakes the waiter. So a budget can always pay for the frames it can buy, and they always exist, because the frames in flight are RAM no budget is charged for.
- Never at an exit to user mode. The thread a hart returns to did not free those frames, and the caller of a destruction waits for the walk of R10 (destruction), not for the zeroing of the lease it freed: a lease’s 4,300 frames, zeroed there, put 140 ms into the steward’s deadline notice in the containment gate.
Why only when every hart is idle: under icount the harts share one virtual clock, so a hart that
zeroes while another works slows that one’s time as surely as if the work were its own; a refused
search in map-anon-search-bound took 406 µs instead of 116 at two harts while the other hart
zeroed. On a real machine that zeroing would run beside the work for free; that is forgone for now
(residual risks).
A pending list is read and written only under the lock, and the frame an idle hart zeroes only by that hart, so a frame in flight is in exactly one place: staged, pending, being zeroed, done or being given back. The kernel’s checked build counts them, list by list, against the ownership table at each destruction’s audit, so a list lost or linked twice stops the boot. Every acquisition of the lock gives back up to 64 done frames from every hart’s list, clearing each link, so a frame enters the bitmap all zero and the step adds a bounded piece to each kernel section. Pending frames are bounded only by RAM.
Every build, the release one too, keeps a count of the frames in flight and says on the console
when a large drain begins and ends: mem: 1024 frames in flight when the count first reaches
1,024 since it was last 0, and mem: frames in flight 0, at most N when the frame that brings it
back to 0 is given back, N the most it held meanwhile. Both are ordinary kernel lines, under the
console’s hold like the rest (the console’s one writer), and
the common give-back pays one comparison with 0. The bench waits on them before it measures a
system at rest, so that window never holds the zeroing of the boot’s and the login’s frames
(the machine at rest). Why 1,024 frames (4 MiB): on
launch’s machine the boot and the first login leave about 35,000 in flight, the first line coming
during the boot and the second only once every hart first idles after the login, and the shell’s
step after the bench’s window frees about 4,600, while a system at rest frees a page or two every
few seconds, which never reaches it, so at rest the console stays quiet; and a drain under 1,024
frames takes under 10 ms to zero, too short to matter to the window. A pair of lines needs 1,024
frames retired and then a drain back to 0, so the console gets at most two lines for every 4 MiB
freed and drained. What they tell a reader of the console is that some process freed 4 MiB or
more and when the zeroing ended: a timing channel, out of scope
(TENETS). The last frames come back at the lock’s next acquisition,
at rest the next timer interrupt, so the second line can follow the drainer’s last frame by up to
about a second.
The zeroing an idle hart does is the work of whoever freed the frames, so it is billed to them: the budget paying for the kernel time when the frame was retired (the caller, a destroyer, or a deadline’s payer (R10 (destruction))), when the hart takes the lock back, for the frames it zeroed, the newest first as the list gives them; the hart keeps at most four such payers, and what it has not zeroed stays theirs until it does. A frame zeroed on demand is the allocation’s own kernel time, as on every allocation before: it comes off the freeing hart’s payers unbilled, the newest first by count, since a frame does not record its payer, so the split among those payers is by counts, not by frame.
Page tables
Status: built · tested: bench:page-table-reclaim, mutation:R6EmptyTableKept
A page table is made when a mapping first needs it, charged to the budget of the process it
maps into, and freed, with its charge, by the call that leaves it mapping nothing: every entry
empty, with no page, no reservation and neither side of a loan. So unmap frees the tables its
range empties, and so do a failed map_anon’s rollback, process_map in its source, and a
transfer in its sender. A lend’s tables in the server go at the reply that returns it; an
abandoned lend’s go from the caller when the call is abandoned, and from the server at the reply
that frees the lend. A call frees tables once it has finished, never between making a table and
filling it. It reads at most ENTRIES entries for each table the range reaches, so the cost
follows the pages it unmapped (R22). The root table goes only with its
process.
Where map_anon puts pages
Status: built · tested (4)
- bench:map-anon-search-bound
- bench:map-anon-placement
- bench:map-fixed-attack
- bench:touch-beyond-ram
The kernel chooses the address, and nothing may depend on it. map_anon takes the first free
run of pages in its placement area, 256 MiB from DEFAULT_BASE (0x6000_0000 to 0x7000_0000),
searching from the start of the run it placed last to the last start that fits, then from the
area’s start. Every start up to the last one that fits is tried, so a run that fits only at the
area’s end is found, and so is the whole of an empty area; every run placed lies inside the
area. A page is free only if its entry is empty: a reservation or either side of a loan is
taken. A request that finds no run, or is larger than the area, is OutOfMemory. The search
looks at each page of the area at most once (R22).
map_device and dma_alloc place their mappings the same way (devices). A
receiver’s lends and transfers land in a second area, 4 MiB from DEFAULT_MESSAGE_BASE
(0x4000_0000), found the same way. The rest of the user layout is on
memory layout.
The model places runs in the same two areas, searched the same way, so the two agree on addresses as well as outcomes.
map_fixed
Status: built · tested (7)
- bench:map-fixed-attack
- bench:map-fixed-tables
- bench:map-fixed-tables-rv32
- bench:return-lent-unmapped
- host:redoubt-model::bad_ranges_are_refused
- host:redoubt-model::partial_overlap_is_refused_whole
- host:redoubt-model::page_tables_half_of_the_charge_check
map_fixed(addr, len, flags) maps zeroed pages at exactly addr in the caller’s own address
space, charged as map_anon’s are. It is the one call that puts new pages at an address the
caller names, so that the loader stub can place a program’s segments at
their link addresses from inside the process it is loading.
Unlike POSIX MAP_FIXED it never replaces a mapping. A range that touches any occupied
entry of the caller’s (a mapping, a reservation, either side of a loan) is InvalidArgument,
and nothing is mapped. The checks run in this order, all before anything is allocated:
- the range: aligned, non-empty, no overflow, below
USER_AREA_END(page 0 is user space); - the flags;
- the pages alone against the budget, by arithmetic;
- the overlap, over the whole range;
- the pages and every page table the range still lacks, counted across table boundaries.
Only then does the mapping loop run, and it cannot fail. So a map_fixed maps everything it
was asked or nothing, and a refused one charges nothing.
Instruction fetch after mapping
Status: built · partly tested: no case can see a missing fence.i, because QEMU keeps instruction fetch coherent with stores; smp-fence shows from the trace that the other hart fenced · tested: bench:smp-fence
A hart may fetch stale instructions from a page just written unless it fences. After any call
that installs an executable mapping (map_anon, map_fixed, set_flags or process_map
with EXECUTE), the kernel runs fence.i, so the hart’s later fetches see every store it made
before. It runs one more at boot, before the first process, for the images the loader wrote.
W^X makes one fence per call enough: a page is written while it is writable and not
executable, and becomes executable only through one of these calls. On several harts the call
also shoots the process down on every other hart running it, and that hart runs fence.i before
it acknowledges; a hart also runs fence.i before it runs a process. A thread that moves between
harts needs no fence of its own: a RAM frame has at most one user entry, so a process’s code
changes only through these calls.
Lending at the page-table level
Status: built · tested (9)
- bench:process-lifecycle
- bench:return-lent-unmapped
- bench:ipc-outcomes
- bench:map-fixed-attack
- bench:move-borrowed-page
- bench:uaf-lent-page
- bench:lend-untouched-page
- bench:smp-shootdown
- mutation:R11LendStaysMapped
A lend or a transfer is page-table edits:
- When the message is sent, each page is checked first: backed (a reserved page is backed at
this point), the sender’s own RAM, and for a lend writable. Then each is taken out of the
sender: its entry keeps the frame, loses
VALIDand gains the lent bitS(a software bit of the entry; memory layout). That entry is the loan’s only record. - When a receiver takes it, the frames are mapped read-write in the receiver’s message
area. A lend’s entries there carry
Stoo, marking them as the borrower’s side. - At the reply the kernel checks that the borrower’s entry and the lender’s entry name the same frame, clears the borrower’s, and makes the lender’s valid again. A message refused or timed out while still queued was never mapped in a receiver: the lender’s entries are simply made valid again.
- A transfer’s sender entry is cleared when the receiver takes it, and the frame’s owner and payer change together (R4 (delivery)). A lend whose call is abandoned goes the same way to the server (R3 (lends and abandoned calls)).
Only these steps of the kernel’s own (the return, a transfer’s delivery, an abandonment)
touch an entry with S. unmap, set_flags, map_fixed and map_anon’s search treat both
sides of a loan as occupied, so the lender cannot unmap, remap or re-flag a lent page, and the
borrower cannot unmap or re-flag its side. A borrowed page stays
credited to its lender, so the borrower cannot lend it on, transfer it or process_map it. The
kernel never reads or writes a call’s record in a borrowed page. When caller and server are the
same process, both sides of the loan sit in one address space, and the return still checks both
markers and the frame before changing either.
A page’s life
Status: built · tested (7)
- bench:device
- bench:ipc-outcomes
- bench:lend-untouched-page
- bench:uaf-lent-page
- bench:process-attack
- bench:dma-rules
- bench:dma-reset-reuse
A RAM frame is taken from the free-frame bitmap and given back to it, so backing a page, a page
table or an object searches nothing, however much of RAM is in use; a dma_alloc run comes from the
DMA pool instead, a fixed 1024 pages (devices).
stateDiagram-v2
state "DMA-held" as DMA
state "In flight" as Flight
[*] --> Free: free at boot<br/>(zeroed when first taken)
Free --> Mapped: map_anon, map_fixed,<br/>first touch of a reservation
Mapped --> Flight: unmap, a reply to an<br/>abandoned call, or the process ends
Flight --> Free: zeroed (its hart idle, or an<br/>allocation), then given back
Mapped --> Mapped: process_map<br/>(moves to the child)
Mapped --> Lent: call with a lend<br/>(unmapped from the caller)
Lent --> Mapped: reply, or the call fails<br/>(back to the caller)
Lent --> Mapped: call abandoned<br/>(the server's own)
Mapped --> Transferred: send with a transfer<br/>(unmapped from the sender)
Transferred --> Mapped: taken (the receiver's), or<br/>the send fails (the sender's)
state "DMA pool" as Pool
[*] --> Pool: taken at boot
Pool --> DMA: dma_alloc (zeroed first)
DMA --> Pool: its process ended and every<br/>device that could hold it was reset
Figure: the states of a RAM frame. A frame is mapped by at most one process at a time, and is zero whenever it leaves Free or the DMA pool: zeroed in flight, on its way back, or at its first allocation if it has been free since boot. A frame in flight is no one’s and not free (R81). A pool frame is never Free, and a Free frame is never DMA-held.
Authority
Status: built · partly tested: that no call names a physical frame is argued from the call table, not attacked · tested (4)
- bench:process-attack
- bench:device
- bench:ipc-outcomes
- bench:map-fixed-attack
- A process maps only into its own address space.
map_anon,map_fixed,unmapandset_flagsact on the caller’s own pages; none names another process. process_mapneeds a process handle to a child that has not started. A started process is closed to its parent: nothing can be slipped into a process that is already running.- Pages come from the budget. Every frame and page table is charged to a budget, so a process can map no more than its budget allows (budgets).
- No call names a physical frame. There is no call that maps RAM by physical address, so holding memory never gives a way to reach anyone else’s.
- The memory calls create no authority: they change pages the caller already has, or move them to a child it already holds.
Security properties
R11 (memory)
Status: built · partly tested: that a frame freed with data in it comes back zero is attacked by `smp-inflight-race`'s witness, which no case ties to the frame another process filled ([above](#backing-and-zeroing)); the absence of any physical-address argument is argued from the call table, not attacked; that the zeroes stay is shown only as user cache-block operations trapping, since QEMU models no cache · tested (19)
- bench:wx
- bench:write-only-attack
- bench:map-fixed-attack
- bench:device
- bench:mem-attack
- bench:process-attack
- bench:return-lent-unmapped
- bench:dma-rules
- bench:dma-reset-reuse
- bench:device-exec-refused
- bench:cbo-user-fault
- bench:smp-inflight-race
- mutation:R11NoZeroing
- mutation:R11SetFlagsAllowsWx
- mutation:R11SetFlagsAllowsWriteOnly
- mutation:R11LendStaysMapped
- mutation:R11MapFixedSkipsOverlap
- mutation:R11ExecOnDeviceMemory
- mutation:R11ProcessMapSkipsFlags
- No RAM page is ever mapped writable and executable (W^X): not by one
entry, and not by two, since a RAM frame has at most one user entry at a time (the kernel’s
physmap aside). W+X is refused when any call is decoded (
WRITEwithEXECUTE, or an unknown bit, isInvalidArgument), again bymap_fixedandprocess_mapbefore they charge, and again by the page-table layer, which refuses to build such an entry, so no check rests on another.set_flagsmay addEXECUTEonly to a page it makes not writable in the same call. - W^X holds per frame, not only per mapping. Only RAM that a process owns is ever
executable. Device registers and DMA frames are never mapped executable: the device, or
another mapping of the same registers (in this process or a co-holder’s, since a mapping
outlives its handle), can write them underneath.
map_deviceanddma_allocmap read-write and never executable,set_flagsrefusesEXECUTEon either, andprocess_maprefuses device and DMA pages. - Writable implies readable. The privileged architecture reserves the write-only entry, so
map_anon,map_fixed,set_flagsandprocess_maprefuseWRITEwithoutREAD. - Flags are checked before anything is charged or moved for the new mapping, so a step
that cannot fail never meets bad flags.
process_mapchecks them before it backs any page of its source. - Every page is zero before a process first sees it: anonymous and fixed pages, backed
reservations, page tables, and
dma_allocpages. Every free frame is zero, by R81, and a frame free since boot is zeroed when first taken, so backing a page writes nothing to it;dma_allocpages are zeroed as they leave the pool. Pages moved by lend, transfer orprocess_mapcarry their contents, because moving them is the point. - The zeroes stay.
senvcfgis 0 on every hart and the firmware turns supervisorcbo.invalinto a flush, so no process can discard the zeroes the kernel wrote to its page and read what the frame held before (above). - No physical addresses. No call takes one, and none maps RAM by one:
map_devicemaps a device object’s registers, and the boot refuses any device object that overlaps RAM. The one physical address a process learns is that of its owndma_allocpages, which its device needs (devices). - DMA pages are held until reset. A
dma_allocpage stays with the process that allocated it until that process ends:unmapdrops only its mapping, and it is never lent, transferred orprocess_mapped. It returns to the pool only after every DMA device that could hold its address has confirmed a reset, and a quarantined device never counts as reset (devices, I16 (DMA pages reset before reuse)). - A lent page is unmapped from its lender until the call ends (above).
map_fixednever replaces a mapping, so it can neither discard a page nor alias one.
Together these are I9 (pages W^X, zeroed, lends unmapped) of the invariants.
R81 (frames in flight)
Status: built · partly tested: QEMU drops a hart's TLB at its next `satp` write, so the order that keeps a stale store out of a zeroed frame is checked from the trace, not seen as a store landing; an allocation that finds the bitmap empty while frames are in flight (it gives back the done ones, zeroes a pending one or waits for the hart zeroing its list) runs in no case, since no case fills RAM while it frees · tested (3)
- bench:smp-inflight-race
- mutation:R81InFlightAllocatable
- mutation:R81CommitUnzeroed
A freed RAM frame is in flight until it is zero: it belongs to no process and is not free.
- No one can name it. Its owner in the ownership table is
IN_FLIGHT, which no process is, so every call that frees, lends, transfers orprocess_maps a frame refuses it, as it refuses a frame of another process’s; and no call names a physical frame (authority). - No allocation takes it. Its bit in the free-frame bitmap is clear until it is given back, and every frame a mapping, a page table or an object gets comes from the bitmap.
- One hart writes it at a time, and only once no other can. Its number is staged until the shootdown of the process whose entry for it was cleared has been acknowledged by every hart asked; only then is it linked through a word of its own, and later zeroed, by the hart that freed it while that hart idles, or under the lock by an allocation that took it off that hart’s list. Pending lists change only under the lock, and an idle hart zeroes only the frame it took off its own, so no two harts hold one frame. A frame of an ended process, whose harts left its space first, or of a kernel object, never mapped, is linked at once.
- It is given back only zero, and only under the lock, by the lock’s next holder, which clears the link it held.
So every free frame is zero and allocation writes nothing. The kernel’s checked build samples
every frame it takes from the bitmap and counts the frames in flight against the ownership table
in its destruction audit (I1 (handles name live objects)); smp-inflight-race’s kernel checks each frame it gives back word
by word, and its trace shows the order above for every frame
(the frames in flight). In the model, a free puts the frame in flight with
what it held, and time passing zeroes and frees it; a frame is held, in flight or free, never two,
and a free frame is zero (the model).
R19 (kernel W^X)
Status: built · partly tested: no case plants a writable kernel code page to show that the check stops the boot · tested: bench:kernel-wx
The kernel’s own mappings are W^X. At boot, before any process runs, the kernel walks its own
area of the address space and, for every executable page there, checks three things: the page
is not writable, its frame’s alias in the physmap is not writable, and that alias is not
executable. The check reads only the aliases of executable kernel frames; it does not walk the
rest of the physmap, whose entries the loader builds never executable. If one fails, the kernel
panics instead of running. It then prints W^X verified: N executable kernel pages, none writable under any alias, the line the case checks. Every mapping the kernel makes
later goes through the same entry constructor that refuses W+X; its one later window, for DMA
device registers, is read-write and never executable.
R22 (range cost)
Status: built · tested: bench:map-fixed-attack, bench:map-anon-search-bound, host:redoubt-model::huge_len_is_refused_promptly
A call that takes a range costs what the page tables hold and what the budget can pay for, never what the length asks. A process could otherwise ask for a huge range for free, and the kernel, running the call to its end with interrupts off, would stall every other process.
unmap,set_flagsandprocess_mapcheck the range page by page and stop at the first page that is not the caller’s, so their cost follows what is mapped there.map_fixedfirst refuses a range the budget cannot pay for by arithmetic alone, before any walk. It then checks overlap by walking the page-table tree and skipping every absent subtree whole: at most the root entries the range spans, plusENTRIES(512 on Sv39, 1024 on Sv32) for each table present in it, and only then counts page tables. Amap_fixedof the whole of user space, which no budget in the case can pay for, is refused well inside the case’s 10 ms bound, over mappings or not.- A lend or transfer checks at most the pages mapped in its range, and a lend is at most
MAX_LEND_PAGES(16). map_anon’s search, which also placesmap_device,dma_allocand a received message, never tests a start twice: when a page is taken, the next start tried is the page after it, and a missing page table skips its whole span. So it looks at each page of its fixed area at most once, 65536 formap_anon’s and 1024 for the message area, whatever the request asks. With every page table of the area present, the worst case, a refusal takes about 9.6 ms in a checked build under virtual time; the case bounds it at 12 ms, and a timer wake during it at 15 ms.
Failure and restart
Status: built · tested (8)
- bench:touch-beyond-ram
- bench:lend-untouched-page
- bench:wx
- bench:uaf-lent-page
- bench:map-fixed-attack
- bench:process-map-untouched-attack
- bench:return-lent-unmapped
- bench:fault-report-bound
- Out of memory is the caller’s error. A mapping call that cannot be paid for returns
OutOfMemory; a process that exhausts RAM getsOutOfMemoryand every other process keeps running. A refusedmap_fixedcharges nothing, a refusedprocess_mapcharges neither the child nor the caller, and a refusedmap_anongives back every page and page table it took. - A permission fault ends the process. A store to a page that is not writable, or a fetch
from one that is not executable, is never mistaken for a page to back. The process faults,
and its exit notice carries the RISC-V cause (12 for an instruction page fault, 15 for a store
page fault; processes). The console gets one line,
PROGRAM HALT: CPU Exception on PID n: <cause> of <address> at <pc>, printed holding the kernel lock; the thread’s registers and the address space’s map, a line a mapped page, are printed only for a fault in the kernel and in adebug-printbuild. Printed for every fault, they held the lock for as long as the process was large: 837 ms on rv64 for a process of 4096 pages, against 7.5 ms for its teardown now (bench:fault-report-bound, undericount). - A process ends: every frame it owns returns to the pool and its charge to its budget. A
page it had lent is its server’s until the server replies (R3), and is never reused while the
server has it mapped. Its
dma_allocpages wait for the device reset (R11). - No argument to any memory call makes the kernel panic (I14 (no call panics the kernel)). Lending untouched or half-mapped ranges, unmapping a lent page, and exhausting RAM are each attacked by a case that passes only on a clean power-off.
Residual risks
map_fixedcan fillmap_anon’s area. A process that maps the whole area withmap_fixedmakes its own latermap_anoncalls fail withOutOfMemory. It harms only that process. The same for the runtime’s thread-stack area (memory layout): amap_fixedof the program’s own there takes a slot, or a guard page, from its own laterspawn, which is then refused.- Several harts.
fence.iand the TLB flush act on the hart that runs the call. A process’s translations carry its ASID, and a hart that ran it may keep them; it flushes that ASID before it next runs the process if any of its mappings were removed meanwhile or its PID was given out again. Every call that clears or narrows one of a process’s entries, frees one of its tables or makes one executable first shoots the process down on each other hart running it now, which flushes its ASID, runsfence.iand acknowledges before the call returns; a destruction’s shootdown also makes the hart leave the process’s space before any of its frames is freed. The asking hart waits for the acknowledgements halted, and each hart sends it an interrupt as it acknowledges, as a hart waits for the kernel lock (R78 (fair kernel entry)): spinning, under QEMU’sicountit spent the turns the other hart needed to acknowledge, and the p99 of a destruction’s kernel time (R10 (destruction)) insched-latencyat two harts was 51.9 ms on rv64 against 3.0 ms halted. The asking hart holds the kernel lock while it waits: measured undericountthe waits total 0.9 ms over the whole containment gate at two harts, so they stay under it, where the asker is the lock’s one holder. So no stale translation reaches a page unmapped, lent or returned, or a frame’s next owner. A missed shootdown cannot be seen on QEMU, which empties a hart’s TLB at everysatpwrite: the checked build stops when a process loses an entry while another hart runs it and is not shot down there (bench:smp-shootdown, the case’s recorded negative), andsmp-fencechecks from the trace that the fence was taken. - The physmap maps every user frame writable for the kernel, code included. Only the kernel can use that alias (memory layout).
- A freed frame may still be mapped on another hart. A free writes nothing into the
frame until the shootdown that clears its last translation is acknowledged: the frame waits
staged by number, and is linked through a word of its own, then zeroed, only after
(R81). So a stale mapping could reach only a frame no one has written
since, never the kernel’s link nor its next owner’s data. Every
path that frees a mapped frame unmaps it and flushes the TLB first, or frees an ended
process’s frames or a refused
process_create’s, whose cached translations carry that process’s ASID, which nothing runs under again until the PID is given out, and that flushes it first (satp); on one hart that leaves no stale mapping. On several, the call shoots the process down on any other hart running it before the frame can be given out again, and a destruction before the free (above,bench:smp-evict,bench:smp-shootdown). - Freed frames are not zeroed beside the work. An idle hart zeroes frames in flight only when
every hart is idle (frames in flight), because the bench runs harts under
icount, whose one virtual clock bills a working hart for another’s zeroing. On a real machine an idle hart could zero while others work, at no cost to them; while any hart is busy, frames are zeroed on demand instead, one per allocation under the lock, as before R81. To revisit when the bench can judge an idle hart’s work without that shared clock, assched-lock-contention-4-mttcgjudges the four-hart tail withouticount. So R81 saves nothing on a busy machine: in the containment gate, at two harts, no hart is idle while another works and 576 MiB never runs short, so allocations zero never-used frames at their first allocation, as before, and frames in flight reach about 79,500 (about 310 MiB), nearly all held to the end of the run, the longest given back 113 s after it was freed. A process that keeps one hart busy keeps every allocation at that cost, never worse than before R81. Meanwhile a freed frame’s old contents stay in RAM, though no call names the frame and no process maps it.
Why
- Back at map time, not on first touch. A process told it has memory, which then faults for
want of it, has been told a lie.
map_anoncharges and backs every page before it returns, so a process’s memory is its own the moment it has the address. Only the loader-started stacks are reserved, because their programs have no parent to map them. - Zero on reclaim, when the harts are idle. A freed frame is zeroed outside the kernel lock by
the hart that freed it when every hart idles, or under the lock by an allocation that needs it,
and the bitmap holds only zero frames, so no path that hands a frame to a process can forget to
zero it. An allocation writes nothing when idle time has zeroed ahead of it; under load, with no
idle time, it zeroes its own frame under the lock as before R81 (a frame never used since boot,
or one in flight), so it is never worse than before. No hart zeroes on its way back to user
mode: that taxes whatever runs next for frames someone else freed, and a lease’s 4,300 frames,
zeroed there, put 140 ms into the steward’s deadline notice in the containment gate. Measured at
two harts under
icountbefore R81, zeroing was the one piece of a frame’s work long enough to matter: 220 µs a page while it checked every word, about a fifth of that after, and 15 s of the containment gate went to zeroing in 68,218 endpoint creations. The other way, zeroing at allocation outside the lock, splits every mapping call in two around a second wait for the lock, and the second must find its process alive and its range still free; zeroing on reclaim needs neither, since a freed frame is already no one’s. Frames free since boot are zeroed at their first allocation, under the lock, rather than all at boot, which would cost seconds of boot undericountfor the RAM the cases give. - The kernel chooses addresses, so no program depends on a layout and no call lands on
another mapping.
map_fixedexists because a program’s segments must sit at their link addresses and the loader stub, running inside the new process, is the only code that parses its ELF. The other ways were worse: mapping the segments from the parent would make the launcher parse ELF images, and relocatable programs would differ from every other binary’s fixed-address link.map_fixednever replaces, so it cannot discard or alias a page. - The lender’s entry is the loan’s record. There is no side table to fall out of step. The
Sbit makes the entry one that only the kernel’s own lend steps may overwrite, and the return checks both sides against each other first. - Flags checked more than once, and before charging. Decoding and the page-table layer
each refuse W+X, and
map_fixedandprocess_mapcheck again, so a slip in one leaves the others. Checking before anything is charged means the steps after it cannot fail, so a bad combination is a cleanInvalidArgument, never a half-done call or a kernel panic. - Cost follows occupancy. The kernel does not preempt itself, so every loop in a call must be bounded by something the caller paid for: pages it has mapped, or pages its budget can buy.
- One fence per executable mapping. A frame reused from another process may still sit in a hart’s instruction cache. That is a correctness hazard, not a privilege one, since the code runs in the new owner’s context, and the fence each executable mapping gets on this hart clears it.
Budgets
A budget is the kernel object that holds resources. It has a page limit, a process limit and a CPU weight, a class, a label set, an account and an optional deadline. Every process runs in one budget, and every kernel object is charged to one. Budgets form a tree: a child’s limits are carved out of its parent’s, so the children never add up to more than the parent. Destroying a budget destroys everything below it, kills the processes in it and closes every handle stamped with it, wherever the copies went. A budget whose deadline passes is destroyed the same way.
Purpose
One object does five jobs that Unix spreads over cgroups, revocation lists, security labels, accounts and the scheduler:
- accounting: every kernel object costs pages from a budget, and running out is an error for the caller, never for anyone else;
- CPU: its free weight is its share of the one stride queue (scheduling);
- revocation: destroying it revokes every handle stamped with it or a descendant (objects);
- information flow: its label set decides whom it may talk to (R1 (flow), IPC);
- identity for servers: its account travels with every message it sends.
The steward builds a budget per principal, per label set, per session and per lease, and ends any of them by destroying it. A lease is a budget with a deadline, so the kernel ends it on time even if the steward is busy or gone.
Interface
The budget object
Status: built · tested (4)
- bench:budget
- bench:budget-destroy-attack
- bench:process-attack
- mutation:ProcessInWeightlessBudget
| Field | What it holds |
|---|---|
id | a u64 shared with endpoints’ ids, never reused (I12 (ids never reused)) |
parent | the budget it was carved from; none only for root |
depth | 0 for root; always below MAX_DEPTH (16: the levels a tree may have) |
class | system or user, its parent’s; see Class is trust, not order |
labels | a sorted set of u64 labels, at most MAX_LABELS (16), fixed at creation, containing all of its parent’s |
account | the principal it bills to, a u64; 0 for none (R8) |
deadline | microseconds since boot at which the kernel destroys it; FOREVER (u64::MAX) for none |
pages | limit and usage, in pages (R6) |
processes | limit and usage: the PIDs held by processes that run or ran in it, those moved to it from destroyed children, and its children’s process limits (R6) |
weight | limit and carved: what its children took (R7) |
dying | set on the whole subtree while R10 destroys it |
The free weight is the weight limit less the carved weight. It is the budget’s stride weight
(R12 (scheduling), scheduling), so a budget with free weight 0
holds no process: process_create into one gets InvalidArgument. A budget also carries its
place in the stride queue, which scheduling describes.
A budget with zero limits is a revocation scope: nothing runs in it, and it exists to be a stamp and later destroyed, so one grant can be revoked without killing any process (objects). It costs its parent one page, like any budget.
Each budget occupies one page of its own, allocated when it is created: the page its parent pays for. So there is no kernel table of budgets for one budget to exhaust at another’s expense. The page starts with a fixed magic word; a frame read as a budget that does not hold one means a stale reference survived destruction, and the kernel stops rather than trust it (I1 (handles name live objects)). A handle names a budget by its page and its id, so a handle to a destroyed budget never reaches a later one in the same page.
A budget lives until it or an ancestor is destroyed or its deadline passes. Closing the last handle to it does not destroy it: its carve stays out of its parent (see Residual risks).
The calls
Status: built · tested (7)
- bench:budget
- bench:budget-syscall-attack
- bench:budget-forge-attack
- bench:budget-reap
- host:redoubt-sys::every_call_round_trips
- host:redoubt-sys::malformed_calls_are_refused
- host:redoubt-model::a_reap_destroys_one_child_and_keeps_the_budget
| Call | Arguments -> result | What it does |
|---|---|---|
budget_create | parent budget handle, spec record -> budget handle | Carve a child from the parent. The spec is (pages, processes, weight, labels, account, deadline) in BUDGET_SPEC_SLOTS (22) slots: three limits, a label count and MAX_LABELS label slots, the account and the deadline. |
budget_destroy | budget handle | Destroy the budget and everything below it (R10). If the caller runs in that subtree, or its process object is charged to a budget in it, the call never returns. |
budget_reap | budget handle -> remaining | Destroy the budget’s first child and everything below it (R10), keep the budget, and return how many children it still has. If the caller runs in that child’s subtree, or its process object is charged to a budget in it, the call never returns. |
budget_usage | budget handle, usage record | Write the budget’s limits and usage (budget_usage). |
The spec has no class and no scheduling flag: a child’s class is its parent’s, and what runs
first is a matter of weight alone. budget_create’s own checks run in this order: the parent
handle (BadHandle, WrongObject); depth (TooLarge if the child would be at MAX_DEPTH); the
labels (LabelDenied, then ClassDenied); pages, counting the child’s own page
(OutOfMemory); processes (OutOfProcesses); weight (InvalidArgument: no error names weight).
The new handle is stamped with the caller’s budget (R9 (stamps)). If the caller’s handle table
cannot take it (TooLarge at MAX_HANDLES, or OutOfMemory for a new table page), the child is
undone and the parent is as it was.
budget_destroy and budget_reap fail only on their handle (BadHandle, WrongObject). Record
checks come first for every call; the full rows are in the
ABI reference.
The kernel itself (PID 1) has no budget and makes no calls. A call from it would get
InvalidArgument from a call that passes a record (the record check comes first, and no user
page is the kernel’s), NotPermitted from most others, BadHandle from handle_close,
budget_destroy and budget_reap, and an answer from time_now and random; none of them
panics, so a bug there cannot become one.
Root, system and users
Status: built · partly tested: no case checks the weights or `INIT_WEIGHT` · tested (5)
- bench:budget
- bench:budget-destroy-kills
- bench:process-attack
- bench:pages-exhaustion
- bench:bundle-mapped
At boot the kernel creates three budgets, all with account 0, no labels and no deadline:
| Budget | Class | Pages | Processes | Weight |
|---|---|---|---|---|
root | system | every RAM page the kernel did not keep for itself or for the DMA pool, less root’s own page | 510 (every PID but the kernel’s) | ROOT_WEIGHT (1,000,000) |
system | system | a quarter of what root does not keep for init | 127 (a quarter) | 250,000 (a quarter) |
users | user | the rest, less the two budgets’ own pages | 382 (the rest, less init’s) | 749,000 |
root pays for the two budgets’ own pages and carves the rest of its pages and processes into
them, but what it keeps for init: one process, INIT_WEIGHT (1,000) of its weight, because a
budget that holds a process needs free weight, and init’s pages
(below): everything the loader gave init, its first
thread and INIT_PAGES (1,024). By R6 (charging) root’s own page is charged to root, so its
limit is the free frames less that page. The boot checks that root’s limit, its own page and
the kernel’s frames fit in RAM, and stops if they do not.
init, the one program the loader starts, runs in root, charged there for everything the
loader gave it (image, stack, page tables, header page, the bundle’s frames) and for its first
thread. It gets handles to root, system and users in slots 1 to 3, stamped with root,
then a handle to every device object (boot). The machine’s device objects are charged
to system. A boot whose init and INIT_PAGES do not fit does not boot: the kernel stops
(fail closed).
flowchart TD
R["root<br/>class system, 510 processes<br/>weight 1,000,000, keeps 1,000 free"]
S["system<br/>class system<br/>a quarter of the pages, 15 processes<br/>weight 250,000"]
U["users<br/>class user<br/>the rest, 47 processes<br/>weight 749,000"]
I[init]
SV["servers and drivers<br/>(manifest weights)"]
P["principals, label sets,<br/>sessions and leases"]
R --> S
R --> U
I -- runs in --> R
SV -- carved from --> S
P -. carved from .-> U
Figure: the budget tree at boot. Solid: built. Dashed: planned, once the steward carves the principals’ budgets.
The tree from the boot manifest
Status: built · tested (11)
- bench:init-boot
- bench:steward-boot
- bench:init-servers
- bench:init-refuses-system-fit
- bench:init-refuses-bound
- host:redoubt-init::servers_that_do_not_fit_in_system_are_refused
- host:redoubt-init::the_image_manifest_s_bound
- host:redoubt-init::an_image_and_a_stack_larger_than_a_batch_count_one_batch
- host:redoubt-init::a_manifest_that_passes_every_other_check_but_costs_init_too_much_is_refused
- host:redoubt-init::the_bound_uses_the_largest_declared_stack_batch
- host:redoubt-init::a_server_heap_cap_is_optional_and_fits_its_budget_beside_its_stack
The loader starts only init, which runs in root on the weight and the one process root
keeps free. What init launches counts in the budgets it launches into.
A server’s heap cap (heap_pages, init) is its runtime’s
own ceiling inside its budget: past it the runtime refuses an allocation before the kernel is
asked, and the cap reserves nothing until it is used. The budget stays the hard bound on
everything else the server holds: its image, its stacks, its buffers and lends, its page tables
and its kernel objects.
-
The kernel keeps its fixed split, the table above. The boot manifest does not size
system: the kernel reads no manifest and the loader parses no JSON, so the split is set in one place, the kernel.initadds up what the manifest’s servers ask for and refuses the boot if it does not fit insystem, before it starts anything (init). -
INIT_PAGESisinit’s working set, with room to spare. Everythinginituses is charged toroot:- its first thread’s stack: 32 pages reserved by the loader, with one page initially backed;
- its heap, one fixed arena mapped once, for the manifest and the startup blocks it builds;
- a handle table of up to 64 pages;
- a page for each server endpoint it owns;
- a thread for each server, watching its exit endpoint: the thread’s stack and IPC page;
- a process object for each process it starts (at most
system’s process limit); - one batch of the program it is starting, at most 64 pages of its image and 64 of its stack at a time, copied through its pages and moved to the child (the loader stub).
Each child’s first-thread stack is charged to that server’s own budget, at the manifest’s
stack_pagessize (the boot manifest).With the steward,
sshdand the userland disk’sblkd,verityanderofsd, the image manifest’s bound is 564 pages on both widths (init-bootprints it; 14 of them the 16-page lendinitpushes the public entries through), and the fixed 1,024-page limit is nearly twice it. The image’s servers need 17,448 ofsystem‘s pages, each budget and its own page (the steward’s 1,051 among them: 1,024, the three pages of its cleanup thread and the twenty-four of its launch thread and the loop’s wider stack, the steward). The shell’s VMs are not among them: the steward carves each session from its principal’s sub-budget underusers, from the top budgets the manifest names, 47,624 pages for Alice, 32,768 for Bob and 24,576 for agent (the steward, the image). A session is 11,392 pages, its VM’s share and its console relay’s. The VM’s is 11,264 pages: a heap cap of twice the largest peak of beamlet’s runtime heap across the memory cases plus what the VM holds outside its heap, 44 pages (its two schedulers’ stacks and thread pages, its locks’ endpoints and its process’s page), rounded up to 128 (beamlet), from a largest peak of 5,556 pages on rv64 (at one hart, with the shell’s machine mode beside the agent principal; 5,542 with the machine mode alone, 5,523 before it) and 5,362 on rv32, which the cap, 11,220 pages, holds twice with 108 to spare. It was 11,136 from a peak of 5,492, 11,008 with one scheduler, 19 pages outside the heap, and 11,904 while the shell loaded every command’s module at its start, when the peak was 5,904. The scan’s cap rule bounds it from below: an rv64 peak above 5,610 pages puts the cap under twice the peak, and the share then moves up a step of 128. The relay’s is 128 pages: its image (about 21 pages on rv64), its four stacks (5, 4, 4 and 4 pages), a heap cap of twice its 22-page peak (consrelay-footprint), and what the kernel charges for a process, rounded up to 128 (consrelay). The steward gives the VM the session’s pages less the relay’s, as its startup block’sbudget_pagesand its heap cap, so the relay’s room is never the VM’s. The console session has no relay and its VM the same share. Each sub-budget still holds two sessions, 22,786 pages with their budgets’ own pages: Alice’s top budget splits into two of about 23,800 pages, one for her unlabelled sessions (the console’s and one SSH context) and one for her vault’s, and Bob’s one sub-budget of about 32,760 holds two, as before, but not three. A session holds ten processes: its VM, its relay, its pipe serverpipedand up to seven stages (native programs).pipedand the stages draw for now on the VM’s share, inside the headroom twice its peak leaves; they are to get their own measured share the way the relay has. The bench andmkimage’s instructions give the image 1 GiB (the image). The bound is a fixed count, not a share of RAM, becauseinit’s needs do not grow with the machine, nor with the size of a program it starts, and a share of a large machine would sit idle inroot. The manifest cannot change it, because the kernel reads no manifest.initworks in a fixed arena, and before it creates anything it bounds what the manifest will cost it inroot: the endpoints it makes, a process object, a startup block and a thread watching its exit endpoint (the thread’s IPC page and stack) for each server, the handles it keeps and mints, and the arena. It readsroot’s free pages withbudget_usageand refuses the boot if the bound is larger; a charge that fails later is a bug in the bound, and refuses the boot too, so no boot runs half started. The number changes only in the kernel, with a stated reason. A tester ininit’s place (test bench) works within the same allowance. A kernel case whose first program needs more works in a budget it carves fromsystem. -
The weights stay.
ROOT_WEIGHTis 1,000,000 andINIT_WEIGHTis 1,000.init’s free weight inrootis a driver’s, and the rest is split betweensystem(250,000) andusers(749,000). Only the budgets that hold processes compete, so what matters is how their weights compare: 1,000 forinit, the steward and the drivers, an ordinary weight for other servers, and 100 for a session. Each server runs in a budget of its own, so no program takes a shared free weight by yielding in a busy loop.
init carves each server’s budget from system with the pages, processes and weight its
manifest entry names. Only init and the steward ever hold a handle to a system-class budget,
and a manifest that grants a server a budget handle is refused (init). The
steward holds users and carves each principal’s budget from it, then a fixed sub-budget per
label set (steward).
Class is trust, not order
Status: built · partly tested: that scheduling ignores class is argued from the code, not attacked · tested (5)
- bench:process-attack
- bench:budget
- mutation:ClassNotInherited
- mutation:LabelsAddedByParentClass
- mutation:R1UsageExemptBySystemTarget
A budget’s class is its parent’s, fixed at creation; budget_create takes no class argument
(I8 (class and account inherited)). root and system are class system; users and everything
below it are class user. Class decides three things and nothing else:
- a message to or from a
system-class budget, and an exit notice into one, is not label-checked by the kernel (R1); - a
system-class caller ofbudget_usageis not label-checked (the caller’s class decides, not the target’s); - only a
system-class caller may create a child with more labels than its parent (ClassDenied). The caller’s class decides, not the parent’s.
Scheduling never reads class. Every budget is in one stride queue, and what matters is weight:
init, the steward and the drivers get large weights instead of running first
(scheduling).
Because class is inherited, a handle to a system-class budget is the authority to create more of
them, with any labels and any account the rules allow, and to run processes in them. The kernel
does not look at the holder’s class for that. What protects system budgets is that their
handles are never handed to anything but init and the steward (see Residual risks).
Labels on budgets
Status: built · tested: bench:budget, bench:process-attack, mutation:LabelsAddedByParentClass
A budget’s labels are fixed when it is created. The kernel sorts the spec’s labels and drops repeats, then checks them:
- they must include all of the parent’s, or the call gets
LabelDenied: labels only grow downward (I6 (labels only grow downward)), so no child can shed a label its parent carries; - if they add any, the caller’s own budget must be class
system, or the call getsClassDenied. Auser-class process can only make children with its parent’s exact label set.
A spec whose label count is above MAX_LABELS does not decode (TooLarge). What a label means,
and who owns it, is the servers’ business (labels); the kernel sees
a number, and carries the sender budget’s label set on every message.
Deadlines
Status: built · partly tested: a process that enters the kernel in a tight loop to put a deadline off is not attacked by a case; the destruction's billing departs from R10 and floods of weight-0 deadlines past 64 are not attacked · tested (6)
- bench:sched-timer-flood
- bench:budget-deadline
- bench:deadline-in-launch
- bench:budget
- mutation:BudgetDeadlineIgnored
- mutation:ExpireBudgetsFirst
A deadline is the kernel’s half of a lease. budget_create takes it as an absolute time in
microseconds since boot (the clock time_now reads); FOREVER means none. The kernel sets no
maximum: a lease’s longest term, MAX_LEASE (24 hours), is the steward’s rule, and the steward
refuses a longer request rather than clamp it (steward). The kernel
knows deadlines, not leases.
When a deadline passes, the kernel destroys the budget exactly as budget_destroy would
(R10), with nobody asking. Budgets with a deadline sit on one list linked
through their pages, and the hart timer is always armed for the earliest
(timer). Every kernel entry (a call, a fault, an interrupt) first
answers every deadline that has passed, before anything reads the entering process, so a deadline
beats any operation that enters after it. A process that never enters the kernel is stopped by the
timer interrupt. There is no state, call or interrupt path in which the kernel puts a deadline
off. So a deadline always destroys its budget, at the first kernel entry at or after it.
- A deadline already past at creation destroys the budget at the next kernel entry; the handle is dead by the time it is used.
- At an equal instant, blocking-call timeouts are answered first
(I13 (every blocking call returns by its timeout)), then deadlines in id order. So a caller
whose server’s budget dies at its own timeout gets
Timeoutwith its lend consumed, notDeadwith it returned. - The processes it kills get exit notices with cause
killed, blaming nobody (processes). A process still being launched is one of them: a deadline that passes between itsprocess_createand itsprocess_startkills it with no thread ever run, and the launcher’s nextprocess_maporprocess_startanswersNotPermitted. A lease shorter than its launch never runs (bench:deadline-in-launch). - The destruction’s whole cost is billed to the budget’s parent, after its carve returns, or to the nearest ancestor with free weight above 0 (R10). A deadline is a preemption point.
- A child may have its own, earlier deadline. A later one never fires, because the child dies with its parent. So a sub-budget (a sub-agent’s lease inside an agent’s) never outlives its parent.
budget_usage
Status: built · tested (5)
- bench:budget
- bench:process-attack
- bench:budget-syscall-attack
- mutation:R1UsageIgnoresLabels
- mutation:R1UsageExemptBySystemTarget
budget_usage(h, usage_rec) writes six slots (USAGE_SLOTS): the page limit and usage, the
process limit and usage, and the weight limit and carved weight. The free weight is the limit less
the carved weight. The record is checked writable before anything is read.
A usage read is a flow from the budget read to the reader’s budget. So a user-class caller reads
only budgets whose labels its own budget’s labels contain, and gets LabelDenied otherwise; a
system-class caller reads any budget it holds a handle to (R1). The check uses
the caller’s budget, not the handle’s stamp.
budget_children
Status: planned · M6 (persist, install, share)
budget_children(h) -> [h] returns a handle to each child of the budget h names, so a restarted
steward can find, and destroy, the budgets it created before it stopped. With it, a budget whose
last handle was closed is reachable again from any holder of its parent. Nothing else about
budgets changes.
Open: direct children only, or the whole subtree; how many handles one call returns, and how a
caller pages through a budget with more children than one record holds, within MAX_HANDLES
(4096); what the returned handles are stamped with (the caller’s budget, as budget_create
stamps, or the stamp of h, so that revoking h revokes them too); whether a holder of h may
then create processes and children in a child another holder carved, which is more than h
alone gives (with h alone it can only destroy that child, with the rest of h’s subtree); whether handing back a
labelled child’s handle to a user-class caller is a flow R1 must check.
Authority
Status: built · tested (5)
- bench:budget
- bench:process-attack
- bench:budget-forge-attack
- bench:budget-destroy-attack
- mutation:ClassNotInherited
- A budget handle is the right to spend the budget and to end it. Its holder can carve
children from it, create processes in it (
process_create), read its usage, narrow a mint to it when it is at or below the source’s stamp (objects), and destroy it with everything below it. Handles carry no rights bits, so there is no read-only budget handle. - Handles reach down, never up. No call turns a handle to a budget into a handle to its parent or a sibling. A holder of a child cannot reach its parent’s limits, beyond the carve it has.
- A new handle is stamped with the caller’s budget (R9): destroying the budget that created a child’s handle revokes that handle, while the child itself survives if it is not below the destroyed budget.
- Class gives a caller two powers: a
system-class caller may add labels, and may read the usage of any budget it holds. It also exempts messages to and from the budget from the kernel’s label check (R1). Asystem-class budget handle held by auser-class process gives that process everything asystem-class budget can hold. - The account is inherited once set (R8): a process cannot give a child a different principal to bill to.
- Who holds which budget handle is policy. init hands servers only revocation scopes, never a budget that holds processes, because a budget handle is a destroy right: a compromised server holding its callers’ budgets could end every session.
Security properties
R6 (charging)
Status: built · partly tested: an endpoint's page charge is attacked only in the model · tested (21)
- bench:budget
- bench:budget-mem-churn
- bench:budget-table-attack
- bench:map-fixed-tables
- bench:page-table-reclaim
- bench:pages-exhaustion
- bench:redoubt-tight
- bench:process-attack
- bench:pid-pinning-attack
- bench:thread-limit
- mutation:R6ChargeAncestors
- mutation:R6OwnPageChargedToItself
- mutation:R6EndpointsFree
- mutation:R6PageTablesFree
- mutation:R6EmptyTableKept
- mutation:R6OpenCallsFree
- mutation:R6ProcessObjectFree
- mutation:R6ProcessObjectChargedToBudget
- mutation:R6LendChargedOnce
- mutation:R6RootPageUncounted
- mutation:R6PidUncountedAtEnd
Every kernel object is charged in pages to one budget, and a charge over the budget’s limit fails
with OutOfMemory before anything changes. Who pays:
- a budget’s own page: its parent, always (
BUDGET_PAGES, 1), androot’s own page toroot:root’s limit is the RAM frames the kernel did not keep for itself or for the DMA pool, lessroot’s own page, so the sum of all charges never exceeds the free frames. A held DMA run is charged to its caller’s budget as well, though its frames are the pool’s, so while runs are held up toDMA_POOL_PAGESfree frames cannot be charged for: conservative, never short (devices). A revocation scope is no special case; - a process object, which holds the exit notice: the creator’s budget, the budget of
process_create’s caller (PROCESS_PAGES, 1), until the notice is received or dropped; - a thread’s IPC page (
THREAD_PAGES, 1), its saved registers, its process’s page tables (each until it maps nothing, memory) and mapped pages: the budget the process runs in; - a handle-table page: the budget of the process whose table it is;
- an endpoint: the budget of the process that created it, its owner;
- an open call’s page: the receiving process’s budget (R4a (open calls));
- a lent page and the page tables that map it: the caller and the receiving process’s budget while the call is open (R3 (lends and abandoned calls)).
A process limit counts PIDs, and every held PID counts once: against the budget the process
runs in, for as long as the PID is held, which for a created process is until its object is
freed and for a program the loader started is while it lives. It counts there whoever launched
the process. If that budget is destroyed while the PID is still held, the count moves to the
destroyed budget’s parent (R10). A process_create over the limit fails
with OutOfProcesses before a PID is drawn (processes).
root holds every PID but the kernel’s, so the limits run out no later than the PIDs do: with
every PID in use, every budget refuses with OutOfProcesses (process-fill).
The page counts per object are in objects. A parent’s usage counts its children’s limits and their own pages, never their live usage, so one child filling up changes nothing its siblings or its parent can see.
Page tables are counted before a mapping is made, and the count names each missing table by the span of address space it covers. A missing table at the same index under two different parents (two gigabytes, say) counts twice. Because the count is exact, a mapping that passes the budget check never runs out of pages halfway; the charge and the allocations agree, so a request one table short is refused with nothing charged (R22 (range cost)). Every page a budget uses is on its ledger, and usage stays within limits after every call (I5 (usage within limits)).
R7 (carving)
Status: built · tested (8)
- bench:budget-carve-attack
- bench:budget
- bench:process-attack
- bench:pid-pinning-attack
- host:redoubt-model::budget_lifecycles
- mutation:R7NoCarveCheck
- mutation:R7CarveToZeroFree
- mutation:ProcessInWeightlessBudget
A child’s page, process and weight limits come out of its parent’s free limits, and the child’s own page comes out of the parent’s pages too. The children never add up to more than the parent; nothing is overcommitted. So an allocation succeeds or fails on the paying budget alone, and a failure says nothing about any other budget. The largest values, and sums that would wrap, are refused like any other excess.
Carving weight moves CPU share to the child; it never copies it. The parent’s stride weight is its
free weight. A carve that would leave a budget that holds a process with free weight 0 is refused
(InvalidArgument), as is process_create into a budget with free weight 0. The carve returns to
the parent, whole, when the child is destroyed (R10).
R8 (accounts)
Status: built · tested: bench:process-attack, mutation:R8AccountFromArgument
A new budget’s account is its parent’s, unless the parent’s is 0; then it is whatever the creator put in the spec. So once a budget bills to a principal, everything below it does too, whatever its creator asks (I8). The boot budgets have account 0, and the steward sets one on each principal’s top budget. The kernel attaches the sender budget’s account to every message it carries, and a server bills or throttles by it (IPC). An account grants nothing by itself.
R10 (destruction)
Status: built · partly tested: destroying the budget a device object is charged to is not checked by a case; destroying `root` is not checked by a case; that the caller is killed last is not pinned by a case: the kernel's kill lines, the only ones in kill order, carry the PIDs it draws, the tester's lines that name each program come in no defined order, and the bench has no check across lines (`budget-destroy-kills` checks that both die); the equal-instant order of timeouts before deadlines is attacked only in the model · tested (36)
- bench:budget
- bench:budget-destroy-attack
- bench:budget-destroy-kills
- bench:budget-destroy-growth
- bench:budget-deadline
- bench:deadline-flood-billed
- bench:redoubt-revoke
- bench:process-attack
- bench:pid-pinning-attack
- bench:endpoint-destroy-open-calls
- bench:endpoint-destroy-full
- bench:handle-chain-attack
- bench:handle-chain-fault
- bench:process-chain-fault
- bench:sched-destroy-billing
- bench:dma-reset-quarantine
- bench:dma-destroy-quarantine
- bench:budget-reap
- host:redoubt-model::budget_lifecycles
- host:redoubt-model::a_reap_destroys_one_child_and_keeps_the_budget
- host:redoubt-model::quarantine_charge_moves_to_a_parent_at_its_limit
- host:redoubt-model::a_destruction_delivers_at_its_end
- mutation:R10KeepForeignHandles
- mutation:R10KeepCarvedLimits
- mutation:R10SpareDescendantProcesses
- mutation:R10ExitNoticesOutlivePayer
- mutation:R10RevokedMessageDelivered
- mutation:R10RevokedCallAnswered
- mutation:R10SweptHandlesDropped
- mutation:R10CreatorDeathSparesProcess
- mutation:R10HeldPidsDropped
- mutation:R10ReapDestroysParent
- mutation:R10ReapKeepsCarve
- mutation:R10ReapSkipsGrandchildren
- mutation:R10DeliveredMidDestruction
- mutation:ExpireBudgetsFirst
Destroying budget B, by budget_destroy or by a deadline, destroys B and everything below it, in
this order:
-
Mark. B’s weight goes back to its parent first, so that the destruction’s own work is billed at the parent’s restored weight, not the sliver it kept while B held the rest (the deadline path departs from this; see below) (scheduling). Then B and every descendant are marked dying.
-
Kill. Every process running in a dying budget is killed, the caller last if it is one of them. Each gets an exit notice with cause
killed, blaming nobody, unless its process object is itself charged to a dying budget. -
Free process objects. Every process object charged to a dying budget is freed, its process killed first if it still runs elsewhere, with no notice. A notice never outlives the budget that pays for it.
A process that step 2 or 3 ends is doomed from the mark on: it runs in a dying budget, or its process object is charged to one, because its creator’s budget is dying.
-
Reach messages in flight. Every endpoint a dying budget owns is destroyed: calls and sends blocked on it and receives waiting on it fail with
Dead, calls a server took through it are abandoned (R3), and exit notices owed to it are dropped. Every device object charged to a dying budget is destroyed. From the mark on, no message stamped with a dying budget is delivered: a message still queued that was sent through a handle stamped with a dying budget fails its sender withDead. A call sent through one that a server already took fails its caller withDeadat once, not when the server replies, and is abandoned. -
Lift. Each dying budget’s work since it entered the queue moves to its parent, bottom-up (scheduling).
-
Sweep. Every handle that names a dying budget, or an endpoint, device or process object that died with one, or that is stamped with a dying budget, is closed in every process’s table. A handle stamped with one inside a message not yet received arrives as 0 in its slot (IPC).
-
Return. B’s page, its page and process limits go back to its parent. The parent’s usage is exactly what it was before B was created (I10 (create-destroy leaves the parent unchanged)), except for what step 8 moves onto it.
-
Move what outlives the carve. DMA pages held in quarantine and charged to a dying budget are charged to B’s parent instead (I16 (DMA pages reset before reuse), devices), and so is every PID still held by a process that ran in a dying budget: one whose object step 3 did not free, because its creator is outside B and has not taken the notice (R6). Both move after the carve came back, and both were inside it, so the parent never goes over its limit.
-
Free. The dying budgets’ pages are freed and they leave the deadline list.
Nothing is delivered while a budget is dying. Every surviving endpoint that lost a receiver or gained a notice during the destruction is pumped once, at its end, when no doomed process and no dying object is left to take anything.
What that end delivers is notices, never a message stamped with a dying budget. Outside a
destruction every delivery is made at once, so at the mark nothing a waiting receiver could take
is queued. A kill gives the survivors only notices, callers woken with Dead and the dying
processes’ freed open-call slots, never a receiver newly able to take a message, and step 4 fails
every message stamped with a dying budget. The one thing a delivery inside the destruction could
do is hand a doomed receiver a notice owed to a survivor, which putting each endpoint on a list
and pumping the list at the end prevents.
A budget is emptied one child at a time by budget_reap, each child destroyed as above (B is
the child: its processes killed, the calls its servers held failing their callers with Dead,
what was sent through handles stamped with it failed) and the budget kept with its limits,
class, labels, account and deadline. The call returns how many children remain, so one call is
one destruction, and the caller loops until the count is 0. The child reaped is the first on the
budget’s child list, the one created last; the call takes no child handle, so the caller cannot
choose, and the order is not a promise. Its authority is the handle, as for budget_destroy: a handle
to a child reaches only below that child, never its parent. Once the last child is gone the
budget’s usage is what it was before any child was carved (I10, per child). Its class is
untouched: it is not made again, which budget_create, taking no class, could not do for
users.
root has no parent, so destroying it (init holds its handle) destroys the whole tree: every
process ends, and what step 8 would move goes with the tree. The kernel keeps running with nothing
to run.
Revocation is complete (I2 (revocation is complete)): no handle, queued message or taken call keeps authority that came through a destroyed budget. Budgets outside B are untouched, including budgets B’s processes created elsewhere and handles to them stamped outside B. A device whose reset fails at the end of a process that used it for DMA is destroyed the same way, at that end: every handle naming it closes, in every table and every unreceived message (devices, R11 (memory)).
A budget whose deadline has passed is destroyed before any operation that enters the kernel at
or after that instant: expiry runs first at every kernel entry, before anything reads the
entering process. At an equal instant, timeouts expire before deadlines, so a caller whose call
a server took, and whose timeout falls with the server budget’s deadline, gets Timeout with
its lend consumed, not Dead with it returned (timer).
Every destruction’s whole cost is billed to someone. For budget_destroy and budget_reap that
is the caller, as the call’s own kernel time. For a deadline it is B’s parent, after its carve
returns, or the nearest ancestor with free weight above 0 if the parent has none; root always
has. No part of a destruction is billed to nobody. On a deadline the kernel names the payer once
step 1 has returned B’s carve, and after step 9 bills it for everything from the expiry walk that
found the deadline on, whatever B’s own free weight (bench:deadline-flood-billed). The frames a
destruction frees are zeroed outside the kernel lock by the hart that freed them while every hart
idles (frames in flight); that time is billed to the same payer
(scheduling). Zeroing any hart still has to bill to a budget the destruction ends is lifted to its payer with the rest of that budget’s debt, between
marking the subtree dying and freeing it, so no bill names a budget that is gone. The one
exception is root’s destruction, which has no payer and leaves nothing to run: its zeroing is
billed to nobody.
stateDiagram-v2
[*] --> Live: budget_create, or boot
Live --> Live: handle_close<br/>(the budget stays)
Live --> Dying: budget_destroy on it<br/>or on an ancestor;<br/>budget_reap on an ancestor
Live --> Dying: its deadline, or an<br/>ancestor's, passes
Dying --> Destroyed: kill, free process objects,<br/>fail messages, lift, sweep,<br/>return carve, free
Destroyed --> [*]
Figure: a budget’s life. It is dying only inside one destruction, which runs in the kernel without preemption.
Failure and restart
Status: built · tested (4)
- bench:budget-destroy-kills
- bench:process-attack
- bench:budget-deadline
- bench:budget-syscall-attack
- A process in a budget ends: its threads, pages, page tables and handle table go back to the budget at once. Its process object stays charged to its creator’s budget until its exit notice is received (processes), and by R6 its PID counts against the budget it ran in as long.
- A budget’s creator ends: the budget lives on. Budgets outlive the processes that made them; only destruction, of it or an ancestor, or a deadline ends one.
- A budget is destroyed while its own process is in the call: the process is killed last, and the call never returns to it.
- A budget’s deadline passes while a process in it runs: the timer interrupts it, and the kernel destroys the budget before the process runs again.
- The steward restarts: it holds the budgets it created only if it kept their handles;
budget_childrenlets it find them. - The ledger disagrees with itself (usage below zero, a frame named as a budget that holds none): the kernel stops rather than continue on a broken ledger (I5, I1). No sequence of calls reaches that (I14 (no call panics the kernel)).
Residual risks
-
Destruction costs time nobody can interrupt. Destroying a budget runs with interrupts off and is not preemptible; every interrupt, wake and timeout on the machine waits for it, and it dominates lease termination, R39 (leases end). The frames it frees are not zeroed on the way back to user mode: zeroing runs on an idle hart while every hart is idle, one frame a pass (about 30 µs, interrupts off), and on demand, one frame under the lock when an allocation finds the bitmap empty (frames in flight), so the destroyer’s caller and whatever runs next wait only for the walk. The cost must follow the objects the dying subtree holds, not every object page in the system and not every live table. Destruction gives three indexes, handle chains, the dying endpoints’ and stamps’ own lists, and a walk of each dying process’s own frames:
-
The budget tree is linked downward. Each budget keeps a
first_childand anext_siblingbeside itsparent, somark_dying,lift_dyingand the final free walk the subtree directly instead of scanning0..=high_framefor budgets belowtop. The top unlinks from its parent’s list at mark time, with its carve; a refusedbudget_createunlinks its child on the rollback. The checked build’scheck_all_dyingproves that afterroot’s destruction no budget outlives it; the child links themselves are read by the destruction’s walk, not re-scanned by an audit. -
Objects are linked to their owner. Each budget heads one chain of the endpoints and devices charged to it (a link word at the same place in both kinds’ frames, read alone), so a destruction ends exactly the dying subtree’s endpoints and devices instead of re-scanning every object frame for one whose owner is dying. Its first walk of the chains destroys the devices, which leave them, each moved to its chain’s head first so that leaving does not walk the endpoints ahead of it; the endpoints stay until the handle chains are closed, and a second walk frees each in a link read and a free. Process objects are not scanned either: each budget heads a chain of the process objects charged to it and one of those whose PIDs it counts (objects). Step 2 kills the live processes on the dying budgets’ counted chains, step 3 frees what is on their charged chains, and step 8 moves what is left on their counted chains onto the parent’s, recounting each; none reads a process object outside the subtree. Only
root’s destruction walks every live PID, since then every process dies,initwith them, which the loader started with no process object and which runs inrootalone. -
Handles held outside a budget are chained to it. A handle dies when the object it names is destroyed or when the budget that stamped it is. A handle whose holder runs inside that budget’s subtree dies with its holder’s table, so it needs nothing more. A handle held outside is entered, when it arrives in a table (made, copied, minted or received), in the chain of the budget it depends on: one doubly linked chain for the budget its object is charged to (a budget handle’s object is the budget itself), and one for its stamp. Each budget heads both chains. A process object is the exception: it can be freed while the budget it is charged to lives (its creator’s), once its exit notice is taken or dropped, so a handle held inside the creator’s subtree would not die with its holder. Every handle to a process object, held anywhere, is therefore in a chain headed by the process object itself, instead of in its budget’s object chain. Freeing a process object, inside a destruction or not, walks that chain, never a sweep. The budgets’ object chains carry budget, endpoint and device handles. A device object is freed outside a destruction only after quarantine, which keeps its full sweep. The inside-or-outside test walks up from the holder’s budget, at most
MAX_DEPTHsteps, and holds for the handle’s life because no budget moves. The cost is a constant on each call that adds or closes a handle, and nothing on a call that only uses one.A destruction walks the chains of the dying budgets and of the process objects it frees, and closes exactly the handles that depend on them. The dying processes’ tables were freed whole when they ended: each table page counts its chain entries, a page with none goes without being read, and on the others each slot’s two entry words are read and any entry unhooked from its chain. No live table is swept. The checked build’s
check_handle_chains, run once after the walk, checks every live handle’s entries against its holder’s place, and the test-only featureshandle-chain-fault, which leaves out every stamp entry, andprocess-chain-fault, which leaves out every process object entry, are what it catches (bench:handle-chain-fault,bench:process-chain-fault);bench:handle-chain-attackholds the dependent handles several levels up, in the dying top’s parent and in its sibling, and handles to a process object the destruction frees while its creator lives: in the creator, inside its budget, and outside it under the live stamp.The chain entries make a slot eight words, 64 bytes, so a table page holds 64 handles and a full table (
MAX_HANDLES) is 64 pages. A message’s copy of a handle keeps the four-word form, since the chains index tables, not messages. -
The endpoints’ teardown reads their own lists.
destroy_endpointonce ran three all-thread scans per endpoint, and laterbudgets_dyingtwo walks of every thread for the whole subtree. Now each endpoint keeps its receivers, its queued senders by R2 (fair waiting) group, the calls owing a notice on it and the calls taken through it whose callers wait; and each budget keeps two chains of what was sent through a handle stamped with it, the queued messages and the taken calls whose callers wait.budgets_dyingwalks the dying subtree’s owner chains and empties each dying endpoint’s lists: its receivers and senders fail withDead, the notices owed on it are dropped, and the callers waiting for a reply through it fail withDead, their calls abandoned, and then every notice owed on it is dropped, those abandonments’ included. Then it empties every dying budget’s chain of queued messages, and then each one’s chain of taken calls. Each fails in list order. No pump runs inside a destruction: each endpoint that may have something to deliver is put once on a to-pump list, linked through the endpoints’ own frames; when nothing more can be listed, one walk of that list takes the subtree’s own endpoints off it (a read per listed endpoint, never a read per endpoint the subtree owns), and the destruction’s last act pumps each survivor on it once. The cost follows the subtree’s own parked messages and calls, never the threads that exist or its endpoint count.process::endpoint_dyingdrops the exit notices owed on a dying endpoint from its own exits and reporters. Freeing an endpoint’s frame touches only the frame, once its handles are closed (item 2), not the dying budget that owns it: the budget’s whole object list is going with it, and its endpoints’ pages come back in one write. -
A process’s frames are found from the process. Ending a process releases the frames it owns by walking its own page tables: the tables themselves, the user half’s pages and the process area’s saved registers, each freed if the ownership table still credits it to the process. Its handle-table, IPC and open-call pages are kernel objects, released with its handles and threads. Nothing walks the ownership array over all of RAM: a term linear in RAM frames is what R12 (scheduling) forbids. A frame it lent stays with the borrower, as R3 says, and a page it borrowed is its lender’s. The checked build’s
check_frame_ownersscans RAM for a frame still credited to an ended process, at a process’s end and once after a destruction’s walk, never inside it.
The invariant the five keep is R10 itself: after a destruction no handle, queued message or taken call keeps authority that came through a dying budget (I2), and no handle names a freed frame (I1); the ledger returns exactly (I10). The checked build’s scans over
0..=high_frame(check_process_index,check_irq_index) prove the PID and IRQ indexes name exactly the live objects,check_frame_ownersandcheck_handle_chainsthe frames and the chains, and they run once off the destruction walk, never scaling it;check_all_dyingproves no budget outlivesroot. The child and owner links are not re-scanned: a missed link shows as an object the destruction fails to end, which the destruction cases exercise. One production full scan remains,destroy_quarantined_devices(message.rs), which a process teardown runs to find the quarantined device objects and stops once it has the machine’s DMA slots. R10’s and the deadline notice’s targets are back at 30 and 40 ms (scheduling);bench:sched-latencyasserts both on both widths (seed 3: R10’s p99 is 5,461 µs on rv64 and 5,722 µs on rv32), andbench:budget-destroy-growthshows the destruction does not grow while another budget owns thousands of endpoints and its running process holds a full table of handles to them (a median of 1,211 µs against 1,123 µs alone on rv64, 1,385 against 1,290 µs on rv32). What those cases do not expose is a lease that itself holds many endpoints:bench:endpoint-destroy-fulldestroys a budget whose running process holds a full table of endpoints it owns (4,095 on both widths, past the containment gate’s full fill of 4,091), and bounds R10’s kernel time from the trace’s records at 30 ms on both widths (10,976 µs on rv64 and 10,985 µs on rv32), the same target the containment gate’s own full-fill run repeats with both leases live.Before items 3 and 5 were built, one sweep read every live slot of every table, and ending a process scanned the ownership array over all of RAM. The containment gate’s lease fills its handle table (containment): 4,091 endpoints. At that fill, the destruction ran several times over the 30 ms target, and more while the other lease’s full table was live. The difference was the sweep, about 36 µs for each live handle in the other table. The rest was the lease’s own objects: about 8 µs to close each of its handles and 10 µs to release each of its endpoints, 18 µs an endpoint in all. There were also fixed walks of about 17 ms: the RAM scan once per process, and a walk of every thread for the abandoned-call notices. Under 30 ms leaves a few microseconds per object in the checked build. The chains therefore come with those costs cut: a table freed whole instead of slot by slot, an endpoint released in a few words, and the notice walk joined to a thread walk the destruction already makes. The budget was 25 ms at the full fill on rv32, the slower width. Built, the gate’s full fill measures an R10 p50 of 22.9 ms and a p99 of 23.5 ms on rv32, and 22.1 and 22.9 ms on rv64, over its 20 destructions with both leases live at each deadline’s end (its pinned seed 13; the sweep is on containment). An ending process pumps each endpoint once, after its threads; pumping after each thread instead, the same kernel measures 24.5 and 21.2 ms on rv32. A traced build brackets each process’s end with
Tandtrecords, and the bench reports the time inside each destruction beside R10’s. On rv32, with the other lease live (the threads’ teardown measured with those records, the other lines by the bisect that set the budget):- the chain walk, 0.2 ms (budgeted under 1 ms);
- the dying tables, 0.3 ms (2 ms);
- the processes’ frames, 2.0 ms (1.5 ms; most of it is reading the Sv32 page tables of two processes; 2.5 ms on rv64);
- the endpoints, 8.8 ms, 2.15 µs each: a 5.0 ms walk that destroys the devices and a 3.8 ms walk that frees the endpoints (8 ms);
- the threads’ teardown, 3.1 ms (8 ms; 6.7 ms pumping after each thread). Every thread ends first, and then each endpoint their waits served is pumped once, so the cost follows the served endpoints, not the parked calls: the four parked lend calls are one pump of the server’s endpoint, which reads that endpoint’s own lists, not four;
- the message reach, 1.3 ms (2 ms) when it was two walks of every thread; it now reads the dying endpoints’ lists and the dying budgets’ two stamp chains;
- the rest, 2.9 ms (3 ms).
Those are the gate’s fill. At full occupancy, every PID in use and each holder’s budget holding
MAX_THREADSthreads (129,796 live threads across 510 processes), one holder’s destruction stays within R10’s 30 ms (bench:worst-walk, net of the checked build’s audits, at the 1 ms slice): the run’s two destructions, one near empty and one at full occupancy, take 16.35 and 16.35 ms on rv64 and 17.35 and 17.38 ms on rv32. Their threads’ teardown is 13.1 and 13.8 ms of each, and the rest, about 3.3 and 3.6 ms at most, follows the dying subtree’s own chains (item 2). -
-
A
system-class budget handle is a lot of authority. The kernel lets any holder createsystem-class children with added labels and any account the parent allows, and run processes in them. The wall is policy: onlyinitand the steward hold one (init). Untilinitbuilds the tree,init(or the tester in its place) holdsroot,systemandusers, and is trusted. -
A budget handle is a destroy right. Whoever holds a copy can end the budget and everything in it. A server given a budget that holds processes could end them; servers are given only revocation scopes (init).
-
A lost budget is carved until its parent is reaped. Closing the last handle to a budget leaves it alive, its limits still carved from its parent, with no handle to it until
budget_childrenexists. A holder of the parent can take it back only with the rest:budget_reapdestroys the parent’s children one at a time, the lost one among them, with no way to pick it out. The loss is the closer’s own tree’s, never another budget’s. -
Quarantined DMA pages stay charged. A DMA run whose device did not confirm its reset is held until reboot. When its budget is destroyed, the charge moves to the parent, which keeps paying for those pages until it too is destroyed or the machine reboots (devices).
-
Usage reads and
OutOfMemoryare signals. Abudget_usageread and a failed carve tell the reader about the budget it names, and only a budget it holds a handle to. Covert and timing channels are out of scope (TENETS).
Why
- One object, five jobs. Accounting, CPU share, revocation, labels and billing identity all follow the same tree, so there is one thing to create, one to destroy, and nothing to keep in step.
- Everything costs pages, threads, handles, endpoints and budgets included, so one number
bounds every kind of exhaustion. Processes are counted apart only because PIDs are
address-space tags, which are scarce on rv32; so the count follows the PID, once per PID held,
in the budget the process runs in. The steward and
initlaunch everyone’s processes, and counting them in the launcher would pool every principal’s PIDs in one budget. - Carved, never overcommitted. An allocation that fails on the caller’s own budget reveals nothing about anyone else’s. A parent counts its children’s limits, not their usage, for the same reason.
- A budget’s own page is its parent’s. A child cannot use up the page it lives in, and a revocation scope, with zero limits, needs no special rule.
- A lend is charged to both sides. A server’s budget covers its open lends up front (256 open 9P calls of 16 pages each is 16 MiB), a server that cannot pay does not take the call (R4 (delivery)), and no budget is ever over its limit.
- Class is inherited, with no class argument. A class check on
budget_createalone would guard one of three doors (process_createandbudget_destroyare the others). Inheriting it leaves one door to guard: never hand asystem-class budget to anything butinitand the steward. - Class is trust, not order. One stride queue by weight means nothing jumps the queue by being
trusted;
init, the steward and the drivers are served first by being given more weight. - The kernel keeps deadlines, the steward keeps leases. A deadline is a time, which the kernel can enforce without a policy; how long a lease may be is policy, and lives with the policy.
- Bounded depth, because revocation and “is this below that” walk the ancestors.
- A budget is its own page, so creating budgets never fills a kernel table that other budgets need.
Scheduling
The kernel runs threads from one stride queue over every budget with a runnable thread. Each
budget has a pass; the lowest pass runs next, for a slice of at most 1 ms, and running
raises the pass in inverse proportion to the budget’s free weight. There is no priority: init,
the steward and the drivers are scheduled by weight like everyone else. A waking budget joins at
no less than the queue’s current minimum, a wake never preempts, and every run is charged,
however short it is and however it ends.
Purpose
CPU is a resource like pages, and a hostile budget must not get more of it than its share, nor delay anyone else beyond its weight. The scheduler has two jobs: hold every budget to its weight whatever pattern of running, sleeping, exiting and budget churn it tries, and keep the steward and the drivers responsive enough that a person can end a session under load. It does both with one mechanism and no exceptions, so the rule that contains a hostile agent is the same rule that serves the steward.
Interface
One flat stride queue
Status: built · tested (16)
- bench:sched-share
- bench:sched-round-robin
- bench:sched-large-weight
- bench:sched-server-busy
- bench:sched-carve-inflation
- bench:smp-boot
- bench:smp-shootdown
- host:redoubt-stride::the_crate_and_the_model_agree
- host:redoubt-stride::a_pick_passes_over_a_budget_whose_threads_all_run_on_harts
- host:redoubt-stride::a_budget_with_two_runnable_threads_runs_on_two_harts_at_one_pass
- host:redoubt-stride::a_budget_running_on_another_hart_stays_queued_through_this_harts_reconcile
- mutation:R12PriorityById
- mutation:R12IgnoreWeight
- mutation:R12StrideWeightIsLimit
- mutation:R12OneRunnerPerBudget
- mutation:R12SpreadChargesOnce
Every budget with a runnable thread is in one queue, whatever its class. There is no priority,
no second queue and no flag that jumps it. The kernel runs the queued budget with the lowest
pass, and running raises its pass by its runtime times STRIDE (2^20) divided by its weight. So
over any stretch in which budgets stay runnable, each gets CPU in proportion to its weight.
On several harts each hart picks the lowest-pass budget that has a runnable thread no hart is
running, so a budget runs on as many harts as it has runnable threads. Its stride state stays one
per budget: each hart charges its own runner, under the kernel lock, to the budget’s one pass. A
reconcile keeps a budget queued while any hart runs it.
The weight the queue uses is the budget’s free weight: its weight limit less what its
children carved (R7 (carving)). Carving moves share to the child and
never duplicates it. Weights are 32-bit and matter only relative to each other. At boot the
kernel gives root 1,000,000; system gets a quarter (250,000) and users the rest less
1,000 (INIT_WEIGHT), which root keeps so that a process in it has free weight
(budgets). Below that, the budget that carves a child chooses the child’s weight.
Class (system or user) decides trust, never order (budgets). A system-class
server doing a user’s work waits its turn like the user.
Within a budget, threads take turns. Each pick runs the budget’s next runnable thread that no
hart is running, after the one it last picked, in (pid, tid) order, wrapping. sched-round-robin
holds one budget’s four threads, two processes of two, to that order on one hart: each thread
notes when the CPU comes back to it after a gap of over half a slice, and over twelve rounds the
four run the cycle their first runs set, in (pid, tid) order from the cursor, each once a round,
wrapping, with their counts within a tenth of each other (over 100 ms, rv64: 25, 24, 24 and 24
slices, counts 589,056, 570,624, 564,480 and 568,832; rv32: 24, 24, 23 and 23 slices, counts
199,936, 201,984, 198,912 and 193,280). The code is kernel/src/sched.rs, which keeps
each budget’s scheduling state in the budget’s own frame, and libs/stride, which holds the
arithmetic, the ranks and the order of steps. libs/stride is #![forbid(unsafe_code)] and has
no dependencies.
Preemption points
Status: built · tested (14)
- bench:sched-wake-no-preempt
- bench:sched-wake-no-preempt-harts
- bench:sched-wake-no-preempt-harts-mttcg
- bench:sched-irq-wake
- bench:sched-deadline-preempt
- host:testbench::the_oracle_decides_whether_a_timer_interrupt_ended_the_slice
- bench:budget-deadline
- bench:sched-timer-entry
- host:redoubt-model::scheduler_contracts_hold
- host:redoubt-stride::the_crate_and_the_model_agree
- host:testbench::a_pick_in_place_is_a_slice_end_that_requeued_its_runner
- mutation:R12PreemptOnWake
- mutation:R12TimeoutWakePreempts
- mutation:R12SliceCountsExitWork
The running thread keeps the CPU until one of these:
- its slice ends:
SLICE_US(1,000 µs) from its return to user mode after the pick; - it blocks, exits, faults or is killed;
- a budget deadline fires.
Nothing else takes the CPU. A timeout expiring or an interrupt firing only makes a thread runnable; it runs when the queue next picks it. The hart timer is always armed for the earliest of the slice end, the next timeout and the next budget deadline (timer).
On several harts a timeout is answered at whichever kernel entry first comes after it is due, on
any hart: a timer interrupt, another budget’s call, or kmain‘s own. That entry deschedules no
runner whose slice has not ended for its own reason (its slice’s end, a block, an exit, a fault or
a deadline): the woken thread first runs at a pick, an idle hart’s or one after a runner left for
such a reason. sched-wake-no-preempt proves it at one hart nap by nap, each against the slice
that follows it; sched-wake-no-preempt-harts judges it at two, each wake at the entry that made
it, its naps’ ends stepped through the slices’ phases, and needs at least twenty of its hundred and
twenty wakes answered where they could have preempted, mid-slice or at another budget’s call (57
to 101 at a call on rv64 and 65 to 74 on rv32, five runs a width alone and three with the timer’s
placement, whose one hart armed for a sleeper’s timeout moves where a wake is answered run to
run; the floor is a third of the least). Whether a slice ended at a timer interrupt the oracle
decides itself, from the decision’s inputs the trace records (its end and now), so a slice that
ends during the entry’s own expiry counts as ended, as the kernel ends it. Under icount a wake
comes mid-slice on rv64 (1 to 61 across runs; 26 to 35 before the placement) but at most four
times on rv32 (0 to 4), whose armed hart takes its interrupt only at its next turn, after the
other hart’s call has answered the timeout; so sched-wake-no-preempt-harts-mttcg, the same
program where the harts run at once, needs five mid-slice. How many come is the host’s race: 49
to 55 on rv64 and 48 to 57 on rv32 with the two harts on eight host cores (three runs a width),
41 to 74 alone on the quiet core set with the placement, 69 to 75 beside other cases, as few as 5
beside a whole bench’s load, and as few as 3 with the two vCPU threads and QEMU’s I/O thread
sharing two cores, so the case runs on the quiet core set, and a read at the floor is re-run alone
before it is believed. A kernel
built with wake-preempts, whose timeout wake preempts the entering thread as a deadline does,
fails both on both widths, in recorded negative runs (on rv32 under icount: a wake took hart 0
from budget 37).
A device interrupt only wakes too. sched-irq-wake runs a driver stand-in on the goldfish RTC’s
alarm beside an equal-weight spinner, so every alarm interrupts the spinner mid-slice, and the
oracle requires each device interrupt that claimed its source and woke the driver to return to
the spinner, which runs to its slice’s end before the driver is picked (irq_wake_no_preempt):
60 of 60 alarms on both widths, on one hart. It also refuses a device interrupt that began no
destruction and returned otherwise than to user mode. No kernel feature makes a device wake preempt, so
the check’s negative is its floor: asked for 61 proofs of 60 alarms it fails, naming the 60 (a recorded
run).
A budget deadline preempts whatever runs, because the destruction lifts passes and removes
budgets, so the queue must be picked again. A thread that entered the kernel just as the deadline
fired takes its trap again when it next runs. sched-deadline-preempt makes empty budgets due
1.3 ms ahead, one at a time, beside a spinner, so the deadlines fall at every phase of the two
runners’ slices, and the oracle requires every timer interrupt that destroyed another budget by its
deadline to leave for kmain with its runner requeued and the hart’s next event a pick, never to
return to the runner (deadline_preempts): 94 preemptions on rv64 and 80 on rv32 over 2.5 s, every
one mid-slice (the checked build’s audits after each destruction take most of the window: 78
audits, 356 ms, in the first 300 ms tried). The deadlines a runner’s own call finds due preempt it
too, outside the trace’s interrupt brackets, and are not counted. Asked for 200 the check fails,
naming the 94 (a recorded run).
The kernel itself is never preempted. It runs with interrupts off (sstatus.SIE clear) and takes
the timer and device interrupts only from user mode or while idle, so a system call or a
destruction runs to its end. Picking is done by kmain, the kernel’s own loop (PID 1). It runs
its pick through a private S-mode ecall into the kernel’s trap handler, which saves kmain’s
context as it saves a thread’s.
So a slice’s end picks again. When nothing is due for kmain’s expiry to answer first and no
other thread of the running budget waits for a hart, the trap picks where it is
(sched::repick): the stride takes the steps of the round trip through kmain, in its order
(Harts::repick), the budget charged and requeued behind its equals, the entry reconciled, the
queue picked. If the pick is the same budget its thread runs on, a new slice from its return, with
no switch to kmain and back; if it is another budget’s thread, the trap switches into it there
(sched::switch_here), with the steps of kmain‘s switch: the thread it ran made ready, the
pick’s address space and thread activated, instruction fetch fenced, one return, which
deschedules the budget switched from and starts the pick’s slice. Only a slice’s end that finds
something due, or whose budget has another thread waiting for a hart, goes through kmain, as
every one did before. The differential takes slice ends through it at 1, 2 and 4 harts and requires
each to leave the queue as the round trip leaves it, its thread counted as the process table counts
it there, and to pick what the model picks. The trace records the requeue and the pick, and the
hart’s new runner at the return when it switched (H), no change of it when the thread ran on;
the oracle counts the thread as waiting only at a slice’s end that picked its own budget again
(the scheduler oracle).
sched-timer-entry bounds what a slice’s end costs the kernel: two spinners and nothing else, so every
timer interrupt from user mode ends a slice, and the oracle bounds their lock sections, trap to
return, net of the checked build’s audits at one hart: the p99 at 2,000 ticks, the steady entry,
which measures 951 on rv64 and 1,452 on rv32 (five runs a width alone, p50 949 and 1,424; 930
and 1,415 before the timer’s placement read the harts’ runners at each re-arm, 15 and 26 ticks,
and 945 and 1,441 before its one-hart rule for an item whose payer runs nowhere asked every
hart’s arming, 6 more: the hart timer), and the longest at 5,000, the
cold first preemptions of a phase, 3,552 and 4,452 (at two harts, where icount’s one clock
counts the other hart’s instructions too, they are recorded: p99 5,447 and 7,378). In a release
build the slice’s end of a thread that only computes, alone on its hart, is about 4,200 kernel
instructions on rv64 and 6,700 on rv32 (QEMU’s exec log), about 3.4 and 5.4 % of a hart at the
1 ms slice; through kmain it was 9,000 and 13,000. One that switches to another budget’s thread,
two such threads sharing a hart, is about 7,050 and 10,600 (the same log, sched-timer-entry’s
program), 5.6 and 8.5 % of a hart; through kmain it was 8,500 and 12,200.
The current minimum and ties
Status: built · partly tested: wakers ahead of requeued budgets, and requeues in order, are checked on the target only when a run happens to produce such a tie; the host tests and the model attack them · tested (22)
- bench:sched-ties
- bench:sched-idle-gap
- host:redoubt-stride::ranks_follow_all_four_clauses
- host:redoubt-stride::the_floor_survives_an_empty_queue
- host:redoubt-stride::a_running_budget_stays_queued_and_counts_for_the_floor
- host:redoubt-stride::a_slice_end_reads_no_more_states_for_a_longer_queue
- host:redoubt-stride::a_rank_out_of_step_with_its_frame_trips_the_audit
- host:redoubt-stride::a_budget_capped_at_its_threads_is_left_out_of_the_floor
- host:redoubt-stride::a_budget_no_longer_capped_is_lifted_to_the_floor
- mutation:R12CappedHoldsFloor
- mutation:R12CapOnce
- mutation:R12AllCappedHoldsFloor
- mutation:R12UncapBanksCredit
- mutation:R12UncapForfeitsWait
- mutation:R12EmptyHoldsFloor
- mutation:R12RequeueWaitsForCap
- mutation:R12BlockLeavesAsRequeued
- mutation:R12WakeBanksCredit
- mutation:R12NoFloorWhenIdle
- mutation:R12TieQueuedFirst
- mutation:R12RequeueAhead
- mutation:R12RequeueLifo
A budget wakes when it goes from no runnable thread to one. Its pass becomes
max(own pass, floor). The floor is the current minimum: the lowest pass among queued
budgets, the running ones included, each once, at the pass it was last charged. The floor only rises, and it
holds while the queue is empty. So a budget that slept while others ran, or through an idle gap,
comes back at the floor and not with credit it banked while away. The floor is raised only when it
can have moved (a budget at it was charged or left, or wakes filled an empty queue), from ranks the
queue keeps beside its slots, each written where the queue changes a queued budget’s pass or tie;
the pick compares the same ranks. Neither reads a budget’s frame, which stays the record: a checked
kernel audits the ranks against the frames with the marks’ audit. So a slice end’s scheduler work
is under 3 µs per queued budget (2.4 µs measured in a checked build, “Charging”), the frame reads
it makes being its hart’s running budget’s own.
On several harts the floor leaves out a capped budget, one whose weight’s share of the harts
is more than it has runnable threads. Running every thread it has, its pass lags, and counting it
would hold the floor below the budgets that share the other harts, so a waker would enter far
behind them and take their harts until it caught up. The cap is found as the shares are: in
descending weight per runnable thread, a budget is capped if w x H > k x W (H the harts, k
its runnable threads, waiting or on a hart, W the queued budgets’ free weight), and the next is
tested with the capped one’s harts and weight taken out. Only a budget capped already, or one every
thread of which runs on a hart, is tested: a budget with a thread waiting for a hart is not taking
the harts its weight would give it, so it stays in the floor and its weight in W. A budget a
switch takes off its hart with its own thread requeued (its slice ended or it was preempted)
counts that thread as running: its own requeue is no wait, but a sibling still waiting after a
block is. A queued budget
whose last thread just ended, which the entry’s reconcile takes out, counts for neither, unless
no queued budget has a thread: then the floor rises to the lowest pass, as at one hart. At one
hart no budget is ever capped. When every queued budget is capped, fewer runnable threads than
harts, the floor rises to the highest of their passes instead of holding: uncontested time banks
for no one, so a budget waking then cannot take the others’ harts later for time no budget wanted.
A budget that stops being capped is lifted by what the floor rose while it was capped: it cannot
bank what it had no thread to run. Its lift target starts at min(own pass, floor) when it is
capped and follows the floor from there, so a lag it had then it keeps, and the target needs no
hold while a thread of it waits: the capped budgets’ threads total at most H - 1, so they all
run at once and a capped thread never waits long enough to be owed, and one lagging below the
floor outranks every budget that is not capped. Without these limits a carve could cap an equal-weight victim
while its thread waited behind the carver’s two on both harts, and the carve’s return lifted it to
the carver’s pass, erasing what it had waited: 82 times in sched-budget-churn-shell‘s window at
two harts on rv64, 7.4 ms of its hart time each, and the victim kept 398 of 1000 (568 with them).
The model’s carve waiter scenario is that pattern (R12UncapForfeitsWait); heavy capped has a
capped budget whose own slice ends fall inside the others’ and gain it nothing; contracts hold
that a budget with no thread raises the floor to no pass of its own (R12EmptyHoldsFloor) and that
a heavy budget is capped by the raise its own slice end makes (R12RequeueWaitsForCap), but not by
a block while its sibling waits (R12BlockLeavesAsRequeued).
With more than one hart online the queue keeps each budget’s free weight, its threads no hart runs
(as the kernel’s settle counts them), its harts and the pass a lift would take it to beside its
slot, counted afresh as a hart comes online and kept by no scan at one hart, so every floor raise
finds the cap set in one scan, keeping the top H - 1 by w / k, linear in the queue, with no
frame read but a lifted budget’s. A checked kernel audits the set, with the marks’ audit, against
water-filling done its own way over every queued budget.
A pick takes the lowest rank, (pass, tie, id). At an equal pass:
- a waker ranks ahead of a budget that was requeued;
- of two wakers, the later kernel entry’s ranks first;
- within one entry, the lower budget id first (ids are the kernel’s, I12 (ids never reused));
- requeued budgets run in the order they were requeued.
The tie key encodes this. Each wake takes front - 1, and one entry’s wakes are processed in
descending id, so the lowest id ends frontmost. Each requeue of a still-runnable budget takes
back + 1. Both counters reset when the queue empties. Wakes are reconciled once per kernel
entry: as the kernel leaves, budgets that lost their last runnable thread leave the queue and
budgets that gained one wake. The reconcile visits only the budgets whose runnable state changed
in the entry, with the same wakes and leaves, the wakes in the same order: each budget counts its
ready threads, and a change of a process’s ready threads marks the process, whose count then moves
to its budget. Leaves have no order a pick can see. Ranking later wakers first starves nobody: a
waker that has run is charged, its pass rises above the floor, and it no longer ties.
bench:sched-ties runs a kernel built with the scheduling trace (R23).
The bench’s own oracle (tools/testbench/src/sched_oracle.rs) rebuilds the order from the trace’s
events with its own reading of the four clauses and checks every pick, keeps its own floor, and
requires every pass never to fall but at a weight change, which it recomputes
(the lead follows the weight). In the guest, two groups of three
each wake in one entry, by one destruction each, and each group runs lowest id first. The oracle
alone judges which group runs first: a slice end between the waker’s two destructions may run the
earlier group before the later one wakes.
flowchart TD
K["kernel entry: a call, an interrupt,<br/>a timeout, a slice end, a deadline"] --> D{"does the running<br/>thread leave the CPU?"}
D -- "no: a wake never preempts" --> REC
D -- "yes: it blocked, exited, faulted,<br/>its slice ended or a deadline fired" --> F["charge: t = rem + ticks x STRIDE<br/>pass += t / w, rem = t mod w<br/>(at least 1 tick)"]
F --> S{"still has a<br/>runnable thread?"}
S -- yes --> RQ["requeue behind its equals<br/>(tie = back + 1)"]
S -- no --> LV[leave the queue]
RQ --> REC
LV --> REC
REC["reconcile: each budget that gained a<br/>runnable thread wakes, highest id first,<br/>pass = max(own, floor), tie = front - 1"] --> FL["floor = max(floor, lowest queued pass)"]
FL --> R{"a pick due: the thread left,<br/>or its slice ended?"}
R -- no --> RUN[the hart's running thread resumes]
R -- yes --> P["the queue picks the lowest (pass, tie, id)<br/>and runs that budget's next thread<br/>after its cursor, for up to 1 ms<br/>(at a slice's end the trap picks where it is;<br/>otherwise kmain does)"]
Figure: the pass and wake rule at one kernel entry. w is the budget’s free weight.
Charging
Status: built · tested (21)
- bench:sched-sleep-gaming
- bench:sched-irq-wake
- bench:sched-exit-churn
- bench:sched-timer-flood
- bench:sched-server-busy
- bench:sched-destroy-billing
- bench:deadline-flood-billed
- host:redoubt-stride::a_split_charge_equals_the_whole
- host:redoubt-stride::every_charge_counts_at_any_weight
- host:redoubt-stride::a_deschedule_charges_at_least_one_unit_and_a_destroy_only_what_ran
- host:testbench::a_timer_interrupt_bills_the_budgets_whose_items_it_expired
- host:testbench::a_device_interrupt_that_claims_nothing_bills_nobody
- host:testbench::a_device_interrupt_a_deadline_preempts_before_its_claim_is_billed_as_claiming_nothing
- mutation:R12ShortRunsFree
- mutation:R12DropRemainder
- mutation:R12ExitRunsFree
- mutation:R12NoMinimumCharge
- mutation:R12FoldAtNewWeight
- mutation:R12DeadlineWorkUnbilled
- mutation:R12TimerWorkUnbilled
- mutation:R12SwitchBilledToPrevious
Runtime is counted in timebase ticks at the trap boundary. There are two ways into user mode (resuming a thread, returning from a call) and one way out (the trap handler). On every trap from user mode the kernel adds the user time since that hart’s last return to the pending runtime of the budget whose thread it ran. So no path runs user code unaccounted, whatever ends the run.
Pending runtime is folded into the pass at a deschedule, before a weight change, at a destruction, and when work is billed to a budget that is not running:
t = rem + ticks x STRIDE; pass += t / w; rem = t mod w (w: the free weight)
- The remainder is exact, so a run split into pieces costs what one run would, at any weight.
Without it a weight above
STRIDEwould round a one-tick run to nothing. - A deschedule charges at least
MIN_CHARGE(1 tick), so a run too short for the clock to see is not free. - One fold counts at most
RUNTIME_CAP(2^40 ticks), so the product fits 64 bits on rv32 and rv64 alike. The pass is a 128-bit number that is only added to and compared, so it never wraps. - A weight change (a carve, or a carve returned) folds first, so runtime is charged at the weight it ran at, and then rescales what the budget owes to the new weight (the lead follows the weight).
Kernel time is billed as well:
- a system call’s time is its caller’s;
- an expired timeout is billed to its thread’s budget, and a deadline’s destruction as below, each with its share of the walk that found it. The timer is armed for a timeout only when its thread blocks; a wait that ends before its timeout leaves it early, and the walk that finds the wait gone is billed to that thread’s budget. The rest of an entry that found either, the timer’s own handling included, is billed to the budget it found last: the budget it interrupted pays for none of it. Each bill closes its interval at the tick that opens the next, so a bill’s own handling is in the interval after it and no part of a timeout’s expiry is nobody’s (residual risks). A timer interrupt that found neither is the budget’s its hart runs when it ends that budget’s slice, and nobody’s otherwise;
- an interrupt’s handling is billed to the owner of its device object
(R5 (interrupts)), the budget it is charged to:
system, which the boot charges every device to, whoever holds the handle (sched-irq-wake’s oracle requires every device interrupt that claimed its source to charge the budget the program marks,system, and lets it charge only that and, for its return, the budget it interrupted: 60 alarms chargedsystem286,980 ticks on rv64 and 208,903 on rv32 in the checked build, 4,783 and 3,482 each, and the interrupted spinner 19,476 and 31,488 for their returns); one with no device object, to nobody; a device interrupt that claims nothing, its source claimed first by another hart, is billed as a timer interrupt that ends no slice: the rest of it after its expiry is the budget its expiry billed last, or nobody’s, never the budget it interrupted; so is one a deadline took at its entry: the expiry every entry answers first destroyed a budget, and the entering thread gave up the hart before the controller was asked (preemption points), leaving the source pending for the next entry. The trace records it with no claim, the destruction inside it and a preempting return, and the oracle accepts a missing claim only so marked (the real steward’s two-hart traces, where a deadline passes while the other hart holds the lock, carry one or two a run; one hart’s none); kmain’s pick and switch into a budget are paid by the budget picked, whatever ended the run before: a block or an exit is paid by its actor, a timer entry’s handling by the budget it served, and the pick that follows by the budget it picks (a pick that cannot run leaves the next pick owing it; a pick of nothing is nobody’s);- idle time is nobody’s.
A slice end that switches budgets costs about 0.056 ms of kernel time on rv64 and 0.085 ms on
rv32 in the release build under QEMU (about 7,050 and 10,600 instructions, two spinners sharing a
hart), billed to the budget picked after it: the trap, one SBI timer call, the deschedule, the
reconcile, the pick and the switch, made where the trap is
(preemption points), with under 3 µs of it per queued budget (the checked, traced build measures 2.4 µs per
queued budget per slice end from three to seventeen queued, net of its audits, over a fixed
175 µs). At a 1 ms slice the nine spinners of bench:sched-large-weight-release do 0.91 (rv64)
and 0.87 (rv32) of the useful work the 10 ms build does; the case requires 0.85, the fixed part
being the rest. A count of user work still falls short of the window: in the checked build, bench:sched-share‘s three
spinners count 975 (rv64) and 964 (rv32) of 1000 of what the loop’s rate alone would fill, the
rest being what a slice end that switches budgets costs over a lone spinner’s, the checked
build’s audits among it (the marks’ audit after about every slice end, the queue’s ranks audited
with it), so the case judges each spinner’s share of what the three counted (600 of 1000 for the
weight-300 spinner on both widths, against its 600; 585 and 578 of the window), not of the
window. It reports the sum beside, with no verdict; bench:sched-share-release requires the
release build’s sum to reach 960.
The top of a destruction returns its carve to its parent before any of the destruction’s work is
billed. So the parent, often the caller of budget_destroy, pays for the destruction at the
weight it has once the child is gone, not at the sliver it kept while the child held the rest.
In bench:sched-destroy-billing a parent that kept 10 of 1000 destroys the child holding 990 and
is back on the CPU within twice the destruction’s cost and four slices; billed at 10, it would wait
for seconds.
Every destruction’s whole cost is billed to someone. For budget_destroy and budget_reap that is
the caller, as the call’s own kernel time. For a deadline it is the top’s parent, after its carve
returns, or the nearest ancestor with free weight above 0 if the parent has none; root always
has. No part of a destruction is billed to nobody
(R10 (destruction)). On a deadline the kernel names the payer once the
carve is back, and bills it after the subtree is gone for everything from the walk that found the
deadline. In bench:deadline-flood-billed a creator floods its own budget with empty weight-0
budgets on short deadlines: its count falls as the flood grows from 16 to 64 a round, and an
equal-weight victim keeps its half. With the bill planted out, the victim fell to 137 of 1000.
The frames a call or a destruction frees are zeroed outside the kernel lock by the hart that freed
them while every hart is idle (memory), and that time is part of the work: when
the hart takes the lock back it is billed to the budgets that were paying when the frames were
freed (the caller, the destroyer, a deadline’s payer), split by frames. No thread’s slice pays
for it, since no hart zeroes on its way to user mode; a frame an allocation zeroes because it
found no free one is that allocation’s own kernel time. A payer destroyed before the bill does not
take its share to nobody: its destruction lifts the share to its own payer with the rest of the
dying budget’s debt (R10), root’s alone excepted.
The trace checks every timer interrupt from user mode (bench:sched-timer-flood): after its
expiry it charges only the budget it found last, an expired item’s or a wait’s that ended before
its timeout, and with neither, only the budget it interrupted, when it ends that budget’s slice.
Against 30 sleepers a microsecond apart, the same with 64 staggered budget deadlines, and two
waits answered at once 15 ms before their timeouts, the victim gets 473, 473 and 477 of 1000 net
on rv64 and 468, 469 and 481 on rv32, and no timer interrupt is nobody’s. The last case requires
interrupts that found the attacker’s waits ended early inside the victim’s window, so the check
cannot pass for want of them: 73 on rv64 and 19 to 20 on rv32. Its attacker holds no other
timed wait that times out within the window: one due at every expiry walk of its process would
reset the timer’s hint each slice, and a hint 15 ms ahead would never come. A sleep of a
microsecond has passed before its call would block, so it arms nothing; when every call armed
the timer, each such sleep made an interrupt that found nothing. With the rest of each timer
interrupt billed to the budget it interrupted (timer-tail-billed, the recorded negative run),
the check fails on both widths: the victim pays for the attacker’s waits that ended early.
A server that works for a caller spends its own budget’s CPU: no time is donated
(Residual risks). CPU charging is separate from page charging
(R6 (charging)). The model charges runtime, and three kinds of kernel
work billed to a payer: a deadline’s destruction (R12DeadlineWorkUnbilled), a timer’s expiry
of the timeouts it ends (R12TimerWorkUnbilled), and the pick and switch into a budget
(R12SwitchBilledToPrevious). Billing other kernel work is the kernel’s alone, and the boot
cases are its only check.
Against threads that each run nine tenths of a slice and exit, beside a main thread polling on a
1 ms timeout (sched-exit-churn‘s first share), the victim got 439 of 1000 net on rv64 and 432
on rv32 while it paid the pick and switch at each of its own slice ends, about 90 µs at the 1 ms
slice, a tenth of what it was charged, and while each expiry of the attacker’s poll left about
50 µs, its bills’ own handling, to nobody. Billed as above it gets 492 and 487.
Inheritance
Status: built · tested (19)
- bench:sched-budget-churn
- bench:sched-debt-lift
- bench:sched-lift-delay
- bench:sched-idle-gap
- host:redoubt-stride::create_then_destroy_without_a_run_moves_nothing
- host:redoubt-stride::a_churned_child_adds_to_a_leading_parent
- host:redoubt-stride::the_inherited_wait_is_not_counted_again
- host:testbench::a_marked_wake_picked_within_one_round_passes
- host:testbench::a_budget_picked_twice_before_the_marked_one_fails
- host:testbench::a_sibling_delayed_as_its_parents_lift_predicts_passes
- host:testbench::a_sibling_held_a_round_past_its_parents_lift_fails
- host:testbench::a_lift_the_floor_had_passed_fails
- host:testbench::each_phase_is_reported_on_its_own
- host:testbench::the_floor_is_recorded_at_each_record
- mutation:R12DestroyDropsDebt
- mutation:R12CreateAtFloorOnly
- mutation:R12LiftByMax
- mutation:R12UnnormalizedLift
- mutation:R12LiftCountsEntryWait
A child budget enters at e = max(floor, parent's pass), and keeps e as its entry. A
running parent is charged first. So a child never starts behind the parent it came from, and
making a child buys no turn.
When a budget is destroyed (R10 (destruction): descendants first, bottom-up), its own work since entry moves to its parent:
W = (pass - max(e, floor))+ x w_child + rem_child
parent's pass = max(parent's pass, floor) + W / w_parent (the remainder carried)
w_parent is the parent’s free weight after the child’s carve has returned. A parent below the
floor starts from the floor, without its remainder: it cannot bank what it did not run. Only
work after entry counts, so creating and destroying a budget that never ran moves nothing. The
child’s debt adds to the parent’s own lead instead of hiding under it, so a parent that spins and
churns children pays for both.
bench:sched-budget-churn runs a churner that creates a weight-1 child, lets it run a slice and
destroys it, over and over (blocked, spinning, by a deadline, and through fresh intermediates),
against an equal-weight victim who keeps half; the oracle recomputes every lift from the trace.
Its last variant, a shell that keeps giving a child half its weight and taking it back with no
run between, leaves the victim at least half, and the recomputed lifts show that creating and
destroying moved nothing. Its victim’s share has no ceiling: the shell pays at its halved weight
for what it runs while carved, its own calls included
(running while carved down), so how far above half the victim gets
follows the kernel’s speed.
Running while carved down
Status: built · tested (8)
- bench:sched-carve-inflation
- bench:sched-carve-return
- bench:sched-budget-churn
- bench:sched-destroy-billing
- host:redoubt-stride::a_carve_and_its_return_leave_the_state
- mutation:R12StrideWeightIsLimit
- mutation:R12FoldAtNewWeight
- mutation:R12RescaleOnlyOnReturn
A budget that runs while most of its weight is carved away accrues its lead at the small weight
it kept, and owes that runtime at whatever weight it has later
(the lead follows the weight). A destroyed child’s work is lifted
at the child’s weight at its destruction, whatever the child kept while it ran. That only
over-charges the budget that carved; it never under-charges, so no share is gained by carving. In
bench:sched-carve-inflation a spinning budget that carves spinning children, one deep and four
deep, gets at most half against an equal victim.
A budget that holds a process keeps free weight above 0. A carve that would take its last free
weight gets InvalidArgument, and so does process_create into a budget whose weight is all
carved (R7).
The lead follows the weight
Status: built · tested (6)
- bench:sched-carve-return
- bench:sched-budget-churn
- host:redoubt-stride::a_carve_and_its_return_leave_the_state
- host:redoubt-stride::the_crate_and_the_model_agree
- host:testbench::weight_changes_are_recomputed
- mutation:R12RescaleOnlyOnReturn
What a budget owes is runtime, and a weight change keeps it exactly. Every weight change, a carve and a carve returned alike, folds at the old weight and then converts the budget’s lead and remainder to the new weight, the same way a lift converts a child’s work:
W = (pass - floor)+ x w_old + rem
pass = floor + W / w_new; rem = W mod w_new
So a carve raises the lead by the ratio of the weights and its return lowers it by the same ratio, and a carve returned with no run between leaves the budget where it was. If the carve raised the floor (the budget had the lowest pass), the return converts from the higher floor and the budget ends a little higher: over-charged, never ahead. Both directions are needed: rescaling only on a return would let a budget carve just before a burst and return just after, and its lead would shrink at the return without having grown at the carve. A budget at or below the floor owes only its remainder, and waking would lift it to the floor anyway. At a destruction the carve returns first, so the parent’s lead is converted before the child’s work is lifted onto it at the parent’s restored weight (inheritance). A budget at weight 0 holds no process, and what it owes is stated at weight 1 meanwhile, so it is carried exactly through 0: carving everything away and then getting back only part of it is the same as the one carve from the old weight to the new.
libs/stride’s rescale is the conversion; the model makes the same one, and the bench’s oracle
recomputes every weight change a traced kernel records, the one place it lets a pass fall. A host
test checks that a carve and its return leave pass and remainder unchanged. In
bench:sched-carve-return a budget of weight 1000 carves 999 away at the start of a slice, runs on
the 1 it kept until nine tenths of the slice into it, and takes the weight back: from then on it
gets 496 to 499 of 1000 against an equal victim. With the remainder alone rescaled, as before, it
got 0.
Responsiveness
Status: built · tested (26)
- bench:sched-latency
- bench:steward-latency
- bench:steward-latency-n4
- bench:steward-latency-n16
- bench:steward-deadline-burst
- bench:steward-deadline-burst-16
- bench:steward-launch-overlap
- bench:sched-lock-contention
- bench:sched-lock-contention-4
- bench:sched-lock-contention-4-mttcg
- bench:sched-cluster
- bench:sched-cluster-old-control
- bench:budget-destroy-growth
- bench:sched-budget-churn
- bench:sched-exit-churn
- bench:sched-timer-flood
- bench:sched-carve-return
- host:testbench::destructions_are_timed_and_bounded
- host:testbench::audits_are_subtracted_inside_each_window
- host:testbench::a_hart_share_is_judged_of_every_charge_against_water_filling
- host:testbench::latency_targets_are_gated_up_to_gate_harts
- host:testbench::the_driver_wake_is_judged_in_searches_of_its_own_run
- host:testbench::an_unmatched_audit_fails
- host:testbench::cluster_envelope_qualifies_where_the_old_rtc_interval_did_not
- host:testbench::the_old_control_must_fail_on_its_lower_witness
- host:testbench::a_control_that_misses_its_slot_is_classified_net_of_audits
No wake latency follows from weight. Unless a hart is idle, a wake waits out a running thread’s slice, a waking budget keeps a pass above the floor if it has one, and several budgets can tie at the floor. So wakeup is prompt but not bounded, and responsiveness is a measured target under a named workload, not a bound derived from the queue.
The workload (tests/programs/src/bin/sched-latency.rs): a driver stand-in (weight 1000, from
system) that waits for the goldfish RTC’s alarm interrupt; a steward stand-in (1000, from
system) that sleeps on timeouts, destroys leases by hand after a timeout (its decision) and
waits for other leases’ deadlines; and N spinning sessions of weight 100 from users, for
N = 1, 4 and 16. At N = 16 a spinning server of weight 1000 joins them. Each run takes 200 wakes
and 50 destructions of each kind, on QEMU, on rv64 and rv32.
Targets are guest instructions. The case runs under -icount shift=3,sleep=off with the RTC
on the same clock (-rtc clock=vm; tests/sched-latency.toml):
every guest instruction advances virtual time by 2^3 ns, and idle time skips to the next timer
deadline. So 1 ms is 125,000 instructions, and each target below is that many instructions; the
milliseconds are for reading. Host load does not change a result.
Targets exclude the checked build’s audits. The case is a checked build, since the trace needs
one (R23). A checked build runs full audit scans after each destruction
and at each process-object free, and a release build compiles none of them. It also checks the
reconcile’s marks. Each reconcile asserts that every budget it visited is queued exactly when it
has a ready thread. At most once a slice, and whenever the hart is about to idle, a full walk of
every live process must find the runnable budgets the marks hold. So a mark missed in one entry is
found within a slice of the next reconcile that visits a budget, or at the next idle. A miss that a
later change undoes before then is not seen, and costs a budget a turn late or lost, never a wrong
thread run. Each target counts the kernel a release build runs, a share as well as a window, so an
audit neither fills a window nor moves the schedule. The audit time inside each window, a share’s
included, is subtracted, from the trace’s audit records, and reported beside it
(checked builds). The scheduler does not see an audit either: its
time is charged to no budget, and its hart’s running slice’s end and the start of the kernel time being
billed both move forward by its length, so the thread that ran it is picked and preempted as in a
release build (sched::audit). With two full handle tables live, the audits are about 24 ms after
a destruction and 8 ms at a free.
Measured at seed 3 with the 1 ms slice in the checked, traced build, p99 in µs, net / gross (audit time inside the windows): the audits hold the deadline notice, which waits out the audit after its own destruction, and little else. They are the sixth sweep’s seed 3, the seed the gate pins; the earlier sweeps’ figures stay as measured.
| Measure (p99, N = 1 / 4 / 16) | rv64 | rv32 |
|---|---|---|
| deadline notice | 3465 / 3985 / 9798 net, 25754 / 28370 / 42262 gross | 3736 / 4046 / 11353 net, 30296 / 32854 / 46806 gross |
| driver wake | 1701 / 2410 / 17871 net, 1944 / 2788 / 21328 gross | 1834 / 2670 / 17229 net, 2137 / 3152 / 20328 gross |
| steward timer wake | 2557 / 2322 / 21075 net, 2998 / 3039 / 24791 gross | 2464 / 3033 / 24001 net, 3000 / 3626 / 28949 gross |
| steward decision wake | 644 / 1024 / 6463 net, 672 / 1078 / 7119 gross | 837 / 2754 / 16897 net, 890 / 2913 / 19259 gross |
| a lease’s end (worst decision wake + R10) | 6463 + 4132 = 10595 | 16897 + 4221 = 21118 |
The run’s 49,535 audits total 12.7 s (rv64), and its 50,037 total 15.3 s (rv32), of the hart; R10
itself has none inside it, which the oracle asserts. Beside each destruction’s R10 time the oracle
reports its threads’ time: the processes’ threads ending inside it, their pumps included (the
trace’s T and t records), at seed 3 a p99 of 54 µs on rv64 and 60 µs on rv32. In the containment gate, with two
full leases live at a deadline’s end (seed 13, at one hart), the deadline notice’s p99 is 25,794 µs
net and 95,579 µs gross on rv64, with 1,158,114 µs of audit inside its windows, and 26,197 µs net
and 97,703 µs gross on rv32; the sweep is on containment. With the audit
after a destruction left unstamped (audit-unstamped, the recorded negative run), it is 84,710 µs
net on rv64: the target misses, since the oracle subtracts only what the trace shows it. With each
audit’s time billed to the budget that ran it and counted against its slice (audit-billed, the
second recorded negative run), it is 44,761 and 45,577 µs net: the target misses. The steward
stand-in spends its slice on the audit at the first killed notice, is requeued, and the victim
and a hostile budget run a slice each before it takes the second.
A share is judged from the same trace: what the kernel charged the budget in the window, against
what it charged every budget, never a count, since under icount a count is the machine’s
instructions (checked builds). An audit is charged to no budget,
so every share is net of the audits, and on several harts net of the lock waits too. In a checked
build only, a hart’s wait for the kernel lock behind another hart’s audit is billed to no one
either, and the waiter’s slice’s end moves by it (the trace’s y). That changes the schedule as
well as the bill: an excused waiter keeps its slice, stays on its hart and meets the next audit.
At two harts deadline-flood-billed-traced’s victim, whose creator’s destructions each run a
20 ms audit, moved from 875 to 413 of 1000 on rv64 with it, from one mode of its share to the
other; with the lift target (“The current minimum and ties”) it kept 482 to 877, by where the
destructions landed; with a destruction armed on the creator’s hart it keeps 919 to 924 or 507 to
530 under icount, by which hart’s entry first reaches a deadline that passed during the destruction
before it, and 762 to 802 where the harts run at once (residual risks,
timer). Each is
owed its water-filling share, which at one hart is its weight’s; the victims of churn, floods and
gaming are owed at least theirs. Measured at 1 ms in the checked build, of 1000, rv64 / rv32:
| Share (owed at one hart, at two) | One hart | Two harts |
|---|---|---|
sched-share, the 300 spinner (600, 500) | 599 / 599 | 467 / 465 |
sched-share, each 100 spinner (200, 250) | 200 / 200 | 266 / 267 |
sched-large-weight’s server (557, kept at one hart) | 555 / 553 | |
sched-idle-gap, B after the gap (333) | 333 / 332 | 333 / 332 |
sched-sleep-gaming, the near-slice victim (500) | 500 / 499 | 498 / 483 |
sched-budget-churn, the spinning parent’s victim (500) | 486 / 486 | 548 / 592 |
sched-exit-churn, against threads that exit (500) | 500 / 504 | 554 / 587 |
sched-exit-churn, against processes that exit (500) | 499 / 500 | 621 / 631 |
sched-exit-churn, against processes that fault (500) | 499 / 499 | 616 / 627 |
sched-timer-flood, against sleepers and staggered deadlines (500) | 499 / 499 | 644 / 552 |
sched-timer-flood, against waits ended early (500) | 499 / 500 | 539 / 563 |
sched-carve-inflation, four deep (500) | 499 / 498 | 460 / 538 |
sched-carve-return, after the carve’s return (500, kept at one hart) | 500 / 499 | |
sched-budget-churn-shell, the shell’s victim (500) | 633 / 535 | 571 / 539 |
deadline-flood-billed-traced, 16 deadlines (500) | 501 / 499 | 507 or 924 by phase / 919 (773 / 765 on the host clock) |
deadline-flood-billed-traced, 64 deadlines (500) | 498 / 498 | 526 or 530 by phase / 920 (802 / 788 on the host clock) |
| the containment gate’s bystander (833, 500 reported) | 833 / 833 | 450, 567 / 557 |
sched-large-weight‘s users and sched-server-busy judge a ratio of counts between budgets alike,
which the machine’s rate leaves as it is: the least user gets 992 and 988 per 1000 of the users’
mean, against a floor of 900, and the server’s work for A is 351 and 363 per 1000 of the users’
mean (one hart, rv64 then rv32).
The gate runs one pinned seed. The guest’s boot RNG seed decides the PIDs the kernel draws, which shift instruction counts and so the phase of every later event; with it pinned, a run repeats exactly, and the case prints the seed so that a failure replays. One seed hides the spread, so a sweep of about 16 seeds measures it. A target that the stride rank decides (how many spinners’ slices a waking budget with a lead lands behind) is the sweep’s worst case plus a stated margin. The sweep’s seeds and worst case are recorded here with the target they set.
| Measure | Target |
|---|---|
| driver wake: the RTC’s time when the driver runs, less the alarm it set | p50 <= 15 ms, p99 <= 50 ms |
steward timer wake: time_now when it runs, less its timeout’s deadline | p50 <= 15 ms, p99 <= 50 ms |
| steward decision wake: the same, for the timeout after which it destroys a lease | p50 <= 25 ms, p99 <= 95 ms (from the fourth sweep below; the third sweep set the same 25 and 95 ms) |
deadline notice: the lease’s killed notice received, less the lease’s deadline | p99 <= 40 ms (back from 54 ms when destruction scanned every object frame; budgets) |
| R10 kernel time of one destruction, from the trace | p99 <= 30 ms (back from 39 ms when destruction scanned every object frame; budgets) |
| a lease’s end from the steward’s decision: the worst decision-wake p99 + R10’s p99 | <= 95 + 30 = 125 ms, asserted as one sum by the post-check |
budget_destroy, call to return | p99 <= 51 ms: one round at N = 16, R10’s 30 ms plus (runnable budgets + 2) slices, 21 of 1 ms (destroy_call_p99_us, judged net of audits as the wakes are) |
| the 1000-weight server’s share of the spinning CPU at N = 16 | at least 384 less 30 per thousand |
In instructions: 15 ms is 1,875,000, 25 ms is 3,125,000, 30 ms is 3,750,000, 40 ms is 5,000,000, 50 ms is 6,250,000, 95 ms is 11,875,000, 125 ms is 15,625,000, and one 1 ms slice is 125,000.
At 2 harts the targets above are gated; at 4 they are recorded (gate_harts). Under icount with
several harts the clock counts every running hart’s instructions: the harts take turns on one host
thread, so a millisecond is still 125,000 instructions, now of the whole machine, and a hart that
halts gives its turns to the others. Measured at the pinned seed 3 in the checked, traced build,
p99 in µs, net / gross:
| Measure (p99, N = 1 / 4 / 16) | rv64, 2 harts | rv32, 2 harts |
|---|---|---|
| deadline notice | 4312 / 4144 / 4369 net, 27733 / 29428 / 40113 gross | 4465 / 4366 / 5352 net, 32146 / 34232 / 46496 gross |
| driver wake | 2700 / 4395 / 7177 net, 4807 / 5395 / 14640 gross | 3285 / 6186 / 11816 net, 5777 / 7607 / 22218 gross |
| steward timer wake | 2663 / 4847 / 16086 net, 3582 / 6525 / 30816 gross | 4066 / 8777 / 20959 net, 7744 / 12428 / 38813 gross |
| steward decision wake | 2115 / 2496 / 5209 net, 2306 / 2777 / 7645 gross | 2087 / 1676 / 9264 net, 2307 / 1890 / 14821 gross |
| a lease’s end (worst decision wake + R10) | 5209 + 2956 = 8165 | 9264 + 2894 = 12158 |
| Measure (p99, N = 1 / 4 / 16) | rv64, 4 harts | rv32, 4 harts |
|---|---|---|
| deadline notice | 4556 / 9490 / 3971 net, 31333 / 36374 / 45392 gross | 5904 / 10897 / 4231 net, 36140 / 42978 / 51390 gross |
| driver wake | 5745 / 17617 / 26569 net, 13543 / 22239 / 44502 gross | 7325 / 21054 / 36694 net, 16467 / 26857 / 67374 gross |
| steward timer wake | 6186 / 17173 / 25141 net, 14497 / 21241 / 45379 gross | 9080 / 18145 / 25771 net, 17931 / 23633 / 46649 gross |
| steward decision wake | 5221 / 13631 / 19727 net, 9871 / 17125 / 40918 gross | 5522 / 16758 / 21598 net, 10683 / 21050 / 34622 gross |
| a lease’s end (worst decision wake + R10) | 19727 + 2962 = 22689 | 21598 + 2902 = 24500 |
Every target is met at two harts on both widths, and over a 16-seed sweep there too: the worst
net p99s over the sweep’s N = 1, 4 and 16 are, rv64 then rv32, 11.5 and 13.0 ms for the driver
wake, 18.2 and 26.5 ms for the steward timer wake, 6.8 and 13.7 ms for its decision wake and 5.4
and 12.0 ms for the deadline notice, and a lease’s end at most 9.8 and 16.6 ms; the worst p50 is
7.2 ms (rv32’s timer wake at N = 16). At four, every p99 is met, and one p50 is not:
rv32’s driver wake at N = 16, 17.2 ms against 15 ms; rv64’s is 12.6 ms. At four harts a wake can
wait for the kernel lock behind three sections (residual risks).
sched-lock-contention holds the lock’s order under R12’s costliest call at two and four harts,
and gates the driver’s wake there, both targets at two harts and the p50 at four
(R78).
A slice end adds its own kernel time (charging). In the release build that is about 7,050 instructions on rv64 and 10,600 on rv32 when it switches budgets (4,200 and 6,700 when the same thread runs on), so a 1 ms slice takes about 1.06 ms and 1.09 ms on the hart with two runnable budgets, each further queued budget adding under 3 µs. The checked, traced build takes longer, its slice end growing by 2.4 µs per queued budget net of its audits: the median gap between slice ends, the marks’ audit included, is about 1.22 ms under three, 1.30 ms under nine and 1.39 ms under seventeen (rv64). A round of N budgets’ slices is N such periods, not N ms.
The decision wake is measured by the stand-in itself (time_now against its own deadline), and
the post-check reads its sample windows, net of audits, while R10’s time comes from the kernel’s
trace: that half of the lease-end sum is the program’s own report, not the kernel’s.
With the real steward. steward-latency measures the same quantities from the steward
itself: alice’s session holds N live leases, the probe’s own lease or the kernel’s deadline lease
among them beside N - 1 spinning agents (Redoubt.Agents.Echo spin, each a beamlet VM of 5,248
pages on a lease), N = 1, 2, 3, 4 and 16, D = 16 of each kind per round and 8 at N = 16; a round’s
deadline leases run one at a time beside the spinners, each a sample of its own lease grouped at N,
the deadline lease twice that round’s worst launch over both widths at one and two harts, rounded
up to a second, so the deadline falls on a running VM and never inside the launch (a deadline runs
from the time the steward sets as the launch’s batch starts, and a lease shorter than its launch
dies before its VM runs, budgets): N = 1 8 s, N = 2 10 s, N = 3 11 s,
N = 4 and N = 16 14 s (2 × 3.84 / 4.47 / 5.09 / 6.62 / 6.64 s; N = 2 rounds to 10 not 9 because the
probe tells its own lease by 9 s, so no measured lease may be 9 s). Each deadline lease is followed
by a sleep of the lease plus a second — the margin over the kernel’s R10 destruction (≤ 30 ms) that
frees alice’s pages — both the guest’s clock under icount, so the next lease is requested only
after the previous is gone, which also holds the N = 16 carve, where alice’s share leaves room for
one measured lease beside the fifteen spinners. One at a time, not a burst: deadline_notice is a
single lease’s notice latency (the sponsor told — the steward’s lease_ended send to its session
returned — less the deadline; target 40 ms; the send adds 10 to 14 ms to the dequeue it was once
sampled at, measured as the first notice of each burst below with the shell idle; the table’s
deadline notice row is re-measured at the send, 14 to 32 ms where the dequeue read 8 to 16). A burst of D
deadlines firing together hands the steward D Exited events at once, and the sponsor is told of
the last of them about D times the core’s decision of one exit after the first, every notice of
the burst sent before any exit’s bookkeeping: measured by steward-deadline-burst (six leases
on one shared deadline, both widths) and steward-deadline-burst-16 (sixteen, rv64), each
lease’s notice the send’s return less that lease’s own deadline; the first 0.43 to 0.96 s gross
(32 to 149 ms net of the checked build’s audits) after it — the kernel destroying the six or
sixteen VM budgets at its one entry at the deadline before any exit notice goes out, each within
R10, and the loop then taking the reports together — then each send returning 13 to 77 ms after
the previous, the decision of the next exit; the worst of six 0.91 to 1.12 s gross after its
deadline (0.13 to 0.16 s net), the worst of sixteen 1.42 to 1.47 s (0.33 to 0.43 s net), one
run per column at the seed, every notice’s line printed by the session; the cases assert twice
the worst rounded up, 3 s at both sizes, and every line; the exits’ bookkeeping follows the
sends, 17 to 297 ms of effects and then 0.17 to 0.78 s of console lines at the loop’s next
turn. With the bookkeeping between the sends they returned 40 to 145 ms apart, the loop’s work
for one exit (18 ms of its own time in a 41 ms gap on a traced boot, the checked kernel’s audits
the rest), the worst of six 1.13 to 1.43 s and of sixteen 1.8 to 2.5 s. The sends themselves
once set the pace: beamlet’s serve thread forwarded each to the session’s VM by a send that
waited for the VM to take it, a VM busy in Erlang took one per scheduler turn, and the sends
returned 40 to 300 ms apart, the last of six 1.5 to 1.8 s and of sixteen 2.1 to 2.8 s after the
deadline, one of six or of sixteen lost at the steward’s bound on rv64 at one hart; under that
gate the steward’s ordering (its notices ahead of the exits’ bookkeeping, 16 ms of the 1.4 s
then, kept now) and the shell’s listener (receiving and queueing, printing in another process)
were each built, measured and found to move nothing, and a block trace placed the wait in that
forward, which the thread’s queue removes
(the steward,
beamlet). What the sponsor sees is the shell’s
print of each notice, 250 to 370 ms a line through the relay. The
deadline inside a launch: these rounds have the steward idle at each deadline, and a
launch in flight once held the serve loop inside a synchronous start_agent (2-6 s), so a
deadline that fired during one waited behind it. steward-launch-overlap stages that overlap
(a lease’s deadline one second inside another lease’s launch, five times a boot, both widths;
judged only when the trace shows the deadline inside the launch’s arrival-to-answer window,
else a failing verdict): with the launch on the loop the notice came 0.36 to 0.58 s net after
the deadline (0.76 to 1.5 s gross, the launch’s remainder: rv64 at one hart, two harts, rv32
at one); with the steward’s launch thread running the launch while the loop serves, the sponsor
is told 16 to 36 ms net after it (p99 20 / 36 / 19 ms on the same three boots, target 40 ms
met), the loop’s worst wait on the thread’s install lock 33 µs, 5.2 ms and 50 µs
(the steward). What still holds the loop inside a lease’s batch
is its mints at the shared servers, within SERVER_US (4 s; 1.66 s worst measured): a deadline
inside a mint waits for it, a residual on the steward’s page. The case measures the per-notice
quantity, one lease at a
time. The trace ring (256 MiB, 8,388,608 records) is per boot, and a single boot of all five rounds
overran it at two harts, a boot of N = 1 to 4 too (the rounds record about 1.0 / 2.2 / 2.4 / 3.1 /
1.5 M at N = 1 / 2 / 3 / 4 / 16, and on rv32 N = 1 to 3 alone fill 6.0 M at two harts), so the
rounds run as three boots judged on their own rings: steward-latency runs N = 1, 2 and 3,
steward-latency-n4 the round at N = 4 and steward-latency-n16 the round at N = 16, on rv64
alone (below). The probe ends each boot’s window on the latency-done marker the case writes after
its last round, so one build serves a three-round boot and a one-round boot alike. The steward’s
test-only probe arms
its timer at the last point before the receive that waits on it, prints its wake, its decision
wake and its budget_destroy call to return, and the kernel’s deadline notice, as the stand-in
prints them; the post-check judges them net of audits with the targets above, unchanged, and
R10 from the trace. The load shapes differ: the stand-in’s spinners were plain budgets of weight
100 under users, the steward’s are lease VMs under alice’s share, each a sizes.agent budget;
the measured quantities are per lease end, whatever carries the load. The steward runs at the
workload’s weight, 1,000 under system, the image’s own, so the targets are measured at the
weight the image runs the steward at. A lease’s end is measured to its
destruction and the notice to the sponsor: the servers’ records are freed after, off the measured
path. N = 4 is measured at one hart since every shared server’s budget became its manifest
entry’s (serving), and the round at N = 16 runs since the
per-account caps’ lease term is derived from the manifest as the context cap is
(the steward): the case’s alice is sized for it by the carve,
213,542 pages, 104 processes and weight 2,200 over two label sets, so her unlabelled share of
106,770 pages, 52 processes and weight 1,100 holds two sessions, the console’s and the SSH
session that drives the rounds (2 × 11,393 pages), and sixteen leases of 5,248 pages, 2
processes and weight 50, their own pages beside (2 × 11,393 + 16 × 5,249 = 106,770), and her
home’s quota, 18 MiB, sixteen project carves of 1 MiB; the servers’ caps follow
(erofsd:system 2 × 5 + 18 connections, the home and /boot 5 + 18, and 21 parked calls at the
home: what the share carves at once and a spare, littlefsd).
N = 3 was not reached while a cleanup step a busy server did not
take was dropped, leaking a State there until the eighth was refused; the step is parked and
retried at the server’s next answer now
(steward), and the round runs on both widths at
one and two harts; the parked after 4 attempts line, which appeared one to four times a run
for a lease’s disconnect at erofsd:system under the two spinners while the loop made the
attempts, appears in neither width’s run now that the steward’s own thread makes them. Measured
at seed 3, one run each, net µs p50/p99 at
N = 1 / 2 / 3 / 4 / 16; each lease destroyed is a VM budget of 5,248 pages, and the gross figures beside
the deadline notice and the destroy call carry 300-500 ms of the checked build’s audits after
such a destruction (subtracted, as every target is net):
| Measure (net µs, N = 1 / 2 / 3 / 4 / 16; D = 8 at 16) | rv64, 1 hart | rv32, 1 hart | rv64, 2 harts | rv32, 2 harts (--smp 2, recorded, not gated) |
|---|---|---|---|---|
| steward timer wake (p50/p99) | 965/1944, 789/3472, 1227/2555, 821/5165, 599/1405 | 727/1579, 1242/1843, 888/1167, 801/1507; N = 16 not run (a spinner dies, below) | 1761/5085, 2867/23003, 1901/4066, 2336/7692, 3592/5357 | 5563/5636, 31831/35239 (the p50 missed, below), 4160/5396, 3284/40940; N = 16 not run |
| steward decision wake (p50/p99) | 1156/8292, 17425/20974, 645/2230, 965/2000, 736/1532 | 30376/33376 (N = 1, recorded, not judged: below; 24235/28493 with the launch on the loop), 1202/1440, 969/2299, 775/1888 | 3474/18540, 1696/21203, 9798/25163, 3265/25254, 14107/29787 | 3963/4517, 4583/39824, 21757/40031, 3112/41174 |
| deadline notice (p99; the sponsor’s send returned, re-measured) | 17882, 18016, 19734, 18813, 14349 | 16303, 17365, 15288, 17349 | 30393, 32398, 24567, 27239, 29907 | 13021, 13996, 12731, 13997 (at the dequeue, not re-measured) |
budget_destroy, call to return (p50/p99) | 6012/8329, 6081/8372, 5016/8412, 5468/8354, 6204/8762 | 6710/10251, 5360/8315, 6404/7910, 6460/9557 | 8178/9787, 8390/11010, 7938/10401, 7800/11411, 7545/9390 | 11738/13577, 11350/14014, 11316/13198, 10016/16204 |
| R10 (p99; the boots at N = 1 to 3 / 4 / 16, 99 / 35 / 31 destructions) | 9695 / 9716 / 4800 | 9651 / 9682 | 10042 / 9804 / 5490 | 9801 / 10063 |
| a lease’s end (worst decision wake p99 + R10, per boot) | 20974 + 9695 = 30669; 2000 + 9716 = 11716; 1532 + 4800 = 6332 | 40468 + 9651 = 50119; 1888 + 9682 = 11570 | 25163 + 10042 = 35205; 25254 + 9804 = 35058; 29787 + 5490 = 35277 | 40031 + 9801 = 49832; 41174 + 10063 = 51237 |
| reply to the next receive (p50/p99, no target) | 8560/8679, 8722/9777, 9861/11494, 8699/10986, 11809/15021 | 8894/8949, 9278/11920, 9344/10578, 10614/12382 | 43647/48305, 23133/25010, 23952/36940, 26169/37061, 24355/32217 | 77231/80354, 39057/84666, 38811/50840, 39781/48369 |
Each boot’s trace fit its ring with nothing dropped: 4,841,622 / 2,876,371 / 1,022,850 records
of 8,388,608 for the boots at N = 1 to 3 / 4 / 16 on rv64 at one hart, 5,525,840 / 3,711,105 /
1,363,142 at two; 5,005,142 / 2,963,667 on rv32 at one hart, 5,979,365 / 3,932,389 at two. The
real steward meets every target on rv64 at one and two harts, N = 1 to 16, and on rv32 at
one hart, N = 1 to 4, each column one run at seed 3 with the cleanup steps on the steward’s own
thread (steward): the timer wake’s p99 at N = 3 and
4 is 2.6 and 5.2 ms on rv64, 1.2 and 1.5 ms on rv32, and the reply-to-receive p99 in those rounds
11.5 and 11.0 ms, 10.6 and 12.4 ms, with no 100 ms retry in any sample. At N = 16 on rv64 at one
hart the timer wake meets its targets, 0.6 ms at p50 and 1.4 at p99 (54.5 and 57.3 ms when the round
first ran, from the tree before the thread, where the wake waited behind whole slices of the
fifteen spinner VMs: the cleanup steps’ turn, not the spinners’, was the slice it waited for),
and at two harts the N = 16 round’s thirty-one launches are all served, with no parked after
line in either run: the refusal of a 29th launch at erofsd:system behind eight parked
disconnects, seen once at two harts before the thread, does not recur. Before the thread, the
loop ran the cleanup steps at its top within its 100 ms send bound, and one sample of sixteen
in the N = 3 and 4 rounds (78 and 93 ms on rv64, 69 and 93 on rv32; the next largest under 4 and
24 ms) held one attempt at a disconnect the busy erofsd:system did not take, so the p99
(50 ms) was missed by construction; the same retry showed in those rounds’ reply-to-receive p99
(124 and 65 ms, 135 and 64) and at N = 4 in the decision wake’s (89 and 66 ms, within its 95),
where the lease the probe ends waited behind that turn, and at two harts on rv64 as the N = 3
timer wake’s one miss (148 ms, 112 in the reply-to-receive row).
A lease’s launch is a measured call, and its bound follows the measurement. The steward’s
served start_agent line gives the launch from the call’s arrival to its answer: the batch
that carves the lease, mints its connections and reads its program from the verified system
volume, run beside the sponsor’s other leases spinning. The worst of each round’s launches, one
run at the seed:
start_agent served, worst (s) | alone | beside 1 spinner | beside 2 | beside 3 | beside 15 |
|---|---|---|---|---|---|
| rv64, 1 hart | 1.70 | 2.23 | 2.56 | 3.00 | 3.13 |
| rv64, 2 harts | 2.67 | 2.89 | 3.65 | 3.95 | 3.77 |
| rv32, 1 hart | 2.27 | 3.53 | 3.90 | 4.97 (5.85 when the bound was derived) | 5.20 |
| rv32, 2 harts | 3.84 | 4.47 | 5.09 | 6.62 | 6.26 |
The launch was the one steward call past the client library’s 5 s bound for a short call, and
that 5 s was the VM’s own ceiling on a call’s timeout, MAX_CALL_MS, a guess
(beamlet): on rv32 the round at N = 4 at one hart and N = 3
at two died on the match of {:error, :timeout}, and the measure then available, the gap
between consecutive AgentStarted audit lines less the session’s sleep, overstated the launch
under load by the shell VM’s own share and its sleep’s lag (6.8 s against 3.1 s served, beside
fifteen on rv64 at one hart). The bound is derived once, over both widths at one and two harts,
from every round the runs reached: twice the worst launch served beside the spinners the case
runs, 6.64 s on rv32 at two harts, the N = 16 round’s fifteenth spinner launched beside fourteen
(a launch between the table’s columns; 5.851 s at one hart beside three, when the 5 s bound
ended that round), 2 × 6.64 s = 13.28 s, rounded up to 14 s, the client’s launch_timeout/0
and the VM’s MAX_CALL_MS one number (the steward); the
rounds reached with it show launches up to 6.64 s, within it. The launch is compute: beside
spinners of its own weight the launching VM’s share of the harts is theirs divided, 3.0 s beside
three on rv64 at one hart against 1.7 s alone, and a block trace (block-trace) of the wedge
before the bound showed the steward’s every call out returned within 1.43 s, its only long waits
receives on its own endpoint, and the read path’s servers (erofsd:system, verity:system,
blkd:system) running whenever a reader asked; verityd’s data cache and read-ahead
(verityd) cut its blkd reads by two
thirds without moving the launch. With the bound the rv32 rounds run: at one hart N = 1 to 4
meet every target, and at two harts N = 1 to 4 complete for the first time, their rows not
judged, since that boot ends in the round after them before the reset dumps the trace the
oracle judges from (below); they are judged when a run reaches the reset. The one-hart rv32
column’s rows at N = 1 to 4 are this run’s; the launch’s own spread beside three spinners on
rv32 at one hart is 2.76 to 5.85 s over the thirteen launches of the round the bound was
derived from, with no step of the steward’s differing between them.
Two rv32 rows are findings, not targets met. With the deadline lease derived from the
launch every round completes on rv64 at one and two harts and on rv32 at one hart up to N = 4,
each boot’s reset dumping a trace the oracle judges whole (dropped 0); before it, the round’s
five-second leases, sized as twice a two-hart launch when that launch was 2.5 s, died inside
their own launch on rv32 (5.2 s beside fifteen spinners at one hart, 5.7 to 6.3 s at two), at
one hart with no deadline-notice sample and at two harts with a Launch the kernel refused
NotPermitted, the deadline passed before the process was made. The round at N = 16 still does
not complete on rv32 when it was measured: beside fifteen spinners one spinner VM exited 1
before the round’s first deadline notice, after which the samples grouped at N = 15 around the
dead VM (the deadline notice’s p99 over the eight that lived about 45 ms, on a round that had
lost a VM). The cause was found and closed on the read path: not the drain rate but admission,
the file servers’ fid cap of 32 fair-shared with every connection’s root fid in the share, so
that at sixteen connections of one account (two sessions’ and fourteen leases’) no VM of the
account could load a module, and the spinner launched last died inside its start module’s read
(erofsd); the root is a fixed cost outside the share now and
the caps are derived from the connections, and sixteen agents of one account launch and load
all their modules on both widths at one and two harts and every one exits 0
(bench:lease-code-load, on the host’s clock: under the virtual clock the guest, idle for an
instant with no nearer timer, reached the oldest live lease’s deadline, the test bench).
steward-latency-n16 still runs on
rv64 alone: its rv32 round was not re-measured with the cap, and the column says so until it
is. At two harts on rv32 the N = 1 to 3 boot reaches the reset and is judged for the first
time, and the N = 2 round misses the timer wake’s p50, 31.8 ms against 15 (its p99 35.2 ms,
met), the same within 30 µs over five boots (31,855, 31,835, 31,825, 31,832 and 31,831 µs),
every other target of the boot met with dropped 0 and the kernel lock waited for in 249 of
1000 ticks; N = 1 is 5.6 ms and N = 3 4.2 ms, so the miss is the one-spinner round’s: a kernel lock-contention finding at
two harts (a wake can wait for the kernel lock there, R78 and
residual risks), to be taken up on its own — why the round with one spinner,
then a fix or a stated residual. The table carries the rv32 two-hart rows with their numbers,
and rv32 is gated at one hart until it lands, rv64 at both. The a device interrupt returned without its claim rejection of the rv32 two-hart trace, one run in two at this seed before, met
neither rv32 two-hart boot here. Before the steward’s console lines were queued and written through one kept
fid at the loop’s turn (steward), the steward
reached its wait with the console round trips of the reply it had just given on its pass (each
line an attach, open, write and close at consoled), the timer wake’s p50 missed on every width
and hart count (23 to 61 ms), and at two harts the decision wake’s p50 and the deadline notice’s
p99 missed too; the reply-to-receive gap was 34-56 ms at one hart and 94-162 at two. Now a call’s
records go before its reply, one write each, and the gap (the last row, recorded, no target) is
the loop’s own work and the reply’s hand-over to the caller, with no console call in it. The
two-hart figures are gated as the stand-in’s are, and the rv32 two-hart row’s miss of the timer
wake’s p50 at N = 2, every sample near 40 ms with the one spinner’s VM live before the round at
N = 3 ran, is the finding above, judged now. The same finding at one hart on rv32, one cell:
the decision wake at N = 1 is a bare 30 ms receive timeout that sleeps 53 to 62 ms with exactly
one lease VM running (the round’s own, just launched and spinning) and wakes 1 to 3 ms late
beside a second, the steward doing nothing in the window; it measured 24,235 / 28,493 µs with
the launch on the steward’s loop, 0.8 ms under the 25 ms p50, and 30,376 / 33,376 with the
launch on the steward’s launch thread (the steward), whose
report lands the loop’s receive about 10 ms earlier against the VM’s first run; the same tree
with the launch put back on the loop measured 23,300 / 31,843, so the thread moved the phase of
a pathological wake, not the wake (the 20 ms timer wake at N = 1 is 2.2 to 3 ms late with the
thread, 0.8 without, within its 15 ms). The cell is recorded, not judged (record= on the case,
the target unmoved; the table above carries both numbers), the one-runnable-VM timeout wake
being the scheduler finding above: a sleeper’s timeout that does not end the running VM’s slice,
the timer apparently armed for the slice’s end rather than the nearest timeout, visible only
when nothing else switches. Landing the phase under by putting process_start back on the loop
would be designing to pass an anomalous wake, and is not done. The cases stay out of the whole run, by name, until
the two-hart rows pass on both widths. The destructions judged are those before the probe’s
LATENCY-END (99 in the N = 1 to 3 boot, 165 over the three); the sixteen after it in each boot
(the marker-file exits reaping the leases) are recorded and not judged.
The sweep (2026-09-27, seeds 1 to 16, on rv64 and rv32, TESTBENCH_QEMU_SEED; the gate runs
seed 3, this sweep’s worst, and no gate sets the variable). The steward decision wake, p50 / p99 in µs at N = 1, 4 and 16, and a lease’s end (the
worst decision-wake p99 plus R10’s p99 from the trace):
| Seed | rv64 N=1 | rv64 N=4 | rv64 N=16 | rv64 lease end | rv32 N=1 | rv32 N=4 | rv32 N=16 | rv32 lease end |
|---|---|---|---|---|---|---|---|---|
| 1 | 5919 / 5920 | 6130 / 6131 | 17903 / 82467 | 108846 | 6700 / 17101 | 6394 / 6396 | 7597 / 29468 | 56874 |
| 2 | 5919 / 5920 | 6130 / 6131 | 17903 / 82466 | 108845 | 6701 / 17099 | 6395 / 6397 | 7596 / 29466 | 56872 |
| 3 | 5918 / 5920 | 6130 / 16512 | 17902 / 103984 | 130363 | 6700 / 17097 | 6394 / 6398 | 7598 / 29476 | 56882 |
| 4 | 5919 / 5920 | 6130 / 16512 | 17902 / 82468 | 108847 | 6701 / 17097 | 6395 / 6397 | 7597 / 29469 | 56875 |
| 5 | 5919 / 5920 | 6130 / 16511 | 17902 / 86653 | 113032 | 6700 / 17098 | 6394 / 6396 | 7597 / 29464 | 56870 |
| 6 | 5919 / 5920 | 6130 / 16511 | 17904 / 82467 | 108846 | 6701 / 17099 | 6394 / 6396 | 7596 / 29469 | 56876 |
| 7 | 5919 / 5920 | 6130 / 6131 | 17902 / 82466 | 108845 | 6700 / 17099 | 6394 / 6396 | 7597 / 29465 | 56870 |
| 8 | 5919 / 5920 | 6130 / 6130 | 17903 / 60947 | 87326 | 6700 / 17098 | 6395 / 6397 | 7598 / 29469 | 56875 |
| 9 | 5919 / 5920 | 6130 / 16512 | 17901 / 82463 | 108842 | 6701 / 17098 | 6394 / 16883 | 7596 / 40403 | 67809 |
| 10 | 5919 / 5920 | 6130 / 16512 | 17900 / 94272 | 120651 | 6700 / 17100 | 6395 / 6397 | 7597 / 40399 | 67805 |
| 11 | 5919 / 5920 | 6130 / 6131 | 17902 / 93225 | 119604 | 6701 / 17095 | 6394 / 16873 | 7596 / 29461 | 56867 |
| 12 | 5919 / 5920 | 6130 / 6131 | 17904 / 82464 | 108843 | 6700 / 17100 | 6395 / 6397 | 7596 / 40407 | 67814 |
| 13 | 5919 / 5920 | 6130 / 16512 | 17901 / 94273 | 120652 | 6700 / 17099 | 6395 / 16882 | 7596 / 40401 | 67807 |
| 14 | 5919 / 5920 | 6130 / 26893 | 17902 / 93225 | 119604 | 6701 / 17098 | 6395 / 6397 | 7597 / 40396 | 67802 |
| 15 | 5919 / 5920 | 6130 / 16510 | 17903 / 93224 | 119603 | 6701 / 17097 | 6394 / 6396 | 7597 / 40403 | 67810 |
| 16 | 5919 / 5920 | 6130 / 16512 | 17902 / 82464 | 108843 | 6700 / 17096 | 6395 / 6397 | 7597 / 29466 | 56872 |
The worst case is rv64 at N = 16: decision-wake p50 17,904 µs (every seed within 4 µs of it) and p99 103,984 µs (seed 3); R10’s p99 is 26,379 µs (rv64) and 27,407 µs (rv32) on every seed. The targets were 15 ms (p50) and 50 ms (p99) before the sweep, which rv64 missed on every seed; the sweep sets them at the worst case plus a margin of a tenth, rounded up to 5 ms: p50 17.9 x 1.1 = 19.7, so 20 ms; p99 104 x 1.1 = 114.4, so 115 ms. Every other measure met its fixed target on every seed; their worst p99s were driver wake 33.3 ms, steward timer wake 34.2 ms and deadline notice 23.7 ms (all rv32), and the server’s share never fell below 380 of 1000.
The second sweep (2026-09-29, the same seeds and widths) followed a change that found process objects by index instead of scanning frames. The change made most destructions cheaper, which moved the workload’s timeline: the last lease destructions at N = 16 now overlap the live sessions. There, taking a lease’s exit notice inside R10 frees its process object, and that object’s handle sweep walks every live process’s handle pages. The N = 16 deadline notice and R10’s kernel time, p99 in µs, and a lease’s end:
| Seed | rv64 notice | rv64 R10 | rv64 lease end | rv32 notice | rv32 R10 | rv32 lease end |
|---|---|---|---|---|---|---|
| 1 | 36195 | 33863 | 73287 | 37902 | 35107 | 86435 |
| 2 | 36316 | 33985 | 73409 | 38023 | 35235 | 86561 |
| 3 | 36287 | 33943 | 94886 | 37989 | 35190 | 86530 |
| 4 | 36331 | 34000 | 94941 | 38042 | 35251 | 97523 |
| 5 | 36156 | 33823 | 73245 | 37868 | 35069 | 75474 |
| 6 | 36495 | 34164 | 84347 | 38220 | 35421 | 86759 |
| 7 | 37940 | 34116 | 84297 | 38164 | 35370 | 75771 |
| 8 | 37836 | 34084 | 105786 | 38144 | 35337 | 97606 |
| 9 | 36174 | 33843 | 73264 | 37882 | 35089 | 86413 |
| 10 | 36198 | 33866 | 94809 | 39346 | 35113 | 86447 |
| 11 | 37919 | 34131 | 84313 | 38177 | 35386 | 75790 |
| 12 | 36486 | 34156 | 73581 | 38203 | 35412 | 86748 |
| 13 | 36432 | 34101 | 73522 | 38146 | 35355 | 75757 |
| 14 | 36177 | 33844 | 94786 | 37885 | 35090 | 86416 |
| 15 | 36291 | 33948 | 105650 | 48851 | 35196 | 75600 |
| 16 | 38661 | 33878 | 73301 | 37924 | 35124 | 86458 |
The worst are the deadline notice at 48,851 µs (rv32, seed 15) and R10 at 35,421 µs (rv32, seed 6). Their targets became the worst case plus a tenth, rounded up to 1 ms: 48.9 x 1.1 = 53.7, so 54 ms, and 35.4 x 1.1 = 39.0, so 39 ms. The lease end’s bound moved with R10’s, to 154 ms; its worst is 105.8 ms. The difference from 30 ms was the handle-sweep term of budget destruction’s cost. A destruction now follows the dying subtree and folds that sweep into one pass, so the term is gone and the targets are back at 30 and 40 ms above (budgets). Every other measure met its target on every seed. The gate stays on seed 3.
The third sweep (2026-09-29, the same seeds and widths) followed a change that frees a page table once it maps nothing. It found the first sweep’s rv64 median was one phase, not a property of rv64: the N = 16 decision wake’s p50 is about 7 ms on most seeds and about 18 ms on some, on either width (rv64 on seeds 6, 7, 11, 12 and 16, rv32 on seeds 5 and 9). The decision wake, p50 / p99 in µs, and a lease’s end:
| Seed | rv64 N=1 | rv64 N=4 | rv64 N=16 | rv64 lease end | rv32 N=1 | rv32 N=4 | rv32 N=16 | rv32 lease end |
|---|---|---|---|---|---|---|---|---|
| 1 | 5919 / 5920 | 6130 / 16511 | 7143 / 39423 | 73286 | 6155 / 6157 | 6395 / 16883 | 7599 / 51328 | 86437 |
| 2 | 5919 / 5920 | 6130 / 16512 | 7142 / 50181 | 84166 | 6155 / 6157 | 6395 / 16883 | 7598 / 51335 | 86570 |
| 3 | 5919 / 5920 | 6130 / 16512 | 7143 / 50182 | 84125 | 6155 / 6157 | 6395 / 16884 | 7598 / 51340 | 86530 |
| 4 | 5919 / 5920 | 6130 / 16512 | 7142 / 60941 | 94941 | 6155 / 6157 | 6395 / 16881 | 7598 / 62271 | 97522 |
| 5 | 5919 / 5920 | 6130 / 16510 | 7143 / 39422 | 73246 | 6154 / 6157 | 6395 / 16883 | 18523 / 40403 | 75472 |
| 6 | 5919 / 5920 | 6130 / 16512 | 17899 / 50182 | 84346 | 6155 / 6157 | 6396 / 16884 | 7599 / 51336 | 86756 |
| 7 | 5919 / 5920 | 6130 / 16512 | 17900 / 50181 | 84297 | 6155 / 6157 | 6395 / 16881 | 7598 / 40400 | 75770 |
| 8 | 5919 / 5920 | 6130 / 16512 | 7143 / 71702 | 105786 | 6155 / 6157 | 6395 / 16881 | 7599 / 40402 | 75739 |
| 9 | 5919 / 5920 | 6130 / 16512 | 7142 / 39421 | 73264 | 6155 / 6158 | 6395 / 16881 | 18529 / 51325 | 86413 |
| 10 | 5919 / 5920 | 6130 / 16512 | 7143 / 39422 | 73288 | 6155 / 6157 | 6395 / 16883 | 7599 / 40403 | 75516 |
| 11 | 5919 / 5920 | 6130 / 16511 | 17900 / 50182 | 84313 | 6155 / 6157 | 6395 / 16881 | 7599 / 40404 | 75789 |
| 12 | 5919 / 5920 | 6130 / 16511 | 17900 / 50184 | 84340 | 6155 / 6157 | 6395 / 16883 | 7598 / 40407 | 75818 |
| 13 | 5919 / 5920 | 6130 / 16512 | 7141 / 82463 | 116564 | 6155 / 6158 | 6395 / 16882 | 7597 / 40398 | 75752 |
| 14 | 5919 / 5920 | 6130 / 16511 | 7143 / 60942 | 94786 | 6155 / 6157 | 6395 / 16885 | 7599 / 51342 | 86433 |
| 15 | 5919 / 5920 | 6130 / 16512 | 7143 / 71701 | 105649 | 6154 / 6157 | 6395 / 16883 | 7599 / 51332 | 86527 |
| 16 | 5919 / 5920 | 6130 / 16512 | 17899 / 39423 | 73301 | 6155 / 6157 | 6395 / 16883 | 7599 / 51334 | 86458 |
The worst are p50 18,529 µs (rv32, seed 9) and p99 82,463 µs (rv64, seed 13). By the same rule, a tenth over the worst rounded up to 5 ms: p50 18.5 x 1.1 = 20.4, so 25 ms; p99 82.5 x 1.1 = 90.7, so 95 ms. The lease end’s bound follows, to 95 + 39 = 134 ms; its worst is 116.6 ms. Every other measure met its target on every seed. The gate stays on seed 3, which is no longer the worst seed; the targets come from the sweep, not from the seed the gate runs.
The fourth sweep (2026-09-30, the same seeds and widths) followed a destruction that walks what it destroys instead of every object frame (budgets), which cheapens R10 and moves the phase of every later event. R10 and the deadline notice return to the 30 ms and 40 ms they had before the second sweep. The decision wake’s worst p50 is 18,541 µs (rv32, seed 11) and its worst p99 82,497 µs (rv64, seed 6); a tenth over the worst rounded up to 5 ms sets the p50 at 25 ms and the p99 at 95 ms. A lease’s end follows to 95 + 30 = 125 ms. Every other measure met its target on every seed; the gate stays on seed 3. The decision wake, p50 / p99 in µs at N = 1, 4 and 16, and a lease’s end (the worst decision-wake p99 plus R10’s p99 from the trace):
| Seed | rv64 N=1 | rv64 N=4 | rv64 N=16 | rv64 lease end | rv32 N=1 | rv32 N=4 | rv32 N=16 | rv32 lease end |
|---|---|---|---|---|---|---|---|---|
| 1 | 5926 / 5926 | 6137 / 16521 | 7149 / 60964 | 82485 | 6162 / 6165 | 6402 / 16894 | 7606 / 62296 | 84795 |
| 2 | 5926 / 5926 | 6137 / 16522 | 7149 / 50199 | 71841 | 6162 / 6164 | 6402 / 16894 | 18537 / 51367 | 73994 |
| 3 | 5926 / 5926 | 6136 / 16521 | 7149 / 60967 | 82567 | 6161 / 6164 | 6402 / 16893 | 7608 / 51364 | 73947 |
| 4 | 5926 / 5926 | 6137 / 16521 | 7149 / 82492 | 104149 | 6162 / 6164 | 6401 / 16892 | 18539 / 62295 | 84939 |
| 5 | 5926 / 5926 | 6137 / 16521 | 7149 / 50202 | 71683 | 6162 / 6164 | 6401 / 16894 | 7607 / 51363 | 73824 |
| 6 | 5926 / 5926 | 6137 / 16521 | 7149 / 82497 | 104318 | 6162 / 6164 | 6402 / 16893 | 18539 / 40424 | 63236 |
| 7 | 5926 / 5926 | 6137 / 16522 | 7149 / 39440 | 61213 | 6162 / 6164 | 6402 / 16894 | 18538 / 51355 | 74116 |
| 8 | 5925 / 5926 | 6137 / 16522 | 7149 / 50202 | 71943 | 6162 / 6164 | 6402 / 16894 | 18538 / 51364 | 74094 |
| 9 | 5926 / 5926 | 6137 / 16522 | 7149 / 50202 | 71702 | 6162 / 6164 | 6402 / 16894 | 7607 / 51357 | 73836 |
| 10 | 5926 / 5926 | 6137 / 16522 | 7149 / 39440 | 60962 | 6162 / 6164 | 6402 / 16893 | 7608 / 51357 | 73860 |
| 11 | 5926 / 5926 | 6136 / 16521 | 7148 / 7149 | 38309 | 6162 / 6165 | 6402 / 16892 | 18541 / 51360 | 74136 |
| 12 | 5926 / 5926 | 6137 / 16521 | 7149 / 82494 | 104307 | 6162 / 6164 | 6402 / 16894 | 18540 / 51357 | 74160 |
| 13 | 5926 / 5926 | 6137 / 16522 | 7149 / 60965 | 82723 | 6162 / 6164 | 6402 / 16894 | 18539 / 51355 | 74101 |
| 14 | 5926 / 5926 | 6137 / 16522 | 7149 / 50204 | 71705 | 6162 / 6164 | 6402 / 16892 | 7607 / 62294 | 84775 |
| 15 | 5925 / 5926 | 6137 / 16521 | 7149 / 50202 | 71807 | 6162 / 6164 | 6402 / 16892 | 18536 / 51353 | 73940 |
| 16 | 5926 / 5926 | 6137 / 16522 | 7149 / 50204 | 71739 | 6162 / 6164 | 6402 / 16893 | 18538 / 51353 | 73869 |
The fifth sweep (2026-09-30, the same seeds and widths) followed a destruction that closes only the handles chained to what it destroys and frees the dying tables whole (budgets). R10’s p99 is 5,462 µs on rv64 and 5,723 µs on rv32 at most, on every seed, and the deadline notice’s worst p99 is 27,089 µs (rv64, seed 7). The decision wake’s worst p50 is 7,606 µs (rv32, seed 5) and its worst p99 50,993 µs (rv64, seed 15); a lease’s worst end is 56,454 µs. The targets stay where the fourth sweep set them. The steward now sets each by-deadline lease’s deadline 300 ms ahead, not 150: a budget’s deadline is fixed at its creation, before the spawn, and at N = 16 the spawn takes about 150 ms, so on one seed every lease ended before its process started and the notice took no sample. The lead is the fixture’s; the notice is measured from the deadline, and its target stays 40 ms. The decision wake, p50 / p99 in µs at N = 1, 4 and 16, and a lease’s end:
| Seed | rv64 N=1 | rv64 N=4 | rv64 N=16 | rv64 lease end | rv32 N=1 | rv32 N=4 | rv32 N=16 | rv32 lease end |
|---|---|---|---|---|---|---|---|---|
| 1 | 5926 / 5927 | 6137 / 16521 | 7149 / 28676 | 34137 | 6162 / 6165 | 7079 / 17572 | 7604 / 45854 | 51576 |
| 2 | 5926 / 5927 | 6137 / 16522 | 7149 / 28675 | 34136 | 6162 / 6165 | 7078 / 28060 | 7604 / 29479 | 35202 |
| 3 | 5926 / 5927 | 6137 / 16521 | 7150 / 39440 | 44901 | 6162 / 6164 | 7079 / 17571 | 7605 / 29479 | 35200 |
| 4 | 5926 / 5927 | 6137 / 16521 | 7149 / 28676 | 34137 | 6162 / 6164 | 7078 / 17570 | 7604 / 29483 | 35205 |
| 5 | 5926 / 5927 | 6137 / 16522 | 7149 / 39440 | 44902 | 6162 / 6165 | 7078 / 28061 | 7606 / 32510 | 38232 |
| 6 | 5926 / 5927 | 6138 / 16522 | 7150 / 50203 | 55664 | 6163 / 6164 | 7078 / 17571 | 7605 / 40422 | 46143 |
| 7 | 5926 / 5927 | 6138 / 6138 | 7149 / 28676 | 34137 | 6163 / 6165 | 7079 / 28060 | 7605 / 40419 | 46140 |
| 8 | 5926 / 5927 | 6137 / 16522 | 7150 / 28674 | 34135 | 6162 / 6164 | 7078 / 17570 | 7605 / 40418 | 46139 |
| 9 | 5926 / 5927 | 6137 / 16521 | 7149 / 28675 | 34136 | 6162 / 6165 | 7078 / 17570 | 7605 / 29478 | 35200 |
| 10 | 5926 / 5927 | 6137 / 16522 | 7150 / 28677 | 34138 | 6162 / 6165 | 7077 / 17569 | 7605 / 40418 | 46139 |
| 11 | 5926 / 5927 | 6137 / 16522 | 7148 / 17911 | 23372 | 6163 / 6165 | 7078 / 17571 | 7604 / 29475 | 35197 |
| 12 | 5926 / 5927 | 6138 / 16522 | 7149 / 17915 | 23376 | 6162 / 6164 | 7078 / 17571 | 7606 / 29482 | 35203 |
| 13 | 5926 / 5927 | 6137 / 16521 | 7149 / 28673 | 34134 | 6163 / 6164 | 7079 / 28060 | 7604 / 40423 | 46145 |
| 14 | 5926 / 5927 | 6137 / 16522 | 7149 / 39434 | 44895 | 6162 / 6165 | 7078 / 17570 | 7604 / 40419 | 46141 |
| 15 | 5926 / 5926 | 6138 / 6138 | 7149 / 50993 | 56454 | 6162 / 6165 | 7078 / 17570 | 7605 / 40423 | 46144 |
| 16 | 5926 / 5927 | 6137 / 16521 | 7149 / 7149 | 21982 | 6162 / 6165 | 7079 / 17572 | 7604 / 29479 | 35201 |
The worst are p50 18,541 µs (rv32, seed 11) and p99 82,497 µs (rv64, seed 6); R10’s p99 is 22,812 µs (rv64, seed 6) and the deadline notice’s is 37,450 µs (rv32, seed 15), both inside 30 and 40 ms. The lease end’s worst is 104,318 µs, inside 125 ms.
The sixth sweep (2026-10-05, seeds 1 to 20, on rv64 and rv32) followed the 1 ms slice. Every seed passes on both widths. The N = 16 decision wake, p50 / p99 in µs, and a lease’s end (the worst decision-wake p99 plus R10’s p99); at N = 1 and N = 4 the decision wake stays under 1 ms and under 3 ms on every seed:
| Seed | rv64 N=16 | rv64 lease end | rv32 N=16 | rv32 lease end |
|---|---|---|---|---|
| 1 | 3217 / 11328 | 15460 | 9998 / 15165 | 19386 |
| 2 | 3220 / 8083 | 12215 | 11677 / 15170 | 19393 |
| 3 | 1600 / 6463 | 10595 | 11679 / 16897 | 21118 |
| 4 | 1601 / 6461 | 10591 | 11657 / 15149 | 19370 |
| 5 | 3218 / 9706 | 13838 | 11657 / 15112 | 19332 |
| 6 | 3217 / 9702 | 13832 | 8241 / 15148 | 19369 |
| 7 | 3216 / 8081 | 12213 | 8262 / 15148 | 19369 |
| 8 | 3217 / 8086 | 12216 | 8229 / 15139 | 19361 |
| 9 | 3218 / 8083 | 12213 | 8218 / 15121 | 19341 |
| 10 | 1600 / 8084 | 12215 | 9954 / 16870 | 21092 |
| 11 | 3216 / 9703 | 13835 | 11688 / 13445 | 17667 |
| 12 | 3218 / 6463 | 10594 | 8220 / 11670 | 15890 |
| 13 | 1599 / 11325 | 15457 | 8234 / 15154 | 19375 |
| 14 | 1600 / 8083 | 12215 | 8225 / 15128 | 19348 |
| 15 | 1601 / 11328 | 15460 | 8258 / 15150 | 19372 |
| 16 | 1601 / 14568 | 18698 | 9975 / 18591 | 22812 |
| 17 | 1600 / 6461 | 10593 | 11670 / 18588 | 22810 |
| 18 | 1599 / 6464 | 10594 | 11667 / 18581 | 22801 |
| 19 | 1600 / 6464 | 10596 | 11661 / 16853 | 21073 |
| 20 | 3217 / 8082 | 12214 | 9974 / 16861 | 21080 |
The worst are p50 11,688 µs (rv32, seed 11) and p99 18,591 µs (rv32, seed 16). R10’s p99 is at most 4,223 µs (rv32), the deadline notice’s at most 12,738 µs (rv32, seed 18), the driver wake’s 23,924 µs (rv64, seed 2) and the timer wake’s 26,937 µs (rv32, seed 12). The lease end’s worst is 22,812 µs. The targets stay where the fourth sweep set them; every measure is well inside them.
The cluster workload (tests/programs/src/bin/sched-cluster.rs) measures the same driver and
timer wakes where retained stride debt bites: sixteen weight-100 spinners released by one common
timeout, a busy weight-1000 server, and 200 attempts each from the two weight-1000 stand-ins, at
fixed 80 ms slots and programmed offsets of 100, 300, 600 and 850 µs. Its whole window is on the
kernel clock (time_now, µs): one reading T gives start H = T + 200 ms, release R = H + 50 ms and
end F = R + 16 s, sent to every child and printed from the same values. H only anchors R: nothing
waits for it, so the server counts from its go to F and the spinners from R to F, each span as
printed. The spinners, the server and a stand-in’s spin to a slot run in fixed chunks and read
time_now between them; that work is billed alike in a candidate and its control. Each attempt
reads B before arming,
fixes L = B + delay before it arms, and reads P after the wake (the driver also reads E and the
RTC’s service time s between them); its envelope is [L, U) with U = P + 1, which holds all of
P’s floored microsecond. The clocks run at one rate and a timeout never ends before its deadline
(time, timeouts), so the physical span from the
deadline to the service lies inside the envelope, and the envelope’s length, net of the audit time
the trace certifies inside it (checked builds), is an upper bound
on the wake’s non-audit latency. The bench requires R <= L <= P and U <= F for every attempt, and
judges the same p50 <= 15 ms and p99 <= 50 ms on all 200 envelopes of each stand-in. Before
that, the trace must show at least 25 wakes per stand-in in each category, five at each offset:
zero lead, a zero-intent attempt whose wake’s pass is at the floor the oracle replays, and
positive lead, a positive-intent attempt whose wake’s pass is above it. A wake that is neither
joins no category but still counts in the percentiles, and how many spinners ranked before each
wake is reported beside the categories. A met target proves the bound; a missed one alone does
not prove the latency is over it. So the known-bad control (the
10 ms slice) must miss an envelope target and have a driver lower witness, the RTC’s
service lateness less the union of every audit’s outer bin [u, v + 1) inside the envelope, above
the matching target, on each width. A control that misses only on the envelope demonstrates
nothing, and so does one whose positive-lead wakes are not behind the cluster: it needs at least
25 per stand-in with eight or more spinners ranked before them, which under a 1 ms slice a
weight-1000 waker never has, since it is picked within one of its own slices of the lowest
spinner. A control can also fail to take its samples: a stand-in that comes back after a
zero-intent attempt’s target has passed cannot arm it, and the construction stops there. That
counts as the demonstrated miss only when the oracle classifies it from the console and the
trace: the attempt is zero-intent and not the first, the stand-in’s earlier attempts joined the
trace as blocked waits, and B less the target, less the certified audit time between them, is
over the p99 target. Any other construction failure, a smaller miss, or a run without its trace
is no control. The control is sched-cluster-old-control: the same program and gates with the
oracle’s cluster_old_control, run by name only, on the kernel built with its test-only
slice-10ms, which keeps the old 10 ms slice. It expects the trace rather than the program’s
pass, so a failure the guest reports still reaches the oracle. The RTC lateness is reported beside
each envelope, not as its length.
Measured at seed 3 in the checked, traced build, envelope net p50 / p99 in µs, the targets 15,000 / 50,000:
| Stand-in | rv64 | rv32 |
|---|---|---|
| driver wake | 3962 / 11610 (98 positive, 100 zero lead) | 4200 / 17727 (97 positive, 100 zero) |
| timer wake | 5887 / 15629 (100 positive, 100 zero) | 5311 / 16364 (99 positive, 100 zero) |
The certified audit credit inside the envelopes was 288 ms and 320 ms on rv64, 384 ms and 376 ms on rv32; the driver’s lower witness p99 was 10,902 and 17,195 µs. At most ten spinners ranked before a positive-lead wake. The control fails as required on both widths, by the classified miss: on rv64 both stand-ins could not take attempt 1, B exceeding its target by 104,188 and 109,853 µs (102,676 and 106,472 µs net of audits); on rv32 the timer could not take attempt 1, by 71,624 µs (69,515 net), and the driver missed attempt 2 by a net 29,843 µs, under the target.
The case fails on any missed. bench:sched-latency-tcg runs the same workload in host time and
only reports, with the oracle still checking every pick.
On hardware (the softcores) the same workload is measured in cycles, from rdcycle or mtime
at the stated clock rate. That is a characterisation of the board, not a gate.
Authority
Status: built · tested: bench:sched-carve-inflation, bench:legacy-gone, host:redoubt-model::scheduler_contracts_hold
- Weight is the only lever. No call sets a pass, a rank, a priority or a slice, and there is
no yield: a thread gives up the CPU by blocking, for instance in a
receivewith a timeout. - Weight is carved. A budget’s weight comes out of its parent’s free weight at
budget_create, by whoever holds a handle to the parent (R7), and goes back when the child is destroyed. A budget’s free weight rises only when a child it carved is destroyed. - Ties are the kernel’s. The budget ids that break ties are assigned by the kernel and never reused; no caller chooses one.
kmain’s switch is not a call. Its tag lies outside the call table; a user-modeecallwith it getsInvalidArgumentlike any unknown number (ABI).- Passes are not readable. No call returns another budget’s pass or rank. The scheduling trace would, which is why the production kernel has none (R23).
Security properties
R12 (scheduling)
Status: built · partly tested: the bound on a call's kernel time is attacked only for `map_anon`'s search, `map_fixed`'s range, `process_create` and, after RAM fills, a one-page `map_anon`, `budget_create` and a rolled-back `process_create` (with a recorded negative run, `alloc-first-fit`); beyond the memory calls, for `endpoint_create`, `mint`, `close`, `thread_create` and `thread_exit`, `call` and `reply`, a `receive` of a queued message, `time_now` and `budget_usage` against a hostile budget's full handle table, two thousand endpoints and 250 parked threads (`bench:call-bounds`); and at full occupancy, every PID in use with every thread, for a delivery, a timer expiry ending 250 waits at once and the reconcile that wakes their 250 budgets (over 8-seed sweeps, 7.8 ms on rv64 and 6.8 to 9.1 ms on rv32, bounded at 10 ms: the sweeps' worst and a tenth) and a destruction (at most 17.1 ms on rv64, 18.2 ms on rv32) (`bench:worst-walk`) · tested (68)
- bench:sched-share
- bench:steward-latency
- bench:steward-latency-n4
- bench:steward-latency-n16
- bench:sched-share-release
- bench:sched-capped
- bench:sched-capped-mttcg
- bench:sched-capped-holds-floor
- bench:sched-sleep-gaming
- bench:sched-idle-gap
- bench:sched-exit-churn
- bench:sched-budget-churn
- bench:sched-budget-churn-shell
- bench:sched-carve-inflation
- bench:sched-debt-lift
- bench:sched-lift-delay
- bench:sched-timer-flood
- bench:sched-server-busy
- bench:sched-large-weight
- bench:sched-large-weight-release
- bench:deadline-flood-billed
- bench:deadline-flood-billed-traced
- bench:sched-carve-return
- bench:map-anon-search-bound
- bench:scan-bounds
- bench:call-bounds
- bench:worst-walk
- host:redoubt-stride::the_crate_and_the_model_agree
- host:redoubt-stride::a_broken_model_disagrees
- host:redoubt-stride::a_slice_end_reads_no_more_states_for_a_longer_queue
- host:redoubt-stride::a_rank_out_of_step_with_its_frame_trips_the_audit
- host:redoubt-model::scheduler_fairness
- host:redoubt-model::scheduler_contracts_hold
- mutation:R12PriorityById
- mutation:R12IgnoreWeight
- mutation:R12WakeBanksCredit
- mutation:R12TieQueuedFirst
- mutation:R12RequeueAhead
- mutation:R12RequeueLifo
- mutation:R12PreemptOnWake
- mutation:R12TimeoutWakePreempts
- mutation:R12NoFloorWhenIdle
- mutation:R12ShortRunsFree
- mutation:R12DropRemainder
- mutation:R12ExitRunsFree
- mutation:R12DestroyDropsDebt
- mutation:R12CreateAtFloorOnly
- mutation:R12LiftByMax
- mutation:R12StrideWeightIsLimit
- mutation:R12UnnormalizedLift
- mutation:R12LiftCountsEntryWait
- mutation:R12FoldAtNewWeight
- mutation:R12NoMinimumCharge
- mutation:R12DeadlineWorkUnbilled
- mutation:R12RescaleOnlyOnReturn
- mutation:R12SliceCountsExitWork
- mutation:R12TimerWorkUnbilled
- mutation:R12SwitchBilledToPrevious
- mutation:R12CappedHoldsFloor
- mutation:R12CapOnce
- mutation:R12AllCappedHoldsFloor
- mutation:R12UncapBanksCredit
- mutation:R12UncapForfeitsWait
- mutation:R12EmptyHoldsFloor
- mutation:R12RequeueWaitsForCap
- mutation:R12BlockLeavesAsRequeued
- mutation:R12OneRunnerPerBudget
- mutation:R12SpreadChargesOnce
A budget’s CPU follows its free weight, in one queue with no priority, on every hart. While it
has a runnable thread, a budget gets at least its weight’s share of the harts the runnable budgets
share, but never more harts than it has runnable threads; what it cannot use goes to the others in
proportion to their weights, so spreading its threads across harts gains it nothing. The shares are
of hart time, as the kernel charges it, each hart’s runner by that hart’s clock; a hart runs at a
rate that does not depend on what the others run on real harts and on a strict barrel
(the FPGA platform), but not under QEMU’s
icount, whose clock counts every running hart’s instructions, so what a budget’s loop counts
there is a share of the machine’s instructions. No
pattern of spinning, sleeping and waking, exiting or faulting, creating, carving and destroying
budgets, or arming timeouts and deadlines gets it more. The rule’s parts are the sections above:
free weight, the preemption points, the wake rule and ranks, charging and inheritance. A slice is
the picked thread’s user time: it starts when the thread returns to user mode, so the kernel’s
work between the pick and that return, however long, never uses it up, and a picked thread runs
its slice unless a budget deadline fires at the return.
A system call’s kernel time is bounded by a constant plus a term linear in the pages it maps or
the objects it names. It never depends on the extent of an address area or on what other processes
hold. map_anon‘s search is linear in the fixed-size area it searches, a constant, and never in
len (bench:map-anon-search-bound). A RAM frame is taken from the free-frame bitmap and given
back to it, so backing a page, a page table or an object costs no search of RAM, however much of it
is in use (bench:scan-bounds). A term linear in a fixed kernel constant (MAX_PROCESS_COUNT, the
platform’s interrupt count, MAX_DMA_DEVICES, a fixed table size) is a constant. A term linear in
RAM frames or kernel-object frames is not. Billing it to the caller does not excuse it, because
every wake waits for it. R10 (destruction) walks only the dying subtree, its owner lists, the
dying processes’ own tables and page tables, and the chains of the handles held outside it
(budgets), so it is no exception. Delivery visits only the endpoint’s
own receivers, groups and notices, so it is no exception either. What a call looks up by PID or by
interrupt number it finds in an index the kernel keeps as objects are made and freed: a process
object in one of MAX_PROCESS_COUNT slots, an IRQ object in one of MAX_IRQS (1024, the PLIC’s
sources; a boot naming a higher interrupt stops). PID 1 is the kernel’s, which has no process
object, so processes take PIDs 2 to MAX_PROCESS_COUNT (511): process_create’s PID draw looks
at most at those 510 slots, an owed exit notice is sought among at most 510 process objects, and
an interrupt finds its object in one lookup; a checked build proves each index against a scan of
every object frame. A checked build checks the delivery lists. At each kernel exit that changed
one, it starts from every thread and its open calls, checking each list it meets whole and the
totals by kind. After each destruction, at each process-object free and before the hart idles, it
starts from every budget and endpoint instead, so a list no waiting thread belongs to is checked
there.
In bench:scan-bounds, after one budget fills 20,000 pages with endpoints, process_create with
its exit notice and an interrupt take what they took on an empty system; with the old scans, the
first took 1.2 s against 22 ms. So do a one-page map_anon, a budget_create and a
process_create rolled back for want of pages, on both widths; a kernel built with
alloc-first-fit, which takes each frame by the first-fit scan of RAM the bitmap replaced,
fails the case on both widths in a recorded negative run (a one-page map_anon: 1174 µs against
374 µs on rv64, 1274 against 474 on rv32).
Beyond the memory calls, bench:call-bounds makes endpoint_create, mint, close,
thread_create and thread_exit, call and reply, a receive of a queued message, time_now
and budget_usage sixteen times each on an empty system and again after a hostile budget has
filled its handle table (4,093 handles), made two thousand endpoints and parked 190 threads in
send at the victim’s endpoint and 60 in timed waits; the kernel’s time for each is its lock
section from a hold-trace kernel, net of the checked build’s audits, and the oracle holds each
call’s longest section after the fill to twice its longest before, or 100 ticks. Each call reads
alike on both sides, in ticks before and after on rv64 (rv32): endpoint_create 1,359 and 1,313
(1,932 and 1,843), mint 1,036 and 1,113 (1,440 and 1,553), close 425 and 433 (560 and 578),
thread_create 770 and 778 (1,170 and 1,201), call 10,625 and 10,638 (10,054 and 10,127),
reply 3,243 and 3,192 (3,311 and 3,378), time_now 280 and 288 (394 and 414), budget_usage 872
and 881 (899 and 917); two grow with the caller’s own objects, as the rule allows: receive reads
9,585 and 11,380 (8,070 and 10,033), a fifth more with the caller’s own endpoint’s queue filled by
the hostile’s 190 senders, one reading at 190, not a slope, and bounded because a receive’s pump
reads the endpoint’s own lists only, never every thread (worst-walk bounds the pump); and
thread_exit by half with 250 threads in the process, 1,045 and 1,519 (1,421 and 2,012).
It is attacked three ways:
- Boot cases, in virtual time, judge what the kernel charged each budget over a window, net of
the harts’ lock waits, against its water-filling share of the harts, within 50 per thousand, at
one hart and two (checked builds): spinners at 100, 100 and
300; near-slice, 20 µs and long-sleep bursts; a sleeper waking into an idle gap; threads and
processes that exit or fault just before their slice ends; budget churn; carving; 30 sleepers a
microsecond apart, 64 staggered deadlines, and waits ended before their timeouts; a system
server flooded by one user; a weight-1000 server among eight users of 100. On two, three and
four harts,
sched-cappedruns the model’s scenarios: a heavy budget with one thread on two harts while others join, a second cap at three, a capped budget gaining a thread, and one budget spread over four harts; at two and three harts the shares are recorded undericountand judged where the harts run at once (sched-capped-mttcg, QEMU’s turns below). A kernel whose floor counts the capped budgets (sched-capped-holds-floor, a debug-only feature whose queue audit reads the same broken rule) passes every rank the oracle checks, and fails late join’s shares at two harts, on both widths: B gets 52 of 1000 (rv64) and 56 (rv32) against 250 while C takes its hart (bench:sched-capped-holds-floor, a case that must fail), and 30 and 31 where the harts run at once (a recorded run each). - The differential drives
libs/stride, wired as the kernel wires it, and the model’s scheduler through 3,000 random sequences of creations, destructions (leaf, on a hart, and whole subtrees), wakes, blocks, runs and preemptions, at 1, 2 and 4 harts, and requires every pass, entry, remainder, tie, queue membership, floor, cap set and pick to agree after every step. A model with any of 30 scheduling rules broken must disagree. - The model’s mutations: each
R12*variant breaks one part, andscheduler_fairness(eleven scenarios, each with an independent check) or the scheduler contracts must catch it (model).
R78 (fair kernel entry)
Status: built · tested: bench:smp-boot, bench:smp-lock-wait, bench:sched-lock-contention, bench:sched-lock-contention-4, bench:sched-lock-contention-4-mttcg, bench:irq-boot-hart-only, host:testbench::lock_waits_take_the_lock_in_ticket_order
On several harts, kernel entry is fair across them: the one kernel lock is a FIFO ticket lock,
so a hart that arrives at the kernel waits behind at most MAX_HARTS - 1 kernel sections, never
for ever. The rule exists for R12. A test-and-set lock is unfair: a hart can lose the lock to
later arrivals indefinitely, and the budget running on that hart loses its share with it, so one
budget’s harts can starve another’s of kernel entry, which no pattern of calls may do. FIFO
bounds the wait by the hart count, and the bound is part of how R12’s shares are judged across
harts (several harts).
The lock (kernel/src/cell.rs, TicketLock) draws a ticket with a relaxed fetch_add, waits
until serving reads it (acquire), and releases by serving + 1. A hart waits halted, not
spinning: it marks itself in the lock’s halted set, reads serving again and, if its turn has
not come, halts (wfi) until an interrupt is pending; the release sends each marked hart the
reschedule interrupt (through the firmware, SBI), and the woken hart reads serving again. The
halt waits for the reschedule interrupt alone: the timer’s and a device’s are masked in sie for
it (arch::halt_for_ipi), since a hart without the lock cannot take them, and one pending would
end every halt at once and turn the wait into a spin. A hart whose awaited interrupt is already
pending runs no wfi at all: the architecture lets wfi return at once then, but QEMU halts the
hart regardless and, under icount, runs it again only after every other hart’s turn
(arch::halt_masking), which cost an idle hart woken by sched-lock-contention-4’s alarm
28.5 ms. A checked build counts the halts it skips, and sched-lock-contention requires some at
two and at four harts (95 to 182 a run). A hart halted in kmain’s idle is not in the lock’s set
and no release wakes it; since a timeout whose payer runs nowhere is armed on one hart
(the hart timer), the machine at rest no longer hands the lock around
for its sleeps: at the prompt the lock changed hands 5 to 6 times a second with every hart
halted, each hand-off an interrupt, and changes hands 1.7 to 1.9 times a second with the rule,
each for a woken thread’s own work (bench:launch-idle). The
mark, the read, the release’s store and its read of the set are sequentially consistent, so either
the release sees the mark or the waiter sees the new ticket: no hart halts past its turn. Under
QEMU’s icount, where the harts take turns on one host thread and the clock counts every running
hart’s instructions, a spinning hart would spend its turns and the guest’s time while the holder
waited for them, and a halted one gives them up. Spinning cost every case at two harts:
ipc-client’s 1000 lend round trips took 17.4 s on rv64 and 20.0 s on rv32 against 2.0 s at
one hart, and take 2.1 s on both widths halted; sched-latency’s N = 16 driver wake p99 was 345
ms, and is 6.3 ms (gross, rv64). On real harts the halt costs one interrupt at each contended
release: without icount, at two harts, the same round trips take 546 ms halted against 505 ms
spinning on rv64 (+8 %) and 563 against 508 ms on rv32 (+11 %), each a mean of five runs alone;
a spin of 300 turns before the halt took back most of that on rv32 and little on rv64, and cost
7.5 % under icount, so there is none. The halt is the Zawrs hook: a wrs.nto reservation wait
on serving would stall the hart with no interrupt, but QEMU runs wrs.nto, like the pause
hint, as a no-op that keeps the hart’s turn. The lock is taken once at trap entry and released
before the return to user mode or an idle wait. A checked build asserts, at every acquisition,
that the hart waited behind fewer sections than harts were started (the draw, the read and the
release are sequentially consistent there, so the count can only under-count); bench:smp-boot prints
the most any hart waited (one section at two harts, three at four, on both widths). Under the
ticket lock the count cannot exceed the harts less one by construction, so the assertion is
structural for the real lock, and the test-and-set negative below is the one attack. It is
attacked by a kernel built with the ticket lock replaced by test-and-set
(sched-test-and-set-entry, a debug-only kernel feature like the tie fault,
below; not a model mutation, since the model has one hart): with it,
smp-boot fails at the FIFO assertion at two and at four harts on both widths, in a recorded
negative run. The one-hart kernel is the uncontended case of the same lock.
bench:smp-lock-wait holds the halt: two threads of one process make 20,000 system calls each
at once on two harts, every call contended, then one thread makes all 40,000 alone, and the calls
at once may take at most three times as long. Halted they take 1.87 s against 1.26 s on rv64 and
4.87 s against 1.97 s on rv32: each hand-off of the lock is an interrupt through the firmware,
dearer on rv32. A kernel whose waiting hart spins (sched-spin-entry, a debug-only kernel
feature) fails it on both widths in a recorded negative run: 250 s against 1.25 s on rv64, 170 s
against 1.97 s on rv32.
bench:sched-lock-contention holds the order in time, under R12’s costliest call: on every hart
but one a budget asks in a loop for a map_anon refused after a search of the whole area
(map-anon-search-bound’s worst case, 9.6 ms of kernel time in a checked build), beside
sched-latency’s driver stand-in. A kernel built with lock-trace records each wait’s ticket,
and the oracle requires the waits to take the lock in ticket order, each drawn behind fewer
sections than harts (the scheduler oracle): at two harts
1433 waits on rv64 and 1436 on rv32, each behind at most one section; at four harts
(sched-lock-contention-4, three such budgets) 2240 and 2548, behind at most three. The driver’s
wake is gated on the responsiveness targets at two harts, and on its p50 at
four. Every started hart takes device interrupts (boot), so the
alarm reaches the idle hart, which draws its ticket behind the search in progress and runs the
driver itself: net of the audits its p50/p99 is 8.5/10.6 ms on rv64 and 8.5/10.4 ms on rv32 at two
harts, against 15 and 50 ms. The wakes are bimodal, under 2 ms when the alarm falls between two
searches and about 10 ms inside one, and the p50 is whichever mode holds half of them (another run
read 1.5 and 1.7 ms). With the boot hart alone taking device interrupts they were 18.5/19.2 and
18.3/19.1 ms: the alarm waited out the boot hart’s search, and the woken hart drew its ticket after
the searcher’s next call. bench:irq-boot-hart-only is that kernel (irq-boot-hart-only, a
debug-only feature) under the same targets, which the post-check must fail (20.2 ms gross p50 on
both widths: its hammer lands on the boot hart); a checked build’s count at power-off shows its
boot hart claiming all 201 and the other hart’s context taking none (no claim, and no claim that
found nothing). The count is each hart’s: its claims, its claims that found nothing (it trapped
for an alarm another hart claimed first) and their sum, the alarms its context took. The idle hart
wins the claims, and which hart idles follows the boot’s timing (claims 200 and 1 in one
sched-lock-contention run; 29, 4, 168, 0 and 196, 1, 2, 2 in two sched-lock-contention-4
runs), so the cases require what does not: sched-lock-contention and sched-lock-contention-4
that a hart other than the boot hart took ten or more alarms, and sched-lock-contention-4-mttcg
that every hart did. At four harts under icount the p50 (4.3 ms on rv64 and 5.3 ms on rv32 in
one run) and the p99 (59.1 and 58.7 ms) wait for QEMU’s turns and are recorded there, not gated
(residual risks): rv32’s p50 moved between 5 and 22 ms when a slice’s end was
made cheaper. It is judged where the harts run at once:
bench:sched-lock-contention-4-mttcg runs the same program under QEMU’s multi-threaded TCG, in
host time, and judges the driver wake’s net p50 and p99 in searches of the same run alone, which
the program times before its children start (one search alone): at most three at p50 and eight
at p99. A ratio, because the host’s speed moves both sides, and a wake waits behind at most three
searches and the one in progress. Twenty runs alone on q’s quiet cores read a p50 of 1.6 to 1.9
searches and a p99 of 2.0 to 3.0, but for one run of each width at 6.1 (rv64) and 5.1 (rv32);
twenty on the general cores read p99s of 3.8 to 6.2 and 3.2 to 5.1 (a search alone about 1.5 to
1.9 ms and 1.3 ms). The p99 bound, at twice the four-search claim, cannot see a wake one or two
searches longer: the irq-boot-hart-only kernel under the same case reads p99s of 5.4 to 7.1
searches and passes it. Its p50, 3.7 to 4.9 searches against IRQ1’s 1.6 to 1.9, fails the p50
bound in all four recorded runs, so the p50 is the gate that sees that regression. The 15 and 50 ms
targets are gated too, but on this host’s TCG a search takes a fifth of its 9.6 ms of virtual
time, so they hold for the twin as well (p99 7.5 to 10.9 ms); on hardware, where a search is its
virtual length, the same count holds 50 ms while R12’s costliest call stays under about 12 ms, and
the twin’s would not.
R23 (no test channels)
Status: built · tested (3)
- bench:bench-no-trace
- bench:bench-trace-scan-catches
- bench:bench-test-only-refused
The production kernel carries no test-only diagnostic channel. The scheduling trace is one: a
record of every budget’s id and pass at every wake, requeue, pass change, pick and lift, which
tells whoever reads the console who runs when. It exists only under the Cargo feature
sched-trace, and no default build enables it:
- the kernel’s default features are
print-panicsalone, and./buildadds only the board (qemu-virt); - every trace site in
kernel/src/{sched,budget,mem,main,message,process,redoubt,time}.rsandkernel/src/arch/riscv/irq.rssits under#[cfg(feature = "sched-trace")], so with the feature off the ring, its records and itsSCHED-TRACEconsole lines are not compiled; - the bench turns it on per case (
kernel_features), only for the cases whosesched_oraclepost-check reads the trace printed atsystem_reset: the share cases (sched-share,sched-large-weight,sched-idle-gap,sched-sleep-gaming,sched-capped,sched-budget-churn,sched-budget-churn-shell,sched-exit-churn,sched-timer-flood,sched-carve-return,sched-carve-inflation,deadline-flood-billed-traced),sched-ties,sched-debt-lift,sched-lift-delay,sched-wake-no-preempt,sched-wake-no-preempt-harts,sched-wake-no-preempt-harts-mttcg,sched-irq-wake,sched-deadline-preempt,sched-cluster,sched-cluster-old-control,sched-latency,sched-latency-tcg,sched-lock-contention,sched-lock-contention-4,sched-timer-entry,irq-boot-hart-only,kernel-containment,endpoint-destroy-full,smp-fenceandworst-walk.
The bench checks it on the binary (bench-no-trace): a build case builds the production
kernel, the release profile with qemu-virt alone as ./build does, and fails on the first of
SCHED-TRACE (the trace’s console lines) and 5sched5trace (the mangled path of its module,
redoubt_kernel::sched::trace, which every symbol of it carries and the kept symbol table would
show) found anywhere in its bytes. bench-trace-scan-catches plants the trace, a checked kernel
built with sched-trace, under the same scan and must fail on the console line (at offset
0x37ba1 on rv64 and 0x3b69e on rv32, in the ring’s SCHED-TRACE-END ... dropped text); and
bench-test-only-refused asks the release profile for sched-trace and must fail on the kernel’s
refusal below, in the compiler’s words.
The other diagnostic features are off by default in the same way: walk-trace, which implies
the trace and brackets each receive’s pump, timer expiry and reconcile in it, for worst-walk
alone; lock-trace, which implies the trace and records after each wait for the kernel lock its
ticket and the sections ahead of it when drawn (k), for irq-boot-hart-only; hold-trace,
which implies lock-trace and records each section of the kernel lock at its release, its ticks,
cause and audits (h, j), for sched-lock-contention, sched-lock-contention-4,
sched-timer-entry and call-bounds; block-trace, which implies the trace and records each
thread’s block at the one place a thread blocks (@: the budget, its wait kind and the endpoint
it waits on), each wait’s end (~: the budget, the wait kind, whether it timed out or was
answered without blocking, and the endpoint, so a block’s length and what ended it are read)
and each budget’s creation (#: the new budget’s id, its parent’s and the creating process’s
PID, so a session’s or a lease’s budget is read off the trace as the one the steward carves at
its login rather than inferred), for no case: a diagnostic run by hand, which told a
budget waiting to receive a call that never came from one blocked in a call out
(responsiveness), and placed a burst of the steward’s notices between
beamlet’s serve thread and its VM (the steward);
irq-boot-hart-only, whose boot hart alone
takes device interrupts, for R78’s negative case at two harts; sched-inject-tie-fault, a debug-only break of the tie rule that implies the trace;
sched-capped-holds-floor, whose floor counts the capped budgets, for R12’s negative case at two
harts; sched-test-and-set-entry, which replaces the kernel lock by test-and-set, and
sched-spin-entry, which makes a hart wait for it spinning, each for one of
R78’s recorded negative runs;
audit-unstamped, which leaves the audit
after a destruction out of the trace, and audit-billed, which bills each audit’s time to the
budget that ran it and counts it against its slice, each for one recorded negative run
(responsiveness); audit-wait-billed, which bills a hart’s wait behind
another hart’s audit to its runner, for one recorded negative run (residual
risks); timer-tail-billed, which bills the rest of a timer
interrupt after its expiry, and its return, to the budget it interrupted, for one recorded
negative run (charging); alloc-first-fit, the first-fit frame scan, for one
recorded negative run (R12); slice-10ms, the old 10 ms slice, for the
cluster’s known-bad control (responsiveness); and debug-print, which prints
every pick’s PID and thread and every trap. dma-reset-deaf is a test-only fault, not a channel
(devices), and so are handle-chain-fault and process-chain-fault, a handle
installed without its stamp entry or its process object entry for the chain audit to catch
(budgets), sum-probe, a stray
kernel load that must fault (R24 (SUM and MXR clear)),
and panic-in-print, a
panic inside print! (boot). Each of these implies the feature
test-only, which the kernel refuses to compile without debug assertions, so the release build
./build makes cannot carry one; a checked build, as ./build --debug makes, still can.
Failure and restart
Status: built · partly tested: a picked thread dying before the switch needs another hart's destruction to land between `kmain`'s pick and its switch, which no case can time, and the queue cannot be filled (one slot per process), so neither is attacked by a case · tested (6)
- bench:sched-exit-churn
- bench:budget-deadline
- bench:sched-idle-gap
- host:redoubt-stride::a_deschedule_charges_at_least_one_unit_and_a_destroy_only_what_ran
- host:redoubt-stride::the_floor_survives_an_empty_queue
- host:redoubt-model::scheduler_stays_fair_past_the_old_pass_saturation_boundary
- A thread exits, faults or is killed on the CPU: its budget is descheduled and charged what it ran, at least one tick, like any other deschedule.
- A budget is destroyed: bottom-up, each budget is charged what it ran, its work is lifted into its parent, and it leaves the queue. A budget destroyed on the CPU (its deadline, with nothing descheduling it first) is charged, and the CPU is free.
- A deadline ends the entering process: the trap handler resumes whatever is current and uses nothing of the dead process (I14 (no call panics the kernel)).
- The picked thread died since the pick:
kmaindoes not run it and picks again. - Nothing is runnable:
kmainidles, billing stops, and the floor holds for the next waker. - Arithmetic cannot stop the kernel. Passes are 128-bit and never wrap; a lift saturates rather than overflow. The queue has one slot per process, and a queued budget has a runnable thread, so it cannot fill; if it ever did, a budget would be left out rather than anything stopping (a debug assertion in checked builds).
- A restarted server in a fresh budget enters like any child, at
max(floor, parent's pass), and carries no credit from the one that died. Restarted in the same budget, it keeps that budget’s pass.
Residual risks
-
Two bills still leave their own handling to nobody. An interrupt’s handling is measured, billed to its device’s owner, and then the interrupted budget’s billing restarts, so the bill’s own work is between the two; and a deadline’s destruction is billed to its payer inside the destruction, so what follows that bill until the expiry’s next interval is nobody’s. Each is a bill’s own handling, about 25 µs in a checked rv64 build, once per interrupt or destruction. A traced kernel measures all the kernel time no budget is charged, these two and what the rules leave to nobody (a walk that finds nothing, a pick of nothing, a timer interrupt that ends no slice), as a share of its time net of audits (
nobody N of 1000, checked builds); an interrupt’s bill leaves out an audit its handling runs, as billing does. Of 1000, rv64 then rv32: 14 and 26 insched-exit-churn, which has neither an interrupt’s bill nor a deadline’s destruction, so that is all time the rules leave to nobody; 27 and 44 insched-latency, which can also show the interrupt’s gap; and 17 and 35 insched-timer-flood, 29 and 47 insched-budget-churnand 61 and 84 inkernel-containment, which can also show the destruction’s. The two gaps are at most that. The counters read the billing’s clock and decide nothing: like the audits, they move no schedule. -
Server work is paid by the server’s weight. Work a server does for a user is paid by the server’s weight, not the requester’s; the steward’s work, by the steward. No time is donated, so a user who floods a server takes that server’s share away from the server’s other callers, never more. In
bench:sched-server-busy, by ratio of counts, the server’s work for one user is at most what each other user runs, and the other users run alike. The flooding user’s calls are kernel time that no count measures, so the case does not check that it takes no more than its own share. Its trace did show that: the flooding user charged 229 of 1000, against 257 for each other user and 258 for the server. Servers therefore bound the work of one request, and the steward, whose weight is large, bounds its per-request work and relies on its per-(account, label set) caps (steward). -
Wakeup is prompt but not bounded. Unless a hart is idle, a wake waits out a running slice, may keep a larger pass, and may tie. Human control rests on a measured steward lease-termination latency, not a proven bound, until something needs a real-time rule (TENETS).
-
The steward decision wake is late by whole slices. A wake never preempts: at N = 16 the steward stand-in waits out a running slice, then the slices of any budgets that rank ahead of it. With the 1 ms slice (the sixth sweep above) its p50 falls in a few modes, depending on where its timeout lands against the running slices: about 1.6 ms or 3.2 ms on rv64, and about 8.2, 10.0 or 11.7 ms on rv32. Its p99 is a wake that lands behind several of the sixteen budgets, so it falls in steps of about 1.6 ms (rv64) and 1.7 ms (rv32), one slice and what runs between them: from 6.5 to 14.6 ms on rv64 and from 11.7 to 18.6 ms on rv32 (seed 16). The exact chain from a seed to its mode was not traced. The targets (25 and 95 ms) were set from the sweeps under the 10 ms slice and stay; a lease’s end from the steward’s decision is at most 22.8 ms in the sweep against its 125 ms. A pinned seed repeats one run; a change that moves the phase can land on a worse one than the sweep saw, which the margin covers and a new sweep re-measures.
-
Fair kernel entry is bounded by count, not time. On several harts, R78 bounds the wait for the kernel lock by
MAX_HARTS- 1 kernel sections, each as long as the call or destruction holding it: a long section delays every waiting hart by its length, which is one more reason R12 bounds a call’s kernel time. So one budget’s kernel work under the one lock costs the other harts up to its own length, bounded by count, not by share: the wait is the waiting hart’s runner’s time, billed to it and counted against its slice, and no billing gives it back. A checked build’s audit is the one exception: like the audit’s own hart, a hart waiting behind it is billed none of the part it waited through, and its slice’s end moves by it (checked builds). A traced kernel records each wait from user mode and the oracle reports them as a share of the harts’ time,lock waits N of 1000, besidenobody, with the ticks behind audits. Shares across harts are judged net of the billed waits, and the one share the lock still decides is judged at one hart (below). Taking them out has a blind spot: the judge subtracts each wait from the budget the hart’s runner record names, so a kernel that billed a wait to the wrong budget, or to none, would pass it, and no case checks the billing of a wait itself. Judged gross, with the billed waits counted as the waiter’s, every share that passes net passes too:sched-exit-churn’s victim against threads that exit, the one that once did not (403 and 445 before the lift target), reads 508 on rv64 and 495 on rv32 gross against 571 and 603 net, at least 450. The shares stay net, so the blind spot stays. Gross sits nearer water-filling where the waits are short:sched-timer-flood‘s three shares read 498 to 500 gross against 522 to 586 net. At two harts the waits are 103 to 228 of 1000 in the share cases (rv64 then rv32:sched-exit-churn228 and 216,sched-timer-flood213 and 207,sched-budget-churn110 and 103,sched-share140 and 196) and 234 and 286 in the containment gate; behind audits are 85 to 89 % of the waits insched-budget-churn-shellanddeadline-flood-billed-traced, whose creators’ destructions each run a 20 ms audit, and 6 to 48 % in the other share cases. Withhold-tracethe oracle splits them by what they waited behind (the scheduler oracle): a wait insched-shareis mostly the lock standing free, about 0.5 ms each, while the woken waiter, halted, takes its turn undericount. What made the sections long, measured at two harts: the destruction’s audit; a scan of all of RAM at every process’s end, 7 ms, billed as work until it became an audit; a fault’s report, which printed the process’s map a line a page holding the lock (837 ms for 4096 pages); and zeroing a frame a checked word at a time, five times a plain store loop. The wait for a shootdown’s acknowledgements stays under the lock: it totals 0.9 ms over the whole containment gate at two harts (memory). A wake behind R12’s costliest call waits at most the section in progress at two harts (sched-lock-contention, R78): the idle hart takes the device’s interrupt and draws its ticket behind it. -
Timer work billed to one budget can still take another’s hart. A timeout’s or a deadline’s work is billed to its payer, and its interrupt is armed on the payer’s hart where the payer runs, so a one-thread victim on two harts, whom no pick can repay, no longer destroys a flood of its neighbour’s budgets (before: up to 17 % of its window in
deadline-flood-billed-traced, by where the destructions landed). What still lands elsewhere, a payer that runs nowhere or ties, an item noted away from its payer’s hart, a passed item served by the first entry (a flood’s next deadline, passing while the payer’s hart destroys the one before, at the victim’s own slice end), is counted and bounded at 10 of 1000 of the victim’s charges in three host-clock cases; under icount which entry comes first is QEMU’s turn order, and the icount cases report the count without a bound (timer). -
A burst of timeouts is served by one hart. Timeouts whose payers run nowhere are armed on one hart, so sixteen sleepers waking together are served there in turn while another hart idles:
bench:sched-latency’s timer wake p99 at N = 16 rose 1.6 and 3.7 ms at two harts, every target met. The trigger for handing the items beyond the first to an idle hart is on the timer page (timer). -
A hart waits for QEMU’s turns. Under
icountthe harts take turns on one host thread, and a hart in a kernel section keeps its turn to the section’s end. A device’s line that rises while the idle hart is halted wakes it on time in virtual time, but the hart runs only when the running hart’s turn ends, and then draws its ticket behind up to three sections. Insched-lock-contention-4that leaves the driver wake’s p99 at 59.1 ms on rv64 and 58.7 ms on rv32 (other runs read 49.7 and 40.6), and its p50 at 5 or 22 ms on rv32 as the kernel’s timing moves which turn the wake lands behind, so both are recorded there, not gated: every long idle halt began with nothing pending, and the kernel cannot shorten another hart’s turn. On harts that run at once the woken hart draws its ticket at the rise, and the four-hart p50 and p99 are judged there, under QEMU’s multi-threaded TCG (R78). Turns move shares too: at two harts on rv32, QEMU ends the heavy budget’s hart’s turn inside its slice end’s time billed to nobody (the checked build’s audit, orkmain’s expiry walk) at every slice, about 2,600 ticks a slice, sosched-capped’s late join reads 449 of 1000 against 500 (434 aticountshift 4) and the oracle’s nobody share is 71 of 1000 where it was 30. The two-hart shares are recorded there and judged insched-capped-mttcg(497 to 501 over five runs a width). Turns fix the order harts take the kernel lock too: insched-capped’s second cap at three harts no timeout fires in the window, every interrupt is a slice end of the hart’s own runner, and the three slice ends meet at the lock every slice in one order, so the heavy budget on the last hart waits 1.3 to 2.3 slice-end sections (6,300 to 6,600 ticks each in the checked, traced build) at every slice, 23 to 45 % of its hart, and reads 276 to 340 of 1000 against 333 (two misses in five runs alone on main, three in five on a kernel differing only in the phase the threads come onto their harts with); the three-hart shares are recorded there and judged insched-capped-mttcgat three harts (327 to 329 over five runs a width, each heavy budget’s lock waits about 1.1M ticks of 60M). Turns decide where a timeout is answered too: a hart’s timer armed for it fires in the hart’s next turn, by which the other hart’s call has answered it, so on rv32sched-wake-no-preempt-hartshas at most one wake mid-slice undericount(0 or 1 in ten runs, and 79 of its interrupts finding nothing due in one); it records them, andsched-wake-no-preempt-harts-mttcgjudges them (41 to 78 on rv64 and 41 to 75 on rv32 over ten runs a width). -
Every hart pays for each device interrupt. An unmasked source raises the external interrupt on every hart’s context, so each hart in user mode or idle when it rises traps, and all but one take the kernel lock for a claim that finds nothing; a hart in the kernel at the rise returns to a source already claimed and masked, and does not trap (
sched-lock-contention-4-mttcgrequires every hart to take ten or more of its 200 alarms; undericounta hammer in a search at every rise took none, sosched-lock-contention-4requires only another hart’s). Under multi-threaded TCG at four harts every alarm traps the other three (518 to 577 such entries in a run of 200 alarms, twenty runs), each holding the lock about 31 µs (p50; 50 µs at p90): at most three are ahead of a wake, about 0.15 ms of a p99 of 4 to 12 ms. So it is not routed: sending each source to one idle hart (a busy hart’s threshold above the sources’ priority while one idles) is the remedy if it comes to matter. -
Some cases keep one hart. A case that does not keep its hart count runs at the count the bench is given (
--smp); these keep theirs (keep_smp), each for one of four reasons: a proof that reads one hart’s slices; where a checked build’s audits fall; a count with no trace, which undericountis the machine’s instructions, not a hart’s time; or a scenario that measures one queue’s rounds. The lock waits, and then the cap set’s lifts, once kept three more:sched-budget-churn-shell,deadline-flood-billed-tracedandsched-exit-churn‘s victim against threads that exit are judged at two harts since the lift target (“The current minimum and ties”) and the waits behind other harts’ audits billed to no one (above). Each kept property has a case at two harts that covers it, or says none:Case Class Why At two harts sched-wake-no-preemptone hart’s slices its proof reads each nap against the slice that follows it, which needs every timeout answered by a timer interrupt mid-slice; at two harts most are answered at a slice’s end or at another budget’s call sched-wake-no-preempt-hartssched-deadline-preemptone hart’s slices the deadline’s timer comes on its payer’s hart; on two the other runner has a hart of its own, so which budget an expiry interrupts is QEMU’s turn order none call-boundsone hart’s sections under icounta lock section on several harts counts the other hart’s instructions too, assched-timer-entry’snone sched-irq-wakeone hart’s slices its proof reads each device interrupt against the runner’s slice that follows it; on two the idle hart takes the wake, and there is nothing to preempt sched-wake-no-preempt-harts(timeouts’ wakes, at the entry that answers them)sched-round-robinone hart’s turns the round-robin is among the threads no hart runs, and on several harts a budget’s threads run at once none sched-large-weightaudits its users’ hart switches budgets each slice and takes most audits, charged to no one, so the server reads 572 of 1000 on rv32 against 450 to 550 sched-share(its 300 holds a hart)deadline-flood-billeda count a release build with no trace deadline-flood-billed-tracedandsched-timer-floodsched-share-releasea count a release build with no trace sched-sharesched-large-weight-releasea count a release build with no trace sched-sharesched-lift-delayone queue’s rounds the capped launcher runs on a hart of its own and the peers spread over more than a round sched-debt-liftsched-carve-returnone queue’s rounds V is capped at two budgets on two harts, so U alone sets the floor and never leads it sched-carve-inflationsched-cluster,sched-cluster-old-controlone queue’s rounds the envelope is calibrated against one runner’s slice period sched-latency’s targetskernel-containmentruns at two harts but judges its bystander’s share at one (@1): at two it reads 450 and 567 of 1000 on rv64 (two builds) and 557 on rv32, against 450 to 550 (464 and 442 before the lift target). The bystander is never lifted and takes no other budget’s billed work on its hart; what moves it is the lock: its waits behind the agents’ kernel sections, 26.6 million ticks against the agents’ subtree’s 19.6 million on rv32, cost its hart time that no billing gives back, which R12 does not promise (above), and shift which budgets run beside it. Every other verdict and target of the gate holds at two harts (containment).bench-poweroff-missingkeeps one hart too, a bound of the bench’s own.sched-latency-tcgruns at two harts: the kernel’s lines wait, whole, for a program’s line in progress (the console’s one writer).sched-capped(two, three and four harts),sched-capped-mttcg(two and three),sched-lock-contention(two),sched-lock-contention-4(four),sched-wake-no-preempt-hartsandsched-wake-no-preempt-harts-mttcg(two) keep the counts they are about. -
A call within one budget crosses harts. A wake sends an idle hart the reschedule interrupt even when the woken thread’s budget runs elsewhere, so a server and its client in one budget hand each call and reply across two harts, each hand-off an interrupt and a wait for the lock, where one hart would run them in turn. Under
icount,ipc-client’s 1000 lend round trips againstlog-serverinsystemtake 2.07 s at two harts against 2.02 s at one (rv64; 2.06 and 2.01 s on rv32), since a hart waiting for the lock halts (R78). No rule keeps a woken thread on its waker’s hart. A call’s or a destruction’s kernel time delays every wake on the machine, which is why R12 bounds a call’s kernel time whoever pays for it. R10’s time dominates lease termination and follows the dying subtree and the handles that depend on it, so its target and the deadline notice’s are 30 and 40 ms, not the 39 and 54 ms a whole-frame scan had (budgets). Ending a DMA driver adds up toRESET_US(1 ms) of reset polling for each device it held, at mostMAX_DMA_DEVICES(16) (devices). -
A destroyed lineage’s debt is carried onto its siblings as the parent’s lead. Debt lifted onto a shared parent is normalized to the parent’s weight, and a sibling created under it enters at the parent’s pass; the oracle recomputes every lift. A fresh lift delays the sibling by the child’s lead, in slices of the other runnable budgets (the peers), times the peers’ weight over the parent’s weight; the sibling’s own weight does not enter. In a checked build a child that ran to the end of a slice leads by 1.4 peer slices, so under a parent of a peer’s weight the next child waits 1.4 rounds, and under a parent of a tenth of it 14.
bench:sched-lift-delaymeasures it in a checked build in virtual time, under sixteen weight-20 peers, with parents of weight 20, 10 and 2 and siblings of weight 2, 5 and 1. The oracle computes the delay from the lift’s records as the child’s work over the parent’s weight over a round’s pass step: 1.4, 2.8 and 14.0 rounds on rv64 (1.4, 2.8 and 13.8 on rv32), and no other budget is picked more than 1, 3 and 14 times before the sibling’s first run (2, 3 and 14 on rv32). The oracle requires that count to be less than a round from the prediction and from the sibling’s lead at its wake, and the floor to have moved less than half a round between the lift and the wake (under 0.3 rounds on rv64, 0.32 on rv32), so each phase’s lead at the wake is within half a round of the prediction. Under a parent as wide asusersthe lift is a few thousandths of a slice. The lead fades as the floor rises, so a sibling made later waits less; with peers of weight 100 the floor passed the lift of a weight-100 parent while the launcher started the sibling, so the case uses weight-20 peers, which the launcher outweighs.bench:sched-debt-liftchecks that a sibling created after a weight-1 lineage’s destruction runs before any other budget is picked twice, which its raw debt, unlifted, would have cost about six rounds. Rounding loses under one pass unit per destroyed budget, and the loss falls on the budget that churns. -
Scheduling is observable.
rdtimeis readable in user mode, so a thread that times its own gaps learns how busy the machine is. Timing channels are out of scope (TENETS). -
Measured on QEMU. The targets are guest instructions under
icount; no hardware run is measured, and a hardware run will characterise in cycles, not gate. A target set from a sweep holds for the seeds swept, not for every seed. The queue and its accounting are judged at 1, 2 and 4 harts; the targets are gated at 1 and 2 and recorded at 4 (several harts). The cases that read the trace run a kernel built with it, which has a record at every queue event and 64 MiB less RAM for the budget tree, taken from the top of RAM below the DMA pool so the frames below sit where a release kernel’s do.
Why
- Weights, not priorities. A server ahead of everyone would let one user make
littlefsdorkeyddo expensive work while no user budget runs. Large weights from the boot manifest (init) keepinit, the steward and the drivers responsive without that; a driver that spins while others are runnable is a bug for the bench to find, not a mode to support. - Free weight. Carving is how budgets delegate, so a child’s share must come out of its parent’s; counting the limit instead would let a tree of carves multiply its share.
- The floor. Without it a sleeper keeps a stale low pass and dominates when it wakes. With it, a waker is still prompt: it joins at the front of the current minimum.
- Wake-first, deterministic ties. A budget that just woke has usually waited, and one that was requeued has just run. Fixed clauses over kernel ids let an independent oracle check every pick.
- No preemption on wake. One preemption source, the timer, keeps accounting to a single point and makes a flood of wakes cost nothing extra. The price is that wake latency is measured, not derived.
- Instructions, a pinned seed and a sweep. Host time makes a gate depend on the machine it runs on; guest instructions do not. A pinned seed makes a failure replay exactly, and the sweep keeps the pinned seed from hiding a worse phase. The lease-end target stays the sum of its two parts, so a slower wake cannot hide inside the destruction’s allowance, or the reverse.
- Ticks, an exact remainder, one tick at least. Whole microseconds would let a run under 1 µs go free, and a large weight would round short runs to nothing. Counting at the trap boundary means no exit path runs unaccounted.
- Additive, normalized inheritance from entry. Otherwise a budget sheds debt by destroying the child that ran, or a light grandchild’s raw pass stalls an unrelated sibling, or an honest parent pays twice for the wait it gave the child.
- No time donation. A server thread running on its caller’s budget could be stopped mid-call by that caller’s lease and hold the server’s locks for good. Servers pay for their own CPU until measured priority inversion says otherwise (beyond).
- No trace in production. A per-pick record of every budget is a cross-principal channel that the containment claims do not cover, so it exists only in test builds.
Time and timeouts
The kernel owns each hart’s timer and keeps it armed for the earliest thing
due: its running thread’s slice end, a blocking call’s timeout or a budget’s deadline. Time is
monotonic microseconds since the kernel started, read with time_now; user mode also reads the
raw counter with rdtime. Every blocking call takes a timeout, and a budget with a deadline is
destroyed when it passes. No process programs the timer, and the kernel keeps no date.
Purpose
Time is how the kernel keeps its promises to waiting threads and to whoever grants a lease. A caller must never block past its timeout because a server stalls or a spinner holds the CPU. A lease must end when its deadline passes, whatever the budget inside it is doing. So the timer is the kernel’s alone, it is armed whenever anything is due, and every kernel entry answers what has passed before it does anything else.
Interface
Time
Status: built · partly tested: that time_now counts from the kernel’s start is not checked by a case · tested: bench:timeouts, bench:timeouts-tcg, host:redoubt-sys::every_result_and_error_round_trips
| Source | Unit | What it is |
|---|---|---|
time_now | microseconds | Monotonic time since the kernel started, rounded down. No arguments, no errors; the value comes back as one 64-bit result in two registers (ABI). |
rdtime | timebase ticks | The hardware time counter, read directly in user mode (the kernel sets scounteren.TM). It counts from reset, not from boot. On rv32 a program reads rdtimeh, rdtime, rdtimeh and retries until the high halves agree. |
time_now is the clock with a unit. rdtime is for fine relative timing: it costs no kernel
entry and resolves single ticks. No call returns the timebase frequency; a program that needs
ticks per microsecond measures rdtime against time_now over a sleep. Microseconds in a u64
last about 584,000 years, so the kernel never handles a wrap; every sum saturates instead.
The loader reads the frequency from the device tree (/cpus/timebase-frequency) and passes it
in the Time tag of the argument block (boot). The kernel converts with it both
ways: ticks to microseconds rounding down, microseconds to ticks rounding up. So a timer set for
a time never fires before time_now reaches it.
The hart timer
Status: built · partly tested: that a stale early hint costs one early interrupt and misses nothing is argued from the code, not attacked · tested (12)
- bench:timeouts
- bench:budget-deadline
- bench:sched-share
- bench:sched-timer-entry
- bench:deadline-placement
- bench:timer-flood-at-rest
- mutation:TimeoutIgnoredWhileOthersRun
- host:redoubt-stride::a_hint_is_lowered_and_a_tie_of_payers_belongs_to_every_hart
- host:redoubt-stride::each_item_is_armed_on_a_hart_that_runs_its_payer_or_on_one_hart
- host:redoubt-stride::a_hart_covers_an_item_only_when_armed_at_or_before_it
- host:redoubt-stride::an_item_stays_covered_when_the_hart_that_covered_it_switches_away
- host:redoubt-stride::a_payer_nowhere_item_stays_covered_across_the_covering_harts_slice_end
The kernel keeps three times (kernel/src/time.rs) and arms the hardware for the earliest:
- the slice end of each hart’s running thread: set to the time plus
SLICE_US(1,000 µs) when a threadkmainpicked returns to user mode, and to never whilekmainitself runs and from a pick until that return (R12 (scheduling)); - the earliest timeout of any blocked thread;
- the earliest deadline of any budget.
The last two are hints, and a hint is only ever early. A thread that blocks with a timeout, or a
new deadline, lowers it at once; a call that does not block lowers nothing. A wait that ends
before its timeout, or a budget destroyed by hand, leaves it where it was. A stale hint costs one
early interrupt and one walk, which recomputes it; nothing is missed. The
kernel re-arms only when the earliest time changes, and only on its way to where a timer
interrupt can be taken: the return to user mode, kmain’s idle, and a timer interrupt taken in
that idle, which returns there. What changes the earliest time in between (a call that blocks
with a timeout, a new deadline, a slice’s end, the interrupt that fired) only records it. So a
preemption arms the timer once, at the return to the thread kmain picks. Each arming is one
ecall to the firmware, through the SBI TIME extension (kernel/src/arch/riscv/timer_sbi.rs).
With nothing due at all (the kernel idle, no timeouts, no deadlines) the timer is set to never.
Whose hart comes by an item. Each hint carries the id of the budget that pays for its item: a
timeout’s, the blocked thread’s budget; a deadline’s, the dying budget’s parent (the bill at the
destruction may go to an ancestor with free weight; the placement reads the parent). A hart arms
its timer for an item if it runs the payer, or if no other hart that runs the payer is armed at
or before it (a timer that has just fired and is being re-armed counts as armed); when no hart
runs the payer, or two payers tie at the instant (the hint then names none), a hart arms it
unless any other hart is armed at or before it; otherwise that hart’s timer comes by it. So the
work the item makes, the timeout’s answer or the whole destruction, lands on its payer’s hart
where the payer runs, in the payer’s own time, and not in another budget’s slice (R12 for timer
work); where the payer runs nowhere, the common case for a
timeout (a budget with one thread blocks to wait), it lands on one hart, the first to re-arm
since the item was noted or the hint moved: usually the hart its thread left, which re-arms at
that very return or idle, but the choice is path-dependent (a hart running another budget may be
it, and then the interrupt is billed to the item’s payer as any timer work is), and what the rule
bounds is the count of harts an item wakes, one, not which. Before, every hart’s timer came by
such an item, so at rest, where every blocked budget’s payer runs nowhere, each timeout woke
every hart and all but one took the kernel lock for nothing: the launched system at the prompt
(bench:launch-idle, four harts, 60 s windows) took 197 timer interrupts a window on rv64 (173
on rv32) for about 65 timeouts, the lock changed hands 371 times (313) with each hand-off an
interrupt to the halted waiter, and the kernel re-armed the timer 345 times (319); with the rule,
five windows a width, 63 to 70 timer interrupts, 100 to 115 hand-offs (a woken thread’s own
calls meeting another hart’s entry, no longer the harts waking for nothing) and 194 to 199
re-arms (three a timeout: the noting hart’s, the slice end’s at the thread’s return, the next
item’s at its idle), the busiest hart from 3.5 interrupts a second to 1.6 to 1.9 and two harts
under 0.3, often without a timer interrupt in the whole window. The decision is a pure function of
the hint, the harts’ runners and what each timer is armed for (redoubt_stride::Hint::due_on,
read at each re-arm from the scheduler’s runners, one read per hart; the deadline list is never
scanned on the way to user mode), and it keeps the liveness the plain rule had: at every return
to user mode and before every idle, at least one hart is armed at or before each kind’s earliest
item, which the host tests above hold over random runner sets, re-armed in turn as the harts do
under the kernel lock; a hart that covers an item re-arms at every exit and either keeps it,
finding no other hart covering, or sees another that does, and one that covered it only by its
own earlier slice end re-arms at that end and then arms the item. At one hart nothing changes:
there is no other hart to cover an item.
While a user thread runs, its slice end is always pending. So the kernel is always entered
within one slice, whatever the thread does. The kernel itself runs with supervisor interrupts
off. A timer interrupt is taken from user mode, or while kmain sleeps in wfi with nothing
runnable; a time that passed while the kernel was busy is already pending, so wfi returns at
once.
flowchart TD
blk["a thread blocks in call, send<br/>or receive with a finite timeout"] --> hto["earliest timeout: lowered,<br/>its payer the thread's budget"]
mk["budget_create<br/>with a deadline"] --> hdl["earliest deadline: lowered,<br/>its payer the parent"]
pick[a picked thread returns<br/>to user mode] --> hsl["slice end: return + SLICE_US"]
leave[the kernel leaves for kmain] --> hnv["slice end: never"]
hto --> M{"this hart's earliest changed?<br/>(an item whose payer another hart<br/>runs, armed by then, is that hart's)"}
hdl --> M
hsl --> M
hnv --> M
M -- yes --> arm["SBI set_timer: the first tick<br/>at or after it"]
M -- no --> K[timer left as it is]
arm --> I[timer interrupt]
I --> X["expiry at trap entry:<br/>answer what is due,<br/>recompute both hints"]
Y[any other kernel entry] --> X
X --> M
I --> over{"from user mode<br/>and slice over?"}
over -- yes --> pick
Figure: what arms the timer. Every arrow is built; nothing in user space reaches the timer.
Timeouts and FOREVER
Status: built · tested (5)
- bench:timeouts
- bench:timeouts-tcg
- bench:sched-wake-no-preempt
- mutation:R12TimeoutWakePreempts
- host:redoubt-rt::sleep_zero_is_one_receive_from_nothing_that_returns_at_once
call, send and receive each take a timeout: relative microseconds from the moment the
call is made. The kernel adds it to the current time with saturation, so the deadline it
records never wraps. FOREVER (u64::MAX) never expires, and neither does any timeout large
enough to saturate. There is no maximum timeout and no minimum. A timeout of 0 is a poll: the
call delivers what it can at once and otherwise returns Timeout without blocking.
A sleep is receive with no handle: it can only time out. The Rust runtime’s sleep
(libs/rt/src/handle.rs) is exactly that. sleep(0) is the runtime’s yield: it enters the kernel
and returns at once. Redoubt’s kernel runs no other thread first: a deadline already past times out
without blocking, and the caller resumes without the scheduler picking again. A backend that
switches threads cooperatively switches there.
When a timeout passes, the blocked call returns Timeout with what it waited for unwound. A
queued call or send gets its buffer back. A call the server already took is abandoned: the
lend is consumed and the server gets one abandoned-call notice
(IPC). A receive on an endpoint or an interrupt simply ends.
A timeout wakes; it does not preempt. At the first kernel entry at or after the deadline
the kernel commits the Timeout result and makes the thread runnable. When it then runs is the
scheduler’s choice under R12 (scheduling) (scheduling): a
thread whose timeout falls mid-slice runs when that slice ends, or later.
Budget deadlines
Status: built · partly tested: that a destroyed child’s later deadline is dropped is seen only as the kernel surviving it · tested: bench:budget-deadline, bench:sched-latency, mutation:BudgetDeadlineIgnored
A budget’s deadline is absolute microseconds since boot, set once by its creator in
budget_create; FOREVER means none. The field and what a lease is belong to
budgets; this section is how the timer fires it.
The kernel links every budget with a deadline into one list, so finding the next deadline never
scans every budget. When a deadline passes, the kernel destroys the budget and its subtree
exactly as budget_destroy does, with nobody asking (R10 (destruction)):
every process in it is killed (an exit notice, where one is sent, blames nobody), its calls
are failed or abandoned, and every handle to it or stamped with it is revoked. A deadline already past at
budget_create destroys the budget at the next kernel entry, so the handle the caller receives
is dead by the time it can use it.
A child’s own deadline may be later than its parent’s. It never fires then: the parent’s destruction takes the child with it, and a destroyed budget leaves the list.
A deadline is a preemption point (R12). If a deadline fires at a kernel entry from user mode, the entering thread gives up the CPU before its trap is handled. If its own process was destroyed, nothing of it is left to handle. Otherwise its trap is taken again when it next runs.
Expiry
Status: built · partly tested: the order at an equal instant is attacked only in the model · tested: bench:budget-deadline, bench:timeouts, bench:expiry-deadline-then-timeout, mutation:ExpireBudgetsFirst
Expiry answers everything due at or before the current time. It runs first at every kernel
entry except kmain’s switch to a thread, before anything reads the entering process. kmain
itself expires, then picks, then switches, with no kernel entry between. So a time that has
passed beats any operation that enters after it.
Expiry takes the earliest item first. At an equal instant, timeouts come before deadlines;
timeouts among themselves go in (pid, tid) order, and deadlines by budget id. The order
matters: a caller whose taken call times out at the instant its server’s budget reaches its
deadline gets Timeout with its lend consumed, not Dead with it returned.
Finding what is due reads the waits with a deadline of each process whose cached earliest timeout has come, which each process keeps on a list of its own, and walks the deadline list. Both are skipped while the hints are in the future. After expiry the kernel recomputes both hints, and the timer is armed for them on the way out (above).
Wall-clock time and time sync
Status: planned · M6 (persist, install, share)
The kernel keeps no date: time_now, timeouts, slices and deadlines stay monotonic time
since boot. Wall-clock time (dates, time zones) is a user-space offset over
time_now. A server holds the offset and answers it on an endpoint. At boot it sets the offset
from the board’s real-time clock, reached as a device object by its driver
(devices). While the box runs, it corrects the offset by an authenticated time
source reached through gatewayd. Every date a server stamps (audit
records, file times, certificate checks) comes from this offset. A lease granted for a wall-clock span is turned into a deadline in
time_now units when it is granted, so a wrong or attacked wall clock can mislabel logs and
files but cannot lengthen a lease or a timeout.
Open: which server owns the offset (the steward, the RTC driver or a server of its own);
which authenticated source syncs it (NTS, Roughtime) and what happens with no network; whether
TLS certificate checks in gatewayd (M5 (self-hosted development)) need wall time earlier;
whether a correction may step the offset backwards or only slews it; how the offset persists
across reboots and how a clock set backwards is detected; how audit records bind monotonic and
wall time.
Authority
Status: built · tested: bench:timeouts, bench:budget-deadline, bench:legacy-gone
- Reading time needs no handle. Every process may call
time_nowand readrdtime. Time is not a capability, because hiding it protects nothing (see Why). - Nothing in user space programs the timer. There is no timer call, no timer device object and no timer interrupt a process can receive. Numbers outside the call table are refused (ABI). A process affects the timer only by running (its slice), by its own timeouts and by the deadlines of budgets it creates.
- A deadline is chosen once, by the creator.
budget_createtakes it from whoever holds a handle to the parent budget. No call changes it later. The holder of a handle to the budget can end it sooner withbudget_destroy(budgets). - A timeout is the caller’s own. It bounds only the calling thread’s wait; no argument sets another thread’s.
Security properties
I13 (every blocking call returns by its timeout), on the timer
Status: built · partly tested: timeouts on more than one hart are not attacked by a case · tested (5)
- bench:timeouts
- bench:timeouts-tcg
- bench:sched-latency
- bench:sched-wake-no-preempt
- mutation:TimeoutIgnoredWhileOthersRun
I13 is owned by invariants; this
is how the timer keeps it. No thread stays blocked past its timeout. The timer is armed for the
earliest timeout whether or not another thread runs, and every kernel entry expires first. The
conversion rounds up, so no timeout ends early. FOREVER and saturated timeouts are the only
ones that never end. A reply racing a timeout lands on exactly one side: either the caller gets
the reply and the server delivered, or the caller gets Timeout and the server discarded
(R13 (one outcome per call), IPC).
“Returns by” means the result is committed and the thread is runnable. It runs when R12 picks it, and a wake never preempts; the bench measures that delay as a target, not a bound (scheduling).
R10 (destruction) at a deadline: a passed deadline comes first
Status: built · partly tested: a process entering the kernel in a tight loop to put its deadline off is not attacked by a case · tested: bench:budget-deadline, mutation:BudgetDeadlineIgnored
R10 is owned by budgets, which states the deadline path and the
equal-instant order; this is how the timer keeps it. A budget whose deadline has passed is
destroyed before any operation that enters the kernel at or after that instant, because
expire_due (kernel/src/time.rs) runs first at every kernel entry. Nothing defers expiry: no kernel state, no interrupt handling and no
call a process can make postpones it. A budget cannot outlive its deadline by spinning, because
its slice end brings the kernel back, nor by blocking, because the timer is armed for the
deadline itself. The only delay is the kernel work already in progress when the deadline passes.
R12 (scheduling) for timer work
Status: built · tested: bench:sched-timer-flood, bench:deadline-flood-billed, bench:sched-exit-churn, bench:deadline-placement, bench:deadline-flood-billed-traced-mttcg, bench:sched-exit-churn-mttcg, mutation:R12TimerWorkUnbilled
R12 is owned by scheduling; this is how its charging applies to the timer’s work. Each expired item’s work is billed under it: a timeout to its thread’s budget, a deadline’s whole destruction to the dying budget’s parent, after its carve returns, or to the nearest ancestor with free weight above 0 (scheduling). The one walk that finds what is due, and its ordering by deadline, is billed in equal shares to the items it handles, each with its own ending, so a budget pays for its own share of a shared instant, not a neighbour’s. The timer is armed for a timeout only when its thread blocks, so a call whose timeout has passed, or that is answered at once, arms nothing. A wait that ends before its timeout leaves the timer early; the walk that finds it gone is billed to the waiting thread’s budget. The rest of the entry, the timer’s own handling, goes to the budget whose item or wait it found last, so the budget the timer interrupted pays for none of it. Each bill closes its interval at the tick that opens the next, and the last opens the rest of the entry’s, so a timeout’s bill never leaves its own handling between two intervals, nobody’s; a deadline’s destruction still does (scheduling). So a process that arms many timers a microsecond apart, or creates many budgets with staggered deadlines, spends its own CPU share, not a neighbour’s.
On several harts the bill is right, and the hart is the payer’s where the payer runs: a hart’s
timer comes by a timeout or a deadline only if it runs the budget that pays for it, or no hart
does, or the payer’s hart is not yet armed by then (the hart timer). Before, every hart’s timer came by the earliest of each,
so the hart that answered one was often running another budget, whose thread waited out the
payer’s work; a pick repays that only where it can, and a budget with one thread beside a payer
with one, on two harts, cannot take a second hart. The traced kernel’s oracle reports the timer
interrupts from user mode that charged a budget other than the one they interrupted, with their
ticks, and a case bounds them inside a victim’s window as a share of what the victim was charged
(foreign_timer_in, the scheduler oracle), at 10 of
1000 at two harts, where the harts run at once (multi-threaded TCG, host time, on the quiet core
set): deadline-placement, whose creator makes its deadlines 600 µs ahead and then only spins,
reads 0 to 1 of 1000 in five runs a width, and a kernel built with timer-any-hart, every hart’s
timer coming by every item as before, fails it at 21 (rv64, 74 such interrupts, 251,881 ticks)
and 29 (rv32, 89, 364,375), the recorded negative runs; deadline-flood-billed-traced-mttcg
reads 1 to 4 with 16 deadlines and 0 to 1 with 64, sched-exit-churn-mttcg 0 (rv64) and 1 to 5
(rv32) against threads that exit and 0 against the processes, five runs a width each. Before the
placement, at two harts: in sched-exit-churn against threads that exit, the attacker’s poll
timeouts, 27 such interrupts and 70,232 ticks on rv64 (0.23 % of the window’s charges), 8 and
17,035 on rv32; in deadline-flood-billed-traced with 16 deadlines, where the destructions landed
decided the victim’s share: mostly on the creator’s hart, 3 such interrupts and 34,046 ticks
(rv32) and the victim kept 872 of 1000; mostly on the victim’s (87 of 96, rv64), 89 and 833,543
ticks, 17 % of the window’s charges, and it kept 482, against at least 450. Under icount the
count is not the placement’s but the order of QEMU’s turns, and a run locks into one phase: the
icount cases report it and bound nothing by it (a hart waits for QEMU’s
turns).
What still lands elsewhere, and is counted, not excluded: a payer that runs nowhere (a
one-thread budget waiting on its own timeout), whose item one hart comes by, the first to re-arm,
usually the one its thread left but not always, so a victim beside such a poller may answer some
of its timeouts (before the one-hart rule, every hart came by it and a victim answered about half:
sched-exit-churn under icount, 20 of 1000 of the victim’s charges, 164 interrupts, on rv64 and 0
on rv32 in one run each, which phase the run locked into); two payers at one instant, whose
items are one hart’s the same way; an item noted on a hart
that does not run its payer, which that hart arms too until the payer’s hart next re-arms; an
item whose payer was destroyed and its id reused before the expiry recomputed the hint, which is
placed as the new holder of the id runs until then (the placement alone, never the bill); and any
item that has passed when some other entry comes, a call’s, or a slice’s end through kmain or
inside its own interrupt, which serves it there (I13 and R10 order every passed item first, on
any hart). The last is a flood’s: the payer’s hart is inside one destruction with interrupts off
while the next deadline passes, and the victim’s own slice-end interrupt reaches it first, the
destruction landing there; under icount, where the victim’s slice end runs 0.7 ms ahead of the
payer’s exact-time interrupt in QEMU’s turns and the 26 ms destruction cycle keeps that phase,
deadline-flood-billed-traced read 114 of 1000 of the victim’s charges with 16 deadlines and
235 and 241 with 64 in two runs of one bundle on rv64 (0 in another, 0 and 1 on rv32), what the
residual can cost under one clock. Device interrupts are placed by the controller, not the payer
(IRQ1’s residual). The deadline notice at two harts
(sched-latency, net p99 at N = 1, 4 and 16): before the placement 3,297, 3,409 and 3,764 µs on
rv64, 3,985, 4,079 and 5,093 on rv32; with it 3,535, 3,428 and 3,758 and 3,933, 4,104 and 5,133,
every move within the same program’s p99 kernel section (10,167 ticks on rv64 and 11,053 on
rv32, 1.0 to 1.1 ms, with hold-trace), the cost of waiting for the payer’s hart;
kernel-containment at two harts 24,001 from 24,027 and 27,120 from 27,349.
Failure and restart
Status: built · partly tested: a boot with no Time tag is not attacked by a case · tested: bench:budget-deadline, bench:timeouts
- No timebase, no boot. If the argument block has no
Timetag, or it is 0, the kernel stops at start and powers off, because no timeout, slice or deadline would mean anything (R17 (fail closed), boot). - A deadline hits the running process. The budget is destroyed with the process that was interrupted killed last; the kernel then runs whatever is current and never resumes the dead thread.
- A server holds a call past its caller’s timeout. The caller gets
Timeoutand its lend is consumed; the server keeps the call open until it replies (R3 (lends and abandoned calls), IPC). - A reboot resets time.
time_nowstarts again from 0. No timeout or deadline survives, because no thread or budget does. - No timeout or deadline value, however large, can make the kernel panic: every sum saturates and every conversion is checked (I14 (no call panics the kernel)).
Residual risks
- A timeout is a wake, not a run. I13 bounds when the result is committed, not when the
thread runs. Under load a woken thread waits for R12’s turn. The bench holds lateness to
1,000 µs for an idle sleep and asserts wake targets under load only in virtual time
(
bench:timeouts,bench:sched-latency);bench:timeouts-tcgreports real-time lateness without judging it. - Timer work can still land on another budget’s hart. An item is armed on its payer’s hart where the payer runs; one whose payer runs nowhere or ties with another is armed on one hart, the first to re-arm, whichever that is; one noted on a hart that does not run its payer until the payer’s hart re-arms, one whose destroyed payer’s id was reused before the hint was recomputed, and one that has passed when some other entry comes are answered where they are. The oracle counts them and three cases bound the count inside a victim’s window (R12 for timer work).
- Kernel work is not interrupted. The kernel runs with interrupts off, so a timeout or
deadline that passes during a long kernel operation (a budget’s destruction, a large range
call) is answered when that operation ends, and one armed on its payer’s hart, or on the one
hart that covers an item whose payer runs nowhere, waits for that hart to leave the kernel,
where an idle hart would have answered it at once: the cost of waking one hart instead of every
hart is a wake’s latency, bounded by the covering hart’s kernel section
(the hart timer). Re-judged at two harts with the rule, every target met:
bench:sched-latency‘s deadline notice net p99 at N = 1, 4 and 16 rose by 0.2 to 0.4 ms on both widths (rv64 3,763, 3,874 and 4,076 µs from 3,535, 3,428 and 3,758; rv32 4,302, 4,390 and 5,407 from 3,933, 4,104 and 5,133), within the program’s p99 kernel section of 1.0 to 1.1 ms; its timer wake at N = 16 by 1.6 and 3.7 ms (10,702 and 21,463 from 9,132 and 17,714) while at N = 1 it and the driver wake fell. The N = 16 rise is more than a section, and its mechanism is the rule itself under a burst of timeouts: sixteen sleepers’ timeouts, every payer running nowhere while it sleeps, are armed on one hart, so their wakes are served there in turn while the other hart idles, where before each hart answered about half of them. It is a cost of the one-hart rule, not of a kernel section, and it has a trigger: if that timer wake’s p99 at N = 16 nears its 50 ms target, or a workload shows sleepers bursting on one hart, the covering hart hands the items due beyond the first to an idle hart, or an idle hart is preferred to cover.bench:kernel-containment’s deadline notice 24,529 and 27,882 from 24,001 and 27,120. At one hart nothing moved.bench:budget-deadlinebounds a deadline’s lateness by the cost of the same destruction by hand plus 1,000 µs. The frames such an operation freed are zeroed by an idle hart, one frame a pass with interrupts off (about 30 µs), only while every hart is idle, and on demand by an allocation that finds no free frame (frames in flight), so the zeroing adds at most one frame to an interrupt’s wait, on any hart count. - Every process has a perfect clock.
rdtimeresolves single ticks andtime_nowsingle microseconds, and both are free to read. Anything a process can time, it learns. Timing and covert channels are out of scope (TENETS). - The timebase is the platform’s word. The frequency comes from the device tree, which the bundle’s signature does not cover (boot). A wrong value scales every timeout, slice and deadline alike. Only the firmware and the emulator or board supply it, and they are TCB.
- The firmware arms the timer. The kernel does not check the result of the SBI
set_timercall. A firmware that failed to arm it would stop slices, timeouts and deadlines. The firmware is TCB (boot). - Expiry walks the processes. An expiry reads every live process’s earliest timeout
(
MAX_PROCESS_COUNT, 511, a compile-time constant no process can change) and, in those whose earliest timeout has come, only their waits with a deadline, never their other threads, plus the deadline list. One walk finds every wait due and orders them by deadline, so a deadline that ends many waits at once walks once, and ordering its R waits takes R log R steps; both are billed in equal shares to the waits it found. At full occupancy, every PID in use with every thread, the walk that finds 250 waits due at once takes 26.8 ms on rv64 and 28.9 ms on rv32, within R12’s 30 (bench:worst-walk). A wait the walk finds ended early is billed to its budget. A budget destroyed before its deadline leaves the timer early: one walk of the deadline list, nobody’s, for each such destruction, which its destroyer pays for in full. A wait that a deadline’s destruction fails during the same expiry leaves its process’s earliest timeout early: one more early interrupt, billed as a wait that ended early. - Equal-instant order is argued, not attacked. Timeouts before deadlines at one instant is checked by the model’s mutation only; no bench case lands a timeout and a deadline on the same microsecond.
- A destroyed child’s later deadline is shown dropped only by the kernel surviving past it:
bench:budget-deadlinecannot observe the deadline list directly.
Why
- The kernel owns the timer. Timeouts and deadlines are promises the kernel makes; a timer server could be starved or killed and take every promise with it. So there is no timer server, no timer interrupt for user space and no call that programs the hardware.
- Always armed for the earliest. One target, the minimum of three, is the whole design. The slice end alone guarantees the kernel back within 1 ms of any user code, so expiry never depends on a thread choosing to enter.
- Expire first, at every entry. A deadline that passed must win over whatever the entering thread asks for; checking first makes that true at every entry without a special case per call.
- Hints that are only ever early. Lowering a hint is cheap and raising it would need a walk. An early hint costs one spare interrupt; a late one would break I13. So the kernel only lowers.
- Relative timeouts, absolute deadlines. A timeout is how long the caller will wait from the moment it asks, so it is relative and cannot be stale. A deadline is when a lease ends, whatever happens to its creator meanwhile, so it is absolute.
- Microseconds in the interface, ticks inside. Microseconds are the same on every board, so programs and the model need no frequency. Charging CPU time in ticks is the scheduler’s business (scheduling).
rdtimeis open, and its frequency is not a call. Hiding time protects nothing: an attacker can count loop iterations just as well (TENETS).time_nowis the clock with a unit, so the kernel keeps no second way to learn one.- SBI TIME, not Sstc. It works under every SBI firmware on both widths with no firmware
configuration (Sstc needs
menvcfg.STCE), and oneecallper arming is nothing against a 1 ms slice. - Timeouts first at an equal instant, so a caller whose server’s lease ends at its own timeout sees the timeout it asked for, with the lend accounting that goes with it.
- No date in the kernel. Wall-clock time comes from outside the box and can be wrong or attacked. Keeping it in user space means no kernel rule depends on it.
Processes
A process is an address space, a handle table and up to 255 threads, running in one budget. A
parent makes one in three steps: process_create makes it empty, process_map moves pages into
it, and process_start gives it its handles and starts its first thread. After that the parent
cannot touch it. When a process ends, the kernel sends one exit notice to the endpoint its
creator named, saying how it ended and, for a crash, whose call it was working on.
Purpose
A process is the unit of containment. It holds nothing it was not given: the pages its parent moved in, the handles its parent listed and one argument. It can reach only what those handles name, and nothing it does can widen the list. It is also the unit of failure. When a server crashes on hostile input, the kernel ends the whole process, fails its callers cleanly and tells its launcher whose call was running, so policy can restart the server and hold the right principal to account. The PID is only a name for reports: no authority is keyed by it, and a process that draws a PID used before inherits nothing.
Interface
Processes and PIDs
Status: built · partly tested: that PIDs are drawn at random is not attacked by a case · tested (5)
- bench:process
- bench:process-attack
- bench:process-fill
- bench:process-lifecycle
- host:redoubt-model::pid_reuse_only_after_notice_receipt
A process has:
- a PID, 16 bits, which the kernel keeps as its own record of the running process (
satpcarries none; memory layout); - the budget it runs in, which pays for everything it holds, counts its PID against its process limit and sets its CPU share (scheduling);
- an address space, a handle table (objects) and up to
MAX_THREADS(255) threads; - an exit endpoint, named by its creator, where its exit notice goes;
- its open calls, at most
MAX_OPEN_CALLS(256) (IPC); - a process object: one page charged to its creator’s budget, which holds the exit notice (see Exit notices). A process handle names this object.
PIDs run from 2 to MAX_PROCESS_COUNT (511: the PIDs there are, the kernel’s included, held
below 2^9 so that each fits Sv32’s ASID field (satp)); PID 1 is the
kernel. process_create draws the PID at random from the free ones: a PID is free when no
process holds it and no process object still names it. So a PID is held from process_create
until the process object is freed, which is after the process has ended (PID lifetime, below).
The creator is not told the PID. It appears only in the exit notice, so a launcher that must
tell children apart gives each an exit endpoint of its own.
The programs the loader starts at boot have PIDs but no process object: nobody created them, so nobody is owed their notice (boot).
stateDiagram-v2
[*] --> Created: process_create
Created --> Created: process_map
Created --> Running: process_start
Created --> Ended: its budget is destroyed (killed)
Running --> Ended: exits, faults, or its<br/>budget is destroyed
Ended --> [*]: receive takes the notice<br/>(object, handles and PID freed)
Ended --> [*]: notice dropped (exit endpoint<br/>gone, or the flow rule refuses it)
Created --> [*]: creator's budget destroyed<br/>(no notice)
Running --> [*]: creator's budget destroyed<br/>(killed, no notice)
Ended --> [*]: creator's budget destroyed
Figure: the life of a process and its process object. From Ended on, only the object is left: the exit notice and the PID it holds.
Closing a process handle ends nothing. A created process that is never started keeps its PID and its pages until its budget or its creator’s is destroyed.
Threads
Status: built · partly tested: a `thread_create` refused for want of a page, and a first thread returning from its entry, are not attacked by a case · tested (4)
- bench:thread-limit
- bench:process
- bench:process-lifecycle
- bench:process-review
A thread’s number within its process, its TID, runs from 1 to MAX_THREADS (255); the first
thread is 1. A process holds at most 255 threads, the first one included.
| Call | Arguments -> result | What it does |
|---|---|---|
thread_create | entry, sp, arg -> TID | Start a thread of the caller’s process at entry, with stack pointer sp and arg in its first argument register. TooManyThreads past 255; OutOfMemory if the budget cannot pay the thread’s page. |
thread_exit | none; does not return | End the calling thread. |
process_exit | code (32 bits); does not return | End the whole process with code. A code wider than 32 bits is InvalidArgument, and the process goes on. |
A thread costs its budget one page, its IPC page, which holds everything it waits for and the
calls it holds open (IPC). The kernel does not check entry or sp: a thread given a
bad one is created, and faults when it runs. TIDs are handed out round-robin from the last one
given, so a TID freed by one thread is used again by a later one.
A thread made by thread_create that returns from its entry ends as if it called
thread_exit, whatever it returns: its return address is one the kernel never maps, and the
fault there is read as the thread’s exit. The first thread has no such return address, and
returning from its entry is a fault.
thread_exit with other threads still running frees only that thread: whatever it was waiting
for is withdrawn, and every call it holds open fails its caller with Dead, lend returned
(R4b (a server dies)). No notice is sent. The last thread’s
thread_exit, or its return, is process_exit(0), so a started process never lives on with no
threads. A fault in any thread ends the whole process.
Creating and starting
Status: built · partly tested: `OutOfMemory` from `process_start` is not attacked by a case · tested (12)
- bench:process
- bench:process-attack
- bench:pid-pinning-attack
- bench:stub-launch
- host:redoubt-model::contexts_are_separate_from_creator_object_on_both_widths
- host:redoubt-model::process_map_destination_validation_precedes_started_state
- mutation:ProcessInWeightlessBudget
- mutation:R6ProcessObjectFree
- mutation:R6ProcessObjectChargedToBudget
- mutation:R6PidUncountedAtEnd
- mutation:R10HeldPidsDropped
- mutation:R11ProcessMapSkipsFlags
| Call | Arguments -> result | What it does |
|---|---|---|
process_create | budget, exit endpoint -> process handle | Make an empty process in budget: an address space and a PID, no thread. |
process_map | process, src, dst, len, flags | Move whole pages of the caller’s own memory into the process at dst, before it starts. |
process_start | process, entry, sp, arg, handle list -> nothing | Copy the listed handles into the process and start its first thread. |
process_create refuses, in this order: a bad budget or exit-endpoint handle (BadHandle,
WrongObject); a budget with no free weight, which could never be scheduled
(R12 (scheduling)) (InvalidArgument); an exit endpoint that is
not a receive right, badge 0 (NotPermitted; see R21); the budget at its
process limit (OutOfProcesses); then OutOfMemory for the budget’s pages, then
the caller’s, and OutOfMemory or TooLarge if the caller’s handle table cannot take the
handle.
A refused process_create costs nothing. The returned handle has badge 0 and is stamped with
the caller’s budget (R9 (stamps)).
What a process costs (objects):
- the process object: one page, charged to the creator’s budget (the caller of
process_create), because it holds the notice and the notice must outlive the budget the process ran in; - its header page (one on both widths), its root page table and every other page-table page, its handle-table pages, one IPC page per thread (which holds the thread’s saved registers too) and the pages mapped in it: all charged to the budget it runs in, and all given back when it ends.
A process limit counts PIDs, and every held PID counts once, against the budget the process
runs in, for as long as the PID is held (R6 (charging)). For a created
process that is from process_create until its object is freed, after its notice goes; for a
program the loader started, which has no object, it is while the program lives. Who launched a
process does not matter: a session the steward starts counts in the session’s budget, not the
steward’s. If that budget is destroyed while the PID is still held (the process was killed and
its creator, outside the destroyed budgets, has not taken the notice), the count moves to the
destroyed budget’s parent once the carve has come back
(R10 (destruction)), so it stays inside the carve that bounded
it. Because every process limit is carved from root’s, which is every PID but the kernel’s, a
process_create into a budget under its limit always finds a free PID, and no budget can take
another’s.
process_map moves pages from the caller into a process that has not started. The source
must be whole pages of the caller’s own RAM, not lent (a reserved page is backed first); device
and DMA pages stay put. Every check runs before any page moves. The ranges, the flags and the
source, in that order, are InvalidArgument (flags never empty, never writable and executable
together, never writable without readable: R11 (memory)), so bad flags
back no page of the source; a process that has ended (its address space gone) is
NotPermitted; a destination page already in use is InvalidArgument; a process that has
started is NotPermitted; last, the process’s budget must pay for the page tables and, unless
parent and child share a budget, the pages (OutOfMemory). The exact order is in the
ABI reference. The pages then belong to the process and
are charged to its budget; the page rules are in memory. The
image and the startup block reach a process this way.
process_start takes a record of up to MAX_START_HANDLES (128: the handles one start
copies) handle slots. It refuses, in this order, while decoding: a process handle of 0 or
wider than 32 bits (BadHandle), a longer list (TooLarge) before reading it, an unreadable
record (InvalidArgument), a slot of 0 or wider than 32 bits (BadHandle); then a process
handle the caller does not hold or of another kind (BadHandle, WrongObject), any listed
handle the caller does not hold (BadHandle), a process already started or ended, or with no
budget left (NotPermitted), a child’s table that would pass MAX_HANDLES (TooLarge), and a budget that
cannot pay for the first thread and the handle-table pages (OutOfMemory). Then nothing
fails:
- the handles are copied into the child’s empty table in list order, so they land in slots 1 to n; the parent keeps its own, and each copy keeps its stamp;
- the first thread, TID 1, starts at
entrywithspand withargin its first argument register.
The kernel passes arg on unchanged and never reads it. By convention it is the address of the
startup block, the read-only page the parent mapped with the child’s namespace, named
handles and arguments; the parent chooses where it goes (init). How a
launcher lays out the image, the stack and the startup block, and the loader stub that maps an
ELF image inside the child, are init’s.
Exit notices
Status: built · partly tested: a notice dropped because its exit endpoint was destroyed is not attacked by a case · tested (13)
- bench:process
- bench:process-attack
- bench:process-review
- bench:process-lifecycle
- bench:destroy-keeps-notices
- bench:destroy-keeps-notices-creator
- host:redoubt-sys::received_layout
- host:redoubt-model::exited_object_handles_and_queued_copies_live_until_notice_receipt
- mutation:ExitNoticeDroppedIfNoReceiver
- mutation:R10ExitNoticesOutlivePayer
- mutation:R10CreatorDeathSparesProcess
- mutation:R1ExitNoticeIgnoresLabels
- mutation:R1ExitExemptBySystemExiting
A process ends in one of three ways, and its exit notice says which:
| Cause | Value | When | Code | Blamed |
|---|---|---|---|---|
exited | 1 | process_exit(code), or the last thread ends, with no open calls | the code given (0 for the last thread) | nobody |
faulted | 2 | a thread faults; or the process exits as above while holding open calls | the RISC-V exception cause (15: a store page fault), or the code given | the sender of the ending thread’s current call, or nobody (R21) |
killed | 3 | the budget it runs in is destroyed (R10 (destruction)) | 0 | nobody |
receive on the exit endpoint returns the notice as a record of kind exit: words 0 to 2 are
the PID, the cause and the code, and the record’s account and labels fields are the blamed
account (0 for nobody) and its labels (empty for nobody)
(IPC).
When a process ends, the kernel first writes the notice into the process object, reading the blame before anything is freed. Then it tears the process down: every open call fails its caller (R4b), every call it made is withdrawn or abandoned (R3 (lends and abandoned calls)), and its threads, memory, handle table and DMA runs (devices) go. Everything it held goes back to the budget it ran in at once, except DMA pages quarantined at this end, which stay charged, and its PID, which that budget keeps counting until the notice goes. The process object’s page stays charged to the creator’s budget as long. Last, the notice is delivered or dropped:
- Delivered to whichever thread receives on the exit endpoint next; notices come before
messages. A doomed process’s thread takes none (R4b).
Nothing is allocated, because the notice’s page was paid for at
process_create. A notice with no receiver waits for one, and the notices waiting on one endpoint are received in the order their processes ended. - Dropped if the exit endpoint has been destroyed, or if
R1 (flow) refuses it. An exit notice is a one-way flow from the budget the
process ran in to the endpoint’s owner: it is delivered only if the owner is class
systemor its labels include all of that budget’s. Asystem-class exiting budget gets no exemption. - Never made if the creator’s budget is destroyed: that frees the process object, killing the process first if it still runs, and there is no notice at all, even for a receiver already waiting on the exit endpoint (R10).
The receive record is checked before a notice is delivered. If it cannot be written, the
receiver gets InvalidArgument and the notice stays, with its PID and its page, for the next
receive.
PID lifetime. Taking or dropping the notice frees the process object: every handle that
names it closes, in every table (a copy still in a queued message arrives as 0), its page goes
back to the creator’s budget, and its PID becomes free. Until then the PID is held, and a
process handle still names an ended process: process_map and process_start get
NotPermitted. So no PID is reused while a notice still names it. A launcher that leaves its
children’s notices untaken keeps their object pages, and their PIDs keep counting in the budgets
they ran in, so its next process_create into one of those can get OutOfProcesses.
sequenceDiagram
participant L as Launcher
participant K as Kernel
participant S as Server (child)
participant C as Client
L->>K: process_create(budget, E) with E a receive right
K->>L: process handle P (PID drawn, not told)
L->>K: process_map(P, image, stack, startup block)
L->>K: process_start(P, entry, sp, arg, [work endpoint])
S->>K: receive(work endpoint)
C->>K: call(work endpoint)
K->>S: the call, which becomes S's current call
Note over S: hostile input, S faults
Note over K: notice written into the object:<br/>faulted, code, C's account and labels
Note over K: teardown: C gets Dead, lend returned
K->>C: Dead
L->>K: receive(E)
K->>L: exit notice: pid, faulted, code,<br/>blamed account and labels
Note over K: object, handle P and PID freed
Figure: a server’s life from process_create to the exit notice that blames the call it
crashed on.
Authority
Status: built · tested: bench:process-attack, bench:pid-reuse-authority, mutation:ExitEndpointBadged
- A budget handle is the right to create a process in that budget. The budget pays for the process; the caller’s own budget pays for the process object.
- A process handle is the right to map into and start the process, and only until it
starts. A started process is closed to its parent: everything it is given, it is given before
it runs, so nothing can be slipped into a process that is already working. After that the
handle only names the object;
budget_usageon it isWrongObject. - The exit endpoint must be named by its receive right. Only the endpoint’s receiver can have a process report there, so nobody can aim notices at an endpoint they merely hold a badged handle to.
- A child gets only what its parent gives it: the pages
process_mapmoved, the handlesprocess_startlisted, in slots 1 to n, andarg. Nothing is inherited, and there is no ambient authority. A child cannot reach any object the parent did not list. - No call ends another process. A process ends itself, faults, or is killed by destroying its budget or its creator’s; killing is budget authority (budgets). The process handle is stamped with the creator’s budget, so destroying that budget revokes it everywhere (R9).
Security properties
R20 (PID reuse)
Status: built · tested (4)
- bench:pid-reuse-authority
- bench:process-attack
- bench:process-lifecycle
- host:redoubt-model::pid_reuse_only_after_notice_receipt
A reused PID inherits nothing. A process that draws a PID another process held gets none of that
process’s handles, mappings, badges, open calls, messages, notices, interrupts or device
access. Everything the kernel keeps per PID is emptied when a process ends (its handle table, its
frames, its threads’ IPC pages and open calls, its message-id counter, the DMA devices its death
must reset) and made afresh by process_create. The PID is also the hardware address-space id,
flushed whole when the PID is given out again, before it first runs, so no cached translation of
the earlier process survives (satp). No authority is keyed by PID: a
handle is an entry in a table, an interrupt reaches whoever receives on its device’s handle, and
message ids are per receiving process
(R14 (unforgeable sender)). And a PID is not reused while a
notice still names it, so every notice names exactly one ended process.
The attack case gives a first child a badged send handle, an endpoint, the console’s interrupt, a device mapping and a budget, lets it exit holding a call, and then spawns children until one draws its PID. That child holds nothing at any index but its own slot 1, its messages carry only its own badge, reading where the device was mapped faults, and the console’s interrupt wakes the parent.
R21 (crash blame)
Status: built · partly tested: blame after the blamed sender's budget is destroyed is not attacked by a case, and a thread that holds a parked call, then receives a send and faults (blaming nobody), is attacked only in parts · tested (9)
- bench:process
- bench:process-attack
- mutation:BlameNobody
- mutation:BlameNewestCall
- mutation:ExitWithOpenCallsNotFaulted
- mutation:CurrentNeverSet
- mutation:ReceiveKeepsCurrent
- mutation:ServeIgnored
- mutation:ExitEndpointBadged
Crash blame names the sender of the current call, or nobody. When a process ends faulted, its
notice blames the account and labels of the sender of the current call of the thread that
faulted or ended the process. If that thread has no current call, nobody is blamed (account 0,
labels empty), even when other threads of the process hold open calls; there is no fallback to
another call. exited and killed blame nobody.
- A thread’s current call is set by
receiveto the call it takes, and to none wheneverreceivereturns anything else, unless its registers fail to decode, which leaves it as it was (ABI);servenames another open call of the thread; replying to it sets it to none (IPC). A server that parks calls and resumes one callsservefirst, so a crash blames that call’s sender and not whoever called last. - A
sendis never an open call, so it is never blamed. process_exitwhile the process holds open calls isfaulted, with the code given, and so are the last thread’sthread_exitand return. A Rust panic ends inprocess_exit, so a server that panics on a request is blamed like one that faults. A server that means to exit replies to every open call first.- The account and labels are copies taken when the call was delivered, so blame survives the sender’s budget being destroyed in between.
- The exit endpoint is a receive right, or
process_createisNotPermitted. So blame goes only to an endpoint whose receiver chose to hear it: a process cannot be made to report its crashes, and whom they blame, to the steward or any other server through a badged handle.
The kernel only reports. What blame costs a principal is policy: the steward counts it by account and label set, so a vault session that keeps crashing a shared server does not log out its owner’s other sessions (steward). The labels in the notice are what let it key by label set.
Failure and restart
Status: built · tested (4)
- bench:process-lifecycle
- bench:process-attack
- bench:stub-launch
- bench:sched-exit-churn
- A process ends by exiting, faulting or being killed. Its callers get
Deadand their lends back (R4b); a call it made that a server had taken is abandoned, and the server is told once (R3). Its budget gets back every page the process held, exactly, except DMA pages quarantined at its end (devices). - A thread ends while others run: only its own calls end, and its process goes on.
- A process destroys its own budget, or its creator’s: the call does not return, because the process is killed with everything else that budget pays for.
- A hostile image can only make its own process exit or fault: the launcher keeps running and the children’s budget is back where it was (init).
- Exiting costs CPU like running. A thread or process that exits, faults or is killed on the CPU is charged for the slice it used (scheduling).
- Restart is policy. The kernel restarts nothing. The endpoint a server received on outlives
it, so callers queued there wait for the server
initstarts again (init). - No argument to any process or thread call can make the kernel panic (I14 (no call panics the kernel)).
Residual risks
- Delayed corruption can misattribute blame. If one principal’s request corrupts a server’s state without crashing it, and another’s later trips over the damage, the second is blamed. The consequence is a wrongful logout.
- A crash can blame nobody. Corruption left by a call that was already answered can crash a
thread later, when it has no current call; nobody is blamed, and the crash counts only toward
init’s restart limit and, past it, a reboot. - A thread that ends itself is not a crash. A server thread that calls
thread_exit, or returns, while its siblings run fails its callers withDeadbut sends no notice and blames nobody. Only the end of the whole process is reported. - Blame is not label-checked on its own. R1 checks an exit notice against the labels of the
budget the process ran in, not those of the blamed sender. A
system-class server’s notice can carry a labelled caller’s account and labels to auser-class owner of its exit endpoint. Only a creator holding asystem-class budget handle can set this up. - A creator’s untaken notices hold PIDs in budgets it was given. A process created into
another’s budget counts there until its creator takes the notice, and after that budget is
destroyed, in its parent. The loss is bounded by the process limit of the budget whose handle
the creator was given, and a budget handle is already the right to spend that limit; the
creators that launch into others’ budgets are
initand the steward. - The loader’s own programs send no notice. Nothing hears when one of them ends (boot).
Why
- The process object is the exit slot. It is charged to the creator, not to the budget the
process runs in, because a
killednotice must outlive that budget, paid by someone still alive. Paying atprocess_createmeans delivering a notice never allocates, and the pending notices a creator can pile up are bounded by its own pages. - The PID lives as long as the notice. A notice names a PID; if the PID could be reused first, a launcher could take a later process’s notice for an earlier one’s.
- The PID counts where the process runs, not where it was launched. The steward and
initlaunch everyone’s processes; counting them in the launcher would pool every principal’s PIDs in one budget, so one principal could spend what the others need, and a vault session’s launches would show in what its unlabelled side can start. Counted where they run, a principal’s processes are bounded by its own budget, as its pages are. - Random PIDs. A counter would let one process watch another’s rate of process creation, including a vault session’s. A PID drawn at random from the free ones says nothing about when other processes were made, and a creator learns it only from the notice.
- Closed once started. Everything a process holds it was given before its first instruction, so a parent can be checked once, at launch, and cannot change a running child.
- No kill call. Budgets are the one revocation mechanism; ending a process is destroying the budget it runs in, which ends everything else it holds in the same step.
- Blame follows the current call, or nobody. Event-driven servers such as
ipd(the TCP/IP server) andsshd(the SSH server) park calls and resume them on an interrupt or a send. Blaming the newest call would let one principal crash the server on its own connection while another’s call is newest, and log out the bystander.servelets the server say which call it is working on; work with no current call blames nobody, because a wrong guess costs a bystander its session. - Exiting with open calls is a fault, because a Rust panic exits through
process_exit, and panics are the commonest way hostile input brings a server down. - Handles in slots 1 to n. The child finds what it was given by position, and the startup block names the positions; there is nothing to search and nothing inherited by accident.
Devices and DMA
A driver reaches hardware only through a device object: an MMIO range (which may carry a DMA flag), an interrupt line, or the Reset right. The kernel makes one for each device the loader describes at boot and never makes another. Holding a handle to one is the whole authority: to map the registers, to receive the interrupt, to allocate memory the device may write, or to power off. Memory given to a device is never reused until the device has been reset, and a device that does not confirm its reset keeps that memory, and loses its object, until reboot.
Purpose
Devices are where a capability system is easiest to get around. A process that can program a bus master can make it read or write any physical address; one that can reach the interrupt controller can silence everyone’s interrupts; one that can name RAM by address can read any page. So the kernel keeps device authority in objects like every other authority, keeps the interrupt controllers and RAM out of reach altogether, and makes sure a dead driver’s device cannot keep writing into pages that have passed to someone else.
Interface
Device objects
Status: built · partly tested: the refusal of a DMA device past the sixteenth is not attacked by a case (QEMU virt has eight virtio-mmio slots, and the bench hands QEMU no device tree of its own: Residual risks) · tested: bench:device, bench:irq-attack, bench:dma-rules, bench:device-object-page
A device object takes one of three forms (kernel/src/device.rs):
| Kind | Holds | Used by |
|---|---|---|
| MMIO | a physical range, whole pages; a DMA flag (the device is a bus master) | map_device; dma_alloc if the flag is set |
| IRQ | an interrupt number, a fired flag and a masked flag | receive (R5) |
| Reset | nothing: it is the right to power off or reboot | system_reset |
The loader reads the machine’s device tree and describes each device in the argument block’s
Devs tag, and the interrupt controllers in its Ctrl tag (boot). The kernel turns
each Devs entry into an object while it builds the budget tree, and refuses to boot on a
malformed one (R18). No call creates a device object, so the set is
fixed by the machine.
A DMA-flagged device also gets a slot in the kernel’s reset registry, keyed by its base: at
most MAX_DMA_DEVICES (16) of them. A DMA device past that gets no object at all, because a
device the kernel could not reset must not be handed out.
Each device object costs DEVICE_PAGES (1) page, charged to the budget that owns it, as an
endpoint is (objects). At boot that owner is system, and that is all system
holds: its usage is one page per device object, and one page comes back when a quarantine
destroys one (device-object-page). There is no
device_destroy: a device object dies when its owner budget is destroyed
(R10 (destruction)), or when its device is quarantined
(Quarantine). Handles to device objects carry badge 0 and can be copied and
closed like any other handle; closing one leaves the object in place. The full error rows of
the device calls are in the ABI reference.
map_device
Status: built · tested (4)
- bench:device
- bench:irq-attack
- host:redoubt-model::reset_at_one_death_does_not_cover_a_co_holder
- mutation:DmaResetClearsCoHolderReach
map_device(h(MMIO)) -> addr, len maps the whole range readable and writable, not executable,
at an address the kernel chooses (R11 (memory)), and returns the address
and the length in bytes. The length is what the driver may touch; which device the handle names
is device_info’s answer, and which handles a driver gets is its launcher’s
choice. Errors, in order: BadHandle, WrongObject (an IRQ, the Reset right or any other
object), OutOfMemory (page tables).
The device’s pages are not RAM and cost nothing; the page tables that map them are charged to the caller’s budget. Each call makes a new mapping, so two calls give two addresses. The kernel keeps no record of who mapped a device: the handle, not a page owner, is the authority, and a mapping outlives its handle. Closing or revoking the handle stops new mappings, not the ones already made; those end when the process unmaps them or ends.
Mapping a DMA-flagged device adds it to the caller’s reset set: the devices the caller’s end must reset before its DMA pages are reused (Reset before reuse), because a process with the registers mapped could have programmed the device with any address it knows.
device_info
Status: built · partly tested: that its kernel time is constant is argued from the code (one handle lookup, one read of the object's frame), not measured · tested (7)
- bench:device-info-attack
- host:redoubt-sys::every_result_and_error_round_trips
- host:redoubt-sys::malformed_results_are_refused
- host:redoubt-model::every_device_handle_answers_its_devs_entry
- host:redoubt-model::what_is_not_a_device_is_refused_in_order
- host:redoubt-model::device_info_changes_nothing
- mutation:DeviceInfoWrongKind
device_info(h(device)) -> kind, a, b, flags says which device a handle names, in the form of
the Devs entry the object was made from (boot). For MMIO it gives
the physical base, the size in bytes and the DMA flag. For an IRQ it gives the interrupt number.
For the Reset right it gives only the kind. Errors, in order: BadHandle, then WrongObject for
anything that is not a device object. It maps nothing and changes nothing, and its kernel time
is a constant.
It is how init matches the manifest’s devices to the handles the kernel gave it
(which process gets which device). It shows a holder where
the device’s registers are, which a holder that maps them could find out anyway. The address is
not RAM and grants nothing, and no call takes a physical address
(R11 (memory)). The kernel says which device a handle names; the
launcher still decides which handles a driver gets.
The result travels as the Devs entry does: the kind in a1 (1 MMIO, 2 IRQ, 3 Reset), a and
b in two registers each, the flags in a6, and a field the kind does not use 0
(ABI). redoubt-sys decodes it as DeviceInfo and refuses
any other shape. The kernel’s time is one handle lookup and one read of the object’s frame,
whatever the device. The executable model checks every answer it gives against the
Devs entry of the device the caller’s handle names, after every step (device_info_answer in
model/src/invariants.rs).
dma_alloc
Status: built · tested (5)
- bench:device
- bench:dma-rules
- bench:dma-max-runs
- host:redoubt-model::dma_pages_stay_put
- mutation:DmaUnmapFrees
dma_alloc(h(MMIO), npages) -> addr, phys returns npages physically contiguous, zeroed pages,
mapped readable and writable at an address the kernel chooses, and their physical address. It is
the one call that returns a physical address, and only through a device the loader flagged as a
bus master. Errors, in order: BadHandle, WrongObject (not MMIO), InvalidArgument (no
pages), NotPermitted (no DMA flag), OutOfMemory (the device’s MAX_RUNS (32) runs all in
use, the caller’s budget, no free run of npages in the DMA pool, or the page tables).
Runs come from the DMA pool: DMA_POOL_PAGES (1024) contiguous pages, 4 MiB on both widths,
which the kernel takes from the top of RAM at boot and keeps outside every budget, root’s limit
included. A run is the first free stretch of npages in the pool, found by a search linear in the
pool’s 1024 pages, a constant, never in RAM (R12 (scheduling)).
The pool holds one device’s MAX_RUNS runs at 32 pages each; the largest run a driver asks for
today is 9 pages. A request longer than the pool’s longest free stretch is OutOfMemory,
whatever the caller’s budget holds. A held run is still charged to the caller’s budget, though
the pool, not the budget tree, supplies its frames: while runs are held, up to DMA_POOL_PAGES
frames of the budget tree go unbought, which is conservative and never short.
Each allocation is a run, recorded against the device it came through. Its pages are charged to the caller’s budget (R6 (charging)), but the ownership table records them as the kernel’s, not the process’s, so no path that frees or moves a process’s own pages can reach them. While the process lives its runs stay put:
- a lend, a transfer or a
process_mapof a DMA page isInvalidArgument; set_flagsworks on them as on any page,EXECUTEincluded, which R11 forbids (Residual risks);unmapdrops only the mapping: the pages stay held, charged and out of the pool.
A run’s device joins the caller’s reset set, as a mapped DMA device does. The pages leave the process only at its end.
Reset before reuse
Status: built · partly tested: that the reset precedes the pooling inside the kernel is attacked only in the model · tested (6)
- bench:dma-reset-reuse
- bench:dma-rules
- host:redoubt-model::exit_pools_after_reset
- host:redoubt-model::reset_at_one_death_does_not_cover_a_co_holder
- mutation:DmaFreeBeforeReset
- mutation:DmaResetClearsCoHolderReach
A DMA device may still hold the physical address of a run after the process that programmed it has died. So no DMA page goes back to the pool until every device that could hold its address has been reset: I16 (DMA pages reset before reuse).
- Trigger: the end of a process, however it ends (exit, fault, kill, its budget’s destruction), after its own pages are released. Not the close of a device’s last handle, because handles copy and a mapping outlives its handle.
- The reset set is every device one of the process’s runs came through, and every DMA device it ever mapped. A confirmed reset at one process’s end does not remove the device from a live co-holder’s set: the co-holder can still program it, so its own end resets it again.
- The reset: for each device in the set, the kernel writes 0 to the virtio-mmio status
register through a register window of its own (the device’s first register page, mapped for
the kernel alone), then reads it until it reads 0. It waits at most
RESET_US(1000 µs) per device on the timebase, and at mostRESET_READS(100 000) reads should the timebase not advance, without preemption. With at mostMAX_DMA_DEVICES(16) devices in a reset set, one process’s end can spend up to 16 ms here. The kernel does nothing else to the device: it never services it on the driver’s behalf. - Pooled only if every device in the set confirmed in that same step: then each run’s pages go back to the pool and their charge back to the budget that paid for them. Otherwise nothing is pooled (see Quarantine).
A device confirms only if it answered as virtio-mmio at boot (magic virt, version 1 or 2, read
once when its slot is made) and has not been quarantined. Any other device never confirms.
stateDiagram-v2
[*] --> Mapped: dma_alloc (zeroed, charged,<br/>device joins the reset set)
Mapped --> Held: unmap (mapping gone,<br/>still charged)
Mapped --> Ended: the process ends
Held --> Ended: the process ends
Ended --> Resetting: status 0 written to every<br/>device in the reset set
Resetting --> Pooled: every device reads 0<br/>within 1 ms
Resetting --> Quarantined: any device does not,<br/>is not virtio, or is quarantined
Pooled --> [*]: pages free,<br/>charge returned
Quarantined --> Quarantined: payer destroyed<br/>(charge moves to its parent)
Quarantined --> [*]: reboot
Figure: the life of a DMA run, from dma_alloc to the pool or to quarantine.
Quarantine
Status: built · tested (6)
- bench:dma-reset-quarantine
- host:redoubt-model::deaf_device_quarantines_the_co_holder_too
- host:redoubt-model::quarantine_charge_moves_to_a_parent_at_its_limit
- mutation:DmaQuarantinedSlotCountsAsReset
- mutation:DmaQuarantineChargeDropped
- mutation:DmaQuarantinedDeviceUsable
When a device in the reset set does not confirm, every run the ending process held is
quarantined, the runs through devices that did confirm included: their pages are never
pooled, and nothing maps them again. Every device that did not confirm is quarantined too, and
the kernel prints DMA: device <base> did not confirm its reset; quarantined until reboot.
- A quarantined device never counts as reset. A later process end whose reset set holds it quarantines all of that process’s runs, even if the device would reset by then.
- Its object is destroyed at that same process end, as R10 destroys one: every handle naming it closes, in every table, and a copy in a message not yet received arrives as 0. Its page goes back to its owner. Its base stays flagged in the registry until reboot, and no device object names it again. The device’s interrupt object is a separate object and is not destroyed. A live co-holder keeps any mapping it already made (see Residual risks).
- Charging: a quarantined run stays charged to the budget that paid for it, the dead driver’s. When that budget is destroyed, alone or in a subtree, the destroyed subtree’s carve first returns to the budget above it, and only then does the charge move there, so that budget never exceeds its limit (I5 (usage within limits)). With no budget above it, the charge ends with the tree.
system_reset
Status: built · tested (5)
- bench:device
- bench:irq-attack
- bench:init-refuses-public-manifest
- bench:init-reboot
- bench:init-quarantine-reboot
system_reset(h(Reset), kind) powers the machine off (kind 1), reboots it (kind 2), or
powers it off reporting a system failure (kind 3), through the firmware’s system reset call;
only kind 3 gives the firmware a reason, SystemFailure. init refuses a boot with kind 3,
as the loader and the kernel refuse one (R17 (fail closed)). On
success it does not return. Errors: InvalidArgument (an unknown kind, refused as the call is
decoded), BadHandle, WrongObject (not the Reset right). The kernel lets go of its memory lock
before it calls the firmware, because that call never returns.
The console’s one writer
Status: built · tested (16)
- bench:console-one-writer
- bench:console-kernel-line-mid-line
- bench:console-hold-stuck
- host:redoubt-conhold::lines_wait_whole_while_held_and_go_out_in_order_at_release
- host:redoubt-conhold::a_line_that_does_not_fit_goes_out_after_the_queue_and_leaves_nothing_behind
- host:redoubt-conhold::one_holder_at_a_time
- host:redoubt-conhold::a_holder_that_dies_gives_the_hold_back_and_only_it_does
- host:redoubt-conhold::a_line_with_nobody_holding_goes_out_at_once
- host:redoubt-conhold::a_free_line_ends_an_unfinished_line_once_and_the_next_take_says_so
- host:redoubt-conhold::nothing_is_ended_while_no_line_is_unfinished_and_a_held_line_still_waits
- host:redoubt-conhold::lines_that_waited_end_an_unfinished_line_at_the_give_back_and_the_next_take_is_told
- host:redoubt-conhold::only_the_holder_whose_line_was_ended_is_told
- host:redoubt-conhold::a_process_that_dies_with_its_line_unfinished_is_owed_nothing_after
- host:redoubt-consoled::writes_go_out_inside_the_console_s_hold_a_chunk_at_a_time
- host:redoubt-sys::every_result_and_error_round_trips
- host:redoubt-sys::malformed_results_are_refused
The console’s UART has two writers. The kernel prints its lines through the firmware’s debug
console, which writes the same UART, and the process that holds the console’s MMIO device writes
its registers: init until consoled starts, then consoled, or in a kernel case the first
program. On several harts the two would interleave byte by byte, so a kill line
([!] Terminating process with PID n) could land inside a program’s line and break both.
console_hold(h(MMIO), hold) keeps each writer’s lines whole. The holder takes the hold (hold
- before it writes and gives it back (2) after. While it holds it, a kernel line waits, whole, in a queue of 4 KiB (about 107 kill lines, a containment-sized destruction’s), and the lines that waited go out when the hold is given back, before the call returns, so they come before the holder’s next write.
- A line left unfinished between writes. A holder’s line can span its writes: a prompt
waiting for its answer, a
\rsent before the text. Between them nobody holds, so a kernel line goes out at once, inside that line. The holder says so: a give-back withhold3 says its line is unfinished, and the kernel then ends that line (\r\n) before the next line of its own that finds nobody holding, or before the lines that waited and go out at that give-back, once; that holder’s next take, and only its, returns 1 if it did, and the holder starts its line again (consoled). A give-back with 2 says the line is finished. Nothing else changes: the kernel never waits, a held line waits whole, and the bound below stands. - The kernel never waits for user mode. A line with nobody holding goes out at once. A line
that does not fit the queue goes out at once too, after everything queued: a holder that keeps
the hold, or writes for long, then sees a kernel line inside its own write. That is the bound:
a holder delays the kernel’s lines by one queue’s worth at most, and never loses or stops one.
A holder’s write is meant to be short:
consoledholds it a chunk of 256 bytes at a time (consoled). - Who may hold it. Only through a handle to the console’s MMIO device: the
Devsentry the loader marks as the console, the one the device tree’s/chosen/stdout-pathnames (boot). A second marked entry stops the boot, and with none every hold isWrongObject. Any other device or object isWrongObject. One process holds it at a time: another’s take isBusy, and writes as before. Taking it again, or giving back a hold the caller does not have, changes nothing. A writer that never takes it writes as before. - A holder that dies holding it was cut off inside a write. The kernel gives the hold back
for it as the process ends, ends that line (
\r\n), then prints the lines that waited and the kill’s own. The hold is the process’s: a thread that exits holding it leaves it to the process’s other threads, and the last thread’s exit ends the process. A process that dies between writes with its line unfinished is treated the same: the kernel ends that line before the kill’s own, and nothing is owed to its PID after, so a later process given that PID is told nothing. - When the machine stops (
system_reset), the hold ends and the lines that waited go out first. A panic’s text goes out after them and never waits.
Errors, in order: decoding (hold not 1, 2 or 3 is InvalidArgument), BadHandle,
WrongObject, Busy. With no kernel console (a build without printing) the hold has nothing to
make wait. The rules and the queue are redoubt-conhold, host-tested; the queue sits beside the
kernel’s console under the lock its prints already take, one hart at a time
(kernel/src/debug/console.rs). The executable model has the call, with the first MMIO
device as the console (the loader’s order), and checks that the holder is a live process after
every step; the lines themselves are not modelled.
Devices handed to the first program
Status: built · partly tested: the loader’s refusal of a device tree that names no console, or a console with no interrupt, is not attacked by a case (the bench hands QEMU no device tree of its own: Residual risks) · tested: bench:device, bench:irq-attack
The kernel gives every device object to init, the one program the loader starts, to place
(below), in the handle order
boot gives. The objects are charged to system,
and the handles are stamped with root, so they are revoked only with the whole tree
(stamps). A program finds its DMA devices by asking: dma_alloc is
NotPermitted without the flag and WrongObject on an IRQ or the Reset right.
Which process gets which device
Status: built · tested (10)
- bench:init-boot
- bench:init-driver-restart
- bench:init-quarantine-reboot
- bench:init-refuses-device-unmatched
- bench:init-refuses-device-dma
- host:redoubt-init::a_device_no_handle_names_is_refused
- host:redoubt-init::a_device_split_between_two_entries_is_refused
- host:redoubt-init::the_dma_flag_must_be_the_kernel_s
- host:redoubt-init::a_device_name_is_at_most_60_bytes_and_never_ends_in_irq
- host:redoubt-init::one_device_has_one_holder
The loader loads only the kernel and init, and init holds every device object. The boot
manifest’s devices entry names each device object by its register base and interrupt, and
says whether it may do DMA; each server’s entry names the devices it gets (init). init places
each driver’s handles in its startup block, by name, so no driver depends on a handle’s index.
The Reset right stays with init. In a confined deployment init refuses a manifest that lets
two label sets share a device.
- The device tree and the manifest decide together. The loader’s reading of the device tree
decides what becomes a device object and which carry the DMA flag, because the kernel must know
both at boot to give each DMA device a reset slot. The manifest decides who gets each object. It
names each device by its register base and, if it has one, its interrupt number.
initasksdevice_infoabout every device handle it holds and matches the answers to the manifest. It refuses the boot if the manifest names a base or an interrupt no handle has, names one twice, or says a device may do DMA when the kernel’s object says it may not, or the other way round. A device object the manifest does not name stays withinitand is never placed. - Drivers find their devices by name.
initinstalls a device’s register region under the name the server’s entry gives it, and its interrupt under that name with-irqadded (consoledlooks upuartanduart-irq,blkddiskanddisk-irq,netdnetandnet-irq). The names areinit’s: it matched them to the kernel’s objects withdevice_info, and a driver relies on the name, never on a handle’s index. initkeeps a copy of every device handle it places, so that it can place the handle again when it restarts a driver. It never maps a device after it startsconsoled, and a copy it never maps never puts it in a reset set, so being a co-holder costs nothing at a driver’s end (reset before reuse).- A driver whose device was quarantined is not restarted;
initreboots the machine. The device’s object is gone, and only a hardware reset can make a device whose reset was never confirmed safe to hand out again. This is the same fail-closed reboot as a server that cannot stay up (init).
Authority
Status: built · tested (4)
- bench:irq-attack
- bench:device
- bench:dma-rules
- bench:dma-reset-quarantine
- A device handle is the device. An MMIO handle is the right to map its registers, an IRQ handle the right to receive its interrupt, the Reset handle the right to stop the machine. Device handles carry badge 0 and no rights bits.
- A DMA-flagged MMIO handle is kernel-level trust. It lets its holder call
dma_allocand point a bus master at physical addresses. Without an IOMMU the device can then reach any RAM, so a holder of one is inside the TCB while it lives. - Handles copy. A device handle passes in a message or a startup block like any other. Two
holders can both map one device; an interrupt goes to whichever holder’s thread is waiting in
receive. A driver that must be alone is alone only because whoever holds the handle gave it to it and to nobody else. Whoever hands out a device handle hands out the device. - Revocation closes the handle (its stamp’s budget destroyed, or the object destroyed with its owner or by quarantine). It does not reach a mapping already made (see Residual risks).
- Nothing else reaches a device. No call takes a physical address;
map_anonandmap_fixedgive fresh zeroed RAM wherever they map (R18).
Security properties
R5 (interrupts)
Status: built · partly tested: masking a fired source is attacked only in the model; completing the claim before masking is argued (Residual risks); billing an interrupt to its IRQ object's owner is attacked by `bench:sched-irq-wake` · tested (7)
- bench:uart-irq
- bench:sched-irq-wake
- bench:irq-first-receive
- bench:receive-bad-record
- mutation:R5NoMaskOnFire
- mutation:R5NoUnmaskOnReceive
- mutation:R5BadRecordConsumesInterrupt
When an interrupt fires, the kernel masks its source and sets the IRQ object’s fired flag.
receive on the IRQ handle unmasks the source when it begins, then returns an interrupt
record as soon as fired is set, clearing it (IPC). There is no
acknowledge call. So a source stays masked from the moment it fires until its driver next
receives, and a driver that is busy, stuck or dead cannot be stormed by its own device. A
level-triggered source still asserted when the driver receives fires again at once, and that
receive returns straight away.
- The kernel completes the controller’s claim first, then masks the source, in the same kernel section. Nothing can be delivered in between: the hart takes no interrupt in supervisor mode, and another hart that traps for the source claims it only once it holds the kernel lock, by which time the source is masked and its claim finds nothing.
- An interrupt no IRQ object owns is completed and masked, and stays masked.
- Every IRQ object starts masked, so a source nobody receives on cannot storm the kernel.
- An interrupt that fires with no thread waiting keeps
firedset; the nextreceivereturns at once. One fire answers one waiting thread. Threads waiting on one interrupt are woken in the order they began to wait. - The kernel’s time handling an interrupt is billed to the budget that owns the IRQ object (scheduling), not to whichever budget it interrupted.
An interrupt raised while its object is masked stays pending in the controller, and the unmask
at the next receive, the first after a handle is handed over included, delivers it. Masking
writes the source’s priority 0, which the PLIC (the RISC-V platform interrupt controller) never
delivers on any hart’s context; unmasking writes it back to 1. Every unmask, the re-arm after a
claim included, is that receive’s. Why a priority and not the enable bits is on
boot.
sequenceDiagram
participant T as Driver thread
participant K as Kernel
participant P as PLIC
participant D as Device
T->>K: receive(IRQ handle, timeout)
K->>P: unmask the source
Note over K: fired not set: the thread blocks
D->>P: raise the line
P->>K: external interrupt, claim
K->>P: complete the claim
K->>P: mask the source
Note over K: fired set, then cleared<br/>by answering the waiting thread
K->>T: record: kind = interrupt
Note over T,D: the driver services the device,<br/>the source stays masked
T->>K: receive(IRQ handle, timeout)
K->>P: unmask the source
Note over P,D: a line still asserted<br/>fires again at once
Figure: one interrupt, from the driver’s receive to the next.
R18 (device authority)
Status: built · partly tested: the kernel’s refusal of a malformed Devs entry (one overlapping RAM or an interrupt controller among them) and of a Grnt boot argument is not attacked by a case (the loader emits neither, and the bench hands the kernel no argument block of its own: Residual risks) · tested: bench:irq-attack, bench:legacy-gone, bench:device-info-attack
Device objects are the only device authority. A process reaches an MMIO range, an interrupt or
the Reset right only through a handle to its device object; a process that holds none gets
BadHandle from every device call, and the holder keeps its interrupt. No call names a
physical address, so neither a device’s registers nor RAM can be reached by address
(R11). No call number outside the call table does anything: each is
InvalidArgument, so no number claims an interrupt or maps a physical address. The bundle
carries no list of device claims: the loader reads no entry it does not load
(boot), and the kernel refuses a Grnt
boot argument.
Two ranges never become device objects, because either would give away everything else: an
interrupt controller (a holder of the PLIC could mask and raise every source; one of the
CLINT, the core-local timer block, could forge timer interrupts) and RAM. The loader leaves
both out of the device list, but that is a reading of a device tree the kernel does not trust,
so the kernel checks every Devs entry itself. It refuses to boot on an MMIO entry that
overlaps RAM or a Ctrl range, wraps the address space, is empty or is not whole pages, and on
an IRQ entry for interrupt 0 (the hart timer, a hart resource and not a device) or for one at or
above MAX_IRQS (1024: the PLIC numbers its sources 1 to 1023, and the kernel’s IRQ index has a
slot for each). The kernel maps
the PLIC for itself alone.
Failure and restart
Status: built · partly tested: at an owner's destruction the waiters' `Dead` and the handle sweep are attacked, the mask of the source is argued (Residual risks); a device handle's stamp is `root`, so the one destruction that closes it by its stamp is the tree's end · tested (5)
- bench:device-owner-destroy
- bench:dma-reset-reuse
- bench:dma-reset-quarantine
- bench:dma-rules
- bench:pid-reuse-authority
- A driver ends, however it ends: its device mappings go with its address space, its handles close, and its DMA runs are reset and pooled, or quarantined. The device objects survive, so another holder can hand the handles to a restarted driver, which finds the device reset. A PID the driver held passes on with none of its mappings or handles (R20 (PID reuse)).
- A driver dies mid-interrupt: the source stays masked until the next
receiveby any holder of the IRQ handle. - A device fails its reset: it is quarantined, its object destroyed and its handles closed everywhere; it is gone until reboot (Quarantine).
- A device object’s owner budget is destroyed: each device object it owns is destroyed.
Threads waiting in
receiveon an IRQ object getDead, its source is masked, every handle naming the object closes, and its page goes back (R10). Nothing makes a device object again, so the device is unreachable until reboot. - The budget that stamped a device handle is destroyed: that handle closes; the object and other holders’ handles stay.
- No argument to any device call can make the kernel panic (I14 (no call panics the kernel)).
Residual risks
- A DMA driver is TCB without an IOMMU. Its device can read and write any physical address
it is given, not only its runs. The DMA flag is the loader’s reading of the device tree
(
compatiblenames virtio); a bus master the loader does not flag is still a bus master, and whoever maps it can program it. Reset-before-reuse closes one hole, a dead driver’s device writing into pages that have passed on; it does not confine a live driver. The answer is hardware confinement (FPGA platform). - Only virtio-mmio devices are reset. A DMA device that does not answer as virtio-mmio never confirms, so every process end that reaches it quarantines, even one whose pages that device never held, and such a platform gets no driver restart. The boot read that classifies each DMA device assumes the read has no side effect, true of the virtio nodes the loader flags.
- A reset stops the device for every holder. Any holder that mapped a DMA device, or allocated through it, resets it when it ends, under every other driver of that device.
- A co-holder keeps its mapping of a quarantined device, and of any device whose handle was revoked: the kernel does not unmap device ranges. It can go on programming a quarantined device until it ends, when its own runs are quarantined in turn.
- One early process holds all device authority:
initholds every device object, the Reset right and every DMA device, and nothing narrows that until it places them (below). - A device tree that hides a controller from the loader’s controller search as well as from
its device list defeats the kernel’s
Ctrlcheck: the kernel’s own list of controllers comes from the same tree (boot). - Quarantine costs memory for good, in exactly the hostile case, a device that ignores its
reset: its runs stay charged to the dead driver’s budget, then its parent, and out of the DMA
pool, until reboot. A quarantined run through a device still in service also keeps one of that
device’s
MAX_RUNSplaces. - The reset poll is not preemptible: up to 1 ms per device in the reset set, 16 ms at most for one process end, on top of the rest of its teardown (scheduling).
- The order inside the kernel is argued, not attacked.
dma-reset-reusereads the device’s status afterdma_allochas handed a frame to a new holder, so it shows the reset came before the new holder’s use. That the reset strictly precedes the pooling rests on the kernel’s own assertion and the model’s I16 check. - Two parts of R5 are not attacked on QEMU. Its 16550 console raises the controller once
per byte, not from a held level, so a kernel that never masked a fired source passes
uart-irq; the mask is attacked in the model. The order, the claim completed before the source is masked, is argued: both writes are made in one kernel section, on a hart that takes no interrupt in supervisor mode, under the kernel lock that another hart’s claim would need, so nothing is delivered between them; and because the mask is a priority write and the enable bits stay, the PLIC does not ignore a completion for a masked source, so even the reverse order would lose nothing on this controller, and QEMU’s PLIC, which accepts either, cannot tell the two apart for a case. The billing, the interrupt’s kernel time charged to the IRQ object’s owner (systemat boot, whoever holds the handle), is attacked bybench:sched-irq-wake: the trace records each charge inside a device interrupt, and its oracle requires every claimed interrupt to chargesystemand nothing but the return to the budget interrupted (scheduling). Masking bounds that time to one interrupt perreceive, at the driver’s pace. - Crafted boot inputs are not attacked. Every case boots QEMU’s own device tree and the
loader’s own argument block. So the loader’s refusal of a tree that names no console or a
console without an interrupt, a seventeenth DMA device (QEMU virt has eight virtio-mmio slots),
and the kernel’s refusal of a
Devsentry that overlaps RAM or a controller and of aGrnttag are code no case reaches: the loader filters the first and emits neither of the last two. A device tree the bench could hand QEMU would reach every one butGrnt, which no tree can produce; that lever is not built. - The mask at an owner’s destruction is argued.
destroy_devicefails the waiters, masks the source and then sweeps the handles, the one path quarantine takes too (dma-reset-quarantine);device-owner-destroysees theDeadand the sweep. A source left unmasked would, at the device’s next interrupt, be completed and masked by the handler for an interrupt no object owns, whose line prints only underdebug-print, and the checked build’s count of claims prints atsystem_reset, which the destruction takes the Reset right away from: nothing a case can see tells a masked source from an unowned one. - A device handle’s stamp is
root. Boot stamps every device handle so, a copy keeps its stamp andminttakes endpoints only (R9 (stamps)), so the one destruction that closes a device handle by its stamp rather than by its object is the whole tree’s.
Why
- Device objects, not claim lists. A per-process list of ranges and interrupts in the
signed bundle would be a second kind of authority, checked by process identity and not
revocable by budgets. A device object is an object like any other: held as a handle, passed
by
init, charged to a budget and revoked with it. - The kernel keeps the controllers and RAM out itself, because the loader’s exclusion is a heuristic over an untrusted device tree, and one missed controller would hand its holder every interrupt.
- Mask on fire, unmask on receive, no acknowledge. The driver’s own
receiveis the acknowledgement, so one fire costs the kernel one interrupt until the driver asks for the next, and a driver that stops asking stops its device’s interrupts, nobody else’s. - Reset at the end of a process, not at the last handle’s close, because handles copy and a mapping outlives its handle: the only moment the kernel knows a process can no longer program a device is when that process is gone.
- Reset before reuse, and quarantine when it fails. A dead network driver’s freed pages hold its descriptor tables and rings, and a virtio device re-reads them on every packet. Whoever next owns those pages could post descriptors pointing anywhere: every received frame becomes an arbitrary physical write of network bytes, every transmit an arbitrary read sent out. A driver may reset its own device on a controlled exit (netd does), but one that faults or is killed cannot, so the kernel’s reset is the one that holds. A device that will not confirm is exactly the hostile one, so its pages are given up rather than risked.
- A quarantined device never counts as reset, or a co-holder’s end would pool pages the device may still write.
- DMA pages stay put so that the process whose end triggers the reset is the only one that ever held them.
- The charge stays with the payer, then its parent after the carve returns, so quarantine never takes a budget over its limit and stays with the tree that paid for it.
map_devicereturns the length so a driver needs to know nothing about where its device is; which device it holds comes from the manifest, by name.
Boot and verified boot
A Redoubt machine starts in four links: the RustSBI firmware, the loader, the kernel, and
init. The loader is the link that matters for what runs: it checks the Ed25519 signature on
the bundle (a ustar archive of the kernel, init and everything init starts) before it
reads a byte of it, loads the kernel and init into an address space each, maps the bundle into
init, describes the machine to the kernel in a tagged argument block, and jumps into the
kernel.
Every refusal on the way powers the machine off. The kernel parses no device tree: what it
knows of the hardware is what the loader wrote.
Purpose
Every guarantee the kernel makes is void if an attacker can choose the kernel. So the boot has
to do three things well: run nothing it has not authenticated, keep each program image inside
the address range meant for it, and stop rather than boot a machine it cannot describe
truthfully (no entropy, no clock, a signature that does not check). It also has to hand the
first program, init, its authority, and nothing else, in a form the kernel can check.
Interface
The chain
Status: built · tested: bench:rustsbi-boot
sequenceDiagram
participant F as RustSBI (M-mode)
participant L as Loader (S-mode, MMU off)
participant K as Kernel (S-mode)
participant P as init (PID 2)
F->>L: enter _start: a0 = hart id, a1 = device tree
Note over L: read the device tree<br/>reserve firmware, loader, tree, bundle
Note over L: write MREx, Ctrl, Devs, Plic, Seed, Time
Note over L: verify the bundle signature (R15)<br/>a bad one powers off (R17)
Note over L: load the kernel, then init (R16)<br/>map the bundle into init, read-only
L->>K: satp on, trap into the entry<br/>a0 args, a1 process table, a2 RAM owners, a3 MMIO owners
Note over K: memory, process table, budgets,<br/>device objects, PLIC, timer, RNG
K->>P: first run: three budgets, every device<br/>a0, a1 = the bundle's address and length
Figure: one boot, from the firmware to init.
The firmware runs in M-mode and enters the loader in S-mode on one boot hart, with the MMU
off, a0 = the hart id and a1 = the physical address of the flattened device tree. The
signed bundle is the initrd: the device tree’s /chosen node names its range. The kernel
starts the other harts, up to 8, through SBI’s hart state management; a hart past the eighth
stays parked, and the kernel says so once
(several harts).
The loader (loader/src/main.rs) builds the kernel’s address
space and init’s, then turns paging on and enters the kernel at its init function
(kernel/src/main.rs) with four physmap pointers: the argument
block, the initial-process table, the RAM ownership table and the MMIO ownership table. The
kernel is PID 1 and init, the bundle’s second entry, PID 2. When the kernel has set itself up
it prints KMAIN (clean boot) and schedules init, which starts everything else. One loader,
built for each width, serves rv32 (Sv32) and rv64 (Sv39); both widths boot.
Firmware
Status: built · tested: bench:rustsbi-boot, bench:verified-boot-rejects-tamper, host:testbench::a_missing_firmware_image_fails_the_case_rather_than_falling_back, host:testbench::every_boot_names_the_checked_firmware_and_a_tree_only_when_edited
The only firmware is the RustSBI Prototyper, vendored in bios/ at a pinned upstream commit
and built for both widths by scripts/build-bios.sh. Its qemu-virt feature compiles only QEMU
virt’s 16550 UART, SiFive CLINT and SiFive test (reset) drivers. The bench hands it to QEMU
with -bios; RUSTSBI_PROTOTYPER (rv64) and RUSTSBI_PROTOTYPER_RV32 (rv32) may name
another build of it. A missing image fails the case (it is a skip only under --allow-skip),
and QEMU’s bundled firmware is never used.
What the kernel takes from the firmware, all through SBI calls:
- the debug console, the kernel’s only output (the kernel owns no UART);
- TIME, which arms the hart timer (timer);
- SRST, which powers off or reboots: the Reset device (devices), and the end
of every refusal and kernel panic, with the reason
SystemFailure. RustSBI keeps that reason, so QEMU exits with status 255.
S-mode ecall belongs to the firmware, so the kernel’s own switch into a thread enters the trap
handler as an ecall would, without executing one. A kernel built without the sbi feature
does not compile.
The boot bundle
Status: built · tested: bench:rustsbi-boot, bench:bundle-mapped
The initrd is signature (64 bytes) || tar. The tar is a plain ustar archive:
- the first entry is the kernel, whatever its name;
- the second entry is
init, or the program in its place, whatever its name: an ELF executable, started as PID 2, the one program the loader starts; - every later entry is data to the loader, which reads no entry it does not load (the handoff).
The loader prints the two names it loads with their PIDs; the kernel is not told the names.
The bench’s builder (tools/testbench/src/build.rs) packs the bundle: the kernel as
kernel, then the programs in PID order, then a case’s [[file]] entries. It refuses two
entries with one name and signs the result (verified boot). ./mkimage
runs the same builder and writes target/image/redoubt.bundle.
What the loader does
Status: built · tested (5)
- bench:rustsbi-boot
- bench:bundle-mapped
- bench:loader-rejects-kernel-address
- bench:loader-rejects-kernel-entry
- bench:loader-rejects-truncated-elf
- Reads the device tree (
loader/src/dt.rs, overfdt-rs), once, into one record: RAM, the initrd’s range,/chosen/rng-seed, the timebase and hart count, every MMIOregoutside RAM, each device’s interrupts, the console/chosen/stdout-pathnames and its interrupt, the PLIC with the boot hart’s S-mode context (the hart ID the firmware passes ina0) and every other started hart’s, and the CLINT. A region that does not start on a page is skipped and said so. Nothing after this step touches the tree. - Keeps memory it must not hand out. The allocator (
loader/src/alloc.rs) gives out zeroed pages from the top of RAM down and never from the firmware (all RAM below the loader), the loader, the device tree or the bundle. Its first allocation is the RAM ownership table: two bytes per page of RAM, the owning PID or 0, which becomes the kernel’s allocation table. The firmware and the device tree are owned by PID 1, so no budget is ever given them. The bundle’s pages areinit’s (step 5). The loader’s own pages are left unowned: the kernel reuses them. A second table, two bytes per MMIO page, follows. Both areredoubt_layout’sOption<Pid>, the same type on both sides of the handoff. - Describes the machine: the
MREx,Ctrl,Devs,Plic,Hart,SeedandTimetags of the argument block, all from the device tree. A seed under 16 bytes, or a tree with no console or no console interrupt, stops the boot here. - Verifies the bundle (R15). Only then does it parse the archive.
- Builds the address spaces. The kernel’s comes first: the physmap (physical memory up to
the end of RAM, readable and writable, never executable;
memory layout), the kernel ELF inside the kernel area with each
read-only page write-protected in the physmap too
(R19 (kernel W^X)), its stack and trap stack, its per-process
pages, and the page tables the kernel later maps its PLIC and its DMA register window into.
Those tables are made before any program’s address space copies the kernel’s root entries,
so every address space shares them. Then
init’s, from the bundle’s second entry: the kernel’s root entries copied, the ELF inside the user area (R16), a stack under0x8000_0000of which the top page is backed and 31 more are reserved for the kernel to back on first touch (memory), its per-process pages, and the whole initrd, signature and archive, mapped read-only atBUNDLE_AT(0x2000_0000, both widths), its pages owned by PID 2. A bundle that does not start on a page, is larger than the 512 MiB betweenBUNDLE_ATand the message area, or shares a page with the device tree is refused, and so is aninitwhose image overlaps the mapping. The loader parses no entry after the second. - Enters the kernel. It writes the initial-process table (one page; two records, the
kernel’s and then
init’s, each a PID, asatp, an entry point, a stack pointer and the first thread’sa0anda1: forinit, the bundle’s address and length), completesXArg, and pointsstvecat the kernel’s entry before writingsatp. The next fetch faults, because the loader is not mapped in the kernel’s address space, and the hart traps straight into the kernel witha0-a3and the stack pointer intact. Nothing of the loader runs again.
The argument block
Status: built · partly tested: the kernel's refusals of a block only the loader could write (a tag past the end, a second `MREx`, a `Devs` entry that names RAM or a controller, wraps or names interrupt 0, a `Grnt` tag) are not attacked by a case: no device tree makes the loader write one, and the kernel has no host tests · tested (6)
- bench:rustsbi-boot
- bench:device
- bench:uart-irq
- bench:rng
- bench:boot-rejects-irq-past-plic
- bench:boot-rejects-no-timebase
The block is ARGS_PAGES (4) pages of 32-bit words, written by loader/src/args.rs and read
by kernel/src/args.rs. It is a run of tags, XArg first:
packet-beta title one tag: a header of two words, then its data 0-31: "name: 4 ASCII bytes (word 0)" 32-47: "crc16: low 16 bits of word 1" 48-63: "words: high 16 bits of word 1" 64-95: "data: words × u32, from word 2 on"
flowchart LR
XArg --> MREx --> Ctrl --> Devs --> Plic --> Hart --> Seed --> Time
packet-beta title one Devs entry, six words 0-31: "kind" 32-63: "a (lo)" 64-95: "a (hi)" 96-127: "b (lo)" 128-159: "b (hi)" 160-191: "flags"
Figure: the tag framing, the tags in block order, and one Devs entry.
| Tag | Data | Read by (in kernel/src/) | If absent |
|---|---|---|---|
XArg | block size in words, version (2), RAM start (2 words), RAM size (2), RAM name (sram) | mem.rs | the boot stops: it must be first |
MREx | every MMIO region in the tree, controllers included, six words each: start (2), size in bytes rounded up to whole pages (2), the node name’s first four bytes, 0 | mem.rs: the MMIO ownership table | no MMIO table; a second MREx stops the boot |
Ctrl | the PLIC and CLINT ranges, four words each: base (2), size (2) | device.rs | no controller check |
Devs | one entry per device object, six words each (below) | device.rs | no device objects |
Plic | PLIC base (2), size (2), the PLIC’s S-mode context of the boot hart (the hart ID the firmware passes in a0), matched to the cpu node whose reg is that ID, 0; a device tree with a PLIC but no S-mode context for the boot hart stops the boot (R17). | arch/riscv/intc_plic.rs | no external interrupts |
Hart | the cpu nodes in the tree, the harts listed, then each listed hart’s id (2), then each listed hart’s PLIC S-mode context (0 without a PLIC), the boot hart first: by boot index, at most MAX_HARTS (8), in tree order. A hart past the eighth, or whose PLIC S-mode context is not inside the PLIC’s window, is left out and stays parked; the loader maps each listed hart’s kernel and trap stacks in HART_STACKS (memory layout), and a boot hart whose context is past the window stops the boot (R17). | arch/riscv/hart.rs, arch/riscv/intc_plic.rs | the boot hart runs alone |
Seed | /chosen/rng-seed: 16 to 64 bytes, zero-padded to words | platform/sbi/rand.rs | the boot stops (R17) |
Time | the timebase: ticks of the time counter per second (2) | arch/riscv/timer_sbi.rs | the boot stops (R17) |
Devs kind | a | b | flags |
|---|---|---|---|
| 1, MMIO | physical base | size in bytes, whole pages | bit 0: the device does DMA; bit 1: it is the console /chosen/stdout-path names, whose hold the kernel’s lines wait on (the console’s one writer); a second entry with bit 1 stops the boot |
| 2, IRQ | interrupt number | 0 | 0 |
| 3, Reset | 0 | 0 | 0 |
Every address and size is two words, low word first, so one layout serves both widths. The
kernel rebuilds each through u64 and narrows it with a checked conversion (args::wide), so
a value that does not fit a usize stops the boot instead of being cut short. It reads the
block only as words, never as a cast structure, because the block promises 4-byte alignment
and a u64 field needs 8. Its iterator stops at a header that does not fit and refuses a tag
whose data would run past the end. The CRC is written but not checked: the kernel takes the
block as the loader’s.
The kernel turns each Devs entry into a device object at boot and refuses to boot on one that
is malformed: an MMIO range that is empty, not whole pages, wraps, overlaps RAM or overlaps a
Ctrl range; an IRQ entry naming 0; an unknown kind. It checks the controllers itself because
the loader’s exclusion of them is a reading of a device tree the kernel does not trust
(devices). It also refuses a Grnt tag: device grants are not a boot input.
Hardware abstraction
Status: built · tested (5)
- bench:rustsbi-boot
- bench:uart-irq
- bench:sched-lock-contention
- bench:irq-boot-hart-only
- host:loader::each_listed_hart_carries_its_s_mode_context_by_boot_index
Machines differ in ways unrelated to the register width, so code never uses target_arch to
mean “has a PLIC” or “runs under SBI”.
- Capability features select backends in
kernel/Cargo.toml:sbi(S-mode under SBI firmware: console, power-off, the timer, the RNG) andplic(the interrupt controller). New hardware is a new backend file and feature. - Board features only compose them:
qemu-virt = ["sbi", "plic"]. - Discovered, not written in: RAM, MMIO regions, interrupts, the PLIC, each hart’s context, the timebase and the seed come from the device tree, read by the loader alone. The kernel has no device-tree parser.
- Width-bound only: page-table geometry (
libs/paging), the saved-context size and the trap assembly key on the pointer width (memory layout).
The interrupt controller contract (kernel/src/arch/riscv/irq.rs): a backend provides
init, online (a started hart takes device interrupts), enable_irq, disable_irq, pending
(claim at most one interrupt, on the calling hart) and complete. The PLIC backend maps the
controller at KERNEL_PLIC_BASE for the kernel alone, from the Plic tag, and every started hart
takes device interrupts: each enables every source Devs names on its own S-mode context (from
Hart), threshold 0, once it holds the kernel lock, and sets the external interrupt in sie. Any
hart in user mode or idle takes a raised source; the first to hold the kernel lock claims it on its
own context, and another hart that trapped for it claims nothing. The trap handler claims a source,
completes it, then masks it until the holder’s next receive, in one kernel section
(R5 (interrupts)). A source is masked and unmasked by its priority (0,
then 1), not by its enable bits: one write reaches every hart’s context, so a hart started later
needs no copy of the masks; a completion is never ignored, as a PLIC ignores one for a source not
enabled on the completing context; and a priority write is what makes QEMU’s PLIC look at its
pending sources again, which an enable write does not, so a source that went pending while masked
is delivered at its unmask. init writes priority 0 to every source Devs names, since a
priority’s reset value is the PLIC’s to choose. Interrupt 0 is never a device: no PLIC has a
source 0, and the hart timer is the kernel’s, arriving as a supervisor timer trap and not
through the PLIC (timer). The loader drops an interrupt 0 that a device asks for.
Hardware bounds
Status: built · partly tested: the compile-time bounds hold by the build and are not attacked by a case; the loader’s device-table refusal and the kernel’s ASID-width refusal are tested on the host, not by a boot (every QEMU hart has the whole field) · tested: host:loader::a_33rd_mmio_region_is_refused, host:loader::a_33rd_interrupt_is_refused, host:loader::thirty_two_devices_are_kept, host:loader::an_interrupt_two_devices_raise_takes_one_slot, host:loader::harts_are_listed_by_boot_index_never_by_id, host:loader::harts_past_max_harts_stay_parked, host:loader::a_hart_whose_s_mode_context_is_past_the_plic_window_stays_parked, host:loader::a_boot_hart_whose_context_is_past_the_plic_window_is_refused, host:paging::the_asid_width_decision
Every constant that mirrors a hardware field or a platform limit is held to it. A field the ISA fixes is a compile-time assert on each width, so a build that breaks it does not exist. A limit the platform reports is checked when the loader or kernel reads it, and a machine past it is refused (R17 (fail closed)), never truncated.
| Constant | Field or limit | Checked |
|---|---|---|
MAX_PROCESS_COUNT | satp’s ASID, 9 bits in Sv32 and 16 in Sv39 (satp) | compile time |
ASID_BITS | the hart’s satp ASID field, found by writing ones to it | at boot |
MAX_THREADS | the kernel’s 8-bit last-TID field | compile time |
USER_AREA_END | Sv39’s lower half (2^38 bytes); Sv32’s half below the kernel’s | compile time |
| the kernel-half bases (rv64) | Sv39 canonical addresses | compile time |
| the PTE and satp PPN | Sv32’s 22 bits, Sv39’s 44 | follows from the physmap’s bounds |
| the handle slot’s frame index | the physmap’s frames | compile time |
| the kernel’s windows (PLIC, DMA registers, process area, stacks) | each other and the physmap | compile time |
| the physmap | the RAM the platform reports | at boot |
| the PLIC window | the PLIC’s reported size | at boot |
MAX_HARTS (8) | the harts in the tree, numbered by the platform sparsely and widely: per-hart state is by dense boot index, and a hart past the eighth stays parked | at boot (loader) |
| a started hart’s PLIC S-mode context | inside the PLIC’s reported window; another hart’s that is not stays parked, the boot hart’s stops the boot | at boot (loader) |
HART_STACKS | MAX_HARTS slots of 18 pages, inside the kernel area below the image, 1 MiB on Sv32 (the two address maps) | compile time |
MAX_IRQS (1024) | the PLIC’s sources, 1 to 1023 | at boot, per device |
| the loader’s device table (32 regions, 32 interrupts) | the device tree’s devices | at boot |
| the timebase | timebase-frequency, not 0 | at boot |
| the kernel’s tables | its 1 MiB data region | at link time |
Two hold by design rather than by a check: every 64-bit value in the ABI takes two registers on both widths (ABI), and the DMA pool lies within every device’s reach because virtio addresses are 64 bits. The firmware programs PMP; the kernel and loader program none.
Devices handed to the first program
Status: built · partly tested: the loader’s refusal of a tree with no console or no console interrupt, and the kernel’s refusal of an object for a DMA device past the sixteenth, are not attacked by a case · tested: bench:device, bench:irq-attack, bench:rustsbi-boot
The kernel hands every device object to init, the one process the loader starts, in Devs
order. The order is positional; init learns which device each handle names from
device_info. init runs in root
(budgets).
| Handle | What |
|---|---|
| 1, 2, 3 | the root, system and users budgets |
| 4 | the Reset right |
| 5, 6 | the console’s MMIO, then its interrupt |
| 7 on | every other MMIO region in device-tree order, then every other interrupt ascending |
The three positions after the budgets are fixed, so a tree that names no console, or a console
with no interrupt, is refused by the loader rather than booted with the indices shifted under a
program that pinned them. A DMA device the kernel has no reset slot for gets no object, and the
later indices close up. The handles to devices and budgets are stamped with root
(R9 (stamps)), so only root’s destruction revokes them; the device
objects are charged to system and die with it.
The loader loads only the kernel and init
Status: built · tested: bench:bundle-mapped, bench:device-info-attack
flowchart LR
F[firmware] --> L[loader:<br/>verify the bundle]
L --> K[kernel]
L --> I[init]
K --> I
I --> S[servers, launched<br/>through the loader stub]
Figure: the handoff. Dashed: planned (what init does with the bundle).
The bundle holds the kernel, init, the boot manifest, every other
server’s program and any data entries. The first entry is the kernel and the second is init,
whatever their names. Every later entry is data to the loader. The loader verifies the bundle as
it verifies every bundle, then loads exactly two images, the kernel and init. There is one
initial process.
The kernel gives init the root, system and users budgets, the Reset right and every
device object, in fixed slots
(devices handed to the first program). init runs in
root, on the process, the weight and the pages root keeps for it
(budgets). The loader also maps the whole
verified bundle into init, read-only: signature and archive, at BUNDLE_AT (0x2000_0000),
outside init’s link range (memory layout). init, which is
trusted, keeps it read-only; a program it starts never holds a bundle frame, only copies of
what it reads. Its frames are charged to root as part of what the loader gave init, and
they stay for as long as init runs, because init launches from them again when it restarts
a server. init’s first thread starts with the bundle’s address in a0 and its length in
a1. The archive was verified before the loader parsed it, and init reads it only within
that length.
No later entry’s name means anything to the loader. An entry named grants is data like any
other and grants nothing. The loader reads no entry it does not load, and the kernel refuses a
Grnt tag, so no file in the bundle grants a device to either of them. What a server holds is
init’s to place, from the manifest (devices).
What init does with the bundle
Status: built · tested (6)
- bench:init-boot
- bench:init-servers
- bench:init-refuses-held-bundle-key
- bench:init-refuses-device-unmatched
- bench:bench-bundle-file
- host:testbench::the_image_recipe_packs_init_the_servers_and_the_manifest
init learns which device each handle names from the kernel, with
device_info, and matches that to the manifest, which names each
device by its register base and its interrupt
(devices). init places each driver’s handles by
name in that driver’s startup block, and drivers use no positional indices. init starts every
other process through the loader stub, straight from the bundle’s pages, and parses no ELF
itself (init). It refuses a manifest that gives keyd the key the loader
verifies the bundle with, since keyd cannot see that itself (keyd).
Data entries, the bench’s [[file]] entries among them, are then data. init reads the
manifest from the bundle’s pages, and a program reads a public entry through bootfsd
(bootfsd). bench-bundle-file already reads its injected entry back
from the bundle’s pages in init’s place, and a case under init reads it again through /boot
(test bench). The bundle’s contents come from one
recipe, image/boot.toml: the kernel, init, the manifest and the servers. The bench’s
builder is the one tool that packs a bundle. It reads a case for the bench, and image/boot.toml
for ./mkimage and for init-boot, which boots the recipe’s bundle to init’s last line.
Verified boot
Status: built · tested (8)
- bench:verified-boot-rejects-tamper
- bench:verified-boot-rejects-bare-archive
- bench:verified-boot-rejects-short-initrd
- host:redoubt-signing::preamble_is_the_documented_bytes
- host:redoubt-signing::domain_is_prefix_free
- host:redoubt-signing::volume_preimage_is_the_documented_bytes
- host:testbench::the_signed_bytes_are_the_documented_preimage
- host:testbench::golden_signature_over_a_known_archive
- Algorithm: Ed25519 (RFC 8032), through the pure-Rust
no_stdcrateed25519_compact. One public key is compiled into the loader (loader/src/verify.rs). There is no algorithm choice and no second key. - Container: the initrd is
signature (64 bytes) || tar. An initrd shorter than 64 bytes is refused. - What is signed is not the bare archive. The signature covers a domain-separated
preimage: the 18-byte NUL-terminated domain
"redoubt.bundle.v1\0", the archive’s length as a little-endianu64, then the archive. The domain’s only NUL is its last byte, so no domain name is a prefix of another. - The volume domain, built the same way. A signed volume’s root block is signed over
"redoubt.volume.v1\0", the length 48 as a little-endianu64, then N, the version and the root, fromredoubt_signing::volume_preimage(verityd). The domain test holds both domains NUL-terminated and neither a prefix of the other, so a bundle signature is never a volume’s. - The length is measured, never read. It is the number of bytes after the signature in the initrd the loader was handed, not a field of the archive, which is the attacker’s to write.
- One construction. Both the loader and every signer take the 26-byte preamble from
redoubt_signing::bundle_preamble(libs/signing:no_std, no dependencies,forbid(unsafe_code)). The loader hashes the preamble and then the archive, copying nothing; the bench’s builder writes the same two pieces. A host test pins the bytes, and a golden signature pins key, algorithm and preimage together. - No fallback. A signature over the bare archive is refused like any other bad one, and a bad one powers the machine off (R17).
flowchart LR
subgraph initrd["the initrd, as the firmware hands it over"]
direction LR
sig["signature<br/>64 bytes"] --> tar["tar<br/>len bytes: the rest of the initrd"]
end
flowchart LR
subgraph covered["what the signature covers: the preamble, 26 bytes, built in libs/signing, then the tar"]
direction LR
d["#quot;redoubt.bundle.v1#quot; and a NUL<br/>18 bytes"] --> l["len<br/>u64 LE, 8 bytes"] --> t["tar<br/>len bytes"]
end
Figure: the container, and the preimage the signature covers.
The development key. The bench signs with a key derived from a fixed, public seed
([0x42; 32]), so every build is reproducible and anyone can sign a bundle. Its public half is
DEV_PUBLIC_KEY in the loader. It is not a secret: a deployment generates a secret key and
compiles its public half in instead (getting started).
What is not covered:
- the loader itself: on QEMU the host loads it with
-kernel, unchecked; on hardware a boot ROM or the firmware would verify it (FPGA platform); - M-of-N signatures, rollback protection and key rotation (packages, M6 (persist, install, share));
- confidentiality: the bundle is signed, never encrypted. Verified boot gives integrity and
authenticity. Whoever can read the bundle image reads all of it, including any key seeds the
boot manifest carries for
keyd(keyd).
Randomness
Status: built · tested: bench:rng, bench:boot-rejects-no-seed, bench:boot-rejects-short-seed
The loader copies /chosen/rng-seed (16 to 64 bytes; QEMU virt writes a fresh one each boot)
into the Seed tag, and refuses to boot with less than 16 bytes. The kernel folds the whole seed
into a 32-byte key by XOR and keys a ChaCha8 generator with it
(kernel/src/platform/sbi/rand.rs); with no Seed tag it panics. There is no fallback: a
clock is not entropy. The kernel draws from it the PID of each process it creates and the
random call’s values. random returns one u64 per call, so a 32-byte key takes four calls
(ABI).
Authority
Status: built · tested: bench:irq-attack, bench:device, bench:device-info-attack
- The firmware keeps M-mode and its memory, which the ownership table gives to PID 1, so no budget ever receives it. It is in the TCB.
- The loader has the whole machine while it runs, and gives all of it away: RAM ownership and the argument block to the kernel, the images to their address spaces. It keeps nothing and leaves nothing running.
- The bundle chooses code, not authority. Its signature decides what runs; what each
program holds is decided by the kernel’s boot code alone (the table above). The loader reads
no entry it does not load, and the kernel refuses a
Grnttag, so no file in the bundle can grant a device. - The device tree chooses what exists, not who holds it: it is the only source of device objects, and the kernel still refuses one that names RAM or an interrupt controller. The tree is the firmware’s and the host’s, and is not signed (Residual risks).
initholds every device and every budget at boot, and is the only process the kernel starts. Every other process holds whatinithands it, and the kernel refuses its attempts at any device it was not given (R18 (device authority)).
Security properties
R15 (verified boot)
Status: built · tested: bench:verified-boot-rejects-tamper, bench:verified-boot-rejects-bare-archive, bench:verified-boot-rejects-short-initrd, host:testbench::golden_signature_over_a_known_archive
No byte of the bundle is parsed or run before its signature checks. The loader reads only the
bundle’s range from the device tree, verifies the signature over
"redoubt.bundle.v1\0" || u64_le(len) || tar with the one compiled-in key, and only then opens
the archive. A changed byte anywhere in the archive, a signature over the bare archive, another
domain’s preimage or a wrong length does not verify, and the loader powers off instead of
printing bundle signature ok. The two boot cases forbid that line and the kernel’s
KMAIN. Ed25519 accepts only the message that was signed, so a foreign domain or a wrong
length fails for free; the host test pins that, and the bare-archive case shows the loader has
no second acceptance path.
R75 (verified userland)
Status: built · partly tested: built for modules and application resources; a program launched from the userland disk comes from the same volume by launching, which is BEAM4’s and not built · tested: bench:userland-bad-start, bench:userland-boot, bench:userland-read-only, bench:verity-flipped-tree, bench:verity-wrong-root, bench:pack-bad-truncated, bench:pack-bad-wrong-length, bench:pack-bad-wrong-name, host:beamlet-redoubt::a_pack_with_a_bad_entry_is_refused_whole, host:beamlet-vm::a_refused_system_module_never_touches_the_code_path, host:beamlet-vm::app_spec_uses_one_source_attempt_and_keeps_its_erlang_result, host:beamlet-redoubt::a_module_is_its_file_and_a_failed_read_is_refused, host:beamlet-redoubt::not_found_at_the_open_is_absent_and_every_other_error_is_refused_by_name, host:beamlet-redoubt::verified_module_lookup_propagates_found_absent_and_refused, host:beamlet-redoubt::verified_application_lookup_propagates_found_absent_and_refused
A module or application resource the system resolves by name, and a program it launches from the
userland disk, comes only from a verified volume
(R76 (verified volumes)). The userland disk is one:
its manifest entry pins the root of its hash tree, the manifest is a bundle entry, so
R15 covers the root, and the volume’s erofsd reads it only through its
verityd. beamlet reads each module and resource as the plain file of its name
(Elixir.Enum.beam, elixir.app) at the volume’s root and checks nothing itself: a reader of a
verified volume trusts the servers that verify it, as it trusts consoled for its console. The
volume also carries the boot pack, boot.pack, a file the image’s builder made from the same
module files, which beamlet reads whole before its VM starts: a module found in it is those bytes,
never searched for on the code path, and a pack that cannot be read, or does not check whole, is
refused, said once naming the pack, and the VM does not start. A volume without a pack is not a
refusal: beamlet says so once and its modules load from their files. A name the volume’s erofsd
answers not_found to is absent: the lookup goes on as for any name the system lacks, and for a
module that absence permits the VM to search its authorized code path. Any other refusal at the
open or on the read (corrupt from a volume erofsd serves as corrupt after verityd failed a
block, a short or long file, a device error) is a refusal: beamlet writes one line on its
console naming the module or application and the error’s name
(error names), retries nothing, and never searches
the code path for the refused name. An application specification likewise makes one source
attempt; absence and refusal both reach Erlang as error
(beamlet on Redoubt). It does not stop code from
running: code:load_binary/3 still loads bytes a session holds, within the session’s own
authority (a lookup, not a gate), so the rule
extends verified boot to the userland’s integrity, not to a code-signing gate.
R16 (image confinement)
Status: built · tested: bench:loader-rejects-kernel-address, bench:loader-rejects-kernel-entry, bench:loader-rejects-truncated-elf, bench:loader-rejects-segment-data-cut, bench:loader-rejects-wx-segment
The loader confines every image to its area (loader/src/image.rs). Every loadable segment,
from its address to address plus memory size, and the entry point must lie in the area: for a
program, from PAGE_SIZE to USER_AREA_END (0x8000_0000 on rv32, 0x40_0000_0000 on
rv64), so page 0 and the kernel’s range are never program pages; for the kernel, the kernel area
(KERNEL_AREA to the top of the address space). A segment that wraps, or whose file size
exceeds its memory size, is refused. A truncated image is refused: program headers past the
end fail the ELF parse, and segment data past the end fails a bounds check. No page is mapped
writable and executable, or writable without readable (libs/paging refuses the entry), and no
page is mapped twice to different frames. This matters beyond the one process, because the
kernel’s page tables are shared by every address space: a program segment in the kernel’s range
would be written into all of them.
R17 (fail closed)
Status: built · partly tested: the kernel's own refusal of a missing `Seed` tag stands behind the loader's refusal of the seed and is not reached by a boot; the physmap and PLIC-context refusals, the loader's device-table refusal and the kernel's ASID-width refusal are tested on the host, not by a boot · tested (15)
- bench:verified-boot-rejects-tamper
- bench:verified-boot-rejects-bare-archive
- bench:verified-boot-rejects-short-initrd
- bench:boot-rejects-no-seed
- bench:boot-rejects-short-seed
- bench:boot-rejects-no-timebase
- bench:boot-rejects-irq-past-plic
- host:redoubt-layout::ram_one_page_past_the_physmap_end_is_refused
- host:loader::a_boot_hart_without_an_s_mode_context_is_refused
- host:loader::booting_on_hart_1_takes_hart_1s_s_mode_context
- host:loader::a_33rd_mmio_region_is_refused
- host:loader::a_33rd_interrupt_is_refused
- host:loader::thirty_two_devices_are_kept
- host:loader::an_interrupt_two_devices_raise_takes_one_slot
- host:paging::the_asid_width_decision
The boot never runs degraded. Each of these powers the machine off through SBI SRST with
SystemFailure rather than boot: a bad bundle signature; an initrd too short to be signed; a
/chosen/rng-seed under 16 bytes, or a kernel given no Seed tag (PIDs and random would
be guessable); no timebase, which the loader reports by leaving out Time and
the kernel refuses (no timeout, slice or deadline would mean anything); no console or console
interrupt; a bundle that is not a tar or has no second entry; a bundle that does not start on a
page, is larger than 512 MiB or shares a page with the device tree; an image R16 refuses; a full
argument block; any argument-block refusal of the kernel’s. A refusal of
the loader’s prints loader PANIC and one of the kernel’s a kernel panic; both then power off.
The refusal cases require the power-off and QEMU’s status 255, on both widths. The seed and
timebase cases boot QEMU’s own device tree with one property removed, cut short or set to 0
(hostile inputs); the timebase stays as 0 rather than going
because the firmware does not come up without it.
A device tree with a PLIC but no S-mode context for the boot hart stops the boot too
(the Plic row), and so does one with more MMIO regions or interrupts
than the loader’s 32 of each (hardware bounds).
The kernel refuses a hart whose satp ASID field is narrower than ASID_BITS, naming both
widths: it writes ones to the field, reads it back and counts the bits that stuck, before
anything else writes satp (hardware bounds).
The loader refuses to boot when RAM does not fit in the kernel’s direct physical map
(PHYSMAP_SIZE from PHYSMAP_PHYS_BASE, memory layout),
naming both ranges.
Failure and restart
Status: built · tested (5)
- bench:verified-boot-rejects-tamper
- bench:device
- bench:panic-in-print
- bench:bundle-mapped
- bench:reboot-verifies-bundle
- A refused boot powers off. Nothing retries it and nothing boots in its place: someone must fix the bundle or the machine.
- A kernel panic at boot or later powers off the same way. A panic inside
print!does not take the console that print holds: it writes through the firmware’s console, which keeps no state, and marks its line(while printing). - A reboot (
system_resetwith the Reset right, devices) starts the chain again from the firmware, so the bundle is verified on every boot. - After the handoff the loader is gone. Its pages are unowned in the ownership table and
the kernel hands them out as free memory. The bundle’s pages are
init’s, charged toroot, and stay mapped ininitfor as long as it runs, so it can read the bundle again at any time.
Residual risks
- The loader itself is not verified on QEMU. The host starts it with
-kernel, and the firmware with-bios. Whoever can change those files on the host chooses the kernel, and verified boot is only as strong as the host. On hardware a boot ROM would check the loader (FPGA platform). - One development key, and it is public. Anyone can sign a bundle the stock loader boots. A
deployment must generate its own key and replace
DEV_PUBLIC_KEY. There is one key, with no rotation and no M-of-N. - No rollback protection. An older bundle, correctly signed, boots as readily as the latest. The rollback counter belongs to system updates (packages, M6 (persist, install, share)).
- Integrity, not confidentiality. Seeds and anything else in the bundle are readable by
whoever reads the bundle image;
keyd’s seeds are the stated case (keyd). Bundle confidentiality is not claimed (TENETS). - RustSBI is in the TCB. It runs in M-mode below the kernel, sees all memory and answers
every SBI call; a flaw in it is a flaw in Redoubt. It is vendored at a pinned commit and built
with QEMU
virt’s drivers only. Its domain support, which would partition harts between Redoubt and another OS, is unchecked, so nothing relies on it (Linux on reserved cores). - The device tree is not signed. It comes from the firmware and, on QEMU, from the host:
RAM, the device list, the timebase and the seed are what it says. The kernel checks that no
device object names RAM or a range the loader reported in
Ctrl. A tree that hid an interrupt controller from the loader’s controller searches as well as from its device exclusion would get it made into a device object, and its holder could mask or raise any interrupt. That parse is the loader’s to get right. - Signatures are checked cofactored.
ed25519-compactaccepts a signature whose R differs by a point of small order, so a valid signature can be turned into another valid one for the same bundle. That forges no bundle without the key; nothing here takes a signature as a bundle’s identity. - The bundle stays read-only because
initkeeps it so.initowns the bundle’s frames, so it couldset_flagsthem writable, unmap them orprocess_mapthem into a child; the kernel does not stop it. The verified bytes are only as safe after boot asinitis (R15 holds up to the loader’s mapping). - The kernel trusts the loader. It checks the argument block’s shape and its device entries, but takes the RAM range, the ownership tables and the process table as true, and does not check the tags’ CRC. On QEMU the loader is not verified (above).
- The kernel’s refusals of a block only the loader could write are argued from the code: a
tag past the end, a second
MREx, aDevsentry naming RAM or a controller, wrapping or naming interrupt 0, aGrnttag. No device tree makes the loader write one, and the kernel has no host tests. The one such entry a tree can produce, an interrupt past the PLIC’s sources, is attacked (boot-rejects-irq-past-plic), as are the seed, timebase and image refusals (attack gaps). - Device indices are positional. Past the fixed three, a device’s handle index depends on the device tree’s order and on which DMA devices the kernel could register. A program that pins one depends on the machine. Placement by name is planned (above).
Why
- Verify before parsing. The tar and ELF parsers are large and see attacker bytes. Checking the signature first means they only ever see bytes the key holder signed, and it is the one check every later guarantee rests on.
- A domain on the bundle signature. A ustar header starts with a 100-byte name field of arbitrary bytes, and another protocol’s domain and length fit inside it, so without a domain of its own a signature made for something else could cover a well-formed archive. Every Redoubt signature domain is a NUL-terminated name and a fixed-width length, built in one crate, so the names are prefix-free and no signature reads as another protocol’s message.
- Measure the length. A length read out of the signed bytes is a length the attacker chose; the container’s own size is not.
- One embedded key, no agility. Negotiating an algorithm or a key is a second acceptance path, and the bare-archive case exists to show there is none.
- The device tree stays in the loader. Parsing a tree is a large job over firmware input. Done once, before the kernel runs, it keeps a parser out of the kernel; the kernel then checks the few facts it must not get wrong (no RAM, no controller) in words it can read.
- Fail closed. A machine without entropy hands out guessable identifiers, and one without a timebase has timeouts that mean nothing. Both would boot and look fine. Stopping is the only outcome nobody can mistake for success.
- Enter the kernel by a fault. Pointing
stvecat the kernel’s entry and letting the fetch aftersatpfault avoids building an identity mapping that would outlive the loader.
Memory layout
Every address space has two halves. The lower half is user space, the process’s own. The upper half is the kernel’s, the same in every address space and unreachable from user mode: it holds the physmap (all of RAM at a fixed offset), the kernel image and stacks, the windows onto the interrupt controller and DMA devices, and one area of per-process kernel data. This page gives every fixed address, for Sv39 (rv64) and Sv32 (rv32), the conventions user space follows, and what each page-table entry bit means to Redoubt.
Purpose
The kernel, the loader, the loader stub and every launcher must agree on where things are. The
kernel-half constants live in one crate, redoubt-layout (libs/layout), which the loader uses
to build the map and the kernel uses to rely on it. The end of user space (USER_AREA_END) and
PAGE_SIZE (4096, on both widths) are redoubt-sys’s, because programs need them. The page-table
code is the paging crate (libs/paging), the only code that reads or writes a page-table entry.
What the mapping calls do with this layout is memory; the rules they keep are
R11 (memory) and R19 (kernel W^X). This page owns one rule, R24: the
kernel never reaches user memory through a user mapping.
The direct physical map
Status: built · tested: bench:kernel-wx, bench:kernel-half-attack
The loader maps all of RAM once, in the kernel half, at
virt = PHYSMAP_BASE + (phys - PHYSMAP_PHYS_BASE)
Code computes it with physmap_virt(phys), never PHYSMAP_BASE + phys, because the offset
differs by width. On Sv39 the map starts at physical 0 (PHYSMAP_PHYS_BASE = 0), so it also
covers the physical addresses below RAM, where the devices sit. On Sv32 it starts at RAM
(0x8000_0000 on QEMU virt), which is also the start of the kernel half, so the map is the
identity there.
The window is PHYSMAP_SIZE long (128 GiB on Sv39, 2032 MiB on Sv32). The loader refuses a
machine whose RAM does not fit in it (R17 (fail closed)): the kernel hands out
frames lowest first, so RAM past the window would stop the kernel the first time a process
allocated a frame there.
Every physmap entry is readable, global and supervisor-only: no U bit and never X. All are
writable but the kernel’s read-only frames. The map is built from the largest leaves (1 GiB on
Sv39, 4 MiB on Sv32), except around those frames: for every kernel page that is not writable
(code and constants) the loader splits the superpages above it down to 4 KiB and clears W on
that frame’s alias. At boot, before any
process runs, the kernel walks its own mappings and stops unless no executable kernel page is
writable and the physmap alias of each is neither writable nor executable (verify_kernel_wx,
R19). It prints the verdict on every boot.
Nothing unmaps the physmap. So the kernel reaches any frame at any time, without switching
satp and without temporary mappings:
- page tables are walked and edited in software, from the root that
satpnames, through the physmap (kernel/src/arch/riscv/physmap.rs); another process’s tables are edited the same way; - kernel objects (budgets, handle-table pages) live in frames no process maps, reached through
the physmap (
kernel/src/kframe.rs); - a system call reads or writes a user record by walking the caller’s tables to the frame,
checking the entry is a user mapping with the permission needed and not lent, and copying
through the physmap (
user_frame). The kernel never dereferences a user pointer.
The split by root entry
Status: built · tested (5)
- bench:map-fixed-attack
- bench:loader-rejects-kernel-address
- bench:loader-rejects-kernel-entry
- host:redoubt-sys::map_fixed_range_check_refuses_rv32_wraparound
- bench:kernel-half-attack
The root table’s lower half is user space; its upper half is the kernel’s. The kernel’s root
entries are made once, by the loader, and never change afterwards. Creating an address space
allocates a root and copies every kernel-half entry into it from the current root, except the
one for per-process kernel data, which each address space fills with its own pages
(MemoryMapping::allocate). Sharing the kernel is therefore sharing those entries: the
physmap’s leaves and the tables under the kernel area.
The kernel maps two things after boot: the interrupt controller (PLIC) and the DMA register
window (devices). The loader creates the tables under both before any user root
copies the kernel’s entries (reserve_tables), so those leaves appear in every address space
and the kernel maps them without allocating. Those are the only kernel-half mappings made after
boot.
The walls on the boundary:
- Mapping calls stop at
USER_AREA_END.map_fixed,process_map,unmapandset_flagsrefuse a range that reaches past it withInvalidArgument, before they look at a page table. A demand-paging fault above it ends the process. - Boot images stay below it. The loader refuses a program whose segment or entry point is
outside
[PAGE_SIZE, USER_AREA_END), and the kernel image outside the kernel area (R16 (image confinement)). - No kernel-half entry has
U. A user-mode load, store or fetch there faults, and the fault handler ends the process.
The two address maps
Status: built · partly tested: the constants’ separation is checked at compile time and the PLIC’s size at boot, not attacked by a case · tested: bench:kernel-wx, bench:legacy-gone, bench:kernel-half-attack
Addresses are from libs/layout/src/lib.rs, the kernel’s link.x and link64.x, and
kernel/src/arch/riscv/process.rs. Compile-time assertions keep the DMA window clear of the
physmap, the per-process area and the kernel stacks; at boot the kernel stops if the PLIC the
device tree reports would run into the DMA window.
Sv39 (rv64)
Root entries are 1 GiB each, 512 of them. Addresses from bit 38 up must all equal bit 38; any other address is not canonical and every access to it faults.
| Root entries | From | What | Shared |
|---|---|---|---|
| 0..=255 | 0x0 | user space, up to USER_AREA_END (0x40_0000_0000, 256 GiB) | no |
| 256..=383 | 0xffff_ffc0_0000_0000 | physmap, PHYSMAP_SIZE (128 GiB), from physical 0, filled to the end of RAM | yes |
| 384..=509 | 0xffff_ffe0_0000_0000 | empty | |
| 510 | 0xffff_ffff_8000_0000 | per-process kernel data (PROCESS_AREA) | no |
| 511 | 0xffff_ffff_c000_0000 | the kernel area (KERNEL_AREA) | yes |
Inside the kernel area:
| Address | What |
|---|---|
0xffff_ffff_f000_0000 | PLIC window (KERNEL_PLIC_BASE), up to 64 MiB |
0xffff_ffff_f400_0000 | DMA register window (KERNEL_DMA_REGS): KERNEL_DMA_PAGES (16) pages, one per DMA device |
0xffff_ffff_ffc0_0000 | hart stacks (HART_STACKS): MAX_HARTS (8) slots of 18 pages, each an unmapped guard page, the kernel stack (8 pages), a guard page and the trap stack (8 pages), by boot index; slot 0 is the boot hart’s (KERNEL_STACK_TOP, TRAP_STACK_TOP), which the loader maps, and the kernel maps a slot when it starts its hart |
0xffff_ffff_ffd0_0000 | kernel code and constants (512 KiB) |
0xffff_ffff_ffd8_0000 | kernel data (1 MiB) |
Figure: the Sv39 address map. Everything above the non-canonical gap is supervisor-only.
Sv32 (rv32)
Root entries are 4 MiB each, 1024 of them. Every 32-bit address is canonical.
| Root entries | From | What | Shared |
|---|---|---|---|
| 0..=511 | 0x0 | user space, up to USER_AREA_END (0x8000_0000, 2 GiB) | no |
| 512..=1019 | 0x8000_0000 | physmap, PHYSMAP_SIZE (2032 MiB), identity | yes |
| 1020..=1021 | 0xff00_0000 | PLIC window, up to 8 MiB less the DMA window | yes |
| 1021, last 64 KiB | 0xff7f_0000 | DMA register window: 16 pages | yes |
| 1022 | 0xff80_0000 | per-process kernel data (PROCESS_AREA) | no |
| 1023 | 0xffc0_0000 | kernel area: hart stacks at 0xffc0_0000 (HART_STACKS: MAX_HARTS slots of kernel and trap stack with guard pages, slot 0 the boot hart’s, as on Sv39), code at 0xffd0_0000, data at 0xffd8_0000 (1 MiB) | yes |
A QEMU virt PLIC is 6 MiB, which is why the PLIC window takes two root entries.
Figure: the Sv32 address map. Everything from 0x8000_0000 up is supervisor-only.
Per-process kernel data
PROCESS_AREA holds the current process’s header: one page on both widths, the process’s own,
charged to its budget, with no U bit. It holds the process’s bookkeeping and thread masks, and
the table from TID to each thread’s IPC page (MAX_THREADS + 1
entries of 4 bytes). The kernel asserts that this fits in the page.
A thread’s saved registers, 32 machine words, are the last bytes of its IPC page, the page a
thread costs its budget (objects), which the kernel reaches through
the physmap. So a process pays for the threads it has, not for MAX_THREADS. The trap handler finds
the running thread’s context through the hart’s block, reached by sscratch and mapped in every
address space: it holds the address of the running thread’s saved context, the hart’s current PID
and thread, and the hart’s own saved context for when it runs the kernel’s thread (PID 1, which
has no budget and no IPC pages). Switching thread on a hart writes that hart’s block, never
another’s.
The rest of the per-process entry is never mapped. A new thread’s return address is
EXIT_THREAD, an address there (0xffff_ffff_8080_3000 on Sv39, 0xff80_3000 on Sv32): a thread
that returns from its entry function fetches there, takes an instruction page fault, and the
kernel ends the thread as if it had called thread_exit (processes). A jump to
any other address there is an ordinary fault.
User space
Regions
Status: built · partly tested: the message area is a convention of the kernel that no case attacks as an address · tested (6)
- bench:map-fixed-attack
- bench:map-fixed-tables
- bench:map-anon-search-bound
- bench:boot-stack-reservation
- bench:bundle-mapped
- bench:thread-stack-guard
User space uses the same addresses on both widths, all below 2 GiB. On Sv39 the rest, up to
256 GiB, is free for map_fixed and process_map and nothing is placed there by default.
| Range | What | Placed by |
|---|---|---|
0x0..0x1_0000 | free; page 0 is user space | nobody |
0x1_0000..0x1FF0_0000 | the program image: the link range | the program’s linker |
0x1FF0_0000..0x1FF4_0000 | the loader stub, in a launched process (at most 256 KiB) | the launcher |
0x2000_0000..0x4000_0000 | the bundle (BUNDLE_AT), only in init’s address space or the tester’s in its place: the whole verified initrd, mapped read-only by the loader and kept so by init, which is trusted (a program it starts holds only copies), charged to root; at most 512 MiB, and the loader refuses a larger one or one that does not start on a page. map_fixed over it is refused as an overlap | the loader |
0x4000_0000..0x4040_0000 | the message area (4 MiB): where the kernel maps a lend or transfer the process receives, every one inside it | the kernel |
0x6000_0000..0x7000_0000 | the map_anon area (256 MiB): where map_anon, map_device and dma_alloc place pages, every run inside it | the kernel |
0x7000_0000..0x7FF0_0000 | the thread-stack area (255 MiB, redoubt_rt::thread::STACKS_BASE): where the runtime’s spawn places every spawned thread’s stack by map_fixed, slot after slot from the bottom, each stack’s pages above one page the runtime leaves unmapped as its guard, so an overflow faults there and the kernel ends the process at once (redoubt-rt); the kernel places nothing here (map_anon’s area ends below it), so a guard stays unmapped under the process’s own map_anon calls, and a map_fixed of the program’s own in the area is refused to the next spawn whose slot it took | the runtime |
0x7FF0_0000 | the startup block, in a launched process | the launcher |
0x7FF8_0000..0x8000_0000 | the first thread’s stack: the loader reserves 32 pages (0x7FFE_0000..0x8000_0000) for a boot process, backing only the top one before first touch; a launcher maps and backs all of a child’s chosen 1 to 128 pages here | the loader for a boot process; a launcher places its own |
The kernel searches its two areas for the first free run of pages, starting at its last
choice; map_anon’s choice is memory’s. A process started by the loader gets its
image, its stack and the bundle; its first thread starts with sp 16 bytes below
0x8000_0000, the bundle’s address in a0 and its length in a1
(boot). On Sv32 the stack top is also the
end of user space.
Figure: user space, the same on both widths. Launched processes get the stub and a startup block; init gets the bundle instead.
Page 0
Status: built · tested: bench:map-fixed-attack, host:redoubt-sys::map_fixed_range_check_refuses_rv32_wraparound
User space starts at address 0 on both widths. No page is reserved at the bottom: map_fixed,
process_map, unmap and set_flags accept page 0 like any other page. A mapped page 0
affects only the process that maps it, or the child a parent maps it into with process_map,
because the kernel never dereferences a user pointer (see Why). Program images do not start
there: the loader and the stub refuse a segment on page 0.
The loader stub and the link range
Status: built · tested: bench:stub-launch, host:stub::plan_refuses_a_segment_touching_page_zero, host:stub::plan_refuses_a_segment_reaching_into_the_stub_region
A launched process runs the loader stub first. Its launcher maps the stub’s
flat binary read-only and executable at STUB_ENTRY (0x1FF0_0000, the same on both widths) and
starts the child’s first thread there. The stub’s link.x fixes that origin, limits the stub to
256 KiB, and fails the build if _start is not its first byte or if the stub has any writable
data. STUB_ENTRY is a constant of the stub crate, shared by the stub and every launcher, not
a kernel constant: process_map’s destination is the launcher’s choice.
Programs link at 0x1_0000. The stub refuses, before it maps anything, a segment that touches
page 0, that ends past STUB_ENTRY, or that overlaps the stub’s region, the startup block or the
image bytes it came from. The child then exits with the stub’s bad-image code, and nothing else
is affected. That leaves just under 512 MiB of link range, and MAX_IMAGE_LEN (0x1FF0_0000)
caps the image the startup block names at the same size.
Launcher placement
Status: built · tested: bench:stub-launch
A launcher places the startup block and the child’s stack outside the link range. Every launcher
takes the placement from one definition beside STUB_ENTRY in the stub crate
(stub/src/lib.rs): the stack top at STACK_TOP (0x8000_0000), the
startup block at STARTUP_AT (0x7FF0_0000) and the copy of the ELF image at IMAGE_AT
(0x4000_0000). The client launcher accepts at most MAX_STACK_PAGES (128) stack pages, so even
its lowest stack page is above the startup block with unmapped pages between them. The stub cannot
see the stack. With the stack outside the link range, no segment of an honest image can meet it;
a hostile segment that names the stack’s pages is still refused by map_fixed, which never
replaces a mapping (R11). The stub does not check for a gap between a
segment and the stack; see Residual risks.
Sv32 and Sv39 compared
Status: built · tested (4)
- bench:wx
- bench:touch-beyond-ram
- bench:stub-launch
- bench:map-fixed-tables
The two modes share the low ten entry bits, and the physical page number starts at bit 10 in
both. So one usize-sized Pte type and one flag set (PteFlags) serve both widths, and the
kernel’s page-table code is written in terms of LEVELS, vpn() and leaf_size() and is the
same source for both. Only these differ, as cfg(target_pointer_width) constants in paging:
| Sv32 (rv32) | Sv39 (rv64) | |
|---|---|---|
| levels | 2 | 3 |
| entries per table | 1024 | 512 |
| VPN bits per level | 10 | 9 |
| entry width | 4 bytes | 8 bytes |
| leaf sizes | 4 KiB, 4 MiB | 4 KiB, 2 MiB, 1 GiB |
satp mode | bit 31 | 8 << 60 |
satp root PPN | bits 0-21 | bits 0-43 |
| canonical addresses | all | bits 63-38 all equal |
USER_AREA_END | 0x8000_0000 | 0x40_0000_0000 |
User mappings are always 4 KiB leaves; only the physmap uses superpages. On Sv39 the table below a level-1 entry is named by its address, not by its index, because an index recurs in every gigabyte: counting missing tables by index would undercount a range that crosses one (R22 (range cost)). Sv32 has one level below the root, so the question does not arise.
satp and page-table entries
satp
Status: built · partly tested: QEMU’s TLB is not tagged by ASID, so a missing flush shows only through the checked build’s audit; one hart is argued from the code · tested: bench:asid-reuse-stale, bench:pid-reuse-authority, bench:smp-evict, bench:uaf-lent-page, host:paging::satp_round_trips_on_both_layouts, host:paging::each_flush_covers_what_the_spec_says
satp holds the mode, the root table’s physical page number and the ASID (SatpLayout::make), and
a process’s ASID is its PID. MAX_PROCESS_COUNT is below 2 to the power of ASID_BITS, 9 in
Sv32 and 16 in Sv39, and PID 0 is never a process, so every PID fits the field with no table
between them. A compile-time assert holds it on each width, and the boot refuses a hart whose
field is narrower, found by writing ones to it. The kernel is PID 1; the loader numbers boot
processes from 2 and names each one’s PID in a field of its own in the handoff record
(boot), and the kernel writes each one’s ASID when it takes them over. Each hart’s
record of the PID it runs is in its per-hart block, read as current_pid and set whenever that
hart switches address space (set_current_pid).
The kernel runs in whichever address space was current when it trapped, because every address
space maps the kernel half. The kernel half’s shared leaves are global (G), so no ASID flush
drops them. Switching process writes satp and flushes nothing: a process’s cached translations
carry its ASID and wait for its next turn. A change to a leaf flushes that address in that
process’s ASID, running or not; a change to a table pointer flushes the process’s whole ASID; a
change to a kernel-half leaf flushes that address in every ASID. A PID’s ASID is flushed whole
when the PID is given out, before it first runs, and a dying process’s space is left before any
of its frames is freed. A user access that faults although the current space’s leaf allows it
met an entry this hart cached before another hart changed it: the kernel flushes that address in
the ASID and runs the instruction again, once. A fault the kernel takes itself is never retried
so: the kernel reaches no user mapping (R24), and its own fault is a
kernel failure. A checked build logs every page-table write and stops if one is
unflushed when it returns to user mode. On QEMU every sfence.vma and every satp change
empties the whole TLB, so the cases check the kernel’s flushes through the checked build’s log,
not through a stale translation; the TLB’s reuse across switches shows only on hardware
(the FPGA platform). The loader enters the kernel through satp
too: it points stvec at the kernel’s entry and writes satp; the next fetch, from the loader’s
own unmapped address, faults, and the trap lands in the kernel with the arguments still in
registers.
Entry bits
Status: built · tested: bench:wx, bench:map-fixed-attack, bench:kernel-wx
packet-beta title a page-table entry, Sv32's 32 bits. Sv39 carries the PPN on to bit 53 and keeps bits 54 to 63 zero 0: "V" 1: "R" 2: "W" 3: "X" 4: "U" 5: "G" 6: "A" 7: "D" 8: "S" 9: "P" 10-31: "PPN"
Figure: a page-table entry, the same low ten bits on both widths.
| Bit | Name | How Redoubt uses it |
|---|---|---|
| 0 | V valid | set on every live mapping and every pointer to a next-level table |
| 1-3 | R, W, X | a leaf has at least one. Never W with X, never W without R (R11): Pte::leaf refuses both, and a mapping call’s flags are checked before that |
| 4 | U user | on every user-half leaf of a process; on no kernel-half entry |
| 5 | G global | on the kernel’s shared leaves: the physmap, the kernel image, the stacks, the PLIC and the DMA register window; never in the user half or on the per-process entry |
| 6-7 | A, D | set when the entry is made, because not every hart updates them in hardware; Redoubt never reads them |
| 8 | S lent | software bit: see below |
| 9 | P | software bit, never set |
A non-leaf entry is V alone. An entry with permissions and no V is a reservation: the
page is backed with a zeroed frame on first touch. A permission fault on a valid page ends the
process; it never counts as a page that wants backing.
The lent bit
Status: built · tested (5)
- bench:return-lent-unmapped
- bench:lender-touches-lent
- bench:move-borrowed-page
- bench:map-fixed-attack
- bench:uaf-lent-page
A lend is recorded in the page tables themselves, with the software bit S:
V | S | Meaning |
|---|---|---|
| 1 | 0 | an ordinary mapping |
| 0 | 1 | the lender’s entry while the page is lent: no access, and the entry keeps the frame, so it is the loan’s only record |
| 1 | 1 | the borrower’s alias: normal loads and stores, but no call may unmap it, change its flags, pass it on, or use it as a system-call record |
| 0 | 0 | empty, or a reservation |
Lending clears V and sets S on the caller’s entries; the pages are mapped into the server,
with S, only when it takes the call. A reply clears the server’s entries and restores V on the
caller’s, checking first that both still name the same frame. A transfer is mapped into its
receiver without S: the page is simply the receiver’s. Only the end of the loan changes a
lender’s entry: a reply restores it, and a transfer or an abandoned call removes it. No new
mapping, reservation or unmap overwrites it (I9 (pages W^X, zeroed, lends unmapped);
R3 (lends and abandoned calls)).
Security properties
R24 (SUM and MXR clear)
Status: built · partly tested: no probe covers MXR (Residual risks); the firmware already enters S-mode with both bits clear, so no case shows the kernel’s own clear doing work · tested: bench:sum-clear
The kernel clears sstatus.SUM and sstatus.MXR at entry, in _start before any Rust runs,
and never sets either. So S-mode cannot load or store through a user mapping, and a stray kernel
dereference of a user address faults. MXR is included because with it set, kernel loads could
read pages that are execute-only. The kernel reaches user memory only by walking the caller’s
tables and copying through the physmap, so no path needs either bit. A boot assertion checks
both are clear. A test build (sum-probe) loads the first caller’s ecall straight through its
user address, and the load faults as a kernel failure.
Residual risks
- The physmap has a writable alias of user code. Every user page, code included, is also mapped writable in the physmap. Only the kernel can use that mapping, but a kernel bug that writes through a wrong address can change any process’s code. The kernel’s own code has no writable alias (R19).
- The kernel can address all RAM. A kernel arbitrary-write bug is not limited to the pages the current process maps. On Sv39 the physmap also covers the physical range below RAM, where device registers sit, as ordinary kernel read-write memory; the kernel never uses those addresses, but a stray write through them reaches a device.
- A flush acts on this hart only. Each change flushes its address or its ASID on the hart that makes it. Another hart that ran the process flushes that ASID before it runs the process again, and one running it now is shot down first, at every removal from the process and at its destruction (memory).
- QEMU cannot show a missing flush. Its TLB is not tagged by ASID, and it empties it on every
sfence.vma, whatever its operands, and everysatpchange, so the bench finds a missing flush only through the checked build’s audit. - The firmware must delegate instruction page faults to S-mode. Kernel entry from the loader
and a thread’s return to
EXIT_THREADare both instruction page faults the kernel must take. The vendored RustSBI delegates them; with a firmware that did not, the boot would never reach the kernel. The firmware is in the TCB (boot). - No probe covers
MXR. Thesum-clearprobe loads from a readable user page, so it showsSUMis clear, notMXR: a setMXRwould let a kernel load read an execute-only page, and only the boot assertion would notice (R24). - No guard gap between a segment and the stack. The stub checks segments against the stub, the startup block and the image, not against the stack, and the kernel refuses only an overlap. A launcher that puts the stack inside the link range can get a child whose data ends right at its stack’s bottom, so a stack overflow writes the data instead of faulting. The launcher convention above prevents it; nothing enforces it.
- No ASLR. Every address on this page is fixed, and
map_anonplaces pages deterministically. A memory-safety bug in a program is easier to exploit, within that program’s own process. Address randomisation is beyond M6.
Why
- A direct physical map, not a recursive window. Mapping the page tables into themselves
works with two levels but not three. With every frame at a fixed address, a table walk is a
plain software walk, creating a process needs no map-fill-unmap dance, and the kernel edits
another process’s tables without switching
satp. The cost is that all RAM is kernel-addressable (Residual risks). - Split by root entry. Sharing the kernel half is copying some root entries. Because the loader makes every kernel-half table before the first user root exists, and the kernel never adds a root entry, no kernel mapping ever has to be pushed into existing address spaces.
- Page 0 in user space,
SUMclear. The null-page attack needs the kernel to dereference a pointer that user space controls. The Redoubt kernel never does: it walks the caller’s tables and copies through the physmap, and withSUMclear any direct supervisor access to a user page faults. So a mapped page 0 can harm only its own process, and refusing it would be a rule with no wall behind it. - The stub at a fixed address, high in the link range. Every launcher and the stub agree without a kernel constant, the kernel stays ignorant of ELF, and programs keep just under 512 MiB to link into, so large programs need no special base.
- The same user addresses on both widths. One convention serves rv32 and rv64 programs and their launchers. Sv39’s extra user space stays free for programs that ask for it by address.
- The lent bit in the entry. The lender’s own entry is the record of the loan, so the kernel keeps no separate table of loans that could disagree with the page tables, and a teardown finds every lent-out frame by walking them.
System call reference
A process reaches the kernel in one way: an ecall with a call number in a0 and its
arguments in a1-a7. This page is the reference for that interface: the register layout,
which is the same on rv32 and rv64; every call’s number, arguments and result; the records that
carry what does not fit in registers; the error codes; and the exact order in which the kernel
checks a call’s arguments. The crate redoubt-sys (libs/sys) is the encoding, shared by the
kernel and every process; what each call does is explained on the page linked from its row.
Purpose
The system call interface is the whole of the kernel’s surface as a process sees it. Every action an agent takes, hostile or not, arrives as eight registers and perhaps a record. So the interface has three jobs: give every value exactly one encoding, so nothing can hide in a register the kernel ignores; refuse every malformed value with an error before the kernel acts on it (I14 (no call panics the kernel)); and report the same first error that the executable model reports, so a trace means the same thing to both.
One register layout on both widths
Status: built · partly tested: that the kernel preserves the registers outside `a0`-`a7` is not attacked by a case · tested (4)
- host:redoubt-sys::every_call_round_trips
- host:redoubt-sys::every_result_and_error_round_trips
- host:redoubt-sys::random_registers
- bench:budget-syscall-attack
A call is an ecall from user mode with a0 = the call’s number and its arguments in a1,
a2, … in the order its row lists them. The kernel answers in the same eight registers: a0
= 0 and the result in a1 onward, or a0 = an error code and a1-a7 = 0. call is the one
exception: its lend and reply dispositions are in a1 and a2 on every return, errors
included (IPC). The kernel preserves every other register of the
thread.
No register ever holds more than 32 bits or one usize, so rv32 carries every encoding and
redoubt-sys has no width cfg in any of them. USER_AREA_END, the end of user space
(memory layout), is its one per-width constant.
| Argument kind | Registers | Rule |
|---|---|---|
address, length, word (usize) | 1 | |
64-bit value (ids, badges, accounts, time, random’s value, a timeout) | 2: low half, then high half | each half fits in 32 bits |
small value (u32: tid, pid, exit code, count) | 1 | fits in 32 bits |
| mapping flags | 1 | only READ (1), WRITE (2), EXECUTE (4); never WRITE with EXECUTE |
| handle | 1 | an index, 1 to 2^32 - 1 |
| optional handle | 1 | 0 is none (index 0 is never allocated) |
| message id, badge | 2: low half, then high half | never 0 as an argument |
| optional page run (lend, transfer) | 2: address, then pages | (0, 0) is none; exactly one of them 0 is InvalidArgument |
tag (reset kind, mint source) | 1 | numbered from 1; 0 is never a valid tag |
Registers a call does not use must be 0. Timeouts are relative microseconds; the kernel
adds one to the current time with saturation, so FOREVER (2^64 - 1) never wraps and never
expires (timer, I13 (every blocking call returns by its timeout)). A budget
deadline is absolute, in microseconds since boot, and FOREVER means none.
| Register | ecall: call | return from call |
|---|---|---|
a0 | 0x10e, the number of call | 0, or the error code |
a1 | endpoint handle | lend: 0 none, 1 returned, 2 consumed |
a2 | body record address | reply: 0 absent, 1 present |
a3 | lend address, 0 for none | 0 |
a4 | lend pages, 0 for none | 0 |
a5 | timeout, low 32 bits | 0 |
a6 | timeout, high 32 bits | 0 |
a7 | 0, unused | 0 |
Table: register use for call and its return. The 64-bit timeout takes two registers on both widths. |
On the process’s side a call travels through a transport (redoubt_sys::Transport): on the
machine Ecall, the ecall itself (the crate’s only unsafe code), whose result
redoubt_sys::decode_result reads; the runtime takes whichever transport is installed, the
ecall by default. decode_result refuses any result the kernel could not have produced: an
unknown code, a non-zero register the result does not use, a value too wide for its field, and for
call and reply a disposition that contradicts the status (IPC).
Call numbers and arguments
Status: built · tested: host:redoubt-sys::every_call_round_trips, host:redoubt-sys::numbers_and_codes_are_dense_from_one, host:redoubt-sys::malformed_calls_are_refused
A call’s number is NUMBER_BASE (0x100) plus its place in the table, from 1. The numbers are
dense, and a number once given is never reassigned: a call added to the table takes the next
one. “(2)” marks a 64-bit value in two registers. A field named ..._rec is the address of a
record.
a0 | Call | Arguments, from a1 | Result, from a1 | Explained in |
|---|---|---|---|---|
| 0x101 | map_anon | len, flags | address | memory |
| 0x102 | unmap | address, len | - | memory |
| 0x103 | set_flags | address, len, flags | - | memory |
| 0x104 | map_device | device handle | address, length | devices |
| 0x105 | dma_alloc | device handle, pages | address, physical address (2) | devices |
| 0x106 | thread_create | entry, stack pointer, argument | TID | processes |
| 0x107 | thread_exit | - | does not return | processes |
| 0x108 | process_exit | code | does not return | processes |
| 0x109 | process_create | budget handle, exit endpoint handle | process handle | processes |
| 0x10a | process_map | process handle, source, destination, len, flags | - | processes |
| 0x10b | process_start | process handle, entry, stack pointer, argument, handles_rec, count | - | processes |
| 0x10c | endpoint_create | - | endpoint handle (the receive right, badge 0) | objects |
| 0x10d | mint | source tag (1 message id, 2 handle), source value (2), badge (2), budget handle or 0 | handle | objects |
| 0x10e | call | endpoint handle, body_rec, lend address, lend pages, timeout (2) | a0 status; lend and reply dispositions | IPC |
| 0x10f | send | endpoint handle, body_rec, transfer address, transfer pages, timeout (2) | - | IPC |
| 0x110 | receive | endpoint or IRQ handle or 0, timeout (2), max_transfer (pages), received_rec | a receive record | IPC |
| 0x111 | reply | message id (2), body_rec | delivered (1) or discarded (0), installed-handle mask | IPC |
| 0x112 | serve | message id (2) | - | IPC |
| 0x113 | handle_close | handle | - | objects |
| 0x114 | budget_create | parent budget handle, spec_rec | budget handle | budgets |
| 0x115 | budget_destroy | budget handle | -, or does not return if the caller’s own budget is in the subtree, or its process object is charged there | budgets |
| 0x116 | budget_usage | budget handle, usage_rec | a usage record | budgets |
| 0x117 | time_now | - | microseconds since boot (2) | timer |
| 0x118 | random | - | one value from the kernel’s CSPRNG (2) | boot |
| 0x119 | system_reset | Reset device handle, kind (1 power off, 2 reboot) | does not return on success | devices |
| 0x11a | map_fixed | address, len, flags | - | memory |
| 0x11b | device_info | device handle | kind (1 MMIO, 2 IRQ, 3 Reset), a (2), b (2), flags: the device’s Devs entry | devices |
| 0x11c | budget_reap | budget handle | the children the budget still has, or does not return if the caller’s own budget is in the reaped child’s subtree, or its process object is charged there | budgets |
| 0x11d | console_hold | console MMIO device handle, hold (1 take, 2 give back, 3 give back with the caller’s line unfinished) | a take: 1 if a kernel line ended the caller’s unfinished line since it last gave the hold back, else 0; a give-back: 0 | devices |
thread_create’s and process_start’s argument reaches the new thread unchanged in its first
argument register; the kernel does not check it, nor the entry or stack pointer. A usize
result (an address, a length) is one register; a handle or TID is one register holding at most
32 bits. device_info’s a and b are 64-bit on both widths, as in the Devs entry, because
an Sv32 physical address has 34 bits; a field its kind does not use is 0
(boot).
Records
Status: built · tested (8)
- host:redoubt-sys::records_round_trip
- host:redoubt-sys::malformed_records_are_refused
- host:redoubt-sys::random_records
- fuzz:redoubt-sys/decode
- bench:budget-syscall-attack
- bench:ipc-outcomes
- bench:process-attack
- bench:map-fixed-attack
Layouts
A record is what does not fit in seven registers: a fixed-length array of 64-bit
little-endian slots in the caller’s memory, passed by address, at an 8-byte-aligned
address. Slot n is at byte 8n. The layout is the same on both widths. Unused slots must be
0. A usize field must fit the target’s usize: on rv32 a slot above 2^32 - 1 there is
InvalidArgument. A list (labels, handles) is a count, then its capacity’s slots, the unused
ones 0.
| Record | Slots | Layout | Used by |
|---|---|---|---|
| body | 9 (BODY_SLOTS) | words 0-3; handle count (at most MAX_MSG_HANDLES (4)); handles 0-3 | call (request in, reply out), send, reply |
| receive record | 32 (RECEIVED_SLOTS) | below | receive (out) |
| budget spec | 22 (BUDGET_SPEC_SLOTS) | pages; processes; weight; label count (at most MAX_LABELS (16)); labels 0-15; account; deadline | budget_create (in) |
| usage | 6 (USAGE_SLOTS) | page limit; pages used; process limit; processes used; weight limit; weight carved to children | budget_usage (out) |
| handle list | the call’s count, at most MAX_START_HANDLES (128) | one handle per slot | process_start (in) |
In a body going in, every handle within the count is a handle; a slot of 0 there is
BadHandle. In a body coming out (the reply call writes back over its request, and a
message’s body), a handle slot within the count may be 0 and keeps its place: a handle revoked
while its message was queued (R10 (destruction)), or a reply
handle the caller could not take (R4 (delivery)).
| Slot | Body: call, send, reply | Slot | Receive record: what receive writes |
|---|---|---|---|
| 0 to 3 | words 0 to 3 | 0 | kind |
| 4 | handle count, 0 to 4 | 1 | msg_id |
| 5 to 8 | handles 0 to 3 | 2 | badge |
| 3 | account | ||
| 4 | label count, 0 to 16 | ||
| 5 to 20 | labels 0 to 15 | ||
| 21 to 24 | words 0 to 3 | ||
| 25 | handle count, 0 to 4 | ||
| 26 to 29 | handles 0 to 3 | ||
| 30 | buffer address | ||
| 31 | buffer pages | ||
| *Table: record slot layout of a body and of the receive record, the same on rv32 and rv64. Slot n | |||
| is at byte 8n, and every unused slot is 0.* |
The record check
Before a call uses a record, the kernel checks it whole: the address is 8-byte aligned, and
every slot lies in user space, in a page that is mapped, backed, readable (and writable, for a
record the kernel writes), not either side of a lend, RAM, and credited to the caller in the
kernel’s frame ownership table. Anything else is InvalidArgument. A page the caller was lent
is its lender’s, so it is not a record; nor is a device mapping, which is not RAM. call’s body
is checked readable and writable before anything is delivered, because the reply comes back
into it (R13 (one outcome per call)).
The check never allocates. A page the caller reserved but never touched is
InvalidArgument, not a page the kernel backs and charges in the middle of decoding. The Rust
runtime’s records are written stack arrays (libs/rt/src/sys.rs, Record, 8-byte aligned),
so every record it passes is backed.
A record may overlap the pages a call acts on. The kernel copies a record in before it changes
those pages, and writes results only to memory still the caller’s: a call body may lie
inside its own lend, and the lend is the caller’s again before the reply is written into it.
The kernel holds its memory lock from the check through the copy, so no other thread of the
process can unmap or remap a record between the two. A record the kernel writes after a call
has blocked (receive’s, or call’s reply) is checked again at that point
(IPC).
The receive record
Status: built · tested (4)
- host:redoubt-sys::received_layout
- host:redoubt-sys::malformed_records_are_refused
- host:redoubt-sys::random_records
- fuzz:redoubt-sys/decode
receive writes one layout for every result: (kind, msg_id, badge, account, labels, words, handles, buffer, pages), in the slots of the figure above. Slot 0 is the kind; a field the kind
does not use is 0. What each kind means is on the IPC page.
| Kind | Slot 0 | Slots it fills |
|---|---|---|
call | 1 | all; the buffer is the lend, at the address the kernel chose |
send | 2 | all; the buffer is the transfer |
interrupt | 3 | none |
exit | 4 | 3 (blamed account), 4-20 (blamed labels), 21-23 (pid, cause, code) |
abandoned | 5 | 1 (the abandoned call’s message id) |
An exit’s cause is 1 exited, 2 faulted, 3 killed (processes).
Timeout is an error, not a record. A record carries no handle kinds: a handle’s kind is
checked when it is used, and the wrong kind is WrongObject.
Received::decode reads the kind first, and refuses any non-zero slot outside the fields that
kind fills, a list’s count included, before it reads a field. So a process decoding a record
never mistakes a notice for a message.
Errors and their codes
Status: built · tested (4)
- host:redoubt-sys::numbers_and_codes_are_dense_from_one
- host:redoubt-sys::every_result_and_error_round_trips
- host:redoubt-sys::error_rows
- bench:budget-syscall-attack
One error enum serves every call. Its code travels in a0; 0 there is success, so codes start
at 1.
| Code | Error | Means |
|---|---|---|
| 1 | BadHandle | a handle that is 0 where one is required, wider than 32 bits, or not in the caller’s table |
| 2 | WrongObject | the handle names the wrong kind of object for this call |
| 3 | InvalidArgument | a malformed or out-of-range value: an unknown number, tag or flag bit, a bad range, a bad record, a message id that is not an open call |
| 4 | OutOfMemory | the paying budget’s page limit, or no room in the address space, no free frame, no free DMA run |
| 5 | OutOfProcesses | a budget’s process limit |
| 6 | TooManyThreads | the process has MAX_THREADS (255) threads |
| 7 | NotPermitted | the right object, without the standing: a badged handle where badge 0 is needed, a started process, a mint budget outside the default stamp, dma_alloc on a device without DMA |
| 8 | ClassDenied | labels added by a caller whose budget is not of class system |
| 9 | LabelDenied | a flow R1 (flow) forbids |
| 10 | Busy | the sender’s group already has WAIT_CAP (32) messages queued on the endpoint (R2 (fair waiting)) |
| 11 | Refused | the receiver’s budget cannot pay for the message (R4) |
| 12 | TooLarge | a count over its fixed limit: body handles, labels, the start list, a lend over MAX_LEND_PAGES (16), budget depth, MAX_HANDLES (4096) |
| 13 | Timeout | a blocking call’s timeout passed |
| 14 | Dead | the object the call needs is gone: its endpoint was destroyed, its server died, or the handle it came through was revoked |
Each call can return only the errors of its row in the table below, plus InvalidArgument for
a malformed encoding; Number::can_return in redoubt-sys is that set. The kernel checks every
error it returns against it in checked builds, so a case built with debug assertions fails on
an error outside its row.
Inside the kernel, the page tables and the frame ownership table report failures in their own
enum (PageError in kernel/src/mem.rs), which never reaches a process. Each call maps it to
the error its row names, explicitly, at the call’s boundary; there is no automatic conversion.
So a lent page and an exhausted budget, which the page layer tells apart, cannot collapse into
one wrong code on their way out.
Errors and the order of checks
Status: built · partly tested: for most rows the order after decoding is argued from the code rather than pinned by a case; the model follows the kernel's order, but no trace has been replayed on the kernel (Residual risks) · tested (15)
- bench:budget-syscall-attack
- bench:syscall-attack
- bench:ipc-outcomes
- bench:process-attack
- bench:process-map-untouched-attack
- host:redoubt-sys::malformed_calls_are_refused
- host:redoubt-model::every_call_and_error_is_reached
- host:redoubt-model::process_map_destination_validation_precedes_started_state
- host:redoubt-model::a_budget_spec_decodes_in_slot_order
- host:redoubt-model::receive_clears_the_current_call_before_its_record_check
- host:redoubt-model::every_page_of_a_record_is_checked
- host:redoubt-model::a_device_has_32_dma_runs
- host:redoubt-model::map_anons_placement_area_is_256_mib
- host:redoubt-model::map_fixed_refuses_pages_it_cannot_pay_for_before_an_overlap
- fuzz:redoubt-sys/decode
A call with several faults returns the first one found, in a fixed order, so that the kernel, the model and a replayed trace agree exactly. Checks go in stages, and within a stage by argument position:
- Decoding. The registers in order,
a1first; then each record the call passes: its alignment, then the record check slot by slot, then its slots in order. A required handle that is 0 or wider than 32 bits isBadHandle; a list longer than its limit isTooLarge; anything else malformed isInvalidArgument(an unknown call number, tag or flag bit, W+X flags, a value too wide for its field, a non-zero unused register or slot, a record that fails the check, a message id or badge of 0). These three are a classification, not a sequence: the first malformed value in register, then slot, order wins. Each register is checked whole when it is reached; unused registers come after the last argument. This stage never allocates, so it never returnsOutOfMemory. - Arguments, one by one: a handle exists (
BadHandle) and names the right kind of object (WrongObject); a size is within its fixed limit (TooLarge); a range is page-aligned, non-empty, inside user space and mapped as the call needs (InvalidArgument). - Permission:
NotPermitted,ClassDenied,LabelDenied. - Resources:
OutOfMemory,OutOfProcesses,TooManyThreads,Busy. A call that adds a handle to its caller’s table (endpoint_create,mint,process_create,budget_create) checks the table last:OutOfMemorywhen it needs a new table page the caller’s budget cannot pay for,TooLargewhen it already holdsMAX_HANDLES. - At delivery (
call,send,receive, whether they blocked or not):Refused,Timeout,Dead; forcallalsoInvalidArgument(the reply record can no longer be written) andOutOfMemory(reply handles that do not fit); forreceivealsoInvalidArgument(its record can no longer be written). Decoding already refused records that were bad from the start; these cover memory that changed while the call waited.
Handle kinds are checked by use: no call reports a handle’s kind, and the wrong kind is
WrongObject at the first call that needs another. Two checks of stage 1 are made again by the
code that acts on them, so neither rests on one check: W+X flags (by the mapping code,
R11 (memory)) and a mint badge of 0 (by mint, I3 (minted badges are non-zero and narrow)).
Per call, in the order checked. “Then” lists stages 2 to 5; “each handle” is every handle of a list, in order.
| Call | Decoding | Then |
|---|---|---|
map_anon | flags: InvalidArgument | InvalidArgument (len 0 or not page-aligned), InvalidArgument (flags 0, or W without R), OutOfMemory (no room in the placement area; then each page and its page tables) |
unmap | - | InvalidArgument (range: len 0, not page-aligned, wraps, or past USER_AREA_END), InvalidArgument (a page that is not a live mapping of the caller’s, is either side of a lend, or is RAM not credited to the caller; the whole range before any page goes) |
set_flags | flags: InvalidArgument | InvalidArgument (range), InvalidArgument (flags 0), InvalidArgument (a page not the caller’s own mapping, as for unmap, or EXECUTE on a page of device registers or a dma_alloc page; the whole range before any page changes), InvalidArgument (W without R) |
map_device | device: BadHandle | BadHandle, WrongObject (not an MMIO device), OutOfMemory (no room in the placement area, or the page tables) |
dma_alloc | device: BadHandle | BadHandle, WrongObject (not an MMIO device), InvalidArgument (pages 0), NotPermitted (the device has no DMA flag), OutOfMemory (all 32 of the device’s runs in use; then the pages; then no contiguous frames; then the placement area and page tables) |
thread_create | - | TooManyThreads, OutOfMemory (the thread’s page) |
thread_exit | - | - |
process_exit | code wider than 32 bits: InvalidArgument | - |
process_create | each handle: BadHandle | BadHandle, WrongObject (budget), BadHandle, WrongObject (exit endpoint), InvalidArgument (the budget’s free weight is 0), NotPermitted (the exit endpoint’s badge is not 0), OutOfProcesses (the budget’s process limit, which the new PID counts against; with room there a free PID exists), OutOfMemory (the budget: page tables; then the caller: the process object), handle table |
process_map | process: BadHandle; flags: InvalidArgument | BadHandle, WrongObject, InvalidArgument (source or destination range), InvalidArgument (flags 0, or W without R), InvalidArgument (a source page unmapped, lent, not the caller’s own RAM, or a dma_alloc page; untouched source pages are counted, and backed once nothing can refuse the call; the caller’s budget too short to back them is this error), NotPermitted (the process has ended), InvalidArgument (a destination page occupied), NotPermitted (started), OutOfMemory (the child’s budget: page tables, and the pages, or only the untouched source pages when the budget is the caller’s) |
process_start | process: BadHandle; entry, stack pointer, argument: not checked; count over MAX_START_HANDLES: TooLarge; handle list: the record, then each slot BadHandle | BadHandle, WrongObject, BadHandle (each handle), NotPermitted (started or ended), TooLarge (the child’s table past MAX_HANDLES), OutOfMemory (the child’s budget: its first thread and table pages) |
endpoint_create | - | OutOfMemory (the endpoint’s page), handle table |
mint | source: each half too wide InvalidArgument (both halves are read before the tag), then an unknown tag InvalidArgument, a message id of 0 InvalidArgument, or a handle of 0 or wider than 32 bits BadHandle; badge 0: InvalidArgument; budget: BadHandle | source: a message id that is not an open call of the caller’s thread (a send’s id included) InvalidArgument, its endpoint or stamp gone Dead; or a handle BadHandle, WrongObject (not an endpoint); budget: BadHandle, WrongObject; NotPermitted (a handle source’s badge is not 0), NotPermitted (the budget is not the default stamp or below it), handle table |
call | endpoint: BadHandle; lend: exactly one of address and pages 0 InvalidArgument; body: the record (read and written), handle count TooLarge, each handle slot BadHandle | BadHandle, WrongObject (not an endpoint), BadHandle (each handle), TooLarge (lend over MAX_LEND_PAGES), InvalidArgument (lend not page-aligned, wraps, unmapped, lent, not the caller’s own writable RAM, or a dma_alloc page; untouched lend pages are backed first), LabelDenied (R1), Busy (R2); at delivery: Refused (R4), Timeout, Dead, InvalidArgument (the reply record cannot be written; reply handles rolled back, reply absent), OutOfMemory (reply handles that do not fit; the reply arrives without them, R4). Dispositions on every return |
send | as call, with the transfer for the lend and the body read only | as call, with no fixed limit on a transfer; at delivery only Refused (R4, a transfer over the receiver’s max_transfer included), Timeout, Dead |
receive | handle (0 is none): BadHandle; the record (written) | none given: sleep until Timeout; BadHandle, WrongObject (not an endpoint or an IRQ device), NotPermitted (badge not 0, I4 (only badge-0 handles receive)); at delivery: Timeout, Dead (endpoint destroyed), InvalidArgument (the record can no longer be written) |
reply | message id 0: InvalidArgument; body: the record (read), handle count TooLarge, each handle slot BadHandle | InvalidArgument (not an open call of the caller’s thread, a send’s id included), BadHandle (each handle); nothing after these fails |
serve | message id 0: InvalidArgument | InvalidArgument (not an open call of the caller’s thread) |
handle_close | handle: BadHandle | BadHandle |
budget_create | parent: BadHandle; spec: the record (read), then its slots: processes or weight wider than 32 bits InvalidArgument, label count over MAX_LABELS TooLarge, a label slot past the count non-zero InvalidArgument | BadHandle, WrongObject, TooLarge (the child would be at depth MAX_DEPTH (16)), LabelDenied (the labels, sorted and deduplicated, are not a superset of the parent’s), ClassDenied (labels added by a caller not of class system), OutOfMemory (pages plus the budget’s own page over the parent’s free pages), OutOfProcesses (over the parent’s free processes), InvalidArgument (weight over the parent’s free weight, or all of it from a parent that holds a process; R7 (carving)), OutOfMemory (no free frame for the budget object), handle table |
budget_destroy | budget: BadHandle | BadHandle, WrongObject |
budget_usage | budget: BadHandle; counters: the record (written) | BadHandle, WrongObject, LabelDenied (R1: a caller not of class system whose labels do not include the target’s) |
time_now | - | - |
random | - | - |
system_reset | device: BadHandle; kind: InvalidArgument | BadHandle, WrongObject (not the Reset device) |
map_fixed | flags: InvalidArgument | InvalidArgument (range: address or len not page-aligned, len 0, wraps, or past USER_AREA_END), InvalidArgument (flags 0, or W without R), OutOfMemory (the pages alone), InvalidArgument (overlaps any mapping or reservation of the caller’s, lent pages included), OutOfMemory (the pages and the page tables they need); nothing mapped or charged on failure |
device_info | device: BadHandle | BadHandle, WrongObject (not a device object); nothing after these fails |
budget_reap | budget: BadHandle | BadHandle, WrongObject; nothing after these fails (a budget with no children returns 0) |
console_hold | device: BadHandle; hold: InvalidArgument | BadHandle, WrongObject (not the console’s MMIO device), Busy (another process holds it); giving back a hold the caller does not have changes nothing |
Every call can also fail decoding in the general ways of stage 1 (a non-zero unused register, a
value too wide). “Handle table” is stage 4’s last check. Three rows depart from the stages, and
say so: a weight over the parent’s free weight is InvalidArgument, because no error names
weight; mint from a message whose endpoint or stamp is gone is Dead at the argument stage;
and receive clears the thread’s current call as soon as its registers decode, before its
record check and every later stage, whatever it then returns
(R21 (crash blame)). A receive refused while its registers
are decoded (a handle wider than 32 bits, a non-zero unused register) leaves the current call
as it was.
map_fixed’s order keeps its cost bounded: the charge for the pages alone comes before any
walk, so a huge length the budget could never pay for is refused by arithmetic, and the overlap
walk that follows skips page-table subtrees that are absent
(R22 (range cost)).
Unknown call numbers
Status: built · tested (4)
- bench:legacy-gone
- bench:budget-syscall-attack
- host:redoubt-sys::malformed_calls_are_refused
- host:redoubt-sys::numbers_and_codes_are_dense_from_one
Every value of a0 outside 0x101-0x11d is an unknown number: InvalidArgument in a0 and 0 in
a1-a7. That includes 0, every number up to and including NUMBER_BASE, the first number
past the table, and on rv64 a value with bit 32 or bit 63 set: the whole register is compared,
never a truncated part of it. An unknown number carries no call dispositions, and nothing
happens.
The trap handler sends every user-mode ecall to one decoder, redoubt::handle
(kernel/src/redoubt.rs); there is no second dispatcher. The kernel’s scheduler enters its own
trap handler with a private tag in a0 (SWITCH_TAG, kernel/src/sched.rs), by setting up the
state of a supervisor-mode ecall and jumping to the trap vector
(kernel/src/arch/riscv/syscall.rs); from user mode that value is an unknown number like any
other.
bench:legacy-gone sweeps every number from 0 to NUMBER_BASE and the first past the table,
with plausible arguments in a1-a7, and on rv64 each small number again with bit 32 and with
bit 63 set. It checks every result is InvalidArgument with zeros, and that nothing such a call
might ask for happened: no page appears at a fixed address, no callback runs when the console’s
interrupt fires, and a child jumping to a fixed kernel return address faults.
Residual risks
- No trace has been replayed on the kernel. The table above is the kernel’s, and the model
follows it: host tests pin the rows where the two once differed (
budget_create’s spec,receive’s current call, every page of a record,dma_alloc’s runs and the placement areas). A difference nobody has read for is found only by replaying traces on the kernel (model). - The order after decoding is mostly argued from the code. Cases pin the order of
decoding, and of the first checks after it for
budget_create,budget_usage,call,receive,serveandprocess_start; most later positions in most rows are read from the kernel, not attacked. A wrong order is a replay mismatch, not a way past a check: every check in a row is still made. - An error may leave a page backed. Stage 1 never allocates, but stage 2 backs untouched
pages of a lend or a transfer before later checks run; if the call then fails (
LabelDenied,Busy), those pages stay backed and charged to the caller, as if it had touched them (memory).
Why
- One layout on both widths. A 64-bit value always takes two registers, so no encoding has a
width
cfgand one set of tests covers both targets. It costs rv64 an extra register for each 64-bit value. - One encoding per value. Unused registers and slots must be 0, and (0, 0) is the only “none”, so a malformed value cannot hide where the kernel does not look, and whatever decodes re-encodes to exactly its input (the fuzz target checks it). A trace of registers then means one thing.
- Records are slots, not structs. An array of
u64slots has one layout on both widths and no padding, and the kernel copies it in once and decodes the copy, so another thread cannot change a value between its check and its use. - Decoding never allocates. An untouched record page is the caller’s error, so decoding can
never fail for want of memory, never charges a budget halfway through, and needs no
OutOfMemoryin its stage. - A positional order. Checking in register order, stage by stage, makes the first error a
function of the arguments alone, which is what lets the kernel, the model and a replayed trace
agree exactly.
BadHandle,TooLargeandInvalidArgumentin decoding sort errors by kind; the position decides which one comes first. - Kinds checked by use. No call reports a handle’s kind and no record carries one: a query
call would be more surface, and a process learns the kind the first time it uses the handle
(
WrongObject). - Numbers from 0x101. Small numbers are never calls, so a stray or guessed small
a0, and a number from any other interface, lands on “unknown”. Numbers are appended, never reassigned, so a compiled program keeps working. call’s dispositions on every return. A caller must know whether it still owns its lend even when an argument was bad, so the kernel sets them before decoding:nonefor a raw lend of (0, 0),returnedotherwise (IPC).- A private error enum inside the kernel. Mapping the page layer’s errors to the ABI’s by hand at each call keeps each row’s errors a reviewed choice; an automatic conversion would let the wrong code leak out unnoticed.
Invariants
The invariants are the sixteen facts about kernel state that hold between any two steps, after any sequence of calls with any arguments. The rules say what each call does; the invariants say what must still be true when it has done it. The executable model checks all of them after every step of every random sequence, and the bench’s attack cases try to break them on the real kernel. This page owns all sixteen: each section gives the statement, where the kernel keeps it, the model’s check and the tests that attack it.
Purpose
A rule can be kept by every call and the system still go wrong, because the calls interact: a destroy that forgets one table, a delivery that charges one page too few, a timeout that fires on one path but not another. An invariant is stated over the whole state, so a check made after every step catches a wrong update wherever it happened. The invariants are also the claims the rest of the documentation leans on: “a revoked handle is gone everywhere”, “no page is writable and executable”, “a DMA page is never reused while a device can write it”.
Most invariants are the state-level form of rules other pages own, such as R1 (flow) and R10 (destruction):
| ID | Short name | Mostly a consequence of |
|---|---|---|
| I1 | handles name live objects | R10 |
| I2 | revocation is complete | R10 |
| I3 | minted badges are non-zero and narrow | R9 (stamps) |
| I4 | only badge-0 handles receive | mint |
| I5 | usage within limits | R6 (charging), R7 (carving) |
| I6 | labels only grow downward | budgets |
| I7 | every flow obeys R1 | R1 |
| I8 | class and account inherited | R8 (accounts) |
| I9 | pages W^X, zeroed, lends unmapped | R11 (memory) |
| I10 | create-destroy leaves the parent unchanged | R6, R7, R10 |
| I11 | fair turns | R2 (fair waiting) |
| I12 | ids never reused | messages |
| I13 | every blocking call returns by its timeout | timer |
| I14 | no call panics the kernel | ABI |
| I15 | abandoned calls reported once | R3 (lends and abandoned calls) |
| I16 | DMA pages reset before reuse | R11, devices |
How the model checks them
Status: built · tested (4)
- host:redoubt-model::kernel_sequences
- host:redoubt-model::budget_lifecycles
- host:redoubt-model::flood
- host:redoubt-model::mutations_are_caught
The model (model/) is a second implementation of the kernel’s calls, written
against the specification and not the kernel source. A generator picks an operation that is
legal in the current state (a call with chosen or hostile arguments, a read, write or fetch of
memory, a fault, an interrupt, time passing), the model takes the step, and Checker::check in
model/src/invariants.rs runs every check on the state that
results. A random sequence is up to 150 steps, and the kernel and budget-lifecycle families run
20,000 sequences each by default.
The checks never read the counters and derived fields the model’s kernel keeps. They recompute
what should be true from the objects themselves and from the ghost
(model/src/ghost.rs): records taken from handle tables, budgets
and call arguments at the moment of each event (how each handle came to be, what each message
was when sent, which devices could still write each DMA page). A wrong update anywhere then
disagrees with the recomputation, whichever call made it.
flowchart LR
G[generator picks an op<br/>legal in this state] --> S[model takes the step]
S --> H[ghost records what the<br/>step did and was given]
H --> C{"Checker::check<br/>every check, in order"}
C -- holds --> G
C -- fails --> F[shrink to the ops that matter,<br/>print the trace and the invariant]
Figure: how one random sequence is checked.
| Invariant | Checked by |
|---|---|
| I1, I2, I3 | i1_i2_i3_i4_handles |
| I4 | i1_i2_i3_i4_handles; delivered (the receiving handle); structure (exit endpoints) |
| I5 | i5_charging |
| I6, I8 | i6_i8_budgets |
| I7 | Checker::flows, delivered, exits_owed |
| I9, I16 | Checker::i9_memory |
| I10 | check::budget_lifecycle (model/src/check.rs): a pair of calls, not one state |
| I11 | Ghost::took, and the flood family in check.rs |
| I12 | Ghost::budget_created, Ghost::delivered; i6_i8_budgets (a child’s id is above its parent’s) |
| I13 | i13_timeouts |
| I14 | the property runner (model/tests/common/mod.rs), which counts a panic in any family as a failure |
| I15 | Checker::i15_abandoned, Checker::flows |
A check that nothing can fail proves nothing, so the model also carries mutations: each
breaks one rule in one place, and mutations_are_caught requires every one to make some
property fail. The mutations named in the status lines below are the ones each invariant’s check
detects; the rule checks run in the same pass and catch the rest.
The invariants
I1 (handles name live objects)
Status: built · tested (5)
- bench:budget-forge-attack
- bench:budget-destroy-attack
- bench:process
- host:redoubt-model::exited_object_handles_and_queued_copies_live_until_notice_receipt
- host:redoubt-model::kernel_sequences
A process can name objects only by indices into its own handle table, which holds at most
MAX_HANDLES (4096: the handles one process may hold). Index 0 is never a handle. Every live
handle names a live object and carries a live stamp. A handle to a process object stays live
while the process runs and while its exit notice waits to be received.
Kept in kernel/src/handle.rs. A lookup reads only the
caller’s table; an index of 0 or past the last table page is BadHandle, as is one wider than
32 bits, refused while decoding. Installing past MAX_HANDLES is TooLarge. A handle names its
object and its stamp each by frame and by id, and every lookup (budget_at, endpoint_at,
device_at, process_at) compares the id with the one in the frame: a handle that escaped
R10’s chains would name a freed and perhaps reused frame, and the kernel stops rather than use
it. An object’s frame is freed only after the handles naming it are closed: a process object’s
through its own chain (free_object in kernel/src/process.rs), a device’s by a sweep
(destroy_device in kernel/src/message.rs), and inside a destruction every endpoint and device
frame waits until destroy_marked has closed the dying budgets’ chains.
Model check: i1_i2_i3_i4_handles: no table uses index 0, and every handle anywhere (in a
table, as an exit endpoint, or carried in a queued message) names a live object.
Attacks: budget-forge-attack passes every unused index, indices on table pages that do not
exist, 0, past the table and wider than 32 bits to every budget call, while a victim lives in the
budget a wrong lookup would destroy. budget-destroy-attack keeps using indices into a destroyed
subtree while frames and indices are reused. process checks that a process handle closes when
its notice is taken. The model test keeps a process handle and a queued copy of it through the
process’s exit and sees both go at notice receipt, the copy arriving as 0.
I2 (revocation is complete)
Status: built · tested (9)
- bench:budget-destroy-attack
- bench:handle-chain-attack
- bench:handle-chain-fault
- bench:process-chain-fault
- bench:redoubt-revoke
- bench:budget-deadline
- bench:process-attack
- mutation:R10KeepForeignHandles
- host:redoubt-rt::badges_are_never_reused_and_ids_are_never_zero
After a budget is destroyed, no handle stamped with it or with any budget below it can be used anywhere: none is left in any process’s table, and a copy carried in a message not yet received arrives as 0 in its slot. This is what makes a budget a revocation scope.
Kept in destroy_marked (kernel/src/budget.rs) and the
chains of kernel/src/handle.rs: a handle held outside the budget
its object is charged to, or outside its stamp, is entered in that budget’s chain when it arrives,
and one naming a process object in that object’s chain. Once the subtree’s processes are gone,
and with them every table inside it, destroy_marked closes every handle in the dying budgets’
chains, and freeing a process object closes its own. The checked build audits every table’s
entries after each destruction’s walk (check_handle_chains), and bench:handle-chain-fault
and bench:process-chain-fault show it catching a missing one. Messages are
reached by budgets_dying in kernel/src/message.rs: a queued message sent through a handle
stamped with a dying budget fails its sender with Dead, and a taken one is abandoned (R3). A
handle carried inside a queued message is checked again at delivery (is_live) and installed as
0 if its stamp or object has gone. Servers built on the runtime (libs/rt) never reuse a badge,
so a revoked grant cannot come back as a later client’s
(serving).
Model check: i1_i2_i3_i4_handles: no handle anywhere, queued messages included, is stamped
with a destroyed budget. R10KeepForeignHandles (the stamp sweep skipped) fails here.
Attacks: budget-destroy-attack destroys a four-deep subtree and tries every call on every
index into it. redoubt-revoke destroys the budget a queued message’s handle was minted into and
receives the message: the handle arrives as 0. budget-deadline lets a deadline destroy a
budget and checks the handles to it, to its child and stamped with it are gone. process-attack
revokes every handle a destroyed budget’s processes created and handed to a process outside it.
I3 (minted badges are non-zero and narrow)
Status: built · tested (5)
- bench:redoubt-ipc-attack
- bench:redoubt-revoke
- bench:redoubt-ipc
- host:redoubt-sys::malformed_calls_are_refused
- mutation:R9MintStampsCaller
A handle made by mint has a badge other than 0, and its stamp is its source’s default stamp or
a budget below it. So minting never makes a receive right, and never makes a grant that outlives
the budget it came from.
Kept in mint (kernel/src/message.rs): decoding refuses badge 0 (InvalidArgument) and
mint checks it again, so neither check rests on the other; a narrowing budget handle must name
the default stamp or a budget below it (is_at_or_below), or the call is NotPermitted. The
default stamp is the source handle’s stamp, or, minting from an open call, the stamp of the
handle the call came through.
Model check: i1_i2_i3_i4_handles: every minted handle has a non-zero badge and a stamp at or
below its recorded default. R9MintStampsCaller (stamping with the minter’s own budget) fails
here.
Attacks: redoubt-ipc-attack mints badge 0 in raw registers (the typed call cannot carry
one) and narrows into a budget it does not hold; redoubt-revoke narrows into a budget above and
one beside the default stamp (NotPermitted); redoubt-ipc has the server mint badge 0 and
badge 77. malformed_calls_are_refused pins the decoder’s refusal of badge 0.
I4 (only badge-0 handles receive)
Status: built · tested (5)
- bench:redoubt-ipc-attack
- bench:redoubt-ipc
- bench:process-attack
- mutation:ReceiveWithBadgedHandle
- mutation:ExitEndpointBadged
Only an endpoint handle with badge 0, a receive right, can receive. Every receive right is
made when its endpoint is made, or copied from one; none is minted.
Kept in receive (kernel/src/message.rs: a badged endpoint handle is NotPermitted);
mint (only a receive right mints, and I3 keeps badge 0 out of its results); process_create
(kernel/src/process.rs: an exit endpoint must be named by its
receive right, so no exit notice can be steered to a handle somebody minted).
Model check: i1_i2_i3_i4_handles (no minted receive right); delivered (every message was
taken through a badge-0 handle to its own endpoint); structure (every exit endpoint is a
receive right).
Attacks: redoubt-ipc-attack receives on its badged handle, mints from it, and mints from
handles it does not hold, while the victim keeps its receive right; redoubt-ipc’s client does
the same with a handle the server minted for it; process-attack names a badged exit endpoint.
I5 (usage within limits)
Status: built · tested (6)
- bench:budget
- bench:budget-carve-attack
- bench:redoubt-tight
- host:redoubt-model::quarantine_charge_moves_to_a_parent_at_its_limit
- mutation:R4OverdrawOnDelivery
- mutation:R7NoCarveCheck
For every budget, pages, processes and carved weight used are at most its limits, and its
children’s limits and own pages, plus its own objects, fit within them. A server’s charge for the
lends it holds is at most MAX_LEND_PAGES (16 pages) per open call, and a process holds at most
MAX_OPEN_CALLS (256), so a process’s lends cost it at most 4,096 pages.
Kept in charge (kernel/src/budget.rs: OutOfMemory past the limit, for every
allocation); the carve checks in budget_create (pages, processes, weight, in that order);
R4 (delivery) checking before a delivery that the receiver can pay for
all of it; dma_migrate_quarantine in kernel/src/dma.rs moving a
quarantined page’s charge to a parent only after the destroyed child’s carve has come back. Every
uncharge and every carve return is a checked subtraction: a bookkeeping bug stops the kernel
rather than wrap a counter.
Model check: i5_charging recomputes every budget’s usage from the objects charged to it
(the cost table) and requires it to equal the budget’s counters and fit its limits.
R4OverdrawOnDelivery fails here, and the R6 mutations fail the recount in the same function.
R7NoCarveCheck is caught first by the scripted scheduling contracts, which find a process in a
budget with no free weight left.
Attacks: budget checks usage within limits after every step of its carving sequence;
budget-carve-attack carves past every limit with the largest and wrapping values, and a victim
in the same budget must still get its pages; redoubt-tight sends a lend to a receiver that can
pay for the pages but not the page tables that map them, and the receiver is charged nothing.
I6 (labels only grow downward)
Status: built · tested: bench:budget, bench:process-attack, mutation:LabelsAddedByParentClass
A budget’s labels never change after it is created. A child’s label set contains its parent’s,
and only a creator whose own budget is class system may add labels.
Kept in budget_create (kernel/src/budget.rs): the labels asked for are sorted and
deduplicated, a set that lacks one of the parent’s is LabelDenied, and a set that differs from
the parent’s is ClassDenied unless the caller’s budget is class system. No call changes a
budget’s labels.
Model check: i6_i8_budgets: every budget’s labels equal the ghost’s record at creation,
contain the parent’s, and differ from them only if the creator was class system.
LabelsAddedByParentClass (checking the parent’s class, not the creator’s) fails here.
Attacks: budget drops a labelled parent’s label and is refused, and adds labels as a
system-class caller; process-attack runs a user-class process that tries to add a label.
I7 (every flow obeys R1)
Status: built · partly tested: a message between user budgets with different labels is attacked only in the model · tested (8)
- bench:process-attack
- bench:process-review
- mutation:R1SkipLabelCheck
- mutation:R1ChecksReceiverNotOwner
- mutation:R1ExitNoticeIgnoresLabels
- mutation:R1UsageIgnoresLabels
- mutation:R1UsageExemptBySystemTarget
- mutation:R1ExitExemptBySystemExiting
Every flow of information the kernel carries obeys R1: messages, exit notices and budget_usage
reads. A message is compared with its endpoint’s owner, the budget that created it, whoever
takes it. So handing a receive right to another budget is delegation of the whole endpoint: what
arrives there was checked against the owner’s labels. Between user budgets a message needs equal
label sets, so a receive right cannot travel between two user label sets; a system-class server
must never hand one across (servers).
Kept in send (kernel/src/message.rs, stage 3: sender against the endpoint owner, for
call and send); allowed (kernel/src/process.rs: an exit notice from the exiting budget to
the exit endpoint’s owner, dropped if it fails); budget_usage (kernel/src/budget.rs).
Model check: Checker::flows and delivered: every delivered message between user budgets
had equal labels with the owner, every notice and usage read went to a system-class budget or a
superset, and every LabelDenied was one R1 requires; exits_owed: no endpoint holds a notice
that was not owed to it. Two of the listed R1 mutations are caught elsewhere first:
R1ExitNoticeIgnoresLabels by I1’s check (a handle to a process object the wrong delivery
freed), and R1ExitExemptBySystemExiting by the ghost’s record of owed notices (an object freed
while its notice is still owed).
Attacks: process-attack reads a labelled sibling’s usage from a user budget
(LabelDenied); process-review sends exit notices up and down between label sets.
I8 (class and account inherited)
Status: built · tested: bench:process-attack, mutation:ClassNotInherited, mutation:R8AccountFromArgument
A child budget’s class is its parent’s. Its account is its parent’s unless the parent’s is 0;
then the creator chooses it. The one exception is users, which the kernel makes at boot as a
class-user child of the class-system root.
Kept in budget_create (kernel/src/budget.rs): new_budget is given the parent’s class,
never the caller’s, and the account is the parent’s when that is non-zero, whatever the
argument says (R8).
Model check: i6_i8_budgets. ClassNotInherited (the creator’s class) and
R8AccountFromArgument fail here.
Attacks: process-attack asks for account 999 under a parent with a non-zero account and
reads the account the kernel attaches to the child’s messages; the same probe, in a child of
users, is refused adding a label, which shows its class is user.
I9 (pages W^X, zeroed, lends unmapped)
Status: built · partly tested: reuse of a freed frame is attacked by `smp-inflight-race`'s witness, which no case ties to the frame another process filled · tested (14)
- bench:wx
- bench:write-only-attack
- bench:mem-attack
- bench:map-fixed-attack
- bench:return-lent-unmapped
- bench:lender-touches-lent
- bench:uaf-lent-page
- bench:device-exec-refused
- bench:smp-inflight-race
- mutation:R11NoZeroing
- mutation:R11SetFlagsAllowsWx
- mutation:R11SetFlagsAllowsWriteOnly
- mutation:R11LendStaysMapped
- mutation:R11ExecOnDeviceMemory
No user page is ever mapped writable and executable, or writable without being readable, and no page of device registers or DMA frame is ever mapped executable. Every page is zeroed before a process first sees it. A lent page is unmapped from its lender until the call ends, so a page is reachable from at most one address space at a time. This is R11’s state-level form; the kernel’s own mappings are R19 (kernel W^X)’s (memory).
Kept in check_permissions (kernel/src/arch/riscv/mem.rs:
every user mapping; decoding refuses W+X flags before that); set_flags’s refusal of EXECUTE
on a frame that is not RAM or is a dma_alloc frame; zeroing a freed frame before it is free
again (R81 (frames in flight), zero_taken in
kernel/src/reclaim.rs and reclaim_wait in
kernel/src/mem.rs), a frame free since boot when first taken
(take in kernel/src/mem.rs), and a DMA pool frame as it leaves the
pool (dma_pool_take);
lend_out, which clears the lender’s valid bit and
keeps the entry as the record of the loan, so the lender faults on the page and cannot unmap or
remap it until reply or a failed call gives it back.
Model check: Checker::i9_memory: no mapping is W+X or write-only, every frame that appears
since the last step reads zero, every free frame is zero and none is also held or in flight
(R81), and no frame is reachable from two address spaces. The four listed R11 mutations fail
here, and so do R81’s.
Attacks: wx runs children that make a writable page executable and write to their code, and
the kernel must end them with the matching fault; write-only-attack asks for W without R through
every call; mem-attack lends a victim fresh pages after the victim freed secret-filled ones,
and the victim finds them zero; map-fixed-attack refuses W+X and reads a fresh page as zero;
return-lent-unmapped has a second thread unmap and remap a page while it is lent;
uaf-lent-page kills a lender and checks the frame is not reused under the borrower.
I10 (create-destroy leaves the parent unchanged)
Status: built · tested (6)
- bench:budget
- bench:budget-deadline
- bench:process-lifecycle
- bench:process-attack
- host:redoubt-model::budget_lifecycles
- mutation:R10KeepCarvedLimits
Creating a budget and then destroying it leaves its parent’s usage and free limits as they were, once the exit notices of its processes are received or dropped. The notices matter because a process object is charged to its creator until its notice is taken.
Kept in return_carve (kernel/src/budget.rs: the child’s limits and its own page back to
the parent) and mark_dying (its weight, first); free_object (kernel/src/process.rs: a
process object’s page back to its creator, and its PID’s count back to the budget it counts in,
when its notice is received or dropped).
Model check: check::budget_lifecycle: after a random prefix, a thread creates a child,
starts a process in it, lets only the child’s subtree act, destroys it and receives the notices;
every budget’s counters must be what they were. R10KeepCarvedLimits breaks it, but the
scripted scheduling contracts catch that variant first (a carve the scheduler holds wrong), so
this family’s own check may be doing no unique work.
Attacks: budget destroys a nested subtree and checks the parent’s usage exactly, then runs
500 create-nest-destroy cycles; budget-deadline checks system’s usage after a deadline
destroys a lease; process-lifecycle checks exact budgets after 520 exits; process-attack
checks that receiving notices refunds the creator’s pages exactly.
I11 (fair turns)
Status: built · partly tested: turns between several groups on one endpoint are attacked only in the model, and on the kernel only as one label set's order beside a vault's turn and a served group going behind one already waiting · tested (5)
- host:redoubt-model::flood
- host:redoubt-model::kernel_sequences
- bench:ipc-fair-label-sets
- mutation:R2FifoAcrossAccounts
- mutation:R2OneCursor
With k groups of R2 blocked on an endpoint, and the receiving process holding fewer than
MAX_OPEN_CALLS open calls, each group’s oldest message is taken within k receives.
Kept in pick (kernel/src/message.rs) and redoubt-ipclist’s groups: each endpoint keeps
its waiting groups in the order their turns fall due, a group’s turn being its oldest queued
message’s arrival or its last take, whichever came later, both values of the kernel’s one order of
arrivals and takes (next_seq). A take (served) moves its group behind every group already
waiting, in one write, and a group that arrives later is due later, so at most k−1 groups go
first. A refused message (R4) takes its turn too, so a group that cannot be paid for does not
stall the others.
Model check: Ghost::took: while one group’s oldest message waits, no other group is taken
twice. The flood family queues up to 10,000 senders from many groups, some servers hoarding
open calls to the limit, and checks one principal’s call is taken within as many receives as
there are groups. R2FifoAcrossAccounts (oldest first across all senders) fails here.
I12 (ids never reused)
Status: built · partly tested: id reuse is not visible to a process, so budget and message ids are attacked only in the model and other object ids not at all · tested: bench:redoubt-ipc, bench:redoubt-ipc-attack, mutation:MsgIdsGlobal
Object ids (budgets, endpoints, devices, process objects) are never reused. A message id is never 0 and never reused within its receiving process, and it comes from that process’s own counter, so it says nothing about anyone else’s traffic. A stale id therefore cannot reach a later message, and a stale handle cannot reach a later object in the same frame.
Kept in next_object_id (kernel/src/budget.rs: one 64-bit counter for every kind of
object, checked so it stops rather than wrap) and next_msg_id (per process, from 1, in the
process’s account; a new process in a reused PID starts again, having inherited nothing:
R20 (PID reuse)). Object ids stay in the kernel; a process sees
handle indices.
Model check: Ghost::budget_created (no budget id issued twice) and Ghost::delivered (each
process sees its own ids 1, 2, 3, …); i6_i8_budgets (a child’s id is above its parent’s).
MsgIdsGlobal (one counter shared by every process) fails here.
Attacks: redoubt-ipc’s server checks each call’s id differs from the previous one;
redoubt-ipc-attack replies to, serves and mints from ids that are not its own.
I13 (every blocking call returns by its timeout)
Status: built · partly tested: timeouts on more than one hart are not attacked by a case · tested: bench:timeouts, host:redoubt-model::kernel_sequences, mutation:TimeoutIgnoredWhileOthersRun
Every blocking call returns by its timeout. Its Timeout is committed, and its thread made
runnable, at the first kernel entry at or after the timeout, and the always-armed timer bounds
when that entry comes by timer latency. When the thread then runs is R12 (scheduling)’s: a
timeout is a wake, and a wake alone never preempts (scheduling).
FOREVER (the largest timeout) never expires.
Kept in expire_due (kernel/src/time.rs), which runs first at
every kernel entry, before anything reads the entering process, and commits everything due,
earliest first (at an equal instant timeouts before budget deadlines); rearm, which arms the
one hardware timer for the earliest of slice end, timeouts and deadlines on the way to user mode
or kmain’s idle, the only places it is taken, rounding up so it never fires early;
next_timeout and time_out in kernel/src/message.rs, which unwind what the thread waited for
(a queued message’s buffer back, a taken call abandoned under R3).
Model check: i13_timeouts: after every step, no thread is still blocked past its timeout.
TimeoutIgnoredWhileOthersRun (timeouts fire only while nothing runs) fails here.
Attacks: timeouts runs under virtual time, so lateness is measured the same on any host and
asserted: idle sleeps end on time and never early; queued and taken calls, sends and receives time
out; FOREVER and saturating timeouts never do; a reply racing a timeout lands on exactly one
side 200 times.
I14 (no call panics the kernel)
Status: built · tested (7)
- bench:budget-syscall-attack
- bench:syscall-attack
- bench:redoubt-tight
- bench:pages-exhaustion
- host:redoubt-sys::malformed_calls_are_refused
- fuzz:redoubt-sys/decode
- host:redoubt-model::kernel_sequences
No sequence of system calls, with any arguments, panics the kernel. A malformed value is an error, never a stop.
Kept in redoubt-sys (libs/sys), whose decoders reject every
malformed register and record slot with an error; kernel/src/redoubt.rs, which checks every
record’s alignment and that it lies in the caller’s own memory before reading it (readable for a
record the call only reads, writable too for one it writes back, such as a call body), then
runs each call’s checks in the fixed order (ABI). The
kernel’s own assertions (I1’s id checks, I5’s checked
subtractions) are for kernel bugs, not for arguments: no argument reaches them.
Model check: the property runner catches a panic in any family and reports it as an I14 failure; the generator mixes calls whose arguments ignore the state entirely with legal ones, and the model builds with overflow checks on, so an arithmetic overflow is a panic too.
Attacks: budget-syscall-attack sends bad and wide handles, unknown numbers, stray
registers, misaligned, kernel, read-only, untouched and straddling records and 4000 random calls,
and the kernel must survive to power off; syscall-attack makes an oversized lend;
redoubt-tight makes a delivery the receiver can pay for only in part. The decoder is fuzzed.
I15 (abandoned calls reported once)
Status: built · tested (11)
- bench:redoubt-ipc
- bench:timeouts
- bench:budget-deadline
- bench:process-lifecycle
- bench:ending-pumps-once
- bench:receive-bad-record
- bench:endpoint-destroy-open-calls
- mutation:AbandonNoticeMissing
- mutation:AbandonNoticeRepeated
- mutation:BadRecordConsumesNotice
- mutation:EndpointDestroyNoticeKept
Every abandoned call is reported to the thread holding it exactly once, and stays open until that
thread replies; the reply reaches nobody. The report is an abandoned-call notice, delivered on the
holder’s next receive on the endpoint the call came in on. When the holder’s reply enters
before it has seen one (the caller timed out or died just before), the report is the reply’s own
result instead: discarded, mask 0, and no notice follows. A receive whose record cannot be
written takes nothing, so the notice stays for the holder’s next good receive
(a bad record takes nothing).
Kept in abandon (kernel/src/message.rs): it runs only while the caller still waits, and
sets the call’s notice flag in the same step that clears its waiting flag; pump delivers the
notice to the holding thread before any exit notice or message and clears the flag; reply to a
call whose caller no longer waits frees the lend and reports discarded, and since the call
leaves the thread’s open calls there, no notice is left to deliver; endpoint_dying clears the
notices owed on every endpoint a destruction ends, those its own failed callers left included,
whose Dead is the holder’s report (and process::endpoint_dying the exit notices owed there),
and reply checks, in a checked build, that none is owed on a destroyed endpoint; and since
nothing is delivered while a budget is dying, no notice reaches a thread the destruction is
about to end. Each step runs to its end with interrupts off, holding the memory manager, so a
reply and an abandonment cannot both win.
Model check: Checker::i15_abandoned: a thread waiting in receive on the call’s endpoint
has been told of every abandoned call it holds; Checker::flows: a notice goes only to the
holder, only when its caller no longer waits, and never twice. AbandonNoticeMissing and
AbandonNoticeRepeated fail here.
Attacks: redoubt-ipc abandons 256 calls and counts 256 notices, and holds one abandoned call
back to see it reported once; timeouts races a reply against the caller’s timeout 200 times and
checks each lands as exactly one of a delivered reply, one notice then discarded, or discarded
with no notice; budget-deadline abandons a taken call by a deadline; process-lifecycle by the
caller’s death.
I16 (DMA pages reset before reuse)
Status: built · partly tested: a co-holder that still reaches a device reset at another holder's death is attacked only in the model · tested (11)
- bench:dma-reset-reuse
- bench:dma-reset-quarantine
- bench:dma-destroy-quarantine
- bench:dma-rules
- host:redoubt-model::reset_at_one_death_does_not_cover_a_co_holder
- host:redoubt-model::deaf_device_quarantines_the_co_holder_too
- host:redoubt-model::exit_pools_after_reset
- mutation:DmaFreeBeforeReset
- mutation:DmaQuarantinedSlotCountsAsReset
- mutation:DmaResetClearsCoHolderReach
- mutation:DmaUnmapFrees
A page dma_alloc handed out goes back to the free pool only after every device that could still
write it has confirmed a reset: the device it was allocated through and every DMA device its
holder mapped. If any of them fails to confirm, or is already quarantined, every DMA page of the
dying holder is quarantined: mapped nowhere, never reused until reboot, and still charged.
The charge stays with the holder’s budget; when that budget is destroyed it moves to the parent,
after the destroyed budget’s carve has come back, so the parent stays within its limits (I5).
A device that failed a reset never counts as reset again, and its device object is destroyed as
R10 destroys one, so no handle reaches it.
Kept in kernel/src/dma.rs. The trigger is the end of the holding
process, not a handle count: dma_release runs as the process ends, after its ordinary pages are
freed, and resets its reset set (the allocation devices of its runs, plus every DMA device it
mapped with map_device). dma_reset writes 0 to the device’s virtio status register and reads
it back until it is 0, for at most 1000 µs per device, not preemptible; a quarantined or
non-virtio device reports “not confirmed” without being touched. The runs are pooled only if the
whole set confirmed in that same call, and an assertion stops the kernel if a run would be pooled
otherwise. The frames belong to the kernel’s DMA owner, not the process, so no generic free, move
or lend path can release one: a DMA page is never lent, transferred or moved by process_map,
and unmap drops only its mapping. The kernel only resets a device; it never drives it for
anyone. Nothing turns the reset off; the test build’s dma-reset-deaf feature makes each
device’s first reset report failure so that the quarantine path runs.
Model check: Checker::i9_memory: no frame in the free pool is still armed, and no
quarantined frame is mapped. “Armed” is the ghost’s record of the devices that could write a
frame: set at dma_alloc (its own device and every device its holder has mapped) and again at
every later map_device by its holder, and cleared for one
device only by that device’s confirmed reset at the frame’s own holder’s death, never by pooling
and never by another process’s death. So a live co-holder’s frames stay armed until its own death
resets the device again. The four listed mutations each pool a frame that is still armed.
Attacks: dma-reset-reuse destroys a driver that brought the virtio disk up, reallocates its
frames to another process, proves the reuse by physical address and checks each was handed out
only after the disk’s status read 0. dma-reset-quarantine makes a device’s first reset fail:
the faulted driver’s run is quarantined and every handle to the device is gone, the co-holder’s
runs are quarantined when it dies, the charges move to the parents, and a search of the DMA pool
takes all of it but the quarantined runs, none of it overlapping them. dma-rules checks DMA
pages cannot be lent, transferred or moved and that unmap keeps them. The model tests script the
co-holder cases.
Residual risks
- The model checks its own abstraction. Every invariant is checked after every step of the
model, but no trace has yet been replayed against the real kernel (model). On the
real kernel some are attacked only in part: flows between user label sets (I7), reuse of a
freed frame (I9;
smp-inflight-race’s witness reads reused frames but cannot name them, andlender-touches-lentattacks a lender’s load and store of its lent page, on one hart), fair turns among several groups (I11), id reuse (I12), timeouts on more than one hart (I13), and a co-holder’s reset (I16). - A kernel bookkeeping bug is a stop. The id checks behind I1 and the checked subtractions behind I5 stop the kernel when they fail. A bug that breaks them halts the machine for every principal on the box, though it does not hand one process another’s memory or objects. No argument reaches those checks (I14), but a kernel bug can.
- System-class servers are trusted with I7. R1 does not check a flow into or out of a
systembudget, so a system server that hands a receive right across label sets, or mixes two label sets’ data, breaks label separation and the kernel cannot see it (servers). - I13 bounds the commit, not the run. A timed-out thread is runnable at the first kernel entry after its timeout, within timer latency measured under virtual time; when it runs is its budget’s share under R12. Neither is a hard real-time bound.
- I10 waits for the notices. A creator that never receives its children’s exit notices keeps paying for their process objects, and their PIDs keep counting in the budgets they ran in, or those budgets’ parents once destroyed. The pages are its own cost; the PIDs are bounded by the process limit of a budget it was given a handle to, the right to spend that limit.
- I16 covers reuse, not a live driver. Without an IOMMU a DMA driver can point its device at any physical address while it lives, so it is TCB. Only virtio-mmio devices can be reset: every death that reaches another DMA device quarantines its pages. Quarantined pages are lost until reboot, and each reset holds the kernel for up to 1000 µs (devices).
Why
- State, not calls. Stating the guarantees over the state and checking them after every step catches a wrong update wherever it happened, including in the interaction of two calls that are each correct alone.
- Recompute, never read back. A check that read the counter a bug had written would agree with the bug. The model’s checks recompute usage, liveness and stamps from the objects and the ghost’s records, so a wrong counter disagrees.
- Mutations prove the checks bite. A check that no break can fail is no evidence; every mutation must be caught before a property run counts.
- Stop rather than trust a frame. A handle that escaped a sweep names a frame that may hold someone else’s object by then. Using it could be an escape; stopping is a denial of service. The kernel stops (I1).
- Ids that never repeat make a stale handle or message id detectable by comparison, and per-process message ids keep one process’s traffic invisible to another.
- A quarantined device never counts as reset, because a device that ignored one reset may ignore the next, and pooling a co-holder’s frames then would give a new owner a page the device can still write. Memory is the cheaper loss.
- The trigger is the holder’s death, not the last handle, because the process that programmed a device is what can leave a device holding an address; resetting everything it could reach, once, as it ends, is one point where the rule is easy to check.
The executable model
The executable model is a Rust crate, redoubt-model in model/, that states the kernel’s
objects, calls, errors, rules and invariants as code, with the steward’s policy core on top. Seeded
random sequences of calls drive it, and after every step a checker recomputes each invariant
from the objects themselves. Deliberate breaks of each rule, the mutations, show that the
checks catch what they claim to catch. Every run can be written as a text trace and
replayed; replaying those traces on the real kernel compares the kernel with the model.
Purpose
A rule in prose can be read two ways; a rule in code cannot. The model is the design in a form a machine checks: if any sequence of calls it generates reaches a state that breaks an invariant, it reports the seed and a shrunk trace that shows how. It is the reference the kernel is built to: the same call names, the same errors, the same constants and the same order of argument checks (ABI). A design change is tried in the model first, because a change that breaks a property fails there in seconds, on a laptop, with the failing sequence in hand.
What the model is
Status: built · partly tested: independence from the kernel’s source and the empty dependency list are held by model/Cargo.toml and #![forbid(unsafe_code)], not attacked by a case · tested: host:redoubt-model::overflow_checks_are_on, host:redoubt-model::map_fixed_near_user_top_does_not_starve_map_anon, host:redoubt-model::event_free_tick_matches_slice_reference
- Independent. The crate is
no_stdwithalloc,#![forbid(unsafe_code)], and its[dependencies]table is empty: it links no kernel crate, notredoubt-sys, nothing from crates.io. It states its constants itself, with the kernel’s names and values:WORDS(4),MAX_MSG_HANDLES(4),MAX_HANDLES(4096),MAX_LEND_PAGES(16),MAX_THREADS(255),WAIT_CAP(32),MAX_OPEN_CALLS(256),SLICE(1,000 µs),STRIDE(2^20). Two crates use the model, each as a dev-dependency:redoubt-stride, for its differential test (below), and the test bench, whose scheduling oracle must reject traces from the model’s scheduler with its tie rules broken. - One method per call.
Kernelinmodel/src/kernel.rshas one method for each call, with the call’s name. Every argument arrives as a rawu64, as from user mode, and the checks run in the kernel’s order: decoding asredoubt-sysdecodes, then each argument from left to right, then permission, then resources. - Overflow is a failure. The root
Cargo.tomlkeeps overflow checks on for this crate in both dev and release builds, so an arithmetic overflow is a panic, and the runner counts any panic as a failure of I14 (no call panics the kernel).overflow_checks_are_onfails if a build turns them off.
| File | What it holds |
|---|---|
spec.rs | constants, errors and the small value types of the calls |
syscall.rs | calls as data, their results, and the other events: user loads, stores and fetches, faults, interrupts, time passing, a thread’s record changing validity |
kernel.rs | the kernel’s objects and one method per call |
sched.rs | the stride scheduler |
ghost.rs | ghost state: facts the checks need that the kernel does not keep |
invariants.rs | the checker run after every step |
check.rs | the property families, the trace epilogue, shrinking |
gen.rs | the seeded generator of operation sequences |
mutation.rs | the deliberate breaks |
trace.rs | the trace format: writing, reading, replaying on the model |
steward.rs, policy.rs | the embedder of the steward’s policy core, and its properties |
serving.rs | a server’s admission share (R26 (admission fairness)) |
What it abstracts
- Memory is page frames with one word of content each. Loads, stores and fetches check the
mapping’s permissions. A freed frame is in flight, still holding its word, until a
tick, when the one hart the model runs zeroes it and frees it, as several harts do between sections (R81 (frames in flight)); a frame never used since boot holds a word of its own until its first allocation zeroes it. A process’s own mapping, a server’s view of a lend and the lender’s reservation are separate states. Page tables follow Sv39’s layout as a placement rule, even when the cost table is rv32’s. - Threads have no registers. A call is instantaneous and atomic; time is logical and
advances only on a
tick. So the model tests accounting and state changes, not real-time latency, and not races between harts. A tick charges time slice by slice, but where nothing is due, to deliver or to wake, its whole slices are charged at once: with one budget queued, or with several (the same picks, charges and requeues, on the queued budgets alone).event_free_tick_matches_slice_referenceholds every such tick equal to the slice-by-slice reference, field by field, on boundary worlds of one to five queued budgets and on random histories, with no mutation and under every one. - Records (the user memory a call reads and writes) are abstracted as a whole: owned, unmapped, read-only, borrowed, device memory, a copy that faults, or an address whose every page, for the call’s record size, is checked against the modelled mappings. Byte layouts are not modelled.
- PIDs are drawn by a seeded generator from 2 to
0xffff;initis 1. This tests reuse and accounting, not unpredictability. - Addresses the kernel chooses are the kernel’s: a
map_anon,map_deviceordma_allocrun is placed in the 256 MiB area fromDEFAULT_BASE, and a receiver’s lend or transfer in the 4 MiB area fromDEFAULT_MESSAGE_BASE, each searched as the kernel searches it (memory). A full area is refused as the kernel refuses it. - Costs come from a
coststable: by default the kernel’s table (one header page per process, one page for each other object, 64 handles per handle-table page). The property tests use 8 handles per page, so handle-table growth and its charge show up in short runs. - Boot.
root,systemandusersget the kernel’s weight split:root1,000,000,system250,000,users749,000, sorootkeeps 1,000 free forinit. Page and process limits are small fixed numbers (root: 1,024 pages, 24 processes), not derived from RAM. The default boot has seven device objects: an MMIO range without DMA, an MMIO range with DMA, IRQ lines 10 and 11, the reset device, a DMA device whose first reset is not confirmed, and a second DMA device whose resets always confirm. - Where the design leaves a detail open, the model makes a choice and the code says so at the site.
Property families
Status: built · tested (7)
- host:redoubt-model::kernel_sequences
- host:redoubt-model::budget_lifecycles
- host:redoubt-model::scheduler_fairness
- host:redoubt-model::flood
- host:redoubt-model::every_call_and_error_is_reached
- host:redoubt-model::reset_at_one_death_does_not_cover_a_co_holder
- host:redoubt-model::the_reach_table_is_reproduced
A family is a function of one seed and an optional mutation. The runner
(model/tests/common/mod.rs) runs seeds on several threads, keeps the lowest failing seed, and
counts a panic as an I14 failure. A failing kernel sequence is shrunk to the steps that matter
and printed as a trace. Rerunning the seed reproduces the failure exactly.
The generator (model/src/gen.rs) first builds a world worth attacking: principals’ budgets
under users, some labelled and with accounts, a system budget, shared endpoints, handles
minted into the principals’ budgets, and a process in each budget. One sequence in five skips
this setup and starts from init alone. Then it picks operations
from the model’s state: a handle the actor holds, a page it has mapped, a message it serves.
About one argument in ten is hostile instead (a handle index the actor lacks, an unaligned
address, a list over its cap). every_call_and_error_is_reached checks over 5,000 seeds that
every call succeeds at least once, every error is returned by some call, and lends, transfers,
each exit cause, interrupts and replies all happen, so no property holds only because nothing
reached it.
| Test | Default sequences | What must hold |
|---|---|---|
kernel_sequences | 20,000, up to 150 steps each | every check of the checker, after every step |
budget_lifecycles | 20,000 | I10 (create-destroy leaves the parent unchanged): a child created, used only from inside its subtree and destroyed leaves every other budget’s counters as they were |
scheduler_fairness | 20,000 | R12 (scheduling), as the scenarios below |
steward_policy, steward_noninterference | 2,000 and 3,000 | the steward model |
flood | 20 scenarios of up to 10,000 senders | R2 (fair waiting), I11 (fair turns) and R4a (open calls) under a flood |
A default run is 65,020 sequences. The bench case model-host-tests runs it but for the steward
families, in under two minutes, the build included; steward-model-host-tests runs those
two, each a job of its own on four threads (fanout), with the
check below, in under two minutes, the build included.
REDOUBT_MODEL_SEQUENCES sets the count per family. A bound on cargo’s test threads is a bound
on the whole binary: each test’s runner spawns MODEL_THREADS threads if it is set (a positive
integer; anything else fails the test, naming it), else one if RUST_TEST_THREADS is set, else
one per core. The test million runs 1,000,000 sequences in each of the five non-flood families
and 1,000 floods; it is marked ignored and runs only when asked for.
A steward operation, the core’s decision, the embedder’s calls on the kernel model and every check after it, costs tens of microseconds. Most of a steward seed is its ticks of minutes over several queued budgets, which cost a thousand slices a second of model time slice by slice and are charged in batches (what it abstracts). Measured on one thread, in release:
| Family | A seed, slice by slice | A seed, batched | Count | One core |
|---|---|---|---|---|
steward_policy | 0.43 s | 0.011 s | 2,000 | under half a minute |
steward_noninterference | 0.73 s | 0.025 s | 3,000 | about a minute and a quarter |
The counts come from what the seeds reach, not from habit. model/tests/steward_reach.rs
records, per seed, each rule of the core’s Policy table that held and each that refused, each
effect that ran, each filter’s answers, and each property’s first instance (a session’s budget
checked, a declassification copied out, a blame, a lockout, a lease ended, an observation after
vault work left out). Searched to 20,000 seeds, steward_policy reaches 148 of these, its
last new one at seed 439, and to 10,000 seeds steward_noninterference 151, its last at seed
296. A family’s count comes from that measured seed of its last new coverage: four times it,
rounded up to a thousand, but never below a seed at which the family’s random search catches a
mutation (PolicyDeclassifyUnfit at 1,418, and PolicyDeclassifyLive at 1,166), so 2,000 for
each; steward_noninterference runs 3,000, a margin it keeps. The counts are for coverage:
the three mutations whose state the random search builds only by chance are held by their named
scenarios (mutations), whatever the counts.
the_reach_table_is_reproduced checks what the first seeds reach: seeds 0 to 439 of
steward_policy reach 148 items and 0 to 296 of steward_noninterference 151, the last of
them first at that seed, so a change to the generator, the core or the checks that moves either
fails. That no later seed reaches a new item, and the catch floor of 1,418 and 1,166, come from
the ignored runs (reach_table, catch_table in model/tests/steward_reach.rs): they are
recorded here and checked by no test. Four of the core’s effects, and the refusal of its rule
is_context, are reached by neither family (residual risks).
The checker
After every step, Checker::check (model/src/invariants.rs) runs its checks in a fixed order:
- the objects’ structure; the console’s holder is a live process (the console’s one writer); serving state;
- handles: I1 (handles name live objects), I2 (revocation is complete), I3 (minted badges are non-zero and narrow) and I4 (only badge-0 handles receive);
- charging: I5 (usage within limits), and R6 (charging)’s backing:
root’s limit and its own page fit in the boot’sram_frames; - budgets: I6 (labels only grow downward) and I8 (class and account inherited);
- the step’s flows and call outcomes;
- memory: I9 (pages W^X, zeroed, lends unmapped) and I16 (DMA pages reset before reuse);
- queued messages, then abandoned-call notices: I15 (abandoned calls reported once);
- delivery, exit notices owed, and timeouts: I13 (every blocking call returns by its timeout).
I12 (ids never reused) is checked from ghost records as ids are issued.
Each check recomputes what should be true from the primary objects (handle tables, budgets,
frames, each thread’s wait state) and from ghost state, never from the counters and derived
fields the kernel model keeps. Ghost state (model/src/ghost.rs) is recorded from the primary
objects at the moment of each event, and nothing in the kernel model reads it. So a bug, or a
mutation, cannot hide itself by also changing what the check believes.
The flow checks judge each step against the ghost records:
- a delivered message is the one sent: its kind, badge, account, labels and handles are what the sender’s handles and budget gave, a handle revoked meanwhile arriving as 0 and every other keeping its stamp;
- it reached a thread receiving through a badge-0 handle to its endpoint, and between user budgets only equal label sets flow, the receiver being the endpoint’s owner (R1 (flow), I7 (every flow obeys R1));
LabelDeniedandBusyhappen only when R1 and R2 say so;- a caller gets a reply only when its server replied, and an abandoned-call notice goes to the thread holding the call, once;
- every completed
callreports the lend disposition its history implies, and apresentreply only with success orOutOfMemory.
For I16, the ghost arms each DMA frame against every device its holder could reach, and
disarms a device only on a reset that the device object confirms (not on what the kernel
model reports). No frame in the free pool may be armed. A confirmed reset at one process’s death
disarms only that process’s frames: a live co-holder that still reaches the device can program
it again, so its frames stay armed until its own death resets the device. A quarantined frame
must be mapped nowhere, and a quarantined device used again is a violation. The default boot’s
second healthy DMA device exists so the generator can build the co-holder shape;
reset_at_one_death_does_not_cover_a_co_holder builds it by hand.
Scheduler scenarios
scheduler_fairness draws one scenario per seed and drives the scheduler alone through time:
| Scenario | Property |
|---|---|
| classic | spinners and sleepers: a spinner gets its weight’s share over every interval it was runnable |
| gaming | short bursts, below a slice or at a large weight, buy no more than the weight |
| idle gap | a sleeper waking into an empty queue banks no credit |
| exit churn | exiting, faulting or being killed on the CPU is charged, and a timer’s work for the exiter’s timeouts is its |
| budget churn | creating, running and destroying children (blocking, with a spinning parent, by deadline, parked) gains nothing |
| carve inflation | carving moves share to the child and never duplicates it |
| debt lift | a light grandchild’s work reaches a shared parent, normalized by weight |
| idempotence | creating and destroying a child with no run leaves the parent’s pass unchanged and its remainder unchanged or smaller by rounding (exactly 0 if it was 0), because the carve rescales the remainder; a carve under a running parent first charges the pending runtime at the weight it ran at |
| rank | every pick among equal passes follows the rank rules, checked by an independent oracle; a wake never preempts |
| shell | a parent that gives each short command a heavy child keeps its share |
| late join | at two harts, A (900, one thread) and B (100, one) run long, then C (100, one) wakes: from a slice after, A gets a hart and B and C half each; the floor that counts a capped budget gives C B’s hart (R12CappedHoldsFloor) |
| second cap | at three harts, A (1000, one), B (100, one), C and D (10, five each) run long, then E (10, one) wakes: A and B a hart each, C, D and E a third each; a cap test that stops at the first capped budget gives E more (R12CapOnce) |
| uncap | at two harts, A (900, one), B and C (100, one each) run long, then A gains a second thread: A 1.64 harts, B and C 0.18 each; an uncapped budget not lifted to the floor takes both harts (R12UncapBanksCredit), and so does one whose second runner is not charged (R12SpreadChargesOnce) |
| carve waiter | at two harts, on half-slice steps with the harts’ slices apart, A (100, one) beside B (100, two), which carves half its weight to an empty child before each step and takes it back after: a hart each; a cap that takes A while its thread waits for a hart, and a lift to the floor when the carve returns, give B A’s waits (R12UncapForfeitsWait: A 666) |
| heavy capped | at two harts, on half-slice steps with the harts’ slices apart, A (900, one) beside B and C (100, one each): A a hart, B and C half each; A is capped and its own slice ends, inside B’s and C’s, gain it nothing; a pick that ignores weight takes A’s hart from it (R12IgnoreWeight) |
| spread | A (100, four threads) and B (100, one): at two harts a hart each, at four A three and B one; a budget limited to one hart at a time gets one (R12OneRunnerPerBudget) |
| idle harts | at two harts, A (100, one) runs alone, then B (100, one) beside it, then C (100, one) wakes: two thirds of a hart each; a floor that holds while every queued budget is capped lets B enter at a stale pass and starve A (R12AllCappedHoldsFloor) |
The shares in the last seven are water-filling’s, computed by the scenario from its weights and
threads, in thousandths of a hart over 400 slices from one slice after the event, within 50 per
thousand of the machine. The scheduler runs any number of harts (Scheduler::set_harts); each
pick is one hart’s, of the lowest-ranked budget with a runnable thread no hart runs, and the cap
set is found its own way, by water-filling to a fixed point over every queued budget.
The rules themselves are on scheduling.
The flood
flood is the endpoint-flooding attack, in the model. A system server receives on one endpoint
with two threads. Bob’s processes, with two label sets under one account (so two R2 groups),
run up to a fixed 31 threads each (the attack’s shape, not MAX_THREADS), all calling with no
timeout. Enough other accounts that more than MAX_OPEN_CALLS calls can queue (seven to twelve)
queue WAIT_CAP calls each, and the server’s budget has a page for each open call. Alice makes
one call in the middle of the flood. Most of Bob’s calls must get Busy, no crowd process may
get Busy within its own group’s cap, and Alice’s call must be taken within as many receives as
there are groups. In half the seeds the server hoards: it receives without replying until the
process holds MAX_OPEN_CALLS, spread over both threads, and it must still take a send. The
checker runs every 512 steps and at the end.
Scripted contracts
Status: built · tested (8)
- host:redoubt-model::ipc_completion_table_and_rollback
- host:redoubt-model::sparse_committed_reply_mask_is_positional
- host:redoubt-model::scheduler_contracts_hold
- host:redoubt-model::pid_reuse_only_after_notice_receipt
- host:redoubt-model::deaf_device_quarantines_the_co_holder_too
- host:redoubt-model::partial_overlap_is_refused_whole
- host:redoubt-model::a_destruction_delivers_at_its_end
- host:redoubt-model::a_siblings_destroy_spares_its_neighbours
Some cases are rare in random runs, so fixed sequences pin them. The IPC contracts are written from the completion table, not from the model’s output. They cover:
- every row of the completion table and the rollback of a reply that cannot be written, for
R13 (one outcome per call); reply masks that are positional, not a count
(
model/tests/current_contracts.rs); - a timeout wakes without preempting, a timeout and a budget deadline at one instant expire in
that order, budgets with no free weight are refused a process, and the pick and switch into a
budget are billed to the budget picked, not to the one whose thread blocked before it; at two
harts, a budget whose last thread ends on its hart raises the floor to no pass of its own
(
R12EmptyHoldsFloor), and a heavy budget whose slice ends before any other floor raise saw it run is capped by the raise its own slice end makes (R12RequeueWaitsForCap), and one whose running thread blocks while its sibling waits is not, so the sibling holds the floor (R12BlockLeavesAsRequeued); - a PID is reused only after its exit notice is received or dropped;
- a destruction delivers nothing until its end: a notice owed during it goes to the receiver that
survives, not to one the destruction ends later, and a send stamped with the dying budget fails
with
Dead; - a destruction among live siblings ends exactly its own subtree: sixteen children of one parent
with a process each, every one reporting to one endpoint, end as a sponsor’s leases do (the
process exits 0, the notice is taken, the budget destroyed), in five orders, and after every end
each other child still holds its budget, which counts its one process, and its process
(
model/tests/destroy_contracts.rs); - DMA frames stay held through
unmap, return to the pool only after a confirmed reset, and are quarantined, with the co-holder’s, when a reset is not confirmed (model/tests/dma_contracts.rs); map_fixed’s ranges, flags, overlap and cost checks (model/tests/map_fixed_contracts.rs).
The mutation check runs the IPC and scheduling contracts first, for every variant, and a steward scenario for each of the three breaks the steward families catch only after many seeds (mutations).
Mutations
Status: built · tested: host:redoubt-model::every_rule_has_a_mutation, host:redoubt-model::mutations_are_caught, host:redoubt-model::declassify_unfit_scenario, host:redoubt-model::one_cursor_scenario, host:redoubt-model::agent_other_set_scenario, host:redoubt-model::end_lease_admitted_scenario
A mutation is one deliberate break planted in the model. Each variant of enum Mutation
(model/src/mutation.rs) breaks one rule, at the sites in the model marked
self.broken(Mutation::...): one site for most variants, two or three where the rule is kept in
more than one place, and a direct comparison with the mutation for AbandonNoticeMissing and
R11LendStaysMapped. With no mutation, the model is the specified kernel.
Mutation::ALL lists all 176 variants. Mutation::rule() returns the ID each one breaks, as in
the table below; the steward’s variants, named Policy..., break the server rules the steward
model checks. Each of those but four is one broken entry of the core’s Policy table
(mutation::policy), since the crate that ships has no mutation switch; the other four break the
model’s embedder: its entropy, its admission, a volume’s write check and the server’s start.
every_rule_has_a_mutationrequires at least one variant for every kernel rule the model holds (each R row of the table below, before the steward’s) and for I16.mutations_are_caughtplants each variant in turn. It runs the scripted IPC and scheduling contracts, the variant’s steward scenario if it has one (below), then every property family, the one that pressures the rule first (scheduler_fairnessfor R12, the steward families for thePolicyvariants,floodfor the open-call limit,steward_noninterferencefor the breaks that show as one domain’s work in another’s view,R2OneCursor’s turns among them), up to 20,000 seeds each (20 for the flood) but 500 in each steward family. A variant not caught within those caps fails it, by name: one the steward families catch only past seed 500 is caught too late, and so is a failure, not a wait. It prints the property and seed that caught each one.REDOUBT_MODEL_MUTATIONSnarrows the run to the variants whose names contain one of its comma-separated words, or to the one a word names whole. The bench casemodel-mutationsruns it once per variant, each a job of its own in release, the names coming fromcargo run --example mutations(fanout);model-host-testsskips it. Every variant is caught within the caps, each job in under 20 seconds on one core.- Four variants whose state the steward families’ random search builds only by chance, or too
slowly for the bench’s deadline, have a
directed scenario each (
model/tests/common/contracts.rs), a few operations that build the state the break needs, tried before any family. Each is a named test that holds on the specified model and catches its variant with the property it names, so these catches are deterministic and the random search is the backstop:declassify_unfit_scenario: a {7} session writes an item one byte overDECLASSIFY_MAX, or one with a control character, asks to declassify it and its owner approves; without the item’s check, P6 sees it copied out. The random search:steward_policy’s seed 1,418.one_cursor_scenario: alice’s and bob’s unlabelled sessions and bob’s {9} session call the server, which takes alice’s call, then the {9} call, then bob’s and alice’s next; with one cursor over every group the last round starts after the {9} group with the vault’s work and after alice’s without, so P10 sees the unlabelled calls taken in another order. The random search:steward_noninterference’s seed 944.agent_other_set_scenario: a {7} session asks for an unlabelled agent and its owner approves; without the own-set check, P10 sees the request in the unlabelled audit view. The random search:steward_noninterference’s seed 48.end_lease_admitted_scenario: alice’s session starts an agent, the two fill her domain’s pending requests, and the session ends the agent; with the embedder ignoring the tables’ ahead-of-admission mark, the full domain refuses it and P13 sees a sponsor that could not end its agent. The random search:steward_policy’s seed 36 andsteward_noninterference’s seed 126.
| ID | Variants | What they break |
|---|---|---|
| R1 (flow) | R1SkipLabelCheck, R1ExitNoticeIgnoresLabels, R1UsageIgnoresLabels, R1UsageExemptBySystemTarget, R1ExitExemptBySystemExiting, R1ChecksReceiverNotOwner, R1SenderClassFromStamp | the label check on messages, exit notices and usage reads, and which side’s class exempts it |
| R2 (fair waiting) | R2FifoAcrossAccounts, R2NoWaitCap, R2KeyByAccountOnly, R2KeyByStampLabels, R2SystemCallersShareGroup, R2OneCursor | turns, the cap, how groups are keyed, and one label set’s turns apart from another’s |
| R3 (lends and abandoned calls) | R3UnmapAbandonedLend, R3ChargeStaysWithCaller, AbandonNoticeMissing, AbandonNoticeRepeated, BadRecordConsumesNotice, EndpointDestroyNoticeKept | an abandoned lend’s mapping and charge; the notice, once, kept for a good record, and none on a destroyed endpoint |
| R4 (delivery) | R4IgnoreMaxTransfer, R4OverdrawOnDelivery | max_transfer; paying for a delivery |
| R4a (open calls) | R4aOpenCallsPerThread, R4aFullTakesNothing, OpenCallsUnlimited, ReceiveDropsOpenCalls | the limit, per process; sends and notices at the limit; keeping open calls across a receive |
| R4b (a server dies) | R4bDeadServerFakesReply | Dead for a dead server’s callers |
| R5 (interrupts) | R5NoMaskOnFire, R5NoUnmaskOnReceive, R5BadRecordConsumesInterrupt | masking on fire, unmasking on receive; an interrupt kept for a good record |
| R6 (charging) | R6ChargeAncestors, R6OwnPageChargedToItself, R6EndpointsFree, R6PageTablesFree, R6EmptyTableKept, R6OpenCallsFree, R6ProcessObjectFree, R6ProcessObjectChargedToBudget, R6LendChargedOnce, R6RootPageUncounted, R6PidUncountedAtEnd | who pays for each object, and for how long a page table; root’s own page; how long a PID counts |
| R7 (carving) | R7NoCarveCheck, R7CarveToZeroFree, ProcessInWeightlessBudget | carving within free limits; no process in a budget with free weight 0 |
| R8 (accounts) | R8AccountFromArgument | inheriting the parent’s account |
| R9 (stamps) | R9ReceivedHandleRestamped, R9MintStampsCaller, R9MsgStampIsSenderBudget | which budget a handle is stamped with |
| R10 (destruction) | R10KeepForeignHandles, R10KeepCarvedLimits, R10SpareDescendantProcesses, R10ExitNoticesOutlivePayer, R10RevokedMessageDelivered, R10RevokedCallAnswered, R10SweptHandlesDropped, R10CreatorDeathSparesProcess, R10HeldPidsDropped, R10ReapDestroysParent, R10ReapKeepsCarve, R10ReapSkipsGrandchildren, R10DeliveredMidDestruction, BudgetDeadlineIgnored | everything a destruction reaches, nothing delivered before its end, a deadline destroying the budget, and a reap destroying one child and keeping the budget |
| R11 (memory) | R11NoZeroing, R11SetFlagsAllowsWx, R11SetFlagsAllowsWriteOnly, R11LendStaysMapped, R11MapFixedSkipsOverlap, R11ExecOnDeviceMemory, R11ProcessMapSkipsFlags | zeroing a frame free since boot, W^X per mapping and per frame, write without read, lends unmapped, map_fixed never replacing, process_map’s own flag check |
| R81 (frames in flight) | R81InFlightAllocatable, R81CommitUnzeroed | no allocation of a frame in flight, caught by the frames’ own check (a free frame not zero, or also in flight), not by a page read stale; a frame freed only zero |
| R12 (scheduling) | R12PriorityById, R12IgnoreWeight, R12WakeBanksCredit, R12TieQueuedFirst, R12RequeueAhead, R12RequeueLifo, R12PreemptOnWake, R12TimeoutWakePreempts, R12NoFloorWhenIdle, R12ShortRunsFree, R12DropRemainder, R12ExitRunsFree, R12DestroyDropsDebt, R12CreateAtFloorOnly, R12LiftByMax, R12StrideWeightIsLimit, R12UnnormalizedLift, R12LiftCountsEntryWait, R12FoldAtNewWeight, R12NoMinimumCharge, R12DeadlineWorkUnbilled, R12RescaleOnlyOnReturn, R12SliceCountsExitWork, R12TimerWorkUnbilled, R12SwitchBilledToPrevious, R12CappedHoldsFloor, R12CapOnce, R12AllCappedHoldsFloor, R12UncapBanksCredit, R12UncapForfeitsWait, R12EmptyHoldsFloor, R12RequeueWaitsForCap, R12BlockLeavesAsRequeued, R12OneRunnerPerBudget, R12SpreadChargesOnce | one flat queue, charging, the floor, ranks, preemption, the slice as user time, inheritance at create and destroy, the capped floor and the uncap lift across harts, a budget on several harts at once and each of its runners charged |
| R13 (one outcome per call) | IpcWrongLend, IpcDropPartial, IpcFalseDelivery, IpcSkipOutputCheck, IpcLeakRollback | the lend disposition, a partial reply, delivered, the completion-time record check, rollback |
| R14 (unforgeable sender) | MsgNoLabels, MsgBadgeZero, MsgAccountZero, MsgIdsGlobal | the attached labels, badge and account; message ids per receiving process |
| R18 (device authority) | R18DeviceByNumber, DeviceInfoWrongKind | a device reached only through a handle to it; device_info naming the device the handle names |
| R20 (PID reuse) | R20NoticePidReused | no PID reused while a notice names it |
| R21 (crash blame) | BlameNobody, BlameNewestCall, ExitWithOpenCallsNotFaulted, CurrentNeverSet, ReceiveKeepsCurrent, ServeIgnored, ExitEndpointBadged, ExitNoticeDroppedIfNoReceiver | blaming the current call’s sender; how receive and serve set the current call; a badge-0 exit endpoint; a notice kept until received |
| R22 (range cost) | R22MapFixedWalksFirst | map_fixed’s pages refused by arithmetic before any walk |
| I3 (minted badges are non-zero and narrow) | MintFromUnservedMessage | minting only from an open call of the caller’s own thread |
| I4 (only badge-0 handles receive) | ReceiveWithBadgedHandle | receive through a minted handle |
| I6 (labels only grow downward) | LabelsAddedByParentClass | adding labels needs a system-class caller |
| I8 (class and account inherited) | ClassNotInherited | a child’s class is its parent’s |
| I13 (every blocking call returns by its timeout) | TimeoutIgnoredWhileOthersRun, ExpireBudgetsFirst | timeouts expire while others run; at one instant, timeouts before deadlines |
| I16 (DMA pages reset before reuse) | DmaFreeBeforeReset, DmaQuarantinedSlotCountsAsReset, DmaUnmapFrees, DmaQuarantineChargeDropped, DmaQuarantinedDeviceUsable, DmaResetClearsCoHolderReach | pooling only after a confirmed reset, co-holders included; unmap keeping DMA frames; quarantine’s charge and sweep |
| R33 (no server holds a system budget) | PolicyServerHoldsSystemBudget | the steward starts with no system-class budget handle |
| R35 (key separation) | PolicyLoginWithKeydKey, PolicyApproveWithLoginKey | no login with a key keyd holds; no approval channel with a login key |
| R36 (unpredictable ids) | PolicySequentialIds | random ids |
| R37 (vault non-interference) | PolicyVaultWithoutOwnership, PolicyWriteUp, PolicyLabelledStartsAgent, PolicyAgentOtherSet, PolicyAuditUnfiltered, PolicyContextsAcrossSets | vault ownership, no write up, a labelled session starting nothing and asking for agents only in its own set, filtered audit reads, a session’s contexts listed and ended only in its own set |
| R38 (out-of-band approval) | PolicyApproveIgnoresHash, PolicyApproveOtherChannel, PolicyShowLabelledToAll, PolicyRenderNotWhitelisted, PolicyLabelledFreeTextShown, PolicyNotifyLabelledToAll, PolicyNoPendingCap, PolicyDeadSessionRequestsKept | approval of the frozen request on the channel that rendered it, by its owner; what the screen and the notices show; the pending cap |
| R39 (leases end) | PolicyUnboundedLease, PolicyEndLeaseAdmitted, PolicyEndLeaseFromVault, PolicySponsoredEndedByAnyone, PolicySponsoredVaultLogin, PolicyNoFairShare | bounded leases, ending always admitted and only from the sponsor’s unlabelled session (a sponsored principal’s session too, and never a labelled login of its), a fair share |
| R40 (blame by label set) | PolicyBlameNoWindow, PolicyNoLockout | the blame window and the lockout |
| R42 (one approved item) | PolicyDeclassifyLive, PolicyDeclassifyWithoutReader, PolicyDeclassifyFromUnlabelled, PolicyDeclassifyUnfit | a snapshot, through a reader or writer with the labelled side’s labels, from a session with them, of printable text within DECLASSIFY_MAX |
| R79 (one session per context) | PolicyContextTwice | a second login to a live context of the same name in its domain |
| R80 (one channel per context) | PolicyTakeoverBeforeAuth, PolicyBothAttached, PolicyStaleCloseDetaches | a takeover only after the login’s key is checked; the old channel let go before the new one attaches; a channel let go no longer named by its attachment id |
| R82 (the context cap) | PolicyNoContextCap, PolicyCapAcrossSets | the cap, and counting only the login’s own label set |
| R83 (idle contexts end) | PolicyIdleNoBound, PolicyIdleClockKept | the idle bound, and an attach clearing the clock |
Five of the steward’s former variants are retired, because the core’s types or another rule’s
keeper keep their rules and they cannot be written: blame or a pending cap counted per account and
a session carved from another label set’s sub-budget each need a second domain,
R41 (narrowing by revocation scope)’s
narrowing to a session’s budget needs a budget where the core’s Connect takes only a scope, and
an approval granting more than its approver holds needs a request whose labels its principal does
not own, which owns_labels refuses at entry and at submission (PolicyVaultWithoutOwnership)
(guards and effects).
Six rules are outside the model and have no variant: R15 (verified boot), R16 (image confinement), R17 (fail closed), R19 (kernel W^X), R23 (no test channels) and R24 (SUM and MXR clear). The model has no loader, no bundle, no kernel mappings of its own and no test build.
Traces
Status: built · tested (4)
- host:redoubt-model::traces_round_trip
- host:redoubt-model::a_rule_breaking_kernel_fails_replay
- host:redoubt-model::the_example_trace_is_what_the_model_does
- host:redoubt-model::hostile_traces_are_refused_cleanly
A trace is a run as ASCII text: the boot, then each event with the result the model gave.
trace::record writes one, trace::parse reads it back, and trace::check replays it on the
model and requires every line to match. The module is no_std and works on &str, so a
replayer running on the kernel can use trace::tokens as it is.
| Line | Meaning |
|---|---|
redoubt-model-trace 2 | the header; any other version is refused |
boot, costs, device | the boot budgets’ limits, the cost table, and each device object in init’s handle order |
start p:1 t:1 | init’s first thread |
do p:P t:T <call> <arguments> -> <result> | a call and its result, or blocked |
read, write, exec | a user load, store or fetch of one word: ok or fault |
fault, irq N, tick DT | a thread faults; a line is raised; time passes |
record p:P t:T <state> | a thread’s record changes validity, as when another thread of its process remaps it |
note | a process or thread the kernel created, naming its PID or TID |
wake p:P t:T -> <result> | a blocked call returns |
Names mark the values the kernel chooses, so a replayer can bind each to its own: p: a
PID, t: a TID, h: a handle index, m: a message id, a:BASE+OFF an address OFF bytes
into a region the kernel placed, pa: a physical address, tm: a time read (compared only for
order). A call’s result carries the three facts of R13: call status=... lend=none|returned|consumed reply=absent|present, with the reply’s words and handles only when present. A reply’s result is
ok reply delivery=delivered|discarded mask=N.
Results alone miss a kernel whose state has diverged but not yet shown it, such as a handle that
should have been revoked but is never used again. So random traces end with an epilogue
(check::epilogue): time passes until every finite timeout has returned; each process with a
runnable thread reads one word of every readable page it maps and the usage of every budget it
holds; init destroys the budgets it made, one at a time, reading usage after each; and each
live process closes every handle index from 1 to EPILOGUE_HANDLES (64). A thread blocked for
ever, a budget’s account (it travels only in messages) and an IRQ source’s mask stay unseen.
model/traces/lender-dies-mid-call.trace is the checked-in example: a client lends two pages to
a system server, and init destroys the client’s budget while the server holds them (R3). Its
end:
do p:61415 t:3 call h:1 [1,2,3,4] [] a:0x60000000+0x0@2 forever -> blocked
wake p:38622 t:2 -> ok message call m:1 badge=5 account=1001 labels=[] words=[1,2,3,4] handles=[] buffer=[lend,a:0x40000000,2]
read p:38622 t:2 a:0x40000000+0x0 -> ok word 42
write p:38622 t:2 a:0x40000000+0x0 99 -> ok
do p:1 t:1 budget_usage h:13 -> ok usage [32,10,1,1,50,0]
do p:1 t:1 budget_destroy h:12 -> ok
read p:38622 t:2 a:0x40000000+0x0 -> ok word 99
do p:1 t:1 budget_usage h:13 -> ok usage [32,10,1,1,50,0]
do p:38622 t:2 receive h:1 0 0 -> ok abandoned m:1
do p:38622 t:2 reply m:1 [0,0,0,0] [] -> ok reply delivery=discarded mask=0
do p:1 t:1 budget_usage h:13 -> ok usage [32,5,1,1,50,0]
do p:38622 t:2 receive h:1 0 0 -> ok exit p:61415 cause=killed code=0 blamed=0 blamed_labels=[]
Figure: the end of the example trace. The server keeps the lent pages after the lender’s budget is destroyed, is told the call was abandoned, and frees them by replying.
The tests:
traces_round_tripwrites 3,000 random sequences, reads each back to the same operations and replays it.a_rule_breaking_kernel_fails_replaylets a mutated model stand in for a kernel that breaks a rule. It replays 2,000 random traces with epilogues, plus seven scripted ones (a partial reply, current-call blame, the equal-instant expiry order, a quarantined device named again, a record gone bad as an abandoned-call notice or an interrupt comes, and a notice owed during a destruction), and requires some trace to fail for every variant that results can show. Not required, because results cannot show them: thePolicyvariants; R12’s, since scheduling shows only in timing;R5NoMaskOnFire, since every result is the same and the model’s own R5 check catches it; the three open-call-limit variants, since random traces never reach the limit (the flood does); andDmaResetClearsCoHolderReach, which only the I16 ghost check sees.the_example_trace_is_what_the_model_doesrebuilds the example and requires the same text.hostile_traces_are_refused_cleanlyfeeds fixed worst cases and 500 randomly damaged traces to the replayer. Each worst case must give an error; a damaged trace must not panic, and may still replay (a damaged line can be one the model accepts).
flowchart LR
S[seed] --> G[generator]
G --> K[kernel model:<br/>one step]
K --> C[checker:<br/>invariants, ghost]
C -- fails --> X[shrink and<br/>print the trace]
K --> R[trace::record]
R --> T[trace text]
T --> P[trace::check:<br/>replay on the model]
T -.-> Q[replayer on the<br/>real kernel]
Q -.-> V[every line<br/>matches]
Figure: how a run becomes a trace and where it is replayed. Solid parts are built; dashed parts are planned.
The steward model
Status: built · tested (6)
- host:redoubt-model::steward_policy
- host:redoubt-model::steward_noninterference
- host:redoubt-model::random_lineage_sequences_and_deliberate_rule_break
- host:redoubt-model::audit_authority_binds_purpose_signer_domain_length_and_every_byte
- host:redoubt-model::confined_read_down_and_owner_approved_one_item_snapshot_push
- host:redoubt-model::approve_and_deny_authenticate_direct_channels_before_any_effect
model/src/steward.rs embeds the steward’s policy core, the crate that ships
(the policy core), on the kernel model, and
model/src/policy.rs holds its properties. init creates the shared server’s endpoint and the
steward’s own, and starts the steward in system, with handles to users, system and both
endpoints. The steward starts the server, a system-class process standing in for littlefsd that
holds no budget handle, receiving on the shared endpoint. Every principal’s budget and its fixed
sub-budget per label set is a budget_create at boot. Then the core decides every call, and the
embedder runs each batch it returns as calls on the kernel model, in order, stopping at the first
failure, and reports it back. So each session and each agent’s lease is a budget carved from its
domain’s sub-budget, a revocation scope inside it, connections to the steward’s endpoint and the
server narrowed to that scope, and a process holding them. Sessions’ work is real calls to the
server; crash blame comes from the kernel’s exit notices of the server, and a session’s end from
those of its process. So every kernel check runs under everything the policy does. The embedder
keeps only its own half: admission, the volumes and their write check, an ideal keyd signing
every audit record, and entropy, one stream per domain for the events that draw ids. The policy is
described on the steward.
| Property | What must hold |
|---|---|
| sessions | a session’s or lease’s budget is carved from its domain’s sub-budget, with the principal’s account and the domain’s labels, which the principal owns |
| login | a login used one of the principal’s login keys, never one keyd holds |
| approvals | an approval channel opened with the principal’s approval key; an approval was answered on the channel that rendered the request last, named the hash it showed, which never changed, and granted no label the approver lacks |
| screens | a channel sees only its own principal’s requests, and labelled ones only if it owns every label; rendered text is printable ASCII with capped free text; a labelled request shows none of its free text; an approval-waiting notice reaches only sessions whose labels include the request’s |
| pending cap | at most PENDING_CAP (4) pending requests per domain, all from live sessions and agents; each holds at most its fair share |
| crossings | a declassification comes from a session with exactly the item’s labels and a push from an unlabelled one; what is copied out or pushed is exactly the snapshot taken at submission, a declassified item at most DECLASSIFY_MAX bytes of printable text, through a reader or writer budget with exactly the labelled side’s labels |
| crash blame | a domain’s sessions and leases end exactly when three server crashes blamed on it fall within ten minutes; nothing else is touched; nothing of it starts for the next ten minutes |
| labelled sessions | a labelled session or agent starts nothing; it only submits requests |
| leases | an agent’s budget has a deadline at most MAX_LEASE (24 hours) away; an expired lease is gone |
| non-interference | a vault session’s work changes nothing an unlabelled session observes: its results, its requests’ ids, the usage of users and of every principal’s budget and unlabelled sub-budget, and the audit records an unlabelled reader may read |
| writes | every write to an item is through a budget with exactly the item’s labels; the steward’s own write goes only to the unlabelled volume |
| system budgets | only init and the steward hold a handle to a system-class budget; a session’s connections are narrowed to a revocation scope inside its budget |
| leases end | a lease’s sponsor can always end it from an unlabelled session, and nothing else can |
| attachments | a context’s console is attached to at most one channel, exactly while it runs and under the one id that names it; a login attaches to a live context only with the principal’s key; a close of a channel the context was taken from changes nothing; a note a relay writes is printable |
| context cap | no domain holds more live contexts than its principal’s cap, and a login is refused cap only when its domain holds that many |
| idle contexts | on the timer, a detached context ends only once its principal’s bound has passed since its idle clock started, and has ended once it has; an attached one never ends on it |
| context commands | a session lists exactly its own domain’s live contexts; only an attached context lets its channel go; end_context ends a context of the caller’s own domain and no other, and always can |
steward_policy runs 20 to 160 random policy operations per seed and checks the properties and
the kernel’s checks after each, and that the steward never exits. Its manifest gives alice’s top
18 processes, so a label set’s share, a third, holds three sessions, one over the cap of two
contexts: some top must hold one more session than the cap, or the kernel’s refusal of the third
login for processes stands in for the cap’s and the cap rule is invisible to the model
(PolicyNoContextCap went uncaught when the one principal with a single label set, carol, became
sponsored and her sessions moved under alice’s share). Bob’s top keeps 12, a share holding two,
so his domains still fill and a start fails there. steward_noninterference runs
one sequence twice, the second time without the vault sessions’ work (their item writes, requests
and calls to the server, and the owner’s approvals and denials of their requests), and compares
everything an unlabelled session observes, the order the server takes its calls in included. The
other host tests pin connection lineage (a delegated badge shares its root’s pending share,
checked by an oracle that walks parent edges itself, with a deliberate break it must catch), audit
signatures bound to purpose, signer, domain, length and every byte, a confined session refused a
read of lower data while its owner pushes one approved item up, and approve and deny refused
on a channel that did not open, before any effect.
What the steward model leaves out: SSH (a login is “this key for this user name”), the approval
terminal (a channel is “a connection that authenticated with this key”), and real entropy: the
words come from a keyed mixer, where the server draws them from the kernel’s random. Audit
signatures are ideal tokens, and no chaining, truncation detection or ordering is claimed. The
volumes are maps the steward’s batches read and write directly; its reader and writer budgets
stand for the processes that would. The steward’s constants are the core’s: PENDING_CAP (4),
DECLASSIFY_MAX (256 bytes of printable ASCII), FIELD_CAP (64 characters), MAX_LEASE (24
hours), and the blame window (3 crashes in 10 minutes).
Where the model meets the kernel’s code
Status: built · tested: host:redoubt-stride::the_crate_and_the_model_agree, host:redoubt-stride::a_broken_model_disagrees, host:redoubt-ipclist::the_groups_follow_the_model
redoubt-stride (libs/stride) holds the stride arithmetic and ranks that the kernel links. Its
differential test drives the crate, wired as the kernel’s sched.rs calls it, and the model’s
Scheduler through the same random sequences: budget creations and destructions (a leaf whose
threads blocked first, the budget on the CPU, a whole subtree bottom-up), wakes, blocks, runs,
slice ends and preemptions, each on a hart drawn at random. Over 3,000 seeds at each of 1, 2 and
4 harts every pass, entry, remainder, tie, queue membership, the floor, the tie counters, the cap
set and each hart’s running thread must agree after every step; at 4 harts some 360,000 of the
steps hold two capped budgets or more. a_broken_model_disagrees shows the comparison bites:
with any of 30 of the 35 R12 variants planted in the model, some sequence disagrees (the ten of
several harts at 2 or 4 harts). It leaves out R12TimeoutWakePreempts, whose
site is the kernel model’s timer path, not the scheduler, and R12SliceCountsExitWork,
R12DeadlineWorkUnbilled, R12TimerWorkUnbilled and R12SwitchBilledToPrevious, kernel work
around a run (the exit work before a slice starts, a deadline’s destruction, a timer’s expiry, the
switch into a budget), which the differential does not drive; the model’s own checks catch
all five. The bench case stride-host-tests runs both tests. This compares one kernel crate with the
model, on the host; the kernel’s own use of it runs in boot cases
(scheduling).
redoubt-ipclist (libs/ipclist) holds the kernel’s IPC lists: the links of what waits on each
endpoint, device and budget, of the exit notices owed on an endpoint and the processes that
report there, of the process objects charged to each budget and counted in it, and of each
process’s waits with a deadline; R2’s groups in the order their turns
fall due, the expiry’s sort, and each list’s audit. The kernel links it and keeps every rule in
message.rs. the_groups_follow_the_model states R2 beside it as the model’s next_sender and
served do, and in 200 seeds of 300 random queues, leaves and takes each, the two must pick the
same message, for a receiver below MAX_OPEN_CALLS and for one at it. The host-tests case runs
it.
Replaying traces on the real kernel
Status: planned · M3 (agents, approvals and the attack suite)
A bench case boots the real kernel with a replayer program and a set of model traces as files
in the bundle (test bench). The replayer reads each trace with
trace::tokens, binds each name to the value the kernel gives, makes each call from the process
and thread the line names, and compares every result, note and wake, the epilogue included. A
trace passes only if every line matches. A line the replayer cannot drive is a failure, never a
pass, and a trace that exercises a question the design has not settled does not count until it
is settled. Usage is compared under the model’s placement of page tables. Traces include the
timer-driven rows of the completion table (a taken call timing out, a budget deadline firing),
run on the real timer. The case passes when 100,000 model traces replay with identical results.
Replay is what turns the model from a reference into evidence about the kernel.
Open: how the replayer gets init’s boot handles and devices without an interface that exists only for testing; how tick, irq, fault and record lines are produced on the real machine; the boot sizes (the model’s fixed limits against the kernel’s, which follow RAM); map_device’s result (the kernel returns the address and the length, the model only the address); a receive record that passes decoding but faults when written (Record::CopyFault), which has no kernel counterpart and stays out of traces; completion races between harts, which a sequential trace cannot express; scheduling and IRQ masking, which results do not show.
Residual risks
- The model checks its own abstraction. Properties that hold and mutations that are caught show that the model agrees with itself and that its checks bite on it. They say nothing about the kernel. No trace has been replayed on the real kernel, so every “attacked only in the model” on the kernel pages rests on the model and the kernel agreeing, which nothing has shown. A model that has drifted from the design keeps passing its own tests; only replay or a reader finds the drift.
- Host tests do not reach the kernel’s boundaries. Timer-driven cancellation, the kernel’s
locking and completion races between harts are outside the model: a step is atomic and time
is a counter. Passing model runs establish none of them. The kernel crate itself has no host
tests: its binary says so (
test = false), and its rules are tested on the target by the bench. - One boot case copies model results by hand.
tests/programs/src/bin/budget-test.rsruns a short budget sequence whose expected results were read off the model. Nothing re-derives them from the model, so a change to the model leaves them stale without a failure. - Finite worlds. The default run is 65,020 sequences on small boots (the testing boot’s
roothas 1,024 pages and 24 processes), and random record changes target blocked calls only. Sizes and mapping geometry bound what the runs explore. The million-sequence run is a separate test that the bench does not run. - The steward model’s embedder is the model’s own. The core it runs ships, but the server’s embedder (transport, admission, running batches) is not this one. Its entropy is ideal, and the non-interference comparison leaves out a server crash on its own and one a vault’s call causes: which call such a crash blames, and when the server takes the calls before it, is service timing, a stated residual of R37 (vault non-interference).
- No property checks that a takeover tells the old channel. The attachments property checks
that every note a relay is given is printable, not that a takeover gives the old channel one, so
a core that let the old channel go in silence would pass; no mutation drops the note. The
server’s host test
a_login_to_an_attached_context_takes_it_overand the bench’ssteward-context-logincheck the note. - The steward families reach no failed crossing, and no login to a starting context. Neither
family, searched to 20,000 seeds, makes a declassification’s copy out or a push fail, so the
core’s effects for a failed crossing (
audit_copy_failed,audit_push_failed,pass_failure) never run in the model and no property is checked on their outcome. The model runs a session’s batch to its end within the login, so no second login finds a context still starting, and thein_userefusal (refuse_in_use) runs only in the core’s host tests. The model has no console session, so every session it detaches is a context and the ruleis_contextnever refuses; its refusal runs only in the core’s host tests too.steward_policynever calls the audit filter (audit_visible), which onlysteward_noninterference’s audit view reaches. The counts are set from what the families reach (property families), which bounds what a lower count gives up, not what the generator never builds. The four breaks whose state the random search builds only by chance, or too slowly, (PolicyDeclassifyUnfitat the policy family’s seed 1,418) are caught first by directed scenarios, which pin the state each break needs rather than show that the generator finds it. - Rules outside the model (R15, R16, R17, R19, R23, R24) have no model check at all; their boot cases are their only attack.
Why
- Independent of the kernel’s source. A model that shared code with the kernel would share its bugs. Its one dependency is the steward’s policy core, which it embeds so that the steward’s families attack the code that ships. Otherwise the model can be read side by side with the design, and every failure is reproduced from one seed.
- Ghost state apart from the kernel model. The checks read facts recorded at each event from the primary objects, never the kernel model’s own counters, so a wrong update cannot also correct the check that should catch it.
- Mutations, because a property can hold for the wrong reason. A check that nothing reaches passes every seed. A planted break that no property catches names the gap, and every rule keeps at least one planted break so that a weakened check shows up as a surviving variant.
- Text traces, with an epilogue. Text is easy to diff, to shrink and to carry into a bundle,
and a
no_stdparser serves the model and a replayer alike. The epilogue exists because a kernel can diverge silently; probing memory, usage and handle tables at the end makes the divergence show in a result. - The steward on the kernel model, not beside it. Running the policy on real modelled calls means every kernel invariant holds under everything the policy does, and non-interference is judged on what the kernel actually returns.
The servers
Everything the kernel does not keep runs as an unprivileged server: drivers, file systems, the network, keys, logins and policy. A server is a process that receives on an endpoint and answers calls. It reaches hardware only through device handles, other servers only through endpoint handles, and the kernel only through the system calls every process has. This page is the map: who starts whom, which server is trusted with what, how servers apply labels, how a client gets a connection of its own, what happens when a server crashes, and which capabilities each server holds.
Purpose
The kernel enforces labels only between user-class budgets
(R1 (flow)). The servers every principal shares run in the system
class, where the kernel does not check, so their code is where separation between principals is
kept or lost. A reader auditing Redoubt needs to know, for each server, what it holds, whom it
serves, and which rule stops one client reaching another’s data through it. The pages below this
one take one server each; this page states what they share.
The server graph
Status: planned · M1 (sessions over SSH, kept apart)
The loader starts only the kernel and init (boot). init reads the boot
manifest and starts every system server from the bundle, through the loader stub
(init). It then starts the steward, which holds the users budget and starts every
session and agent. Nothing else starts a server.
flowchart TD
L[loader] --> K[kernel]
K -.-> I[init]
I -.-> CO[consoled<br/>the console]
I -.-> BF[bootfsd<br/>/boot]
I -.-> BL[blkd<br/>the disk]
I -.-> VD[verityd<br/>one per verified volume]
I -.-> FS[littlefsd:volume<br/>one per flash volume]
I -.-> EF[erofsd:volume<br/>one per read-only volume]
I -.-> WF[walfsd:volume<br/>one per writable SSD volume]
I -.-> ND[netd<br/>the network card]
I -.-> IP[ipd:network<br/>TCP/IP]
I -.-> KD[keyd<br/>keys]
I --> ST[steward<br/>principals and policy]
I --> SS[sshd<br/>logins]
ST --> SE[sessions<br/>Elixir VMs]
ST -.-> AG[agents<br/>under leases]
Figure: who starts whom. Solid arrows are built; dashed are planned for M1 (sessions over SSH, kept apart), and agents for M3 (agents, approvals and the attack suite).
init starts the drivers and the servers that need no principal first (consoled, bootfsd,
blkd, each verityd, each littlefsd, erofsd and walfsd, netd, each ipd, keyd), then the steward
and sshd.
It keeps each server’s receive right, so a restarted server receives on the same endpoint (Restarts
and crash blame, below). The servers planned for later milestones join the same graph:
the resolver and gatewayd in M5 (self-hosted development), and
the package server and the supervisor in
M6 (persist, install, share).
Open: whether the supervisor of M6 (persist, install, share) takes over restarting from
init or runs beside it (supervisor).
Trust tiers
Status: planned · M1 (sessions over SSH, kept apart)
| Tier | Members | Trusted for | A compromise reaches |
|---|---|---|---|
| TCB | firmware, loader, kernel; blkd and netd while there is no IOMMU | everything | the whole machine |
| Trusted system servers | init, the steward, keyd, sshd | crossing principals: logins, keys, approvals, launching | every principal |
| Shared servers | consoled, bootfsd, each littlefsd, erofsd and walfsd, each ipd; later the resolver and gatewayd | serving many principals and keeping them apart by badge and label | the principals that server serves |
| Per-principal code | sessions, agents, native programs, a session’s piped | nothing beyond their own capabilities | that principal’s own capabilities |
- The DMA drivers are TCB. A driver that holds a DMA-flagged device handle can point a bus
master at any physical address (devices).
blkdandnetdare small and parse only their own device’s structures for that reason. - Every trusted system server is Rust. Everything whose compromise could cross principals
(the steward,
keyd,sshd) is written in Rust and runs outside the Elixir VMs. The BEAM is userland: a compromised session VM holds exactly its principal’s capabilities, like a native program. - A shared server is split by network or medium, so one parser bug does not reach every
principal: one
littlefsd,erofsdorwalfsdper volume, oneipdper network or trust domain. - Server work is paid by the server’s weight, not the caller’s; no time is donated. Each shared server therefore bounds the work one request can cause and admits by caps (scheduling, serving).
Open: none.
Labels
The rule servers apply
Status: built · tested (6)
- host:redoubt-rt::matches_the_set_definition
- host:redoubt-rt::properties
- host:redoubt-rt::labels_are_checked_on_every_request
- host:redoubt-rt::every_write_needs_equal_labels
- host:redoubt-rt::labelled_metadata_does_not_flow_down
- host:redoubt-rt::an_unlabelled_caller_cannot_reach_labelled_data_to_destroy_or_probe_it
Labels are information-flow labels on budgets and volumes. The kernel checks them between user budgets; system servers check them themselves, on every request, using the label set the kernel attaches to each message (R14 (unforgeable sender)). A system server applies one check, written once in the serving library (R25 (the label check)):
- Read an object only if the object’s labels are a subset of the caller’s: no read up.
- Write an object only if the two label sets are equal: no write down, and no blind write up. A write up could truncate or remove what the writer cannot read.
- Metadata is a read. A 9P qid and a
statare reads of their node: a walk into a node the caller cannot read is refused, and a directory listing leaves out the entries the caller cannot read. Otherwise every write in a vault would change what an unlabelled caller sees.
Label sets are compared as sets. Data enters a label only by being read down: a vault session reads it from where it is. Nothing writes into a label from outside it.
The labels are the kernel’s; the server trusts only them, never a label named in a request. Two budgets with equal label sets are one trust domain: a handle passed between them crosses nothing.
Who checks, and sinks
Status: built · partly tested: only ipd’s refusal of labelled callers is attacked in a boot; the skeleton’s checks are attacked in host tests · tested: bench:net-attacks, host:redoubt-rt::labels_are_checked_on_every_request
- Every 9P server gets the check from the skeleton, against the labels its file server reports for each node (serving).
- A sink is a server whose output leaves a principal or the machine. A sink is cleared for no
label and refuses every labelled caller before it reads the request.
ipdis one (ipd); innet-attacksa labelled caller’s connect reaches nothing on the network while its unlabelled twin’s does. - User-level servers get no exemption. A server in a
userbudget is under R1: a vault session cannot call an unlabelled user server. A service that must serve several label sets runs one instance per label set, or in thesystemclass. - A receive right is never handed across label sets. R1 compares a sender with the endpoint’s owner, not with whichever process receives, so a badge-0 handle given to a budget with other labels would deliver messages nobody compared with it. A system server that does this is at fault, not the kernel.
Labels on the running system
Status: planned · M1 (sessions over SSH, kept apart)
The steward creates every labelled budget, after an approval, and a child inherits its parent’s
labels (budgets). A volume has one label set, fixed by
the boot manifest or the steward and never read from the medium (littlefsd). The steward
mounts known-sensitive places (~/.ssh, credential directories) on the principal’s labelled
volume, and keys belong in keyd, which uses them without releasing them. sshd is the one
sink cleared for a label, and only on the channel whose owner authenticated it
(sshd); gatewayd is a sink cleared for nothing (gatewayd). Each shared server’s
page states what its objects are and which label set each carries, so that nothing a labelled
caller influences is visible to a caller without that label: littlefsd keeps state per volume,
sshd per channel, and the steward applies the check to its own records.
Open: none.
Connections
Status: built · partly tested: `init-boot` exercises fresh child connections on the machine; their isolation and disconnect behavior are attacked only against the runtime's fake kernel · tested (7)
- bench:init-boot
- host:redoubt-rt::a_launcher_gives_its_child_a_fresh_connection_and_disconnects_it
- host:redoubt-rt::new_connection_is_rooted_below_the_callers_root
- host:redoubt-rt::a_disconnect_frees_its_fids_and_every_connection_minted_under_it
- host:redoubt-rt::a_strangers_id_is_refused_like_one_that_does_not_exist
- host:redoubt-rt::copies_of_one_badge_in_other_accounts_or_label_sets_share_nothing
- host:redoubt-rt::self_minting_does_not_multiply_the_share
A capability to a server is an endpoint handle whose badge names the grant: for a 9P server, a connection with its own attach root and fid table. The pattern is one endpoint per service and one badge per client (objects).
- One badge, one client. Every copy of a handle carries the same badge, and the kernel gives a
server no per-process identity, so every holder of a copy shares one connection. So a
launcher never passes its own connection to a child. It asks the server for a fresh one with
new_connection(9P servers) orgrant(typed servers) and passes that. Otherwise a hostile agent started from Alice’s shell could read, close or wipe her open files. - A second line of defence. Servers key per-client state by badge, account and label set, so holders of one copied handle in different accounts or label sets never share fids.
- Disconnect, not a kernel notice. The kernel does not tell a server when a client’s handles
are gone.
new_connectionreturns a random connection id with the handle; only the client that received the id candisconnect(id), which frees that connection and everything minted under it. Anyone else, like an id that does not exist, gets the samenot_yours.
The serving library implements all of it once (serving).
Cleaning up after a child
Status: planned · M1 (sessions over SSH, kept apart)
A launcher disconnects a child’s connections, and releases its grants from typed servers, when it
receives the child’s exit notice (processes); the steward
does the same at logout and at a lease’s end. A child’s grants never outlive its launcher’s
(wire). The serving library provides the
disconnect it uses.
Open: none.
Restarts and crash blame
Status: planned · M1 (sessions over SSH, kept apart)
initrestarts every server that ends, on the same endpoint. Calls the server had taken getDead, and clients retry; senders still queued wait and are served by the restarted server (R4b (a server dies)). A server keeps no state across a restart: what a badge meant lived in its memory. It draws its first minted badge at random, so its clients’ old handles do not name its new grants (R27 (badge allocation)).- More than five restarts of one server within 60 seconds reboot the machine: fail closed (init).
- Crash blame. A server’s exit notice blames the sender of the call it was working on
(R21 (crash blame)).
initpasses the blame to the steward, and three crashes blamed on one account and label set within ten minutes end every budget of that account and label set (steward). A crash that blames nobody counts only toward the restart limit. - Session VMs are not restarted. A dead session is a logout.
Open: none.
Capability holdings
Status: planned · M1 (sessions over SSH, kept apart)
| Server | Receives on | Holds | Never holds |
|---|---|---|---|
init | the exit endpoint of every server | root, system and users; every device object and the Reset right; every server’s receive right; the bundle’s pages | network, user data, keys |
| steward | its own endpoint | users; a badge at bootfsd, each volume’s walfsd or littlefsd, erofsd and ipd, through which it asks for each session’s connections; a keyd grant for the audit purpose, from M4 (files in and out) | any key; a budget of a server |
keyd | its own endpoint | the keys the manifest names | a key a person logs in or approves with; the bundle key |
sshd | its own endpoint | the network through ipd; a keyd badge for the host key; the steward’s endpoint | any login key |
consoled | its own endpoint | the UART’s MMIO and IRQ handles | anything else |
bootfsd | its own endpoint | the public bundle entries, pushed by init | the bundle itself |
blkd | its own endpoint | the disk’s MMIO (DMA) and IRQ handles | anything else |
verityd | its own endpoint | a blkd range for its verified volume | a device; a write to the range |
littlefsd:volume | its own endpoint | a blkd range for its volume, or a verityd range for a verified one | another volume |
erofsd:volume | its own endpoint | a blkd range for its read-only volume, or a verityd range for a verified one | another volume; a write to the range |
walfsd:volume | its own endpoint | a blkd range for its writable volume on the SSD | another volume |
netd | its own endpoint | the network card’s MMIO (DMA) and IRQ handles | anything else |
ipd:network | its own endpoint | a netd connection | a budget; a labelled caller’s request |
piped | the endpoint its session’s launch made | the buffers of one session’s pipes | any other handle; another session’s bytes |
crossing | the endpoint the steward calls it on, and one connection to the labelled volume at its root, in a budget with exactly that label set | one item read or written (the steward) | a second call; anything outside its label set |
No server’s startup block carries a budget handle. A manifest that gives a server one is refused,
because a budget handle is a destroy right and a create right: a compromised ipd holding its
budget could create system-class children with any labels and any account. A server that must
revoke a grant on its own is given a revocation scope, a budget with zero limits used only to
be destroyed (init, steward).
flowchart LR
I[init<br/>root, system, users,<br/>Reset, receive rights] -. places disk handles .-> BL[blkd]
I -. places NIC handles .-> ND[netd]
I -. places UART handles .-> CO[consoled]
I -- hands users --> ST[steward]
I -. pushes public entries .-> BF[bootfsd]
I -. passes seeds .-> KD[keyd]
FS[littlefsd:volume] -. range badge .-> BL
EF[erofsd:volume] -. range badge .-> BL
WF[walfsd:volume] -. range badge .-> BL
IP[ipd:network] -. netif connection .-> ND
ST -. audit grant .-> KD
SS[sshd] -. host-key badge .-> KD
SS -- login and sessions --> ST
SS -- connections --> IP
ST -- connections --> FS
ST -- connections --> WF
ST -- scoped grants --> IP
Figure: the capabilities each server holds. An edge from init is a handle it places; any other
edge runs from a holder to the server its handle reaches. Solid edges are built; dashed ones are
planned.
Open: whether init keeps a copy of each device handle it places, so it can restart a driver,
and so stays a co-holder (devices).
Naming
Status: built · tested: bench:init-boot, bench:userland-boot
A file server is named for the format it serves, and its endpoints for the volumes: erofsd
serves EROFS and erofsd:system is the system volume; walfsd serves walfs and walfsd:data
is the data volume; littlefsd serves littlefs. The name says what parser stands between a
client and the medium, which is what
R47 (one volume per instance) bounds. Servers that
serve no format keep their role’s name (blkd, bootfsd, verityd). What the volume servers
share beside their formats, their arguments, their client of blkd’s range and the quota ledger,
is one crate beside the serving library, redoubt-fileserver
(serving), so a volume server’s own code is its format’s.
The network path
netd and ipd
Status: built · tested: bench:net-tcp, bench:net-attacks, bench:session-tcp-echo
netd, the network card’s driver, moves Ethernet frames between the card and one client, ipd.
ipd, the TCP/IP server, serves the /net tree to its callers, each scoped to the prefixes and
ports its badge allows (ipd, netd). A test program in the first program’s
place launches the real netd and ipd through the loader stub and starts the cases’ clients:
net-tcp carries TCP both ways through them, and net-attacks shows a scope’s refusals
from the bench’s own network peers. A session’s VM reaches ipd through the /net of its
namespace: gen_tcp over the tree’s files, by address
(beamlet; session-tcp-echo).
flowchart LR
S[a session's VM: gen_tcp over /net] --> IP[ipd]
P[a person's session] -. name-scoped TCP .-> R[resolver]
R -. pinned connection .-> IP
A[agent] -. gateway capability .-> G[gatewayd]
G -. TLS .-> T[tlsd]
T -. TCP .-> IP
C[client with a scope] --> IP
IP --> N[netd]
N --> V[virtio-net]
Figure: the network path. Solid is built; dashed is planned: the resolver and gatewayd
for M5 (self-hosted development), tlsd beyond M6.
The path for people and agents
Status: planned · M5 (self-hosted development)
Agents never get sockets. An agent holds gateway capabilities to gatewayd, which makes the
request itself, over TLS, to one allowed service, checks it and logs it
(gatewayd). Only people get TCP, and only by name: the resolver answers only the
names in the caller’s allowlist, and a connection is made by name and pinned to what the name
resolved to (resolver). The host’s and the cloud metadata addresses, the box’s
own services from inside, and inbound traffic other than SSH are always refused.
Open: whether TLS for gatewayd runs in a separate tlsd in M5 (self-hosted development) or
inside gatewayd until the web stack needs tlsd beyond M6.
Residual risks
- System servers are trusted to check labels. The kernel does not check a flow into or out of
a
systembudget (R1). A shared server that mixes two label sets’ data breaks label separation, and nothing below it can see that. - A dead launcher leaks its children’s connections. A launcher that dies without disconnecting leaves its children’s connections in each server until its own connection is freed; the leak counts against its own account and label set (serving).
- Server work is paid by the server’s weight. A client that floods a shared server takes that server’s share of the CPU from its other callers, never more (scheduling).
- The DMA drivers are TCB while there is no IOMMU (devices).
- Volumes are kept apart by placement. The image’s
initrunswalfsd:dataanderofsd:system, one instance per volume, so one volume’s data is out of another’s instance only becauseinitplaces each volume once (R47 (one volume per instance)).
Why
- Servers, not kernel code. A driver, a file system or a TCP stack in the kernel would put its parser in the TCB. As a server it holds only its handles, and a bug in it reaches only what those handles reach.
- One endpoint per service, one badge per client. Endpoints cost their owner a page each and are not reclaimed until the owner budget goes (objects); badges cost nothing and carry the grant.
- Servers keep no authority across a restart. A server that saved what its badges meant could be made to write itself a root badge. Endpoints outlive servers, so clients keep their handles; the grants behind them are re-made by whoever made them.
- Sinks refuse labels. Whatever enters a sink can leave the machine, so a sink cleared for a label would be a declassification nobody approved.
The serving library
Every Redoubt server links one serving library, redoubt_rt::server in libs/rt/src/server/.
It holds the rules a shared server must never get wrong, written once: admission (how much one
client may hold in the server), the label check, the connections and grants a server mints for
its clients, calls parked for later, connections with many requests outstanding, typed-message
dispatch, and a 9P2000 server skeleton. It
also finishes every call, so that a reply that never reached its caller undoes what the request
made.
Purpose
A shared server serves many principals from one system-class budget, where the kernel does not
check labels (R1 (flow)) and does not bound what one client makes the
server hold. So each server needs the same answers: whose request is this, may it read or write
that object, how much of the server may it use, and what happens to a grant whose reply was lost.
Written once, those answers are tested once and cannot drift between servers.
Interface
serve
Status: built · tested (4)
- host:redoubt-rt::serve_answers_calls_and_closes_what_a_send_brought
- host:redoubt-rt::serve_survives_abandoned_and_exit_notices
- host:redoubt-rt::serve_exits_ok_when_the_endpoint_dies
- host:redoubt-rt::serve_exits_receive_failed_on_any_other_error
A server that takes only calls runs redoubt_rt::server::serve, the one receive loop. Each call
goes to the server’s handler, which replies to it; a send is dropped and what it brought closed.
The endpoint’s death ends the server with OK, and any other failure to receive with
RECEIVE_FAILED (3). A server whose handler must still reply or undo after an error keeps a
loop of its own, closing a send’s handles with close_delivery: consoled, netd and ipd.
admit
Status: built · tested (17)
- host:redoubt-rt::the_key_is_the_account_and_the_label_set
- host:redoubt-rt::limits_are_per_key_and_per_resource
- host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share
- host:redoubt-rt::caps_are_big_enough_for_a_share_to_mean_anything
- host:redoubt-rt::buckets_are_bounded_so_caps_fit
- host:redoubt-rt::open_calls_leave_headroom
- host:redoubt-rt::caps_fit_the_budget
- host:redoubt-rt::released_keys_leave_the_table
- host:redoubt-rt::an_override_is_its_root_badge_s_alone
- host:redoubt-rt::an_account_override_is_its_unlabelled_bucket_s_alone
- host:redoubt-rt::overrides_are_sized_and_checked
- host:redoubt-rt::the_worst_order_never_passes_the_headroom
- host:redoubt-rt::fits_counts_overrides_at_their_caps
- host:redoubt-rt::a_connection_minted_for_another_account_is_admitted_there_from_its_first_request
- host:redoubt-rt::a_first_request_on_a_connection_its_account_cannot_hold_is_refused_and_it_stays_pending
- host:redoubt-rt::a_servers_budget_is_its_entrys_pages_less_its_heap_cap_and_stack
- host:testbench::every_servers_buckets_leave_the_open_call_headroom
Admission (libs/rt/src/server/admit.rs) limits what one client may hold in a server at once,
per kind of resource:
Resource | Counts |
|---|---|
InFlight | calls the server has taken and holds open (parked calls) |
Files | open files, or 9P fids |
State | any other per-client state: connections and grants minted for the client |
Requests | a multiplexed connection’s requests, each held until its answer is delivered (multiplexed connections) |
Pages | the pages their transfers brought, one per page however many requests it carries, held until the last of its requests too long for the words is answered: one that fits them is copied out as it is taken |
Requests and Pages are both outside the open-call headroom, since a request is a send, not an
open call, and each has its own share (R26).
- Buckets. Limits are kept per bucket, keyed by the caller’s account and label set
(
AdmitKey::of, the one place the key is made). Accounts, not badges or budgets, because both of those are cheap to create. With the label set, so that a vault session filling a server’s slots is invisible to its owner’s unlabelled session, which shares the account. Account 0 (no principal: everysystem-class caller) is keyed by badge as well, so one daemon cannot fill a bucket the steward needs. - A fair share per badge. Within a bucket each badge is a share, and the bucket is the
ceiling. A share may take one more of a resource only while it holds less than
pool / (n + 1)of it (and at least one), where n is the number of shares in the bucket holding that resource, itself included, and the pool is the cap less the bucket’s fixed costs (below). So an agent flooding its sponsor’s bucket alone gets half of it, and the sponsor can still take a third (R26 (admission fairness)). An account-0 bucket is one badge, so it has one share and the bucket’s cap is that share’s. - A fixed cost is outside the share. What a client holds for its connection’s life whatever
it does is charged to the bucket’s cap and never to its share: the root fid a connection’s
first
Tattachmakes (Admission::admit_fixed; a later attach is a working fid like a walk’s, or a client would hold a bucket by attaching again and again). The share is over the working fids beside it, so a connection holding only its root can always walk while the bucket has room, and a file server’s fid cap is derived so that every connection the account may hold there has its root and its working need:n + w (n + 1), n the connections (thestatecap’s own sum, so the roots alone never fill the bucket) and w the most working fids one connection needs at once, one at the system volume and/boot(a file walked, opened, read and clunked by the VM’s one reader), two at the home (the shell’s hub keeps its working directory beside the fid of the request in hand):erofsd28 + 29 = 57,walfsdandlittlefsd23 + 2 × 24 = 71,bootfsd23 + 24 = 47, held by the bench over every manifest (the_file_servers_fid_caps_leave_every_connection_its_root_and_working_share). Before the root left the share, sixteen connections of one account at the system volume’s cap of 32 held one fid each, the root, and none of the account’s VMs could load a module. A refusal of the moment,too_manywhile another connection of the account holds the share’s working fid or the kernel’sbusyat a full queue, is tried again by the client library’s file connection before it is the caller’s, eight times at waits doubling from 10 ms within the connection’s own timeout (redoubt_client::file). - A minted connection is its holder’s. A connection minted for another account is admitted
in that account’s bucket from its first request; until then it holds one of its minter’s
pending slots. Minted, its
Stateis charged to the minter’s bucket and share, so a flood of mints is bounded by the minter’s share before anyone calls (R26). The first request on it, a T-message, aninep_commonone or a typed one, moves thatStateto the caller’s bucket and share, where the caller’s caps apply: a caller whose bucket is full is refused that request and the connection stays pending for the next. The same bucket (the minter’s own call, or any account-0 holder, keyed by the root its chain came through) settles it where it is, and a connection settled once never moves again. A pending connection nobody calls on goes back with its minter’sdisconnector the scope’s end. So what the steward mints for a session or a lease is the session’s or the lease’s account’s to hold, under that account’s caps, and the steward’s own share holds only what the steward itself calls on. - Caps sized to fit.
Limitsnames each resource’s cap per bucket and the number of buckets that may hold anything at once.Admission::newrefuses limits (Unsized) whose buckets could hold more open calls thanMAX_OPEN_CALLS(256) lessOPEN_CALL_HEADROOM(64) (R4a (open calls)), or with a non-zero cap belowSMALLEST_CAP(2): below two, a lone badge’s share is the whole bucket. A file server’s per-account caps are derived from what the tree’s manifests let one account hold (littlefsd) and its count is each manifest’s, so the bench holds every entry of every manifest to the headroom (every_servers_buckets_leave_the_open_call_headroom): a manifest past it fails the bench, never its boot.Limits::fitssays whether every bucket at its cap fits a budget of so many bytes, and a shared server checks it at start against the budget its manifest entry gives (budget): the entry’s pages less its heap cap and its stack, times the page, the heap cap and the budget read from the startup block and the stack counted at the launched bound of 128 pages, since a program does not learn its own; an uncapped heap takes nothing off, since the budget alone bounds it and admission’s records share those pages, which is the memory rule’s business, not this check’s; a block naming no budget is refused as one naming no count is. So the manifest is the one source, sizing the count and the budget together by the entry rule (walfsd), and a larger manifest meets no wall in the code.consoledchecks a constant of its own in the budget’s place (consoled). - Overrides.
Admission::with_overridesgives named buckets caps of their own, each named once and keyed asAdmitKeyis (Override::covers): an account-0 root badge’s (non-zero, below the minted range), so a daemon’s capacity is its manifest’s per daemon; or a non-zero account’s unlabelled bucket, so a person’s capacity is the manifest’s per account (ipd’saccount=, ipd), whatever badges the account’s sessions call through. A labelled bucket of the account keeps the defaults. The fair share within an overridden bucket is the same rule. The worst case, each override slot at the larger of its cap and the default, must still leave the headroom. - Refusal.
admitrefuses when the bucket or the share is at its cap, when a new bucket is needed andbucketsalready hold something, or when there is no memory to track one more. Tables grow withtry_reserve, and room for both entries is made before either changes, so a refusal changes nothing.releasegives one back; an entry holding nothing leaves the table.
A request the server answers at once takes no admission. That is how a request that must always get through, the sponsor ending a lease, is answered ahead of admission: straight from the receive loop, never parked, in the headroom the caps leave.
check
Status: built · tested: host:redoubt-rt::matches_the_set_definition, host:redoubt-rt::properties
check(caller_labels, object_labels, access) (libs/rt/src/server/label.rs) is the label check
(R25 (the label check)). Read passes if the object’s labels are a subset
of the caller’s; Write only if they are equal. It compares sets: order and repeats do not
matter. The caller’s labels are those the kernel attached to the message, never ones named in
it.
Minted connections
Status: built · tested (9)
- host:redoubt-rt::the_first_badge_is_random_and_leaves_room
- host:redoubt-rt::badges_are_never_reused_and_ids_are_never_zero
- host:redoubt-rt::a_chain_a_client_minted_for_itself_folds_into_its_own_share
- host:redoubt-rt::disconnect_frees_everything_minted_under_it_and_only_for_its_holder
- host:redoubt-rt::disconnect_all_frees_everything_one_holder_minted
- host:redoubt-rt::a_mint_that_fails_records_nothing
- host:redoubt-rt::the_badge_space_runs_out_cleanly
- host:redoubt-rt::a_strangers_id_is_refused_like_one_that_does_not_exist
- host:redoubt-rt::minted_connections_are_admitted_and_fold_into_the_share_they_came_from
Minted<T> (libs/rt/src/server/minted.rs) is the table of capabilities a server mints for its
clients: a 9P server’s connections (new_connection, disconnect) and a typed server’s grants
(grant, release) alike, with a different payload T.
- Two steps.
reservedraws the badge and a random id and reserves the record;commitmints the handle and records it. Between them the server has the last word (a file server’s quota) before any handle exists. A dropped reservation mints nothing and spends no badge; a commit whose mint fails spends its badge and records nothing. - The badge comes from a counter that only goes up, starting at a point drawn from the
kernel’s random words (R27 (badge allocation)). Badges below
FIRST_MINTED_BADGE(2^63) are the server’s own, given meaning by whoever set it up; a badge at or above it that is not in the table is no capability at all. - The handle is minted from the message in hand (
mintnaming the message id), so it carries the stamp of the handle the request came through and dies with it (objects). - The id is a random non-zero 64-bit word no live entry has, never a counter, which would tell every client how many the others made.
disconnect(id)frees the entry with that id, and every entry minted under it, only for the client that received the id: the same badge, account and label set. Anyone else, and an id that does not exist, get the sameNotYours. The table is kept in mint order, so the entry’s descendants all follow it and one forward pass frees them. It allocates nothing, so it cannot stop halfway.disconnect_allfrees everything one client minted, for a client that has lost its ids;keyd’srelease(0)reaches it, and no 9P operation does.- Shares. A capability a client mints for itself counts in the share of the one it minted it
through (
Minted::sharewalks up the chain while the requester is the same client), so minting more badges buys no bigger share. One minted by someone else for a client (the steward, for a session or a lease’s agent) is a share of its own, in that client’s bucket from its first request (admit): until then it is pending, charged to its minter (pending), and the first request settles it on the caller’s bucket and share (settle), which is what its release names from then on. Within account 0 every capability minted through a root badge counts in that root’s share however deep the chain; the code departs from that rule (R26).
The server admits one State before reserving, gives it back if the mint fails, moves it at the
first request on the capability, and gives it back, from wherever it was charged by then, for
each entry disconnect frees.
Parked calls
Status: built · tested (4)
- host:redoubt-rt::parked_calls_are_served_abandoned_and_expired
- host:redoubt-rt::parking_is_admitted_per_bucket_and_share
- host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share_and_its_lease_end
- host:redoubt-rt::a_waiting_write_is_parked_abandoned_and_expired
A server that cannot answer yet (a console read with no input, a connect waiting for the
network) parks the call rather than blocking or answering a default. Parked<T>
(libs/rt/src/server/parked.rs) holds the calls one thread has parked, each with the server’s
state for it.
parktakes oneInFlightfrom the caller’s bucket and share, in the sameAdmissionthe server’s fids are charged to, so a client cannot fill a server’s fid table and its parked calls separately. A full bucket or share, or no memory, hands the request back to be answered now (R28 (parked-call accounting)).- A server-side deadline. Every parked call has one, at most the
longestwait the table was made with.expiredhands back the calls past it, for the server to answer with its protocol’s timeout error;next_deadlinebounds the thread’s nextreceive. A server whose calls wait on a person (consoled, for a key press) passesFOREVER: its calls never expire, and what reclaims one is its caller giving up. servebefore resuming.resume,resume_firstandexpiredeach make the call the thread’s current call (serve) before handing it back, so a crash while working on it blames its caller and not whoever called last (R21 (crash blame)).resume_firstserves the call that has waited longest among those its test accepts.- Abandoned calls. On an abandoned-call notice (R3 (lends and abandoned calls)),
abandonedreplies to the call at once, which frees it and its lend (the reply reaches nobody), releases its admission and hands back the server’s state. - One thread. A call is replied to by the thread that took it, and its notice arrives at that
thread’s
receive, so every serving thread keeps receiving on the endpoint its calls came in on. A thread that parked calls and stopped receiving would never learn they were abandoned. - A server never pushes. Delivery the server starts is a call the client makes and the server
parks, answered when the event happens (a console’s
resize); no endpoint is ever handed to the server for it, and the client calls again for the next event.
stateDiagram-v2
[*] --> Taken: receive
Taken --> Answered: answered at once<br/>(no admission)
Taken --> Parked: park (InFlight admitted)
Taken --> Answered: park refused,<br/>answered now
Parked --> Working: resume / resume_first<br/>(serve, admission released)
Parked --> Working: expired<br/>(serve, admission released)
Parked --> Freed: abandoned notice<br/>(reply to nobody, admission released)
Working --> Answered: reply
Answered --> [*]
Freed --> [*]
Figure: the life of a parked call. Admission is held exactly while the call is parked.
Multiplexed connections
Status: built · partly tested: attacked in host tests with the runtime's fake kernel; a boot runs it only in `aio-many-reads` and `aio-many-reads-two` · tested (15)
- bench:aio-many-reads
- bench:aio-many-reads-two
- host:redoubt-rt::a_sends_pages_count_once_and_go_back_with_its_last_request
- host:redoubt-rt::a_parked_read_sent_in_a_page_leaves_the_page_to_a_write
- host:redoubt-rt::a_completion_call_is_held_at_most_its_hold_and_the_servers_bound
- host:redoubt-rt::a_dispatch_longer_than_the_hold_answers_the_parked_call_after_it
- host:redoubt-rt::a_never_polling_client_holds_only_its_share
- host:redoubt-rt::death_with_requests_parked_frees_the_connection
- host:redoubt-rt::a_death_between_completion_calls_is_found_at_the_session_bound
- host:redoubt-rt::a_request_late_in_a_hold_leaves_the_session_its_whole_bound
- host:redoubt-rt::a_flush_racing_a_completion_answers_once
- host:redoubt-rt::a_flood_of_sends_at_wait_cap_never_blocks_the_server
- host:redoubt-rt::a_reused_or_out_of_range_tag_ends_the_connection
- host:redoubt-rt::a_second_completion_call_is_refused
- fuzz:redoubt-rt/ninep_server
A call holds its caller’s thread until the reply, so a client with one call per request needs a
thread per outstanding request. The 9P skeleton also serves a connection multiplexed: many
requests outstanding on its one badge, sent without waiting, and answered together through one
long-poll call. Both ways are served on one endpoint, and a client may use both on one
connection; the client half is the client library’s hub
(native programs).
(libs/rt/src/server/ninep_mux.rs.)
- A request is a
sendon the connection’s badge with word 0 = 0: one T-message, packed into words 1 to 3 (three machine words: 24 bytes on rv64, 12 on rv32), or one or more T-messages end to end at the start of a transfer, word 1 their length, so 64 reads cost one page on either width. A T-message that fits the words (24 bytes, three 64-bit words, on either width) is kept in the request’s own record whichever way it came, copied out of its page as it is taken, so a page is held only by requests too long for that: on rv32, where every read comes in a page, a read that waits (a console’s, for typing) pins no page. A send is never an open call, so the server takes requests even while it holdsMAX_OPEN_CALLS(R4a (open calls)), and it never waits on the client: everything it says goes back as a reply. - The completion call is a 9P call with words
[0, COLLECT, hold, 0](COLLECTis 1) and a lend: the server holds it at mostholdµs or the session bound, whichever is shorter (the server’s longest wait orCOLLECT_WAIT, 10 s, whichever is shorter), andhold0 answers at once. The first opens the connection’s session, answered at once with 1 in word 2; the server holds nothing for a connection before it, and drops a request that comes with no session open. Later ones are parked, one at a time (a second is malformed), and answered[0, bytes, 0, 0]with R-messages end to end at the front of the lend, or empty when the hold runs out. The hold is how long the server keeps the call with nothing to say, not when it returns: a server in a dispatch (its file server’s time, including the calls that makes to other servers) answers when the mux next runs. So the client puts no clock on the call: a late answer is a busy server, and a dead one frees the call withDead(R3). - Served only into a parked completion call’s lend. Requests are answered when a completion
call is parked, straight into its lend, in the order they came: nothing is copied out or held
in the server, and a request waiting for a call holds its admission. A read is served only where
its whole count fits what is left of the lend, so packing never shortens a read; the rest wait
for the next call. A
request the file server asks to hold (
Read::Wait) stays where it is, served again at the next completion call, the next request, or the server’s wake-up, and is answeredRerror“timeout” at the server’s deadline for it (none forconsoled, whose reads wait on a person). - Tags. At most
MAX_TAGS(256) are outstanding on a connection: a request’s tag from its arrival until its answer is delivered. A tag in use or out of range, a message that does not frame, or aTversion(whose tag isNOTAG) is a protocol error, which ends the session. - Flush.
Tflush(oldtag)drops oldtag’s request when it arrives, and is answeredRflushin its turn. Whatever was delivered for oldtag was delivered before, so a client sees an answer thenRflush, orRflushalone, and never an answer after it (intro(5), flush). - Admission (R77 (multiplexed requests)). The session holds one
InFlightof the connection’s bucket and share for its completion call, its one open call. Each request holds oneRequests, and the pages a send brought onePageseach, counted once for the send and held until the last of its requests too long for the words is answered or dropped (a send whose requests all fit them holds no page past its taking); each resource has its own share. A request that would take its badge past either share is not served: its tag joins the session’s refused set, a 256-bit map, answeredRerror“busy” first in the next completion call. Pages the share cannot pay for refuse every request they came with, and go at once; a batched page whose requests are partly refused is held only by those admitted. - The end. The session ends when its completion call is abandoned (its client died or gave up:
R3 (lends and abandoned calls)), when a reply
to it reaches nobody, at a protocol error, at the connection’s
disconnect, and when no completion call has been parked for the session bound since the last returned. Every request goes with it, its admission released, and a parked completion call is answered status 4. So a client that dies between two completion calls is found at the session bound, and a live client keeps a completion call parked whenever it has requests outstanding. - Crash blame. Requests are served with the completion call as the thread’s current call, so a crash while serving them blames their client (R21 (crash blame)).
Who runs it. A 9P server runs NineServer::run: calls, sends, transfers of up to
MAX_LEND_PAGES, abandoned-call notices and deadlines (littlefsd, bootfsd). One that keeps state
beside its files runs run_around with its Around, which is given the calls, the abandoned-call
notices that are no completion call’s, and a turn before each receive, after what the deadlines made
due, for whatever moved since: consoled parks its own reads, and at each turn reads its UART and
serves again the reads that wait for input. ipd keeps a loop of its own: it polls its network
stack only after a receive that returned no call, so never with a call current
(R21), receives without waiting after each call, takes
frames from netd, and bounds each wait by its stack’s timers and its link’s retry, so hooks for
all of that would be its loop again. It hands each send to deliver and each abandoned-call notice
to abandoned first, calls expire and bounds its receive by next_deadline, and calls wake
after each poll. sshd’s driver keeps one too, around its channel’s console, for the same reasons
(the SSH core and its reader’s calls), and does the same, calling wake after each turn of the
core. A loop of its own owes all of this: a server that never calls expire, or receives without
the bound, never answers a parked completion call at its hold, so a client whose console is quiet
for longer than its hold and margin reads the server’s silence and its session ends. A receive
bounded so that runs out (Timeout) is a turn, never the server’s end. A file server that pays
for what a request makes around it does so in the serving and served hooks (ipd’s sockets,
ipd).
Typed dispatch
Status: built · tested (5)
- host:redoubt-rt::requests_replies_and_errors
- host:redoubt-rt::handles_travel_and_unread_ones_come_back
- host:redoubt-rt::malformed_requests_and_oversized_replies
- host:redoubt-rt::random_words_never_panic
- host:redoubt-rt::typed_replies_close_the_handles_made_for_the_caller
A typed server (libs/rt/src/server/typed.rs) names its protocol by implementing Protocol, a
few lines over the codecs generated from its wire table (wire), and answers requests
by implementing TypedServer::handle. serve_call decodes the request, calls the server, and
replies with the encoded reply or the error’s status.
- A request that does not decode, arrives with a handle slot empty (revoked on the way), or whose reply does not fit the lend is malformed: status 1, the same in every protocol. Its handles are closed unread.
- A reply names its handles and whether to close them once sent: true for a handle made for the caller (a minted connection), false for one the server keeps using.
- Typed messages are served as calls. A protocol’s few one-way messages (
send) are decoded by the server itself.
The 9P server skeleton
Status: built · tested (16)
- fuzz:redoubt-rt/ninep_server
- bench:r4-host-tests
- host:redoubt-rt::the_9p_conformance_vectors_hold_for_a_minimal_server
- host:redoubt-rt::attach_walk_open_read_write
- host:redoubt-rt::dot_dot_never_leaves_the_attach_root
- host:redoubt-rt::walk_names_are_components
- host:redoubt-rt::depth_is_bounded
- host:redoubt-rt::fids_are_bounded_per_connection_and_per_account
- host:redoubt-rt::copies_of_one_badge_in_other_accounts_or_label_sets_share_nothing
- host:redoubt-rt::a_typed_operation_resolves_only_the_callers_own_fids
- host:redoubt-rt::offsets_counts_and_modes_are_not_trusted
- host:redoubt-rt::malformed_requests_get_errors
- host:redoubt-rt::random_requests_never_panic
- host:redoubt-rt::labels_are_checked_on_every_request
- host:redoubt-rt::unasked_handles_are_closed_and_other_opcodes_are_malformed
- host:redoubt-rt::a_client_at_its_connection_cap_costs_the_server_no_walk
NineServer (libs/rt/src/server/ninep.rs) keeps a 9P2000 server’s protocol state
(connections, fids, open modes, directory offsets) and applies every rule that does not depend on
what the files are. A FileServer supplies the files: attach, walk, open, read, write,
create, remove, stat, and labels for each node.
Beside the skeleton, the volume servers (littlefsd, walfsd, erofsd, and verityd on
blkd’s protocol) share redoubt-fileserver (libs/fileserver): the arguments init passes
them, endpoint=NAME and labels=ID[,ID...], parsed once under the manifest’s rules
(host:redoubt-fileserver::arguments_it_does_not_understand_stop_it_before_serving,
host:redoubt-fileserver::a_number_and_a_label_set_follow_the_manifests_rules); and their range,
blkd’s protocol on the volume badge (blkd) or a verityd‘s, as a format
needs it (Range: whole sectors read, written and flushed, and bytes from any offset for a
format whose structures are not sector-aligned,
host:redoubt-fileserver::a_byte_read_over_a_sector_range_reads_whole_sectors) with the one client
of the protocol (Blkd), which checks every reply before it is believed, splits a read at
MAX_SECTORS, the limit blkd and its clients take from the wire crate’s blkd module, and
refuses a read of part of a sector as a fault
(host:redoubt-fileserver::reads_split_at_the_lend_and_part_of_a_sector_is_a_fault). The
servers’ host tests drive the client against a fake blkd, and their files against one range in
memory, the crate’s Memory (feature test-support, never in a target build). littlefsd and
walfsd share the quota ledger too (littlefsd): what each live root holds
and keeps in reserve, told every change by its server, which counts a root when it goes live
(host:redoubt-fileserver::a_mint_at_a_new_root_counts_it_and_charges_the_root_above,
host:redoubt-fileserver::a_mint_past_the_room_above_is_refused,
host:redoubt-fileserver::a_quota_at_the_granters_own_root_is_refused,
host:redoubt-fileserver::connections_at_one_root_sum_and_the_last_disconnect_returns_its_bytes,
host:redoubt-fileserver::a_root_over_its_quota_after_a_disconnect_grows_no_further); the
servers’ quota tests drive it through their 9P. The three volume servers’ programs start
through the crate too (program::start: the arguments, the endpoint, the admission’s fit and the
range, in that order), with one set of exit codes. What a format needs of its medium stays with its
server: how a range is mounted as its blocks, and the files.
On the wire. A request whose word 0 is 0 is 9P: a call whose words are all zero, with the
T-message at the start of its lend; the R-message is written over it (wire). Any other
word 0 is a typed opcode: 1 to 15 belong to ninep_common, which the skeleton serves itself, and
higher ones go to the server’s own protocol (serve_with). A 9P call with a non-zero word or no
lend, or an unknown opcode in 1 to 15, is malformed, except a multiplexed connection’s completion
call, words [0, 1, hold, 0] (multiplexed connections). Handles sent
with a 9P call are closed unread.
What the skeleton guarantees a FileServer, whatever a client sends:
- At most
MAX_FIDS(64) fids per connection, each admitted as aFilesof its client; both limits are checked before the server is asked to attach or walk. - Every fid is looked up; no request reaches the server for a fid that does not exist. Fids are keyed by badge, account and label set.
- Walk names are valid path components. A fid keeps the node and qid of every step from its attach
root, so
..is the step before and never a question to the server, and at the root it stays at the root. A fid is at mostMAX_COMPONENTSbelow its root. - Only directories are walked from or created in, and opened only for reading. Reads need a fid opened for reading, writes one opened for writing.
offset + countnever overflows; a read asks for at most what fits the caller’s lend and themsize; a directory read continues only from where the last one ended.- The label check runs on every request against the labels of the node named:
Readon the attach root, on the directory walked from and every node walked into, on the node forTstat,Treadand opening for reading, and on each directory entry listed (entries the caller cannot read are left out);Writeon the node forTwrite, opening for writing,OTRUNCandTremove, and on the directory forTcreate.
Protocol corners. Tversion is accepted at any time and clunks every fid of the connection;
the msize is fixed. Tauth and Twstat are refused: access is by capability. Tflush on a
call is answered at once, since a call’s request is handled whole; on a multiplexed connection it
drops the request it names (multiplexed connections). An Rerror
carries one of a fixed set of texts, so a hostile request cannot choose it. Every allocation a
request makes fails cleanly with an Rerror, never by killing the server.
ninep_common (the table is on wire): new_connection(root, quota, scope) mints
a connection rooted at root, a path relative to the caller’s own root, cleaned so it never climbs
above it and walked with the same label checks as a Twalk. A mint at the caller’s own root (an
empty path) walks nothing and reads nothing: the reply carries a handle and an id, no qid, and the
first read of that root is the holder’s Tattach, checked against the holder’s labels like every
request after it, so a server may hand out a connection to data it cannot read itself and learns
nothing by it. It is admitted as a State of the caller’s share, and the file server’s minted hook may refuse it (a quota it cannot grant).
scope, an optional budget handle, is the revocation scope the connection is minted in: the
skeleton passes it to the kernel’s mint as the stamp, so destroying the scope ends the
connection in every table at once
(R41 (narrowing by revocation scope)), and
closes it once minted with; absent, the connection is stamped as the server’s own. A scope not at
or below the server’s own stamp is the kernel’s NotPermitted, answered refused like any mint
that fails. The skeleton learns nothing of a scope’s destruction: the connection’s record, its
fids and admission stay until its disconnect, which is the cleanup and never the cutoff.
disconnect(id) frees the connection and everything minted under it: their fids are clunked,
their admission released, and the file server told (disconnected). mint_rooted mints a
connection at a root the file server chose, in the same table and in the revocation scope the
grant brought, if any, for a typed grant whose scope the server has already checked
(ipd).
Waiting. A read whose answer is not there yet (Read::Wait) or a write that cannot be taken
yet (Write::Wait) is not answered: serve_parking hands the request back with its T-message
still in its lend and its handles closed, the server parks it, and serves it again when it can.
Nothing of the request stays in the skeleton meanwhile, so a Tclunk or Tversion in between
makes the second serving an Rerror. A server that asks to wait through serve_with, which cannot
hand a call back, gets the call refused, never stranded.
The conformance corpus. libs/wire/vectors/9p.txt holds 9P2000 request and reply vectors.
Every 9P server runs them against its own skeleton (libs/rt/fake/src/vectors.rs): no vector
panics, every answer decodes and carries the request’s tag, a malformed request gets an Rerror,
an R-message sent to a server is refused, and nothing a vector sends mints a connection.
r4-host-tests runs them for bootfsd and consoled.
Replies and rollback
Status: built · partly tested: the exit when even the malformed reply is rejected is host-tested only, since no caller can make the kernel reject it · tested (7)
- bench:ninep-newconn-discard
- bench:init-rollback
- bench:init-restart
- host:redoubt-rt::what_was_minted_here_can_be_undone
- host:redoubt-rt::a_rooted_mint_is_an_ordinary_connection_rooted_where_the_server_says
- host:redoubt-rt::mapping_reborrows_and_failed_reply_recovery
- host:redoubt-keyd::serving_grant_rolls_back_discard_missing_capability_and_error
A successful reply says delivered or discarded, and which of the reply’s handle slots were
installed in the caller (IPC). Reply success is not
acceptance: a caller can abandon a call after the server made a grant and before the reply
reached it. So a server that creates a connection or grant keeps it provisional until the
outcome is known:
discarded, ordeliveredwithout every handle slot the new resource needs, rolls back the new record and its admission charge.new_connectionandkeyd’sgrantneed slot 0, the capability itself (ReplyOutcome::accepted(1)). No abandoned-call notice is needed to learn it.Minted::answeringstarts each request clean;minted_herenames what the request minted, andforget(orNineServer::unmint) undoes it and everything under it.deliveredmeans the kernel committed the reply record. It does not mean the caller read it, or that its handles survive a later revocation; ordinarydisconnectand the admission caps bound what a client that vanishes afterwards leaves behind.
Rollback reaches only provisional records: a file write already made stays made. An operation
that makes more than one resource needs an explicit policy for each: which of them a missing slot
rolls back. The bench attacks the skeleton’s own serve path: a client whose handle table is full
has every new_connection reply’s capability dropped by the kernel, more times than its bucket
holds, and the server’s tables show each one rolled back (ninep-newconn-discard).
finish is the one place a call is finished, for 9P, ninep_common and typed protocols
alike. It closes the handles that do not travel before replying, so a caller holding its reply
knows the server no longer holds them, and closes those that travel once the reply has copied
them. If the kernel rejects the reply, the call is still open: finish answers it with the
handle-free malformed reply, and if even that is rejected the server exits, so the caller gets
Dead (R4b (a server dies)) rather than waiting for good.
stateDiagram-v2
[*] --> Provisional: reserve, admit State,<br/>commit (handle minted)
Provisional --> Kept: reply delivered,<br/>slot 0 installed
Provisional --> RolledBack: reply discarded
Provisional --> RolledBack: delivered,<br/>slot 0 not installed
Provisional --> RolledBack: reply rejected<br/>(malformed sent instead)
RolledBack --> [*]: forgotten, with its record, descendants<br/>and admission released
Kept --> [*]: disconnect by its holder
Figure: the outcome of a reply that carries a new connection or grant.
Parking a typed call
Status: built · tested (4)
- bench:consol-size
- host:redoubt-rt::a_waiting_request_comes_back_unanswered_with_its_lend_as_it_came
- host:redoubt-rt::a_waiting_request_that_carries_handles_is_malformed_and_they_close
- host:redoubt-rt::a_waiting_typed_call_is_parked_answered_later_and_freed_when_abandoned
A typed request that must wait (the console’s resize, which returns when the window’s size
changes) parks like a 9P read. Its admission, deadline, serve and abandonment follow the rules of
parked calls exactly.
- The server says which requests wait.
TypedServer::waitsis asked once the request has decoded; it is false unless a server says otherwise, so no other typed server changes.typed::serve_parkinghands a waiting request back unanswered, its words and lend as they came, for the server to park in the sameAdmissionas its other calls.serve_call, which must answer every call, answers one malformed, as the 9P skeleton’sserve_withdoes a waiting read. On a 9P server’s endpoint the server’s own dispatch (own) hands a waiting typed request back throughNineServer::serve_parkingbeside its waiting reads. - Decoded again, not kept. Nothing of a waiting request is kept but the call and the server’s
own state for it in
Parked<T>(forresize, the size it waited from). Served again, it is decoded afresh from its lend, as a parked 9P read is; or the server answers it from its state withtyped::reply, which writes the reply into the call’s own lend. - A request that carries handles never waits. It is answered malformed and its handles are closed, so a parked call holds none of its caller’s handles.
- Which operations wait is each protocol’s to say on its owning page: only
consol’sresizedoes (consoled).
Authority
Status: built · tested (8)
- host:redoubt-rt::a_strangers_id_is_refused_like_one_that_does_not_exist
- host:redoubt-rt::unasked_handles_are_closed_and_other_opcodes_are_malformed
- host:redoubt-rt::labels_are_checked_on_every_request
- host:redoubt-rt::a_hostile_client_does_not_hurt_the_server_or_other_clients
- host:redoubt-rt::parked_calls_are_served_abandoned_and_expired
- host:redoubt-consoled::a_refused_typed_request_leaves_no_handle_behind
- host:redoubt-rt::a_dropped_request_is_refused_and_closes_what_it_carried
- host:redoubt-rt::a_rejected_reply_closes_no_carried_handle_twice
- The library adds no authority. It uses the server’s own handles and the facts the kernel attaches to each message: badge, account and labels. It trusts nothing a request says about who sent it.
- A badge is the grant. What a badge below
FIRST_MINTED_BADGEmeans is the server’s, given by whoever set it up; a badge the table minted means what was recorded when it was minted. - Minting keeps the stamp. A minted handle is stamped like the handle the request came through, so it is revoked with it (R9 (stamps)).
- Only the recipient of an id can name it.
disconnectandreleasecheck the requester’s badge, account and label set, and answer a stranger as they answer an id that does not exist. - Unasked handles are closed. Every handle a request carries that its protocol did not ask for is closed, so a client cannot grow the server’s handle table.
- A refusal closes what it refuses. A request is answered through
finish, which closes the handles that do not travel, or through a refusal built on it:refuse(a 9PRerror),refuse_malformed(a typed opcode the server does not serve: whatNineServer::serveandconsoledanswer) andParked::abandoned. None of them leaves a carried handle open. The places the runtime and the servers combine the raw calls (Request::reply,Request::serve,handle::close) with the owning views (Request,Delivery,Parked,NineServer):NineServer::serve’s answer to a typed opcode,serve_with’s answer to a wait it cannot hold, andconsoled’s own-protocol callback replied raw and kept the handles: nowrefuse_malformed.refuseandParked::abandonedclosed nothing; their requests had already been emptied byserve_parking, but a server may park any request: both now close what it still carries.finish’s fallback reply after a rejected one: the handles were closed before the first try, so it carries none. Sound.Parked::resume’sRequest::servechanges which call a fault blames, not what it holds. Sound.- Every server’s
Event::Sendarm closes the delivery’s handles, inserveor withclose_delivery; aDeliveryowns none of them. Sound.serve_parkingcloses a held call’s handles and empties its list, so serving it again cannot close them twice. Sound. Request::replyis the library’s alone (acompile_failtest inipc.rs), so there is no raw reply left to add, and aRequestdropped unanswered is refused: its carried handles are closed and the caller gets the malformed reply.
Security properties
R25 (the label check)
Status: built · tested (10)
- host:redoubt-rt::matches_the_set_definition
- host:redoubt-rt::properties
- host:redoubt-rt::labels_are_checked_on_every_request
- host:redoubt-rt::every_write_needs_equal_labels
- host:redoubt-rt::labelled_metadata_does_not_flow_down
- host:redoubt-rt::an_unlabelled_caller_cannot_reach_labelled_data_to_destroy_or_probe_it
- host:redoubt-rt::a_mint_at_its_own_root_reads_nothing
- bench:net-attacks
- bench:littlefsd-label-check
- bench:steward-agent-escape
A system server lets information flow from an object to a caller only if the object’s labels
are a subset of the caller’s, and from a caller into an object only if their label sets are
equal. Metadata (a qid, a stat, a directory entry) is a read of its node. The caller’s labels
are the ones the kernel attached to the message
(R14 (unforgeable sender)). With the kernel’s R1
between user budgets, this makes every flow through a shared server one the kernel would have
allowed between the two budgets directly: a write then a read carries a’s data to x only if x
could read a’s labels itself (properties checks exactly that). Minting a connection is not a
read: nothing of the node flows to the minter, and the holder’s requests are checked against the
holder’s labels.
R26 (admission fairness)
Status: built · partly tested: the rule is attacked in host tests with the runtime's fake kernel, and boots flood a real walfsd while a second bucket keeps its share, one holding handles to its bucket's share while another account saves, sixteen agents of one account load code at once with every connection its root and working share, and a session opens and closes sockets at `ipd` to its share of its account's override and is refused the next · tested (25)
- bench:walfsd-flood-turn
- bench:vault-flood-turn
- bench:walfsd-pools
- bench:lease-code-load
- bench:session-tcp-churn
- host:redoubt-rt::the_key_is_the_account_and_the_label_set
- host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share
- host:redoubt-rt::a_root_fid_is_a_fixed_cost_outside_the_share
- host:testbench::the_file_servers_fid_caps_leave_every_connection_its_root_and_working_share
- host:redoubt-client::an_admission_refusal_of_the_moment_is_tried_again
- host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share_and_its_lease_end
- host:redoubt-rt::self_minting_does_not_multiply_the_share
- host:redoubt-rt::a_connection_minted_for_another_account_is_admitted_there_from_its_first_request
- host:redoubt-rt::a_first_request_on_a_connection_its_account_cannot_hold_is_refused_and_it_stays_pending
- host:redoubt-rt::an_account_0_chain_holds_one_bucket
- host:redoubt-rt::an_account_0_rooted_chain_holds_one_bucket
- host:redoubt-rt::caps_are_big_enough_for_a_share_to_mean_anything
- host:redoubt-rt::open_calls_leave_headroom
- host:redoubt-rt::the_worst_order_never_passes_the_headroom
- host:redoubt-rt::a_bucket_count_is_given_once_and_never_defaulted
- host:redoubt-bootfsd::a_bad_public_list_stops_the_server
- host:redoubt-consoled::a_console_with_no_device_does_not_start
- host:redoubt-keyd::bad_key_arguments_stop_keyd_starting
- host:redoubt-ipd::every_scope_and_the_milestone_parse
- host:redoubt-ipd::a_heap_that_cannot_hold_every_socket_stops_ipd
One client cannot use up a shared server that serves others. What a client holds in a server is
counted per (account, label set), and per badge for account 0; within a bucket of a non-zero account
each badge may hold less than pool / (n + 1) (at least one) of the resource beyond the bucket’s
fixed costs, a connection’s root fid among them (admit), so a lone badge never fills its
bucket and a second always finds room; minting more badges for oneself buys no bigger share; and
every bucket at its cap together holds fewer open calls than MAX_OPEN_CALLS by at least
OPEN_CALL_HEADROOM, so the server keeps room to take calls beyond what its clients hold, including
one answered ahead of admission. Within account 0, every capability minted through a root badge (one
below FIRST_MINTED_BADGE, given by whoever set the server up) counts in that root’s share, however
many links deep and whoever holds it: a chain of self-mints spends one share, and system callers get
separate shares only from separate root badges, which the manifest gives. A capability used under a
non-zero account is keyed by that account, the connection itself included: one minted for it by
another (the steward’s, for a session or a lease) is pending in its minter’s share until the
account’s first request on it, then in the account’s own bucket (admit), so a
minter’s share bounds what it mints before anyone calls, and never what others hold. The kernel’s
R2 (fair waiting) shares turns at the endpoint the same way;
this rule shares what the server holds afterwards. Minted::key is the one fold every charge
goes through: an account-0 caller’s key names the root badge its chain was minted through, so the
skeleton’s fids and connections, a server’s parked calls and ipd’s sockets all land in that
root’s bucket. A multiplexed request is admitted in the same bucket and share as a call, under
its own resource, Requests, until its answer is delivered (R77).
How many buckets a shared server has is buckets=N in its startup block, parsed
once by the serving library with no default: a server not told, or told a count outside 1 to 32
or its budget, does not start, so no count is fixed in code where the manifest cannot follow it.
What a server holds for its buckets must fit where it lives: ipd‘s sockets’ buffers are its
heap’s, and it does not start with a heap cap that cannot hold every socket its buckets may charge
(ipd), since a socket past it would stop ipd for every client.
R27 (badge allocation)
Status: built · tested (4)
- host:redoubt-rt::the_first_badge_is_random_and_leaves_room
- host:redoubt-rt::badges_are_never_reused_and_ids_are_never_zero
- host:redoubt-rt::the_badge_space_runs_out_cleanly
- host:redoubt-rt::a_mint_that_fails_records_nothing
A server never gives out one badge twice. Its minted badges start at a point drawn uniformly from 2^62 values above 2^63, from the kernel’s random words, and count up; the badge space runs out rather than wrapping into the server’s own badges. So a handle revoked in flight never reaches a later connection, and two runs of a server (before and after a restart, on the same endpoint) agree on a badge with probability 2^-62 per badge, not with certainty.
R28 (parked-call accounting)
Status: built · partly tested: attacked in host tests with the runtime's fake kernel; `consoled` and `ipd`, which park, are attacked only in part in a boot, though `ipd` is now flooded from a session too · tested (5)
- host:redoubt-rt::parking_is_admitted_per_bucket_and_share
- host:redoubt-rt::parked_calls_are_served_abandoned_and_expired
- host:redoubt-rt::a_waiting_write_is_parked_abandoned_and_expired
- bench:net-pinned
- bench:net-pinned-ssh
A parked call holds exactly one InFlight of its caller’s bucket and share, from park to the
moment it is resumed, expires or is abandoned; the caps keep every bucket’s parked calls under
MAX_OPEN_CALLS with the headroom free; every parked call has a server-side deadline unless it
waits on a person; and an abandoned one is answered at once. So a client that parks calls and
walks away cannot pin a server’s open calls (R4a (open calls)).
A multiplexed request is never an open call: only its connection’s completion call is, and that call
is held as any parked call is; the requests count under Requests
(R77).
In net-pinned a client abandons 64 parked reads at ipd, which answers and frees each one;
a read with nothing coming ends at ipd’s deadline, and the connection still works after.
R77 (multiplexed requests)
Status: built · partly tested: attacked in host tests with the runtime's fake kernel; no boot floods a real server with requests · tested (9)
- host:redoubt-rt::a_never_polling_client_holds_only_its_share
- host:redoubt-rt::a_sends_pages_count_once_and_go_back_with_its_last_request
- host:redoubt-rt::death_with_requests_parked_frees_the_connection
- host:redoubt-rt::a_death_between_completion_calls_is_found_at_the_session_bound
- host:redoubt-rt::a_request_late_in_a_hold_leaves_the_session_its_whole_bound
- host:redoubt-rt::a_flush_racing_a_completion_answers_once
- host:redoubt-rt::a_flood_of_sends_at_wait_cap_never_blocks_the_server
- host:redoubt-rt::a_reused_or_out_of_range_tag_ends_the_connection
- host:redoubt-rt::a_second_completion_call_is_refused
A multiplexed connection’s requests, and the pages they brought, are bounded by its admission
shares of Requests and Pages, and a request whose answer is not yet delivered still counts. So a
client that floods requests, or never collects their answers, holds no more of a server than one
share, and its excess is answered busy, never queued without bound. A session ends, freeing
every request it held, when its completion call is abandoned (the client died or gave up), or
when no completion call has been parked for the session bound: the server’s longest wait or
COLLECT_WAIT (10 s), whichever is shorter. A flushed request is answered at most once, before
its Rflush, never after.
Failure and restart
Status: built · partly tested: the exit after a rejected fallback reply is argued from the code, not attacked · tested (4)
- host:redoubt-rt::mapping_reborrows_and_failed_reply_recovery
- host:redoubt-rt::a_held_9p_call_closes_what_it_brought_exactly_once
- host:redoubt-rt::disconnect_all_frees_everything_one_holder_minted
- host:redoubt-rt::a_panic_is_reported_on_the_console_once
- A request the server cannot answer (it does not decode, its reply does not fit) gets the malformed reply; a reply the kernel rejects is replaced by it; if that too is rejected the server exits rather than strand the caller (R4b).
- The server panics. The runtime’s panic handler runs the program’s panic hook first, if it
names one in
entry!(netdresets its device there), then prints once on the console, and exits throughprocess_exitwith code 101. Holding open calls, that is a fault that blames the current call’s sender (R21). - A client dies holding connections or parked calls: its parked calls come back as abandoned-call notices and are freed, and so does a session’s completion call, which ends the session and frees its requests. Its connections stay until its launcher disconnects them (servers).
- The server restarts. It keeps nothing: its tables start empty and its first badge is drawn again (R27). A client’s old handle names no connection until it asks for a new one.
Residual risks
- An undersized server is a channel. A server sized for fewer buckets than the (account,
label set)s it serves refuses the latecomers, which tells them others hold state: across
accounts, and between the label sets of one account, where it is a channel out of a vault. The
manifest sizes each server’s bucket count to the label sets it serves, and a server not sized
does not start, and
initrefuses a boot whose N for a server is below the domains the manifest declares there (init). - A full bucket makes the last comer wait. With three or more badges in one bucket, the bucket can fill, and a further badge is refused until one gives something back.
- A parked call costs its caller and the server. Each holds one of the caller’s
MAX_OPEN_CALLSand one of the server’s admission slots for as long as it waits. A console read has no deadline and is reclaimed only by its caller giving up; a multiplexed one is reclaimed with its session, which ends a session bound (at mostCOLLECT_WAIT) after its last completion call returned if none is parked. - A dead launcher leaks its children’s connections until its own connection is freed; the leak counts against its own account and label set, never another’s. A connection it minted that nobody ever called on holds one of its pending slots the same way, until then.
- A minter’s share bounds what it mints before anyone calls. The steward mints every
session’s and lease’s connections through its one root badge at a server, so between a mint and
the first request on it that connection is a pending slot of the steward’s share there: a
login batch’s connections are pending only until the session’s first reads, but a server whose
statefor the steward’s badge is below the connections one batch mints would refuse the batch at its mint, not at the account’s cap. The caps are sized above a batch, and the account’s own cap binds once the connection is in use. - A disconnect a busy server does not take holds its connection’s admission until the server
answers. The steward’s deferred cleanup retries a server’s disconnect four times a second
apart and then parks it (the steward); a connection so parked
keeps its
Statein the account’s bucket it was admitted in until the server answers a call of the steward’s, or restarts. - Rollback ends at provisional state. A client that abandons a request after the server performed a non-provisional effect (a file write) keeps the effect without learning of it.
- Admission counts objects, not bytes. Bytes are the file server’s to meter (
littlefsd’s andwalfsd’s quotas); every other server keeps no byte count. - A server wedged in a dispatch holds its clients. Their completion calls come back only when it answers or dies; the bench, not the client, finds a server that never does.
- A share of two pages is one page. At a server whose cap is 2
Pages(consoled,erofsd,bootfsd) a badge of a non-zero account may hold one page at a time (R26: less than half, and at least one), so of two requests too long for the words outstanding at once (two writes) the second is answeredbusy, and is its caller’s to send again. beamlet’s console keeps one write out at a time.
Why
- Buckets by account and label set. Badges and budgets are cheap to create, so a limit per badge is no limit. An account is what a principal cannot multiply; the label set keeps a vault session’s use of a server invisible to its owner’s other sessions.
- A fair share inside the bucket. An agent shares its sponsor’s account. Without shares it could fill the bucket for a lease’s whole life and lock its sponsor out, even of ending the lease.
- Headroom under
MAX_OPEN_CALLS. Parked calls count against the kernel’s open-call limit; if the caps could reach it, the server could take no new call at all, including the one that would free the others. - A random first badge. Endpoints outlive servers and a server keeps no state across a restart, so a restarted server that began at a fixed number would reissue badges its clients still hold, and their old handles would match its new grants.
- Provisional until delivered. A grant whose reply never arrived can never be disconnected, since only the recipient of its id can name it; left in place, it would hold its admission for the life of the server.
- Written once and fuzzed. 9P parses untrusted bytes; one skeleton, fuzzed and run against a shared corpus, is cheaper to trust than one per server.
Wire formats
Every message between Redoubt processes uses one of two wire formats: plain 9P2000 for files, or
a typed message whose layout is fixed by a table. Both share one encoding and one codec
library, redoubt_wire in libs/wire/, which also holds the strict JSON parser for files people
write. The typed codecs are generated from the tables, so a sender and a receiver cannot disagree
on a layout.
Purpose
Every server parses bytes from processes it does not trust. One codec, written once without
unsafe, fuzzed and shared, is cheaper to trust than one per server, and a table that is also
the source of the code cannot drift from it. The library holds no state and no authority: it
turns bytes into values and values into bytes.
Interface
The message convention
Status: built · tested (9)
- host:redoubt-wire::inline_packs_little_endian_into_three_words
- host:redoubt-wire::inline_overflow_is_refused
- host:redoubt-wire::words_must_fit_in_32_bits
- host:redoubt-wire::buffer_length_is_bounded
- host:redoubt-wire::error_replies_carry_only_a_status
- host:redoubt-wire::file_framing
- host:redoubt-wire::example_vectors
- host:redoubt-wire::layouts_by_hand
- fuzz:redoubt-wire/typed
A message is four machine words, up to four handles (MAX_MSG_HANDLES) and at most one buffer
(IPC). libs/wire/src/typed.rs lays a typed message out in them:
- Word 0 of a request is its opcode, from the protocol’s table. Opcode 0 is not one: word 0 of a reply is a status, and 0 is success.
- Inline shape. If the request’s fields and the reply’s fields each have a fixed size and
fit in
INLINE_BYTES(12), they are packed into words 1 to 3, four bytes per word, little-endian, zero-padded. There is no buffer; an inline message that arrives with one is refused. - Buffer shape. Otherwise the request’s fields go in the buffer (a lend for a
call, a transfer for asend) with their length in word 1, and words 2 and 3 are zero. The reply’s fields are written back into the caller’s lend, their length in word 1, because a reply carries only words and handles. So a small request whose reply carries data (a block read) is a buffer message. The shape is fixed per message type by its table. - Every word fits in 32 bits, on both widths, and a word the layout does not use must be zero. So a layout is the same on rv32 and rv64.
- Handles travel in the message’s slots, never in the bytes. The codec checks their number
against the layout; a table names each slot’s kind for the reader, but nothing checks the kind
on receipt. A handle of the wrong kind is found by use: its first use fails with
WrongObject(the ABI) and the server answersmalformed. - Errors. An error reply carries its code in word 0, zeros in words 1 to 3, and no handles;
the caller ignores the buffer. Code 1 is
malformedin every protocol: a request that does not decode (unknown opcode, wrong shape, bad lengths, a missing handle, or one of the wrong kind). A protocol’s own codes start at 2. A reply does not repeat the opcode: the caller knows what it sent and names it when decoding. - 9P in a lend. A 9P request is a
callwhose four words are all zero, with the T-message at the front of its lend; the server writes the R-message over it and replies with four zero words. A 9P call with a non-zero word or no lend is malformed (status 1), except a multiplexed connection’s completion call,[0, 1, hold, 0]with a lend (serving). - Typed operations on a 9P endpoint. Word 0 non-zero on a 9P endpoint is a typed opcode.
Opcodes 1 to 15 belong to
ninep_common(below); a protocol served on a 9P endpoint starts at 16, and the generator refuses a lower opcode in its table. - In a 9P file. A typed operation written into a file (
ipd’sctlfiles) has no words, so it is its opcode as au32followed by the buffer-shape encoding of its fields, one operation perTwrite. A message that carries handles cannot be written into a file.
packet-beta title the inline shape: a request, and a reply with status in place of the opcode 0-31: "word 0: opcode (reply: status)" 32-127: "words 1 to 3: the fields, 12 bytes"
packet-beta title the buffer shape: the fields travel in the lend, and the reply's fields come back in the same lend 0-31: "word 0: opcode (reply: status)" 32-63: "word 1: length of the fields in the lend" 64-127: "words 2 and 3: 0"
Figure: a typed request and its reply, in the inline and buffer shapes; every word holds at most 32 bits. A status is 0 for ok, 1 for malformed, and 2 on for the protocol’s own. Handle slots 0 to 3 are separate from the bytes and counted against the layout.
The encoding
Status: built · tested (7)
- host:redoubt-wire::integers_are_little_endian
- host:redoubt-wire::lengths_are_bounded_by_the_input
- host:redoubt-wire::writer_refuses_overflow
- host:redoubt-wire::atomic_writes_leave_nothing_on_failure
- host:redoubt-wire::trailing_and_padding
- fuzz:redoubt-wire/typed
- fuzz:redoubt-wire/ninep
9P and typed messages share 9P’s encoding (libs/wire/src/codec.rs): little-endian u8, u16,
u32 and u64; a string as a u16 length and UTF-8; bytes as a u32 length and the bytes.
There are no compound types: a label set or an address travels as bytes, its inner layout
stated in the table’s page.
- A
Readerhands out borrowed views of its input and never reads past it: a length may claim anything, but the bytes it names must be present. - A
Writerfills a buffer the caller supplies and refuses to run past its end. Writes are atomic: an encoding that fails leaves nothing in the buffer. - Decoding is strict: bytes left over after the last field, a string that is not UTF-8, or a word the layout does not use that is not zero are refused. So a value has exactly one encoding (R29 (strict decoding)).
- Nothing in 9P or typed decoding allocates; only JSON does.
9P2000 as Redoubt uses it
Status: built · tested (10)
- host:redoubt-wire::tversion_bytes
- host:redoubt-wire::twalk_bytes
- host:redoubt-wire::rstat_sizes_nest
- host:redoubt-wire::framing_is_strict
- host:redoubt-wire::walk_limit
- host:redoubt-wire::encode_respects_msize_and_buffer
- host:redoubt-wire::a_directory_entry_that_does_not_fit_is_not_written
- host:redoubt-wire::directory_reads
- host:redoubt-wire::ninep_vectors
- fuzz:redoubt-wire/ninep
libs/wire/src/ninep.rs is plain 9P2000, as in Plan 9’s intro(5): no .u, no .L.
msizeis fixed atMSIZE(64 KiB:MAX_LEND_PAGES, 16 pages of 4 KiB), the largest lend. The only version string is9P2000.- One connection per endpoint handle. A 9P connection is a badge on the server’s endpoint: fids are kept per badge, account and label set, so each badge the server mints is a connection of its own (the 9P server skeleton).
- Strict framing. The size field must frame the message exactly, every string is UTF-8, a
walk has at most
MAXWELEM(16) names, and a stat’s own size fields must agree with its contents. Soencode(decode(b)) == bfor every acceptedb. - What the codec does not judge: whether a walk name is
..or holds a/, whether a fid is in use, whether the version is right. Those are protocol state, and the serving library checks them. - The conformance corpus.
libs/wire/vectors/9p.txtholds request and reply vectors, each accepted one checked against a message built by hand; every 9P server also runs it against its own skeleton.
Error names
Status: built · partly tested: the table is libs/wire’s and both client paths read names by it; servers still send their texts from constants of their own, and the drift check that would hold them to the table is not built (M1 (sessions over SSH, kept apart)) · tested: host:redoubt-wire::every_text_reads_back_to_its_name_and_any_other_is_other, host:redoubt-client::an_rerror_keeps_its_name_not_found_against_the_rest, host:redoubt-client::an_rerror_through_the_hub_keeps_its_name, host:beamlet-redoubt::every_row_of_the_error_table_maps_to_its_posix_error, host:beamlet-redoubt::not_found_at_the_open_is_absent_and_every_other_error_is_refused_by_name
A 9P Rerror carries one of a fixed set of texts, so a hostile request cannot choose it
(serving). The set is one table in libs/wire
(ErrorName): a server answers only with a text of the table;
the client library, a blocking call or the hub alike, reads the text back to its name, never
keeps the text, and calls a text outside the table other
(native programs). beamlet’s File boundary maps a
name to the POSIX error OTP’s file expects by the last column, and nowhere else
(files).
A name means one thing wherever it is used, in a 9P reply and in a typed protocol’s error table
alike: not_permitted, not a second word for it.
| Text | Name | beamlet’s file error |
|---|---|---|
file does not exist | not_found | enoent |
permission denied, not_permitted | not_permitted | eacces |
file exists | exists | eexist |
not a directory | not_dir | enotdir |
is a directory | is_dir | eisdir |
directory not empty | not_empty | enotempty |
no space, quota refused | no_space | enospc |
read-only, read-only volume | read_only | erofs |
removed | removed | estale |
too many open files, too_many | too_many | emfile |
out of memory, no charge | no_memory | enomem |
not supported | not_supported | enotsup |
bad file name, path too deep | bad_name | einval |
file too large | too_large | efbig |
corrupt | corrupt | eio |
refused | refused | econnrefused |
timeout | timeout | etimedout |
unreachable | unreachable | ehostunreach |
in_use | in_use | eaddrinuse |
state | state | enotconn |
busy | busy | eio |
malformed message, unknown fid, fid already in use, fid is open, fid not open for this, bad open mode, bad offset, count too small, reply too large, no such connection, no connection id, authentication not required | protocol | eio |
| a text not in the table | other | eio |
removed is a file removed under a fid still held, so estale; a name never there is
not_found, so enoent. A typed protocol’s error code means the same as the 9P name it shares:
littlefsd’s not_permitted is not_permitted, so a refusal by label or policy is eacces
whichever way it comes, and refused is only ever a connection’s refusal.
A walk of several names that stops short says only how far it got, not why: a refusal after
the first name (a label check, a failed read) reads as not_found, so a caller that must tell the
two apart walks one name at a time
(an Rerror has a name).
Where two texts share a name, the first is the one a server sends from then on; the second is the
text ipd or a quota hook sends today, read to the same name. A new error is a new row, and the
drift check holds the serving library, the client library and this page to it.
Wire tables and the generator
Status: built · tested (14)
- host:redoubt-wire-gen::generated_files_are_current
- host:redoubt-wire-gen::the_elixir_client_has_one_function_per_message
- host:redoubt-wire-gen::parses_tables
- host:redoubt-wire-gen::refuses_bad_tables
- host:redoubt-wire-gen::every_row_is_read_or_refused
- host:redoubt-wire-gen::ninep_marker_sets_the_opcode_floor
- host:redoubt-wire-gen::malformed_is_code_one_everywhere
- host:redoubt-wire-gen::kind_column_is_checked
- host:redoubt-wire-gen::inline_boundary_is_twelve_bytes
- host:redoubt-wire::generated_vectors_are_current
- host:redoubt-wire::layouts_are_the_tables
- host:redoubt-wire::replies_and_error_codes_decode_through_the_trait
- bench:wire-host-tests
- bench:elixir-oracles
Each typed protocol is defined by one file, libs/wire/tables/<protocol>.md: a message table and
an error table, each under a marker line (<!-- wire: NAME -->, <!-- wire-errors: NAME -->).
Its owning server’s page includes the file, so the table on the page is the definition.
libs/wire/tables/example.md is the guide to writing one; the generator (libs/wire/gen/,
crate redoubt-wire-gen) enforces every rule in it:
- Rows. A message row is
| Opcode | Message | Fields | Reply |: a non-zero decimal opcode with one spelling, unique in the table; a snake_case name that clashes with nothing in the generated code; the request’s fields and the reply’s, each`name: type`(u8,u16,u32,u64,string,bytes, orhandle[N] KIND) or-for none. An error row is a code and a name; the generator addsmalformedas code 1 and refuses a table that names code 1 itself. - Sends. A table’s messages are
calls. A protocol that needs asendadds aKindcolumn after Opcode, and asend’s reply must be-, since nothing answers a send. - A 9P endpoint. A table marked
<!-- wire: NAME ninep -->is served on a 9P endpoint, and its opcodes start at 16. - No silent rows. A table ends at its first blank line, and a row-like line right after it is refused, so a stray blank line cannot drop rows. A table with the header but no marker is refused. Tables inside fenced code blocks are ignored.
- Output.
cargo run -p redoubt-wire-genwriteslibs/wire/src/proto/NAME.rs,libs/wire/elixir/proto/NAME.exand its client,libs/wire/elixir/client/NAME.ex(generated clients), for each protocol. The generated files are checked in, andgenerated_files_are_currentfails if they differ from the tables or if a generated file has no table any more (-- --checknames them). So changing a table means regenerating, in the same commit (R30 (one layout per message)).
flowchart LR
T["libs/wire/tables/NAME.md<br/>message and error tables"] --> G["redoubt-wire-gen"]
T --> P["the owning server's page<br/>(included)"]
G --> R["libs/wire/src/proto/NAME.rs<br/>(checked in)"]
G --> E["libs/wire/elixir/proto/NAME.ex<br/>(checked in)"]
G --> C["libs/wire/elixir/client/NAME.ex<br/>(checked in)"]
R --> S["servers and clients"]
G -. "generated_files_are_current" .- R
Figure: one table feeds the page, the Rust codec, the Elixir codec and its client; the drift check compares the checked-in code with the tables.
Each generated Rust module has a Message and a Reply enum with decode, encode,
decode_file and encode_file, an ErrorCode enum, and a unit type Protocol implementing
typed::Protocol (each message’s layout, and the module’s own encode and decode), so code generic
over every protocol, the client library’s one typed call, needs no copy of a layout. The vector files
libs/wire/vectors/example.txt (written by hand) and example-generated.txt (thousands of
hostile inputs with the Rust codec’s verdict on each) are run by both codecs, through the
fixture protocol example, which no server speaks.
Generated clients
Status: built · tested: bench:beamlet-natives, host:redoubt-wire-gen::the_elixir_client_has_one_function_per_message, host:redoubt-wire-gen::generated_files_are_current, bench:elixir-oracles
The generator writes, beside each protocol’s Elixir codec in libs/wire/elixir, an Elixir client,
libs/wire/elixir/client/NAME.ex (Redoubt.Wire.Client.Keyd): one function per message, taking
the request’s fields and handles in table order and a timeout, and returning the decoded reply, or
the protocol’s error by name, over beamlet’s call native; a send row’s function goes over send.
It is what a session binds a server with (beamlet). In Rust, the
client library’s one typed call over the generated codec is the binding, one call per message
(native programs).
- Nothing to drift. The clients are generated from the table the server is generated from,
checked in, and held to it by
generated_files_are_current, as the codecs are. - One call, one outcome. A reply is
{:ok, reply}, its fields and its handles by the table’s names, each handle a resource term the caller now holds; an error is{:error, name}, the protocol’s error code by its table’s name, a codec’s refusal (:bad_handles,:short_fields), or why the call was not made (:busy,:timeout,:disconnected). The wait for the reply is bounded by the call’s timeout and a margin for a call that waits for one of the platform’s threads; a reply later than that stays in the caller’s mailbox until it drops it. The timeout is the function’s last argument, the shared client’s 5 s by default; a call measured past that passes its own, as the steward’sstart_agentis passedRedoubt.Wire.Client.launch_timeout/0(the steward). A per-message default in the protocol’s table, emitted by the generator, is the durable form once more than one message needs it; until then the one constant lives beside the default it is derived against. An error reply, or one that does not decode, keeps none of the handles it brought: they are dropped, and closed when collected (R13 (one outcome per call)). - No policy. A generated function makes the call its message describes and nothing more;
what a session may do is the server’s check and the handle’s reach. Their shared call,
Redoubt.Wire.Clientinlibs/wire/elixir/lib/client.ex, holds no handle of its own.
The hand-written layer above is thin: littlefsd‘s operations, which name fids that live in Rust, and
in Elixir the modules that make a server idiomatic, such as Redoubt.Keys over keyd. The
generated clients’ checks run on the BEAM in elixir-oracles, against a stand-in for the natives
(libs/wire/elixir/test/client_test.ex): a call encoded and its reply decoded, an error reply by
name, an error reply or an undecodable one keeping none of its handles, and a request that does
not encode making no call.
Granting and releasing
Status: built · tested: host:redoubt-rt::typed_replies_close_the_handles_made_for_the_caller, host:redoubt-rt::a_strangers_id_is_refused_like_one_that_does_not_exist, host:redoubt-keyd::serving_grant_rolls_back_discard_missing_capability_and_error
A typed protocol that mints a narrower capability for a client names two operations for it: a
grant that replies with the new endpoint handle and a random id, and a release that frees
it by id. keyd’s grant and release are the pattern (keyd). A 9P server has the
same pair in ninep_common, new_connection and disconnect. Both are served by the one table of
minted capabilities (minted connections): only the client that
received an id may release it, and a stranger’s id is answered like one that does not exist.
A launcher releases its child’s grants
Status: built · tested: bench:launcher-orphan
A process that launches a child and asks servers to grant capabilities for it keeps each grant’s id. On the child’s exit notice (processes) the launcher releases every grant it made for that child, at every server, and disconnects the child’s fresh connections (init), so a dead child’s grants do not hold their servers’ admission for the life of the launcher.
A child’s grants never outlive its launcher’s: disconnecting a launcher’s connection releases every connection minted under it, at every depth. Servers track no exits. A child’s connections were minted through its launcher’s, so when a launcher dies and its own launcher disconnects it, every descendant’s connections go with it. An orphaned child keeps running in its budget, if that budget lives, but loses its server connections: it fails closed.
The attack test: a launcher faults with its orphan still running, and once its own launcher has released its grants, the server it held a connection at has dropped the orphan’s connection, minted under the launcher’s, as well.
ninep_common
Status: built · tested (7)
- host:redoubt-rt::minted_connections_are_admitted_and_fold_into_the_share_they_came_from
- host:redoubt-rt::disconnect_frees_everything_minted_under_it_and_only_for_its_holder
- host:redoubt-rt::a_strangers_id_is_refused_like_one_that_does_not_exist
- host:redoubt-rt::unasked_handles_are_closed_and_other_opcodes_are_malformed
- host:redoubt-rt::a_connection_is_minted_in_the_scope_its_requester_sends
- host:redoubt-wire-gen::an_optional_handle_is_a_slot_that_may_be_empty
- host:redoubt-wire::an_optional_slot_may_be_empty
Every 9P endpoint also serves ninep_common, typed opcodes 1 to 15. new_connection(root, quota, scope) mints a fresh connection rooted at root, a path relative to the caller’s own root that
never climbs above it, and replies with the connection’s endpoint handle and its id. quota is the
byte quota asked for: the skeleton passes it to the file server’s minted hook, which may refuse
it (refused), and a server that meters no bytes ignores it. What a quota means is the file
server’s (littlefsd). scope is an optional budget handle, the request’s
one handle slot (handle[0]? budget: a slot the sender may leave empty): the revocation scope
the connection is minted in, which stamps its handle; absent, the server’s own stamp
(R41 (narrowing by revocation scope); how the skeleton serves it is on
the serving library). disconnect(id) frees the connection with that id and
everything minted under it; an id the caller did not receive is not_yours, the same answer as
an id that does not exist. Opcodes 1 and 4 to 15 are reserved and malformed. How the skeleton
serves them is on the serving library.
The table: libs/wire/tables/ninep_common.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 2 | new_connection | root: string, quota: u64, scope: handle[0]? budget | conn: handle[0] endpoint, id: u64 |
| 3 | disconnect | id: u64 | - |
| Code | Error |
|---|---|
| 2 | not_yours |
| 3 | refused |
Strict JSON
Status: built · tested (13)
- fuzz:redoubt-wire/json
- host:redoubt-wire::accepts_json
- host:redoubt-wire::integers_only_within_2_to_53
- host:redoubt-wire::big_integers_as_strings
- host:redoubt-wire::duplicate_members_are_refused
- host:redoubt-wire::depth_is_bounded
- host:redoubt-wire::size_is_bounded
- host:redoubt-wire::text_rules
- host:redoubt-wire::syntax_errors
- host:redoubt-wire::unknown_members_are_errors
- host:redoubt-wire::schema_errors_name_the_path
- host:redoubt-wire::heap_is_bounded
- host:redoubt-wire::stack_is_bounded
libs/wire/src/json.rs is the one parser for files people write (the boot manifest, package
manifests, configuration): RFC 8259 syntax under the I-JSON profile (RFC 7493), narrowed so
each value has one spelling.
- UTF-8 only, no byte-order mark, no surrogates or Unicode noncharacters, escaped or raw.
- No object has two members with the same name, compared byte for byte after unescaping.
- Each field’s JSON type is fixed by its schema. A 64-bit quantity (an id, an account, a size
in bytes or pages, a deadline) is a canonical decimal string (digits only, no sign or leading
zeros); a small count (a weight, a depth, a restart limit) is a number. The wrong type is an
error. Numbers are integers within ±(2^53 − 1): no fractions, exponents or
-0, so the parser has no floating point. - Nesting at most
MAX_DEPTH(32) deep; a file at mostMAX_LEN(64 KiB). - Unknown members are errors. An object is decoded only through
Value::object, which refuses any member the decoder did not take. - An error names where it happened (
servers[2].budget.pages). - Cost is bounded for a caller that must size its memory before parsing: time linear in the input apart from a sort per object to find duplicate names (O(n log n)), heap at most 32 bytes per input byte (2 MiB for the largest file), stack bounded by the depth.
The fuzz target checks it against serde_json: whatever it accepts, serde_json accepts with the
same value, and whatever it refuses as plain syntax, serde_json refuses.
Authority
Status: built · tested: host:redoubt-wire-gen::kinds_are_documentation_only
The library has none. It holds no handles and makes no calls; it decodes what a server received and encodes what the server sends. Nothing in a message says who sent it: the badge, account and labels are the kernel’s, attached to the message outside the bytes (R14 (unforgeable sender)). A table’s handle kinds grant nothing either: they are documentation, and the kernel checks a handle by use.
Security properties
R29 (strict decoding)
Status: built · tested (10)
- fuzz:redoubt-wire/ninep
- fuzz:redoubt-wire/typed
- fuzz:redoubt-wire/json
- host:redoubt-wire::lengths_are_bounded_by_the_input
- host:redoubt-wire::framing_is_strict
- host:redoubt-wire::trailing_and_padding
- host:redoubt-wire::words_must_fit_in_32_bits
- host:redoubt-wire::buffer_length_is_bounded
- host:redoubt-wire::heap_is_bounded
- host:redoubt-wire::stack_is_bounded
Decoding untrusted bytes never panics, never reads outside its input, and never loops without
consuming it; a 9P or typed value that decodes re-encodes to exactly the bytes it came from, so
every value has one encoding; and JSON’s time, heap and stack are bounded by its input. The crate
forbids unsafe. So a hostile client can make a server refuse a message, never crash it through
the codec or make two servers read one message two ways. The fuzz targets check the round trip
for every accepted input.
R30 (one layout per message)
Status: built · tested (6)
- host:redoubt-wire-gen::generated_files_are_current
- host:redoubt-wire-gen::refuses_bad_tables
- host:redoubt-wire-gen::every_row_is_read_or_refused
- host:redoubt-wire-gen::malformed_is_code_one_everywhere
- host:redoubt-wire-gen::ninep_marker_sets_the_opcode_floor
- bench:wire-host-tests
Each typed message has exactly one layout, defined by one table, and the codecs every sender and
receiver use are generated from it and checked against it in the bench. Code 1 means
malformed in every protocol, and no protocol on a 9P endpoint uses an opcode ninep_common
owns. So a server and its client cannot disagree on what a message says, and a table on a page
cannot say something the code does not do.
Failure and restart
Status: built · tested: host:redoubt-rt::malformed_requests_and_oversized_replies, host:redoubt-wire::atomic_writes_leave_nothing_on_failure, host:redoubt-wire::encode_respects_msize_and_buffer
The library keeps no state, so it has nothing to restart. A message that does not decode is an
Error its server answers with malformed (or an Rerror for 9P). An encoding that does not
fit its buffer fails with TooLarge and leaves the buffer untouched; the serving library then
answers the call with the malformed reply (replies and rollback).
Residual risks
- Handle kinds are not checked on receipt. A server that keeps a received handle without using it keeps a handle of whatever kind was sent, until it first uses it.
- JSON costs 32 times its input in heap. A caller parsing the largest file needs 2 MiB free; one that cannot spare it must refuse the file before parsing.
- The fuzz targets run outside the bench. No bench case runs the wire codecs’ fuzz targets; they are run by hand.
- The codec does not judge paths.
..and/in walk names pass the codec; a server that bypassed the serving library would have to check them itself.
Why
- Tables as the source. A layout written in prose and again in code drifts; one table that the page shows and the generator reads cannot.
- 9P’s encoding for everything. One encoding means one codec to fuzz, and typed messages gain its bounds for free.
- 32-bit words. One layout on rv32 and rv64 means a codec tested on one width is tested on both.
- Reply data in the caller’s lend. A reply carries only words and handles, so data can only come back where the caller lent room for it; a message whose reply carries data is buffer-shaped for that reason.
- Kinds checked by use. The kernel does not report a received handle’s kind, and every call already checks the kind of the object it acts on; a second check on receipt would need a new call and add nothing.
- Strings above 2^53 in JSON. I-JSON’s safe range is where every parser agrees on a number; a 64-bit id written as a number would be read differently by different tools.
Init
init is the first process. It reads the boot manifest from the signed bundle, starts every
system server from the bundle’s pages through the loader stub, gives each its handles, names and
arguments in a startup block, and restarts a server that exits. It holds the boot budgets and
every device, and it has no network and no user data. The startup block and the loader stub
every launcher uses are described here as well.
Purpose
Something must turn the kernel’s handful of boot handles into a running system, and it must do
so from one signed, checked description rather than from code that decides as it goes. init
is that step: the manifest says what runs, with which devices, volumes, labels, weights and
arguments, and init refuses a manifest that breaks a rule before anything runs. It parses only
that one file and no ELF, so the most privileged process after the kernel has the smallest input.
Interface
The boot manifest
Status: built · tested (34)
- bench:init-boot
- bench:init-refuses-overcommit
- bench:init-refuses-public-manifest
- bench:init-refuses-device-dma
- bench:init-refuses-stack
- host:redoubt-init::what_is_not_strict_json_is_refused_with_where
- host:redoubt-init::names_follow_the_rule_and_differ
- host:redoubt-init::principal_and_label_names_hold_no_separator_and_none_is_reserved
- host:redoubt-init::references_name_what_the_manifest_and_bundle_hold
- host:redoubt-init::a_handed_badge_is_a_root_badge_given_once_at_its_endpoint
- host:redoubt-init::principals_values_are_checked
- host:redoubt-init::a_device_name_is_at_most_60_bytes_and_never_ends_in_irq
- host:redoubt-init::a_device_split_between_two_entries_is_refused
- host:redoubt-init::the_dma_flag_must_be_the_kernel_s
- host:redoubt-init::one_device_has_one_holder
- host:redoubt-init::public_names_entries_the_bundle_holds_never_the_manifest
- host:redoubt-init::a_shared_server_needs_a_bucket_per_declared_domain_and_root_badge
- host:redoubt-init::handles_and_arguments_must_fit_one_startup_block
- host:redoubt-init::the_fuzz_corpus_still_passes
- host:redoubt-init::each_volume_s_range_is_minted_at_its_own_disk_s_blkd
- host:redoubt-init::a_blkd_receives_where_init_mints_its_ranges
- host:redoubt-init::a_server_stack_defaults_and_is_checked_against_its_budget
- host:redoubt-init::a_verified_volume_s_server_reads_through_its_verifier
- host:redoubt-init::a_verified_volume_s_key_and_verifier_are_refused_naming_the_field
- host:redoubt-init::a_verified_volume_is_pinned_or_signed_and_never_both
- host:redoubt-init::the_steward_s_entry_alone_is_given_the_manifest_lines
- host:redoubt-init::the_steward_object_and_console_name_what_the_manifest_holds
- host:redoubt-init::the_steward_s_sizes_fit_every_principal_s_smallest_share
- host:redoubt-init::a_key_in_two_roles_across_principals_is_refused
- host:redoubt-init::the_steward_s_own_lines_bind_homes_vaults_and_scopes
- host:redoubt-init::home_quotas_are_checked_against_their_volume
- host:redoubt-init::the_over_committed_case_is_refused_at_its_volume
- host:redoubt-init::a_vault_is_one_principals
- host:redoubt-init::no_home_is_another_s_or_inside_it
The boot manifest is one strict JSON file (wire) in the signed bundle,
and init’s only input. Its entries:
| Entry | Holds |
|---|---|
devices | each device’s name, its register base and its interrupt number (either may be absent, not both), and whether it may do DMA |
labels | each label’s name, owner principal and 64-bit id |
volumes | each volume’s name, blkd partition, label set and disk (the servers entry of the blkd serving it), and for a verified volume verity: its verifier (the servers entry of a verityd) and one mode, pinned, the root and data blocks it pins, { "server", "root": 64 lowercase hex digits, "blocks": a decimal string }, or signed, the key its root block is signed under and the lowest version it may carry, { "server", "key": 64 lowercase hex digits or "bundle", "floor": a decimal string }; and bytes, its size, a decimal string: its partition’s, required where homes are carved from it (home quotas) |
servers | each server’s name, program (a bundle entry), budget (pages, processes, weight), the devices it gets (each a devices name and the name the program looks it up by), volume (its range badge, minted by init, and its label ids as labels=; a volume’s server has program walfsd or littlefsd for a writable volume, or erofsd for a read-only one, and no other key says the format), the endpoints it receives on, the endpoints it is handed (each an endpoint name and the root badge init mints for it: a decimal string below FIRST_MINTED_BADGE, never used twice at one endpoint), arguments, and its stack in pages (stack_pages, 16 if absent, at most 128), and its heap cap in pages (heap_pages, none if absent) |
public | the bundle entries bootfsd serves at /boot, by exact name |
principals | each principal’s name, SSH public keys (ssh-ed25519 only, each once across every principal’s login and approval lists) for login and approval, budget (or, for a sponsored principal, sponsor: another, unsponsored, principal’s name, and no budget: its sessions are leases carved from the sponsor’s, steward), account, owned labels, the label sets it works under (each a fixed, equal share of the principal’s budget), home (volume and path) and its quota (home_quota, bytes as a decimal string, required with a home), network scope (IP prefixes and ports), and optionally its contexts (contexts: max, the live contexts per label set, and idle_secs, how long one stays detached, each a number; contexts) |
steward | optional; the servers entry that is the steward, which alone init hands users at step 6, and the sizes it carves (sizes: session, agent, sub_agent and crossing, each a budget, agent also with an optional quota, the bytes of its sponsor’s home a lease’s root is carved with, and cost, a budget object’s own pages). init checks every limit nonzero, each size within every principal’s smallest share and the agent’s quota below every principal’s home_quota, and hands the steward the principals and sizes as the manifest lines, then its own lines: label names, each principal’s home, labelled volumes and network scope, whose servers the steward’s entry must be handed, and confined when the manifest sets it (steward) |
console | optional; the principal whose unlabelled session the steward opens on the UART console (steward); it needs a steward, and a name that is not a principals entry refuses the boot |
confined | optional; a boolean at the top level (confinement) |
- Types. Each field has one JSON type. A 64-bit quantity (a label id, an account, a size in
pages or bytes, a deadline) is a decimal string; a small count (processes, a weight, a depth, a
restart limit, a port) is a number.
heap_pagesis a decimal string of at most 4294967295, the startup block’su32; a larger one is the wrong type. A wrong type, an unknown member or a repeated one is an error, and an error refuses the boot. - Names. Every name (device, label, volume, server, endpoint, principal) is 1 to 64 bytes of
[a-z0-9_:+-], starting with a letter (walfsd:data,alice+secrets), compared byte for byte. Names become endpoint names, volume names and 9P paths, so no empty name, NUL, U+FEFF or control character may reach them. The startup block applies the same rule (valid_name). A principal’s and a label’s name hold no:or+either, so that a login’s user name,principal[+label][.context], splits one way only, and no principal is namedapprove, the approval terminal’s user name (contexts). - Stacks. A server’s
stack_pagesis the size of its first thread’s stack, charged to its budget.initrefuses zero, more than 128 pages, or a stack not smaller than its budget’s pages, before it starts any server. A budget that holds the stack but not the image beside it makes that server’s launch fail, which refuses the boot, or on a restart reboots the machine (restarts and reboots). The bench measures each server’s peak across the required boot and userland paths and requires the declaration to hold at least twice the largest peak (the memory budget). - Heaps. A server’s heap cap is the most pages its runtime’s allocator holds; past it an
allocation fails in the runtime before the kernel is asked. The budget stays the bound on
everything else.
initrefuses 0, or a cap and stack the budget cannot hold. The bench requires each image server’s cap to hold at least twice its largest heap peak (the memory budget). A shared server’s entry follows the entry rule besides: its pages hold itsbuckets=Nat their caps beside the heap cap and the stack (walfsd), which the server checks at start from the budget and heap cap its startup block carries, and refuses to start without. - One entry per device. A
devicesentry names one device, by its register region and its interrupt, andinithands that device’s objects together to the one server that holds the entry. They go under the name the server’s entry gives the device:NAMEfor the register region andNAME-irqfor the interrupt, whichever exist. Two entries for one device would let a manifest split it between two holders, and the interrupt’s holder could then mask the other’s device and time its activity. A device name, and a name a server looks a device up by, is at most 60 bytes and may not end in-irq, soNAME-irqnever collides and fits the name rule.consoledtakesuartanduart-irq, so two machines’ manifests may call the same UART by different names. Adevicesentry is held by at most one server, and an endpoint is received on by at most one; a manifest naming either twice is refused, confined or not. - No server gets a budget handle. A
serversentry names the budgetinitcreates for the server, never a handle to one; a manifest that grants a server a budget handle is refused (R33 (no server holds a system budget)).root,systemandusersare reserved: no endpoint takes one of those names, and areceivesorhandeditem that names one is refused as a budget grant. - Arguments are opaque strings.
initpasses them unchanged and in order as the startup block’sargvand never interprets them; each server’s page defines its own (keyd’s keys,ipd’s addresses and bucket count).initchecks only that each is UTF-8 with no NUL, and that together they leave the startup block inside its page. A badge a server’s arguments name for a caller is that caller’shandedbadge, written in both places by the manifest’s author;initmints it from thehandeditem, never from the argument. Whereinitcalls a server itself (keyd,consoledandbootfsd, at the first endpoint each receives on), its own handle carries the smallest badge from 1 that nohandeditem there uses; at the steward’s entry it is badge 3, the class the steward acceptsblameon, which nohandeditem may name there (restarts and reboots). A manifest names each of these programs at most once,keydexactly once: a second would run beside the oneinitcalls, unchecked, and a secondkeydcould hold keysinitnever asked about (R35). A program that may run more than once (blkd, one per disk;walfsd,littlefsdorerofsd, one per volume) is told the endpoint it receives on by its argumentendpoint=NAME, which the manifest gives it; ablkd‘s must name the endpoint it receives on first, whereinitmints its volumes’ ranges. - Volumes. A
volumesentry is one GPT entry of its disk, which no other entry names on that disk, and at most one server attaches it, and no entry is handed a badge at the endpoint anyblkdreceives on (R47 (one volume per instance)). Itsdisknames theserversentry of theblkdserving its disk: required when the manifest has more than oneblkd, and refused if it names noblkd; with one, it may be left out. For the attaching serverinitmints the range badge, the entry number + 1, at the endpoint its disk’sblkdreceives on first, hands it asvolume, and addslabels=the volume’s label ids after the entry’s own arguments (none for an unlabelled volume); it gives eachblkdonelabels.P=ID,...per labelled volume on its disk, P its entry number (blkd). A volume a server attaches without its disk’sblkd, and an entry carrying one of these arguments itself, are refused. - Verified volumes. A volume with
verityis read through its verifier, averitydentry (verityd): its server still lists the volume, butinitmints that server’svolumebadge, 1, at the verifier’s endpoint instead of atblkd, and mints the range badge at the disk’sblkdfor the verifier, handed as itsvolume. The verifier getsendpoint=the endpoint it receives on first,labels=the volume’s ids, androot=andblocks=, or, signed,key=andfloor=,bundlegiven in hex as the key the loader verified the bundle with, named so no copy drifts (verityd). Refused, each naming the field: both modes, neither, or a part of one; a root that is not 64 lowercase hex digits, or a block count of 0 or one whose tree does not count in sectors; a key that is neither 64 lowercase hex digits norbundle; a floor that is not a decimal string (the wrong type); aservernaming no entry, or one that is not averityd; a verifier named by two volumes, or by none; a verifier attaching a volume itself, receiving on no endpoint, or carrying any argument (each isinit’s); a verifier whose labels differ from its volume’s; and ahandeditem at a verifier’s endpoint, since the one badge there is the volume’s range. - Sizing. Every shared server takes
buckets=Nas an argument, parsed once in the serving library; none has a compiled-in count.initrefuses the boot unless N is at least the number of (account, label set)s the manifest declares (each principal’s unlabelled set and every label set it works under) plus the root badgesinitmints at that server, one per system caller. It counts every declared domain at every shared server, not only those a session will reach: an over-count costs buckets, and an under-count would bind. So a principal added to the image raises every shared server’s count by its domains: the image’s three principals declare four, and each server’sbuckets=is at least those plus its system callers (keyd6,ipd7;walfsd:dataanderofsd:systemneed 5 and carry 6, the spare for a case’s extra client;sshd4, its connections served at once, one channel for each domain (sshd)), within what the server’s own budget holds.initreadsbuckets=Nfrom a server’s arguments, with the serving library’s parser, and no other argument. So a server’s bucket count never binds in normal use, and a full server cannot tell a latecomer that others hold state (serving). A server whose block has nobuckets=N, or one outside 1 to 32, does not start. - Weights. One stride queue serves every budget (scheduling), so
the manifest’s weights are the whole scheduling policy.
init, the steward and the drivers (consoled,blkd,netd) get weights an order of magnitude above a session’s (1000 against a principal’s 100), so they are served promptly without running ahead of the queue; the servers that work for principals (bootfsd, the file servers,ipd,keyd,sshd) get ordinary weights and bound the work of one request. The weights carve thesystembudget like every other limit. The kernel sizessystem, not the manifest, andinitrefuses a manifest whose servers’ pages, processes or weights add up to more thansystemholds (budgets). - Devices are matched by address.
initasks the kernel which device each of its handles names, finds eachdevicesentry’s base and interrupt among the answers, and the DMA flags must agree (devices). - What
/bootshows.bootfsdserves exactly the entriespublicnames, matched byte for byte, as one flat read-only directory.initpushes their bytes tobootfsditself (bootfsd), and refuses apubliclist that names an entry the bundle does not hold, or the manifest. The manifest is never public: it holdskeyd’s seeds and every principal’s account and keys.
{ "servers": [ { "name": "walfsd:data", "program": "walfsd", "volume": "data",
"budget": { "pages": "4096", "processes": 1, "weight": 100 },
"receives": ["walfsd:data"], "args": ["endpoint=walfsd:data", "buckets=4"] } ],
"principals": [ { "name": "alice", "account": "1001", "labels": ["alice-secrets"],
"ssh_keys": ["ssh-ed25519 AAAA..."], "home": "data:/home/alice",
"home_quota": "8388608",
"net": [ { "prefix": "0.0.0.0/0", "ports": [22, 443] } ] } ] }
A fragment: one file server and one principal.
The attack tests: a manifest that splits a device between two entries, or names a device ending
in -irq, is refused; a manifest giving a server fewer buckets than it serves refuses the boot.
Sizing a server when principals are added at run time is the steward’s, in M6 (persist, install, share).
Home quotas
A principal’s home holds at most its home_quota bytes, all its sessions together: the steward
carves one connection per principal with it and mints each session’s home through that one
(steward). init refuses, naming the field:
- a home without a
home_quota, ahome_quotaof 0, or one with no home; - a home equal to another principal’s on the same volume, inside it or holding it: the volume’s server would count one under the other’s root, so each would spend the other’s room;
- a volume homes are carved from that gives no
bytes, or whose homes’ quotas sum past itsbytes: so the steward can carve every principal’s home at once; - a labelled volume that two principals’ label sets name: a vault is one principal’s, and has no quota of its own, bounded by its volume’s room.
bytes is a promise about the disk that init cannot read: it never sees a partition table. So
the disk’s packer refuses a recipe whose manifest gives a volume bytes other than its
partition’s size, and the bench refuses a case whose disk and manifest disagree the same way
(the test bench). The volume’s server bounds what a
carve may take all the same: it refuses a quota its room cannot hold
(walfsd), and a session then has no home.
Contexts
A principal’s contexts caps its live contexts in each label set and bounds how long a detached
one lasts (steward). init writes both into the principal’s manifest
line, contexts=N idle=S. Absent, max is what one label set’s share holds: the share’s pages,
less a budget’s own cost, over the steward’s session size, or its processes over a session’s,
whichever is fewer, and at most 16; idle_secs is a day, 86,400. init refuses, naming the
field, a max of 0, above 16, or above what the share holds, so the cap, not a failed carve,
is what refuses a login; a max below 2 for the console principal, whose console session
counts against its unlabelled set’s cap and would leave it no SSH context there; and an
idle_secs below 60 or above 604,800, a week.
The confinement check
Status: built · tested (15)
- bench:init-refuses-confined-server
- bench:steward-confined-push
- host:redoubt-init::confined_refuses_two_label_sets_on_one_endpoint
- host:redoubt-init::confined_refuses_two_label_sets_on_one_volume
- host:redoubt-init::confined_counts_a_home_for_the_principals_unlabelled_domain_alone
- host:redoubt-init::the_steward_is_told_a_confined_manifest
- host:redoubt-steward-server::a_confined_boot_leaves_a_labelled_sessions_home_unbound
- host:redoubt-init::confined_gives_a_labelled_domain_no_network
- host:redoubt-init::confined_refuses_two_label_sets_on_one_disk
- host:redoubt-init::confined_refuses_a_server_instance_serving_two_label_sets
- host:redoubt-init::confined_counts_only_a_shared_servers_own_label_set
- host:redoubt-init::confined_does_not_count_an_exempt_server_handed_a_labelled_endpoint
- host:redoubt-init::confined_lets_label_sets_that_share_nothing_share_the_cores
- host:redoubt-init::confined_gives_each_label_set_its_own_userland_disk
- host:redoubt-init::confined_gives_each_label_set_its_own_verifier
A manifest may set confined, a deployment profile for the whole boot, never per domain. Set, it
makes init refuse the boot whenever two entries with differing label sets share any of:
- an endpoint: one name in a
serversentry’s receives or handed list both hold; - a volume: one
volumesentry both attach; - a network instance: one
ipdornetdboth use (a labelled domain gets no/netat all); - a device object: one
devicesentry both hold, since a shared disk or NIC is a shared scheduler, cache and timing surface; - a server instance: one
serversentry serving both.
The kernel and the cores are not on the list. Every label set shares the one kernel, which is the trusted base, and its cores, whose timing is no more partitioned than the caches around them (side channels).
The label set compared is a budget’s labels (a server’s labels, a principal’s label sets) and,
for a volume, its volumes entry’s label set. Two sets differ when they are not equal: {a}
differs from {}, from {b} and from {a,b}. A system server such as the steward carries no
labels, so it is a domain of its own. A confined manifest in which a labelled domain’s principal
has a home on a shared unlabelled volume is not refused: the check counts the home’s volume for
the principal’s unlabelled domain alone, because the steward binds a home to unlabelled sessions
alone under confined, which init tells it as one of its own lines, leaving a labelled
session’s home unbound and offering the push; data enters such a domain by an audited push from
the steward (steward). So the static half of R34
(volumes, disks, servers, endpoints) is this check’s, and the one dynamic half, which domain a
home is bound for, is the steward’s. Every other refusal is a boot failure, not a warning
(R34 (confined placement)).
The domains compared are each servers entry, under its labels ({} if none), and each
principal’s label sets. A server’s users are the servers handed one of its endpoints, or a
volume’s range at it (a volume’s server on that blkd’s disk, or a verified volume’s verityd; the
server attaching a verified volume at its verityd, which counts at the verifier’s first endpoint too),
and, for a shared server (one that takes
buckets=N), every principal domain with the server’s own label set. Only such a domain may later
be granted a connection there, since the steward grants within a label set by this same rule, so
a server a session may later reach is never missed; a domain with another set is not counted, and
the bucket rule still sizes the server for every principal domain. An exempt server (the steward,
sshd) handed an endpoint is not counted as its user either. It is not the user: it mints a
session’s connection in that session’s scope, and the session is the user. The steward binds a
labelled volume only to a session in exactly that volume’s label set (its binding rule, which the
model’s routes check and the vault’s exact_labels guard keeps), and the server it mints at
admits only a caller whose labels equal its volume’s
(R25 (the label check)), so a steward that routed a labelled
endpoint to another set would be refused by the server it routed to. The steward itself never
reads a vault: a declassification reads through a crossing carrying the item’s labels
(R42 (one approved item)). So the exemption says only who is
not counted; it widens nothing the steward may route. The kinds are checked in the
order listed, and the refusal names the kind. So in a confined boot each disk holds one label
set’s volumes, and its blkd carries that set, and each label set reading a verified volume has
its own verityd.
The one named exception is the control plane: the steward and sshd may reach across label
sets, and only by three kinds of edge: the request and owner-approval path; per-item reader and
writer budgets, each carrying exactly one label set and dying after one item (declassification and
push); and lease-ending supervision. No shared data server or device is exempt. init
checks the declared graph at boot, and the steward enforces the same rule for the budgets and
grants it creates later.
It is a check on the manifest, not a run-time invariant: a capability handed over after boot (by
mint, by a grant, by a system server) is outside it, and a system server that hands one
across label sets is at fault, not the kernel. Without confined, a shared server is ordinary
multi-tenancy and the serving library’s residual risks apply.
Starting the servers
Status: built · tested (19)
- bench:init-boot
- bench:init-servers
- bench:littlefsd-boot
- bench:init-refuses-system-fit
- bench:init-refuses-device-unmatched
- bench:init-refuses-bound
- bench:init-refuses-held-bundle-key
- bench:init-refuses-consoled-handed
- host:redoubt-init::the_image_manifest_passes_and_its_plan_is_what_the_boot_follows
- host:redoubt-init::a_manifest_without_keyd_is_refused_and_init_calls_each_server_at_an_endpoint
- host:redoubt-init::init_calls_one_of_each_server_it_calls
- host:redoubt-init::more_servers_than_init_has_threads_to_watch_are_refused
- host:redoubt-init::no_server_is_handed_a_root_badge_at_consoled
- host:redoubt-init::a_volume_s_labels_go_to_its_server_and_to_blkd
- host:redoubt-init::a_volume_is_one_entry_for_one_server_at_one_blkd
- host:redoubt-init::no_server_is_handed_a_badge_at_blkd
- host:redoubt-init::a_verifier_costs_init_one_server_and_its_range
- bench:steward-boot
- bench:steward-ssh-two-principals
The kernel gives init the root, system and users budgets, every device object and the
Reset right. The loader maps the bundle into it, read-only
(boot). init then:
- parses and checks the manifest, and refuses the boot on any error, or if what the manifest
will cost
initdoes not fit in whatrootkeeps for it (budgets), or if it names more servers thaninitcan watch, one thread each beside its own: at mostMAX_THREADS- 1. Untilconsoledstarts,initwrites its own lines to the UART, which it maps for itself, each inside the console’s hold (the console’s one writer). A refusal is printed there, and the machine powers off with a system-failure status, before any other process has run; - creates every endpoint the manifest’s servers receive on. Each is owned by and charged to
root, so it outlives any one instance of its server, and R1 (flow) does not bind it becauserootissystemclass (IPC).initkeeps the receive right, hands the server a copy, and mints, for eachhandeditem that names the endpoint, a handle with the item’s badge for the server whose entry lists it, and atblkd’s, each volume’s range badge for the server attaching it; - starts
keydand runs the key-separation check against it. A manifest with nokeydentry is refused at step 1, since the bundle’s key always needs asking about; - unmaps the UART and starts
consoledwith it. From then on,initwrites through its own connection toconsoled, and it prints each child’s console connection id when it starts the child (consoled). The check refuses a manifest that hands any server an endpointconsoledreceives on: a root badge there writes bare lines, and onlyinitholds one. Without aconsoledentry,initkeeps the UART; - starts the rest of the drivers and the servers below the steward:
bootfsd,blkd, each volume’swalfsd,littlefsdorerofsd,netd,ipdandsshd, then pushes thepublicentries tobootfsd, whose arguments named each with its length from the bundle (bootfsd); - starts the steward, the entry
steward.servernames, after the public entries, since it starts sessions from/boot: handing it theusersbudget (that entry alone, by name in its startup block; nohandeditem names a budget), itshandedbadges atbootfsd, each volume’s server andipd, through which it asks each server for a session’s fresh connections, and the manifest lines as its arguments, after its entry’s own.
Each server runs in a budget of its own, carved from system, and is started through the loader
stub straight from the bundle’s pages, so no file server is needed to start anything. init
holds every device and places each driver’s handles, by name, in that driver’s startup block
(R33).
sequenceDiagram
participant L as loader
participant K as kernel
participant I as init
participant S as system servers
participant KD as keyd
participant ST as steward
participant SH as sshd
Note over ST,SH: planned
L->>K: verified bundle: kernel and init
L->>I: the bundle, read-only
K->>I: root, system, users budgets,<br/>devices, Reset
I->>I: parse and check the manifest,<br/>make the servers' endpoints
I->>KD: launch keyd with its keys
I->>KD: holds(each login, approval and bundle key)
KD->>I: no (a yes stops the boot)
I->>S: launch through the stub:<br/>consoled, then bootfsd, blkd, netd, ipd
I->>S: launch walfsd, littlefsd or erofsd,<br/>one per volume
I-->>ST: launch, with the users budget
I-->>SH: launch, with keyd's host-key badge
SH-->>ST: a login: whose key is this?
ST-->>ST: carve the session budget,<br/>launch the first session
Figure: the boot from the loader to the first session. Dashed: planned (the steward and sshd).
The attack tests: a manifest whose servers do not fit in system, or whose device entries do not
match the kernel’s device objects, is refused before any server runs. The verdict is init’s
refusal line, printed when nothing else has run, and the power-off status.
The key-separation check
Status: built · tested (5)
- bench:init-refuses-held-login-key
- bench:init-refuses-held-bundle-key
- bench:init-refuses-second-keyd
- host:redoubt-init::every_login_and_approval_key_then_the_bundle_key_is_asked_about
- host:redoubt-init::init_calls_one_of_each_server_it_calls
init refuses a manifest that hands keyd a key the box is authenticated by: a key listed both
as a principal’s login or approval key and as a keyd key, or the key the loader verifies the
boot bundle with, which init carries as the same compiled-in constant. keyd cannot see either
itself: it is given seeds and purposes, not what the rest of the system does with the public keys.
init holds no cryptography, so once keyd is started and before anything else runs, it asks
keyd holds(public key) for each such key (keyd), and a yes stops the boot
(R35 (key separation)).
The startup block
Status: built · partly tested: the parser's fuzz target runs in no bench case; in a boot, only the blocks `init` and `stub-launch` write are parsed · tested (17)
- bench:rt-host-tests
- host:redoubt-rt::round_trip
- host:redoubt-rt::the_page_is_the_wire_message
- host:redoubt-rt::image_round_trips_and_is_validated
- host:redoubt-rt::heap_pages_budget_pages_and_tag_round_trip
- host:redoubt-rt::resolve_takes_the_longest_prefix
- host:redoubt-rt::handle_names_follow_the_manifest_rule
- host:redoubt-rt::hostile_blocks_are_refused
- host:redoubt-rt::fields_hold_whole_entries
- host:redoubt-rt::handle_counts_are_what_process_start_can_install
- host:redoubt-rt::random_bytes_never_panic
- fuzz:redoubt-rt/startup
- host:redoubt-init::a_servers_block_carries_its_budgets_pages
- host:beamlet-redoubt::a_launch_takes_what_it_is_given_and_its_end_is_an_event
- bench:stub-launch
- bench:net-tcp
- bench:heap-cap
A launcher gives each child one read-only page, the startup block, naming the handles it
installed in the child’s slots 1 to n (process_start,
processes). process_start’s argument register
carries the page’s address; there is no fixed address. The runtime (libs/rt/src/startup.rs)
parses it before the program’s main runs, and a program started with no block (address 0) gets
an empty one.
The page holds the block’s length as a little-endian u32, then one typed message, startup,
laid out as a typed operation written into a file: its opcode as a u32, then the buffer-shape
encoding of its fields (wire). The rest of the page is not read.
| Field | Holds |
|---|---|
version | 3; a block of any other version is refused |
handle_count | n, the handles process_start installed, at most MAX_START_HANDLES (128) |
namespace | entries handle: u32, path: string: where a handle is bound, a clean absolute path (/, /dev/cons) |
handles | entries handle: u32, name: string: a named handle, the name under the manifest’s rule |
argv | strings, the arguments in order (each may be empty) |
image_addr, image_len | where the program’s ELF image sits in the child and its exact length, for the loader stub; both 0 for none |
heap_pages | the child’s heap cap in pages (Heaps), 0 for none |
budget_pages | the pages of the budget the launcher made for the child, 0 for none: init writes the manifest’s budget.pages; the steward writes a session VM’s share of its session’s budget, the session’s pages less its relay’s 128, and the relay’s its 128 (budgets); a session’s launch writes the pages the VM carved the budget with, and for a child launched into the VM’s own budget, that budget’s whole pages, the session’s share, which the child then shares with the VM. The launcher’s word, information and not authority: a program holds no handle it could read its budget from, and beamlet sizes its limits by it (limits) |
tag | the launch tag the bench measures the child’s stack and heap by, 0 for none: init gives each server its place in the manifest, from 1 |
Checked whole. The parent may be hostile, so the parser bounds everything and checks
everything before handing the block to the program: every handle is in 1 to n, paths are unique
and clean, names are unique and valid, each bytes field holds whole entries and nothing else,
image_addr is 0 exactly when image_len is, is page-aligned, and does not overflow with its
length, and the block fits its page. A block breaking any rule is refused whole, and the process
exits with code 102 before main runs (R31 (startup block checked whole)).
Using it. resolve(path) finds the namespace entry with the longest matching prefix and the
rest of the path; handle(name) finds a named handle; args gives the arguments. The runtime
notes the handle bound at /dev/cons for its panic report, caps its heap at heap_pages, and
marks its heap record with tag, all before main; budget_pages it gives the program to read.
StartupBuilder writes a block for a
launcher, and its finish runs the parser on the result, so a launcher can only write blocks a
child accepts.
The table: libs/wire/tables/startup.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 1 | startup | version: u32, handle_count: u32, namespace: bytes, handles: bytes, argv: bytes, image_addr: u64, image_len: u64, heap_pages: u32, budget_pages: u64, tag: u16 | - |
| Code | Error |
|---|
Launching through the loader stub
Status: built · partly tested: on target the kernel's refusal masks the stub's overlap checks, which only host tests pin · tested (21)
- bench:rt-host-tests
- bench:stub-launch
- host:redoubt-client::an_image_moves_one_batch_at_a_time
- host:redoubt-client::a_refusal_on_the_third_batch_leaves_the_launcher_as_it_was
- host:stub::plan_maps_a_well_formed_segment
- host:stub::plan_refuses_a_segment_reaching_outside_the_image
- host:stub::plan_refuses_a_segment_overlapping_an_excluded_range
- host:stub::plan_refuses_writable_and_executable
- host:stub::plan_refuses_writable_without_readable
- host:stub::plan_refuses_a_non_riscv_machine
- host:stub::plan_refuses_an_entry_outside_any_executable_segment
- host:stub::plan_refuses_two_segments_that_overlap_each_other
- host:stub::plan_refuses_a_misaligned_p_align
- host:stub::plan_refuses_more_than_max_phnum_segments
- host:stub::plan_refuses_a_segment_touching_page_zero
- host:stub::plan_refuses_a_segment_reaching_into_the_stub_region
- host:stub::plan_refuses_a_non_exec_type
- host:stub::image_in_bounds_refuses_an_image_overlapping_the_stub
- host:stub::image_in_bounds_refuses_an_image_overlapping_the_startup_page
- host:stub::read_image_refuses_a_short_page
- host:stub::read_image_refuses_an_image_len_over_the_cap
Every process after init starts the same way, and no launcher parses an ELF: the loader
stub (stub/), a small flat binary mapped into the new process, does it there, where a hostile
image can hurt only the process it was going to become.
- The launcher creates the child’s budget and process (
process_create) with an exit endpoint. - It maps into the child, with
process_map: the stub, read-only and executable, atSTUB_ENTRY(0x1FF0_0000); a copy of the program’s ELF image, read-write, placed 64 pages at a time; the stack, zeroed, or painted with a pattern the bench reads when the launcher tags it, asinittags each server’s (the memory budget); and the startup block, read-only, naming the image withimage_addrandimage_len. Where each goes is on memory layout. - It starts the child’s first thread at
STUB_ENTRY, with the startup block’s address as the argument, installing the child’s handles. - The stub reads
image_addrandimage_lenfrom the block, checks that the image lies clear of the stub and the startup page and is at mostMAX_IMAGE_LEN, and plans everyPT_LOADsegment before mapping any (stub::plan, pure and allocation-free). - It maps each segment at its link address with
map_fixed, copies its bytes in, sets its final permissions, unmaps the image copy, and jumps to the ELF’s entry with the startup block’s address ina0. From then on the process is the program.
What the stub refuses, whole, before mapping anything: an ELF that does not parse, is not
ET_EXEC, is not RISC-V of the stub’s own width, or has more than 64 program headers; a segment
whose bytes reach outside the image, whose pages touch page 0, reach past STUB_ENTRY, or
overlap the image, the startup block, the stub or another segment; a segment both writable and
executable, or writable without readable; a p_align that is not a power of two or disagrees
with the segment’s offset; an entry outside every executable segment. The stub holds no writable
data and depends only on redoubt-sys and redoubt-wire.
Exit codes. The stub exits with 110 for a startup block that is missing, does not parse or names
no image, or whose image overlaps the stub or the startup block or is over MAX_IMAGE_LEN; 111 for
a hostile image, including a segment the kernel refuses to map (one over the stack); 112 when
map_fixed is out of memory in the child’s budget; and 101 if it panics. After the jump, the exit
code is the program’s.
The stub allocates nothing. It links the wire crate, which declares an allocator, so it installs
a zero-sized global allocator, NullAlloc (stub/src/lib.rs), whose alloc returns null and is
never reached: the typed decoding the stub uses does not allocate.
The bench’s launcher, stub-launch, and init both launch this way
(R32 (a hostile image hurts only its process)).
Fresh connections per child
Status: built · tested (7)
- bench:init-servers
- bench:init-console-forgery
- bench:init-restart
- bench:steward-restart
- bench:steward-server-restart
- host:redoubt-init::a_restart_strands_the_stewards_connections_at_the_endpoints_the_server_receives_on
- host:redoubt-init::the_outcome_of_the_hand_over_is_said_in_blames_words
A launcher never passes its own connection to a child. Every handle in a child’s namespace is a
fresh connection the server made for that child with new_connection
(wire), rooted where the child’s view of that server begins, and the
launcher disconnects it when the child exits (releasing grants).
A copied connection would share the launcher’s fids and admission with the child, and the
launcher could not free the child’s state without losing its own.
The steward, which launches sessions, is handed a fresh connection in place of the root badge at
each server its manifest entry names: init mints the badge, makes the connection through it with
new_connection at the badge’s own root, hands the connection and closes the badge. At the
steward’s exit init mints the badge again for one call, disconnects the connection, and closes
it, so every connection the steward minted under it for its sessions goes too
(steward). init holds none of these between the calls. A server
that makes no connection, because it mints none (keyd), refuses (a file server whose volume is
corrupt) or does not answer within a second, is handed the badge instead, as every server is, and
init says so (init: <endpoint> made the steward no fresh connection: the badge is handed): the
steward starts and meets that server’s own answers. Only then can a dead steward’s session
connections outlive it at that server, if it serves sessions later.
A shared server’s restart strands the steward’s fresh connection there, and every session’s
minted under it, in the dead instance’s tables. So after restarting a server, and after the
fault’s blame, init makes the steward a new connection at each of that server’s endpoints the
steward is handed, as above, waiting up to three seconds for the new instance to serve (a file
server recovers its log before it serves, and under a test feature checks its volume: on the
image’s data volume the first restart’s recovery measured 589 to 953 ms on both widths, one
run past a second unmeasured, and the restarts after it 8 to 71 ms; the bound is twice the
measured maximum plus a second’s margin for the run the old bound cut short, rounded up to the
next whole second: 2 × 0.953 s + 1 s = 2.9 s, so 3 s, reconnect::SERVER_TIMEOUT), and
hands it over with reconnected(server, connection) through its own badge at the steward (the
one blame goes on), within the same second a blame waits; the connection replaces the one in
init’s list, so the release at the steward’s exit frees the new one. init says how long the
new instance took to serve and what became of the connection (init: walfsd:data reconnected for the steward after 812 ms: the steward took it, or lost: the steward did not answer within 1 s, lost: the steward ended, lost: the steward refused it), and a connection the steward
did not take is disconnected again: its sessions keep their dead connections there, and the
steward stays stranded at that server until its own restart, nothing retried
(residual risks). A login that reaches the steward between the restart and
reconnected fails its home step and is refused; the person retries. A server that makes no
connection within the bound is said too (init: walfsd:data made the steward no new connection within 3 s of its restart), and is stranded the same way. The steward tells
its sessions and they re-bind (steward). A restarted
consoled is not on this path (residual risks).
Restarts and reboots
Status: built · partly tested: the machine shows a fault blamed through `init`'s own badge and taken by the steward, three of them reaching the lockout, and the steward's connection at the restarted server handed over; the timeouts' outcomes are decided on the host; a restarted `consoled`'s attach is read from the code, not attacked · tested (20)
- bench:steward-blame-lockout
- bench:steward-server-restart
- host:redoubt-init::a_blame_goes_to_the_steward_only_for_a_named_account_while_the_steward_runs
- host:redoubt-init::a_blame_the_steward_does_not_take_in_time_is_lost_and_said
- host:redoubt-init::a_blame_taken_a_dead_steward_and_a_refusal_are_told_apart
- bench:init-restart
- bench:init-handed-revoked
- bench:init-reboot
- bench:init-driver-restart
- bench:netd-restart
- bench:init-quarantine-reboot
- host:redoubt-init::the_fifth_restart_goes_ahead_and_the_sixth_exit_reboots
- host:redoubt-init::a_restart_older_than_the_window_is_dropped_from_the_count
- host:redoubt-init::restarts_spread_wider_than_the_window_never_reboot
- host:redoubt-init::a_clock_that_reads_earlier_counts_the_restart_as_recent
- host:redoubt-init::emptying_reaps_until_none_are_left_and_counts_each
- host:redoubt-init::an_empty_budget_is_not_reaped
- host:redoubt-init::a_refused_reap_stops_the_loop_with_its_error
- bench:steward-restart
- bench:steward-restart-reboot
-
Restart. A server that exits is restarted on the same endpoint. Calls it had taken get
Dead(R4b (a server dies)), and clients see the error and retry; senders still waiting on the endpoint are served by the restarted server. The new instance gets the same manifest name, arguments and receive endpoint, but a new startup block:init, as its launcher, disconnects the dead instance’s connections at every server (releasing grants) and mints new ones. The server’s own tables start empty, with a newly drawn first badge (R27 (badge allocation)), so a client’s old connection ids are dead. -
A fresh budget.
initdestroys the dead instance’s budget and carves a new one from the manifest entry, so nothing the dead instance made or held outlives it (R10 (destruction)). The receive endpoint isinit’s, owned byroot, so the budget’s destruction does not reach it, and every client’s handle to it stays good. The badgesinithands the instance are stamped with its budget (R9 (stamps)), so a copy it passed on dies with it, and the badge minted again for the next instance has no other holder. The steward’s entry alone has its badges, and its console connection, stampedusers, so that a session’s or lease’s revocation scope, which lies underusers, narrows the connections the steward mints through them (R41 (narrowing by revocation scope)); what the instance stamp bought, the steward’s restart gets below:initdisconnects its fresh connections and reapsusersto nothing, so no handle the dead steward made or passed on outlives its restart, their only holders being sessions and leases, all underusers. -
A driver gets its device handles again from
init’s copies. The kernel reset the device at the dead instance’s end, before its DMA pages were reused (devices), and the new instance brings it up from a reset of its own. A driver whose device was quarantined is not restarted:initfinds its own copy of the device handle closed, and reboots (devices). -
The boot’s own steps, again. A restart repeats what the boot did after starting that server:
keydis checked again (the key-separation check),initattaches to a newconsoledagain, and it pushes thepublicentries to a newbootfsdand seals it again. Until then a client meets the new instance as the boot left it:bootfsdanswers “does not exist”, never a half-written entry. If one of these steps fails,initreboots. -
During the boot too. A server that exits before the boot is done is restarted and counted the same way, and a step that was calling it waits for the new instance and tries again. So a server that cannot start reboots the machine by the rule below:
consoledrefusing abuckets=Nits budget cannot hold is one. -
Blame. Each exit notice for a fault names the account and label set of the call the faulting thread was serving (R21 (crash blame)).
initpasses them to the steward in one typed call,blame(account, labels, server), in the steward’s table, whereserveris the faulting server’s manifest name for the audit record; the steward’s rule counts by (account, label set) only (steward). The fault line says whom the notice named (init: walfsd:data (PID 9) faulted, code 10, serving account 1002, labels none), and a second line, after the restart, what became of the blame (init: walfsd:data's fault blamed on account 1002, labels none: the steward took it). -
Only
initcan blame. The steward acceptsblameonly through a root badge it givesinitalone, badge 3 on its endpoint, whichinitmints for itself at each of the steward’s starts and the manifest check hands to no server: anyone who could send it could have another principal’s sessions ended by forging three crashes. -
Reconnected. After the blame,
inithands the steward a new connection at the restarted server (fresh connections), so the sessions the server served get their files back, and a login after the restart its home. The blame goes first because its second must not be spent behind the server’s recovery, which the new connection waits for. -
A wedged steward cannot stall restarts.
initrestarts the server first, then blames, with a timeout of one second (blame::TIMEOUT, the same as a dead child’s grant releases); a blame lost to the timeout is reported on the console (... : lost: the steward did not answer within 1 s) and counts toward no lockout, and nothing is retried. A fault before the steward runs, or of the steward itself, is reported on the console and blamed on nobody (; blamed on nobody: no steward); a fault whose notice names nobody says so (; blamed on nobody). -
Reboot. More than 5 restarts of one server within 60 seconds, not stopped by blame, reboots the machine: failing closed beats a server that cannot stay up. So does a restart
initcannot make: a budget it cannot carve, a badge or console connection it cannot mint, or a launch the kernel refuses. -
The steward is part of the trusted base; its crash is a bug. If it dies,
initlogs every session out and ends every lease before it starts the steward again, in this order:- The dead instance’s budget is destroyed and carved afresh, as every server’s is (above).
The sessions’ process objects are charged to the steward, their creator, so R10 frees them
with that budget, their processes killed first and no notice owed: the sessions end there.
initdisconnects the fresh connections it made for the steward (fresh connections), which frees every session’s connections at the shared servers. initemptiesuserswithbudget_reapuntil no child is left (R10 (destruction)): every principal’s budget, its label sets’ sub-budgets, and their sessions’ and leases’ budgets, endpoints and handles go, one principal’s subtree per kernel entry, the work billed toinit. Every session’s VM ends with its budget; ansshdchannel stays open untilsshdis told (steward).initsaysinit: emptied users: N budgets reaped; a reap the kernel refuses is a restartinitcannot make, and it reboots.- The steward starts, finds
usersempty and carves afresh, and starts the console principal’s session again, as at boot.
The steward’s own check at its start stays: a steward that finds
usersholding anything exits, and the reboot rule above is the bound on a steward that dies the same way every time. - The dead instance’s budget is destroyed and carved afresh, as every server’s is (above).
The sessions’ process objects are charged to the steward, their creator, so R10 frees them
with that budget, their processes killed first and no notice owed: the sessions end there.
stateDiagram-v2
[*] --> Running: launched through the stub
Running --> Exited: exit or fault
Exited --> Blamed: fault notice names<br/>(account, label set)
Blamed --> Running: steward told,<br/>restarted on the same endpoint
Exited --> Running: clean exit,<br/>restarted on the same endpoint
Exited --> Reboot: more than 5 restarts<br/>in 60 seconds
Reboot --> [*]
Figure: a system server’s restarts. Until the steward runs, a fault is reported on the console and blamed on nobody.
The attack tests: blame from any badge but init’s is refused; a restarted server’s old
connection ids are dead; a killed driver, netd among them, is restarted and its clients are
served again (netd); more than 5 restarts in 60 seconds reboot the
machine.
A worked configuration
Status: planned · M1 (sessions over SSH, kept apart)
Alice and Bob each log in over SSH; Alice has a vault label alice-secrets and an agent,
alice/researcher, on a two-hour lease.
kernel
└── init root
├── consoled bootfsd blkd walfsd:data walfsd:alice-secrets system
│ netd ipd:lan keyd steward sshd
├── session alice-1 users/alice/{}/session-1
├── agent alice/researcher [lease 2 h] users/alice/{}/researcher
├── vault session alice+secrets-1 users/alice/{alice-secrets}/session-1
└── session bob-1 users/bob/{}/session-1
Figure: the budget tree. {} and {alice-secrets} are the fixed sub-budgets, one per label set.
| Name | Alice’s session | Bob’s session | Enforced by |
|---|---|---|---|
/ | walfsd:data at /home/alice, read-write | walfsd:data at /home/bob, read-write | walfsd (badge) |
/dev/cons | her SSH channel | his | sshd (badge, channel labels) |
/net | ipd:lan, connect out to ports 22 and 443, not the box’s own addresses | ipd:lan, connect out to 443 | ipd (badge) |
powerbox, budget | hers | his | the steward, the kernel |
- Weights: Alice 100, Bob 100; the agent 20, carved from Alice’s, sharing her account.
init, the steward and the drivers are 1000 each in the same queue. - The vault session reads and writes
walfsd:alice-secrets, reads (never writes) her home on the unlabelledwalfsd:data, which is how data enters the vault, has no/net, and prints only to its own channel. - The agent has its own principal,
/workonly and no/net; its escalations wait for Alice’s approval, and the lease’s end destroys its budget and everything it passed on. - No session or lease holds a
keydgrant until M6 (persist, install, share):keyd’s purposes are the host key and audit signing. - Bob crashing
walfsd:datathree times is blamed on his account each time: his sessions end and he is locked out for a while; Alice is not affected.
Open: none.
Authority
Status: built · partly tested: `init-boot` shows each copy `init` closed gone from the kernel's side, but no call lists a handle table, so a copy `init` never closed would not be caught · tested (5)
- bench:init-boot
- bench:init-restart
- bench:init-driver-restart
- host:redoubt-init::a_server_handed_a_budget_is_refused
- bench:steward-boot
initholds theroot,systemandusersbudgets, every device object, the Reset right and the bundle’s pages. It gives each driver only its own device objects, each server only the endpoints the manifest names, and the steward theusersbudget.- It keeps what it needs to restart a server: the receive right of every endpoint it made, a copy
of every device handle it placed, and the bundle. It never receives on a server’s endpoint, and
it maps no device once
consoledhas the UART. - Beside those it holds only its own handle at each server it calls, its own connection to
consoled, and each server’s exit endpoint. It closes its copy of every badge it minted for ahandeditem or a volume’s range, and of each child’s console connection, once the child is started, so it calls as no system caller and writes as no child. - It holds no keys and no cryptography, and parses no ELF: launching goes through the loader stub, inside the child.
- It has no network and no user data, and after boot it receives only exit notices.
- The loader stub holds nothing but what the child holds: it runs as the child, in the child’s budget, with the child’s handles.
Security properties
R31 (startup block checked whole)
Status: built · tested (9)
- bench:rt-host-tests
- bench:session-hostile-launch
- host:redoubt-rt::hostile_blocks_are_refused
- host:redoubt-rt::fields_hold_whole_entries
- host:redoubt-rt::handle_names_follow_the_manifest_rule
- host:redoubt-rt::image_round_trips_and_is_validated
- host:redoubt-rt::handle_counts_are_what_process_start_can_install
- host:redoubt-rt::random_bytes_never_panic
- fuzz:redoubt-rt/startup
A program runs only with a startup block that passed every rule: handles within what
process_start installed, clean unique paths, unique names under the manifest’s rule, whole
entries, a well-formed image range. A block breaking one is refused whole and the process exits
before main, so a hostile or buggy parent cannot hand its child a namespace the program would
read two ways, or a name no rule allows.
R32 (a hostile image hurts only its process)
Status: built · partly tested: on target the kernel's own refusal masks the stub's overlap checks, which only host tests pin · tested (11)
- bench:stub-launch
- bench:session-hostile-launch
- host:stub::plan_refuses_a_segment_overlapping_an_excluded_range
- host:stub::plan_refuses_writable_and_executable
- host:stub::plan_refuses_two_segments_that_overlap_each_other
- host:stub::plan_refuses_a_segment_touching_page_zero
- host:stub::plan_refuses_a_segment_reaching_into_the_stub_region
- host:stub::image_in_bounds_refuses_an_image_overlapping_the_stub
- host:stub::read_image_refuses_an_image_len_over_the_cap
- host:stub::plan_refuses_a_segment_over_its_own_image
- host:stub::the_fuzz_corpus_still_passes
No launcher parses an ELF. The loader stub, running as the child, refuses an image whose segments
overlap each other, the image, the stub, the startup block or page 0, reach past the link range,
or ask to be writable and executable, before it maps anything; a segment the kernel refuses (one
over the stack) makes the child exit too. So a hostile image can at most exit or fault the process
it was going to become. stub-launch launches hostile images on both widths and checks that each
only exits or faults the child, that the parent’s budget returns to the same usage after each,
and that a well-formed child still runs afterwards and finds the image copy unmapped. A segment
the child’s budget cannot hold exits 112, and the same image runs in a budget that holds it.
session-hostile-launch launches hostile images from a user’s session, through the launch native
(native programs): an image cut to 40 bytes, one
cut in half and one of zeros, read from her home, each exit the child 111 with its budget holding
nothing; an empty image and a startup block naming a handle outside the rule are the client’s
refusals before any kernel call; the session’s processes in use are the same after, and a
well-formed program runs. The
stub’s parser was fuzzed for an hour; its kept corpus (stub/fuzz/seeds/plan) reruns in the
stub’s host tests.
R33 (no server holds a system budget)
Status: built · tested (3)
- bench:init-refuses-budget-handle
- host:redoubt-init::a_server_handed_a_budget_is_refused
- bench:steward-boot
Only init and the steward ever hold a handle to a system-class budget. A server’s startup
block carries no budget handle, and init refuses a manifest that grants one. A compromised
server holding its budget could create system-class children with any labels and any account,
and so forge admission keys and crash blame at every other server. The attack test starts a
server from a manifest that grants it a budget and expects the boot refused.
R34 (confined placement)
Status: built · tested (15)
- bench:steward-confined-push
- bench:init-refuses-confined-server
- host:redoubt-init::confined_refuses_two_label_sets_on_one_endpoint
- host:redoubt-init::confined_refuses_two_label_sets_on_one_volume
- host:redoubt-init::confined_counts_a_home_for_the_principals_unlabelled_domain_alone
- host:redoubt-init::the_steward_is_told_a_confined_manifest
- host:redoubt-steward-server::a_confined_boot_leaves_a_labelled_sessions_home_unbound
- host:redoubt-init::confined_gives_a_labelled_domain_no_network
- host:redoubt-init::confined_refuses_two_label_sets_on_one_disk
- host:redoubt-init::confined_refuses_a_server_instance_serving_two_label_sets
- host:redoubt-init::confined_counts_only_a_shared_servers_own_label_set
- host:redoubt-init::confined_does_not_count_an_exempt_server_handed_a_labelled_endpoint
- host:redoubt-init::confined_lets_label_sets_that_share_nothing_share_the_cores
- host:redoubt-init::confined_gives_each_label_set_its_own_userland_disk
- host:redoubt-init::confined_gives_each_label_set_its_own_verifier
With confined set, no two entries with differing label sets share a server instance, volume,
endpoint, network instance or device object, and no labelled domain reads a shared
unlabelled volume: a manifest that would place entries so fails the boot, and a labelled
session’s home, the one shared unlabelled volume a principal’s domains would share, is left
unbound by the steward, which init tells of the profile. The one exception is the
control plane: the steward and sshd, by the request and owner-approval path, per-item single-label
reader and writer budgets, and lease-ending supervision only. The attack verdict is the boot
failing, not the manifest’s claim.
R35 (key separation)
Status: built · tested (5)
- bench:init-refuses-held-login-key
- bench:init-refuses-held-bundle-key
- bench:init-refuses-second-keyd
- host:redoubt-init::every_login_and_approval_key_then_the_bundle_key_is_asked_about
- host:redoubt-init::init_calls_one_of_each_server_it_calls
keyd never holds a key that authenticates anyone to the box: not a principal’s login or
approval key, and not the key the loader verifies the bundle with. init asks keyd about each
before anything else runs, and a yes stops the boot. So the boot root and a key some badge may
sign with are never one key, and no badge at keyd can sign a login.
Failure and restart
Status: built · partly tested: the runtime’s exit on a refused block is read from the code, not attacked · tested: bench:stub-launch, host:redoubt-rt::hostile_blocks_are_refused
- A child’s startup block is refused: the runtime exits with 102 before
main; the stub exits with 110 if it cannot find the image or the image lies outside its bounds. - A child’s image is refused or does not fit: the stub exits with 111 or 112, and only the child is affected; its launcher sees the exit notice and the child’s budget returns what it held.
- What
initdoes when a server exits is under restarts and reboots.
Residual risks
- The manifest is a secret held in the bundle. It carries
keyd’s seeds, so whoever can read the bundle image holds the box’s private keys; the bundle is signed, never encrypted, and what keeps the seeds off/bootis that the manifest is never public. Sealing the keys to the machine is keyd’s. - A labelled session in a confined boot still reads code volumes. The check counts no
read-only code volume (
/boot, the system volume) for a labelled domain, and the steward binds both for a labelled session as for a plain one: a labelled domain reading code it shares with every other is R25 (the label check)’s business, not a flow out; the data volume it would share, its principal’s home, is the one left unbound (steward). - A confined boot is checked once. Capabilities handed over after boot are outside the check; a system server that hands one across label sets breaks confinement without the kernel noticing.
- Every child pays for a copy of its image. There is no shared text: a launcher copies the ELF into pages charged to the child, and the stub copies each segment again. This is decided, for M1 (sessions over SSH, kept apart) and after. A read-only image-page cache shared between principals is not planned: it would be a cross-principal timing surface (a shared image cache). The steward may still cache an image’s bytes, to avoid reading them again, because each child still gets its own copy.
- A blame can be lost. If the steward does not take
init’s blame within its timeout, the crash is reported on the console but counts toward no lockout. A session’s end no longer holds the steward past that timeout: the end is answered once its scope is destroyed, and itsendedtosshdand its disconnects are the steward’s cleanup thread’s, each within the client’s call bound, never the loop’s (steward);steward-blame-lockoutandsteward-server-restartforbidlostfor a blame. - A new connection can be lost the same way. If the steward does not take
init’sreconnectedwithin its second, the restarted server’s sessions keep their dead connections and the steward mints no home there until its own restart;initsays so and retries nothing (fresh connections). - The mediators are trusted across labels. The steward and
sshdare the confinement check’s one exception; a bug in either reaches every label set they serve. - The stub cannot see the stack. A segment that names the stack’s pages is refused by
map_fixed, but nothing checks for a gap between a segment and the stack (memory layout). - The startup block’s fuzz target runs outside the bench. Its host tests and the stub’s run
in
rt-host-tests; the fuzz target needscargo fuzz, which a host-tests case does not run, so it is run by hand. - On target, the kernel’s refusal hides the stub’s overlap checks. Dropping one still ends
in exit 111, because
map_fixednever replaces a mapping; only the host tests pin the stub’s own. - A restart loop reboots the machine. A client that can crash a server repeatedly without being blamed (a bug the blame rule does not reach) can reboot the box.
- A restarted
consoledforgets every server’s console. The servers’ connections lived in the dead instance’s tables, andinithas no way to hand a running server a new one, so a server’s lines are refused until it restarts too.initattaches again and says so. The steward alone is handed new connections after a restart, at the servers it is handed (fresh connections);consoledis not among them. - A console that cannot start reboots silently.
inithas given up the UART, so its lines aboutconsoled’s restarts and the reboot go nowhere.
Why
- One signed manifest. A boot decided by code is reviewed by reading code; one checked file is reviewed by reading the file, and a rule it breaks stops the boot before anything runs.
- The stub, not the launcher, parses ELF. An ELF parser is a large surface on hostile input.
Run inside the child, a bug in it compromises only the process being made, never
initor the steward, which launch everything. - The startup block as one typed message. A second framing format would need its own parser and fuzzing; the typed-message codec is already both. The parent writes both sides, so checksums would protect nothing.
- Strings for 64-bit numbers in the manifest. Every JSON tool agrees on integers up to 2^53; an account or a page count written as a string is read the same way by every tool.
- A fresh connection per child. A launcher that could not free a child’s state without freeing its own would leak every dead child’s fids for its own lifetime.
- Reboot on a restart loop. A server that cannot stay up is a server whose rules are not being enforced; stopping the box is the closed failure.
The steward
The steward holds the system’s policy about people and agents. It knows the principals,
authenticates logins that sshd passes it, carves every session and agent lease from the
users budget and launches it, runs the powerbox where a principal asks for authority it lacks,
declassifies and pushes single items across a label under an out-of-band approval, decides what a
crash blamed on a principal costs it, and keeps the audit log. It holds no keys and parses no ELF.
Its policy is one pure state machine (the policy core), which the model also
runs, on the executable model of the kernel.
Purpose
The kernel enforces budgets, labels and handles; it does not know who Alice is. Something must
turn “Alice logged in with this key” into a budget with Alice’s account and labels and a
namespace of fresh connections, and must decide when an agent may have more than it was given.
Putting all of that in one system-class server keeps the policy in one place, reviewable and
modelled, and keeps the kernel free of it. The steward parses the most untrusted input in the
system (every agent’s requests), so it holds nothing it could leak: no keys, no ELF parser, no
standing labelled reader.
Interface
The policy core
Status: built · tested (58)
- host:redoubt-steward::a_manifest_with_a_key_in_two_roles_is_refused
- host:redoubt-steward::the_lines_read_as_the_manifest_and_write_back_the_same
- host:redoubt-steward::keyd_and_servers_are_empty_when_absent_and_sizes_is_required
- host:redoubt-steward::a_malformed_line_is_refused_with_its_number
- host:redoubt-steward::a_name_with_any_bytes_writes_back_the_same
- host:redoubt-steward::the_key_id_is_the_first_eight_bytes_of_the_key_s_sha_256_little_endian
- host:redoubt-steward::the_kept_corpus_reads_or_is_refused_without_a_panic
- host:redoubt-steward::boot_carves_a_fixed_sub_budget_per_label_set
- host:redoubt-steward::login_key
- host:redoubt-steward::owns_labels_reads_the_manifest_not_the_domains
- host:redoubt-steward::a_login_s_refusals_tell_nothing_apart
- host:redoubt-steward::context_free_holds_one_session_per_name
- host:redoubt-steward::a_session_is_carved_from_its_domain_with_a_scope_for_its_connections
- host:redoubt-steward::the_batch_steps_for_a_session
- host:redoubt-steward::approval_key
- host:redoubt-steward::caller_unlabelled
- host:redoubt-steward::lease_bounded_and_carve_lease
- host:redoubt-steward::blame_window_and_not_locked
- host:redoubt-steward::pending_cap_and_fair_share
- host:redoubt-steward::drop_requests
- host:redoubt-steward::exact_labels_and_item_fits
- host:redoubt-steward::agent_own_set
- host:redoubt-steward::reaches_and_render
- host:redoubt-steward::rendered_here_and_hash_matches
- host:redoubt-steward::a_closed_channels_id_reopened_answers_nothing_it_rendered
- host:redoubt-steward::reaches_only_requests_whose_labels_the_principal_owns
- host:redoubt-steward::a_granted_lease_starts_in_its_own_domain
- host:redoubt-steward::not_locked_keeps_a_grant_pending
- host:redoubt-steward::a_declassification_copies_out_exactly_its_snapshot
- host:redoubt-steward::a_push_writes_its_snapshot_through_a_writer_with_the_target_labels
- host:redoubt-steward::sponsor_session_and_notify_sponsor
- host:redoubt-steward::a_lease_ends_at_its_deadline_or_its_process_exit
- host:redoubt-steward::notify_reaches_only_channels_with_the_requests_labels
- host:redoubt-steward::audit_visible
- host:redoubt-steward::a_failed_start_ends_the_session
- host:redoubt-steward::an_excluded_event_makes_the_steward_exit
- host:redoubt-steward::an_event_naming_nothing_gets_one_answer
- host:redoubt-steward::the_same_events_give_the_same_effects
- host:redoubt-steward-gen::parses_tables
- host:redoubt-steward-gen::refuses_unknown_names
- host:redoubt-steward-gen::refuses_bad_rows
- host:redoubt-steward-gen::refuses_bad_machines
- host:redoubt-steward-gen::every_row_is_read_or_refused
- host:redoubt-steward-gen::fenced_code_is_not_a_table
- host:redoubt-steward-gen::link_checks_names_and_vocabulary
- host:redoubt-steward-gen::dispatch_takes_rows_in_order
- host:redoubt-steward-gen::the_tables_parse
- host:redoubt-steward-gen::generated_files_are_current
- host:redoubt-model::steward_policy
- host:redoubt-model::steward_noninterference
- host:redoubt-steward-trace::equal_outputs_pass
- host:redoubt-steward-trace::a_changed_output_line_is_caught
- host:redoubt-steward-trace::the_reference_output_splits_into_traces_and_rows
- host:redoubt-steward-trace::a_written_trace_reads_back
- host:redoubt-steward-trace::strings_round_trip
- bench:elixir-oracles
- bench:bench-elixir-oracles-broken-guard
- host:redoubt-steward-server::the_binding_table_binds_each_slot_as_the_page_says
Everything the steward decides is one pure state machine, the crate redoubt-steward (no_std,
no unsafe, no I/O), shared by the steward server and the model. The server is its embedder:
it turns messages and exit notices into events, carries out the effects the core returns, and
decides nothing itself.
One decision function
decide(&mut Store, Event) -> Effects. An event carries the time (now, the embedder’s clock)
and the fresh random words the event may need for ids (R36: the embedder draws them from the
kernel’s random, so the core holds no generator and no counter). Effects are data: budgets to
create and destroy, connections to ask for, a program to launch, an item to read or write
through a crossing budget, a reply, a notification or a rendered screen for an approval
channel, and audit records. An effect that produces something (a budget, a connection, a
process, a snapshot) names a token. One event’s effects form a batch, which the embedder runs in
order, giving a later effect the result of an earlier one by its token. It stops at the first
failure, and then reports the batch as one Done event, with every result or the step that
failed. A machine waiting for a batch is in a state of its own, so every failure has a
transition.
Events one machine raises for another (a grant’s lease, a crossing’s snapshot for its request, a
lockout, a session’s end for its requests) are the core’s own and never a batch’s steps: decide
runs them in the order they were raised before it returns, and drops without an answer one that
names an object already gone. An event the embedder’s guarantee excludes is a steward bug, and
the server fails closed: it exits, and init restarts it (below).
The embedder guarantees, and the core assumes, that:
- events come one at a time, and a batch’s
Donearrives before any other event about that object; - each event’s caller is the one the kernel stamped. The badge class decides the role (
sshd,init, an approval channel, a session’s minted badge, the steward’s exit reports), and the embedder maps a session’s badge to its session through the core’s routing index; nownever goes back, and the random words come from the kernel’s generator.
The server’s own work is transport, admission and effects, all read from the tables: it decodes
the protocol, admits by R26 (admission fairness) except for
the events the tables mark ahead of admission (EndLease), runs each batch through the client
library, and signs audit records through keyd. Rendering, every check and every audit record
are the core’s.
Domains
The store is partitioned by domain: (account, label set), where the account is a
principal’s (never 0) and the label set is sorted and at most MAX_LABELS long. System-class
callers (sshd, init, an approval channel) are roles of an event, never domains. A domain
holds:
- its fixed sub-budget (below), the one budget its sessions and leases are carved from;
- its sessions and leases, and the counters that number and name them (R37);
- the pending requests its sessions submitted, with their cap and fair shares;
- its blame window and lockout (R40).
Outside every domain is what the boot manifest fixes and nothing changes: principals, keys,
owned labels, the keys keyd holds. Then the routing index, which says where each session and
lease is and holds nothing else of a domain: it is written when one starts or ends, and read to
route an event and to address a notice. And the approval channels, which belong to a principal
across its label sets.
A handler sees one domain: it gets &mut DomainState for the domain of the event’s caller and
no other domain’s. Exactly three functions take two domains, one for each of
R34 (confined placement)’s control-plane edges:
- the request and approval path: an approval acts on a request in the requester’s domain, and a grant may start a lease in another domain of the same account;
- the crossing: declassification and push move one item between a labelled domain and the unlabelled domain of the same account, through a reader or writer budget;
- lease supervision: a sponsor ends a lease, and learns that it ended.
Only the module that holds these three can borrow two domains at once. So no other handler can
read or change a second domain: R37 (vault non-interference) is
kept by the type, not by review. Audit records have one constructor, which takes a domain and
stamps its account and labels, so no record can lack the labels it must be read under. An edge’s
record is stamped with the labelled side’s domain. So a request an unlabelled session submits for
a labelled target (a labelled agent, a push) is recorded under its target’s domain from
submission on: whether it was approved depends on that domain’s lockout. A labelled caller’s
request for an agent names its own label set (agent_own_set), so its record, its lease and its
number stay in its own domain.
Machines
Each object is a plain enum with one transition table: a session, a lease, a request, a
crossing, a domain’s blame and an approval channel. A request is rendered before it can be
answered: Approve and Deny are transitions only from Rendered, on the channel that rendered
it last. What an approval starts belongs to the machine it starts (a lease, a crossing), so
Approved is final and a failure after it is that machine’s transition. A crossing’s kind is fixed
when it opens and each kind has rows of its own: a declassification’s read and a push’s write go
through a budget carrying exactly the labelled side’s labels; a declassification’s copy out is
the steward’s own write to the unlabelled volume, with no budget.
The events are Boot (the manifest), Login, ChannelClosed, ApprovalOpened and
ApprovalClosed from sshd; StartAgent, Submit, EndLease and EndSession from a session;
Pending, Approve and Deny from an approval channel; Blame from init; Exited from the
steward’s exit reports (a session, a lease or a crossing budget’s process); and Done for a
batch.
Each machine’s table has the rows | From | Event | Guard | To | Effects |. Guards and effects are
named in the table and written by hand in the core. A generator in the style of the
wire generator writes from the tables the core’s
dispatch (an exhaustive match on state and event) and the state diagrams on this page, both
checked in, with a drift check; with the Elixir reference it writes that reference’s clause
skeletons too. No state-machine library and no macro.
The session
| From | Event | Guard | To | Effects |
|---|---|---|---|---|
| - | Login | !login_key | - | refuse |
| - | Login | !owns_labels | - | refuse |
| - | Login | !sponsor_set | - | refuse |
| - | Login | !not_locked | - | refuse |
| - | Login | !context_free | - | take_over |
| - | Login | !under_cap | - | refuse |
| - | Login | - | Starting | carve_session, create_scope, launch_relay, attach_relay, connect, launch |
| - | Console | !not_locked | - | refuse |
| - | Console | - | Starting | carve_session, create_scope, connect, launch |
Starting | Done | !not_locked | Ending | refuse, destroy_budget |
Starting | Done | - | Running | route, audit_login, reply_login |
Starting | Failed | - | Ending | refuse, destroy_partial |
Starting | EndSession, ChannelClosed, Exited, Detach, Leave | - | = | unreachable |
Starting | EndLease (ahead) | - | = | refuse |
Starting | Idle | - | = | - |
Starting | Attach | - | = | refuse_in_use |
Starting | LockedOut | - | = | - |
Running | Attach | - | = | detach_relay, attach_relay, audit_attached |
Running | ChannelClosed, Detach | - | Detached | detach_relay |
Running | Leave | !is_context | = | refuse |
Running | Leave | - | Detached | detach_relay, reply_ok |
Running | Idle | - | = | - |
Running | EndSession | - | Ending | unroute, drop_requests, notify_sponsor, reply_ok, destroy_budget |
Running | EndLease (ahead) | !sponsor_of | = | refuse |
Running | EndLease (ahead) | - | Ending | unroute, drop_requests, audit_lease_ended, notify_sponsor, reply_ok, destroy_budget |
Running | Exited, LockedOut | - | Ending | unroute, drop_requests, notify_sponsor, destroy_budget |
Running | Done | - | = | reply_login |
Running | Failed | - | Ending | refuse, unroute, drop_requests, notify_sponsor, destroy_budget |
Detached | Attach | - | Running | attach_relay, audit_attached |
Detached | ChannelClosed, Detach, Done | - | = | - |
Detached | Leave | - | = | refuse |
Detached | Idle | !idle_due | = | - |
Detached | Idle | - | Ending | audit_idle, unroute, drop_requests, notify_sponsor, destroy_budget |
Detached | EndSession | - | Ending | unroute, drop_requests, notify_sponsor, reply_ok, destroy_budget |
Detached | EndLease (ahead) | !sponsor_of | = | refuse |
Detached | EndLease (ahead) | - | Ending | unroute, drop_requests, audit_lease_ended, notify_sponsor, reply_ok, destroy_budget |
Detached | Exited, LockedOut, Failed | - | Ending | unroute, drop_requests, notify_sponsor, destroy_budget |
Ending | Done, Failed | - | Ended | forget |
Ending | EndSession, ChannelClosed, Exited, Attach, Detach, Leave | - | = | unreachable |
Ending | EndLease (ahead) | - | = | refuse |
Ending | LockedOut, Idle | - | = | - |
stateDiagram-v2
[*] --> Starting: Login
[*] --> Starting: Console
Starting --> Ending: Done [not not_locked]
Starting --> Running: Done
Starting --> Ending: Failed
Running --> Detached: ChannelClosed, Detach
Running --> Detached: Leave
Running --> Ending: EndSession
Running --> Ending: EndLease
Running --> Ending: Exited, LockedOut
Running --> Ending: Failed
Detached --> Running: Attach
Detached --> Ending: Idle
Detached --> Ending: EndSession
Detached --> Ending: EndLease
Detached --> Ending: Exited, LockedOut, Failed
Ending --> Ended: Done, Failed
Ended --> [*]
Figure: the session machine, generated from its table.
The lease
| From | Event | Guard | To | Effects |
|---|---|---|---|---|
| - | StartAgent | !caller_session | - | refuse |
| - | StartAgent | !caller_unlabelled | - | refuse |
| - | StartAgent | !lease_bounded | - | refuse |
| - | StartAgent | !not_locked | - | refuse |
| - | StartAgent | - | Starting | carve_lease, create_scope, connect, launch |
| - | Granted | - | Starting | carve_lease, create_scope, connect, launch |
Starting | Done | granted, !not_locked | Ending | audit_start_failed, destroy_budget |
Starting | Done | !not_locked | Ending | refuse, destroy_budget |
Starting | Done | granted | Running | route, audit_agent_started |
Starting | Done | - | Running | route, audit_agent_started, reply_agent |
Starting | Failed | granted | Ending | audit_start_failed, destroy_partial |
Starting | Failed | - | Ending | refuse, destroy_partial |
Starting | EndLease (ahead), Exited | - | = | unreachable |
Starting | LockedOut | - | = | - |
Running | EndLease (ahead) | !sponsor_session | = | refuse |
Running | EndLease (ahead) | - | Ending | unroute, drop_requests, audit_lease_ended, notify_sponsor, reply_ok, destroy_budget |
Running | Exited, LockedOut | - | Ending | unroute, drop_requests, audit_lease_ended, notify_sponsor, destroy_budget |
Running | Done, Failed | - | = | unreachable |
Ending | Done, Failed | - | Ended | forget |
Ending | EndLease (ahead), Exited | - | = | unreachable |
Ending | LockedOut | - | = | - |
stateDiagram-v2
[*] --> Starting: StartAgent
[*] --> Starting: Granted
Starting --> Ending: Done [granted, not not_locked]
Starting --> Ending: Done [not not_locked]
Starting --> Running: Done [granted]
Starting --> Running: Done
Starting --> Ending: Failed [granted]
Starting --> Ending: Failed
Running --> Ending: EndLease
Running --> Ending: Exited, LockedOut
Ending --> Ended: Done, Failed
Ended --> [*]
Figure: the lease machine, generated from its table.
The request
| From | Event | Guard | To | Effects |
|---|---|---|---|---|
| - | Submit | !owns_labels | - | refuse |
| - | Submit | !exact_labels | - | refuse |
| - | Submit | !agent_own_set | - | refuse |
| - | Submit | !lease_bounded | - | refuse |
| - | Submit | !pending_cap | - | refuse |
| - | Submit | !fair_share | - | refuse |
| - | Submit | declassifies | Snapshotting | open_read |
| - | Submit | pushes | Snapshotting | read_source |
| - | Submit | - | Frozen | freeze, audit_submitted, reply_request, notify |
Snapshotting | Done | declassifies, !item_fits | Dropped | refuse |
Snapshotting | Done | - | Frozen | freeze, audit_submitted, reply_request, notify |
Snapshotting | Failed | - | Dropped | refuse |
Snapshotting | Pending | - | = | - |
Snapshotting | Approve, Deny | - | = | refuse |
Snapshotting | SessionEnded | - | Dropped | - |
Frozen | Pending | - | Rendered | render |
Frozen | Approve, Deny | - | = | refuse |
Frozen, Rendered | SessionEnded | - | Dropped | - |
Frozen, Rendered | Done, Failed | - | = | unreachable |
Rendered | Pending | - | = | render |
Rendered | Approve | !rendered_here | = | refuse |
Rendered | Approve | !hash_matches | = | refuse |
Rendered | Approve | !not_locked | = | refuse |
Rendered | Approve | grants_lease | Approved | audit_approved, reply_ok, grant_lease |
Rendered | Approve | declassifies | Approved | audit_approved, reply_ok, open_copy_out |
Rendered | Approve | pushes | Approved | audit_approved, reply_ok, open_write |
Rendered | Approve | - | Approved | audit_approved, reply_ok |
Rendered | Deny | !rendered_here | = | refuse |
Rendered | Deny | - | Denied | audit_denied, reply_ok |
stateDiagram-v2
[*] --> Snapshotting: Submit [declassifies]
[*] --> Snapshotting: Submit [pushes]
[*] --> Frozen: Submit
Snapshotting --> Dropped: Done [declassifies, not item_fits]
Snapshotting --> Frozen: Done
Snapshotting --> Dropped: Failed
Snapshotting --> Dropped: SessionEnded
Frozen --> Rendered: Pending
Frozen --> Dropped: SessionEnded
Rendered --> Dropped: SessionEnded
Rendered --> Approved: Approve [grants_lease]
Rendered --> Approved: Approve [declassifies]
Rendered --> Approved: Approve [pushes]
Rendered --> Approved: Approve
Rendered --> Denied: Deny
Dropped --> [*]
Approved --> [*]
Denied --> [*]
Figure: the request machine, generated from its table.
The crossing
| From | Event | Guard | To | Effects |
|---|---|---|---|---|
| - | Open | reading | Open | carve_crossing, read_item, destroy_crossing |
| - | Open | copying | Open | copy_out |
| - | Open | - | Open | carve_crossing, write_item, destroy_crossing |
Open | Done | reading | Closed | pass_snapshot |
Open | Done | copying | Closed | audit_declassified |
Open | Done | - | Closed | audit_pushed |
Open | Failed | reading | Closing | pass_failure, destroy_partial |
Open | Failed | copying | Closed | audit_copy_failed |
Open | Failed | - | Closing | audit_push_failed, destroy_partial |
Open | Exited | - | = | unreachable |
Closing | Done, Failed | - | Closed | - |
Closing | Exited | - | = | unreachable |
stateDiagram-v2
[*] --> Open: Open [reading]
[*] --> Open: Open [copying]
[*] --> Open: Open
Open --> Closed: Done [reading]
Open --> Closed: Done [copying]
Open --> Closed: Done
Open --> Closing: Failed [reading]
Open --> Closed: Failed [copying]
Open --> Closing: Failed
Closing --> Closed: Done, Failed
Closed --> [*]
Figure: the crossing machine, generated from its table.
A domain’s blame
| From | Event | Guard | To | Effects |
|---|---|---|---|---|
| - | Boot | - | Open | - |
Open | Blame | blame_window | LockedOut | count_blame, audit_blamed, lock_out, audit_locked_out |
Open | Blame | - | = | count_blame, audit_blamed |
Open | Login | - | = | - |
LockedOut | Blame | blame_window | = | count_blame, audit_blamed, lock_out, audit_locked_out |
LockedOut | Blame | - | = | count_blame, audit_blamed |
LockedOut | Login | not_locked | Open | - |
LockedOut | Login | - | = | - |
stateDiagram-v2
[*] --> Open: Boot
Open --> LockedOut: Blame [blame_window]
LockedOut --> Open: Login [not_locked]
Figure: the blame machine, generated from its table.
The approval channel
| From | Event | Guard | To | Effects |
|---|---|---|---|---|
| - | ApprovalOpened | !approval_key | - | refuse |
| - | ApprovalOpened | - | Open | reply_ok |
Open | ApprovalOpened | - | = | refuse |
Open | ApprovalClosed | - | Closed | - |
stateDiagram-v2
[*] --> Open: ApprovalOpened
Open --> Closed: ApprovalClosed
Closed --> [*]
Figure: the approval_channel machine, generated from its table.
Guards and effects
Each guard and each effect that carries a rule is one function, and each has a mutation in the model that breaks it, which a property family must catch. The shipped crate carries no mutation switch: the dispatch calls guards and effects through a table of functions, the shipped one by default. The model swaps one entry for a broken one.
| Guard or effect | Rule | Mutation |
|---|---|---|
login_key, approval_key | a login uses only one of the principal’s login keys and an approval channel only one of its approval keys; the boot manifest fixes both sets and keyd’s keys, and keeps all three apart (R35 (key separation)). The mutations widen a guard to the key an attacker could sign with: one keyd holds, or a login key | PolicyLoginWithKeydKey, PolicyApproveWithLoginKey |
owns_labels | a vault login, a labelled agent, a declassification or a push needs the labels’ owner, read from the manifest’s owned labels, never from a domain’s existence; a login it refuses is answered as a wrong key is (contexts) | PolicyVaultWithoutOwnership |
context_free | R79 (one session per context) | PolicyContextTwice |
take_over, detach_relay | R80 (one channel per context): a takeover only after every authentication guard; the channel taken over let go before the new one is attached; a channel’s attachment id forgotten when it is let go | PolicyTakeoverBeforeAuth, PolicyBothAttached, PolicyStaleCloseDetaches |
caller_unlabelled | a labelled session or agent starts nothing; it only submits requests | PolicyLabelledStartsAgent |
agent_own_set | an agent request names a labelled agent: a labelled caller’s, exactly its own label set, so its lease and records stay in its domain (R37); an unlabelled agent is StartAgent’s | PolicyAgentOtherSet |
not_locked, blame_window | R40 | PolicyNoLockout, PolicyBlameNoWindow |
pending_cap, fair_share | the pending cap per domain, a fair share per session | PolicyNoPendingCap, PolicyNoFairShare |
drop_requests | a session’s end drops its requests | PolicyDeadSessionRequestsKept |
lease_bounded, carve_lease | R39: a lease at most MAX_LEASE, its budget from its domain’s sub-budget, ending with it | PolicyUnboundedLease |
reaches, render, notify | R38’s screens: an approval channel reaches only its own account’s requests, whose every label its principal owns; printable ASCII, capped, no labelled free text; an approval-waiting notice reaches only channels whose labels include all the request’s | PolicyShowLabelledToAll, PolicyRenderNotWhitelisted, PolicyLabelledFreeTextShown, PolicyNotifyLabelledToAll |
rendered_here, hash_matches | R38’s binding | PolicyApproveOtherChannel, PolicyApproveIgnoresHash |
exact_labels, item_fits | R42: a declassification is submitted from a session with exactly the item’s labels, a push from an unlabelled session; a declassified item is at most DECLASSIFY_MAX bytes of printable text | PolicyDeclassifyFromUnlabelled, PolicyDeclassifyUnfit |
carve_crossing, copy_out | R42: a reader or writer budget carries exactly the labelled side’s labels; a copy out writes exactly the snapshot | PolicyDeclassifyWithoutReader, PolicyDeclassifyLive |
sponsor_session | a lease is ended only from an unlabelled session of its sponsor | PolicyEndLeaseFromVault |
sponsor_of | a sponsored principal’s session is ended only from an unlabelled session of its sponsor | PolicySponsoredEndedByAnyone |
sponsor_set | a sponsored principal logs in to its unlabelled set alone | PolicySponsoredVaultLogin |
audit_visible | every audit read goes through it: a record is read under R25 (the label check) | PolicyAuditUnfiltered |
A guard that reads only an object’s kind (which crossing it is, whether a request snapshots) carries no rule, is named only in the tables, and has no mutation.
Each rule has one keeper, and its mutation breaks that keeper: a second check of the same rule
would hide the mutation. So the approval edge’s reaches, a function of the policy table, keeps
R38’s screens to their owners, and no guard repeats it.
not_locked guards every start in a domain, and its keeper is the login. A session’s StartAgent
in a locked domain is reached only if time goes back: a lockout ends every session of the domain,
and none starts there again until the window has passed. The row stays, because the core takes
now from its embedder and does not assume that it only goes forward. It is the same guard, not a
second keeper, so it hides no mutation.
Five of the model’s mutations break a rule the types or the keepers now keep, and they cannot be
written. Blame or a pending cap counted per account (PolicyBlamePerAccount,
PolicyCapPerAccount) and a session carved from another label set’s sub-budget
(PolicyCarveFromUnlabelled) each need a second domain. A narrowing handle for a session’s
budget (PolicyNarrowToSessionBudget) needs a budget where the effect takes only a revocation
scope, a type of its own that only a zero-limit CreateScope makes (R41). An approval granting
more than its approver holds (PolicyApproverExceeds) needs a request whose labels or asked
labels its principal does not own, and owns_labels refuses every one at entry and at
submission (its mutation is PolicyVaultWithoutOwnership), while reaches keeps a request to
its own principal’s channels. They are retired, and this table says why. Ids drawn from a counter
(PolicySequentialIds) and end-lease admitted behind others (PolicyEndLeaseAdmitted) break the
embedder’s half, and stay mutations of the model’s embedder.
So does an item written by a session without exactly its labels (PolicyWriteUp): that check is
the volume’s (R25), and no steward event.
The request binding hash is SHA-256 over the request’s canonical encoding, through the box’s
own libs/sha256, which keyd and init use too: one function with no dependencies
(tenet 5). It is the
core’s one cryptographic function, with a login key’s id. It binds content and signs nothing,
so the steward still holds no key.
Two embedders and a reference
- The steward server (
servers/steward) binds effects to the client library and the kernel: it runs each batch’s steps in order, stopping at the first that fails, and reports the batch as oneDone. Its binding table maps eachSharedslot to a server, a root and a badge per domain class; a slot bound to nothing for that class is produced without a call. One function is the table (own::binding): the connections and the launch both read it. The slots, in order:bootfsdat/boot; the home volume’s server at the home’s path; the label set’s volume’s server at/vault(a vault session only);ipdat/net, granted the principal’s scope (an unlabelled session only); the console at/dev/cons(sshd’s channel, orconsoledfor the console principal’s session); and the system volume’serofsd. The child finds the first and last also under beamlet’s handle names,bootfsdanderofsd:system, the latter named by itsendpoint=argument. Every random word it hands the core has its top bit set, so every id and badge the core draws is in the minted range (R27 (badge allocation)) and no session’s badge is a root badge. It says on its console each audit record, each step that failed with the kernel’s error, and each sub-budget’s usage when it changes, before it answers the call that caused them; and how each process it launched ended, once, assteward: lease NAME of ACCOUNT/LABELS exited CODE(faulted,was killed), NAME the name thelease_endednotice gives the lease (alice/agent-1; a session’s isalice/session-2), so the two lines correlate: the lease’s end audit while audit records are console lines; a crossing’s program the same way assteward: crossing ID of ACCOUNT/LABELS exited 0, ID the crossing’s: the program exits once its one call is answered, orwas killed CODEwhen its budget’s destruction came first (declassification and push). It says its own time on eachend_lease,start_agentandsubmit,steward: served submit in N us, from the call’s arrival to its answer, one queued console line per call, so a lease’s launch is measured at the steward and not inferred from the gaps between its audit lines (residual risks). - The model binds the same crate to the kernel model, in place of its own copy of the policy, so the property families (P1 to P18) and the mutations attack the code that ships. The model’s families drive events; its checks read the core’s state through a read-only inspection API that the server does not use.
- The Elixir reference (
libs/steward/elixir/, its clause skeletons generated from the same tables intogen/),decide/2as multi-clause functions over adefstructstate, runs on beamlet on the build host as a differential oracle, in the bench’selixir-oraclescase (the test bench). The same event traces must give equal states, effects and audit records. Its guards, effects, rendering and binding hash (SHA-256 through:crypto) are written by hand from this page and the tables; they follow the core’s in order and shape, so the two are not independent readings (residual risks). It is a test oracle in the sense tenet 3 allows on the build host. It is never authoritative and never runs on the box.
The manifest lines
The core’s view of the boot manifest is a few text lines, which init hands the steward as its
arguments, one line each after its entry’s own (init, step 6):
the manifest is never public, so /boot cannot carry it. A trace begins with the same lines.
principal "NAME" account=N login=[..] approval=[..] owned=[..] sets=[[..],..] top=P,N,W contexts=N idle=S: one per principal, its keys by id, its owned labels and each label set by label ids, its unlabelled set first, its budget, and its cap of live contexts per label set and their idle bound in seconds (contexts);sponsor="NAME"on a sponsored principal’s line, the one field a line may leave out, names the principal that answers for it (leases): boot carves it no top budget (top=0,0,0), and its sessions are leases of its sponsor’s. A sponsor is another principal, itself unsponsored: no chains here.keyd [..], the ids of keyskeydholds;initwriteskeyd [](see residual risks).servers N, the shared servers’ slots a session connects to.sizes session=P,N,W agent=.. sub_agent=.. crossing=.. cost=N [agent_quota=B], the budgets the steward carves, and the bytes of its sponsor’s home a lease’s root is carved with (the one field the line may leave out: 0 then, a root that holds nothing).
After the core’s lines init appends the steward’s own, which the server parses and the core
never sees, each naming only what init’s check found in the manifest:
label "NAME" id=N, a label’s name, for a login’s label;home "PRINCIPAL" handle=H path=/P quota=N, its home at the serverinithanded the steward as the named handleH, holding at mostNbytes, 1 or more (home quotas and vaults);vault "PRINCIPAL" labels=[..] handle=H, the labelled volume of one label set it works under (a set with no volume has no line);net "PRINCIPAL" PREFIX:PORTS ..., its network scope in the manifest’s form,*for every port, which the steward passes toipd’sgrant.console "PRINCIPAL", the manifest’sconsole: the principal whose unlabelled session the steward opens on the UART;confined, alone, when the manifest setsconfined: a labelled session’s home slot binds nothing, and a push is the domain’s way in (declassification and push; init).
The parser is the core’s (libs/steward/src/manifest.rs), no_std and strict: each field once and
no other, keyd, servers and sizes at most once and sizes required, a number digits only.
A line it refuses is a start failure, said once on the console. init writes the lines with the
core’s own writer, so the two cannot drift. A key’s id is the first eight bytes, little-endian, of
SHA-256 over its 32 raw bytes (hash::key_id): init computes it for the lines, and the steward
for a login, so the core never sees a key. The parser is fuzzed
(tenet 6).
The trace encoding
A trace is a text file, libs/steward/trace/traces/*.trace, and the crate
redoubt-steward-trace (host-only, outside the shipped core) reads it, runs it through the core
and checks the reference’s output against the core’s. Its input is the boot manifest, then one
event per line:
- the manifest lines, read by the core’s own parser; blank lines are
skipped and
#starts a comment. event now=N random=[..] reply=N Kind field=value...is one event: its time, at most the core’s count of random words (the rest zero), the reply slot, and the event with its fields by name. An object iskind@account/labels#id(session@1/7#101). A string is quoted, with the escapes\",\\,\nand\xNN. ADonecarriesok=[..], what each step made in order (budget(N),scope,connection,process(N),bytes("..")ordone), orfailed=STEP,ERROR. ALoginwithoutcontext=names the default context,"".hash=shownin anApprovestands for the hash the last screen of that request showed, as an approver copies it, so a hand-written trace names no SHA-256. Both sides substitute it alike.
The output is boot, then what boot fixed and carved, once (principal, fixed, carve,
carve-sub lines; what is fixed never changes, so it is not repeated). Then for each event,
event N and its effects in order: replies, notices, screens, audit records and forgets as
reply, notice, screen, audit and forget lines, then each batch as batch OBJECT and its
step lines, and exit if the event makes the steward exit. Then store and the store read
through inspect: each domain in the manifest’s order with its objects by id, the routing index
(route, id), the approval channels, and exited. Each side writes its output, and the two are
compared byte for byte, so a map is written in key order on both.
The reference also writes row MACHINE LINE for each row it takes, the row’s line in its table,
which is not compared: coverage is the reference’s claim, and the core has no row hook to
confirm it (residual risks). A trace’s output
follows a line trace NAME, and the run ends with done, so a run cut short is refused.
steward-trace check fails on a trace whose outputs differ, naming its first differing event, the
event’s input line, the first differing line from each side (the core’s, then the reference’s)
and the rest of that event’s output from both, and goes on to the next trace. It also fails on a
trace with no events, a trace the reference did not run and a row the hand-written traces leave
untaken.
The model writes the events its steward families drive (steward_policy at seeds 1 to 4,
steward_noninterference at seed 1) as model-*.trace, afresh on each run, so they never drift
from the model; check lists the rows they never reach. libs/steward/elixir/run-traces runs
both sets; --break GUARD holds one of the reference’s guards always, the negative run, which
bench-elixir-oracles-broken-guard requires to fail.
Principals
Status: built · tested: bench:steward-boot, bench:steward-ssh-two-principals, host:redoubt-steward-server::each_principal_gets_a_top_budget_under_users_and_a_sub_budget_per_label_set
- A principal is a named, accountable identity: a way to authenticate, a set of capabilities (a namespace and service grants), and an audit identity. People, agents and projects are the same kind of principal; they differ in how they authenticate and in default policy, not in mechanism.
- Each principal’s top budget carries its account, which the steward sets. Everything under it, its agents included, shares that account.
- No root, no sudo. Administration means holding specific capabilities over shared things.
- The principals come from the boot manifest (init): each one’s login and approval keys, budget, account, owned labels, the label sets it works under, home and network scope. The steward is stateless across boots.
- Nesting. Every principal has its own space, and its sponsor can destroy its budget. A
sponsored principal (
sponsorin the manifest) has no top budget of its own: its sessions are leases carved from its sponsor’s unlabelled sub-budget, which the sponsor can end (leases). Principals can run their own servers and delegate into them.
Fixed sub-budgets per label set
Status: built · tested: bench:steward-boot, bench:steward-vault-session, bench:steward-sub-budget-flood, host:redoubt-steward-server::a_vault_login_carves_from_the_vault_s_sub_budget_and_has_no_network
At boot the steward splits each principal’s top budget into fixed sub-budgets, one per label set
the manifest names for it (users/alice/{}, users/alice/{alice-secrets}), each an equal share
of the principal’s top budget, less a budget’s own cost. Every session and lease of one
(principal, label set) is carved from its own sub-budget. So a vault session’s leases never
change what the unlabelled side can carve: carving under one shared top budget would let the
unlabelled side read the vault’s activity in its free limits
(R37 (vault non-interference)). The model checks that every
session and lease is carved from its label set’s sub-budget (its P1). A carve from another label
set’s sub-budget would need a second domain, which no handler can borrow, so it has no mutation
(guards and effects). Sizes per label set may return with run-time
principals in M6 (persist, install, share), if evidence asks for them.
The steward carves at its start: for each principal a top budget under users with its account,
and under it a sub-budget per label set with that set’s labels; a carve the kernel refuses is a
start failure, and the box has no users.
Home quotas and vaults
Status: built · tested: bench:steward-home-quota, bench:init-refuses-overcommit, host:redoubt-fileserver::sessions_minted_through_one_carve_share_its_quota_across_a_restart, host:redoubt-steward-server::a_malformed_line_is_refused, host:redoubt-init::home_quotas_are_checked_against_their_volume, host:redoubt-init::a_vault_is_one_principals, host:redoubt-init::no_home_is_another_s_or_inside_it
- A home’s quota is the principal’s, whatever its sessions. The manifest gives each home a
byte quota (
home_quota, init), which reaches the steward on itshomeline. The first time a session of the principal needs its home, the steward mints one connection at the home’s server, rooted at the home, with that quota, and keeps it for its own life; every session’s home is minted through it with no quota of its own, at its root, so they all share the one quota: two sessions, or a session and the console session, stop together at it, and so do a principal’s named contexts. A session’s connection is disconnected through the kept one when the session ends. A lease’s root, the home’sprojectdirectory, is minted through the kept one too, but below the home, with the manifest’sagent_quotabytes carved from the home’s quota for the lease’s life (leases). A write past the quota is the server’senospc(walfsd); the principal’s other homes’ room, and every other principal’s, is untouched. - A restart re-carves. A dead steward’s kept connections go with it:
initdisconnects the fresh connection it made for the steward at each server, and every connection minted under it with it (failure and restart). The restarted steward carves each home again when it is next needed, and the server counts what the home already holds against the new carve, so what was written counts once. - The quotas fit their volume.
initrefuses a manifest whose homes on one volume ask for more than the bytes it gives, or in which one principal’s home is another’s or inside it, so the steward can carve every principal’s home at once, each its own root; the volume’s server refuses a carve its room cannot hold all the same, and a session then has no home. - A vault is its labelled volume, with no quota of its own. A vault session’s slot is minted
at the labelled volume’s own root, which carves nothing: it is bounded by the volume’s room. A
labelled volume is one principal’s:
initrefuses a manifest in which two principals’ label sets name it (a label set need not be owned, so the manifest alone could otherwise share one), as principals are kept apart.
Authentication and sessions
Status: built · partly tested: a session's badge sending `login` is refused in a host test only (a session runs only beamlet) · tested (10)
- bench:userland-boot
- bench:steward-ssh-two-principals
- bench:steward-vault-session
- bench:steward-login-refused
- bench:steward-session-ends
- host:redoubt-steward-server::a_login_runs_the_session_batch_and_answers_the_session
- host:redoubt-steward-server::a_refused_login_makes_nothing
- host:redoubt-steward-server::a_mint_the_server_does_not_take_in_time_fails_the_step_and_the_next_caller_is_served
- host:redoubt-steward-server::the_console_session_opens_at_the_start_and_again_when_it_ends
- host:redoubt-steward-server::sessions_coming_and_going_reuse_the_watchers
- Login.
sshdruns SSH and asks the steward whose key a login used. The steward accepts only one of that principal’s login keys, never a keykeydholds, andsshditself refuses any keykeydholds (keyd). The model checks both (its P2;PolicyLoginWithKeydKey). A login carries two connections to its channel’s console: the steward keeps the first and hands the second to the context’s relay (contexts). When the channel is let go, the steward tells the firstended, then releases it; only that one ends the channel (sshd). A login whose server does not answer one of the batch’s calls withinSERVER_USis refused, and the person logs in again (residual risks). - A session is processes started with capabilities derived from the principal’s set, never
more. The steward carves the session budget from the right sub-budget, with the principal’s
account and the session’s labels, gives it a namespace of fresh connections it asked each server
for (init), and launches it through the loader stub,
streaming the session’s image from
bootfsdthrough a 16-page lend, the most a call may lend, so the console session’s VM is running within a second of the steward’s start (beamlet on Redoubt). Its arguments tell it what it is (own::session_args):principal=NAME,label=NAME:IDfor each label of its set,context=NAMEfor a named context, none for the default context or the console’s session, andhome=/PATHwhere slot 1 binds the principal’s home, none for a principal with nohomeline (sessions); they are the steward’s word to the session and grant nothing. It tells the VMschedulers=2, two scheduler threads in the session’s process and budget: the count is the image’s choice, since the kernel does not say how many harts there are, and a call that does is a later package’s if an image ever needs to follow the machine (beamlet). The VM’s heap cap is its share less what the two schedulers’ stacks and thread pages, its locks’ endpoints and its process’s page take, 44 pages (budgets). Each session’s process has an exit endpoint of its own, which a watcher thread of the steward’s waits on and reports on the steward’s endpoint when the process ends. A thread’s stack is never given back, so a watcher that has reported waits for the next session, and a new one starts only when every watcher is watching: there are never more watchers than sessions alive at once. - Normal sessions are unlabelled (
ssh alice@box): full network and every tool; they cannot read labelled volumes. - A vault session carries exactly one label.
ssh alice+secrets@boxopens a session labelled{alice-secrets}, only if the person who authenticated owns that label. It reads and writes its labelled volume; outside a confined deployment it may read, never write, unlabelled volumes, which is how data enters the vault. It reaches no external sink, and its output reaches only its own SSH channel, which the steward opened for the label’s owner. The model checks the ownership rule (PolicyVaultWithoutOwnership). - A labelled session starts nothing. It can only submit requests to the steward; everything it
asks for is started, if at all, by the steward (its P8;
PolicyLabelledStartsAgent). - Every id is unpredictable. Session, request and connection ids are random 64-bit words from a keyed generator, never a counter, which would tell every principal how many the others made (R36 (unpredictable ids)).
- The console is one principal’s session. The manifest’s
consolenames a principal; at boot the steward opens that principal’s unlabelled session on the UART console (consoled’s connection at/dev/cons) and reopens it when it ends. The console’s authority is that principal’s, stated in the manifest, never an unnamed one: physical access to the box is already outside the threat model (the tenets), so the field records who the cable is, it does not grant more. A manifest withoutconsolestarts no console session; one naming no principal is refused byinitat the manifest check, so the steward never sees a bad name. Reopening is the server’s decision, not the core’s: when the core forgets the console session, the server opens a new one through the core’sConsoleevent, and a console session that cannot start is said once, not retried. - Defaults. The steward mounts known-sensitive places (
~/.ssh, credential directories) from the principal’s labelled volume.
sequenceDiagram
participant C as client
participant SH as sshd
participant KD as keyd
participant ST as steward
participant F as walfsd, ipd, consoled
participant S as session
C->>SH: SSH, user alice+secrets, key K
SH->>KD: sign the exchange (host key)
SH->>KD: holds(K)?
KD->>SH: no
SH->>ST: login(alice, secrets, K)
ST->>ST: K is a login key of alice,<br/>alice owns secrets
ST->>ST: carve users/alice/{alice-secrets}/session-1
ST->>F: new_connection for the session's namespace
ST->>S: launch through the stub with the namespace
ST->>SH: session id, the channel's labels
SH->>C: the session on its labelled channel
Figure: a vault login.
Contexts
Status: built · tested: bench:steward-context-login, bench:steward-login-refused, bench:steward-session-ends, bench:steward-context-cap, bench:steward-three-contexts, bench:steward-context-idle, bench:shell-contexts, bench:steward-restart-context, bench:steward-login-timing, host:redoubt-steward::a_login_s_refusals_tell_nothing_apart, host:redoubt-steward::the_cap_refuses_a_new_context_and_keeps_the_oldest, host:redoubt-steward::idle_ends_a_detached_context_only, host:redoubt-steward-server::a_login_past_the_cap_is_refused_and_makes_nothing, host:redoubt-steward-server::a_session_lists_detaches_and_ends_its_own_domain_s_contexts, host:redoubt-steward::context_free_holds_one_session_per_name, host:redoubt-steward::a_context_is_attached_to_one_channel_at_a_time, host:redoubt-steward-server::a_refused_login_makes_nothing, host:redoubt-steward-server::a_login_to_an_attached_context_takes_it_over, host:redoubt-steward-server::a_takeover_from_a_stalled_channel_still_takes_it_over, host:redoubt-steward-server::closing_the_channel_detaches_the_context_and_a_login_reattaches, host:redoubt-steward-server::sshd_gone_detaches_every_attached_context, host:redoubt-init::principal_and_label_names_hold_no_separator_and_none_is_reserved
A login’s session is a context of its principal: ssh alice@box the default one, whose name
is empty, ssh alice.work@box the context work (sessions).
sshd splits the user name and sends each part; the steward trusts none of its parse.
- Names. A principal’s, a label’s and a context’s name is 1 to 64 bytes of
[a-z0-9_-], starting with a letter (manifest::name).initholds principals and labels to it and refuses a principal namedapprove, the approval terminal’s user name; the steward holds a login’s context to it. - The key first, then the rest. A login’s key is checked against the principal alone
(
login_key); the label set and the context are looked at only after it, and each is a table lookup. A key that is not the principal’s, a principal the manifest does not name, a label the manifest does not hold or the principal does not own, and a context that is not a name are all refusedbad_key, so a login cannot tell them apart by the answer. The model checks that every such login gets that answer (its P17). The answers are uniform in content, not in time: an unknown principal or label set is refused before any machine runs, and a wrong key for a known principal runs the blame and session machines beforelogin_keyrefuses it; nothing is counted (R40 counts server crashes). The difference lies inside one steward call, and SSH’s exchange hides it:steward-login-timingtimes thirty refused logins at the client, ten of each kind, and their medians differ by less than their spread (residual risks). - Identity. A context is (account, label set, name): its sessions are numbered and carved in its domain, as every session is, so the same name in another label set is another context, and nothing about it crosses the label sets (R37).
- One session at a time. A context has at most one session (R79).
A login naming a context whose session is still starting is refused
in_use; one naming a context that runs reaches that session, as below. The console’s session is no context. The audit record of a login carries its context, and each later attach is recordedAttachedwith its key, the client’s address and whether it took the context over. - A context outlives its channel. Its VM’s
/dev/consis served by a console relay,consrelay, that the steward launches in the session’s budget beside the VM and attaches to one SSH channel at a time (R80). The session’s budget holds 128 pages for it, and the VM is told the rest as its startup block’sbudget_pagesand capped there (budgets, init). Closing that channel detaches the context: the relay lets the channel go, the steward gives its console back (sshdcloses the channel with status 0), and the VM runs on, its output kept by the relay.exit, a VM that ends, and the steward’s other ends still end the session. - Reattach and takeover. A login to a detached context attaches the new channel, which is
told
[context NAME: reattached]before the kept output. A login to an attached context takes it over, only after every authentication guard has passed: the old channel is told[context NAME taken over from ADDRESS at up XhYm]and ended, and the new one is told[context NAME: reattached; taken over from OLD-ADDRESS]. The addresses are the clients’, whichsshdreads fromipd’s/tcp/N/remote; the steward builds both notes, so the relay writes only text it was given. Each attach answers the login with a fresh attachment id, the namesshdgives the channel’s close: a close naming an id let go names nothing and changes nothing. A takeover from a channel that has stopped reading still takes the context over, and that channel may close untold: the relay gives up on the note after 150 ms, and a relay’sdetachthat runs past its 1 s bound is counted as done, since the relay lets the channel go as it takes the call and only the old channel’s note was late (consrelay). sshd’s end detaches every context. Whensshd’swatchcall ends, every channel has gone with it, so the steward detaches each attached context, and the next login reattaches.- A cap per label set (R82). A principal’s manifest line carries
contexts=N: at most N live sessions in each of its label sets, attached or detached, the console’s session counted where it runs, since it holds a session’s budget as a context does.initwrites the manifest’scontexts.max, or by default what one label set’s share holds: the share’s pages, less a budget’s own cost, over a session’s pages, or its processes over a session’s, whichever is fewer, and at most 16 (init); for the console’s principalinitrefuses amaxbelow 2, which would leave it no SSH context. The image’s shares each hold two sessions, so alice has one SSH context beside the console in her unlabelled set and two in her vault’s, and bob and agent two each. A login that would make a context past the cap is refusedcapby the guardunder_cap, afterlogin_key,owns_labels,not_lockedand the context check, and before anything is carved; no context is evicted for it. A login that reaches a live context, a reattach or a takeover, is never capped. At the client it is the same failed authentication as every other refusal;sshdnames the code on its console line alone (sshd). - Idle contexts end (R83). The line’s
idle=Sbounds how long a context stays detached:initwrites the manifest’scontexts.idle_secs, 60 to 604,800, or by default a day. A detach that finds the context’s clock clear starts it, and an attach clears it, so an attached context never ends on it, however old its last detach. The steward runs the timer itself, since a budget’s kernel deadline cannot be cleared on a reattach: its receive waits no longer than the next bound (store::next_idle), and the eventIdlethen ends each detached context past its bound throughEnding, its budget, relay and kept output with it, recordedIdleEndedwith the seconds it was idle. The image keeps the day; a production image sets hours, and the 60 s floor is for the bench’s case. - From the session. A session’s own
stewardconnection, the minted badge every session holds, carries three operations whose domain is the kernel-stamped one of that badge, never a claim:contextslists the domain’s live contexts, one line each of the name (empty for the default context),attachedordetached, and the seconds since its last attach or detach, with no client address;detachlets the caller’s own channel go, as its close would, and isunknownfor a session that is no context;end_contextends the domain’s context of that name asend_sessionends a session, the caller’s own included, which isexit. A name outside the caller’s domain, another label set’s or another principal’s, isunknown, the answer a name nobody holds gets (R37). The shell’scontexts(),detach()andend_context(name)call them (the shell). - Contexts end with the steward. A steward that dies takes every context with it:
initreapsusers(failure and restart),sshdends each attached channel, and the new steward starts on an empty table with fresh random ids, so a name never reattaches to anything after a restart: the next login makes a new context. That is the rule for M2 (usable shell). Re-adopting contexts across a restart needs a store that outlives the steward and a kernel lookup of its child budgets, and is planned with M6 (persist, install, share).
The steward’s protocol
Status: built · tested: bench:steward-login-refused, host:redoubt-steward-server::every_operation_on_another_badge_class_is_malformed, host:redoubt-steward-server::closing_the_channel_detaches_the_context_and_a_login_reattaches
The steward serves one typed protocol, its table libs/wire/tables/steward.md, included by this
page. Its sessions’ operation is login (from sshd); the operations for leases, approvals and
crash blame are below (the lease and approval operations).
Each operation is accepted only through the badge class it belongs to. The steward gives a
root badge per caller role (sshd 1, a handed badge in the manifest; init 3, which init
mints for itself and the manifest check hands to nobody; the approval channel’s, 2, the steward’s
own, a send right it mints on its endpoint for each channel’s reader, held by nobody else), and
sessions get minted badges. An operation on any other badge is malformed, the same answer as an
unknown one, so a session cannot send login, approve or blame.
logincarries the principal’s name, the label’s name (empty for an unlabelled session), the context’s name (empty for the default one), the raw key and the channel’s console connection, whichsshdmade for the session and which becomes its/dev/cons. The steward derives the key’s id (the manifest lines), then checks the label’s name against the manifest’s; a name the manifest does not hold isbad_key, as every refusal before the session is but a live context’sin_use(contexts). The reply is the session’s id, its name and the channel’s labels.channel_closedends the session the channel carried.start_agentandend_lease, from a session’s badge, are the lease operations (leases);submit, from a session’s or an agent’s,approval_openedandapproval_closedfromsshd, andpending,approveanddenyon the approval channel’s badge are the powerbox’s (the powerbox and approvals);lease_endedandapproval_waitingare the steward’s own sends to a session’snoticesendpoint, never calls: on any badge each is malformed.blame, frominit’s root badge alone, is crash blame: the account and labels the exit notice named, counted by the core, and answered as it arrives whatever it counted toward.reconnected, frominit’s root badge alone, carries the new connection at a restarted shared server, andrebind, from a session’s badge, answers the slot’s connection again with the path it is bound at;server_restartedis the steward’s send to a session’snoticesendpoint, never a call (failure and restart).- A
submitof a declassification (kind 2) names its item bywhat, a path in the vault; one of a push (kind 3) names its source and target bywhat, the source’s path in the submitter’s home, a newline, the target’s path in the vault;itemandsourceon the wire are ignored, the server drawing the ids the core hashes (declassification and push).
The table: libs/wire/tables/steward.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 1 | login | principal: string, label: string, context: string, from: string, key: bytes, console: handle[0] endpoint, relay: handle[1] endpoint | session: u64, name: string, labels: bytes |
| 2 | channel_closed | session: u64 | - |
| 3 | approval_opened | key: bytes, console: handle[0] endpoint, reader: handle[1] endpoint | channel: u64 |
| 4 | approval_closed | channel: u64 | - |
| 5 | pending | channel: u64 | - |
| 6 | approve | channel: u64, request: u64, hash: bytes | - |
| 7 | deny | channel: u64, request: u64 | - |
| 8 | blame | account: u64, labels: bytes, server: string | - |
| 9 | submit | kind: u32, labels: bytes, lease: u64, item: u64, source: u64, what: string, reason: string | request: u64 |
| 10 | start_agent | lease: u64 | lease: u64, name: string |
| 11 | end_lease | lease: u64 | - |
| 12 | end_session | - | - |
| 13 | watch | - | - |
| 14 | contexts | - | list: string |
| 15 | detach | - | - |
| 16 | end_context | name: string | - |
| 17 | lease_ended | lease: u64, name: string | - |
| 18 | approval_waiting | - | - |
| 19 | reconnected | server: string, connection: handle[0] endpoint | - |
| 20 | server_restarted | server: string, slot: u16 | - |
| 21 | rebind | slot: u16 | at: string, connection: handle[0] endpoint |
| Code | Error |
|---|---|
| 2 | unknown |
| 3 | bad_key |
| 4 | not_owner |
| 5 | labelled |
| 6 | cap |
| 7 | too_big |
| 8 | not_printable |
| 9 | not_rendered |
| 10 | hash_mismatch |
| 11 | bad_lease |
| 12 | locked_out |
| 13 | not_sponsor |
| 14 | failed |
| 15 | in_use |
The lease and approval operations
Status: built · partly tested: start_agent, end_lease, submit and the approval channel’s operations run on the machine, blame on the host · tested: bench:steward-end-lease, bench:steward-approve-box, bench:steward-declassify, bench:steward-push, host:redoubt-steward-server::start_agent_runs_the_lease_batch_and_answers_the_lease, host:redoubt-steward-server::end_lease_from_the_sponsor_destroys_the_lease_and_tells_her_sessions, host:redoubt-steward-server::an_approval_channel_opens_on_the_approval_key_alone, host:redoubt-steward-server::every_operation_on_another_badge_class_is_malformed, host:redoubt-steward-server::three_blames_in_the_window_lock_the_domain_out_until_the_window_passes
The steward’s protocol adds start_agent(lease: u64) (from an unlabelled session, answered with
the lease’s id and name once its batch is done) and end_lease(lease: u64) (from the sponsor,
answered at once: the loop never parks it, protocol::ahead), submit (from sessions and
agents), approve and deny (from the approval channel), and blame(account: u64, labels: bytes, server: string) (from init, init), each accepted only
through its badge class, as above; and lease_ended(lease: u64, name: string), the steward’s
send to a session (leases). A shared server’s restart adds reconnected(server: string, connection: handle) from init, rebind(slot: u16) from a session, answered at: string, connection: handle, and the steward’s send server_restarted(server: string, slot: u16) (failure and restart).
Leases
Status: built · partly tested: a lease’s roots are its sponsor’s ~/project and the system volume (the task-scoped grants and gatewayd are later) · tested: bench:steward-lease-ends, bench:steward-end-lease, bench:steward-sub-agent-dies-with-agent, bench:agent-login, bench:steward-agent-escape, bench:steward-lease-cut, bench:steward-agent-flood, host:redoubt-steward-server::start_agent_runs_the_lease_batch_and_answers_the_lease, host:redoubt-steward-server::end_lease_from_the_sponsor_destroys_the_lease_and_tells_her_sessions, host:redoubt-steward-server::a_leases_deadline_ends_it_without_a_failed_step, host:redoubt-steward-server::a_leases_index_recycled_for_a_new_budget_before_its_notice_is_not_destroyed_as_it, host:redoubt-steward-server::a_leases_index_recycled_for_a_connection_fails_no_step, host:redoubt-steward-server::an_end_lease_after_the_deadline_makes_no_destroy_by_the_revoked_handle, host:redoubt-steward-server::a_login_as_the_sponsored_principal_is_a_lease_of_its_sponsors, host:redoubt-steward::a_sponsored_principals_session_is_a_lease_of_its_sponsors, host:redoubt-steward::sponsor_session_and_notify_sponsor, bench:steward-launch-overlap, host:redoubt-steward-server::a_launch_runs_on_the_thread_and_the_lease_is_answered_when_it_reports, host:redoubt-steward-server::a_deadline_exit_of_another_lease_is_decided_and_its_notice_sent_while_a_launch_waits, host:redoubt-steward-server::the_launching_leases_own_end_lease_is_held_and_served_after_the_report, host:redoubt-steward-server::a_launch_the_thread_fails_answers_failed_and_rolls_the_batch_back, host:redoubt-steward-server::a_second_start_agent_during_a_launch_queues_and_both_are_answered_in_order, host:redoubt-steward-server::sshd_s_end_waits_for_a_logins_launch_and_detaches_the_context_after, host:redoubt-steward-server::a_sponsored_logins_launch_defers_the_sponsors_usage_line_until_its_report
An agent is its own principal, never an impersonation, with an accountable sponsor: a person, or an agent with a person at the top of the chain.
-
start_agentfrom an unlabelled session’s badge (a lease’s badge is toldunknown: a lease is not a session, and a lease nested in a lease is a later package) is the core’s lease batch: the lease’s budget from the sponsor’s unlabelled sub-budget with the manifest’sagentsize and the deadline asked for, a scope, the steward’s badge on it, a connection per slot of the binding table for a lease (/boot, the sponsor’s home’sprojectdirectory at/project, minted through the home’s one carve with the manifest’sagent_quotabytes carved from the home’s quota and given back at the lease’s end, since a root below the home with no quota of its own holds nothing (walfsd); aprojectholding more than that quota refuses the mint, the step fails andstart_agentanswersfailed; and the system volume: no vault, no network, no console), and beamlet launched on the agent’s module,Redoubt.Agents.Echoin this image, toldprincipal=the sponsor andhome=/project; its process has an exit endpoint the steward watches as a session’s. The reply names the lease (agent-1, numbered per domain). -
A sponsored principal (the image’s
agent,sponsor: "alice"in the manifest) has no top budget: a login as it runs the session machine as any login does, but its session’s budget is carved from the sponsor’s unlabelled sub-budget with a deadline ofMAX_LEASE, so it is a lease of the sponsor’s:end_leasewith the session’s id ends it, and its every end tells the sponsor. It stays its own principal: its records and routes are its own domain’s, its replysession-N, and a login of its in a labelled set is refused (labelled): its vault work goes throughsubmitand an approval, never a login. The harness that drives it (the machine mode, a compile on the box) runs inside the lease unchanged. -
Each path tells the sponsor: every unlabelled session of the sponsor receives one
lease_endedsend on thenoticesendpoint it was launched with (the receive right is the named handlenoticesin its startup block, which the shell serves; the steward keeps the send right and closes it with the session), naming the lease as<principal>/<name>(alice/agent-3,agent/session-7) and never why; the shell prints[lease NAME ended]. A session whose VM does not take the send in the steward’s bound misses it, and the steward is not held: the core forgets a notice no session can take. Leases ending together are told one send at a time, every notice of the burst ahead of any exit’s bookkeeping, each send taken at once by beamlet’s serve thread, which queues it for the VM and takes the next (beamlet), so the last of a burst is late by the core’s decisions of the exits before it, not by their bookkeeping or the VM’s take, and none is missed while the queue has room, a full queue holding the thread until the VM’s poll (failure and restart). The same endpoint carriesserver_restarted(failure and restart). -
A launch runs on the steward’s launch thread, and the loop serves meanwhile. The slow part of a lease’s or a session’s launch, the VM’s image streamed from the system volume into the child’s pages and
process_start, is seconds under load (theserved start_agentfigures under residual risks), and a deadline’s exit notice that arrived while the loop was inside it waited behind it. So the loop prepares the launch (the exit endpoint, a session’snoticesendpoint, every handle and name the child is given: all of it exists before the hand-over) and hands the rest to one thread of its own as a self-contained job, which runs the launches serially, in the order they were handed over, and reports each as a send on the loop’s endpoint; the loop finishes it (its own copies closed, the exit watched, the notices send right kept) and only then gives the core the batch’sDone, so the core sees the object’s events in the order it always did (servers/steward/src/drive.rs,Steward::launched); the usage line of the domain the batch carves from (its own, or the sponsor’s for a sponsored principal’s session) is said then too, after the batch’s record, as the inline launch ordered them. The caller’sstart_agentorloginis parked and answered when the launch is done: its contract is unchanged, and itsservedtime now counts a launch’s wait in the thread’s queue behind an earlier one (before, a second call was not even received until the first launch returned, so no caller waits longer than it did). While a launch waits the loop serves every other object: a deadline’s exit and itslease_ended, the idle timer, a blame, and another lease’send_lease, still answered ahead and at once; the one call the loop holds a reply for is the launching object’s own (end_leasenaming the starting lease, its channel’s close,sshd’s end while a session starts), held in arrival order and replayed after the launch’s report, at most the launch bound later, since the core takes those only once the batch is done. A launch the thread fails (the lease’s own deadline inside its launch, a read the server did not take withinSERVER_US) fails the step as any step fails: the core rolls the batch back and the caller is toldfailed, and nothing of the launch is left. The thread touches nothing of the steward’s tables and creates no budget; the handles it installs are under the install lock (failure and restart). Measured bysteward-launch-overlap(a lease’s deadline one second inside another lease’s launch, five times a boot): the sponsor told 16 to 36 ms net after the deadline (p99 20, 36 and 19 ms on rv64 at one and two harts and rv32 at one), against 0.36 to 0.58 s net (0.76 to 1.5 s gross, the launch’s remainder) with the launch on the loop (responsiveness). -
A lease the kernel’s deadline took reaches the steward as its process’s exit notice with the budget already gone and the steward’s handle to it revoked, its table index free for the next handle the steward installs: the core’s
DestroyBudgeton it is the end asked for, done already by the kernel, and the steward never makes it by that handle. The steward keeps each budget with the deadline it minted it with, reads the clock right before any destroy, and makes none once the deadline has passed: the kernel destroys a budget at the first entry at or after its deadline, before anything else, and never before (deadlines), nothing but the deadline revokes a budget handle the steward holds, no reply the steward’s threads take carries a handle (the cleanup thread’s disconnect and ended, the reader threads’ calls at consoled), and the launch thread installs its handles only under the install lock the loop holds across that read and the call (failure and restart), so no handle is installed in its table between the two and none can take the index; a reply that one day carries a handle rechecks this argument. Before this, the destroy was made and itsBadHandletolerated on a lease whose process was gone, which held only while deadlines came one at a time: several leases expiring together (seen whensteward-latency’s deadline leases overlapped, before they ran one at a time) recycle the freed index before the notice is served, and a destroy by it found another kind of object (WrongObject, a failed step) or would have destroyed another lease’s budget with no error. Anend_leaseserved after the deadline but before the notice makes no destroy either; one on a live lease destroys it as before, and a handle that answersBadHandlebefore its deadline is a handle the steward lost: a failed step. -
A principal’s live leases are bounded by its share. Its leases and its sessions of one label set are carved from that set’s fixed sub-budget (fixed sub-budgets), and the kernel’s carve refuses past it (R7 (carving)); and an unlabelled lease’s root is
project, minted below the home withsizes.agent.quotacarved from the home’s quota, which its server refuses past (walfsd; a labelled lease’s root is its vault, no quota carve, agents). So a principal holds at most what its unlabelled share holds inagentbudgets, by pages, processes and weight, and what its home’s quota holds inagentquotas, the smallest:min(share_pages / sizes.agent.pages, share_processes / sizes.agent.processes, share_weight / sizes.agent.weight, home_quota / sizes.agent.quota), the share an equal part of its budget per label set, less a budget’s own cost, asinitderives the context cap (R82); a sponsored principal’s is its sponsor’s. It is an upper bound: a lease is refused by whichever carve runs out first, the kernel’s or the server’s, the sessions carved from the same share only lower what is left for leases, and a sponsored principal’s sessions are leases of that share. The image’s alice: 81,920 pages over two label sets, less one, is 40,959, over 5,248 is 7; 64 processes over two, over 2, is 16; weight 1,000 over two, over 50, is 10; her home’s 8,388,608 bytes over a lease’s 1,048,576 is 8: 7. Bob, with one set: 32,767 over 5,248 is 6; 20 over 2 is 10; 500 over 50 is 10; 8 by his home: 6. The steward keeps no such count and the manifest names none; the shared servers’ per-account caps take the term, the bench deriving it from every manifest in the tree (serving;the_servers_admit_what_a_manifest_lets_one_account_hold). -
An agent’s budget sits under its sponsor’s, so it shares the sponsor’s account: it is carved from the sponsor’s fixed sub-budget for the lease’s label set, never from a session’s budget. So a lease outlives the login session that started it, and ends only by its sponsor, its deadline or blame. Its requests count against the sponsor’s admission for its label set, with a fair share per badge inside (R26 (admission fairness)), so an agent cannot lock its sponsor out.
-
A lease is task-scoped: “read
~/project, write~/project/out, themodelgateway, 2 hours, 256 MB, 4 processes, weight 20”. An agent never holds a socket or a name rule: it reaches outside the box only throughgatewaydcapabilities (gatewayd). Its budget has a deadline (budgets) at mostMAX_LEASE(24 hours) away. The steward refuses a longer lease rather than shortening it silently; the kernel knows only deadlines, not leases. -
Delegation only narrows. An agent may start sub-agents as budgets inside its own budget. It holds only its own budget handle, so it cannot create siblings, and its lease’s end destroys its sub-agents with it, whatever their own deadlines. A new durable principal, or a budget with more labels than its parent, needs the steward and an approval. An agent request asks for a labelled agent: from a labelled session, in exactly its own label set; from an unlabelled session, in a label set its principal owns. An unlabelled agent is started with
StartAgent, never through an approval. -
Ending a lease is always accepted from the sponsor, ahead of admission: the steward answers it straight from its receive loop (serving). The sponsor ends it from any of its unlabelled sessions. A vault session cannot, since ending a lease it does not share labels with would be a flow out of the vault.
-
The sponsor learns that a lease ended, however it ended: the steward notifies the sponsor’s unlabelled sessions through the lease-supervision edge, naming the lease and not why it ended.
-
Narrowing is a revocation scope. To give a server a way to narrow a session’s or lease’s connections, the steward passes it a revocation scope made for that purpose, never a budget that holds processes, which would let a compromised server end every session (R41 (narrowing by revocation scope)).
-
An agent holds no credentials. It uses keys through
keydand, from M5 (self-hosted development), models throughgatewayd. Until M6 (persist, install, share), which brings keys in leases, no session or lease holds akeydgrant, sincekeyd’s purposes are the host key and audit signing (keyd). -
Assume every agent is compromised by something it read: it can do what its capabilities allow until its lease ends, and nothing more.
-
Each agent runs in its own VM; sub-agents with different authority are separate VMs.
The model checks leases and their end (its P9 and P13; PolicyUnboundedLease,
PolicyEndLeaseAdmitted, PolicyEndLeaseFromVault, PolicyNoFairShare), the sponsored principal (its P1 and P13; PolicySponsoredVaultLogin,
PolicySponsoredEndedByAnyone) and the lease lifecycle (R39 (leases end)):
the lease machine’s, whose table and generated diagram are above, and the session
machine’s EndLease rows.
Residuals: a lease’s “read ~/project” root is writable, since no server takes a read-only
connection yet; a sponsor whose sessions all missed the notice has no leases listing to ask
(later, with the powerbox); the lease’s connections end by the launcher’s disconnect at its end
until R41’s scope lands.
The powerbox and approvals
Status: built · partly tested: a labelled agent’s request, a note, a declassification and a push are submitted, rendered, approved and denied on the machine · tested: bench:steward-approve-box, bench:steward-declassify, bench:steward-push, bench:steward-screen-inert, bench:steward-pending-cap, bench:steward-vault-note, host:redoubt-steward-server::a_request_is_rendered_bound_to_its_screen_and_its_approval_starts_the_labelled_agent, host:redoubt-steward-server::notes_are_rendered_by_their_requesters_labels_and_a_dead_sessions_requests_are_gone, host:redoubt-steward-server::an_approval_channel_opens_on_the_approval_key_alone, host:redoubt-steward-server::the_three_commands_parse, host:redoubt-steward-server::the_fuzz_corpus_still_passes, mutation:PolicyApproveIgnoresHash, mutation:PolicyApproveWithLoginKey, mutation:PolicyNoPendingCap
The powerbox grants authority a principal lacks and confirms a principal’s own high-stakes steps: an agent asking its sponsor, a principal asking the holder of a shared resource, a new trusted key, a declassification. Most things need none.
- Out of band. An approval happens only where the steward alone talks to the terminal:
ssh approve@box. Sessions and agents only notify that an approval is waiting. The requester can never influence the approval channel. - The approval key is the person’s own. Keys that authenticate a person to the box stay on the
person’s machine and never live in
keyd(R35 (key separation)); the steward refuses to enrol one key in both roles; a session’s network scope never includes the box’s own addresses. Otherwise a hijacked session could log in toapprove@boxover loopback, signing withkeyd, and approve itself. - Rendering. The steward renders from the structured request: the requester’s kind (agent,
session) and steward-assigned name (
agent-7) beside its principal, what, where, how long, and the label consequences. Every rendered field is printable ASCII (0x20 to 0x7E; anything else is escaped), with no control character (U+0000 to U+001F, U+007F to U+009F) and so no ESC, so no terminal escape can repaint the approval screen and no bidi or format character (U+202E, U+2066, U+200B) can disguise it. A requester-supplied field is at mostFIELD_CAP(64) characters, counted as Unicode scalar values, and is shown marked as the requester’s text. An unlabelled requester’s free-text reason is quoted, escaped and marked untrusted. A labelled requester’s request shows only text the steward generates (kind, target, size): its free text would be a channel out of the vault. - Binding. Each request has a random 64-bit id and a hash of its exact content; approving names both. The request is frozen until answered, and any change makes it a new request.
- Limits and labels. Each (account, label set) has a cap on pending requests, and a session
holds at most a fair share of it; a dead session’s requests are dropped. A labelled request is
shown only to principals owning every label it carries, and otherwise refused at submission. Its
“approval waiting” notification reaches only channels whose labels include all of the request’s,
and
approve@box. The share is the cap divided among the domain’s live sessions and leases, at least one: beside Alice’s console and SSH sessions her agent submits one request and its second iscap, whatever the domain has left. - An approval grants no more than the approver holds. A request reaches only its own principal’s approval channels, and that principal owns every label the request carries or asks for, checked when it entered and when it was submitted. A project’s requests, which several members may approve from M6 (persist, install, share), bring a check of their own.
sequenceDiagram
participant A as agent
participant ST as steward
participant SH as sshd (approve@box)
participant P as Alice
A->>ST: submit(content, reason)
ST->>ST: freeze, id, hash(content),<br/>check the pending cap
ST->>A: request id
ST->>P: approval_waiting on her sessions'<br/>notices endpoints
P->>SH: ssh approve@box with her approval key
SH->>ST: approval_opened(key, console, reader)
ST->>SH: the screen on the console (printable ASCII)
P->>SH: approve ID HASH-or-CODE
SH->>ST: the line, read by the channel's reader
ST->>ST: hash matches, grant at most<br/>what Alice holds, audit
ST->>A: the labelled agent starts
Figure: an agent’s request approved out of band.
The channel, as built. sshd recognises the user name approve, which names no principal:
it mints two connections to the channel’s console as it does for a login and sends them with the
key in approval_opened; the steward finds the principal from the key (the one whose approval
keys hold it, else the one whose login keys do, so that approval_key, the keeper, answers
bad_key; a key of nobody’s is unknown), the core opens the channel, and the server starts a
reader for it: a thread that blocks in read on the second connection for the person’s lines
and sends each, parsed (approve ID HASH, deny ID, pending:
servers/steward/src/approval.rs, fuzzed in servers/steward/fuzz), to the steward’s own
endpoint on badge 2, which the main loop serves as any call and answers on the channel
(approved ID, refused: hash_mismatch, unknown command). The main loop writes the screen
through the first connection, bounded (SCREEN_US: a terminal that stops reading loses a screen,
which pending redraws, and never holds the steward), so the pair cannot deadlock: a reader’s
call waits only on the main loop, and the main loop never waits on a reader. Open channels are
at most CHANNELS (4), and readers are pooled as the exit watchers are: a reader whose channel
ended waits for the next, and a new one starts only when every reader is busy, so a hundred
logins cost the stacks of the most open at once; the steward’s threads stay bounded and
declared. The screen is a header naming the channel’s principal; per pending request
request ID hash HASH code CODE (the id and hash in hex, the code the hash’s first four bytes)
and the core’s rendered line; and the line to type. The person types the full hash or the code:
the server expands a code only from the last screen it wrote on that channel, so either binds the
approval to the screen the person saw, and a code the last screen did not show is
hash_mismatch to the channel alone, never to the requester, as a stale hash is. The core’s Approve takes the full
hash. The requester’s sessions are told by approval_waiting on their notices endpoints (the
shell prints [an approval is waiting]), which says nothing of the request; a notice to an open
channel of the principal redraws it. A granted labelled agent runs the lease batch in
the label set’s domain, its /project the set’s vault.
The model checks binding, screens, caps and the approval channel (its P3, P4 and P5;
PolicyApproveIgnoresHash, PolicyRenderNotWhitelisted, PolicyLabelledFreeTextShown,
PolicyShowLabelledToAll, PolicyNoPendingCap, PolicyDeadSessionRequestsKept,
PolicyApproveWithLoginKey, PolicyNotifyLabelledToAll, PolicyApproveOtherChannel)
(R38 (out-of-band approval)).
The steward’s constants are the model’s, changed only by a new system bundle, never per
principal: PENDING_CAP 4 pending requests per (account, label set); FIELD_CAP 64 characters;
DECLASSIFY_MAX 256 bytes; BLAME_COUNT 3 blamed crashes within BLAME_WINDOW, 10 minutes;
MAX_LEASE 24 hours.
The attack test: a field full of ANSI escapes renders inert (steward-screen-inert: 64
characters of screen clears, bidi overrides, isolates, zero-width spaces and bells reach the
channel as 64 ?, the verdict the bytes sshd relayed).
Declassification and push
Status: built · tested: bench:steward-declassify, bench:steward-push, bench:steward-confined-push, host:redoubt-steward-server::a_declassification_reads_through_a_crossing_and_its_approval_copies_the_snapshot_out, host:redoubt-steward-server::an_item_too_big_or_not_printable_is_refused_at_submission_and_the_crossing_destroyed, host:redoubt-steward-server::a_reader_that_dies_fails_the_submission_and_the_partial_crossing_is_destroyed, host:redoubt-steward-server::a_push_reads_the_source_unlabelled_and_its_approval_writes_through_a_crossing, host:redoubt-steward-server::a_writer_that_fails_is_audited_and_the_partial_crossing_destroyed, host:redoubt-steward-server::a_denied_requests_items_are_forgotten, host:redoubt-steward-server::a_confined_boot_leaves_a_labelled_sessions_home_unbound, host:redoubt-crossing::a_read_answers_the_item_once_and_is_then_served, host:redoubt-crossing::an_item_over_the_bound_is_cut_one_past_declassify_max, host:redoubt-crossing::a_write_writes_the_bytes_as_the_item_and_the_other_mode_s_call_is_failed, host:redoubt-crossing::a_missing_item_is_failed, mutation:PolicyDeclassifyLive, mutation:PolicyDeclassifyWithoutReader, mutation:PolicyDeclassifyFromUnlabelled, mutation:PolicyDeclassifyUnfit, mutation:PolicyWriteUp
Declassification moves one item from a label down to an unlabelled volume. Only the label’s owner declassifies, from a session carrying exactly that label set, one item at a time, after a high-stakes approval:
- Submission.
submit(kind 2) names the item bywhat, its path in the vault (notes/memo); the server draws the id the core hashes and keeps the path beside it for the request’s life. The steward snapshots the item through a read crossing: the core’s crossing machine carves a budget underuserswith exactly the item’s labels and a deadline ofCROSSING_LIFE(one second), which the server stamps with the labelled side’s account, as boot stamps a top budget: it bills to the owner (R8 (accounts)), a crash of it is blamed on the domain (R40), and its connection falls in the account’s bucket at the vault’s server, where it holds one connection record (State) for at mostCROSSING_LIFEand no completion call, since the program’s 9P calls are plain (walfsd). In it the steward launches thecrossingprogram (servers/crossing: one program, its mode an argument) through the loader stub, with one connection to the vault’s server at the vault’s root, minted in the crossing budget as its scope, so the budget’s destruction ends the connection in every table at once (R41), and the receive right of an endpoint the steward made and kept a call right on. The steward calls it once (read, the table below), withinCROSSING_LIFE: the program walks to the item, reads it whole up toDECLASSIFY_MAX+ 1 bytes (READ_MAX: one past the bound tells an item over it from one at it, and bounds the lend whatever the item’s size), answers it into the steward’s lend and exits; the steward destroys the budget and, after, gives the connection back with adisconnect(the cleanup, never the cutoff). The steward, unlabelled, never reads the vault: a mint below the vault’s root walks the path with the caller’s labels (R25), which it has not, so the connection is at the root and the labelled program walks; and the labelled program only ever answers, since the endpoint’s owner is the steward (R1 (flow)). The core checks the snapshot: overDECLASSIFY_MAX(256 bytes) istoo_big, not printable text (ASCII 0x20 to 0x7E and newlines)not_printable, each after its own crossing, destroyed the same way. A reader that exits, hangs past the deadline or answers wrongly fails the step: the partial crossing is destroyed and the submission isfailed. The request is frozen with the hash of its content, the ids and the snapshot: what is approved is the bytes. - The screen shows all of it: the core’s line names the item by id and quotes the snapshot
whole; beneath it the server adds one line per item the request names, its path as the
requester’s text, sanitized and cut as a field is (
FIELD_CAP) and never part of the hash. - On approval the steward copies exactly the snapshot (the core’s
Bytes::Literal, never a re-read) to~/out/<name>in the submitter’s unlabelled home,<name>the item’s last path element, by its own write through the connection it keeps at the home, makingoutif it is missing and never over a file that is there: a submission whose landing is taken is refusedin_usebefore any budget is carved. The copy out is auditedDeclassifiedwith the snapshot and the reader budget’s id.
A push is the mirror, low to high: how input enters a labelled domain in a confined deployment,
where the domain reads no shared unlabelled volume (init). One
push moves one item from the submitter’s unlabelled home into the labelled domain’s volume. The
target label’s owner submits it from an unlabelled session (kind 3, what the source’s path in
the home, a newline, the target’s path in the vault); a vault session’s push is refused at
submission, as is another principal’s (not_owner): the confined domain cannot trigger one, name
the item, or pull one. At submission the steward reads the source itself (it is unlabelled),
snapshots it and hashes the snapshot. A pushed item is not held to DECLASSIFY_MAX but to
PUSH_MAX, 61,440 bytes: fifteen of the steward’s sixteen lend pages, the sixteenth for the
item’s path and the message’s lengths, so the snapshot reaches the writer in one call; a source
over it is too_big by a stat before the core sees the request, and one grown past it between
the two is failed. The steward’s heap holds the pending snapshots: PENDING_CAP pushes of
fifteen pages each beside a read’s sixteen-page buffer and its working set, so its heap_pages
is 128 in the image’s manifest (the cap on the runtime’s heap: 32 was short of one 4 KiB push’s
read on rv64). Its screen shows the source and the target by id with their paths, the
item’s size and the snapshot’s SHA-256. On approval it writes exactly that snapshot through a
write crossing in the target’s domain, the same budget and program with the target’s labels,
called once with the snapshot (write), since a write needs equal labels; the program truncates
an item that is there and makes one that is not, in a directory that must exist (a target whose
directory is missing fails the program’s walk, and the crossing is failed). A writer that fails
after some bytes leaves the vault as it is: the vault’s owner sees a short file named as the
target, the request is audited PushFailed, and the next push overwrites it. A push that lands is
audited Pushed with the request’s labels, the target’s domain’s. There is no standing path,
queue or batch.
Confined deployments. The steward declines a labelled session’s mount of a shared unlabelled
volume and offers the push instead: when init’s confined line is among the steward’s own, the
binding table leaves a labelled session’s home slot bound to nothing and tells its VM no home=;
the shell says so once at its start (the shell). init’s check
no longer refuses such a manifest, counting the home’s volume for the principal’s unlabelled domain
alone (init). /boot and the system volume, read-only code,
stay bound for a labelled session: a stated residual of the check, which does not count code
volumes.
The crossing’s protocol, included from libs/wire/tables/crossing.md:
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 1 | read | path: string | bytes: bytes |
| 2 | write | path: string, bytes: bytes | - |
| Code | Error |
|---|---|
| 2 | failed |
| 3 | served |
sequenceDiagram
participant P as Alice (owner)
participant ST as steward
participant R as crossing budget {alice-secrets}
participant V as walfsd:alice-secrets
participant U as walfsd:data
P->>ST: submit(declassify, notes/memo)
ST->>R: create (exact labels, the account, a deadline), launch crossing, call read
R->>V: walk to the item, read it
R->>ST: the snapshot, in the steward's lend
ST->>R: destroy, then disconnect
ST->>ST: size and text checks, hash, freeze
ST->>P: approve@box shows all of it, with the path
P->>ST: approve(id, code)
ST->>U: write exactly the snapshot to ~/out/memo
ST->>ST: audit Declassified
Figure: declassifying one item.
The model checks that what is copied out is exactly the snapshot, read through a reader with the
item’s labels, and the push’s shape (its P6 and P11; PolicyDeclassifyLive,
PolicyDeclassifyWithoutReader, PolicyDeclassifyFromUnlabelled, PolicyDeclassifyUnfit,
PolicyWriteUp) (R42 (one approved item)). The server’s host tests
drive the same code against a kernel that records its calls: the crossing’s budget carries exactly
the labels and the account, the copy out is the steward’s one write and no read of the vault, the
reader that dies and the writer that fails each end in a destroyed crossing with nothing held.
The steward’s reader and writer budgets are edges of the confinement check’s one named exception: each carries exactly one label set and dies after one item (init).
Crash blame
Status: built · partly tested: the machine shows one blame counted (steward-blame-lockout) and the lockout at the third, bob’s three crashes of walfsd:data ending his session and refusing his login while alice’s home answers on (steward-server-restart); the vault arm and the window’s end are the host test · tested: bench:steward-blame-lockout, bench:steward-server-restart, host:redoubt-steward-server::three_blames_in_the_window_lock_the_domain_out_until_the_window_passes, host:redoubt-steward-server::every_operation_on_another_badge_class_is_malformed, host:redoubt-init::a_blame_the_steward_does_not_take_in_time_is_lost_and_said, host:redoubt-init::a_handed_badge_is_a_root_badge_given_once_at_its_endpoint
A server that faults, or exits holding open calls, names in its exit notice the account and
labels of the current call of the thread that failed
(R21 (crash blame)), and init passes them on
(init) through blame, which the steward accepts only on init’s
root badge (badge 3 on its endpoint; init’s manifest check hands it to nobody). Three crashes
blamed on one (account, label set) within ten minutes destroy every budget of that (account,
label set), sessions and leases alike, their agents with them, and the steward refuses new
sessions for it until the window passes, recording both in the audit log. A logout alone would
not stop a principal logging straight back in, or its agent carrying on. Keyed by the label set
too: a vault session crashing a shared server must not end its owner’s unlabelled sessions, which
would be a channel out of the vault. A crash with no current call blames nobody and counts only
toward init’s restart limit.
The core runs it: one blame machine per domain (a domain’s blame) counts
each blame and, at the third within the window, raises LockedOut to every session and lease of
the domain, whose end batches destroy their budgets; a login or start_agent in the domain is
refused locked_out until the window passes; a blame on no domain of the manifest (nobody, or a
label set it does not know) is taken and counts nowhere. Every blame is audited Blamed and a
lockout LockedOut with the window’s end, console lines until the audit file; init’s own line
names the server and whom it blamed. A blame that reaches no lockout changes nothing visible but
the count: the caller sees the same answer either way. Residual: the Blamed record carries no
server name; init’s line does, until the audit log carries it.
The model checks it on the kernel model’s own exit notices (its P7; PolicyBlameNoWindow,
PolicyNoLockout) (R40 (blame by label set)). Blame counted per account
would need a second domain, which no handler can borrow.
The transfer audit log
Status: planned · M4 (files in and out)
The audit log begins with file transfers. On sshd’s request the steward starts a transfer server
for an unlabelled session’s channel, in a budget carved from the session’s, with the session’s file
binds and a badge on the steward’s own endpoint for its records (sshd).
The steward appends a record for every file-transfer operation it receives there, taking the
principal and labels from that badge, to a file only it can write, append-only, each record signed
through keyd’s audit purpose exactly as below.
Open: none.
The audit log
Status: planned · M5 (self-hosted development)
The same log extends to every steward action: the steward appends a record for every mint,
delegation, revocation, approval, denial, lease end, blame and lockout, with the principal chain, to
a file only it can write. Each record carries the request’s or target’s labels and is read under
the label check (R25 (the label check)), so a labelled
request’s target never reaches an unlabelled reader. A lease’s end is audited with the lease’s
labels, though its sponsor is unlabelled. Each record is signed: the steward asks keyd to sign it under
the audit purpose over the preimage "redoubt.audit.v1\0" || u64_le(len) || record, whose digest
keyd computes itself, and stores the signature beside the record. The steward holds a keyd grant
for that one purpose, never a key. A record cannot be altered undetected by anything that can write
the file later.
The model checks that audit views are filtered by labels and that every record’s signature binds
purpose, signer, domain, length and every byte (PolicyAuditUnfiltered,
audit_authority_binds_purpose_signer_domain_length_and_every_byte).
Open: none.
Retention, chaining and the verifier
Status: planned · M6 (persist, install, share)
Records are chained, each naming the one before, so a record dropped or reordered is found as well as one edited; an operator tool verifies the chain and the signatures; and the log is kept for a set time and rotated without breaking the chain.
Open: the retention period and rotation; where the verifier runs and which key it trusts.
Persistence, run-time principals and enrolment
Status: planned · M6 (persist, install, share)
The steward keeps its state across boots: principals, their keys and label sets can be added and removed at run time, and the capabilities it minted are re-minted after a restart from its records, since every server restarts with empty tables. The first owner is enrolled at first boot on the physical console, a trusted path, and delegates from there; approvals can then also be given on that console.
Open: how a shared server’s bucket count is sized when principals are added at run time; where the steward’s state lives and how it is protected; how re-minted capabilities reach sessions that held the old ones; how a key is enrolled and revoked at run time.
Packages and trust
Status: planned · M6 (persist, install, share)
The steward keeps each principal’s package records and never parses a package (packages):
- it starts a pkg server per install, handing it the archive, a write handle to that principal’s package directory, the principal’s trust list and the system bundle’s module names, and a badge on which it asks the steward to record;
- it keeps the profile,
userecords, trust lists, the signer of each installed program and the grants a manifest’s requests were given; - it launches a package’s code with those grants only if a key on the principal’s trust list signed
it, and refuses at
usetwo packages in one profile that define one module; - on a key’s removal from a trust list it refuses that key’s launches and stops the principal’s running processes it signed, by destroying their budgets.
Open: none.
Projects and sharing
Status: planned · M6 (persist, install, share)
A project is a principal sponsored by several members, with its own budget, volume
(walfsd:project-x), package directory and profile, and optionally a label. Membership is
capabilities minted into a revocation scope per member; removing a member destroys the scope. A
labelled project is worked on in project vault sessions (ssh alice+project-x@box), and
declassifying one of its items needs a project owner’s approval. No kernel mechanism is involved.
Open: which members count as owners for an approval (project policy).
Authority
Status: built · partly tested: the keyd grant for audit comes with the audit file in M4 (files in and out) · tested: bench:steward-boot
- The steward holds the
usersbudget and asystem-class budget of its own, the only process besidesinitthat holds asystem-class budget handle (R33 (no server holds a system budget)). - It holds connections to the servers it builds namespaces from, and a
keydgrant for theauditpurpose. It never holds the host key’s badge: the manifest hands that tosshd. Its own share at each server holds only what it calls on itself: its kept connections (the home carves, the fresh ones it keeps) and, pending, what it has minted for a session or a lease that has not yet made its first request; from that request the connection is admitted in the session’s or the lease’s account (serving), so the steward’s share caps nothing a principal holds. - It holds no keys and signs nothing itself, and parses no ELF. Its one cryptographic function is the request binding hash (the policy core).
- It filters every request by the caller’s labels, as the kernel attached them; a labelled caller can only submit requests.
- It passes a server a narrowing handle only as a revocation scope made for that purpose (R41).
- Its work is paid for by the steward, not the requester, and it runs at a large manifest weight in the one stride queue, bounding the work of any one request and relying on its caps.
- The steward and
sshdare the confinement check’s one named exception, and only by three kinds of edge: the request and owner-approval path, per-item reader and writer budgets, and lease-ending supervision (init). The steward enforces the same rule for every budget and grant it creates.
Security properties
R36 (unpredictable ids)
Status: built · tested: bench:steward-session-ends, bench:vault-no-leak, host:redoubt-steward-server::a_login_runs_the_session_batch_and_answers_the_session
Every id the steward hands out (session, request, connection) is a random 64-bit word from a keyed
generator, never a counter. So no principal learns how many sessions or requests another started.
The model found the leak with sequential ids and checks the rule (PolicySequentialIds); on the
machine, an unlabelled session’s two request ids, with its vault session’s request between them,
are three distinct words and the second is no successor of the first (vault-no-leak).
R37 (vault non-interference)
Status: built · tested: bench:steward-vault-session, bench:steward-sub-budget-flood, bench:vault-flood-turn, bench:vault-no-leak, bench:shell-contexts, host:redoubt-steward-server::a_vault_login_carves_from_the_vault_s_sub_budget_and_has_no_network, host:redoubt-steward::a_session_sees_and_ends_its_own_domain_s_contexts_only, host:redoubt-steward-server::a_session_lists_detaches_and_ends_its_own_domain_s_contexts
A vault session’s work (item writes, requests, calls to a shared server) changes nothing an
unlabelled session observes: its results, the usage of users, of every principal’s budget and
unlabelled sub-budget, and the audit records an unlabelled reader may read. No counter is shared
across a principal’s label sets: sessions and agents are numbered and named per (account, label
set), as users/alice/{alice-secrets}/session-1 is, so a vault agent started on approval does not
move the number of its owner’s next unlabelled session. A session lists and ends only its own
domain’s contexts, and another label set’s name is unknown, as a name nobody holds
(contexts); the model checks it after every operation (its P21), and
PolicyContextsAcrossSets lets a session list and end every label set’s contexts of its
principal. The model checks this
on kernel results by replaying sequences with the vault’s operations removed
(steward_noninterference, its P10). It also checks the order a shared server takes unlabelled
calls in, which R2 (fair waiting) keeps the same whatever a
vault sends. The crashes P10 replays are ones an unlabelled session’s call causes. A server that
crashes on its own, or on a vault’s call, is a stated residual
(residual risks): which call it holds, and when it takes the calls queued
before, is service timing.
What an unlabelled session can observe, the list vault-no-leak records in alice’s unlabelled
session before and after her vault session writes files in its volume, launches a program and
submits a request, and prints equal: its own budget’s limits and processes in use, and that a probe
carve of 2048 pages succeeds; its contexts, by name and state; its home’s listing, with each
entry’s kind, size and mtime; a launch of a program, its ending and its budget’s use. Beside the
list: no approval notice but its own requests’ reaches it (the steward notifies the sessions whose
labels include the request’s); its request ids and the vault’s are distinct random words
(R36); and its writes, creates, removes and truncates in the vault’s
volume are enoent before and after, the namespace giving it no connection there, which
R25 (the label check) would refuse at the server if it had one.
This is every observable a session has: message ids, PIDs, qids, file versions and audit records
are not reachable from a session today (a launch is a reference, a stat gives size, kind and
times, and no unlabelled read of the audit exists), so they are not on the list; exposing a PID or
a qid to a session would be its own change, judged against this rule.
R38 (out-of-band approval)
Status: built · partly tested: declassification and push, the other approved contents, are STEWARD4’s · tested: bench:steward-approve-box, bench:steward-screen-inert, bench:steward-pending-cap, bench:steward-vault-note, host:redoubt-steward-server::a_request_is_rendered_bound_to_its_screen_and_its_approval_starts_the_labelled_agent, host:redoubt-steward-server::an_approval_channel_opens_on_the_approval_key_alone, mutation:PolicyApproveIgnoresHash, mutation:PolicyApproveWithLoginKey, mutation:PolicyApproveOtherChannel, mutation:PolicyShowLabelledToAll, mutation:PolicyRenderNotWhitelisted, mutation:PolicyLabelledFreeTextShown, mutation:PolicyNotifyLabelledToAll
An approval takes effect only if it came through approve@box with the approver’s own approval
key, named the frozen request’s id and content hash, and grants no label or authority the approver
lacks; a labelled request is shown only to owners of all its labels and shows none of its free
text.
R39 (leases end)
Status: built · tested: bench:steward-lease-ends, bench:steward-end-lease, bench:steward-sub-agent-dies-with-agent, bench:agent-login, bench:steward-agent-escape, bench:steward-lease-cut, host:redoubt-steward::a_sponsored_principals_session_is_a_lease_of_its_sponsors, mutation:PolicyUnboundedLease, mutation:PolicyEndLeaseFromVault, mutation:PolicySponsoredEndedByAnyone, mutation:PolicySponsoredVaultLogin
Every lease’s budget has a deadline at most MAX_LEASE away, an expired lease is gone, and its
sponsor can always end it, ahead of admission, however hard the agent floods the servers they
share. A sub-agent is a child budget inside the agent’s, which the kernel ends with its parent
whatever its own deadline (R10 (destruction)); the
steward carves none itself, a lease nested in a lease being a later package’s (leases).
A sponsored principal’s session is a lease of its sponsor’s under the same rule.
R40 (blame by label set)
Status: built · tested: bench:steward-blame-lockout, bench:steward-server-restart, host:redoubt-steward-server::three_blames_in_the_window_lock_the_domain_out_until_the_window_passes, host:redoubt-steward::blame_window_and_not_locked, host:redoubt-steward::a_leases_exit_is_its_end_and_no_blame, mutation:PolicyBlameNoWindow, mutation:PolicyNoLockout
An (account, label set)’s sessions and leases are ended exactly when three server crashes blamed on it fall within ten minutes, no new session of it starts for the next ten minutes, and no other (account, label set)’s sessions are touched.
R41 (narrowing by revocation scope)
Status: built · partly tested: the attack case handing a server a session’s budget, and a lease’s connections ending at its deadline, are STEWARD3A’s with the leases · tested: host:redoubt-rt::a_connection_minted_in_a_scope_ends_when_the_scope_is_destroyed, host:redoubt-rt::a_connection_is_minted_in_the_scope_its_requester_sends, host:redoubt-ipd::a_granted_connection_in_a_scope_ends_when_the_scope_is_destroyed, host:redoubt-ipd::a_grant_is_minted_in_the_revocation_scope_it_brings, host:redoubt-steward-server::a_session_s_end_destroys_its_budget_before_it_releases_its_connections, host:redoubt-init::the_steward_s_entry_alone_is_given_the_manifest_lines, bench:steward-agent-escape, bench:steward-lease-cut
A server that narrows a session’s connection to that session’s life holds a revocation scope made
for it, never a budget holding processes. So a compromised server can revoke what it minted,
never destroy a session. The model checks it (its P12; PolicyServerHoldsSystemBudget): a
connection narrowed to a session’s budget cannot be written, since Connect takes only a scope,
which only a zero-limit CreateScope makes. The attack test hands a server a session’s budget and
expects it refused.
A server narrowing a connection mints it in the scope it is handed: new_connection(root, quota, scope) carries the scope, the server’s mint stamps the handle with it
(R9 (stamps)), the kernel refuses a scope not below the server’s
own stamp, and destroying the scope ends the handle in every table at once, forwarded copies
included (R10 (destruction)). What the server holds for
the connection (fids,
admission, quota) it cannot see die: the steward disconnects each connection of a scope after
destroying it, at its loop’s turn (failure and restart), cleanup that
follows the cutoff and never is it. Every Connect the steward
issues is minted in the session’s or lease’s scope, its own badge and the shared servers’ alike,
ipd’s typed grant carrying the scope in its one optional handle slot as new_connection does
(ipd), so every shared server’s connection a session holds, 9P
through new_connection and the network through ipd’s grant, ends with its scope;
the steward’s connections at those servers are stamped users by init
(init), the budget every scope lies under, so the kernel’s
narrowing passes. The steward holds no copy of a child’s scoped connections once the child has
them, so none can outlive its scope in the steward’s own table, whose freed index the kernel
gives to the next handle installed.
R42 (one approved item)
Status: built · partly tested: the declassification and the push run on the machine; a confined boot’s push is host-tested · tested: bench:steward-declassify, bench:steward-push, host:redoubt-steward-server::a_declassification_reads_through_a_crossing_and_its_approval_copies_the_snapshot_out, host:redoubt-steward-server::a_push_reads_the_source_unlabelled_and_its_approval_writes_through_a_crossing, host:redoubt-crossing::an_item_over_the_bound_is_cut_one_past_declassify_max, mutation:PolicyDeclassifyLive, mutation:PolicyDeclassifyWithoutReader, mutation:PolicyDeclassifyFromUnlabelled, mutation:PolicyDeclassifyUnfit, mutation:PolicyWriteUp
Data crosses a label only as one item per owner-approved request: declassification copies out exactly the snapshot taken at submission, read through a reader budget with exactly the item’s labels; a push writes one item through a writer budget with exactly the target’s labels, triggered only by the target label’s owner. Every other write to an item is by a session with exactly the item’s labels.
R79 (one session per context)
Status: built · tested: bench:steward-context-login, host:redoubt-steward::context_free_holds_one_session_per_name, host:redoubt-steward-server::a_login_to_an_attached_context_takes_it_over, host:redoubt-model::steward_policy
A domain holds at most one live session of a context’s name: a login naming a context whose
session lives carves nothing. While that session starts the login is refused in_use; once it
runs, the login reaches it (R80). The guard context_free keeps
it, reading only the login’s own domain, so a name in another label set or of another principal
is never looked at. The model checks it after every operation (its P17; PolicyContextTwice).
R80 (one channel per context)
Status: built · tested: bench:steward-context-login, bench:steward-session-ends, bench:steward-context-labels, bench:sshd-restart-detaches, host:redoubt-steward::a_context_is_attached_to_one_channel_at_a_time, host:redoubt-steward-server::a_login_to_an_attached_context_takes_it_over, host:redoubt-steward-server::closing_the_channel_detaches_the_context_and_a_login_reattaches, host:redoubt-steward-server::sshd_gone_detaches_every_attached_context, host:redoubt-sshd::a_login_s_two_consoles_and_only_the_steward_s_ends_the_channel, host:redoubt-model::steward_policy
A context’s console is attached to at most one SSH channel, and only to a channel of a login
that passed every authentication guard for that context’s own domain. A takeover lets the old
channel go, told who took it, before the new one is attached, and the old channel’s attachment
id names nothing from then on, so its close cannot detach the new one. Only the steward’s own
connection ends a channel; the relay’s, in the context’s budget, can read and write it but not
end it or keep it past its end (sshd). The relay runs in the context’s
budget, so every write it makes to a channel carries the context’s labels and meets sshd’s
label check (R25) a second time. steward-context-labels attacks that second check: a steward
built with a test-only probe, a broken embedder, also hands a vault context’s relay the console
of an unlabelled login’s channel; sshd refuses the relay’s writes there, so the vault VM’s
output stays in the relay and only the vault’s next login is shown it. The relay’s calls may also
take that channel’s session bucket at sshd, whose console admits two, so the unlabelled channel
may show nothing at all: admission refusing a broken embedder, not a leak. The model checks the attachment rules after every operation
(its P18): PolicyTakeoverBeforeAuth takes over before the key is checked,
PolicyBothAttached attaches the new channel without letting the old one go, and
PolicyStaleCloseDetaches keeps the old id, so the old channel’s close detaches the new one.
R82 (the context cap)
Status: built · tested: bench:steward-context-cap, bench:shell-contexts, host:redoubt-steward::the_cap_refuses_a_new_context_and_keeps_the_oldest, host:redoubt-steward-server::a_login_past_the_cap_is_refused_and_makes_nothing, host:redoubt-init::the_steward_s_sizes_fit_every_principal_s_smallest_share, host:redoubt-model::steward_policy
A principal holds at most its cap of live contexts per label set; a login past it is refused
after the key and before anything is carved, and no context is evicted for it. The guard
under_cap keeps it, counting only the login’s own domain, so one label set’s contexts never
use up another’s cap. init refuses a cap of 0, one above 16 and one the label set’s share
cannot hold, so the cap, not a failed carve, is what refuses
(contexts). The model checks it after every operation (its P19): no domain holds
more live sessions than its cap, and a login is refused cap only at it.
PolicyNoContextCap lets every login past, and PolicyCapAcrossSets counts every session of
the principal’s account, in every label set, so a vault’s contexts refuse an unlabelled login.
R83 (idle contexts end)
Status: built · tested: bench:steward-context-idle, host:redoubt-steward::idle_ends_a_detached_context_only, host:redoubt-model::steward_policy
A detached context ends at its principal’s idle bound; an attached one never does; an attach
clears the clock. The steward’s own timer sends Idle, which only a detached context’s row
acts on, past idle_due (contexts). The model sends Idle at every tick and
checks after it (its P20): a detached context ends only once its bound has passed since its idle
clock started, it has ended once the bound has passed, and an attached one never ends on the
timer. PolicyIdleNoBound ends a detached context at the first tick, however briefly it has
been idle, and PolicyIdleClockKept keeps the clock running across an attach, so a context
detached again ends by its first detach’s clock, before its bound.
Failure and restart
Status: built · partly tested: steward-restart proves the release of a dead steward’s connections by count at bootfsd, erofsd:system, walfsd:data and ipd, and at littlefsd:alice-secrets only by the code they share, since it opens no vault session; a shared server’s restart is re-bound on the machine at walfsd:data, and at the vault’s server and ipd by the code they share and the host test · tested: bench:steward-restart, bench:steward-restart-ssh, bench:steward-restart-reboot, bench:steward-session-ends, bench:steward-server-restart, host:redoubt-steward-server::users_not_empty_is_a_start_failure_before_any_carve, host:redoubt-steward-server::watch_is_held_only_from_sshd_and_malformed_on_any_other_badge, host:redoubt-steward-server::a_servers_restart_tells_its_sessions_and_a_session_rebinds_its_slot, host:redoubt-steward-server::every_operation_on_another_badge_class_is_malformed, host:redoubt-init::a_restart_strands_the_stewards_connections_at_the_endpoints_the_server_receives_on, host:redoubt-init::the_outcome_of_the_hand_over_is_said_in_blames_words, bench:steward-blame-lockout, host:redoubt-steward-server::a_session_s_end_is_answered_before_any_cleanup_call, host:redoubt-steward-server::an_ended_session_s_pending_request_is_withdrawn_before_the_end_s_reply, host:redoubt-steward-server::a_cleanup_step_that_times_out_is_retried_then_parked, host:redoubt-steward-server::a_timed_out_step_does_not_hold_the_steps_behind_it, host:redoubt-steward-server::a_cleanup_step_the_server_refuses_or_cannot_take_is_done_at_once, host:redoubt-steward-server::the_cleanup_queue_holds_each_released_connection_once, host:redoubt-steward-server::the_loops_turn_makes_no_call_to_a_server_that_never_answers, host:redoubt-steward-server::a_step_that_never_completes_holds_only_itself, host:redoubt-steward-server::a_step_that_completes_after_the_loop_moved_on_is_settled_then, host:redoubt-steward-server::reconnected_forgets_a_servers_due_steps_too, host:redoubt-steward-server::a_console_line_is_one_write_on_the_kept_fid, host:redoubt-steward-server::a_console_line_the_console_did_not_take_costs_one_bound_and_keeps_the_order, host:redoubt-steward-server::the_console_fid_is_re_opened_after_a_failed_write_and_the_lines_dropped_are_said, host:redoubt-steward-server::the_exit_s_last_turn_writes_the_lines_left_within_one_bound, host:redoubt-steward-server::a_parked_step_runs_at_the_servers_next_answered_call, host:redoubt-steward-server::a_mint_at_a_server_with_parked_steps_wakes_them_and_the_login_after_succeeds, host:redoubt-steward-server::reconnected_forgets_a_servers_parked_steps, host:redoubt-steward-server::a_never_answering_server_costs_no_bound_once_its_steps_are_parked, host:redoubt-steward-server::a_scopes_index_recycled_after_its_leases_deadline_is_not_destroyed_as_it, host:redoubt-steward-server::a_scopes_index_recycled_after_its_budgets_destroy_is_not_destroyed_as_it, bench:steward-latency, bench:steward-deadline-burst, bench:steward-deadline-burst-16, bench:steward-launch-overlap, host:redoubt-steward-server::the_destroy_pair_runs_under_the_install_lock_and_nothing_installs_under_it, host:redoubt-steward-server::a_bursts_notices_all_go_before_any_exits_bookkeeping
- The steward is part of the trusted base; its crash is a bug. If it dies,
initreapsusers, which logs every session out and ends every lease, and restarts the steward; the console session starts again (init). The steward says whatusersholds as it starts (users holds N pages, M processes), and checks it: ausersholding any pages, process or child is a start failure, said once (users not empty), never a second set of carves beside the first. A steward that dies the same way at every start ends ininit’s reboot. - A session crashes: the steward destroys its budget and tells its console
ended;sshdcloses its channel; nobody else is affected. - A budget the kernel’s deadline took (a lease’s, a crossing’s) is destroyed once, by the
kernel, which revoked the steward’s handle to it and freed the index. The deadline the steward
minted is its witness: at or past it the destruction is the kernel’s, and the steward’s own
DestroyBudgetfor the budget, at the exit notice, at anend_leaseafter the deadline or in a forget, makes no call by that handle, since the index may name a newer object by then; ahead of it the handle still names the budget, and the destroy is the steward’s (leases). The rule holds at every destroy the steward makes by a handle it keeps, not only a lease’s and a crossing’s: a revocation scope, minted with no deadline of its own inside its object’s budget, ends with that budget, at the budget’s deadline or at the steward’s own destroy of it, so a scope’s destroy takes its parent’s deadline as its witness and makes no call once the parent’s record is gone. The core asks for no scope’s destroy today; the guard is there for the day it does. The connections it gives back are the cleanup thread’s steps as for any end. - A reader or writer budget outlives nothing: it has a deadline,
CROSSING_LIFE, and the steward destroys it when its one item is done, or when its program fails; its connection is given back after (declassification and push). - A shared server restarts. Its tables are empty (R27),
so every connection it served is gone: the steward’s kept connection, through which it mints
each session’s home, and every live session’s. One path for every shared server puts them
back.
init, having restarted the server and offered the fault’s blame, makes the steward a new fresh connection there as it did at the steward’s start, once the new instance serves, and hands it over withreconnected(server, connection), on the root badge it alone holds (init); the steward reaches the server by it from here, and forgets what it held at the old instance (its attached connection, the carves it kept, every session’s connection there: ids the new instance never drew, so nothing is disconnected and the records go). Then it sendsserver_restarted(server, slot)on thenoticesendpoint of every live session whose binding table has a slot at that server, one send per slot. A session re-binds:rebind(slot)from its badge is the slot’s connection again, minted as its login batch minted it (through the new kept connection for a home, in the session’s own scope), answered with the connection and the path the session binds it at; the new connection takes the dead one’s place in the steward’s records, so the session’s end releases it as it would have (R41’s order), and the shell binds it there in place of the dead one and prints one line (the shell). A login after the restart is minted below the new connection and gets its home. The blame counts are kept across all of this, so a crasher’s lockout comes at the third blame (crash blame).rebindis the server’s, no event of the core’s: it grants nothing the login did not; a slot bound to nothing at a path for the caller (the vault’s for an unlabelled session, the console’s, the system volume’s named handle) isunknown, and a mint the server refusesfailed. What the path does not reach: a lease’s VM has nonoticesendpoint and no shell, so an agent’s/projectstays dead until its deadline or its sponsor’send_lease(arebindfrom a lease’s badge is answeredunknown, as a foreign slot’s is: an agent cannot re-bind what its sponsor was not told of), and the sponsor’s way out isend_leaseand a newstart_agent(the sponsor’s own sessions are re-bound; anoticesendpoint for leases is a later node, when agents must outlive a server’s restart); the system volume is a named handle beamlet loads code through, which no bind replaces, so anerofsd:systemrestart leaves every session as good as dead until it ends; a login that reaches the steward between the server’s restart andreconnectedfails its home step and is refused, and the person retries; areconnectedthe steward does not take within the second leaves it stranded at that server until its own restart (init); and a restartedconsoledisinit’s residual (init). - An end answers at the cutoff; its servers are told by the steward’s cleanup thread, never
by its loop. A session’s or lease’s end batch is answered once
DestroyBudgethas run: the scope’s end is the cutoff (R41), and what the end still owes its servers only frees their records. Each connection it gives back is a step in the steward’s cleanup queue (servers/steward/src/cleanup.rs): adisconnectby its id at the server that minted it, or anendedonsshd’s channel console. The queue is shared with one thread of the steward’s, started with it, under a lock neither holds across a call. The thread takes the steps due in order and makes each call within the client library’s own call bound (a second, what a launcher’s release of a dead child’s grants gets: it holds nobody up, so a step may wait what a call waits), settles each as the queue says and reports its fate to the loop as a message on the loop’s own endpoint. The loop only hands a step over (at an end’s release, arebind‘s replacement, a detach’s console), wakes a server’s parked steps or forgets a restarted server’s, and says a parked step’s line through its console queue as the fates arrive, so the lines keep the steps’ order; a step done, or failed because the server refused the call or is dead, says nothing. The loop’s top is the idle timer’s ends, the console lines queued, the receive: it makes no call to a shared server, and a server that never answers costs it nothing. The hand-over is no send, since a send waits for its receiver (IPC) and the thread may be inside a server’s bound: the thread’s idle state is a call on the loop’s endpoint, which the loop holds and answers when a step is due or handed over; the loop is the one arbiter of the queue, so a hand-over either precedes its take of that call, which then finds the step due and is answered at once, or follows it, and answers it, and no wake-up is lost. Two threads call on one kept connection: adisconnectof the thread’s and anew_connectionof the loop’s may be in flight at once, each a kernel call with its own words and reply, and the client’s connection holds the endpoint, a tag counter and a timeout, no buffer and no fid, so neither disturbs the other; thedisconnectcarries its id in its words and lends nothing, so the thread has no lend page. A step the server did not take in time is retried no sooner than a second later, up to four attempts, which span at least 3.4 s: pastinit’s 3 s bound on a restarted server’s first answer (init), so a server that has not served by then is lost toinittoo, and its restart frees what the step would have. The fourth failure parks the step with a console line (steward: disconnect 12 at walfsd:data parked after 4 attempts: retried when the server answers), since the count tells a lost server from a busy one only for the lost: a busy server answers other callers,initsees it alive and nothing restarts it, so a step dropped there would leave the server’s admission for the connection taken until a restart that never comes (seen under two agents reading the system volume: a lease’sdisconnectaterofsd:systemdropped, and the next lease’s connection there refused on the share’s eighthState). A parked step has no timer and costs nobody anything while its server does not answer; it runs again once a call of the steward’s to that server completed within its bound: a step there done (the thread wakes the server’s parked steps and takes them next), or a mint there taken or refused. A mint the steward makes at a server with parked steps needs the record a step frees: the loop wakes them before the mint and the thread runs them meanwhile, but the mint waits for nothing: refused all the same, it fails its step and the caller is told, as a mint the server does not take in time is (residual risks): nothing retries inside the steward, and the caller’s next attempt finds the record freed. The loop parks no call and no window of its own waits on the thread, so no wake of the timer’s lands behind one.reconnectedfor a server forgets its steps, parked and due alike, with its records, and the handles the thread may still have a call out on are closed after its next report. The residual is narrower: a step a server did not take in four attempts waits for that server’s next answer or its restart. Any other outcome ends a step at once: anendedto a channelsshdalready closed is taken or failsDead, never retried; a connection at a server that restarted since has no record left (reconnectedforgot it) and nothing to tell. The queue is bounded by the connections live sessions and leases hold: a connection enters once, when its owner’s end releases it or arebindreplaces it (one step per call), at most the slots plus four per session (its badge, a connection per slot, its relay’s control connection, the attached channel console and its notices send right) and the slots plus one per lease, and leaves done; a parked step is a queued step that has not left, and the step whose call is out is one taken and not yet settled, so the bound covers both unchanged. The bound is argued, not enforced: the queue has no cap; it drains whenever its servers answer, and a session re-binding at a server that is not taking its steps queues nothing more, since the mint there fails too. What the thread costs: a stack of one page (twice its measured peak, 1,432 bytes on rv64 and 1,168 on rv32,stack steward thread 1insteward-session-ends; twice 1,432 is 2,864), its thread page and the lock’s endpoint page, three pages the steward’s entry grew by in the image’s manifest, 1,024 to 1,027 (the memory budget). Before this, the loop ran the steps at its top within its own 100 ms send bound, stopping at the first the server did not take: one attempt at a busyerofsd:systemlanding in a sample’s window ofsteward-latencywas a 100 ms wait before the receive, so the timer wake’s p99 (50 ms) was missed by construction, one sample per round at N = 3 and 4 at one hart on both widths and at N = 3 at two on rv64 (responsiveness). Before that, eachdisconnectandendedran inside the end batch with the call’s full second, during which the steward served nobody: measured onsteward-server-restart, theendedwaited its second whensshdhad closed the channel first (it was calling the steward itself then) or was busy, adisconnectat a recovering server waited its second too, and a blame whose lockout ended an SSH session was answered after it and reportedlostbyinitthough the steward took it. Theendeda client’s close brought was lost the same way, every time:sshd’s thread was inside its ownchannel_closedcall, so the channel only ended when something else ended the session; now it ends as the detach is cleaned up, as the rule above says, andsteward-pending-capends its session before its channel closes rather than a second after.steward-blame-lockoutandsteward-server-restartnow forbidlostfor a blame on both widths. Already here: a relay the destroy took is told nothing, since a call to it would only wait out its bound. - The console lines go at the turn too, one write each. Every line the steward says (an
audit record, a usage line, a lease’s exit, a step’s drop, a call’s time, the probe’s samples)
is queued (
servers/steward/src/console.rs) and written through one fid it keeps open atconsoledfor its life, opened once on the connectioninitgave it, with one lent page kept for it: a line is one write within the same send bound (100 ms). Before this, each line was a fresh one-page lend and an attach, open, write and close atconsoled, so astart_agent’s two lines (its record and the usage line) cost about eight calls and two lend maps after the reply and held the loop 34-56 ms before its next receive at one hart, 94-162 ms at two (steward-latency’sreply_wait). The queue drains in the order the lines were said, at two points: before a call’s reply, so a call’s records come before anything its caller says of the answer (the order the bench’s cases match on:sshd‘sloginline after the steward’sLoginrecord), and at the loop’s top for every other event’s lines, a parked step’s among them, said when its fate arrives. Either drain stops at the first line the console did not take in time, the rest queued for the next, so a busyconsoledcosts the loop, or a caller, one bound per turn, the one bound a turn pays. A line the console did not take is tried again a second later, four attempts as a cleanup step’s, then dropped; a write it refused, or a dead console, drops every line queued, and the fid is opened again at a later turn; a dropped line is counted, and the first line written once the console answers says the count (steward: 3 console lines dropped: the console did not take them): no line vanishes silently, though a write the console took but did not answer within the bound is written again, so a line may appear twice. A restartedconsoledknows none of its clients’ connections andinithands a running server no new one (init), so after one the steward’s writes are refused, its lines dropped and counted, until the steward itself restarts. The queue’s bound is argued, not enforced, as the cleanup queue’s: an event says at most two lines (its record and the usage line of the one domain it changed), a call’s time one, a step’s drop one, and a turn raises one event and the steps due. - A burst of exits tells the sponsor one send at a time, every notice ahead of the exits’
bookkeeping. Leases whose deadlines fall together hand the loop their exits together: the
loop takes every exit report already waiting with the first, with no wait, decides each exit in
order and sends its
lease_endedto the sponsor’s session as it is decided, one send within the same 100 ms bound (leases), and only then runs each exit’s effects in order (its end batch and theDoneit raises, its forgets, its records, its console lines at the loop’s next turn); any other event the take finds waits for the next turn in its order, and an exit of an object whose batch waits at its launch is held as a single one is. Measured bysteward-deadline-burst(six leases on one deadline, both widths) andsteward-deadline-burst-16(sixteen, rv64), each lease’s notice being thelease_endedsend’s return less that lease’s own deadline: the first 0.43 to 0.96 s after its deadline (32 to 149 ms net of the checked build’s audits), the kernel destroying every budget the deadline took at its one entry before any exit notice is delivered, N destructions and their frames, each within R10 (responsiveness), and the loop then taking the N reports; then each send returning 13 to 77 ms after the previous, the core’s decision of one exit, so the worst of six is told 0.91 to 1.12 s after its deadline (0.13 to 0.16 s net) and the worst of sixteen 1.42 to 1.47 s (0.33 to 0.43 s net), every lease’s[lease ended]line printed, at one and two harts; the cases assert twice the worst rounded up, 3 s at both sizes, and every line. The bookkeeping follows the sends: the exits’ effects 17 to 297 ms for the whole burst, then its console lines 0.17 to 0.78 s at the loop’s next turn (residual risks). With the bookkeeping between the sends they returned 40 to 145 ms apart, the loop’s work for one exit (its event and effects, its record and its console lines: 18 ms of the steward’s own time in a 41 ms gap on a traced boot, the checked kernel’s audits the rest), the worst of six 1.13 to 1.43 s and of sixteen 1.8 to 2.5 s, bounded at 3 and 6 s. The send itself no longer waits: beamlet’s serve thread once forwarded each notice by a send that waited for the session’s VM to take it, which a VM busy in Erlang did once a scheduler turn, so the sends returned 40 to 300 ms apart, the last of six 1.5 to 1.8 s and of sixteen 2.1 to 2.8 s after the deadline, and one of six or of sixteen was lost at the bound on rv64 at one hart (the session printed five lines of six); under that gate neither the steward’s ordering (built then, measured at 16 ms of the 1.4 s and withdrawn, kept now that the gate is gone) nor the shell’s listener (one that only received and queued, printing in another process, built and measured) moved it, and a block trace placed it: the serve thread blocked in its forward while the VM ran. The thread now queues each notice and returns to its receive (beamlet). What remains on the sponsor’s side is the shell: its listener prints each notice inline through the relay, 250 to 370 ms a line (measured with the sends 37 to 131 ms apart: the last line of six drawn 2.3 to 2.6 s after the deadline and of sixteen 6.1 to 6.6 s), the print’s pace and not the steward’s setting them (residual risks). - A launch runs on the launch thread, under the install lock. The second thread the
steward starts (leases) installs handles of its own: the child’s process at
process_createand the fresh pages of each batch of the image before they are moved into the child, between its reads of the image at the system volume. The deadline witness above rests on no handle being installed between the loop’s clock read and its destroy, so one lock, the install lock, is held by the loop across that pair (two kernel calls, microseconds) and by the thread across the whole of a launch’sstartexcept inside each read of the image, which drops it on entry and takes it again on return: every install of the thread’s is under the lock, no install lands between the pair, and the lock is never held across a call to a server (the cleanup thread’s rule). The loop’s worst wait for it is one stretch of the thread’s between two reads, a batch’s pages made and moved in orprocess_start, kernel work: measured onsteward-launch-overlap, 33 µs on rv64 at one hart, 50 µs on rv32 and 5.2 ms on rv64 at two harts (the thread’s install on the other hart), said by the probe as it grows: the loop’s cost of the lock, beside the 50 ms timer-wake p99 the page’s targets hold it to. Defence in depth, beside the lock: the thread creates no budget, so the silent form of a wrong destroy (a newer budget at a recycled index) is unreachable from its installs whatever the lock does; the lock removes the loud form. The launch thread’s hand-over is the cleanup thread’s: it idles in a call on the loop’s endpoint, which the loop answers when a job is queued, the queue under a lock held for one operation at a time, and it reports with a send; it reads the image through the loop’s attached connection atbootfsd, which several threads call on at once, with a lend of its own, the loop’s size, so a page batch costs the four reads it cost the loop. What the thread costs: a stack of three pages (twice its measured peak, 4,840 bytes on rv64 and 3,808 on rv32,stack steward thread 2insteward-session-ends; twice 4,840 is 9,680), its thread page, its sixteen-page lend, the two locks’ endpoint pages, one page for a notice the loop sends while the thread holds a batch (before, no notice went during a launch), and one more page of the loop’s own stack, whose peak grew from 17,560 to 18,536 bytes (18,616 in a later run; twice it, 37,232, is ten pages where nine held 17,560) though the launch left it: the growth is the late answer’s path, the report’s arm answering a parked call (the kept request, the call decoded again, the typed reply and its outcome, frames the loop did not have), not the batch kept while the launch runs (boxing it left the peak at 18,472): twenty-four pages the steward’s entry grew by in the image’s manifest, 1,027 to 1,051 (the memory budget). A launch that overlaps another lease’s exit now shares the harts with the exit’s work instead of delaying it: onsteward-launch-overlapthe longestserved start_agentgrew from 2.03 to 2.52 s (rv64, one hart), 2.63 to 2.91 s (two) and 2.58 to 3.08 s (rv32), the work moved, not added.
What a dead steward held at the shared servers goes with it. init hands the steward a
fresh connection at each server it is handed (bootfsd, the volumes’ file servers, ipd), not
the server’s badge, and disconnects it at the steward’s exit
(init). Every connection the steward minted for a
session’s namespace hangs under one of these, so the disconnect frees them all, at every depth
(releasing grants): a steward that dies over
and over leaves no session’s connections holding the servers’ admission. Revoking the handles
alone would not do it: a server tracks no exits and keeps a connection whose holders are gone
until it is disconnected.
The steward learns of sshd’s end the same way: the held watch call is abandoned, and the
steward detaches every attached context (contexts); each runs on, detached, until a
login reattaches it.
sshd learns of the steward’s end from the kernel. It keeps one watch call at the steward,
accepted on its root badge only and held, unanswered, for as long as the steward runs; a dying
steward fails it (Dead, R4b (a server dies)) and one
that exits answers it, so either way sshd ends the channels of every session the steward ran
(sshd). The steward holds two at most: a restarted sshd’s, and
the last one’s until the kernel says its caller is gone.
The restart probe is a test-only feature, restart-probe (src/bin/steward.rs,
PROBE_EXIT), off in every default build; the bench’s steward-restart builds the steward with
it. The latency probe, latency-probe, is the second such feature, built by steward-latency
and its two companions, steward-latency-n4 and steward-latency-n16 (the rounds at N = 4 and
N = 16, each on a trace ring of its own), by steward-deadline-burst and steward-deadline-burst-16
(leases expiring together), and nothing else (the bench’s host test
only_steward_latency_builds_the_latency_probe holds it to those five):
the steward prints a probe lease’s start as a reply_wait sample (its answer to its next
receive: its work after the reply, the call’s records having gone before it), arms two timeouts there and ends that lease by its own
EndLease at the second, prints its wake, deadline-notice (the sponsor’s lease_ended send
returned, less the deadline) and budget_destroy samples for the
bench’s oracle, then LATENCY-END with its clock after the third round’s counts (the window’s
end: the oracle judges no destruction ending after it and no sample printed after it), and exits
at its start when the case’s marker file is in the console principal’s home, so init’s reboot
rule dumps the kernel’s trace (responsiveness).
It raises only events the core has, touches no row, guard or effect, and prints nothing but its
samples. Every instance then serves as usual and exits 14 seconds after it starts the console session,
so the case restarts it thirteen times, each time with that session and its connections live, without meeting
init’s reboot rule.
Residual risks
-
Under the bench’s virtual clock a live lease ends at its deadline as soon as the guest idles. With sixteen leases of one account ending by their VMs’ own exits (bench:lease-code-load, both widths at one and two harts), the guest first idles with no timer armed nearer than the oldest live lease’s deadline, and an idle guest’s clock skips to it (the test bench): that lease is destroyed at its deadline and reported
was killed 0, R39 kept, the kernel’s walk shown bounded to the destroyed budget (its chain holding exactly its own VM’s process). The case runs on the host’s clock, where every boot ends all sixteen; a steward case under the virtual clock whose guest can idle while a lease is live expects the lease’s end. -
A login’s refusals are uniform in content, not in time. An unknown principal or label set is refused before any machine runs; a wrong key for a known principal runs the blame and session machines before
login_keyrefuses it, and nothing is counted. The difference is inside one steward call, behind SSH’s key exchange and signature check. Measured at the client bysteward-login-timingon rv64 under QEMU, thirty refusals interleaved, the medians are 0.104 s for an unknown principal, 0.105 s for a known one with a wrong key and 0.104 s for an unknown label set, against an interquartile range of 0.103 to 0.105 s over all thirty: the difference is below the jitter, so nothing equalizes it. A faster client link, or many more samples, could still show it (contexts). -
An approved text can carry a hidden message. Text an agent wrote and a person approved for declassification can still hide one; no rule on the item’s form prevents that.
-
A push is one human action, so a confined domain’s input rate is a person’s approval rate.
-
approve@boxsharessshdwith every channel. Asunsetbug reached from any channel controls the approval screen, and a network flood delays approvals. Its ownsshdinstance or the console is sshd’s to give. -
The core’s
keyd-key guard is vacuous on the box:init’s andsshd’sholdschecks are the live ones.initwriteskeyd []in the manifest lines, since it holdskeyd’s seeds, not their public keys; it refuses the boot ifkeydholds any login or approval key, andsshdrefuses a keykeydholds before a login reaches the steward. The guard stays, exercised by the model and the traces. -
Steward work is paid by the steward. A principal’s requests cost the steward’s budget and time, bounded by its caps and per-request work, not by the requester’s budget. Measured by its
servedlines (a call’s arrival to its answer) on the bench’s checked builds: at most 100 ms persubmit(a declassification’s read crossing or a push’s read of the source included; rv64, 95 ms on rv32,steward-declassifyandsteward-push) and 589 ms perend_leaseof a lease carrying a beamlet VM (rv64, one hart, undericount:steward-latency), the VM budget’s destruction being most of it (responsiveness); and perstart_agent, the batch that carves the lease, mints its connections and reads its program from the verified system volume, 1.70 s alone and 3.05 s beside three of the sponsor’s leases spinning on rv64 at one hart (2.67 and 3.62 s at two; beside fifteen 3.13 and 3.72 s), 2.28 and 5.85 s on rv32 at one hart, 3.86 s alone and 5.15 s beside two at two (steward-latency’s rounds, the worst of each group, one run at the seed). The launch is the one call measured past the client library’s 5 s bound for a short call, so its caller passes a bound of its own, derived: twice the worst launch measured over both runs, 6.64 s on rv32 at two harts, the N = 16 round’s launch beside fourteen spinners (5.851 s at one hart; the table on responsiveness), 2 × 6.64 s = 13.28 s, rounded up to 14 s (Redoubt.Wire.Client.launch_timeout/0; the short bound stays for every other call); a launch past it istimeoutto the caller and a scheduler finding on the responsiveness page, never a longer bound. Eleven bench cases and the escape probe (agents/escape.ex) callstart_agentat the 5 s default, each a launch alone or beside few: alone on rv32 at two harts a launch measures 3.84–3.86 s, so atimeoutthere is that default’s, covered by the per-message default wire names as the follow-up. The steward’s own cost is the batch’s calls at the shared servers, each withinSERVER_USbelow; the rest of the seconds is the launching VM’s share of the harts beside the spinners. Aserved start_agentis the call’s arrival to its answer, so since the launches run on the launch thread it includes a launch’s wait in the thread’s queue behind an earlier one (leases); with the launches on the thread the worst served in these rounds is 4.82 s (rv64 at one hart, the N = 16 round beside fifteen spinners; 3.74 s at two harts, 3.53 s on rv32 at one in its rounds to N = 3), under the 6.64 s the bound derives from, so the bound stands. A launching lease runs at theagentweight it keeps for its life, never above its sponsor’s running leases, since a launch weight would let a flood of launches take CPU from running agents: the launch’s measured cost and the bound derived from it stand. -
A burst of expiring leases is told at the steward’s pace and shown at the shell’s. N leases taken by one deadline cost the last
lease_endedthe kernel’s N destructions at the deadline’s entry (0.43 to 0.96 s to the first notice, 32 to 149 ms net of the checked build’s audits, each within R10) and then the core’s decision of each exit, 13 to 77 ms a send, every notice going before any exit’s bookkeeping: the worst of six told 0.91 to 1.12 s after its deadline, of sixteen 1.42 to 1.47 s, bounded at 3 s, the send itself taken at once by beamlet’s serve thread (failure and restart). The bookkeeping the notices no longer wait for still holds the loop after them: the exits’ effects 17 to 297 ms for the burst and its console lines 0.17 to 0.78 s at the loop’s next turn, which a call or event arriving among the burst waits behind; and the first notice of sixteen waits the loop’s take of the sixteen reports, about 0.1 s more than one exit’s. A notice is lost only if the session’s VM is gone, or its forwards queue is full, 128 entries against the sixteen leases, the binding slots and the approvals one steward can tell a session of (beamlet). The sponsor sees the shell print them, one every 250 to 370 ms through the relay (measured with the sends 37 to 131 ms apart: the last of six 2.3 to 2.6 s after the deadline and of sixteen 6.1 to 6.6 s): the shell’s print, which took nothing from the steward’s side while the serve thread was the gate and is the visible term now that it is not. The per-notice target, 40 ms, is met one lease at a time (responsiveness); the image’s own burst is at most three to five leases, alice’s unlabelled share beside her sessions. -
A context’s relay holds the steward’s one thread for a bounded time. The steward waits up to 2 s for a relay’s
helloat each SSH login that starts a context, and up to 1 s for eachattachanddetach, so a relay that never answers delays every other principal’s call by that much, outside R26 (admission fairness), which orders calls but cannot shorten one in service. The relay runs in the context’s budget and shares the VM’s CPU, so a VM that keeps its harts busy slows its own relay’s answers toward those bounds. -
A call to a shared server inside a batch holds the loop at most
SERVER_US, 4 s. Every connection the steward makes to a shared server is made within that bound: its attached ones, the ones it mints through, the carves it keeps and its own file connections at them,ipd’sgrant, the console session’s mint atconsoled. A call the server did not take in time fails its step; the batch’s own failure path answers the caller (failedtostart_agentandsubmit, a refused login), the core rolls the batch back, and nothing inside the steward retries: the sponsor or the person does. The bound is derived from the box serving its load, every caller alive: twice the worst single call measured understeward-latencyin its completed rounds on both widths, 1.66 s, a lease’s/projectcarved below the home atwalfsdon rv32 at one hart in the round with three spinning agents (rv32 at two harts 1.50 s, rv64 at two 1.06 s and at one 0.64 s; the worst operation astatof three calls, 1.62 s): 2 × 1.66 s = 3.32 s, rounded up to 4 s, under every caller’s own bound (the client library’s 5 s for a short call and 14 s forstart_agent,sshd’s 30 s login), so a step the bound fails is told to a caller still waiting. It is never reached on a quiet box, and a mint’sTimeoutwakes no step parked at that server (a timeout is no answer: failure and restart). The residual: a hart saturated by lease VMs of the sponsor’s weight puts a shared server’s call past the bound (on rv32, with three spinning agents left running after the case’s own loop had died, astatatwalfsdtook 4 to 10 s); the step fails and the caller is told, and the steward’s own probe poll, no batch, only polls again. A lease batch’s mints (itsConnectsteps,new_connectionat the shared servers) still run on the loop within this bound, 1.66 s worst measured, so a deadline that fires inside a mint waits for it; the launch, the batch’s seconds, runs off the loop (leases), and the install lock would let a whole batch follow it (a budget installed under the same lock) if a measurement showed the mints bite. -
Blame follows the current call. A request that corrupts a server which crashes later, while serving someone else, blames the wrong account, and one that crashes an idle thread later blames nobody; the consequence is a logout or a restart, not data loss.
-
A shared server’s own crash shows whose call it was serving. A server that serves a vault and unlabelled sessions, and crashes on its own, holds whichever call it took last. If a vault’s calls were queued ahead, the vault’s call is in service. The vault is blamed (R21 (crash blame)), and the unlabelled call waits for the restarted server (R4b (a server dies)). Without them, the unlabelled caller is blamed and gets
Dead, and three such crashes end its sessions (R40).This is service timing from one server instance serving two label sets. No fair turn order hides it, and blaming nobody would not either, since the
Deadresult alone shows it. A confined deployment has no such server (R34 (confined placement)). A crash a vault’s call causes is the same timing: it moves when the server takes the unlabelled calls queued before it. R37 holds for crashes an unlabelled session’s call causes, which is what P10 checks. -
Per-record signatures catch edits, not drops. Until records are chained, a record dropped or reordered wholesale is not detected.
-
The mediators are trusted across labels. The steward and
sshdare the confinement check’s one named exception and see several label sets; a bug in either reaches all of them. -
The reference shares the core’s reading. Its guards and effects were written by hand from the page and the tables, but follow the core’s in order and shape. A misreading of the page shared by the core and the reference passes both; the broken-guard run shows that the harness catches a difference, not that the two readings are independent.
-
Row coverage is the reference’s claim. The core takes rows with no hook to report them, so a row counts as taken when the reference says it took it. Two rows whose effects and next state give identical output could not be told apart.
-
The model checks the core, not the embedder. The model runs the core that ships, but with its own embedder. It leaves out SSH, the approval terminal and real randomness, and the server’s embedder (transport, admission, running batches) is shown only by the server’s own cases.
Why
- Policy in one server. People, agents, approvals and blame change more often than kernel mechanism; keeping them out of the kernel keeps it small, and keeping them in one server keeps them in one reviewable, modelled place.
- One pure machine, keyed by domain. The steward parses the most hostile input in the system and stands across every label set, so the classes of bug that matter are a step the policy forgot and a function that saw two domains. A pure core with exhaustive tables leaves no event without a row, and a store keyed by domain lets only R34’s three edges see two. Sharing the core with the model means the properties are checked on the code that ships.
- Agents are principals, not impersonations. Every action is attributable, and a sponsor answers for its agents without the agent borrowing its identity.
- Fixed sub-budgets. Anything a vault session changes that its owner’s unlabelled side can observe is a channel; fixed sub-budgets remove the shared free limit.
- Approvals out of band. A request that could reach the approval screen through the channel the requester controls could approve itself; the approval key and terminal are the person’s.
- Short-lived readers and writers. A standing labelled reader in the steward would make the steward a labelled process, or a universal one; a budget per item, with exactly its labels and a deadline, gives each crossing its own audited authority.
- Blame destroys, not logs out. A logout the principal can undo at once, or that leaves its agents running, bounds nothing; three crashes then a lockout bounds how often one principal can restart a shared server.
keyd
keyd holds every private key on the box. It signs; it never exports. Each badge on its endpoint
names one key and one purpose, and a purpose is the one message shape that badge may have signed,
built by keyd itself: an SSH key exchange for the host key, an audit record for the steward’s
audit key. No caller chooses the bytes keyd signs, and no key it holds authenticates a person
to the box.
Purpose
A key that leaks cannot be revoked; authority can. So private keys live in one small process,
separate from the steward and from sshd, which parse hostile input, and what leaves it is a
signature over a message shape keyd built, never the key and never a signature over bytes a
caller chose. A hijacked holder of a badge can then do what the badge’s purpose is for, and
nothing more: it is not a signature oracle.
Interface
Keys and purposes
Status: built · tested (9)
- bench:r4-host-tests
- host:redoubt-keyd::arguments_become_keys_with_root_badges_in_order
- host:redoubt-keyd::hostile_arguments_are_refused
- host:redoubt-keyd::bad_key_arguments_stop_keyd_starting
- host:redoubt-keyd::an_all_zero_seed_is_refused_rather_than_panicking
- host:redoubt-keyd::two_keys_may_not_share_a_name_or_a_public_key
- host:redoubt-keyd::there_is_a_bound_on_how_many_keys_there_are
- host:redoubt-keyd::signatures_are_rfc_8032_ed25519
- host:redoubt-keyd::hex_decoding_matches_the_obvious_decoder
keyd reads its keys from its startup block’s arguments, one key per argument
(init), in the form name,purpose,seed:
namefollows the manifest’s name rule;,is outside that rule, so no field can swallow the next.purposeisssh_host(the box’s SSH host key:sign_ssh_exchangeonly) oraudit(the steward’s audit key:sign_recordonly). There is no purpose that signs a caller’s bytes as they came, and none for a key that authenticates a person.seedis exactly 64 lower-case hex digits, the 32-byte Ed25519 seed. An all-zero seed is refused (the signing crate would panic on it).
Every key is Ed25519 (RFC 8032), the one signature scheme on the box, so no message names an
algorithm. At most MAX_KEYS (16) keys; two keys may not share a name or a public key. Any bad
argument stops keyd starting: a key it cannot read is one it cannot sign with, and serving
without it would fail silently. There is no operation to add, replace or remove a key, so keyd
holds exactly what its startup block gave it for as long as it runs.
Badges. The key in position i of the arguments has root badge i + 1, so a restarted keyd
gives each root badge the same meaning without keeping anything. Badges at or above
FIRST_MINTED_BADGE are grants (below); any other badge names nothing.
Messages
Status: built · tested (13)
- bench:r4-host-tests
- host:redoubt-keyd::a_signature_round_trips_over_the_real_ipc_path
- host:redoubt-keyd::the_host_key_comes_from_keyd
- host:redoubt-keyd::public_key_and_holds_answer_for_the_key_the_badge_names
- host:redoubt-keyd::holds_answers_only_about_keys_that_are_here
- host:redoubt-keyd::a_badge_signs_only_its_own_key_and_only_its_own_purpose
- host:redoubt-keyd::the_preimage_is_the_rfc_4253_transcript
- host:redoubt-keyd::the_hash_matches_an_independent_implementation
- host:redoubt-keyd::parts_cannot_be_slid_into_each_other
- host:redoubt-keyd::a_transcript_no_exchange_could_make_is_refused
- host:redoubt-keyd::a_relayed_ssh_user_auth_blob_is_never_what_gets_signed
- host:redoubt-sha256::fips_180_4_vectors
- host:redoubt-sha256::chunking_never_changes_the_digest
keyd serves a typed protocol, not 9P: a file that answers Tread is the export it must not
have, and it has nothing to name. Every request first resolves the caller’s badge to one key and
purpose and applies the label check to the key’s labels; only then does any work happen.
sign_ssh_exchange(v_c, v_s, i_c, i_s, q_c, q_s, k),ssh_hostonly.keydbuilds the SSH exchange hash (RFC 4253 section 8,curve25519-sha256, RFC 8731) itself from the transcript’s parts, with the host key blob taken from its own key, never from the request, and signs that hash. The hash is also the session identifier. A transcript no key exchange could produce ismalformed.sign_record(record),auditonly: a signature overSHA-256("redoubt.audit.v1\0" || u64_le(len) || record), whichkeydcomputes itself. The steward’s audit log uses it (steward).public_key: the badge’s key’s public half.holds(key): whether any keykeydholds has this public key.sshdasks it before accepting a login key;initasks it before trusting the manifest (R35 (key separation)). It answers about every key, not only the badge’s: public keys are published, and the asker already holds the one it asks about.grantandrelease(id): below.
keyd’s SHA-256 (libs/sha256, shared with init and the steward) is the box’s own, one
function with no dependencies, because the maintained crate brings six more into the process
holding every private key.
The table: libs/wire/tables/keyd.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 1 | sign_ssh_exchange | v_c: bytes, v_s: bytes, i_c: bytes, i_s: bytes, q_c: bytes, q_s: bytes, k: bytes | signature: bytes |
| 2 | sign_record | record: bytes | signature: bytes |
| 3 | public_key | - | key: bytes |
| 4 | holds | key: bytes | held: u32 |
| 5 | grant | - | id: u64, capability: handle[0] endpoint |
| 6 | release | id: u64 | - |
| Code | Error |
|---|---|
| 2 | not_permitted |
| 3 | too_many |
| 4 | failed |
Granting and releasing
Status: built · tested (9)
- bench:r4-host-tests
- host:redoubt-keyd::a_launcher_grants_a_fresh_capability_and_releases_it
- host:redoubt-keyd::a_granted_capability_cannot_grant_again
- host:redoubt-keyd::a_granted_capability_names_the_same_key_and_dies_with_release
- host:redoubt-keyd::release_zero_frees_everything_this_caller_granted
- host:redoubt-keyd::a_grant_whose_reply_never_arrives_is_undone
- host:redoubt-keyd::a_grant_that_fails_gives_its_admission_back
- host:redoubt-keyd::serving_grant_rolls_back_discard_missing_capability_and_error
- host:redoubt-keyd::a_stale_grant_does_not_name_a_new_key_after_a_restart
keyd’s grants follow the typed pattern (wire), over the serving
library’s minted table (minted connections):
grantmints a fresh capability for the caller’s own key and purpose, stamped like the handle the request came through, and replies with it and a random id. A launcher asks for one per child rather than passing its own on. Nothing granted is wider than the badge it came through.- Only a root badge may grant. A granted capability cannot grant again, so grants never chain. An account-0 caller is admitted per badge, and a chain would let one daemon open a fresh bucket per link until nobody, the steward included, could grant at all (serving).
release(id)frees the grant and everything granted under it, for the caller that receivedidonly; anyone else gets the samenot_permittedas an id that does not exist.release(0)frees everything this caller granted: no grant has id 0, and a restarted holder that lost its ids needs it.- A grant whose reply was discarded, or arrived without the capability, is undone (replies and rollback).
Bounds and errors
Status: built · tested (10)
- bench:r4-host-tests
- host:redoubt-keyd::one_requests_work_is_bounded
- host:redoubt-keyd::a_flood_takes_only_the_flooders_share
- host:redoubt-keyd::a_hostile_client_does_not_hurt_keyd_or_other_clients
- host:redoubt-keyd::the_image_entry_holds_its_buckets
- host:testbench::every_manifest_entry_holds_its_buckets_at_their_caps
- host:redoubt-keyd::malformed_requests_are_refused_and_their_handles_closed
- host:redoubt-keyd::a_one_way_message_is_dropped_and_its_handles_closed
- host:redoubt-keyd::random_requests_never_panic
- host:redoubt-keyd::a_labelled_caller_reads_but_does_not_sign
- One request’s work is bounded: each transcript part at most
MAX_PART(16 KiB), a record at mostMAX_RECORD(8 KiB); over it istoo_many. - Admission counts the one thing a client can make
keydhold, its grants: at most 8 live grants per (account, label set), across at mostbuckets=Nof those at once, sized to fit the budget its manifest entry gives, the entry’s pages less its heap cap and stack, by the entry rule (walfsd; R26 (admission fairness)): a bucket at its caps is 4,096 bytes, the image’s six 24,576, six pages, which with the heap cap’s 8 and the stack’s 128 round to the image’s 256, and the entry leaves 120 pages of budget, more such buckets than a server may have; a block with nobuckets=Nor no budget, or a count the budget cannot hold, andkeyddoes not start (init).keydparks no calls and keeps no other per-caller state, so a flood of signing requests grows it by nothing and is bounded by the kernel’s fair waiting. - The label check.
keyd’s keys carry no labels, so anyone may read a public key or askholds, and only an unlabelled caller may sign, grant or release: signing puts the caller’s data into something the key vouches for, which is a write (R25 (the label check)). - Errors:
not_permittedfor a badge that names no key, names a key whose purpose does not allow the operation, fails the label check, or is a grant asking to grant, and it says no more than that;too_manyfor a request over a bound or a client at its cap;failedfor a mint the kernel refused;malformedfor a request that does not decode. A one-way message is dropped and its handles closed.
Seeds from the manifest
Status: built · partly tested: no sshd or steward is started, so no badge is handed them · tested: bench:init-boot, bench:init-refuses-held-login-key
The seeds are written in keyd’s servers entry in the boot manifest, which init passes
through unchanged as keyd’s arguments (init). The manifest is
never public, so no session can read them. The manifest hands sshd the ssh_host key’s root
badge and the steward the audit key’s; the steward never holds the host key’s badge.
Running under init
Status: built · partly tested: a restarted keyd’s check is read from the code, not attacked · tested: bench:init-boot, bench:init-refuses-held-bundle-key, bench:init-refuses-held-login-key
init starts keyd through the loader stub before the steward and sshd, then asks it holds
for every login and approval key and the bundle key (init).
keyd answers calls on its endpoint until the endpoint is destroyed. Restarted, it reads the same
arguments and gives each root badge the same key, and every earlier grant names nothing.
Sealed keys, labelled keys and keys in leases
Status: planned · M6 (persist, install, share)
- Sealed keys generated at first boot.
keydgenerates its keys on the box at first boot and seals them to the machine, so no seed is in a manifest or the bundle. - Labelled keys. A key may carry labels, so a vault session can sign with a key of its label.
holdsthen applies the label check per key, not only the badge’s. - Keys in leases. A lease may carry a principal’s key only if its approval named the key, and then only with the one message shape it may sign, never arbitrary bytes, or a hijacked agent would be a signature oracle that lets its peer log in as its sponsor elsewhere (steward).
- A
pkgpurpose. A key with it signs only a package: the full preimage"redoubt.pkg.v1\0" || u64_le(len) || archivethatkeydbuilds itself from the archive, in the loader’s form, with an approval per signature (packages). It is the one purpose whose signed message is not a 32-byte digest. It adds one shape to R44 and no more: apkgsignature covers only a preimagekeydbuilt under the package domain, which is never an SSH exchange, an audit record or a bundle, since domains are prefix-free.
Open: where sealed keys are kept and what they are sealed with; the message shapes a
principal’s key may sign; who enforces the approval per pkg signature, since keyd makes no
calls (the recommendation: the steward mints a single-use grant after each approval, and keyd
signs once per grant).
Authority
Status: built · tested (4)
- bench:r4-host-tests
- host:redoubt-keyd::nothing_in_the_protocol_returns_a_key
- host:redoubt-keyd::no_request_can_put_a_key_into_keyd
- host:redoubt-keyd::a_badge_signs_only_its_own_key_and_only_its_own_purpose
keydholds its keys, its own endpoint and the handles it minted as grants. It makes no calls to any other server.- A badge’s authority is its key’s purpose and nothing else: an
ssh_hostbadge speaks as the box in a key exchange, with any peer, for as long as it is held, which is what a host-key capability is for. - No operation returns a private key or any function of one but a signature, and none puts a key
into
keyd. - The crate forbids
unsafe.
Security properties
R43 (no export)
Status: built · tested (4)
- bench:r4-host-tests
- host:redoubt-keyd::nothing_in_the_protocol_returns_a_key
- host:redoubt-keyd::no_request_can_put_a_key_into_keyd
- host:redoubt-keyd::random_requests_never_panic
No message of keyd’s protocol returns a private key or a function of one other than a
signature, and none adds, replaces or removes a key. A caller cannot ask for what the protocol
cannot say, so the only way a key leaves keyd is a bug in keyd itself.
R44 (one key, one purpose, keyd’s own digest)
Status: built · tested (6)
- bench:r4-host-tests
- host:redoubt-keyd::a_badge_signs_only_its_own_key_and_only_its_own_purpose
- host:redoubt-keyd::a_relayed_ssh_user_auth_blob_is_never_what_gets_signed
- host:redoubt-keyd::parts_cannot_be_slid_into_each_other
- host:redoubt-keyd::the_hash_matches_an_independent_implementation
- host:redoubt-keyd::a_granted_capability_names_the_same_key_and_dies_with_release
A badge names one key and one purpose. Under the purposes keyd holds, ssh_host and audit,
every signature is over exactly 32 bytes, a digest keyd computed itself: an SSH exchange hash
over a transcript naming keyd’s own public key, or the SHA-256 of a fixed domain string, a length
and a record. So a holder of a badge gets signatures only in its purpose’s shape, and no container
that covers longer messages (a boot bundle’s archive, an SSH user-authentication request, which is
at least 36 bytes before its user name) can be what a keyd signature covers.
R45 (constant-time signing)
Status: built · partly tested: the timing tests (signing_takes_the_same_time_whatever_the_key, signing_takes_the_same_time_whatever_the_nonce in servers/keyd/tests/timing.rs) are ignored in an ordinary test run and need an optimised build (cargo test --release -p redoubt-keyd --test timing), which no bench case runs
Signing takes the same time whatever the key: no branch and no memory index depends on secret
material, in the seed’s decoding, in SHA-256, or in the Ed25519 crate keyd links (read at
version 2.4.2; keyd never verifies, so the crate’s variable-time verification code is never
reached). The timing tests measure it fixed-against-random, with a deliberately leaking
signer as the control the same statistic must flag in the same run. They resolve a few per cent
of one signature; the claim itself rests on reading the code.
Failure and restart
Status: built · tested (4)
- bench:r4-host-tests
- host:redoubt-keyd::bad_key_arguments_stop_keyd_starting
- host:redoubt-keyd::a_stale_grant_does_not_name_a_new_key_after_a_restart
- host:redoubt-keyd::release_zero_frees_everything_this_caller_granted
- A bad key argument stops
keydbefore it serves anything, loudly. keydrestarts: root badges mean the same keys again; every grant from before the restart names nothing (not_permitted), since the minted table starts empty with a new first badge (R27 (badge allocation)).- A holder restarts and lost its grant ids: it frees them with
release(0).
Residual risks
- The seeds live in
init’s memory and the bundle. They arrive in the read-only startup page, so they exist ininit’s memory and in the signed, unencrypted bundle, andkeydcannot erase its copy. Whoever can read the bundle image holds the box’s private keys. Sealed keys are planned (above). keydsees the SSH shared secret.sign_ssh_exchangecarries the key exchange’s shared secretk, because the exchange hash covers it. A compromisedkeydcan derive any session’s keys and read its traffic, not only speak as the box;keydis written small enough to read.holdsanswers about every key. It reveals nothing a caller could not learn by connecting while keys are unlabelled; with labelled keys it needs a per-key check.- An
ssh_hostbadge speaks as the box. Its holder can complete a key exchange as the box with any peer, for as long as it holds the badge.
Why
- Separate from the steward. A leaked key cannot be revoked; a steward bug that leaked authority can be. Keeping keys out of the process with the most hostile input keeps the unrevocable thing furthest from the attacker.
- Purposes as message shapes. A badge that signed any bytes would be a signature oracle: a
hijacked holder could relay someone’s SSH user-authentication request and log in as the box’s
owner elsewhere. A digest
keydbuilds cannot be one. - No enrolment. An operation that adds a key is a way to make
keydhold a key that authenticates a person; with none, keys come only from the signed manifest. - Its own SHA-256. About ninety lines read once are cheaper to trust than six crates inside the process that holds every private key.
- Root badges by position. A restarted
keydgives each root badge the same key without storing anything, and grants cannot survive it.
bootfsd
bootfsd serves /boot: the programs and module archives the boot manifest marks public, as one
flat, read-only 9P directory every session can read. It never sees the boot bundle. Its launcher
hands it the public entries’ bytes, one at a time, and then seals it; after that nothing can
change, and nothing else in the bundle, the manifest least of all, is ever in its address space.
Purpose
Sessions need the system’s programs and modules (a VM’s .beam files, for one) without a file
server holding the whole signed bundle, which carries keyd’s seeds and every principal’s keys in
its manifest. bootfsd gives them exactly the published entries: the answer to “what may a
session read from the bundle?” is the list it was built from, not a filter it applies.
Interface
Serving /boot
Status: built · tested (11)
- bench:r4-host-tests
- bench:bootfsd-build
- host:redoubt-bootfsd::a_session_reads_the_public_entries_and_sees_nothing_else
- host:redoubt-bootfsd::a_walk_to_an_unpublished_name_is_the_same_as_to_one_that_never_existed
- host:redoubt-bootfsd::entries_read_back_byte_for_byte_at_any_offset
- host:redoubt-bootfsd::every_way_of_writing_is_refused
- host:redoubt-bootfsd::the_directory_lists_exactly_the_public_list_in_order
- host:redoubt-bootfsd::the_conformance_vectors_run_against_bootfsd
- host:redoubt-bootfsd::serving_connection_rolls_back_discard_missing_capability_and_error
- host:redoubt-bootfsd::the_image_entry_holds_its_buckets
- host:testbench::every_manifest_entry_holds_its_buckets_at_their_caps
bootfsd is a 9P server over the 9P server skeleton, and its
endpoint also serves ninep_common (wire).
- Flat. Every connection attaches at
/bootitself, whatever the attach name; the root holds one file per published entry, in the order of thepubliclist, and nothing else. - Exact names. A walk finds a name from the
publiclist, byte for byte, and nothing else. Any other name is “does not exist”, the same answer as a name the bundle never held, so/bootsays nothing about the rest of the bundle. - Read-only.
Twrite,Tcreate,TremoveandTwstatare refused, and so is opening for writing or withOTRUNC: the read-only rule does not rest on the label check, which an unlabelled caller passes. There is no code that changes an entry once sealed. - Unlabelled. Every entry carries no labels, so every caller, labelled or not, may read it.
- Every byte is already there. A read never waits; an offset past the end reads nothing. Entries never change, so every qid version is 0.
- Admission (R26 (admission fairness)): at most 47 open
fids (the 23 connections’ roots, each a fixed cost outside the share, and a pool of one working
fid per connection and one over, an entry walked, opened, read and clunked:
serving), 23 minted connections, 2 multiplexed connections’ completion calls, 64 of their requests
and 2 pages they brought (serving) per (account, label
set), and per badge for account 0, across at most
buckets=Nof those at once, sized to fit the budget its manifest entry gives, the entry’s pages less its heap cap and stack, by the entry rule (walfsd). The connections are what a manifest lets one account hold at/bootat once, whoever minted them (serving): one per session the steward starts, the console session, the cap of live contexts per label set and a spare, and one per lease, the leases what the principal’s unlabelled share holds inagentbudgets (the steward), held by the bench to at least the largest such sum over the tree’s manifests, (1 + 3 + 1) + 18 understeward-latency’s (the_servers_admit_what_a_manifest_lets_one_account_hold). A bucket at its caps costs 173,568 bytes; the image needs five (init, the steward, and alice’s{}and{alice-secrets}and bob’s{}domains) and asks six, a case’s extra client the sixth, 1,041,408 bytes or 255 pages, which with the heap cap’s 2,304 (the published entries live there) and the stack’s 128 round to 2,816 of the image’s 4,096; the entry leaves 1,664 pages of budget, thirty-nine such buckets, so each more principal’s domain costs one more; a block with nobuckets=Nor no budget, or a count the budget cannot hold, andbootfsddoes not start (init).
Filling it
Status: built · partly tested: the refusal of `add` and `seal` from badge 0 is read from the code, not attacked · tested (7)
- host:redoubt-bootfsd::a_bad_public_list_stops_the_server
- host:redoubt-bootfsd::the_public_list_is_checked_before_anything_is_served
- host:redoubt-bootfsd::add_only_appends_to_a_listed_name_in_order
- host:redoubt-bootfsd::setup_is_refused_after_seal_and_from_every_minted_connection
- host:redoubt-bootfsd::nothing_is_visible_before_seal
- host:redoubt-bootfsd::a_client_cannot_publish_into_boot
- host:redoubt-bootfsd::the_published_bytes_are_bounded
- The list.
bootfsd’s arguments are thepubliclist, one entry per argument, in the manifest’s order, eachLENGTH:NAME: the entry’s length in bytes as its launcher read it from the bundle, a canonical number (no leading zero,0itself allowed), then a colon, then the name, split at the first colon so a name may hold one. Each name must be one 9P path component (not empty,.or.., no/or NUL), named once, at mostMAX_ENTRIES(64), and the lengths together at mostMAX_BYTES(8 MiB). Each entry’s buffer is reserved for its length once, here, so the server’s heap holds the entries and never a growth step: before, an entry grew by doubling from its first chunk, and its peak (the old and the new buffer together) depended on the pusher’s chunk size and on where the image fell against a power of two. A list breaking any of that, or one the heap cannot hold, stops the server before it serves anything: a/bootthat is not what the manifest named is worse than none. add(name, offset, data)appendsdatato a listed name.offsetmust be exactly what has been added to that name so far, so a chunk cannot be lost, repeated or reordered, and the chunk must end within the entry’s declared length.sealends setup. Before it the directory is empty and every walk is “does not exist”, so no client sees a half-written entry; after itaddandsealare refused for good. A seal while an entry is short of its declared length is refused and/bootstays empty: the launcher, which cannot finish the boot without it, says so, rather than a file that is not what the manifest named being served.- Only the founding handle fills it. Both messages are refused (
refused) from badge 0 and from any connectionnew_connectionminted (a badge at or aboveFIRST_MINTED_BADGE), so only a holder of a root badge, handed out when the server was set up, can publish.
The table: libs/wire/tables/bootfs.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 16 | add | name: string, offset: u64, data: bytes | - |
| 17 | seal | - | - |
| Code | Error |
|---|---|
| 2 | refused |
Started by init
Status: built · partly tested: no session’s launcher is started, so no fresh connection is rooted at /boot for one · tested: bench:init-servers, bench:init-boot, bench:init-refuses-public-manifest
init starts bootfsd from the bundle’s pages with the manifest’s public list as its
arguments, each entry with its length from the bundle, reads the bundle itself, pushes each
public entry’s bytes with add, and sends seal
(init). init refuses a manifest whose public list names an
entry the bundle does not hold, or the manifest itself. Sessions get fresh connections to
bootfsd, rooted at /boot, from their launcher.
Authority
Status: built · tested: host:redoubt-bootfsd::a_client_cannot_publish_into_boot, host:redoubt-bootfsd::every_way_of_writing_is_refused
bootfsd holds its endpoint and the connections it minted, and the bytes it was handed. It holds
no handle to the bundle, no device and no connection to any other server, and it makes no calls.
Its clients get read access to the published entries and nothing more; its founding handle’s
holder gets add and seal until the seal.
Security properties
R46 (only the public list)
Status: built · tested (6)
- host:redoubt-bootfsd::a_session_reads_the_public_entries_and_sees_nothing_else
- host:redoubt-bootfsd::a_walk_to_an_unpublished_name_is_the_same_as_to_one_that_never_existed
- host:redoubt-bootfsd::setup_is_refused_after_seal_and_from_every_minted_connection
- host:redoubt-bootfsd::nothing_is_visible_before_seal
- host:redoubt-bootfsd::every_way_of_writing_is_refused
- host:redoubt-bootfsd::a_client_cannot_publish_into_boot
/boot shows exactly the entries of the public list, with exactly the bytes the founding
handle’s holder added before the seal, and nothing else: nothing before the seal, no change after
it, no publishing from any minted connection, and the same answer for a name outside the list as
for one that never existed. The rest of the bundle, the manifest included, never enters
bootfsd, so no bug in it can serve them.
Failure and restart
Status: built · partly tested: that a restarted bootfsd serves nothing until sealed again follows from its start (an empty, unsealed table) and is not attacked across a restart · tested: host:redoubt-bootfsd::a_bad_public_list_stops_the_server, host:redoubt-bootfsd::serving_connection_rolls_back_discard_missing_capability_and_error
- A bad
publiclist stopsbootfsdwith an exit code before it serves. - A connection whose reply is lost is rolled back (replies and rollback).
bootfsdrestarts: it starts empty and unsealed, and serves nothing until its launcher fills and seals it again.
Residual risks
- Whatever is public is public to everyone. Every session, labelled or not, reads every
published entry; a secret put on the
publiclist is a secret published. - A restart empties
/bootuntil its launcher fills it again; who does that after boot is init’s.
Why
- Never see the bundle. A server that holds the bundle and filters it serves the manifest the day its filter has a bug; a server that was only ever handed the public entries cannot.
addat an exact offset. A lost, repeated or reordered chunk would publish wrong bytes that every session trusts; an offset that must match makes each of them a refusal.- Empty until sealed. A session that reached
/bootwhile it filled would see a half-written module; an empty directory until the seal means it sees all of an entry or none.
littlefsd
littlefsd is the littlefs file server: one instance per volume, each holding one partition from
blkd and serving it over 9P as a littlefs filesystem. A volume has one label set, and every
request is checked against it. Each connection is rooted where its granter chose, with a byte quota
carved from the granter’s own. littlefs is Redoubt’s own pure-Rust implementation of the littlefs
on-disk format, tested against the C reference on the host.
Purpose
Sessions need files that survive a reboot and a power cut, on a disk shared by principals who do
not trust each other. littlefsd keeps each volume’s parser apart from every other volume’s, keeps
labels per volume so a vault’s files never share metadata with an unlabelled volume’s, and meters
bytes per attach root so one principal filling a shared volume cannot make another’s saves fail.
littlefs was chosen for a published format, an independent second implementation to test against,
power-loss safety by design, and a size that can be read. littlefs is a file system for
writable volumes only: its format is built for a written medium (commits, power loss). A
read-only volume is EROFS, served by erofsd. The SSD’s writable volumes, the
image’s data volume among them, are walfs (walfsd); littlefsd serves littlefs for
a flash medium, and for the cases that ask for a littlefs volume.
Interface
Volumes, connections and labels
Status: built · tested (38)
- bench:littlefsd-boot
- bench:littlefsd-confined-labelled
- bench:littlefsd-corrupt-volume
- bench:littlefsd-label-check
- bench:littlefsd-large-directory
- bench:littlefsd-one-volume
- host:testbench::the_servers_admit_what_a_manifest_lets_one_account_hold
- host:testbench::every_manifest_entry_holds_its_buckets_at_their_caps
- host:redoubt-littlefsd::the_manifests_entry_holds_its_buckets
- host:redoubt-littlefsd::attach_walk_open_read_write
- host:redoubt-littlefsd::files_and_directories_survive_a_remount
- host:redoubt-littlefsd::a_removed_files_other_fids_get_removed
- host:redoubt-littlefsd::the_volumes_labels_are_checked_on_every_request
- host:redoubt-littlefsd::a_blank_range_is_formatted_and_only_a_blank_one
- host:redoubt-littlefsd::a_range_of_noise_is_served_as_corrupt
- host:redoubt-littlefsd::noise_is_never_formatted_and_never_mounted
- host:redoubt-littlefsd::a_read_only_range_is_never_written
- host:redoubt-littlefsd::a_read_only_range_is_served_read_only
- host:redoubt-littlefsd::a_volume_whose_ids_do_not_hold_together_is_corrupt
- host:redoubt-littlefsd::a_higher_reader_writes_nothing
- host:redoubt-littlefsd::a_rename_cut_short_still_mounts
- host:redoubt-littlefsd::a_directory_aliasing_the_root_is_refused_in_linear_time
- host:redoubt-littlefsd::two_directories_sharing_a_pair_are_corrupt
- host:redoubt-littlefsd::a_tail_that_is_another_directorys_pair_is_corrupt
- host:redoubt-littlefsd::two_chains_joining_at_one_pair_are_corrupt
- host:redoubt-littlefsd::a_chain_looping_back_to_its_head_is_corrupt
- host:redoubt-littlefsd::split_directories_still_mount
- host:redoubt-littlefsd::a_tree_renames_made_deep_still_mounts
- host:redoubt-littlefsd::a_device_that_fails_makes_the_volume_corrupt_until_it_is_mounted_again
- host:redoubt-littlefsd::writes_and_truncations_move_the_qid_version
- host:redoubt-littlefsd::the_conformance_vectors_run_against_littlefsd
- host:redoubt-littlefsd::files_survive_a_restart
- host:redoubt-littlefsd::arguments_it_does_not_understand_stop_it_before_serving
- host:redoubt-littlefsd::a_range_too_small_is_no_volume
- host:redoubt-littlefsd::fids_are_bounded_and_disconnect_frees_them
- host:redoubt-littlefsd::listing_a_directory_reads_it_once_per_window
- host:redoubt-littlefsd::a_change_between_reads_refills_the_window
- host:redoubt-littlefsd::one_reply_costs_a_bounded_number_of_passes
- One instance per volume. A
littlefsdholds one block-range handle, a partition fromblkd, and no MMIO, interrupt or DMA. An untrusted medium gets its own server holding only that medium, so a parser exploit reaches that medium and nothing else (R47 (one volume per instance)). - Arguments.
littlefsdgets one named handle,volume, its range atblkd, or at averitydfor a verified volume, and the argumentsendpoint=NAME, the manifest name of the endpoint it receives on (littlefsd:data),labels=ID[,ID...], the volume’s label set, andbuckets=N. littlefs blocks are 4096 bytes, eight ofblkd’s sectors, so the volume’s block count is its range’s sectors divided by 8. - Mounting. At start
littlefsdmounts its range. A range whose first two blocks are all zero has never been written, andlittlefsdformats it. Any other range that does not mount is served as corrupt: every attach is refused withcorrupt,littlefsdsays so on its console, and it stays up, so a damaged or hostile medium never becomes a restart loop.littlefsdnever formats a range that holds anything. A range that mounts is then checked, without writing: every file and directory carries its id, no two the same, and the id counter is above the highest, and no metadata pair is named twice, within one directory’s chain or across two; a volume that fails is served as corrupt too. Solittlefsdserves only volumes it wrote. - Read-only ranges. A range
blkdreports read-only is served read-only: every change is refused before it reachesblkd, so a refused write never makes the volume corrupt, and a blank read-only range is not formatted. - 9P, over the 9P server skeleton: one connection per
client, each rooted where the granting party chose with
new_connection(wire). - A fid is a path and an id. Every file and directory has an id,
littlefsd’s own attribute and its qid path, from a counter on the root that never gives one twice. The counter moves in one commit and the entry is created with its id in the next, so a power cut skips an id and never leaves an entry without one, and no read writes the volume. Every operation on a fid finds its path again and checks the id: an entry gone, or with another id, isremoved. So a remove or a rename over a file ends every other fid on it, and a fid on a renamed file, or on anything below a renamed directory (a connection’s root included), isremovedas well: fids do not follow renames. - A directory is listed a window at a time.
littlefsdserves a directory read from a window of up to 64 entries with their stats, filled by one pass over the directory and dropped by any change to the volume. Listing n entries costs about n / 64 passes and no lookup per entry, and one request costs at most 22 passes over the directory it reads, 22 fixed by the largest message. - Labels are per volume. Each volume has one label set, from the boot manifest or the steward,
and
littlefsdreports it as every node’s labels, so the skeleton’s label check runs on every request: a read (a qid and astatincluded) needs the volume’s labels to be a subset of the caller’s, a write needs them equal (R25 (the label check)). There are no per-file labels, owners or permission bits: access is by capability. - A remove succeeds while another connection holds a fid on the file. An “in use” refusal
would be a channel between connections. The remove frees the file’s blocks at once, and every
other fid on it gets
removedon its next read, write or stat (theRerrortext for 9P, the table’sremovedfor a typed operation); only a clunk succeeds. So there is no orphan to track and no invisible data holding quota, and it tells a fid’s holder no more than the name vanishing from the directory tells anyone who can walk there. (littlefs itself keeps a removed file readable through open handles;littlefsddoes not use that.) - Removing a file is not revocation. It ends the file, not anyone’s access to the volume; revocation is destroying the grant.
- Admission is the serving library’s, per (account, label set) with a fair share per badge
(R26 (admission fairness)): 71 fids (the 23 connections’
roots, each a fixed cost outside the share, and a pool of two working fids per connection and
two over, the shell’s hub’s working directory and the fid of its request in hand:
serving), 23 minted
connections, 21 multiplexed connections’ completion calls, 128 of their requests and 32 pages
they brought (serving) per bucket, sized to fit the
budget its manifest entry gives, the entry’s pages less its heap cap and stack, by the entry rule
(walfsd): a bucket at its caps is 1,697,280 bytes; the manifests’ four take
6,789,120, 1,658 pages, which with no heap cap and the stack’s 128 round to 1,792, their
entries’, and the entry leaves 1,664 pages of budget, four such buckets and not a fifth; a
disconnectfrees a client’s fids. The completion calls are not a guess: a bucket is one account’s, and each live VM of the account at the server, a session’s or a lease’s, parks one, so they are what a manifest lets one account have open at once. Sessions and leases of one label set are carved from one share, the principal’s fixed sub-budget (the steward), an equal part of its budget per label set less a budget’s own cost, and the kernel’s carve refuses past it (R7 (carving)); so what can be live at once is what the share carves at once, its pages over the smaller of a session’s and a lease’s (sizes.session.pages,sizes.agent.pages), and one spare for a context detaching while another attaches (its connection lives until the relay ends), and the bench holds the constant,walfsd’s too, to the largest such sum over the tree’s manifests: 106,770 / 5,248 + 1 = 21 understeward-latency’s manifest, 40,959 / 5,248 + 1 = 8 under the image’s (image). Fewer refused a principal’s third live context its home. A crossing’s program, in a budget stamped with the account, makes plain calls and holds no completion call: one connection record (State) of the account’s bucket for at most a second (the steward). When a manifest’s sum grows past the constant the host test fails by name, so the constant moves with the manifests, never ahead of them; and every manifest’s buckets at those caps must leave the open-call headroom (serving), which the bench holds too (every_servers_buckets_leave_the_open_call_headroom). The connections are the account’s by the same rule, whoever minted them: a home connection the steward mints for a session or a lease is admitted in that account’s bucket from its first request (serving), so the 23 per bucket are held by the same host test to at least one per session, the console session, the cap of live contexts per label set and a spare, and one per lease, the leases what the share holds inagentbudgets (the steward): (1 + 3 + 1) + 18 understeward-latency’s manifest; and the steward’s own share holds only its kept carves. - Metadata lives in littlefs user attributes: what
statneeds (mtime, qid version) and the per-file attributes ofget_attrandset_attr. No access time is kept. A file’s qid version moves with every write and truncation, so a client caching it sees the change. mtime is 0 until a clock reacheslittlefsd.
The attack test: after a remove, the file’s other fids get removed on read, write and stat.
Typed operations
Status: built · tested (14)
- host:redoubt-littlefsd::rename_moves_within_the_volume_and_keeps_the_files_id
- host:redoubt-littlefsd::a_directory_is_not_renamed_into_itself
- host:redoubt-littlefsd::a_rename_over_a_file_removes_it
- host:redoubt-littlefsd::copy_file_copies_and_counts_the_bytes
- host:redoubt-littlefsd::a_copy_that_does_not_fit_leaves_nothing
- host:redoubt-littlefsd::a_read_only_volume_refuses_every_typed_change
- host:redoubt-littlefsd::fids_on_a_renamed_file_or_below_a_renamed_directory_are_removed
- host:redoubt-littlefsd::attributes_set_and_get_with_littlefsds_own_types_refused
- host:redoubt-littlefsd::a_strangers_fid_is_not_found
- host:redoubt-littlefsd::typed_operations_check_the_volumes_labels
- host:redoubt-littlefsd::removed_and_corrupt_are_the_tables_answers
- host:redoubt-littlefsd::the_client_library_works_against_littlefsd
- host:redoubt-littlefsd::a_failing_range_answers_corrupt
- host:redoubt-rt::a_typed_operation_resolves_only_the_callers_own_fids
littlefsd serves typed messages on its 9P endpoint for what 9P2000 does not express, with the same
label and quota checks as 9P, and exactly these four:
rename(old_dir, old_name, new_dir, new_name): atomic, within one volume;old_dirandnew_dirare the caller’s fids on directories. Renaming a directory into itself isnot_permitted. There is no rename across volumes: onelittlefsdserves one volume and cannot act on another’s files, so the client’sFile.renamereturns{:error, :exdev}, and a move is the caller’s own copy and remove, which is not atomic.copy_file(src_fid, dst_dir, dst_name): copies a file within the volume and replies with the bytes copied. A directory is not copied:not_supported.set_attr(fid, attr, value)andget_attr(fid, attr): a file’s or directory’s user attributeattr.
Refusals carry the table’s names, each meaning what it means in a 9P reply
(error names): a caller whose labels may not change the volume, a rename
that would end a live root or move a directory into itself, and a set_attr of one of
littlefsd’s own attribute types are not_permitted; what the server does not do is
not_supported; a name that is not one plain path element is bad_name; and a change to a
read-only volume is read_only. None is refused, which is a connection’s refusal: in the
shell it reads as econnrefused.
The table: libs/wire/tables/littlefsd.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 16 | rename | old_dir: u32, old_name: string, new_dir: u32, new_name: string | - |
| 17 | copy_file | src_fid: u32, dst_dir: u32, dst_name: string | count: u64 |
| 18 | set_attr | fid: u32, attr: u8, value: bytes | - |
| 19 | get_attr | fid: u32, attr: u8 | value: bytes |
| Code | Error |
|---|---|
| 2 | not_found |
| 4 | exists |
| 5 | not_dir |
| 6 | removed |
| 7 | too_large |
| 8 | corrupt |
| 9 | no_space |
| 10 | not_permitted |
| 11 | not_supported |
| 12 | bad_name |
| 13 | read_only |
| 14 | no_memory |
| 15 | is_dir |
| 16 | not_empty |
Attributes. A value is at most littlefs’s attr_max, 1022 bytes; a larger one is refused with
too_large. Attribute types 0 to 15 are littlefsd’s own (the id, mtime, qid version, the root’s id
counter, and later use), and set_attr refuses them (not_permitted); types 16 to 255 are the
user’s. The id counter moves before each create, in a commit of its own, so a create that then
fails (the name exists, or the volume is full) still uses up an id and a write; ids are never
reused, and a 64-bit counter does not run out.
Corruption. An operation that meets a corrupt volume, or an I/O error from blkd, answers
corrupt, for 9P and the typed operations alike, so a client can tell a broken volume from a
refusal.
Quotas
Status: built · tested (19)
- host:redoubt-littlefsd::a_write_past_one_roots_quota_is_refused_while_another_still_writes
- host:redoubt-littlefsd::a_root_with_quota_0_cannot_create_but_can_read_and_remove
- host:redoubt-littlefsd::a_mint_the_room_cannot_take_is_refused_and_disconnect_gives_it_back
- host:redoubt-littlefsd::two_connections_at_one_directory_share_it
- host:redoubt-littlefsd::a_connection_at_its_granters_own_root_carves_nothing
- host:redoubt-littlefsd::a_root_minted_over_files_counts_them
- host:redoubt-littlefsd::a_root_minted_above_a_live_root_holds_its_quota_in_reserve
- host:redoubt-littlefsd::minting_over_files_frees_no_room
- host:redoubt-littlefsd::minting_above_a_live_root_frees_no_room
- host:redoubt-littlefsd::a_rewrite_at_the_start_needs_room_for_the_tail
- host:redoubt-littlefsd::a_rename_or_remove_never_ends_a_live_root
- host:redoubt-littlefsd::a_rename_between_two_roots_moves_the_bytes
- host:redoubt-littlefsd::a_copy_past_the_quota_is_no_space
- host:redoubt-littlefsd::the_volume_never_runs_out_while_every_root_is_within_its_quota
- host:redoubt-littlefsd::a_root_at_its_quota_creates_while_its_entries_fit_one_pair
- host:redoubt-littlefsd::with_room_the_directory_splits_and_the_split_is_charged
- host:redoubt-littlefsd::a_mkdir_without_room_for_its_pair_changes_nothing
- host:littlefs::file_blocks_counts_what_a_file_holds
- host:littlefs::pair_room_bounds_splits_and_new_directories
Each root a connection is minted at has its own byte quota, set by whoever granted it in
new_connection’s quota field and carved from the room of the live root above it. littlefsd
records it in the skeleton’s minted hook, which refuses a quota that room does not have
(refused), and gives it back when the connection is disconnected. A change that would take a
root past its quota is refused with no space. So Bob filling the data volume cannot make
Alice’s saves fail (R48 (a quota per attach root)). The serving
library holds no byte counters; littlefsd and walfsd meter bytes, with one
ledger, redoubt-fileserver’s (serving).
- A root holds what lies under it: whole blocks for a file stored in blocks, the byte length of an inline file, and each directory’s metadata pairs, less what lies under the live roots minted below it; for each of those it holds in reserve the larger of that root’s quota and what that root holds, so minting a root never frees room. A change is charged to the nearest live root above it, whichever connection made it. The volume root is always live, and every attached connection is at it. A directory is split into another metadata pair only when the root above it has room for one; otherwise littlefs keeps it in the pairs it has, so a split never takes a root past its quota.
- Nothing is stored.
littlefsdcounts a root by walking its directory when the first connection is minted there, and keeps the count while one is live, so the medium holds no counter to trust or to lose in a power cut. Connections minted at one directory share its count, and its quota is the sum of theirs; one minted at its granter’s own root is that root and carves nothing. A root minted over more than its quota can read and remove only, until it is under. - A rewrite counts what it writes. littlefs rewrites a file from the first block written to
its end before it commits, so a write needs room for those blocks as well as any growth.
copy_filewrites new blocks and is charged in full. - No promise the disk cannot keep. The volume root’s quota is the usable blocks less a fixed reserve for the blocks littlefs takes outside any root, and carved quotas never exceed it. A commit splits a directory only into room its change’s own root has; any other directory it touches (the id counter’s, a rename’s source) is compacted in the pairs it already has.
- A quota of 0 means nothing: the connection can read and remove, but not create or grow. A quota is never charged to a parent root, which would reopen a shared pool.
- A rename or remove never ends a live root. Moving a live root or a directory holding one,
removing a live root’s directory, or renaming over it is
not_permitted: it would end that root’s connections and carry its count away. A rename between two roots’ parts of the tree moves the bytes and needs room in the second.
The attack tests: a write past one root’s quota is refused while another root still writes; a root with quota 0 reads and removes, but cannot create.
littlefs
Status: built · tested (16)
- fuzz:littlefs/image
- fuzz:littlefs/mutate
- host:littlefs::random_operations_small_blocks
- host:littlefs::random_operations_large_blocks
- host:littlefs::random_operations_tiny_blocks
- host:littlefs::random_operations_crowded_small_volume
- host:littlefs::directory_split_and_drop
- host:littlefs::full_volume
- host:littlefs::handles_follow_renames
- host:littlefs::bad_arguments
- host:littlefs::path_and_handle_rules
- host:littlefs::a_failed_write_commits_nothing
- host:littlefs::a_create_with_attributes_is_never_seen_without_them
- host:littlefs::a_create_refuses_attributes_set_attr_would
- host:littlefs::a_directory_read_carries_attributes_and_pairs
- host:littlefs::one_commit_splits_only_as_far_as_its_room
libs/littlefs implements the littlefs on-disk format, version 2.1, in pure Rust: no_std with
alloc, no dependencies, no unsafe. Images it writes mount in the C reference (v2.11.3) and
the other way round; the C code runs only on the host, as a test oracle (libs/littlefs/diff/).
Nothing C runs on the target.
Filesystemformats and mounts a volume and provides every operationlittlefsdneeds: files (open, read, write, seek, truncate, sync, close), directories (mkdir, remove, rename, read), stat, user attributes on files and directories, which a create can write in its own commit and a directory read passes along, directory reads by a directory’s pair as well as by path, and a volume check.- Paths are
/-separated names relative to the root;.and..are refused. Names read back from the medium are opaque bytes that need not be UTF-8 or nameable by a path (the volume check reports those), solittlefsdnever joins one into a path it then resolves. - Memory is bounded: one block-sized buffer per metadata fetch, one block per file handle that
is writing, and an allocation bitmap of
block_count / 8bytes. - Where it departs from the C reference: open handles follow renames and survive removal (their
data stays readable); renaming a directory into itself is refused (
not_permitted); directory reads return no.or..; CRC-valid commits that make no sense (duplicate names, entries without names, tags out of range) are corrupt; the configured block count must equal the superblock’s; only on-disk version 2.1 mounts; a file’s attributes and its data are two commits. - Left out on purpose: wear levelling and bad-block relocation, since a virtio disk’s device handles both (a failed program or erase is reported, not worked around); growing the superblock chain; migration from older versions.
The model tests run random operations against an in-memory model, with handles held open and volumes run full.
The medium is hostile
Status: built · tested (16)
- fuzz:littlefs/image
- fuzz:littlefs/mutate
- host:littlefs::corrupted_bytes_never_panic
- host:littlefs::noise_never_panics
- host:littlefs::duplicate_names_are_corrupt
- host:littlefs::nul_names_are_found_and_refused
- host:littlefs::geometry_mismatch_is_refused
- host:littlefs::tail_list_cycle_is_refused
- host:littlefs::directory_chain_cycle_is_refused
- host:littlefs::directory_inside_itself_is_found_by_fsck
- host:littlefs::file_larger_than_the_volume_is_refused
- host:littlefs::file_head_outside_the_volume_is_refused
- host:littlefs::skip_list_pointing_at_itself_terminates
- host:littlefs::forged_file_sizes_do_not_amplify_allocation
- host:littlefs::stale_handle_after_pair_drop_does_not_touch_another_file
- host:littlefs::unnameable_names_fail_the_check
Every length, offset, block pointer and tag read from the device is checked before use, and a
malformed image yields Error::Corrupt, never a panic. Every walk is bounded: along the list of
metadata pairs at most block_count / 2 steps, a file’s skip list by its size (itself checked
against the volume), a whole-volume walk at most 3 * block_count blocks. The only recursion is
one level deep. Metadata is checksummed; file data is not
(R49 (a hostile medium is corrupt, not a crash)).
Power loss
Status: built · tested (7)
- host:littlefs::crash_at_every_write_small_blocks
- host:littlefs::crash_at_every_write_tiny_blocks
- host:littlefs::crash_at_every_write_large_blocks
- host:littlefs::crash_at_every_write_random_workloads
- host:littlefs::crash_at_every_write_torn_erases
- host:littlefs::crash_during_repair
- host:littlefs::io_error_poisons_until_remount
Every change reaches the disk as one metadata commit, or, for renames and directory removal, a sequence the next mount completes or undoes, so an interrupted operation leaves the volume as it was before or after. Mounting writes nothing; the first write after a mount first repairs what an interrupted operation left. An I/O error poisons the filesystem until it is mounted again.
The guarantee holds for a device that keeps littlefs’s block-device contract, which
blkd keeps:
- a torn program persists a prefix of whole program units, possibly followed by one partly written unit, and nothing after;
- a torn erase leaves the block erased, untouched, or erased in part;
- an erase and later programs of one block reach the medium in the order issued;
syncmeans durable: when it returns, everything before it survives power loss. littlefs syncs before each metadata commit that depends on data blocks, and after each commit.
The crash tests inject exactly these failures at every block write of fixed and random workloads (R50 (power loss leaves before or after)).
Authority
Status: built · tested: bench:littlefsd-one-volume
littlefsd holds its endpoint, its one block-range handle at blkd, the console init gave it, and
the connections it minted. It holds no device, no budget handle and no connection to any other
file server. What a client may reach is the subtree its connection is rooted at, under the
volume’s labels and its root’s quota.
Security properties
R47 (one volume per instance)
Status: built · tested (3)
- bench:littlefsd-one-volume
- host:redoubt-init::an_erofsd_entry_is_a_volume_server_as_a_littlefsd_one_is
- host:redoubt-init::no_server_is_handed_a_badge_at_blkd
Each littlefsd instance serves one volume and holds only that volume’s block range. A client who
exploits the filesystem parser through a crafted volume or request reaches that volume’s data and
nothing else: no other volume, no other partition, no device. A read-only volume’s
erofsd is placed the same way.
R48 (a quota per attach root)
Status: built · tested (4)
- bench:littlefsd-quota
- bench:walfsd-pools
- host:redoubt-littlefsd::a_write_past_one_roots_quota_is_refused_while_another_still_writes
- host:redoubt-littlefsd::a_root_with_quota_0_cannot_create_but_can_read_and_remove
Every connection’s root has a byte quota carved from its granter’s, and no write takes a root past it. So one principal filling a shared volume uses up only its own quota and cannot make another’s writes fail. (How many connections and fids a client may hold is admission’s, R26, not the quota’s.)
R49 (a hostile medium is corrupt, not a crash)
Status: built · tested (10)
- bench:erofs-corrupt
- fuzz:erofs/image
- fuzz:littlefs/image
- fuzz:littlefs/mutate
- host:littlefs::corrupted_bytes_never_panic
- host:littlefs::noise_never_panics
- host:littlefs::tail_list_cycle_is_refused
- host:littlefs::skip_list_pointing_at_itself_terminates
- host:littlefs::forged_file_sizes_do_not_amplify_allocation
- host:littlefs::stale_handle_after_pair_drop_does_not_erase_another_files_data
Whatever bytes the medium holds, littlefs refuses them as corrupt rather than panicking, looping
or allocating beyond the volume’s size, and a stale handle never touches another file’s metadata
or data. So a hostile disk image can make its own volume unreadable, never crash or hang its
littlefsd in the parser. erofsd keeps the rule for a read-only volume: whatever
bytes it holds, libs/erofs refuses them as corrupt, and a range that fails makes the volume
corrupt until erofsd starts again.
R50 (power loss leaves before or after)
Status: built · tested (4)
- host:littlefs::crash_at_every_write_small_blocks
- host:littlefs::crash_at_every_write_random_workloads
- host:littlefs::crash_at_every_write_torn_erases
- host:littlefs::crash_during_repair
On a device that keeps the block-device contract, a power cut at any block write leaves every metadata change either done or not done, and the next mount reads a consistent volume.
Failure and restart
Status: built · tested: bench:littlefsd-restart, bench:littlefsd-corrupt-volume
littlefsdcrashes: its clients’ calls getDead,initrestarts it on the same endpoint (init), and littlefs’s copy-on-write keeps the volume consistent. Clients ask for fresh connections.- The medium is corrupt: requests that reach the corruption fail; the volume check reports it.
- An I/O error from
blkdpoisons the filesystem until it is mounted again.
A restarted littlefsd mounts as it does at boot, with the same checks over every metadata pair, then
serves; it reads no file’s blocks first. A power cut leaves the volume consistent
(R50), and damage in a file’s blocks is corrupt
wherever a request meets it (R49).
Residual risks
- littlefs does not checksum data, except on a verified volume
(R76 (verified volumes)). A block device that returns wrong data undetected,
beyond
blkd’s contract, corrupts file contents silently; only metadata is checksummed. - No wear levelling. On a medium that does not level its own wear (raw flash), littlefs wears it out; a virtio disk levels its own.
- Attributes and data are two commits. A power cut between them leaves a file’s new data with its old attributes, or the reverse.
- Large directories and files scale poorly in littlefs’s format. A listing is linear in the
directory, and so is a lookup, and
littlefsdlooks a file up from the root for each request on it: three times for a walk, twice for an open or a read. When the image’s userland volume was littlefs, its root held every module, and a lookup fetched about 21 metadata pairs, each three block reads (the revision count of each block of the pair, then the newer block); the boot’s 652 9P operations made 1,194 lookups and 25,040 fetches, 75,120 of its 77,710 block reads (97 %). That volume is now EROFS, anderofsdresolves a name once per fid. - A listing across a change may skip or repeat an entry. A directory read goes on by entry
index, and a change between two reads refills the window from the directory as it is then, so
an entry created or removed before that index shifts the rest by one (9P allows it). And a
window is filled whole: an entry
littlefsdcannot read, up to 63 places past the one asked for, fails the earlier read ascorrupt, as it would within one reply (R49). - littlefs’s fuzz targets and C oracle run outside the bench. Its host tests run in
littlefs-host-tests; the differential run against the C library (libs/littlefs/diff/) is its own workspace with a C toolchain, and the fuzz targets needcargo fuzz, neither of which a host-tests case runs, so both are run by hand. - A shared
littlefsdis shared state. Principals on one volume share one server’s memory and scheduling; where that matters, each gets its own volume and instance. A mint walks its root’s directory once, so minting in a loop costslittlefsdthat walk each time. - A refused rename or remove says a live root is there. A connection that tries to move or remove a directory learns whether some connection is rooted at or under it.
Why
- One instance per volume. A filesystem parser is a large surface on hostile bytes; one per medium keeps an exploit inside the medium it came from.
- Labels per volume, not per file. Per-file labels would put labelled and unlabelled metadata in one directory structure, a channel through its layout; a volume per label set has none.
- Fids do not follow renames. Following them needs a table of every file a client has walked to, keyed on a number littlefs does not keep; a path and an id need no memory beyond the fid.
- Quotas per attach root. A shared volume without them lets any client fill it; a quota carved from the granter’s keeps the whole tree of grants within what its root was given.
- littlefs, reimplemented. A published format with a second implementation gives a differential oracle; the C library wrapped in Rust would put C on the target.
- Writable volumes only. A volume written once on the build host and read whole at every boot
gains nothing from littlefs’s commits and pays for its metadata walks on every lookup, so
read-only volumes are EROFS (
erofsd). - No wear levelling. A virtio disk levels its own wear; the code left out is code that cannot be wrong.
erofsd
Purpose
erofsd serves a read-only volume in EROFS, the Enhanced Read-Only File System, written
once on the build host and read whole at every boot. The system volume (OTP, Elixir and the
shell as objects) is one. Its clients see the same 9P face every file server shows
(littlefsd); what changes is underneath: a file is one sequential run of 4 KiB blocks,
found through one inode and one sorted directory, so a read of an object touches each of its
blocks once, and a verifier’s tree over the volume is read in order. It is named for the format
it serves (naming); a writable volume on flash, and the data volume, are
littlefs (littlefsd); the SSD’s writable volumes are walfs (walfsd).
Interface
The format
Status: built · tested (12)
- bench:erofs-corrupt
- bench:erofs-host-tests
- bench:erofs-oracle
- fuzz:erofs/image
- host:erofs::both_inode_sizes_parse
- host:erofs::both_layouts_read_whole_and_inline_tails_stay_in_their_block
- host:erofs::directory_blocks_are_counted_bounded_and_ordered
- host:erofs::every_superblock_field_out_of_range_is_corrupt
- host:erofs::extended_attributes_are_sized_and_bounded_never_read
- host:erofs::lookup_and_iteration_agree_with_the_packed_tree
- host:erofs::what_mkfs_erofs_packs_our_parser_reads_as_the_tree
- host:redoubt-erofsd::a_volume_that_is_not_in_the_subset_is_served_as_corrupt
EROFS is Linux’s read-only file system, documented in the kernel tree
(Documentation/filesystems/erofs.rst) and written by mkfs.erofs (erofs-utils). erofsd
reads the uncompressed subset, with 4 KiB blocks:
- the superblock at byte 1024 of block 0: the magic, the block size, the root inode’s number, where the inode area begins, and the block count;
- inodes, addressed by number as an offset into the inode area, in either of the format’s
two sizes (compact, 32 bytes; extended, 64 bytes): the mode, the size, the link count, the
data layout and the data’s first block, and a count of extended attributes, which
erofsdskips over and never interprets; - two data layouts: flat plain, the file’s bytes in consecutive blocks from its first block, and flat inline, the same with the last partial block stored after the inode; every other layout (the compressed and the chunk-based ones) is refused as corrupt;
- directories as blocks of fixed-size entries (the child’s inode number, the offset of its name in the block, its type) followed by the names, each block’s entries sorted by name, so a lookup is a binary search per block and a listing is one pass.
Everything erofsd reads is bounded before it is used: the superblock’s block count against
the range’s size, every inode’s offset against the inode area, every data block against the
block count, every name offset against its block, and the entry count against the block size. A
volume that fails any of these, or asks for a layout outside the subset, is corrupt and is
served as littlefsd serves one
(R49 (a hostile medium is corrupt, not a crash)):
every attach refused with corrupt, the server up. The parser is these checks, two inode
sizes, two layouts and a sorted directory: the smallest thing that can stand between a hostile
medium and a client, and under
R47 (one volume per instance) a client who exploits it
reaches that volume’s data and nothing else; there is less of it to exploit.
erofsd does not hash a file as it serves it: integrity is the verifier’s
(verityd), not the parser’s. The packer records each file’s SHA-256 as the
extended attribute user.sha256, for tools and audits (a volume’s listing can be checked
against what was packed); erofsd skips it with every other attribute.
Serving
Status: built · tested (16)
- bench:erofs-read-only
- host:redoubt-erofsd::a_directory_lists_exactly_its_children_in_order_by_page
- host:redoubt-erofsd::a_minted_connection_sees_only_below_its_root
- host:redoubt-erofsd::a_read_is_one_range_read_and_a_walk_reads_no_more_than_it_needs
- host:redoubt-erofsd::a_session_rooted_below_the_root_sees_only_there
- host:redoubt-erofsd::every_way_of_writing_is_refused_read_only
- host:redoubt-erofsd::files_read_back_byte_for_byte_at_block_edges_past_the_end_and_across_an_inline_tail
- host:redoubt-erofsd::stat_says_the_inode_and_the_name_it_was_walked_by
- host:redoubt-erofsd::the_client_library_reads_the_volume_through_blkd
- host:redoubt-erofsd::the_conformance_vectors_run_against_erofsd
- host:redoubt-erofsd::the_image_entry_holds_its_buckets
- host:testbench::every_manifest_entry_holds_its_buckets_at_their_caps
- host:redoubt-erofsd::the_volumes_labels_are_checked_on_every_node
- host:redoubt-init::an_erofsd_entry_is_a_volume_server_as_a_littlefsd_one_is
- host:testbench::the_file_servers_fid_caps_leave_every_connection_its_root_and_working_share
- bench:lease-code-load
erofsd is started by init as littlefsd is, one instance per read-only volume, with the same
arguments (endpoint=, buckets=) and the same range: a blkd range for an unverified
volume, a verityd range for a verified one (blkd, verityd). Its
clients share the budget its manifest entry gives, the entry’s pages less its heap cap and stack,
by the entry rule (walfsd): a bucket at its caps costs 286,720 bytes, and every
session’s domain is one, beside the steward’s (the steward);
the image’s six (a case’s extra client the sixth) are 1,720,320 bytes, 420 pages, which with the
heap cap’s 26 and the stack’s 128 round to 768 of the image’s 1,024, and the entry leaves 870 pages
of budget, twelve such buckets and not a thirteenth.
A bucket holds 28 minted connections: what a manifest lets one account hold at the system volume
at once, whoever minted them (serving), two per session the steward starts
(its shell’s and its relay’s) for the console session, the cap of live contexts per label set and
one spare for a context detaching while another attaches, and one per lease, the leases what the
principal’s unlabelled share holds in agent budgets (the steward), held by
the bench to at least the largest such sum over the tree’s manifests, 2 × (1 + 3 + 1) + 18 under
steward-latency‘s manifest, 2 × (1 + 3 + 1) + 7 under the image’s
(walfsd, the same derivation). A bucket holds 57 fids, derived from the same
connections (serving): each holds its root fid for its life, a fixed cost
outside the fair share, and beside it one working fid at a time, a module file walked, opened,
read and clunked by the VM’s one reader, so the cap is the 28 roots and a pool of one per
connection and one over, 28 + 29, which the share max(1, pool / (holders + 1)) divides into
exactly one each with every connection holding. The account’s concurrent VMs at the system
volume are bounded by this and nothing else on the read path: before the root left the share,
sixteen connections of one account (two sessions’ and fourteen leases’) at the cap of 32 held one
fid each, the root, and no VM of the account could load a module, so a lease’s VM died or
parked by where the refusal landed and the supported count was thirteen leases beside two
sessions, seven for anything the shell’s hub needed at the data volume
(walfsd); now every connection the manifest lets the account hold has its
root and its working fid, so the supported count is the manifest’s own lease term, the sixteen
leases steward-latency’s alice carves beside her two sessions and the image’s seven with
margin, held by bench:lease-code-load (sixteen agents of one account loading 27 modules each at
once, every one ending by its own exit and none refused, on both widths at one and two harts).
The case runs on the host’s clock: under the virtual clock every boot launched and loaded all
sixteen and ended fifteen, the sixteenth destroyed at its own deadline the instant the guest
idled with no nearer timer, the bench’s clock finding and not the read path’s
(the test bench); on the host’s clock every boot ends all
sixteen. The read path
is slow under that load, never a death’s cause: beside three to eleven others a lease’s VM
starts (its boot pack of about 3.8 MB and its start module) in 35–140 s on either width at one
hart and 43–143 s at two, loads at 30–85 KB/s per VM after that, and launches in 1.3–11.5 s
(scheduling); verityd holds about a tenth of the
blocks asked and read-ahead gives about 2.2 blocks a blkd read
(verityd). A buckets=N the budget cannot hold
and erofsd does not start. At start it reads the superblock and the root inode, then serves:
attach,walk,openfor reading,read,stat, directory reads andclunk, with the serving library’s 9P skeleton, admission and label check as every file server (serving);- a
walkof one component reads the directory’s blocks and finds the name; the inode it names is read once and kept with the fid; - a read of a file is one range read of its blocks from its first block plus the offset, cut to the file’s size, with an inline tail copied from the inode’s block;
write,create,removeand any open for writing are refused withread-only; there is no quota, since nothing is written, and attributes are not served.
Each connection is rooted where the granting party chose with new_connection, as littlefsd’s are
(littlefsd); every node reports the volume’s labels.
Memory is bounded: one block of scratch for directory and inode reads, and one inode per open
fid.
The packer
Status: built · tested (7)
- bench:erofs-oracle
- bench:erofs-read-only
- bench:userland-boot
- host:erofs::the_writer_refuses_what_it_cannot_name
- host:erofs::two_packs_of_one_tree_are_the_same_bytes
- host:erofs::what_our_writer_packs_fsck_erofs_checks_and_extracts_as_the_tree
- host:testbench::an_erofs_partition_is_its_stage_and_each_damage_is_corrupt_where_it_is
The bench’s disk packer writes an EROFS volume from a staged directory (fs = "erofs" in a
disk recipe, in place of fs = "littlefs"), with Redoubt’s own writer in libs/erofs: flat
plain and flat inline layouts only, no compression, sorted directories, the user.sha256
attribute per file, deterministic (the same tree gives the same bytes; no timestamps but a
fixed one). The build host’s mkfs.erofs (erofs-utils, installed by the setup script) is a test
oracle, as littlefs’s C reference is (littlefsd): a volume our writer packs is
checked by fsck.erofs, and one mkfs.erofs packs from the same tree is mounted and read by
our parser with equal results. Nothing of erofs-utils runs on the target. The userland disk’s
recipe packs its objects this way. A verified volume is followed in its range by the verifier’s
tree over its blocks, as any verified volume is (verityd), and the rest
of its range, zeros, is covered too. A file’s blocks are consecutive, so each is read once; the
blocks read again are a directory’s and those holding inodes, which
verityd’s cache of checked data blocks holds.
Authority
Status: built · partly tested: that erofsd holds nothing but its endpoint and its range is init’s placement, checked by the manifest’s tests, and not probed from inside as littlefsd-one-volume does · tested: host:redoubt-erofsd::the_client_library_reads_the_volume_through_blkd, host:redoubt-init::an_erofsd_entry_is_a_volume_server_as_a_littlefsd_one_is
erofsd holds its own endpoint and one range, at blkd or at a verityd, and nothing else. It
sends no write; verityd refuses every write, and blkd refuses them only on a read-only disk.
It parses one format’s subset, bounded as above.
Security properties
Status: built · tested: bench:erofs-corrupt, fuzz:erofs/image, host:redoubt-erofsd::a_range_that_fails_makes_the_volume_corrupt_until_erofsd_starts_again, host:redoubt-erofsd::the_volumes_labels_are_checked_on_every_node
erofsd claims no rule of its own. It keeps littlefsd’s R47 (one volume per instance), R49 (a
hostile medium is corrupt, not a crash) and the serving library’s R25 (the label check) and
R26 (admission fairness), each stated on its owning page, and under a verifier the volume’s
R76 (verified volumes).
Failure and restart
Status: built · partly tested: a restart is init’s, as for every server, and no case restarts an erofsd · tested: bench:erofs-corrupt, host:redoubt-erofsd::a_range_that_cannot_be_sized_is_no_volume, host:redoubt-erofsd::a_range_that_fails_makes_the_volume_corrupt_until_erofsd_starts_again, host:redoubt-erofsd::a_volume_of_noise_or_a_failing_range_is_served_as_corrupt, host:redoubt-erofsd::arguments_it_does_not_understand_or_no_range_stop_it_before_serving, host:redoubt-fileserver::arguments_it_does_not_understand_stop_it_before_serving
A bad argument or a missing range handle is an exit at start; a corrupt volume is served as
corrupt, not an exit. init restarts erofsd on the same endpoint as any server, and a
restarted erofsd reads the same superblock and serves the same files: a read-only volume has
no state to lose.
Residual risks
- A subset of a larger format. EROFS has compressed and chunked layouts, which
erofsdrefuses; an imagemkfs.erofswrites with other options may use them, so the bench packs with Redoubt’s writer and checksmkfs.erofs’s output (erofs-utils 1.9) only with the options that stay in the subset: no-z(compression), no--chunksize(chunks) and no-Efeature that is off by default (fragments,dedupe,48bit,dot-omitted), with-b4096,-T0 and--all-root, and each of-Eforce-inode-compact,-Eforce-inode-extended,-E^inline_dataand--root-xattr-isize=64(libs/erofs/tests/oracle.rs). - No hashing on the serving path. Without a verifier, a read-only volume’s bytes are trusted as the medium gives them; the per-file digests are checked only by tools. The system volume is verified (R75 (verified userland)).
- Directory blocks are checked one at a time. Each block’s names are checked in order, but
not one block’s against the next; a walk binary-searches the blocks by their first and last
names, so a volume whose blocks are out of order makes a listed name walk to
not_found. It is a wrong answer, not an unsafe one, and a verified volume’s blocks are the ones its builder wrote. - Extended attributes are skipped, not parsed. Their count and size are bounds-checked so that skipping them cannot leave the inode area; their content is never read.
Why
On littlefs, a log-structured file system built for flash that is written in place, files are
skip lists of blocks, read a block-sized piece at a time with the list re-walked for each piece,
and directories are metadata pairs replayed at mount. For a volume that is written once on the
build host and read whole, module by module, at every boot, that costs each block many reads
and, under a verifier, many hashes per boot (verityd). Nothing
littlefs does for a writable medium (wear, power loss, commits)
applies to a volume nothing writes. EROFS is the format the access pattern asks for, with a
standard tool chain on the host, and its uncompressed subset’s parser is about a quarter of the
code of the one it replaces (libs/erofs is 578 lines with its writer, libs/littlefs 2,033),
which under R47 is a smaller thing to trust. Writable volumes keep littlefs, which is built for a
written medium.
walfsd
Purpose
walfsd serves each writable volume on the SSD in walfs, a write-ahead-log file system of
Redoubt’s own: a superblock, a log of whole blocks, a table of inodes, a block bitmap, and a
SHA-256 for every block, checked on every read. littlefs (littlefsd) is built
for raw flash, with wear levelling and erase units an SSD does not need, and it checksums no
data; walfs makes every operation one transaction, data and metadata together, and finds a
damaged block where it is read. ext2, the alternative, has a host tool chain to test against
but no journal and no checksums, so power loss and a hostile medium would be met by rules on
top rather than by the format.
Interface
The format
Status: built · tested (35)
- bench:walfs-host-tests
- fuzz:walfs/image
- fuzz:walfs/mutate
- host:walfs::a_damaged_hash_block_is_refused_as_itself
- host:walfs::a_directory_sized_as_the_largest_file_is_corrupt_at_its_first_hole
- host:walfs::a_forged_log_header_is_corrupt
- host:walfs::a_forged_orphan_list_ends
- host:walfs::a_forged_size_reads_as_holes_and_allocates_nothing
- host:walfs::an_indirect_entry_outside_the_data_region_is_corrupt
- host:walfs::an_orphan_open_at_unmount_is_freed_at_mount
- host:walfs::a_reserved_bitmap_bit_clear_fails_the_mount
- host:walfs::a_torn_log_is_dropped_or_replayed_never_half_applied
- host:walfs::a_volume_reports_its_data_blocks_and_inode_count
- host:walfs::bad_arguments
- host:walfs::crash_at_every_write_fixed_workload
- host:walfs::crash_at_every_write_random_workloads
- host:walfs::crash_during_recovery
- host:walfs::crash_inside_a_write_of_many_transactions_leaves_a_prefix
- host:walfs::crash_while_freeing_a_large_file
- host:walfs::cycles_and_shared_blocks_are_found
- host:walfs::directories_grow_past_their_direct_blocks
- host:walfs::every_bad_attribute_area_is_corrupt
- host:walfs::every_bad_directory_entry_is_corrupt
- host:walfs::every_flipped_bit_is_corrupt_where_it_is_read
- host:walfs::every_inode_field_out_of_range_is_corrupt
- host:walfs::every_superblock_field_out_of_range_is_corrupt
- host:walfs::full_volume
- host:walfs::generations_and_mtimes
- host:walfs::handles_follow_renames_and_outlive_removal
- host:walfs::large_and_sparse_files_free_every_block
- host:walfs::noise_never_panics
- host:walfs::random_operations_crowded_small_volume
- host:walfs::random_operations_large_volume
- host:walfs::random_operations_small_volume
- host:walfs::the_records_are_the_pages_tables
libs/walfs implements this section and nothing beyond it: a field this page does not have is
a field the crate does not have. Its shape is xv6’s (the MIT xv6-riscv tree, kernel/fs.h and
kernel/log.c): a superblock, a redo log of whole blocks, fixed inodes with direct and
indirect blocks, directories of fixed entries, and a bitmap. The layout is Redoubt’s: 4 KiB
blocks, a hash per block, 64-bit file sizes, user attributes. Every integer is little-endian;
a block address is a u32, so a volume holds at most 2³² blocks (16 TiB).
The constants
| Name | Value | What it fixes |
|---|---|---|
BLOCK | 4096 | bytes per block, the SSD’s and the kernel’s page |
LOG_BLOCKS | 32 | the most blocks one transaction writes |
LOG | 33 | the log’s blocks: the header and LOG_BLOCKS |
INODE | 128 | bytes per inode; 32 inodes per block |
ATTRS | 256 | bytes of user attributes per inode; 16 areas per block |
HASH | 32 | bytes per SHA-256 |
HASH_SLOTS | 127 | slots per hash block, whose last HASH bytes are its own hash |
DIRENT | 260 | bytes per directory entry; 15 entries per block |
NAME_MAX | 255 | bytes in a name |
DIRECT | 12 | direct block addresses per inode |
PER_INDIRECT | 1024 | block addresses per indirect block |
ROOT | 1 | the root directory’s inode number |
A file holds at most 12 + 1024 + 1024² = 1,049,612 blocks, 4,299,210,752 bytes (4 GiB and
a little over); a write or truncate past that is FileTooBig, and a size past it on the medium
is corrupt.
Geometry
A volume of block_count blocks with inode_count inodes is laid out in this order, each
region starting where the one before it ends:
| Region | First block | Blocks |
|---|---|---|
| superblock | 0 | 1 |
| log | 1 | LOG: the header, then the logged blocks |
| inode table | 1 + LOG = 34 | inode_count / 32 |
| attribute table | after the inodes | inode_count / 16 |
| hash region | after the attributes | ⌈(block_count − LOG) / HASH_SLOTS⌉: a slot for every block but the log’s |
| bitmap | after the hashes | ⌈block_count / 32768⌉ |
| data | after the bitmap | the rest, at least one block |
inode_count is a multiple of 32, at least 32. Everything in the superblock but
block_count and inode_count follows from those two, and mount refuses a superblock whose
other fields differ from what they give, or whose block_count differs from the device’s. A
block address read from the medium outside the region it must lie in (a file’s or directory’s
block, or an indirect block, outside the data region; a log entry outside the inode table,
attribute table, bitmap and data region) is corrupt.
The superblock
Block 0, written by format and never again.
| Offset | Width | Field |
|---|---|---|
| 0 | 8 | magic, walfs\0\0\0 |
| 8 | 4 | version, 1 |
| 12 | 4 | block size, 4096 |
| 16 | 4 | block_count |
| 20 | 4 | the log’s first block, 1 |
| 24 | 4 | the log’s length in blocks, LOG |
| 28 | 4 | the inode table’s first block, 34 |
| 32 | 4 | inode_count |
| 36 | 4 | the attribute table’s first block |
| 40 | 4 | the hash region’s first block |
| 44 | 4 | the bitmap’s first block |
| 48 | 4 | the data region’s first block |
| 52 | 4 | the root’s inode number, 1 |
| 56 | 4008 | zero |
| 4064 | 32 | SHA-256 of bytes 0 to 4063 |
Mount checks the superblock’s own hash first, then its fields, then recovers the log, and only then checks the block against its slot, in a hash region as the last committed transaction left it.
The log
One transaction at a time, of at most LOG_BLOCKS blocks. The log’s first block is the
header; logged block k (from 0) is kept at log block 1 + k.
| Offset | Width | Field |
|---|---|---|
| 0 | 8 | magic, walfslog |
| 8 | 4 | commit: 0 for an empty log, 1 for a committed transaction |
| 12 | 4 | count of logged blocks, at most LOG_BLOCKS; 0 when commit is 0 |
| 16 | 32 × 36 = 1152 | LOG_BLOCKS entries of 36 bytes, one per logged block: its home address (4), then the SHA-256 of its 4096 bytes (32) |
| 1168 | 2896 | zero |
| 4064 | 32 | SHA-256 of bytes 0 to 4063 |
A transaction is written in this order, with the device’s sync at each step:
- every logged block to its place in the log;
sync; - the header with commit 1 and the blocks’ addresses and hashes: the commit;
sync; - every block to its home address;
sync; - the header with commit 0 and count 0;
sync.
Recovery at mount reads the header. If its own hash fails, the header was torn by a power
cut in step 2 (nothing went home yet) or step 4 (everything already had), so either way the
transaction is dropped and an empty header written. If it holds a committed transaction, each
logged block is read and checked against its hash in the header, each home address against the
regions, and no address may appear twice; a block that fails is corrupt, since step 1’s sync
put every logged block on the medium before the header that names it. The blocks are copied
home and the header emptied, as steps 3 and 4. A second cut inside recovery leaves the same
committed header, and copying home again writes the same blocks: recovery is repeated, not
undone.
The sync between steps is all the format asks of the device: no order between writes inside a
step, and a torn block write may leave the block in any state (its hash, in the header or the
hash region, catches it). blkd’s flush returns only when the device has made
everything before it durable, which is this sync; the host’s packer writes a RAM image, where
sync does nothing.
A transaction’s blocks are home in the inode and attribute tables, the hash region, the bitmap and the data region; never the superblock or the log. File data goes through the log as metadata does, so a block is overwritten in place inside a transaction, never copied elsewhere.
Inodes
Inode i is at byte (i mod 32) × 128 of block inode_start + i / 32.
| Offset | Width | Field |
|---|---|---|
| 0 | 2 | kind: 0 free, 1 file, 2 directory |
| 2 | 2 | nlink: 1 while a directory entry names it, 0 once removed |
| 4 | 4 | the next inode on the orphan list, 0 for none |
| 8 | 8 | size in bytes |
| 16 | 8 | generation |
| 24 | 8 | mtime, microseconds since the Unix epoch; 0 when the writer has no clock |
| 32 | 4 × 12 | direct block addresses |
| 80 | 4 | the single-indirect block |
| 84 | 4 | the double-indirect block |
| 88 | 40 | zero |
- A block address of 0 is a hole, read as zeros; block 0 is the superblock, so no data block has that address. An indirect block holds 1024 addresses, every one a hole or in the data region. Bytes past the size in the last block are zero, and no address is set past the last block the size reaches.
- A directory’s size is a whole number of blocks, with no holes.
- The generation counts the inode’s allocations: a free inode keeps the generation it had, every other field zero, and allocating it adds 1. A server tells a removed file from a later one in the same inode by the pair.
- Inode 0 is never allocated: its record is zero but for the next-inode field, the head of the orphan list. Inode 1 is the root directory, always allocated.
- There are no hard links (9P2000 has none): nlink is 0 or 1, and anything else is corrupt.
The orphan list holds the inodes whose blocks are still to be freed, linked from inode 0
through the next-inode field, each at most once (a walk longer than inode_count is corrupt).
An inode on it with nlink 0 was removed while open, or its freeing was cut short: all of its
blocks are freed, then the inode. One with nlink 1 was truncated: its blocks past its size are
freed. Mount finishes the list; an open file that is removed stays on it until its last handle
closes. So removing or truncating a file of any size is one transaction, seen before or after,
though freeing its blocks may take several. Between the two, a reader sees the volume as after
the operation in everything but space: the blocks still to be freed are not yet free for
another write, and nothing else differs. A mount between them finishes the freeing before it
serves anything, one transaction at a time from the list’s head.
User attributes
Inode i’s attributes are 256 bytes at byte (i mod 16) × 256 of block attr_start + i /
16: records of a type (1 to 255, one byte), a length (one byte) and that many bytes of value,
one record per type, ended by a type of 0 or the area’s end; every byte after the end is zero.
A value is at most 254 bytes, and all of one inode’s attributes share the 256. A free inode’s
area is zero, and allocating an inode writes its area in the same transaction. Setting one is
a transaction of the area’s block and its hash, so a file’s attributes and its data are
changed in separate transactions, each whole.
Directories
A directory’s blocks hold 15 entries each, at byte k × 260, then 196 zero bytes:
| Offset | Width | Field |
|---|---|---|
| 0 | 4 | inode number; 0 for a free slot |
| 4 | 1 | the name’s length, 1 to 255; 0 for a free slot |
| 5 | 255 | the name, zero past its length |
A free slot is zero throughout. A name is any bytes but / and 0, and neither . nor ..; no
two entries of a directory have one name. Entries are unsorted, and a lookup or a listing reads
the directory’s blocks in order: linear in its size, which suits the volumes’ directories of
tens to hundreds of entries, not of tens of thousands. No . or .. entries are stored, since
9P needs none. A new entry takes the first free slot, or a new block at the directory’s end; a
directory never shrinks until it is removed.
The bitmap
One bit per block of the volume, block b’s at bit b mod 8 of byte b / 8 of the region: 1
in use, 0 free. The blocks before the data region are always in use, and so are the bits past
block_count in the last bitmap block. A data block’s bit is 1 exactly when an inode, an
indirect block or a directory reachable from the root or the orphan list holds it: every
transaction that sets or clears an address sets or clears its bit with it. The mount reads the
bitmap whole. The volume check (check) walks every directory from the root and every inode
on the orphan list, reads every block they hold against its hash, reads every hash block, inode
and attribute area, and names each problem it finds: among them a block held with its bit 0,
one with its bit 1 that nothing holds, a block held twice, an inode named twice or held by
nothing.
The hash region
One SHA-256 slot for every block of the volume but the log’s, HASH_SLOTS to a hash block, and
in each hash block’s last HASH bytes (offset 4064) the SHA-256 of its first 4064. Slot i is
block i’s for the superblock (i = 0), and block i + LOG’s for the rest, from the inode
table on; it is at byte (i mod HASH_SLOTS) × HASH of block hash_start + i /
HASH_SLOTS. A slot holds the SHA-256 of its block’s 4096 bytes. A transaction that writes a
block writes its slot, so the slot’s hash block is in the same transaction, and its own hash is
written last.
A read of any block is checked before anything in it is parsed, and a mismatch is Corrupt
for that read
(R49 (a hostile medium is corrupt, not a crash)):
a hash block against its own hash, every other block against its slot, and a logged block
against the header that names it. The log has no slots because the header carries a hash for
every block it logs, and a transaction cannot write its own log blocks’ slots in itself. A hash
block’s own slot (its blocks are past the inode table, so they have one) is zero and never read:
it checks itself, so a damaged hash block is refused as that block, not as the blocks it
covers. A free block’s slot is whatever it last held; nothing reads a free block.
This finds a damaged medium, and one that lies about a block: a bit flipped, a block torn or lost, a block another block’s bytes. It does not find a medium that rewrites a block and its slot, and the slot’s block’s own hash, together to agree: it is not a signature, and not a tree under a sealed root.
What is corrupt
Each of these is Corrupt where it is read, and nothing else is:
- Any block that fails its check: the superblock or the log’s header against its own hash (a header that fails is a torn one and is dropped, not refused), a hash block against its own hash, a logged block against its header’s entry, every other block against its slot.
- The superblock: a wrong magic, or any field, the version, the block size or a zero byte
among them, other than what
block_countandinode_countgive; ablock_countother than the device’s. - The log’s header, its own hash holding: a wrong magic; a commit other than 0 or 1; a commit
of 1 with a count of 0, or of 0 with a count above 0; a count above
LOG_BLOCKS; a byte after the entries other than zero; a home outside the inode table, the attribute table, the hash region, the bitmap and the data region; a home twice. - The bitmap: a bit 0 among the blocks before the data region or past
block_count; freeing a block whose bit is already 0. - An inode: a kind above 2; a free inode with any field but its generation set (inode 0: but
its next-inode field); inode 0 allocated; an nlink above 1; a next-inode field past
inode_count; a size past the largest file; a directory’s size not whole blocks; an address neither a hole nor in the data region; a reserved byte other than zero. The root not an allocated directory with nlink 1 fails the mount. - The orphan list: a free inode on it, or a walk longer than
inode_count. - An indirect block: an address neither a hole nor in the data region.
- A directory: a block that is a hole; a free slot with any byte set; an inode number of 0
with a length, or a length of 0 with an inode number; an inode number past
inode_count; a name the format does not allow, or bytes after it other than zero; a block’s last 196 bytes other than zero; an entry naming a free inode or one with nlink 0; a name twice, where a lookup meets it. - An attribute area: a record that runs past the area’s end, a type twice, or a byte after the end other than zero.
Atomicity
The eight operations that change a volume are create (an open that creates), write,
truncate (an open that truncates, too), mkdir, remove, rename (over an existing file or
empty directory too), set_attr and remove_attr. Each is one transaction, data and metadata
together, and a power cut leaves the volume as it was before the operation or after it; a
write is one while it fits one transaction’s blocks. A write adds a block of data to its
transaction only while 14 of the LOG_BLOCKS are left: the data block, a single- and a
double-indirect block and three of the bitmap’s blocks, each with its hash block (12), and the
inode with its hash block (2). A larger
write is several transactions, each before or after, so a cut inside it leaves the file with
a prefix of the write that ends at a block boundary. Removing or truncating a file is one
transaction that the reader sees; the freeing of its blocks that follows may take more, from
the orphan list, and the next mount finishes it. A write that finds the volume full ends at
the last block there was room for and says how much it wrote.
Memory
The crate holds the bitmap whole (block_count / 8 bytes; 32 KiB for a 1 GiB volume), one
4 KiB buffer per block of the transaction being built (at most LOG_BLOCKS), the last hash
block read, a block of scratch for each block being read, and a few words per open handle. A
write goes to the medium in the call that makes it, so no handle holds a block.
The oracle
No second implementation exists to test against, so the crate is checked against a model, an
in-memory file system: random operations, valid or not, with handles held open across renames
and removals and volumes run full, each outcome compared with the model’s and the volume
remounted, read whole and checked as it goes (tests/model.rs). The power-loss harness cuts
the device at every block write of fixed and random workloads, with every write since the last
sync landing whole, not at all or torn, and accepts only the model’s state before or after the
operation, also when a second cut falls inside the recovery (tests/crash.rs). Hostile volumes
(noise, every block’s bits flipped in turn, and forged structures whose hashes are made to agree)
must read as before or as corrupt where the damage is read (tests/hostile.rs). The bench runs
these as walfs-host-tests; the two fuzz targets, image (arbitrary bytes mounted, walked and
written) and mutate (a valid volume with bytes changed, its hashes forged or not), need
cargo fuzz and are run by hand from libs/walfs.
The packer
Status: built · tested (1)
- host:testbench::a_walfs_partition_is_its_stage_and_two_packs_are_the_same_bytes
The bench’s disk packer writes a walfs volume from a staged directory (fs = "walfs" in a disk
recipe), with libs/walfs’s own code on a RAM device: format, with an inode for every 16
blocks rounded up to whole inode blocks (or, if more, for every staged entry and inodes 0 and 1,
rounded up the same way), then, in the stage’s order, a mkdir for each directory and a create
and a write for each file, each the transactions it takes. It is deterministic: the same tree gives the same bytes, with every mtime 0.
Serving
Status: built · tested (39)
- bench:steward-three-contexts
- bench:walfsd-boot
- bench:walfsd-confined-labelled
- bench:walfsd-corrupt-volume
- bench:walfsd-flipped-block
- bench:walfsd-host-tests
- bench:walfsd-label-check
- bench:walfsd-reboot
- bench:lease-code-load
- host:redoubt-init::a_walfsd_entry_is_a_volume_server_as_a_littlefsd_one_is
- host:testbench::the_servers_admit_what_a_manifest_lets_one_account_hold
- host:testbench::every_manifest_entry_holds_its_buckets_at_their_caps
- host:testbench::every_servers_buckets_leave_the_open_call_headroom
- host:redoubt-walfsd::a_blank_range_is_formatted_and_only_a_blank_one
- host:redoubt-walfsd::a_cut_at_every_write_of_a_write_and_a_rename_leaves_before_or_after
- host:redoubt-walfsd::a_device_that_fails_makes_the_volume_corrupt_until_it_is_mounted_again
- host:redoubt-walfsd::a_flipped_bit_in_a_file_is_corrupt_for_that_file_alone
- host:redoubt-walfsd::a_range_too_small_is_no_volume
- host:redoubt-walfsd::a_read_only_range_is_never_written
- host:redoubt-walfsd::a_read_only_volume_refuses_every_typed_change
- host:redoubt-walfsd::a_removed_files_other_fids_get_removed
- host:redoubt-walfsd::a_rename_over_a_file_removes_it
- host:redoubt-walfsd::a_sequential_read_costs_a_few_block_reads_per_request_and_per_block
- host:redoubt-walfsd::a_strangers_fid_is_not_found
- host:redoubt-walfsd::arguments_it_does_not_understand_stop_it_before_serving
- host:redoubt-walfsd::attach_walk_open_read_write
- host:redoubt-walfsd::attributes_set_and_get_with_the_reserved_types_refused
- host:redoubt-walfsd::copy_file_copies_and_counts_the_bytes
- host:redoubt-walfsd::every_transaction_is_four_flushes
- host:redoubt-walfsd::fids_are_bounded_and_disconnect_frees_them
- host:redoubt-walfsd::files_and_directories_survive_a_remount
- host:redoubt-walfsd::listing_a_directory_reads_it_once_per_window
- host:redoubt-walfsd::noise_is_never_formatted_and_never_mounted
- host:redoubt-walfsd::rename_moves_within_the_volume_and_keeps_the_inode
- host:redoubt-walfsd::the_client_library_works_against_walfsd
- host:redoubt-walfsd::the_conformance_vectors_run_against_walfsd
- host:redoubt-walfsd::the_image_entry_holds_its_buckets
- host:redoubt-walfsd::the_volumes_labels_are_checked_on_every_request
- host:redoubt-walfsd::typed_operations_check_the_volumes_labels
walfsd serves walfs as littlefsd serves littlefs
(littlefsd), with the same 9P face, labels and
typed operations, so a client names no format:
- One instance per volume. A
walfsdholds one block-range handle,volume, its partition atblkd, and no MMIO, interrupt or DMA.initstarts it as it startslittlefsdanderofsd: aserversentry whoseprogramiswalfsdand whosevolumenames the volume, with the argumentsendpoint=NAME(walfsd:data),labels=ID[,ID...]andbuckets=N(init). A block is eight ofblkd’s sectors, so the volume’s block count is its range’s sectors divided by 8; a range of fewer than 40 blocks, the smallest volume at the packer’s inode density, is no volume andwalfsdexits. - Mounting. A range whose first two blocks (the superblock and the log’s header) are all zero
has never been written, and
walfsdformats it at the packer’s inode density (The packer). Any other range is mounted, which recovers a transaction left in the log (The log) and finishes the orphan list; one that does not mount is served as corrupt: every attach is refused withcorrupt,walfsdsays so on its console, and it stays up, so a damaged or hostile medium never becomes a restart loop.walfsdnever formats a range that holds anything. A rangeblkdreports read-only is served read-only: every change is refused before it reachesblkd, and a blank one is not formatted. syncisblkd’sflush. walfs’s devicesync, between each of a transaction’s four steps (The log), is aflushon the range, which returns only when the device’s own flush has completed (blkd): four flushes a transaction, and nothing written is acknowledged before them.- A fid is a path, an inode and a generation. A node is the path its client walked, built only
from names clients walked or created, and the inode and generation the file had there
(Inodes). Every request finds the path again and checks the pair: an entry gone, or
another file in its place, even in the same inode at a later generation, is
removed. So a remove or a rename over a file ends every other fid on it, and fids do not follow renames, as onlittlefsd. A qid’s path is the inode and its version the generation’s low 32 bits, so a qid names one file for the volume’s life, and survives a reboot. - A remove ends the file at once. No walfs handle outlives a request, so a removed file’s blocks are freed in the same call, and nothing removed holds space or quota.
- One transaction an operation. Each of 9P’s
create,write(one transaction while it fits one, Atomicity),removeand an open that truncates, and each typedrenameandset_attr, is the walfs operation of the same name; a power cut leaves it before or after (R50 (power loss leaves before or after), on walfs volumes). - Damage is corrupt where it is read. A block that fails its hash, or any structure the format
calls corrupt (What is corrupt), fails the request that read it with
corrupt(theRerrortext for 9P, the table’scorruptfor a typed operation), and nothing else: other files read and the volume still writes. An I/O error fromblkdpoisons the volume untilwalfsdstarts again, as onlittlefsd. - Labels are per volume. Each volume has one label set, and
walfsdreports it as every node’s, so the skeleton’s label check runs on every request (R25 (the label check)); there are no per-file labels, owners or permission bits. - Typed operations are
littlefsd’s table, served alike:rename,copy_file,set_attrandget_attr(littlefsd). Attributes live in the inode’s 256-byte area (User attributes): types 16 to 255 are the user’s and 0 to 15 arenot_permitted, and every refusal haslittlefsd’s name for it, so a client sees one contract; a value is at most 254 bytes (too_largeabove), and attributes that no longer fit the area areno_space.copy_filewrites a new file a block at a time and removes it if it does not finish. Times: a file’s mtime is walfs’s, 0 until a clock reacheswalfsd. - Listings are served from a window of up to 64 entries filled by one pass over the
directory, as on
littlefsd, so listing n entries costs about n / 64 passes. - Admission is the serving library’s, with
littlefsd’s caps per bucket (R26 (admission fairness)), the completion calls per bucket derived from what a manifest lets one account have open at once (a parked call per live VM of the account, what its unlabelled share carves at once in sessions and leases, and a spare: the image’s alice 40,959 / 5,248 + 1 = 8, the tree’s largest 21), and the connections the account’s whoever minted them, the steward’s share holding only its kept carves (littlefsd); a crossing’s program (the steward) makes plain calls, no completion call, and holds one connection record of the account’s bucket for at most a second. Its budget is its manifest entry’s, the entry’s pages less its heap cap and its stack, which every bucket at its caps must fit orwalfsddoes not start (serving). The entry rule: an entry’s pages are what its count of buckets at their caps needs, plus its heap cap and its stack at the launched bound of 128 pages (a program does not learn its own, so the server’s check is conservative by the bound less the entry’s stack), rounded up to 256; a case’s manifest sizes by the same rule, never by what its case happens to use, and the bench holds every entry in the tree to it (every_manifest_entry_holds_its_buckets_at_their_caps). A bucket at its caps is 1,697,280 bytes: the image’swalfsd:datawith six is 10,183,680 bytes, 2,487 pages, which with its heap cap of 64 and the stack’s 128 is 2,679, so 2,816, the entry’s, which leaves 2,624 pages of budget, six such buckets and not a seventh;walfsd:alice-secretswith five is 2,072 + 192 = 2,264, so 2,304, its entry; an entry of four buckets is 1,658 + 192 = 1,850, so 2,048, and one of nine 3,730 + 192 = 3,922, so 4,096. An entry may keep more pages than the rule asks (the memory rule below is the other bound), never fewer. - Fids. A bucket holds 71, derived from the same connections (serving):
each holds its root fid for its life, a fixed cost outside the fair share, and beside it its
working fids, two for the shell’s hub (its working directory and the fid of the request in
hand) and one for a lease’s VM, so the cap is the 23 roots and a pool of two per connection and
two over, 23 + 2 × 24, which the share
max(1, pool / (holders + 1))divides into two each with every connection holding. Before the root left the share, the hub lost its working fid at this volume (emfile) from the eighth lease of its account beside two sessions, and at the system volume’s cap of 32 the account’s VMs could load nothing from the fourteenth; the supported count is now the manifest’s own lease term, held by bench:lease-code-load (erofsd): every lease’s VM attaches its/projectat launch (its namespace attaches each entry of its startup block once, beamlet), so the sixteen leases hold sixteen roots here beside the two sessions’ and read their input through their hubs, and the shell’s probe shows its own hub still admitted with all sixteen attached. - Memory is the format’s (Memory), a node per fid (its path), the listing window, and the quota’s records; the image declares the heap the memory scan measured (testbench).
- Reads. Each request finds its file by path and opens it, about nine block reads with their
hash blocks, then reads its data blocks, each about two block reads, since a data block’s hash
block takes turns with the inode table’s in the one hash block walfs keeps: 10.8 block reads per
data block in 4 KiB reads, 2.9 in 32 KiB reads.
walfsdkeeps no block cache.
Quotas
Status: built · tested (11)
- bench:walfsd-quota
- bench:walfsd-pools
- host:redoubt-walfsd::a_copy_past_the_quota_is_no_space
- host:redoubt-walfsd::a_mint_at_a_stale_root_records_nothing
- host:redoubt-walfsd::a_mint_the_room_cannot_take_is_refused_and_disconnect_gives_it_back
- host:redoubt-walfsd::a_rename_between_two_roots_moves_the_bytes_and_never_ends_a_live_root
- host:redoubt-walfsd::a_root_minted_over_files_counts_them
- host:redoubt-walfsd::a_root_with_quota_0_cannot_create_but_can_read_and_remove
- host:redoubt-walfsd::a_write_past_one_roots_quota_is_refused_while_another_still_writes
- host:redoubt-walfsd::file_bytes_counts_data_and_indirect_blocks
- host:redoubt-walfsd::the_volume_never_runs_out_while_every_root_is_within_its_quota
A quota is in bytes, carved at new_connection from the room of the live root above, and
kept under littlefsd’s rules and refusals, by the one ledger the two servers share
(redoubt-fileserver‘s): a root’s quota is the sum of its connections’, a change is charged to
the nearest live root above it, a root minted over more than its quota can read and remove only,
nothing is stored on the medium, and a rename or remove never ends a live root
(littlefsd). What walfs counts:
- The volume root’s room is the data region’s bytes: its blocks times 4096.
- An entry holds its share, ⌈room / (
inode_count− 2)⌉ bytes (inodes 0 and 1 are never an entry’s), and 4096 bytes for each data block its size reaches, holes included, and each indirect block that would map them; a directory holds its own blocks too, and what lies under it. So entries that fit a quota never take more inodes than the volume has, nor blocks: no quota is a promise the volume cannot keep, in blocks or inodes. - A change is refused before its transaction begins. A write needs the bytes its new size adds; a create, its share and a block its directory may gain; a copy, the whole file; a rename between two roots, what moves less what it replaces, and the block. What the change then made is charged: the file’s size, the directory’s blocks, as they are after it.
With the packer’s inode for every 16 blocks, the share is about 64 KiB, so a root of quota Q holds about Q / 64 KiB entries, fewer if they hold data (Residual risks).
The steward is the main granter: it carves each principal’s home once, with the manifest’s
home_quota, and mints every session’s home through that connection with no quota of its own,
so a principal’s sessions share one root and one quota
(steward). What a home can hold is a little under its
home_quota, since every entry costs its share as well as its blocks: in steward-home-quota, an
8 MiB home with two files in it held 7,936 KiB of data. A vault session’s connection is minted at its
labelled volume’s own root, which carves nothing: a vault is bounded by its volume’s room. What
init checks, that a volume’s home quotas fit its bytes
(init), is a promise about the partition; the bound itself is this
server’s refusal of a carve its room cannot hold.
Authority
Status: built · tested: bench:walfsd-one-volume
walfsd holds its endpoint, its one block-range handle at blkd, the console init gave it,
and the connections it mints, as littlefsd does. It holds no device, no budget handle and no
connection to any other file server. What a client may reach is the subtree its connection is
rooted at, under the volume’s labels and its root’s quota.
Security properties
R47 (one volume per instance), on walfs volumes
Status: built · tested (2)
- bench:walfsd-one-volume
- host:redoubt-init::a_walfsd_entry_is_a_volume_server_as_a_littlefsd_one_is
Each walfsd instance serves one volume and holds only its block range, placed by init as a
littlefsd’s is: a parser exploit through a crafted volume or request reaches that volume and
nothing else.
R48 (a quota per attach root), on walfs volumes
Status: built · tested (3)
- bench:walfsd-quota
- host:redoubt-walfsd::a_root_with_quota_0_cannot_create_but_can_read_and_remove
- host:redoubt-walfsd::a_write_past_one_roots_quota_is_refused_while_another_still_writes
Every connection’s root has a byte quota carved from its granter’s, and no change takes a root past it (Quotas); an entry’s share keeps inodes, as well as blocks, within what the quotas promise, so one principal filling a shared volume cannot make another’s creates or writes fail.
R49 (a hostile medium is corrupt, not a crash), on walfs volumes
Status: built · tested (8)
- bench:walfsd-corrupt-volume
- bench:walfsd-flipped-block
- fuzz:walfs/image
- fuzz:walfs/mutate
- host:redoubt-walfsd::a_flipped_bit_in_a_file_is_corrupt_for_that_file_alone
- host:redoubt-walfsd::noise_is_never_formatted_and_never_mounted
- host:walfs::every_flipped_bit_is_corrupt_where_it_is_read
- host:walfs::noise_never_panics
Whatever bytes the medium holds, walfs refuses them as corrupt where they are read rather than
panicking, looping or allocating past the volume (What is corrupt), and
walfsd answers corrupt for that request alone; a volume that does not mount is served as
corrupt, and walfsd stays up. A block of a file changed on the medium is found by its hash, so
unlike littlefs’s, a walfs volume’s data is checked too.
R50 (power loss leaves before or after), on walfs volumes
Status: built · tested (5)
- bench:walfsd-power-loss
- host:redoubt-walfsd::a_cut_at_every_write_of_a_write_and_a_rename_leaves_before_or_after
- host:walfs::crash_at_every_write_fixed_workload
- host:walfs::crash_at_every_write_random_workloads
- host:walfs::crash_during_recovery
On a device that keeps blkd’s flush, a power cut at any block write leaves each operation,
data and metadata together, as before it or after it (Atomicity), and the next mount
recovers the log. The bench cuts walfsd itself after a block write drawn from its seed, inside a
write of three blocks and a rename, and the restarted instance serves the file as before or after
each, with the volume check finding nothing.
Failure and restart
Status: built · tested: bench:walfsd-power-loss, bench:walfsd-corrupt-volume, bench:walfsd-reboot, host:redoubt-walfsd::a_poisoned_volume_says_so_once, host:redoubt-fileserver::a_fault_says_why
walfsdcrashes: its clients’ calls getDead,initrestarts it on the same endpoint (init), and its mount recovers a cut transaction from the log, so the volume is as of its last committed transaction. Clients ask for fresh connections.- The medium is corrupt: a volume that does not mount is served as corrupt, never exited on; a damaged block fails the requests that read it.
- An I/O error from
blkdpoisons the volume untilwalfsdstarts again, whose mount recovers what the error left.walfsdsays so once on its console,walfsd: volume poisoned: <operation>: <reason>, with the reason its range client noted: the call failed,blkdanswered an error, or a reply was malformed.
Residual risks
- The hash is not a signature. A medium that rewrites a block and its slot together is not caught; a hash tree with a sealed root would be, at a tree update per write, and is not part of the format.
- A header damaged after a cut is a dropped transaction. A committed header whose own hash fails reads as a commit torn by power loss, so the transaction it held is lost rather than refused as corrupt. The blocks it would have written are left as they were, each still checked by its slot.
- A directory lookup is linear in the directory’s size, and
walfsdfinds a file by its path on every request (Serving, reads). - No wear levelling: an SSD levels its own wear; walfs on raw flash would wear its log and hash region first. littlefs stays the format for raw flash.
- A large write is several transactions. Each is before or after, so a power cut inside a write of more than one transaction’s blocks leaves a prefix of it.
- A quota is counted coarsely. An entry costs its share (Quotas) whatever it holds, and a file every block its size reaches, holes included, so a quota holds fewer small or sparse files than its bytes suggest; and once every inode’s share is carved, the volume’s last directory blocks are out of every quota’s reach.
- A qid’s version is the generation, not a count of writes: a write does not move it, so a
client caching a file by its qid does not see the change. mtime is 0 until a clock reaches
walfsd. - A shared
walfsdis shared state, as a sharedlittlefsdis (littlefsd). - A write to the data volume under the account’s concurrent load is slow, and can time out.
Measured with four to twelve agents of one account loading code and writing their results at
once (bench:lease-code-load’s measurement, both widths): an agent’s 2 KB write to its project
directory through the shell’s hub took 8–29 s, and two of seven reached the hub’s 10 s give-up
(
etimedout, beamlet) and failed; an agent’s read of its input fails the same way. This is the write path’s throughput,walfsd‘s requests each finding their file by path and writing their transaction behind the others’, and the hub’s one request at a time per connection, not the fid admission above, which is sized now: a finding of its own, open until the agentic workload needs a bound on a write’s latency. On the host’s clock, twelve such agents writing a line after each load had 224 of their 348 writes served and the rest answeredetimedout, each within its own bound (bench:lease-writes, whose claim is the answer, not the rate). What that measurement first took for a write whose completion never came back (an agent of four stopped after a line, its VM alive for 2,000 s of guest time) was the virtual clock’s doing, not this server’s or the hub’s: an idle guest’s clock skips to its next armed deadline (the test bench), and the file ended at the line before the stalled step, so no served write’s answer was lost. Every hub request ends in exactly one answer to its caller within its bound, by construction (files).
Why
A format of Redoubt’s own gives the volumes what littlefs does not on an SSD: every operation one transaction, data with metadata, and every block checked where it is read. With no second implementation to test against, the oracle is a model: an in-memory file system driven by random operations, a power-loss harness that cuts the device at every write and accepts only the state before or after the operation, and image fuzzing against the parser. The log is the simplest thing that makes operations atomic on a block device: a redo log of whole blocks with one commit block, replayed at mount.
blkd
blkd is the virtio-blk driver. It owns one disk, reads its GPT partition table once, and serves
each partition to one file server, a littlefsd or an erofsd, as a
range of sectors, named by a badge. A client’s sector
numbers are relative to its range, the device only ever sees blkd’s own DMA region, and a device
that lies is refused for good rather than believed.
Purpose
A file server must see only its own partition, and the disk device must be driven by code that
trusts neither its clients nor the device. blkd does both in one small process: it turns a
badge into a window on the disk, copies every byte between a client’s lend and a DMA buffer the
device alone can reach, and treats every value the device writes as a claim to check. On a
machine where nothing confines DMA it is inside the trusted base, and it is written so that what
it asks the device to touch can be checked.
Interface
Ranges and badges
Status: built · tested (13)
- bench:blkd-host-tests
- host:redoubt-blkd::blkd_receives_on_the_endpoint_its_argument_names_and_never_guesses
- host:redoubt-blkd::a_block_round_trips_through_a_range_badge
- host:redoubt-blkd::info_describes_the_range_not_the_disk
- host:redoubt-blkd::a_full_run_round_trips
- host:redoubt-blkd::a_range_cannot_name_a_sector_outside_itself
- host:redoubt-blkd::a_badge_that_names_no_range_is_refused
- host:redoubt-blkd::roots_have_one_slot_per_gpt_entry
- host:redoubt-blkd::a_gap_in_the_table_does_not_renumber_the_volumes_after_it
- host:redoubt-blkd::a_labelled_caller_may_read_but_not_write
- host:redoubt-blkd::a_ranges_labels_come_from_its_argument
- host:redoubt-blkd::a_malformed_argument_a_partition_named_twice_or_none_is_refused
- host:redoubt-blkd::one_clients_read_never_carries_anothers_bytes
- A range is a badge. The root badge of GPT entry i is i + 1, counting every entry of the
array, used or not. So a launcher mints each volume’s range from the manifest’s entry number
without asking
blkd, a restartedblkdgives the same badges the same meaning from the same disk, and a badge naming an unused entry is refused like one past the end. Counting only used entries would renumber every volume after a gap. - Sector numbers are relative to the range, so no number a client can write names a sector
outside it. Turning one into a disk sector is one overflow-checked function (
range::Range). blkdmints nothing and remembers nothing. There is nograntand norelease: every range comes through a badge its launcher minted, and a client can makeblkdhold nothing beyond its request, so there is no admission to keep. A flood of requests is bounded by the kernel’s fair waiting (R2 (fair waiting)) and byMAX_SECTORSper request.- A verified volume’s range is held by its
verityd, which serves the same protocol to the volume’s file server, checking every block against the root the manifest pins. - Its endpoint.
blkdreceives on the endpoint its argumentendpoint=NAMEnames, the manifest’s name for it (blkd,blkd:system). Without one it does not start: oneblkdruns per disk, and it does not guess which is its own. - The label check runs on every request against the range’s labels, which
initgivesblkdas arguments (labels.P=ID,...for partition entry P; a range named by none has none) (R25 (the label check)): reading is a read, writing and flushing are writes. A badge that names no range and a caller who fails the check get the samenot_permitted.
Messages
Status: built · tested (7)
- host:redoubt-blkd::a_request_over_the_bound_is_refused_and_one_of_no_sectors_is_malformed
- host:redoubt-blkd::a_read_whose_reply_would_not_fit_the_lend_is_refused_before_the_disk_is_touched
- host:redoubt-blkd::malformed_requests_are_refused
- host:redoubt-blkd::arbitrary_requests_never_panic
- host:redoubt-blkd::a_read_only_device_refuses_writes
- host:redoubt-blkd::a_broken_read_only_device_does_not_answer_ok_to_a_flush
- fuzz:redoubt-blkd/request
blkd serves a typed protocol, not 9P: four operations on a range, and no namespace.
info: the range’s size in sectors, the sector size (512) and whether the disk is read-only.read(sector, count): at mostMAX_SECTORS(64) sectors, 32 KiB, into the caller’s lend. A read whose reply would not fit the lend is refused before the disk is touched; a count of 0 is malformed.write(sector, data): at mostMAX_SECTORSsectors, a whole number of them.
MAX_SECTORS and the sector size are the protocol’s limits, named once in the wire crate’s blkd
module (libs/wire/src/blkd.rs), beside the generated messages: blkd and every client of a
range take them from there (serving, the volume servers’
shared client).
flush: returns only when the device says its flush completed, so what was written before it is durable. The device must offer flush at negotiation; one that does not is refused at bring-up. On a read-only disk nothing was ever written, soflushanswers at once without asking the device (a broken device still answersfailed).
Errors: not_permitted; out_of_range for sectors outside the range; too_many for a request
over the bound; failed for a device error or a broken device; malformed for a request that
does not decode.
This is the block-device contract littlefs’s power-loss safety rests on
(littlefsd): a write overwrites whole sectors; a write torn by power loss persists
a prefix of its sectors, never an arbitrary subset of them; requests complete in order (one is
outstanding at a time); and sync is a flush that waits for the device. blkd keeps the order
and the flush itself; the torn-write prefix is the device’s behaviour, which blkd relies on and
cannot check (Residual risks).
The table: libs/wire/tables/blkd.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 1 | info | - | sectors: u64, sector_size: u32, read_only: u32 |
| 2 | read | sector: u64, count: u32 | data: bytes |
| 3 | write | sector: u64, data: bytes | - |
| 4 | flush | - | - |
| Code | Error |
|---|---|
| 2 | not_permitted |
| 3 | out_of_range |
| 4 | too_many |
| 5 | failed |
The DMA region
Status: built · tested (4)
- host:redoubt-blkd::a_broken_device_is_never_handed_a_clients_write_payload
- host:redoubt-blkd::a_short_write_reads_back_as_zeros_not_as_the_last_requests_bytes
- host:redoubt-blkd::impossible_requests_never_reach_the_device
- host:redoubt-blkd::a_hundred_thousand_random_liars_never_panic_and_never_stray
The device can reach one contiguous run of DMA_PAGES pages from dma_alloc
(devices), laid out by constants: the descriptor table, the
available ring, the used ring, one request header and one status byte in the first page, and one
data buffer of MAX_SECTORS sectors after it. Every address blkd writes into a descriptor is
the region’s base plus one of these constants, checked at compile time to lie inside the region
(queue::LAYOUT_FITS), and the only other addresses it gives the device are the three queue base
registers, which point into the same region. A client’s lent pages are never named to the device:
a write is copied from the lend into the data buffer before the request is offered, and a read is
copied out of the data buffer once, with a length blkd chose, before anything looks at a byte of
it (R51 (DMA stays in its region)).
flowchart TB
subgraph R["DMA region: DMA_PAGES pages from dma_alloc, the only memory the device is told about"]
direction TB
subgraph P0["the first page, base + 0 to base + PAGE_SIZE"]
direction LR
dt["descriptor table<br/>written, never read back"] --- ar["available ring<br/>written, never read back"] --- ur["used ring<br/>three values read, checked"] --- hd["header"] --- sb["status byte"]
end
DB["data buffer, from base + PAGE_SIZE: MAX_SECTORS × 512 bytes"]
P0 --- DB
end
L["client lend<br/>never named to the device"] -- "copied in before a write" --> DB
DB -- "copied out once after a read" --> M["blkd memory"]
Figure: the DMA region and where a client’s bytes go.
Device lies
Status: built · tested (13)
- fuzz:redoubt-blkd/device
- host:redoubt-blkd::rewriting_the_rings_changes_nothing_the_driver_believes
- host:redoubt-blkd::a_device_that_lies_about_a_completion_is_refused_and_never_spoken_to_again
- host:redoubt-blkd::a_lying_device_becomes_failed_and_stays_failed
- host:redoubt-blkd::lies_that_are_not_protocol_violations_are_still_harmless
- host:redoubt-blkd::a_reported_io_error_does_not_break_the_device
- host:redoubt-blkd::a_device_that_is_not_one_is_refused_at_bring_up
- host:redoubt-blkd::only_the_features_we_need_are_accepted
- host:redoubt-blkd::an_interrupt_storm_is_bounded_even_when_the_clock_has_failed
- host:redoubt-blkd::an_interrupt_storm_with_a_working_clock_ends_at_the_deadline
- host:redoubt-blkd::every_completion_acknowledges_the_interrupt
- host:redoubt-blkd::avail_flags_is_written_again_before_every_request
- host:redoubt-blkd::both_completion_paths_work
- Nothing the device writes is believed. The descriptor table and the available ring are
written from constants for every request and never read back, so a device that rewrites them (a
looping chain, a
nextout of range, an overflowing length) changes nothingblkdbelieves. Of the used ring three values are read, and each is checked:idxmust be exactly one more than the last seen; the entry, read from the slotblkd’s own counter names, must name the one descriptorblkdsubmits;lenmust not exceed what the device was given. No value the device writes into the rings is ever an index or a length. The two values it states once, the capacity at bring-up and the partition table on disk, are checked before use: the capacity bounds every range, and the table is parsed as hostile (below). - Bring-up checks the magic, version 2, the block device ID, and accepts only the features it needs (version 1, flush, read-only).
- A lie is permanent. A device that breaks the protocol, or misses
REQUEST_TIMEOUT_US(10 seconds), is marked broken and every later request getsfailed: carrying on after a timeout would mean a completion for a request no longer tracked, and carrying on after a lie would mean trusting the liar. A device that merely reports a failure (IOERR,UNSUPP) fails that one request. - Interrupt storms are bounded, by the deadline, and even when the clock has failed.
The fake device (servers/blkd/src/fake.rs) is deliberately hostile, and the host tests run the
whole driver against it, including a hundred thousand random liars
(R52 (a lie is a failure, never corruption)).
The partition table
Status: built · tested (13)
- fuzz:redoubt-blkd/gpt
- host:redoubt-blkd::a_good_table_reads_back
- host:redoubt-blkd::header_fields_sit_where_the_specification_says
- host:redoubt-blkd::crc32_matches_the_published_check_value
- host:redoubt-blkd::unused_entries_are_skipped_and_indices_are_the_entry_s
- host:redoubt-blkd::a_disk_with_no_signature_is_refused
- host:redoubt-blkd::a_header_crc_that_does_not_match_is_refused
- host:redoubt-blkd::an_entry_array_crc_that_does_not_match_is_refused
- host:redoubt-blkd::hostile_header_numbers_are_refused
- host:redoubt-blkd::hostile_partition_entries_are_refused
- host:redoubt-blkd::adjacent_partitions_are_allowed
- host:redoubt-blkd::arbitrary_header_bytes_never_panic
- host:redoubt-blkd::arbitrary_entry_arrays_never_panic
The GPT (UEFI 2.10, section 5.3) is the one on-disk structure blkd parses, once, at start.
- The medium is hostile. Every length, LBA and count is checked before use; the entry array is sliced with checked access and its size bounded (at most 128 entries of 128 to 512 bytes, at most 64 sectors) before it is allocated; every partition’s first and last LBA are checked against the disk’s capacity and the header’s usable range; both CRCs must match. A malformed table yields an error, never a panic.
- Overlaps are refused whole. Two partitions sharing a sector would let two volumes alias each other’s bytes (R53 (a filesystem sees only its partition)).
- Primary header only.
blkdnever writes a partition table, so a table that does not check out is a disk to refuse, not damage to repair: no fallback to the backup header, and the protective MBR is not read.
Started by init
Status: built · tested: bench:init-boot, bench:littlefsd-boot, bench:littlefsd-one-volume
init starts blkd with two named handles in its startup block, disk (the virtio MMIO region,
with DMA allowed) and disk-irq (its interrupt), placed from the boot manifest’s devices list
(init). blkd parses no device tree and hardcodes no address;
without both handles it does not start. init mints each volume server’s range badge from the
manifest’s volumes entry, and hands it to that server alone.
Authority
Status: built · tested: host:redoubt-blkd::a_range_cannot_name_a_sector_outside_itself, host:redoubt-blkd::impossible_requests_never_reach_the_device
blkdholds one disk’s MMIO region and interrupt, its DMA region, and its endpoint. It makes no calls to other servers.- It is trusted, on a machine without an IOMMU, to name only its DMA region to the device; nothing in it can make a device that ignores those addresses stay inside them.
- A client’s badge is its range and nothing more: it reads and writes those sectors, under the label check.
- The crate denies
unsafeeverywhere butservers/blkd/src/kernel.rs, the one seam to the kernel, which holds the volatile register and DMA accesses.
Security properties
R51 (DMA stays in its region)
Status: built · tested: host:redoubt-blkd::a_broken_device_is_never_handed_a_clients_write_payload, host:redoubt-blkd::a_hundred_thousand_random_liars_never_panic_and_never_stray, host:redoubt-blkd::impossible_requests_never_reach_the_device
blkd never asks the device to touch anything but the pages dma_alloc gave it: every address it
gives the device is the region’s base plus a constant checked to lie inside it, and a client’s lend
is never named. On a platform whose hardware confines DMA this bounds the device; without it, it
bounds what blkd asks for, not what the device does.
R52 (a lie is a failure, never corruption)
Status: built · tested (6)
- fuzz:redoubt-blkd/device
- host:redoubt-blkd::rewriting_the_rings_changes_nothing_the_driver_believes
- host:redoubt-blkd::a_device_that_lies_about_a_completion_is_refused_and_never_spoken_to_again
- host:redoubt-blkd::a_lying_device_becomes_failed_and_stays_failed
- host:redoubt-blkd::a_hundred_thousand_random_liars_never_panic_and_never_stray
- host:redoubt-blkd::one_clients_read_never_carries_anothers_bytes
Nothing a device writes can corrupt blkd’s memory, panic it, or make it return one client’s bytes
to another: no ring value is an index or a length, the rings blkd writes are never read back,
the three used-ring values it reads are checked, and a device that breaks the protocol is refused
for good.
R53 (a filesystem sees only its partition)
Status: built · tested (5)
- fuzz:redoubt-blkd/gpt
- host:redoubt-blkd::a_range_cannot_name_a_sector_outside_itself
- host:redoubt-blkd::hostile_partition_entries_are_refused
- host:redoubt-blkd::a_badge_that_names_no_range_is_refused
- host:redoubt-blkd::a_gap_in_the_table_does_not_renumber_the_volumes_after_it
A client’s badge names one partition, its sector numbers are relative to it, and no partition table with overlapping entries is accepted. So no client can read or write a sector outside its own partition, and no two partitions share one.
Failure and restart
Status: built · partly tested: the exits without a device, a disk or a partition table (NO_DEVICE, NO_DISK, NO_PARTITIONS in servers/blkd/src/bin/blkd.rs) are read from the code, not attacked; no case restarts blkd, whose restart is init’s; the device reset before reused DMA pages is the kernel’s · tested: host:redoubt-blkd::a_lying_device_becomes_failed_and_stays_failed, host:redoubt-blkd::a_disk_with_no_signature_is_refused
- No device handles, or no disk, or no valid partition table:
blkdexits with a code before serving. - The device lies or times out: every later request gets
faileduntilblkdis restarted, which resets the device from the beginning. blkdrestarts: it reads the same table and gives the same badges the same ranges, holding nothing across the restart. Its DMA pages are never reused while the device can still reach them: the kernel resets the device before it frees them (I16 (DMA pages reset before reuse)).
Residual risks
blkdis trusted without an IOMMU. On QEMU no hardware confines DMA, so a compromisedblkd, or a device that ignores its addresses, can write anywhere in RAM: a compromised kernel.- A slow or lying device holds the disk for up to
REQUEST_TIMEOUT_US(10 seconds) before it is marked broken; with one request outstanding, every client waits meanwhile. - A torn write is the device’s to keep. littlefs’s power-loss safety needs a torn write to
persist a prefix of its sectors;
blkdsends whole sectors in order but cannot make a device that tears differently honour that. - Wrong bytes are not detected. A device that returns wrong data within the protocol is believed;
neither
blkdnor littlefs checksums data. Disk encryption with authentication is beyond M6.
Why
- Our own virtqueue. The existing Rust virtio crate is thirteen thousand lines of device classes
Redoubt does not have, behind six dependencies,
unsafeat every call site; the queue and register code here is about five hundred lines, and with one request outstanding there is no descriptor state to shadow. - Copy through a DMA buffer. Naming a client’s lend to the device would let the device, or a race, reach memory the client still holds; a copy makes the device’s reach one fixed region.
- Badges by entry position. A restarted
blkdmeans the same thing by the same badge without storing anything, and a gap in the table renumbers nothing. - Broken stays broken. A device that lied once may lie again in a way no check catches; refusing it until a reset is the closed failure.
verityd
verityd makes a volume verified. It sits between blkd and the volume’s file server,
erofsd for the read-only system volume or a littlefsd: it holds the
volume’s range at blkd, checks every block it reads against a hash tree whose root the signed boot
manifest pins, or that the volume’s own root block gives, signed under a key the manifest names, and
serves only blocks that check, on blkd’s own protocol. So everything the file server parses on a
verified volume, data and metadata alike, is what the image’s builder wrote.
Purpose
The userland disk holds code: the modules and application resources the system resolves by name.
The disk itself is not signed, and a hostile or damaged medium must not be able to change what
runs. The boot bundle is signed (R15 (verified boot)); verityd
extends that signature to a whole volume, one block at a time, so a reader of the volume needs no
check of its own. It is one small process holding one range and no device, written so it can be
read whole.
Interface
Arguments
Status: built · tested (2)
- host:redoubt-verityd::arguments_are_inits_and_nothing_else
- host:redoubt-verityd::only_the_volumes_badge_is_served
init gives every argument (init, the volumes entry’s verity
key); a manifest that sets one itself is refused.
endpoint=NAME, the manifest’s name of the endpoint it receives on (verity:system).labels=ID[,ID...], the volume’s label set, absent when it is empty.- One mode (below):
- pinned:
root=<64 lowercase hex>, the root the manifest pins, andblocks=N, the volume’s data blocks; - signed:
key=<64 lowercase hex>, the Ed25519 key the volume’s root block is signed under, andfloor=N, the lowest version it may carry.
- pinned:
- One named handle,
volume: the volume’s range at its disk’sblkd.
Anything else, any of them twice, both modes or a part of one, or a block count of 0 stops it before
it serves. It serves one badge, 1, the one init mints at its endpoint for the volume’s file
server; any other gets not_permitted.
The tree
Status: built · tested (5)
- host:redoubt-verity::a_known_vector
- host:redoubt-verity::every_level_boundary
- host:redoubt-verity::the_geometry_refuses_no_blocks_and_overflow
- host:redoubt-verity::a_flipped_bit_at_each_level_is_refused
- host:redoubt-verity::the_root_pins_the_block_count
One definition, libs/verity, shared by the packer that writes the tree and verityd that checks
it:
- A volume of N data blocks of 4096 bytes (littlefs’s block, eight sectors) is followed in its range by its tree.
- Level 1 holds one digest per data block,
SHA-256(0x00 ‖ block). Each level above holds one digest per block of the level below,SHA-256(0x01 ‖ block). A tree block holds 128 digests, zero-filled after the last. Levels are stored bottom-up, the top level one block. - The root is
SHA-256(0x02 ‖ N as u64 LE ‖ top block), so it pins the block count too.
The three prefixes keep the kinds apart: no data block can stand for a tree block, nor either for the root. The geometry is overflow-checked arithmetic in one function. A tree covers every bit of its volume, so any error in data or tree is detected, which is stronger than a CRC; nothing corrects one.
The root block, and the two modes
Status: built · tested (11)
- bench:verity-signed
- bench:verity-bad-signature
- bench:verity-rollback
- host:redoubt-verity::a_root_block_is_its_documented_bytes
- host:redoubt-verity::a_malformed_root_block_is_refused
- host:redoubt-verity::arbitrary_bytes_are_one_root_block_or_none
- host:redoubt-verity::the_root_block_is_the_last_whole_block
- host:redoubt-verityd::a_signed_volume_opens_from_its_root_block_at_or_above_its_floor
- host:redoubt-verityd::a_root_block_that_does_not_verify_is_refused_naming_the_signature
- host:redoubt-verityd::a_root_block_below_the_floor_is_refused_naming_the_version
- host:testbench::a_signed_partition_ends_in_its_root_block_signed_deterministically
A volume is checked against one of two things, which its manifest entry’s verity states
(init):
- Pinned: the manifest gives the root and N. The volume changes only with the bundle.
- Signed: the manifest gives a key and a floor, and the volume carries its own N and root in a root block, so it can be updated apart from the bundle.
The root block is the last whole block of the volume’s range, after the tree, defined once in
libs/verity (RootBlock):
- a magic,
RVOLROOT; N and a version, each au64little-endian; the root; an Ed25519 signature of 64 bytes; then zeros to the end of the block. Any other byte there is malformed, so a block has one reading. - The signature covers the preimage
redoubt_signing::volume_preimagebuilds, under the domain"redoubt.volume.v1\0", built as the bundle’s is: the domain, a fixed-width length, then N, the version and the root (boot). - It is checked with
ed25519-compact, the loader’s andkeyd’s crate at the same version: one Ed25519 on the box.
At start, signed, verityd reads the root block, parses it, verifies the signature under
key=, and refuses the volume if the version is below floor=, a rollback; nothing in the block
is used before its signature verifies. Then it takes N and the root from the block and goes on as
pinned: the data and tree must end before the root block, and the top tree block must hash to the
root. A refusal names its reason in the one line below: its root block is malformed, its root block's signature does not verify under its key, or its root block's version 1 is below the floor 2. A refused signed volume, whose N is not believed, answers info
with its range’s whole blocks before the root block.
The key is the manifest’s: 64 hex digits, or bundle, the key the loader verified the
bundle with, named so no copy of it can drift. The floor is the
manifest’s too, so it comes and goes with the bundle (below).
Signing is on the build host only (R35 (key separation)): the
bench’s packer signs a recipe’s sign = { key = PATH, version = N } with the 32-byte seed in
PATH (the bench). The device never holds the key.
Starting
Status: built · tested (4)
- bench:verity-wrong-root
- bench:verity-flipped-tree
- host:redoubt-verityd::a_truncated_range_is_refused_and_still_sized
- host:redoubt-verityd::a_wrong_root_or_top_is_refused
At start verityd calls info at blkd, for a signed volume checks its root block
(above), refuses a range shorter than the data blocks and their
tree, reads the top tree block and checks it against the root. If any of that fails, it says one
line on its console naming the reason (verityd: the volume is refused: ...), answers info
truthfully, answers every read with failed, and stays up. So a bad medium is never a restart
loop, and the volume’s file server, whose first read then fails, serves the volume as corrupt
(R49 (a hostile medium is corrupt, not a crash)) and stays up too. Answering info
matters: a file server that cannot size its range exits, and would be restarted.
Messages
Status: built · tested (8)
- bench:userland-boot
- bench:userland-read-only
- host:redoubt-verityd::sub_block_and_multi_block_reads_return_the_volume
- host:redoubt-verityd::a_mismatch_is_failed_and_said_naming_the_block
- host:redoubt-verityd::a_failed_block_is_never_kept
- host:redoubt-verityd::writes_are_refused_and_flush_answers_at_once
- host:redoubt-verityd::the_label_check_runs_on_every_request
- host:redoubt-verityd::arbitrary_media_never_panic
verityd serves blkd’s protocol, unchanged, so a file server cannot tell it from a
blkd:
info: N × 8 sectors, read-only. The tree is not part of the volume.readworks in whole blocks. It fetches each block the request touches, hashes it, checks it through the tree up to a block already checked, and only then copies the requested sectors out. A mismatch isfailed, asblkdanswers a device error, with one console line naming the block (verityd: block 200 does not match the tree, ortree block ...). A block that failed is never kept: the next read checks it again.writeisnot_permitted, asblkdrefuses one on a read-only disk;flushanswers at once.- The label check (R25 (the label check)) runs on every request against
labels=, asblkd’s does:infoandreadare reads,writeandflushwrites.
Memory and cost
Status: built · tested (5)
- bench:boot-profile
- bench:boot-profile-unverified
- bench:init-boot
- host:redoubt-verityd::the_last_block_and_the_tree_cache_save_reads
- host:redoubt-verityd::the_data_cache_is_sized_from_the_manifests_heap
- Memory is fixed at start, whatever the volume’s size: the top block, pinned; a cache of 32
checked tree blocks and one of checked data blocks sized from the manifest’s heap cap, each
least recently used out first (below); a read-ahead buffer
and the reply’s scratch, one
blkdread (8 blocks) each; and a 2-page lend atblkd. The image’sverity:systemruns with a heap cap of 118 pages and peaks at 58 on both widths, 59 where a block fails and the line naming it is formed (userland-boot’s flipped block; the memory budget). - A block is checked from the top down. The lowest block on its path already held (the top always is) gives the digest the next block down must hash to; a block fetched is checked before it is kept. A level-1 block held costs one hash per data block.
- Per block the file server reads: a copy of at most 4 KiB, and one more read (up to five
blkdcalls at the two-page lend) per run of cold blocks a request asks for, not per block (read-ahead). The tree’s reads are about 1/128 more, mostly cached. Its weight is ordinary, like the file servers’: each request is a bounded amount of work, at most 8 blocks. - Measured by bench:boot-profile and bench:boot-profile-unverified: what verification costs the boot, verified against unverified, on littlefs and on EROFS, is beamlet’s table.
- A signed volume costs one Ed25519 verification at start:
verityd’s start onverity-signed’s volume, root block and top block read, takes about 140 ms in guest time on either width undericount(shift=3). - The verifier’s code is built for size.
ed25519-compact, the box’s one Ed25519, is built atopt-level = "z"(the rootCargo.toml): at the workspace’s"s"its verification inlines into about 900 KB on rv32, more than a verifier’s budget holds; at"z"it is about 20 KB, and the check is slower for it (the loader’s bundle check by under 7% per byte).
A cache of checked data blocks
Status: built · tested (4)
- bench:boot-profile
- host:redoubt-verityd::the_data_cache_holds_the_last_blocks_least_recently_used_out
- host:redoubt-verityd::the_data_cache_is_sized_from_the_manifests_heap
- host:redoubt-verityd::two_readers_share_the_hot_set_and_read_ahead_their_runs_with_the_manifest_cache
The data blocks checked last are kept, least recently used out first, in place of the one
last-block buffer: a block asked for again while it is held is neither read nor hashed again.
On EROFS a file’s blocks are read once each, in order, but erofsd reads the same few blocks
again and again: a directory’s blocks at every walk, and the blocks holding the inodes. By its
512th read in the rv64 boot profile (boot-stats build, seed 1) verityd was asked for 849 data
blocks: one block held 100 of them (12 %), 4 hold 197 (23 %) and 8 would hold 289 (34 %).
The cache’s size is the manifest’s, not a compiled constant: the memory budget holds a
server’s heap cap to twice its peak, so half the entry’s heap_pages is the peak the instance
may reach, and the data cache takes that half less what the rest of the heap holds (the tree
cache, the read-ahead buffer and the scratch, 8 blocks each, the top block, about 3 pages of
bookkeeping and 4 of margin: 56), never fewer than 4 blocks, the floor a host test or a launch
with no cap gets. The image’s cap of 118 pages affords the floor; a cap of 166 would afford 27.
Read-ahead reads a request’s run of consecutive blocks not held, a file’s cold data, from
blkd in one read of at most 8 blocks (at the two-page lend, up to five blkd calls of 15
sectors) and at most the cache’s size, hashes each through the tree before it is kept, and
serves the run from the cache; a block that does not check fails the request, the checked
blocks before it stay kept, and the rest is read again next time. erofsd asks a file’s blocks in one
call of up to 64 sectors, which verityd once split into a blkd call per block. Two VMs
loading code at once through one erofsd are two readers of this one cache: in the host test,
each walking a twelve-block hot set a block at a time and then reading a file’s eight cold
blocks, eight files each, the floor of 4 holds none of the hot set between the readers (0 of 320
blocks asked held, 229 blkd reads), and 27 blocks with read-ahead hold 180 and read 33. On the
box, in a boot-stats build of steward-latency at two harts on rv32 (seed 3), the round with
two spinner VMs asked 1,802 data blocks for 1,511 blkd reads with the 4-block cache and a read
per block; read-ahead cuts the reads by two thirds (2,704 to 924 by the 1,024th request served,
the floor’s hit rate 19 to 20 %), and a 27-block cache beside it to 784 (24 %), a 92-block one to
624 (41 %), without moving that round’s outcome
(responsiveness): the floor is what the image pays for.
Under sixteen VMs of one account loading code at once (bench:lease-code-load, both widths) the
cache holds about a tenth of the blocks asked (11,700 asked, 1,100 to 1,250 held at the 4,096th
request) and read-ahead gives about 2.2 blocks a blkd read (4,750 reads); the account’s VMs
load at 30–85 KB/s each. The rate is the read path’s, never a death’s cause: what bounded the
account’s concurrent VMs was erofsd’s fid admission, now derived from the connections
(erofsd).
Authority
Status: built · tested: host:redoubt-verityd::only_the_volumes_badge_is_served, host:redoubt-verityd::writes_are_refused_and_flush_answers_at_once
veritydholds one range atblkd, its endpoint, and a console connection. It holds no MMIO, interrupt or DMA, mints nothing, and never writes its range.- The volume’s file server holds the one badge it serves.
- The crate forbids
unsafe.
Security properties
R76 (verified volumes)
Status: built · tested (13)
- bench:userland-boot
- bench:userland-bad-start
- bench:verity-flipped-tree
- bench:verity-wrong-root
- bench:verity-bad-signature
- bench:verity-rollback
- host:redoubt-verityd::a_root_block_that_does_not_verify_is_refused_naming_the_signature
- host:redoubt-verityd::a_root_block_below_the_floor_is_refused_naming_the_version
- host:redoubt-verity::a_flipped_bit_at_each_level_is_refused
- host:redoubt-verityd::a_wrong_root_or_top_is_refused
- host:redoubt-verityd::a_mismatch_is_failed_and_said_naming_the_block
- host:redoubt-verityd::a_failed_block_is_never_kept
- host:redoubt-verityd::arbitrary_media_never_panic
A reader of a verified volume sees only blocks that hash, through the tree, to the root the signed
manifest gives, or, for a signed volume, to the root its root block gives, signed under the
manifest’s key at a version no lower than the manifest’s floor. Otherwise it sees a device failure,
which the file server serves as corrupt. erofsd and littlefsd both poison a volume on an I/O
error until they start or mount it again, so one bad block fails closed for the whole volume: later
loads fail too, and a reader keeps what it already has.
Failure and restart
Status: built · partly tested: the exits on bad arguments and on no volume handle (BAD_ARGS, NO_VOLUME in servers/verityd/src/bin/verityd.rs) are read from the code, not attacked; no case restarts verityd · tested: host:redoubt-verityd::a_truncated_range_is_refused_and_still_sized
- Bad arguments or no
volumehandle:veritydexits with a code before serving. - The start check fails: it stays up, refused, as above.
- A read does not check: that read is
failed;veritydkeeps serving, and the file server poisons the volume. veritydrestarts: it checks the range again from the root, holding nothing across the restart.
Residual risks
- Rollback is the bundle’s. The root, or a signed volume’s floor, comes with the signed manifest, so an older bundle brings its older volume, or a lower floor, back with it; boot has no rollback protection, and this is no worse. Nothing on the box raises a floor: a higher one arrives with a new bundle, and a monotonic store belongs to M6 (persist, install, share).
- A root block names no volume. Its signature covers N, the version and the root, not which volume it is, so two volumes signed under one key at versions over the floor can stand in for each other on a disk. A deployment gives each signed volume a key of its own, until a later version of the domain signs a name.
- Writable volumes are unverified. They keep littlefs’s metadata CRC and R49 only.
- A reader trusts the file server and
verityd. Under R76 the reader of a verified volume trusts the servers that verify it, as it trustsconsoledfor its console; neither checks the other’s answers beyond the protocol. - One bad block fails the volume until its file server starts again: detection, not correction.
Why
- The block layer, not littlefs. To find a signed file inside littlefs,
littlefsdwould parse the volume’s metadata before anything checked it; once a reader takes code from the volume, R47 (one volume per instance)’s bound to “that volume’s data” means nothing. Belowlittlefsd, every byte littlefs parses has been checked, and one tree covers data and metadata. - Not 9P above
littlefsd.littlefsdwould still parse unverified metadata; a partial read could be checked only against a per-file tree; and listings,statand walks would go unchecked. - A server, not a layer inside
littlefsd. The check runs in its own small process,littlefsdand littlefs stay untouched, and any client ofblkd’s protocol can be verified. Its cost is one call and a copy per block and one process per verified volume; a layer inlittlefsdwould save the call and stays possible if the cost says so.
netd
netd is the virtio-net driver. It owns one network device and serves its frames to one client,
ipd: ipd transmits through netd’s netif protocol, and netd hands each received
frame to ipd as a one-way message carrying one page. It is written like blkd: two
DMA regions the device alone can reach, nothing the device writes believed, and a device that
lies reset and refused for good. What a sender on the wire puts in a frame is content, never a lie.
Purpose
The network card is driven by code that must trust neither the device nor anyone on the wire, and
that must keep every earlier frame, which may be another principal’s traffic, out of every later
one. netd does only that: frames in, frames out, for one client, with no addresses, sockets or
policy. Everything above the link is ipd’s.
Interface
Serving ipd
Status: built · partly tested: the drop of a frame `ipd` does not take within `SEND_TIMEOUT_US` is read from the code, not attacked · tested (10)
- bench:netd-host-tests
- bench:net-tcp
- host:redoubt-netd::info_and_transmit_for_the_client
- host:redoubt-netd::anyone_else_is_not_permitted
- host:redoubt-netd::a_frame_of_the_wrong_length_is_too_many
- host:redoubt-netd::a_full_ring_is_busy_and_a_lie_is_failed_for_good
- host:redoubt-netd::a_request_that_does_not_decode_is_malformed
- host:redoubt-netd::arguments_are_exactly_one_client_badge
- host:redoubt-netd::randomized_requests_reach_the_wire_only_from_the_client
- fuzz:redoubt-netd/request
- One client.
netd’s one argument names the badgeipd’s handle carries. Any other badge, and any labelled caller, getsnot_permitted.netdmints nothing for a client and parks nothing, so a call holds nothing after its reply. (It mints one badge on its own endpoint, for the receive thread’s report.) info: the device’s MAC address and the MTU (1500).transmit(frame): one Ethernet frame of 14 to 1514 bytes (MIN_FRAMEtoMAX_FRAME), copied out of the caller’s lend into a transmit slot. A frame of another length istoo_many; a full transmit ring isbusy; a broken device isfailed.- Received frames go to
ipdas itsframemessage: asend, the frame in one transferred page of anonymous memory (ipd). A frameipdcannot take withinSEND_TIMEOUT_US(50 ms) is dropped, as on any wire.
The table: libs/wire/tables/netif.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 1 | info | - | mac: u64, mtu: u32 |
| 2 | transmit | frame: bytes | - |
| Code | Error |
|---|---|
| 2 | not_permitted |
| 3 | too_many |
| 4 | busy |
| 5 | failed |
Rings and slots
Status: built · tested (9)
- host:redoubt-netd::an_honest_device_comes_up_with_two_features_and_its_mac
- host:redoubt-netd::bring_up_refuses_and_resets
- host:redoubt-netd::frames_arrive_exactly_and_every_slot_comes_back
- host:redoubt-netd::a_transmit_sends_exactly_its_header_and_frame
- host:redoubt-netd::an_inflated_length_reads_zeros_not_old_frames
- host:redoubt-netd::the_ring_counters_wrap
- host:redoubt-netd::spurious_interrupts_deliver_nothing
- host:redoubt-netd::a_frame_completed_before_the_first_wait_without_an_interrupt_is_delivered
- host:redoubt-netd::a_frame_completed_silently_during_a_drain_is_delivered_before_the_next_wait
- Two DMA regions, one per queue, each a run of
REGION_PAGESpages fromdma_alloc(devices): the descriptor table, available ring and used ring on the first page, thenQUEUE_SIZE(16) slots ofSLOT_LEN(2048) bytes. Every addressnetdgives the device is a region’s base plus a constant, checked at compile time (ring::LAYOUT_FITS), and descriptor i always names slot i (R54 (DMA stays in netd’s regions)). - Bring-up accepts version 2 only and exactly two features, version 1 and the MAC, and refuses a
MAC that is all zeros or has the group bit set (multicast or broadcast); it
configures both queues, offers every receive slot, and sets
DRIVER_OKbefore the receive thread exists. A device that fails any step is reset and refused. - Receive. All sixteen slots are always offered. Each frame is copied out of its slot once, with
netd’s own length, intonetd’s own memory before any byte is looked at, then into a fresh page foripd; the slot is zeroed before it is offered again, so a device that reports more than it wrote hands back zeros, never an earlier frame. - Transmit. Up to sixteen frames are with the device at once, reclaimed when the next is sent. A transmit’s descriptor carries exactly its 12-byte header and its frame, never the whole slot, so no byte of an earlier frame goes out behind a later one (R56 (no earlier frame leaks)). The transmit queue asks for no interrupts.
- Interrupts only say when to look. The receive queue is drained before every wait, so a lost interrupt delays nothing and a spurious one delivers nothing.
- A DMA page never leaves
netd. No path lends, transfers or maps one; the kernel refuses to anyway (devices).
flowchart TB
subgraph RX["receive region, REGION_PAGES pages"]
direction TB
rx0["page 0: descriptors (write-only), available ring (write-only),<br/>used ring (only index, id and length read); descriptor i names slot i"]
rxs["slots 0 to 15, 2048 bytes each"]
rx0 --- rxs
end
subgraph TX["transmit region, REGION_PAGES pages"]
direction TB
tx0["page 0: descriptors (write-only), available ring (write-only),<br/>used ring (only index, id and length read); descriptor i names slot i: header and frame only"]
txs["slots 0 to 15, 2048 bytes each"]
tx0 --- txs
end
rxs -- "copied out once, the slot zeroed" --> M["netd memory"]
M -- "send, one page" --> I["ipd"]
I -- "transmit call: copied in from ipd's lend" --> txs
Figure: netd’s two DMA regions; descriptor i always names slot i, and only the used ring’s index, id and length are read.
Lies and bad frames
Status: built · tested (9)
- fuzz:redoubt-netd/device
- host:redoubt-netd::receive_lies_are_refused
- host:redoubt-netd::transmit_lies_are_refused
- host:redoubt-netd::a_buffer_completed_twice_is_a_lie
- host:redoubt-netd::scribbling_devices_change_nothing_netd_believes
- host:redoubt-netd::a_scribbled_descriptor_is_rewritten_when_offered_again
- host:redoubt-netd::a_device_that_keeps_every_slot_is_busy_then_broken
- host:redoubt-netd::a_bad_frame_from_the_wire_is_dropped_never_a_lie
- host:redoubt-netd::randomized_hostile_devices
A lie and a bad frame are different things.
- A lie is a used entry that breaks the ring protocol: the index running past what is
outstanding, an id out of range, not outstanding or seen twice, a length below the header or above
the slot, or a header asking for checksum or segmentation that was never negotiated; or a
transmit slot held longer than
TX_TIMEOUT_US(10 seconds). A lie ends the device: it is reset and every later request isfailed. The descriptor tables and available rings are written from constants andnetd’s own counters and never read back, so a device that scribbles on them changes nothingnetdbelieves. - A bad frame has a length outside 14 to 1514 bytes but inside the slot: some sender on the wire sent it (a tagged frame, a runt), and an honest device delivers it. It is dropped and counted, and the slot is offered again. A packet on the LAN never bricks the NIC (R55 (a bad frame is content, not a lie)).
Two threads, reset on exit
Status: built · partly tested: the serving thread's reset before it exits is read from the code, not attacked · tested (4)
- host:redoubt-netd::only_the_receive_threads_report_breaks_the_device
- host:redoubt-netd::the_receive_loop_resets_and_reports_a_lie
- host:redoubt-netd::the_receive_loop_resets_and_reports_when_the_interrupt_fails
- host:redoubt-netd::a_panic_resets_the_device
- The serving thread maps the registers, allocates both regions, brings the device up and only
then starts the receive thread. It owns the transmit queue, answers
netifcalls, and waits on nothing but its ownreceive. - The receive thread takes its half out of
netd’s own memory, so no address travels in a message. It waits on the interrupt, drains the receive queue and sends each frame toipd. It tells the serving thread only that the device is broken, on a badge drawn at random above 2^63 that carries no data. - Every way out stops the device. The receive thread resets it and reports on a lie, a fault reading the rings, or the interrupt failing; the serving thread resets it on a lie, on that report, and before it exits; a panic resets it from the runtime’s panic hook. A reset is status 0, read back, so the device stops touching its rings before its pages can return to the pool (R57 (the device stops before netd does)).
- A device fault never stops
netd. A broken device is answeredfailedfor good, andnetdstays up, so a lying device cannot make the box restart it in a loop.
Started by init
Status: built · tested: bench:init-boot, bench:netd-restart
init starts netd with the network card’s MMIO region (DMA allowed) and interrupt, placed by
name from the boot manifest’s devices list, and its one argument, the badge ipd’s handle
carries; it hands ipd the matching handle to netd and netd a handle to ipd’s endpoint for
frames (init). In the bench, a case’s own manifest does the same,
naming the card at the fixed slot the bench gives it
(disks and network cards).
A restart is a driver’s restart, like any other server’s
(init). A killed or faulted netd runs none of its own reset
code, but the kernel resets the card at its end, before its DMA pages are reused
(devices). init starts a new instance in a fresh
budget with the same device handles, the same argument and a handle to ipd’s endpoint, and
netd’s bring-up begins with a reset of its own (rings and slots). ipd
finds the new instance without being told. netd’s endpoint is init’s and outlives the dead
instance, and ipd meets the failure as unreachable and asks again with backoff
(ipd). Frames in flight are lost, and TCP resends them. If the
card was quarantined, init reboots instead
(devices).
The restart probe is a test-only feature, restart-probe (src/restart_probe.rs), off in every
default build; the bench’s netd-restart builds netd with it. netd then faults on one UDP
datagram the bench sends from outside the guest, and answers a call carrying INSTANCE with 32
bits it drew when it started. That call is answered before netd’s check of the caller’s badge and
labels, so in that build any caller holding a handle to netd gets the answer.
Authority
Status: built · tested: host:redoubt-netd::anyone_else_is_not_permitted, host:redoubt-netd::randomized_requests_reach_the_wire_only_from_the_client
netdholds one network device’s MMIO region and interrupt, its two DMA regions, its endpoint, and a handle toipd’s endpoint for frames. It holds no addresses, sockets or policy.- Only
ipd’s badge may transmit; nothing reaches the wire from anyone else. - It is trusted, on a machine without an IOMMU, to name only its DMA regions to the device.
- The only
unsafein the crate is inservers/netd/src/kernel.rs, the seam to the kernel.
Security properties
R54 (DMA stays in netd’s regions)
Status: built · tested: host:redoubt-netd::scribbling_devices_change_nothing_netd_believes, host:redoubt-netd::randomized_hostile_devices, host:redoubt-netd::a_transmit_sends_exactly_its_header_and_frame
netd never asks the device to touch anything but its two DMA regions, every address it gives is a
region’s base plus a checked constant, and no DMA page is ever lent, transferred or mapped out of
netd. Without an IOMMU this bounds what netd asks for, not what the device does.
R55 (a bad frame is content, not a lie)
Status: built · tested: host:redoubt-netd::a_bad_frame_from_the_wire_is_dropped_never_a_lie, host:redoubt-netd::receive_lies_are_refused, fuzz:redoubt-netd/device
Nothing a sender on the wire puts in a frame can stop netd or break its device: a frame of a
length netd does not carry is dropped and counted, and only a used entry that breaks the ring
protocol, which only the device can write, ends the device.
R56 (no earlier frame leaks)
Status: built · tested: host:redoubt-netd::a_transmit_sends_exactly_its_header_and_frame, host:redoubt-netd::an_inflated_length_reads_zeros_not_old_frames, host:redoubt-netd::frames_arrive_exactly_and_every_slot_comes_back
No byte of an earlier frame reaches a later one in either direction: a transmit tells the device exactly its header and frame, and a receive slot is zeroed before it is offered again, so a device that claims more than it wrote hands back zeros.
R57 (the device stops before netd does)
Status: built · partly tested: a kill or a fault of `netd` runs none of its code, and the device reset then is the kernel's; the serving thread's reset before it exits is not attacked · tested (4)
- host:redoubt-netd::a_panic_resets_the_device
- host:redoubt-netd::the_receive_loop_resets_and_reports_a_lie
- host:redoubt-netd::the_receive_loop_resets_and_reports_when_the_interrupt_fails
- host:redoubt-netd::only_the_receive_threads_report_breaks_the_device
On every way out of either thread that netd controls, a lie, a failed interrupt, an exit or a
panic, the device is reset and the reset read back before anything else, so the device writes
nothing more into pages that may be returned. A kill or a fault runs none of netd’s code; then the
kernel resets the device before its DMA pages are reused
(I16 (DMA pages reset before reuse)).
Failure and restart
Status: built · partly tested: the exit codes (NO_DEVICE, BAD_ARGS, NO_RESOURCES in servers/netd/src/bin/netd.rs) are read from the code, not attacked · tested: host:redoubt-netd::a_full_ring_is_busy_and_a_lie_is_failed_for_good, host:redoubt-netd::bring_up_refuses_and_resets, host:redoubt-netd::a_panic_resets_the_device
- No device or bad arguments:
netdexits with a code before serving. - The device lies or times out: it is reset, every later request is
failed, andnetdstays up. netdpanics: the panic hook resets the device, and the runtime reports and exits (serving).ipdis slow: received frames it cannot take within 50 ms are dropped.
Residual risks
netdis trusted without an IOMMU. A compromisednetd, or a device that ignores its addresses, can write anywhere in RAM.- A frame flood costs
netd’s CPU at its large manifest weight. Under a flood the receive thread drains without waiting on the interrupt, paced only by each send toipd(up to 50 ms), and its drops fall on every connectionipdserves. - A reset stops the device for everyone. One lie ends the network for every principal until the
device is brought up again;
netdis not restarted to do that.
Why
- One client. Every policy about who may send what belongs to
ipd; a driver with one client has none to get wrong. - Lies and bad frames apart. If a wrong-length frame counted as a device lie, anyone on the LAN could reset the NIC with one packet.
- Exact transmit lengths and zeroed slots. A slot reused without clearing carries its last frame; telling the device exactly what to read, and zeroing what it may write, keeps one principal’s traffic out of another’s.
- Reset before exit. A device that keeps writing after its driver is gone writes into whatever its pages become next.
- Stay up on a fault. A driver that exits on a lie hands the attacker a restart loop; one that
stays up and answers
faileddoes not.
ipd
ipd is the TCP/IP stack for one network: it serves /net over 9P, a Plan 9 style tree of TCP
sockets, and talks to the network card through netd. A socket capability is a
connection to /net carrying a scope of IP prefixes and ports; a connection reaches only what
its scope allows and never the box’s own addresses, whatever the scope says. ipd is a sink: it
refuses every labelled caller. It runs smoltcp 0.14.0, vendored, with IPv4, Ethernet and TCP only.
Purpose
People’s sessions need the network, and the network is where data leaves the box; agents reach it
only through gatewayd, never a socket of their own. ipd puts the rule at the socket: a capability names exactly the prefixes and ports it may connect to or
listen on, a grant can only narrow it, and no scope can reach back into the box, where a forwarded
port could lead a session to approve@box. Because ipd is a sink, no labelled data reaches it
at all.
Interface
The /net tree
Status: built · tested (13)
- bench:net-tcp
- bench:ipd-host-tests
- host:redoubt-ipd::clone_makes_a_socket_and_tcp_lists_only_the_callers
- host:redoubt-ipd::a_connect_waits_then_carries_data_through_the_files
- host:redoubt-ipd::ctl_refusals_are_named_and_checked_before_the_stack
- host:redoubt-ipd::a_listener_accepts_through_ctl
- host:redoubt-ipd::numbers_are_per_connection_and_invisible_to_others
- host:redoubt-ipd::a_connection_carries_bytes_both_ways_and_ends
- host:redoubt-ipd::a_far_host_is_reached_through_the_gateway
- host:redoubt-ipd::a_write_waits_while_the_send_buffer_is_full
- host:redoubt-ipd::a_listener_accepts_its_backlog_and_listens_again
- host:redoubt-ipd::the_conformance_vectors_run_against_ipd
- host:redoubt-ipd::a_client_connects_and_echoes_through_the_program
/net is served over the 9P server skeleton. Everything a
file holds is typed, in the wire encoding (wire), never text.
/ the connection's root; it carries the connection's scope
/tcp/ lists only this connection's sockets
/tcp/clone read at offset 0: a new socket N, as n: u32
/tcp/N/ctl write: one net_ctl operation; read: state: u32, n: u32 (waits)
/tcp/N/data read: bytes (waits; 0 bytes is the peer's end); write: bytes (waits)
/tcp/N/remote read: addr: bytes[4] (network order), port: u16
The tree one connection sees.
clone: a read at offset 0 makes a new socket, charged to the caller, and returns its number, the lowest free one for this connection./tcplists only the connection’s own sockets, and a walk to another connection’s number is “not found”.ctl: a write is onenet_ctloperation, its opcode as au32then its fields (wire):connect(addr, port),listen(port, backlog)(backlog at most 8),close(the graceful end) orabort. A read returns the state (1 connecting, 2 established, 3 closing, 4 closed, 5 listening) and, for a listener that accepted, the new socket’s number; it waits while a connect is in progress or a listener has nothing accepted, at mostCTL_WAIT_US(8 s), thentimeout, and the client asks again.data: the byte stream. A read waits while there is nothing to read, a write while the send buffer (8 KiB) is full; either at mostDATA_WAIT_US(8 s), thentimeout, and the client asks again; bytes that arrive after a read’stimeoutwait in the socket’s buffer for the next read. Waiting calls are parked (parked calls), for strictly less than their clients wait (pinned and abandoned calls).- A socket lives until
close,abort, or thedisconnectof its connection (which aborts every socket of it), then lingers at most 60 s beforeipdresets it, plus TCP’s TIME-WAIT; it stays charged to its owner until it is gone. An established socket without acknowledged progress for 60 s is ended; a half-open one gives its slot back after 3 s. - Ports. A listened port belongs to its connection and the connections
new_connectionminted from it, not to grants; an ephemeral port is drawn at random and drawn again if in use. - What is not there: no IPv6, DHCP, DNS, UDP, raw or ICMP sockets, and no fragment reassembly (a fragment is dropped). Non-TCP IPv4 and datagrams from martian sources get no answer.
stateDiagram-v2
[*] --> Closed: clone
Closed --> Connecting: ctl connect<br/>(scope, self set checked first)
Closed --> Listening: ctl listen<br/>(listen rule checked first)
Connecting --> Established: handshake done
Connecting --> Closed: refused, unreachable<br/>or 60 s timeout
Listening --> Listening: accept, a new socket N
Established --> Closing: ctl close, or the peer ends
Closing --> Closed: done, or linger 60 s
Established --> Closed: ctl abort, disconnect,<br/>or 60 s without progress
Closed --> [*]: gone, its State unit returned
Figure: a TCP socket’s states as its ctl file reports them.
The table: libs/wire/tables/net_ctl.md.
| Opcode | Message | Fields | Reply |
|---|---|---|---|
| 1 | connect | addr: bytes, port: u16 | - |
| 2 | listen | port: u16, backlog: u8 | - |
| 3 | close | - | - |
| 4 | abort | - | - |
| Code | Error |
|---|---|
| 2 | not_permitted |
| 3 | in_use |
| 4 | too_many |
| 5 | state |
| 6 | unreachable |
| 7 | refused |
| 8 | timeout |
Scopes and grants
Status: built · tested (13)
- bench:net-attacks
- host:redoubt-ipd::a_scope_permits_exactly_what_its_rules_contain
- host:redoubt-ipd::a_granted_connection_in_a_scope_ends_when_the_scope_is_destroyed
- host:redoubt-ipd::a_grant_is_minted_in_the_revocation_scope_it_brings
- host:redoubt-ipd::a_grant_only_narrows
- host:redoubt-ipd::a_grant_never_widens_or_adds_listen
- host:redoubt-ipd::the_encoding_is_canonical_and_round_trips
- host:redoubt-ipd::only_a_root_badge_with_a_scope_attaches
- host:redoubt-ipd::new_connection_keeps_the_scope
- host:redoubt-ipd::a_grant_narrows_and_disconnect_frees_everything
- host:redoubt-ipd::grant_mints_a_narrower_connection
- host:redoubt-ipd::a_grant_nobody_received_is_undone
- host:redoubt-ipd::connects_outside_the_scope_or_to_the_box_are_refused_and_send_nothing
- A scope is at most
MAX_RULES(8) rules, each a connect rule (an IPv4 prefix and a port range) or a listen rule (a port range). A connection may connect to an address and port only if some connect rule contains both, and listen on a port only if some listen rule contains it (R58 (a scope reaches only what it allows)). - Where scopes come from. A root badge’s scope is in
ipd’s arguments; only a root badge with a scope may attach. A connection minted bynew_connectionkeeps its parent’s scope (rooted at""or"tcp"only). A connection made bygranthas the scope the grant asked for. grant(scope)mints a connection whose scope isscope, only if every rule of it lies inside one rule of the caller’s own, of the same kind, prefix within prefix and ports within ports. So a grant never widens a scope or addslistento it (R61 (scopes only narrow)). It replies with the connection and its id, and follows the typed grant pattern (wire); the connection is an ordinary one in the skeleton’s table (mint_rooted), freed bydisconnect, and undone if its reply is lost. Its one optional handle slot,revocation: handle[0]? budget, is the revocation budget the connection is minted in, whatnew_connection’sscopeslot names (wire), calledrevocationhere because on this page a scope is the prefixes and ports a connection may reach: the skeleton stamps the handle with it, so destroying the budget ends the connection in every table at once, andipdcloses the handle once minted with; a budget not belowipd’s own stamp is the kernel’s refusal, answerednot_permitted(R41 (narrowing by revocation scope)). The steward sends a session’s or lease’s revocation budget with every grant it asks for.- The scope’s encoding in a
grant:count: u8, then per rulekind: u8(1 connect, 2 listen),addr: bytes[4](network order; zero for listen),len: u8(zero for listen),lo: u16,hi: u16. It is canonical and round-trips. - Scopes are kept by an id never reused, so a node that outlives its connection names no scope, which permits nothing.
- Refusals are checked before smoltcp sees the operation, so a refused connect sends nothing.
The table: libs/wire/tables/ipd.md.
| Opcode | Kind | Message | Fields | Reply |
|---|---|---|---|---|
| 16 | call | grant | scope: bytes, revocation: handle[0]? budget | conn: handle[0] endpoint, id: u64 |
| 17 | send | frame | frame: bytes | - |
| Code | Error |
|---|---|
| 2 | not_permitted |
| 3 | too_many |
frame is netd’s: each received frame, as a send on the ingress badge with the frame in one
transferred page (netd). Frames count only from the unlabelled ingress badge.
The box’s own addresses
Status: built · tested (4)
- bench:net-attacks
- bench:bench-net-self-unrefused
- host:redoubt-ipd::the_self_set
- host:redoubt-ipd::connects_outside_the_scope_or_to_the_box_are_refused_and_send_nothing
Before any scope is looked at, ipd refuses every address of the box itself: its own address, its
network’s network and broadcast addresses, the limited broadcast, the loopback and “this host”
networks, multicast, the reserved class E, and every self= prefix its arguments list (on QEMU
10.0.2.0/24, which the emulator maps to the host, where a forwarded port leads back to the
guest). So a manifest that wrongly scoped 0.0.0.0/0 still cannot reach the box
(R59 (never the box’s own addresses)). net-attacks tries
a forwarded self address, ipd’s own address, loopback and the gateway from a scope allowing
everything, and checks the capture shows no SYN to them; the broadcast, “this host”, multicast and
class E refusals are attacked by the host test the_self_set;
bench-net-self-unrefused is the same boot without the self= entry, and must fail, which proves the
case catches a missing check.
Labelled callers
Status: built · tested: bench:net-attacks, host:redoubt-ipd::a_labelled_caller_gets_nothing_and_holds_no_bucket, host:redoubt-ipd::frames_count_only_from_the_unlabelled_ingress_badge
ipd is a sink, cleared for nothing. A caller whose message carries any label is refused before
its request is decoded: before 9P, ninep_common, grant or admission. So it opens no bucket and
cannot even make a socket by reading clone, which the label check alone would let it read
(R60 (a sink refuses labels)).
Pinned and abandoned calls
Status: built · tested (6)
- bench:net-pinned
- bench:net-pinned-ssh
- host:redoubt-ipd::parked_calls_are_freed_when_abandoned_and_capped_by_the_share
- host:redoubt-ipd::two_reads_one_byte_one_answer_and_ipd_goes_on
- host:redoubt-ipd::an_accept_nobody_answers_ends_with_the_ctl_deadline
- host:redoubt-ipd::a_data_read_with_nothing_coming_ends_with_the_data_deadline_and_late_bytes_wait_for_the_next
Every waiting read or write is parked with a server-side deadline, in the caller’s bucket and share
(R28 (parked-call accounting)); it is served again once
the stack says it would not wait, and an abandoned one is answered at once. The stack is polled only
right after a receive that returned something other than a call, so no call is current while
smoltcp runs, and a crash inside it blames no parked caller
(R21 (crash blame)).
A call is parked for strictly less than its client waits. A client that gives up on a request
drops the answer that comes later (the beamlet hub at FILE_WAIT_US, 10 s, retiring the fid:
files over 9P); a data read’s answer carries bytes, which
no retry can ask for again, so an answer dropped that way is lost to the stream. ipd therefore
answers timeout first: DATA_WAIT_US and CTL_WAIT_US are 8 s, the 10 s less a 2 s margin,
a round figure far above what it covers (the kernel’s delivery of the reply, microseconds), so
the only answer a client could drop is one whose delivery took longer than the margin. Bytes arriving after the timeout wait
in the socket’s buffer for the next read, which the client makes at once (sshd’s reader asks
again on timeout; the hub answers its caller etimedout, who asks again; a session’s gen_tcp
read abandons its parked read when the caller’s own recv timeout fires, and parks the next read
when the caller asks again); ctl is asked again the same way, and finds the same state. In
net-pinned a client abandons 64 parked reads, each answered and freed; a read
with nothing coming ends at the data deadline, a listener’s ctl read at the ctl one, and the
connection still works after; the host test parks a read on an established connection, sees it
wait at DATA_WAIT_US - 1 and end at DATA_WAIT_US, then has the peer send and the next read
answered at once with the bytes. net-pinned-ssh runs the same abandon-and-free from a session’s
gen_tcp over /net: 64 reads a 20 ms recv abandons, Bob’s SSH login reaching its prompt
through the churn, then Alice’s echo on the same connection.
Sizing
Status: built · tested (20)
- host:redoubt-ipd::every_scope_and_the_milestone_parse
- host:redoubt-ipd::anything_else_stops_ipd
- host:redoubt-ipd::an_account_override_parses_and_is_refused_malformed_zero_twice_or_past_eight
- host:redoubt-ipd::every_scope_and_the_milestone_fit
- host:redoubt-ipd::the_images_account_override_fits
- host:redoubt-ipd::the_worst_case_must_fit_or_ipd_does_not_start
- host:redoubt-ipd::a_heap_that_cannot_hold_every_socket_stops_ipd
- host:redoubt-ipd::a_heap_cap_short_of_its_sockets_stops_ipd_before_it_serves
- host:redoubt-ipd::a_socket_the_heap_cannot_hold_is_refused_and_noted
- host:redoubt-ipd::a_full_table_refuses_a_listen_or_a_socket
- host:testbench::every_manifest_entry_holds_its_buckets_at_their_caps
- host:redoubt-ipd::clone_stops_at_the_buckets_socket_cap
- host:redoubt-ipd::sockets_stop_at_the_buckets_cap
- host:redoubt-ipd::an_agent_cannot_take_all_its_sponsors_sockets
- host:redoubt-ipd::a_lingering_socket_keeps_its_bucket
- host:redoubt-ipd::a_lingering_socket_is_bounded
- host:redoubt-ipd::a_disconnect_aborts_and_returns_the_charges
- host:redoubt-ipd::a_half_open_connection_gives_its_slot_back
- bench:session-tcp-churn
- fuzz:redoubt-ipd/args
Arguments, parsed strictly and all at once; anything ipd does not understand stops it before
it serves:
addr=A.B.C.D/LEN: the box’s address and network, once, a unicast host address;gateway=A.B.C.D: the default route, at most once, on the network and notaddr;self=A.B.C.D/LEN: more of the box’s own addresses, at most 8;ingress=BADGE: the badgenetd’s frames arrive on, once; it has no/net;scope=BADGE:RULE[,RULE...]: one root badge’s scope, at most 8 badges of at most 8 rules; a rule isc:A.B.C.D/LEN:PORTSorl:PORTS,PORTSbeingPorLO-HI;buckets=N: admission buckets, 1 to 32, required, parsed by the serving library as every shared server’s is (init);limits=BADGE:INFLIGHT:STATE:SOCKETS: caps for one scope badge’s bucket in place of the defaults, at most 8 (an account-0 override, serving);account=ACCOUNT:INFLIGHT:STATE:SOCKETS: the same caps for one non-zero account’s unlabelled bucket, at most 8 (an account override, serving).
Numbers are decimal without leading zeros; badges are non-zero, below FIRST_MINTED_BADGE, and
each appears once; an account= account is non-zero and appears once.
Admission. Fids, minted connections, parked calls and sockets all go through the skeleton’s
admission (R26 (admission fairness)): by default 5 parked
calls, 4 connections, 8 sockets, and 128 multiplexed requests and 32 pages they brought
(serving) per bucket. A socket is one State unit,
reserved around each request that may make one and held until the socket is gone, lingering
included, so one badge
cannot take all its bucket’s sockets. A connection granted for a session is admitted in the
session’s account from its first request (serving); that move comes before
the request’s reservation, which would otherwise fill the bucket it moves into.
A person’s sockets are the manifest’s. account= gives one account’s unlabelled bucket caps
of its own, as limits= gives a daemon’s root badge its own. A session’s /net is minted by the
steward’s grant and admitted in its account’s bucket from its first request, so the account’s
sessions divide that bucket by the fair share, the number in ipd’s arguments alone. A labelled
bucket of the account keeps the defaults, since ipd refuses labelled callers
(R60 (a sink refuses labels)) and it holds no socket, and an agent
never holds one. The image gives alice’s account account=1001:5:4:32 and bob none: her bucket
has 4 + 32 = 36 State units, and its fids are files_for(32), 2 × 32 + 8 = 72, held to the
skeleton’s 64 a connection. So the fids bind her live sockets below 32 (a ctl and a data fid
each, beside her directories), while a closed socket in TIME-WAIT holds its unit and no fid, which
is the churn the override is for. Each session’s /net connection is one unit in a share of its
own, so her SSH session beside her console session has 36 / 3 = 12 units, its connection and 11
sockets; at the defaults it is 12 / 3 = 4, so 3 sockets (session-tcp-churn opens and closes 11 in
succession and is refused the twelfth while they linger).
Each socket is counted once, where it lives. A socket’s buffers are two 8 KiB heap blocks of
two pages each, with smoltcp’s state for it (432 bytes on 64-bit): SOCKET_BYTES, 17,408 bytes,
4.25 pages, is what a socket costs the heap. So the heap cap holds a base, HEAP_BASE_PAGES = 74,
twice ipd‘s largest measured heap peak apart from its socket tables, 37 pages
(the memory budget), and every socket that can exist at once,
max_sockets × SOCKET_BYTES. The budget, the entry’s
pages less its heap cap and stack, holds every bucket at its caps by the entry rule
(walfsd), a State unit at a connection’s 512 bytes, the buffers being the
heap’s. ipd does not start unless both hold; with no heap cap, the budget holds the sockets’
heap beside the buckets. In the image: a default bucket at its caps is 509,952 bytes (5 parked
calls of 64 KiB, 24 fids and 12 units of 512, 128 multiplexed requests of 256 and their 32 pages),
alice’s 40 fids and 24 units more, 542,720, the seven together 3,602,432, 880 pages. Its sockets
at their worst are 6 × 12 + 36 = 108, so the heap cap is 74 + 108 × 4.25 = 533 pages, and with the
stack’s 128 the entry needs 1,541 of its 4,096 (sshd’s and the steward’s overrides beside five
defaults need 4,775,936 bytes of budget). A heap short of its sockets would fail an allocation in
the middle of a request, a panic, and one person could stop ipd for everyone. smoltcp’s socket
set and ipd‘s socket table are sized at start, so neither grows while serving: the set for every
socket, a slot of 432 bytes on 64-bit, and the table for twice as many entries of 240, a socket’s
own and listeners’, since a listener whose backlog was accepted while its bucket was full keeps
its entry and no socket; past that a socket or a listen is refused too_many. A slot and two
entries are 912 of the 1,024 bytes SOCKET_BYTES allows beside a socket’s buffers, a bound the
build asserts on each width; the image’s 108 sockets’ tables are 98,496 bytes, held from the
start. The bench
holds every manifest’s ipd entry to the rule
(every_manifest_entry_holds_its_buckets_at_their_caps), and ipd refuses at start.
A backstop, not the policy. Should a socket’s buffers ever fail to fit, the socket is refused
too_many, a listen whose backlog cannot be made changes nothing, and ipd says once
ipd: an invariant broke: the heap could not hold a socket's buffers. The line is reachable only
at the records’ residual (residual risks), which no case reaches, so every boot
case forbids it (the test bench).
Sequence numbers and the link
Status: built · tested (10)
- host:redoubt-ipd::each_active_open_takes_one_seed_and_its_isn_is_that_seeds
- host:redoubt-ipd::each_passive_open_takes_one_seed_and_its_isn_is_that_seeds
- host:redoubt-ipd::without_a_seed_nothing_opens
- host:redoubt-ipd::no_link_is_unreachable_until_it_comes_back
- host:redoubt-ipd::martian_sources_are_dropped
- host:redoubt-ipd::non_tcp_and_martian_datagrams_get_no_answer
- host:redoubt-ipd::randomized_frames
- host:redoubt-ipd::randomized_sessions
- fuzz:redoubt-ipd/frames
- fuzz:redoubt-ipd/session
- Every initial sequence number comes from a fresh seed drawn from the kernel’s random words, one per connection, never from smoltcp’s own generator; without a seed nothing opens (R62 (sequence numbers from the kernel)).
- Receiving is one slot: each frame from
netdis processed at once, and one arriving while the slot is full is dropped, as a full wire drops. - Transmitting is a call to
netdper frame.busyor a timeout drops the frame (TCP sends it again);failedputs the link down, andipdanswersunreachableand asksnetdagain with backoff.ipdnever exits on a link fault.
Started by init
Status: built · partly tested: no session’s launcher is started, so no scope is handed one · tested: bench:init-boot
init starts one ipd per network, with its arguments from the manifest: its addresses, its
self= prefixes, the ingress badge, a root scope per badge it hands out, and its bucket count
(init). It hands each session’s launcher a root badge with that
session’s scope, never including the box’s own addresses. In the bench, a case’s manifest does the
same: one scope= and one handed badge for each client.
Name-scoped connections
Status: planned · M5 (self-hosted development)
A person’s session connects by name, never by address: its connection’s rule names the domains
it may reach, and it writes connect(name, port) to a socket’s ctl.
ipdchecks the name against the connection’s rule (a suffix matches only at a label boundary:example.comcoversa.example.com, neverevilexample.com; a blocklist entry always wins).ipdasks the resolver through the resolver connection the steward handed it with this principal’s root, one per (account, label set), never shared across roots, so the resolver’s cache follows the principal.ipddrops every always-forbidden address from the answer: the box’s own addresses (the self set), the host, metadata addresses, anything that routes back to the box. It connects to one of the rest, on a port the rule allows.- The connection is pinned to that address for its whole life, so an answer’s expiry never matters and nothing is granted per answer.
So a name that resolves to a forbidden address is refused at ipd, whatever the resolver said,
which is also the defence against DNS rebinding. A name the rule does not allow is refused, and
net_ctl gains the name-taking connect for it. Agents get no sockets at all: an agent reaches
outside the box only through its gatewayd capabilities (gatewayd).
The attack tests: a name resolving to a forbidden address is refused; a rebinding attempt (a first answer allowed with a lifetime of 0, a second forbidden) fails.
Open: none.
Authority
Status: built · tested: host:redoubt-ipd::only_a_root_badge_with_a_scope_attaches, host:redoubt-ipd::a_labelled_caller_gets_nothing_and_holds_no_bucket, host:redoubt-ipd::frames_count_only_from_the_unlabelled_ingress_badge
ipdholds its endpoint, a handle tonetdfor transmitting, and the connections it minted. It holds no device and no DMA.- A connection’s authority is its scope: which prefixes and ports it may connect to and listen on.
- Only the ingress badge’s frames reach the stack.
ipdhas nounsafeof its own.
Security properties
R58 (a scope reaches only what it allows)
Status: built · tested (4)
- bench:net-attacks
- host:redoubt-ipd::a_scope_permits_exactly_what_its_rules_contain
- host:redoubt-ipd::connects_outside_the_scope_or_to_the_box_are_refused_and_send_nothing
- host:redoubt-ipd::numbers_are_per_connection_and_invisible_to_others
A connection connects only to an address and port some connect rule of its scope contains, and
listens only on a port some listen rule contains; a refused connect sends nothing. In
net-attacks a client scoped to one host reaches nothing at another (the peer counts none)
while its control to the scoped host succeeds.
R59 (never the box’s own addresses)
Status: built · tested: bench:net-attacks, bench:bench-net-self-unrefused, bench:session-tcp-loopback, host:redoubt-ipd::the_self_set
No connection reaches an address of the box itself, whatever its scope: ipd’s own address,
loopback, broadcast, multicast and every self= prefix are refused before the scope is consulted.
So a hijacked session cannot reach approve@box or any other service of the box over the network:
in session-tcp-loopback a session’s gen_tcp connect to the box’s own sshd, at its loopback
and its listed address, is refused and the capture shows no SYN to either.
R60 (a sink refuses labels)
Status: built · tested: bench:net-attacks, host:redoubt-ipd::a_labelled_caller_gets_nothing_and_holds_no_bucket
ipd refuses every caller whose message carries a label, before decoding its request or admitting
it, so no labelled data leaves the box through the network and a labelled caller opens no bucket
that an unlabelled one could observe.
R61 (scopes only narrow)
Status: built · tested: host:redoubt-ipd::a_grant_only_narrows, host:redoubt-ipd::a_grant_never_widens_or_adds_listen, host:redoubt-ipd::new_connection_keeps_the_scope
Every connection’s scope is contained in the scope of the connection it came from: new_connection
keeps it, and a grant succeeds only if each rule it asks for lies inside one of the caller’s own
of the same kind. No chain of grants reaches further than its root badge.
R62 (sequence numbers from the kernel)
Status: built · tested: host:redoubt-ipd::each_active_open_takes_one_seed_and_its_isn_is_that_seeds, host:redoubt-ipd::each_passive_open_takes_one_seed_and_its_isn_is_that_seeds, host:redoubt-ipd::without_a_seed_nothing_opens
Every TCP connection’s initial sequence number comes from its own seed drawn from the kernel’s random words, so an off-path attacker cannot predict one from another connection’s.
Under Miri
Status: built · partly tested: a recorded run, not a bench case
The vendored crates’ unsafe sits outside the ratchet, so Miri checks it instead, with the
features ipd builds them with (alloc, medium-ethernet, proto-ipv4, socket-tcp for
smoltcp; alloc and map for managed; defaults for the rest), under nightly Miri with Stacked
Borrows. ipd’s tests run with MIRIFLAGS=-Zmiri-disable-isolation (the
conformance vectors read a file) and take fewer rounds under cfg(miri). Each vendored crate’s own
tests run in an unedited copy outside the tree.
| Run | Result |
|---|---|
ipd: args, ctl_deadline, fs, grant_undo, isn, program, scope, sizing, stack, sweep, vectors | pass |
| smoltcp 0.14.0, its library tests | 156 pass; storage::assembler::test_random alone runs too long under Miri to finish |
| managed 0.8.0 | 30 pass |
| heapless 0.9.3 | 216 pass; the pool tests cannot run (inline assembly, which Miri does not interpret; smoltcp does not use pool) |
| byteorder 1.5.0 | 412 pass |
| stable_deref_trait 1.2.1 | 6 pass |
| hash32 0.3.1 | no unit tests |
Failure and restart
Status: built · tested: host:redoubt-ipd::no_link_is_unreachable_until_it_comes_back, host:redoubt-ipd::anything_else_stops_ipd, host:redoubt-ipd::a_heap_cap_short_of_its_sockets_stops_ipd_before_it_serves, host:redoubt-ipd::a_disconnect_aborts_and_returns_the_charges
- Bad arguments, limits that do not fit its budget, or a heap cap that does not hold every
socket’s buffers (sizing):
ipdexits before serving. - A socket’s buffers do not fit the heap: reachable only at the records’ residual
(residual risks); should it happen, the
socket is refused
too_manyandipdsays once that an invariant broke. The decision is a backstop, not a panic, because a failed allocation in a shared server would stop it for every client, and the line makes the broken invariant a failure every boot case sees. netdfails:ipdanswersunreachableand asksnetdagain with backoff; it stays up.- A client disconnects: every socket of the connection is aborted and its charges returned.
ipdrestarts: every socket is gone; clients ask for fresh connections (init).
Residual risks
- A NAT hairpin is refused only if listed. An address outside
self=that the network loops back to the box is reachable unless the manifest lists it;sshd’s refusal ofkeyd’s keys is the backstop forapprove@box. - A full backlog costs one ARP per SYN. A flood of SYNs at a listener with a full backlog makes
ipdresolve each sender. - Blind injection. An off-path attacker needs about 2^19 guesses per connection to inject into one, and gains at most a reset; SSH’s MAC rejects injected bytes.
- A SYN flood holds a listener’s backlog. Each half-open connection keeps a backlog slot for up to 3 s before it gives it back, so a flooder can keep a listener from accepting others meanwhile; the listener listens again once its backlog clears.
- Connection rates are observable. Unlabelled principals sharing one
ipdcan see each other’s connection rate in admission-slot occupancy and CPU and queue timing; no label boundary is crossed, since labelled callers are refused. - The skeleton’s records are costed in the budget, and live in the heap. Fids, minted
connections and multiplexed requests are records on
ipd’s heap, while sizing costs them in the budget’s term (512, 512 and 256 bytes), not the heap’s. At those costs the image’s buckets at every cap hold 391,168 bytes of them, 95.5 pages (a default bucket’s 24 fids, 12 units and 128 requests are 51,200 bytes, alice’s 64, 36 and 128 are 83,968), against the 37 pages the base leaves aboveipd’s measured peak. Every such record grows its table fallibly, so a heap they fill refuses the requestno_memory, a refusal of the moment, never a panic. It is one heap, so records past that margin, several buckets at their request caps together and never one person’s alone, can also leave a socket’s buffers no room: the socket is refusedtoo_manyandipdsays its invariant line, which is reachable only here. Every boot case still forbids the line, since none reaches this residual. Counting the records in the heap term too is the follow-up. - A shared
ipdis shared state. Its clients share one stack’s memory, timers and link; where that matters, each trust domain gets its ownipd. - smoltcp is vendored code. It is read and built from the tree, not fetched, but a bug in it is a
bug in
ipd; it is fuzzed only throughipd’s own targets, and itsunsafeis checked by recorded Miri runs, not by a bench case (under Miri).
Why
- Names resolved at
ipd, at connect time. A name a client resolved for itself could stand for any address;ipdresolving the name itself, filtering the answer and pinning the connection makes the name the thing checked, and defeats rebinding. - The box’s addresses before the scope. A scope is written by people and can be wrong; the self set is a check no manifest can widen.
- A sink. Labelled data may not leave the box, and the network is the widest way out; refusing labelled callers outright, before admission, leaves nothing to leak through.
- Vendored smoltcp. A TCP stack is too large to write first and too important to fetch at build time; vendoring pins the exact source that was read.
- Kernel-random sequence numbers. smoltcp’s generator is seeded once; a fresh seed per connection from the kernel keeps one connection’s numbers from predicting another’s.
The resolver
The resolver is the mediated DNS server. It answers only names its caller’s rule allows, from an
allowlist and a blocklist, and never sends a disallowed name upstream. ipd asks it when
a person’s session connects by name, and filters and pins what it answers. So a principal’s network
capability can name domains, not only prefixes and ports, and DNS itself is not a way out.
Purpose
People need to reach services by name, and addresses behind a name change. A name a client
resolved for itself could stand for any address, so a person connects by name at ipd, which
asks the resolver. The resolver is the one place names become addresses and it applies the name
rules; ipd filters what it answers and pins the connection. It also stops DNS being a channel: a
client that can send arbitrary queries to an arbitrary resolver can encode data in the names it
asks for.
Interface
Resolving a name
Status: planned · M5 (self-hosted development)
- Name rules. A caller’s badge names an allowlist of domain names and suffixes and a blocklist
that subtracts from it and always wins. A suffix matches only at a label boundary:
example.comcoversa.example.comand neverevilexample.com. The rules come from whoever granted the connection, and a grant only narrows them. - Callers. The steward mints one resolver connection per (account, label set) from its
resolver root and hands it to
ipdwith that principal’sipdroot;ipduses exactly that connection for everyconnect(name, port)under that root and never shares one across roots (ipd). A session’s own lookups (forgetaddrinfo) use its own steward-minted resolver connection, carrying the same rule; their answers are only informational, since the session connects by name. resolve(name)answers the IPv4 addresses fornameif the rules allow it, with their lifetime. A disallowed name is refused locally with its own error,refused, and never sent upstream, so the names queried cannot carry data out; a nonexistent allowed name is the upstream’s NXDOMAIN (R63 (only allowed names)).- Queries go upstream from the resolver alone, as DNS over TCP through its own
ipdconnection, to the upstream resolvers its arguments name. Sessions have no route to port 53, and no route to anything by address. - Names are checked strictly: lower-cased, at most 253 bytes, labels of letters, digits and hyphens, no trailing tricks; a name that is not a plain host name is refused.
- The resolver is a sink. Like
ipdit refuses every labelled caller, since a query leaves the box.
The attack test: a disallowed name never reaches upstream (the test upstream sees no query).
Open: the protocol table and which page includes it.
Connections by name, pinned
Status: planned · M5 (self-hosted development)
ipd resolves at connect time: it checks the name against the connecting connection’s rule, asks
the resolver, drops every always-forbidden address from the answer, connects to one of the rest,
and pins the connection to that address for its whole life
(ipd). A client never names an address, and an answer widens
nothing beyond the one connection it was asked for
(R64 (connections by name are pinned)). Name rules are for
people’s sessions only; an agent holds no socket and no name rule, and reaches outside the box only
through gatewayd (gatewayd).
Open: none.
Caching
Status: planned · M5 (self-hosted development)
The cache is keyed by the connection a query arrives on, its own badge, never by anything in the
query and never by the connection’s admission share: the account-0 fold of
R26 (admission fairness) governs admission only.
Since the steward mints one connection per (account, label set) for ipd and one for the session,
each principal’s lookups fill only its own caches, and no principal’s answers are served from
another’s: a shared cache would tell one principal, by how fast it answers, which names another
asked for. A cached answer keeps the lifetime the upstream gave it, capped by the resolver’s own
maximum.
The attack test: two principals resolve one name and the upstream sees two queries; a mutation that keys the cache by rule or name alone fails it.
Open: the cap on an answer’s lifetime and on each connection’s cache size.
Authority
Status: planned · M5 (self-hosted development)
- The resolver holds its endpoint, one
ipdconnection scoped to its upstream resolvers’ addresses and port. - A client’s authority is its name rules: which names it may have answered.
- It holds no keys and no device.
Open: none.
Security properties
R63 (only allowed names)
Status: planned · M5 (self-hosted development)
The resolver sends upstream, and answers, only names its caller’s rules allow and its blocklist does not; a refused name causes no query. So a client cannot use DNS queries to carry data to a resolver it chose, or to learn names outside its rules.
Open: none.
R64 (connections by name are pinned)
Status: planned · M5 (self-hosted development)
A connection made by name reaches exactly one address, one the resolver answered for that name at connect time and that is not always-forbidden, on a port its rule allows, for its whole life.
Open: none.
Failure and restart
Status: planned · M5 (self-hosted development)
- The resolver restarts: its cache is gone; connections by name made meanwhile fail and are
asked again. Connections already open are
ipd’s, pinned, and stay. - An upstream resolver fails or lies: the answer is an error, or another address, which
ipdstill filters; a lie cannot reach a forbidden address or a port the rule does not allow.
Open: none.
Residual risks
- Upstream answers are believed. DNS over TCP is unauthenticated: an upstream or on-path attacker can answer an allowed name with another address, and the person’s connection goes there on the allowed ports. Forbidden-address filtering still holds. DNS over TLS waits for a TLS implementation.
- The allowed names are a channel. A client may choose which allowed names it asks for and when; the resolver bounds the alphabet to its rules, not the timing.
- The upstream’s cache is shared. The upstream DNS server’s own cache serves everyone outside the box as well, so timing there is a side channel the resolver cannot close.
- A name’s addresses are shared. An allowed name hosted beside other services on one address reaches all of them on the allowed ports.
Why
- Names resolved for
ipd, not for the client.ipdchecks the name the session asked for and the resolver answers it; the resolver alone could not stop a connection it does not carry, andipdalone would not know what a name means. Pinning ties the two together. - No route to port 53 for sessions. A client that can query any resolver can encode anything in the names it asks for; one resolver that answers only allowed names closes that.
- Per-principal caches. A shared cache is a timing channel between principals.
gatewayd
gatewayd is the gateway between agents and the services outside the box that need credentials:
first one model provider’s API, and git hosting. It holds the API keys and speaks TLS to the
service itself; an agent holds a capability to gatewayd, never a key. Each capability names what
its holder may ask for, and gatewayd checks every request against it, meters tokens and money
per principal, and logs every call. It is a label sink, cleared for nothing.
Purpose
An agent needs a model and a git remote, and both want a secret: an API key, a token. A secret in
an agent’s memory is a secret the agent can send anywhere. gatewayd keeps credentials out of
every session: it adds the key to a request it has checked, sends it over its own TLS connection to
the one service the capability names, and returns the answer. What an agent can do with the
service is what its capability allows, for as long as its lease lasts, and every call is on the
record.
Interface
Gateway capabilities
Status: planned · M5 (self-hosted development)
- A gateway capability is a connection to
gatewaydwhose badge names a grant by name (themodelgateway, say), optionally narrowed to some of its hosts, with a meter: tokens and money the holder may spend, carved from its principal’s. Agitgrant names its remotes, grants fetch and push separately, limits push to named ref patterns, and refuses force-push unless granted; the client, a Rustgit, speaks git’s smart HTTP togatewayd(the userland pages on agents and development use these grants). - A capability is granted by the steward, for a session or a lease, and a grant only narrows: a lease’s capability names no more service, operations or budget than its sponsor’s (steward).
- Model requests are typed, one message per operation, never raw HTTP:
gatewaydbuilds the outgoing request itself from the checked fields, so a holder cannot reach another path, host or header of the service. The reply comes back as a stream of typed events (text, a tool call, the end of a turn, the usage), whichgatewayddecodes from the provider’s own stream, so no provider’s wire format reaches an agent’s VM (agents). Git is the one protocol relayed: the client speaks git’s smart HTTP togatewayd, which parses each request and checks its remote, operation, refs and force against the grant before forwarding it with the credential, and passes nothing through unchecked. - Every request is checked against the capability before anything leaves the box: the service, the operation, the model or repository, the size, and what the meter has left; a request over the meter is refused (R65 (a request only within its capability)).
- Metering. The steward owns the meters, so they survive a
gatewaydrestart, and reboots once the steward keeps state in M6 (persist, install, share). Before sending,gatewaydchecks the principal’s spend cap against the steward’s figure, and refuses a request over it; after each response it reports the usage the provider gave. - Logging. Every call is recorded with the principal chain, the capability, the operation and its cost, in the steward’s audit log (steward).
sequenceDiagram
participant A as agent
participant G as gatewayd
participant I as ipd
participant P as model provider
participant S as steward (audit)
Note over A,S: planned
A-->>G: complete(model, prompt) on its gateway capability
G-->>S: spend cap and the principal's figure
G-->>G: check service, model, size,<br/>under the cap
G-->>I: TLS connection to the provider<br/>(gatewayd's own scope)
G-->>P: HTTPS request with the API key
P-->>G: completion and usage
G-->>S: usage for the meter, audit record:<br/>principal chain, operation, cost
G-->>A: the completion (no key, no headers)
Figure: an agent’s model call through gatewayd. All of it is planned.
The attack test: a request over the spend cap is refused before anything is sent.
Open: the protocol table and its operations for the first provider and for git; how money is
priced from usage; the finer checks on git requests beyond remote, operation, refs and force
(size limits, path rules).
Keys and TLS
Status: planned · M5 (self-hosted development)
- API keys are bearer secrets, not signing keys, so
keydcannot hold them:gatewaydholds them itself, from its arguments, and no operation returns one (R66 (no credential leaves gatewayd)). - TLS is
gatewayd’s own, to each service’s fixed host name, checking the service’s certificate against rootsgatewaydis configured with.gatewaydconnects by name, as a person does (ipd), on its ownipdconnection whose rule names only the services it serves. - Where the keys come from. In M5 (self-hosted development) the API keys arrive as
gatewayd’s arguments in the boot manifest, which is never public, at the bundle’s trust, askeyd’s seeds do. - A git host’s SSH or token credentials are held the same way.
Open: the TLS implementation is pure Rust with no C (rustls with a pure-Rust cryptography provider, not one wrapping C); which provider is still to be chosen. Where API keys live across a reboot once the steward keeps state.
A label sink
Status: planned · M5 (self-hosted development)
gatewayd refuses every labelled caller, like ipd: whatever it forwards leaves the box. A model
that runs on the box can be a server cleared for a label, one instance per label set; gatewayd is
for services outside.
Open: none.
Authority
Status: planned · M5 (self-hosted development)
gatewaydholds its endpoint, the API keys and credentials of the services it serves, oneipdconnection whose name rule names those services, and a way to append to the audit log.- A client’s authority is its capability: one grant, named operations, a meter.
- It never holds a principal’s own keys;
keyddoes.
Open: none.
Security properties
R65 (a request only within its capability)
Status: planned · M5 (self-hosted development)
Every request gatewayd sends is built by gatewayd from fields it checked against the caller’s
capability: the capability’s service, one of its operations, within its meter. So a hijacked agent
can spend at most its meter on its named operations, and reach no other service, path or account
with the key.
Open: none.
R66 (no credential leaves gatewayd)
Status: planned · M5 (self-hosted development)
No operation returns an API key or credential, and none is ever sent to anything but the service it belongs to, over TLS to that service’s name. So no session, agent or lease holds a credential it could send elsewhere.
Open: none.
Failure and restart
Status: planned · M5 (self-hosted development)
gatewaydrestarts: requests in flight fail and are asked again; the meters are the steward’s and survive.- The service fails or is unreachable: the request fails with the service’s error, and nothing is charged but what the service reported used.
Open: none.
Residual risks
- The service sees what the agent sends. A prompt carries whatever the agent put in it; the gateway limits where it goes and how much, not what it says. Labelled data never reaches it, since it is a sink.
- The keys sit beside the response parser.
gatewaydholds every API key and parses provider responses (TLS, HTTP, JSON) in one process, so a response-parser bug reaches the keys. Splitting TLS out would not hide them, since the key travels inside the TLS plaintext. A leaked key is revoked at the provider. - Metering trusts the provider’s usage figures.
- The API keys sit in the manifest and the bundle. Until the steward keeps state, they arrive as
gatewayd’s arguments, so whoever can read the bundle image holds them, as withkeyd’s seeds; the manifest is never public, and the bundle is signed, not encrypted. - An answer is untrusted input. What the model returns can try to steer the agent; the agent’s capabilities, not the gateway, bound what that can do.
Why
- No credentials in agent memory. A key in an agent is a key the agent can leak; a capability to a gateway is revoked with the lease.
- Typed operations, not a proxy. A general HTTPS proxy with a key attached lets its holder call any endpoint the key allows; typed operations built by the gateway allow only the named ones.
- A sink. A gateway is a way off the box, so it takes no labelled caller.
sshd
sshd is the box’s front door: SSH, on ipd’s port 22. It authenticates a person’s key with the
steward’s answer, has keyd sign the key exchange with the host key it never holds, and carries
each session over its own channel, labelled with the session’s labels. It serves /dev/cons to
each session on that channel, and ssh approve@box, where only the steward talks. For file
transfers it only relays: the steward starts a transfer server per channel, holding the session’s
file binds and its audit connection, and nothing else. It uses sunset, an SSH library in no_std Rust without allocation.
Purpose
People reach the box only over SSH, so the one process that parses every byte from the network
before login is also the one that decides which channel a session’s output may reach. sshd keeps
both small: it holds no keys and decides no policy. Whose key a login used is the steward’s
question, the host key’s signature is keyd’s, and a channel’s labels come from the session the
steward started.
Interface
The core and its platforms
Status: built · tested (10)
- bench:sshd-host-tests
- bench:sshd-build
- bench:sshd-loopback-logins
- bench:sshd-loopback-r67
- bench:sshd-loopback-interrupt
- bench:sshd-loopback-independent
- bench:sshd-loopback-window-change
- bench:sshd-loopback-window-change-zero
- bench:sshd-loopback-env-refused
- bench:bench-ssh-loopback
sshd is a core and a platform. The core runs sunset over byte slices and makes every decision
this page states: the login name, the key checks, a channel’s labels, and what a channel may not
do. The platform is only what the core asks for, and is one trait:
- sign an exchange:
keyd’ssign_ssh_exchange, given the transcript’s parts (keyd); holds(key):keyd’s answer;- login: the steward’s answer, a session and its labels or a refusal (steward);
- a session’s console: its bytes each way, the window size, the interrupt, and its end;
- a refusal: the kind of each channel request the core refuses (
env,exec,subsystem, and anypty-req,shell,window-change,signalorbreakit does not pass on), as a name the core chooses; a labelled session’s shell without a pty is named apart,shell-without-pty, for the audit of R67. Never the request’s content: anenvname or value is the client’s bytes, and a log line built from it would be a line the client wrote.
The connection’s bytes are not in the trait: the core takes and gives slices, and the platform
moves them. On the box the platform is ipd’s listen scope, keyd’s ssh_host badge, the
steward and /dev/cons. On the build host it is a host tool, so the bench logs in with OpenSSH’s
ssh long before init and the steward exist
(against Redoubt’s sshd):
- Transport: standard input and output, one connection per process, as
sshstarts it for aProxyCommand; a log file of the server’s own lines. A client that hangs up, assshdoes after a refused login, ends the connection without an error. A write to a client that has gone can still fail (a broken pipe, aftersshrefuses the host key); the bench sends the server’s standard error to its log, so nothing of the server’s followsssh’s own last line. - Signer:
keyd’s own server code, in the same process, given one key inkeyd’s argument form,name,ssh_host,seed. So OpenSSH verifies a signature over the exchange hashkeydbuilt, end to end. The host tool tests the protocol, the login flow and the channel rules, not key separation: itskeydshares its memory, where on the box it is a separate process. - Logins: a fixed table. Principal
Plogs in with its test key fromtests/keys/, asP(no labels) orP+L(labels{P-L}); anything else is refused. - Console: a scripted line console, no shell:
echo,sleep,tty(whether the channel has a pty),labels(the channel’s labels),exit N, with;between commands. It logs each window change and interrupt it receives, and the platform logs each refusal by its name.
The key exchange is curve25519-sha256 only, the one keyd signs, with Ed25519 host and login
keys. sunset is used as published with one patch (patched
crates), which changes three things:
- The server’s host key signs outside it. Given a host key’s public half only,
sunsethands out the exchange’s parts (V_C,V_S,I_C,I_S,Q_C,Q_S,K) and waits for the signature, as its client already does for an SSH agent. It keeps a bounded copy of the peer’sKEXINITfor this, since after the first exchange onlysunsetsees one in the clear, and refuses a larger one (over 4 KiB). It still computes the hash itself for the session keys, and checks the signature against it before sending it, so a disagreement fails the exchange on the server; the client checks it too. - A client’s
window-change,signalandbreakrequests reach the core, and so does a pty’s starting size; publishedsunsetdrops the three requests on a server, and does not hand out the size. Its client gainsterm_signal, beside theterm_breakandterm_window_changeit has, so the patch’s tests send all three. The server gainssession_exit, which sends a session’s exit status, then its EOF, then its close, where publishedsunsetsends EOF and close only as echoes of the client’s. The server no longer echoes the client’s EOF, which is one direction only (RFC 4254): publishedsunsetwould end the session’s output there. A public key request gainssigned(), which tells a signed request from a query. - Its X25519 and Ed25519 verification use
ed25519-compact, the crate the loader andkeydalready link, in place of thedalekcrates. The box then has one implementation of each curve operation.
Under Miri
Status: built · partly tested: a recorded run, not a bench case
The vendored crates’ unsafe sits outside the ratchet, so Miri checks it instead, as for
ipd’s: nightly 2026-09-23 (rustc 1.100.0, Miri 0.1.0 of the same
nightly), Stacked Borrows, MIRIFLAGS=-Zmiri-disable-isolation (cmov’s property tests and
getrandom’s read the working directory or the system’s random source), and
RUSTFLAGS='--cfg aes_backend="soft" --cfg chacha20_backend="soft" --cfg poly1305_backend="soft" --cfg sha2_backend="soft"', so that each crate runs the software backend the box compiles. Each
crate’s own tests run in an unedited copy outside the tree, with the features the box builds it
with. Two of the box’s paths are not the ones Miri runs: on RISC-V, cmov makes its masks and
zeroize its optimisation barrier with inline assembly, which Miri does not interpret, so under
Miri both crates take their portable Rust paths. Those two assembly blocks are read
(vendor/README.md).
| Run | Result |
|---|---|
sshd: core (19 tests, each a whole SSH connection) | pass, in four groups, about 25 minutes in all |
aes 0.9.3 (zeroize) | 2 pass |
chacha20 0.10.2 (cipher, zeroize) | 5 pass |
| poly1305 0.9.1 | 6 pass |
| sha2 0.11.0 (no default features) | 14 pass; sha256_rand and sha512_rand, which hash a long generated stream, run too long under Miri to finish |
| inout 0.2.2 | 1 pass |
block-buffer 0.12.1 (zeroize) | 14 pass |
hybrid-array 0.4.15 (zeroize) | 70 pass |
| cmov 0.5.4 | 121 pass; its 73 property tests run too long under Miri to finish (7 in nine minutes) |
| zeroize 1.9.0 | 27 pass |
| subtle 2.6.1 (no default features) | 33 pass |
ascii 1.1.0 (the vendored copy: as published it does not compile on this nightly: a pattern binding named like an AsciiChar variant is now an error, which its patch fixes by renaming two bindings) | 94 pass; is_digit_strange_radixes fails natively too, since char::is_digit now panics on a radix below 2, and two doctests hit the same error |
| getrandom 0.4.3 | 16 pass, on its Linux backend; the box’s custom backend calls a function the program provides, which no test defines |
Sessions over SSH
Status: built · tested (30)
- bench:steward-ssh-resize
- bench:steward-ssh-two-principals
- bench:sshd-login-bound
- bench:sshd-refuses-slots
- host:redoubt-sshd::the_bound_is_twice_the_slowest_login_rounded_up
- host:redoubt-sshd::a_connection_not_logged_in_is_closed_at_the_bound_and_not_before
- host:redoubt-sshd::a_connection_logged_in_is_never_closed_by_the_bound
- host:redoubt-sshd::the_image_entry_holds_its_slots
- host:redoubt-sshd::every_manifests_slots_fit_ipds_parked_calls
- bench:steward-context-login
- host:redoubt-sshd::the_login_grammar
- host:redoubt-sshd::a_user_name_outside_the_grammar_reaches_the_steward_as_nobody
- bench:steward-ssh-idle
- bench:steward-vault-session
- bench:steward-vault-launch
- bench:steward-login-refused
- bench:steward-session-ends
- bench:steward-sub-budget-flood
- host:redoubt-sshd::a_read_waits_for_input_and_takes_what_was_typed
- host:redoubt-sshd::a_write_waits_for_room_and_the_channel_takes_it
- host:redoubt-sshd::an_accept_ipd_s_wait_ran_out_on_is_asked_again
- host:redoubt-sshd::a_data_call_refused_too_many_is_asked_again_a_bounded_number_of_times
- host:redoubt-sshd::an_ended_session_reads_the_end_and_cannot_write
- host:redoubt-sshd::the_console_is_one_file_carrying_the_channel_s_labels
- host:redoubt-sshd::consol_size_is_the_pty_s_and_a_resize_is_due_when_it_changes
- host:redoubt-sshd::a_login_s_two_consoles_and_only_the_steward_s_ends_the_channel
- host:redoubt-sshd::only_a_pty_channel_s_end_of_input_closes_the_terminal
- host:redoubt-sshd::a_slot_returns_when_the_client_hangs_up_first
- host:redoubt-sshd::a_slot_returns_when_the_server_ends_the_connection_first
- bench:steward-restart-ssh
- Listening.
sshdis the sole holder of anipdscope that listens on TCP port 22. Its accept, and each connection’s reads and writes, are asked again whenipdanswerstimeout(its wait ran out, ipd), and after 10 ms, up to 500 times in a row, when it answerstoo many(every in-flight call ofsshd‘s share taken: the accept, the slots’ parked reads and a driver’s waiting write). Any other failure of the accept endssshd(failure and restart), and of a connection’s call that connection only. - The host key.
sshdholdskeyd’sssh_hostroot badge, handed to it by the manifest, and askskeydto sign each key exchange;keydbuilds the exchange hash itself (keyd). The host key is never insshd’s memory, and the steward never holds its badge. - Login. A user name is
principal[+label][.context], in that order only, each part 1 to 64 bytes of[a-z0-9_-]starting with a letter (alice,alice+secrets,alice.work,alice+secrets.work);approvetakes no label and no context (contexts).sshdrejects a login key thatkeydholds (holds), then asks the steward whose key it is with the typed calllogin(principal, label, context, from, key, console, relay); the steward checks every part again and answers with a session, or refuses (steward).fromis the client’s address,a.b.c.d:port, whichsshdreads fromipd’s/tcp/N/remotefor the connection, or empty ifipddoes not say: only the steward’s notes of a context’s takeover show it.sshdparses the name only to split it and refuses nothing on it: a user name outside the grammar goes to the steward as the empty principal, which no manifest names, so every refusal after a verified signature takes the one path and says nothing about which names exist. Login keys are the person’s own and never live inkeyd(R35 (key separation)). - A key is tried twice. A client first asks whether a key would do, then sends a signature
with it.
sshdanswers the question withholdsalone: a keykeydholds is refused, any other may be tried. It asks the steward only once the signature has verified. So the steward never weighs a key whose private half the client has not shown, and a client that has not authenticated learns nothing about whose keys are whose. Passwords, keyboard-interactive andnoneauthentication are refused. - A pty session. A session gets one channel with a pty, on which
sshdserves its/dev/cons(consoled has the same protocol): input from the channel, output to it, and the window’s size and its changes. The channel’ssignalrequest (INT) andbreakrequest reach the session as its own interrupt key, the 0x1C byte (Ctrl+\), which no full-screen program can take (the shell): protocol messages, not signals, since nothing in Redoubt has signals. The core checks what arrives raw before the session sees it: any other signal is refused, a break’s length is not passed on, and a window size over 1,024 columns or rows reaches the session cut to 1,024. A zero means no size, as RFC 4254 says (a client whose input is not a terminal sends zeros): awindow-changecarrying one is refused, and a pty asked for with one starts at 80 by 24. On the console’s endpointsshdservesconsol:sizeanswers the window’s size (80 by 24 on a channel without a pty), and aresizeis parked until the window changes to another size, then answered with it; awindow-changeto the size it already has wakes nothing, and the session’s end answers every one still waiting, for the last time: after the end aconsolcall is refused (malformed), so a VM still running after its channel has ended stops asking rather than being answered at once for ever. A waitingresizeis one of the channel’s parked calls, in its caller’s share of the 4 a bucket holds, beside its waiting reads and writes: a client that parks more is refused at once (malformed), and what any number of waiters costssshdis that bucket’s fixed slots, not memory beyond them. The console’s parked completion call is answered at the end of each hold (multiplexed connections), so a session stays however long its channel is quiet. A program the session launches gets no connection to the console: its standard streams are pipes the session serves through itspiped, and what it writes reaches the channel only as the session draws it (native programs). - Two connections to a channel’s console. A login mints two, and sends both to the steward:
the steward keeps the first and hands the second to the context’s console relay
(consrelay), which serves the session’s own
/dev/consand forwards it here. Both read and write the channel, under its label check; onlyconsol’sendedthrough the steward’s ends it (console::Consoles), so neither the relay nor anything in the session can end its channel, and once the steward has, the relay’s reads find the end and its writes are refused. The channel’s skeleton is sized for this: two buckets,sshd’s own, through which it mints, and the session’s account; and two connections in the first, one login’s, since a channel has one login at a time. - Randomness. The program provides
getrandom’s__getrandom_v03_custom, the only sourcegetrandomhas on bare metal, and it writes all of the buffer it is given before it returnsOk:getrandomthen reads every byte as initialised (vendor/README.md). - State is per channel, and each channel carries its session’s labels (
alice@: none;alice+secrets@:{alice-secrets});sshdapplies the label check to them (R25 (the label check)). Channels are independent: one channel’s close, logout or VM death leaves every other channel,approve@box’s included, usable, and a channel learns nothing of another’s window size or waiting reads. - The one sink cleared for a label. A vault session’s output may reach its own channel, which
the steward opened for the label’s owner, and only a pty channel its owner authenticated: no
forwarding, no subsystems, no
execon a labelled channel. No other sink has an owner exemption (R67 (a channel keeps its labels)). - The box’s platform serves as many connections at once as its manifest entry’s
buckets=Nsays, each in a slot of two threads started once: a driver that runs the core, serves the channel’s/dev/consand makes the connection’s calls tokeyd, the steward andipd, and a reader that waits inipdfor the client’s bytes and hands each read to the driver. A call tokeydor the steward holds up only its own connection; a connection beyond the slots is closed when it arrives. A slot is free again once the reader’s last call for its connection is taken, whichever side ended the connection first.- The slots are the manifest’s.
initsizes N as it sizes every shared server’s count: at least the (account, label set)s the manifest declares, one channel for each at once, four in the image (init).sshdreads N with the serving library’s parser and refuses an entry it cannot hold, as every server does: the slots’ stacks must fit the entry’s budget, its pages less its heap cap and its first stack’s bound of 128 (serving). A slot’s two stacks are 48 pages for the driver (a whole login in one thread needs more than 88 KiB and fits 96 KiB; twice that) and 4 for the reader, and the steward’s watcher takes 4, so N slots take 52 N + 4 pages: the image’s four take 212 of the 512 its entry leaves (1,024 less the heap cap’s 384 and the 128), and nine would fit where ten would not. An entry that does not fit is left at once with a line saying the arithmetic, beforekeyd,ipdor the steward is asked, andinitrestarts it until its reboot rule. Each slot also keeps one read parked atipdbeside the listener’s accept, so N + 1 is at most the parked callsipd’s bucket forsshdallows, 5 by default, or a manifest’slimits=forsshd’s badge there (ipd): four slots and the accept are the default’s five.sshddoes not readipd’s entry, so that bound is held by a host test over every manifest in the tree, which parsesipd’s arguments asipddoes; a manifest with more domains raisesipd’s cap forsshdwith it. - Before login a slot is held for a bounded time. A connection that has not logged in
within the bound is closed, and the slot serves the next; the client is told nothing, as
every refusal before login is uniform. The bound is twice the slowest login the bench
records, rounded up to a second: from
ssh’s start to the shell’s prompt, one run of each case on each width,launch-systemtook 2.01 s (rv64) and 1.70 s (rv32), andsteward-ssh-two-principals2.00 s and 1.78 s for alice, then 2.41 s and 2.06 s for bob beside her running session; twice 2.41 s is 4.82 s, so the bound is 5 s. After login the connection is a channel, bounded by its session: a login past the context cap is refused and the connection ends (R82 (the context cap)), so no rule on how many connections one principal may hold is needed, and none is kept.
- The slots are the manifest’s.
- The steward’s end.
sshdkeeps onewatchcall at the steward, which the steward holds unanswered while it runs. When the steward ends, the call ends with it (Dead, R4b (a server dies), or an answer as it exits), and every session it ran is over:sshdends each of their channels with status 1 and sayssshd: session <id> ended: the steward is gone, and callswatchagain, which waits at the steward’s endpoint for its next instance (a second’s pause first if the call ended at once). A login, or a channel’s close, waits at most 30 seconds for the steward’s answer and is then refused, so a steward that is slow to come back holds no slot. - Ending. A channel closes when the steward lets it go: when its session ends or its VM
dies, or when a login takes its context over; the steward tells
sshdwithendedon its own connection, andsshdcloses the channel with status 0. A channel the client closes, or a pty channel whose input it ends once the session has read it all (the person closed the terminal), is reported to the steward (channel_closed), and in the second case closed with status 0; the steward detaches the context: the session runs on, and the next login of the context reattaches it (steward); a read on that channel finds no end of file until the channel ends, after the steward’s detach. On a channel without a pty (ssh alice@box < file) the end of input is the input’s own end: the session reads it as the end of the file, through its context’s relay, and the channel stays until the session ends or the client closes it.
sequenceDiagram
participant C as client
participant SH as sshd
participant KD as keyd
participant ST as steward
participant S as session
C->>SH: key exchange
SH->>KD: sign_ssh_exchange(transcript)
KD->>SH: signature
C->>SH: userauth alice+secrets, key K
SH->>KD: holds(K)
KD->>SH: no
SH->>ST: login(alice, secrets, K)
ST->>SH: session, labels {alice-secrets}
C->>SH: pty channel
SH->>S: /dev/cons on the labelled channel
Figure: an SSH login to a vault session.
The operations sshd sends the steward are in the steward’s table
(steward).
Measured by the bench from its ssh’s start to the prompt’s match (QEMU, release build, seconds
on the host’s clock; three runs each of launch-system at four harts and
steward-ssh-two-principals at one, at main of 2026-10-15), a login reaches its shell’s prompt about
1.9 s after ssh starts on either width: 1.65–2.08 s alone, and 2.1–2.3 s for a second
principal’s login beside the first’s running session, rv64 and rv32 within 5 % of each other. The
connection, the key exchange, the login, the steward’s carve and the VM’s start, to beamlet’s first
read of the shell from the boot pack, are the first part, 0.8 s alone and 1.15 s beside another
session; the shell’s load from that read to its prompt is the rest, 1.0–1.1 s, the larger stage on
both widths and in both cases. The console’s lines carry no host time, so the handshake and the
carve are not told apart within the first part. The bench prints each login under its case’s
result, so every train re-measures it (the test bench); no ceiling
is asserted until two trains agree within 20 %.
approve@box
Status: built · tested: bench:steward-approve-box, bench:steward-screen-inert, bench:steward-pending-cap, host:redoubt-steward-server::an_approval_channel_opens_on_the_approval_key_alone
ssh approve@box authenticates with the person’s own approval key, one of their own SSH keys that
the manifest lists for approval, and on that connection only the
steward talks: sshd relays the steward’s rendered requests and the person’s answers, and nothing
a session or agent sends reaches it (steward). The user
name approve names no principal, so sshd sends the key alone: it mints two connections to the
channel’s console, as for a login, and asks the steward with approval_opened(key, console, reader); the steward finds the principal from the key, opens the channel only for one of its
approval keys (a login key is bad_key, and sshd denies the login) and answers the channel’s
id, which serves as the session’s here; the close is approval_closed. The steward writes its
screens through the first connection and its reader for the channel reads the person’s lines
through the second, so the bytes each way are the steward’s and the person’s alone. The person
answers approve ID HASH with the hash the screen showed in full, or with the eight-character
code the screen shows beside it: a person cannot type 64 hex characters reliably, and the code
binds the approval to the screen the person saw as the full hash does, because the steward
expands it only from the last screen it wrote on that channel, and the screen is the steward’s
alone to write. A session’s
network scope never includes the box’s own addresses (ipd), so a
hijacked session cannot log in to approve@box over loopback
(R68 (only the steward on approve@box)). An address that
loops back to the box without being listed as the box’s own (a NAT hairpin) is refused only if the
manifest lists it; the backstop is that sshd refuses any login key keyd holds, so a hijacked
session that reaches approve@box that way still has no key to sign with.
Files in and out
Status: planned · M4 (files in and out)
File transfer is SFTP, for unlabelled sessions only (transfer has the operation table).
sshdrelays bytes only. On asubsystem sftprequest it asks the steward to start the transfer server for that session and pipes the channel to it, as it does/dev/cons.sshdnever parses SFTP.- The transfer server is a small native Rust program. The steward starts one per channel, in a
budget carved from the session’s, holding only the session’s file binds and one connection to
the steward’s audit path: no
/net, no/dev/cons, no powerbox, no budget or process handles. An SFTP-only connection gets a session budget as a login does, with the transfer server in place of the VM. - Confinement is by capability, not by path strings: the server holds namespace handles and
nothing else, so no path, however written, reaches anything outside them, and
..at a root stays at the root. - Unsupported operations fail visibly. Symlink, readlink and link get “operation unsupported”;
setting a size truncates, and mtime is set where
littlefsdstores it; mode, owner and group get “operation unsupported” rather than a silent no-op. - SCP is served only as SFTP. Current
scpclients use SFTP by default; legacyscp -O, which runsscpon the server, is refused, since there is noexecand no shell to run it. Old clients usesftporscp -s. - Vault sessions get no SFTP or SCP. A labelled channel has no subsystems (R67 (a channel keeps its labels)); vault input enters by an audited push and output leaves by declassification (steward).
- Every operation is audited: each open (reading and writing), each close with its byte count, remove, rename, mkdir, rmdir and setting attributes gets one record, signed as every audit record is (steward). The principal and labels in a record come from the badge the steward minted for the transfer server, never from the server’s own claim.
The attack tests: a path-escape suite (.., absolute paths, long and odd names) reaches only the
session’s binds; symlink, chmod and chown are refused; scp -O is refused; a subsystem request on a
vault channel is refused; every operation yields exactly one signed audit record with the right
principal; a second principal’s files are unreachable.
Open: auditing at littlefsd of the transfer server’s handles, needed only if the audit must survive
a compromised transfer server.
Authority
Status: built · partly tested: the transfer server request comes with M4 (files in and out) · tested: bench:steward-ssh-two-principals
sshdholds itsipdlisten scope for port 22,keyd’sssh_hostroot badge, a connection to the steward, and the/dev/consendpoints it serves to sessions.- It holds no private key and decides no login: it asks the steward whose key a login used, and asks the steward to start a transfer server for an SFTP request (files in and out). It never holds a session’s file binds itself.
- It is trusted across the labels of the channels it carries: with the steward it is the confinement check’s one named exception (init).
Security properties
R67 (a channel keeps its labels)
Status: built · tested: bench:sshd-loopback-r67, bench:steward-vault-session, bench:steward-vault-launch, host:redoubt-sshd::the_console_is_one_file_carrying_the_channel_s_labels
Each SSH channel carries its session’s labels, and a labelled session’s output reaches only its own
pty channel, authenticated by the label’s owner, with no forwarding, subsystem or exec. So vault
data leaves the box over SSH only to the person who owns the label, on the channel they opened.
R68 (only the steward on approve@box)
Status: built · partly tested: a route back to the box the manifest does not list is the stated gap · tested: bench:steward-approve-box, bench:steward-screen-inert
On an approve@box connection, authenticated with the person’s own approval key, every byte shown
comes from the steward and every answer goes to it; no session, agent or other channel can write to
it or open one from inside the box. A route back to the box that the manifest does not list as the
box’s own is the gap in the second half; sshd’s refusal of every key keyd holds is the backstop,
so such a session still cannot authenticate there with a keyd key.
Failure and restart
Status: built · tested: bench:sshd-restart-detaches, host:redoubt-sshd::a_refused_signature_fails_the_exchange
sshdcrashes: every SSH connection drops; the steward sees itswatchcall end and detaches every context, which runs on until a login reattaches it.initrestartssshd(init). The restarted instance is handed its predecessor’s badge atipd, and with it that one’s sockets, which outlive their owner until they are closed or aborted (ipd): its listener on port 22 would take every new connection and never accept it. So at its startsshdreads/tcpand aborts every socket the badge holds, then sendsTversion, which clunks every fid the predecessor left (serving), before it attaches and listens.keydfails a signature: the key exchange fails and the client sees a closed connection.
Residual risks
approve@sharessshdwith the most hostile input. Asunsetbug reached from any channel, before or after login, controls every channel and the approval screen, and a network flood delays approvals. A separatesshdinstance forapprove@, or the physical console, is planned for M6 (persist, install, share).sshdis trusted across labels. As one of the confinement check’s named mediators it carries every session’s channel; a bug in it reaches all of them.- The transfer audit is accountability, not a wall. A principal who compromises their own transfer server with crafted input holds only their session’s file capabilities, which they had already, and could suppress their own records; they could move data out unaudited through the pty anyway.
- An
ssh_hostbadge speaks as the box. A compromisedsshdcan complete key exchanges as the box for as long as it runs. sunsetcarries our patch. The patch is ours to read and keep: asunsetrelease that changes the code it touches needs it redone and read again.- A few unusual clients fail closed.
sunsethashes its own re-encoding of the peer’sKEXINIT, so a client whoseKEXINITdoes not re-encode to the same bytes fails the exchange; anded25519-compactrefuses a non-canonical X25519 public value. - Ed25519 signatures are checked cofactored, as the loader’s are (boot): a client’s signature can be made into another valid one for the same exchange, which authenticates nobody new.
- Agent forwarding is refused where no platform sees it.
sunsethas no agent code:auth-agent-req@openssh.comis left out of its request types, so it parses as an unknown request, whichsunsetrefuses before the core (with a failure only if the client wants a reply, andsshdoes not). Read from the vendored source; no case shows it, since nothing reaches the log. - The key exchange is not post-quantum. Traffic recorded now could be read by whoever later
breaks X25519. A post-quantum exchange (
mlkem768x25519-sha256) changes the transcriptkeydsigns. - Connections are first come, first served.
sshdserves its manifest’s count of connections at once, whoever opened them, and closes any beyond. A connection that never logs in holds a slot for at most the login bound, and a principal’s channels are bounded by its context cap (R82), so the slots a client can hold are bounded in time and in number; but within those bounds a principal’s open connections still keep another’s logins out until they end, and nothing favours a principal with no session over one with several.
Why
- Keys elsewhere. The process that parses pre-authentication bytes from the whole network is the
last place for a key;
keydsigns, andsshdasks. - The steward decides logins. Principals and their keys are the steward’s;
sshdasking keeps one place that knows them. - One cleared sink. A vault’s output must reach its owner somewhere; one sink, one kind of channel, the owner’s own authentication, and nothing else keeps the exemption as narrow as it can be.
sunset. An SSH implementation inno_stdRust with no allocation and nounsafe, by an author of dropbear, is small enough to read. Its core does no I/O, so the platform is a thin trait, and the host tool tests the same core the box runs.- A patch, not a fork. Carrying the published crate and one patch keeps what we changed
visible and small; its signer and channel-request changes are offered to
sunset’s author. - One curve implementation.
ed25519-compactis already read for the loader andkeyd, has no dependencies, and does X25519 as well; thedalekcrates would add about 30,000 lines for the same arithmetic. - Relay, never parse, transfers. An SFTP parser inside
sshdwould put a second protocol in the process that carries every channel; a transfer server per channel, holding only that session’s file binds and its audit connection, keeps a bug in it inside one session’s own files. Running SFTP in the session’s own VM would let the principal skip the audit records.
consoled
consoled is the ns16550 UART driver. It serves the physical console as /dev/cons, one file
over 9P: a write goes out of the UART, and a read returns what was typed, or, when nothing has
been, parks until a key arrives rather than blocking or answering nothing. The physical
console carries no labels, so any caller may read it and only an unlabelled one may write it.
Purpose
The serial line is the box’s physical console: where the first owner is enrolled, where recovery
happens, and where a panic is reported. Its driver must never stop serving because of one client or
one broken device: a read with no input must wait without holding the server, and a device that
claims data for ever must cost a bounded amount of work. Session consoles are served per SSH
channel by sshd, not here.
Interface
/dev/cons
Status: built · partly tested: attacked with a fake UART against the runtime's fake kernel, and in a boot only written to; no test writes as a labelled caller · tested (14)
- bench:r4-host-tests
- host:redoubt-consoled::a_session_s_console_opens_beside_the_one_it_replaces_and_a_third_waits_for_one_to_go
- bench:consoled-build
- bench:init-boot
- bench:init-servers
- host:redoubt-consoled::a_refused_typed_request_leaves_no_handle_behind
- host:redoubt-consoled::typing_on_the_uart_reaches_a_ninep_reader
- host:redoubt-consoled::a_read_with_no_input_waits_and_is_freed_when_its_caller_gives_up
- host:redoubt-consoled::a_multiplexed_read_waits_for_input
- host:redoubt-consoled::writes_go_out_of_the_uart_in_order
- host:redoubt-consoled::writes_go_out_inside_the_console_s_hold_a_chunk_at_a_time
- host:redoubt-consoled::a_flood_of_input_keeps_what_was_typed_first
- host:redoubt-consoled::the_console_refuses_what_it_is_not
- host:redoubt-consoled::the_conformance_vectors_run_against_consoled
/dev/cons is served over the 9P server skeleton as one file
with nothing below it.
- A write sends its bytes out of the UART, in order, each line saying who wrote it
(started by
init), inside the console’s hold 256 bytes at a time, so the kernel’s lines land between those chunks and never inside one; a chunk that leaves a line unfinished gives the hold back saying so, and a line the kernel ended meanwhile starts again at the next write, prefix first (the console’s one writer). A hold the kernel refuses is a write as before. - A read returns the input held, from the start of the queue; the offset is ignored, since a console is a stream. With no input it parks its call with no deadline, because it waits on a person, and is served again, unchanged, when a key arrives; a caller that gives up abandons it and it is freed at once (parked calls). A multiplexed connection’s read waits the same way, as a request in the skeleton, and is answered into its completion call when a key arrives (multiplexed connections).
- One line, one queue. Input goes to whichever waiting read has waited longest. At most
MAX_INPUT(1024) bytes are held; a byte beyond that is dropped and counted, so a flood keeps what was typed first. statreports length 0: a console has no size.- What it refuses: opening with
OEXECorOTRUNC, walking, creating and removing, and a write from a labelled caller, through the label check against the console’s empty label set (R69 (no write down onto the console)). - Beside 9P and
ninep_common,consol’ssizeandresize(below). Any other typed opcode,endedsent as a call among them, is answeredmalformed, and the handles it carried are closed. - Admission: per (account, label set), at most 2 parked calls (waiting reads and
resizecalls together), 2 connections and 6 fids, and 80 multiplexed requests and 2 pages they brought (serving), across at mostbuckets=Nof those;init’s own bucket, by an override on its root badge, holdsMAX_THREADSconnections with 2 fids each instead. A block with nobuckets=N, or one the budget cannot hold, andconsoleddoes not start (init). The two kinds of bucket are derived, not chosen:- A principal’s bucket holds the consoles the steward mints for that account here: the
console principal’s UART session’s, minted through the steward’s own console (a context
over
sshgets the console its relay serves and a lease gets none: the steward’s slot table), admitted in the session’s account from its first request (serving), plus one spare for a session ending while its successor starts: 2. Each console is a share of the bucket, and a share takes one more only while it holds less thanlimit / (n + 1), n the shares holding (a fair share per badge), so the fids are what lets the last console open itsCONSOLE_FIDS(2: the root it attaches and theconsfile it opens) while the others hold theirs:CONSOLE_FIDS × 3, 6; at 4, the successor of an ended session, whose console holds its two until the steward’s cleanup gives it back, would get one fid of two. The bench holds the caps to at least that need over the tree’s manifests (tools/testbench,the_servers_admit_what_a_manifest_lets_one_account_hold). init’s bucket holds every server’s console:initmints each through its one root badge and starts at mostMAX_THREADS - 1servers, and each of those consoles, a system caller’s whose chain came throughinit’s badge, is charged toinit’s bucket however it is used, the steward’s own among them.init’s badge is always 1 here:initmints its own first badge at each server it calls, the first not handed to anyone at that endpoint, and refuses a manifest handingconsoledto any server (started byinit).- The fit, by the worst case of
Admission::with_overrides(init’s bucket at its override’s caps, every other at the defaults): a default bucket at its caps costs 2 parked calls at 64 KiB, 2 connections at 256 bytes and 6 fids at 256, 80 requests at 256 and 2 pages at 4 KiB, 161,792 bytes;init’s the same withMAX_THREADSconnections and their fids, 355,584 bytes at 255 (rv64) and 183,552 at 31 (rv32). The 2 MiB budget holdsinit’s and 10 default buckets on rv64 (1,973,504; an eleventh does not fit) andinit’s and 11 on rv32 (1,963,264); the image’s manifest asks for 5. The entry’s pages follow the entry rule (walfsd) with this constant as the budget: 512 of it, 24 of heap and the stack’s bound of 128, 664, so 768 within its 1,024. - The count:
buckets=Nis the manifest’s principal domains plus one forinit, whose bucket every system caller’s console shares; the steward’s own console isinit’s minting and counts there, not as a bucket of its own.
- A principal’s bucket holds the consoles the steward mints for that account here: the
console principal’s UART session’s, minted through the steward’s own console (a context
over
Two threads and the UART
Status: built · tested: host:redoubt-consoled::a_device_stuck_on_data_ready_does_not_hang_the_server, host:redoubt-consoled::typing_on_the_uart_reaches_a_ninep_reader, host:redoubt-consoled::a_console_with_no_device_does_not_start
- The serving thread owns the UART and the 9P skeleton, answers writes, answers reads from the input it holds, and parks reads when it holds none, so every other client is still served.
- The interrupt thread only receives on the interrupt handle and sends one word to the serving
thread’s endpoint; it touches no register. The UART’s registers are not
Sync, so the split is the type system’s, not a convention, and neither thread needs a lock. The kernel masks the interrupt when it fires and the next receive unmasks it (R5 (interrupts)). - The FIFO is drained whole on each wake-up and before a read parks, so two bytes between two interrupts are both read and no byte is left behind with a reader waiting. A drain reads at most the FIFO’s 16 bytes, so a device that says “data ready” for ever costs a bounded number of register reads per call instead of hanging the server (R70 (a stuck UART never hangs the console)).
- The registers are mapped from the device handle by the runtime and reached through its
bounds-checked accessors, every access volatile; the crate forbids
unsafe.
The consol protocol
Status: built · tested (8)
- bench:consol-size
- bench:steward-ssh-resize
- host:redoubt-consoled::consol_size_is_the_argument_and_a_resize_waits_until_its_caller_gives_up
- host:redoubt-consoled::a_console_with_no_size_refuses_consol
- host:redoubt-consoled::a_refused_typed_request_leaves_no_handle_behind
- host:redoubt-consoled::a_console_with_no_device_does_not_start
- host:redoubt-sshd::consol_size_is_the_pty_s_and_a_resize_is_due_when_it_changes
- host:redoubt-client::a_session_writes_and_reads_the_console
Every server of a /dev/cons (consoled, and sshd for each channel) also serves consol on
the same endpoint, through the runtime’s server::consol:
sizeanswers the console’s columns and rows at once.consoledtakes its size from its arguments,size=COLS,ROWS, each from 1 to 1,024; a malformed size, or two, and it does not start. A UART cannot know the size of the terminal at its far end, so when the manifest names noneconsoleddoes not guess: it refusessizeandresize(malformed), and its clients’ console is of unknown size, as the image’s is.sshdanswers from the channel’s pty request (sshd).resizeanswers the size when it next changes: it parks like a read, with no deadline, and is freed when its caller gives up (parking a typed call). A UART has no window, so onconsoledaresizewaits until its caller gives up. A client learns the size of its own console only; another channel’s waiter stays parked and learns nothing.- What a waiter costs. A waiting
resizeis a parked call of its caller’s, in its bucket and share, beside its waiting reads and, on a multiplexed connection, its session, which holds one of the share too (multiplexed connections). Onconsoleda lone connection’s share is one of the bucket’s 2: a connection with aresizewaiting has no room for a waiting read, and one with a multiplexed session, as the shell’s VM has, has no room for aresize, so the VM’s is refused. Onsshdthe share is two, the session’s and one waiter’s, and the VM’s reads and writes wait inside its session, so itsresizestarves nothing of its own. One more is refused at once (malformed), so however many waiters a client asks for, what they cost the server is the bucket’s fixed slots and their admitted cost, never memory beyond them. endedis the steward’s word that a minted console’s session is over (started byinit); sent as a call it is malformed.
The table: libs/wire/tables/consol.md.
| Opcode | Kind | Message | Fields | Reply |
|---|---|---|---|---|
| 16 | call | size | - | cols: u16, rows: u16 |
| 17 | call | resize | - | cols: u16, rows: u16 |
| 18 | send | ended | - | - |
| Code | Error |
|---|
Started by init
Status: built · tested (17)
- bench:init-boot
- bench:init-servers
- bench:init-console-forgery
- bench:console-kernel-line-mid-line
- bench:bench-init-reporter-forged
- host:redoubt-consoled::the_prefix_is_the_id_in_sixteen_hex_digits
- host:redoubt-consoled::a_root_line_is_bare_and_a_minted_one_carries_its_id_on_every_line
- host:redoubt-consoled::a_line_is_continued_by_its_writer_and_ended_by_any_other
- host:redoubt-consoled::a_line_the_kernel_ended_starts_again_with_its_prefix
- host:redoubt-consoled::an_unfinished_line_is_given_back_as_such_and_starts_again_once_the_kernel_ended_it
- host:redoubt-consoled::whatever_the_uart_takes_no_line_mixes_writers_or_carries_another_s_id
- host:redoubt-rt::the_file_server_learns_the_id_the_requester_got
- host:redoubt-consoled::a_console_with_no_device_does_not_start
- host:redoubt-consoled::init_s_badge_mints_a_console_for_every_server_init_can_start
- host:redoubt-consoled::every_console_init_mints_attaches_and_opens_in_its_one_bucket
- bench:init-refuses-consoled-handed
- host:redoubt-init::no_server_is_handed_a_root_badge_at_consoled
init starts consoled once the manifest and the keys are checked, with the UART’s MMIO region
and interrupt as named handles and its endpoint, placed from the boot manifest’s devices list
(init). consoled parses no device tree; without both handles it
does not start. Only one process ever maps the UART: two would both reach the registers, and two
readers of one FIFO would each take half the line. init writes to the UART itself only before
consoled starts, and unmaps it first.
Every line says who wrote it. A write through init’s own connection goes out as it is.
init holds the only handle to the root, and it never hands the root to a child: its check
refuses a manifest that hands any server an endpoint consoled receives on
(init). A write through
any connection minted under the root starts each of its lines with [con N] , where N is that
connection’s id, the one its requester was given, in 16 lowercase hex digits. init prints the id
of each child’s connection, bare, when it starts the child (init: started NAME, console N), so a
reader of the console can tell init from every program and each program from the others,
as the bench’s log server does today
(rule F). ended on a minted connection releases it:
the steward says so when the console session it started is over. If a write comes from a
different connection than the one that left the last line unfinished, consoled ends that line
first. The kernel is a writer consoled cannot see, and a program’s line can stay unfinished
between two of its writes (a prompt waiting for its answer; a \r sent before the text), when
nobody holds the console: so consoled gives the console’s hold back saying the line is
unfinished, the kernel ends that line before a line of its own, and says so at consoled’s next
take, which starts the program’s line again, prefix first
(the console’s one writer). So a line a program
writes cannot come out bare, not even after a kernel line broke it, and it cannot carry another
connection’s id. A transmitter that takes only part of a prefix leaves the line open, and the rest of the
prefix goes out before any more of the writer’s bytes; ended by another writer, such a line holds
the start of its writer’s prefix and nothing else.
The rule names the handles NAME and NAME-irq from one devices entry
(init); consoled takes uart and uart-irq.
Authority
Status: built · partly tested: no test writes as a labelled caller · tested: host:redoubt-consoled::the_console_refuses_what_it_is_not
consoledholds the UART’s registers and interrupt, its endpoint, and the connections it minted. It makes no calls to other servers.- Any caller with a connection may read the console; only an unlabelled caller may write it.
Security properties
R69 (no write down onto the console)
Status: built · partly tested: no consoled test writes as a labelled caller; the rule rests on the 9P skeleton’s label check, which is attacked in the serving library’s tests · tested: host:redoubt-rt::labels_are_checked_on_every_request, host:redoubt-rt::every_write_needs_equal_labels
The physical console carries no labels, so a caller with any label cannot write to it: no labelled data reaches a screen someone else may be looking at.
R70 (a stuck UART never hangs the console)
Status: built · tested: host:redoubt-consoled::a_device_stuck_on_data_ready_does_not_hang_the_server, host:redoubt-consoled::a_flood_of_input_keeps_what_was_typed_first
Whatever the UART reports, consoled does a bounded amount of work per call and keeps serving: a
drain reads at most the FIFO’s depth, and held input is bounded, dropping new bytes rather than
growing.
Failure and restart
Status: built · partly tested: the restart claims are read from the code, not attacked across a restart · tested: host:redoubt-consoled::a_console_with_no_device_does_not_start, host:redoubt-consoled::a_read_with_no_input_waits_and_is_freed_when_its_caller_gives_up
- No UART or interrupt handle:
consoledexits with a code before serving. - The hold refused:
consoledtakes and gives back the console’s hold once before serving. A kernel that refuses it (WrongObject: the UART is not the kernel’s console;Busy) would leave every line open to a kernel line, soconsoledexits with code 8 (NO_HOLD) instead. - A reader gives up: its parked read is answered and freed at once.
consoledrestarts: held input is lost, parked reads andresizecalls getDead, and clients ask for fresh connections.
Residual risks
- One keyboard, one queue. A client reading
/dev/constakes input another was waiting for; a session that must not share a console gets its own fromsshd. - A parked read, or
resize, has no deadline. It holds one of its caller’s open calls and one ofconsoled’s admission slots until a key arrives (for aresize, never) or its caller gives up. - Anyone with a connection reads the console. What is typed on the physical console is visible to
every holder of a
consoledconnection.
Why
- Park, rather than block or answer empty. A blocked server serves nobody else; an empty answer looks like a closed console; parking costs one slot and nothing more.
- The interrupt thread touches nothing. Keeping the device on one thread removes every lock and every shared-state bug between the two.
- Drain at most the FIFO. A 16550 can hold no more; a device claiming otherwise is broken, and bounding the loop turns it into dropped input instead of a hung server.
piped
piped serves one session’s pipes. A session starts it when a pipeline needs it and none is
running, in a budget carved from its own, and destroys that budget when its last pipeline ends, so
its pages are the session’s again between pipelines. Through the one connection
it was given the session makes a directory per pipe, holding its two ends, and mints each native
stage a connection rooted at exactly one end: that stage’s standard input, output or error
(native programs).
Purpose
Redoubt has no pipe object, no file-descriptor table and no inheritance, so a pipe is a 9P file
somebody serves. A stage reads its standard input as a file and writes its output as one, and the
server of those files decides what a write does when nobody has read yet and what a read sees when
the writer is gone. The session’s VM could serve them only through new natives that make endpoints
and mint badges, with a serve thread that ends every request at 5 s while a pipe’s reader waits on
its writer for as long as the writer takes, and with the multiplexed protocol and a 9P server
written again in Elixir, every byte then going through the interpreter. piped is the
9P server skeleton with one small file server, so all of that
is already built and attacked.
Interface
Serving pipes
Status: built · tested (16)
- bench:piped-build
- bench:piped-host-tests
- host:redoubt-piped::the_limits_a_session_passes_fit_the_budget_and_admission_takes_them
- host:redoubt-piped::a_pipe_carries_one_stages_bytes_to_the_next_and_ends_when_its_writer_goes
- host:redoubt-piped::a_full_pipe_holds_its_writer_until_the_reader_takes_some
- host:redoubt-piped::a_writer_whose_reader_has_gone_is_refused_even_while_it_waits
- host:redoubt-piped::a_multiplexed_read_of_the_sessions_waits_for_a_stage_to_write
- host:redoubt-piped::a_write_takes_what_fits_and_waits_when_nothing_does
- host:redoubt-piped::a_read_waits_until_the_write_end_goes_then_reads_the_rest_and_the_end
- host:redoubt-piped::a_write_is_refused_once_the_read_end_goes_and_what_was_buffered_goes_with_it
- host:redoubt-piped::the_sessions_open_fid_holds_an_end_until_its_own_clunk
- host:redoubt-piped::a_connection_is_minted_only_at_one_end_and_holds_it
- host:redoubt-piped::the_tree_is_the_root_the_pipes_and_their_two_ends
- host:redoubt-piped::pipes_are_bounded
- host:redoubt-piped::the_conformance_vectors_run_against_piped
- host:redoubt-piped::only_the_sessions_own_badge_attaches_and_its_labels_are_the_pipes
piped is a 9P server over the skeleton, and its endpoint also serves ninep_common
(wire); it serves calls and multiplexed requests alike
(serving).
- The tree. The root holds one directory per pipe, named by the session; each holds
r, the read end, andw, the write end. At mostMAX_PIPES(32) pipes at once: the longest pipeline a session can run needs one more than its stages, and one for their standard error. - An end opens only its own way.
ropens for reading,wfor writing; any other mode, andOTRUNC, isnot_permitted. - A pipe is a buffer of one page (
PIPE_BYTES). A write takes what fits, at least a byte, and answers how much it took, so a writer’s loop goes on; while nothing fits, the write waits. A read takes what is buffered, up to its count, and waits while nothing is. The offset means nothing: a pipe is read in order. - Waiting is parking. A call that waits is parked (serving), and a multiplexed request waits in the skeleton, both with no deadline: a reader waits on its writer for as long as the writer takes. What reclaims one is its caller giving up or dying, which arrives as an abandoned-call notice. After any call that moves a pipe (bytes in or out, an end let go, a pipe removed) every waiting call is served again, longest wait first.
- Holding an end. A connection minted at an end holds it until it is disconnected; a fid of the session’s open on an end holds it until that fid is clunked. A stage’s own clunk lets go of nothing: its connection holds the end until its launcher disconnects it, at its exit notice.
- The end of the stream. Once a write end has been held and is let go, a read takes what is left and then reads 0 bytes, and so does every read waiting then. Before the write end is first held a read waits, so the order the session starts its stages in does not matter.
- The read end gone. Once a read end has been held and is let go, what was buffered is dropped,
and every write, waiting or new, is refused
state(enotconn): the stream has nowhere to go. Before the read end is first held a write is taken, up to the buffer. - A pipe removed answers every call on it
removed; a fid left on it reaches no later pipe, since a pipe is named by an id never reused.
Started by a session
Status: built · tested (6)
- bench:pipe-carries
- bench:pipe-eight
- bench:pipe-sixty-four
- bench:pipe-no-authority
- host:redoubt-piped::only_the_sessions_own_badge_makes_and_removes_pipes
- host:redoubt-piped::a_stage_reaches_only_the_end_it_was_given
A session starts piped with launch and serve => "serve"
(beamlet): the platform gives piped the endpoint’s receive right
and the session the one connection nobody minted, badge 1 (ROOT_BADGE). Only that badge attaches,
at the root and with no name; only it makes and removes pipes, and only in the root; any other badge
below the minted range is refused. The session mints each stage’s connections through it with
new_connection, at NAME/r or NAME/w; a mint at the root or at a pipe’s directory is refused,
so every connection the session hands out is rooted at a file. A connection rooted at a file
reaches nothing else: no walk leaves a file, .. at the root is the root, and a new_connection
through it mints at its own root or not at all.
Its arguments are buckets=N, as every shared server’s are, and a session passes 2, the fewest
admission allows.
- Admission (R26 (admission fairness)). A session’s stages
carry its account and labels (R8 (accounts)), and the session
minted their connections through its own, so the session and every stage are one client: one
bucket, and one share in it, which may take half the bucket. A bucket’s caps are 16 parked
calls, 128 fids, 64 connections, 64 multiplexed requests and 4 pages they brought, so the share
holds 8 parked calls: 7 stages each parked on one stream, a stage reading or writing one at a
time, and the session’s completion call. A parked call holds its caller’s lend, 64 KiB at
worst, so two buckets at their caps are 2,260,992 bytes, which the program checks against its
allowance (
BUDGET) before it serves; the buffers, 32 pages at most, are beside it. The session carvespiped768 pages for all of it: the lends at worst are 552 pages, the buffers 32, and its image, stack, heap and records the rest; 512 would not hold the two buckets admission needs at the least. Afterpipe-carries’ pipelines it holds 86 pages on rv64 and 83 on rv32: the rest of the carve is what its clients’ parked calls may lend it at worst. The carve is held only while a pipeline runs:Redoubt.Pipesdestroys it when the last process holdingpipedlets go or ends, and starts another at the next pipeline, whichpipe-carriestimes at about 60 ms under QEMU (about 200 ms for the session’s first, which loads the shell’s modules too). No budget ofpiped’s outlives its pipeline: after each of eight pipelines in one session the session’s budget holds what it held before them, processes, weight and all (pipe-eight), and nothing ofpipedoutlives its budget in the session either: its binding at/dev/pipeand the VM’s handle to it go when the budget does (beamlet; after 64 pipelines,pipe-sixty-four, the namespace table is its size before the first). - Labels.
pipedruns in a budget carved from the session’s, so it carries the session’s labels, and the kernel lets no other set’s message reach a server with no exemption (R1 (flow)): its files carry the label set of the session’s first attach, which is every caller’s.
Authority
Status: built · tested: host:redoubt-piped::a_stage_reaches_only_the_end_it_was_given
piped holds its endpoint and the connections it minted, and the bytes buffered in its pipes. It
holds no other handle, makes no calls and reads no file. A stage holding one of its connections can
read or write one end of one pipe and nothing else; the session can make, remove and read or write
every pipe of its own, and no other session’s, since each session starts its own piped.
Security properties
Status: built · tested: host:redoubt-piped::a_stage_reaches_only_the_end_it_was_given, host:redoubt-piped::a_connection_is_minted_only_at_one_end_and_holds_it, host:redoubt-piped::the_conformance_vectors_run_against_piped
piped claims no rule of its own. It is how a pipe carries no authority
(native programs): every connection
it lets the session hand out is rooted at one end file, and the serving library’s walk and mint
rules (the 9P server skeleton) keep a connection below its
root. It keeps the serving library’s R25 (the label check), R26 (admission fairness) and
R28 (parked-call accounting), each stated on its owning
page.
Failure and restart
Status: built · partly tested: a piped that ends while a pipeline runs is argued, not attacked
- No
buckets=N, or one its allowance cannot hold, andpipedexits before it serves. pipedends (its budget destroyed, a fault): its endpoint dies, and every stage’s call on it fails, as a read or write error; the session sees the exit notice, and starts anotherpipedat its next pipeline. The session destroys its budget itself when no pipeline holds it (bench:pipe-carries, bench:pipe-never-reads, bench:pipe-interrupted).- A connection whose reply is lost is rolled back (replies and rollback).
Residual risks
- A stage cannot end its output before it exits. Its clunk is not the end of the stream, so a stage that closes its standard output and runs on leaves its reader waiting until it exits. A stage that has not yet opened its output must not end its reader’s stream, and the two cannot be told apart without a rule the stages would have to keep.
- The stages of one session share one share. The session and its stages are one client
(admission), so a stage that parks calls on many connections
of its own can fill the share and make another stage’s wait, or the session’s, refused
too_many. Every stage is the same session’s, with the same authority.
Why
- One server per session. A shared
pipedwould hold several principals’ bytes in one process and need the label exemption a system server has; one per session, in the session’s own budget, is a user-level server the kernel’s flow check already confines. - A connection per end, not a file per stage. A stage given a directory could open the other end, or another pipe; a connection rooted at one end file cannot, whatever it sends.
- Short writes. A write that waited for room for all its bytes would hold a page-sized buffer hostage to one large write; taking what fits keeps every write’s wait to the reader’s next read.
consrelay
consrelay is a context’s console relay. The steward starts one beside each SSH context’s VM,
in the context’s budget (the steward). It serves the VM’s /dev/cons,
and forwards it to whichever SSH channel the context is attached to. While none is, it keeps the
VM’s newest output, a bounded amount, and replays it to the next channel. So closing a terminal
detaches the context instead of ending it.
Purpose
A session’s /dev/cons used to be its channel’s own console file in sshd, which dies with the
channel. A context that outlives its channel needs a console that outlives it too, served by
something that is neither the channel nor the VM: the VM cannot keep its own console across
channels, and the steward must not hold a person’s output on its own heap. The relay runs in the
context’s budget, so what it keeps is charged to the context, and its writes to a channel carry
the context’s labels.
Interface
/dev/cons for the VM
Status: built · tested: bench:consrelay-build, bench:consrelay-host-tests, host:redoubt-consrelay::output_kept_while_detached_is_replayed_after_the_note_and_input_reaches_the_vm, host:redoubt-consrelay::a_channel_let_go_is_gone_to_its_threads_and_a_takeover_takes_its_input, host:redoubt-consrelay::typed_input_outlives_a_detach, host:redoubt-consrelay::input_read_before_a_detach_and_given_after_it_is_the_vm_s, host:redoubt-consrelay::a_takeover_s_detach_drops_the_old_channel_s_input, host:redoubt-consrelay::the_last_line_typed_reaches_the_vm_after_the_detach, host:redoubt-consrelay::the_attached_channel_s_input_end_is_the_vm_s_end_of_file, host:redoubt-consrelay::a_channel_s_input_end_reaches_the_vm_as_the_end_of_the_file, host:redoubt-consrelay::the_size_is_the_attached_channel_s_and_each_attach_is_a_change, host:redoubt-consrelay::the_vm_s_size_is_the_channel_s_and_an_attach_redraws
One file over 9P, with the semantics of sshd’s channel console: a read with nothing typed
parks, a write goes to the channel, stat says length 0, and the file carries the context’s
labels. The VM reaches it only through the connection the relay minted for it at its start; an
attach through any other badge is refused. On the same endpoint the relay serves consol’s size
and resize (consoled): size answers the attached channel’s
size as sshd last gave it, 80 by 24 before any channel has; a resize is parked until the size
changes, then answered with it, and each attach’s first size counts as a change, the same size or
not, so a VM waiting is told its new terminal and redraws. What was typed on a channel and not yet read stays
for the VM when the terminal closes, and so does what the reader read before the steward’s
detach reached the relay, until another channel is attached: a last exit typed just before
the terminal closed still counts. A takeover’s detach, which carries a note, drops what the old
channel typed and refuses what its reader brings in after. When the attached channel’s input
ends, which sshd gives as the end of a file only on a channel without a pty
(ssh alice@box < file), the VM reads what it gave and then the end of the file, as from a pipe;
an end from a channel let go changes nothing.
The bound
Status: built · tested: host:redoubt-consrelay::a_write_never_waits_while_detached_and_the_newest_bytes_are_kept, host:redoubt-consrelay::an_attach_cuts_to_a_line_says_what_was_dropped_and_replays, host:redoubt-consrelay::nothing_dropped_means_no_cut_and_no_count, host:redoubt-consrelay::an_attached_write_waits_for_room_and_goes_out_in_order, host:redoubt-consrelay::a_broken_channel_keeps_output_as_if_detached, host:redoubt-consrelay::input_is_bounded, host:redoubt-consrelay::a_flood_while_detached_is_counted_and_the_rest_replayed, bench:consrelay-footprint
- While attached, the VM’s output waits in a buffer of 64 KiB (
KEEP) for the writer thread, and a write with no room waits, as onsshd’s console. - While detached, or while the attached channel’s calls fail, a write never waits: the oldest kept bytes go to make room, and are counted.
- On attach, if any were dropped, the kept bytes are cut forward to the end of their first line,
so no half line or half escape sequence leads them, and the channel gets the steward’s note, a
line
[N bytes of output dropped while detached], then the kept output. Kept output with no newline in it (a full-screen program’s redraws) is not cut, so its first bytes may be the tail of an escape sequence. - Input held for the VM’s reads is at most 4 KiB; past that the reader thread’s call waits,
which leaves the rest in
sshdand SSH’s window. - The output and input buffers are allocated at the start, so a VM that later spends its budget’s
pages cannot starve the relay. Measured once it serves (
consrelay-footprint), its heap holds 22 pages and its first thread’s stack peaks at 8,360 bytes on rv64; the steward launches it with a 44-page heap cap and a 5-page stack, its helpers with 4 pages each, and a session’s budget holds 128 pages for it beside the VM’s share (budgets).
Four threads
Status: built · tested: host:redoubt-consrelay::output_kept_while_detached_is_replayed_after_the_note_and_input_reaches_the_vm, host:redoubt-consrelay::the_reader_is_given_a_channel_once_the_writer_opened_it, host:redoubt-consrelay::a_console_is_closed_once_no_thread_is_on_it
- The serving thread owns the state and the 9P skeleton, and serves the VM, the steward’s control calls and the three helpers’ calls. It makes no call that waits on anything but its own endpoint, so neither the VM nor the steward ever waits on a channel through it.
- The writer thread opens each channel’s console as it is attached, asks for output and writes it there.
- The reader thread reads what is typed on the channel the writer opened and hands it over.
- The sizer thread asks that channel’s console its size, then keeps one
resizewaiting there and hands over each answer;sshdanswers the last one when the channel’s session ends, and refuses any after, so the sizer leaves a channel that has ended.
A helper names the channel’s generation in each call, so one still on a channel the steward has let go is told so and leaves it. A channel’s console handle is closed once no helper is on it. The helpers and their stacks are made once, at the start.
The consrelay protocol
Status: built · tested: host:redoubt-consrelay::the_vm_cannot_attach_or_detach, host:redoubt-consrelay::a_control_call_without_its_console_is_malformed, host:redoubt-consrelay::a_detach_waits_until_its_note_is_written, host:redoubt-consrelay::a_detach_waits_for_nothing_it_cannot_write, host:redoubt-consrelay::a_detach_from_a_stalled_channel_is_answered_at_its_bound, host:redoubt-consrelay::a_busy_writer_s_channel_gets_its_note_before_the_detach_returns, host:redoubt-consrelay::a_note_is_capped, host:redoubt-steward-server::a_login_to_an_attached_context_takes_it_over, host:redoubt-steward-server::a_takeover_from_a_stalled_channel_still_takes_it_over
At its start the relay makes its endpoint in its own budget, mints the control badge and the VM’s
console on it, and sends both to the steward with hello on the badge its startup block names
hello; the steward waits at most 2 s for it. attach gives the relay a channel’s console with a
note to write there first; detach lets the attached channel go and has the writer write a note
there. The relay lets the channel go as it takes the call, and answers it once the note is written,
or after 150 ms (NOTE_WAIT_US) at the latest: a writer in the middle of a chunk on a channel that
reads finishes it and writes the note well within that, so the old channel is told, while one
still in its write past it is on a channel that has stopped reading, which may close untold (the
note follows only if that write moves first). The steward waits at most 1 s, and a detach past
that bound is still a detach: only the note was late. Both are accepted only on
the control badge; through the VM’s badge they are malformed, as any opcode that is not 9P is. A
note is at most 384 bytes.
The table: libs/wire/tables/consrelay.md.
| Opcode | Kind | Message | Fields | Reply |
|---|---|---|---|---|
| 24 | call | attach | note: string, console: handle[0] endpoint | - |
| 25 | call | detach | note: string | - |
| 26 | send | hello | vm: handle[0] endpoint, control: handle[1] endpoint | - |
| Code | Error |
|---|---|
| 2 | failed |
Authority
Status: built · tested: host:redoubt-consrelay::the_vm_cannot_attach_or_detach, host:redoubt-sshd::a_login_s_two_consoles_and_only_the_steward_s_ends_the_channel
- The relay holds its endpoint, the hello badge until its hello, and the console of each channel it
is attached to:
sshd’s second connection to that channel, which can read and write it but not end it (sshd). It holds nothing else, and calls nothing but the channel’s console. - It runs in the context’s budget, so its writes to a channel carry the context’s labels and meet
sshd’s label check (R25 (the label check)), and what it keeps is charged to the context.
Security properties
Status: built · tested: host:redoubt-consrelay::a_channel_let_go_is_gone_to_its_threads_and_a_takeover_takes_its_input, host:redoubt-steward-server::a_login_to_an_attached_context_takes_it_over
The relay owns no rule of its own. It is how the steward keeps R80 (one channel per context): it forwards to one channel at a time, the one the steward last attached, and a helper still on a channel let go is told so before it can write there again.
Failure and restart
Status: built · partly tested: a relay that dies alone is read from the code: no test kills one · tested: host:redoubt-consrelay::a_detach_waits_for_nothing_it_cannot_write, host:redoubt-consrelay::a_detach_from_a_stalled_channel_is_answered_at_its_bound, host:redoubt-steward-server::a_takeover_from_a_stalled_channel_still_takes_it_over
- A channel’s call fails: output is kept as if detached until the steward
says what happened, which it does when
sshdreports the channel closed. - A channel stops reading: the writer waits in its write there, and the VM’s writes wait for
room. A takeover’s
detachis answered after 150 ms without its note, and the steward’sendedon its own connection ends that channel, untold, which fails the waiting write and frees the writer for the new one. - The relay dies: the VM’s
/dev/conscalls fail and the shell ends; the steward watches the relay’s exit as a session’s and ends the context. - No hello within 2 s: the steward’s launch step fails, and the session’s budget, the relay with it, is destroyed.
- The context ends: destroying its budget ends the VM and the relay together.
Residual risks
- What the VM wrote while detached may be cut. Only the newest 64 KiB are kept; the count says how many went, not what they were. Output with no newline is replayed uncut, so a full-screen program’s redraw may start mid-sequence until it next redraws the screen.
Why
- A process in the context’s budget, not the steward. The steward’s heap would otherwise grow with every person’s detached output, and a bug in forwarding would sit in the trusted base.
- Helper threads, not blocking calls in the serving thread. A slow or stuck channel then holds one thread of the context’s own, never the VM’s console or the steward’s control call.
Packages
A package is a signed ustar archive with a manifest: the unit in which code arrives on the box, from the boot bundle itself to one principal’s tool. The pkg server installs packages: the steward starts one per principal’s install, and it checks the signature against the installing principal’s trust list before it parses a byte, then writes only that principal’s package directory. The steward keeps each principal’s profile, trust list and grants, and launches a package’s code only if its principal trusts the signer, with at most what the principal grants. System updates are packages too, written to one of two bundle slots and protected against rollback.
Purpose
Signing answers “who vouches for this code”; capabilities answer “what can it do”. Redoubt keeps the two apart: a signature never grants authority, and authority is granted only to code a trusted key signed. One container, one verifier and one domain rule cover the boot bundle, system updates and every user package, so there is one parser of signed archives to trust, and it runs in a process that can reach only one principal’s packages.
Interface
What is signed
Status: planned · M6 (persist, install, share)
- A package is signed as a whole: a ustar archive with a manifest, in the boot bundle’s
signature container (boot).
.beamarchives and native programs are package contents. - Its own domain, the loader’s form. The Ed25519 signature is over the full preimage
"redoubt.pkg.v1\0" || u64_le(len) || archive, never the bare archive, and is checked by the same verifier the loader uses for the bundle, whose domain is"redoubt.bundle.v1\0". Domains are prefix-free, so a package signature is never a bundle signature, and the reverse. - Where signing keys live. A person’s signing key may live off the box, where they sign a
package themselves, or in
keydunder apkgpurpose, which signs only the package preimage it builds itself from the archive, with an approval per signature. An agent’s key lives inkeyd. - The manifest is strict JSON (wire). It names the contents and
requests capabilities (“
/netconnect to 443”, “read my config directory”); it can grant nothing. - The steward records which key signed each installed program.
Open: the package manifest’s schema; the maximum package size and how an archive is streamed to the verifier.
The pkg server
Status: planned · M6 (persist, install, share)
pkg (the command, over the Redoubt.Pkg module) talks to the pkg server; the steward never
parses an archive, as init and the steward never parse an ELF.
- Verify before parse. The pkg server checks the signature over the whole archive against the installing principal’s trust list before it reads a single tar header or manifest byte. An archive not signed by a trusted key never reaches the parser.
- One principal’s packages. A key on one principal’s trust list can still sign a hostile
archive, so the steward starts the pkg server per principal, per install. It holds the archive it
was handed, a write handle to that principal’s package directory,
/system/pkgs/<principal>/, the installing principal’s trust list and the system bundle’s module names (both passed in by the steward), and a badge on which it asks the steward to record, and nothing else. A parser bug then reaches only that principal’s own packages (R74 (a hostile package stays in its principal’s packages)). Nothing a session holds can write there. - The steward keeps the authority. The pkg server asks the steward to record the profile,
userecords, trust lists and the grants a manifest requests; it holds no grant authority itself.
The attack tests: an unsigned or untrusted archive is refused before any parse (a fuzz corpus confirms the parser is never reached); a hostile archive signed by a trusted key cannot write outside its principal’s package directory.
Open: the pkg server’s protocol table.
Signer trust and granted authority
Status: planned · M6 (persist, install, share)
- The steward launches code for a principal only if the principal trusts the signer, and with at most what the principal grants: at install or run time the principal grants the manifest’s requests, attenuated from its own capabilities, and the launcher builds the namespace from exactly those grants (R71 (no new authority without trust)).
- System code (drivers, servers, the loader stub, the VM) is signed by the system key. The
system key is on no trust list and cannot be removed: system code is trusted by the bundle’s
verification, not by a list. A principal may run native code signed by keys on its trust list, its
own included;
.beamcode follows the same rule. - Adding a key to a trust list is a high-stakes approval (steward).
- Agents sign with their own keys. Code an agent built runs within the agent’s lease; running it outside needs the sponsor to trust the agent’s key.
- Removing a key from one’s own trust list needs no approval, since it only narrows, and it is
audited. New launches and loads of that key’s packages are refused at once; the steward finds the
principal’s running processes whose recorded signer is that key and stops them by destroying their
budgets;
userecords are never switched silently to another version or signer, so a package left without a trusted signer is unusable until the principal chooses. - Every process the steward launches with grants has a recorded signer in the audit log. Code a principal runs itself without new grants needs none.
The attack test: removing a key stops that signer’s running processes and leaves use records
unchanged.
Open: none.
Per-principal packages and profiles
Status: planned · M6 (persist, install, share)
pkg addhas the pkg server write a package into/system/pkgs/<principal>/<name>-<version>-<hash>/. A principal’s profile (the versions it uses, its/bin) and trust list are steward records, not files in its space.- Installing a package signed by a key already on the trust list needs no approval.
- Upgrading is atomic per package:
pkg addinstalls a new version beside the old,pkg useflips one steward record, flipping back is the rollback, andpkg gcremoves unused versions. - Loading code. Launching never consults a session’s writable namespace, and the VM’s
load_moduleresolves module names only from the system bundle and the profile’s package directories, through read-only handles, so write access to someone’s home never becomes a launch or a load with their authority. - No shadowing. A package may not define a module the system bundle defines (the pkg server
refuses it at install), and two packages in one profile may not define the same module (the
steward refuses it at
use); the system bundle always resolves first, even before a directory a session adds to the front of its own code path (beamlet). - Code one loads oneself (compiling a string, requiring one’s own file) runs within one’s own authority; the code-path rule is not a wall against it.
- A project has its own package directory, so a shared toolchain is installed once.
alice> pkg add logscan-1.3.xpkg
signed by: alice (on your trust list) requests: read /logs, write ~/reports
alice> pkg use logscan 1.3 # pkg use logscan 1.2 rolls back
Installing and choosing a version.
The attack tests: a module named like a system module is refused; a .beam planted in the session’s
home is never loaded by name.
Open: none.
System updates
Status: planned · M6 (persist, install, share)
- A/B bundle slots.
sys updatewrites the new signed bundle to the inactive slot; the loader verifies it on the next boot. The new system is healthy onceinitreaches a steady state, every system server running, before any user session starts, so a user’s crash loop cannot force a rollback. If it fails to boot or to become healthy, the next boot falls back to the other slot. - Rollback protection. A version counter kept outside both slots, which the loader refuses to go below (R72 (no rollback below the counter)).
- M-of-N signatures on system bundles, so that no one key owns every machine, and builds are reproducible and confirmed by independent builders.
- Key rotation. The system’s signing keys can be replaced through a system update, so a compromised or retired key stops being one the loader trusts.
Open: where the version counter lives on a virtio-only machine and what protects it; the M and N, and how the loader holds the N keys; how a rotation is signed and how the loader learns the new keys.
Authority
Status: planned · M6 (persist, install, share)
- The steward keeps the profile, trust and grant records; it does not write packages or parse them.
- Each pkg server instance holds one archive, a write handle to one principal’s package directory, that principal’s trust list, the system bundle’s module names and a badge for the steward’s records, and nothing else.
- A package’s code gets exactly the grants its principal made from the manifest’s requests, never more than the principal holds.
- A signature grants nothing.
Open: none.
Security properties
R71 (no new authority without trust)
Status: planned · M6 (persist, install, share)
The steward launches code with grants a principal makes only if a key on that principal’s trust list signed it, and with at most those grants. Code a principal or its agent wrote runs, but never with more authority than its author already holds.
Open: none.
R72 (no rollback below the counter)
Status: planned · M6 (persist, install, share)
The loader refuses a system bundle whose version is below the counter kept outside both slots, so a validly signed but older, vulnerable system cannot be booted in place of a newer one, as long as the counter’s storage cannot be reset (Residual risks).
Open: none.
R74 (a hostile package stays in its principal’s packages)
Status: planned · M6 (persist, install, share)
No archive reaches the package parser unless a key on the installing principal’s trust list signed it, and the parser runs in a pkg server holding only that archive, that principal’s package directory and trust list, the bundle’s module names and a badge for the steward’s records. So a hostile archive, even one a trusted key signed, can write only its own principal’s packages.
Open: none.
Failure and restart
Status: planned · M6 (persist, install, share)
- An update fails to boot or to become healthy: the next boot uses the other slot.
- An install is interrupted: the new version’s directory is incomplete and unused; the profile
still names the old one, and
pkg gcremoves the remains. - The pkg server crashes on an archive: that install fails; the principal’s other packages and records are unchanged.
Open: none.
Residual risks
- A hijacked agent runs code it wrote, within its own authority: any process can create a child and map pages into it, and the shell evaluates any Elixir. Signatures gate only new grants.
- Every principal reaches the whole system-call interface through native code; it must hold on its own, and the bench attacks it with hostile native programs.
- A trusted key that is stolen launches code with whatever its trusters grant, until it is removed.
- A resettable counter undoes rollback protection. If whatever holds the version counter can be reset or rewritten (a virtio disk the host controls), an older signed system boots again.
Why
- Signatures and capabilities apart. A signature says who vouches for code, not what it may do; a system that granted authority by signature alone would make every signing key a master key.
- One container and verifier. The boot bundle, updates and packages share one verifier of signed archives, the most attacked code in the system.
- Parse in a per-principal server. Keys a principal trusts can still sign a hostile archive; a parser that can write only that principal’s packages keeps the damage there, and the steward, which holds everyone’s records, never parses one.
- Healthy before any session. Judging an update healthy only before users run keeps a user from forcing a rollback to an older system.
- No shared content store. A store shared by every principal is a covert channel (add a blob, probe for it); per-principal directories give up deduplication, which a handful of users hardly need.
The supervisor
The supervisor keeps long-running services up: it starts each from a record, restarts it when it
exits within limits the record sets, keeps its log, and gives it exactly the authority its record
names on every start. Which services it supervises, principals’ own or also the system’s after
boot, and whether it takes over restarting from init or runs beside it, are open.
Purpose
init starts and restarts the system servers the signed manifest names. Once the steward keeps
state and principals install packages, some code must run as a service: a principal’s own server,
or a project’s build worker. Without a supervisor each would need a session kept open to run it,
or its own restart logic. The supervisor gives them one mechanism, under the same rules as every
launch: the loader stub, fresh connections, and grants no wider than their owner’s.
Interface
Services
Status: planned · M6 (persist, install, share)
- A service record names an installed package’s program, its owner, the grants its owner made for it from the package’s requests, a budget carved from the owner’s, and a restart policy. The steward keeps the records, since they carry grants.
- Starting a service is a launch like any other: a budget carved from the owner’s, fresh connections for exactly the recorded grants, through the loader stub (init).
- Restarting. When a service exits, the supervisor starts it again with the same record, up to the policy’s limit in a window; past it, the service stays stopped and its owner is told.
- Logs. A service’s console output is kept in a log its owner can read, bounded in size.
- Control. A service’s owner can start, stop and inspect it; nobody else can.
Open: what the supervisor supervises (principals’ services only, or also system services after
boot), and whether it takes over restarting from init or runs beside it; whether it is part of the
steward or a server of its own; whether services can be started at set times; the restart policy’s
form; where logs live and how they are bounded.
Authority
Status: planned · M6 (persist, install, share)
- A service holds exactly the grants its record names, never its owner’s whole set.
- Whatever the supervisor holds to launch, it holds no
system-class budget handle: onlyinitand the steward do (R33 (no server holds a system budget)).
Open: whether the supervisor holds handles to its owners’ budgets itself or asks the steward for each launch.
Security properties
R73 (a restart never widens)
Status: planned · M6 (persist, install, share)
Every start of a service, first or restarted, gets exactly the grants and budget its record names, through fresh connections; nothing a previous run held is handed to the next. So a service that was compromised and crashed comes back with its recorded authority, not more.
Open: none.
Failure and restart
Status: planned · M6 (persist, install, share)
- A service crash-loops: past its restart limit it stays stopped, and a crash blamed on another principal is the steward’s to judge (steward).
- The supervisor restarts: it holds none of its earlier exit notices; it rebuilds its records from the steward’s.
Open: how the supervisor learns which services are still running after its own restart.
Residual risks
- A service runs unattended. It spends its owner’s budget and grants while nobody watches; its budget and grants bound it, not a person.
- A restart does not clean what the service wrote. A compromised service that planted state in files it may write finds that state again when it restarts; a restart resets authority, not data.
Why
- The same launch as everything else. A supervisor with its own way of starting code would be a second place for launch rules to go wrong.
- Records with the steward. A service’s record carries grants, and grants are the steward’s to keep.
Userland
Userland is everything above the servers: what a person, an agent or a developer meets on the
box. A person logs in over SSH and gets an Elixir session, the shell’s interactive prompt running in a
beamlet VM of its own, in a budget of its own, with a namespace the steward built for it. An agent
gets the same kind of VM under a lease, with capabilities narrowed from its launcher’s. Native
programs, written in Rust against redoubt-rt, run in budgets the session carves for them. There
is no POSIX shell, no fork, no signals, no root and no global file tree: a session reaches
exactly what its handles name, and nothing else.
Purpose
These pages say what userland looks like from the prompt, and why. Each one takes one thing a person or agent does:
| Page | What it covers |
|---|---|
| Sessions and namespaces | logging in, the session VM and its budget, vault sessions, namespaces, approve@ |
| beamlet, the Elixir VM | the BEAM interpreter every session and agent runs in, and its boundary with the system |
| The shell | Redoubt.Shell: commands, jobs, line editing, completion, help, full-screen programs, the editor and file manager |
| Files and binds | files over 9P, what File and Path do, labels on files, binds, sharing |
| Native programs | launching Rust programs, standard I/O and pipes, killing jobs, redoubt-rt |
| Agents, leases and labels | an agent as a principal with a sponsor, leases, delegation, the agent harness |
| File transfer | SFTP and SCP inside SSH |
| Development on Redoubt | git, compilers on the box, Rust built off the box |
| Packages | pkg add, use, gc, trust lists, profiles |
The mechanisms underneath belong to other pages: handles, IPC, budgets and processes to
the kernel; the steward, the file server, sshd and the rest to
the servers.
What a person sees
Status: planned · M1 (sessions over SSH, kept apart)
Alice runs ssh alice@box. sshd and the steward authenticate her with her own key, the steward
starts her session, and she is at the shell’s prompt. The session is one beamlet VM in one budget carved
from hers; everything she types runs there, and every call it makes carries her account and the
session’s label set, which the kernel stamps and no process can forge
(R14 (unforgeable sender)).
$ ssh alice@box
/home/alice (1)> File.read!("/home/alice/notes.txt")
"buy milk\n"
/home/alice (2)> File.read!("/home/bob/notes.txt")
** (File.Error) could not read file "/home/bob/notes.txt": no such file or directory
Bob’s home is not refused; it is absent. Alice’s namespace has no entry for it, and nothing is inherited, so the path matches nothing (sessions). Bob’s session is a different VM in a different budget, and destroying either budget ends that session and nothing else (R10 (destruction)).
ssh alice+tax@box opens a vault session: one carrying Alice’s tax label, which can read
her labelled data and cannot send it anywhere unlabelled
(R1 (flow)). ssh approve@box is the one place Alice answers
requests for authority nobody has granted; only the steward talks to that terminal.
In M1 (sessions over SSH, kept apart) the session is the shell with what the milestone needs: the console, files, and launching native programs. The working shell (commands, file operations, pipes, jobs, line editing, completion, help and the editor) is the shell’s, for M2 (usable shell). Files in and out over SFTP and SCP are file transfer’s, for M4 (files in and out).
Open: none.
What an agent sees
Status: built · partly tested: a lease’s capabilities are its sponsor’s ~/project and the system volume, started by start_agent or by the sponsored principal’s login; the narrowed grants, the gateway and the harness’s tools are later · tested: bench:steward-lease-ends, bench:steward-end-lease, bench:steward-sub-agent-dies-with-agent, bench:agent-login
An agent is its own principal with a sponsor, the principal who answers for it. It runs in its own beamlet VM, under a lease: a budget with a kernel deadline, placed under its sponsor’s budget (budgets). It holds only the capabilities its launcher narrowed for it, and a sub-agent it starts holds only what it narrows again. When the lease ends, everything in it ends, sub-agents included. The design assumes every agent has been hijacked by something it read: it can do what its capabilities allow, until its lease ends, and nothing more (agents).
An agent never inherits network access. From M5 (self-hosted development) it reaches a model
provider through a gatewayd capability that holds the API key, never through a socket
(gatewayd).
What a developer sees
Status: planned · M5 (self-hosted development)
Redoubt is developed on Redoubt. A developer works in a session like any other: git reaches its
remotes through a gatewayd git capability, the Elixir and Erlang compilers run on the box in
beamlet, and Rust is built off the box. System code arrives in the signed boot bundle; a
developer’s own program arrives by SFTP and runs, unsigned, with the developer’s own authority
(development). Programs and libraries reach other principals as signed packages
from M6 (persist, install, share) (packages).
Open: none.
The layers
Status: planned · M1 (sessions over SSH, kept apart)
flowchart TB
P["a person (ssh alice@box)"] -.-> S["sshd"]
S -.-> ST["the steward"]
ST -.-> VM
subgraph VM["session: one beamlet VM in one budget"]
SH["the shell"] -.-> EX["Elixir modules: File, IO, Redoubt.*"]
EX -.-> NAT["beamlet natives and the Platform"]
end
NAT -.->|"9P and typed calls"| SRV["servers: littlefsd, consoled, ipd, ..."]
NAT -.->|"launch in a carved budget"| N["native programs on redoubt-rt"]
N -.->|"9P"| SRV
Figure: the userland layers of one session. The arrows show planned session wiring (dashed); beamlet already boots the shell on Redoubt’s UART with verified modules (beamlet).
Three layers, each with one job:
- A beamlet VM per session. beamlet is a BEAM interpreter in safe Rust. One VM is one trust domain: everything in it runs with the session’s authority, and code with different authority (an agent, a sub-agent) runs in a different VM (beamlet).
- Elixir modules. OTP’s
File,IO,Pathandgen_tcpwork unchanged over the VM’s natives. What has no POSIX equivalent (handles, namespaces, budgets, leases, labels, approvals) gets its ownRedoubt.*module and no compatibility shim. - Native programs. Rust programs, statically linked, each started in a budget the session
carves, with a namespace the session builds (native programs). Everything that
needs no address space of its own is Elixir: there is no
cporlsbinary.
Open: none.
Why
Elixir, not a POSIX shell. A shell language is a second programming language with its own quoting, word splitting and globbing, and a long history of injection bugs. A session that is an Elixir VM has one language, with real data structures, and the same code serves the prompt, a script and a program. The prompt gets short commands, plain Elixir functions, not a different language (the shell).
One VM per trust domain. Inside a VM, processes share memory-safe terms and a scheduler, so a VM cannot keep two parties apart. The kernel can: two VMs in two budgets are separated by page tables, handle tables and R1 (flow). So the unit of isolation in userland is the VM, and anything with different authority gets its own.
No inheritance. A Unix process inherits its parent’s user, file descriptors, environment and file tree, and has to shed what it should not keep. A Redoubt process starts with nothing and is handed each capability by name on its startup block. What a program can reach is what its launcher wrote down, so it can be read, audited and narrowed.
Sessions and namespaces
A session is a person’s or agent’s working environment on the box: one beamlet VM in one
budget, with a namespace and capabilities the steward built for it. A namespace is the
session’s own map from path prefixes to capabilities; it is built before the session starts,
nothing in it is inherited, and there is no global file tree behind it. This page covers logging
in over SSH, what a session is, vault sessions, namespaces and binds, and approve@, the one
terminal where a person grants authority.
Purpose
A session is the unit a person works in and the unit Redoubt keeps apart. Alice’s session and Bob’s are different VMs in different budgets, and each reaches only what its namespace and handles name. So the questions this page answers are the ones a person asks first: how do I get in, what can my session reach and why, how do I work with data that must not leak, and where do I say yes when something asks for more.
How to use it
Log in with your own SSH key. The user name picks the session:
$ ssh alice@box # an ordinary session: Alice's unlabelled data
$ ssh alice+tax@box # a vault session carrying Alice's `tax` label
$ ssh alice.work@box # a second ordinary session beside the first: Alice's context `work`
$ ssh alice+tax.work@box # the context `work` of the `tax` vault, another context again
$ ssh approve@box # the approval terminal: only the steward talks here
At the prompt, the session’s namespace is a table you can print:
/home/alice (1)> ns()
/boot bootfsd
/home/alice
/net
/dev/cons
steward
budget
bootfsd
erofsd:system
/home/alice (2)> File.ls!("/home/alice")
["notes.txt", "src"]
/home/alice (3)> File.read("/home/bob/notes.txt")
{:error, :enoent}
A line with a / is a path the session reaches, with the handle’s name beside it when the same
handle was also handed by name (/boot is bootfsd); a line without one is a named handle: the
steward’s grant to this session, the session’s own budget, and the system volume beamlet loads
its modules from.
bind/2 makes a connection the session already holds appear at another prefix. It creates no
authority, only a name:
/home/alice (4)> {home, _rest} = ns_lookup("/home/alice")
/home/alice (5)> bind("/h", home)
:ok
/home/alice (6)> File.ls!("/h/src") # the same files as /home/alice/src
Leaving the session with exit ends it: the steward destroys the session’s budget, and every
process in it ends with it. Closing the SSH terminal instead detaches it, and the next login of the
same context reattaches it; a context is one session at a time, so a second ssh alice.work@box
while the first is open takes it over (contexts).
What it can and cannot do
Logging in
Status: built · tested: bench:steward-ssh-two-principals, bench:steward-login-refused, bench:steward-context-login, bench:userland-boot
sshd (the SSH server) accepts a connection, and sshd and the steward authenticate the person
with the principal’s login key. In M1 (sessions over SSH, kept apart) the principals and their keys
come from the boot manifest (init). A login key is the person’s own: it stays
on their machine or security key and never lives in keyd (the key server). sshd refuses
authentication with any public key keyd holds, so a hijacked session that can sign with keyd
cannot log in as anyone (sshd).
The steward then starts the session: it carves a budget for it under the principal’s budget, builds its namespace from the principal’s capabilities, and launches a beamlet VM in it (the steward). Each principal has one fixed sub-budget per label set, and sessions are carved from the one that matches their labels, so a flood of sessions in one label set cannot starve another.
Contexts
Status: built · tested: bench:steward-context-login, host:redoubt-sshd::the_login_grammar, host:redoubt-sshd::only_a_pty_channel_s_end_of_input_closes_the_terminal, host:redoubt-steward::context_free_holds_one_session_per_name, bench:steward-session-ends, bench:steward-context-labels, bench:sshd-restart-detaches, host:redoubt-consrelay::an_attach_cuts_to_a_line_says_what_was_dropped_and_replays, host:redoubt-consrelay::the_vm_s_size_is_the_channel_s_and_an_attach_redraws, bench:steward-context-cap, bench:steward-context-idle, bench:shell-contexts, bench:steward-restart-context, bench:steward-login-timing
A context is a named session of one principal in one label set. The SSH user name is
principal[+label][.context], in that order only: alice is Alice’s default context,
alice.work her context work, alice+tax.work the context work of her tax vault. Each part
is 1 to 64 bytes of lower-case ASCII letters, digits, _ and -, starting with a letter, with no
case folding; . is the separator, not :, which scp, sftp and rsync read as the start of
a path. Contexts are not declared: a login names one, and the first login makes it.
- Its identity is (principal, label set, name).
alice.workandalice+tax.workare two contexts, each carved from its own label set’s sub-budget, so a context never crosses from one label set to another. - One session at a time (the steward). A second login naming a context reaches its session rather than making another; while that session is still starting, the login is refused. Once the session has ended, the name is free again.
- Closing SSH detaches. Closing the terminal of a session with a pty (an interactive
ssh) leaves the context’s VM running; what it writes meanwhile is kept, the newest 64 KiB (consrelay). The nextssh alice.work@boxreattaches:[context work: reattached], a line saying how many bytes were dropped if any were, then the kept output; the relay answers the VM’s waitingresizewith the new terminal’s size.exitat the prompt ends the context. Ifsshditself restarts, every attached context is detached the same way. Without a pty (ssh alice@box < file) the end of the input is the end of the file the shell reads, as before, not a closed terminal. - A second terminal takes over. A login to a context attached elsewhere takes it: the old
terminal is told
[context work taken over from ADDRESS at up 2h13m]and closed (one that has stopped reading may be closed untold), and the new one is told where it was taken from (R80 (one channel per context)). - No enumeration. A key that is not the principal’s, a principal or a label set the manifest does not give, and a name outside the grammar all get the same refusal, so its content says nothing about which names exist. Its timing is not made equal: inside the steward’s one call, an unknown principal or label set, or a name outside the grammar, is refused before any of its machines runs, and a wrong key for a known principal runs the blame and session machines before the key check refuses it; nothing is counted. Measured at the client, the three kinds’ median refusals lie within a millisecond of each other, inside their spread (the steward).
- Reserved names take no suffix.
approveis the approval terminal’s name: no principal takes it, andapprove+xandapprove.xare not logins. - A cap per label set (R82 (the context cap)). A principal has at
most a set number of live contexts in each label set, attached or detached, the console’s
session counted where it runs: by default what the label set’s share of the principal’s budget
holds, or fewer if the manifest says so. A login that would make one more is refused like any
other refusal; nothing running is ended to make room. In the image alice has two SSH contexts
beside the console, three in her vault, and bob two;
agentis the image’s principal for a sponsor-run agent, sponsored by alice: its session is a lease of hers, carved from her unlabelled share (so it counts among her contexts there), with a home of its own and no labels and no network scope (agents). - Idle contexts end (R83 (idle contexts end)). A context left detached for its principal’s idle bound, by default a day, ends with everything in it. An attached context never does, and each attach starts the count again.
- From the prompt.
contexts()lists the session’s label set’s contexts,detach()closes the terminal and leaves the context running, andend_context(name)ends one (the shell). Another label set’s contexts are never shown, and its names are unknown. - A steward restart ends them. Contexts end with the steward; after it restarts, a login makes a new context, however it is named (the steward).
A session is a VM in a budget
Status: built · tested: bench:steward-ssh-two-principals, bench:steward-session-ends, bench:steward-sub-budget-flood
One session is one beamlet VM, in one budget of class user, under the principal’s budget
(budgets). What that gives:
- Everything the session does is paid for there. Every page, process and share of the CPU it uses is charged to the session’s budget or to a budget carved from it (R6 (charging)). A session cannot spend Bob’s pages. Its VM’s two schedulers are threads of its one process: on several harts they run at once, and the kernel charges each hart’s run to the session’s budget, so the session’s share is its weight’s however many harts it uses (beamlet).
- Every call it makes says who it is. The kernel stamps each message with the budget’s account (the principal it bills to) and label set; the session cannot choose either (R14 (unforgeable sender)). Servers admit and check calls by that (account, label set).
- Ending it ends everything in it. Logout, a lost connection, or the steward ending the session destroys its budget. Every process in it ends, every handle stamped with it dies wherever copies went, and nothing outside it is touched (R10 (destruction)).
- One VM is one trust domain. Everything typed at the prompt, every script and every Erlang process in the VM runs with the session’s full authority. Code that must have less (an agent, an untrusted parser) runs in a VM or native program of its own, in a budget of its own (agents, native programs).
There is no root and no sudo. “Admin” means holding specific capabilities over shared things,
and a session holds only what its principal was granted.
Vault sessions
Status: built · partly tested: a vault session’s own reads and writes wait for its namespace to reach the VM; the label check they meet at walfsd is attacked on its own · tested: bench:steward-vault-session, bench:steward-vault-launch, bench:steward-sub-budget-flood, bench:walfsd-label-check, host:redoubt-steward-server::a_vault_login_carves_from_the_vault_s_sub_budget_and_has_no_network
A vault session carries one of its principal’s labels: ssh alice+tax@box starts a session
whose budget has Alice’s tax label. A budget’s labels are fixed when it is created and only grow
downward (I6 (labels only grow downward)), so nothing started inside a vault session can shed the
label. A program the session launches runs in a budget carved from the session’s with the same
labels, which are the only ones the kernel lets a session’s child carry: it writes its standard
output and error to pipes of the session’s, which the session draws on its own channel, and to
nothing unlabelled (native programs). It holds no
console of its own: the M1 (sessions over SSH, kept apart) residual, a launched program reading and writing the session’s console
past the shell, is closed.
What the label changes, per the label rule of the servers (labels):
- It can read unlabelled data and data carrying
tax: reading needs the caller’s labels to include the object’s. - It can write only data carrying exactly
tax: writing needs the labels to be equal. So it can read Alice’s home volume and cannot write a byte to it. - It cannot send to an unlabelled process at all
(R1 (flow)), and servers that are sinks refuse it:
ipd(the TCP/IP server) refuses every labelled caller, so a vault session has no network, and itsgen_tcpconnect is{error, eacces}, the refusal’s name (beamlet).
What it shows on the SSH channel reaches only Alice, who owns the label; sshd keeps each channel’s
labels, and a labelled channel is its owner’s terminal only, with no forwarding, no subsystems (so
no file transfer: file transfer) and no exec. Data leaves the label only by
declassification: a request to the steward, approved by the label’s owner at approve@ (the
steward).
Namespaces
Status: built · partly tested: on the box the cases show which connections the steward made and in which budget, and a session’s paths are walked once its namespace reaches the VM; the binding table, slot by slot, is host-tested · tested: bench:steward-ssh-two-principals, bench:steward-vault-session, bench:steward-home-quota, host:redoubt-steward-server::a_login_runs_the_session_batch_and_answers_the_session, host:redoubt-steward-server::a_vault_login_carves_from_the_vault_s_sub_budget_and_has_no_network
A namespace is a table inside the process: path prefixes, each naming a capability (a 9P connection) the process holds. The launcher writes it into the child’s startup block before the child starts (processes); for a session, the launcher is the steward. There is no mount call and no kernel mount table: the kernel knows handles, never paths.
flowchart LR
subgraph S["Alice's session: one beamlet VM"]
direction TB
subgraph NS["namespace: prefix to connection"]
h["/home/alice"]
c["/dev/cons"]
b["/boot"]
n["/net"]
end
subgraph NH["named handles"]
st["steward"]
bu["budget: the session's own budget"]
sy["erofsd:system"]
end
end
h --> FS["walfsd, Alice's home volume"]
c --> SH["sshd, this SSH channel"]
b --> BF["bootfsd, read-only"]
n --> IP["ipd, a scope of hosts and ports"]
st --> ST["the steward, this session's grant"]
sy --> SY["erofsd, the system volume, read-only"]
Figure: a session’s namespace. Each entry is a connection the session holds; a path reaches
only what an entry names, so /home/bob/notes.txt, which no entry is a prefix of, is enoent.
What follows from a table of capabilities:
- A path resolves by its longest matching prefix, and the rest of the path is walked on that
connection.
/dev/cons/xgoes to the console’s connection withxleft over. - Nothing is inherited. A path whose prefix names nothing the session holds is
:enoent, not a permission error: there is nothing there to refuse. - Above the entries is a directory the namespace answers itself.
/or/home, above an entry and inside none, lists the next name of each entry below it, and nothing in it can be removed or written (:eacces); making it is:eexist, as it is there, soFile.mkdir_ppasses through it;/home/bob, beside/home/alice, is still:enoent. ..never climbs out. Paths are cleaned lexically before lookup, in the client and again in the server, so/../../etcis/etcon the same connection.- Different prefixes are usually different servers.
/home/aliceand a vault’s volume are two connections, so a rename between them is a copy and a remove, never atomic (files). - A home holds its principal’s quota, whatever its sessions. Every session of Alice’s,
her console session and her named contexts among them, is minted through the one connection the
steward carved at her home with her
home_quota, so together they hold at most that many bytes; a write past it is:enospc, and Bob’s home on the same volume keeps its own (home quotas and vaults). A vault has no quota of its own: it is its labelled volume, bounded by its room. - A copy of a connection is the same connection. All holders of a handle share one badge and
one set of open files, so a launcher never passes its own connection to a child: it asks the
server for a fresh one with
new_connection, and disconnects it when the child’s exit notice arrives (the serving library).
Binds. bind/2 puts a connection the session already holds at another prefix. It needs no
server and creates no authority. A namespace is the session’s own: binding in it changes nothing
for any other process, and a child sees only the table its launcher wrote for it
(files and binds).
What a session is told
Status: built · partly tested: a plain session and a vault session read theirs on the machine; a named context’s is host-tested only · tested: bench:shell-commands, host:beamlet-redoubt::a_sessions_identity_is_read_from_its_arguments, host:beamlet-redoubt::the_platform_tells_a_session_its_identity, host:beamlet-vm::the_identity_is_the_platforms_and_without_a_system_is_not_supported, host:redoubt-steward-server::a_sessions_arguments_name_its_principal_labels_context_and_home, host:redoubt-steward-server::a_sessions_launch_is_told_its_context
A session’s VM learns what it is from its launch arguments, which the launcher writes into its
startup block, as the steward does for every session it launches
(the steward): principal=NAME; label=NAME:ID, one for each label of its set, in the set’s
order; context=NAME for a named context, none for the principal’s default one or the
console’s session; and home=/PATH, the path the session’s namespace binds the principal’s home
at, for a vault session as for a plain one, none for a principal with no home or for a vault
session in a confined boot, whose home slot binds nothing
(the steward). beamlet reads them
once at its start and gives them to Elixir as redoubt:identity/0 (beamlet);
beside them a session is launched with the named handle notices, the receive right of the
endpoint the steward tells it a lease’s end on, and a shared server’s restart, which the shell
serves from its start, re-binding the slot the restart named through the steward
(Redoubt.Shell.Notices; the steward,
failure and restart);
a VM started without principal= is no session and gets not_found, and one whose home= is
not a clean absolute path, or comes twice, is told nothing either. They are fixed for the VM’s
life, and they are information, not authority: the label set the kernel stamps is
redoubt:labels/0, and what the session can reach is its handles, whatever the arguments say;
the shell starts in the home named, and can only because the namespace reaches it
(the shell). A session that could rewrite them would lie only
to itself.
How a program reads its namespace
Status: built · tested (6)
- host:redoubt-rt::resolve_takes_the_longest_prefix
- host:redoubt-rt::dot_dot_never_climbs_above_the_root
- host:redoubt-rt::bad_names_are_refused
- host:redoubt-rt::hostile_blocks_are_refused
- host:redoubt-rt::handle_names_follow_the_manifest_rule
- host:redoubt-rt::a_client_without_its_namespace_fails_cleanly
For native programs the namespace table exists in code: redoubt-rt (the native runtime) parses the
startup block and resolves paths against it
(libs/rt/src/startup.rs,
libs/rt/src/path.rs).
- The block names the handles
process_startinstalled (slots 1 to n, at mostMAX_START_HANDLES, 128), namespace entries (a handle and a clean absolute path), named handles (a handle and a name) and the arguments. Paths are unique, names are unique, and a block that breaks any rule is refused whole: the parent may be hostile. resolvereturns the entry with the longest matching prefix and what is left of the path; a prefix matches only at a/boundary, so/dev/consxdoes not match/dev/cons. A relative path resolves to nothing.cleanresolves.and..lexically and never climbs above the root:../../etc/passwdisetc/passwd. A name longer than 255 bytes or holding a NUL, and a path deeper than 64 components, are refused.- A named handle’s name is lower-case ASCII letters, digits and
_:+-, starting with a letter, at most 64 bytes (walfsd:data,alice+secrets). - A program started with an empty namespace fails cleanly: the echo client exits with its error
code, and the echo server with
NO_ENDPOINT, instead of reaching anything.
approve@
Status: built · partly tested: a labelled agent’s request and a note; a declassification waits for the steward’s declassification and push · tested: bench:steward-approve-box, bench:steward-screen-inert, bench:steward-pending-cap
ssh approve@box is the approval terminal. When an agent asks its sponsor for more authority, or
a session asks for a declassification, the request goes to the steward, and the requester’s own
terminal only shows that an approval is waiting. The person answers at approve@, where only the
steward talks to the terminal, so the requester can never draw on the screen the person decides
from (the steward).
- The person’s own key.
approve@authenticates with the person’s own SSH key, which never lives inkeyd. A session’s network capabilities never include the box’s own addresses, so a hijacked session cannot reachapprove@over loopback. - What is shown is rendered by the steward from the structured request: who asks (its kind,
such as agent or session, and its steward-given name,
agent-7), what, where, for how long, and the label consequences. Every field is printable ASCII, escaped and length-capped. A labelled requester’s free text is never shown; an unlabelled requester’s is quoted and marked untrusted. - What is approved is bound: each request has a random 64-bit id and a hash of its exact
content, and approving confirms both:
approve ID HASH, the hash in full or as the eight-character code the screen shows beside it, which the steward expands only from the last screen it wrote on that channel (sshd). An approval grants no more than the approver holds. The requester’s own sessions print[an approval is waiting]and nothing of the request.
In M3 (agents, approvals and the attack suite) approve@ is served by the same sshd as every
other channel. That is a stated residual: a bug in the SSH code reached from any channel could
control the approval screen, and a network flood can delay approvals (sshd).
The design protects the approval channel from the requester; it does not protect the person’s
judgment, and an approval that grants more than the person intended is a wall the design names, not
one it closes (agents).
Why
Sessions are budgets because budgets are what the kernel enforces. The kernel knows nothing about people. It knows budgets, which pay for everything and whose destruction revokes everything, and label sets, which it checks on every message. Making a session a budget with a label set means the separation between Alice and Bob is the kernel’s, and ending a session is one call that cannot leave anything behind.
Namespaces instead of a file tree. A global file tree with permission bits asks every server to decide, per path, who may do what, and a mistake anywhere is a leak everywhere. A namespace of capabilities asks one question once, at launch: what does this process get. A path that is not in the table does not exist for that process, so there is nothing to probe, and a child can be given a narrower table than its parent by writing fewer entries. Plan 9 showed that per-process namespaces make a usable system; Redoubt adds that every entry is a capability.
Approval out of band. If an approval could happen in the requester’s own terminal, a hijacked session could draw a fake prompt, or answer it. Putting approvals behind a separate login, with the person’s own key, rendered only by the steward, makes the channel something the requester cannot touch. The human is still a wall that can be talked into things; the design says so rather than hide it.
beamlet, the Elixir VM
beamlet is a BEAM interpreter in safe Rust: it runs Erlang and Elixir code compiled by the
standard compiler, OTP 28 with Elixir 1.20. Every session and every agent on Redoubt is one
beamlet VM. The VM gets from its embedder, through one Rust trait (Platform), exactly the
services it is granted (a clock, a console, random bytes, code, files, programs) and nothing
else, and it treats every .beam file, literal and message as hostile input. beamlet runs on
the host and boots the shell on Redoubt with verified modules from the userland disk, and its
files are 9P files in its namespace. Native launching on Redoubt remains planned.
Purpose
A session needs a language, a standard library and a prompt, and it needs them without C, without
a POSIX kernel underneath, and small enough to audit. The BEAM gives Elixir and Erlang, OTP’s
libraries, the compilers, supervision and message passing; beamlet gives the BEAM in a form
Redoubt can trust: its own crates hold no unsafe, a loader checks everything before code runs,
limits fail closed, and one narrow boundary is where the operating system comes in. The code is in
userland/otp.
How to use it
On the host, beamlet is a command:
$ cargo run -p beamlet -- -pa ebin my_module start
$ cargo run -p beamlet -- --root ./sandbox -pa ebin my_module start # expose one directory
$ cargo run -p beamlet -- --mount /data=./data:ro --root ./sandbox ... # add a read-only mount
$ cargo run -p beamlet -- --exec --schedulers 4 ... # grant programs; 4 threads
Without --root, file calls fail with enotsup; without --exec, opening a port to a program
fails with eacces. The VM’s environment starts empty, with HOME set to / when there is a
--root (--env NAME[=VALUE] adds to it), so the host’s is not visible. Tests: cargo test in
userland/otp runs the unit and hostile-input tests; the differential suites (tools/difftest,
tools/elixir-tests) need the pinned OTP and Elixir toolchains installed.
On Redoubt there is no command to run: the steward starts a session’s VM when a person logs in
(sessions), and a launcher starts an agent’s (agents). What the code
in the VM sees is ordinary Elixir: File.read!/1, IO.puts/1, :gen_tcp.connect/3.
What it can and cannot do
Loading hostile code
Status: built · partly tested: hostile loader tests run on the host; checked modules boot on Redoubt, but the refusal of other OTP versions' opcodes and atom tables is not attacked by a named test · tested (12)
- host:beamlet-vm::fixtures_load
- host:beamlet-vm::every_truncation_is_rejected
- host:beamlet-vm::wrong_formats_are_named
- host:beamlet-vm::mutants_never_panic
- host:beamlet-vm::empty_frames_count_against_the_stack
- host:beamlet-vm::rejects_hostile_input
- host:beamlet-vm::safe_mode_creates_no_atoms
- host:beamlet-vm::safe_mode_refuses_an_export_fun_of_code_not_exported
- host:beamlet-vm::safe_mode_decodes_no_local_fun
- host:beamlet-vm::nesting_is_bounded
- host:beamlet-vm::deep_terms_are_handled_iteratively
- bench:userland-boot
The VM crate (beamlet-vm) is #![forbid(unsafe_code)], and so are beamlet-re and
beamlet-crypto (userland/otp/vm/src/lib.rs).
- One compiler. The loader accepts bytecode from OTP 28 only. It refuses deprecated or unknown opcodes, old atom tables and anything else another version would need compatibility code for.
- Checked before it runs. The loader checks every table index, label, register number and
literal before any code runs (
userland/otp/vm/src/loader.rs). A malformation that survives loading isbad_codeat run time: the process that ran it dies, uncatchably. Nothing in the VM panics on bad input. Every truncation of real.beamfiles is refused, and 20,000 mutated modules per run load or fail without a panic or a hang. - Deep terms do not recurse. Copying, comparing, printing and collecting use work lists, never Rust recursion, so a million-level nested term is fine. The external term format limits nesting to 256.
- Safe decoding names no code.
binary_to_term(Bin, [safe])creates no atoms, decodes an export fun only of a function exported now (a loaded module’s export, or a BIF), as BEAM does, and decodes no local fun at all, where BEAM decodes one whose atoms exist: a fun is code to run, with free variables the sender chose, and safe mode is for data from outside (userland/otp/vm/src/etf.rs).
Limits inside one VM
Status: built · partly tested: in a boot, only the process heap limit and the budget's backstop are attacked · tested (42)
- host:beamlet-vm::full_mailbox_kills_the_receiver
- host:beamlet-vm::a_term_equal_to_itself_is_not_scanned
- host:beamlet-vm::full_own_mailbox_kills_the_sender
- host:beamlet-vm::a_roomy_mailbox_is_not_a_limit
- host:beamlet-vm::the_vm_heap_limit_kills
- host:beamlet-vm::a_process_can_lower_its_own_limit
- host:beamlet-vm::spawn_opt_sets_a_limit
- host:beamlet-vm::under_the_limit_nothing_happens
- host:beamlet-vm::ets_inserts_past_the_limit_raise
- host:beamlet-vm::memory_is_reported
- host:beamlet-vm::jump_loops_are_preempted
- host:beamlet-vm::garbage_is_collected_and_live_data_survives
- host:beamlet-vm::unreferenced_binaries_are_freed
- host:beamlet-vm::an_atomics_array_within_the_limit_is_held
- host:beamlet-vm::an_atomics_array_past_a_processs_own_heap_limit_ends_it
- host:beamlet-vm::counters_arrays_past_the_limit_together_end_their_holder
- host:beamlet-vm::a_zlib_stream_within_the_limit_is_held
- host:beamlet-vm::a_zlib_streams_queue_past_the_limit_ends_its_holder
- host:beamlet-vm::zlib_codecs_past_the_limit_together_end_their_holder
- host:beamlet-vm::a_zlib_streams_stash_past_the_limit_ends_its_holder
- host:beamlet-vm::a_deflaters_boxed_state_is_the_constant
- host:beamlet-vm::an_inflater_holds_nothing_beyond_its_own_box
- bench:beamlet-heap-flood
- bench:beamlet-budget-flood
- bench:beamlet-compile-footprint
- bench:beamlet-compile-flood
- host:beamlet-vm::a_deflaters_inline_state_is_a_few_hundred_bytes
- host:beamlet-vm::the_footprint_is_reported_at_the_first_wait_for_input
- host:beamlet-vm::held_bytes_follow_the_runtime_heap
- bench:beamlet-footprint
- host:beamlet-vm::a_128_mb_atomics_array_in_persistent_term_past_the_limit_is_refused
- host:beamlet-vm::a_replaced_persistent_value_still_counts
- host:beamlet-vm::a_zlib_stream_grown_in_persistent_term_past_the_limit_refuses_the_next_put
- host:beamlet-vm::a_zlib_stream_grown_in_ets_past_the_limit_refuses_the_next_insert
- host:beamlet-vm::deleting_the_table_holding_a_grown_stream_makes_room
- host:beamlet-vm::an_update_element_past_the_ets_limit_is_refused
- host:beamlet-vm::heir_data_past_the_ets_limit_is_refused
- host:beamlet-vm::heir_data_counts_toward_the_ets_limit
- host:beamlet-crypto::mod_exp_refuses_an_oversized_operand_before_it_runs
- host:beamlet-crypto::dh_generate_key_refuses_oversized_parameters_before_it_runs
- host:beamlet-crypto::dh_compute_key_refuses_an_oversized_private_key_before_it_runs
- host:beamlet-crypto::an_oversized_rsa_private_key_is_refused_before_it_is_built
One VM is one trust domain, but a buggy or hostile Erlang process must not take the rest of the
VM down. Every limit fails closed: the offender ends, and nothing is lost silently
(userland/otp/vm/src/vm.rs).
- Mailbox (
max_mailbox, 2^20 messages): a message that would overflow a mailbox kills the receiver with{system_limit, message_queue}, untrappably. Dropping it would break protocols silently, like a TCP stream with a hole in it. - Process memory (
max_heap_words, 2^27 words, andmax_heap_size, which a process can only lower): checked at the end of each slice; a process over it is collected first and killed only if what is live is still over. A resource whose native declares its size counts that size as the memory of each process whose heap holds it, towardmax_heap_sizeas heap words do: a screen buffer; anatomicsorcountersarray, its cells at 8 bytes each, up to 2^24 of them; and azlibstream, its queued input (at most 256 MB), the output it has not handed out, its codec’s state (a deflater’s includes the tables its library keeps behind boxes of its own, measured against the library by a test) and the term stashed in it, declared afresh by every native that changes them. A process past its limit through any of them is ended as for heap growth. Only a process’s heap counts one toward its limit, and a holder that is not the one resizing it counts the new size from its next collection. A resource in ETS orpersistent_termcounts toward their limits, below. One in a message not yet received counts toward nobody’s: the screen buffer’s limit of four a process and the mailbox’s limit bound a buffer there, but an atomics array or a zlib stream sent and dropped by its sender is bounded only by the budget’s page limit, below. - ETS (
max_ets_words, 2^27 words for all tables together): an insert past it raisessystem_limit, as does anupdate_counterorupdate_elementthat would take it there. A table’s heir data ({heir, Pid, Data}) is kept with the table and counts as an object does: anets:neworets:setoptsthat would take ETS past the limit with it raisessystem_limit. A resource an object holds counts at its live size, once for each object holding it: a zlib stream that grows after its insert counts its growth at once, so the next insert past the limit is refused (the growth itself is bounded by the grower’s own heap limit). ETS keeps its total as objects enter and leave and resources resize, never by scanning: an insert, delete or replacement costs one step for each resource on the objects it adds or removes, a resize one step, and deleting a table one step for each resource its objects hold. persistent_term(max_persistent_words, 2^27 words): its keys, and every value ever put, each resource at its live size as in ETS. A put past it raisessystem_limit, as does a put of a new key past 2^16 keys. A value replaced or erased is never freed: it stays in its literal chunk, where BEAM frees it once no process refers to it, so its words count for good. An erased key’s own words are released. A value’s parts that are already literals (another persistent value, a module’s constant) are not copied, and count only where they were first put.persistent_term:info()reports the count as itsmemory.- CPU: reductions preempt every process, including a loop of plain jumps with no calls. A
native is not preempted, so what one may compute is bounded before it runs:
crypto:mod_powand finite-field Diffie-Hellman hold the modulus (P), the base and the exponent each to 10,000 bits, OpenSSL’sOPENSSL_DH_MAX_MODULUS_BITS, and an RSA key to 8,192 bits, itsDand primes toN’s width; within the bound every call answers as BEAM’s does, and a wider operand is refused withbadargbefore any arithmetic, where the unbounded call on a megabyte’s would hold its scheduler thread, and every process on it, for days. - Fixed limits, each
system_limit: 2^20 atoms of at most 255 characters, 2^16 processes, a stack of 2^24 slots, bignums of 2^24 bits, binaries of 2^30 bits. - A term is equal to itself without being read. Comparing a term with itself (
==,=:=, a map key, a sort) answers before a byte of it is touched, as BEAM’seqdoes: sound for every Erlang term, since the language has no NaN and so no term that differs from itself. It matters at the prompt:erl_evalcompares every binding with itself on each call of an interpreted fun, so a loop typed at the prompt over a 256 KiB binary once cost a scan of it a step (a_term_equal_to_itself_is_not_scanned).
These limits are measurements, not an allocator: one native that allocates a lot at once is caught
afterwards. The hard backstop is the embedder’s allocator, and on Redoubt the session budget’s page
limit (R6 (charging)). The image also caps beamlet’s heap
(init), but at the budget’s edge, where the budget binds
first: that cap is there for the bench’s measurement. On Redoubt the platform lowers
max_heap_words, max_ets_words and max_persistent_words to a sixteenth of the VM’s budget
each, which it reads from its startup block’s budget_pages
(init): the launcher that made the budget fills it, init
from the manifest’s budget.pages, the steward with the VM’s share of its session’s budget, so
no argument has to be kept equal to the budget by hand, and a manifest that names one is refused
by the bench (host:testbench::no_manifest_names_budget_pages). Each counts the VM’s own 8-byte words, as the VM
counts a process (two for a 16-byte term), so a limit is the same bytes on rv32 as on rv64. A flooding process peaks at about four times its
heap limit, the old heap, the collector’s copy and its growth, so the budget must be at least twice
what the VM uses with no Erlang process running; then one flooding process, the tables or
persistent_term meets its limit while the VM still has pages. A session’s sixteenth is 696
pages, 2,850,816 bytes; the shell at its prompt holds 3,696 bytes in ETS and 36,112 counted in
persistent_term, about a thousandth and a hundredth of it. Several flooding at once, or a native’s single large allocation,
reach the backstop instead, which ends the VM, and init restarts it. It is a server like any other
under init’s restart rule: a VM that cannot stay up (a start module that fails every time, a
startup block that tells it no budget, exit code 4) is restarted until the limit, and then the machine reboots
(init). CPU between VMs is the kernel’s to share, by
budget weight (scheduling).
What the VM holds at its prompt
Started with the argument report_memory, which the image never passes, the VM prints what it
holds the first time it waits for console input with nothing else to run, for the shell at its
prompt, one console line a row (memory.rs). It is the
VM’s own count: each allocation as the runtime’s heap holds it (a power-of-two block up to 2 KiB,
whole pages above), every waiting process collected first; B-tree nodes are not counted.
beamlet adds its runtime heap’s pages held at that moment and its record’s peak, and the bench’s
scan of that record (the memory budget) is the independent
total, read once the case has typed one command. In pages, each row rounded to whole pages on its
own:
| What | rv64 | rv32 |
|---|---|---|
| Decoded code: instructions | 535 | 535 |
| Decoded code: operands | 2,169 | 2,169 |
| Literals: each module’s | 37 | 37 |
| Literals: the shared table | 719 | 717 |
| Module tables | 235 | 205 |
| Atoms | 97 | 68 |
| Processes (22): heaps, collected | 57 | 57 |
| Processes: the rest | 118 | 112 |
| ETS and binaries | 1 | 1 |
| Accounted | 3,968 | 3,901 |
| Runtime heap at the prompt: held, peak | 4,196, 4,601 | 4,023, 4,431 |
| Not accounted (held less accounted) | 228 | 122 |
| The scan’s peak, after one command | 5,542 | 5,362 |
A module’s instructions are 8-byte entries over one array of its operands, 16 bytes each on either
width, a list operand’s items in the same array, so decoded code is the same size on both widths
and still about two thirds of the count. The five largest modules are unicode_util, erl_parse,
Elixir.Enum, Elixir.Kernel and string. What is not accounted is free small blocks, B-tree
nodes and the platform’s buffers; the peak above what is held is the boot pack, which is held
until the prompt, and a load’s transient, and the scan’s peak is higher than the prompt’s because
the first command loads more modules.
- Loaded code keeps no spare room. The loader shrinks a module’s instructions and its operand array to their counts once decoded, where the decoding’s doubling left up to twice as many.
- A literal chunk keeps no spare room. A module’s constants are shrunk to their count before they join the shared table (about 350 pages on either width).
- Loading stays eager, but for the shell’s commands. The shell’s start loads what its prompt needs. The largest modules are the ones evaluating any line needs, so loading them on first call would move their pages to the first command, not save them. A session calls few of its commands, so a command’s module is loaded when the command is first called (the shell), and the boot pack holds none of them.
The image budgets the VM twice the largest peak the scan finds across its memory cases, in
beamlet-footprint, the one case that scans a shell’s VM now that the steward starts the others’
shells, plus what the VM holds outside its heap, rounded up to 128 pages: 11,264 pages from the
largest peak, 5,556 on rv64 at one hart, which its heap cap of 11,220 holds twice
with 108 to spare, and with which the single VM boots in 512 MiB
(budgets). Outside the heap are 44 pages:
its two schedulers’ 18-page stacks (the second’s above a guard page of address space alone, which
costs no page; its peak at the prompt is 21,496 to 25,200 bytes on rv64 over three runs, stack beamlet thread 3 in beamlet-footprint’s scan) and thread pages, up to five pages of endpoints its locks
make, and its process’s own page. The share moves up a step of 128 when the peak passes what
the cap holds twice: the rv64 peak may reach 5,610 pages before it moves again
(budgets). The shell’s machine mode is
19 of the pages at the prompt, the driver’s and the loop’s code. The line editor under the shell’s driver is loaded at
the prompt: OTP’s group, edlin, edlin_key, group_history, prim_tty, shell,
gen_statem, sys and kernel, with Redoubt.Term and the driver. The shell’s protocols are not
consolidated, but nothing at the prompt, nor a plain line, dispatches a protocol on a struct, so
Elixir’s Protocol and Enumerable load only for a line that does.
That rule sizes the idle VM, and a compile is a workload within it: beamlet-compile-footprint
types a three-line defmodule at the prompt under the same manifest, calls the module, and
judges that the compile fits the session’s cap with at least an eighth of it to spare
(heap_fits, the memory budget). The compiler’s modules
stay loaded, so its heap peak is 9,135 pages on rv64 and 8,798 on rv32 of the 11,220 cap, 2,085
and 2,422 pages under it (an eighth is 1,402). It runs one scheduler, so every process is on the
first thread, the one the scan paints: with two, the compile runs on whichever scheduler holds the
shell’s evaluator, and the second thread’s stack is not measured. Its first thread’s stack peaks at
34,280 bytes on rv64 and 25,832 on rv32, under beamlet-footprint’s idle 36,136, so the 18-page
declaration stands. It was 88,888 and 81,056 while term_to_binary(T, [compressed]), which the
compiler calls for a chunk, kept miniz_oxide’s whole compressor, 65,712 bytes of it a code buffer,
as a local of its own function: one native frame larger than the stack left under the
interpreter’s, which defmodule at the prompt ended with a store page fault below the first
scheduler’s stack on rv64 and rv32 alike, and zlib:deflateInit/2 built the same temporary
on the stack before boxing it (on rv32 most times, not every time). Redoubt’s patch to the crate
boxes the code buffer under with-alloc, as the crate does on wasm32
(patched crates), so the state is a few hundred bytes wherever
it is built and the frame is gone.
Residual: a session that has compiled has spent its flood headroom on the compiler’s code. The
sixteenth limits are static, so one flooding process after a compile reaches the budget’s
backstop before its own limit, an allocation fails, the VM ends and init restarts it: a logout,
within the session’s own budget and no other principal’s, where before the compile the process
would have been killed alone (beamlet-compile-flood, both widths: the allocation that fails is
about 1.2 MiB on rv64 and 2.4 MiB on rv32, from one run of each). The follow-up is the VM deriving its limits from
the pages not held by loaded code, recomputed after a load, which keeps the guarantee at any
footprint without resizing a session.
Residual: QEMU’s default 256 MiB is out of reach. Its system budget leaves the VM about 2,700
pages beside the other servers, and the VM holds more than that at its prompt (the table above),
most of it decoded code and the shared literal table. Fitting needs code kept in its on-disk form or
loaded a function at a time.
The Platform boundary
Status: built · partly tested: file and program grants run on the host only; Redoubt's verified lookup is tested below · tested (9)
- host:beamlet-vm::programs_need_the_platform_to_grant_them
- host:beamlet-vm::the_bundle_wins_over_a_front_directory
- host:beamlet-vm::a_name_the_bundle_lacks_is_found_on_the_path
- host:beamlet-vm::a_refused_system_module_never_touches_the_code_path
- host:beamlet-vm::app_spec_uses_one_source_attempt_and_keeps_its_erlang_result
- host:beamlet-vm::names_resolve_inside_the_root
- host:beamlet::symlinks_cannot_leave_the_root
- host:beamlet::mounts_are_separate_and_may_be_read_only
- host:beamlet::files_round_trip
Everything the VM gets from outside comes through the Platform trait
(userland/otp/vm/src/platform.rs):
| Method | What it gives | Default |
|---|---|---|
monotonic_us, idle | a clock that never goes backwards; sleeping until a deadline or an event | required |
system_time_us | wall-clock time | required; may answer None |
console_write, console_read, console_size, prompt_drawn | the user I/O device; input never blocks; the shell’s driver says when its first prompt is drawn | no input; size unknown; nothing |
console_listening | told whether a process reads the console; with none, input held is no reason for idle to return | holds nothing |
console_resized | the console’s new size since the last call, the newest only; the VM sends it to the console’s reader as {beamlet_console_resize, {Cols, Rows}} ahead of input | never changes |
random | random bytes from a cryptographic source; on failure the VM raises rather than use a weaker source | required |
load_module, load_app, module_file | a system .beam or .app lookup answers found bytes, an absent name or a refused object; module_file names a loaded module | applications absent |
files | a file system, as prim_file sees it | none: file calls fail with enotsup |
programs | starting programs behind ports | none: open_port fails with eacces |
system | the system’s own calls: the namespace, calls and serving, budgets, labels, launching (natives) | none: each redoubt native answers {error, not_supported} |
load_moduleis a lookup, not a gate. It asks the platform for a module before examining the code path.Founduses those bytes, so no directory shadows a system module (packages);Absentsearches the code path’s directories in order, including those added withcode:add_patha/1;Refusedstops without touching the code path (userland/otp/vm/src/vm.rs,locate_module). On Redoubt a name the userland volume’serofsdanswersnot_foundto is absent, while any other refusal at the open or on the read (corrupt, a short or long file, a device error) is refused, with one console line naming the file and the error’s name (R75 (verified userland)).load_appcarries the same three outcomes:beamlet:app_spec/1makes one platform attempt and returns the bytes onFound, orerroronAbsentandRefused. Residual: the bundle’s protocols are not consolidated when it is built, so a protocol consolidated in a session’s own directory is not used and protocol dispatch stays the slower, unconsolidated kind; behaviour is the same. Consolidating them when the bundle is built is not done, at the cost of that speed and of Elixir’sProtocolmodule once a line dispatches on a struct: adefimpltyped at the prompt for a protocol already consolidated would be ignored. Code in the VM can also load any bytes it holds withcode:load_binary/3(userland/otp/vm/src/bif/info.rs), through the same loader checks. So what confines loaded code is not how it arrived but what the VM holds: every module, however loaded, reaches only what thePlatformgrants. Loading one’s own bytecode acts within one’s own authority.- Files are the platform’s. OTP’s own
file,file_serverandfile_io_serverrun unchanged over aFilestrait, path-based and POSIX-shaped:open,read,pread,pwrite,seek,info,list_dir,rename,deleteand the rest; a 9P client implementing it is Redoubt’s, below. Names resolve inside the VM, relative to its own working directory, with.and..resolved lexically, so no name climbs above/. What/is, is the platform’s choice, and the platform must still refuse what the VM cannot see, such as a symbolic link out of a mount. An open file belongs to the Erlang process that opened it and closes when it exits; a VM has at most 1024 open files. - Programs are a large grant. A program is outside the VM altogether, so
programsdefaults to none. The host CLI grants it only with--exec, and the programs it then starts are host processes with the user’s own rights, not sandboxed. - The host embedding exposes one directory with
--rootthroughcap-std, and more with--mount, read-only if asked; a symbolic link resolves within the VM’s own name space, and a link that would leave a mount is refused.
What runs on it
Status: built · partly tested: beamlet boots the shell on Redoubt; the differential suites against the real BEAM need OTP 28 and Elixir installed and are not run by the bench, and linear-time matching, crypto's refusal without randomness, the cofactored Ed25519 check and the bound on a zlib stream are not attacked by a named test · tested (16)
- host:beamlet-vm::decodes_otp_output
- host:beamlet-vm::encodes_like_otp
- host:beamlet-vm::printing_matches_otp
- host:beamlet-vm::matches_otp
- host:beamlet-vm::block_hash_handles_every_tail_length
- host:beamlet-re::pcre_spellings
- host:beamlet-re::braces_are_quantifiers_only_when_counted
- host:beamlet-crypto::certificates_round_trip
- host:beamlet-crypto::nesting_is_bounded
- host:beamlet-crypto::mutants_never_panic
- host:beamlet-crypto::ed25519_is_rfc_8032
- host:beamlet-crypto::the_all_zero_seed_is_refused_not_a_panic
- host:beamlet-crypto::x25519_refuses_a_low_order_point
- host:beamlet-vm::compressed_terms_round_trip
- bench:userland-boot
- bench:userland-read-only
Where beamlet implements something, it behaves as the real BEAM does, and the differential suite checks it: each test runs on BEAM and on beamlet and the printed results must be identical.
- OTP and Elixir unchanged. OTP’s
stdlib,logger,file,ssl(TLS 1.2 and 1.3) andsshrun unmodified, and so do Elixir’s standard library, its compiler, OTP’s Erlang compiler and IEx, whose transcript matches BEAM’s. Every live OTP 28 opcode is implemented excepton_load. - erts’s Erlang where it is code. Of BEAM’s preloaded modules, those whose Erlang is
useful load from erts like any other:
erlang,erts_internal,persistent_term,atomicsandcounters, their NIF stubs answered by natives, andprim_eval, whosereceiveis BEAM assembly: it runs areceivethaterl_evalevaluates, such as one typed at the shell’s prompt. The rest stand on BEAM’s C runtime (the boot process, ports, sockets, tracing) and never load; a call to one isundefunless a native answers. - A fun’s identity is its module’s checksum. A fun read from the external format with the
loaded module’s checksum is that code’s fun; one with any other checksum keeps the identity it
came with, is written back byte for byte, and a call to it is
badfun, as on BEAM. Three artifacts of BEAM’s fun table are not reproduced: the order of two funs that differ only in their checksum (here the loaded code’s sorts first, then by checksum); theOldIndexa second decode of one unknown checksum is written back with (BEAM keeps the first decode’s; here each fun keeps its own); and a fun decoded before its code is loaded, which stays apart from that code once it loads with the fun’s checksum (BEAM’s table then makes the two equal; here they compare unequal). - Processes as on BEAM. Links, monitors, aliases, exit signals, registered names, timers and ETS, on one or more scheduler threads with per-process heaps and copying garbage collection.
- Regular expressions (
beamlet-re) run in linear time for every pattern, so a hostile pattern cannot backtrack for ever. A pattern either means what it means in PCRE or fails to compile; backreferences and general lookaround do not compile. - Crypto (
beamlet-crypto) implements OTP’scryptonatives in pure Rust, socrypto.erl,public_key,sslandsshrun on it. It takes randomness only fromPlatform::random, and a failure to get it fails the operation. Itsrsacrate has a known timing side channel in decryption; side channels are a stated wall, not one the design closes. - One X25519 and Ed25519 for the whole box. They are
ed25519-compact’s, the crate the loader,keydandsshduse, built from the same vendored bytes, and the RustCrypto primitives beamlet shares with the servers are built from them too (vendored dependencies). Where it departs from OpenSSL, and so from the real BEAM: an all-zero Ed25519 seed is refused as a bad key, where the crate would panic; and a signature is checked cofactored, as the loader’s andsshd’s are, so one whose R differs by a point of small order verifies. That forges nothing without the key, but signatures are malleable, and code that takes a signature as an identifier must not. - Compression is OTP’s
zlib, whose natives run onminiz_oxide: deflate and inflate in raw, zlib and gzip formats, so:zlib,:zipand compressed external terms work unchanged. Each stream bounds what it holds queued, so a hostile archive cannot make one call allocate without limit, and what it holds counts toward its holder’s heap limit (limits inside one VM). - A compiled pattern is a compiled matcher.
binary:compile_pattern/1builds a matcher the VM keeps behind the reference it returns ({bm, Ref}for one pattern,{ac, Ref}for several, as on BEAM): the patterns indexed by their first byte, the longest first, sobinary:match,matchesandsplitcompare a byte of the haystack only against the patterns that start with it, and a search costs the haystack’s length whatever the pattern count, the leftmost and then longest match still winning. An uncompiled pattern is compiled for the call, as on BEAM; the haystack is read in place; what a compiled pattern holds counts toward its holders’ heap limit. On the host, 88 patterns over 64 KiB cost what one does, 0.44 ns a byte, where comparing every pattern at every byte cost 11 ns with 8 patterns and 127 ns with 88 (the_search_is_flat_in_the_pattern_count). - Not supported: NIFs and port drivers (foreign code runs as a separate program), distribution, hot code upgrade, and any OTP version but the pinned one.
The console on a host
Status: built · partly tested: host only; a change of the terminal's size is not delivered on a host · tested (9)
- host:beamlet::the_terminal_is_raw_while_the_vm_reads_it_and_restored_at_a_normal_end
- host:beamlet::the_terminal_is_restored_when_the_run_ends_in_an_exception
- host:beamlet::the_terminal_is_restored_when_the_vm_halts
- host:beamlet::the_terminal_is_restored_when_a_signal_ends_beamlet
- host:beamlet::a_panic_restores_the_terminal
- host:beamlet-vm::a_second_console_subscription_is_refused_and_the_first_reader_keeps_the_console
- host:beamlet-vm::the_console_is_free_once_its_reader_has_exited
- host:beamlet-vm::the_console_size_is_the_platforms_answer_asked_at_each_call
- host:beamlet-vm::a_change_of_the_console_s_size_reaches_its_reader_once
On a host, beamlet’s command line puts the terminal in raw mode from the moment a process of the
VM first reads the console, so that every byte typed reaches the VM as it is (Ctrl+C included),
and restores it on every exit: a result, an exception, a halt, a signal, a panic included
(userland/otp/cli/src/tty.rs). Output processing stays
on, so a line written past the shell’s encoder still lands where a line does on a host; the
encoder ends its lines with CR LF itself, as it must on Redoubt. console_size is the
terminal’s size, read afresh at each call. A change of the terminal’s size is not delivered on a
host (console_resized answers nothing there); the VM hands a platform’s change to the console’s
reader, as on Redoubt, and the shell reads the size at each prompt
(the shell).
Console input goes to one Erlang process, the shell’s driver, which takes it with
beamlet:console_subscribe/0; a second subscription is refused, so no code run at the prompt
can take the keyboard, or the interrupt key with it, from the driver.
The console, the clock and randomness
Status: built · partly tested: its tests run on the host, on the fake kernel, against a console server that keeps `consoled`'s protocol with a host terminal for its device; it runs in a boot in bench:beamlet-boot and bench:beamlet-console · tested (14)
- host:beamlet-redoubt::writes_reach_the_screen
- host:beamlet-redoubt::a_long_write_reaches_the_screen_whole_and_in_order
- host:beamlet-redoubt::a_write_answered_busy_goes_again_after_the_retry_interval
- host:beamlet-redoubt::the_console_is_one_hub_connection_with_one_waiter
- host:beamlet-redoubt::typing_reaches_the_vm_then_its_end
- host:beamlet-redoubt::a_read_waits_for_typing_without_holding_the_vm
- host:beamlet-redoubt::an_end_of_input_already_waiting_ends_the_idle_that_takes_it
- host:beamlet-redoubt::the_console_s_size_is_its_server_s
- host:beamlet-redoubt::a_change_of_the_console_s_size_reaches_the_vm_once_reading_has_begun
- host:beamlet-redoubt::idling_with_a_deadline_returns_by_it
- host:beamlet-redoubt::after_the_console_ends_idling_still_waits_for_its_deadline
- host:beamlet-redoubt::there_is_no_wall_clock
- host:beamlet-redoubt::input_nobody_reads_holds_no_idle_and_waits_for_the_next_reader
- host:beamlet-vm::the_platform_hears_the_console_reader_come_and_go
The first part of beamlet’s platform on Redoubt, beamlet-redoubt
(userland/otp/redoubt), serves the console, the clock
and randomness over the client library and the runtime’s calls. /dev/cons is opened once, if
the namespace holds it (a lease’s does not: below), and its reads and writes go through the VM’s hub
(asynchronous underneath): one read is out at a
time, held by the server until there is typing, and one write, a page at most, with what the VM
wrote after it waiting in order behind it, since the console’s share is a page a badge. So
neither a read that waits nor a slow console holds the VM’s thread, until 64 KiB wait unwritten,
when a write waits in place as one to a slow terminal does. When the VM ends, what it wrote
reaches the console before its last line, waiting at most 2 s. After the console’s end, idle
still sleeps until its deadline. When the process reading the console exits, the VM says so
(console_listening) and no read goes out until another process reads it: typing waits at the
console, what came in before is held for the next reader, and idle does not return for it, so a
VM with nobody at the console sleeps rather than spins. The console’s size is asked of its server
at each call, and from the first read one thread keeps a resize out there and hands each change
to the VM (console_resized), whose idle returns for it. There is no wall clock, so
system_time_us is None.
./shell --fake runs the shell on it.
- The size is still the VM’s own call. It is a typed call, which no hub carries, so a console that stops answering a size query stops the VM.
- Randomness is the kernel’s. On the fake kernel it is seeded from the host for a person’s run, and fixed for a test’s, so a test repeats. On the machine it is the kernel’s own.
beamlet on Redoubt
Status: built · partly tested: programs are not built · tested: bench:session-tcp-echo, bench:session-tcp-loopback, host:beamlet-redoubt::a_connect_is_clone_ctl_and_data_and_the_peers_end_is_closed, host:beamlet-redoubt::active_mode_delivers_what_arrives_and_the_end, host:beamlet-redoubt::refusals_are_named_and_a_failed_socket_is_given_back, host:beamlet-redoubt::a_listener_accepts_through_ctl, host:beamlet-redoubt::a_read_the_server_times_out_is_asked_again, host:beamlet-redoubt::without_a_net_the_in_vm_loopback_still_serves, bench:beamlet-natives, bench:beamlet-reduction-rate, bench:beamlet-schedulers-mttcg, bench:beamlet-schedulers-one-hart, bench:shell-compile-module, bench:shell-compile, host:beamlet-vm::two_schedulers_lose_nothing_across_the_system_lock, host:beamlet-vm::schedulers_go_offline_and_online_again, host:beamlet-redoubt::two_schedulers_lose_nothing_across_the_system_lock, host:beamlet-redoubt::four_schedulers_lose_nothing_either, host:beamlet-redoubt::without_the_argument_the_vm_has_one_scheduler, host:beamlet-redoubt::the_count_is_one_decimal_argument_capped_and_one_otherwise, host:beamlet-redoubt::the_io_report_counts_the_schedulers, host:beamlet-redoubt::without_a_console_writes_are_dropped_reads_end_and_the_size_is_unknown, host:beamlet-redoubt::a_vm_without_a_console_runs_its_module_to_exit, bench:beamlet-boot, bench:beamlet-console, bench:beamlet-files, bench:boot-profile, bench:boot-profile-unverified, bench:pack-outside-module, bench:pack-bad-truncated, bench:pack-bad-wrong-length, bench:pack-bad-wrong-name, bench:beamlet-heap-flood, bench:beamlet-budget-flood, bench:userland-boot, bench:userland-bad-start, bench:lease-bad-start, bench:userland-read-only, bench:verity-flipped-tree, bench:verity-wrong-root, host:beamlet-redoubt::a_module_is_its_file_and_a_failed_read_is_refused, host:beamlet-redoubt::not_found_at_the_open_is_absent_and_every_other_error_is_refused_by_name, host:beamlet-redoubt::verified_module_lookup_propagates_found_absent_and_refused, host:beamlet-redoubt::verified_application_lookup_propagates_found_absent_and_refused, host:beamlet-redoubt::a_packed_module_comes_from_the_pack_and_any_other_from_the_volume, host:beamlet-redoubt::a_pack_with_a_bad_entry_is_refused_whole, host:testbench::the_boot_pack_is_deterministic_sorted_and_only_of_the_objects, host:beamlet-vm::the_search_is_flat_in_the_pattern_count, host:beamlet-vm::the_matcher_finds_what_every_pattern_at_every_byte_finds
On Redoubt, beamlet is a native program. Its built Platform adapter uses the client library
(native programs) for the console, files, verified code lookup
and the system natives, and the kernel’s calls for the clock and randomness. The program
adapter remains planned.
| Method | On Redoubt |
|---|---|
monotonic_us, idle | the kernel’s time_now (microseconds since boot); idle is a receive with a timeout (timer) |
system_time_us | None until wall-clock time and time sync exist, in M6 (persist, install, share); the VM then counts system time from the Unix epoch at boot, so the logger and anything else that stamps a time works and a date says 1970. A check that a date has begun (a certificate’s notBefore) then fails, a check only that one has not passed (a token’s expiry) passes, and times from two boots cannot be ordered |
console_write, console_read | the client library’s console: writes and reads on the /dev/cons connection; a read with nothing to read is parked by the server, so input arrives as a completion and Eof means the connection ended (consoled). /dev/cons is optional: a lease’s namespace has none (agents), and the platform then serves the console as one that has gone: a write is dropped, a read answers Eof at once, console_size is None and nothing resizes, so an agent’s output has nowhere to go but its files |
console_size | a fresh consol size call on every query, never cached; a server that does not serve it, or a consoled its manifest does not size, refuses the call and the answer is None |
console_resized | one thread, started by the first console read, keeps a consol resize call out at the console’s server and calls again for each change; it stores the newest size and wakes the VM, whose idle returns for it. On sshd’s channels a change of the window’s size arrives so, until the channel ends: sshd answers a waiting call then and refuses the next, and the thread ends; consoled refuses the call, since a multiplexed session’s completion call fills a connection’s one parked call there, and its UART never resizes anyway: the thread ends, and the console never changes size |
prompt_drawn | nothing, but in a boot-stats build the line beamlet: first prompt drawn [t=N], the end of the span the boot profile times |
random | the kernel’s random call |
load_module, load_app | takes the requested file from the boot pack, if the pack holds it (below); otherwise reads the requested file (Elixir.Enum.beam, elixir.app) whole from the root of the verified userland volume, through its erofsd (erofsd:system), which reads it through its verityd; a reader of the volume trusts that erofsd and verityd (R76 (verified volumes)) in place of checking each object itself. A name erofsd answers not_found to at the open is Absent; any other refusal at the open or on the read is Refused, with one console diagnostic naming the file and the error’s name and no other source tried; the bytes read whole are Found (R75 (verified userland)). From M6 (persist, install, share), the principal’s profile joins the lookup (packages), never the session’s writable namespace. This decides which module a name finds, not what code may run |
files | 9P on the namespace’s connections through the VM’s hub: walk, open, create, read, write, stat, remove and clunk, and littlefsd’s rename (files) |
programs | the client library’s launch: native programs in carved budgets (native programs); planned, as ports. Launching is the launch/1 native’s |
system | the namespace’s files and named handles, typed calls on a pool of threads, served endpoints on a thread each, budgets from the named handle budget, the kernel’s label stamp, and launch (natives) |
TCP is Plan 9’s /net, served by ipd (ipd): gen_tcp
works unchanged over a backend in Erlang, beamlet_tcp, embedded in the VM
(vm/lib/beamlet_tcp.erl), and the Rust side only
moves bytes. When the namespace holds a /net, a socket is a process that reads
/net/tcp/clone for its number, writes connect(addr, port) to its ctl in the wire encoding
(the generated codec, wire), reads ctl until the state is
established, and moves bytes through data: each a file operation through the VM’s hub like
any other (files), a reader process per socket for what arrives,
writes from the socket process. Framing, packet modes and the active-mode mailbox stay in
Erlang; the hub’s etimedout on a wait is asked again, and 0 bytes is the peer’s end
(tcp_closed, or {error, closed} to a recv). There is no backpressure: the reader drains
the socket as bytes arrive and a passive socket’s unread bytes accumulate in the socket process,
bounded only by the VM’s budget. close is ctl close, and a socket process
that dies has its socket aborted by its reader, so nothing of ipd’s outlives it.
- Every socket is
ipd’s then, loopback addresses included. The loopback inside the VM is not consulted: a connect to127.0.0.1or to the box’s own address isipd’s refusal (R59 (never the box’s own addresses)), and a refusal keeps the nameipdgave it, by the error table’s last column (wire):eaccesfor the box’s own addresses, for a labelled caller (R60 (a sink refuses labels)) and for an address or port outside the scope; a connect the stack could not complete (nobody answering, a reset) reads back as the closed state,econnrefused.session-tcp-loopbackconnects to the box’s ownsshdat its loopback and its listed address and the capture shows no SYN to either;session-tcp-echoechoes through the bench’s peer in each packet mode. - A session’s sockets are its share of
ipd’s admission (sizing): aStateunit each, as its/netconnection is one, within its account’s bucket divided among the account’s sessions, and a socket closed by the session keeps its unit through TCP’s TIME-WAIT (10 s). Past the share a connect is{error, emfile},ipd’stoo_many. The image’sipdgives alice’s account 32 sockets, 4 + 32 = 36 units, so her session with her console session beside it has 36 / 3 = 12, its connection and 11 sockets: eleven connections opened and closed in quick succession all succeed, and the twelfth while they linger isemfile(session-tcp-churn). A principal without an override has the defaults, 12 / 3 = 4 beside a console session, so 3 sockets, and a client that opens and closes connections in quick succession reuses one instead, assession-tcp-echodoes. - Addresses are IPv4 tuples. A name is
{error, nxdomain}until the resolver (name-scoped connections); an IPv6 tuple iseafnosupport. listenisipd’s listen rule (scopes): a scope without one, as a session’s in the image, answers{error, eacces}; one with it listens, at most 8 in the backlog, andacceptis a read of the listener’sctlthatipdholds until a connection arrives, the new socket’sremoteits peer name.- Without a
/net(a lease, a VM on the fake kernel) a loopback network inside the one VM stands in:listenregisters a port,connectto a loopback address finds it, and any other address is{error, enetunreach}, since the VM has no network (agents). One VM is one trust domain, so the stand-in adds no authority: it reaches only the VM’s own processes. - Not inet.
socknameof a socket ofipd’s is{error, enotsup}:ipddoes not tell a socket its own address and port, and nothing is invented; a listener’s is{0,0,0,0}and its port.
The waits fit together because a server parks a request strictly shorter than its clients wait
for it: ipd holds a data or ctl read 8 s against the hub’s 10 s
(ipd, files), so a read the hub
gives up has already been answered timeout, the client asks again, and bytes arriving between
wait in the socket’s buffer for the next read; the host test
a_read_the_server_times_out_is_asked_again runs the backend against a /net that keeps the
rule and feeds the bytes after the timeout.
The size is asked afresh because the only console
whose size changes is an SSH channel, and a cached size would answer a redraw with the size from
before the change; a caller that wants to be told of a change uses the parked resize call
(the shell).
On the machine, beamlet is the program beamlet, started like any other with a console, a
budget, and a connection to the userland disk’s erofsd, a named handle its argument
endpoint= names (erofsd:system). Its waiter threads are the runtime’s thread::spawn.
schedulers=Ngives the VM N scheduler threads, at most 8; absent or malformed, one. The count is the image’s choice: the steward gives a session’s VM two (the steward). The kernel does not tell a program how many harts there are, so the VM cannot follow the machine; a call that says so is a later package’s, if an image ever needs one. The first scheduler is beamlet’s own thread, and each other is a thread of the runtime’sthread::scopewith an 18-page stack, the size of a session VM’s first: twice the largest first-thread peak the memory cases find, the idle shell’s 36,136 bytes (beamlet-footprint) over a compile’s 34,280 (beamlet-compile-footprint), now that the compressorterm_to_binary(T, [compressed])runs is a few hundred bytes and not a 65 KiB frame (what the VM holds at its prompt); a module compiled at the prompt over SSH runs (shell-compile-module). A helper thread’s stack sits above an unmapped guard page (the native runtime): an overflow there faults at once and the kernel ends the VM at the overflow, not later from a heap it wrote over (thread-stack-guard). The runtime paints every spawned stack, so the scan measures them: at the idle prompt the second scheduler peaks between 21,496 and 25,200 bytes on rv64 over three runs and at 21,752 on rv32, the platform’s three other threads under 1,600 (beamlet-footprint,stack beamlet thread N). A compile runs the compressor’s frames on whichever scheduler holds the process;beamlet-compile-footprintruns one scheduler so that is the painted first thread, whose 34,280 bytes above are the figure every scheduler’s 18 pages are sized from; no case compiles on a helper, so a helper’s peak under a compile is not measured. Several schedulers below.bind=PREFIX=HANDLEputs a named handle beamlet was handed at a prefix of its namespace (bind=/home/alice=walfsd:data): thebind/2a session performs for itself (namespaces), for a VMinitlaunches alone, whose namespace holds only/dev/cons. In the steward’s sessions the session’s namespace does this. It creates no authority: the handle was handed already, and a bind naming a handle it was not handed, or a prefix that is not a clean absolute path, is refused before any server is asked anything, with one line on its console.report_iohas the platform say, when the VM ends, how many requests went through the hub and how many threads it ran (beamlet: io: N requests through the hub; threads: S schedulers, W waiters,1 schedulerfor one); bench:beamlet-files reads it.
In a boot-stats build (checked builds) beamlet says beamlet: first console read [t=N] at the VM’s first console read, with time_now in µs, and beamlet: first prompt drawn [t=N] when the shell’s driver has drawn its first prompt
(beamlet:prompt_drawn/0): the driver takes the console before it draws its banner and prompt,
so the second line is the boot’s time to its prompt, where a user sees the box ready, and the span
the target is on; both lines are drawn beside the prompt. boot-profile and
boot-profile-unverified measure it.
Measured in that build under icount (shift=3, sleep on) with seed 1, in guest time
(bench:boot-profile, bench:boot-profile-unverified):
| system volume | rv64 verified | rv64 unverified | rv32 verified | rv32 unverified |
|---|---|---|---|---|
littlefs (littlefsd, retired for this volume) | 1,016.7 s | 534.7 s | 1,044.2 s | 558.0 s |
EROFS (erofsd) | 16.0 s | 12.5 s | 15.7 s | 12.1 s |
| EROFS, with the boot pack | 12.4 s | 9.9 s | 12.0 s | 9.6 s |
| EROFS, with the boot pack, the shell the steward’s console session, to the driver’s first read | 15.4 s | 13.2 s | 17.6 s | 15.3 s |
| the same, 16-page lends for the public entry’s push and the session’s stream, to the first prompt drawn | 14.7 s | 12.2 s | 16.3 s | 13.7 s |
| the same, the shell’s commands loaded when first called, a boot pack of 78 entries | 13.1 s | 10.9 s | 14.7 s | 12.4 s |
On littlefs 99 % of the boot was in the VM’s 96 lookups: littlefsd found each file’s name in the
volume’s root directory again two or three times for every 9P operation, 77,710 block reads of 673
distinct blocks (littlefsd). On EROFS the same 96 loads
make the same 652 9P operations, and erofsd reads the volume 641 times (112 inodes, 243
directory blocks, 286 runs of a file’s blocks) in 642 calls to blkd of 3.35 MB in whole sectors;
the loads take 7.1 s verified and 3.7 s unverified, and the VM’s own work and its console about
6.5 s either way.
With the boot pack (below) the 96 lookups become one read of the pack, 115 9P reads of 16 KiB:
the boot makes 208 9P operations, and erofsd reads the volume 208 times (16 inodes, 35 directory
blocks, 157 runs of a file’s blocks) in 209 calls to blkd of 2.46 MB. (That build counted each
call erofsd made to blkd, and the whole sectors it carried; erofsd’s boot-stats now
counts each read it asks of its range, whatever calls the range client splits it into, and the
bytes it asks for.) Reading the pack takes 3.6 s verified and 1.2 s unverified, and the VM’s work
after it, decoding the modules as they are called, 8.0 s; the verified prompt is the same across
seeds 1 to 5. On littlefs the prompt with the boot pack is at 152 s verified, since littlefsd
finds the file again for each read.
Since the steward starts the shell as the console principal’s session
(the steward), the verified first prompt on
rv64 comes at 14.7 s: init has started its servers by 0.6 s, and pushes beamlet’s 4 MB public
entry to bootfsd until 1.9 s, when it starts the steward; from 1.9 to 5.8 s the steward carves
the session’s budget, streams its image from bootfsd into the new process in the client
library’s launch batches of 64 pages (native programs), and the VM
reads its boot pack; from 5.8 to 11.8 s the shell’s application starts, until its driver takes
the console; from 11.8 to 14.7 s the shell starts under the driver, to its banner and first
prompt. Unverified the same points are at 1.8, 3.6, 9.5 and 12.2 s. On rv32 the steward starts
at 2.3 s, the boot pack is read by 6.6 s, the driver reads at 13.2 s and the prompt comes at
16.3 s; unverified at 2.2, 4.4, 10.9 and 13.7 s. The push and the stream are calls of the same
cost whatever they carry, about 2.5 ms each in this build: before init and the steward lent 16
pages a call (one page of a public entry per add, a 9P read of 8 KiB), they were 1,024 and about
512 calls, and the steward started at 3.1 s and the shell’s driver read at 13.7 s on rv64 (4.0
and 15.7 s on rv32). The add carries 32 KiB, a power of two, so bootfsd’s entry doubles onto
4 MiB and its heap’s peak stays half its cap. Since the shell’s commands are loaded when first
called, its start loads fewer modules: the driver reads at 11.3 s and the first prompt comes at
13.1 s on rv64 (10.9 s unverified), and at 12.7 and 14.7 s on rv32 (12.4 s unverified).
The boot-time target: in this build, the prompt within 20 s of guest time, verified and
unverified, on both widths: the slowest measured prompt plus a tenth, rounded up to 5 s.
boot-profile and boot-profile-unverified fail past it, and run in every whole run of the bench.
How fast compiled code runs. In the console session of the image’s boot, under icount
(shift=3, so a microsecond is 125 guest instructions) with seed 1,
Enum.reduce(1..65536, 0, &max/2), compiled Elixir calling a native through a fun on every
step, takes 98,723 reductions; its fastest of 30 runs a second apart, a run the shell’s driver
did not share the hart with, gives the rate (bench:beamlet-reduction-rate):
| rv64 | rv32 | |
|---|---|---|
| reductions a second | 57,529 | 52,150 |
| before the kernel’s slice end picked and switched where the trap is: reductions a second | 55,674 | 49,928 |
| before the kernel’s slice end was cut (below): reductions a second | 52,936 | 46,959 |
| then: guest instructions a reduction | 2,374 | 2,657 |
| then: guest instructions a step of the loop | 3,577 | 4,002 |
| before the interpreter’s changes below: reductions a second | 34,041 | 31,215 |
| before: guest instructions a step of the loop | 5,531 | 6,033 |
A native call no longer fills all 255 argument slots it could pass (765 instructions on rv64 for
a native of two arguments), a fun’s arguments stay in the x registers instead of going through a
vector, and the interpreter’s step is inlined into its loop, so the registers it saves are saved
once a time slice rather than once an instruction. On the host, beamlet runs a tail-recursive
loop and lists:foldl/3 at 70-200 ns a step. Of what is left on rv64, about four fifths is the
interpreter’s own work, some 200 guest instructions a BEAM instruction, and the rest the
kernel’s, most of it the slice’s end: about 8,800 kernel instructions on rv64 and 12,900 on rv32
each time the VM’s slice ends, against 15,000 and 20,700 before the kernel stopped re-arming the
timer three times a preemption and decoding a whole budget to move one word
(scheduling). No yield and no count changed: a
process still yields after its 2,000 reductions or 200,000 instructions, and the kernel’s time slice still preempts the VM’s
thread whatever it runs. The floor: 42,000 reductions a second, the slower width’s rate less
a tenth, rounded down to a thousand; beamlet-reduction-rate fails below it, and the rate before
the interpreter’s changes is below it on both widths. A session where a line has just been typed runs slower
than this for a while: the shell’s driver measures the line being edited again for each piece of
input it echoes, which can take as much of the hart as the line’s own code for seconds after a
long line; the floor’s run is the fastest of its 30 for that reason.
Several schedulers. The VM’s schedulers share one run queue, under the lock on the VM’s
shared state, and take its processes in turn (vm.rs). A
process runs on one scheduler at a time, its heap, stack and collections its own, and the lock is
held between time slices and by the instructions and natives that touch what is shared: the atom
table, the loaded modules and loading them, ETS, persistent_term, registered names, timers, and
the mailbox of a process another scheduler runs, which takes its messages when its slice ends.
Loaded code and the literal chunks are read without it: each scheduler keeps the calls it has
resolved, checked against counters bumped when code changes. The platform, and the hub in it, is
locked after it, never before, and only a scheduler with nothing running anywhere waits in the
platform’s idle. A process still yields after its 2,000 reductions or 200,000 instructions, on
whichever scheduler runs it, and keeps that scheduler while nothing else waits: another is woken
only for more work than the schedulers ending a slice take back themselves. The kernel’s time
slice preempts each scheduler’s thread. On Redoubt the lock is the runtime’s mutex and the wait
for work its condvar, which sleep on endpoints (native programs).
Their endpoints are made when the VM starts, five pages with two schedulers: the system’s lock,
the platform’s, the wait for work’s own, and one for each scheduler that waits; so schedulers
contending allocate nothing, and a VM at its budget’s limit does not end at its first contended
lock. If the kernel refuses one, the VM runs on one scheduler. A resource’s lock (an atomics
array, a zlib stream, ETS’s count of what its objects hold) makes its endpoint, a page, the
first time two schedulers contend for it, and gives it back with the resource; one the kernel
refuses leaves that lock’s waiters polling.
The schedulers are the session process’s threads, in its one budget: the kernel charges each
hart’s runner to that budget’s one pass (scheduling),
so the session’s share among budgets is its weight’s as before, and on a machine whose other harts
are idle it runs on as many as it has schedulers busy. Nothing new is carved, and the heap, ETS and
persistent_term limits are a sixteenth of the budget each as before; the second scheduler’s
stack and thread page and the locks’ endpoints are 25 of the share’s pages
(above).
In the console session of the image’s boot, two Task.async loops of
Enum.reduce(1..65536, 0, &max/2), awaited, timed 10 times with one scheduler online and 10 with
two, alternated; the fastest of each (bench:beamlet-schedulers-mttcg, and
bench:beamlet-schedulers-one-hart at 1 hart):
| rv64 | rv32 | |
|---|---|---|
| 2 harts, one scheduler online | 417,514 µs | 487,274 µs |
| 2 harts, two schedulers | 234,728 µs | 257,310 µs |
| the ratio, one over two | 1.77 | 1.89 |
| 1 hart, one scheduler online, in guest time | 3,459,590 µs | 3,790,698 µs |
| 1 hart, two schedulers, in guest time | 3,601,614 µs | 3,999,102 µs |
| the ratio at 1 hart | 0.96 | 0.94 |
Across runs alone the 2-hart ratio measured 1.77 to 2.14 on rv64 (7 runs) and 1.83 to 1.94 on rv32 (5). The 2-hart times are host times: the case runs
without icount, in multi-threaded TCG, where the harts are host threads that run at once.
Under icount QEMU runs the harts in turn on one host thread and guest time counts every hart’s
instructions, so a second hart cannot make a run faster in guest time
(which cases run in guest time). Both times come from one boot,
so the ratio holds beside a steady load but not beside a stalled vCPU thread, and is a verdict
only alone. At 1 hart nothing runs at once, and the cost of two schedulers is the instructions
their switching takes: the kernel’s slice ends alternate the two threads where one scheduler
keeps the hart. So the one-hart case runs under icount (shift=3, seed 1), where the ratio is
a count of instructions, the same on any host however loaded: seven boots, two beside a
twelve-core load, read 0.94 on rv32 and 0.95 or 0.96 on rv64; across boots the one-scheduler
side’s fastest runs lie within 100 µs of each other and the two-scheduler side’s in two clusters
2.3 ms (rv64) and 2.8 ms (rv32) apart, within 0.1 % of a 3.6 or 4.0 s run, and a boot’s ten pairs
lie within 1.7 % of each other. On the host’s clock the same case read 0.81 to 0.95 across six
boots alone on rv32 (0.96 twice on rv64): a boot’s ten host times of either side spread by a
tenth, and the fastest of each side met by chance. Sampled beside those runs, the vCPU thread
moved among the four cores of q‘s quiet set 13 to 28 times a run, and those cores’ clocks
read anywhere from 0.8 to 4.7 GHz; pinned to one host core the case read 0.93 on rv32 and 0.96
on rv64, and a side’s ten times still spread by 3 to 9 %. The floor: a ratio of 1.40 at 2 harts, a fifth under the lowest measured
alone, since the train’s bench runs the case beside other work, where it once measured 1.53; a VM
whose schedulers ran one at a time would measure about 1.0. And 0.90 at 1 hart: two schedulers
sharing one hart keep nine tenths of one’s throughput, four points under the slower width’s 0.94
in guest time, where a boot’s pairs vary by less than two. The case does not measure what the kernel
charges for the two threads: by the kernel’s rule each hart’s run is charged to the session’s one
budget (scheduling), which the scheduling cases
judge, not this one. With two schedulers at 1 hart, beamlet-reduction-rate measures 52,936 (rv64) and
46,959 (rv32) reductions a second, the table’s rates within a fifth of a percent. The rate is
judged at the 1 hart its floor was set at, under --smp N too: under icount a second hart’s
instructions count in guest time, so at 2 harts main measured 45,354 (rv64) and 37,157 (rv32),
under the floor on rv32, as with two schedulers (45,778 and 36,967). Before a yielding
process kept its scheduler it changed threads every slice, a kernel wake-up each, and measured
43,511 on rv64.
Residual: loading a module holds the lock, so while one scheduler loads, no other starts or ends
a time slice. To the shell’s first prompt, 116 loads hold it 8.2 s of guest time on rv64 (8.9 s
on rv32), the longest unicode_util’s, 877 ms (969 ms). A typed call the platform makes in place
(the console’s size, littlefsd’s rename) holds the platform’s lock. The runtime’s heap is one
spin lock (native programs): two schedulers allocating
at once take turns there, and one waiting for a holder the kernel preempted spins. A native is
still not preempted, but holds only its own scheduler.
Before its VM starts, beamlet reads the volume’s boot pack, boot.pack, whole: one open, one stat
for its length, one allocation of that length charged to the VM, and reads of 16 KiB in order, the
lend a module’s file is read through (pack.rs). The
image’s builder makes the boot pack from the modules and resources the shell loads to its prompt,
which image/userland.toml names, their bytes copied from the files beside it: a magic and a
version word, the entry count, an index of each entry’s name, offset and length sorted by name, then
the entries’ bytes back to back. The same files and names give the same pack, byte for byte. beamlet
checks the boot pack whole before using any of it: the magic and the version, each name a module’s
or a resource’s file name in strictly ascending order, the entries’ bytes back to back from the
index’s end to the file’s, and each module’s first atom, its own name, the one its entry names. A
lookup takes a name the boot pack holds from it, a copy of its bytes that the loader decodes and
drops as it decodes a file’s, and asks the volume for any other name, as above, so the order is the
boot pack, then the volume’s file, then the code path. Once every entry has been taken, at the
prompt, the boot pack’s allocation goes; a module it holds that is never called keeps it, at most
the pack’s size. A boot pack the volume does not hold is said once on the console and the lookups go
to the files; one that cannot be read, or does not check, is said once, naming the boot pack and
why, and beamlet ends as below. The boot pack is per VM and read-only: no sharing of its pages
between VMs, no byte cache in the steward, no decoded snapshot, no pack per profile. It is a file of
the verified volume (R75 (verified userland)), never
written at run time.
If the volume does not attach, because erofsd serves it as corrupt, the boot pack cannot be read
or does not check, or the module it is told to start cannot load, what it does is decided by its
console. With one (a session’s VM, or one init starts), it says why there and waits without
exiting: a tampered disk must not become a restart loop that reboots the machine
(bench:userland-bad-start, bench:pack-bad-truncated). Without one, a lease’s VM
(agents), waiting would be silent, a budget held to its deadline with nothing
said: it exits, and its exit code, the one thing the steward’s lease … exited line carries
(the steward), is the diagnostic, 1 in a default build and the
end’s own code in the test-only exit-kinds build (bench:lease-bad-start), a halt’s the status
the process gave (an agent whose input could not be read, bench:lease-writes). That exit is an exit,
not a fault: crash blame is init’s, for a server it restarted whose exit notice names a faulting
thread’s current call (init), and a lease’s VM is the
steward’s launch, so its exits count toward no lockout
(R40 (blame by label set)): a sponsor’s retry of a lease that cannot start
costs its own budget and nothing else
(host:redoubt-steward::a_leases_exit_is_its_end_and_no_blame). A read refused by the
volume’s admission for the moment is tried again before it counts as refused
(the client library): a VM loading beside its account’s other VMs
rides out the moment.
Each label set that runs beamlet reads its own attachment of the userland image through its own
blkd, verityd and erofsd, all carrying that set
(R34 (confined placement)).
The timer’s counter frequency is not needed: time_now’s microseconds serve the clock and
idle’s deadlines (bench:beamlet-console).
Natives
Status: built · partly tested: the machine's cases run the VM under a tester in the steward's place, but for budgets and launching, which also run in the steward's own sessions over SSH, a vault session's among them · tested (40)
- bench:steward-vault-launch
- bench:beamlet-natives
- bench:beamlet-serve
- bench:beamlet-launch
- bench:beamlet-natives-attack
- host:beamlet-vm::a_lookup_gives_the_prefixs_connection_and_the_rest_and_refuses_by_name
- host:beamlet-vm::a_bind_names_a_connection_and_refuses_anything_else
- host:beamlet-vm::the_table_lists_path_name_and_handle
- host:beamlet-vm::a_calls_reply_arrives_as_a_message_with_its_handles_as_resources
- host:beamlet-vm::a_send_is_one_way
- host:beamlet-vm::budgets_are_carved_read_and_destroyed
- host:beamlet-vm::labels_are_fixed
- host:beamlet-vm::the_identity_is_the_platforms_and_without_a_system_is_not_supported
- host:beamlet-vm::a_launch_takes_everything_from_its_caller_and_its_end_arrives_as_a_message
- host:beamlet-vm::a_launch_that_serves_gives_its_caller_the_send_right
- host:beamlet-vm::requests_arrive_with_badge_account_and_labels_and_are_answered
- host:beamlet-vm::a_decoded_handle_grants_nothing
- host:beamlet-vm::arguments_of_the_wrong_type_are_badarg_and_reach_no_platform_call
- host:beamlet-vm::lists_past_their_caps_are_refused
- host:beamlet-vm::a_handle_of_the_wrong_kind_is_refused_by_name
- host:beamlet-vm::a_dropped_handle_is_closed_when_its_holder_is_collected
- host:beamlet-vm::without_system_calls_every_native_is_not_supported
- host:beamlet-redoubt::the_labels_are_the_kernels_stamp_on_the_vms_own_send
- host:beamlet-redoubt::a_lookup_gives_the_longest_prefix_and_the_rest_and_refuses_by_name
- host:beamlet-redoubt::a_bind_is_the_files_namespace_and_one_connection
- host:beamlet-redoubt::a_budget_is_no_connection_and_no_endpoint
- host:beamlet-redoubt::a_bind_to_a_server_that_never_answers_is_refused_within_its_bound
- host:beamlet-redoubt::binds_past_the_cap_are_refused_and_a_bound_prefix_is_still_replaced
- host:beamlet-redoubt::a_copy_within_one_volume_is_the_servers
- host:beamlet-redoubt::a_sessions_identity_is_read_from_its_arguments
- host:beamlet-redoubt::the_platform_tells_a_session_its_identity
- host:beamlet-redoubt::a_typed_call_goes_out_on_a_pool_thread_and_its_reply_is_an_event
- host:beamlet-redoubt::requests_arrive_with_badge_account_and_labels_and_an_answer_reaches_the_caller
- host:beamlet-redoubt::a_request_never_answered_is_answered_by_the_serve_thread_at_its_deadline
- host:beamlet-redoubt::a_request_already_waiting_ends_the_idle_that_takes_it
- host:beamlet-redoubt::an_endpoint_served_stays_open_when_its_term_is_dropped
- host:beamlet-redoubt::a_launch_takes_what_it_is_given_and_its_end_is_an_event
- host:beamlet-redoubt::a_launch_that_serves_gives_the_child_the_receive_right_and_its_caller_a_send_right
- host:beamlet-redoubt::a_launch_past_its_jobs_is_refused_and_a_serves_endpoint_with_it
- host:beamlet-redoubt::a_labelled_sessions_child_takes_its_labels_and_runs
A native is one of three kinds, and no other:
- OTP’s own, reimplemented in Rust where BEAM has C (
crypto,re,zlib, the file and buffer primitives): the API is OTP’s, so the differential test holds each to the real BEAM (what runs on it). - The system’s: what has no POSIX equivalent, the small fixed set below.
- A primitive the interpreter is too slow for, and no more than the primitive: the screen buffer (screen natives).
Anything else is Elixir. Every native’s own code holds no unsafe; it bounds the work one call
does, since a native is not preempted by reductions; it reaches no panic from any argument; and it
is attacked with hostile arguments, fuzzed where it parses. OTP’s own natives parse what OTP’s do
(certificates, compressed streams, patterns), held to BEAM by the differential test; a native of
the other two kinds never parses a large untrusted format, which goes to a native program in a
budget of its own instead (native programs).
What has no POSIX equivalent reaches Elixir through a small fixed set of beamlet natives, and every server binding is pure Elixir over them:
| Native | Shape |
|---|---|
ns_lookup/1, bind/2, ns/0 | the namespace table: the longest matching prefix and the rest of the path |
copy_file/2 | a copy the file server makes itself, within one server |
call/3 | submit a call; the reply arrives as a message to the calling Erlang process |
send/2 | one-way |
serve/1, reply/2 | serve an endpoint: requests arrive as messages carrying badge, account and labels |
budget_create/1, budget_destroy/1, budget_usage/1 | carve and end budgets; a deadline makes one a lease |
labels/0 | this VM’s label set, fixed when its budget was made |
identity/0 | what the steward told a session’s VM of itself: its principal, its labels’ names, its context, where its home is bound |
launch/1 | launching a native program: the image, budget, namespace, handles and arguments come from the Elixir caller, and the client library’s launch makes the calls and writes the startup block; a server is given a new endpoint to serve, and its caller the send right |
They are the module redoubt’s, over the platform’s System
(userland/otp/vm/src/platform.rs; the argument checks in
bif/system.rs, the calls in
userland/otp/redoubt/src/system.rs). A platform
with no System, the host CLI’s, answers each {error, not_supported}; the host’s
fake-redoubt runs Redoubt’s, on the fake kernel. Each native, as Erlang sees it:
ns_lookup(Path)is{ok, Handle, Rest}: the connection the longest matching prefix of a clean absolute path names and the rest below it, or, for a name with no/, the named handle the VM was started with (keyd,budget, a session’snotices) and<<>>. A path that is not clean (..,//) isbad_name, one nothing is bound atnot_found.bind(Prefix, Connection)puts a connection at a clean absolute prefix of the files’ own namespace: a path under an existing prefix shadows it for longer matches only. A handle that is not a 9P connection is attached once, then and there, each of the attach’s two calls waiting at mostATTACH_USon the VM’s thread; one whose server does not answer or does not speak 9P, or a budget, isnot_a_connection. A bind at a prefix already bound replaces it; the table holds at mostMAX_BINDINGS(64) entries, those the VM started with included, and a new prefix past them issystem_limit, so a loop of binds fails visibly and never on the VM’s heap.ns()is[{Path, Name | nil, Handle}], the namespace’s entries in binding order, then the named handles.copy_file(From, To)is{ok, Bytes}: the file server copies the fileFromto the new fileToitself, by its typedcopy_file, so no byte crosses into the VM. Both paths resolve asFile’s do, against the working directory; on two connections it is{error, exdev}, and the caller copies through the VM; onto a name that is there,eexist. It is a typed call, which no hub carries, made on the VM’s thread as a rename is, so the VM waits while the server copies. A platform whose servers do not copy, the host CLI’s, answersenotsup.call(Handle, {Words, Buffer, Handles}, TimeoutMs)is{ok, Ref}, and the reply arrives as{reply, Ref, {ok, {Words, Buffer, Handles}} | {error, Name}}. A buffer is a binary for a message that lends one andnilfor an inline one; the reply’s buffer is the bytes its word 1 says at the front of the lend. No hub carries a typed call, and no scheduler waits for one: a pool ofCALL_THREADSthreads makes them,MAX_QUEUEDmore wait, and past that a call isbusy; each is bounded by its timeout, at mostMAX_CALL_MS, a ceiling on what a caller may ask and not any call’s default: the generated clients pass 5 s for a short call and the ceiling itself for the one call measured past it, a lease’s launch (wire), and a timeout above the ceiling isbadargbefore any call is made. A VM that asks for the whole of it holds one of its own call threads that long, a cost inside its own trust domain. The caller waits inreceive, as for any message.send(Handle, {Words, Buffer, Handles})is one way, waiting at mostSEND_TIMEOUT_USfor its receiver. The natives encode nothing of a protocol: the generated clients do (wire).serve(Endpoint)serves a receive right the VM holds, on a thread of its own, which keeps the endpoint open and serves it for the VM’s life (at mostMAX_SERVEDendpoints; one served already, by the same handle, isalready_served; a second handle to the same receive right is not told apart): each call is admitted by the serving library’s admission, per (account, label set) with a share per badge (R26 (admission fairness)), and parked with the library’s deadline,REQUEST_WAIT_US(R28 (parked-call accounting)); it reaches the serving process as{request, Request, Badge, Account, Labels, {Words, Buffer, Handles}},Requestnilfor a one-way send; each request or send the thread takes goes into the VM’s forwards queue and the thread is back at its receive at once, the VM taking the queue at its next poll or woken if it idles (the forwards queue).reply(Request, {Words, Buffer, Handles})answers it once. A request the Erlang side never answers is ended by that thread at its deadline and its admission given back: the VM holds nothing for it. These are calls, not 9P, so the multiplexed connections’ rule (R77 (multiplexed requests)) is not this one’s.budget_create(#{pages, processes, weight, labels, account, deadline})carves a child from the VM’s own budget, the named handlebudget(no_budgetwithout one);labels,accountanddeadlinemay be left out, and a deadline, in the clock’s microseconds, makes it a lease (deadlines). Labels left out are the VM’s own, the setlabels()reads: a session isuser-class, and the kernel gives auser-class caller’s child exactly its parent’s labels, so in a labelled session that default is what lets it carve, and launch, at all. Labels given go to the kernel as they are: fewer than the session’s arelabel_denied, moreclass_denied(labels on budgets).budget_destroy(Budget)destroys it and everything in it;budget_usage(Budget)is{ok, #{pages => {Limit, Used}, processes => {Limit, Used}, weight => {Limit, Carved}}}. A handle of another kind iswrong_objectbefore the kernel is asked.labels()is the VM’s label set, read at its start off the first call thread’s first wake-up, which the kernel stamps with the sender’s labels as it stamps every message: the kernel’s word, not a launcher’s, and fixed.identity()is{ok, #{principal => Name, labels => [{Name, Id}], context => Name | nil, home => Path | nil}}: what the steward told a session’s VM of itself in its arguments (principal=,label=NAME:ID,context=,home=/PATH; sessions), read once at the start and fixed. It is the launcher’s word, information and not authority:labels()is the kernel’s, and what the session can reach is its handles. A VM started with noprincipal=, as one underinit, gets{error, not_found}.launch(#{image, budget, namespace, handles, args, stack_pages, heap_pages, serve})is{ok, Job}, and the job’s end arrives as{exit, Job, Cause, Code}(exited,faultedorkilled): the client library’slaunchwith the image the caller read, a budget it carved, its namespace entries and named handles (at mostMAX_START_HANDLEStogether,serve’s counted), and arguments; the platform adds the loader stub, which it carries asinitdoes, the job’s own exit endpoint (native programs), and the budget’s pages, which it recorded when it carved the budget, into the child’s startup block (init): one source, nothing a caller types. A receive right the VM serves among them goes to the program as a send right with a badge of the launch’s own, in a budget the VM carved (below). Withserve => Namethe program is a server: the platform makes an endpoint, gives the child its receive right as the named handleName, keeps none of it once the child has started, and answers{ok, Job, Connection}, the caller’s send right with the server’s own badge (below every minted one), whichbind/2attaches like any other; this is how a session starts the server of its pipes (native programs). At mostMAX_JOBSrun at once, each watched by a thread, started the first time one is needed, that waits for its exit notice; a launch past them istoo_many, before anything is made; a job no thread takes to watch is killed, its budget destroyed, since nothing would hear its end.
Every refusal is a Redoubt name: a term of the wrong type is badarg, as for any native, and a
well-formed request the platform refuses is {error, Name}, the kernel’s error by its name in the
ABI (wrong_object, label_denied), a 9P error by the one table’s
(wire), or the platform’s own (not_found, not_a_connection,
no_budget, busy). What waits on the VM’s thread is bounded and named: a send/2, at most
SEND_TIMEOUT_US; a bind of a handle not yet a connection, its attach’s two calls at most
ATTACH_US each; a hand-off to a thread of the platform’s, at most HAND_US; and the kernel’s
own calls for budgets and launching, which answer at once. Each native bounds its work: no list is
walked past its cap, and no binary is copied past its own:
| Bound | Value | Where |
|---|---|---|
MAX_MESSAGE_HANDLES, MAX_LABELS | 4 handles a message, 16 labels a spec, and a message’s 4 words | bif/system.rs |
MAX_START_HANDLES, MAX_ARGS | 128 namespace entries and named handles together, 128 arguments | bif/system.rs |
MAX_BUFFER, MAX_IMAGE, MAX_NAME | 64 KiB a message’s buffer, 8 MiB an image, 4 KiB a path, name or argument | bif/system.rs |
MAX_CALL_MS | 14 s, the longest a call may ask to wait: the system’s longest call, a lease’s launch under load, twice the worst start_agent the steward served under the bench’s rounds on both widths (6.64 s, rv32 at two harts; 5.851 s at one), rounded up to a second; the Elixir client’s launch_timeout/0 is the same number (the steward) | bif/system.rs |
CALL_THREADS, MAX_QUEUED | 2 calls out at once, 64 waiting | pool.rs |
SEND_TIMEOUT_US, ATTACH_US, HAND_US | 1 ms a send waits for its receiver, 1 s each of a bind’s attach calls, 1 s a thread of the platform’s to take its work (one that does not has ended, and is set aside) | system.rs |
MAX_SERVED, REQUEST_WAIT_US | 2 endpoints served, 5 s a request waits for its answer | serve.rs |
FILE_WAIT_US | 10 s a file operation waits for each request’s answer, for a share, or for a waiter’s place | files.rs |
MAX_JOBS | 16 jobs running at once, a thread of 4 pages each once one is needed | jobs.rs |
Handles are resource terms: unforgeable, collected, and never serialisable. A copy of a handle
inside the VM is the same connection (one badge, one client), so passing one to another Erlang
process is sharing it. A handle no process holds is closed at the next collection; a budget’s is
closed and the budget lives on, since destruction is a call. A handle dies with the object it names
or the budget that stamped it (objects), and the kernel gives
its index to the next handle the VM gets, so the VM marks a handle gone when it destroys the
budget it came through, and a collection closes nothing at a gone handle’s index. That covers the
budget’s own handle, and every handle the server launched with serve in that budget minted and
returned to calls on it, since its mints are stamped with that budget (a pipeline’s stage
connections, which piped mints). The VM cannot tell a handle such a server minted from one it
forwards, so a forwarded handle is marked gone too and is never closed: it stays in the VM’s table
until the VM ends, the price of never closing a reused index. The VM carves only from its own
budget, so no budget it carved is below another. What is the VM’s own goes the other way: the
send right a launch with serve gives it is to an endpoint the VM made, charged to the VM’s
budget, so the server’s death leaves the slot to the VM, and it is closed when the right’s last
term goes. A handle the VM started with is known to the namespace for the VM’s life; one a bind
made known lives with the budget it came through (the budget a launch with serve ran in, or
the one the connection a call brought it on came through), so destroying that budget forgets it,
unbinds its prefixes and ends its hub connection, and a session’s piped, bound at every
pipeline, leaves no entry and no handle behind (each once stayed for the session’s life). The
hub’s session on the forgotten connection is ended as the budget is destroyed, so a request on it
after (a stale file’s clunk at its term’s collection) is refused rather than sent: once the
handle’s last term is collected its index may be the next launch’s send right, and a request
sent on it then would reach that server, inside the session’s own table. Tested:
a_destroyed_budgets_index_is_not_closed_when_its_term_is_collected,
what_a_served_server_returned_goes_with_its_budget_and_is_not_closed_again,
a_served_servers_binding_and_handle_go_with_its_budget and
a_forgotten_connections_requests_reach_no_later_holder_of_its_index on the host, against
a fake kernel that frees those indices as the kernel does, and pipe-eight and pipe-sixty-four
on the machine. A request’s handles come with no budget: the kernel tells a receiver the sender’s
badge, account and labels, never its budget, and a carved budget carries the session’s account and
labels. So a receive right the VM serves, given in a launch‘s handles or namespace to a
program in a budget the VM carved, goes to the program as a send right with a badge of that
launch’s own (LAUNCH_BADGE on, the next for each launch on that endpoint), minted from the
receive right and stamped with the budget, never the receive right itself. What the program sends
through it is stamped with its budget at best, as its own mints are, so the handles a request with
that badge brings die with the budget and go under its Life: a program that sends a handle it
minted and is then destroyed leaves a term whose collection closes nothing at the index the kernel
has given on, be it a sibling job’s budget or a connection of the session’s. A handle such a
program forwarded rather than minted is kept, never closed, as a server’s is. The badge dies with
the budget, so no request carries it after; one on its way to the VM as the budget went is known
by its badge’s range, and its handles kept. A badge in that range the VM did not mint (a holder of
the receive right outside the VM mints what it likes) is taken for such a launch’s, so what it
brings is kept: a leak within the session’s table, bounded as a forwarded handle’s is, each
such handle staying in the table until the VM ends, and never a close. Tested:
what_a_launched_program_sends_to_a_served_endpoint_goes_with_its_budget_and_is_not_closed_again
on the host and pipe-stage-handle on the machine, where a stage given the session’s served
endpoint sends it the receive right of an endpoint it made, its budget is destroyed, and the
budget carved next is still held once the term is collected. The residual: a handle revoked
outside the VM, such as a grant whose stamp the steward destroys while the VM lives, is not known
gone, and its index may be reused before its term is collected; collecting it then closes the
handle that took the index, inside the session’s own table. A generation in the handle word is to
guard that, in work of its own. A handle cannot reach another VM in a
message: the term format writes a resource as a plain reference, with no state behind it, so a
decoded copy grants nothing, and a handle crosses between processes only in a kernel call that
names it. Delegation is always new_connection, a typed call on a connection, not a native
(sessions). Tested: a_decoded_handle_grants_nothing,
a_dropped_handle_is_closed_when_its_holder_is_collected,
arguments_of_the_wrong_type_are_badarg_and_reach_no_platform_call,
lists_past_their_caps_are_refused and a_handle_of_the_wrong_kind_is_refused_by_name on the host,
and beamlet-natives-attack on the machine, where a VM offers every native a handle another VM
wrote out, which it reads back as a plain reference, and asks the kernel for a child budget that
adds a label (class_denied: a user-class caller may not); a VM in a labelled session runs
nothing there, since the console carries no labels and its write-open of /dev/cons is refused
(consoled). Over SSH a vault session’s channel carries its labels, and
there steward-vault-launch carves with labels left out, launches a program the vault’s channel
shows, and has fewer and more labels refused, in the steward’s own session
(sshd). The cases’ tester carves each session as the steward does
(steward): the principal’s account, a
sub-budget per label set, the session from it; what it hands beyond a session’s own is said in
each case.
Residuals:
- A request the Erlang side never answers is ended at its deadline with
malformed(status 1): no status every protocol shares means a timeout, so its caller cannot tell the two apart. - A typed call whose caller exits is not cancelled: it holds its pool thread until its reply or its timeout, and the reply, with any handles it brought, is dropped and closed.
- A forgotten connection’s waiter calls on the server’s handle by its index until the server’s death fails the call. Between two of its calls, the handle’s last term could be collected and the index given to the next launch’s send right, so that one completion call reaches that server, inside the session’s own table. Holding the handle until the waiter has returned would close that window; it stays, stated.
- The six waiter places are a hard bound: a seventh live server a session uses at once is refused
emfileafterFILE_WAIT_US(10 s), and a session that needs seven servers at once has no way but to let one go first. The wait is the operation’s, parked off the VM’s thread: an ended server whose waiter has not handed its end over yet holds its place only until it does, and the fresh request is served then (once, with a fixed second on the VM’s thread, one pipeline in sixty-four was refused on rv32 beside ten bench jobs).
The namespace and launching natives are the client library’s ns and launch, so policy stays
in Elixir and encoding in Rust. The file server’s operations that name fids (Redoubt.File’s
rename, copy_file and attributes) go through the library’s littlefsd beside the file natives,
since the fids are the platform’s, not Elixir’s (native programs).
The Elixir modules over them are of two layers. A server’s typed calls are generated from its
wire table, one function per message (wire), so the
binding cannot drift from the server. Above the natives and the generated calls, a thin
hand-written module gives what is idiomatic and adds no authority: Redoubt.Namespace,
Redoubt.Budget and Redoubt.Process over the natives, Redoubt.Keys over keyd’s calls
(userland/shell/lib/redoubt).
The Elixir modules live on the userland disk, one file per module, on a volume whose root the
signed manifest pins (R75); only what the VM needs
before it can read the disk is embedded in it: its own console server, code and kernel modules
(beamlet_io, beamlet_code, beamlet_kernel, beamlet_port, beamlet_tcp) and its
stand-ins for application, gen_tcp and ram_file
(userland/otp/vm/src/vm.rs, EMBEDDED). The application
stand-in runs no kernel application; when the first application with a callback module starts,
it starts the kernel’s servers that other code calls, as BEAM starts them at boot:
erl_signal_server, global_name_server, and kernel_safe_sup, which OTP’s group waits for
before it serves a line. With no C library to ask a character’s width, prim_tty:wcwidth/1
answers {error, enotsup} and OTP measures with its own table, which agrees with BEAM’s libc on
ASCII and wide East Asian characters and parts from it on a combining mark: libc gives it no
column, the table one.
Screen natives
Status: built · partly tested: host only, but for `frame` and `draw`, which a native program's screen reads and draws its frames with on the machine (bench:nscr-interrupt, bench:nscr-beyond-cells); no boot draws a screen program's buffer · tested (30)
- host:beamlet-screen::a_buffer_is_at_most_1024_a_side_and_65536_cells
- host:beamlet-screen::the_first_frame_clears_and_sends_what_is_not_blank
- host:beamlet-screen::a_frame_sends_only_what_changed
- host:beamlet-screen::a_resize_is_blank_and_its_frame_clears_and_sends_it_all
- host:beamlet-screen::put_reads_no_more_graphemes_than_the_row_has_cells
- host:beamlet-screen::a_wide_grapheme_takes_two_cells_and_the_edge_cutting_one_leaves_a_space
- host:beamlet-screen::overwriting_half_of_a_wide_grapheme_leaves_a_space_in_the_other
- host:beamlet-screen::a_control_character_is_refused_and_nothing_of_the_call_is_written
- host:beamlet-screen::widths_are_otps
- host:beamlet-screen::a_control_character_is_badarg_and_nothing_is_drawn
- host:beamlet-screen::only_the_process_that_made_a_buffer_draws_into_it
- host:beamlet-screen::a_process_holds_at_most_four_buffers
- host:beamlet-screen::a_buffer_nothing_holds_is_given_back
- host:beamlet-screen::a_large_buffer_ends_its_owner_at_its_heap_limit
- host:beamlet-screen::a_buffer_resized_past_the_heap_limit_ends_its_owner
- host:beamlet-vm::sized_resources_past_a_processs_own_heap_limit_end_it
- host:beamlet-screen::every_vector_is_answered_as_the_decoder_answers_it
- host:beamlet-screen::a_frame_read_in_puts_what_the_reference_puts_whatever_its_bytes
- host:beamlet-screen::a_frame_of_another_size_touches_nothing
- host:beamlet-screen::a_joined_symbol_stops_the_reading_and_it_goes_on_only_from_where_it_stopped
- host:beamlet-screen::a_refused_frame_keeps_the_cells_before_the_refusal_and_a_wide_one_at_the_edge_is_refused
- host:beamlet-screen::a_clearing_frame_blanks_the_buffer_first
- host:beamlet-screen::the_positions_given_are_a_frames_own_and_a_resumed_frame_neither_puts_again_nor_forgets_them
- host:beamlet-screen::a_programs_frame_is_read_in_stopping_at_a_joined_symbol_and_refused_as_the_decoder_refuses
- host:beamlet-screen::a_buffer_is_drawn_as_the_shells_encoder_draws_its_frame_control_characters_made_visible
- host:beamlet-screen::no_byte_drawn_from_a_buffer_starts_a_sequence_but_the_encoders_own
- host:beamlet-screen::a_draw_beside_a_writer_on_another_thread_sees_whole_calls_and_loses_no_change
- host:beamlet-screen::another_process_draws_a_buffer_as_escape_sequences_once_and_may_not_draw_into_it
- bench:nscr-interrupt
- bench:nscr-beyond-cells
The screen buffer is beamlet-screen
(userland/otp/screen), a crate of the VM’s with no
unsafe and no dependency but the VM and the cells crate. Its natives, in the Erlang module
redoubt_screen, are primitives over cells; widgets, layout and focus are Elixir
(the shell).
| Native | What it does |
|---|---|
new(W, H) | a buffer: a resource, every cell blank, owned by the calling Erlang process |
resize(B, W, H) | a new size, blank; the next diff clears the screen and sends it all |
put(B, X, Y, Graphemes, Style) | writes Graphemes, a list of binaries, along the row from (X, Y), one a cell and a wide one two, clipped at the edge (a wide grapheme cut by it becomes a space, and a grapheme longer than a cell’s symbol may be, U+FFFD); returns the columns written |
fill(B, Rect, Symbol, Style) | one symbol, one code point of one column, and one style over a rectangle, clipped |
plot(B, Rect, Dots, Style) | a bitmap, one byte a cell holding its 2×4 dots in Braille’s own order, drawn as U+2800 plus the byte |
diff(B) | the cells changed since the last diff, as a cells frame, which then becomes what is shown |
draw(B) | the cells changed since the last diff or draw, as the escape sequences that draw them, which then become what is shown: the bytes the shell’s Redoubt.Term.Frame.draw/1 draws for diff’s frame. Any process holding B may call it; it writes no cell |
frame(B, Bytes, From) | reads a native program’s cells frame into the buffer from byte From (0 for its header), a cell at a time as the one decoder reads it (cells::reduce): ok; {size, W, H} for a frame of another size, nothing read; {joined, Next, X, Y, Symbol, Style} for a symbol joining code points, not put, for the caller to check is one grapheme, put, and go on from Next; {error, Why}, the decoder’s reason or cell (a wide symbol in the last column). A frame that clears blanks the buffer first |
A Style is {Fg, Bg, Modifiers}, each colour reset, {indexed, I} or {rgb, R, G, B} and the
modifiers the cell protocol’s bits; a Rect is {X, Y, W, H}.
- A control character is refused, not drawn.
putandfillraisebadargon one (the ASCII and 8-bit controls, DEL, the bidirectional embedding, override and isolate controls and the invisible format characters, a zero-width joiner inside an emoji sequence apart: thecellscrate’s own rule,userland/native/cells/forbidden.txt) and write nothing of that call, so a caller that forgot to make text visible fails loudly, and nothing reaches the encoder that the cell protocol would not carry. - Bounded. A buffer is at most 1024 cells on a side and 65,536 cells in all (larger is
system_limit);putreads no more of its graphemes than the row has cells; no native does more than one pass over a buffer;framereads a frame in place, allocating only its record of the positions given, a bit a cell of the buffer, made afresh for each frame, and charges by the bytes it read. So every call has a ceiling on its time and its allocation, and it is charged in reductions by the cells it touched. - Counted. A process holds at most four buffers (a fifth is
system_limit; one nothing holds any more is given back), counted in its process dictionary, and each buffer declares the bytes of its two grids and offrame’s record of positions, which count as its holder’s own memory, toward its own heap limit (limits), fromnewand fromresize. So a loop ofnew/2is ended by the limit that ends any runaway allocation. Code that erases the dictionary’s count starts a new one; the heap limit still holds. - One writer. A buffer answers only the Erlang process that made it; any other gets
badarg, but fordraw, which writes no cell. It sits behind a lock because a resource may move between schedulers, and because the shell’s driver draws it: a screen registers its buffer with the driver when it opens, and asks the driver to draw it between whole frames. Holding a buffer’s term gives nothing but that draw, whose bytes go nowhere but to its caller; the driver writes only the bytes it drew itself from the buffer the screen in front registered. - One segmentation and one width table, OTP’s. Text arrives split into graphemes by OTP’s own
segmentation (
String.graphemes/1), and a grapheme’s columns come from a table generated from OTP’sunicode_util:is_wide/1(tools/gen-width.escript, which fails the build’s check when the table is not current): wide if a presentation selector follows its first code point or any of its code points is wide. The shell measures with the sameis_wide/1, so what is measured is what the buffer lays out, at the pinned OTP’s Unicode version. The buffer does not segment text itself, sofilltakes a single code point, and a caller that handsputtwo characters as one grapheme draws them in one cell, misplacing what follows, and nothing more;drawthen places the next cell by the glyph’s width ascolumnsmeasures it, where the shell’sRedoubt.Term.Frame.draw/1sums its graphemes’, the one case where the two draw otherwise.frameputs a symbol of one code point, always one grapheme, itself, and hands back any other unput for the caller to segment. - The diff speaks the cell protocol, and
drawis the shell’s encoder for screens in Rust, over the buffer’s own cells: the bytesRedoubt.Term.Frame.draw/1draws for the diff’s frame, byte for byte, which the shell’s suite holds it to over every code point below U+3000 that a symbol may be, the planes’ edges, hostile text made visible, every attribute and colour form, and frames after partial changes; and this crate’s tests hold it todraw.json, frames thatFrame.draw/1draws, written and kept current by the shell’s suite, with symbols holding control characters forced in, whichdrawmakes visible again asRedoubt.Term.Textdoes (the shell). It writes only the style reset,CSI 2J,CSI row;col Hand SGR from a cell’s numeric colours and attributes. It holds the buffer’s lock across the diff and the advance of what is shown, so a draw beside its owner’s writes on another scheduler sees each call whole and loses no change, and it is charged in reductions by the cells it examined. A new buffer, and one resized, is drawn whole at its firstdraw, as at its firstdiff.
Asynchronous underneath, synchronous on top
Status: built · tested (12)
- host:beamlet-redoubt::the_console_is_one_hub_connection_with_one_waiter
- host:beamlet-redoubt::sixteen_sends_are_taken_at_once_and_arrive_at_one_poll
- host:beamlet-redoubt::a_send_to_a_served_endpoint_wakes_an_idle_vm
- host:beamlet-redoubt::a_full_forwards_queue_holds_the_next_send_until_the_vms_poll
- host:beamlet-redoubt::a_read_waits_for_typing_without_holding_the_vm
- host:beamlet-redoubt::a_vm_busy_past_the_session_bound_keeps_its_console
- host:beamlet-redoubt::two_askers_wait_at_once_and_each_gets_its_own_answer
- host:beamlet-vm::a_completion_reaches_the_process_that_asked_and_no_other
- host:beamlet-vm::a_message_does_not_end_a_wait_for_a_file
- host:beamlet-vm::a_process_killed_while_it_waits_has_its_operation_dropped
- bench:beamlet-console
- bench:beamlet-files
The VM is one trust domain on a few scheduler threads, so a blocking call would stop every Erlang
process on that thread. So the VM’s I/O is asynchronous underneath: a request is submitted and
the VM keeps running, its answer comes back later to whoever asked, and Platform::idle returns
on a timer deadline or an answer. The kernel has no queued sends (a message occupies its sender’s
thread until taken: IPC), so on Redoubt the asynchrony is the client
library’s hub (native programs) over the servers’ multiplexed
connections (the serving library),
in beamlet-redoubt (userland/otp/redoubt/src/io.rs):
- One hub per VM, owned by the platform and run by whichever scheduler holds it. A request is sent from the scheduler’s own thread, waiting at most 1 ms for its server to take it, and its buffer is the hub’s until its answer.
- One waiter thread per connection, started the first time the VM uses the connection, blocked in its completion call; it hands each batch of answers to the VM’s own endpoint with one send.
idleis areceiveon that endpoint until the next timer deadline, and the platform hands each answer to whoever asked: the console’s to its reader, a file’s to the Erlang process whose call it was. An answer, request, input or the input’s end that arrived before the VM idled returns the idle at once: thereceiveis entered only with nothing to hand over, so nothing taken waits for the next wake-up to be looked at.
Above that, Elixir is ordinary synchronous code: File.read/1 blocks the calling Erlang process,
not the scheduler. OTP’s prim_file runs unchanged: a file native whose operation the platform
has begun answers it later (Files names the asking process), the process waits, and only that
operation’s end (or an exit signal) wakes it, not a message; the native is then called again and
takes the result. Several requests of one operation (a walk, an open, the reads up to a count) go
out as each answer comes. A process that dies while it waits has its operation dropped with what
it holds, and an operation that opened a file closes it. The VM’s own code loading reads files
waiting in place. Concurrency is bounded by each server’s shares of requests and pages per
connection (R77 (multiplexed requests)); over
them, a request is answered busy and asked again RETRY_US (10 ms) later, a console write and
a file operation’s request alike; the answers are counted, and said with the I/O report.
Threads. A process has at most 255 threads (processes). The VM’s are
its schedulers (schedulers=N, two in a session) and its waiters, one per connection it uses, at most 6:
a session’s bindings (bootfsd, the home volume, a labelled volume, ipd, the console, the system
volume), a bind being one of them again. A server that ends takes its session with it: its waiter
hands the end over and returns, closing the handle it woke the VM through, and the connection
gives its place up when the next one needs it. A session’s piped is a new server at every
pipeline, so a session runs any number of pipelines, each taking one place while it runs
(servers_bound_and_ended_one_after_another_never_run_out_of_waiters; pipe-eight, eight in one
session). An ended server’s waiter hands its end over only once its completion call has failed
with the server’s death and the VM has received the hand-over. The VM waits for it when it
forgets the connection, as the server’s budget is destroyed, up to HAND_US (1 s); an operation
that finds every place held parks, off the VM’s thread, until an ended server’s waiter hands one
over, or fails emfile at FILE_WAIT_US (10 s), the VM’s own bound and not a device’s error
(a_fresh_connection_at_the_bound_waits_for_an_ended_waiters_hand_over: the connect returns
Later at once and the operation completes when the hand-over comes;
a_fresh_connection_past_the_bound_is_refused_emfile_and_the_live_ones_still_serve: every place
held by a live server, the live ones still served). Once, before the VM waited for a hand-over at
all, six ended pipeds whose waiters had not yet handed over refused a session’s seventh pipeline
its first request as eio, and once a wait of 1 s on the VM’s thread was outlasted by a hand-over
under load and refused a fourteenth (pipe-sixty-four on rv32 beside other work): the wait is
now the operation’s, not the VM’s, and ten times longer. No call holds a thread while it waits: a console read,
a file read and a read on a /net connection’s data file are each one request on the hub, so
what a person or a peer takes to answer costs nothing but the request. Every 9P server in the
image serves multiplexed sessions, so no call needs a thread of its own. What the scheduler’s
thread still waits for itself: typed calls, which no hub carries (the console’s size and
littlefsd’s rename), and the system volume’s module lookups, since loading code is
synchronous in the VM; a server that stops answering one of them stops the VM. A scheduler away
from its endpoint that long, past a server’s session bound, keeps its sessions: each waiter holds
what arrives meanwhile and keeps calling (a busy caller). The
serving natives and a job’s exit notice are launching’s (planned).
The forwards queue
A served endpoint’s thread never waits on the VM. Each call or send it takes is queued, with a
copy of its lent bytes, in the VM’s forwards queue (serve.rs, Forwards), and the thread is
back at its receive at once; the VM takes the whole queue at its next poll, the poll that takes
the hub’s completions, each entry reaching its serving process as a request. A VM in its idle
receive is woken by one send through the thread’s badge, carrying nothing: the VM says it is
about to idle under the queue’s own lock, before its last look at the queue, so a thread that
queues after that look sees the VM idle and wakes it, and one that queued before is found by the
look; the wake waits at most WAKE_US (1 ms) at a time and only while the VM still says it
idles, so a VM that woke on its own meanwhile is left to its poll. The queue is bounded:
MAX_FORWARDS = MAX_SERVED (2) x (the calls one endpoint’s admission parks, 8 buckets of 4 =
32, each forwarded once, + SENDS = 32 one-way sends) = 128. The sends are what the steward can
send a session before its VM polls: one lease_ended per live lease of the sponsor’s (sixteen
under the latency manifest), one server_restarted per slot of its binding table,
approval_waiting, with room over it (the steward). A thread
that finds the queue full waits for the VM’s next poll before it takes its endpoint’s next
message, as every forward once did
(a_full_forwards_queue_holds_the_next_send_until_the_vms_poll): the residual, and the one way a
sender’s bounded send to a served endpoint still expires with the serve thread alive.
The queue’s worst hold is 128 entries of a lend’s MAX_BYTES each, 65,280 bytes, so 2,040 pages
of the session VM’s heap (a cap of 11,220 pages holding the scanned peak twice, no forward in
that scan), copied as the thread takes the message; a copy the heap refuses refuses the
forward, as the lend’s own buffer once did, never a failed allocation in the VM.
Once the thread forwarded each message by a send to the VM’s endpoint that waited for the VM
to take it, and a VM busy in Erlang took one per scheduler turn (its zero-wait poll each 2,000
reductions), so the thread could not take its endpoint’s next message until then: a burst of
the steward’s lease_ended sends to one session was taken a turn each, 40 to 300 ms apart, the
last of six returning 1.5 to 1.8 s after the deadline and of sixteen 2.1 to 2.8 s, and one send
was lost at the steward’s 100 ms bound on rv64 at one hart. With the queue each send returns as
soon as the thread takes it: when the queue landed, 37 to 68 ms after the previous at one hart
on rv64, 42 to 86 on rv32 and 73 to 131 at two harts, the steward’s own work per exit between
them, the last of six 1.1 to 1.3 s after its deadline and of sixteen 1.8 to 2.5 s, none lost
over five boots; since the steward sends a burst’s notices before any exit’s effects the
figures are its page’s (steward.md, “Failure and restart”)
(steward-deadline-burst, steward-deadline-burst-16). On a block trace of the six-lease burst
the thread’s one wake to the idle VM is taken in the thread’s own kernel entry and no send of
the VM’s process blocks on its wake endpoint for the notices after it, where before each forward
blocked there 107 ms median until the VM’s next turn. A reply to an idle VM is unchanged (a file
read from an idle shell 1.0 to 1.3 s on the checked, traced kernel under icount, as before;
beside a spinning process 1.4 to 1.6 s, the read’s own VM work at half the reductions). The VM’s
poll cadence is unchanged: a busy VM still takes the queue once a turn, which under the checked
kernel’s audits (about 60 % of a hart in the burst) is 100 to 140 ms at a session’s quarter
share, a bench condition (a release kernel has no audits); a waiter’s hand-over of a completion
to a busy VM still waits for that turn, one send a batch, 10 to 135 ms on the same trace. What
the sponsor sees is the shell’s print of each notice, which its listener makes inline through
the relay at 250 to 370 ms each, so the last [lease ended] line of six is drawn 2.3 to 2.6 s
after the deadline and of sixteen 6.1 to 6.6 s: the shell’s, not the hub’s
(the steward).
Residual: each connection costs its waiter thread, since a thread waits on one endpoint at a time; one thread waiting on several at once would be kernel work (IPC).
Why
An interpreter, not a JIT or a port of BEAM. BEAM is C and a JIT emits machine code, and Redoubt has neither C nor writable-and-executable pages anywhere (R11 (memory)). An interpreter in safe Rust is slower (up to about five times, measured on the host) and keeps the whole VM inside the language’s memory-safety argument. Speed is not a goal; auditability is.
One boundary. Every service the VM can reach is a method of Platform, with a default of
nothing. A reader checks what a VM can do by reading one trait and one embedder, and an embedder
that grants less gets a VM that can do less, with no code in between to trust.
One pinned compiler. Accepting exactly one OTP version lets the loader refuse everything else instead of carrying compatibility code for old formats, which is where parsers go wrong.
Natives are few, and of three kinds. A native is code every Erlang process in the session can reach, the ones handling hostile data among them, and it is not preempted. So what may be one is closed: OTP’s own natives, which the differential test holds to BEAM; the system’s, a fixed set with Elixir above it; and a primitive an interpreter cannot do fast enough, kept to the primitive. Nothing else is.
Its dependencies are userland’s. beamlet’s own crates hold no unsafe; the crates it links
(RustCrypto, regex-automata, miniz_oxide, num-bigint) do, for speed and for the platform.
That is the latitude an application has and privileged code does not
(the tenets): the VM is
per-principal code, and a bug in it reaches that principal’s own capabilities, which the kernel
contains (trust tiers). What is shared with the servers is
built from the same vendored bytes, and the rest is vendored too, one version of each
(vendored dependencies); only what builds or tests on
the host comes from crates.io, pinned by the lockfile.
One 9P client, not a method per service. Files, TCP and the console are all 9P on Redoubt, so one generic client in Rust covers them, and the framing (packet modes, line mode) stays in Erlang where OTP already has it. Adding a service means serving a tree, not growing the trait.
The shell
The shell is Redoubt.Shell, a read-eval-print loop of Redoubt’s own over Elixir, in the
session’s beamlet VM. A line at the prompt is Elixir and nothing else. The everyday work is done
by commands (cat, ls, cp, grep), which are Elixir functions, each declared once with its
types and its help and imported at the prompt. Native programs, joined by pipes, run in budgets of
their own when a stage must. Line editing, history, completion, help, the pager and full-screen
programs, the editor and file manager among them, are Elixir in the session, drawing into a screen
buffer beamlet holds natively. Everything the session draws reaches /dev/cons through one
encoder, the only writer of control sequences, so hostile text can never drive the person’s
terminal (hostile text).
Purpose
A person who logs in has to be able to work: look at files, change them, run programs, stop what runs away, and find out how. On a box with no Unix, Elixir is the natural shell language (the Nerves project uses IEx the same way on devices), and a better scripting language than any shell language. What a bare Elixir prompt lacks is the short surface a shell gives, launching and piping programs, job control, screens, and line editing on a console nobody echoes for. This page describes that surface.
The scope is three things done well: the command line, an editor with a file manager, and the agent (agents). Everything else keeps to plain lines on the terminal, and the terminal is the only front end there is.
How to use it
The prompt shows the current directory and the line’s number. The same work in a script and at
the prompt is the same Elixir; the commands are imported at the prompt and are plain functions
anywhere else (Redoubt.Util.cat/1):
/home/alice (1)> cat("app.log") |> grep("error") |> count()
42
/home/alice (2)> cat("config.txt") |> sub("staging", "prod") |> w("config.txt")
:ok
/home/alice (3)> cp "notes.txt", "notes.bak"
:ok
/home/alice (4)> ls_r("logs") |> Enum.filter(&(stat(&1).size > 1_000_000))
["logs/big.log"]
/home/alice (5)> pipe(~w(zcat big.gz | sort | uniq)) |> w("uniq.txt")
:ok
/home/alice (6)> help(:cp)
A call needs no parentheses when it is the whole line (cp "a", "b"); inside a pipeline it does,
as Elixir says. Stop a runaway job with Ctrl+C. See what the session is using with top(). Edit a
file with ed("notes.txt"); browse and copy files in two panes with fm("project").
What it can and cannot do
The shell in a session
Status: built · partly tested: the steward starts it as the console principal’s session on the UART and as each SSH login’s session; launching a program is tested in the steward’s sessions over SSH, a vault session and a plain one, and on the UART under a tester in the steward’s place and in the console principal’s session, and a session’s files wait for its namespace to reach the VM; the start in the home runs in a plain and a vault session over SSH, and the session with no home on the host only · tested: bench:userland-boot, bench:steward-ssh-two-principals, bench:steward-vault-launch, bench:beamlet-launch, bench:pipe-hostile-output, bench:shell-commands, bench:launch-system, host:beamlet-redoubt::a_launch_takes_what_it_is_given_and_its_end_is_an_event
Every session starts Redoubt.Shell over the session’s console connection, /dev/cons
(consoled on the UART, sshd for an SSH channel).
In M1 (sessions over SSH, kept apart) the shell is what the milestone’s sessions need and no more:
the console, reading and writing files through OTP’s File, and launching a native program
through the launch natives (beamlet): exec("name", args) runs /boot/name
in a budget carved from the session’s, as a pipeline of one stage: it reads the lines typed, until
Ctrl+D on an empty line, and what it writes is drawn as the line’s own output; it holds no console
of its own. exec waits for it to end, and returns how it ended and what its budget held
(native programs). ns(), ns_lookup/1 and bind/2 are the
session’s namespace (sessions). The shell’s modules come from the userland
disk, checked against the signed bundle
(R75 (verified userland)).
Everything the prompt evaluates runs with the session’s authority, in the session’s VM. There is nothing the shell can do that the session’s handles do not allow, and nothing a command adds to them.
A session starts in its home. The VM starts at /, the namespace’s root, which holds only
the bound prefixes and nothing writable; before its first prompt the shell moves the working
directory to the home the steward told the session of (home=/PATH, the path its namespace
binds the principal’s home at, sessions), so a bare name
at the prompt, ed("notes.txt") or File.write("notes.txt", text), is a file in the home, and
the prompt shows where it is: /home/alice (1)> . The console’s session, an SSH session and a
vault session start the same way, a vault session’s home being its principal’s as well (what it
may write there is the label’s business, vault sessions). A
session told no home, or whose home cannot be entered, says so once, ** no home to start in (:enoent); this session starts in /, and starts at /; a vault session told no home in a
confined boot says instead ** no home in this label set: a push is the way in; this session starts in / (the steward). The start is
information acted on, not
authority: the shell can only move where the session’s handles already reach. A VM that is no
session, as on a host, starts where it is and says nothing.
The userland disk’s modules carry no Docs chunk, so h/1 says no documentation is available;
an optional documentation package h/1 reads when present is not built.
The loop
Status: built · partly tested: runs on the host only, on beamlet and on the BEAM; its tests are the shell’s own ExUnit suite, which ./test-shell runs and no bench case does
Redoubt.Shell is its own loop, not IEx’s (userland/shell):
- Read a line from the group leader with
IO.gets/1; a line that ends too soon to parse (an opendo, a trailing|>) reads another. - Parse it with
Code.string_to_quoted/2. - Evaluate it in a fresh Erlang process, with a killing heap limit, through
Code.eval_quoted_with_env/4. The shell holds the bindings and the environment between lines, so an evaluation that dies loses only its own line. The compiler’s diagnostics are taken withCode.with_diagnostics/2and printed by the shell, not written by the compiler. Afntyped at the prompt is interpreted, and on the machine each call of it costs the evaluator’s constant, about 3.6 ms, whatever its bindings hold. It once cost 0.18 µs more for every byte of a binary among them, so a loop written at the prompt over a 256 KiB binary took 50 ms a step where the compiled walk takes 0.14 ms: the evaluator compares each binding with itself on every call, and the VM read the whole binary to answer. The VM now answers that a term is equal to itself without reading it (beamlet), and the step no longer grows with the binary. - Print the value through
Redoubt.Term.Text, which draws every control character in it visibly (^[,^G,<U+009B>,<U+202E>); a%Lines{}is printed a line at a time.
A line that is not UTF-8, or that the tokenizer cannot read, is that line’s error, and the next is read; the tokenizer’s warnings, like the compiler’s, are printed through the same guard.
It is built on Elixir’s public API only, and uses none of IEx’s modules.
What the loop does not have:
- A file run at start. IEx runs
.iex.exsfrom the current directory when it starts, with the session’s authority, so a file planted in a shared directory would run in the session of anyone who started there. The shell reads and runs nothing at start but itself. - A second syntax. A bare command name is an undefined variable, as Elixir says; the shell
adds that it is a command to call as
pwd().exitalone on a line is the one word the loop reads itself, and ends the shell asexit()does. - Pry, remote shells, or a break menu.
What a line writes to the console itself, as IO.puts/1 does, reaches the console through the
shell’s driver and its encoder too (line editing), so it is guarded
the same, and so is what the VM logs, the crash reports of processes a line spawned among it
(hostile text).
The machine mode
Status: built · partly tested: on the host, on beamlet and on the BEAM, in the shell’s own ExUnit suite (test/redoubt/shell/driver_test.exs: entering and leaving, a one-line and a multi-line block, a block that raises, one that ends too soon, one that calls exit(), one interrupted, a result line beginning with the token, a block reading its shell’s mailbox, a block forging the mode’s messages without the key, a mark after output that left the line open, a line past the bound, a refused token, a screen refused and a long value printed whole); on the machine one session over SSH enters the mode, sends a three-line block and a call, reads an error’s mark and leaves · tested: bench:shell-machine
A program driving a session over its terminal (an agent’s loop, M3 (agents, approvals and the attack suite))
needs to send a block of Elixir and know exactly where its result ends and whether it failed.
machine(token), typed at the prompt, enters a mode made for it, and machine(nil) or the end
of the session leaves it. The token is the caller’s, 8 to 64 characters of [A-Za-z0-9_-];
anything else is a usage error and the prompt stays. In the mode:
- Input is lines, as sent. The driver takes the bytes itself, past
groupandedlin: no echo, no completion, no history, no redraw, no prompt. Bytes up to a newline are a line, a carriage return before it dropped, every other byte kept (a tab, a backspace:group’s echo-off reader would edit them); a line longer than 1 MiB is dropped whole, and the block it was part of is not evaluated and is markederror, saying so. A line that is exactly the token ends an input block, parsed and evaluated whole, so a program sends a multi-line definition without the loop’s “reads another line” rule deciding where it ends; a block that ends before its expression does is that block’s syntax error, not an unfinished line. The driver holds the lines, and the shell asks for one at a time, between blocks only: lines a program sends ahead wait in the driver, not in the shell’s mailbox. Ctrl+C and the session’s key keep their meaning: they end the line being evaluated, whose mark then sayserror, and with none they drop the block typed so far, the lines held with it. Ctrl+D at the start of a line is the end of the input, as it is at an empty prompt, and ends the session; a block ends only by its token line. - Every block’s end is marked. After the block’s output, one line,
<token> <n> ok,<token> <n> erroror<token> <n> exit,nthe counter the prompt would show, the status what the evaluator returned:okfor a value,errorfor a raise, a syntax error, an interrupt or a heap kill,exitforexit(),exitalone, Ctrl+D or the console’s end. An empty block is a line of the counter too, markedok. The line that enters the mode is marked under the token it set, so the program knows the mode is on and the counter; the block that leaves it is marked under the token it was sent with, and the next prompt follows the mark. The mark begins a line of its own: the driver draws it for the shell, after a newline when the block’s output left the line open, throughRedoubt.Term.Textlike everything else; the token is ASCII, which the guard leaves as it is. - Output is otherwise unchanged: values, errors and diagnostics print as at the prompt,
through the same guard, so hostile text in a result is drawn visibly and cannot forge a mark:
a forged mark is a line starting with the token, which the program chose and the evaluated
code is never given. The shell holds it in its loop’s state alone, never in the bindings, the
environment or the process dictionary of the evaluating process;
machine/1ends its line asexit()does, keeping nothing of it; the driver cuts the line that named the token fromgroup’s history when the mode opens, since any process can readgroup’s state; and while a block runs no later line, the next token line among them, is in the shell’s mailbox, which any process can read too. Nor can a block ask the driver for one: the mode’s messages (open, the next line, the mark, close) carry a key, a reference the driver mints once for the first process that asks, the shell at its start before any line runs, and keeps with that process’s pid; a message without it is dropped unanswered and not counted, so a block that learns the shell’s pid pulls no line, draws no mark and closes nothing. The token and the key are in no process a block can read: the shell’s loop state and the driver’s. What remains is the driver’s own mailbox, the console bytes it has not read yet, and a:nextof the shell’s in flight, readable as any mailbox is (beamlet): that is the residual, and a program that wants none sends the next block only after the mark. - Screens are refused. A program driving the terminal cannot answer one: the editor,
pick,fmand everyRedoubt.Screen.run/3fail with anArgumentErrornaming the mode, and the line is markederror. The pager is not entered: a long%Lines{}value prints whole, as it does on a console that does not say its size, since the lines are what the program asked for. The feed is refused too, so a native program that reads what is typed gets no input: the lines are the block’s. - The width is the terminal’s.
machine/1does not resize anything: a value is laid out at the terminal’s width as a person would see it, and a program that wants long lines sets its pty’s size. - A lease’s end is a line of its own.
[lease NAME ended], the steward’s notice that a lease of the session’s principal ended (the steward), is printed by the shell’s listener when the send arrives, through the same guard as every line and outside any block: in the mode as at the prompt it lands wherever the stream is, between a block’s output and its mark if that is when it came, and never begins with the token. So is a shared server’s restart: the listener re-binds the slot the steward’sserver_restartednames, through the session’s own connection to the steward (rebind), at the path the steward answers, and prints[walfsd:data restarted: /home/alice re-bound], or[walfsd:data restarted: slot 1 not re-bound (REASON)](the steward).
A defmodule typed at the prompt compiles and runs on the machine (shell-compile-module, a
line at a time; beamlet); the mode’s cases bind a function, none
defines a module.
Commands
Status: built · partly tested: runs on the host only; its tests are the shell’s own ExUnit suite, which ./test-shell runs and no bench case does
Every command is a commandlet: an Elixir function declared once, with defcommand, in any
module of the shell that uses Redoubt.Commandlet:
defmodule Redoubt.Shell.Helpers do
use Redoubt.Commandlet, area: "Files"
@summary "Copy a file"
@help """
Copies src to dst. Into an existing directory, the copy keeps its name.
"""
@args src: "the file to copy", dst: "where the copy goes"
@examples [{~S'cp("a.txt", "b.txt")', "copy a.txt to b.txt"}]
defcommand cp(src :: path, dst :: path) do
File.cp!(src, dst)
end
end
- Typed. A parameter is
name :: type, with an optional default:path,string,integer(with bounds),pattern(a string or a~r//),lines,boolean,one_of,flags(...)for keyword options,ref(a function or module),name, a command’s name,term(any value), andmany(t). The arguments are checked, and coerced where a type says so, before the body runs; a wrong one raises a usage error naming the parameter and showing the usage. - The help is not optional. A command without a summary, a page, a line for each parameter,
or an example that calls it does not compile. The same declaration becomes the function’s
@doc, its page inhelp, and what completion offers for each argument. - Nothing else to wire. The registry finds every module of the shell’s application that
declares commands; they are imported at the prompt and listed by
help, grouped by area. The finding is done when the shell is built, which writes an index of the commands, each a function that calls its own; the prompt imports the index, so a command’s module is loaded when the command is first called, not at the shell’s start.
A command’s arguments are Elixir values the person wrote, evaluated as Elixir evaluates them anywhere. There are no bare words for the shell to quote, so there is no quoting to get wrong and no second meaning a line can have.
Files and text
Status: built · partly tested: every command runs on the host, over files beamlet’s host platform serves, in the shell’s own ExUnit suite against a seeded tree of real files; on the machine a session over SSH runs the commonest (mkdir_p, w, cat, cp within and across volumes, mv, ls, stat, touch, cd, glob, checksum, rm_rf), not every one · tested: bench:shell-commands
Files come in through cat and go out through w; everything between takes lines and chains with
|>. Relative paths resolve against the session’s current directory.
| Command | What it does |
|---|---|
pwd(), cd(dir) | the current directory; there is no kernel working directory |
ls(path), ls_r(path), find(path, pattern), glob(pattern), stat(path) | list, walk, find by name, expand a pattern, stat |
cp(src, dst), mv(src, dst) | copy and rename; within one volume the server copies or renames, across volumes the copy passes through the session and mv is not atomic (files) |
rm(path), rm_rf(path), mkdir(path), mkdir_p(path), touch(path) | remove, make directories and files |
cat(paths) | the files’ lines, read as they are consumed |
grep, grep_v, sub, cut, sort, uniq, uniq_c, head, tail, count | lines in, lines or a number out |
w(lines, path), append(lines, path) | write or add lines to a file |
table(rows) | rows laid out in columns, with a header and a titled border if asked |
hexdump(path), checksum(path) | a file’s bytes, and its hash |
follow(path) | the lines added to a file from now on, printed as they come, until the interrupt; it looks at the file’s size twice a second, since no file server says when a file grows, prints a line not yet ended as it stands once it passes 4 KiB, and ends by itself if the file gets shorter or goes away |
catreturns%Lines{}: enumerable and lazy, socat("big.log") |> head(5)reads only the start of the file, and a consumer that stops early closes it. It checks every file when called, so a missing one fails on its own line.wwrites a new file beside the old one and renames it into place, so a failure leaves the old file whole andcat(f) |> sub(...) |> w(f)is safe.- A file’s contents and names are shown with every control character made visible, by
Redoubt.Term.Text; bytes are looked at withhexdump.
What each file operation does underneath, and why a rename across volumes cannot be atomic, is files and binds’s.
The pager
Status: built · partly tested: the host only, on beamlet alone (the BEAM has no screen buffer, and prints instead); its tests are the shell’s own ExUnit suite (test/redoubt/screen/pager_test.exs, test/redoubt/screen/pager/doc_test.exs), judged on a model of the terminal, and a pseudo-terminal test of the real binary, both of which ./test-shell runs; on the machine, only the UART’s console, which does not say its size, is tested: a bench case shows a long value printed whole there, and no case pages over SSH · tested: host:beamlet::help_longer_than_the_terminal_is_paged_and_q_gives_the_screen_back, bench:userland-read-only
The pager (Redoubt.Screen.Pager) shows a
%Lines{} that is the value at the prompt a screen at a time, when it is longer than the screen;
help’s pages and topics are drawn in it with their headings bold. It is a screen program
(full-screen programs).
- When it takes the screen. Only on the shell’s own terminal, of a size the console says, as
an SSH channel’s does: lines that fit the screen, and any lines on a console of unknown size
(the machine’s UART, whose
consoledthe image does not size: consoled) or printed elsewhere, are printed as they are.out(value)prints without it. - Keys: Space,
for Page Down a page on,bor Page Up a page back; Down,jor Enter a row on, Up orka row back;gor Home the top,Gor End the end;/, a text and Enter search forward,nandNthe next and the one before, each match drawn reversed;qor Esc leaves, and the line’s value is not printed again. A line wider than the screen goes on in further rows, a list item’s under its text. - It reads what it shows. The lines are read as far as the screen or a search needs, so
cat("big.log")reads what is looked at, and leaving the pager stops the reading and closes the file. They are read by the line’s evaluator, which opened the file, and handed to the pager a batch at a time. What the pager has read it keeps, to page back, counted toward the line’s heap limit, binaries too: paging to the end of more than that ends the line with{:screen, :killed}, and the shell goes on. - Text and style. Every line is drawn through the visible-text rule, the search text typed
too. Help’s style (bold headings, indented list items) is chosen by the shell’s own help, never
by the lines: anything made from them,
help() |> grep(...), is plain. A line typed at the prompt can make lines of help’s style too; that costs nothing, since a style only makes some of them bold and indents others, and the text still passes the visible-text rule.
Session commands
Status: built · partly tested: clear() runs on the host only, in the shell’s own ExUnit suite (test/redoubt/shell/driver_test.exs); ns, ns_lookup, bind with its refusals and its cap, whoami and labels run in sessions over SSH on the machine, a vault session’s among them, and on the host, where a VM is no session (test/redoubt/shell/session_test.exs); contexts(), detach() and end_context(name) run in sessions over SSH on the machine, and on the host only the listing’s parse (test/redoubt/contexts_test.exs) · tested: bench:shell-commands, bench:shell-contexts
| Command | What it does |
|---|---|
ns(), ns_lookup(path), bind(prefix, conn) | show the namespace; the connection a path resolves to; bind a held connection at a prefix (binds) |
whoami() | the principal, with a named context after a dot (alice.work), as the steward told the session (sessions); nil for a VM that is no session |
labels() | the session’s label set, the kernel’s, by the names the steward gave |
contexts() | the live contexts of the session’s principal in its label set, its own among them: each one’s name ("" for the default), state (:attached or :detached) and age, the seconds since it was last attached or detached (contexts); this and the two below are {:error, :no_steward} in a VM that is no session |
detach() | close the terminal and leave the context running; {:error, :unknown} in a session that is no context |
end_context(name) | end the context name of the session’s label set, its own included, as exit; {:error, :unknown} for any other name, another label set’s included |
clear() | clear the screen; the next prompt is drawn at its top |
machine(token), machine(nil) | enter the machine mode, for a program driving the session, and leave it (the machine mode) |
Not built:
now(),today(),ago(time), wall-clock time, which a session has from M6 (persist, install, share).
Native programs and pipes
Status: built · partly tested: Redoubt.Pipeline, which pipe/1 and exec run, runs in the bench under a tester in the steward’s place, and exec in the console principal’s session too; pipe/1’s own splitting and refusals are the shell’s ExUnit suite’s, which no bench case runs; Redoubt.Cmd, the explicit form, and run/2 are not built · tested: bench:pipe-carries, bench:pipe-never-reads, bench:pipe-no-authority, bench:pipe-interrupted, bench:pipe-hostile-output
A native stage is a program in a budget of its own, joined to the next by a served pipe file.
pipe(~w(grep error log.txt | wc -l)) is the short form, and its value is the lines of the last
stage’s standard output, or, if a stage did not exit 0, those lines and every stage’s ending;
cat("log.txt") |> pipe(~w(grep error)) gives the first stage lines to read. The stages’ standard
error is drawn as it comes, through the guard. bg runs one in the background and gives its job
(jobs):
job = cat("log.txt") |> bg(~w(grep error))
Job.await(job).stdout
Redoubt.Cmd, an explicit builder over the same (Cmd.new() |> Cmd.source("log.txt") |> Cmd.pipe({"grep", ["error"]}) |> Cmd.run()), is not built.
screen(name, args) runs one program as a stage that draws a screen of cells rather than writing
text (a native program’s screen).
A native stage is for what should not run with the session’s authority (an untrusted parser) or needs its own address space; everything else is Elixir. How programs are launched, how their standard streams are named and who serves a pipe are native programs’s.
Running a script. run("tool.exs", ["a", "b"]) runs the script in the session’s VM with the
session’s full authority, with System.argv/0 set: exactly as if it were typed.
run("tool.exs", isolated: true) starts a child VM through the same launch path as an agent’s
lease (agents), with capabilities the same as or narrower than the
session’s. With no grants, the child gets a budget of its own carved
from the session’s (bounded CPU and memory, ended by destroying it), a read-only view of the
current directory and no network. More is granted with options that map one to one onto the agent
harness’s grant kinds (agents): read:, write:, gateway:,
git: and launch:.
Interrupting and killing jobs
Status: built · partly tested: the bound on what a background job keeps of its output is the shell’s ExUnit suite’s (test/redoubt/jobs_test.exs keeps the job table’s own rules), not attacked on the machine; the interrupt’s path in the driver and the evaluator runs in that suite on both VMs too · tested: bench:job-interrupt-line, bench:job-interrupt-native, bench:job-kill, bench:job-interrupt-ssh
There are no signals and no per-process kill. A job is one pipeline, an exec or a pipe/1, and
it is killed by destroying its stages’ budgets
(R10 (destruction)): the shell carves one budget per native
stage from the session’s, so Job.kill(job) ends the job’s stages, everything they started, and
nothing else. Job.status(job) is :running, :exited, :faulted or :killed, read from the
exit notices (processes). A job’s budgets are carved from
the session’s, so ending it can never touch the session.
- The session’s jobs (
Redoubt.Jobs, started at the first job, never at the prompt) are each pipeline’s owner process, the one that launched its stages and gets their exit notices.jobs()lists them: number, foreground or background, state and command. Killing a job is asking its owner to destroy every stage’s budget, which it does at once; it holds no other job’s. - In the background,
bg(words)(orJob.start(stages, opts)) starts a pipeline and gives itsJobat once. It reads only the lines it is given, never what is typed, and neither the interrupt nor the line that started it ends it. What it writes, and its stages’ standard error, is kept, 64 KiB each, what comes past that counted, untilJob.await(job)takes it; until thenjobs()lists it. A job ends by itself, byJob.kill, or with the session, whose budget its budgets were carved from. The session keeps the results of 16 ended background jobs nobody has awaited: past them the earliest started is dropped, andjobs()counts the jobs dropped. There is nofgorbgto move a running job, and no suspending: there are no process groups and no stop state.Redoubt.Cmd, the explicit builder, is not built (native programs and pipes).
The interrupt is Ctrl+C or the session’s own key, Ctrl+\ (0x1C), which no full-screen program can take and the driver never forwards:
- The interrupt while a line is evaluated ends the line: the driver draws
^Cand tells the shell, which kills the line’s Erlang process with an untrappable exit (:kill), and the processes linked to it go with it. Every pipeline the line runs in the foreground watches it, so their stages’ budgets are destroyed; the shell waits for that, at most 2 s, saying so in a line if it is still under way, before the next prompt. A screen’s process is sent the exit:interruptfirst, so its line tells the interrupt from a failure. The session and its VM survive, and the shell keeps the bindings of every line before the interrupted one; a binding the interrupted line made is gone with it. - The interrupt at an idle prompt clears the line, drawing
^Cafter it whichever key it was. There is no break menu and no job-control menu: both are OTP’suser_drv, which the shell’s driver replaces (line editing). A session ends only byexitor Ctrl+D. - With a full-screen program in front, Ctrl+\ ends it. So does Ctrl+C, unless the program declared that it takes Ctrl+C as a key (a screen’s life): then Ctrl+C reaches it and Ctrl+\ is the only interrupt.
- Over SSH,
sshdturns the channel’ssignalrequest (INT) andbreakrequest into the session’s own key, the 0x1C byte, so they interrupt whatever is in front. It is a protocol message, not a Unix signal; nothing inside Redoubt has signals (sshd).
What a job cannot do:
- Swallow the interrupt. The session’s driver reads
/dev/consall the time, not only while a line is requested, and a native stage never gets the raw console: its standard input is a pipe the session feeds. So no foreground program can hide the interrupt from the shell, and a screen that takes Ctrl+C still cannot take Ctrl+\: the driver finds that byte in what was read before decoding any key, so neither an escape sequence begun before it nor a paste carries it to the screen. Under a screen that does not take Ctrl+C, the 0x03 byte is found the same way: ESC then Ctrl+C is the interrupt, never the key Alt+Ctrl+C. Nor can a stage the interrupt is ending take what is typed for the next line: the session feeds a stage only while its line is evaluated, and a stage that asks for what is typed after the interrupt ended its line, which a stage launched just before it does, is given nothing. - Take the session’s memory on the heap. Every Erlang process the session starts, the
evaluator, what a line spawns and every screen program, runs with Erlang’s
max_heap_sizeflag (killing), set to a fixed share of the session’s budget, so a runaway allocation kills that process, as Ctrl+C would. A screen buffer counts toward its owner’s limit. What lies off the heap, a large binary, is bounded only by the session’s own page limit (R6 (charging)), which ends the session, not the box.
Processes an expression spawned without a link are not killed by Ctrl+C; background work belongs
in a Job.
The terminal library
Status: built · partly tested: the line editor and screens on the host only, and screens on beamlet alone (the BEAM has no screen buffer); printed text is drawn on the machine, a long line over SSH and the encoder's rate at the console; its tests are the shell's own ExUnit suite (`test/redoubt/term_test.exs`, `test/redoubt/term/frame_test.exs`, `test/redoubt/term/keys_test.exs`, `test/redoubt/term/width_test.exs`, `test/redoubt/shell/driver_test.exs`, `test/redoubt/screen_test.exs`), judged on a model of the terminal that takes only the encoder's sequences, and a pseudo-terminal test of the real binary, both of which `./test-shell` runs · tested (4)
- host:beamlet::pick_on_a_terminal_takes_the_screen_and_gives_it_back_with_the_choice
- bench:shell-long-output
- bench:shell-output-rate
- bench:shell-text-profile
Nothing between the keyboard and the shell edits a line: consoled and sshd serve /dev/cons
as a raw byte stream with no echo. So the shell owns the terminal, through one library,
Redoubt.Term (userland/shell/lib/redoubt/term.ex),
that everything drawn on it goes through: the line editor, the pager, top, the agent’s output,
and every full-screen program.
- The only writer of control sequences.
Redoubt.Term‘s encoder, in the shell’s driver, which alone holds the session’s connection to/dev/cons, is the one code that emits escape sequences for what the session draws, and what it draws is cells: a grapheme with a colour and attributes, never bytes passed through. A screen’s frames are drawn by the same encoder in Rust, the screen natives’draw/1over the screen’s buffer (beamlet), which the driver alone calls and writes, and which is held byte for byte to the encoder’s Elixir half (Redoubt.Term.Frame.draw/1). A control character in any text is drawn visibly (hostile text). There is no raw pass-through. The encoder ends every line with CR LF itself, since the console does no output processing; a host terminal that does so too draws the same. - One target: VT102 plus the common xterm extensions every current emulator speaks, with no terminfo.
- The line editor’s drawing:
group’s requests, drawn by going back to the line’s start, erasing below it and drawing the line again, which needs nothing of the terminal but relative cursor movement, CR, LF, erasing below and bold; typing at the end of the line draws only what was typed. The terminal’s size is read at the start and at each prompt, and a change of size lays the line being edited out again at once (below). - Printed text a piece at a time: the driver’s process has the session’s per-process heap
limit, a sixteenth of its budget, and the VM counts the binaries a process holds toward it, so
a line drawn whole beside itself would pass it (one
IO.putsof 70,000 bytes did, and the session ended). Text printed with no line open is drawn and written 64 KiB at a time (Term.slices/3), and each slice made visible and measured 4096 bytes at a time, every cut before a newline or where a character starts, so a line of any content is drawn in memory beyond it bounded by the slice: the driver holds the text and one slice’s drawing. The line itself is not bounded:groupdelivers it whole, so one line larger than the heap limit (about 2.75 MiB on rv64) still ends the session. Tab stops count on from the code points the printed line already holds, across cuts and across prints. A grapheme a cut falls inside is measured as its parts. - Printed text in runs: printable text, UTF-8 with no control character, is made visible a run
at a time, kept whole, and measured likewise: a run of code points each a grapheme one column wide
whatever stands beside it (printable ASCII, and the Latin, IPA, Greek and Cyrillic letters below
U+0530 that no mark is part of) takes a column a code point at once, but for its last when more
follows, which a combining mark may join; other text is measured a grapheme at a time, and a run
of ASCII control characters is drawn in caret notation into one binary. A run is taken by the
VM’s own matchers (
:binary.match,:unicode,:re) from its first byte, a window of 128 and then up to 512 bytes at a time: on the machine a step of an Erlang loop over code points costs thousands of guest instructions (beamlet, about 3,600 on rv64), about what a matcher spends on two bytes of text, so a line of 80 is one call of each matcher, and a run of one code point costs a call, a few steps’ worth, which text of such runs pays (the profile, below). A window is small so that the lists a matcher’s answer makes do not grow the driver’s heap toward its limit. The run’s end is found by:binary.matchover the 38 byte patterns a control character may start with (the ASCII controls, DEL and five UTF-8 prefixes), a hit settled bycontrol?/1and a character that only shares a prefix (a no-break space, a dash) stepped over, rather than over one pattern per control: beamlet’s matcher tries every pattern at every byte, so a run costs the pattern count a byte. What is drawn is the same, byte for byte. - The line being edited is drawn again in one pass over its graphemes, which places the cursor on its way, and a printable ASCII grapheme that fits on its row costs one step of it. A key typed at the line’s end draws itself alone and moves the cursor the encoder keeps, so it costs the key, not the line: the 200th character typed costs what the 10th did.
- The rate: on the machine, timed in guest time (
icount,shift=3), the encoder draws 256 KiB of ASCII at about 52,000 bytes a second on rv64 and 41,000 on rv32 (62,500–66,000 and 49,500–51,200 inshell-output-rate’s own run, three runs), and of Cyrillic at 123,000 and 98,600; a line of 1 MiB at 79,300 and 61,500, in 13.2 and 17.1 s; text of short runs between tabs, controls and wide characters at about 4,000 and 3,500. The baseline is the run path with one matcher pattern per control character, before beamlet’s:binary.matchcompiled its pattern (beamlet): 34,600 and 31,400 for ASCII, 43,700 and 41,000 for Cyrillic, 36,900 and 33,100 for the line of 1 MiB, 1,150 for the short runs; the encoder before runs drew about 620 and 1,240 (rv64, on the same interpreter). A keystroke in the middle of a line of 400 characters, wrapped over six rows of 80, draws the line again in about 110 ms on either width (98–134 ms in the runs; the matcher moved it nowhere), where it took 1.64 and 1.84 s.shell-output-rateholds both widths to 24,000 bytes a second for each line, a margin of 2.1 on the slowest (rv32’s ASCII); the page of the profile below to 8,000 and the same page in ASCII to 24,000 (10,300 and 31,300 in this case’s run on rv32), margins of 1.3, close because they trap the return of a per-line prologue the encoder once paid (about 6,000 and 9,400 with it); and the least of three redraws to 250 ms, a margin of 2. It times the encoder alone: what reaches the person also passes the driver’s writes andconsoledorsshd, and a keystrokegroupandedlin’s own work. The encoder’s time is mostly the VM’s matchers. - The profile (
shell-text-profile, run by name, both widths, guest time): of a 256 KiB line’s draw (4.9–5.0 s on rv64 and 6.3–6.5 on rv32; 6.6–6.8 and 7.8–7.9 before beamlet’s compiled matcher), making it visible is three tenths (1.5 and 2.0 s; it was three fifths) and the run path’s wrap and encoding the rest, about 19 and 24 µs a byte in all. Text that leaves the run path is measured a grapheme at a time, about 0.85 ms a grapheme on rv64 and 1.0 on rv32: the walk (String.next_grapheme, which is:unicode_util.gc/1) 0.42 ms, the width (:unicode_util.is_wide/1) 0.22 ms, and a matcher call for each run of one code point between them. So CJK, emoji and text broken by combining marks draws at about 4,000 and 3,500 bytes a second, 64 KiB of it in 16 to 19 s (3 % faster with the compiled matcher: the per-grapheme walk does not search): slow and bounded. A page of 82-byte lines of English with one CJK word, one emoji and one accented name each draws at 13,100 and 11,300 bytes a second (10,500 and 8,900 before), and the same page in ASCII alone at 37,000 and 31,200 (22,200 and 18,200). A line’s five graphemes, and the two runs they split, cost about 4 of the page’s 6.3 ms a line on rv64; the rest, where the same bytes of a long line cost 1.6, is the line’s own as a piece: a call of each matcher inText.visibleand again inWidth.run, the cut at its newline, and the row’s bookkeeping. A run path for wide text would gain such a page under half, and is not built; the per-grapheme path stays as it is. Whether a line’s runs should be carried past its newline, so that a page pays the matchers once a window rather than once a line, is not decided. - Frames are the cell protocol (the cell protocol): the screen buffer’s
diff speaks it (beamlet), and the encoder reads it through the one
decoder (
Redoubt.Term.Cells) and draws it (Redoubt.Term.Frame): each cell placed absolutely unless the cursor is already there, its style set when it changes (the attributes, and 16, 256 and 24-bit colour), on the alternate screen with the cursor hidden. Drawing is a diff: only the cells that changed are sent. Box drawing, block elements and Braille patterns (a 2×4 dot grid per cell, the buffer’splot) are cells like any other. - Keys for a screen: a key decoder
(
Redoubt.Term.Keys) for VT100, xterm and Linux sequences, including modifier forms (ESC [1;5Cis Ctrl+Right) and UTF-8. A lone ESC is held for a short timeout (50 ms, the driver’s) before it is the Esc key, so Alt+F (ESC thenf) is one key; the decoder itself reads no clock. At the promptedlindecodes its own keys. - Width: a grapheme’s columns are OTP’s judgement,
:unicode_util.is_wide/1, from which the screen buffer’s table is generated, so what the session measures is what it draws. The person’s terminal has a table of its own, which may disagree on a wide or ambiguous grapheme, so after one the encoder places the cursor absolutely again, and a disagreement costs a cell’s misplacement, never the rest of the line.
The console’s size
Status: built · partly tested: on a host a change of the terminal's size reaches only the driver's tests, since beamlet's command line does not deliver one; the driver's part is the shell's own ExUnit suite, which `./test-shell` runs · tested (5)
- bench:steward-ssh-resize
- host:beamlet-vm::a_change_of_the_console_s_size_reaches_its_reader_once
- host:beamlet-redoubt::a_change_of_the_console_s_size_reaches_the_vm_once_reading_has_begun
- host:redoubt-consoled::consol_size_is_the_argument_and_a_resize_waits_until_its_caller_gives_up
- host:redoubt-sshd::consol_size_is_the_pty_s_and_a_resize_is_due_when_it_changes
- Size:
:beamlet.console_size/0asks the console’s server afresh on every call, throughconsol’ssize(consoled), and gives{cols, rows}or:unknown; the driver reads it at the start and at each prompt, and lays out an unknown size as 80 by 24. - A change of size is the server’s answer to a
resizecall the VM keeps parked there, made again after each answer (beamlet); nothing calls the session back. The VM hands it to the console’s reader, the driver, as{:beamlet_console_resize, {cols, rows}}, and that size is the console’s from then on. A screen in front is sent{:resize, cols, rows}: its buffer takes the new size, blank, and the screen lays itself out again, so its next frame clears the terminal and draws it all. With no screen in front, the line being edited is drawn again at the new width. On an SSH channel a change of the window arrives so (sshd). On the UART nothing resizes: the image namesconsoledno size, so it refusessizeandresizeand the shell’s console there is of unknown size; aconsoledgiven one refuses theresizea multiplexed session would make, since the session’s own call already fills the connection’s one parked call there, so the console keeps that size.
Paste, scrolling and a plainer terminal
Status: planned · M2 (usable shell)
What the line editor and the screens built so far do not need:
- Output: scroll regions, bracketed paste, and synchronized update so a redraw does not flicker.
- Glyphs: a 16-colour ASCII fallback a session can choose, for a UART or a console font without box drawing, blocks or Braille.
- Input: bracketed paste as one event, so a paste can never trigger completion; a mouse report, if one is ever taken, is SGR only and decoded under the same rules.
Open: until bracketed paste is built, a paste reaches a full-screen program as keys, so a pasted control character is a key: the editor asks before one saves, closes or opens a file (the editor), and the gap closes with paste as one event; and whether the shell sends a terminal query at login and adapts, or assumes the VT102 and xterm target (the recommendation: assume, because a query on a UART that never answers costs a timeout at every login).
Hostile text never drives the terminal
Status: built · partly tested: the host only, and the paths that exist there: the printer, a line's own writes to the console, what the VM logs, the prompt and the typed line, and a screen program's text, the pager's included, through the screen buffer (on beamlet alone); a native program's frames and standard error on the machine too (bench:nscr-hostile-text); the attack case is the shell's own ExUnit suite (`test/redoubt/shell/driver_test.exs`, `test/redoubt/term_test.exs`, `test/redoubt/screen_test.exs`, `test/redoubt/screen/pager_test.exs`), judged by a model of the terminal that refuses any sequence but the encoder's own, which `./test-shell` runs and no bench case does, and the buffer's own refusals · tested (3)
- bench:nscr-hostile-text
- host:beamlet-screen::a_control_character_is_badarg_and_nothing_is_drawn
- host:beamlet-screen::a_control_character_is_refused_and_nothing_of_the_call_is_written
Text the session draws, from a file’s contents, a file name, a program’s output or a model’s reply,
reaches /dev/cons only as visible characters: every control character in it (the ASCII and 8-bit
controls, DEL, the bidirectional embedding, override and isolate controls, and the invisible
format characters: the soft hyphen, the zero-width space, non-joiner and joiner, the word joiner
and the invisible operators, the byte order mark, the tags of plane 14, and the line and paragraph
separators) is drawn as text (^[, ^G, <U+009B>, <U+200B>), as less does. So hostile
text cannot set the person’s clipboard (OSC 52), retitle their window, forge a link (OSC 8),
reorder what is shown, make the terminal answer as if typed, or hide a character in a name
(rm report.txt and rm re<U+200B>port.txt are drawn apart; a tag sequence such as a
subdivision flag draws as its flag followed by its tags). The one character of the set a
grapheme may hold is the zero-width joiner inside an emoji ZWJ sequence, between an element (a
pictograph, alone or with the presentation selector U+FE0F or a skin tone modifier) and a
pictograph: 👩💻 is one symbol of two columns and draws as one; a joiner anywhere else is
<U+200D>. The set and the pictographs (Extended_Pictographic, as the pinned OTP has them) are
stated once, in
userland/native/cells/forbidden.txt, and the
printer, the session’s cell decoder and the cells crate are each held to it code point by code
point. It holds because the encoder is the one writer of what the session draws, and
because nothing reaches the encoder but cells, whose symbols cannot hold a control character: from
the shell’s printer, from a screen program through the buffer’s natives, which refuse one, and
from a native program only as cells frames. The line editor is the one path that hands the
encoder text rather than cells: group’s requests, which the encoder makes visible under the same
rule, a run of printable text or one control character at a time.
What the VM logs goes through group too. OTP’s logger writes through its default handler
to user, the VM’s own console server, past the driver. While the driver holds the console, the
logger’s handler is the shell’s instead
(Redoubt.Shell.Log): every event, the crash
report of a process a line spawned, the emulator’s report of one that died, an error_logger or
Logger call, is formatted as the default handler would have (OTP’s formatter bounded to 4 KiB
an event), and written to group like any other output, so the encoder draws it visibly and
group draws the line being edited again after it. A byte that is not UTF-8 is written as
<FF>.
- It never waits. A handler runs in the process that logs, which may be one the console’s own
path waits on, so it only formats and sends; a relay process writes to
groupand is the only one that waits. An event logged bygroupor the driver themselves is drawn as one fixed line,[a log event from the shell's terminal, not shown], by the driver, never throughgroup. - It is bounded. At most 32 events wait in the relay; a process that logs faster loses the
rest, and their count is drawn with the next event shown (
[N log events dropped]). - It ends with the driver, which puts the
defaulthandler back as it was.
What it does not cover: code the person runs holds the session’s authority, and can write to its
own console as it can do anything else the session can, user and standard_error among them;
a VM without OTP’s logger in its code (the host’s beamlet run with no system path) loads beamlet’s
small stand-in, which prints to standard_error past the driver, though Redoubt’s userland volume
holds OTP’s; and the bidirectional marks (U+200E,
U+200F, U+061C) are drawn, since they only settle the direction of the weak and neutral characters
next to them. A grapheme cut by the edge of a printed piece (4 KiB) is drawn as its parts, so a
joiner the cut separates from its pictograph is <U+200D>. The attack case writes hostile text
through every path above and judges the bytes the session wrote to /dev/cons.
The cell protocol
Status: built · partly tested: the Elixir decoder is held to the Rust one, and the Elixir event encoder to the Rust event decoder, by the shared vectors in the shell’s ExUnit suite, which no bench case runs · tested: host:cells::good_frames_decode_and_encode_back_to_their_bytes, host:cells::no_control_character_is_ever_a_symbol, host:cells::a_joiner_is_a_symbols_only_inside_an_emoji_zwj_sequence, host:cells::the_forbidden_characters_and_the_pictographs_are_the_shared_tables, host:cells::vectors_are_current, host:cells::events_are_current, host:cells::reduce_hands_over_the_header_then_each_cell_and_stops_where_asked, host:cells::a_cursor_reads_a_cell_at_a_time_and_refuses_as_decode_does
A frame of changed cells, cells (userland/native/cells)
in Rust and Redoubt.Term.Cells in Elixir, is everything anything may hand the session to draw: a
position, a symbol, two colours and attributes per cell. It is strict. A symbol is a non-empty
string of at most 32 bytes, one cell’s worth as the session lays it out, that holds no control
character, a cell cannot fall outside its
screen or be given twice, and a frame that decodes re-encodes to exactly its bytes; anything else
is refused, never repaired. One file of vectors holds the two decoders to the same answers. Each
decoder reads a cell at a time (cells::reduce, Redoubt.Term.Cells.reduce/3), checking each
field as it reads it, so the two refuse a frame for the same reason, and neither need hold a whole
frame’s cells.
A native program with a screen and the session exchange records on its standard streams: a
u32 length, then exactly that many bytes. On its output each record is one frame; on its input
each is one event, its screen’s size (each side 1 to 1024) or a key (a symbol under the same
rule as a cell’s, or a named key: Enter, Tab, Backspace, Esc, the arrows, Home, End, Page Up and
Down, Insert, Delete, F1 to F24) with Shift, Alt and Ctrl. The session writes events, and the
crate decodes them for programs, as strictly as frames; a second file of vectors, events.json,
holds the two to the same bytes (a native program’s screen).
Full-screen programs
Status: built · partly tested: the host only, on beamlet alone (the BEAM has no screen buffer); its tests are the shell's own ExUnit suite (`test/redoubt/screen_test.exs`, `test/redoubt/screen/layout_test.exs`), judged on a model of the terminal, and a pseudo-terminal test of the real binary, both of which `./test-shell` runs and no bench case does · tested (1)
- host:beamlet::pick_on_a_terminal_takes_the_screen_and_gives_it_back_with_the_choice
flowchart LR
APP["screen program: an Erlang process<br/>init, update, view"] -->|"widgets draw"| W["Redoubt.Screen: layout, widgets"]
W -->|"put, fill, plot"| B["the screen buffer<br/>(beamlet natives)"]
B -->|"diff: a cells frame"| E["Redoubt.Term's encoder"]
NP["a native program with a screen"] -->|"cells frames on a pipe, through its host's buffer"| B
E -->|"escape sequences"| C["/dev/cons"]
C -->|"raw bytes"| K["the session's key decoder"]
K -->|"key events"| APP
K -->|"key events on a pipe"| NP
Figure: how a full-screen program reaches the terminal. Only the session’s encoder writes to
/dev/cons; everything else hands it cells. A native program’s frames are decoded and put into a
buffer its host holds (a native program’s screen).
A full-screen program (the pager, help’s pages, top, the editor, a menuconfig-style form, a
QBasic-style menu bar and dialogs) is an Erlang process in the session’s VM, not a program of its
own. It draws into a screen buffer, a grid of cells beamlet holds natively
(beamlet), and everything above the buffer is Elixir:
Redoubt.Screen(userland/shell/lib/redoubt/screen.ex), a behaviour of three functions in the Elm style:init,updateon a key or a message, andview, which draws the whole screen into the buffer, blank each time; only what changed since the last frame is sent. One Erlang process runs a screen, with its caller’s heap limit (the evaluator’s, from a line), the binaries it holds counted too: the shell’s driver sends it the keys while it is in front, and it answers each withupdate,view, and a request that the driver draw what changed in its buffer, the one buffer it registered when it opened, waiting until the driver has. Its first event is{:resize, cols, rows}, with its size.- Layout is rectangles only: split into rows or columns by fixed size, percentage or what is left; centre; inset. A screen lays out in fixed rectangles.
- Widgets are functions, not processes: each draws into a rectangle of the buffer from what it is given, every text made visible first: a box with a title and a shadow, a status line, and those that take keys (widgets).
- A screen’s life. A line starts a screen with
Redoubt.Screen.run(module, args, opts), which returns when the screen ends, with the value itsupdateended it with. While it is in front, the driver shows the alternate screen with the cursor hidden, sends it every key as{:key, key, modifiers}but the interrupt, and holds other processes’ output, answering them at once so none waits on the screen; then it shows the main screen again, as it was, and draws what it held. The interrupt ends it withnil: Ctrl+\, and Ctrl+C unless it was started withctrl_c: :key, which makes Ctrl+C a key it is sent (the editor copies with it). One screen is in front at a time. A screen can ask the process that started it for what only that process may touch (Redoubt.Screen.serve/5), as the pager asks its line’s evaluator for lines from a file the evaluator opened. pick(items)and the pager are screens.pickismenuconfig’s chooser: a list in a box, the arrows, Page Up and Down, Home and End to move, Enter to choose, Esc to leave. It returns the chosen item, ornil.- Small things skip it. A spinner, a progress line or a single status line is drawn by
Redoubt.Termdirectly.
What a full-screen program cannot do:
- Draw a control sequence. What a screen program draws reaches the terminal only through the buffer’s natives, which refuse a control character, and through the encoder, which the driver runs over that buffer’s own cells and which makes any control character visible again: no message of the screen protocol carries bytes to the terminal. The driver draws only the buffer the screen in front registered, and ends a screen whose registered term is no buffer (hostile text).
- Keep the interrupt from the session. Ctrl+\ ends the screen in front, as it ends a line at the prompt, and so does Ctrl+C unless the screen takes it as a key (interrupting).
Widgets, focus and themes
Status: built · partly tested: the host only; the keys of every widget, the focus ring and the dialog stack on beamlet and on the BEAM, and what they draw on beamlet alone (the BEAM has no screen buffer); its tests are the shell’s own ExUnit suite (test/redoubt/screen/widget_test.exs, test/redoubt/screen/drawing_test.exs, test/redoubt/screen_test.exs), judged on a model of the terminal, which ./test-shell runs and no bench case does
A screen program is built of widgets
(userland/shell/lib/redoubt/screen/), which are
plain data and functions, never processes. One that takes keys is a struct with key, which
answers a key with the widget changed, with the value it ended with (Enter on a list, a button
pressed), or with :pass for a key it does not take; and draw, which draws it into a
rectangle of the buffer, with the focus or without. The screen’s update stays the one place
its state changes. The widgets’ code is not held at the prompt: they declare no commands,
so nothing loads them until a screen first draws with them.
- The widgets: a list, which is also the radio list and the checklist (Space marks); a row
of buttons; a one-line text input whose cursor is a cell drawn in its own style, the
terminal’s cursor staying hidden; a menu bar with drop-downs, opened by F10 or Alt and a
menu’s first letter, modal while open; a table with a header, its columns sized as
tablesizes them; a Braille canvas of dots, two across and four down in each cell, drawn with the buffer’splot; and the completion pop-up, a list placed below a cell, or above it with no room below. The prompt’s own completion isgroup’s list (completion), not the pop-up. - A stack of modal dialogs: a message, a yes or no, and an input with OK and Cancel. Keys go
to the top dialog, which keeps those it does not take, or to the screen when there is none;
Esc closes the top dialog with
nil. The stack is laid out inviewfrom the size the screen has, top to bottom, so a screen of a new size draws it again at that size. - Focus: inside a dialog or a screen, Tab and Shift+Tab move along a focus ring, and every other key goes to the widget with the focus.
- A theme is a map from roles (the text, the selection, a border, the menu, a button, the
input, its cursor, and the parts of code the editor highlights) to styles. Three are built:
the terminal’s own colours, the default, with code in bold, dim and italic; QBasic’s blue; and
menuconfig’s. - Every style is the code’s. A widget draws each part in its role’s style, and its text
through the visible-text rule: a label, an item, a cell, a title or what was typed sets no
colour, and a control character in it is drawn as
^[in the role’s style (hostile text). A theme is chosen by name from the three, never read from text.
What is not built: a plot(values) command drawing a series on the canvas.
A native program’s screen and the session’s key
Status: built · partly tested: a change of size reaches a native program's screen in the shell's ExUnit suite only (`test/redoubt/screen/native_test.exs`, which `./test-shell` runs on beamlet), since the UART names no size; so do the read and draw bounds; a key per principal is not built · tested (11)
- bench:nscr-interrupt
- bench:nscr-hostile-text
- bench:nscr-beyond-cells
- bench:nscr-frame-rate
- host:cells::good_events_decode_and_encode_back_to_their_bytes
- host:cells::bad_events_are_refused_for_their_reason
- host:cells::a_record_is_its_length_and_its_bytes_and_a_long_one_is_refused_unread
- host:cells::events_are_current
- host:beamlet-screen::every_vector_is_answered_as_the_decoder_answers_it
- host:beamlet-screen::a_frame_read_in_puts_what_the_reference_puts_whatever_its_bytes
- host:beamlet-screen::a_programs_frame_is_read_in_stopping_at_a_joined_symbol_and_refused_as_the_decoder_refuses
A native program with a screen, a package’s own TUI, draws by sending cells frames on its
standard output, and reads its keys and its size as events on its standard input. The line asks
for it: screen("menu", args) runs /boot/menu as a one-stage pipeline
(native programs and pipes), in a budget of its own, with its three
streams and nothing else; the same program run with exec or pipe has its output drawn as text
through the guard. The program never chooses how its output is read. screen returns how the
program ended ({:exited, code}, {:faulted, cause}), {:error, {:refused, why}} when the
session refused what it sent, or nil when the interrupt ended it; the value is drawn like any
other, through the guard. Redoubt.Screen.Native.run/3 takes ctrl_c: :key, as
Redoubt.Screen.run/3 does.
While it runs, one Erlang process, the host
(Redoubt.Screen.Native), is the screen in
front for the shell’s driver, as a screen program’s process is, with its line’s heap limit:
- Records. Each way, a stream is records: a
u32length, then exactly that many bytes (the cell protocol). A record on the program’s output is one frame, and one longer than a frame of its screen can take is refused before any of it is held: at most 10 + 47 bytes a cell (the header, then each cell with the longest symbol), so 90,250 bytes on 80 by 24, and a header claiming 4 GiB costs nothing. - Frames. Each record is read straight into a screen buffer the host holds by the screen
natives (beamlet): the one decoder, in Rust, a cell at a time, so a
whole screen’s frame is never held as terms, and nothing is allocated for it beyond a bit a cell
of the buffer. It is refused if the decoder refuses it; a
frame of any size but the one the session gave the program is refused, but a frame of a size it
gave before the terminal last changed size is dropped, since it was on its way; and a cell must
be one cell’s worth as the session lays it out: one grapheme, and a wide one not in the last
column, or one cell could draw past its neighbours and the screen’s edge. A symbol joining code
points is handed back unput, and the host asks OTP’s segmentation whether it is one grapheme
before it is put. The same reading with the decoder in Elixir (
Redoubt.Screen.Native.reference/3, onRedoubt.Term.Cells.reduce/3) is kept as its reference: the shell’s suite holds the two to the same answers and the same buffer over the vectors and seeded mutations of a whole screen’s frame. A frame refused partway draws nothing: the screen ends, and its buffer is never drawn again. At most 30 times a second, between whole frames, the host asks the driver to draw what changed in its buffer and waits until it has, so nothing of a frame half read is drawn. A program sending frames faster costs at most 30 drawn a second: the rest are applied to the buffer as they come. - Pace. The host asks for the program’s next 4 KiB of output only once it has decoded what it
holds, and for at most 1 MiB a second: until then the program’s next write waits, parked at
piped, its pipe holding one page. So the host holds at most one record’s bound and one read however fast the program writes. Reading frames is the session’s own work, on its VM: under QEMU a whole 80 by 24 frame of 30 KB, every cell changed, is read into the buffer in about 20 ms on rv64 and 15 ms on rv32 (in Elixir it took about 0.75 s), so the 1 MiB, about 34 such frames a second, is what holds a flooding program back. Drawing what changed is the driver’s, in Rust: under 30 ms for a whole changed screen (in Elixir it took about 0.7 s), and then the console takes its 30 KB. A program redrawing every cell of an 80 by 24 screen is drawn about 12 times a second on either width at two harts, every frame it sends, the console’s write the larger part of each;nscr-frame-rateholds it to at least 8 on the VM’s clock, which there is the host’s (the case boots a disk and types at the console), so its verdict is one taken alone. A write that fails ends the driver, and the session’s terminal with it. The host takes the share of the VM any busy process takes, and the driver, which ends it, keeps its own (nscr-interruptfloods one and ends it). - Keys and size. The program’s first event is its size, and it is sent again when the terminal’s size changes; each key the driver sends the screen is sent as an event, never the session’s key, nor Ctrl+C unless the line gave it (the driver finds both in the bytes read before decoding any key; the event encoder refuses them too, Ctrl+\ with any modifiers and Ctrl+C unless the screen was given it, and a key’s symbol cannot hold a control character). Past 64 KiB of events not yet written into the program’s input pipe, keys are dropped, so a paste cannot grow the session; the program may also have the pipe’s one page of events written and not yet read.
- Its end. A refusal ends the program at once, its budget destroyed by its pipeline’s owner, and the screen with it; nothing of the refused record is drawn. The interrupt, Ctrl+\ or Ctrl+C, ends the host, which ends the pipeline as an interrupted line’s pipelines end; the line waits for its budget to be destroyed, as after an interrupt. When the program exits or faults, the screen ends. Its standard error is kept, 64 KiB, and drawn as the line’s own output after the screen, through the guard.
So a hijacked program can draw wrong cells inside its own screen, draw nothing, send frames at
the pace above, read the keys typed while it is in front, and end or spin in its own budget. It
holds its pipes and its budget, no /dev/cons, and nothing reaches the terminal from it but cells
of the size it was given: it cannot write a control sequence, place a cell outside its screen,
take the interrupt, or outlive its screen.
- A key per principal. The session’s own key, Ctrl+\ (interrupting), is the same for every session; a principal choosing another is planned here and not built.
Line editing and history
Status: built · partly tested: the host only; its tests are the shell’s own ExUnit suite (test/redoubt/shell/driver_test.exs: typing, editing keys, history and Ctrl+R, the interrupt, Ctrl+D, the input’s end), which ./test-shell runs on beamlet and on the BEAM and no bench case does
flowchart BT
C["the console: raw bytes, no echo"] --> D["the shell's driver: keys to group, drawing through Redoubt.Term"]
D --> G["OTP's group and edlin, unchanged: editing, history, Ctrl+R"]
G -->|"expand_fun"| CO["completion"]
G --> S["Redoubt.Shell: the loop"]
R["the registry (defcommand)"] --> CO
R --> HE["help"]
Figure: the shell’s layers, bottom up. One registry feeds completion and help.
On the BEAM, line editing is OTP’s edlin under group, plain Erlang; only the driver under
them (user_drv and prim_tty) needs the operating system. So the shell keeps group and
edlin unchanged and replaces the driver with its own
(userland/shell/lib/redoubt/shell/driver.ex),
the one process holding the console, which hands the bytes typed to group and draws group’s
requests through Redoubt.Term’s encoder. group’s driver protocol is small: requests to draw
(put_chars_sync, move_rel, insert_chars, delete_chars, beep, redraw_prompt,
put_expand and a few more), input as {data, Chars}, the geometry and terminal-state queries,
and the interrupt as an exit signal. That gives Emacs keys, a kill ring, multi-line input and
history with Ctrl+R search. group writes one escape sequence itself, its bold Ctrl+R prompt;
the encoder recognises exactly that, at the head of the request that carries it, and draws every
other byte through the guard. The driver reads three keys itself: Ctrl+C and Ctrl+\ end the line
being edited and nothing else (interrupting), and Ctrl+\ never
reaches group; Ctrl+D on an empty line, like
the console’s end, ends the input and so the shell. group has no way to hand its reader an end
of input (edlin takes eof for a line’s end), so the driver answers the pending read with the
error eof, which group keeps in order behind the keys before it, and the shell reads as its
end. A byte that is not UTF-8 is read as the Latin-1 character it is.
- History is
group’s, kept for the session; the driver cuts it to the newest 1000 lines at each prompt.
Saved history, secret reads and pasting
Status: planned · M2 (usable shell)
- History persists per principal in the principal’s home volume, capped in lines. A vault session keeps its history in memory only: its label forbids writing to the unlabelled home volume, so there is nowhere to save it.
- Echo is the editor’s job, so a password prompt is a call that reads with echo off
(
Redoubt.Term.read_secret/1), not a terminal mode. - A paste is one event, so a pasted Tab does not complete and a pasted newline does not run a line before the person presses Enter.
Open: how a paste becomes one event inside edlin, which has no bracketed paste.
Completion
Status: built · partly tested: the host only, for the parameter types the host has (paths, commands and help’s names); its tests are the shell’s own ExUnit suite (test/redoubt/shell/completer_test.exs, test/redoubt/shell/driver_test.exs) and a pseudo-terminal test of the real binary, which ./test-shell runs and no bench case does · tested: host:beamlet::tab_completes_a_command_on_a_terminal
The shell’s completer
(Redoubt.Shell.Completer) is group‘s
expand_fun, which the shell sets before each read over the names the prompt then has; its
module is loaded at the first Tab, and a command’s name is completed from the commands’ index,
loading no command. It looks at the line before the cursor with Code.Fragment:
| Line so far | Completes from |
|---|---|
c⇥ (a name being typed) | the commands and functions imported at the prompt, and its variables; a function alone gets its ( |
cp("no⇥ (inside a string argument of a command) | the type that parameter is declared with: a path, or a command’s or help’s name (help(:gr⇥ too) |
File.re⇥, :lists.re⇥ | the functions of the module |
Fi⇥ | aliases and modules, a segment at a time |
- Paths resolve against the session’s working directory and read the directory once per
Tab, with the session’s own authority, as
lsdoes; whether a name is a directory is asked of the names that match only. So completion cannot show a name the session could notls: on Redoubt the file server lists only entries the caller’s labels may read. A name starting with a dot is offered once its dot is typed. - The first Tab inserts what every candidate shares; the second lists them below the line, in
columns, a few rows of them, and a third lists them all. Directories complete with a trailing
/. The list isgroup’s, drawn by the encoder under the visible-text rule, with the line kept on the screen. - Nothing typed becomes an atom. No typed text is parsed: the command a string or an atom
is given to, and which of its arguments that is, are found from the brackets and commas before
the cursor, and the command is looked up by its name as a string.
Mod.fu⇥names a module only if its atom exists already, and loads that module from the code path if it is not loaded yet, as calling it would. - A completer never launches a program and never writes. Reading a directory or the code path’s modules runs in a process of its own with 300 ms; past that it is stopped, and Tab inserts and lists nothing.
- A parameter declared as a principal, a budget or a label completes on Redoubt, where the shell has those types.
Help
Status: built · partly tested: runs on the host only, and in the pager on beamlet alone; its tests are the shell’s own ExUnit suite and a pseudo-terminal test of the real binary, which ./test-shell runs, and a bench case that prints help() on the machine’s console · tested: host:beamlet::help_longer_than_the_terminal_is_paged_and_q_gives_the_screen_back, bench:userland-read-only
help() # commands grouped by area, one line each
help(:cp) # the command's page: usage, parameters, examples
help(:elixir) # a topic: how Elixir reads at the prompt; help(:terminal), and more
h(File) # Elixir's own documentation of a module; h(&File.cp/2) of a function
A command’s page comes from its defcommand (commands), so a command cannot exist
without one. A topic is a short Markdown page bundled with the shell. h/1 reads the
documentation chunks of the module’s .beam file. What help shows is lines, so it can be
searched (help() |> grep("file")); longer than the screen, it is shown in
the pager, with a topic’s headings, the index’s areas and a page’s usage line and
“Examples” in bold. Only the shell’s own help is styled so: h/1 shows a module’s documentation,
which is the module’s, as it is.
Resource use
Status: built · partly tested: runs on the host only, where there are no budgets but the fake kernel’s; its tests are the shell’s own ExUnit suite, which ./test-shell runs and no bench case does
free() # the session's pages: limit, used, free; what the VM's processes, binaries, atoms, ETS hold
uptime() # since the box booted, by the kernel's clock, and since the session started
ps() # the session's budget, then the VM's Erlang processes, the largest first
top() # all of it on a screen, refreshed each second, the busiest process first; q leaves
They read and change nothing (Redoubt.Shell.Resources;
top’s screen, Redoubt.Screen.Top, is loaded only when it is called).
The session’s budget is read through budget_usage on the named handle budget
(budgets), so they show only what is the caller’s own: its
(account, label set). Another principal’s processes, and a vault session’s from an ordinary one,
are not listed, because a count of someone else’s work is a channel. PIDs are drawn at random for
the same reason (processes). The processes listed are
the VM’s own, Erlang processes; a native program the session runs is counted in the session’s
pages and processes, in a budget carved from its own. A platform with no budgets, the host’s,
shows the VM’s part and says so.
Not built:
df(), the session’s volumes’ space: no file server answers a call for it yet, neither the free space of a volume nor what is left of a byte quota (walfsd, littlefsd); that call comes first.- The session’s jobs in
ps()andtop(), each native stage and its budget, with jobs (interrupting and killing jobs): a launch gives the session a job, not a PID.
The editor
Status: built · partly tested: the host only; the editor, its highlighting and the file manager; their keys, files and highlighting on beamlet and on the BEAM, their drawing on beamlet alone; their tests are the shell’s own ExUnit suite (test/redoubt/editor_test.exs, test/redoubt/editor/buffer_test.exs, test/redoubt/editor/syntax_test.exs, test/redoubt/editor/manager_test.exs), judged on the files and on a model of the terminal, which ./test-shell runs and no bench case does
The editor and the file manager work as one, in the manner of Midnight Commander:
ed("notes.txt") opens the editor on a file, and fm("project") opens two panes on a directory,
from which F4 edits the selected file in the editor and closing the editor returns to the panes.
- Modeless, with the keys people expect. The editor keeps micro’s keys: Ctrl+S saves, Ctrl+Q quits, Ctrl+F finds, Ctrl+Z undoes, Ctrl+C and Ctrl+V copy and paste, and the mouse is not used. The editor takes Ctrl+C as a key, and the session’s own key, Ctrl+, ends it, as it ends any screen (the session’s key). The panes keep Midnight Commander’s: Tab changes pane, Enter goes into a directory, F3 views, F4 edits, F5 copies, F6 moves, F7 makes a directory, F8 removes, F10 leaves.
- What it edits well: search and replace by regular expression, in linear time for every pattern (beamlet); syntax highlighting for the languages of the box (Elixir, Erlang, Rust, Markdown, TOML, JSON); undo and redo; several files open at once. A file is held as lines, and as a rope only if a large file is measured to need one.
- Scripted edits are the commands’. A script changes a file with
cat |> sub |> w(files and text), not by driving the editor.
The editor is ed(path) (Redoubt.Editor); its
text is lines around a cursor (Redoubt.Editor.Buffer),
which a file read and saved unedited gives back byte for byte, a missing final newline and \r
included. A file that is not UTF-8 opens read only, each byte that is not text drawn as <FF>.
A pattern between slashes is a regular expression, matched within a line. A tab is drawn to the
next stop of four, and any other control or bidirectional character visibly, in the style of
the part of the line it is in; the cursor and the selection are styles of the theme, drawn over
the highlighting. A line is read only as far as the window’s right edge when it is drawn, so a
line of a megabyte costs a draw what the window shows of it. Like every command, its code is
loaded when it is first called. A key that would save, close or open a file asks first when it
comes in a burst: with more keys already waiting behind it, as a paste does, or within 300 ms of
a key that had, as a paste’s last key does. The keys arriving with the question are dropped,
until 300 ms after the last of them however long the paste, so a pasted Enter cannot answer it.
The 300 ms are measured between the editor’s handling of two keys, not their arrival, so a
paste whose keys each take the editor longer than that (an edit to a line of megabytes) could
outrun the window; what such a paste can do is bounded by the screen’s heap limit, which ends
the editor.
The highlighting (Redoubt.Editor.Syntax)
is chosen by the file’s extension (.ex and .exs, .erl and .hrl, .rs, .md, .toml,
.json; any other file is plain text), and a language’s module is loaded when a file of it first
opens. A language cuts each line into parts, each a role of the theme: a keyword, a string, a
comment, a number, a constant or a heading. The parts are the line’s own bytes, so a file chooses
a role and nothing more: what a role looks like is the theme’s, and an escape sequence in a
string is drawn visibly in the string’s style. A line starts from the state the line above left
(inside a string or a comment that runs on, or not); the editor keeps that state every 128
lines down to the window, and drops what an edit may have changed, from the edited line down,
and all of it on an undo, a redo or a replace through the file. So the first jump to the end of
a large file scans every line once, and an edit costs only the lines from it to the window. Only
a line’s first 4 KiB are read, the rest drawn plain, so a window costs a bounded scan whatever
its lines hold; a scan costs time in a line’s length (a 1 MiB line in some 7 s on beamlet, on
the host, were it read whole).
The file manager is fm(dir) (Redoubt.Editor.Manager),
a screen program of two panes, each listing one directory through
the editor’s files: a directory is drawn /name, and a name those refuse
!name, which every key refuses in turn. Enter goes into a listed directory, or up on ..
through the pane’s own path, never through a listed name, and on a file edits it. F3 opens the
editor read only, F4 to edit, and closing it comes back to the panes, listed again. F5 copies
and F6 moves the selected name into the other pane’s directory, F7 makes a directory by a name
typed, and F8 removes a file or a directory and all it holds. Copy, move and remove ask first,
naming what and where, and a question is deaf for its first 300 ms and, while keys arrive with
more queued behind them, until 300 ms after the last: so no paste, however long, answers it with
its Enter. A failure is said in a dialog.
Undo keeps at most 500 steps, and holds at most a million lines between them. A step holds a
new copy of the list of lines the cursor crossed since the step before (the lines’ bytes are
shared), so 500 steps each taken after a jump between the top and the bottom of a 2 MiB file
held, unbounded, about 350 MiB of heap with 64-byte lines and 2.6 GiB with 8-byte lines on the
BEAM, past the 16M words a screen may grow to. A step also holds its own copy of the cursor’s
line, so 500 keys typed into a file of one 1 MiB line, the cursor moved between them, would keep
500 MiB. Each step counts the lines it crossed or inserted, never more than the file’s, and a
line for every 16 bytes of the cursor’s line; once the steps hold more than a million the oldest
go, and the newest is always kept. After 500 edits across a 2 MiB file of 15-byte lines, the
buffer, undo and all, fits in half a screen’s heap; after 500 on one 1 MiB line, the copies kept
come to some 16 MiB (buffer_test.exs).
It is an Erlang process in the session’s VM, drawn through the screen buffer (full-screen programs), and it runs with the session’s authority, as every tool in the session does. A file is data to it, handled as the session handles all data: by Elixir on a memory-safe VM (why). What the editor must not have is a way to turn a file’s contents into an action:
- No file content becomes an action. It has no modelines, evaluates nothing it opened, takes no path from a file, and saves only to the file it was asked to, through a temporary file and a rename.
- The panes act only on what they list. A name in a listing that holds
/, is.or.., or holds a NUL is shown and refused, and every copy, move and removal is made on the listed directory, so a server that lists a crafted name cannot make the file manager act outside it.
The editor’s files
Status: built · partly tested: the host only, on beamlet and on the BEAM; the editor and the file manager use it; its tests are the shell’s own ExUnit suite (test/redoubt/editor/files_test.exs), each verdict read from the file system afterwards, which ./test-shell runs and no bench case does
What the editor and the file manager do to files is one module,
Redoubt.Editor.Files, over File, with
the session’s own authority and no more:
- Reading. A file is read whole, up to 2 MiB (
max_bytes/0), and comes back as its bytes, whether they are UTF-8, and a digest. A larger file, or a path that is not a regular file, is refused by name, with the size and the limit. At most one byte past the limit is read, so a file that grew past it since its size was looked at is refused without being read whole. - Saving. A save writes a new file in the same directory, under a name the module makes
(
.NAME.saving-and random hex), and renames it over the path; a write, close or rename that fails (a full disk, a read-only volume) removes the new file and is returned as an error, never raised. So a save reaches its own path and nothing else, and a reader never sees half a file. A file changed on disk since it was read (its digest differs, or it appeared or went) ischangedand left as it is, unless the caller asks to overwrite it; the check reads at most one byte past the limit. - Listing. A directory’s entries come back by name, each with its name as shown, every
control or bidirectional character drawn visibly (hostile text),
and whether it may be acted on. A name that holds
/or NUL, or is empty,.or.., is refused: it is not looked at, and no operation joins it to a directory. - The panes’ operations: copy, move (a rename, or a copy and a removal across volumes, as
mv), make a directory and remove, each on a name in its listed directory, to the same name in the other pane’s. Nothing is overwritten, and a directory is never copied or moved into itself. - Contents are data. Nothing here takes a path, a name or an action from what a file holds: a modeline or an escape sequence in a file comes back as the bytes it is.
Residual: a move across volumes is a copy and a removal, so a removal that fails leaves the copy beside the source. A directory is kept out of itself by its path as written, not with links resolved: on a volume with symbolic links, copying a directory into a link that points inside it copies into its source. Both cost only the session’s own files; follow-ups for the file manager.
Why
Elixir is the shell, and the loop is Redoubt’s. No shell in the Elixir world replaces bash,
and none needs to: Elixir pipelines read like shell pipelines and have none of the quoting,
word-splitting or injection problems. IEx is the obvious loop, but it runs a file from the
current directory at start with the session’s authority, its evaluator and
completion are internal modules with no stable API, and its terminal driver is the one part that
needs the operating system. The shell keeps what is sound under IEx (group, edlin, the
compiler) and writes the loop, a small one, on Elixir’s public API. The surface of a shell (cat,
ls, grep, top) is borrowed from Toolshed, which was written for this situation, and
reimplemented over Redoubt’s namespace instead of Linux /proc.
One syntax. The prompt is Elixir and nothing else: there is no second syntax to keep in step with Elixir, no rule for which lines are commands, and no quoting to get wrong. Plain Elixir costs a pair of quotes and a comma, and a call that is the whole line needs no parentheses. One language and one parser leave nothing to inject into.
Commands are declared, not written twice. A command’s types, its help, its page and what completion offers come from one declaration that does not compile without its help, so a command cannot drift from its documentation or ship without it.
A mark ends a block, not a prompt. A program driving a session cannot take the next prompt as the end of a result: a result can contain a prompt, printed or forged, and a line editor echoes, completes and redraws, all noise to a program. So the machine mode marks every block’s end with a line the program chose the token of, and the evaluated code never sees the token: it is not in the bindings, the environment or the evaluating process, the line that set it is cut from history, the lines ahead wait in the driver until the shell asks, and the asking carries a key minted for the shell alone; so the token and the key are in no process a block can read, and the driver’s own mailbox is the one residual. Hostile text in a result is drawn through the same guard as everything else, so it is visible text that merely resembles a mark, and the real mark follows it.
One layer. An operation that does not need its own address space is an Elixir function, not
a program: there is no cp or ls binary to launch, to sign or to audit. A native program is
for what must run apart from the session: untrusted input, or authority the session should not
lend.
The terminal is the session’s, and the C reference stays a reference. libvterm, a C
terminal library, is read for its state machine and key tables and is never built or linked:
Redoubt has no C in its build. Terminal code is a parser of untrusted bytes (anything that reaches
/dev/cons can be typed or pasted), which is one more reason to keep it in a memory-safe language.
Cells, not bytes. Whatever reaches the person’s terminal can drive the terminal program on their own machine, and on Redoubt much of what is shown was written by a hostile party: an agent’s reply, a file it made, a name it chose. Sanitising at every place that prints would be one check to forget per program. Making the session’s encoder the only writer, and cells the only thing anyone else can hand it, leaves no path for a control sequence to take.
Screens are drawn in the session. A screen is an Erlang process in the session’s VM: it costs the box no process and no copy of any code. Widgets and layout need neither speed nor anything outside Elixir; what needs speed on an interpreter is the loop over cells, and that alone is native (beamlet).
The editor is Elixir. It is a screen program like the pager, so it reads a file with the same
memory-safe code every tool of the session uses and costs no process of its own. Its search is
Regex, which costs linear time in every pattern, as it does wherever Redoubt matches one; its
highlighting reads each line once, from left to right.
One VM per session. Every tool of a session, the shell, the editor, top, the pager, runs in
the session’s one VM. A VM is not a wall inside one principal: code that takes one over holds the
session’s handles and could load any bytecode it liked, whichever tool it came in through. The
wall is the kernel’s, around the VM, and it holds whichever tool read the file: what a session
reaches is what its principal was granted, keys stay in keyd, and approvals are drawn only at
approve@. So a person or an agent pays for one VM’s code and heap, not one per tool. Running
code someone else wrote is a risk the person takes, here as in any environment; a child VM with
narrower grants is there for whoever wants one.
The terminal is the only front end. A web interface for administration would be an inbound service and a GUI, both non-goals (the tenets), and it would render agent-written text in the most privileged person’s browser. The design it would need is recorded beyond M6 (a browser GUI).
No resize callback. IPC is caller-initiated, so a server tells a client something by answering a call the client made and the server parked. A callback would need the console server to hold an endpoint into the session, which is authority it should not have; a parked call needs none.
Files and binds
A file on Redoubt is something a server serves over 9P, reached through a connection in the
session’s namespace. Elixir’s File, IO and Path work unchanged on top: beamlet’s file
natives speak 9P to the file server, one instance per volume, which keeps the data in walfs on
the SSD (walfsd), or in littlefs on a flash medium (littlefsd). There are no permission bits, no owners, no symlinks and no hard links. Access is
by capability (holding the connection) and by label (the volume’s labels against the caller’s);
a bind puts a connection the session already holds at another path.
Purpose
People and agents work with files more than with anything else, and the shape of the file system
is where Redoubt differs most visibly from Unix. This page says what File does over 9P, which
Unix habits carry over and which do not (chmod, ln -s, an atomic rename between two
directories), how labels decide who may read and write a volume, and how a directory is shared
with another principal.
How to use it
Ordinary Elixir:
File.write!("/home/alice/notes.txt", "buy milk\n")
File.read!("/home/alice/notes.txt")
File.ls!("/home/alice")
File.cp!("/home/alice/notes.txt", "/work/notes.txt") # across volumes: a copy loop
File.rename!("/home/alice/a.txt", "/home/alice/b.txt") # one volume: the server renames
The shell’s commands are the same operations with short names: cat, cp, mv, rm, mkdir,
ls, stat (the shell). A bind names a connection the session holds under another
prefix:
{home, _rest} = ns_lookup("/home/alice") # the connection behind the prefix
bind("/h", home)
File.ls!("/h/projects") # the same files as /home/alice/projects
Redoubt-only operations are in Redoubt.File: copy_file, a copy the file server makes itself.
File.rename within one volume is already the file server’s own rename, between two directories
of it; per-file metadata through the server’s set_attr and get_attr is planned.
What it can and cannot do
Files over 9P
Status: built · partly tested: the host tests run beamlet's platform against the real `littlefsd` on the fake kernel, and OTP's `prim_file` over the natives runs in a boot in bench:beamlet-files; Elixir's `File` over them runs in a session once the steward's sessions do · tested (32)
- host:beamlet-redoubt::files_are_written_read_listed_renamed_and_removed
- host:beamlet-redoubt::a_rename_the_volume_refuses_is_eacces
- host:beamlet-redoubt::opening_to_write_replaces_and_appending_adds
- host:beamlet-redoubt::a_read_past_one_answer_comes_in_pieces
- host:beamlet-redoubt::the_position_lives_in_the_vm_and_moves_with_reads_and_seeks
- host:beamlet-redoubt::a_path_under_no_binding_is_enoent
- host:beamlet-redoubt::a_path_above_a_binding_is_a_directory_the_namespace_answers
- host:beamlet-redoubt::two_askers_wait_at_once_and_each_gets_its_own_answer
- host:beamlet-redoubt::the_vms_own_calls_wait_in_place
- host:beamlet-redoubt::closed_files_give_their_fids_back
- host:beamlet-redoubt::an_abandoned_operation_stops_at_its_next_answer
- host:beamlet-redoubt::a_file_operation_on_the_consoles_connection_is_answered
- host:beamlet-redoubt::a_write_answered_busy_goes_again_after_the_retry_interval
- host:beamlet-redoubt::a_close_answered_busy_is_retried_and_its_fid_is_kept_until_the_clunk_is_served
- host:beamlet-redoubt::a_request_unanswered_past_file_wait_fails_etimedout_and_the_connection_still_serves
- host:beamlet-redoubt::a_request_busy_past_file_wait_fails_busy
- host:beamlet-redoubt::an_operations_bound_is_the_sum_of_its_requests
- host:beamlet-redoubt::a_give_up_on_a_walked_fid_answers_its_asker_before_the_clunk
- host:beamlet-redoubt::a_fid_given_up_on_is_clunked_when_its_late_answer_comes
- host:beamlet-redoubt::a_stat_reports_only_what_9p_has
- host:beamlet-redoubt::what_has_no_9p_field_is_refused_visibly
- host:beamlet-redoubt::a_field_set_on_a_missing_file_is_enoent
- host:beamlet-redoubt::a_directory_that_is_there_is_eexist_to_make
- host:beamlet-redoubt::mkdir_p_of_a_nested_new_path_makes_it
- host:beamlet-redoubt::a_create_refused_is_its_refusal
- host:beamlet-redoubt::every_row_of_the_error_table_maps_to_its_posix_error
- host:beamlet-redoubt::a_bind_argument_puts_a_handed_volume_in_the_namespace
- host:beamlet-vm::a_completion_reaches_the_process_that_asked_and_no_other
- host:beamlet-vm::a_message_does_not_end_a_wait_for_a_file
- host:beamlet-vm::a_process_killed_while_it_waits_has_its_operation_dropped
- bench:beamlet-files
- bench:lease-writes
flowchart LR
F["File, IO<br/>(Elixir)"] -.-> FM[":file, file_io_server<br/>(OTP, unchanged)"]
FM -.-> PF["prim_file natives<br/>(beamlet)"]
PF -.-> C["the 9P client<br/>(beamlet's Platform)"]
C -.->|"9P over call and lend"| LFSD["walfsd, one per volume"]
LFSD -.-> LFS["walfs"]
LFS -.->|"typed calls"| B["blkd"]
B -.-> D["virtio block device"]
Figure: the file I/O path from File to the block device. The links are built; they are drawn
dashed until Elixir’s File runs over them in a session. The servers are
the file server’s and blkd’s pages; littlefsd
serves the same 9P and typed calls over littlefs.
File.read!/1 becomes OTP’s file module, whose prim_file calls beamlet implements over its
9P client (beamlet;
userland/otp/redoubt/src/files.rs). The client
resolves the path in the session’s namespace, walks the rest of it on that connection, and reads.
A path above a binding, such as /, is a directory the namespace answers itself; a path
neither inside nor above one is :enoent (sessions).
- A fid is the file descriptor, and a directory fid is a capability. The file position lives in the VM, because 9P reads and writes carry explicit offsets; nothing else is kept between calls. A closed file’s fid is clunked, and comes back when the server has let it go.
- Everything moves in pieces: a read asks at most what one answer carries (the 64 KiB
msizeless its header), a write at most a page, each one request on the VM’s hub (asynchronous underneath), so only the Erlang process that asked waits for it. - Every hub request ends in exactly one answer to its caller, within its bound. The
operation waits at most
FILE_WAIT_US(10 s) for each request,busyretries counted, and for a connection’s place atMAX_WAITERS; past that it fails with the name of what it waited for (etimedoutan answer,ebusya share,emfilea place), the connection kept and a later answer dropped, after the caller has its own; the fid that request walked or held is clunked then, as a closed file’s is, or its number given back if the walk it asked for failed, so the server lets go of every fid the hub gave up on and the connection’s pool recovers (a give-up that kept the fid leaked one at the server and one in the pool peretimedout, and under sixteen agents’ timeouts drained the account’s working pool until the server refused the late starters’ first read). The bound is each request’s, so an operation’s is the sum over its requests: a write to append is a walk, a create or open, a stat, a write per page and a clunk, five or six requests, and one of them took 47 s to fail under sixteen agents’ load before it was answered. A fid the operation still holds at its end (a walk whose open the server refused, a request given up on while it waited to go again) is clunked as a closed file’s is, off the caller’s path: the answer is the caller’s at the bound, and the clunk is cleanup, the fid given back when the server answers it. The VM’s own calls, its code loading, wait in place with no bound of their own: the hub answers by construction, and a second clock on the same wait would hide a fault of the hub’s as a timeout of the VM’s. A server whose answer cannot be asked for again therefore parks a request for strictly less than the bound, by a stated margin:ipd’sdataandctlreads forDATA_WAIT_USandCTL_WAIT_US, 8 s, the 2 s margin the kernel’s delivery of the reply, so a read’stimeoutreaches the hub before the hub gives up, and the bytes that arrive after it wait in the socket’s buffer for the read made next (pinned and abandoned calls). None of it waits on the VM’s thread. The file server’srenameis a typed call, which no hub carries: it is made on the VM’s thread. - Plain 9P2000, with no Unix extensions: a
stathas a name, a length, a modification time and a qid (the server’s identity and version for the file), and nothing else. Custom per-file metadata is the file server’s typedset_attrandget_attr, kept in the volume’s attributes. - An error is a Redoubt error first. The file server refuses with its own reasons
(
not_found,not_permitted,exists,not_dir,removedfor a fid whose file was removed,too_largefor an attribute over its limit), and labels and budgets add theirs, each a name of the one table (wire). Everywhere but theFileboundary a Redoubt error keeps its name; there, beamlet’s platform maps each name to the POSIX error OTP’sfileexpects, by the table’s last column.
| Operation | What happens |
|---|---|
File.stat, :file.read_file_info | what 9P and the file server have: the type (from the qid), the size and the modification time (0 until the file server keeps one: the residual below); access, mode, uid, gid, links, inode and major_device are :undefined, since no server says what a connection may do; attributes through get_attr |
File.ls | a read of a directory fid; entries the caller may not read are left out |
File.rm of an open file | succeeds: an “in use” refusal would tell one client about another |
File.chmod, File.chown, File.write_stat, File.touch of a file that is there | :ok, and nothing changes: there are no mode or owner bits, access is by capability, and no time is kept (the residual below). Each native refuses enotsup, which OTP’s write_file_info takes as done; on a path that is not there each is {:error, :enoent}, so File.touch in a missing directory fails |
File.ln_s, File.ln | {:error, :enotsup}: 9P2000 has no links, and binds do their job |
Refuse visibly; report only real fields. The File API does not emulate POSIX: no program
should rely on a permission bit that no server enforces, because authority on Redoubt is the
capability, never a mode bit. :undefined is within OTP’s own file_info type for exactly these
fields, so nothing is invented. This is the one statement of the rule; file transfer follows it
(file transfer). Below File the refusal is
visible: each native that would set a mode, an owner or a time answers enotsup for a file that
is there. OTP’s own write_file_info takes that answer as done, as it does on a system without
the field, so standard-library code that writes a file’s info back (the mode preservation in
File.cp and File.cp_r, File.touch) succeeds and sets nothing, and is never fed a fake
value; the M5 (self-hosted development) case that compiles a Mix project on the box catches any
caller that breaks. The host tests above hold the undefined fields, the natives’ enotsups and a
missing path’s enoent, and bench:beamlet-files sees mode undefined in a boot, a file’s info
written back as ok and written to a missing file as enoent.
Residuals, each a departure from the table, until the file server serves what it needs:
- A request that times out retires its fid. An operation whose request went unanswered for
FILE_WAIT_USkeeps that fid out of use for the connection’s life, as a lost reply’s is, since the server may still hold it; a connection hasMAX_FIDS(64) of them, so a server that never answers costs its connection at most 64 timed-out requests before every operation on it isemfile. The VM’s own waits (the console’s writes) consult the console’s retry, not a file operation’s deadline: a timed-out operation is noticed when the VM next idles or takes a completion, bounded by the console’s progress. - No times are set and none is stored. The 9P skeleton refuses
Twstat, so a time given toFile.write_statorFile.touchis not kept, though the call is:ok, and a cut at a position (:file.truncate/1) other than an open’s is{:error, :enotsup}; and no file server has a clock to keep a modification time by, so it reads as 0 (1970).
Copying, moving, removing and binds
Status: built · partly tested: in a session on the machine a copy across volumes is shown only from a read-only volume and into a vault, and a move between two writable volumes, which no session holds, runs on the host only · tested (6)
- bench:shell-commands
- host:beamlet-redoubt::a_copy_within_one_volume_is_the_servers
- host:beamlet-redoubt::a_bind_is_the_files_namespace_and_one_connection
- host:beamlet-redoubt::binds_past_the_cap_are_refused_and_a_bound_prefix_is_still_replaced
- host:beamlet-redoubt::a_served_servers_binding_and_handle_go_with_its_budget
- host:beamlet-redoubt::a_rename_the_volume_refuses_is_eacces
Two paths on different prefixes are usually on different servers, and one server cannot act on another’s files. So what an operation costs depends on where its two ends are:
| Operation | Within one volume | Across volumes |
|---|---|---|
copy (cp, Redoubt.File.copy_file) | the file server’s copy_file: no bytes cross into the VM, though the VM’s thread waits while the server copies, at most the home’s quota (8 MiB in the image) or a vault’s room; cp over a file that is there copies through the VM | a read and write loop in the VM (File.cp and File.cp_r always loop) |
rename or move (File.rename, mv) | the file server’s rename, atomic, within one directory or between two | File.rename returns {:error, :exdev}; a move is the caller’s copy and remove (mv does both), not atomic |
remove (rm, rm_rf) | a 9P remove, recursively for rm_rf | |
make a directory (mkdir, mkdir_p) | a 9P create with the directory bit |
Binds. bind(prefix, conn) adds an entry to the session’s own namespace: the connection
conn, already held, appears at prefix. It changes nothing on any server and creates no
authority, and no other process sees it. It replaces what Unix does with symbolic links, hard
links and bind mounts: giving something a second name. A bind at a prefix already bound replaces
what was there, and the table holds at most 64 entries, so a bind of a new prefix past them is
{:error, :system_limit} (beamlet). A bind lasts as long as the session,
or as long as the budget the connection came through when it came through one the session
carved (the send right a launch with serve in that budget returned, or a handle a call on
such a connection brought): destroying the budget unbinds every prefix naming the connection, so
a path there is :enoent and not a dead server’s refusal, and the session’s handle to it is
closed when its last copy goes (a pipeline’s piped, bound at /dev/pipe;
a_served_servers_binding_and_handle_go_with_its_budget).
- Replacing is within the session’s own authority. A bind over
/bootor/dev/conschanges what the session’s ownexecreads its program from and mints its child’s console at, as any path does: a session can already run any code it writes, so a program read from elsewhere gains nothing (native programs)./bootin a session is the boot bundle only while the session leaves it so. - A child gets only what its launcher writes into its startup block: the shell’s
execwrites/dev/consalone, so a session’s binds reach a child only if the session passes them on (native programs). - One connection, one badge. A connection bound at two prefixes is one connection, with one
badge and one label set, so every server’s check is the same through either name. A handle the
VM holds that is no connection yet is attached once, over 9P; a server that does not speak it,
or a budget, is
not_a_connection.
Labels on files
Status: planned · M1 (sessions over SSH, kept apart)
Labels are per volume: each volume has its own file server instance and its own label set, fixed when the volume is set up (the file server). The file server checks every request against the caller’s label set, which the kernel stamps on the message (R14 (unforgeable sender)), by the servers’ label rule (labels):
- Read needs the caller’s labels to include the volume’s. A session with
{tax}reads an unlabelled volume and thetaxvolume; an unlabelled session reads only unlabelled volumes. - Write needs the caller’s labels to equal the volume’s. A vault session cannot write its principal’s unlabelled home, so it cannot copy labelled data down into it.
- Metadata follows the data. Names, sizes and listings of a labelled volume are refused to a caller that cannot read it, so an unlabelled caller can neither read labelled data nor learn that it exists.
Open: none.
Sharing a directory
Status: planned · M6 (persist, install, share)
Alice shares a directory with Bob through the steward. The only delegation primitive is
new_connection(root, quota): it mints a connection rooted at a subdirectory, with its own byte
quota, and returns it with a random id (the serving library).
- Alice asks the steward to share
/home/alice/sharedwith Bob. - The steward mints the share into a revocation scope: a budget with no pages, processes or weight, made under Alice’s budget only to be destroyed later. It passes the connection to Bob.
- Bob can narrow it further: asking the file server for
shared/subgives a handle that keeps the share’s stamp (R9 (stamps)). - Alice un-shares: the steward destroys the scope, and every handle stamped with it dies,
subincluded, wherever the copies went (R10 (destruction)).
Labels still decide access: a share of a labelled volume reaches only a principal whose sessions carry the label. A project, a principal sponsored by several members, shares its own volume the same way, one revocation scope per member (the steward).
Open: none.
Why
Capabilities instead of permission bits. A mode and an owner are a question every server asks
about every file (“may this user do that?”), and the answer depends on a global table of users.
A connection is the answer given once: holding it is the permission, narrowed by the server when
it was minted (a subdirectory, read-only). There is nothing to chmod because there is no table
to consult.
No symlinks or hard links. A symbolic link is a path the server follows on the client’s
behalf, which is how a program is tricked into writing where it should not (a link from /tmp
into someone’s home). A hard link gives one file two parents, which breaks per-directory quotas
and makes “remove” mean two things. A bind gives something a second name in one process’s
namespace, and cannot point anywhere that process could not already reach.
Rename is not always atomic, and the docs say so. A rename between two servers cannot be atomic without a transaction protocol between them, and a transaction protocol between file servers would be the largest shared code in the system. Redoubt keeps each server alone and says which renames are copies.
Removing an open file succeeds. If a server refused to remove a file another client has open, the refusal would tell one client something about another: a channel. So a remove always does what it says.
Native programs
A native program is a statically linked Rust program that runs as a process of its own, in a
budget of its own, with exactly the handles and namespace its launcher wrote into its startup
block. The session launches one when a stage must run apart from the session’s VM: untrusted
input, work that needs its own address space, or authority the session should not lend. Programs
are written against redoubt-rt, the native runtime; they read and write their standard streams
as files, join into pipelines through served pipe files, and end when their budget is destroyed.
Purpose
Most work in a session is Elixir and needs no process of its own. A native program is for the
rest: a parser for a file format nobody trusts, a compressor, a compiler’s back end, a tool from
outside. Redoubt has no fork, no exec and no file-descriptor table, so launching, standard
I/O, pipes and killing all work differently from Unix. This page says how, and what the runtime
gives a program.
How to use it
From the session, a pipeline of native stages:
pipe(~w(zcat big.gz | sort | uniq)) |> w("uniq.txt")
cat("dump.bin") |> pipe(~w(parse --json)) |> grep("warn") # an untrusted parser as one stage
The explicit form gives every authority by name:
{:ok, job} =
Redoubt.Process.start("/boot/bin/grep", ["-n", "needle"],
namespace: [{"/", work}, {"/dev/stdin", pipe_r}, {"/dev/stdout", pipe_w}], # recommended names
handles: [keys: keyd],
budget: [pages: 4096, processes: 1, weight: 10])
Job.await(job).exit
Job.kill(job)
A program is a Rust crate on redoubt-rt; src/bin/echo-client.rs in libs/rt is a complete
one:
#![allow(unused)]
#![cfg_attr(target_os = "none", no_std, no_main)]
fn main() {
redoubt_rt::entry!(run);
fn run(startup: &redoubt_rt::startup::Startup) -> u32 {
let (conn, rest) = startup.resolve("/echo").expect("no /echo in my namespace");
// ... a 9P client on `conn`, walk `rest`, read and write ...
redoubt_rt::exit::OK
}
}
What it can and cannot do
The loader stub
Status: built · tested (12)
- bench:stub-launch
- fuzz:stub/plan
- host:stub::plan_maps_a_well_formed_segment
- host:stub::plan_refuses_two_segments_that_overlap_each_other
- host:stub::plan_refuses_a_segment_reaching_into_the_stub_region
- host:stub::image_in_bounds_refuses_an_image_overlapping_the_startup_page
- host:stub::plan_refuses_writable_and_executable
- host:stub::plan_refuses_writable_without_readable
- host:stub::plan_refuses_a_non_riscv_machine
- host:stub::plan_refuses_an_entry_outside_any_executable_segment
- host:stub::plan_refuses_more_than_max_phnum_segments
- host:stub::read_image_refuses_an_image_len_over_the_cap
By design every process after init is launched one way. Both halves are built: the launcher’s
calls, made in the bench by a user-class parent, and the stub
(stub/src/lib.rs, stub/src/main.rs):
- The launcher creates an empty process in the target budget, naming the endpoint that will receive its exit notice (processes).
- It maps the loader stub into the process at its fixed address: a flat binary, the same for everyone, which needs no parsing to map.
- It copies the program’s ELF bytes into the process as data, 64 pages at a time, each batch into fresh pages it then moves in, so a launcher never holds more than one batch, writes the startup block (namespace, named handles, arguments) into a page mapped read-only, and starts the process at the stub with the startup page’s address as its argument.
- The stub, running inside the new process’s own budget, parses the ELF from memory, maps its segments at their link addresses (code executable, never writable), frees the image pages, and jumps to the entry point.
The launcher copies bytes and never parses an ELF, so a malicious ELF can at most compromise the process it was going to become. The stub refuses, by exiting: segments that overlap each other, the stub, the startup page or the stack; a segment writable and executable, or writable without readable; an entry outside every executable segment; more than 64 segments; a machine other than RISC-V; and an image longer than the cap. In the bench a user-class parent launches a well-formed child and a set of hostile ELFs, including 32 with fuzzed headers, through the real stub: each hostile child only exits or faults, the budget the children run in is back to empty after each, and a well-formed child still runs afterwards.
Launching from a session
Status: built · partly tested: over SSH a steward’s session launches, a vault session and a plain one; on the UART console the session is a tester’s in the steward’s place · tested: bench:steward-vault-launch, bench:beamlet-launch, host:beamlet-redoubt::a_launch_takes_what_it_is_given_and_its_end_is_an_event, host:beamlet-redoubt::a_labelled_sessions_child_takes_its_labels_and_runs, host:beamlet-vm::a_launch_takes_everything_from_its_caller_and_its_end_arrives_as_a_message, host:redoubt-client::a_bad_launch_is_refused_before_any_kernel_call
A session launches a native program through beamlet’s launch native
(beamlet), and its end arrives as a message to the process that launched
it; the shell’s exec is a pipeline of one stage and its wait
(the shell, pipes).
The namespace, the handles and the budget come from the Elixir caller, so every authority the
child gets is on that one call.
- The launcher reads the program. There is no kernel path lookup: a session that cannot read
a program’s file cannot run it. In M1 (sessions over SSH, kept apart) programs come from the boot
bundle,
/boot. - No signature is needed to run code within one’s own authority. A session can already run any Elixir it writes, so a program it launches with a subset of its own handles gains nothing. Signatures gate only what the steward launches with new grants (packages).
- At most
MAX_START_HANDLES(128) handles, namespace entries included. - No shared text. Each launch copies the program image; there is no demand paging and no code shared between processes (init).
- No dynamic linking. Code shared at run time is a server, not a library. The dynamic part of the system is the BEAM, whose modules load at run time.
The launch native is the client library’s launch: the namespace, the handles and the budget come
from the Elixir caller, and Rust makes the calls and writes the startup block
(the client library).
Standard input and output, and pipes
Status: built · partly tested: in a boot the stages run under a tester in the steward's place, but for `exec`, which also runs in the steward's sessions over SSH, and for hostile output, drawn by the console principal's shell; a stage's console input is host-tested only · tested (12)
- bench:pipe-carries
- bench:pipe-eight
- bench:pipe-sixty-four
- bench:pipe-never-reads
- bench:pipe-no-authority
- bench:pipe-interrupted
- bench:pipe-hostile-output
- bench:beamlet-launch
- bench:steward-vault-launch
- bench:piped-host-tests
- host:redoubt-piped::a_stage_reaches_only_the_end_it_was_given
- host:redoubt-piped::a_pipe_carries_one_stages_bytes_to_the_next_and_ends_when_its_writer_goes
There is no pipe object, no file-descriptor table and no inheritance. A program’s standard
streams are names in its namespace, /dev/stdin, /dev/stdout and /dev/stderr, and a pipe is
a 9P file that the session’s piped serves: a pipeline is the session
minting each stage a connection at one end of one pipe for each of its streams, and binding those
three in its namespace before starting it (Redoubt.Pipeline).
A stage gets those three entries and nothing else: no other file, no console, no handle of the
session’s.
flowchart LR
Z["zcat<br/>budget 1"] -->|"its /dev/stdout"| P1["pipe 1"]
P1 -->|"its /dev/stdin"| SO["sort<br/>budget 2"]
SO -->|"its /dev/stdout"| P2["pipe 2"]
P2 -->|"its /dev/stdin"| U["uniq<br/>budget 3"]
subgraph PD["the session's piped, in a budget of its own"]
P1
P2
end
Figure: a pipeline. Each stage runs in its own budget; each pipe is a served file, bound as one stage’s standard output and the next stage’s standard input. Pipe 0, before the first stage, and the last pipe are the session’s to feed and read, and every stage’s standard error is one more pipe the session draws.
What follows from pipes being served files:
- Backpressure is free. A pipe holds one page; a write takes what fits and its call waits, parked, while nothing does (piped).
- End of file falls out of the exit notice. When a stage exits, the launcher disconnects its
connections, and
pipedsees its writer go; the next stage reads what is left and then the end of its input. Its writer’s next write is refused (state,enotconn) once it is gone. - Labels work out.
pipedis a user-level server with no label exemption, carved from the session’s budget and serving that session alone, so a pipe between two label sets fails, as it should (R1 (flow)). - No stage holds the console. A native stage never gets
/dev/cons. A stage the person runs withexecreads the lines they type, which the shell’s driver hands the session while the stage runs, and Ctrl+D on an empty line is the end of its input; the interrupt keys stay the shell’s (the shell). What a stage writes, its output and its standard error, reaches the console only as the session draws it, through the shell’s guard, so a control sequence in it shows as text (the shell). - A stage can draw a screen. Run with the shell’s
screen, a stage writescellsframes, each a length and its bytes, and reads its keys and its screen’s size as events; the session checks each frame and draws it, and ends the stage, its budget destroyed, at the first it refuses (the shell). It holds the same three streams and nothing else. - A pipe is readable as a file. A zero-copy alternative, stages sending pages to each other over an endpoint, is not 9P, so a program could not read its input as a file; it is not taken.
Who serves a pipe: piped, not the session’s VM. The VM could serve them only through natives
that make endpoints and mint badges, with a serve thread that ends every request at 5 s
(REQUEST_WAIT_US) while a reader waits on its writer for as long as the writer takes, and with
the multiplexed protocol a native client speaks written again in Elixir, every byte then going
through the interpreter. piped is the serving library’s 9P skeleton with one small file server,
started from /boot when a pipeline needs it and none is running, with the launch native’s
serve (beamlet), and ended, its budget destroyed, when the last pipeline
holding it ends: nothing of it exists at the prompt.
A pipeline (Redoubt.Pipeline.run/2, and the shell’s pipe/1 and exec):
- One budget per stage, carved from the session’s (256 pages, 1 process, weight 1, unless the caller says), and the stages started last first.
- The job is complete when its last stage ends. Nothing more can reach its output, so every other stage’s budget is destroyed then, a stage that never reads and one that never ends among them, and every budget of the job is destroyed before the pipeline returns.
- Its owner. One Erlang process launches the stages, so their exit notices come to it, and watches the line that asked: if the line ends first, interrupted or crashed, the owner destroys every stage’s budget, so no stage outlives the line that ran it.
- Bounds. At most 7 stages, and no more than the session’s budget has processes for: a
pipeline the budget cannot hold is refused
out_of_processesbefore any stage starts. A refusal by a server of the session’s own names the step and the server’s reason,{:error, {:refused, why}}:{:read, path, reason}for a program’s image from/boot,{:mkdir, reason}for a pipe’s directory atpiped,{:mint, reason}for a stage’s stream connection;:not_foundis a program/bootdoes not have. A freshpipedneeds one of the VM’s six waiter places: with every place held, the pipeline’s first operation parks off the VM’s thread until an ended server’s waiter hands its end over, up toFILE_WAIT_US(10 s), so pipelines back to back under load get their place (beamlet); six live servers at once refuse the seventh{:mkdir, :emfile}at that bound. The image’s session holds 10 processes, its VM andpipedamong them, and its console’s relay once a session has one.pipedlets the session and its stages park 8 calls between them, a stage’s one and the session’s own, which is what bounds a pipeline at 7. The VM runs 16 jobs at once (MAX_JOBS), so that never binds first. - Pages.
piped’s budget and each stage’s are carved from the session’s own pages, so they compete with the VM’s headroom while a pipeline runs: the session’s budget is the bound.piped‘s is 768 pages, carved when a pipeline starts it and destroyed when the last pipeline holding it ends, so a session that is not piping holds none of it, after any number of pipelines (pipe-eightruns eight in one session): not its binding at/dev/pipenor the VM’s handle to it, which go with its budget (beamlet;pipe-sixty-four, 64 in one session, finds the namespace table its size before the first and nothing at/dev/pipe), where each pipeline once left one entry and one handle for the session’s life. It holds 86 pages on rv64 and 83 on rv32 afterpipe-carries’ pipelines; the rest is what its clients’ parked calls may lend it at worst, 552 pages for the two buckets admission needs at the least, which is why 512 would not do (piped). Starting it again costs about 60 ms under QEMU, whichpipe-carriesprints.
The M1 (sessions over SSH, kept apart) residual is closed. Until pipes, a program the shell’s exec launched held a connection
of its own to the session’s console, read what the shell would have read, and wrote to the
terminal past the shell’s guard. It now holds neither.
Residuals:
- A stage cannot end its output before it exits. Its clunk is not the end of the stream, so a stage that closes its standard output and runs on leaves its reader waiting until it exits (piped).
- The stages of one session share one admission bucket at
piped, so one that parks calls on many connections of its own can make another’s wait refusedtoo_many; every stage is the same session’s.
Killing a job
Status: built · tested: bench:job-kill, bench:job-interrupt-native, bench:job-interrupt-ssh
There is no per-process kill and no signal. Killing is destroying a budget
(R10 (destruction)): every process in it ends, every handle
stamped with it dies wherever it went, and every budget carved from it goes too. So the shell
carves one budget per native stage from the session’s, and Job.kill(job) destroys that budget
and nothing else.
A process has one exit notice, delivered once, on the endpoint named when it was created;
there are no death subscriptions. The notice says how it ended: exited with its code, faulted
with the fault’s cause, or killed when its budget was destroyed
(processes). Job.status/1 reports those as :exited,
:faulted and :killed. When the notice arrives, the launcher disconnects the child’s
connections and releases what typed servers granted it.
Ctrl+C, the session’s own key, and over SSH a channel’s INT or break end the line being evaluated, and with it every native stage of the pipelines it runs in the foreground; a job in the background runs on (the shell).
Underneath: beamlet’s budget_destroy/1 is the kill, and the launch native’s {exit, Job, Cause, Code} message is the exit notice, with exited, faulted or killed
(beamlet); each pipeline’s owner process (Redoubt.Pipeline) is the job,
and Redoubt.Job and Redoubt.Jobs the shell’s handle on it.
redoubt-rt, the native runtime
Status: built · partly tested: the 9P client's walk limit and its checks of a reply's tag, type and counts are not attacked; only its closing of stray handles is · tested (33)
- bench:rt-build
- bench:net-tcp
- host:redoubt-rt::echo_pair_runs_on_the_runtime
- host:redoubt-rt::a_launcher_gives_its_child_a_fresh_connection_and_disconnects_it
- host:redoubt-rt::exit_codes_reach_the_parent
- host:redoubt-rt::a_panic_is_reported_on_the_console_once
- host:redoubt-rt::heap_over_map_anon
- host:redoubt-rt::heap_in_a_fixed_arena
- host:redoubt-rt::capped_heap_refuses_before_the_kernel
- host:redoubt-rt::a_fixed_heap_is_never_capped
- host:redoubt-rt::the_record_is_zero_until_marked
- host:redoubt-rt::call_lend_and_reply
- host:redoubt-rt::send_transfers_pages_for_good
- host:redoubt-rt::timeouts_dead_endpoints_and_refusals
- host:redoubt-rt::ownership_lifecycle_partial_reply_and_address_reuse
- host:redoubt-rt::mapping_reborrows_and_failed_reply_recovery
- host:redoubt-rt::the_9p_client_closes_handles_a_hostile_server_sends
- host:redoubt-rt::threads_share_one_connection_with_their_own_lends
- host:redoubt-rt::a_consumed_lend_is_replaced_by_fresh_pages
- host:redoubt-rt::a_spawned_thread_runs_its_closure_as_this_process
- host:redoubt-rt::a_refused_thread_drops_its_closure_unrun
- host:redoubt-rt::a_spawned_stack_sits_above_an_unmapped_guard_page
- host:redoubt-rt::a_spawned_stack_is_painted_from_the_top_for_the_scan
- bench:thread-stack-guard
- host:redoubt-rt::a_mutex_excludes_and_loses_nothing_under_contention
- host:redoubt-rt::waiters_for_a_held_lock_sleep_and_each_gets_it
- host:redoubt-rt::a_condvar_ping_pong_loses_no_wake_up
- host:redoubt-rt::notify_one_wakes_each_waiter_once
- host:redoubt-rt::semaphore_tokens_are_neither_lost_nor_doubled
- host:redoubt-rt::scope_waits_for_its_threads
- host:redoubt-rt::a_refused_thread_is_not_waited_for
- host:redoubt-rt::prepared_locks_make_no_endpoint_when_contended
- host:redoubt-rt::a_refused_endpoint_leaves_a_working_lock
redoubt-rt is everything a no_std Rust program or server needs between the system-call ABI
(redoubt-sys) and its own logic (libs/rt/src/lib.rs). It builds
for rv64 and rv32, and the network servers built on it run on the real kernel, launched through
the loader stub.
| Module | What it gives |
|---|---|
start | the entry point (entry!, and first_entry! for init), exit codes (OK 0, PANIC 101, BAD_STARTUP 102) and the panic handler |
startup | the startup block, parsed defensively (sessions) |
handle | typed handles and the system calls that are not IPC |
ipc | lends and transfers, call, send, receive, reply, serve |
heap | the global allocator, over map_anon, or over one arena mapped once (fix_heap, for init’s bound); capped at the startup block’s heap_pages (init), past which it refuses before map_anon is asked (an infallible allocation then panics, and the runtime exits with its panic code, 101: heap-cap); and a record of its cap and peak for the bench (the memory budget) |
path | lexical path cleaning, so .. never climbs above a root |
client | a small synchronous 9P client |
server | the shared server library (the serving library) |
thread | spawn: a closure on a thread of this process, with a stack that outlives it, placed by map_fixed in the thread-stack area (memory layout) above an unmapped guard page, so an overflow faults there and the kernel ends the process at once (thread-stack-guard) instead of writing over what map_anon placed below; painted before the thread starts, so the bench’s memory scan reports its peak as stack NAME thread N PEAK (the memory budget); scope: threads that borrow from their caller, all ended before it returns |
sync | a semaphore, a mutex and a condvar for the threads of one process, whose waiters sleep on an endpoint |
- Start and end.
entry!(run)receives the startup page’s address from the loader stub, parses the block, and callsrun; its return value is the exit code. A block that does not parse exits withBAD_STARTUP. A panic prints its message once on/dev/cons, if the program has one, and exits withPANIC; a program that names a panic hook inentry!has it run first, once per process, before the report (netdstops its device there). If the program held open calls, the kernel blames the sender of the call it was serving (R21 (crash blame)).init, which the loader starts with no startup block, declaresfirst_entry!(run)instead:runreceives the bundle the loader mapped read-only, as a&'static [u8](boot). - The lend belongs to the call.
calltakes ownership of the buffer it lends. When the call completes, the outcome hands the buffer back unless the server had received the call and it was then abandoned, in which case the buffer is consumed: disarmed without touching or unmapping its old address, since the pages stay with the server (R3 (lends and abandoned calls)). So a program can never hold a safe object claiming memory it no longer has, or unmap an address that has since been reused. - A partial reply is not lost. The outcome carries the status, the buffer when returned, and
the reply when the kernel committed one, independently. A reply that arrived with some handles
missing (
OutOfMemory) still owns the handles that were delivered; dropping an unclaimed reply closes them, so an error never leaks a handle slot (R13 (one outcome per call)). Any facade above the runtime has to keep this accounting. - The 9P client does not trust the server. A
Connectionholds the endpoint and no buffer or fids, so threads share it; each request lends its caller’sLend, one per thread, and a lend a call consumed maps fresh pages on its next call. A walk is at most 16 components after cleaning, refused rather than split; a reply must decode, carry the request’s tag and be the matching reply, and every count is checked against what was asked. A 9P reply carries no handles, so any that arrive are closed. For launchers it also hasnew_connection(root, quota, scope), the scope the connection is minted in, anddisconnect. - Locks sleep on endpoints. The kernel has no futex and the runtime no thread-locals, so a
syncsemaphore makes an endpoint the first time a thread must wait on it: a waiter that takes its count below zeroreceives there, and a release that raises it from below zerosends one empty message, which one waiter takes. AMutexspins a little, then sleeps on its semaphore, and its unlock hands it to a sleeper, so a thread waiting for a holder the kernel preempted sleeps rather than spins. ACondvargives each waiter a semaphore of its own, so a later waiter cannot take a wake-up meant for an earlier one. A semaphore’s endpoint costs a page of the process’s budget and two handles, paid at its first contended wait and given back when it is dropped;preparepays it at once, for a program that must not allocate when its locks are contended (beamlet’s VM, for its schedulers’ locks). An endpoint the kernel refuses ends nothing: the lock’s waiters poll every 100 µs for the hand-over instead of sleeping in the kernel. A release that wakes a sleeper waits until that thread reaches itsreceive, so an unlock can wait for the woken thread to be scheduled; the woken thread holds nothing then, so it never deadlocks. Taking a lock its thread already holds deadlocks. Under Miri (rt-miri) the locks run on host threads acting as one process, sincethread_create’s closure crosses the fake kernel as an integer Miri cannot follow;scopeis tested natively only. - Tested on the host against a fake kernel. Every system call goes through one function, to
a
Transport: on the machine theecall, on the host the fake kernel a test installs, and any other backend the same way, so the runtime and programs built on it (the echo client and server) run in host tests. Nothing above the runtime calls theecallitself: only the loader stub and the kernel’s test programs, which test the ABI from below it, useredoubt_sys::syscall. - No safe call pulls memory from under its owner. The runtime’s calls that could invalidate
memory a safe owner holds are its owners’ alone:
unmapis private to the heap,Buffer,DmaandRegisters, each unmapping only what it mapped itself,set_flagsis not offered at all, andProcess::mapmoves pages only by taking theBufferthat owns them, so the raw address it once took is no longer a way round.map_anononly makes memory, and hands back an address that takesunsafeto use. A publicunmapdoes not compile (acompile_failtest inhandle.rs). Adma_allocrun is held by aDma, which unmaps it on drop and not before; the frames stay the kernel’s until the process ends. A device’sRegistersare unmapped only byRegisters::unmap, which takes the value, so no access is left to reach them (howinitgives the UART up toconsoled). - No raw call from safe code.
redoubt_rt::abiis the kernel’s types and limits withoutredoubt-sys’ssyscall, so a program built on the runtime makes every call through it. The no-cruft case refuses a wholesale re-export and a single-line re-export orpubitem namingsyscall; a multi-line re-export list, and apub fnthat wraps the call, are review’s. The loader stub and the bench’s test programs callredoubt-sysdirectly and are not built on the runtime. - A thread’s stack is its own.
thread_createtakes anextern "C" fn(usize) -> !and aBuffer, and keeps theBuffer’s pages mapped for good, even after the thread exits, so no owner holds a thread’s stack and no entry is an arbitrary address. A raw form does not compile (acompile_failtest inhandle.rs). - Its
unsafeis few and attacked. The runtime holds 10 uses (unsafe-budget.toml): the allocator’s trait, the heap’s two words of a free block, a page buffer’s two views, the device registers’ read and write, and the pages mapped before the first instruction (the startup page andinit’s bundle). Each states what it rests on and who guarantees it; the heap, the views and the registers run under Miri in the bench (rt-miri).
Libraries for native programs
Status: planned · M2 (usable shell)
Native programs are no_std with alloc, so most of Rust’s command-line crates, built on std‘s
files, processes and threads, do not build for them. These do: each builds for
riscv64imac-unknown-none-elf with the features named, and none has C in its tree. Applications
have more latitude than servers (the tenets),
but each is vendored under vendor/ when a program first uses it, with its provenance recorded as
the others’ is (vendored dependencies).
| Purpose | Crates |
|---|---|
| Screens | cells (ours: the frames a program with a screen writes, the shell), unicode-width, unicode-segmentation |
| Text | crop (a rope), regex, memchr, aho-corasick |
| Compression | miniz_oxide (deflate, as beamlet uses), ruzstd (zstd), lzma-rust2 (xz), lz4_flex |
| Archives | tar-no-std (reading; writing is ours) |
| Data | serde_json (alloc), jaq-core, jaq-std, jaq-json (a jq in pure Rust) |
| Hashing | sha2, blake3 (feature pure, which skips its assembly) |
| Time | jiff |
Three more need a small patch before they build, each only a missing no_std attribute or one
std path: imara-diff (diff), wildmatch (glob patterns) and nucleo-matcher (fuzzy
matching). Argument parsing (after lexopt, over the startup block’s arguments) and a zip reader
over miniz_oxide are ours to write.
Every one is also to build for rv32, where the vendored crates are checked too, after rv64.
Open: which of these build for rv32 as they stand; none has been tried there.
The client library
Status: built · partly tested: `init-boot` exercises launching on the machine; `littlefsd`'s operations and `consol`'s `size` and `resize` remain tested against in-test servers until `littlefsd` exists and `consoled` serves `consol` in M2 (usable shell); a partial reply is the runtime's accounting, attacked there, not here · tested (29)
- bench:client-host-tests
- bench:client-build
- bench:init-boot
- host:redoubt-client::each_operation_names_its_files_fids
- host:redoubt-client::files_on_two_connections_are_refused_before_any_call
- host:redoubt-client::a_child_gets_the_stub_its_image_a_stack_and_its_block
- host:redoubt-client::a_bad_launch_is_refused_before_any_kernel_call
- host:redoubt-client::a_refusal_midway_hands_the_budget_back
- host:redoubt-client::the_exit_notice_releases_every_grant
- host:redoubt-client::a_hung_server_does_not_stop_the_reaping
- host:redoubt-client::a_killed_job_ends_with_its_notice
- host:redoubt-client::typed_calls_reach_keyd
- host:redoubt-client::a_refusal_is_the_servers_code
- host:redoubt-client::a_hostile_reply_leaves_no_handle
- host:redoubt-client::a_server_dying_mid_call_is_disconnected
- host:redoubt-client::a_session_reads_boot_through_the_library
- host:redoubt-client::a_refusal_is_the_servers_and_costs_no_fid
- host:redoubt-client::a_path_too_long_to_send_costs_no_fid
- host:redoubt-client::a_dropped_file_keeps_its_fid_and_a_closed_one_returns_it
- host:redoubt-client::files_are_created_written_and_removed
- host:redoubt-client::a_minted_connection_cannot_climb_out_of_its_root_and_a_refused_quota_mints_nothing
- host:redoubt-client::threads_share_a_connection
- host:redoubt-client::a_gone_server_is_disconnected_every_time
- host:redoubt-client::a_session_writes_and_reads_the_console
- host:redoubt-client::a_labelled_session_cannot_write_the_console
- host:redoubt-client::size_and_resize_come_from_the_server
- host:redoubt-client::the_namespace_resolves_by_longest_prefix
- host:redoubt-wire::layouts_are_the_tables
- host:redoubt-wire::replies_and_error_codes_decode_through_the_trait
redoubt-client (libs/client) is the one client API every
userland binds to: native programs link it, beamlet’s Redoubt platform and natives are thin
adapters over it (beamlet), and init launches and asks its
servers through it. beamlet’s VM is a caller of ns, through its namespace natives, and of
launch, through its launch native (beamlet).
It is no_std with alloc, has no unsafe, and sits on the runtime and the wire codecs, adding
what is more than one typed call. Its calls block, one per thread, or a hub keeps many 9P requests
outstanding on as few threads as one (below); beamlet’s VM owns a hub
(asynchronous underneath).
| Module | What it gives |
|---|---|
ns | the namespace, built from the startup block: the longest matching prefix, bind, the listing |
file | files over 9P on a connection: walk, open, create, read, write, stat, read a directory, remove; one fid per open file |
aio | the hub: many 9P requests outstanding on multiplexed connections, their buffers in and out by value (below) |
littlefsd | the file server’s typed operations that name open files’ fids: rename, copy_file, set_attr, get_attr (littlefsd) |
console | /dev/cons: read, write, size, and the parked resize (consoled) |
launch | the process builder: the image bytes the caller read, a budget the caller carved, the endpoint for the exit notice, namespace entries, named handles and arguments, written by the runtime’s StartupBuilder; it returns a job, whose exit notice the caller waits for and whose budget ends it |
grants | the launcher’s ledger of what servers granted a child, released and disconnected when the child’s exit notice arrives (wire) |
typed | one call for any typed protocol, over the module the generator wrote from its table (wire) |
The launch builder’s stack_pages chooses the first thread’s mapped stack (16 pages by
default, at most 128). Its stack_tag identifies a server’s stack to the bench: a tagged launch
paints the stack before the child starts, so a stopped-guest memory scan can measure the deepest
unit it touched (the memory budget); an untagged stack is
zeroed.
Time and randomness are the runtime’s kernel calls, and raw call, send and serve are the
runtime’s ipc, which beamlet’s natives use directly. /net is files, so file covers it. There
is no module per typed server beyond littlefsd, whose operations name fids that live in Rust: for
every other server typed with the generated module is the binding, and a session binds the same
tables through generated Elixir clients (wire).
- A namespace owns its connections. A
bindputs the same connection under another prefix: one connection, one badge, as a copied handle is in beamlet. An open file keeps its connection for as long as it is open. A handle bound at two paths of a startup block is one connection. - Every call lends the caller’s pages. A call takes the caller’s
Lend, one per thread and reused, so the library maps nothing behind its caller’s back; an inline typed message lends nothing at all. - Nothing is buffered, cached or retried. One read or write is one 9P request of at most the
connection’s
iounit, and its error is its own, never deferred. Every open walks from the connection’s root, so a rename, a removal or a revoked connection shows on the next open. A connection whose server has gone isDisconnectedon every call; the library never reconnects, since a new connection is its launcher’s to grant (init). - A connection is shared by threads. Its fids come from one allocator, and each request lends
its own buffer, so several threads use one connection at once. A fid goes back to the allocator
only once the server has let it go (its clunk’s reply), or once its request is refused before
it is sent (a path that does not clean or does not fit the lend), so an untrusted path cannot
drain a shared connection’s fids. A file dropped without
closemakes no call from its drop, and one whose clunk timed out stays in use: a fid is never reused while the server may still hold it. How a dropped file’s fid comes back is below. - Policy is the servers’. The library holds none and makes no check a server does not make: the label check is the server’s (R25 (the label check)).
- One error type tells apart the kernel’s error, a reply that does not decode, the server’s
protocol error (a typed error code or a 9P
Rerror) andDisconnected. No error path drops a handle: the runtime’s accounting of a call’s outcome is kept whole (R13 (one outcome per call)). - No second copy of the ABI, the startup encoder or a wire format:
launchcallsStartupBuilderand places the child where the stub crate’s launching convention says, andtypedcalls the generated codecs through thetyped::Protocoltrait each generated module implements (wire). - A launch refuses before the kernel does. More than
MAX_START_HANDLEShandles, an empty image, a stack of no pages or more than 128, or a block the parser refuses fails beforeprocess_create; a kernel refusal after it hands back the caller’s budget, holding the process that never started, for the caller to destroy. The caller brings the stub’s bytes as it brings the image’s, and each job has its own exit endpoint, sinceprocess_creategives no PID to tell two children’s notices apart. - A release is bounded. A child’s grants are released when its exit notice arrives, each
within
RELEASE_TIMEOUT(a second: one short call a live server answers at once), so one hung server cannot stop a launcher reaping; a release that times out is reported in the job’s end, not retried, and the rest still go. A typed grant is released through the endpoint handle it was recorded with, which must stay open until the job has ended: closed sooner, its slot is stale, and the release goes to whatever the slot holds then, or nowhere. - Tested on the host against the real servers: the runtime’s fake kernel is a crate of its own
for tests (
libs/rt/fake), andbootfsd,consoledandkeydrun on it, so each userland’s bindings, beamlet’s platform included, are tested long beforeinitboots them. The fake kernel also keeps what a launcher gives each child, solaunch’s block is read back by the runtime’s own parser. - One scripting language. Elixir on beamlet is the box’s scripting language; no embedded script language is taken as a further userland, and the library binds any language that might be (other runtimes).
The attack cases: no call succeeds where the underlying call is refused (a label, a quota, a walk
above a connection’s root, a launch with MAX_START_HANDLES + 1 handles refused before any kernel
call); an error path never leaks a handle (a partial reply, a server that dies mid-call, a hostile
reply carrying handles); a request refused before it is sent costs no fid; a child’s grants are
released at every server when its exit notice arrives, and a server that never answers delays the
reaping by one timeout, no more.
Many requests at once
Status: built · partly tested: on the machine only in `aio-many-reads` and `aio-many-reads-two` · tested (13)
- bench:aio-many-reads
- bench:aio-many-reads-two
- host:redoubt-client::an_inline_submit_to_a_busy_server_returns_and_goes_at_the_next_poll
- host:redoubt-client::one_connection_needs_no_waiter_thread
- host:redoubt-client::buffers_come_back_to_their_submitter_by_value_in_any_order
- host:redoubt-client::a_flushed_requests_buffer_is_returned_exactly_once
- host:redoubt-client::a_tag_a_flush_names_is_not_reused_before_its_rflush
- host:redoubt-client::a_tag_is_not_reused_before_its_completion_is_taken
- host:redoubt-client::a_batch_goes_a_page_at_a_time
- host:redoubt-client::a_write_is_at_most_one_page
- host:redoubt-client::two_connections_have_a_waiter_each_and_the_caller_idles_in_receive
- host:redoubt-client::a_caller_busy_past_the_session_bound_keeps_its_session
- host:redoubt-client::a_server_late_past_its_hold_keeps_the_session
A call holds its thread until its reply, so a thread per call is a thread per outstanding
request. aio’s hub is the client half of a multiplexed connection
(the serving library): many 9P requests
outstanding on a connection, and as few threads as one. beamlet’s VM is a hub owner: its
schedulers submit, and a waiter per connection wakes it
(asynchronous underneath).
- The hub owns; it does not run. One
Hubvalue holds every connection’s tags, its queue and its completion buffer, and every buffer a request was submitted with. A buffer goes in by value with its request and comes back by value with its completion, once, whatever became of the request, so a page has one owner at a time. Its methods take the hub mutably: whichever thread holds it runs it. It isSend, neverSync: a process that shares it between threads locks it itself. - Submitting is inline. A submit sends the request from the caller’s own thread, waiting at
most
SUBMIT_TIMEOUT_US(1 ms) for the server to take it, so a busy server never stalls the caller (a VM’s scheduler). A request not taken stays queued, in order, and goes at the hub’s next entry: a submit, a completion handed in, a wait or a poll. Nothing else sends it: a caller that queues work and then idles re-enters the hub withinRETRY_US(10 ms) while anything is queued (a poll, or a wait bounded byRETRY_US); a receive that outlives that is the caller’s bug, not the hub’s. Requests sent together go end to end in transfers of one page, so a batch of 64 reads is one page on either width, and a send never needs more than the one page a server’s share may give a badge (a bucket of 2 pages is a page a badge); a request longer than a page goes alone. The server keeps a request that fits the words out of the page it came in, so on rv32, where the words carry 12 bytes and every read goes in a page, a read that waits does not pin the share’s one page against the next write (multiplexed connections). - Data moves as the kernel moves pages. A write’s data goes in a page of its own, transferred
to the server; a read’s data comes back in the completion call’s lend and is copied into the
read’s buffer, which is handed back. Writes go a page at a time, at most
MAX_WRITE(4 072 bytes) each, so a server whose share is one page a badge takes every one; a longer write is refusedTooLarge, for its caller to split. - One connection needs no other thread. The caller waits in the completion call itself when
it would idle, asking the server to hold it no longer than its own next deadline, and gets it
back by then only from a server with nothing to do: a server in a dispatch answers when it next
runs, so the caller’s deadline slips by at most the dispatch, never its session
(serving). That holds
only while nothing else must wake the caller, and only for a caller that idles at least every
COLLECT_WAIT / 2(5 s), since a session with no completion call parked for its session bound (at mostCOLLECT_WAIT) ends. Any other gives the connection a waiter. - More connections have a waiter each, a thread blocked in that connection’s completion call.
The caller idles in
receiveon an endpoint of its own, taking transfers of a completion buffer’s size; a waiter hands its filled buffer over as the transfer of a one-word wake-upsendthere, and calls again at once with a fresh one. A waiter talks to no other server, and the hub takes a wake-up only from the badge it minted for that waiter. - A busy caller keeps its sessions. A wake-up the caller does not take within
HAND_OVER_US(2.5 s, a quarter ofCOLLECT_WAIT) is held by its waiter, moved into the answers’ own pages, while the waiter calls again with a hold of 0, which keeps the session and takes what is ready, and offers the oldest again. It holds at mostMAX_HELD(4) such wake-ups; with that many it reads no more and waits for the caller without bound, and the server may end the session at its bound. - An
Rerrorkeeps its name, read by the same table as a blocking call’s (anRerrorhas a name);busy, over the connection’s share, isBusy, for its submitter to ask again. - The server is not trusted. An answer must frame, decode, carry a tag outstanding on its
connection and, for a read, fit its buffer; anything else ends the connection, as the server’s
own end does, and every request outstanding comes back ended, with its buffer, its fate
unknown. A request flushed before it was sent comes back flushed at once; one already sent
comes back with its answer, or flushed with the
Rflush. - A tag names one request until its completion is taken, not only until its answer is read: one completion buffer carries several answers, a server answers out of order, and a request the caller sends on taking the first must not take the tag of one still to come, so a caller may match completions to its requests by tag (beamlet’s VM does).
Dropped files, calls by path and generated calls
Status: planned · M1 (sessions over SSH, kept apart)
What the client library adds before beamlet’s files run on it, each keeping the rules above:
- A dropped file’s fid comes back. A fid is put on its connection’s list to clunk only when its file’s last reference is gone, a call in flight on it included, so no call is ever still using a listed fid. The next call on that connection, from any thread, takes one listed fid off the list, which no other caller can then take, and clunks it first with that caller’s lend; the fid goes back to the allocator on its clunk’s reply, as a closed file’s does, and a clunk that fails or times out keeps its fid in use and never fails the call it came before. So nothing is mapped or called from a drop, each fid is clunked once, a fid is never reused while the server may hold it, and a program whose files are dropped (beamlet’s belong to Erlang processes, which can be killed mid-read) does not run out of fids for them.
- Calls by path.
Namespaceopens, creates, stats and removes by a full path: the lookup, then the call on the connection it found, so a caller cannot take one connection and use another’s rest of the path. - Whole reads and writes.
read_to_end, up to a limit its caller gives, andwrite_allloop over one-request calls; nothing is buffered between calls, each request’s error is its own, a read past the limit is refused rather than grown, and a write that fails midway says how much was written. - Generated calls for Elixir. A session binds each typed server through a client generated
from its table, one function per message (wire); Rust
keeps
typedwith the generated codec, which is already one call per message.
The attack cases: a program that drops every file it opens, a thousand times over one connection,
never gets NoFid, and no fid is reused before its clunk’s reply; a file dropped while another
thread is mid-read on it is clunked only after that read’s reply, and once; a server that never
answers a clunk delays one later call by one timeout, and fails none; a server that answers with a
text outside the table is other, and the text reaches no caller.
Open: none.
An Rerror has a name
Status: built · tested (4)
- host:redoubt-wire::every_text_reads_back_to_its_name_and_any_other_is_other
- host:redoubt-client::an_rerror_keeps_its_name_not_found_against_the_rest
- host:redoubt-client::an_rerror_through_the_hub_keeps_its_name
- host:beamlet-redoubt::not_found_at_the_open_is_absent_and_every_other_error_is_refused_by_name
A 9P server answers with one of a fixed set of texts, one table the serving library and this
library share (wire); the library keeps the name, never the
text, and a text not in the table is other (Error::Rerror(Name::NotFound),
Error::Rerror(Name::Exists), …). A blocking call and the hub read it alike: a hub’s
completion is Outcome::Rerror(Name). A walk is one Twalk of the whole path, and a walk of
several names that stops short says only how far it got, so a refusal after the first name (a
label check, a failed read) is not_found too. That is deliberate: a caller told “refused” where
it was told “absent” would learn that a name it may not read exists, which the label rule’s
“metadata follows the data” forbids (files); a caller that must
tell the two apart for a path it may read walks one name at a time, as beamlet’s lookup does.
That is what a lookup needs: beamlet’s module lookup takes not_found at the open as a name the
system lacks, and silently goes on, and any other refusal as a file it may not load, said on its
console with the name (R75 (verified userland)).
The Rust std target
Status: planned · M5 (self-hosted development)
For Redoubt to be developed on Redoubt, native programs need more of the client library: modules
for servers whose use is more than one typed call (the steward’s sessions and leases, ipd’s
scopes), grown into it from what their callers need, and perhaps a Rust std target so ordinary
crates build for the box. Programs are
built off the box (development).
Open: whether std gets a backend (std::fs over the client library’s files) or is rejected
in favour of explicit capability calls; the runtime’s ownership and error contract and the client
library are the base either way.
Why
Launch copies, the child parses. If the launcher parsed ELF files, a malformed program could
take over the launcher, which in a session is the session and at boot is init. With the parser
in a stub inside the child, running in the child’s own budget, the worst a hostile ELF can do is
wreck the process it was about to become. seL4 and Fuchsia launch the same way.
Standard streams as names, pipes as files. Unix hands a child its parent’s descriptors, and the child inherits whatever the parent forgot to close. Here a child has exactly the names its launcher bound: a standard stream is a file like any other, and a pipe is a server’s file whose backpressure and end-of-file come from 9P and the exit notice with no new kernel object.
Killing by budget. A per-process kill would be a new authority (who may kill whom?) and would leave a killed process’s children and handles to clean up. Destroying a budget is the one revocation the kernel has: it takes the process, its children and every handle stamped with it, and the launcher holds the budget, so the authority to kill is simply having launched.
One runtime, tested on the host. Every native program and server links the same runtime, so its ownership rules are written and tested once. The fake kernel makes those tests fast and lets them build situations (an abandoned call, a partial reply, a reused address) that are hard to arrange on the machine.
Agents, leases and labels
An agent is a program that acts for a principal and is assumed hostile: an AI agent or any other automated worker. On Redoubt an agent is its own principal, never an impersonation of a person, with a sponsor who answers for it. It runs in its own beamlet VM under a lease, a budget with a kernel deadline, holding only capabilities narrowed from its launcher’s. It can start sub-agents, each narrower again and inside its own lease. Labels bound what it can leak; capabilities bound what it can do; the lease bounds for how long.
Purpose
Redoubt exists so that the most capable and most malicious agents can run on it, do real work, and not get out. This page is what that means from the agent’s side and from its sponsor’s: how an agent is started and what it holds, how long it lives, what a hijacked agent can and cannot do, how the agent harness gives it tools, and the game that tests all of it continuously.
How to use it
Alice starts an agent on a task, with a lease scoped to it:
{:ok, agent} =
Redoubt.Agent.start(
lease: [time: :timer.hours(2), pages: 65_536, processes: 4, weight: 20],
labels: [],
grants: [
read: "~/project",
write: "~/project/out",
gateway: "model",
git: {"origin", fetch: true, push: ["refs/heads/agent/*"]}
]
)
Redoubt.Agent.prompt(agent, "Audit /project/src for unchecked lengths; write findings to /out")
Redoubt.Agent.status(agent) # %{pages: ..., weight: 20, lease_ms: 6_912_000}
Redoubt.Agent.kill(agent) # the steward ends the lease: the agent and its sub-agents end
When the agent needs more (another directory, a host to reach), it asks; the request waits for
Alice at ssh approve@box (sessions).
What it can and cannot do
An agent is a principal with a sponsor
Status: built · partly tested: the sponsored principal and the leases start_agent makes run on the machine; the chain of sponsors (an agent sponsoring an agent) is not built: a sponsor is a person’s principal · tested: bench:agent-login, bench:steward-lease-ends, host:redoubt-steward::a_sponsored_principals_session_is_a_lease_of_its_sponsors
Humans, agents and projects are the same kind of principal to the steward: a named, accountable identity with a way to authenticate, a set of capabilities and an audit identity. They differ in authentication and default policy, not mechanism (the steward).
- Its own principal, never an impersonation. Every action is the agent’s own and is recorded as such, with the chain of principals above it.
- Every agent has a sponsor: a person, or an agent with a person at the top of the chain. The sponsor answers for its agents.
- Its budget sits under its sponsor’s, so it bills to the sponsor’s account (R8 (accounts)). The sponsor can destroy it at any time.
- One VM per agent. An agent is its own beamlet VM, one trust domain; a sub-agent with different authority is another VM (beamlet).
- Assume it is hijacked. Anything an agent reads may take it over. A hijacked agent can do what its capabilities allow, until its lease ends, and nothing more; it can run code it wrote, but never with more authority than it holds (packages).
Leases
Status: built · partly tested: a lease’s grants are its sponsor’s ~/project and the system volume, and the read root is writable until a server takes a read-only connection; the task-scoped grants and gateways are later · tested: bench:steward-lease-ends, bench:steward-end-lease, bench:steward-sub-agent-dies-with-agent, bench:agent-login, bench:session-tcp-loopback, bench:steward-labelled-agent-no-sink, bench:steward-agent-escape, bench:steward-lease-cut
A lease is a budget with a deadline, made by the steward for an agent or a session. When the
deadline passes, the kernel destroys the budget and everything in it; the sponsor can end it
sooner. A lease is task-scoped: “read ~/project, write ~/project/out, the model gateway,
2 hours, 256 MB, 4 processes, weight 20”.
- At most
MAX_LEASE, 24 hours. That is the steward’s rule, not the kernel’s: the kernel knows deadlines, not leases. A longer request is refused, not clamped (budgets). - Ending a lease is always accepted from the sponsor, ahead of admission, so an agent that floods its sponsor’s share of a server cannot stop the sponsor from ending it.
- Three crashes blamed on one (account, label set) within ten minutes destroy every budget of that (account, label set), sessions and leases alike, and the steward refuses new sessions for it until the window passes: the steward’s blame-by-label-set rule (crash blame). An agent shares its sponsor’s account, so the sponsor answers for its agents, and a crash loop is stopped at the sponsor.
- When a lease ends, the scope’s destruction ends the agent’s connections at once, in every
table (R41 (narrowing by revocation scope)); the steward then
disconnects them at each server to free what they held, and releases what typed servers
granted; everything is recorded in the audit log from M5 (self-hosted development).
However it ended, the sponsor’s unlabelled sessions are told: the shell prints
[lease NAME ended]from the steward’slease_endedsend, naming the lease and never why (the steward). - An agent that must stay alive keeps a process runnable. A VM with nothing runnable, no
timer pending and nothing it waits on exits,
beamlet: Deadlock, an exit that is not 0 (beamlet), and a lease’s VM’s exit is its lease’s end as the steward reads it, said asexited CODE(the steward). So an agent that has done its work and must live to its deadline or its sponsor’send_leaseidles by a busy recursion, never by a receive with no timeout and no sender:Process.sleep(:infinity)is one, and an agent that slept so ended as soon as it slept. The echo agent’s live paths and the scripted agents that outlive their work idle that way; one whose work is done returns, and that exit, 0, is its lease’s end on purpose. - The image’s
agentis a sponsored principal: its login is a lease of alice’s, carved from her unlabelled share forMAX_LEASE, which her sessions can end and are told the end of; the harness that drives it runs inside the lease unchanged. - A lease’s root has a quota of its own.
/projectis the sponsor’s~/project, minted below the home with theagentsize’squota(1 MiB in the image) carved from the home’s and given back at the lease’s end: among the lease’s sizes, since a root below a home with no quota holds nothing (walfsd); a~/projectalready holding more refuses the lease, andstart_agentanswersfailed. So does a server that does not answer one of the lease batch’s calls (the carve, a mint, the image’s reads) within the steward’s bound,SERVER_US: the lease is rolled back and the sponsor asks again (the steward). - A launch takes seconds under load, and its caller waits for it.
start_agentis the one steward call measured past the client’s 5 s bound for a short call: its served time beside the sponsor’s other leases spinning is at worst 6.64 s under the bench’s rounds, so the client passes it a bound of its own, 14 s, twice that worst rounded up to a second (Redoubt.Wire.Client.launch_timeout/0; the figures on the steward). A launch past it istimeout, as any call, and a scheduler finding on scheduling, never a longer bound. - A labelled agent’s
/projectis its label set’s vault: the vault is the task’s scope, and R25 (the label check) lets the agent touch nothing of the unlabelled~/project; an unlabelled agent’s is its sponsor’s~/project, with a quota carved from the home. A labelled agent is started only by an approval atapprove@box(the steward). - A lease has no console. Its namespace holds no
/dev/cons, and beamlet’s platform runs without one (beamlet): what the agent writes to the console is dropped, a read answers the end of the input, so an agent’s output has nowhere to go but/project/out. How its VM ended is the steward’s console line (steward: lease alice/agent-1 7303562901476581370 of 1001/{} exited 0, the lease’s id beside its name), never the agent’s. - A lease has no network. Its namespace holds no
/net, so its VM’sgen_tcphas only the loopback inside the VM (beamlet): a connect to any other address is{error, enetunreach}, and the bench’s peer sees nothing from it (session-tcp-loopback; for a labelled agent in its vault,steward-labelled-agent-no-sink). An agent reaches outside the box only through its gateway capabilities, later (gatewayd). - A lease is not re-bound. When a shared server restarts, the steward tells its sessions on
their
noticesendpoints and each shell re-binds its home (the steward); a lease’s VM has no such endpoint and no shell, so an agent’s/projectat a restartedwalfsdstays dead until its deadline or its sponsor’send_lease(the steward answers a lease’srebindunknown); the sponsor’s way out isend_leaseand a newstart_agent, the sponsor’s own sessions being re-bound. Ending the lease for the server’s fault would punish the sponsor’s work when the sponsor already holds the choice; anoticesendpoint for leases is a later node, when agents must outlive a server’s restart.
A lease’s end is the kernel’s
Status: built · partly tested: a process that enters the kernel in a tight loop to put a deadline off is not attacked by a case · tested: bench:budget-deadline, bench:steward-lease-ends, bench:steward-sub-agent-dies-with-agent, mutation:BudgetDeadlineIgnored
The deadline under a lease is a kernel mechanism, and it is built
(budgets). When it passes, the kernel destroys the budget exactly
as budget_destroy would, at the first kernel entry at or after it, with nobody asking; a process
spinning in user mode is stopped by the timer interrupt. There is no call or state in which the
kernel puts a deadline off. The processes it kills get exit notices with cause killed, a call a
server took is abandoned, every handle to the budget is revoked
(R10 (destruction)), and a child budget’s later deadline
never fires: a sub-agent’s budget inside an agent’s never outlives it.
An agent cannot crowd out its sponsor
Status: built · partly tested: the share and the lease-end-under-flood run on the host, in the serving library; the boot arm times the sponsor’s own calls under the flood · tested: host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share_and_its_lease_end, host:redoubt-rt::an_agent_flooding_a_bucket_leaves_its_sponsor_a_share, bench:steward-agent-flood
An agent shares its sponsor’s account, so its calls count against its sponsor’s admission limits
for that label set. Inside them, the serving library’s admission gives every badge a fair share,
so an agent that floods a server through its own connection leaves its sponsor a share of the same
bucket. The host tests put an agent and its sponsor in one bucket and flood it from the agent’s
side (libs/rt/src/server/admit.rs,
the serving library).
Delegation only narrows
Status: planned · M3 (agents, approvals and the attack suite)
flowchart TB
A["Alice's session<br/>/home/alice (read, write), /net: a scope"]
AG["agent-7, lease under Alice's budget<br/>/project: /home/alice/project (read)<br/>/out: /home/alice/project/out (write)"]
SA["sub-agent, budget inside agent-7's<br/>/src: /home/alice/project/src (read)"]
X["a sibling of agent-7,<br/>or a budget with more labels"]
A -.->|"steward: fresh, narrower connections"| AG
AG -.->|"its own budget: narrower again"| SA
AG -.-x|"refused: needs the steward and an approval"| X
Figure: a capability delegation tree. Every part is planned (dashed). Each step holds a subset of the step above, through fresh connections; nothing reaches sideways or up.
Person to agent to sub-agent, each step is narrower, and the chain is recorded.
- Fresh, narrower connections. A launcher never passes its own connection: it asks the server for a new one, rooted lower or read-only, and passes that (sessions). A handle minted this way keeps the stamp of the one it came from, so it dies with it (R9 (stamps)).
- Sub-agents live inside their parent’s budget. An agent holds only its own budget’s handle, so it cannot make a sibling; a sub-agent’s budget is carved from the agent’s, and the agent’s lease ending ends its sub-agents, whatever their own deadlines.
- Labels only grow downward. A child budget carries all its parent’s labels, and adding one
needs a
system-class caller (I6 (labels only grow downward)). So an agent cannot shed a label it was started with, and a budget with more labels, or a new durable principal, needs the steward and an approval. - Never network by default. An agent inherits no network access. From
M5 (self-hosted development) it reaches a model through a
gatewaydcapability that holds the API key; only people get name-scoped TCP (gatewayd).
Open: none.
Labels and vaults
Status: planned · M3 (agents, approvals and the attack suite)
Capabilities bound what an agent can do; labels bound what it can leak. The unit of
isolation is the label set, not the capability set: a budget with a label can read that label’s
data and cannot send to anything without it (R1 (flow),
labels). So an agent that must read a secret runs with the
secret’s label, and then has no network (ipd refuses labelled callers), cannot write down to
unlabelled volumes, and can reach its sponsor only through channels that carry the label.
The high/low pair is the case the design is built for. Agent A holds a secret and no network; agent B has a network and no secret; they are co-conspirators with a pre-arranged code. The claim is exactly one thing: no intentional software path crosses the label boundary between them. Covert channels (timing, shared physical state) are a stated wall, not one the design closes.
Two stated residuals:
- No non-collusion guarantee within one label set. Two agents with different handle sets but the same label set are not kept from cooperating: labels separate label sets, and within one label set the kernel carries messages between whoever holds each other’s handles. Agents that must not collude get different labels.
- The approval human. The design protects the approval channel from the requester (only the
steward draws on
approve@); it does not protect the person’s judgment. An agent that persuades its sponsor to approve more than they meant has won, and the design names that wall rather than closing it (sessions).
Open: none.
No credentials in agent memory
Status: planned · M3 (agents, approvals and the attack suite)
An agent never holds a key or an API token. It uses keys through keyd (the key server), and,
from M5 (self-hosted development), models through gatewayd, which holds the API keys. Until
M6 (persist, install, share) no session and no lease holds keyd capabilities at all.
A lease holds no key until principals’ signing keys exist, which the plan places with keys in leases
in M6 (persist, install, share). Then a lease carries only a key its approval named, and a keyd
capability names one key and one purpose, with the one message shape it may sign (for SSH, a
signature over a session identifier keyd computed itself), never arbitrary bytes. Otherwise a
hijacked agent would be a signature oracle, and its peer could log in as its sponsor elsewhere
(keyd).
Open: none.
The agent harness
Status: planned · M5 (self-hosted development)
Redoubt.Agent starts and drives an agent from a session. The harness runs in the launcher’s VM;
the agent runs in a VM of its own, in its lease, with its own label set, and the model loop runs
there, through the agent’s own gatewayd connection.
| Function | What it does |
|---|---|
Agent.start(opts) | ask the steward for a lease: lease: (time, pages, processes, weight), labels:, and grants: |
Agent.prompt(agent, text) | hand the agent a task; {:error, :lease_expired} once the lease is gone |
Agent.status(agent) | the lease’s usage, weight and remaining time, from the steward |
Agent.kill(agent) | ask the steward to end the lease; the agent and its sub-agents end |
There is no pause or resume. No kernel call freezes a budget, none can be emulated (a carve
that would leave a process’s budget with no free weight is refused:
R7 (carving)), and a lease’s deadline keeps running, so a pause
would silently eat lease time.
The steward makes the lease. The harness asks; the steward places the lease under the
sponsor’s budget for that label set, checks that every grant is the same as or narrower than what
the launcher holds, refuses a term over MAX_LEASE rather than clamping it, and records the
grant with the principal chain (the steward). Agent.start waits for
the launch with the client’s launch bound, Redoubt.Wire.Client.launch_timeout/0, not the 5 s
of a short call: a launch beside the sponsor’s other leases is measured in seconds
(leases), and a harness that reintroduced the short bound would time out under the
load the bench runs.
-
Grants are closed, typed terms, the same terms the steward’s lease request carries.
labels:is its own option, because adding a label needs an approval. The kinds are:read: PATHandwrite: PATH, each a path in the launcher’s namespace;writedoes not implyread, so name both when both are meant;gateway: NAME, one of the launcher’s owngatewaydgrants by name, or{NAME, hosts: [...]}to narrow it;git: {REMOTE, fetch: true, push: [REF_PATTERN, ...], force: false}(development);launch: [pages:, processes:, weight:], a sub-budget the agent may start processes in.
Nothing is free-form: there is no “all” and no wildcard beyond
git’s ref patterns. An unknown kind, a path that does not resolve, or a term broader than the launcher holds is refused whole by the steward, before any budget exists. Planned cases: each kind is refused when broader than the launcher’s; an unknown kind is refused;run’sisolated:options and the keyword list give the same steward request. -
Each grant becomes the agent’s own connection. The steward mints each one fresh, with the agent’s own badge, so every request through it is the agent’s and never its sponsor’s: a file connection rooted where the grant says, a
gatewaydorgitcapability no wider than the launcher’s, or a sub-budget to launch programs in. What the agent holds is the whole of what it can do beyond its own lease; there is no permission check to get wrong. -
No approval while everything narrows. A label the launcher lacks (an agent that must read a secret) or a new durable principal goes through an approval at
approve@. -
Sub-agents are started the same way, by the agent’s harness asking the steward through its own connection; the sub-lease sits inside the agent’s budget and ends with it.
-
killandstatusgo through the steward. Ending a lease is always accepted from the sponsor, ahead of admission, and is audited. The launcher never holds the lease’s budget handle, so it cannot create processes inside the agent’s budget and blur whose work is whose.
What the harness cannot be made to do:
- Take code or atoms from the agent. The link between harness and agent is a typed Redoubt
channel, never Erlang distribution, which would give each VM full remote calls into the other.
What the harness receives (replies to
prompt, status) is untrusted data, decoded by a bounded decoder that creates no atoms and no code, never bybinary_to_term. - Parse model output. The model’s replies reach the agent’s VM, not the launcher’s.
- Hand the agent the sponsor’s things. The agent never gets the sponsor’s
/dev/cons, keys or any budget handle but its own. - Drive the sponsor’s terminal. What the harness shows of an agent’s replies it draws as cells
through
Redoubt.Term, so a control sequence in a reply is drawn visibly and never acts (the shell).
Open: none.
The agent loop
Status: planned · M5 (self-hosted development)
The model loop that runs in an agent’s VM is small by design, after Mario Zechner’s pi: a system
prompt under 1,000 tokens, four tools, and nothing else in the loop.
| Tool | What it does |
|---|---|
read | a file’s contents, by the shell’s cat |
write | a whole file, by w (a temporary file and a rename) |
edit | replace one exact span of a file with another, failing if the span is absent or not unique |
eval | an Elixir expression, evaluated in the agent’s own VM in a monitored process with a timeout, its output captured |
evalis the agent’s shell. Wherepigives a modelbash, a Redoubt agent gets the same shell a person does (the shell): its commands, native pipes, andRedoubt.Agentfor sub-agents when its grants allow them. It adds no authority: whateverevalruns, runs with the agent’s handles, inside its lease.- Nothing more in the loop. No MCP, no plan mode, no to-do list, no tools beyond the four. What an agent can be told to do is bounded by what it holds, and a short loop leaves little to review in how it is told.
- The model is a stream from
gatewayd. The loop sends the conversation through itsgatewaydcapability and receives the reply as a stream of typed events (text, a tool call, the end of a turn), whichgatewayddecoded from the provider’s own stream; it opens no socket, holds no key and parses no provider’s format (gatewayd). Compaction shortens a conversation that nears the model’s context. - No dependencies. The loop is plain Elixir, about 1,500 lines, with no Hex packages: JSON is
Elixir’s own
JSON, and there is no HTTP client because there is no HTTP. Thepieproject, a rewrite ofpiin Elixir, is the model for its structure and is not copied: it has no licence. Itsevalfollows Tidewave’s (Apache-2.0).
Open: none.
The escape room
Status: planned · M5 (self-hosted development)
The bench tests properties someone already thought of. The escape room (the game) tests the ones nobody did, against real agents told to get out, and it runs continuously. Its scenarios:
- Single-agent escape: expand its capabilities, reach what it was not granted, survive a restart with authority it was not given.
- The high/low pair, above: any intentional path across the label boundary.
- Authority expansion: forge a handle, mint wider than the source, reach a system budget, add a label, outlive a lease through a child.
- Collusion through the human: persuasion, approval fatigue, a request that reads one way and means another.
A referee sets each match up (which of the kernel and the servers are assumed correct; a confined manifest, in which differing label sets share no server instance, volume, endpoint, network instance or device; a planted secret and a canary; B’s egress and the audit log recorded) and decides from the record, never from an agent’s own output. Every confirmed finding is classified:
| Class | Meaning | What follows |
|---|---|---|
| Design hole | a path the design claims is closed is reachable | the design changes, then the code, with a new attack case |
| Implementation bug | the design holds; the code does not | a fix and the case that would have caught it |
| Covert | a path through shared physical state | an observation; neither a win nor a loss |
| Configuration | the manifest was permissive | a default-policy change if it recurs |
| Human | the agent got a person to approve something | measured and reduced, not claimed closed |
Open: none.
Why
Agents are principals, not impersonations. If an agent acted as Alice, every action it took would be Alice’s, and nothing could tell her work from its work or limit it separately. As its own principal it has its own audit identity, its own lease and its own capabilities, and the sponsor link makes someone answerable without making the agent the person.
Assume compromise. An agent reads untrusted text for a living, and any of it can take the agent over. Designing for the honest agent would mean every guarantee fails at the first prompt injection. Designing for the hijacked one means the guarantees are about what the agent holds, which a prompt cannot change.
Leases, not permissions that last. A task has an end, and so should the authority for it. A kernel deadline means a forgotten agent stops by itself, and a sub-agent cannot outlive the agent that made it, without anyone having to remember to clean up.
Labels bound the leak, capabilities the damage. Capabilities alone cannot stop an agent that may read a secret from passing it to one that may send it: both are acting within their grants. Labels make the flow itself the kernel’s business, so the pair has no path to use.
File transfer
Files come in and go out over SSH: SFTP, and SCP served as SFTP, inside the same SSH connection a person logs in with, and nothing else. A transfer runs in a small transfer server the steward starts for the channel, holding only the session’s file binds and its audit connection, so it can reach no file the person could not reach at the prompt, and every operation it performs is recorded in the audit log. Vault sessions have no file transfer at all. There is no FTP, no HTTP upload and no shared network drive: SSH is the only inbound service.
Purpose
A box people and agents work on needs data in (source trees, datasets, programs built elsewhere) and results out. The usual tools for that (a file-sharing daemon, a web upload) are inbound services, each a parser exposed to the network and each a second way in. Redoubt keeps one way in and puts file transfer inside it, with the same walls as a session.
How to use it
From the person’s own machine, with their own SSH key:
$ sftp alice@box
sftp> put report.csv /home/alice/in/report.csv
sftp> get /home/alice/out/results.txt
$ scp build/logscan alice@box:/home/alice/bin/logscan # OpenSSH 9 and later: SFTP underneath
$ scp -s build/logscan alice@box:/home/alice/bin/ # older clients: ask for SFTP explicitly
Paths are the session’s namespace paths: /home/alice/... is Alice’s home volume, and a path no
entry of her namespace names does not exist. ln -s, chmod and chown are refused, because the
files have no links, mode bits or owners (files and binds). scp -O (the old SCP
protocol) and sftp alice+tax@box (a vault session) are refused.
What it can and cannot do
Transfers inside SSH
Status: planned · M4 (files in and out)
SFTP runs inside an SSH connection to sshd (the SSH server), authenticated like a login, with the
person’s own key, which never lives in keyd (sshd).
sshdrelays bytes only. On asubsystem sftprequest it asks the steward to start the transfer server for that session and pipes the channel to it, as it pipes/dev/consto a session.sshdnever parses SFTP.- One transfer server per channel. It is a small native Rust program, started by the steward in a budget carved from the session’s. A connection that only transfers files gets a session budget as a login does, with the transfer server in it instead of beamlet.
- SCP is served only as SFTP. OpenSSH 9 and later run
scpover the SFTP protocol. The old SCP protocol (scp -O, which runsscp -torscp -fon the server) is refused: Redoubt has noexecand no shell to run it, and one protocol parser is less than two. Older clients usesftporscp -s. - Inbound traffic other than SSH is refused, so SSH, with SFTP inside it, is the only inbound
service. (Outbound fetches, such as
gitthrough a gateway, are a session’s own requests, not a service.)
Open: none.
Confined to the session’s files
Status: planned · M4 (files in and out)
The transfer server holds the session’s file binds and one connection to the steward’s audit path
(Audited), and nothing else: no /net, no /dev/cons, no powerbox connection, no
budget or process handles. That is narrower than the session, as a launch always may be.
- Confinement is by capability, not by path strings. The server reaches files only through the
namespace connections it holds, so no path, however it is written, reaches anything else.
..at a connection’s root stays at the root, by 9P’s walk; cleaning names lexically makes them predictable, and is not the wall. - Unsupported operations fail visibly.
SYMLINK,READLINKandLINKgetSSH_FX_OP_UNSUPPORTED.SETSTAThonours a size (truncation), and the modification and access times where the file server stores them; a mode, a user or a group getsSSH_FX_OP_UNSUPPORTEDrather than a silent success. This is the same rule theFileAPI follows on the box (files). - The same budget and file rules. Its pages and CPU are charged to the session’s budget, a full volume refuses the write, a rename between volumes is a copy and a remove, and removing an open file succeeds (files).
Open: none.
No transfers in a vault session
Status: planned · M4 (files in and out)
A vault session gets no SFTP and no SCP: sshd refuses a subsystem request on a labelled channel.
sshd is a sink cleared for a label only on its owner’s terminal channel, with no forwarding, no
subsystems and no exec (sessions). Labelled data enters a vault
through an audited push by the steward, and leaves only by declassification
(the steward). A file-transfer channel for vaults would be a new way for
labelled data to leave, and it is not part of the design.
Open: none.
Audited
Status: planned · M4 (files in and out)
Every operation a transfer performs is one record in the audit log: each open (for reading and for
writing, since files go out as well as in), each close with its byte count, and each remove,
rename, mkdir, rmdir and setstat. The log that holds them comes with transfers: written by the
steward, append-only, recording transfers, with each record signed through keyd’s audit purpose
over redoubt.audit.v1, the record’s length and the record (keyd). The same
log grows to every steward action in M5 (self-hosted development), and chaining (which catches
dropped or reordered records) and the offline verifier come with log retention in
M6 (persist, install, share) (the steward). The principal
and labels in a record come from the badge the steward minted for the transfer server, never from
the server’s own claim, so the server cannot forge whose transfer it was.
The audit is accountability, not a wall. A principal who compromises their own transfer server with crafted input holds only their session’s file capabilities, which they already had; they could suppress their own records, and they could move data out unaudited through their terminal anyway.
Open: whether the file server audits transfer handles itself, which matters only if the audit must survive a compromised transfer server.
Why
One way in. Every inbound service is a parser the whole network can reach. SSH is needed for logins anyway; putting file transfer inside it adds a subsystem behind authentication, not a new listener in front of it.
A transfer is narrower than a session. If file transfer had its own authority (a service account, a drop directory writable by everyone), it would be a way around the session’s walls. A transfer server holding only the session’s file binds has less than the session, so the attack cases for sessions cover it, and a bug in its SFTP parser reaches only files the person could already reach.
Not in the session’s VM. OTP ships an SFTP daemon, and serving SFTP from the session’s own VM would be less code. But the principal controls their own VM, so audit records written there could be skipped at will; it would need a whole VM for a connection that only moves files; and the daemon is built for OTP’s SSH server, not a relayed byte stream.
Refuse, do not pretend. A client told that chmod succeeded believes the file is protected.
Refusing an operation that means nothing here is more honest, and a tool that needs it finds out
at once.
Development on Redoubt
Redoubt is developed on Redoubt. A developer, or a developer’s agent, works in a session: the source
lives in their home volume, git reaches its remotes through a gateway, and the Elixir and Erlang
compilers run on the box in beamlet. Rust, including the kernel and the servers, is built off the
box, because the Rust compiler is not ported: system code ships in the signed boot bundle, and a
developer’s own program arrives by SFTP. The client library lets programs written for the box use
the system; whether Rust’s std gets a Redoubt target is open
(native programs).
Purpose
A system that is only ever built elsewhere has never been used for real work by the people who know it best. Developing Redoubt on Redoubt puts its own developers and their agents inside the walls: every missing tool, awkward API and wall that is too tight or too loose shows up in daily use. It is also the first serious workload for agents on the box, which is what the system is for.
How to use it
A working day, sketched:
$ ssh alice@box
/home/alice (1)> cd("/home/alice/redoubt")
/home/alice/redoubt (2)> pipe(~w(git pull)) # through a gatewayd git capability
/home/alice/redoubt (3)> ed("userland/lib/redoubt/shell.ex")
/home/alice/redoubt (4)> Mix.Task.run("test") # the compiler and ExUnit, in the session
/home/alice/redoubt (5)> pipe(["git", "commit", "-am", "shell: complete labels"])
/home/alice/redoubt (6)> pipe(~w(git push))
Rust is built on the developer’s own machine. A program for one’s own use arrives by SFTP and runs unsigned, then and later. A program the steward is to launch with new grants, for another principal, arrives from M6 (persist, install, share) as a signed package:
laptop$ cargo build --release --target riscv64gc-unknown-redoubt-elf
laptop$ xpkg build && xpkg sign --key alice # -> logscan-1.3.xpkg
laptop$ scp logscan-1.3.xpkg alice@box:/home/alice/in/
/home/alice (1)> pkg_add("/home/alice/in/logscan-1.3.xpkg")
What it can and cannot do
The compilers on beamlet
Status: built · partly tested: the compiler cases (tests/elixir/compiler_test.ex, tests/erlang/selfcompile.erl) are differential suites that need OTP 28 and Elixir installed and are not run by the bench, and they only compile, load and call: nothing checks the chunk equality or the error handler below · tested: bench:shell-compile, bench:shell-compile-module
Elixir’s compiler (Code.compile_string, Code.eval_string) and OTP’s Erlang compiler
(compile:forms) run on beamlet, and the modules they produce load and run in the same VM
(userland/otp/tests/elixir/compiler_test.ex,
userland/otp/tests/erlang/selfcompile.erl).
On the machine, a module a session compiles is kept as a .beam in the session’s home and loaded
again from its bytes (:code.load_binary/3); the system’s module lookup never searches home, and
a directory of home is looked in only once the session puts it on its own code path
(beamlet, packages).
The code, atom, export and literal chunks they write are identical to BEAM’s; compressed chunks
(debug information, documentation) differ in bytes, because the deflate implementation differs,
and decode to the same terms. A call to a function not yet loaded goes to the process’s error
handler, which Elixir’s parallel compiler uses to wait for modules.
Compiling on the box
Status: planned · M5 (self-hosted development)
mix compile and mix test run in the developer’s session, in its VM, with its authority. Source
and build output are files in the developer’s volumes; the .beam files a build writes load into
the session through the VM’s code path, which grants nothing, since a session can already load
any bytes it holds (beamlet). A build that should not run with
the whole session’s authority (an untrusted dependency’s code, a test suite from someone else)
can run in a child VM with its own budget and a narrower namespace, as an agent does
(agents).
Code a developer compiles runs within their own authority and needs no signature. Code that is to run with authority the steward grants (for another principal, or as a package) is signed (packages).
Open: none.
git through a gateway
Status: planned · M5 (self-hosted development)
git reaches its remotes through a gateway: a gatewayd capability for git, the documented path
for people and agents alike (gatewayd).
- Scoped by name and by operation. The capability names its remotes; fetch and push are granted separately; push is limited to named ref patterns; force-push is refused unless granted.
- No credentials in the session.
gatewaydholds the remote’s credentials (a token or a deploy key), terminates TLS, checks each request and logs it. The client never sees a credential. - The client is a Rust
git. Redoubt has no C, so the client is a pure-Rust implementation, built off the box and shipped in the system bundle like other system Rust. It runs as a native program in a budget carved from its launcher’s (a session or an agent), holding only the working tree it was given and one remote capability; the gateway speaks git’s smart HTTP to it. Ctrl+C or the end of a lease destroys it like any native stage (native programs). - Agents get only this path, since they never get sockets. A person may instead hand the client their own name-scoped TCP capability to an allowlisted remote, with credentials from their own session: that is their general network right, not the development path.
Open: the gateway’s finer checks on git requests beyond remote, operation, refs and force
(size limits, path rules), which belong to gatewayd.
Rust built off the box
Status: planned · M5 (self-hosted development)
The Rust compiler is not ported, so native programs, servers and the kernel are built off the box
with the riscv64gc-unknown-redoubt-elf target, against the client library (native
programs). “Shipped signed” means signed where a
signature gates something:
- System Rust (servers, drivers, beamlet, the kernel) reaches the box in the signed boot bundle, checked by verified boot (boot).
- A developer’s own program arrives by SFTP (file transfer) and runs with the developer’s own authority, unsigned. Code never runs with more authority than its author holds, and any process can create a child and map pages into it, so a signature on code one runs oneself buys nothing enforceable (native programs).
- A program the steward launches with new grants, for another principal or as a package, needs a signature the principal trusts. That, with trust lists and packages, is M6 (persist, install, share) (packages).
Open: none.
Signing keys
Status: planned · M6 (persist, install, share)
The box only checks signatures against trust lists; it does not care where a private key lives.
- A person may sign off the box with their own key, and that is always valid.
keydmay also hold a person’s package-signing key, as its own key with the purposepkgand its own domain-separated preimage (keyd). The rule that keys which authenticate a person never live inkeydcovers login and approval keys, not this one, andkeydstill refuses a key enrolled both as a login or approval key and as one of its own.- Every signature with a person’s key in
keydneeds an approval atapprove@, showing the package’s digest, name and manifest requests. That signature vouches to everyone who trusts the key, so a hijacked session must not become a signing oracle for its owner. - An agent signs with its own key in
keyd, without an approval: its code runs only within its lease until a sponsor trusts its key, and that trust is itself a high-stakes approval (agents).
Open: none.
Why
Develop where the walls are. A capability system is easy to make safe and hard to make usable; the only test of usability is use. Developing Redoubt on Redoubt makes its developers meet every wall daily, and makes their agents the first real tenants.
Elixir on the box, Rust off it. The Elixir and Erlang compilers are Erlang and Elixir code, and beamlet already runs them. The Rust compiler is millions of lines with an LLVM back end in C++, which Redoubt will not carry: no C, and no C++, anywhere in the system. Building Rust elsewhere keeps the box’s trusted code small while still running native code written for it.
Signatures for grants, not for running. Requiring a signature to run any code would stop nothing, because a session can already run any Elixir it writes, and would make development slower. Signatures matter where the steward adds authority: that is where “who wrote this” has to be answered.
Packages
A package is a signed archive of programs and Elixir code with a manifest that requests
capabilities. A principal installs one with pkg add, chooses the version it runs with
pkg use, and removes unused versions with pkg gc. Installed packages live in a directory no
session can write; which versions a principal runs (its profile) and whose code it runs (its
trust list) are steward records, not files in its space. A signature says who vouches for the
code; the capabilities it runs with are what the principal grants, never more.
Purpose
Code arrives on the box from people, from agents and from the system’s own developers. Packages answer two questions about it: who vouches for this code, and what may it do. The first is a signature checked against the principal’s trust list; the second is the principal’s grant, narrowed from its own capabilities. Keeping installs, profiles and trust lists out of the principal’s writable space means that write access to someone’s home never becomes a launch with their authority.
How to use it
/home/alice (1)> pkg_add("logscan-1.3.xpkg")
signed by: alice (on your trust list) requests: read /logs, write ~/reports
/home/alice (2)> pkg_use("logscan", "1.3") # pkg_use("logscan", "1.2") rolls back
/home/alice (3)> pipe(~w(logscan --since yesterday))
/home/alice (4)> pkg_gc() # remove versions no profile uses
Adding a signer to the trust list is a high-stakes step, answered at ssh approve@box
(sessions).
What it can and cannot do
Installing a package
Status: planned · M6 (persist, install, share)
A package is a ustar archive with a manifest, signed as a whole, the way the boot bundle is: the
boot bundle is the system’s first package, and boot, system updates and user packages share one
verifier (pkg, boot). The signature covers the domain
string redoubt.pkg.v1, the archive’s length and the archive, so a package signature is never a
bundle signature, and the reverse.
- The manifest requests; it grants nothing. It is strict JSON naming the contents and the
capabilities the code asks for (“
/netconnect to 443”, “read my config directory”). At install or run time the principal grants those requests, narrowed from its own capabilities, and the launcher builds the program’s namespace from exactly those grants. - Verified before it is parsed. The package server (
pkg) checks the signature over the whole archive, with the same verifier boot and updates use, against the installing principal’s trust list, before it reads a single tar header or manifest byte. An archive no trusted key signed never reaches the parser. - The parser is contained. A trusted key can still sign a hostile archive. So the steward
starts a
pkginstance per principal for each install. It holds the archive it was handed, a write handle to that principal’s package directory,/system/pkgs/<principal>/(each package goes in<name>-<version>-<hash>/there), the steward’s call to record the install, and, read only, the principal’s trust list and the names of the modules the system bundle defines. It writes nowhere else, so a parser bug reaches only that principal’s own packages. Nothing a session holds writes there. - The steward keeps the authority.
pkgdoes the parsing, so the steward never parses an archive, asinitand the steward never parse an ELF. Profiles,userecords, trust lists and the grants a manifest requests are steward records;pkgasks the steward to record and never holds grant authority. The steward records which key signed each installed program. - Routine when trusted. Installing a package signed by a key already on the principal’s trust list needs no approval.
- One front end.
pkg add,pkg useandpkg gcare apkgcommand and aRedoubt.Pkgmodule over the package server (pkg).
Open: none.
Profiles and upgrades
Status: planned · M6 (persist, install, share)
A principal’s profile is the set of package versions it runs and the /bin its sessions see.
It is a steward record, not a file the principal can write.
- Upgrading is atomic per package.
pkg addinstalls a new version beside the old;pkg useflips one steward record; flipping back is the rollback;pkg gcremoves versions no profile uses. - Installed code comes only from installed packages.
Platform::load_moduleresolves a module name only from the system bundle and the profile’s package directories, through read-only handles, and the steward launches only installed programs. Neither ever consults the session’s writable namespace (a directory a session puts on its own code path is its own choice, below), so a file dropped in a person’s home cannot pose as their installed code (beamlet). - No shadowing. The system bundle always resolves first, and a package may not define a module
the bundle defines:
pkgrefuses it at install. Two packages in one profile may not define the same module: the steward refuses it atuse. beamlet looks a module name up in the bundle before any directory on the session’s code path, even one added to the front (beamlet). - One’s own code is not installed code. A session that compiles or loads its own code
(
Code.compile_string,Code.require_fileon its own files) runs it within its own authority. The code-path rule is about what loads implicitly, not a wall against one’s own code (development). - A project has its own package directory and profile, so a shared toolchain is installed once for its members (the steward).
System code is not a package. OTP, Elixir and Redoubt’s own Elixir live on the userland disk,
whole applications, every module of each one the shell’s prompt uses, one file per module,
read-only and immutable, on a verified volume whose root the signed manifest pins, read through
its own verityd and littlefsd, which a reader trusts in place of checking each file
(R75 (verified userland)): changing it is a new bundle
and disk, never an install. Packages live apart from it, on the data disk, and a profile’s modules
resolve only after the system’s.
Open: none.
Trust lists
Status: planned · M6 (persist, install, share)
A principal’s trust list is the signing keys whose code it runs. The steward launches code on a principal’s behalf only if the principal trusts the signer, and with at most what the principal grants.
- System code (drivers, servers, the loader stub, beamlet) is signed by the system key.
- A principal may trust its own key, and others’.
.beamcode follows the same rule as native code. - Adding a key is a high-stakes approval, at
approve@, because it lets code launch with the principal’s grants. - Removing a key needs no approval, because it only narrows, and it is audited. New launches and
loads of that key’s packages are refused at once, and the steward stops the principal’s running
processes whose recorded signer is that key, by destroying their budgets.
userecords are never switched silently to another version or signer: a package left without a trusted signer is unusable until the principal chooses. - The system key is on no trust list and cannot be removed: system code is trusted by the bundle’s verification, not by a list.
- Agents sign with their own keys. Code an agent builds runs within its lease; running it outside needs the sponsor to trust the agent’s key, which is a high-stakes approval (agents).
Open: none.
What signatures do not do
Status: planned · M6 (persist, install, share)
A hijacked agent can run code it wrote: any process can create a child and map pages into it (the launcher needs exactly that), and the shell evaluates any Elixir. What holds is that such code never runs with more authority than its author already holds. Signatures gate only what the steward launches with new grants (native programs).
What a signature does buy:
- No launch with new authority without trust. Code gets grants from the steward only if a trusted key signed it.
- A compromised key is containable. Removing it from trust lists stops its code launching, and every launched process’s signer is on record.
- Attribution. Every launched process has a signer in the audit log.
Open: none.
Why
Signature and capability answer different questions. A signature cannot say what code should be allowed to do, and a capability grant cannot say whether the code is what its author meant to ship. Asking both, separately, means a trusted author’s buggy code still runs with only the grants it was given, and an untrusted author’s code gets no grants at all.
Installs outside the principal’s space. If installed code lived in a principal’s home, any process that could write there could replace it, and the next launch would run the replacement with the principal’s grants. With installs, profiles and trust lists as steward records, write access to a home is only write access to a home.
Versions beside each other. Installing next to the old version and flipping one record makes upgrade and rollback one step each, with nothing half-installed in between.
M1 (sessions over SSH, kept apart)
Goal
Alice and Bob log in over SSH, on QEMU, and on the console, into Elixir sessions, and are kept
apart: each session runs in a fixed sub-budget for its principal’s label set, with files (the
system read-only, /home writable). A dead steward restarts without a reboot, and the
scheduler’s targets hold. The attack suite is the kernel cases below and the cases for what this
milestone builds.
The milestone is one thin vertical slice through the whole system: the kernel, init and the
boot manifest, the drivers, the file servers, the steward, keyd and sshd, and beamlet running
the shell in each session. A session in this milestone needs only the console, files and
launching native programs. The rest of the shell is M2 (usable shell);
agents, approvals and the scripted hostile agent and user are
M3 (agents, approvals and the attack suite).
Attack suite
Each attack is a deterministic program in the test bench. Its outcome is judged by a party the attacker cannot impersonate: the kernel, a victim, or a clean power-off, never the attacker’s own output (rule F). A covert channel an attack finds is recorded as an observation against the side-channel wall, not as a breach.
Kernel cases
| Attack | Rules | Bench case |
|---|---|---|
| Revocation by budget: mint into a revocation scope and destroy it; the handles are dead everywhere, messages already sent through them fail and carry no reply’s handles, and handles inside queued messages arrive as 0 | R9 (stamps), R10 (destruction) | redoubt-revoke, budget-destroy-attack |
| Budgets: carving, charging and exhaustion | R6 (charging), R7 (carving) | budget, budget-carve-attack, budget-table-attack, budget-mem-churn, touch-beyond-ram |
| The scheduler: churn, carving, sleeping and ties gain nothing | R12 (scheduling) | sched-budget-churn, sched-carve-inflation, sched-debt-lift, sched-destroy-billing, sched-exit-churn, sched-idle-gap, sched-lift-delay, sched-ties, sched-wake-no-preempt |
| The timer: every blocking call returns by its timeout | I13 (every blocking call returns by its timeout) | timeouts, budget-deadline |
Memory: W^X, zeroed pages, no replacement by map_fixed | R11 (memory) | wx, kernel-wx, mem-attack, write-only-attack, map-fixed-attack |
| Devices: no device authority without a device object; DMA pages reset before reuse | R18 (device authority), I16 (DMA pages reset before reuse) | irq-attack, dma-rules, dma-reset-reuse, dma-reset-quarantine |
| Containment: hostile agents in leases are preempted, then ended at their deadlines or revoked by hand with their messages and lends in flight, while the driver, the steward and a victim server stay responsive | R10, R12, R3 (lends and abandoned calls), I2 (revocation is complete), I10 (create-destroy leaves the parent unchanged) | kernel-containment |
| Boot: a tampered or confused bundle does not run | R15 (verified boot), R16 (image confinement) | verified-boot-rejects-tamper, verified-boot-rejects-bare-archive, loader-rejects-kernel-address, loader-rejects-kernel-entry, loader-rejects-truncated-elf |
The gaps the kernel’s own cases leave (R1 (flow) across label sets on the real kernel, R2 (fair waiting)’s turns between groups, completion races between harts) are listed in kernel attack gaps.
Remaining work
Each part lands with the attack cases for what it builds. The order the parts run in, and which are under way, is kept with the project’s work plan (AGENTS.md), not on this page.
- The client library.
redoubt-client: the namespace, files over 9P, the file server’s typed operations, the console, launching, a launcher’s grants and one typed call, the API every userland binds to, tested on the host against real servers (native programs).initand beamlet’s platform are built on it, and anRerrorkeeps its name by one table (wire); a session binds each typed server through a generated Elixir client (wire). Still planned: dropped files’ fids that come back, calls by path, and whole reads and writes (native programs). - The VM’s remaining platform work. The VM and shell boot on the UART console, and its I/O
is asynchronous through the client library’s hub, files over 9P included
(beamlet,
files), and its system natives (the namespace, typed
calls, serving, budgets, labels and launching) run on the machine in a session a tester starts
in the steward’s place, and budgets and launching in the steward’s own sessions over SSH, a
vault session’s among them (beamlet); Elixir’s
Filein a steward’s session waits for the session’s namespace to reach its VM. - The steward. Principals from the manifest, fixed sub-budgets per label set, and sessions
on the console and over SSH run (the steward,
sub-budgets,
sessions), on the server graph, trust
tiers and capability holdings (the servers). Still to come: a dead
steward restarts without a reboot:
initempties theusersbudget, which ends every session, and relaunches the steward (the steward, init). - Files. The system volume read-only, and
/homewritable, served bywalfsd(walfsd).
Every follow-up page is placed above.
Progress
Built and attack-tested today:
- The kernel, except SUM and MXR clearing: handles and objects, IPC, memory, budgets, scheduling, the timer, processes, devices and DMA, verified boot (the kernel), and the executable model with its mutations (the model).
- The kernel containment gate: hostile leases preempted, ended at their deadlines and revoked by hand with their calls and lends in flight, while the victims stay responsive, on both widths (containment).
- The serving library and the wire formats: admission, the label check, minted connections, parked calls, typed dispatch, the 9P skeleton (serving, wire).
- The drivers and the network:
blkdandnetdattacked by hostile devices in host tests,blkd,netdandipdon the real kernel underinit(blkd, netd, ipd). - The file system’s core: littlefs against a hostile medium and power loss (littlefsd).
- The flash file server:
littlefsdoverblkd, placed byinit, with one volume per instance, quotas and typed operations (littlefsd). - walfs, the format for the SSD’s writable volumes: its library against a model, a power cut
at every write and hostile volumes, the bench’s packer, and its server,
walfsd, serving the image’s data volume underinit, with a power cut on the machine before or after (walfsd). bootfsd,consoledandkeyd, attacked in host tests and booted underinit(bootfsd, consoled, keyd).- Launching: the startup block and the loader stub (init).
initand the boot manifest: the loader loads only the kernel andinit;initchecks the manifest, builds the budget tree, hands each server its devices, and starts every server through the loader stub with fresh connections. It restarts a server that ends, a driver on its reset device, and reboots when one cannot stay up. Every server’s case bootsinit(init, starting a case’s programs).- The client library, with launching exercised by
init-booton the machine and its other tested operations exercised on the host against real servers (native programs). - beamlet on the host and under
initon Redoubt, with console, clock and randomness; its modules come from the userland disk, a volumeveritydchecks block by block against a root the signed manifest pins, those the shell’s prompt loads from one boot pack read whole before the VM starts (beamlet, verityd). Its I/O is asynchronous through the client library’s hub, and its files are 9P files in its namespace (files). - The shell on beamlet on the host: the loop, the commands, the file and text commands and
help, with hostile text drawn visibly and the cell protocol held to its vectors
(the shell,
the cell protocol).
The shell also boots on Redoubt’s UART console, and in a session its
execlaunches a native program from/boot(native programs); its files wait for a session’s namespace (the shell on the machine). - The steward: its policy core, tested on the host, and the server, which carves the
principals from the manifest, logs in
sshd’s users and starts each session as a beamlet VM in its label set’s sub-budget, and the console principal’s on the UART (the steward). A steward that dies is restarted with every session logged out,usersemptied by the kernel’sbudget_reap, and the console session starts again; the machine does not reboot (failure and restart). Each principal’s home holds the manifest’s quota, shared by all its sessions, its console session among them (home quotas and vaults). sshd’s core on its host platform, which the bench’s SSH sessions run against, with OpenSSH’s server as the reference (sshd, SSH sessions), and on the box’s, logins through the steward (sessions over SSH).
Not built: a session’s files, and a steward restart without a reboot.
M2 (usable shell)
Goal
The Elixir shell is a working environment. A person logged in over SSH does everyday work without writing Elixir by hand:
- commands: short, typed Elixir functions, each with its help, for the everyday work;
- file operations (copy, move, rename, delete, make a directory) and binds;
- viewing and searching files;
- native programs with standard input and output, joined by pipes;
- interrupting and killing jobs, by budget destruction, with no signals;
- line editing, history, completion and help;
- showing resource use;
- full-screen programs drawn as cells through the session, from menus and dialogs to the pager;
- the editor and file manager.
And the kernel runs user code on every hart, not only the boot hart.
Attack suite
Every convenience is attacked for the authority it might add. Each case runs a hostile input or program against a real session and is judged by the session or the kernel, never by the hostile party’s own output.
- Nothing runs at start. A
.iex.exs, or any other file, planted in the directory a session starts in is neither read nor run (the shell). - An interrupted line loses only itself. Ctrl+C in the middle of
x = 1; loop()ends the evaluation; the bindings of every earlier line are still there (the shell). - A job ends, and only it.
Job.killand Ctrl+C destroy the budgets of the job’s native stages and everything they started, never the session (R10 (destruction), the shell). - No program swallows the interrupt. A foreground native stage never holds the raw console, so the interrupt reaches the shell whatever the stage does, and Ctrl+\ whatever a screen takes; a runaway allocation on the heap, by a line, anything it spawns or a screen program, is ended by that process’s heap limit, and the session’s processes together by its budget, which ends the session and nothing else.
- A pipe carries no authority. A native stage reaches only what its launcher bound into its namespace; its standard streams are served files, and nothing is inherited (native programs).
- Resource use shows only one’s own.
top,psand friends list only the caller’s own (account, label set): another principal’s work, or a vault session’s from an ordinary one, never appears (the shell). - Completion and help reveal nothing unreadable. Completion lists only entries the caller’s labels may read, launches nothing and writes nothing (the shell).
- Hostile text never drives the terminal. A file, a file name or a program’s output holding
control sequences (OSC 52, OSC 8, a title report, a bare ESC) reaches
/dev/consonly as visible characters; the case judges the bytes the session wrote (the shell). - A screen writes no control sequence. Hostile text drawn by a screen program reaches
/dev/consonly as visible characters, the screen buffer’s natives refuse a control character, and a native program with a screen can send the session nothing but cells (the shell, beamlet). - The screen natives hold under hostile arguments. Every screen native, given any sizes,
rectangles, text and styles, answers or raises
badargwithin its bound; none panics, and a buffer answers only the process that made it (beamlet). - The editor turns no file into action. A file crafted to look like a modeline, a script or a path is shown and edited as text, and saving writes only the file the editor was asked to; the case judges what is on the volume, not what the editor reports (the shell).
- The file manager acts only inside its panel. A 9P server that lists
..,a/../..or a name holding a NUL gets each name shown and refused, and a copy, move or removal from the panel touches nothing outside the listed directory (the shell). - A paste is one event. Pasted text arrives as one bracketed event, never triggers completion, and runs nothing until the person presses Enter, however many lines it holds (the shell).
- Binds add nothing. A bind makes a held capability appear at another path and cannot point anywhere the process could not already reach (files).
- A parked console call is accounted. The console’s
resize, parked as a typed call, holds exactly one admission slot of its caller’s share (R28 (parked-call accounting)). - Several harts add no reach. A page unmapped, lent or returned on one hart is never reachable through another hart’s TLB, and a page made executable on one hart never runs stale on another (memory); completion races between harts on one call are attacked, where on one hart they are argued (kernel attack gaps); and every scheduling case, rerun with several harts, shows that no budget gains share by being spread across them (R12 (scheduling)).
Remaining work
Two tracks: the shell and several harts. They touch different code, the userland and the kernel, so they run side by side. The shell’s host steps need nothing M1 (sessions over SSH, kept apart) builds, so they start ahead of it, as the kernel on every hart under one lock does (below). The shell’s steps on Redoubt and the rest of several harts follow M1 (sessions over SSH, kept apart), the several-harts steps first: the kernel has no other work in this milestone, and every shell case built after them then runs on several harts too. The milestone is done only when the bench’s cases pass booted with several harts as well as with one.
The shell
The shell itself is built on a host first, ahead of M1 (sessions over SSH, kept apart), where each step is one a person can use, in this order:
- The terminal: the host console, the terminal library, the shell’s driver under
groupandedlin, line editing and history, and the interrupt ending a line (beamlet, the shell, the shell). - Screens: the screen natives, then
Redoubt.Screen, its layout, andpickfirst (beamlet, the shell). - The pager, help drawn in it, and completion (the shell, the shell).
- The widgets: the menu bar, dialogs, the input, the lists, the table and the canvas (the shell).
- The editor, then the file manager (the shell).
- Resource use (the shell).
On Redoubt, after M1 (sessions over SSH, kept apart), in this order:
- Parking a typed call in the serving library, then the console’s
consolprotocol (size,resize) on it (serving, consoled). - The commands on Redoubt: files through beamlet’s platform, the session’s own commands, and binds (the shell, files).
- Native programs and pipes: standard streams as served files, pipelines of native stages (native programs, the shell).
- Jobs: one budget per native stage,
Job.kill, the interrupt key over the console and SSH (native programs, the shell). - A native program’s screen: its
cellsframes read and drawn by the session (the shell).
Several harts
A second hart changes every rule that assumes one running thread in the kernel: completions, TLB flushes, instruction fences and the scheduler’s queue. So the steps go from coarse to fine, each attacked before the next, in this order:
- Any boot hart. The firmware may choose any hart, and nothing assumes hart 0. The loader already takes the boot hart’s own PLIC context (boot).
- Per-hart kernel state: a trap stack and the current process and thread per hart, reached
through
sscratch, and scheduling on every hart. - One big kernel lock taken at trap entry, and one global run queue (scheduling). The lock is a FIFO ticket lock, so kernel entry is fair across harts (R78 (fair kernel entry)): test-and-set would let one budget’s harts starve another’s. Every kernel global is reached only under it; the one-hart kernel is its uncontended case. The run queue stays global at every step below: each hart picks from the one stride queue, so a budget’s share is judged across harts as on one.
- Cross-hart interrupts, shootdowns and fences. Inter-processor interrupts to reschedule, and device interrupts taken on every hart’s PLIC context, so an idle hart takes them rather than waiting for the boot hart’s kernel section (boot). A process’s translations carry its PID as their ASID, and a hart keeps them across switches. A hart running the process when its tables lose a mapping is sent a shootdown, flushes that ASID and acknowledges before the page is reused; any other hart that ran it flushes that ASID before it next runs it, so a shootdown goes only to the harts running the process now. It comes in two stages: first at a destruction, while a budget runs on one hart at a time; then at every unmap, lend and return, once one process’s threads run on several harts at once, as a beamlet VM’s schedulers do. An instruction fence goes to the harts running a process when a page of it becomes executable, and a hart fences before it runs a process; a thread that moves needs no fence of its own (memory, memory layout).
- Lock to decide, unlock to do. A freed frame is zeroed outside the lock by the hart that
freed it, only while every started hart is idle: it passes from its owner into an in-flight
state no other hart can name, is zeroed only after the shootdown of its last mapping has been
acknowledged by every hart asked, and enters the free-frame bitmap zero, under the lock again;
an allocation from the bitmap writes nothing unless the frame was never used since boot, and
one that finds the bitmap empty zeroes a frame in flight itself
(R81 (frames in flight)), with an attack case (a
second hart racing to allocate, map or free the in-flight frame). Measured at two harts under
icount, zeroing was the only frame work long enough to move: 220 µs a page whilekframe::zerochecked every word, about a fifth of that after, and 15 s of zeroing in the 68,218 endpoint creations of the containment gate. That gate, busy on every hart, saves none of it: allocations there still zero frames never used since boot, as before (memory). The wait for shootdown acknowledgements totalled 0.9 ms over that whole gate, and the kernel makes no large copies (messages are words, pages move by remapping), so both stay under the lock (memory). - Per-hart frame magazines, each refilled from the bitmap under the lock and drawn from with interrupts off, so a hart’s common allocation takes no lock at all; a magazine is filled from the bitmap, whose frames are zero (step 5) but for those never used since boot, which the fill must zero.
- Finer locking: one trap-entry lock whose guard owns a token, so that the kernel’s globals become token-guarded cells and a compile-time lock order exists if the lock is ever split; the per-process thread-context pages first, and anything finer only once the steps above are stable and attacked.
Steps 1 to 3 and the first stage of step 4 are the package that puts the kernel on every hart under one lock; the second stage of step 4 and steps 5 and 6 are the package that runs one process’s threads on several harts; step 7 waits on their measurements (lock hold time and contention), as the platform page says.
Progress
On the host, ahead of the milestone: the shell’s loop, its commands (the file and text commands
and table), and help, tested by the shell’s own suite on beamlet and on the BEAM
(the shell); the cell protocol, in Rust and in Elixir, held to
one set of vectors; and beamlet’s raw terminal, the shell’s driver under group and edlin
with Redoubt.Term drawing for it, line editing and the session’s history, the interrupt ending
a line, and hostile text drawn visibly on every path the host has
(the shell); and screens on beamlet:
the screen buffer’s natives, Redoubt.Screen with its layout, and pick
(the shell); the widgets: the menu bar, dialogs,
the input, the lists, the table, the Braille canvas, focus and themes
(the shell); the pager, with help drawn in it
(the shell); Tab completion of commands, variables, modules and
paths (the shell); and resource use, free, uptime,
ps and top, over the session’s own budget and its VM, df not yet
(the shell); and the editor, ed, with syntax
highlighting, and the file manager, fm, host-tested (the shell).
On Redoubt the shell runs in each session over SSH, reached about 1.9 s after ssh starts on
either width, 1.0–1.1 s of it the shell’s own load from beamlet’s first read of it to its prompt
(the bench prints each login’s latency under the case’s result;
sshd), and so do its commands: files through
beamlet’s platform in the session’s home and vault, binds, and whoami and labels, as the
steward told the session (bench:shell-commands; the shell);
and native programs and pipes: a native stage’s standard streams are pipes the session serves
through a piped of its own, pipe/1 runs pipelines of stages, each in a budget of its own, and
exec a stage that reads what is typed and holds no console; a stage reaches only its three
streams, and its output reaches the console only through the shell’s guard
(native programs); and jobs: the
interrupt, Ctrl+C, the session’s own key, or an INT or break over SSH, ends the line being
evaluated and every native stage it runs in the foreground, never the session, bg runs a
pipeline in the background, and Job.kill destroys one job’s stage budgets and nothing else
(the shell); and a native program’s screen:
screen runs a program whose cells frames the session checks, paces and draws, in Rust, a
whole 80 by 24 screen redrawn about 12 times a second, and whose keys
and size reach it as events on its standard input, the interrupt ending it whatever it does
(the shell); and the machine
mode: a program driving a session over its terminal sends blocks ended by a token line, read
unedited and evaluated whole, and reads each block’s mark, token n ok|error|exit, host-tested
and in one session over SSH (bench:shell-machine;
the shell). What the milestone builds on: the serving
library’s parked calls (serving), consoled’s 9P console
(consoled), and budget destruction as the only way to end a process
(budgets). The first step on Redoubt is built: a typed call
parks as a 9P read does (serving), consoled and
sshd serve consol’s size and resize (consoled),
and a change of an SSH channel’s window reaches the shell’s driver, which lays the line or the
screen in front out again (the shell). For several harts: every hart runs user code under one FIFO kernel lock, for which a waiting
hart halts rather than spins (bench:smp-lock-wait), and a destruction’s
shootdown is attacked (bench:smp-boot, bench:smp-evict). One process’s threads run on
several harts at once, and every unmap, lend and return shoots the process down on the others
(smp-shootdown, smp-fence), and a session’s VM runs two schedulers on them: two Elixir tasks
finish in 0.53 to 0.56 of the time on 2 harts as on one, measured in host time, and a session at 1
hart keeps its single-scheduler rate (beamlet;
beamlet-schedulers-mttcg). A freed frame is in flight where no other hart can name it until it
is zeroed, by the hart that freed it when idle or by an allocation that finds no free frame, and
allocation from the bitmap zeroes nothing (step 5; bench:smp-inflight-race,
R81). R12 holds across harts: each budget gets its water-filling share
of the harts, judged from the kernel’s charges by the oracle and by the model at 1, 2 and 4 harts
(R12), and the latency targets are gated at 2 harts and
recorded at 4 (responsiveness). The console has one
writer at a time: the kernel’s lines wait, whole, while a program holds the UART for a write
(bench:console-one-writer, bench:console-hold-stuck), so the real-time reference
sched-latency-tcg runs at 2 harts (devices). For named contexts: a login names its context, ssh alice.work@box, the
steward holds one session per context at a time, and every refusal before a session reads the same
(sessions); a context outlives its SSH connection, its console
kept while detached by a relay in its own budget, and a later login reattaches or takes it over
(consrelay). A principal holds at most its cap of live contexts per
label set, and a detached context ends at its idle bound
(R82 (the context cap), R83 (idle contexts end));
a session lists, detaches and ends its label set’s contexts from the prompt
(the shell).
M3 (agents, approvals and the attack suite)
Goal
Alice’s agent runs contained under a lease. An approval counts only from approve@box, data
crosses a label only as one approved item, and crash blame ends only the blamed (account, label
set)’s sessions and leases. Every property is backed by an attack case, and the attack suite
below passes.
The milestone completes the slice M1 (sessions over SSH, kept apart) began: the
steward’s leases, agents, approvals, declassification and push and crash blame; vault sessions
and approve@box over SSH; TCP for a session’s VM; one scripted hostile agent and one scripted
hostile user; and the model’s traces replayed on the real kernel.
Attack suite
Each attack is a deterministic program in the test bench. Its outcome is judged by a party the attacker cannot impersonate: the kernel, a victim, or a clean power-off, never the attacker’s own output (rule F). A covert channel an attack finds is recorded as an observation against the side-channel wall, not as a breach. “Not yet” means the case waits for the work below; a case named for part of an attack covers the part the table says.
A scripted hostile agent
Alice’s leased agent, trying to get out.
| Attack | Rules | Bench case |
|---|---|---|
Write outside its /project, or reach the network: the case judges the writes by alice’s canaries; that its reads, listings and network lookup are refused rests on the lease’s namespace holding only /project, /boot and the system volume, host-tested | R25 (the label check), R41 (narrowing by revocation scope) | steward-agent-escape |
| A labelled agent reaches a sink not cleared for its labels | R60 (a sink refuses labels) | net-attacks for ipd, a labelled caller refused before its request is read; steward-labelled-agent-no-sink for the agent: approved at approve@box and running in its vault, its lease holds no /net (leases), so no request of the agent’s reaches ipd; the verdict is the peer’s count of nothing from it and the capture’s bound on the box’s own addresses, the agent’s own record of enetunreach inside its VM read beside them; the namespace is the first wall and R60 the second |
| Outlive its lease: expiry destroys everything, the handles it passed on and its sub-agents included | R39 (leases end), R10 (destruction) | budget-deadline, budget-destroy-attack for the kernel; the lease case steward-lease-cut |
Ask for a lease longer than MAX_LEASE | R39 | steward-agent-escape |
| Spoof the approval screen (control, bidi and format characters, swapped requests); send free text from a vault | R38 (out-of-band approval) | steward-screen-inert, steward-approve-box (a stale hash); free text from a vault is the server’s host test notes_are_rendered_by_their_requesters_labels_and_a_dead_sessions_requests_are_gone, the boot case steward-vault-note |
| Use its sponsor’s 9P connection instead of the fresh one it was given | R41 | steward-agent-escape |
| Slow Bob beyond its weight | R12 (scheduling) | sched-share, sched-sleep-gaming, sched-timer-flood |
Flood the steward and walfsd until Alice cannot open a file or end its lease | R26 (admission fairness) | steward-agent-flood |
Have keyd sign arbitrary bytes (an SSH user-auth blob relayed from its peer) | R44 (one key, one purpose, keyd’s own digest) | keyd’s host tests a_relayed_ssh_user_auth_blob_is_never_what_gets_signed, a_badge_signs_only_its_own_key_and_only_its_own_purpose, parts_cannot_be_slid_into_each_other and the_hash_matches_an_independent_implementation, run by r4-host-tests. No attacker in the image can drive this in a boot: keyd’s badges are sshd’s ssh_host and the steward’s audit, and no session or lease holds one until keys reach leases (keyd, M6 (persist, install, share)). The boot evidence that keyd signs only its own digest is every login’s end-to-end check, the client verifying the host signature over the exchange hash it computed itself (steward-ssh-two-principals) |
A scripted hostile user
Bob, attacking Alice.
| Attack | Rules | Bench case |
|---|---|---|
| Call fuzzing: any arguments to any call get an error, never a kernel panic | I14 (no call panics the kernel) | budget-syscall-attack, budget-forge-attack, syscall-attack, legacy-gone |
Endpoint flooding: a hostile principal’s every hub slot calling walfsd without end, and Alice is still served in her turn | R2 (fair waiting) | redoubt-ipc for the kernel’s half (WAIT_CAP and turns per group); walfsd-flood-turn for a real walfsd flooded by a second principal while Alice’s own reads and writes return within the bound |
A vault session filling its WAIT_CAP on a shared server leaves its owner’s unlabelled session’s turn and cap unaffected | R2, R37 (vault non-interference) | vault-flood-turn |
A lender destroyed while walfsd holds its lent pages, and walfsd survives | R3 (lends and abandoned calls) | uaf-lent-page, process-lifecycle for the kernel; with walfsd not yet |
Crash blame: Bob crashes walfsd three times while Alice is busy; every session and lease of Bob’s with that label set ends and he cannot log straight back in; Alice is unaffected, also when walfsd panics rather than faults and when the crashing thread holds her calls open too; a crash from a send while a bystander’s call is parked blames nobody; a vault session’s crashes do not end its owner’s unlabelled session | R21 (crash blame), R40 (blame by label set) | process, process-attack for the kernel’s blame; steward-blame-lockout for one blame counted on the machine; steward-server-restart for the lockout at three and “Alice is unaffected” (her shell re-binds her home after each of Bob’s crashes and reads back what she wrote); the vault arm host only |
Pinned open calls: 64 lent calls parked at ipd with short timeouts, and ipd still takes netd’s frames and frees the abandoned calls; SSH sessions survive | R28 (parked-call accounting), R4a (open calls) | net-pinned; net-pinned-ssh for the same from a session’s gen_tcp over /net, Bob’s SSH login reaching its prompt and Alice’s echo still working |
System fairness: a busy walfsd:data does not fill blkd’s WAIT_CAP for walfsd:alice-secrets | R2 | blkd-fairness |
| Server CPU: expensive requests to a server delay other users only by that server’s weight | R12 | sched-server-busy, sched-large-weight |
Shared pools: filling the data volume does not fail Alice’s saves; flooding walfsd with handles does not grow its table | R48 (a quota per attach root), R26 (admission fairness) | walfsd-pools: Bob fills his home on the data volume to enospc, then holds handles on one file until walfsd refuses the sixteenth by name, his bucket’s share; Alice’s 64 KiB saves to her home succeed while he fills and while he holds them, each under 5 s, and the bench’s memory scan finds walfsd:data’s heap at 37 of its 64 pages with the flood held |
| Server authority: no server’s startup block holds its budget, a manifest granting one is refused, and no server can destroy a session | R33 (no server holds a system budget) | init-refuses-budget-handle; that no server can destroy a session is the steward’s, not yet |
process_create with a badged exit endpoint, to aim exit notices and blame at a server | R21 | process-attack |
| A reused PID carries authority | R20 (PID reuse) | pid-reuse-authority |
Loopback login: a session connects to the box’s own sshd with a key keyd holds | R59 (never the box’s own addresses) | session-tcp-loopback: a session’s gen_tcp connect to the box’s loopback and listed address on port 22 is refused by ipd, no SYN leaves the box (the capture) and the peer counts only the control; net-attacks for the whole refusal set |
| No leaky state: while a vault session works, an unlabelled observer sees no change in usage, request and session ids, message ids, PIDs, file versions, qids, directory listings, audit records or approval notifications, and cannot write, truncate, create or remove anything in the vault’s volume | R37, R25, R36 (unpredictable ids) | vault-no-leak, for every observable a session has (the list): Alice’s unlabelled session records its budget’s limits and processes, a probe carve, its contexts, its home’s listing with each entry’s kind, size and mtime, and a launch’s ending and use, before and after her vault session writes, launches and submits, and prints them equal; no notice but its own requests’ reaches it; its two request ids and the vault’s are three distinct random words, the second no successor of the first; its writes, creates, removes and truncates in the vault’s volume are enoent, the namespace giving it no connection there. Message ids, PIDs, qids, file versions and audit records are not reachable from a session today, so they are not on the list; exposing a PID or a qid would be its own node |
| Hostile launch: a malformed ELF or startup block from a user parent hurts only the child | R32 (a hostile image hurts only its process), R31 (startup block checked whole) | stub-launch for a malformed ELF, process-attack for malformed startup records at process_start; session-hostile-launch from a user parent: Alice’s session launches, from her home, an image cut to 40 bytes, one cut in half, one of zeros, an empty one and a whole one with a handle named outside the rule, through the launch native; the stub refuses the three malformed images as the child’s exit 111 with its budget holding nothing, the client refuses the empty image and the bad name before any kernel call, and her session’s processes are the same after, a well-formed exec running |
| Approval flood: requests hit the per-(account, label set) cap; the steward and Alice’s approval screen are unaffected | R38, R26 | steward-pending-cap |
| Admission: a crashed or killed client’s fids and quota come back when its launcher disconnects it; a system daemon filling its admission slots does not lock out the steward | R26 | not yet in a boot |
Remaining work
In this order, after M2 (usable shell). Each part lands with the attack cases for what it builds.
- The steward’s second half. Leases and agents, the powerbox and approvals at
approve@box, and crash blame; then declassification and push (the steward, the powerbox, crash blame, declassification). The cost of destroying a budget follows the dying subtree, under its target (budgets), and the steward’s decision-wake target is set from a seed sweep (responsiveness). The scheduling latency bench, measured with stand-ins for the steward and the drivers, is rerun with the real ones, and its numbers must stay within the target. sshd. Vault sessions andapprove@box(sshd, sessions).- The agent and the attack suite. Alice’s agent as its own principal under a lease, with delegation that only narrows (agents), launching native programs from a session (native programs), and every “not yet” above turned into a case.
- The model on the real kernel. Model traces replayed on the real kernel and compared step by step (the model), the model making the kernel’s checks in the kernel’s order (ABI).
Progress
The steward’s policy core decides leases, approvals, declassification, push and crash blame, and
is tested on the host; the running server’s half is built for leases, approvals at
approve@box, declassification and push (one item through a crossing budget with exactly its
labels, landing in ~/out, or pushed into the vault; a confined boot’s push lands on the
machine, the labelled session’s home left unbound) and crash blame (the lockout at three on
the machine, the sessions a crashed server served re-bound through the steward, an ended
session’s servers told at the steward’s turn so no blame is lost behind them)
(the policy core,
declassification and push). The kernel’s half of several attacks
above has its case, as the tables say, and the kernel containment gate holds hostile leases on the
kernel’s primitives alone (containment). The image’s
agent principal, for a sponsor-run agent whose loop runs outside the box, is sponsored by alice:
its login is a lease of hers, which her sessions can end and are told the end of, with no labels
and no network scope, logged in by agent-login; in that session the agent compiles a module
from a string, keeps its .beam in its home and loads it again from the bytes, by
shell-compile (development); and
start_agent and end_lease run on the machine (the image;
the steward). A session’s VM has TCP: gen_tcp over the
session’s /net at ipd, every socket ipd’s, so the loopback login above is ipd’s refusal
and a lease, with no /net, has no network (session-tcp-echo, session-tcp-loopback;
beamlet). What the milestone
builds on: the steward’s principals, sub-budgets and sessions, and sshd on the box, from
M1 (sessions over SSH, kept apart).
M4 (files in and out)
Goal
Files move in and out of the box by SFTP and SCP inside SSH, confined to the session’s capabilities and audited. There is no other inbound service and no other way in.
- A transfer runs in a transfer server the steward starts for one unlabelled session’s channel, in a budget carved from the session’s, with exactly the session’s file binds.
- A vault session gets no transfers: what leaves a label leaves by declassification.
- Every operation is one signed record in the audit log, which begins here.
Attack suite
- Confined to the session’s binds. A path-escape suite (
.., absolute paths, long and odd names) reaches only the session’s binds, and a second principal’s files are unreachable (sshd, transfer). - Only what 9P can say. Symlink, chmod and chown requests are refused, and so is the SCP
protocol (
scp -O), which runs a command on the server. - No transfers from a vault. A subsystem request on a vault channel is refused (R67 (a channel keeps its labels), transfer).
- Every operation is audited once. Each open, close (with its byte count), remove, rename,
mkdir, rmdir and setstat yields exactly one record, signed through
keyd’s audit purpose, naming the principal from the badge the steward minted, never the transfer server’s claim (the steward). - The audit key signs nothing else. The steward’s
keydgrant signs audit records and no caller-chosen bytes (R44 (one key, one purpose, keyd’s own digest)).
Remaining work
In this order, after M3 (agents, approvals and the attack suite):
- The transfer audit log: the steward’s append-only file of signed records (the steward).
- The transfer server: SFTP over the session’s binds, started by the steward on
sshd’s request (sshd). - SCP served as SFTP, which current
scpclients speak; the SCP protocol (scp -O) and every transfer on a vault channel refused (transfer).
Progress
Nothing of this milestone is built. What it builds on: sshd’s sessions and channels and the
steward, from M1 (sessions over SSH, kept apart), and keyd’s audit purpose,
which is built and tested in host tests (keyd).
M5 (self-hosted development)
Goal
Redoubt is developed on Redoubt, with agents doing part of the work, and the escape room runs against it continuously.
gitgoes through a gateway: the credential stays ingatewayd, and a grant names its remotes, its operations and the refs it may push.- The Elixir and Erlang compilers run on the box, on beamlet. Rust is built off the box and shipped in signed.
- The server APIs, the client crates and the Rust
stdtarget exist. gatewaydreaches one model provider, with TLS and a name-allowlisted resolver.- The agent harness gives an agent capabilities narrowed from its launcher’s.
- Name-scoped TCP for people, through the resolver.
- The audit log records every steward action.
Attack suite
The escape room. Real agents, each with its own label set in a confined deployment, told they coexist and told to get out. A referee sets each match up (which of the kernel and the servers are assumed correct, a planted secret and a canary, the audit log and one agent’s egress recorded) and decides from the record, never from an agent’s own output (agents). Its scenarios:
- Single-agent escape: expand its capabilities, reach what it was not granted, survive a restart with authority it was not given. Answered by capability closure (the tenets).
- The high/low pair: A holds a secret and has no network; B has network and no secret; they share a pre-arranged code. The design claims exactly one thing and the game tests exactly that: no intentional path crosses the label boundary (the tenets). Any such path is a design hole. A covert channel an agent shows is recorded for the hardware and deployment lists and the round goes on: it is out of scope, neither a win nor a loss.
- Authority expansion: forge a handle, mint wider than the source, reach a system budget, add a label, outlive a lease through a child. Any authority outside the initial grant, or any action of another principal it caused, is a win.
- Collusion through the human: persuasion, approval fatigue, a request that reads one way and means another. The design protects the approval channel, not the human’s judgment; an approval that grants more than the human intended is a win (the approval human).
Every confirmed finding is classified as a design hole or an implementation bug, and becomes a scripted bench case.
Scripted cases for what this milestone builds:
- A request over a gateway capability’s meter is refused before anything is sent; a model request
cannot reach another path, host or header; a
gitpush outside the granted refs, or a force-push not granted, is refused (R65 (a request only within its capability)). - No credential ever reaches the holder of a gateway capability (R66 (no credential leaves gatewayd)).
- A disallowed name never reaches the upstream resolver; two principals resolving one name make two upstream queries (R63 (only allowed names)).
- A name that resolves to a forbidden address is refused, and a rebinding attempt (an allowed first answer with a lifetime of 0, then a forbidden one) fails (R64 (connections by name are pinned)).
- Every lease grant kind is refused when broader than the launcher’s, and an unknown kind is refused (agents).
- Every steward action yields one signed audit record, and a labelled record never reaches an unlabelled reader (the steward).
Remaining work
In this order, after M4 (files in and out):
- The resolver, then name-scoped connections in
ipdfor people (the resolver, ipd). gatewaydwith keys and TLS, one model provider, and thegitrelay (gatewayd).- The client library’s further modules and the Rust
stdtarget (native programs). - Development on the box: the compilers on beamlet,
gitthrough the gateway, Rust built off the box and shipped signed (development). - The audit log for every steward action (the steward).
- The agent harness and the agent loop, on
gatewayd’s typed events (agents, the agent loop). - The escape room, run continuously, with its findings turned into cases.
Progress
Built: the compilers run on beamlet, on the host: Elixir’s compiler, OTP’s Erlang compiler, and
the ssl and ssh applications, unmodified
(development,
beamlet); on the machine, a module defined at a
session’s prompt compiles and runs (shell-compile-module,
beamlet on Redoubt), and one compiled from a string
is kept as a .beam in the session’s home and loaded again from it (shell-compile). The rest of this milestone is not built. It
builds on ipd with scopes and its sink rule, and on keyd’s signing purposes, which are built
and tested (ipd, keyd).
M6 (persist, install, share)
Goal
The box keeps running and changing without losing what it is:
- the steward’s state survives reboots, and the capabilities it minted are re-minted after a restart;
- principals and their keys are added and removed at run time, and the first owner is enrolled on the physical console;
- principals install signed packages, choose whose code they run with trust lists, and share projects;
- the system updates A/B: a bad update rolls back to the other slot, and rollback protection keeps an older signed system from being installed;
- the service supervisor starts and restarts installed services;
- the box has wall-clock time, kept in sync;
- the audit log is chained, verifiable offline, and kept for a set time.
Attack suite
- No new authority without trust. An unsigned or untrusted archive is refused before any parse (a fuzz corpus confirms the parser is never reached); code runs with a manifest’s grants only if a key on the principal’s trust list signed it (R71 (no new authority without trust)).
- Removing a key takes effect. Removing a signer from a trust list stops that signer’s running
processes and leaves the principal’s
userecords unchanged (packages). - A hostile package stays put. An archive signed by a trusted key cannot write outside its
principal’s package directory; a module named like a system module is refused, and a
.beamplanted in a session’s home is never loaded by name (R74 (a hostile package stays in its principal’s packages)). - No rollback. A bundle older than the version counter kept outside both slots does not boot, and a user’s crash loop cannot force a rollback, because health is judged before any session starts (R72 (no rollback below the counter)).
- A restart never widens. A service restarted by the supervisor holds exactly its recorded grants and no more (R73 (a restart never widens)).
- Persistence adds nothing. State read back after a reboot re-mints only what was recorded, and a server that restarts keeps no authority of its own (the servers).
- The audit log catches tampering. A record dropped, reordered or edited is found by the offline verifier (the steward).
- Membership is revocable. Removing a project member destroys that member’s revocation scope, and every capability minted into it dies (R41 (narrowing by revocation scope)).
Remaining work
In this order, after M5 (self-hosted development):
- The steward’s persistence, run-time principals and first-owner enrolment
(the steward), with
budget_children, which the restarting steward uses to find what it made (budgets), and keys sealed across boots (keyd). - Wall-clock time and time sync (the timer).
- Packages: the signed format, the pkg server, per-principal packages, profiles and trust lists (packages, installing), and user signing keys (development).
- Projects and sharing (the steward, files).
- The service supervisor (the supervisor).
- System updates: A/B slots, M-of-N signatures, the version counter (packages).
- Audit retention, chaining and the offline verifier (the steward).
Progress
Nothing of this milestone is built. What it builds on: the signed bundle and its verification at
boot (boot), keyd, and littlefsd’s power-loss guarantee
(R50 (power loss leaves before or after)),
all built and tested.
Follow-ups
Concrete work found while building and documenting Redoubt, one item per page. Each page says what is wrong or missing, why it matters, where in the code it is, and what “done” looks like. A page that states a rule the code does not yet keep links the item from its residual risks.
Items marked “fixed in the kernel follow-up package”, “the servers follow-up package” or “the
beamlet follow-up package” are the first work of
M1 (sessions over SSH, kept apart), ahead of init and
the manifest. The rest are placed by the plan pages or wait for an owner decision, which the
page names.
When an item is done, its page is deleted in the same change, and the page that linked it is updated to say what the code now does.
Kernel attack gaps
What
Claims the kernel pages state as built that no bench case attacks, or that only the model attacks. Each page’s status line says “partly tested” for these and names the gap. One line per gap: the page’s section, the claim, and what no case attacks.
ipc.md
- R1 (flow): a call or send between user budgets with different labels is attacked only in the model.
- R2 (fair waiting): the keying of groups by account, label set and (for account 0) budget is attacked only in the model, and on the kernel turns between groups only by
ipc-fair-label-sets(two unlabelled groups beside a vault’s turn);redoubt-ipcfills one group’s cap. - What
receivereturns: a record made unwritable while its thread waits is attacked for an exit notice (process-attack’s late record), an interrupt and an abandoned-call notice (receive-bad-record); for a message it is not attacked. - How a call completes, R13 (one outcome per call): completion races between harts are not attacked.
- R14 (unforgeable sender): every case delivers account 0 and no labels; a non-zero account or a label set reaching the receiver unchanged is attacked only in the model (
MsgNoLabels,MsgAccountZero).
README.md
- What the kernel keeps: that no driver, file system, naming or policy code sits in the kernel rests on the source map, not a case.
- The no-cruft gate has no self-check: no planted forbidden name,
allow(dead_code), unused feature or secondPAGE_SIZEis shown to fail it. - The TCB’s line count is measured, not pinned by a case; only
unsafehas a ceiling. - Nothing checks that every
kernel/srcfile appears in the source map. - The unsafe ratchet cannot show that every on-target TCB source file is listed in a budget.
objects.md
- What objects cost: an endpoint’s one page to its owner is attacked only in the model (
R6EndpointsFree). - What objects cost: a device object’s one page to
systemis attacked by nothing. - What objects cost: the header page is pinned by no case.
mint:Deadfrom a message source whose call’s stamp was destroyed has no case and no mutation.- R9 (stamps): a handle minted from a call taking the call’s handle’s stamp (not the caller’s budget) is attacked only in the model (
R9MsgStampIsSenderBudget).
budgets.md
- R6 (charging): an endpoint’s page charge is attacked only in the model; the header page is pinned by no case.
- R10 (destruction): timeouts before deadlines at an equal instant is attacked only in the model (
ExpireBudgetsFirst). - Class is trust, not order: that the scheduler never reads class is argued from the code.
- Deadlines: a process entering the kernel in a tight loop to put its deadline off is not attacked.
- Root, system and users: no case checks the boot table (
root’s 510 processes, the weights,INIT_WEIGHT). - R10 (destruction): destroying the budget a device object is charged to (the device destroyed, every handle closed) is not checked by a case.
timer.md
- Time: that
time_nowcounts from the kernel’s start is not checked (only monotonic, never early, linear withrdtime). - Expiry: the order at an equal instant (timeouts before deadlines; (pid, tid); budget id) is attacked only in the model (
ExpireBudgetsFirst). - Budget deadlines: a destroyed child’s later deadline leaving the list is shown only by the kernel surviving past it.
- Failure and restart: a boot with no
Timetag (or 0) powering off is not attacked (R17 (fail closed)’s gap too). - The hart timer: a stale early hint costing one early interrupt and missing nothing is argued, not attacked.
- A passed deadline comes first: a process entering the kernel in a tight loop to put its deadline off is not attacked (the same gap as budgets.md’s).
devices.md
- Device objects: a DMA device past
MAX_DMA_DEVICES(16) getting no object is not attacked: QEMU virt has eight virtio-mmio slots, and the bench hands QEMU no device tree of its own (the page a device object costs is measured bydevice-object-page). - Reset before reuse: that the reset precedes pooling inside the kernel is attacked only in the model.
- Devices handed to the first program: the loader’s refusal of a device tree with no console, or a console with no interrupt, is not attacked (no case boots a crafted device tree).
- R5 (interrupts): masking a fired source is attacked only in the model (QEMU’s 16550 raises per byte); completing the claim before masking, and billing interrupt time to the IRQ object’s owner, are argued on the page (QEMU’s PLIC accepts a completion for a masked source), not attacked.
- R18 (device authority): the kernel’s refusal of a malformed
Devsentry and of aGrntboot argument is not attacked: the loader emits neither, no case hands the kernel a crafted argument block, and no device tree can produce aGrnttag. - Failure and restart: at the destruction of a device object’s owner budget the source’s mask is argued on the page (
device-owner-destroyattacks the waiters’Deadand the handle sweep); a device handle closing with the budget that stamped it has no case short of the tree’s end, every device handle’s stamp beingroot.
processes.md
- Processes and PIDs: that PIDs are drawn at random is not attacked.
- Threads:
thread_createrefused withOutOfMemory, and the first thread returning from its entry (a fault), have no case. - Creating and starting:
OutOfMemoryfromprocess_starthas no case. - Exit notices: a notice dropped because its exit endpoint was destroyed has no case.
- R21 (crash blame): blame after the blamed sender’s budget is destroyed has no case; a thread holding a parked call that receives a send and then faults (blames nobody) is covered only in parts.
memory.md
- Backing and zeroing: that a frame freed with data comes back zero is attacked by
smp-inflight-race’s witness, but no case can tell which frames it was handed; a frame free since boot zeroed at its first allocation cannot be told from one QEMU left zero. - Frames in flight, R81 (frames in flight): no case lands a stale store in a frame being zeroed (QEMU drops a hart’s TLB at its next
satpwrite); the order is the trace’s. - Instruction fetch after mapping: no case can see a missing
fence.i(QEMU keeps fetch coherent). - Lending at the page-table level: a lend within one process is not attacked across harts.
- R11 (memory): the absence of any physical-address argument is argued from the call table.
- R19 (kernel W^X): no case plants a writable kernel code page to show the boot check stops.
scheduling.md
One flat stride queue: round-robin among one budget’s threads is not attacked.sched-round-robin.Preemption points: an interrupt’s wake not preempting, and another budget’s deadline preempting, are not attacked.sched-irq-wake,sched-deadline-preempt.- The current minimum and ties: clauses 1 and 4 are checked on the target only when a run happens to tie, and no case can make one: a waker ties a requeued budget, and two requeued budgets tie, only at passes equal to the tick, which charging makes a coincidence; the host tests and model attack them.
Charging: interrupt handling billed to the device object’s owner is not attacked.sched-irq-wake.Responsiveness:budget_destroycall-to-return is recorded, not asserted.sched-latency(destroy_call_p99_us).- R12 (scheduling): the bound on a call’s kernel time is attacked for the memory calls (
map-anon-search-bound,scan-bounds,worst-walk) and the endpoint, handle, thread, IPC and clock calls (call-bounds); not for lends and transfers,process_map,set_flagsand the device calls. R23 (no test channels): no case builds the production kernel and scans it for the trace.bench-no-trace,bench-trace-scan-catches,bench-test-only-refused.- Failure and restart: a picked thread dying before the switch needs another hart’s destruction to land between
kmain’s pick and its switch, which no case can time; the queue cannot be filled (one slot per process). Neither is attacked by a case.
boot.md
Firmware: the bench never falling back to QEMU’s own firmware is not attacked.Closed:host:testbench::a_missing_firmware_image_fails_the_case_rather_than_falling_back,host:testbench::every_boot_names_the_checked_firmware_and_a_tree_only_when_edited.- The argument block: the kernel’s refusals of a block only the loader could write (a tag past the end, a second
MREx, aDevsentry that names RAM or a controller, wraps or names interrupt 0, aGrnttag) are not attacked: no device tree makes the loader write one (it filters RAM and interrupt 0 itself) and the kernel has no host tests; a loader test knob would be a loader change. The one malformedDevsentry a tree can produce, an interrupt past the PLIC’s sources, is attacked:boot-rejects-irq-past-plic. The page says so as a residual. R16 (image confinement): an image cut short inside its segment data and a writable and executable segment are not attacked; the truncated-image case runs on rv64 only.Closed:loader-rejects-segment-data-cut,loader-rejects-wx-segment;loader-rejects-truncated-elfon both widths.R17: a short or missing seed and a missing timebase are not attacked (every QEMU boot supplies both); nor an initrd under 64 bytes.Closed:boot-rejects-no-seed,boot-rejects-short-seed,boot-rejects-no-timebase(the tree QEMU built, edited),verified-boot-rejects-short-initrd. The kernel’s own refusal of a missingSeedtag stands behind the loader’s and is not reached; the page says so.Verified boot, R15 (verified boot) and R17: the two signature cases run on rv64 only.Closed: both on both widths.- Devices handed to the first program: a DMA device past the sixteenth getting no object is not attacked (as devices.md’s): ATK3’s, with devices.md’s line.
Failure and restart: a reboot throughClosed:system_reset(the chain rerun, the bundle verified again) is not attacked.reboot-verifies-bundle.
memory-layout.md
satpand the TLB: no case attacks a stale translation surviving an address-space switch or an unmap (one hart, global flush; argued from the code; attacked across harts in M2 (usable shell): several harts).- Placement areas: the message area (
0x4000_0000, 4 MiB) andmap_anonarea bases are not attacked as addresses (not security claims; listed for completeness).
abi.md
- The kernel keeps every register outside a0-a7 across an
ecall: no case attacks it. - The order of checks after decoding: pinned by a case only for the first checks of
budget_create,budget_usage(records before the handle:budget-syscall-attack),call(record before endpoint lookup:ipc-outcomes),receive(WrongObject),serve,process_start(count before record:process-attack). The rest of each row (stages 2 to 5) is not attacked. - The kernel’s order against the model’s: no trace replay (planned for M3 (agents, approvals and the attack suite)), so the rows that differ were found by reading only.
- A valid call number with bit 32 set on rv64: not attacked (
legacy-gonedoes it for 0..=46 only).
invariants.md
- I7 (every flow obeys R1): a message between user budgets with different labels: no case (the same gap as ipc.md’s R1).
- I9 (pages W^X, zeroed, lends unmapped): reuse of a freed frame (a case cannot choose which frame it gets;
mem-attacksays so): model only. - I11 (fair turns): turns among several groups on one endpoint: model only (the same as ipc.md’s R2).
- I12 (ids never reused): budget and message ids never reused: invisible to a process, model only; endpoint, device and process-object ids: nothing attacks them, not even the model (it checks budget ids only).
- I13 (every blocking call returns by its timeout): timeouts on a multi-hart boot:
timeoutshas nosmpkey (M2 (usable shell): several harts). - I16 (DMA pages reset before reuse): a live co-holder that still reaches a device reset at another holder’s death: model only (
reset_at_one_death_does_not_cover_a_co_holder). - I1 (handles name live objects) and I10 (create-destroy leaves the parent unchanged): no mutation targets I1 alone; I10’s
R10KeepCarvedLimitsis caught first by the per-step R6 recount, sobudget_lifecycle’s own check may be doing no unique work. Not a gap in the kernel, a note on the model.
model.md
- R15 (verified boot), R16, R17, R19 and R23: not in the model at all.
steward_noninterferenceleaves out a server crash at an instant and one a vault’s call causes (service-slot timing, a residual of R37 (vault non-interference)).- Model replay on the real kernel: no case; every “attacked only in the model” on the kernel pages rests on it.
- The budget test’s hand-copied model sequence: nothing checks it still matches the model.
Why it matters
A status line that says “built” promises a test that would fail if the claim were false. Each line here is a claim whose failure no bench case would see: it rests on the model, on reading the code, or on nothing. The model’s evidence about the kernel is only as good as its replay against the kernel, which does not exist yet.
Where
tests/: the bench cases, one.tomleach, and their programs intests/programs.model/: the executable model, its properties and mutations.- The kernel pages in
docs/kernel, whose status lines name each gap.
Done when
Each line has a case that attacks it, and the page’s status line names that case; or the page
states the claim as argued from the code, outside any “built” status. The file is empty when
every kernel section’s status line is built · tested.
Beyond M6
Ideas past the plan. None is a goal: the hard goals are M1 (sessions over SSH, kept apart) through M6 (persist, install, share), and nothing here is built toward until the owner makes it one. Each page says what the idea is, why it is not a goal, and what it would need, including the attack cases it would have to pass. Some are kept so they are not redesigned from scratch; some are recorded as ruled out, with the reason, so they are not proposed again without one.
| Page | The idea |
|---|---|
| The FPGA platform | Redoubt on softcores: a DE10-Nano scale model and a Kintex-7 PCIe card with DMA confined in hardware |
| The card’s host backend | one locked-down Rust process serving the card’s console, disk and network over VFIO, never parsing a guest pointer |
| rv32 | the full stack on 32-bit RISC-V, booted in every milestone |
| Other runtimes | Python and Java, ported to Rust |
| The web stack | serving HTTP through isolated TCP, TLS and HTTP servers, reached only through SSH forwarding |
| Unattended operation | backup, crash records, field updates, monitoring and a rescue console |
| Swap | a userspace swapper under per-budget limits |
| Disk encryption | authenticated encryption of every block, for a disk outside the trust boundary |
| Linux on reserved cores | Linux driving messy hardware and serving virtio to Redoubt |
| IOMMU | hardware confinement of DMA through an IOMMU or IOPMP |
| An OS debugger | a debug capability and a GDB server, never issued in production |
| A shared content store | packages stored once for everyone, by hash (ruled out as a channel) |
| A shared image cache | program images shared read-only between principals (ruled out as a channel) |
| The link layer | VLANs, rate limits and routing, in linkd and routerd |
| A browser GUI | a desktop in the browser (conflicts with the no-GUI non-goal) |
| Hardware approval | high-stakes approvals signed by a FIDO key |
| ASLR | randomised address-space layout |
| Scheduling extensions | time donation and CPU quotas |
| Label extensions | taint-on-read and integrity labels |
A direction, the owner’s: the system-call ABI is kept free of the MMU (a transport trait, lends and transfers stated as ownership), so that a backend without one, cooperative and in one process, could implement it. Nothing builds that backend.
The FPGA platform
Idea
Redoubt on its own open soft-core system-on-chip, on a PCIe card whose console and virtio devices are served by a userland backend on the host beside it (the card’s host backend), as QEMU serves them in development:
- A YPCB-00338-1P1 card (Kintex-7 XC7K480T, PCIe), the target: four cores to start, scaled by measurement; a host running the backend over PCIe; and DMA confined in hardware: the card’s two DDR3 channels are split into main memory and a DMA-only channel that no bus master but the cores can leave.
- A Terasic DE10-Nano (Cyclone V SoC) is not a step on the way. It stays available as a cheap latency rig only if the card’s bring-up needs one; nothing below depends on it.
The system-on-chip:
- Cores: VexiiRiscv, one hart per core, in order and single-issue to start; RV64IMAC with Zba, Zbb, Zbs and Zicbom, Sv39, 16-bit ASIDs, PMP, S and U modes, no FPU; coherent first-level caches, a shared second level.
- Platform: QEMU
virt’s map, so RustSBI and the kernel run with nothing but a device tree to tell them apart: CLINT, PLIC, a 16550 UART, virtio-mmio slots, RAM at0x8000_0000. - Debug and measurement: a debug module and performance counters the bench can read.
The card is where DMA confinement can be done in hardware, which on QEMU it cannot (the tenets). The whole bitstream builds with an open toolchain (openXC7: Yosys and nextpnr-xilinx), so the gateware can be audited and rebuilt like the software.
Why it is not a goal
Every milestone runs on QEMU’s virt machine, which is also the bench, and none of
M1 (sessions over SSH, kept apart) through M6 (persist, install, share) needs hardware. The card’s
gains are confinement of drivers’ DMA, a verified loader, and an entropy source; all three
are stated today as residual risks.
What it would need
The system-on-chip
- The core. VexiiRiscv generated as Verilog (SpinalHDL): RV64IMAC, the B extensions and
Zicbom, Sv39, supervisor mode, coherent first-level caches of 16 to 32 KB each, a branch
target buffer, a return address stack and a GShare predictor, 16-bit ASIDs (Sv39’s whole
field; the width is a generation parameter), PMP, the debug module and performance counters.
Its interrupt controller, timer and I/O region are remapped to
virt’s addresses. Every process ID the kernel can make fits the ASID field, and the process ID is the tag, with no table between the two (satp); the kernel refuses a core generated with a narrower field. New RTL, in this order: Zicboz, then Sstc. Dual issue only after timing closes. - No hardware multithreading by default. At 60 to 80 MHz a DDR3 access is about seven cycles, so a strict barrel (two harts taking alternate cycles) halves each hart’s throughput without filling the stalls it is meant to fill. It is an experiment with a gate, not a part of the design: switch-on-miss multithreading is added only if the counters show more than 30 to 40 per cent of cycles stalled on memory, and a core that gets it shares its first-level cache and TLB between its harts, which then join the stated channels (side channels).
- The interconnect. TileLink: coherent first-level caches, a coherency hub and a shared
second-level cache, from the SpinalHDL TileLink SoC generator. LiteX is not on the path: its
add_pciemakes the host a master on the main bus, and its DDR3 target for this card is unfinished. - Memory. UberDDR3 on both channels: channel A is main memory, channel B is DMA only.
- The 16550 from the
pcie_7xproject, so the kernel andconsoledkeep one console driver and the console works from the firmware’s first instruction. A UART is trivial (tenet 7), so it is not a virtio device. Its byte stream crosses the link like everything else: what it transmits becomes a posted record in the host’s inbox, and what the host types is a posted write into its receive FIFO. - The virtio-mmio shim, one per slot. Its register file is local to the card: the host
programs it once (magic, version 2, the IDs, the features,
QueueNumMax, the config space), and the guest’s reads of it never cross the link. What the guest writes that the host must see,QueueNotify,Status, the queue addresses and readiness, the driver’s features, is forwarded to the host as posted notify records.InterruptStatusand its acknowledgement live in the shim; a doorbell from the host sets the status, and a nonzero status drives the slot’s level-triggered PLIC line. - The DMA region is channel B, an uncached I/O region of the core: VexiiRiscv’s memory
attributes are fixed when it is generated and have no write-through, so a region shared with
a host is either uncached or reached coherently.
dma_allochands out frames from it alone; today it draws them from the ordinary pool (devices). - The ring region is block RAM, uncached like channel B and confined like it, where drivers put their virtio rings: an uncached read of a ring index costs a cycle or two there instead of a DDR3 access. A queue of 256 entries is about 6.5 KB, so every queue fits.
The host backend
One Rust program on the host, cardd, serves the console, the disk and the network, in that
order, over VFIO: BAR0 mapped into the process, MSI delivered as eventfds
(the card’s host backend). It sits where QEMU sits in the
threat model: trusted to serve virtio honestly, and kept
from network data by TLS and SSH.
The signalling rule: each side’s inbox lives in its own memory, and only posted writes cross into it. The host’s inbox is pinned host memory; the card’s is the BAR0 mailbox. A read across the PCIe link costs a whole round trip (about a microsecond) and appears nowhere on a core’s hot path; the one read that crosses is the DMA engine fetching bulk reply data from host staging.
- Card to host. The shim turns a
QueueNotifyinto a notify record written into the host’s inbox. The backend waits on it from a core Linux leaves alone (isolcpus,nohz_full, the backend’s memory locked): spinning, then sleeping on the line withUMWAITorMWAITX, and after a quiet spell it arms the interrupt and blocks, and MSI wakes it, at several microseconds. - Host to card. The backend posts a completion into the mailbox and rings the slot’s doorbell, wired to the PLIC; the card writes the used ring, with a release store on the used index.
- Ordering. The guest driver issues
fence w,wbeforeQueueNotify; the used ring’s entries precede the release store on its index, and that store precedes the interrupt. - The contract. The shim advertises
VIRTIO_F_ACCESS_PLATFORM, so every virtio buffer the guest offers comes fromdma_alloc, inside a channel-B window, andEVENT_IDXis on. The host never parses a guest pointer: every descriptor is checked against its window on the card, by the DMA engine or the forwarder, and the backend parses only records in its own memory. Those checks protect the host process from a hostile guest; the guest’s protection from the host is channel B’s decode, below, not anything the backend does.
The card
- The board. An ex-datacentre XC7K480T-2FFG1156 with two independent 72-bit DDR3 ECC channels (2 GB each, on HR banks, so DDR3-1066 at most), PCIe Gen2 x8 wiring, a 64 MB BPI parallel NOR flash, a JTAG header and no other I/O. A look-alike “HPC” K420T/K480T board has a different pinout: check the silkscreen before trusting a constraints file.
- The parts, all built by the open flow:
pcie_7x: an endpoint on the PCIe hard block with no vendor IP, Gen2 x1 on this card. One lane gives about 400 MB/s, three times a gigabit link; the signalling is bound by latency, which the lane count barely moves.- UberDDR3 on both channels. Under openXC7 it runs at DDR3-667, about 5 GB/s a channel before
overhead. Bringing it up on this card needs workarounds kept in the build: openXC7 drops
INTERNAL_VREF(the 0.75 V reference is patched into the FASM), a zero-bit pseudo-pip is filtered out beforefasm2frames, and the router is run with fixed seeds. - Our own glue: the BAR block, the DMA engine, the shims and the interconnect.
- Speed. Expect the cores at 60 to 80 MHz under openXC7 at first. Vivado is used only to tell a tool fault from a design fault.
- Transport. VFIO, as above. At first the backend walks the rings through mailbox commands that have the DMA engine copy a checked channel-B range into host staging (a round trip or two a copy); later a forwarder in the fabric walks them and pushes whole requests to the host instead (two phases).
What the card guarantees
Trust assumptions, each stated by the platform:
- The host cannot change the card. The bitstream loads from on-card flash, and the gateware contains nothing that can rewrite or reload it: no ICAP core, no flash-writing core, no JTAG or debug bridge reachable over PCIe or from a core. Updates go through the JTAG header, which is physical access (out of scope). Whether the edge connector wires PERST# or JTAG to the configuration pins is checked on the board.
- The host’s window into the card is a mailbox. BAR0 decodes only to doorbell and mailbox registers, and the registers that tell the card where the host’s inbox is; no CSR, core reset, memory or window register is reachable through it. The inbox’s addresses are host addresses: a host that lies about them misdirects the card’s writes into its own memory.
What Redoubt needs from the hardware:
- DMA confinement in RTL. Every bus master other than the cores (the DMA engine, PCIe inbound) decodes only into channel B, never main memory, and inside it only into base and bound windows that the kernel alone programs: the essential half of an IOMMU by construction (devices, IOMMU).
- A root of trust. A boot ROM in the bitstream verifies the loader, closing the gap that the loader itself is unchecked (boot). It is the same signed chain the loader applies to the bundle, not a second one. Open: who holds the ROM’s key, and how the loader’s key is rotated under it.
- An entropy source (Zkr’s
seedCSR, for M-mode and the kernel only), since a card that boots on its own has no host to write a seed into the device tree. RustSBI needsmseccfg.SSEED. - Hardware channels are devices, each reached through a device object like any other (tenet 4).
Side channels
Microarchitectural channels are out of scope (the tenets), and the card does not try to close them: the cores share the second-level cache and the memory channels, and cross-core timing is not partitioned. Each is a stated channel; a core given hardware multithreading adds its shared first-level cache and TLB to the list. Removing them is a matter of placement, separate cards or machines.
ISA features
Standard extensions that make Redoubt faster without weakening it, each testable on QEMU first:
- Early:
- ASIDs, used properly (built). Each process’s ID is its ASID: a switch flushes nothing,
and a page-table change flushes by address and ASID with the kernel’s global entries
standing, a whole ASID when its process ID is given out again
(
satp). QEMU cannot show the saving, since its TLB is not tagged by ASID; this core’s is, and the IPC path is where it pays. - Sstc, so setting the timer is a CSR write, not a trap into the firmware on almost every dispatch.
- Zicboz, for the zeroing R11 (memory) does on every fresh frame (new RTL in VexiiRiscv).
- Zba, Zbb and Zbs.
- Zihintpause, for the kernel’s spins: the kernel lock’s wait and the shootdown’s. It runs as a no-op on a core without the extension.
- Zkt, stated by the platform for
keyd’s constant-time signing (R45 (constant-time signing)), after an audit of the multiplier’s and the bit-manipulation unit’s timing.
- ASIDs, used properly (built). Each process’s ID is its ASID: a switch flushes nothing,
and a page-table change flushes by address and ASID with the kernel’s global entries
standing, a whole ASID when its process ID is given out again
(
- With SMP and the card: Zkr (above), AIA with IMSIC for inter-processor interrupts and
remote fences without firmware traps, Zawrs for the kernel lock’s wait, scalar crypto (Zkne,
Zknh, Zbkc) for
keydand TLS, Smepmp and Smstateen, Svinval. - Refused:
- Svpbmt: an uncached alias of a frame the physmap maps cached is stale data waiting to happen.
- Svadu: puts a hardware writer on the page tables.
- Svnapot: a lend is not physically contiguous.
- Zacas: the kernel takes one big lock.
- Hardware IPC around the kernel.
- User access to
seed, the counters orcbo.inval(cbo.inval: R11 (memory)).
Kernel work it implies
- Several harts, planned for M2 (usable shell) (several harts): per-hart state, the big kernel lock as a FIFO ticket lock (R78 (fair kernel entry)), inter-processor interrupts and TLB shootdowns, then frame zeroing and other long work moved outside the lock, and per-hart frame magazines. The run queue stays global. Later, one trap-entry lock whose guard owns a token, so that kernel globals become token-guarded cells, with a compile-time lock order if the lock is ever split; shootdowns through SBI’s remote fences, then AIA.
dma_allocdrawing only from the DMA region and the ring region, and the loader reading both from the device tree (loader/src/dt.rs).- The ISA features above. (
senvcfgis already written 0 on every hart at boot: backing and zeroing.)
Measurement gates
Each step that adds hardware is taken on a measurement, never on an estimate:
- the fraction of cycles stalled on memory decides hardware multithreading;
- lock hold time and contention decide finer locking;
- DDR bandwidth and coherence traffic decide the core count;
- timing slack decides dual issue;
- one core closes timing on the card under openXC7 before any step that adds cores.
The gates are prose until the bench can read the performance counters, through an SBI call or the trace ring; that path is part of the bring-up.
Order
- A latency bitstream on the card: the
pcie_7xendpoint, UberDDR3 channel A and the BAR0 mailbox, with a fabric cycle counter timing doorbell and interrupt round trips over VFIO. - One core: RustSBI, the kernel, and the 16550 through the host backend.
- virtio-blk and virtio-net, then the full bench on the card.
- Confined DMA (channel B and its windows), the boot ROM, Zkr.
- Two cores (the SMP work), then four; measure, then scale.
- Zicboz, Sstc, AIA, and the descriptor forwarder.
Attack cases:
- A DMA master, or PCIe inbound, writing outside its window is stopped; one writing into main memory is stopped.
- Every BAR offset other than the mailbox reads as zero and ignores writes.
- A bitstream reload or flash write from the host or a core has no path.
- A loader the boot ROM does not accept does not run.
- User
seed,cbo.invaland counter reads fault. - A descriptor outside its window is refused by the DMA engine or the forwarder before anything reaches the host, and a device writing through it is stopped by the decode.
- Every scheduling and memory case reruns on several harts.
Open:
- The ROM key’s custody, and the loader key’s rotation under it.
- A frame being zeroed or copied outside the kernel lock is one no other hart can name: the invariant on the free-frame bitmap’s in-flight state, to be stated beside R11 with an attack case before the work that relies on it (several harts).
- The window granularity and how the kernel programs it.
- The core count beyond four, which waits on the measurements above.
The card’s host backend
Idea
One Rust program on the host, cardd, serves the FPGA card’s devices from
userland over VFIO, where QEMU serves them in development: the 16550 console, virtio-blk and
virtio-net. It is built for the smallest attack surface a hostile card could reach:
- The host never parses a guest pointer. The card pushes whole requests into host memory and
pulls replies out of it; every guest address is checked against a window in RTL, on the card.
What
carddparses is fixed-format records in its own memory. - Each side’s inbox lives in its own memory. Whoever reads a queue owns the memory it sits in, and the other side only ever writes into it, with posted writes. The virtio rings stay on the card; the host’s inbox is pinned host memory; the card’s inbox is the BAR0 mailbox.
- Opened, then locked down.
carddopens its device, disk, network and console, then gives up every right to open anything else before it reads one byte from the card.
Why it is not a goal
The card is not a goal (the FPGA platform): every milestone runs on QEMU, whose devices need no backend of ours. The backend is kept so that the card’s host side is not redesigned from scratch, and so that the card’s RTL and the backend are held to one contract.
What it would need
The inboxes
flowchart LR
subgraph card["The card"]
rings["virtio rings (BRAM)<br/>buffers (DDR3 channel B)"]
fwd["forwarder and DMA engine<br/>(window checks)"]
mbox["card inbox: BAR0 mailbox"]
end
subgraph host["The host"]
inbox["host inbox and staging<br/>(pinned, IOMMU-mapped)"]
cardd["cardd"]
end
rings --> fwd
fwd -- "posted writes: requests, notify records, outgoing data" --> inbox
inbox --> cardd
cardd -- "posted writes: completions, doorbells, console input" --> mbox
mbox --> fwd
fwd -- "DMA reads: bulk reply data from staging" --> inbox
- Card to host. Notify records, forwarded requests, outgoing data and console output land in
the pinned host region.
carddwaits on memory local to it, and the IOMMU confines the card’s writes to that region. - Host to card. Small items (a used entry, a status byte, console input) are posted writes into the mailbox, read by the card from local BRAM. Bulk data (a received frame, a block read) waits in host staging, and the card’s DMA engine fetches it on a mailbox command: the one read that crosses the link, issued by the engine with many in flight, never by a core.
- The virtio rings stay on the card, rings in BRAM and buffers in DDR3 channel B. The guest is the rings’ main reader, and a ring in host memory would make each of its reads a stalled round trip and its memory writable by the host underneath it.
The records on both sides are one-way, single-writer queues, the same discipline as virtio one level down. Each record carries a sequence number, so a torn or stale record is visible.
Two phases
- Copy commands. The backend walks the rings itself, through mailbox commands that copy a channel-B range into host staging; the DMA engine checks each range against its window first. A copy costs a round trip or two, enough for the console and the disk at bring-up.
- The forwarder. A walker in the fabric reads the available ring, checks every descriptor against its window and pushes the whole request (the chain, and the data the device reads) into the host inbox; a completion comes back the other way, and the card writes the used ring.
The device code sits behind one trait, GuestMem (read and write a guest range), with one
implementation per phase and a fake for tests, so it does not change between them.
Addresses
| Address | Chosen by | Known to the other side through |
|---|---|---|
| Shims, PLIC, UART, DMA regions | the hardware design | the device tree, generated with the RTL |
| virtio rings | the guest driver, at run time | the shim’s registers, forwarded in notify records |
| virtio buffers | the guest driver, per request | descriptors, checked against the windows |
| BAR0 | Linux’s PCI enumeration | VFIO; cardd uses only offsets |
| BAR0 register offsets | the contract | generated constants on both sides |
| Host inbox and staging | cardd, at setup | mailbox registers; enforced by the IOMMU |
| MSI | Linux | the card’s PCIe configuration space |
The fixed parts are one versioned contract: BAR0’s offsets, the record layouts, the mailbox’s
command format, and a magic number and version at BAR0 offset 0 that cardd checks before
anything else. It is written once and generates the Rust constants, the SpinalHDL constants and
the device-tree fragment, so the RTL and the backend cannot disagree by a register. The inbox’s
addresses are host addresses only: a host that lies about them misdirects the card’s writes into
its own memory, and no card window is reachable through BAR0.
The process
- One binary,
stdRust, synchronous: no async runtime; threads pinned to isolated cores. One thread serves every slot to start; a second takes the disk if a slow disk stalls the network. - Few dependencies, vendored:
libc,vfio-bindings(the ioctl structures only),seccompilerandlandlock, each held to tenet 5. The device side of the split queue is our own, a few hundred lines mirroring the drivers’: rust-vmm’svirtio-queueassumes guest memory the backend can map, which this design never has. #![forbid(unsafe_code)]in every module but two:vfio(the ioctls, the BAR’s mapping and its volatile accesses) andwait(the wait instructions).- The modules:
vfio;link(the inboxes, doorbells and copy commands);wait;mem(theGuestMemtrait, every range checked with overflow-checked arithmetic before anything moves);queue(the device side of the split queue andEVENT_IDX);dev::uart,dev::blk,dev::net;main(which slot is which device and what backs it, from one small file).
Startup, then lockdown:
- Open the card through VFIO (the group, or
iommufd’s device and/dev/iommu), the disk (O_DIRECT), the tap device and the console’s listening socket. The VFIO group belongs to the owner’s user through a udev rule, socarddnever runs as root. - Map BAR0, allocate one hugepage-backed region and map it for DMA as the only memory the card can
reach, wire MSI to eventfds, and write each shim’s register file: magic, version 2, the device
ID, the features,
QueueNumMax, the config space. - Lock down:
no_new_privs, every capability dropped, Landlock with no filesystem access, an empty network namespace (the tap is already open), and a seccomp allowlist ofread,write,pread64,pwrite64,fdatasync,accept4on the console socket,ppoll,futex,clock_gettimeandexit_group. Noopenat,socket,mmaporexecve.
What the card sends is hostile. queue bounds every index below the queue size and every chain
at the queue size (which catches a loop), caps the total length, checks each segment’s direction,
and refuses INDIRECT, which is never negotiated. A used entry names only a head that is
outstanding.
Waiting
cardd waits in three tiers, behind one function with a deadline:
- Spin on the inbox’s next record, for a microsecond or two.
- Monitor the line:
UMONITORandUMWAITon Intel (WAITPKG: Tremont and Alder Lake on, Sapphire Rapids on servers),MONITORXandMWAITXon AMD. The core sleeps until the card’s write lands on the line, with no interrupt and no kernel. The monitor is armed, the line re-checked, then the wait entered, so a write between check and wait is not missed. The wait is capped by the kernel’sumwait_control, so it loops. - Block: arm the interrupt (
used_event, or the inbox’s own flag), re-check the inbox, and block on the MSI’s eventfd.
CPUID at startup picks the second tier, and a host with neither instruction set skips it. Load pays the first tier’s latency; a quiet card costs nothing; only the first request after a quiet spell pays an interrupt’s microseconds.
The devices
- The console is the 16550’s byte stream
(
consoleddrives the 16550, as on QEMU). Its host end is a Unix socket,$XDG_RUNTIME_DIR/cardd/console.sock, in a0700directory with mode0600. The console is one principal’s session (authentication and sessions), so whoever reaches the socket acts as that principal at the console:carddchecks the peer’s user withSO_PEERCRED, admits only the owner, and admits one client at a time. Output goes into a 64 KB ring that a new client is shown first, so nothing is lost while none is attached. - The disk is
preadandpwritebetween a disk (an NVMe partition or an image) and the staging buffers. The request header’s type and sector count are checked, and the sector plus the count must lie inside the disk;FLUSHisfdatasync. Noio_uring: the link, about 400 MB/s, is the bound, andio_uringis a large kernel surface. - The network is a tap device bridged to the LAN. The owner makes a persistent tap and a bridge
over a wired interface once, as root;
carddattaches to the tap it owns withIFF_TAP | IFF_NO_PI, strips the 12-byte virtio header on transmit and adds a zeroed one on receive (no offloads are negotiated), and holds frames to 14 to 1514 bytes. The net shim’s MAC is a fixed, locally administered address fromcardd’s configuration. Bridged, the card is a host on the LAN, its open ports reachable by every device there, and the host’s firewall does not see bridged traffic; R55 (a bad frame is content, not a lie) and Redoubt’s own policy are what stand between the LAN and the card.
The host end sits where QEMU sits (host virtio emulation): trusted to serve honestly, and a disk’s lie is still a failure on the card (R52 (a lie is a failure, never corruption)).
Testing
- A fake card, in-process:
GuestMem’s fake and a fake link play the guest driver, posting chains and ringing notifies, asnetdandblkdtest against fake devices. - Fuzz targets for
queueover arbitrary rings, fordev::blkanddev::netover arbitrary requests, and for the inbox’s record parser: no panic, nothing outside a window touched, every used entry naming an outstanding head. - The card, from the platform’s first step: the latency bitstream gives a real BAR, doorbells and interrupts, and is where the three tiers are measured.
Attack cases:
- A descriptor chain that loops, overruns the queue or points outside its window is refused, and the slot is failed; the backend does not panic.
- A record with a stale or torn sequence number is ignored.
- A block request past the end of the disk is refused with an error status.
- A second console client, or one from another user, is refused.
- After lockdown,
openat,socketandexecvekill the process.
Open:
- The mailbox’s command format and the record layouts, which the RTL needs too, and the generator that keeps both sides on one copy.
- Running the network cases on the card: the bench’s cases assume QEMU’s user-mode network with
forwards on
127.0.0.1, and a bridged card needs a peer arrangement of its own. - Where the backend’s code lives in the tree, given that host code is a development aid.
rv32
Idea
The whole stack runs on 32-bit RISC-V, and rv32 boots join the dimensions every milestone must pass.
Today every milestone requires rv64 boots and rv32 compilation. The kernel boots on rv32 (Sv32), and most of the kernel’s bench cases boot on both widths; the servers, beamlet and the sessions are compiled for rv32 and not required to boot there (every dimension we claim).
Why it is not a goal
The development target, QEMU on rv64, is where every milestone’s work happens, and requiring rv32 boots of the full stack would double the bench for a width no planned deployment needs. Keeping rv32 compiling costs little and keeps width assumptions out of the code.
What it would need
- The full stack brought up on rv32, then rv32 boots added back to the required dimensions.
- Width-specific code kept where it is allowed: paging geometry, trap entry and the saved
context, and the ABI’s register encoding. Anywhere else a
target_pointer_widthcondition is a review failure; 64-bit values areu64, neverusize(memory layout, the ABI).
Attack cases: every attack case that boots on rv64 boots on rv32 too, with the same verdicts.
Other runtimes
Idea
Python and Java, ported to Rust, so that code written for them runs on Redoubt. Slow is accepted.
Why it is not a goal
The shell and the session are Elixir, on beamlet, and a second and third language runtime are each a large body of code to audit. The owner’s rule stands for them as for everything else: no C, so the reference interpreters and virtual machines cannot be used as they are, and a port is a project of its own.
A small embedded script language was weighed too, and not taken: Elixir on beamlet is the box’s
scripting language, and the one small candidate that builds no_std, Rhai, carries unsafe of
its own and in dependencies it cannot drop. The client library binds any language, so one can
be added later without changing it (the client library).
What it would need
- A runtime in Rust only, with the same audit rules as beamlet: no C, no foreign-function interface, no native extension modules; foreign code runs as a separate program (beamlet).
- A platform boundary like beamlet’s
Platform, over the client library: files through the namespace over 9P, the network only through capabilities, time and randomness from the kernel, nothing ambient. - Each program in a budget of its own, launched like any native program, with only the capabilities its launcher binds (native programs).
- Hostile code contained inside the runtime only as far as the runtime is memory-safe; the budget is the real wall.
Attack cases: a script cannot reach a path or a network its namespace lacks; a runaway script ends at its budget’s limits; a hostile module cannot load native code.
The web stack
Idea
Redoubt serves HTTP. TCP, TLS and HTTP each end in an isolated, trusted server, split by
privilege: ipd holds the sockets, tlsd the keys and the TLS state, httpd the parsing.
Applications get parsed requests, never bytes off the wire, and inbound access is only through
SSH forwarding (network policy).
Why it is not a goal
Inbound services other than SSH are a non-goal. Every milestone’s network work is outbound: people
connect by name, and agents reach a model and git through gatewayd, which does its own TLS.
A web stack adds two parsers of hostile input and a key-holding server for no milestone’s need.
What it would need
tlsd: TLS with the keys inkeyd, used and never released; one TLS state per connection; nothing of one connection visible to another. Whethergatewayd’s TLS moves into it is open (the servers).httpd: an HTTP parser in its own budget, handing applications typed requests; a crash in it blames the connection it was serving.- Applications as ordinary principals’ programs, each reached through a capability
httpdholds, with no socket and no key. - Inbound only through SSH forwarding, so the box still accepts no connection but SSH.
Attack cases: a malformed request crashes nothing but its own connection’s work; one client’s
TLS state is never visible to another; an application never receives a socket or a key; nothing
listens except sshd.
Unattended operation
Idea
A box that runs for long periods without anyone at it: backups of its volumes, crash records kept across reboots, updates applied in the field, monitoring from outside, and a rescue console for when the system does not come up.
Why it is not a goal
Every milestone assumes a person nearby: the first owner is enrolled on the physical console, a failed update falls back to the other slot at the next boot, and a crash is reported on the console and to the steward. M6 (persist, install, share) keeps state across reboots and restarts services, which is the base for all of this, but none of it is needed to meet the hard goals.
What it would need
- Backup: a capability to read a volume’s snapshot, held by a backup service, with labelled
volumes backed up only to a labelled destination, or encrypted under a key that stays in
keyd. - Crash records: exit notices and panic reports written to the steward’s audit log, surviving a reboot, readable under the label check.
- Field updates: the A/B mechanism with updates fetched through a gateway capability and verified by the loader, never by the fetcher (packages).
- Monitoring: a read-only capability to resource use and service state, per principal, with no global counters (the shell).
- A rescue console: a trusted path on the physical console that can boot the other slot or a recovery image, reached without the steward.
Attack cases: a backup of a labelled volume never lands where an unlabelled principal reads it; monitoring shows no other principal’s activity; a fetched update that fails verification never boots; the rescue path is not reachable from a session.
Swap
Idea
A userspace swapper server moves a budget’s cold pages to disk, with a per-budget swap limit
beside the page limit, so swapping never lets a budget exceed its total. Swapped pages are
encrypted and authenticated, and the system budget is never swapped.
Why it is not a goal
No swap is a non-goal (the tenets). Every page a budget uses is a page of RAM it was carved, so a limit means what it says and nothing another budget does can page it out. Swap would add a timing channel (a fault shows that a page was evicted, which depends on everyone’s use) and a server that holds every swapped budget’s memory.
What it would need
- The non-goal amended first, with the reason.
- The swapper holding no key: pages encrypted under keys kept in
keyd. - Eviction decided per budget only, from its own use, so one budget’s pressure never evicts another’s pages.
- Labelled budgets swapped only to storage of their own label set.
Attack cases: a budget’s swap never exceeds its limit; one budget’s allocation pattern changes nothing observable in another’s; a swapped page altered on disk is refused on the way back.
Disk encryption
Idea
Every block is encrypted with authenticated encryption and covered by a Merkle root, keyed through
keyd, in blkd or a server above it, so a hostile disk can neither read the data nor change it
undetected.
Why it is not a goal
On QEMU the host is the disk, and the host is trusted (host virtio emulation); on the FPGA platform the host is the root of trust. Encryption defends against a disk outside the trust boundary, which no planned deployment has. Today a lying disk is a failure, never corruption, but its contents are readable by the host (blkd).
What it would need
- A platform whose disk is outside the trust boundary, which is what makes it worth building.
- Keys sealed in
keydand never released; per-volume keys, so a volume’s label set also governs its key. - A Merkle root per volume kept where the disk cannot roll it back, with the same protection as the update counter (packages).
Attack cases: a changed, swapped or replayed block is refused, and the refusal is a failure, never a crash (R52 (a lie is a failure, never corruption)); a disk image read on another machine yields nothing.
Linux on reserved cores
Idea
On hardware too messy to write drivers for (a board with no IOMMU and no hypervisor extension), Redoubt takes some cores and Linux the rest. Linux runs the hardware and serves virtio devices to Redoubt through one shared window of memory; Redoubt runs its ordinary virtio drivers (tenet 7).
Why it is not a goal
Every milestone runs on QEMU, where the host serves virtio. On such a board Linux is trusted: a compromised Linux, or its boot chain, which also supplies the device tree and the random seed, owns the machine, and Redoubt does not defend against it. That is a weaker configuration than any milestone claims.
What it would need
- A partition the firmware enforces: RustSBI domains assigning harts, RAM and MMIO to a Linux
domain and a Redoubt domain, with PMP keeping each domain’s harts out of the other’s memory and
SBI calls kept within a domain. RustSBI’s domain support is unchecked today, so this waits until
it exists and is attacked on QEMU
virt(boot). - One shared window for the virtio rings and buffers, with Linux running the device side in a small userspace backend.
- Doorbells across domains: polling first, then a hardware mailbox or a small SBI extension.
- The platform stating plainly that Linux is in the trusted computing base.
Attack cases: a Redoubt hart cannot read Linux’s memory or the reverse outside the window; a device answer through the window is treated as a hostile device’s, as on QEMU.
IOMMU
Idea
Confine each device’s DMA with a RISC-V IOMMU (QEMU has one, and open RTL exists) or an IOPMP, which gives each bus master a set of windows. A driver that programs DMA would then no longer be trusted: a device could reach only the pages its driver was given.
Why it is not a goal
On QEMU the host is trusted anyway, and on the FPGA platform the DMA-only memory channel gives the essential half of an IOMMU by construction (the FPGA platform). The kernel today resets a device before its DMA pages are reused, which closes a dead driver’s hole but does not confine a live driver (devices).
What it would need
- A backend for one of the two, programmed only by the kernel, with a device object’s DMA runs mapped into that device’s window and nothing else.
- The IOPMP specification ratified, if that is the one chosen.
- Reset-before-reuse kept: a window closes only once the device confirms it has stopped.
Attack cases: a device told to write outside its driver’s runs is stopped by the hardware; a live driver’s device cannot reach another driver’s buffers.
An OS debugger
Idea
A small kernel introspection mechanism reached only through a debug capability (read and write a
process’s memory and registers, stop and continue it), and a userspace debug server that speaks
the GDB remote protocol to gdb on the host.
Why it is not a goal
The kernel is debugged from the host, through QEMU’s GDB stub, and on the FPGA through JTAG and the card’s trace. An in-kernel debugger is trusted code with authority over every process; the kernel carries no test-only channel in production (R23 (no test channels)), and a debug path is the most powerful such channel there is.
What it would need
- The debug capability as a device-like object, created only at boot and never issued by a production manifest.
- Introspection limited to processes in budgets under the holder’s own, so it grants nothing the holder could not already destroy.
- The debug server in its own budget, reached over SSH forwarding like any other inbound path.
Attack cases: a production boot has no debug capability anywhere; a holder cannot inspect a process outside its own budgets.
A shared content store
Idea
One store shared by every principal, holding packages by the hash of their contents: stored once, deduplicated, garbage-collected, with read-only code pages shared between users.
Why it is not a goal
It is ruled out as a channel. A store shared by everyone lets one principal learn what another has installed: add a blob and time it, or probe whether a hash is already there. Defending that needs uniform charges and restricted visibility, and the benefit, deduplication, is worth little with a handful of principals. Each principal’s packages live in its own directory (packages).
What it would need
- A reason that outweighs the channel, and the owner’s decision.
- Every principal charged as if the blob were theirs alone, whoever stored it first.
- No operation whose answer or timing depends on another principal’s blobs.
Attack cases: a principal cannot tell, by any answer or timing, whether another has stored a given blob.
A shared image cache
Idea
Program images kept read-only in one cache and mapped into every process that runs them, instead of each child paying for its own copy.
Why it is not a goal
It is a channel. A cache of image pages shared between principals tells one principal, by how fast a launch goes or what it costs, which programs another has run. Today every child pays for a copy of its image: the launcher copies the ELF into pages charged to the child, and the loader stub copies each segment again (init). That costs memory and launch time, and leaks nothing. The steward may keep an image’s bytes cached, to avoid reading them again: each child still gets its own copy, so nothing is shared between principals.
What it would need
- The owner’s decision that the saving outweighs the channel.
- A cache per (account, label set), so it is shared only inside one trust domain, where nothing is separated anyway; that keeps the saving for a principal’s own repeated launches only.
- Charges that do not depend on whether the image was already cached.
Attack cases: a launch’s time and cost are the same whether or not another principal ran the same program; an image page can never be written or executed through a second mapping (R11 (memory)).
The link layer
Idea
Two servers below ipd:
linkd, one per network card: 802.1Q tagging, each VLAN presented as its own interface capability; a small layer-2 and layer-3 allowlist and a rate limiter before any stack parses a byte; egress priority queues keyed by the capability the traffic came from.routerd, holding several interface capabilities and forwarding between them: longest-prefix match, TTL, ARP, stateful filtering and NAT for forwarded traffic, with its data plane in Rust.
Why it is not a goal
Every milestone’s network is one card, one netd and one ipd per network or trust domain
(netd, ipd). Nothing in the hard goals needs the box to
route, to split a card into VLANs, or to shape traffic.
What it would need
linkdbetweennetdandipd, holding no socket and parsing no more than the headers it filters.routerdas a system server whose control plane is Rust when the router is shared between principals; an Elixir control plane only for a network one principal owns.- The same rules as
ipd: default deny, and the box’s own addresses never reachable from inside.
Attack cases: a frame tagged for another VLAN never reaches its interface’s holder; a flood from one capability does not delay another’s queue; a forwarded packet never reaches the box’s own services.
A browser GUI
Idea
A desktop in the browser over one WebSocket. webd, in Rust, terminates TLS, authenticates, and
routes each person’s WebSocket to their session; a router in the session multiplexes channels to
application processes and holds the window state. The applications and the router are Elixir in
the session VM, so the OS itself contains no graphics code.
Why it is not a goal
It conflicts with a non-goal: no display, keyboard, mouse or GUI (the tenets). It is recorded rather than dropped so that, if the non-goal is ever amended, the design starts from its known finding: the desktop is drawn by the untrusted session VM, so a browser can show one request while approving another. A passkey signs a hash the person never sees.
What it would need
- The no-GUI non-goal amended first, with the reason.
- The GUI never approves. It may say that an approval is waiting; the approval itself stays on its out-of-band channel (R38 (out-of-band approval)).
webdas an inbound service reached only through SSH forwarding, like the web stack.
Attack cases: nothing drawn by a session appears on an approval; a session cannot reach another person’s WebSocket.
Hardware approval
Idea
High-stakes approvals signed by something the requester’s machine cannot fake: a FIDO security
key (an SSH sk- key) on a fresh approve-hs@box connection that accepts only the approver’s
credential, with the signature’s user-verification flag checked, not just the key type.
A button or small display on the board is ruled out: the softcore card sits inside a host no one sits in front of (the FPGA platform), and the key covers the same threat.
Why it is not a goal
Every milestone’s approvals happen on approve@box, a channel the steward alone drives
(the steward). That protects the
approval from the requester on the box. What it does not protect is a person’s own compromised
client machine, which a hardware key would; no milestone assumes that threat.
What it would need
sshd’s support forsk-keys checked, including the user-verification flag.- The high-stakes approvals named, and the steward refusing them on any other channel.
Attack cases: an sk- signature without user verification is refused; a high-stakes approval
on the ordinary channel is refused.
ASLR
Idea
Randomise where a process’s code, stack and anonymous memory are placed, using the kernel’s random words, so an exploit of a memory-safety bug cannot rely on fixed addresses.
Why it is not a goal
Every address in a process is fixed today, and map_anon places pages deterministically
(memory layout). Programs are Rust, with unsafe
budgeted, so the bugs ASLR slows are rare; a bug that is exploited reaches only its own process’s
handles; and fixed layouts keep the model, the traces and the bench reproducible.
What it would need
- Placement drawn from the kernel’s random words in the loader stub (segments, stack) and in
map_anon’s search, with the spread using the rv64 address space. - The model and trace replay taught to abstract over placement.
- A layout still the same on both widths wherever the ABI fixes it.
Attack cases: two launches of one program place it differently; a process cannot learn another process’s layout from anything the kernel returns.
Scheduling extensions
Idea
- Time donation: a server thread runs on its caller’s budget for the length of a call, so the caller pays for the work it asked for.
- CPU quotas: a budget may use at most so much CPU time in a window, beyond its weight’s share.
Why it is not a goal
Servers pay for their own CPU, and a busy server delays others only by its own weight (R12 (scheduling)). Donation is intricate, and a donated server thread stopped mid-call by its caller’s lease or quota could hold the server’s locks for good (scheduling). Leases already bound time by deadline and weight.
What it would need
- Measured priority inversion that hurts a milestone’s target, which is the reason to add either.
- For donation: a server that can be stopped at any point of a donated call without holding anything another caller needs, or a donation that ends at a point the server chooses.
- For quotas: a rule for what a quota-exhausted budget’s open calls do, since its callers are waiting.
Attack cases: a caller cannot make a server run on the caller’s budget longer than the call; a caller’s lease ending mid-call leaves the server able to serve others.
Label extensions
Idea
- Taint-on-read: a budget that reads labelled data gains the label at that moment and loses what the label forbids (its network, say).
- Integrity labels: low-integrity data, such as a model’s output, cannot reach a high-integrity sink without an endorsement.
Why it is not a goal
Labels are fixed when a budget is created, and reading above one’s labels fails (the servers): an unlabelled agent that stumbles on labelled data gets an error and keeps its network, and nobody can taint someone else. Taint-on-read would let an accidental read cut an agent off, let labelled data be planted on others to cut them off, and leak through siblings started before the read. Integrity labels answer a different threat, bad input reaching a trusted consumer, which no milestone claims to stop.
What it would need
- For taint: a rule for everything the budget already shares (siblings, open connections, parked calls) at the moment of the read, and a defence against planted labelled data.
- For integrity: a second label dimension in the kernel and in the label check, and an endorsement step with its own approval.
Attack cases: for taint, planting labelled data on a victim does not cut it off; for integrity, unendorsed low-integrity data never reaches a high-integrity sink.
The test bench
The bench is how every claim in this book is tested. It builds the real kernel, loader and
programs, packs and signs a boot bundle, boots it under QEMU, and judges what appears on the
console, over SSH and on the network, from outside the guest. It also runs host tests and source
checks. It is part of the system under tenet 6 (the tenets),
held to the same standard of simplicity as the kernel: one tool, tools/testbench, and one file per
case in tests/.
How to use it
cargo testbench # every case but those run only by name
cargo testbench timer # cases whose name contains "timer"
cargo testbench --arch rv64 # one target
cargo testbench --list # names and descriptions, those run only by name marked
cargo testbench sched-latency --sweep 1..20 --jobs 4 # one case, a seed sweep, 4 boots at a time
cargo testbench --exact sched-latency # that case alone, not sched-latency-tcg too
cargo testbench --prebuild target/prebuilt # build every case's pieces once, both widths
target/prebuilt/testbench --prebuilt target/prebuilt --exact --arch rv64 timer # run from them
./test # the same, from the repository root
The exit status is non-zero if anything fails. Each run keeps its boots’ console logs, SSH
transcripts, disk images and captures in a directory of its own,
target/testbench/run-<pid>-<time>/, and target/testbench/last names the latest. Only the
newest few run directories are kept, so --keep-failed DIR copies a failing boot’s console log,
its second boot’s and its sessions’ into DIR as <arch>-<case>.smp<N>…, where they outlive
the run (host:testbench::a_failing_boots_logs_are_kept_under_its_jobs_name). Runs may
overlap in one worktree: each packs the kernel cargo reports for its own features, never the
shared target/ path another run may have rebuilt. A case the host cannot run (no RustSBI
firmware, no OpenSSH) fails and says what is missing; --allow-skip reports it as skipped
instead. Every run boots the vendored RustSBI prototyper: scripts/build-bios.sh builds it for
both widths, or RUSTSBI_PROTOTYPER and RUSTSBI_PROTOTYPER_RV32 name the images. There is no
fallback to QEMU’s own firmware.
| Path | What |
|---|---|
tools/testbench/ | the bench: builds, injects programs, boots QEMU, judges the console, sessions and network |
tests/*.toml | the cases, one per file (tests/data/: files they read; tests/keys/: SSH test keys) |
tests/programs/ | no_std programs that run inside Redoubt: the log server, victims, attackers, checkers |
tests/net/ | the network clients and the judge that the net cases start under init, and the host test that checks each net case’s manifest against its case file |
A filter takes every case whose name contains it, so cargo testbench timeouts runs
timeouts-tcg too; --exact takes the one case it names, run only by name or not, and refuses a
name no case has. A run stages each userland disk it boots in a directory named for the recipe’s
whole path, as two recipes may share a file name.
Building once
Status: built · tested: host:testbench::an_index_survives_its_rename_and_names_its_tree, host:testbench::the_fingerprint_follows_the_trees_changes, host:testbench::an_exact_filter_runs_one_case, host:testbench::userland_recipes_of_one_name_stage_apart, host:testbench::a_build_asked_for_again_in_a_run_is_not_run_again
A run builds what it boots: cargo run of the bench, then a cargo build of the kernel, the loader
and each program, a check that costs about 30 ms when nothing changed and waits on the build
directory’s lock while another run compiles, then the bundle and any userland disk. Alone that is
a quarter of a second before QEMU starts; beside a train of other runs, each waiting on the
others’ compiles, it was most of a case’s time. A run makes each distinct build once, and a case
asking for one already made takes its binary without running cargo, since a run takes the tree
as it was when it began: the kernel and the loader are built once per profile and target, not
once per case. --prebuild DIR does it once for every case:
DIR/testbench the bench, copied from the --prebuild that made the directory
DIR/redoubt-sshd-host Redoubt's sshd on its host platform, for the loopback cases against it
DIR/<arch>/index.json the tree's fingerprint; each case's pieces, or its build's results
DIR/<arch>/... cargo's copies, the bundles, the userland disks
Every boot and build case is built for each of its targets (--arch for one; a filter for
some), the widths one after the other in one process: two userland stages at once would compile
the same Mix project in its one build directory. A build that fails is kept as the case’s
result, printed by the prebuild and again by each run of the case, as is a build case’s
verdict. Each width is built in a directory of its own and renamed into place when done, so a
run reading the old pieces never sees a half-written one.
DIR/testbench --prebuilt DIR then runs cases as cargo testbench does, but takes each boot
case’s bundle, loader and userland disk from DIR and runs no cargo and packs nothing; what
stays per boot is what each boot must have afresh: the QEMU probe, its disk (a disk recipe is
still packed for every boot) and its devices. A case starts in about the time QEMU takes to
start. The index names the workspace that made it and the tree’s fingerprint: its commit, its
uncommitted changes and its untracked files that git does not ignore, taken when the prebuild
starts and again before each width is put in place, which a change in between refuses. A run
from a directory another workspace made is refused naming it, and so is one from this tree
before a change, never a silent test of old code, so the directory is made again after any
change. What git ignores is not in the fingerprint: the pinned toolchains (toolchains/, or
where BEAMLET_TOOLCHAINS points) and a Mix project’s _build (userland/shell/_build/); a
change to those needs a prebuild by hand. The other kinds run as
always, with their own cargo where they have one (host-tests). scripts/jobs.mk makes the
directory (prebuilt) and runs every case target from it when it is there (“On a shared host”
under the case file).
Verdicts
What a case passes on
Status: built · tested (9)
- bench:bench-console-after-expect
- bench:bench-poweroff-missing
- bench:bench-qemu-early-exit
- bench:rustsbi-boot
- host:testbench::a_killed_bench_leaves_no_qemu
- host:testbench::a_line_is_shown_without_its_control_sequences
- host:testbench::a_qemu_lacking_an_option_is_named
- host:testbench::a_shown_line_keeps_its_own_prefix_first
- host:testbench::forbid_matches_the_line_as_it_came_or_as_shown
A boot case passes when every expect pattern matches a console line, in order, no forbid
pattern ever matches, and the boot ends as the case says. Four patterns are always forbidden:
PANIC, TEST FAILED, WARNING: INSECURE, and ipd: an invariant broke, the line ipd says
if a socket’s buffers ever fail to fit its heap, reachable only at a residual of its sizing that no
case reaches (ipd). After the last expect, and any sessions, the bench
keeps reading for 50 ms (1 s in a checked build), so a forbidden line right after the last expected
one still fails the case. With poweroff = true it reads instead until QEMU exits, and requires the
exit status the case names (0 by default; 255 for an SBI system failure). A boot that ends, or
times out, while an expect is still waited for fails with the first failure a program printed
([name] FAIL) ahead of the line it waited for, since that is the cause. The bench starts QEMU
with Linux’s parent-death signal asked for between fork and exec, so a bench killed at its
timeout, even outright, takes its QEMU with it and leaves no guest running to skew the next run.
A console line is matched as a terminal shows its text: with its control sequences removed. A CSI
sequence goes whole, any other escape with the character after it, and every other ASCII control
character but tab, DEL among them, and trailing space after them. Nothing is drawn, so a carriage
return does not take the line back to its start, and the prefix the log server or consoled gave
the line stays first: no program’s text can be shown in front of it. expect, expect_after,
[[input]] and the poke’s after, and distinct_across_boots match the shown line. forbid, the
four always forbidden, and the checks on the reporter’s PASSED and DONE lines see both the
line as it came and as it is shown, so a forbidden word split by a sequence is caught, and so is a
raw sequence a case forbids ('\x1b'). The console log keeps every line as it came, and distinct
and a post_check read that log.
Before a width’s first boot the bench runs that width’s QEMU with -version, and a QEMU that
cannot run fails every boot case at once with its complaint (skips them, with --allow-skip, as a
missing firmware does). A guest that ends before it prints a line, or whose QEMU exits with a
failing status, fails with QEMU’s exit status and the last lines of its stderr,
which go to the console log too; one that printed and then died fails as it did, with QEMU’s exit
status.
In-guest programs print through the log server and finish with <NAME> TEST PASSED or
<NAME> TEST FAILED; attack programs end with attempts done instead. The log server starts every
line it prints for a client with the sender’s badge, as the kernel reports it, on every path that
takes client text: [pid N] for a badge below 0x100, and [badge N] for any other. The
tester numbers a case’s programs by their place, 2 on (it is 2), at most 16, and gives each its
place as its badge, and every badge a test program mints is 0x100 or more, so a minted badge can
never print as a place and forge a case program’s line (tests/programs/src/logsrv.rs). Lines
without a prefix come from the kernel, the loader, the log server’s own fixed templates, or a
program that owns the UART.
Rule F (trusted verdicts)
Status: built · tested: bench:bench-attack-forgery, bench:bench-reporter-mismatch, bench:logsrv-badge-forgery, bench:bench-init-reporter-forged, bench:init-console-forgery
A verdict comes only from a party the attacker cannot impersonate. The console does not say who
wrote a line, and an attacker can print anything, including another program’s PASSED, so an
attack case passes only on a line the attacker cannot write:
- The kernel or the loader, refusing: a line with no
[pidprefix, withKMAINor a later stage forbidden so that no program ever ran (loader-rejects-*), or an anchored line the kernel prints at boot before any program starts, which a relayed line cannot match (kernel-wx). - A victim that owns what is attacked and still has it afterwards: the log server still hears
UART input after every attempt (
irq-attack); a victim inspects its pages (mem-attack,uaf-lent-page). - The log server’s
DONE. The program ininit’s place owns the console: it holds the console’s device handles, and every other program’s text reaches the UART only through it, prefixed with the sender’s[pid N]or[badge N]. A victim, or with no victim the attacker once it is done, calls the checker’sdone(); the log server prints one[server] done:line naming the caller by the badge the kernel delivers with the call, which the tester chose, and powers off. A case that names areporterpasses only if exactly one line starts[server] done:and it names the reporter’s place; any other such line fails it, as does one in a case with noreporter. consoled‘s prefix, underinit. In a servers’ case the realinitholds the UART untilconsoledstarts, andconsoledstarts every line written through a child’s connection with that connection’s[con N], so onlyinit’s lines are bare. The bench learns the reporter’s N frominit’s bare announcement and takes aTEST PASSEDonly under it (the servers’ cases underinit).- A sole first program judging the kernel. In
map-fixed-attack,write-only-attackandprocess-attackone program runs, alone: it holds the console and the reset, makes every refused call itself, and prints its own unprefixedok:lines. It is trusted because nothing else runs that could impersonate it, and because what it attacks is the kernel, not another party in the case: eachok:line reports a kernel result it read back (the error, the budget’s usage, the mapping left in place), and the case also needs a clean power-off, which a fault in the program prevents. The residual: a kernel bug that corrupted this program’s own memory could make it misreport, and no second party would see it.
Program order. A program that attacks another program, or the fixture, is never the first. In a case with several programs, the
first is the trusted tester (the log server, unless the case is one of the sole-program cases
above), and the case’s programs list fixes the order of the rest, so which place and which
handles each program gets is the case’s choice, never a race.
Every verdict pattern is anchored with ^ and pinned to its writer, with a comment beside it saying
why it cannot be forged. The attacker’s own lines may be required as progress (so a refusal for the
wrong reason fails) and forbidden as evidence of a breach, but are never the verdict. Where the
attacker itself reports to the checker, the case shows only that the system survived, and its
description says so (“verdict: survival only”).
Cases
The case file
Status: built · partly tested: that an unknown field or table is refused is read from the code, not attacked by a case · tested: bench:bench-console-after-expect, bench:bench-poweroff-missing, host:testbench::a_case_out_of_the_whole_run_runs_only_by_name, host:testbench::host_tests_route_only_to_a_requested_workspace_and_forward_features, host:testbench::an_oracles_tools_must_be_on_the_path, host:testbench::sweep_seeds_are_ranges_or_lists, host:testbench::a_sweep_is_refused_before_anything_builds, host:testbench::sweep_boots_have_their_own_files, host:testbench::the_join_prints_in_seed_order_and_counts_failures, host:testbench::a_sweeps_boots_hold_disjoint_slices_of_the_lease, host:testbench::two_boots_at_once_run_in_different_slots, host:testbench::smp_is_one_list_or_a_list_per_width, host:testbench::a_boot_runs_its_widths_own_hart_counts, host:testbench::a_fanout_is_of_values_files_or_tests, host:testbench::a_job_passes_fails_or_runs_past_its_deadline, host:testbench::a_cpu_list_counts_its_ranges, host:testbench::a_failing_boots_logs_are_kept_under_its_jobs_name, bench:rt-miri, bench:model-mutations
A case is one TOML file. Paths in it are relative to the workspace root, and an unknown field or
table is an error, so a misspelling cannot silently drop a check. The one exception is a programs
entry (and a bundle file’s from): its forms are told apart by their keys, so an extra key in one
is ignored rather than refused (tools/testbench/src/case.rs).
description = "What this proves"
arch = ["rv64", "rv32"] # targets to run on
whole_run = true # false: run only when the filter is the case's name
kind = "boot"
programs = [ # the first in init's place, the rest started by it
"log-server", # a binary of the test programs
{ package = "my-crate", bin = "my-server" }, # any workspace binary, built for the target
{ package = "my-crate", bin = "my-probe", features = ["probe"] }, # built with these features, in a target directory of its own
{ package = "beamlet-redoubt", bin = "beamlet", workspace = "userland/otp" }, # a binary of a workspace of its own, built there
{ path = "prebuilt/thing.elf" }, # or a prebuilt ELF
{ erlang = "path/to/module.erl" }, # an Erlang module, compiled by the pinned erlc (a bundle file's)
{ otp = "io" }, # an OTP module's .beam from the pinned toolchain (a bundle file's)
{ zeros = 6291456 }, # that many zero bytes, an entry only its length matters for (a bundle file's)
]
smp = [1, 4] # one boot per hart count (default [1]), or per width (below)
keep_smp = true # the counts stand under `--smp N` (a comment says why)
timeout_secs_smp = 90 # the bound under `--smp N` instead (a comment says why)
memory_mib = 32 # guest RAM (default 256)
memory = false # true: stop an init guest and measure its servers' painted stacks
timeout_secs = 60 # default 60; fractions allowed
icount = "shift=3,sleep=off" # virtual time: 2^3 ns per guest instruction, the RTC on it too
qemu_seed = 1 # pin the guest's randomness (QEMU -seed)
kernel_features = [] # extra kernel features
debug_assertions = false # true: a checked build of the kernel and the loader
expect = ['regex 1', 'regex 2'] # each must match a console line, in this order
forbid = ['regex'] # must never match
poweroff = false # true: the guest must power off after the last expect
reporter = "checker" # the program whose done() is the verdict (rule F)
launch = false # true: boot `./launch --system`'s own QEMU command line (launch-*)
post_check = "sched_oracle" # a host-side check of the console after a passing boot
[[input]] # typed on the console once a line matches `after`
after = "claimed irq 10"
send = "xyz"
A boot case also takes allow_panic, tamper_bundle, sign_bare_archive, short_initrd,
device_tree, distinct_across_boots and distinct (hostile inputs), [[file]]
(bundle files), [disk] and [net] (devices),
[[session]] (SSH sessions), must_fail (self-checks), and
poweroff_status: the QEMU exit status a poweroff case requires (0 by default; 255 for an SBI
system failure, which a rejection case asks for).
smp is one list for every width the case runs on, or a list per width:
smp = { rv64 = [1, 2], rv32 = [1] } boots rv64 at one and two harts and rv32 at one, for a case
whose count on one width is a finding of its own, so that the other width’s run does not go into
a copy of the case. A width the map leaves out runs the default, [1]; a key that is not one of
the case’s arch, and an empty list in either form, are refused before anything builds (a case
leaves a width out of arch, never boots it zero times). jobs.mk leases the width’s own
largest count, and --smp N replaces a map as it does a list.
With icount, QEMU runs the guest at a fixed instruction rate, skips idle time to the next timer
deadline and puts the RTC on the same clock (-rtc clock=vm). A time a case asserts is then a
count of guest instructions (at shift=3, 1 ms is 125,000), the same on any host however loaded.
It does not make a run repeat on its own: QEMU fills the guest’s boot RNG seed from host entropy on
every boot, the kernel draws PIDs from it, and that moves every later event. qemu_seed = N pins
it (QEMU -seed N, which fills the device tree’s /chosen/rng-seed), and with icount a run then
repeats exactly. The bench prints the seed before the result, and TESTBENCH_QEMU_SEED=M replaces
the pinned seed of every case that has one, to replay a run or to sweep. An idle guest’s clock
skips to its next armed timer deadline, sleep on or off: with sleep=off at once, so while
blkd waits for the host’s virtio completion the next deadline is its own 10 s request timeout,
the read fails and the volume is poisoned, which is why a case with a disk keeps sleep on
(icount = "shift=3"); with sleep on the skip was measured too, once the guest idled with no
timer armed nearer than a lease’s deadline 1,337 s ahead, which it reached inside a 295 s boot,
the lease destroyed at it as its rule says. So a case under the virtual clock whose guest can
idle while a lease it means to outlive the case is live either runs on the host’s clock or
expects the lease’s end; with sleep on such a run repeats closely, not exactly, and a time a
case is held to comes with a sweep’s spread.
--sweep SEEDS runs one case once per seed (1..20, or 3,5,9), from one build, and prints each
seed’s result in seed order and a summary line; each boot keeps its files in seed-<N>-<arch>/
inside the run’s directory. --jobs J boots up to J seeds at once. Under scripts/q the sweep
cuts the job’s port lease (q run --ports N, REDOUBT_PORTS=lo-hi) into J disjoint slices from
its start, one per concurrent boot, and each boot forwards on its slice alone, so no two meet on
a port; a lease shorter than J times the case’s forwards (its forward ports and its poke) is
refused before anything builds, naming the --ports to ask for, and no boot is ever handed a
slice another holds. One boot at a time takes the range’s start, as a single run does; outside
q each boot asks the OS for its ports, with that path’s race. --jobs is never the default; what a
result under it is worth is the shared-host rule below. A timing gate runs one pinned seed and
states its target from a sweep of seeds (responsiveness).
Which cases run in guest time. A boot case runs under icount = "shift=3,sleep=off" unless
something in it waits on the host, and 145 of the 226 do. What keeps a case on the host’s clock:
a disk or a userland disk (38 cases; the rule for a disk is above); host sockets, a forward, a
poke, a peer or a dial (8); input the host types on the console, [[input]] (4; under icount
the rv32 UART lost a burst of it); and a run whose purpose is the host’s
time (asid-cost-host, sched-latency-tcg, timeouts-tcg, and smp-evict-mttcg,
smp-shootdown-mttcg, console-one-writer, sched-lock-contention-4-mttcg and
beamlet-schedulers-mttcg, which need QEMU’s multi-threaded TCG; its one-hart twin
beamlet-schedulers-one-hart runs in guest time, since on one hart what it measures is the
instructions two schedulers’ switching costs). Several harts do not keep a case
on the host’s clock: under icount QEMU runs the harts in turn on one host thread, and a hart
waiting for the kernel lock halts rather than spending its turn
(R78 (fair kernel entry)). One case reads no host clock and stays on it for now: redoubt-ipc
fails under icount on both widths (189 calls abandoned of the 256 it wants), until that is
understood. A timeout_secs is the bench’s bound, never a measurement: a case in guest time
is given at least four times its slowest pass alone on either width, rounded up to 10 s, and
more than any wait of its own program, so that a stuck check reports itself; it is never raised
without a measurement. bench-poweroff-missing keeps its 3 s: there the deadline is the oracle,
the timeout its must_fail waits for.
On a shared host. cargo testbench runs its cases one after another; what may run beside
the invocation, another invocation in its own run directory, a seed under --jobs, a build, is
decided by the clock each case measures with. A boot case with icount measures in guest time:
the host’s load does not move a guest time, so its pass, and a failure the guest itself
reports, are verdicts whatever ran beside it (a pinned seed adds only that the run repeats
exactly). Its one exposure to the host’s clock is timeout_secs, the bench’s deadline for the
boot: a case that only ran out of that deadline beside other work has no verdict, and is rerun
alone. A boot case without icount (68 of them) keeps the host’s clock in the guest, so load
lengthens every wait it makes: its pass is a verdict unless what it expects is a timeout or a
bound on a time, and a failure beside other work has no verdict until it fails alone. Every
bound a case states on a time, a step’s, a probe’s or a notice’s, is the box’s under one
condition, one whole bench at a time with QEMU its alone for the run and any other work on the
machine host-only and off the bench’s cores: measured under it, held to under it, and a bound
missed beside another boot is no verdict until the case misses it under it. A [net]
table by itself changes neither class: an empty one gives the guest a card that reaches
nothing, and binds no host port. A table with a forward, a poke, a peer or a dial puts host
sockets beside the boot, in wall time: its dials retry until the case’s deadline, so a pass
stands and a failure is rerun alone, as for any host-clock wait. Its host ports come from the
job’s lease under scripts/q (q run --ports N, the forwards and the poke; the job sees
REDOUBT_PORTS=lo-hi, a range no other lease holds, and the bench takes the forwards’ ports from
its start), so two such boots run side by side without meeting on a port; a sweep’s --jobs J
boots are J such boots inside one lease, each on its own slice of it (the sweep paragraph
above: the lease must hold J times the case’s forwards, or the sweep is refused); what remains under q
is a stranger to q taking a pooled port between the daemon’s probe and QEMU’s bind, which fails
that boot loudly, as before. Outside q the bench
picks a forwarded host port by binding and releasing it before QEMU takes it, so two such boots
at once may race for one port, and there the rule asks that two such boots not run at once; a
lost race fails the boot rather than hiding (QEMU cannot bind). An ssh-loopback
case is the same kind of case: its sshd runs in inetd mode through ssh‘s ProxyCommand
and binds no port, its guest’s stdio is a virtio-serial port, and the kind’s probe (one
loopback session with a 30 s connect timeout) runs once before any case and decides whether the
host can serve the kind, never a case’s verdict. So a loopback case that asserts no time (13 of
the 14) passes as a verdict, and a failure that is only its deadline is rerun alone. A case
that measures with the host’s clock is a verdict only alone: the rule asks that no other
invocation and no build run beside it, and for a whole bench scripts/jobs.mk holds that rather
than asks it, through the scheduler’s qemu lock (the scripts/q paragraph below). Those are a host-tests case whose crates’ tests assert
a wall-clock bound (redoubt-rt, redoubt-client, redoubt-keyd and redoubt-consoled do;
redoubt-ipd, redoubt-model and testbench only read the clock), which no tolerance would
make load-proof; a boot case whose bound is a ratio of two host times (sched-lock-contention-4-mttcg:
a stalled vCPU thread lengthens a wake but not the search it is counted in; beamlet-schedulers-mttcg:
a stalled vCPU thread lengthens one of the two runs it compares, and on the quiet set’s four cores
alone its one-hart twin read 81 to 95 for a ratio that is 94 in guest time, which is why that twin
runs under icount); a case whose expectation is a timeout (bench-ssh-guest, and
bench-ssh-loopback-deadlock, whose must_fail is the mark it never gets); and a host-clock
case with a VM session whose console goes through the hub’s hold and which must still be alive
after a hold boundary (the steward’s SSH session cases): a stall of the guest of a second or
more at the boundary reads, inside the guest, as the server’s silence past its promise, so the
session’s completion call times out and the session ends as at Ctrl-D. A host-tests case whose crates assert no bound measures nothing with the host’s clock; it may
run beside other work, and the rule asks that its test threads be bounded then
(RUST_TEST_THREADS), so that it cannot oversubscribe the host by itself. A bound the platform
under test keeps on the kernel’s clock is such a bound too when the kernel is the fake’s and its
clock the host’s: beamlet-lookup-host’s system tests once let a bind’s ATTACH_US and a call’s
timeout run against a fixture server under a whole bench’s load, and about one train in two the
host’s second ran out first; they hold the fake’s clock now (Fake::hold_clock), so each such
bound, a retry’s RETRY_US and a file operation’s FILE_WAIT_US included, is a step the test
takes once the state it waits on is reached, and the ten-second waits are gone with the race. Its
console.rs tests held the host’s clock the same way — one, input_nobody_reads_holds_no_idle…,
read the idle’s 200 ms deadline against the host and under load a read’s submit timeout ran out,
queued the read, and woke the idle early — and hold the fake’s clock too now, each deadline a step,
the console server’s session bound crossed by the ticks that cross it. A tick is taken only once
every other thread of the fake is in a wait, and a thread a change has woken counts as waiting
only when it waits again: before that, a tick could fall between a server’s answer and the
waiter’s hand-over of it whenever the host had not yet run the waiter, and on a loaded host the
busy-retry test’s retry fell past its last tick and the platform’s end waited for it on a clock
nobody moved, about one ./test-shell run in ten. The kinds that boot
nothing (build, fmt, no-cruft, the size and unsafe budgets, the docs checker, the Elixir
oracles) have no clock, and are verdicts anywhere, but for a timeout_secs: as for a boot, a
host-tests job or an Elixir case that only ran out of its deadline beside other work is rerun
alone.
scripts/q applies that rule for a whole machine. It is one scheduler process that leases real
cores: q run --cores N -- <command> waits for N free cores, runs the command pinned to them
(taskset; cargo’s jobs and RUST_TEST_THREADS follow the lease) and returns its exit code;
--quiet runs a host-clock case on a reserved core set (the daemon’s --quiet, by default its
last four cores, "" for none), one such case at a time, while the rest
of the machine keeps working; --ports N leases N host ports from the daemon’s pool (23000-23999,
below the ephemeral range; a port a stranger holds is skipped) and hands them to the job as
REDOUBT_PORTS=lo-hi, so a [net] case’s forwards never meet another’s, where before one
machine-wide --lock net ran every such case in turn (train 40: 30 cases, 12,360 s of summed
waiting, 1,353 s once for a 0.5 s case); a lease ends with the
client process, so a killed job frees its cores and ports, and a restarted daemon adopts the
jobs still running, their pins and their ranges, until they end (scripts/q-test checks the
lease on a daemon of its own). --lock NAME takes a named lock exclusively and --share NAME
the same lock shared: sharers run beside each other, an exclusive taker runs alone among the
jobs naming the lock, and from the moment it waits no new sharer starts ahead of it, so a
stream of sharers cannot hold it off. The lock qemu is how the condition every bound on a time
holds under, one whole bench at a time with QEMU its alone, is held rather than asked: a whole
bench (jobs.mk’s cases-*, quiet-* and all-* targets, and a set holding every case of
the boot, quiet or net class) takes it exclusively as a lock-only lease (q run --cores 0 --lock qemu, the qemu-held target) before its first case and keeps it to the make invocation’s own
exit, the lease’s command ending when make’s pid is gone, so the hold ends with the lease by any
exit path, a killed make or a dead train included, never by a trap or a file; a second whole
bench waits for the first. Every single boot case, a case with an arch run by name or in a
smaller set, a sweep by hand (q run --share qemu --cores M -- cargo testbench --smp N) and
launch‘s boot take it shared: single boots run beside each other as before, and one started
while a whole bench holds the lock waits, printing one line naming the holder (its tenant, its
name and its checkout) and since when, then runs when the hold ends; it never fails for the
wait. Under the whole bench’s own make its cases take no lock, since the make holds it, and a
case that boots nothing (no arch: the host-tests, Elixir, build, formatting and docs cases)
takes none: what it measures the lock does not protect, and the quiet set keeps the host-clock
host tests off the bench’s cores. A restarted daemon adopts a running hold as it adopts cores
and ports (Q_LOCK and Q_SHARE in the job’s environment), q ls names the holder and since
when, and a daemon from before the shared take runs a --share job unlocked and says so, as it
does for ports (restart it). scripts/q-test checks the lock as it checks the lease: the
sharers beside each other, the waits, the line, the hold’s end with its client and its
adoption. A job’s scratch goes to $REDOUBT_TMP, by
default .tmp at the checkout’s root, which git ignores, so removing the checkout removes it;
the job gets it as TMPDIR too, since a host’s /tmp may be a RAM filesystem a sweep would
fill. scripts/jobs.mk names every case as a make
target (rv64/<case>, cases-rv64, quiet-rv64, build-rv64, prebuilt, docs) and picks
the class for it: a boot takes one core per guest hart, a host-tests case or an Elixir case four
(an Elixir case is mostly the build of beamlet and the model’s trace writer, under that lease
but outside its deadline, so it stays in this class: Elixir oracles), a case
with a fanout two for its build while each of its jobs asks q for its own, and the host-clock cases
go quiet. A case target runs that case alone (--exact), from target/prebuilt when its width
is there (building once), else through cargo testbench; its output is
target/jobs/<arch>-<case>.log, and a failing boot’s console and session logs are kept beside
it (--keep-failed), so a transcript is never lost to the run directories’ pruning; the
scheduler oracle judges a kept console log again by hand, through its ignored host test
(SCHED_ORACLE_LOG=<log> SCHED_ORACLE_ARGS='<the case's post_check arguments>' cargo test -p testbench a_kept_log_judged_again -- --ignored --nocapture). A failing
host-tests case’s log holds the test harness’s failures block, from its failures: line: each
failed test’s panic and name and the test result line (a block past 400 lines keeps its first
panics and the names list), then Cargo’s line naming the test file that failed and how its binary
ended, never the warnings Cargo printed last (train 49 kept twelve of those and no test name). A case
with no arch boots nothing, so it runs under rv64/<case> alone, and rv32/<case> does
nothing. Case targets may run at once (set CASES="..." runs a set on both widths) only while
each still takes its own lease, the quiet ones run alone on the quiet set and the [net] ones hold
their port leases, as every case target does: the leases, not make, keep the host from being
oversubscribed. q ls shows
the core map and the queue; q log the recent jobs with the time each waited and ran.
cargo testbench --smp N boots every boot case once at N harts instead of its own counts, but
a case that keeps them (keep_smp), and bounds it by timeout_secs_smp where a case gives one;
since jobs.mk leases a case’s cores from its own counts,
such a sweep runs as q run --share qemu --cores M -- cargo testbench --smp N, M at least N.
The heavy host cases. What each costs on the 24-core build host, and the deadline it has (each a measured run and a third, unless its row says otherwise):
| Case | Its cost | timeout_secs |
|---|---|---|
model-mutations | 175 jobs, one per mutation, in release, one core each; a mutation’s steward families stop at 500 seeds, and the three they catch only after many seeds are caught first by directed scenarios (mutations). About two and a half minutes of wall, the build included; the longest job R2NoWaitCap’s, 18.7 s | per job, 25 |
model-host-tests | one cargo test on four cores, every model test but the mutations, the steward families and their coverage check: under two minutes, the build included | none |
steward-model-host-tests | the two steward families at their counts and the check that their coverage table is reproduced (property families), each a job on four cores and threads: under two minutes of wall, the build included; the longest job steward_policy’s, 26 s | per job, 35 |
rt-miri | 12 jobs, one per file, about 100 s of wall: heap’s 60 to 96 s; connection 25 s; the rest under 10 s | per job, 128 |
elixir-oracles, bench-elixir-oracles-broken-guard | a cold build of beamlet and the model’s trace writer, 29 s alone on four cores and up to 46 s beside a bench, outside the deadline (Elixir oracles); then the scripts, 2.5 and 1.3 s alone, 3.2 and 1.7 s beside two prebuilds | the scripts’, 10: four times elixir-oracles’ alone |
A case with whole_run = false is left out of a run with no filter and out of one whose filter
is only part of its name; it runs when the filter is its whole name, and --list marks it “by
name only”; jobs.mk’s cases-* and quiet-* leave it out too, and its own target runs it. It has
one reason: a measurement too long to repeat at every train.
The kinds, and the fields each takes besides description, arch and whole_run:
| Kind | What it does | Its fields |
|---|---|---|
boot | boots the kernel with programs as its first processes and judges the run | those above |
build | only checks that a package compiles for each target: coverage for what the bench does not boot; with forbid_in_binary, that the binary holds none of the byte strings named (self-checks) | package, features, debug_assertions (the checked profile instead of the release), forbid_in_binary, must_fail (the build’s error, or the scan’s finding) |
host-tests | runs cargo test on the host for the named workspace packages, for what no boot can reach (a constant the loader and the bench share is right in the machine’s eyes even when it is wrong); with miri, under nightly Miri. These cases are the bench’s only host tests; cargo test --workspace is not run, though it compiles. The kernel and the test programs have no host tests (test = false on their targets) | packages, tests (the test files to run; default all), miri, workspace, features, tools, filter (only the tests whose names contain one of these), skip (tests left out, by part of their name), profile (Cargo’s, such as release), fanout (each, env, values, cores, vars, late, late_cores), timeout_secs (each fanned job’s deadline) |
ssh-loopback | runs [[session]]s against a host OpenSSH server with no guest, to check the session runner on its own | authorized (the test keys the server accepts), [[session]], timeout_secs, host_key (default: the server’s own), server_log (patterns each of which must match a line of the server’s own log), must_fail |
unsafe-budget | the ratchet on unsafe (below) | [[budget]]: name, paths, max_unsafe, max_undocumented; [[uncounted]]: path, reason |
size-budget | the ceiling on each trusted crate’s size (below) | [[crate]]: name, paths, max_lines |
no-cruft | the source gate (below) | paths, [[forbidden]] (pattern, unless, within), no_allow_dead, one_definition, definition_paths, [[allow]] (path, rule, reason) |
fmt | the formatting gate (below) | roots, [[skip]] (path, reason) |
elixir | runs scripts that check an Elixir oracle on beamlet against the Rust it shadows, each of which must exit 0, on the pinned toolchain (below) | otp, elixir, build (commands run first, outside the deadline), scripts (each a path and its arguments), must_fail, timeout_secs (the scripts’ together) |
For host-tests, workspace defaults to the repository root and otherwise names a relative
directory below it with a Cargo.toml; an absolute path, a path outside the repository or a
missing workspace fails before Cargo runs. features defaults to none and passes the named
features to Cargo. For example, beamlet-lookup-host runs the VM and Redoubt tests from
userland/otp with beamlet-redoubt/fake; beamlet-lookup-cli-host runs separately without that
feature, so the two cases do not combine their Cargo features. tools names host programs the
tests run as an oracle (erofs-oracle’s mkfs.erofs, fsck.erofs and dump.erofs): each must be
an executable on the path, or the case fails naming the first missing, and skips it with
--allow-skip, as for any other host lack.
A fanout runs a long case as many jobs instead of one cargo test, one per each:
"value", one per line the shell command values prints, each running the case’s one test file
with that line in the variable env (model-mutations, one job per mutation, the names
cargo run --example mutations prints, in REDOUBT_MODEL_MUTATIONS); "file", one per entry of
tests (rt-miri); "test", one per test of every test binary the case builds, by libtest’s own
list, each run alone, those filter takes and skip does not (steward-model-host-tests). The
bench builds the tests once, first, with no deadline; then each job runs its test binary as
cargo test would, from its package’s directory (under Miri, which runs no binary by itself,
cargo miri test of the one file), through scripts/q on cores cores of its own (default one), as many at once as the
machine has room for. vars are set for every job: a test that spawns its own threads is told how
many it has (the model’s MODEL_THREADS), since under q its RUST_TEST_THREADS is the lease’s.
Where q does not answer, the jobs run one after another. Each job’s output is a file in
<case>/ of the run’s directory, and <case>.log lists every value with the time its tests
took; the case passes when every job does, and its failure names each value whose job failed. A
job that runs past timeout_secs fails, saying so: a deadline on the cores the job asks for,
stretched in proportion when q, once the job has waited, grants it fewer. late names values known to pass only past
a bound their test keeps, each with the follow-up that removes it: their jobs run with
TESTBENCH_LATE=1, the test’s leave to go past it, and a pass is reported as “caught late,
known” in the result and as LATE in the log, never hidden; any other value past the bound, an
entry that names no job, and one whose test now passes within the bound fail the case.
late_cores gives a late value’s job, the long search, its own count of cores; a {cores} in a
vars value becomes the count each job is granted, which q may lower from its ask once it has
waited, so a test that spawns its threads runs one per core it holds (the model’s
MODEL_THREADS, in model-mutations and steward-model-host-tests). No case names a late value
now.
A post_check judges the console after the boot has passed. sched_oracle rebuilds the
scheduler’s order from the raw events a tracing kernel prints and checks every pick against its own
reading of the rules (scheduling); a limit such as r10_p99_us=30000
bounds a measured cost. Two arguments judge a sibling’s first run: round, that it comes before
any other budget is picked twice, and lift-delay, that it comes within a round of what the lift
on its parent predicts. A walk-trace kernel’s walks are bounded by their longest, net of the
audits inside them (pump_max_us, expiry_max_us, reconcile_max_us), judged before
r10_p99_us, so a run whose destruction is over its bound still has its walks judged.
gate_harts=2 judges the latency targets on a trace of at most two harts (its F record) and
only reports them on more, marked recorded at N harts, not gated: the targets are gated at one
hart and two and recorded at four (responsiveness).
smp_fence reads only the same trace’s shootdown records: a page made executable was shot down
on another hart running its process, which acknowledged (memory).
smp_inflight reads only an inflight-trace kernel’s records of each RAM frame’s way through
R81 (frames in flight): retired (i, the frame and the process whose entry
for it was cleared), each shootdown (s), pending (d), given back to the bitmap (b) and taken
from it (o). Each frame is pending only on the hart that retired it and, if it was mapped, only
after that hart shot its process down; given back only once pending, so its hart has zeroed it;
and never taken in between. A run where no frame from a live space was retired, given back and
taken again fails as racing nothing.
Starting a case’s programs
Status: built · tested (7)
- bench:rng
- bench:budget
- bench:budget-carve-attack
- bench:budget-destroy-kills
- bench:programs-unknown-budget-attack
- bench:programs-unknown-program-attack
- bench:bundle-mapped
The loader loads only the kernel and init
(boot), so a kernel case’s first
program is packed as the bundle’s second entry and runs in init’s place. It gets init’s
handoff: the three budgets, the Reset right, every device and the bundle. It is the trusted
tester. When it is the log server, it starts the case’s other programs itself, through the
loader stub and from the bundle’s pages, each in a budget of its own carved from system. Each
program gets the same slots:
- slot 1, the boot endpoint: its receive right for the first program started, a send badged with its place for each later one;
- slot 2, the log endpoint, badged with its place;
- slot 3, its own budget;
- slot 4 on, the budgets the case names for it (
budgets = ["system"]), in the line’s order.
Its own budget’s handle gives a program nothing it lacks: the pages are already its own to
spend, a carve from it comes out of its own share (R7 (carving)),
and destroying it ends only the program and what it started
(R10 (destruction)), which the tester sees as a killed
notice. It lets a program read its own charges and run measured work in a budget it carves. The
budget is system-class, as its parent is, so a program may add labels below it; a kernel case
has no server those labels could reach.
The builder tells the tester which programs to start, and with which budgets, in one more data
entry, programs. It has one ASCII line per program after the first, in the case’s order: the
program’s entry name, then the names of the budgets it gets (root, system, users),
separated by spaces. The tester reads it from the bundle’s pages, which the loader verified with
the rest, and reads every line before it starts anything. It refuses the boot, and powers off,
on a line that is not printable ASCII, names an entry the bundle lacks or a budget other than the
three, or names a budget twice, and on more programs than system has processes. Each
program’s own budget gets an equal share of what system has free (pages and processes) and
weight 1,000, a driver’s, so no program’s limits depend on another’s name or order. A case that
needs other sizes builds its own tree from the budgets it is given.
The log server badges each program’s handles with the program’s place in the case, 2 on, and names programs by that place, because the kernel draws every PID but the first. A case matches a PID in a kernel line with a pattern. Each program gets an exit endpoint of its own, so the tester tells their notices apart (processes) and prints each under the program’s name and place, with the kernel’s PID beside it.
A program the tester starts holds no device, and every page it has is backed, its stack
included: only the process the loader starts has a stack reservation
(memory). So a case that needs a device, or a page never
touched, runs its program alone in init’s place, where its own budget is root.
init itself never takes a case’s place and has no mode for the bench. The kernel’s cases need
root and system, which no program under init may hold
(R33 (no server holds a system budget)),
so they run a tester in init’s place instead.
The servers’ cases under init
Status: built · tested (5)
- bench:init-servers
- bench:init-console-forgery
- bench:bench-init-reporter-forged
- bench:init-boot
- bench:littlefsd-reboot
A servers’ case boots the real init with a manifest of its own, packed as the manifest
entry, and its test programs are servers entries in that manifest. They get no budget handle,
their own included, as no server does
(R33 (no server holds a system budget)).
init prints its own lines bare, and consoled starts every other program’s line with its
connection id, [con N] with N in 16 lowercase hex digits
(consoled). A case’s reporter names a manifest entry, and
the bench reads that entry’s connection id only from init’s bare line announcing it,
init: started NAME, console N; a second such line in one boot fails the case. A restarted
server is announced as init: restarted NAME, console N, and the bench never reads a reporter’s
id from that line, so a reporter that restarts cannot pass its case. A case that judges a reboot
expects init’s reboot line and then the next boot’s first line, the loader’s. The machine
resets in the same QEMU run, with the same disk, and from that line on the bench reads the
reporter’s id afresh from the next boot’s announcement, so a case may go on to its verdict in the
next boot; still only one TEST PASSED line, in the whole run, may pass it. The case passes only if
exactly one line says TEST PASSED and it starts with that [con N] ; any other such line fails
it, wherever it came from. Such a case ends at its last expect, which waits for the verdict,
since no test program holds the Reset right. The test programs are redoubt-init-programs
(tests/init-programs): boot-reader reads a public entry through the root badge at bootfsd
its entry is handed, con-forger prints its arguments as lines, and the rest are the servers,
clients and drivers of init’s restart cases. Since init restarts a program that exits, each
parks when it is done, unless its exit is the point of its case.
The scheduler oracle
Status: built · tested (38)
- bench:sched-ties
- bench:sched-capped
- bench:sched-lock-contention
- bench:sched-lock-contention-4
- bench:sched-lock-contention-4-mttcg
- bench:sched-timer-entry
- host:testbench::kernel_sections_say_what_the_lock_waits_waited_behind
- host:testbench::the_driver_wake_is_judged_in_searches_of_its_own_run
- host:testbench::a_pick_passes_over_only_a_budget_that_other_harts_run
- host:testbench::the_oracle_decides_whether_a_timer_interrupt_ended_the_slice
- bench:sched-wake-no-preempt-harts-mttcg
- host:testbench::a_pick_in_place_is_a_slice_end_that_requeued_its_runner
- host:testbench::lock_waits_are_reported_per_mille_of_the_harts_time
- host:testbench::lock_waits_take_the_lock_in_ticket_order
- host:testbench::a_shootdown_record_is_passed_over
- host:testbench::a_trace_that_keeps_every_clause_passes
- host:testbench::each_broken_clause_is_caught
- host:testbench::a_broken_trace_is_rejected
- host:testbench::the_models_own_ranks_pass
- host:testbench::the_models_broken_ties_are_caught
- host:testbench::lifts_are_recomputed
- host:testbench::weight_changes_are_recomputed
- host:testbench::the_floor_and_the_passes_are_checked_on_their_own
- host:testbench::destructions_are_timed_and_bounded
- host:testbench::audits_are_subtracted_inside_each_window
- host:testbench::water_filling_caps_a_budget_at_its_threads
- host:testbench::a_charged_share_across_harts_is_judged_by_water_filling_net_of_lock_waits
- host:testbench::a_hart_share_is_judged_of_every_charge_against_water_filling
- host:testbench::timer_work_billed_elsewhere_in_a_budgets_time_is_bounded_inside_its_share
- host:testbench::idle_sections_that_found_nothing_are_bounded
- host:testbench::an_unmatched_audit_fails
- bench:sched-irq-wake
- bench:sched-deadline-preempt
- bench:call-bounds
- host:testbench::a_device_wake_that_preempts_is_caught
- host:testbench::a_device_interrupt_bills_its_devices_owner
- host:testbench::a_deadline_that_does_not_preempt_is_caught
- host:testbench::call_bounds_hold_each_call_to_its_empty_system_section
The oracle is independent of the kernel’s code: it reads what the queue did (woke, requeued, left,
pass changed, picked), never why, and rebuilds the order from the events alone: the lowest pass
first; at an equal pass a budget that woke ahead of one requeued; of two that woke, the later
kernel entry’s first, and within one entry the lower id; requeued ones in the order they were
requeued. On several harts it also reads which budget each hart runs (H) and how many of each
budget’s threads wait for a hart (J, those no hart runs): a pick takes the first budget in that
order with a thread waiting, and passes over one ranked ahead only if another hart runs it and
none of its threads waits, never one it runs itself. A slice’s end that picks where it is
(preemption points) records the requeue and the pick of
the hart’s own runner, with no H and no J: the oracle counts the preempted thread as waiting,
as the round trip’s J counted it, only at a timer interrupt on that hart that requeued the
budget, began no destruction and decided the slice over (=), and only for a budget with no other
thread waiting; anything else is refused, and the rank clauses judge the pick as any other. One
that picks another budget’s thread records that pick inside the interrupt and the hart’s new
runner at its return (H), and is judged as any pick. It
recomputes each lift and each weight
change from the rule, and lets a pass fall only at a weight change. A kernel built with
lock-trace, a test feature that implies the trace, also records each wait for the kernel lock’s
ticket after the wait (k: the ticket and the sections ahead of it when drawn); a release build
carries neither the feature nor the record. The records are written holding the lock, so the waits
appear in the order they took it, and the oracle requires that order to be the tickets’, each
drawn behind fewer sections than the harts: no wait outlasts the sections queued ahead of it
(R78 (fair kernel entry); sched-lock-contention at
two harts, sched-lock-contention-4 at four). A kernel built with hold-trace, which implies
lock-trace, also records each section of the lock as it is released (h: its first and last
tick; then j: its cause, a system call’s number, an interrupt’s or an exception’s code, and the
ticks of audits inside it). The oracle reports the sections’ lengths net of their audits, the
longest by cause, and splits the lock waits into the part another hart’s section covered (its
audits pro rata) and the part with the lock free, the hand-off to the halted waiter. The
contention cases carry it and judge nothing by it. fault_section_max_ticks=N bounds the
longest section a page fault began, net of its audits (fault-report-bound), and
timer_section_max_ticks=N the longest a timer interrupt began and timer_section_p99_ticks=N
their p99, the steady entry’s cost, which the few cold sections (a program’s first preemptions)
cannot hide a regression of; both are gated up to gate_harts as the latency targets are,
since under icount a section on several harts counts the others’ instructions too
(sched-timer-entry, at one hart; recorded at two). With the trace beside it,
idle_sections_max=<mark>:<N> bounds the kernel sections a hart entered from the idle and left
with no record of its own but an audit’s, a lock take that found nothing due and nothing runnable,
from the first run of the budget the program marks (the mark’s weight, as a share’s):
timer-flood-at-rest‘s two sleepers each sleep a thousand short timeouts on two harts, and a
kernel that arms every hart’s timer for a timeout whose payer runs nowhere wakes each hart for
the other’s naps too (the hart timer). Where the clock is the host’s,
driver_wake_p50_searches=K and driver_wake_p99_searches=K judge the driver wake’s net p50 and
p99 against K searches of the same run alone, from the program’s one search alone line, at any
hart count (sched-lock-contention-4-mttcg). Three checks read the interrupts’ brackets
(preemption points, charging):
irq_wake_no_preempt=N needs N device interrupts from user mode that claimed a source, woke one
budget and returned to the runner with no pick inside, whose hart’s next pick or interrupt was the
runner’s slice’s end (sched-irq-wake), and refuses any device interrupt that began no destruction
yet did not return to user mode; irq_billed_to=<mark> needs every device interrupt that claimed a
source to charge the budget the program marks after the claim, and lets
it charge only that budget and, for its return, the one it interrupted: the owner is the program’s
to name, and sched-irq-wake names the boot’s, system; deadline_preempts=N needs
every timer interrupt from user mode that destroyed another budget by its deadline to leave for
kmain with its runner requeued and the hart’s next event a pick, and N of them
(sched-deadline-preempt). With hold-trace, a CALL-BOUNDS line names two windows in the
kernel’s ticks, a factor and a floor: for every system call made in the first, its longest
section in the second, net of audits, is held to factor times its longest in the first or the
floor; a call made in the second alone is reported; at least eight must be judged (call-bounds,
R12 (scheduling)). sched-capped runs the model’s capped scenarios
(model) on the machine at two, three and four harts, each
budget’s share judged as below; hart_shares_from=4 records the shares on a trace of fewer harts,
since under icount QEMU’s turns move them, and sched-capped-mttcg judges the two- and
three-hart shares where the harts run at once (QEMU’s turns). It is itself checked against the model’s ranks and against traces broken one clause at a
time. The tracing kernel is a test build only
(R23 (no test channels)).
Elixir oracles
Status: built · tested (5)
- bench:elixir-oracles
- bench:bench-elixir-oracles-broken-guard
- host:testbench::another_version_is_refused
- host:testbench::the_scripts_share_the_case_deadline
- host:testbench::the_deadline_starts_after_the_build
An elixir case runs its scripts in order from the workspace root, each judged by its exit
status: elixir-oracles runs the steward core’s reference over its traces
(the trace encoding) and the wire codec’s vectors. The
scripts source userland/otp/tools/oracle.sh, which puts the pinned toolchain on the path and
runs a module on beamlet. Before any script the case checks the toolchain: the erl that
userland/otp/tools/env.sh puts on the path must be of the OTP release otp (its
releases/<major>/OTP_VERSION), and elixir --version must say elixir. A missing or other
toolchain fails the case even with --allow-skip, and no must_fail waits for it: an oracle that
does not run catches nothing; the dev image carries both, and scripts/setup.sh --with-beam
builds both on your own machine. Then the case runs its build commands, each of which must
exit 0, together within 600 s, a bound only a hung build reaches (a cold build’s times are
below). A failed or late build fails the case as a build, never as the oracle’s timeout, and is
never what a must_fail waits for. Both cases name userland/otp/tools/build-oracles, which
builds beamlet, the model’s trace writer and the trace checker as the scripts build them, so the
scripts’ own builds find nothing to do. With timeout_secs, the scripts together have that deadline, counted
from the end of the build, each run under timeout with what the ones before it left; one past
it fails the case, saying so, and is never what a must_fail waits for. The build is most of a
cold run (29 s alone on four cores), and its time is the host’s load: beside a whole bench, the
two cases building the same targets at once on loaded cores, it has taken 46 s. Built, the
scripts take 1.3 s (the self-check) and 2.5 s (elixir-oracles), so the deadline measures the
oracles alone. The case’s log says what the build and the scripts each took.
Checked builds
Status: built · tested (20)
- bench:bench-debug-assertions
- bench:bench-debug-assertions-off
- bench:boot-profile
- bench:boot-profile-unverified
- bench:sched-latency
- bench:sched-budget-churn
- bench:sched-exit-churn
- bench:timer-flood-at-rest
- bench:sched-exit-churn-mttcg
- bench:sched-timer-flood
- bench:sched-carve-return
- bench:deadline-flood-billed-traced
- bench:deadline-flood-billed-traced-mttcg
- bench:deadline-placement
- host:testbench::audits_are_subtracted_inside_each_window
- host:testbench::a_hart_share_is_judged_of_every_charge_against_water_filling
- host:testbench::an_unmatched_audit_fails
- host:testbench::cluster_credit_is_the_certified_interior_only
- host:testbench::cluster_lower_witness_counts_the_union_of_outer_bins
- host:testbench::a_device_interrupt_that_claims_nothing_bills_nobody
debug_assertions = true builds the kernel and the loader (the trusted base, not the programs) with
the workspace’s checked profile: release with debug assertions and overflow checks on. It is the
same kernel checked harder, not a special build. Three things that are silent in a release build
then panic, and every case forbids PANIC:
core’s preconditions on raw-pointer calls (slice::from_raw_parts,ptr::read,copy_nonoverlapping): a misaligned or null pointer, which is undefined behaviour and silent in a release build;- the kernel’s own
debug_assert!s: every error a call returns must be in that call’s row of the error table (the ABI), whichbudget-syscall-attackexercises over every call and thousands of hostile values; - arithmetic overflow, wherever the kernel or the loader does not use a checked or wrapping operation on purpose.
A checked build also runs the kernel’s audits, full scans that check the indexes, frame owners and
handle chains after a destruction and when a process object is freed, and the live PIDs, the
frame owners and the IRQ index when one of them changes outside a destruction (a process’s end
scans all of RAM, about 7 ms under icount). Every scan of a whole table is an audit; the checks a
return to user mode or a shootdown makes, a few entries each, are not. They hold the hart while
they run, and a release build has none of them. So a latency target or a share, which is measured
in a checked build because the scheduler trace needs one, excludes them: an audit neither fills a
window nor moves the schedule. The scheduler charges an audit’s time to no budget and moves the
running slice’s end past it, so the thread that ran it is picked and preempted as in a release
build; a kernel built with audit-billed, which keeps the old charge, misses the containment
gate’s deadline notice, in a recorded negative run. On several harts an audit also holds every
hart that waits for the kernel lock behind it, and that wait is the audit’s too: each hart marks
when it came from user mode, each audit’s end gives every marked hart the part of the audit it
waited through, and the waiter, once it holds the lock having waited, bills its runner none of it
and moves its slice’s end by it, as the auditor does. The trace records it after the wait (y:
the ticks), never more than the wait by the waiter’s own clock (harts’ clocks differ by a few
ticks under icount), and the oracle takes out of a share only the part of a wait that was
billed. A hart’s zeroing, idle, of the frames it freed
(frames in flight) is billed when it takes the lock back, to
the budgets that freed them; the trace records it (0: the ticks, the frames), and the oracle
reports the total. A destruction gives any hart’s zeroing still to bill to a budget it ends to
its own payer, lifted with the rest of that budget’s debt (p: the payer, the entries), which
the oracle counts and refuses outside a destruction; the checked kernel stops if a bill names a
budget that is gone. A kernel built with audit-wait-billed bills the whole wait as before:
sched-budget-churn-shell at two harts reads 329 and 339 of 1000 on rv64 and rv32 against 404
and 509, in a recorded negative run. The traced kernel stamps each audit’s start
and end (records U and V: which audit, and the time). The program prints each latency
sample’s window, its end on time_now and its length (LATENCY-SAMPLE <group> <measure> <end> <gross>) and how many it took of each, so a window lost on the way fails the check, and it
judges none of them. A program whose run goes on past its measurement marks the window’s end,
LATENCY-END <time> on the kernel’s clock: sched_oracle then judges no sample printed after it
and no destruction ending after it, reporting their count (the real steward’s steward-latency,
whose marker-file exits reap every lease). sched_oracle subtracts the audit time inside each window,
counting only the part of an audit that falls in it, and then applies the case’s bounds
(deadline_notice_p99_us=40000). It reports the gross, the net and the audit time beside each
target (responsiveness). A share is the kernel’s
charges, never a count: under icount a count is the machine’s instructions, not a hart’s time
(R12 (scheduling)). The program prints its window, marks the budget it
judges (an empty child of the mark’s weight, carved and destroyed, so the trace’s lift names the
parent), and names by weight and runnable threads each budget it runs against it (HART-SHARE <name> <start> <end> <tolerance>[+|-][@<harts>] <mark>:<threads> <weight>:<threads>...; + for
at least, - for at most, @ judged only at that many harts and reported at any other).
sched_oracle sums what the kernel charged each budget in the window, its pass’s rises times its
weight as the trace states it (each hart’s runner, H, carries its weight), less the part of each
hart’s waits for the kernel lock billed to the waiting hart’s runner though no thread of it ran
(a Q less its y); a lift
out of the cap set (u, followed by z with the floor then, which the lift may not pass) is no
charge. The part is the marked budget’s and what was lifted into
it, the whole every budget’s, and what the budget is owed is its water-filling share of the
trace’s harts (F) among the budgets the program names, which on one hart is its weight’s share;
every want is stated in the result. An audit is charged to no budget, so the share is net of the
audits; on several harts they fall mostly on the hart that switches budgets each slice, so
sched-large-weight keeps one hart. Each program notes its counts beside with no verdict, and
sched-share reports what the three counted of the calibrated rate (counted <n> of the calibrated 1000), the efficiency the slice ends leave. The release twins (sched-share-release,
sched-large-weight-release, deadline-flood-billed) have no trace and judge their counts at one
hart, in their expect lines; deadline-flood-billed-traced judges the same run’s share across
harts. Where the budgets a share is judged among are hostile agents the program cannot name one
by one, the share is of the charges under marks (CHARGED-SHARE <name> <start> <end> <tolerance> <mark>[:<threads>]..., the containment gate’s bystander against the leases under users). The
program prints its window and marks the budgets it means, each by carving an empty child of
the mark’s weight and destroying it, so the trace’s lift names the parent; sched_oracle sums
each budget’s pass rises in the window times its weight as the trace states it, for the first
mark’s budget against every marked budget and those lifted into them. What the budget is owed is
its weight over the weights of those the kernel charged in the window, its competitors, at one
hart, and on several its water-filling share of the harts among them, net of their harts’ lock
waits as above, a mark naming its runnable threads; the share must lie within the tolerance of it; a window in which a budget under the marks is
reweighed or ended is refused, since its competitors changed. The audits are charged to no
budget, so the share is net of them by construction; what a budget is charged out of the queue
shows only under its next wake’s floor lift and is not counted, so the wakes in the window are
reported beside, with every budget charged in it that no lift places under a mark. The residual:
it judges what the kernel charged, so a kernel that under-bills every budget under the marks
alike (idle or switch time charged to no one) passes it; the gate’s own expect lines and its
latency clauses bound that, not this one. An audit
that never ends, ends without beginning or runs inside a destruction fails the check. The oracle subtracts only what the trace shows it: a
kernel built with audit-unstamped, which leaves the audit after a destruction unstamped, misses
the containment gate’s deadline notice, in a recorded negative run. The audits themselves stay
full. The cluster’s envelopes (responsiveness) are credited
more strictly: a stamp is a floored microsecond, so an audit stamped u and v ran from somewhere in
[u, u + 1) to somewhere in [v, v + 1), and only its certified interior [u + 1, v) (empty
unless v > u + 1) is subtracted, where it meets the envelope. The uncertain edge bins stay counted
as elapsed time, so the credit is never more than the audit time inside, and an audit outside an
envelope lowers nothing. The credit is summed with checked arithmetic and may not exceed the
envelope. The other cases keep the whole-stamp subtraction above. The same trace records each
timer interrupt from user mode with every charge inside it, and sched_oracle checks that the
budget it interrupted pays only for its own items or its slice’s end
(charging); a kernel built with timer-tail-billed, which keeps
the old billing, fails that check in a recorded negative run. It records each device interrupt
from user mode the same way (x), with its claim (c: the source, or 0 when another hart claimed
it first), and checks that one that claims nothing is billed as a timer interrupt that ends no
slice: after its expiry, to the budget the expiry billed last or to nobody, never to the budget it
interrupted; one a deadline answered at its entry preempted before its claim (a destruction
inside it, O 2 and no c) the same way, and any other missing claim fails. It reports the
timer interrupts that charged a budget other than the one they interrupted and those ticks:
another budget’s timer work in the interrupted budget’s time on its
hart (timer); with foreign_timer_in=<share>:<N>
it bounds them inside that share’s window, the ticks of timer interrupts from user mode that
interrupted the judged budget (or one lifted into it) and charged a budget outside its marks, at
N of 1000 of what the judged budget was charged there, on a trace of two harts or more (at one
every interrupt is on the victim’s hart: recorded). Only timer interrupts count against the
bound: an item that has passed when a call’s entry or kmain’s comes is served there, so the
kernel records an expiry’s charges outside an interrupt too ($: the payer, the ticks and the
time in µs, which wraps after 2^32 µs, 71 minutes, longer than any case), and the check reports
beside each share the ticks they charged others at the judged budget’s own entries and in kmain
on a hart it had just left, judging nothing by them, and in its summary how many such charges the
trace holds, at a call’s entry and in kmain, and their ticks. The bound is judged where the
harts run at once (deadline-placement, deadline-flood-billed-traced-mttcg,
sched-exit-churn-mttcg, host-clock cases on the quiet core set): under icount which hart’s
entry first reaches an item that has passed is the order of QEMU’s turns, and a run locks into
one phase (timer). An
interrupt’s return (O) says where it goes: 1 to user mode with the interrupted runner’s slice
running, 0 the slice ended at this entry (to kmain, or in place to user mode, where the return
is recorded as the entry’s handling ends), 2 to kmain without, a preemption. Whether the slice
is over the oracle decides itself: the kernel records its decision’s inputs after the entry’s
expiry (=: the slice’s end and now, µs), the slice is over if its end is not after now, and a
return that says otherwise fails, as does a decision outside a timer interrupt on its hart or a
second one. With
wake_no_preempt=N the oracle proves a timeout only wakes: at one hart N naps each against the
slice that follows it; on several, each wake of the one budget that woke N times or more at the
entry that made it, a return to user mode or a call its runner goes on from counting as a witness
and a preemption (O 2, or the runner requeued outside an interrupt, with no destruction) failing
it, with at least wake_witnesses=K witnesses and at least wake_mid_slice=M of them mid-slice. A
kernel built with wake-preempts, whose timeout wake preempts as a deadline does, fails
sched-wake-no-preempt-harts and sched-wake-no-preempt-harts-mttcg on both widths in recorded
negative runs. The trace ends with one record of
the kernel’s time (C): every hart’s ticks in the kernel, from a trap’s entry or kmain‘s loop
to the return to user mode or the idle, in its id; the ticks charged to budgets in its pass; and
in its entry field, which it alone uses so, the ticks the checked build’s audits took.
sched_oracle reports nobody N of 1000 (kernel K ticks, audits A, charged C), the share of
the kernel’s time net of audits that no budget was charged, and judges nothing by it
(residual risks). Ahead of it, one record (F) holds the
ticks since boot and the harts started, and each wait for the kernel lock from user mode, the lock
held by another hart, is a record (Q: its start and end in ticks); sched_oracle reports
lock waits N of 1000, the waits as a share of the harts’ time, judging nothing by it either. The counters read the billing’s clock
and move no schedule, and a kernel without sched-trace compiles none of them. A kernel built with
alloc-first-fit, which takes each frame by the first-fit scan of RAM the bitmap replaced,
fails scan-bounds on both widths in a recorded negative run
(R12 (scheduling)).
Many cases use the profile: every case file with debug_assertions = true, most of them over
both widths (budget, budget-syscall-attack, lend-untouched-page, ipc and smp-boot among
them), and some, such as all-together, on rv64 only. The kernel
prints one line under cfg!(debug_assertions): bench-debug-assertions expects it, and
bench-debug-assertions-off forbids it in an ordinary boot. To check the whole suite:
CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS=true CARGO_PROFILE_RELEASE_OVERFLOW_CHECKS=true cargo testbench
That run fails bench-debug-assertions-off, as it should; everything else must pass.
sched-trace keeps the trace in a 64 MiB ring, and a trace that drops a record fails its case;
sched-trace-large is the same ring at 256 MiB, and two cases build it. The containment gate
does, at 576 MiB of RAM, since at the 1 ms slice its run of nine leases a slot writes about ten
records a slice, and at two harts each hart’s picks, waiting counts and lock waits besides, some
7.1 million, past 192 MiB’s 6.3 million. The ring’s frames are kept as 32-bit page numbers, so its
table fits the kernel’s RAM region. So does worst-walk, whose run at full occupancy
writes about 2.6 million records on rv64 and 2.9 million on rv32, past the 64 MiB ring’s 2.1
million.
boot-stats is a diagnostic feature like sched-trace and walk-trace, but of programs, not the
kernel: a Cargo feature of init, blkd, verityd, littlefsd, erofsd and beamlet-redoubt, off by
default and never in the image’s build. Without it the release binaries of init, blkd,
verityd, littlefsd, erofsd and beamlet have the section sizes they had before it, on rv64 and rv32
(measured with llvm-size against the commit before it, both built from the same path: beamlet’s
sizes move with its build path). With it, init’s lines and beamlet’s line for its first object
carry [t=N], time_now in µs; blkd, littlefsd and erofsd say their counts at each power
of two of their requests from 2^12 and verityd from 2^7, and littlefsd and erofsd once more, exactly, on a
walk of
Elixir.BootStats.beam, a name no volume holds; at the VM’s first console read beamlet says
beamlet: first console read [t=N] and what its lookups cost: their count and the guest time spent
in them; and when the shell’s driver has drawn its first prompt, beamlet: first prompt drawn [t=N], the end of the boot’s span (beamlet on Redoubt). The feature needs no
checked build. boot-profile and boot-profile-unverified boot the image’s programs with it under icount and a pinned seed: measurements, which bound only the boot’s time
to its prompt (the boot-time target).
Hostile inputs
Status: built · tested (17)
- bench:loader-rejects-kernel-address
- bench:loader-rejects-kernel-entry
- bench:loader-rejects-truncated-elf
- bench:loader-rejects-segment-data-cut
- bench:loader-rejects-wx-segment
- bench:verified-boot-rejects-tamper
- bench:verified-boot-rejects-bare-archive
- bench:verified-boot-rejects-short-initrd
- bench:boot-rejects-no-seed
- bench:boot-rejects-short-seed
- bench:boot-rejects-no-timebase
- bench:boot-rejects-irq-past-plic
- bench:rng
- host:testbench::a_dropped_property_is_gone_and_the_rest_still_reads
- host:testbench::a_shortened_property_keeps_its_first_bytes
- host:testbench::an_overwritten_property_has_the_words_and_its_length
- host:testbench::a_property_the_tree_lacks_is_an_error_not_a_quiet_boot
A program entry can be a good binary the bench corrupts before injecting it:
programs = ["log-server", { corrupt = "rng-test", with = { segment-vaddr = "0xffffffffffd00000" } }]
with is segment-vaddr, entry, truncate (a byte count smaller than the file),
segment-flags (letters of rwx for the first loadable segment: "rwx" asks for a page no
loader may map) or truncate-segment-data (the section table dropped and the file cut this many
bytes before the end of the last loadable segment’s data, so the headers parse and the data runs
past the end). tamper_bundle = true flips one bundle byte after signing, sign_bare_archive = true
signs the archive without its domain and length, and short_initrd = 63 hands the loader only that
many bytes of the signed bundle; the loader must refuse all three
(R15 (verified boot), R17 (fail closed)).
device_tree changes the device tree QEMU built for the case’s own command line: the bench dumps
it (-machine dumpdtb=), edits it in place and boots it with -dtb, so the firmware, the loader
and the kernel see the one machine with that one difference. Each edit names a property by path
and must find it:
device_tree = [{ drop = "/chosen/rng-seed" }] # removed
device_tree = [{ shrink = "/chosen/rng-seed", to = 8 }] # its first 8 bytes
device_tree = [{ set = "/cpus/timebase-frequency", words = [0] }] # its cells, as many as it has
A dropped or shortened property leaves FDT_NOP tokens behind, so no byte moves and the header
stays true (tools/testbench/src/fdt.rs). A case that expects a deliberate panic
sets allow_panic = true, which drops only PANIC from the always-forbidden list, and forbids what
must not happen instead. distinct_across_boots = ['id: (.*)'] boots twice and requires the captured
text to differ; distinct = ['console (.*)'] requires, within one boot, two or more lines it
matches and no capture twice, such as a restarted program’s new console. A hostile program is an
ordinary programs entry; hostile data for a program to use, such as a malformed ELF for a parent
to launch, is a bundle file.
The file systems’ parsers are also fuzzed, by hand with cargo fuzz and outside the bench, each
from its crate’s own fuzz/ workspace: littlefs’s image and mutate
(littlefsd), and beside them walfs’s image, arbitrary
bytes mounted, walked and written, and mutate, a valid volume with bytes changed and its hashes
forged or not (walfsd).
Files in the bundle
Bundle files
Status: built · tested: bench:bench-bundle-file, bench:programs-unknown-budget-attack, bench:littlefsd-reboot, host:testbench::a_file_s_servers_merge_into_its_manifest_by_name
[[file]] # a data entry, after the programs
name = "trace"
from = { path = "tests/data/bundle-file.txt" } # or any `programs` form, corrupted ones included
[[file]] # a manifest, with entries merged into its `servers`
name = "manifest"
from = { path = "tests/data/littlefsd/boot.json" }
servers = [{ name = "client", args = ["reboot", "littlefsd:data"] }]
[[file]] # a manifest pinning the userland disk's root, from the run's pack
name = "manifest"
from = { path = "image/manifest.json" }
verity = "image/userland.toml"
A file read from a path may be a manifest with servers entries merged in by name: each replaces
the members it gives in the entry of its name, or is added after the rest if none has it, so cases
that differ in one entry share one manifest, and a case can boot the image’s own manifest with a
client added.
Entry names must differ from each other and from kernel. A file named programs replaces the
listing the builder writes, so an attack case can hand the tester a hostile one. The bench
refuses a [[grant]] table: a program reaches a device only through the handles it is given
(R18 (device authority)). The loader starts only the
kernel and the program in init’s place, so a data entry reaches the guest signed and is never
run. bench-bundle-file reads its entry back: the program in init’s place finds it in the
bundle’s pages and compares it, byte for byte, with the file it was built with.
Data entries for init
Status: built · partly tested: no model trace is replayed under `init` yet · tested (1)
- bench:init-servers
init receives the verified bundle and hands the public entries to bootfsd, and a data entry
is how a model trace reaches an in-guest replayer, which compares results itself and prints its
verdict (boot). The program in init’s place
already reads a data entry from the bundle’s pages (bundle files); under init,
a program reads it again through /boot once the manifest’s public list names the entry.
Devices and the network
Disks and network cards
Status: built · tested (38)
- bench:bench-virtio-devices
- bench:bench-virtio-legacy-off
- bench:erofs-corrupt
- bench:erofs-read-only
- bench:image-disk
- bench:init-boot
- bench:net-tcp
- bench:userland-boot
- bench:userland-read-only
- bench:verity-flipped-tree
- bench:verity-wrong-root
- bench:verity-signed
- bench:verity-bad-signature
- host:testbench::a_recipe_can_generate_a_directory_of_files
- host:testbench::a_recipe_packs_a_table_and_a_volume_per_partition
- host:testbench::a_stage_is_walked_parents_first_in_name_order
- host:testbench::devices_sit_on_fixed_slots
- host:testbench::the_vault_disk_sits_beside_the_data_disk
- host:testbench::each_disk_holds_the_volumes_on_its_device
- bench:walfsd-boot
- host:testbench::every_network_is_restricted
- host:testbench::a_lease_slices_into_disjoint_ranges_or_none
- host:testbench::virtio_devices_are_modern
- host:testbench::the_userland_disk_sits_on_its_slot_read_only
- host:testbench::the_userland_pack_is_deterministic_and_stages_each_object_by_name
- host:testbench::a_verified_partition_is_its_volume_then_its_tree
- host:testbench::an_erofs_partition_is_its_stage_and_each_damage_is_corrupt_where_it_is
- host:testbench::a_walfs_partition_is_its_stage_and_two_packs_are_the_same_bytes
- host:testbench::the_manifest_pins_the_packs_root
- host:testbench::a_signed_partition_ends_in_its_root_block_signed_deterministically
- host:testbench::the_cases_volume_seed_is_the_development_seed
- host:testbench::a_volume_s_bytes_are_held_to_its_partition
- host:testbench::the_image_s_volumes_are_its_disk_s_partitions
- host:testbench::a_case_s_manifest_is_held_to_its_disk
- bench:launch-keep-disk
- host:testbench::a_kept_disk_is_attached_as_it_is
- host:testbench::a_kept_disk_of_another_layout_is_refused
- host:testbench::a_kept_disk_is_held_to_its_recipe_s_manifest
[disk] # a virtio-blk disk, zeroed, created afresh for every boot
size_kib = 4096
partitions = 1 # optional: a GPT of this many equal partitions, by blkd's builder
# or, instead of both: a disk recipe packed for every boot as ./mkimage packs it
# recipe = "image/disk.toml"
# stage = "tests/data/littlefsd/stage" # optional: what every partition holds instead of its stage
# flip_version = true # optional, with a recipe: one bit of each signed volume's version flipped
# keep = true # optional, with two boots: the second boots the disk the first left
[vault] # optional, with [disk]: a second writable disk, disk2, the same keys
size_kib = 2048
partitions = 1
[userland] # the userland disk, attached read-only, packed once per run
recipe = "image/userland.toml"
# flip = "Elixir.Enum.beam" # optional: one bit of this file's data flipped on the disk
# flip_tree = true # optional: one bit flipped in the first level-1 tree block
# wrong_root = true # optional: the manifest pins a root one digit off the pack's
[net] # a virtio-net card on QEMU's user-mode network
forward = [22] # guest TCP ports reachable from the host (default: none)
host_key = "ssh-ed25519 AAAA..." # optional: the only SSH host key sessions accept
[idle] # with launch = true: measure a session's `idle` step (one)
interrupts_per_hart = 9.0 # optional ceilings, each per second of the window
user_ecalls = 18.0
host_cores = 0.01
A disk recipe (image/disk.toml) is packed by the code ./mkimage runs (testbench --pack-disk): a GPT of equal partitions by blkd’s builder, then each partition as its fs
says, holding its stage’s tree: littlefs, a writable volume written through littlefsd’s own
code; erofs, a read-only volume written by libs/erofs’s writer
(erofsd); or walfs, a writable volume written by libs/walfs
itself (walfsd), so a case boots the disk the image ships.
A disk with keep = true is made for the case’s first boot and booted again, as that boot left
it, by the second (distinct_across_boots): launch-keep-disk writes a file at the prompt in one
boot and reads it in the next, as ./launch --system --keep-disk keeps a person’s disk between
launches. A kept disk whose size or partition table is not the one a fresh disk would have is
refused, never reformatted.
A manifest’s volume that gives bytes (init) must be exactly
its partition’s size, which init never reads. A recipe names the manifest its disk serves
(manifest = "image/manifest.json"), and the pack is refused if a volume’s bytes differs from
its partition’s, or the partition at its index has another name; at load the bench refuses a case
whose manifest file gives a volume bytes its disk’s partition does not have, whether the disk
is a recipe or size_kib and partitions. With a [vault], each disk holds the volumes of the
blkd its device goes to: a volume’s disk names its blkd, whose devices entry is disk0 or
disk2.
[vault] is a second writable disk beside [disk], with the same keys and its own recipe or
layout, for a manifest that keeps the unlabelled home volume and the vault on separate devices,
as a confined one must (R34 (confined placement): one blkd serves one label set,
so two label sets need two disks). It is disk2 on its own slot, made and kept as the data disk
is, beside it. walfsd-boot boots both: a blkd on each, a walfsd on each disk’s one volume,
and a client writing and reading back on both. Its slot, bus 4, is the one
tests/data/init/driver.json names slot5: the empty slot dma-driver is given in
init-driver-restart and init-quarantine-reboot, empty only because no disk sits there. A case
carries [vault] or that manifest, never both.
A recipe’s volume may also generate files, for a case that needs many and not their
contents: generated = { files = 600, read = "f000" } makes f000 to f599 in the volume’s root
(as many digits as the last needs), all empty except read, which holds its own name and a
newline. They sit beside the stage’s tree, if there is one, and a name in both is refused.
A volume may be verified, verity = true: it holds the largest volume that
fits beside its hash tree, then the tree (verityd), and the pack says
its root and data blocks. An erofs volume is followed in its range by zeros, which the tree covers
too. A verified partition may be signed, sign = { key = PATH, version = N }: its last whole block
is then a root block holding N, the version and the root, signed with the 32-byte Ed25519 seed in
the file PATH (relative to the workspace root) under the volume domain
(verityd), the volume and tree fit before it,
and the pack says the version too. Signing is on the build host only; a manifest names the key,
never the seed. The cases’ seed, tests/data/verity/dev-seed, is 32 bytes of 0x42 (B): the
bundle builder’s development seed (DEV_SEED in tools/testbench/src/build.rs), so "key": "bundle" verifies it. Ed25519 is deterministic, so two packs are byte-identical. A case’s
flip_version flips one bit of each signed volume’s version after the signing, on the disk and
never in the manifest.
For erofs-corrupt, an erofs partition may be damaged after its pack, through the parser
erofsd uses to find the place: damage = { what = "magic" } flips a bit of the superblock’s
magic; { what = "block-past-count", path = "tail.txt" } starts that file’s blocks at the
volume’s block count; { what = "compressed", path = "motd" } lays that file out compressed;
{ what = "name-offset", path = "lib" } starts the last name of that directory’s first block past
the block’s end.
The userland disk (image/userland.toml) is packed by the same code: first its objects are
staged, each module of the applications the recipe names, compiled by the pinned toolchain and
stripped, as a plain file under its own name, then its one partition is packed as a verified
volume of those files. The bench stages and packs it once per run: a bundle file read from a path
with verity = "image/userland.toml" is a manifest, and the builder writes the pack’s root and
data blocks into the verity of the volumes entry of the partition’s name, so the signed
manifest pins the disk; [userland] attaches the same pack. A case’s damage goes on its own copy
of the disk after the pack, never on the root: flip flips one bit of a file’s data where the
volume holds it, and flip_tree one bit of the first level-1 tree block. wrong_root changes the
manifest’s root by one digit instead, never the disk
(R76 (verified volumes)). Nothing is generated beside the objects.
Devices use virtio-mmio’s modern transport, which blkd and netd require. Each sits on a fixed
virtio-mmio slot, whether or not the case has the others, so a case’s manifest names its devices
as the image’s does (image/manifest.json names the first three):
| device | [table] | QEMU bus | base | irq | manifest line |
|---|---|---|---|---|---|
| data disk | [disk] | virtio-mmio-bus.7 | 0x10008000 | 8 | { "name": "disk0", "base": "268468224", "irq": 8, "dma": true } |
| network card | [net] | virtio-mmio-bus.6 | 0x10007000 | 7 | { "name": "net0", "base": "268464128", "irq": 7, "dma": true } |
| userland disk | [userland] | virtio-mmio-bus.5 | 0x10006000 | 6 | { "name": "disk1", "base": "268460032", "irq": 6, "dma": true } |
| vault disk | [vault] | virtio-mmio-bus.4 | 0x10005000 | 5 | { "name": "disk2", "base": "268455936", "irq": 5, "dma": true } |
The userland disk is attached read-only (readonly=on): QEMU refuses every write
to it, so nothing on the box can change it. The guest reaches nothing outside QEMU
(restrict=on, checked for every [net] case): there is no outside peer, only forwarded
connections coming in, unless a case adds one deliberately. Nor is it offered IPv6 (ipv6=off),
which it does not speak: slirp would otherwise send it router advertisements. Each boot gets its
own host ports, from the job’s lease under scripts/q (a sweep’s concurrent boots each from
their own slice of it) and chosen by the operating system otherwise, so benches running side by
side do not collide.
Peers, dials and the capture
Status: built · tested (16)
- bench:bench-net-peer
- bench:bench-net-peer-twice
- bench:bench-net-peer-count
- bench:bench-net-peer-pcap-empty
- bench:netd-restart
- bench:sshd-login-bound
- host:testbench::a_dial_with_a_line_waits_for_it
- host:testbench::a_held_dial_ends_when_the_far_side_closes
- host:testbench::a_poke_gets_a_udp_forward
- host:testbench::peers_are_judged_on_records_and_capture
- host:testbench::a_capture_is_read_fail_closed
- host:testbench::what_the_guest_sends_is_checked
- host:testbench::records_are_counted_per_peer
- host:testbench::a_dial_needs_its_echo
- host:testbench::prefixes_and_peers_are_checked
- host:testbench::peers_get_the_wider_network_and_a_capture
A network case can give the guest hosts to reach, and judge what it sent, all from outside the guest, after the boot has passed:
[net]
forward = [8000]
self_forbidden = ["10.0.2.0/24", "127.0.0.0/8"] # the guest must never send a SYN to these
[[net.peer]] # a host the guest may connect to, and exactly how often
addr = "10.0.9.100:7"
connections = 1
[[net.dial]] # the bench connects in through a forwarded port and needs the echo
port = 8000
send = "hello\n"
expect = "hello\n"
after = '^\[con [0-9a-f]{16}\] judge is up$' # optional: dial only once a console line matches
until_closed = true # optional: then hold the connection until the guest closes it
[net.poke] # one UDP datagram into the guest, sent when a console line matches
port = 47000
payload = "redoubt netd restart probe"
after = '^\[con [0-9a-f]{16}\] judge knows netd.s instance: poke it$'
- Peers are QEMU
guestfwds to a program: for each connection the guest makes, the bench’s own binary starts as a helper on it, records the connection as a file before it echoes a byte, and then echoes. After the boot each peer’s count must equal itsconnections. There is no host listener another process could take. - The poke is one UDP datagram, sent once, through a host port QEMU forwards to its guest port,
when a console line first matches its
after: a trigger from outside that nothing resends, as TCP would.netd-restartfaultsnetdwith it, through a test-only feature. - A dial is a TCP connection the bench makes into the guest through a forwarded port, retried
every 100 ms until one connection echoes
expectforsend, from the boot’s start or, withafter, from the console line that matches it; one never echoed by the deadline fails the case, with what came. Withuntil_closedthe bench then keeps the connection, typing nothing, until the guest closes it, and notes under the case’s result how long it was held; one still open at the deadline fails. That is a client that connects and never goes on, which the guest must end itself:sshd-login-boundholds everysshdslot that way and the verdict is the login the box then serves, never the dials, which write nothing on the console. - The network. A case with peers widens slirp’s network to a /16 in which its host, resolver
and the guest’s address stay where they were; the peers sit outside the /24 the guest is
configured for, reached through its gateway.
restrict=onstill holds. - The capture. A case with peers records every frame on the guest’s card, before slirp, and
reads it fail closed: missing, empty, cut or malformed fails the case (
net.truncate_capturecuts it on purpose, for the self-checks). The guest may send only ARP requests for the gateway, ARP replies (to any address) and IPv4 TCP, never a fragment and never a SYN to aself_forbiddenprefix. Each peer’s count must also match the distinct SYNs to it in the capture, and a case with peers needs one that expects a connection, whose SYN shows the capture was live.
The guest’s own claims about the network are never trusted.
SSH sessions
Sessions and the loopback server
Status: built · tested (20)
- bench:bench-ssh-loopback
- bench:bench-ssh-loopback-openssh
- bench:bench-ssh-loopback-deadlock
- bench:bench-ssh-loopback-forbid
- bench:bench-ssh-loopback-exit
- bench:bench-ssh-loopback-host-key
- bench:bench-ssh-loopback-aborted-text
- bench:bench-ssh-guest
- bench:steward-ssh-two-principals
- host:testbench::expect_after_matches_in_order_and_names_the_first_miss
- host:testbench::the_reference_proxy_quotes_only_what_the_bench_chose
- host:testbench::the_cpio_writer_writes_newc
- host:testbench::the_guest_recipe_parses_and_hashes
- host:testbench::a_bad_package_is_broken_and_no_network_is_the_hosts
- host:testbench::the_keeper_finds_what_names_the_case
- host:testbench::the_keeper_waits_to_the_deadline
- host:testbench::resize_needs_a_pty
- host:testbench::only_marks_follow_exit
- host:testbench::ended_keeps_the_input_open_until_sshs_own_exit
- host:testbench::no_other_child_inherits_a_sessions_terminal
Sessions need net.forward = [22]. They start once every expect has matched and run concurrently
while the bench keeps watching the console; a session whose first steps are waits starts its
ssh only once those marks are set, so its login follows what they mark. Each drives the host’s
OpenSSH ssh, an implementation independent of the box’s, to the guest’s port 22 with a test key.
What the box says about them, a login it refused or a session’s budget given back, is asked for
with the case’s expect_after: console patterns matched in order from the sessions’ start to the
case’s deadline, a miss failing the case with the first pattern unmatched. expect cannot ask for
those lines, since the sessions start only once it has all matched.
[[session]]
user = "alice+secrets" # the login name
name = "vault" # optional; unique in the case, names its log; defaults to `user`
key = "alice" # optional; defaults to `user` up to any '+' or '.'
pty = false # true asks for a terminal
forbid = ['bob-secret'] # never in this session's output, until ssh exits
steps = [
{ expect = '\(1\)> ' }, # wait for the prompt
{ send = "File.read(\"/work/x\")\n" },
{ expect = 'id ([0-9a-f]+)' }, # a pattern with groups keeps its captures...
{ send = "approve $1\n" }, # ...for a send to type back ($1 to $9; $$ is a $)
{ mark = "alice-ready" }, # tell other sessions this one got here
{ wait = "bob-ready" }, # wait for another session's mark
{ resize = [132, 43] }, # with `pty = true`: resize ssh's terminal
{ idle = 60 }, # with [idle]: type nothing for 60 s while the bench measures
{ within = 30 }, # the next step must finish within 30 s, before the case's deadline
{ exit = 0 }, # close input, read until ssh exits, require this status
{ ended = 0 }, # or: input open, read until ssh exits on its own, require this status
{ mark = "alice-gone" }, # only marks follow `exit` or `ended`: this session's ssh is gone
]
ssh_args = ["-W", "host:9"] # optional: more ssh arguments, before the host
command = "echo hi" # optional: a command (with `-s`, a subsystem) in place of a shell
A session the box ends, after a line it must act on (a call that ends the session, say), follows
that line with ended, not exit: exit closes the input first, and with pty = true that
close hangs up the terminal ssh reads and discards what it has not read yet, so the line may
never leave the host; the box’s end of a channel whose input is still open has nothing to race.
exit stays for the sessions the bench itself closes. The log notes when the end came, as it
does an expect’s match. Every session’s exit status is checked and all of its output passes
forbid; a session that fails stops the others. Its log notes each expect that matched and when, in seconds after its ssh
started, and the bench prints under the case’s result those matched before the session first
typed: a login’s latency to its first prompt, re-measured by every run
(sshd). A pty = true session’s ssh reads its input from a pseudo-terminal the bench
opens at 80x24, so that ssh reports a size change as OpenSSH does for a user; its output stays on
pipes. resize sets that terminal’s size and signals ssh (SIGWINCH), which then sends a
window-change if the size changed. The terminal is the client’s input and never a verdict. With net.host_key set, ssh refuses any other host key. Test keys live in
tests/keys/; they are public and marked not for production, and a boot manifest that lists one
must never ship. A launch = true case (launch-system) boots the command line ./launch --system builds, unchanged, and its sessions log in with a key made beside the bundle when it is
packed, which alice alone holds in that bundle’s manifest, as launch gives her the checkout’s key
(try the system). The ssh-loopback kind runs sessions against a server that ssh starts itself
for each session as its ProxyCommand, so nothing listens on a port: Redoubt’s sshd on its host
platform (against Redoubt’s sshd), or with server = "openssh"
OpenSSH’s server in inetd mode, in a QEMU guest. Its log goes to a file beside the transcripts,
never to ssh’s output; the standard error ssh would otherwise share with it goes to that log
for Redoubt’s sshd and is discarded for QEMU’s. So a late line of the server’s, such as the
broken pipe it may meet writing to an ssh that has refused its host key and hung up, cannot
stand in for the session’s last output, which is ssh’s own. The bench creates the log, empty,
before the first session, so a case whose sessions time out before ssh starts the server reads
it empty. A case’s server_log asks what the server saw (a refused key, say), not only what the
client printed, and its server_log_forbid what the server must not have done (a console
started, say).
OpenSSH’s server runs in a QEMU guest. On a host that enforces SELinux, sshd moves every
login shell into the user’s default context, and a bench running in a service’s context may not
enter it, so the host’s own sshd cannot serve the reference case. A container needs a runtime
and user namespaces that the bench’s host may refuse. A guest under the bench’s own
qemu-system-riscv64 needs neither; the client, the session runner and the case are unchanged,
so the case is still a witness independent of Redoubt’s server. The guest is not Redoubt, and it
boots QEMU’s own OpenSBI: the rule that only RustSBI boots is Redoubt’s.
- The image is a Linux kernel and an initramfs built from
tests/ssh-reference/guest.toml: Debian’s riscv64 kernel,openssh-server, the libraries it links andbusybox-static, each pinned by version and sha256 in one timestamp ofsnapshot.debian.org, which keeps every version it has published; with them the guest’s/init, its logins (root andsshd’s privilege-separation user), the kernel module it loads (virtio_mmio) and what it leaves out, each with its reason. The bench fetches each package withcurl, checks its sha256, unpacks it withdpkg-deb, and writes the initramfs’snewcarchive itself. It keeps the image intarget/ssh-reference/under the first 16 hex digits of the recipe’s sha256, and builds it only when that is missing, so only the first run after a change to the recipe needs the network. - Each session boots its own guest:
ssh‘sProxyCommandis QEMU with no network and no host filesystem. The case’s configuration and keys reach the guest in its initramfs: the bench writes, for each case, the image’s archive followed by an archive of the case’s files. The session’s stdio is a virtio-serial port; the guest’s/initrunssshd -ion it, reading and writing one open of the port, and powers off whensshdis done. Everysshdprocess opens its log anew and a port takes one open at a time, so they share a FIFO that one reader carries, a line per write, to a second port, which QEMU appends to the case’s log: a line of up to 4 KiB, the most a FIFO takes in one write, reaches the log whole among other sessions’ lines. The guest’s console goes toguest.logbeside it, for people reading a failed run, and is never a verdict; a failed boot powers off rather than reboots. The server logs in only root, inside the guest, runs/bin/shfor every login and allows nothing else. - ssh ends each guest. Once the session ends,
sshexits without waiting for the server and hangs up on its proxy. Whatsshdlogs after that is lost; the lines the cases ask for come earlier.sshhas no parent-death signal, so a bench killed outright leaves it running until its session ends, and its guest with it. The keeper: once a case’s sessions have all exited, anyqemu-system-riscv64whose command line names the case’s directory (the run’s own) has until the case’s deadline, and at least five seconds, to go, as a loaded host may slow a guest’s shutdown with nothing wrong; one still running then is killed, and the case fails, naming it, unless a session has already failed it. - Before the first OpenSSH loopback case the bench logs in once and runs
exit 0, and the server’s log must name OpenSSH’s version, so that no other server can pass for it. Itssshwaits at most 30 seconds for the server’s banner, so a guest that never boots fails the probe rather than hanging the bench. Without aqemu-system-riscv64the bench can use, withoutcurl,dpkg-deborxzto build the image, or with no image andsnapshot.debian.orgnot answering, every OpenSSH loopback case fails with that reason, as on a host without OpenSSH, and--allow-skipskips them. A package whose sha256 is not the recipe’s, a fetch that fails while the snapshot answers, a build that fails and a probe that fails all fail every OpenSSH loopback case. The check connects directly: on a host whose only way out is an HTTP(S) proxy thatcurluses, a fetch that fails there finds the snapshot silent, and is a host lack that--allow-skipskips.
The machine at rest
Status: built · tested (11)
- bench:launch-idle
- host:testbench::the_log_is_counted_by_hart_and_name
- host:testbench::a_rate_over_its_ceiling_fails
- host:testbench::a_window_that_measured_nothing_fails
- host:testbench::the_monitor_is_reached_and_left_by_the_mux_escape
- host:testbench::an_idle_step_needs_idle_and_launch
- host:testbench::the_window_opens_once_the_frames_in_flight_are_back
- host:testbench::the_window_opens_after_the_lines_already_queued
- host:testbench::a_drain_that_never_ends_fails_at_the_bound
- host:testbench::no_drain_seen_fails_at_the_bound
- host:testbench::only_the_kernel_s_lines_open_a_drain_and_one_inside_is_named
What an idle Redoubt costs its host is measured from outside the guest. A launch = true case
with an [idle] table and one session step { idle = N } boots ./launch --system’s command
line; when the session reaches the step, it types nothing for N seconds, still reading its output,
and the bench measures that window:
- Traps, from QEMU’s own log.
-nographicmultiplexes QEMU’s monitor on the console, so the bench types Ctrl-A c,logfile <case>.int.logandlog int, and Ctrl-A c back, when the window starts, andlog nonewhen it ends: QEMU logs each trap a hart takes (riscv_cpu_do_interrupt: hart:H, async:A, … desc=NAME) for the window alone, and the command line stays launch’s. The session’s quiet starts once the bench has typedlog int, and its next step waits until the bench has typedlog none, so no other step’s work falls in the window. Logged from the boot on, every system call would be a line. The bench counts each hart’s interrupts per second by name (timers, software interrupts the firmware and the kernel send between harts, devices) and the exceptions per second by name, every hart’s (user_ecall, the system calls;supervisor_ecall, the kernel’s calls to the firmware). - QEMU’s host CPU time, its user and system time from
/proc/<pid>/statat both ends, per second of the window: the host cores the idle machine takes. The kernel counts that time in clock ticks (getconf CLK_TCK, 100 a second here), so a 60 s window reads it to 1/6000 of a core: 0.002 cores is about 12 ticks, and a 0.01 ceiling is 60.
The case log gets one [idle] line with every rate, and a rate over its ceiling fails the case;
a ceiling left out is measured and printed only. A window that measured nothing fails too, never
passing under its ceilings for want of data: no log, no timer interrupt on any hart (an idle hart
may take none in a whole window, since a timeout whose payer runs nowhere is armed on one hart:
the hart timer), no system call from user mode, or no host CPU
time. launch-idle’s ceilings are twice the most that ten 60 s windows measured, five a width
(the case’s comment gives each), so a ceiling catches a cost that doubles, not the
window-to-window spread: 3.8 interrupts a second on the busiest hart (1.6 to 1.9 measured), 12.2
system calls from user mode (6.0 to 6.1), and 0.01 host cores, the measure’s own resolution
(0.001 to 0.002). Before the one-hart rule the busiest hart took 3.4 to 3.5 a second, three
quarters of them the kernel lock’s hand-off interrupts, each timeout waking every hart
(the hart timer).
The window never holds the zeroing of the frames in flight
(frames in flight): about 35,000, which the boot and the
login leave in flight and one hart zeroes once every hart first idles after the login, about half
of a window’s host time when the window held them. The kernel says on the console when a drain of
1,024 frames or more begins (mem: 1024 frames in flight) and when the frames in flight are back
to 0 (mem: frames in flight 0, at most N). When the session reaches its idle step, the bench
reads the console lines already queued, until it falls silent for 50 ms (a host-clock settle, for
those lines only), and holds the session and
the window until a drain has ended since the sessions began, none being open. Six runs a width
alone, at four harts, saw them back 0.32 to 0.39 s after the step began on rv64, at about 0.6 host
cores, and 5.8 to 6.2 s on rv32, at about 0.15; why rv32’s every hart idles that much later
after the prompt is not yet measured. The bench waits at most 30 s from the step’s start, about five times
rv32’s most, the margin for a loaded host; a drain still open then, none ended, or a console never
silent fails the case, and the failure names which; the drain’s end is the kernel’s line, never a
settle time. The case log’s [idle] drain: line, before the window’s own, says what
the wait came to: the drain’s most frames, the wall time from the step’s start to its end and
QEMU’s host CPU meanwhile, or that none ended in the wait. A drain that opens inside the window
is named on the window’s own [idle] line, written once it has ended: the window’s work,
measured. Before R81 the busiest hart took 3.1 interrupts a
second on rv64 and 2.8 on rv32, at 0.002 cores; with the wait, those twelve windows read 2.9 to
3.5 at 0.001 to 0.003 cores. The measures are the hardware’s view: they say how often the guest wakes, not which budget woke it;
that would take the kernel’s own trace, which prints only at a reset. launch-idle is a
host-clock case, run in the quiet class.
Against Redoubt’s sshd
Status: built · partly tested: agent forwarding is refused inside `sunset`, which no case sees, since `ssh` asks for it without a reply ([residual risks](servers/sshd.md#residual-risks)) · tested (13)
- bench:sshd-loopback-logins
- bench:sshd-loopback-r67
- bench:sshd-loopback-interrupt
- bench:sshd-loopback-independent
- bench:sshd-loopback-window-change
- bench:sshd-loopback-window-change-zero
- bench:sshd-loopback-env-refused
- bench:bench-ssh-loopback
- bench:bench-ssh-loopback-host-key
- bench:sshd-host-tests
- host:testbench::an_ssh_lacking_an_option_is_named
- host:testbench::the_redoubt_proxy_keeps_its_errors_off_ssh
- host:testbench::the_redoubt_servers_log_exists_before_any_session
ssh-loopback cases run the host’s OpenSSH ssh against Redoubt’s own sshd on its host
platform, redoubt-sshd-host (the core and its platforms),
which the bench builds and ssh starts as its ProxyCommand. Its host key is loopback-host,
and each of the case’s authorized keys is a principal of the same name. Nothing there needs a
shell, a login context or a guest. The box’s platform, on the guest’s port 22 with the steward’s
logins and real sessions, is the steward-* boot cases’ (steward-ssh-two-principals and the
rest, sshd).
- The self-checks run on it, their steps written for its scripted console, and their
server_logpatterns for its log. - Cases, verdicts from
ssh’s exit status and the server’s log: a login key the server’skeydholds is refused and never reaches the login table, and so is an unknown principal;alice+secretsgets the labels{alice-secrets}; on a labelled channel a shell without a pty,exec, a subsystem, and remote and local port forwarding are refused, and no console starts (R67 (a channel keeps its labels)), and the log names each refusal, the shell’s asshell-without-pty; a break reaches the console as its interrupt; one connection’s end leaves another’s session running; a host key other than the case’s is refused. A resized terminal’s window change reaches the console as its new size, and one to no size (0x0) is refused and never reaches it.envis refused, and the log names only the request’s kind, never the client’s name or value.sshwants no reply to either refusal, so the server’s log is the whole evidence: each case requires the refusal’s line and forbids its opposite. - One reference case stays on OpenSSH’s own
sshd, withserver = "openssh": concurrent sessions, marks, exit statuses, a pty and a refused key. It is the session runner’s independent witness, so a bug the runner shares with Redoubt’s server cannot pass every self-check (bench-ssh-loopback-openssh). Its server runs in a QEMU guest (sessions and the loopback server). sshgetsWarnWeakCrypto=no-pq-kexagainst Redoubt’s server, whose exchange is not post-quantum: OpenSSH’s warning would otherwise be session output. OpenSSH 10.1 introduced the warning and the option together, so the bench hasssh -Gparse the option once per run, with no configuration file as the sessions run it, and passes it only to ansshthat takes it: an older one has no warning to quiet, and none to filter from its output (OpenSSH 9.6 prints nothing over such an exchange). When it drops the option, the run’s output says why.
Self-checks
Status: built · tested (17)
- bench:bench-attack-forgery
- bench:bench-console-after-expect
- bench:bench-poweroff-missing
- bench:bench-reporter-mismatch
- bench:bench-init-reporter-forged
- bench:bench-debug-assertions
- bench:bench-debug-assertions-off
- bench:bench-net-peer-twice
- bench:bench-net-peer-count
- bench:bench-net-peer-pcap-empty
- bench:bench-net-self-unrefused
- bench:bench-cbo-self-unrefused
- bench:bench-qemu-early-exit
- bench:bench-elixir-oracles-broken-guard
- bench:bench-no-trace
- bench:bench-trace-scan-catches
- bench:bench-test-only-refused
The harness can fail, and each feature shows it. Cases named bench-* check the bench itself:
each feature has a case that passes only if the feature works and, where the bench can be
misconfigured on purpose, a case that must fail:
must_fail = '^regex$' # passes only if the run fails with a matching reason
must_fail is judged against the run’s verdict only (console, sessions, devices); a build error or
the bench’s own trouble is a failure regardless, and so is an elixir case’s toolchain. A build
case’s must_fail alone matches its build’s error, or its binary scan’s finding (below). Each case
writes its pattern anchored and quoting the evidence, so it cannot pass by failing for some other
reason; the bench does not enforce the anchoring, so a reviewer checks it. An attack case can have
a self-check of its own: bench-net-self-unrefused runs net-attacks’s boot with ipd not told
one of the box’s addresses, and must fail on the SYN the capture then shows.
bench-cbo-self-unrefused runs cbo-user-fault’s boot with a kernel that leaves senvcfg
permissive: all four cache-block operations must be seen running, and the run must fail on the
verdict line that never comes.
bench-elixir-oracles-broken-guard runs the steward core’s reference with its not_locked guard
held always, and must fail on the first event where its output leaves the core’s
(the trace encoding).
A build case with forbid_in_binary reads the binary it built and fails on the first byte string
named that it holds anywhere, quoting the offset and what is around it: bench-no-trace builds the
production kernel, the release profile with the board feature alone as ./build makes it, and
forbids the scheduling trace’s console-line prefix and its module’s mangled path: the scan proves
this trace absent, not every channel
(R23 (no test channels)); bench-trace-scan-catches
builds a checked kernel with sched-trace (debug_assertions = true: a test-only feature
compiles into no other) under the same scan and must fail on the trace’s console line; and
bench-test-only-refused asks the release profile for sched-trace and must fail on the kernel’s
own refusal, a test-only feature needing a checked build, in the compiler’s words.
The unsafe budget
Status: built · tested (13)
- bench:unsafe-budget
- host:testbench::actual_source_counts_still_enforce_the_budget
- host:testbench::empty_configuration_is_not_coverage
- host:testbench::every_configured_root_must_contain_rust_source
- host:testbench::missing_paths_fail_regardless_of_extension
- host:testbench::unreadable_source_reports_its_path
- host:testbench::broken_nested_symlink_is_not_silently_skipped
- host:testbench::zero_unsafe_source_is_valid_as_a_file_or_nested_directory
- host:testbench::every_on_target_source_is_in_a_budget
- host:testbench::a_long_safety_block_directly_above_justifies
- host:testbench::a_raise_needs_its_unsafe_budget_line
- bench:rt-miri
- host:testbench::a_miri_case_runs_its_files_under_miri
unsafe-budget.toml lists every source directory of the trusted computing base that runs on the
target, each with the most uses of unsafe it may hold and the most that may lack a justification
(zero everywhere): a // SAFETY: comment above an unsafe block, and a # Safety section in the
doc comment of an unsafe fn or unsafe impl, in the comment block directly above or within a
few lines. The case counts both and fails if either is over.
Budgets only go down. Raising either limit, dropping a budget or narrowing its paths needs a line
Unsafe budget: <name>: <reason> in the commit that does it, which the case reads the way the
size budget reads its own (below), with the same history check. A page that
quotes the ceilings, in a `unsafe` (pinned) column like the
kernel’s TCB table, is held to them by the docs checker
(below), so a ceiling cannot move without the page.
A configured path with no Rust source in it fails. So does coverage left out: every workspace
member that can be built for the target (its crate root is no_std) must have each of its Rust
sources in some budget, unless the file lists it under [[uncounted]] with a reason (the test
programs, the model, host tools, vendored code), and an uncounted entry that names no such
member fails too. Vendored third-party crates are outside the ratchet
(below). So are crates outside the workspace: the gate reads only the
workspace’s members, and userland/otp is its own workspace whose no_std crates (re, crypto,
vm) no budget counts. They are not in the trusted computing base; if one ever joins it, it joins
the workspace and the gate sees it.
The ratchet counts unsafe; it does not check it. rt-miri runs the native runtime’s host tests
under Miri (Stacked Borrows, isolation off), which checks the heap’s free lists, page buffers,
lends and device registers that a native run only executes. It runs the files that finish in
seconds, among them registers over the fake kernel’s real device memory, and the heap’s own,
heap, in a minute or two (fewer random rounds under Miri), each file a job of its own
(fanout); ipc and echo take minutes under Miri and are run by hand. Its
files assert no wall-clock bound, only hang guards, so it is not a host-clock case. Without
nightly Miri the case is missing, not passed.
With the runtime’s page-buffer aliasing fix reverted, mapping_views fails it with the Stacked
Borrows error not granting access to tag <wildcard> because that would remove [Unique for <…>] which is strongly protected.
The size budget
Status: built · tested (12)
- bench:size-budget
- host:testbench::only_code_lines_count
- host:testbench::a_test_module_does_not_count
- host:testbench::a_test_module_file_does_not_count
- host:testbench::a_shipped_file_always_counts
- host:testbench::a_form_the_case_cannot_follow_fails
- host:testbench::a_raise_needs_its_reason
- host:testbench::the_ratchet_reads_the_commit_that_raised
- host:testbench::a_merge_is_judged_against_its_first_parent
- host:testbench::merged_history_is_not_read_again
- host:testbench::a_new_budget_file_needs_every_reason
- host:testbench::a_deleted_budget_file_fails
The size of the trusted computing base is budgeted, not observed
(the tenets). size-budget.toml lists each trusted crate (the kernel, the loader,
the stub, the libraries they and the servers link, the model and the servers) with a ceiling in
lines of code: every line of every .rs file under its paths that is not blank, a // comment
(doc comments included) or inside a /* */ comment. Tests are left out, so writing them costs a
crate nothing: a #[cfg(test)] item counts for nothing, and neither does a file only test modules
reach (mod tests;, the modules it declares in turn, and those declared inside an inline test
module). A file any other module declaration reaches counts, whatever else names it; a module is
found by its name, its enclosing inline modules and its #[path] or cfg_attr path, and one whose
file is not under the crate’s paths fails the case. So does a form the case does not follow, which
could reach a file past a test’s #[path] to it: a mod whose name is not a plain identifier
(r#name, a macro’s $name) and the word include, however it is invoked. The case fails when
a crate is over its ceiling. The ceilings started at each crate’s size when the case landed and only
fall: the case reads the commits that changed its file on the branch it runs on, merges included,
and where one raised a ceiling over the file in its first parent, dropped a crate (a rename drops
the old name) or narrowed a crate’s paths (fewer lines counted under the same ceiling), requires a
line Size budget: <crate>: <reason> for each such crate, in its message or, for a merge, in a
commit it brings in. A commit whose parent lacks the file is judged against the last version
before it; where there is none, as for a file the main branch lacks (new or renamed), every entry
in it needs its line. A commit that deletes the file fails the case, so deleting it and adding it
back raised cannot escape. A raise not yet committed fails, and so does a path with no Rust source in
it; uncommitted changes do not stop the committed ones being judged.
A branch is judged on its own commits: those since its merge-base with the main branch
(redoubt, or origin/HEAD in a clone that has no such branch), so a package is judged before it
merges and merged history is never read again. On the main branch itself only a raise not yet
committed is judged. With neither branch to measure from, as in a shallow clone that lacks the
merge-base, the case fails rather than passing unchecked.
The residual: a macro defined outside the budgeted paths, a dependency’s macro_rules! or a
proc macro, can emit mod x; or include! at a call site, and the case reads only the call. No
trusted crate’s dependencies are known to, and adding one is a Cargo.toml change reviewed as
part of the trusted computing base
(tenet 5).
The memory budget
Status: built · tested: bench:init-boot, bench:userland-boot, bench:userland-read-only, bench:beamlet-footprint, bench:beamlet-compile-footprint, bench:steward-session-ends, bench:steward-ssh-two-principals, bench:init-refuses-stack, bench:memory-host-tests, host:testbench::a_qmp_socket_is_private_and_binds, host:testbench::heap_fits_holds_an_eighth_of_the_cap_over_the_peak
A boot case under init can set memory = true. After its console verdict the bench stops
QEMU over QMP and dumps the guest’s physical RAM beside the case’s log, as
<case>-<arch>-smp<N>.ram. The runtime writes each server’s heap record before main, so the
record is the witness that a server has started: a dump missing a declared server’s record lets
the guest run on and is taken again, until every record is present or the case’s deadline
passes, when the case fails. A server’s peak is therefore the most pages its heap had held when
the last declared server had started, or later, never earlier than the stop line. A wait is
printed first, as memory: dumped twice, waited S s for NAMES. The launcher has painted each
server’s first-thread stack with a tag for that server and an index for every eight-byte unit.
The bench scans the RAM for those tags at their encoded offsets within physical pages. That
ignores paint words copied into ordinary stack slots, while refusing a missing server, a
duplicate unit or an out-of-range index at an encoded offset. A stack page’s units are counted
from the one physical page that holds the most of them, so a stack word copied into a buffer or a
message is not a duplicate. The lowest missing unit marks the stack’s deepest touched point; two
pages holding equally many of a stack page’s units are refused for that unit’s stack page or one
below it, and ignored above it, where either leaves the peak where it is. The bench prints
stack NAME PEAK of PAGES pages for each server and fails when the declared pages are less than
twice the measured peak rounded up to a page. The runtime paints each stack it spawns a thread on
the same way, with the server’s tag, the spawn’s number from 1 and each unit’s index from the
top, since the stack’s size is the program’s, not the manifest’s
(redoubt-rt); the bench prints stack NAME thread N PEAK for
each it finds, the peak the deepest unit overwritten under the deepest one found, which is the
stack’s bottom short of an overflow (which ends the process at the guard page), and checks no
margin: the program’s constant (beamlet’s SCHEDULER_STACK_PAGES) is sized from the largest by
hand. The dump, the size of the guest’s RAM, is deleted
once scanned; a scan that fails on a duplicate or out-of-range unit, or cannot read the dump,
keeps it as the evidence. The QMP socket is in a directory of the run’s own under the temporary
directory, made 0700 under a name drawn at random, so whatever the bench’s umask only its user
can reach the socket and no other user can leave a file where it will be; the directory and the
socket go when the run ends, passed, failed or in error. A bench killed outright leaves the
directory, empty of any listener since QEMU dies with the bench, under $REDOUBT_TMP, the
job’s scratch. A temporary directory so deep that the socket’s path would reach a Unix socket’s
108 bytes is refused before QEMU starts, naming the limit. The manifest’s
stack_pages defaults to 16 and cannot exceed 128
(the boot manifest).
Each server’s runtime also keeps a heap record, 32 bytes in its data: a magic word, the server’s
launch tag, its heap cap and the most pages its heap has held at once
(the native runtime). The runtime writes the
magic word and the tag before main, so the program’s file and the launcher’s copies of it hold
none. The same scan finds each server’s record by its magic word and tag and prints
heap NAME PEAK of CAP pages, or heap NAME PEAK pages uncapped. It fails a missing or
duplicated record, a record whose cap is not the manifest’s heap_pages, and a capped server
whose cap is less than twice its peak; an uncapped server is reported only. A case that drives a
workload within a server another case sizes from its idle peak sets heap_fits = true, and its
capped servers pass instead when the peak leaves at least an eighth of the cap unused: the
question there is whether the work fits, not what the server’s size should be
(beamlet-compile-footprint).
The standard image is scanned after init-boot, userland-boot and userland-read-only on both
widths, and after steward-session-ends and steward-ssh-two-principals, whose SSH logins are the
most its servers do. Its declarations are twice the largest peak across those runs: each stack
rounded up to pages, each heap cap in pages. sshd is sized for its four slots (its entry’s
buckets=4, sshd) busy at once: its
heap held 8 pages at boot, 54 with one login (rv32, before the boot pack) and 56 with two at once,
so at most 46 a connection, and its cap is twice 8 pages and four connections, 384. Its declared
stack is a page over twice its peak. ipd’s cap is not twice its peak either: it holds a base of
twice its peak apart from its socket tables, 2 × 37 = 74 pages, and every socket its buckets may
charge, its buffers, its slot and its table entries, 108 × 4.25 = 459 pages, since a socket the
heap could not hold would fail an allocation in a shared server (ipd).
Its peak of 61 pages holds those tables, sized at start for its 108 sockets, 24 pages beside the 37
of everything else. consoled’s row is from the runs once it serves each
session’s console as a multiplexed session. The two walfsd rows are one server’s, from six runs of
walfsd-quota, three on each width, which writes through it, as the image’s memory cases do not
(their boots peak at 16,040 bytes of stack and 9 heap pages); its heap cap is also above what a
transaction of the format’s 32 blocks adds to that case’s peak, 32 pages, since no case writes
that many at once (walfsd). The read-only case also scans its
additional client from the merged manifest.
| Image server | Largest stack peak (bytes) | Declared stack (pages) | Largest heap peak (pages) | Heap cap (pages) |
|---|---|---|---|---|
keyd | 7,368 | 4 | 4 | 8 |
consoled | 10,384 | 6 | 11 | 22 |
bootfsd | 7,320 | 4 | 1,089 | 2,304 (the image publishes beamlet, piped, consrelay and crossing, and the cap follows each manifest’s own published total) |
blkd | 4,520 | 3 | 19 | 38 |
netd | 4,296 | 3 | 2 | 4 |
ipd | 13,768 | 7 | 61 | 533 (twice its peak apart from its socket tables, 74, and its buckets’ 108 sockets’ buffers, which the cap must hold, ipd) |
walfsd:data | 28,616 | 14 | 15 | 64 |
walfsd:alice-secrets | 28,616 | 14 | 15 | 64 |
blkd:system | 4,520 | 3 | 17 | 34 |
verity:system | 8,264 | 5 | 59 | 118 |
erofsd:system | 9,704 | 5 | 13 | 26 |
steward | 16,392 | 9 | 15 | 30 |
sshd | 5,256 | 4 | 56 | 384 |
The read-only client’s largest stack peak is 6,616 bytes and its heap’s 31 pages; its case uses
the 16-page stack default and no cap. A session’s beamlet is no manifest server: the steward
launches it with an 18-page stack, twice beamlet’s stack peak of 36,136 bytes (beamlet-footprint
on rv64, with the boot pack read before its VM starts and its console on the hub; a compile at the
prompt peaks at 34,280 on rv64 and 25,832 on rv32 in beamlet-compile-footprint, whose heap is
judged under heap_fits: 9,135 and 8,798 pages of the 11,220 cap), its second
scheduler’s thread with a stack of the same size (its peak at the prompt between 21,496 and
25,200 bytes on rv64 over three runs, 21,752 on rv32, stack beamlet thread 3; the platform’s
three other threads stay under 1,600, beamlet-footprint), and a heap cap of what its share holds beside
its two schedulers’ stacks and thread pages, its locks’ endpoints and its process’s page, 11,220
pages of the 11,264 the steward gives it of the 11,392 sizes gives a session, the rest its
console relay’s (budgets, the steward): 108 pages over twice its heap’s
largest peak across the memory cases on each width, 5,556 pages (rv64 at 1 hart),
above what its prompt holds (beamlet). beamlet-footprint scans
the VM alone, under its own copy of the single-VM manifest. Its cap leaves its process heap,
ETS and persistent_term limits, a sixteenth of its budget each (696 pages), reachable: a flooding process, about
four times its limit, still fits under the cap (beamlet).
A context’s console relay is no manifest server either: consrelay-footprint has init start it
under a manifest of its own, beside a stand-in that takes its hello, and scans it once it says it
serves, since it reserves every buffer before then. Its first thread’s stack peaked at 8,360 bytes
on rv64 and 7,392 on rv32, its heap at 22 pages on both; the steward launches it with a 5-page
stack and a 44-page heap cap (consrelay). The steward’s own
cleanup thread and launch thread are the two threads it spawns painted (stack steward thread 1
and thread 2): in steward-session-ends the cleanup thread’s stack peaked at 1,432 bytes on
rv64 and 1,168 on rv32, and the steward gives it one page, twice 1,432 being 2,864; the launch
thread’s at 4,840 and 3,808, and it gets three pages, twice 4,840 being 9,680
(the steward).
This is a measurement of the paths the case drove. Other requests or deeper call paths may need more stack or heap, and any guest, including another server, can forge the public paint pattern and the heap record. The result is evidence for the image’s declarations, not a proof against hostile guest code.
Vendored dependencies
Crates as published
Status: built · partly tested: provenance against crates.io needs the network, so no bench case runs it; it is run by hand in the review of any change to `vendor/` · tested (11)
- bench:vendor-check
- bench:vendor-build
- host:redoubt-vendor-check::the_real_structure_passes
- host:redoubt-vendor-check::a_renamed_header_fails_before_any_download
- host:redoubt-vendor-check::a_missing_row_fails
- host:redoubt-vendor-check::a_row_without_its_directory_and_a_stray_directory_fail
- host:redoubt-vendor-check::vendored_files_are_the_published_bytes
- host:redoubt-vendor-check::the_vendored_copies_are_the_ones_that_build
- host:redoubt-vendor-check::the_patches_point_at_vendor
- host:redoubt-vendor-check::the_readme_records_each_crate
- host:redoubt-vendor-check::every_vendored_file_is_tracked
vendor/ holds third-party crates exactly as crates.io published them, each used through a
[patch.crates-io] path, and vendor/README.md records each one’s version, license and published
checksum. Git must track every vendored file: a crate’s own .gitignore applies inside vendor/,
and many name Cargo.lock. Two checks guard them, and they prove different things:
- Integrity since vendoring (
vendor-check, every bench run): every file is byte for byte whatvendor/SHA256SUMSrecords, nothing is added or removed, and the build takes the crates fromvendor/, never from a registry copy. The checksums were taken from the tree itself, so this proves the crates have not changed since they were vendored, not that they are what crates.io published.vendor-buildchecks they buildno_stdfor both widths. - Provenance (
tools/vendor-check/provenance.sh, in review): downloads each published crate, checks its SHA-256 against the live crates.io index and againstvendor/README.md, and compares it with the vendored copy. It fails closed: before any download it checks that the README’s table andvendor/name the same crates, in the same number, and it exits 0 only if every crate it checked matches on all three counts and it checked as many crates asvendor/holds.
The residuals: provenance is only as current as the last review that ran it, and the vendored
crates’ unsafe is checked by recorded Miri runs, not by a bench case
(ipd under Miri, sshd under Miri).
Patched crates
Status: built · tested (4)
- bench:vendor-check
- host:redoubt-vendor-check::vendored_files_are_the_published_bytes
- host:redoubt-vendor-check::a_patched_crate_differs_only_by_its_patch
- host:redoubt-vendor-check::every_patch_is_a_vendored_crates_and_recorded
A crate we must change is still vendored from its published bytes, with exactly one patch file,
vendor/patches/<crate>.patch, holding every change. vendor/<crate>/ is the published crate
with that patch applied, since that is what Cargo builds, and nothing else differs.
- Integrity:
vendor/SHA256SUMSrecords the published bytes.vendor-checkcopies the crate, reverses the patch on the copy, and requires the result to match the sums byte for byte, so the patch file is the whole difference. - Provenance:
provenance.shdownloads the published crate, applies the patch, and compares the result withvendor/<crate>/. - The README records each patched crate’s patch: what it changes and why, as its own section.
- A new release of the crate means taking the published bytes again and redoing the patch on them; a patch the crate’s author has taken goes away with the release that carries it.
sunset is patched (sshd), and what its patch does
is tested through its public API (tools/vendor-check/tests/sunset_patch.rs). ascii is patched
too: rustc 1.98 refuses to build it from a path as published, since Cargo caps a registry crate’s
lints, not a vendored one’s. miniz_oxide is patched to box the deflater’s 64 KiB code buffer
under with-alloc, as it is on wasm32, so its state is never a 65 KiB stack local on a scheduler
thread (beamlet; tests/zlib_codec.rs measures the
inline and the boxed parts).
The no-cruft gate
Status: built · tested: bench:no-cruft, host:testbench::a_rule_scoped_where_nothing_is_searched_fails
no-cruft.toml reads the sources and boots nothing. It fails on:
- a name of an interface the tree has dropped (its
forbiddenpatterns); - the native runtime handing out the raw call:
libs/rt/srcre-exportingredoubt-syswhole (alias, glob,selforextern crate), or apubitem or one-line re-export there namingsyscall(forbiddenpatterns heldwithinthat path; awithinthat is missing or outside the case’spathsfails the case); allow(dead_code)orallow(unused...)in the kernel, the loader, the layout and paging crates or the test programs;- a Cargo feature that no
cfg(feature)reads; - a second literal definition of
PAGE_SIZEorUSER_AREA_END, or anyconst PAGEalias; the model is searched too, and its ownPAGE_SIZEinmodel/src/spec.rsis an[[allow]], because the model is the independent oracle and depends on no implementation crate.
Its [[allow]] entries (path, rule, reason) are the only exemptions, and an entry that no longer
covers anything fails the case too.
The formatting gate
Status: built · partly tested: that a skip with no reason, or one that names no workspace, fails is read from the code, not run by a self-check · tested: bench:formatting, host:testbench::only_tracked_workspaces_are_reported, host:testbench::diff_headers_of_both_rustfmt_forms_name_the_file, host:testbench::every_elixir_file_is_walked_from_its_deepest_root
formatting.toml holds the tree to the formatting rule: in each
cargo workspace root it names (the main workspace, each host-only fuzz or oracle crate that is its
own workspace, and userland/otp), it runs cargo +nightly fmt --all --check and fails on every
file that is not formatted with the repository’s rustfmt.toml. It also fails on a tracked Cargo.toml
with a [workspace] table that is neither a root nor a [[skip]], so a new crate outside the main
workspace cannot drift unchecked, and on a skip with no reason or no workspace. Two are skipped:
bios/, the upstream RustSBI firmware with its own configuration, and the docs checker’s fixture
crate, whose layout is the checker’s input. The generated wire codecs (libs/wire/src/proto/) are
in rustfmt.toml’s ignore: the generator is their one definition, and its own test holds them to
its output. Without nightly rustfmt the case fails, unless
--allow-skip. The case file is not called rustfmt.toml: rustfmt would read it as the
configuration for everything under tests/.
The Elixir is held the same way, and by the file rather than by the project: the case lists every
.ex and .exs git tracks or has not ignored (so a file about to be added counts, wherever it
is put) and runs mix format --check-formatted on them, under the pinned toolchain, from the
deepest of the [elixir] roots above each, which is where its .formatter.exs is: the
repository root, whose .formatter.exs (110 columns, as rustfmt.toml sets) covers the
steward’s and the wire’s Elixir, the OTP test programs, the bench’s script and the docs
checker’s fixtures; and userland/shell, the shell’s own. Each root must hold a
.formatter.exs, and one anywhere else is a finding, as a configuration the walk would not use;
a file under no root is one too. Three directories are skipped with their reason, as the Rust
codecs are: libs/wire/elixir/proto and client, redoubt-wire-gen’s output, and
libs/steward/elixir/gen, redoubt-steward-gen’s; each generator’s test holds its files to its
output. A skip over no Elixir, or without a reason, fails. Before this walk only the shell’s
./test-shell ran mix format, over the shell’s own inputs, and the bench ran none: the
steward’s reference, the wire’s hand-written Elixir and the OTP test programs went unformatted
for as long as they existed. Without the pinned mix the case fails, unless --allow-skip.
The docs checker
What it checks
Status: built · tested (9)
- host:redoubt-doccheck::good_tree_is_clean
- host:redoubt-doccheck::narrow_cases_fire
- host:redoubt-doccheck::c1_reports_each_failure
- host:redoubt-doccheck::c13_reports_each_failure
- host:redoubt-doccheck::index_over_its_ceiling_names_the_largest_pages
- host:redoubt-doccheck::pages_scope_keeps_only_the_listed_pages
- host:redoubt-doccheck::pages_scope_keeps_a_directory
- host:redoubt-doccheck::untracked_code_is_not_checked
- host:redoubt-doccheck::a_copy_under_an_ignored_directory_is_checked_whole
redoubt-doccheck (tools/doccheck) holds this book to its own rules:
cargo run -q -p redoubt-doccheck prints each finding as path:line: C<n>: message;
--pages <path>... keeps only the findings on those pages, and --code adds C11, the check of
code comments and case descriptions for the documentation switch-over. It checks that
every section has one well-formed status line and every test it names exists; that milestones carry
their names; that no page carries process references; that every rule ID is defined once and cited
by its short name; that every relative link resolves; that the security register
agrees with the pages; that no binary sits under docs/; that pages keep their templates; that
every wire table is included once; that every page is in the table of contents; and that every
count in a `unsafe` (pinned) column equals the ceilings in unsafe-budget.toml
(the unsafe budget), summed over the budgets under a row’s Where paths or
read by the name in its Ratchet budget cell. Each rule has a small bad tree it must fire on and a
good one it must not. mdbook build docs renders the book; its search index is held to the
ceiling the book’s rules state, a failure naming the size and the
three largest pages, and the check has its own small book over the ceiling.
Running its tests. cargo test -p redoubt-doccheck runs them from any directory and any
shell the project supports (through scripts/q too); the bench’s docs case runs the same suite
(below). The fixtures are found by the crate’s own path, not
the working directory. C11 asks git which files under the tree it checks are tracked, so that a
checkout’s untracked files (reference sources, scratch) are not Redoubt’s code; a tree that is no
tracked part of a repository, outside one or under an ignored directory such as .tmp/ or
target/, is checked whole, and the caller’s GIT_DIR does not redirect the question.
The docs checker in the bench
Status: built · tested: bench:docs
A host-tests case runs the checker’s tests, among them one that checks this whole book and one
that builds it with mdbook and fails on any warning, on a search index over its ceiling and on
an indexer older than the book needs; the checker also reads code comments and
case descriptions, so they cite pages and rule IDs that exist.