init: a protocol you were not granted does not exist

The registry consults the open rows it has been parsing since P2, so
reaching a contract now takes a grant as well as a binding. A caller
without one is answered exactly as it would be for a name nobody ever
bound: same status, same empty reply, same absent capability, byte for
byte, and no log line on either path — klog_read is ungated, so a line on
one and not the other would be the oracle the design set out to remove.
Refusal and absence being one answer is what lets a supervisor later
narrow, fake or park a child's namespace without the child learning what
it was denied.

The manifest gains a third permission for a shape the plan did not
foresee: attestation is one hop, but the driver tree is three deep — the
PS/2 keyboard and mouse are spawned by ps2-bus, which the device manager
spawned — so no row could name them and PS/2 input would simply stop.
 lets a delegate vouch for what its children *reach*, never for
what they claim; the bind path is untouched, and the laundering deputy is
still refused.

The review found the receive side of a rule this track had already
written down. Every process holds a sendable handle to the registrar —
resolve installs one for anyone who asks — and ipc_reply_wait never asked
who owned the endpoint, so a stranger could dequeue there: take the
provider endpoints riding bind requests, and answer other clients' opens
in the registrar's name. Receiving is the owner's privilege, like binding
a signal or a timer; sending remains anyone's.

Suite 109/109.
This commit is contained in:
Daniel Samson
2026-08-01 04:44:15 +01:00
parent 1379b699f3
commit 7f3eb848f0
13 changed files with 670 additions and 24 deletions
+1
View File
@@ -339,6 +339,7 @@ pub fn build(b: *std.Build) void {
"thread-test", // the multi-threaded fixture (its package sets .threaded) "thread-test", // the multi-threaded fixture (its package sets .threaded)
"user-memory-test", // aims deliberately bad user pointers at the checked copy layer "user-memory-test", // aims deliberately bad user pointers at the checked copy layer
"protocol-registry-test", // drives the registrar: ungranted bind, collision, restart "protocol-registry-test", // drives the registrar: ungranted bind, collision, restart
"protocol-denied-test", // restriction stage one: an ungranted open answers as absence
}) |fixture| { }) |fixture| {
const package = b.lazyDependency(fixture, .{}) orelse const package = b.lazyDependency(fixture, .{}) orelse
@panic("a test fixture package is missing under test/system/services"); @panic("a test fixture package is missing under test/system/services");
+1
View File
@@ -76,6 +76,7 @@
.@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true }, .@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true },
.@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true }, .@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true },
.@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true }, .@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true },
.@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true },
// See `zig fetch --save <url>` for a command-line interface for adding dependencies. // See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{ //.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding // // When updating this field to a new URL, be sure to delete the corresponding
+4 -2
View File
@@ -1,7 +1,9 @@
# The protocol namespace # The protocol namespace
*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. Not yet implemented — *Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. P1–P3 of the
the migration plan at the end is the work list.* migration plan at the end have landed (the envelope, the registry and the
`ServiceId` flag-day, and restriction stage one); P4 and P5 are the remaining
work list.*
How a program finds, connects to, and is restricted from the things it talks to. How a program finds, connects to, and is restricted from the things it talks to.
Three ideas, kept deliberately separate: Three ideas, kept deliberately separate:
+68 -1
View File
@@ -31,13 +31,39 @@ the logging/USB-lifecycle track).
## Status ## Status
**Live state — updated on `main` after every phase, so this file read from a
plain `main` checkout always tells the truth about where the work is.**
| | |
|---|---|
| Working on | **P4a** — protocol rebase onto `envelope.Define` (next) |
| Branch carrying it | `feat/security-group-2` (pushed to origin) |
| On `main` | Phase 0, PM, H1, P1, P2, P3 (group 2 merged) |
| Awaiting merge | nothing — group 2 is on `main` |
| Suite | 109 cases, all passing |
| Last updated | 2026-08-01 |
A checkbox below means the phase met its definition of green and was
committed — on the branch named above, which reaches `main` at the next
group boundary.
- [x] **Phase 0** — baseline: suite green on `main` (106/106, 2026-07-31; `zig build` + `zig build test` clean at 9a32380), plan committed - [x] **Phase 0** — baseline: suite green on `main` (106/106, 2026-07-31; `zig build` + `zig build test` clean at 9a32380), plan committed
- [x] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`; vfs carve-out for the two writable `/system` subtrees, FAT's `/var` mount split in two; suite 106/106) - [x] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`; vfs carve-out for the two writable `/system` subtrees, FAT's `/var` mount split in two; suite 106/106)
- [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107) - [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107)
- [x] **merge** group 1 → main, push (f3bc23c, 2026-07-31) - [x] **merge** group 1 → main, push (f3bc23c, 2026-07-31)
- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107) - [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107)
- [x] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups; `protocol.csv` grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own. - [x] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups; `protocol.csv` grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own.
- [ ] **P3** — open grants: `protocol.csv` enforcement, denial test - [x] **P3** — open grants: `protocol.csv` enforcement, denial test. `onOpen`
consults the manifest with the same chain-attested identity a bind uses, and a
refused caller gets the *same* answer as one naming a contract nobody bound —
`-ENOENT`, no capability, the same reply bytes, no log line, and both questions
asked on every open so there is nothing to time. Twenty-seven `open` rows cover
the whole live client set. One wrinkle the plan had not foreseen: the driver
tree is three deep (device manager → PS/2 bus → keyboard/mouse) and attestation
is one hop, so a legitimate grandchild read exactly like a laundering deputy;
the manifest gained a third permission, `supervise`, which names an authorized
supervising task per contract and is deliberately **open-only**, leaving P2's
bind attestation and every refusal it makes untouched (suite 109/109)
- [ ] **merge** group 2 → main, push - [ ] **merge** group 2 → main, push
- [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input) - [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input)
- [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer) - [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer)
@@ -103,6 +129,19 @@ that implements it.
and both stamped names satisfy the row while the chain is entirely the and both stamped names satisfy the row while the chain is entirely the
attacker's. Walking to the root of the chain does not fix it either, since attacker's. Walking to the root of the chain does not fix it either, since
the laundered chain still roots at the real PID 1.)* the laundered chain still roots at the real PID 1.)*
*(P3 amendment: a third permission, `supervise`, joins `bind|open`. One-hop
attestation cannot express the one three-deep chain in the tree — the device
manager starts the PS/2 bus, and the bus starts the keyboard and mouse
drivers — and nothing structural tells that chain apart from the laundering
deputy, since both are a granted binary spawned by a granted binary. Only
policy can: a `supervise` row names the authorized supervising task the way
every other row names a claimant (binary, its own supervisor, the contract it
concerns), and an `open` row may then name that task in its supervisor
column. The delegate is itself attested the ordinary strict way, so the chain
still anchors in init or the kernel one hop above it and the recursion stops
there. It is **open-only** on purpose — a delegate may vouch for what its
children *reach*, never for what they *claim* — so the bind path is
byte-for-byte P2's and the laundering-deputy refusal is untouched.)*
4. **Test fixtures bind under `/protocol/test/...`**, granted to any 4. **Test fixtures bind under `/protocol/test/...`**, granted to any
binary whose path starts `/test/` — the subtree-scoping rule from the binary whose path starts `/test/` — the subtree-scoping rule from the
design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId
@@ -297,6 +336,34 @@ Every existing scenario doubles as conversion proof. Suite 108.
the first succeeds and the second fails identically to not-found. Suite the first succeeds and the second fails identically to not-found. Suite
109. 109.
*Landed. Four things the plan did not foresee, recorded because P4 and P5
inherit them:*
- *`supervise` — decision 3's amendment. The PS/2 keyboard and mouse drivers
are started by the PS/2 bus driver, which the device manager started: the
tree's one three-deep chain, and one hop deeper than attestation reaches.
Nothing structural separates it from the laundering deputy, so the manifest
says which delegate is authorized, per contract. Open-only, so P2's bind
attestation is unchanged.*
- *Indistinguishability is a claim about work, not only about bytes. `onOpen`
refreshes the process table, identifies the caller, scans the grants and
scans the bindings on **every** open and forms one verdict at the end; and
it logs nothing on any branch, because `klog_read` is ungated (a line
written on one branch is a line the refused caller can read) and a serial
line is milliseconds it could time. The operator's diagnosis is the pair the
namespace publishes anyway: `readdir /protocol` for what is bound, the
manifest for who may reach it.*
- *The fixture is `protocol-denied-test`, and its scenario boots the **input
service** so the forbidden name is genuinely bound — the fixture reads the
namespace listing to prove it before asking for it. Without a live provider
the case would be comparing two boot races and asserting nothing.*
- *Two channels stay open by design, named rather than papered over: `readdir`
over `/protocol` lists every bound name to anyone (deliberate — the tree is
diagnosable), and `/system/configuration/protocol.csv` is world-readable on
the `/system` mount. Stage one hides neither the set of contracts nor the
policy; what it removes is the **oracle in the reply**, which is what stage
two's parked and faked opens depend on.*
## P4a — clean protocols onto Define ## P4a — clean protocols onto Define
vfs, block, display, scanout, input — the modules whose shapes map vfs, block, display, scanout, input — the modules whose shapes map
+91 -6
View File
@@ -1,9 +1,17 @@
# /system/configuration/protocol.csv — who may claim, and who may reach, a name # /system/configuration/protocol.csv — who may claim, and who may reach, a name
# under /protocol (docs/os-development/protocol-namespace.md). # under /protocol (docs/os-development/protocol-namespace.md).
# #
# init is the registrar: it serves /protocol, and every bind is checked against # init is the registrar: it serves /protocol, and every bind AND every open is
# this file. It is AUTHORITATIVE — a name no row grants cannot be bound, and a # checked against this file. It is AUTHORITATIVE — a name no row grants cannot be
# missing file means nothing may be bound at all. # bound or reached, and a missing file means nothing may be bound or reached at
# all.
#
# A refused open is answered exactly as a name nobody bound is: -ENOENT, and no
# capability. That is not politeness, it is the model — the namespace IS the
# restriction, so what a process may not open simply does not exist for it, and
# there is no "permission denied" for it to tell apart from "no such contract".
# Which is why a missing row here shows up as a client retrying forever rather
# than as an error: check this file first, and `readdir /protocol` second.
# #
# '#' starts a comment (whole-line or trailing); blank lines are ignored. # '#' starts a comment (whole-line or trailing); blank lines are ignored.
# Whitespace around a field is trimmed, so columns may be padded. Four # Whitespace around a field is trimmed, so columns may be padded. Four
@@ -26,13 +34,21 @@
# confer; init's own path means this init; any other path means a # confer; init's own path means this init; any other path means a
# task init spawned itself or one the kernel spawned. Task ids are # task init spawned itself or one the kernel spawned. Task ids are
# monotonic and never reused, so an id cannot be borrowed. # monotonic and never reused, so an id cannot be borrowed.
# permission bind (provide this contract) | open (speak to it) # permission bind (provide this contract) | open (speak to it) |
# supervise (stand in someone else's chain — see below)
# name the contract, relative to /protocol # name the contract, relative to /protocol
# #
# A trailing '*' on any field matches any tail — how a subtree is granted whole. # A trailing '*' on any field matches any tail — how a subtree is granted whole.
# #
# NOTE: 'open' rows are parsed but not yet enforced; every open resolves today. # 'supervise' exists because attestation is one hop deep and the driver tree is
# The milestone that turns them into refusals is P3 (docs/security-track-plan.md). # three: the device manager starts the PS/2 bus, and the bus starts the keyboard
# and mouse drivers. Init never met the bus, so it cannot vouch for it by
# acquaintance — and it must not vouch for it by name, or the laundering deputy
# walks straight in. A 'supervise' row is the manifest saying it: a task running
# this binary, under this supervisor, may be the supervising task an 'open' row
# names, for this contract and no other. It grants the delegate nothing itself,
# and it is deliberately open-only — a delegate may vouch for what its children
# REACH, never for what they CLAIM, so every bind refusal is untouched by it.
# #
# binary supervisor permission name # binary supervisor permission name
@@ -68,3 +84,72 @@
# another fixture did. # another fixture did.
/test/*, kernel, bind, test/* /test/*, kernel, bind, test/*
/test/*, /test/*, bind, test/* /test/*, /test/*, bind, test/*
# ============================================================================
# open — who may REACH each contract. One row per client per contract; a client
# with no row here simply finds the name absent, forever.
# ============================================================================
# --- init's own services ----------------------------------------------------
# fat reaches the block device behind the volume it mounts; the compositor
# reaches the scanout its driver announced, its own endpoint (the mouse-listener
# thread opens /protocol/display like any other client — threads share no
# handles), and the input stream that moves the cursor.
/system/services/fat, /system/services/init, open, block
/system/services/display, /system/services/init, open, scanout
/system/services/display, /system/services/init, open, display
/system/services/display, /system/services/init, open, input
/system/services/display-demo, /system/services/init, open, display
# --- the same two when the kernel test harness starts them directly ---------
/system/services/display, kernel, open, scanout
/system/services/display, kernel, open, display
/system/services/display, kernel, open, input
/system/services/display-demo, kernel, open, display
# --- the drivers, and the discovery service ---------------------------------
# Every driver says hello to the manager that started it — one row for the whole
# subtree, because that handshake is what being a driver means. The rest are per
# driver: the storage and HID class drivers talk to their controller, the HID
# drivers publish into the input stream, and the GPU driver announces its scanout
# to the compositor.
/system/drivers/*, /system/services/device-manager, open, device-manager
/system/services/discovery, /system/services/device-manager, open, device-manager
/system/drivers/usb-storage, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, input
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, input
/system/drivers/virtio-gpu, /system/services/device-manager, open, display
# --- the PS/2 child drivers, one hop further down ---------------------------
# The keyboard and mouse drivers are started by the BUS driver, not by the
# device manager — the one three-deep chain in the tree. Init cannot vouch for
# the bus by acquaintance (it never started it), so the manifest authorizes it
# explicitly, and only for the two contracts its children need.
/system/drivers/ps2-bus, /system/services/device-manager, supervise, ps2-bus
/system/drivers/ps2-bus, /system/services/device-manager, supervise, input
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, ps2-bus
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, input
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, ps2-bus
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, input
# --- test fixtures ----------------------------------------------------------
# The /protocol/test subtree is theirs whole, the way the bind rows give it to
# them. Everything ABOVE that subtree is named one fixture at a time, so a
# fixture reaches a system contract only where a scenario needs it — which is
# what leaves the rest genuinely absent for the rest of them (the protocol-denied
# case asks for one it was not given, and is told there is no such thing).
/test/*, kernel, open, test/*
/test/*, /test/*, open, test/*
/test/*, kernel, open, device-manager
/test/*, /system/services/device-manager, open, device-manager
/test/system/services/input-source, kernel, open, input
/test/system/services/input-test, kernel, open, input
# The laundering-deputy probe (test/system/services/protocol-registry-test) runs
# a grandchild whose supervisor is a fixture nobody authorized — that is the
# point of it, and its bind must stay refused. It still has to report the verdict
# it got, so its reporting channel, and nothing else, is delegated.
/test/*, /test/*, supervise, test/verdict
Can't render this file because it contains an unexpected character in line 12 and column 15.
+8 -1
View File
@@ -332,7 +332,14 @@ fn systemIpcCall(state: *architecture.CpuState) void {
/// ipc_reply_wait(handle, reply_ptr, reply_len, receive_ptr, receive_cap) -> receive_len, /// ipc_reply_wait(handle, reply_ptr, reply_len, receive_ptr, receive_cap) -> receive_len,
/// with the sender's badge in the secondary result register (rdx). /// with the sender's badge in the secondary result register (rdx).
fn systemIpcReplyWait(state: *architecture.CpuState) void { fn systemIpcReplyWait(state: *architecture.CpuState) void {
const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); const t = scheduler.current();
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
// Receiving is the owner's privilege, the same rule the notification binders
// enforce: a sendable handle means only "you may talk to this". Anything
// else and a mount's backend endpoint — which `fs_resolve` installs in every
// caller's table — would let a stranger dequeue the requests meant for the
// server, taking the capabilities they carry and answering in its name.
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
var badge: u64 = 0; var badge: u64 = 0;
var received_cap: u64 = abi.no_cap; var received_cap: u64 = abi.no_cap;
const r = ipc.replyWait(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &badge, &received_cap); const r = ipc.replyWait(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &badge, &received_cap);
+55
View File
@@ -248,6 +248,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
deviceManagerTest(boot_information); deviceManagerTest(boot_information);
} else if (eql(case, "protocol-registry")) { } else if (eql(case, "protocol-registry")) {
protocolRegistryTest(boot_information); protocolRegistryTest(boot_information);
} else if (eql(case, "protocol-denied")) {
protocolDeniedTest(boot_information);
} else if (eql(case, "reboot")) { } else if (eql(case, "reboot")) {
rebootTest(); rebootTest();
} else { } else {
@@ -3843,6 +3845,59 @@ fn protocolRegistryTest(boot_information: *const BootInformation) void {
result(); result();
} }
/// P3 — restriction stage one (docs/os-development/protocol-namespace.md). The
/// registrar now checks `open` against `/system/configuration/protocol.csv`, and
/// a caller with no grant is told exactly what a caller asking for a name nobody
/// bound is told.
///
/// The scenario is the assertion's scaffolding: `/protocol` (init in its registry
/// role), the **input service** — which binds a real contract the fixture is
/// deliberately not granted — and the fixture. Without a live provider on the
/// forbidden name, "refused" and "not bound yet" would be the same observation
/// and the case would prove nothing; the fixture reads `/protocol`'s own listing
/// to confirm the name is there before it asks for it.
///
/// The fixture's `protocol-denied: ok` is the marker; each step prints its own
/// line, which the harness's ordered regex reads.
fn protocolDeniedTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-denied\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
check("registry (init) spawned", spawnRegistry(rd));
// The provider of the contract the fixture may NOT reach. It needs no
// hardware: it binds /protocol/input and waits for subscribers.
check("input service spawned", spawnNamed(rd, "input"));
check("protocol-denied-test spawned", spawnNamedWithArg(rd, "protocol-denied-test", "run"));
const pass_marker = "protocol-denied: ok";
const fail_marker = "protocol-denied: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("no step of the restriction contract failed", !saw_fail);
check("the fixture completed every restriction assertion", saw_pass);
result();
}
fn deviceManagerTest(boot_information: *const BootInformation) void { fn deviceManagerTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: device-manager\n", .{}); log("DANOS-TEST-BEGIN: device-manager\n", .{});
if (boot_information.initial_ramdisk_len == 0) { if (boot_information.initial_ramdisk_len == 0) {
+106 -13
View File
@@ -35,6 +35,12 @@
//! stranger's bytes; the only identity on it is the task id the kernel stamps. //! stranger's bytes; the only identity on it is the task id the kernel stamps.
//! Content never authorizes (`onPowerEvent`), and neither does a name — the //! Content never authorizes (`onPowerEvent`), and neither does a name — the
//! registrar attests a caller's supervision by task id (`supervisorSatisfies`). //! registrar attests a caller's supervision by task id (`supervisorSatisfies`).
//! - **Absence is the enforcement.** P3: `open` consults the manifest with the
//! same attested identity a `bind` does, and a caller with no grant is told
//! exactly what a caller asking for a name nobody bound is told — `-ENOENT`,
//! and no capability (`onOpen`). Restriction stage one of
//! docs/os-development/protocol-namespace.md: what a process cannot open does
//! not exist for it, so there is no "permission denied" to distinguish.
//! - **A capability that arrives is closed unless it is claimed** (`Arrival`), //! - **A capability that arrives is closed unless it is claimed** (`Arrival`),
//! because PID 1's thirty-two handle slots are a resource an unauthenticated //! because PID 1's thirty-two handle slots are a resource an unauthenticated
//! caller would otherwise be able to spend. //! caller would otherwise be able to spend.
@@ -153,7 +159,7 @@ const maximum_restarts = 3;
/// init holds. /// init holds.
const maximum_name = 64; const maximum_name = 64;
const maximum_bindings = 16; const maximum_bindings = 16;
const maximum_grants = 48; const maximum_grants = 64;
/// One bound contract: the name, the provider's endpoint (a capability init /// One bound contract: the name, the provider's endpoint (a capability init
/// holds and hands to whoever opens the name), and the provenance a diagnostic /// holds and hands to whoever opens the name), and the provenance a diagnostic
@@ -177,10 +183,26 @@ const Binding = struct {
var bindings: [maximum_bindings]Binding = .{Binding{}} ** maximum_bindings; var bindings: [maximum_bindings]Binding = .{Binding{}} ** maximum_bindings;
/// What a grant row permits: claiming a name, or reaching one. `open` rows are /// What a grant row permits.
/// parsed and held but not yet enforced — every open resolves in P2, and P3 is ///
/// the milestone that turns these into refusals (docs/security-track-plan.md). /// - `bind` — claim the name, i.e. provide the contract.
const Permission = enum { bind, open }; /// - `open` — reach the name, i.e. speak the contract to whoever provides it.
/// - `supervise` — stand in a third task's supervision chain: a task running this
/// binary, under this supervisor, may be the supervising task named by an
/// `open` row for this contract. It grants the *delegate* nothing itself.
///
/// `supervise` exists because attestation is deliberately one hop deep
/// (`supervisorSatisfies`): init vouches only for tasks it or the kernel started.
/// The driver tree is deeper than that — the device manager starts the PS/2 bus,
/// and the bus starts the keyboard and mouse drivers — so without a way to say
/// "this task is an authorized supervisor", a legitimate grandchild would be
/// indistinguishable from a laundering deputy. Naming the delegate in the
/// manifest is what tells them apart, and it is the same shape as every other
/// row: a binary, the supervisor it must have, and the contract it concerns.
/// Deliberately `open`-only — a delegate may vouch for what its children may
/// *reach*, never for what they may *claim* — so the bind path's attestation is
/// exactly what P2 shipped and every refusal it makes still holds.
const Permission = enum { bind, open, supervise };
/// One row of `/system/configuration/protocol.csv`. Every field may end in `*`, /// One row of `/system/configuration/protocol.csv`. Every field may end in `*`,
/// which matches any tail — the subtree scoping the design doc describes, and /// which matches any tail — the subtree scoping the design doc describes, and
@@ -193,8 +215,9 @@ const Grant = struct {
}; };
/// Roomier than init.csv's: this manifest carries a row per provider per spawn /// Roomier than init.csv's: this manifest carries a row per provider per spawn
/// path, its own format documentation, and grows again with the open grants. /// path, a row per client per contract it reaches, and its own format
var protocol_csv: [8192]u8 = undefined; /// documentation — which is most of the bytes, and is the point of the file.
var protocol_csv: [16384]u8 = undefined;
var grants: [maximum_grants]Grant = .{Grant{}} ** maximum_grants; var grants: [maximum_grants]Grant = .{Grant{}} ** maximum_grants;
var grant_count: usize = 0; var grant_count: usize = 0;
@@ -225,6 +248,8 @@ fn loadGrants() void {
.bind .bind
else if (std.mem.eql(u8, permission, "open")) else if (std.mem.eql(u8, permission, "open"))
.open .open
else if (std.mem.eql(u8, permission, "supervise"))
.supervise
else else
continue; // an unreadable row grants nothing rather than something wrong continue; // an unreadable row grants nothing rather than something wrong
grants[grant_count] = .{ .binary = binary, .supervisor = supervisor, .permission = kind, .name = name }; grants[grant_count] = .{ .binary = binary, .supervisor = supervisor, .permission = kind, .name = name };
@@ -393,6 +418,44 @@ fn granted(identity: Identity, permission: Permission, name: []const u8) bool {
return false; return false;
} }
/// Whether `identity` may reach `name` — `granted(.open, …)`, plus the one hop
/// `open` takes that `bind` does not (`Permission.supervise`).
///
/// The hop is needed because the driver tree is three deep and attestation is
/// one: the PS/2 keyboard driver's supervising task is the PS/2 bus driver,
/// which the device manager started, which init started. Init cannot vouch for
/// the bus by acquaintance — it never met it — so the manifest says so instead,
/// and says it per contract: `ps2-bus` may be the supervisor named in an `open`
/// grant for `ps2-bus` and for `input`, and for nothing else.
fn mayOpen(identity: Identity, name: []const u8) bool {
if (granted(identity, .open, name)) return true;
return delegatedOpen(identity, name);
}
/// The delegated `open`: the row's supervisor column names the caller's actual
/// supervising task by binary, that task is one init cannot vouch for directly,
/// and a `supervise` row authorizes it for exactly this contract.
///
/// The delegate itself is attested the ordinary way (`granted` → strict
/// `supervisorSatisfies`), so the chain is still anchored one hop above it in
/// init or the kernel and the recursion stops there. Two hops of manifest, never
/// an unbounded walk — a laundering deputy is refused at the first hop nobody
/// wrote a row for.
fn delegatedOpen(identity: Identity, name: []const u8) bool {
if (identity.supervisor_task == 0) return false; // a kernel-spawned caller needs no delegate
if (identity.supervisor_vouched) return false; // already answered by `granted` above
const delegate = identify(identity.supervisor_task) orelse return false;
if (!granted(delegate, .supervise, name)) return false;
for (grants[0..grant_count]) |grant| {
if (grant.permission != .open) continue;
if (!matches(grant.binary, identity.binary)) continue;
if (!matches(grant.supervisor, identity.supervisor_binary)) continue;
if (!matches(grant.name, name)) continue;
return true;
}
return false;
}
fn findBinding(name: []const u8) ?*Binding { fn findBinding(name: []const u8) ?*Binding {
for (&bindings) |*binding| { for (&bindings) |*binding| {
if (binding.used and std.mem.eql(u8, binding.nameSlice(), name)) return binding; if (binding.used and std.mem.eql(u8, binding.nameSlice(), name)) return binding;
@@ -501,7 +564,7 @@ fn serveRegistry(request_bytes: []const u8, reply: []u8, sender: u32, arrived: *
// Only `bind` claims a capability; one attached to anything else is closed by // Only `bind` claims a capability; one attached to anything else is closed by
// the turn's `defer` in the loop, along with the ones sent to a request that // the turn's `defer` in the loop, along with the ones sent to a request that
// was too short to name a verb at all. // was too short to name a verb at all.
if (operation == @intFromEnum(vfs_protocol.Operation.open)) return onOpen(reply, payload); if (operation == @intFromEnum(vfs_protocol.Operation.open)) return onOpen(reply, sender, payload);
if (operation == @intFromEnum(vfs_protocol.Operation.readdir)) return onReaddir(reply, cursor); if (operation == @intFromEnum(vfs_protocol.Operation.readdir)) return onReaddir(reply, cursor);
// Everything else a filesystem answers is meaningless here: `/protocol` holds // Everything else a filesystem answers is meaningless here: `/protocol` holds
// contracts, not bytes. // contracts, not bytes.
@@ -576,12 +639,42 @@ fn onBind(sender: u32, raw_name: []const u8, arrived: *Arrival) i32 {
} }
/// `open(name)` -> the provider's endpoint, delivered as the reply's capability. /// `open(name)` -> the provider's endpoint, delivered as the reply's capability.
/// A name nothing has bound is `-ENOENT`; in P3 an ungranted one becomes the same ///
/// answer, because absence and refusal are deliberately indistinguishable. /// **A refusal and an absence are the same answer, and that is the whole point.**
fn onOpen(reply: []u8, raw_name: []const u8) usize { /// The namespace is the restriction (docs/os-development/protocol-namespace.md):
/// what a process may open is what exists for it, so "you may not have this" and
/// "there is no such thing" collapse into one reply — `-ENOENT`, no payload, no
/// capability. A caller therefore has no oracle: it cannot use `open` to learn
/// that a contract it lacks is bound, and — the reason this matters beyond
/// tidiness — stage two's supervisor can refuse, stall for a human, or substitute
/// a fake without the child being able to tell which happened.
///
/// Indistinguishable is a claim about *work done*, not only about the bytes, so
/// both questions are asked on every open whatever the first one answers: the
/// process table is refreshed, the caller identified, the grants scanned and the
/// bindings scanned, and only then is the single verdict formed. Nothing here
/// logs, either — `klog_read` is ungated (system/kernel/process.zig), so a line
/// written on one branch is a line the refused caller can read, and a serial line
/// costs milliseconds it could time. The operator's diagnosis is the pair the
/// namespace already publishes on purpose: `readdir` over `/protocol` says what is
/// bound, `/system/configuration/protocol.csv` says who may reach it, and the
/// client's own retry loop says which one it wanted.
///
/// (Not constant-time in the cryptographic sense, and not claimed to be: the two
/// scans stop at the row they match, and the optimiser is free to sink a pure
/// table walk past a branch that discards it. What is removed is the difference a
/// caller could actually measure or read — a syscall on one branch and not the
/// other, a line in a world-readable log ring, or a serial write costing
/// milliseconds.)
fn onOpen(reply: []u8, sender: u32, raw_name: []const u8) usize {
const name = contractName(raw_name) orelse return answer(reply, -envelope.ENOENT, 0, 0); const name = contractName(raw_name) orelse return answer(reply, -envelope.ENOENT, 0, 0);
const binding = findBinding(name) orelse return answer(reply, -envelope.ENOENT, 0, 0); refreshProcessTable();
pending_capability = binding.endpoint; const identity = identify(sender);
const permitted = if (identity) |who| mayOpen(who, name) else false;
const binding = findBinding(name);
if (!permitted) return answer(reply, -envelope.ENOENT, 0, 0);
const found = binding orelse return answer(reply, -envelope.ENOENT, 0, 0);
pending_capability = found.endpoint;
return answer(reply, 0, 0, 0); return answer(reply, 0, 0, 0);
} }
+17 -1
View File
@@ -869,10 +869,26 @@ CASES = [
r"(?=.*protocol-registry: restarted provider reached)" r"(?=.*protocol-registry: restarted provider reached)"
r"(?=.*protocol-registry: laundering deputy refused)" r"(?=.*protocol-registry: laundering deputy refused)"
r"(?=.*protocol-registry: foreign signal binding refused)" r"(?=.*protocol-registry: foreign signal binding refused)"
r"(?=.*protocol-registry: foreign timer and exit binding refused)" r"(?=.*protocol-registry: foreign timer and exit binding refused)(?=.*protocol-registry: foreign receive refused)"
r"(?=.*protocol-registry: capability-carrying pings did not exhaust the harness)" r"(?=.*protocol-registry: capability-carrying pings did not exhaust the harness)"
r"(?=.*DANOS-TEST-RESULT: PASS)", r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|protocol-registry: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL|protocol-registry: FAIL"},
# Restriction stage one (docs/os-development/protocol-namespace.md): the
# registrar checks `open` against /system/configuration/protocol.csv, and a
# caller with no grant gets the same answer as a caller naming a contract
# nobody bound. The scenario boots /protocol plus the input service, so the
# forbidden name is genuinely BOUND — the fixture reads the namespace listing
# to prove it — and then compares the refusal with an unbound name field by
# field: status, node, payload length, the whole reply packet, and the
# presence of a capability. All three failure shapes (refused-and-bound,
# granted-and-unbound, neither) must collapse into one answer.
{"name": "protocol-denied",
"expect": r"(?s)(?=.*protocol-denied: granted open succeeded)"
r"(?=.*protocol-denied: ungranted open refused as absent)"
r"(?=.*protocol-denied: refusal is indistinguishable from absence)"
r"(?=.*protocol-denied: ok)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|protocol-denied: FAIL"},
# Device manager: a ring-3 service enumerates /system/devices, matches the PCI host # Device manager: a ring-3 service enumerates /system/devices, matches the PCI host
# bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn # bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn
# -> driver-up (the spawned pci-bus logs "<N> functions found"). # -> driver-up (the spawned pci-bus logs "<N> functions found").
@@ -0,0 +1,15 @@
//! The protocol-denied-test fixture as a binary package (docs/build-packages-plan.md):
//! this file names the binary and EXACTLY the modules its source imports —
//! build-support resolves each name from the domains this zon declares.
const std = @import("std");
const build_support = @import("build-support");
pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "protocol-denied-test",
.root_source_file = b.path("protocol-denied-test.zig"),
.imports = &.{ "channel", "envelope", "file-system", "ipc", "logging", "process", "time", "vfs-protocol" },
});
b.installArtifact(exe);
}
@@ -0,0 +1,19 @@
.{
.name = .protocol_denied_test,
.version = "0.0.0",
.fingerprint = 0xab37a6fa7116698f, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{
// build-support supplies the shared recipe; kernel is implicit in
// every binary (the root shim + link script live there). The rest
// are exactly the homes of this binary's declared imports.
.@"build-support" = .{ .path = "../../../../build-support" },
.kernel = .{ .path = "../../../../library/kernel" },
// envelope: the errno the registrar answers a refused open with;
// vfs-protocol: the request and reply this fixture compares byte for
// byte, which is why it speaks the wire itself instead of using the
// Channel client.
.protocol = .{ .path = "../../../../library/protocol" },
},
.paths = .{""},
}
@@ -0,0 +1,269 @@
//! protocol-denied-test — restriction stage one's own test fixture
//! (docs/os-development/protocol-namespace.md, "Restriction: per-process
//! namespaces, not ACLs"). One binary, one role, driven by the
//! `protocol-denied` kernel case:
//!
//! - `protocol-denied-test run` — the driver, and the assertions:
//! 1. a contract this binary IS granted opens: the reply says success and
//! carries a capability — the channel itself. The control comes first
//! and is repeated last, because every refusal below would read exactly
//! the same against a registrar that had simply stopped opening things;
//! 2. a contract this binary is NOT granted is refused — and `/protocol`'s
//! own listing is read first to prove the name is genuinely BOUND, so
//! the refusal is a policy decision and not an accident of boot order;
//! 3. the refusal is **indistinguishable from a name that does not exist**.
//! The fixture asks for a name nothing ever bound and compares the two
//! answers field by field — status, node, payload length, the whole
//! reply packet byte for byte, and the presence of a capability. That
//! collapse is the model, not a nicety: "permission denied" and "not
//! found" are one answer, so an open can never be used as an oracle for
//! what exists outside a process's view, and stage two's supervisor can
//! refuse, park for a human, or substitute a fake with the child unable
//! to tell which happened;
//! 4. the third shape — neither granted nor bound — answers identically
//! too, so all three collapse into one rather than two.
//!
//! **What this fixture deliberately does not claim.** Two channels are outside
//! what a ring-3 client can honestly assert:
//!
//! - *The registrar's log.* `klog_read` is ungated, so a line written on one
//! branch and not the other would be readable here — but the ring carries
//! every task's output, so searching it for a contract name proves nothing
//! either way (the input service prints "input:" lines of its own). The
//! guarantee is made at the source instead: `onOpen` in
//! system/services/init/init.zig writes nothing on any branch, and says why.
//! - *Timing.* The difference worth measuring — a syscall or a serial write on
//! one branch — is milliseconds, but this fixture shares four cores and a
//! serial line with a booting system, so a measurement here would be noise
//! wearing an assertion's clothes. `onOpen` asks both questions on every
//! open, whatever the first one answers; that is the claim, and it is a claim
//! about the code, checked by reading it.
//!
//! Prints `protocol-denied: ok` on success, or a `protocol-denied: FAIL` line
//! naming the step. Spawned bare (the initial-ramdisk sweep starts every bundled
//! binary), it exits silently so it cannot derange other tests.
const std = @import("std");
const channel = @import("channel");
const envelope = @import("envelope");
const file_system = @import("file-system");
const ipc = @import("ipc");
const logging = @import("logging");
const process = @import("process");
const time = @import("time");
const vfs_protocol = @import("vfs-protocol");
/// The contract this fixture provides and then reaches — under `/protocol/test`,
/// the subtree every `/test/` binary is granted. Binding it ourselves keeps the
/// granted case to one process: the registry does not know or care that the
/// provider on the other end of the channel is us.
const granted_contract = "test/denied-probe";
/// A contract this fixture is NOT granted and that the case makes sure IS bound:
/// the input service claims it, and only `input-source` and `input-test` are
/// named against it in /system/configuration/protocol.csv.
const forbidden_contract = "input";
/// A contract this fixture IS granted (the `test/*` subtree) and that nothing
/// ever binds. The comparison partner: refusal must look like this.
const absent_contract = "test/never-bound";
/// Neither granted nor bound. The third shape, so the collapse is into one
/// answer rather than two.
const forbidden_and_absent_contract = "display";
fn fail(step: []const u8) noreturn {
_ = logging.write("protocol-denied: FAIL ");
_ = logging.write(step);
_ = logging.write("\n");
process.exit(1);
}
// --- talking to the registrar directly --------------------------------------
//
// `channel.openEndpoint` folds every failure into null, which is exactly right
// for a client and useless here: the whole assertion is about the *shape* of the
// answer, so this fixture speaks the vfs protocol to the registry itself and
// keeps every byte that came back.
/// One `open` answer, kept whole.
const Answer = struct {
/// Bytes the registrar replied with — the reply packet's length is itself a
/// channel, so it is compared like any other field.
length: usize = 0,
packet: [vfs_protocol.message_maximum]u8 = .{0} ** vfs_protocol.message_maximum,
/// Whether a capability rode the reply. The one field that actually matters
/// to a client: the capability IS the channel.
capability: bool = false,
/// The reply header, decoded — compared field by field as well as byte for
/// byte, so a failure says *which* field diverged.
reply: vfs_protocol.Reply = .{ .status = 0, .node = 0, .len = 0 },
fn bytes(self: *const Answer) []const u8 {
return self.packet[0..self.length];
}
};
/// The registry's endpoint, obtained the way every process obtains it: resolve
/// `/protocol`. The handle is the kernel's, shared with every other user of the
/// mount, so it is never ours to close.
fn registryEndpoint() ?ipc.Handle {
// Patiently, for the same reason `bindPatiently` is patient: the kernel test
// harness starts the registrar and this fixture together, so a first resolve
// can land in the window before init has mounted `/protocol` at all. An
// absent mount is a boot race and worth waiting out; a registrar that
// answers has decided, and that answer is what the assertions below weigh.
var attempt: u32 = 0;
while (attempt < resolve_attempts) : (attempt += 1) {
var relative: [channel.path_maximum]u8 = undefined;
if (file_system.fsResolve(channel.root, 0, &relative)) |route| switch (route) {
.kernel => return null, // a kernel route means something other than the registry owns the name
.backend => |backend| return backend.handle,
};
time.sleepMillis(resolve_retry_ms);
}
return null;
}
/// The cadence `channel.bindPatiently` uses, for the same window.
const resolve_attempts: u32 = 200;
const resolve_retry_ms: u64 = 20;
/// One vfs-protocol request at the registry: the fixed header, then the contract
/// name inline. Names go bare (`input`, not `/input`) — the registrar normalises
/// both, and bare is what `bind` sends.
fn transact(registry: ipc.Handle, operation: vfs_protocol.Operation, name: []const u8, cursor: u64) ?Answer {
var request: [vfs_protocol.message_maximum]u8 = undefined;
if (vfs_protocol.request_size + name.len > request.len) return null;
const header = vfs_protocol.Request{
.operation = operation,
.node = 0,
.offset = cursor,
.len = @intCast(name.len),
.flags = 0,
};
@memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header));
@memcpy(request[vfs_protocol.request_size..][0..name.len], name);
var answer: Answer = .{};
const got = ipc.callCap(
registry,
request[0 .. vfs_protocol.request_size + name.len],
&answer.packet,
null,
) catch return null;
if (got.len < vfs_protocol.reply_size) return null;
answer.length = got.len;
answer.capability = got.cap != null;
answer.reply = std.mem.bytesToValue(vfs_protocol.Reply, answer.packet[0..vfs_protocol.reply_size]);
// A capability we did not ask to keep is a handle slot spent; the assertions
// below only care that one arrived.
if (got.cap) |handle| _ = ipc.close(handle);
return answer;
}
/// `open(name)`, kept whole. Null only if the registry could not be reached at
/// all — a registrar that answered has decided, and its decision is the subject.
fn openContract(registry: ipc.Handle, name: []const u8) Answer {
return transact(registry, .open, name, 0) orelse fail("the registry stopped answering");
}
/// Whether `/protocol` currently lists `name`. The namespace is browsable on
/// purpose (docs/os-development/protocol-namespace.md: `readdir` lists protocol
/// nodes like any others, so the tree stays diagnosable), and that is what lets
/// this fixture prove a refused name is really there — without it, "refused"
/// and "not bound yet" would be the same observation and the test would assert
/// nothing.
fn listed(registry: ipc.Handle, name: []const u8) bool {
var cursor: u64 = 0;
while (cursor < 64) : (cursor += 1) {
const answer = transact(registry, .readdir, "", cursor) orelse return false;
if (answer.reply.status != 0 or answer.reply.len == 0) return false; // end of directory
const payload = answer.packet[vfs_protocol.reply_size..answer.length];
if (payload.len < vfs_protocol.directory_entry_size) return false;
const entry = std.mem.bytesToValue(vfs_protocol.DirectoryEntry, payload[0..vfs_protocol.directory_entry_size]);
const text = payload[vfs_protocol.directory_entry_size..];
const length = @min(@as(usize, entry.name_len), text.len);
if (std.mem.eql(u8, text[0..length], name)) return true;
}
return false;
}
/// Wait until `/protocol` lists `name` — the providers this case needs come up
/// alongside the fixture, and racing them would make the assertions meaningless
/// rather than merely flaky.
fn awaitListed(registry: ipc.Handle, name: []const u8) void {
var attempts: u32 = 0;
while (attempts < 400) : (attempts += 1) {
if (listed(registry, name)) return;
time.sleepMillis(20);
}
_ = logging.write("protocol-denied: FAIL /protocol never listed ");
_ = logging.write(name);
_ = logging.write("\n");
process.exit(1);
}
/// Every caller-visible field of two answers, compared. `step` names the pair so
/// a failure says which comparison broke and in which field.
fn expectIdentical(step: []const u8, refused: Answer, absent: Answer) void {
if (refused.reply.status != absent.reply.status) fail(step); // the errno
if (refused.reply.node != absent.reply.node) fail(step); // the node id an open would return
if (refused.reply.len != absent.reply.len) fail(step); // payload bytes promised
if (refused.length != absent.length) fail(step); // reply packet length
if (refused.capability != absent.capability) fail(step); // the channel itself
if (!std.mem.eql(u8, refused.bytes(), absent.bytes())) fail(step); // and every byte of it
}
fn run() void {
const registry = registryEndpoint() orelse fail("resolve /protocol");
// Provide the granted contract ourselves. `bindPatiently` waits out a
// registry that has not mounted `/protocol` yet, which is the one thing
// worth retrying — a registrar that answered has decided.
const provider = ipc.createIpcEndpoint() orelse fail("create the provider endpoint");
if (!channel.bindPatiently(granted_contract, provider)) fail("binding a granted contract was refused");
// Both names must be bound before anything is asked of them, or the
// comparison below would be between two boot races.
awaitListed(registry, granted_contract);
awaitListed(registry, forbidden_contract);
// 1. The control. A granted, bound contract opens: success, and the
// capability that IS the channel.
const allowed = openContract(registry, granted_contract);
if (allowed.reply.status != 0) fail("a granted open was refused");
if (!allowed.capability) fail("a granted open carried no channel");
_ = logging.write("protocol-denied: granted open succeeded\n");
// 2. The refusal. `input` is bound — the listing above proved it — and no
// manifest row names this binary against it.
const refused = openContract(registry, forbidden_contract);
if (refused.reply.status != -envelope.ENOENT) fail("an ungranted open did not answer -ENOENT");
if (refused.capability) fail("an ungranted open carried a channel");
_ = logging.write("protocol-denied: ungranted open refused as absent\n");
// 3. The point of the whole fixture. A name this binary IS granted and that
// nothing has ever bound, answered by the same registrar in the same
// breath — and every caller-visible field of the two answers is the same.
const absent = openContract(registry, absent_contract);
expectIdentical("a refused open differed from a nonexistent name", refused, absent);
// 4. And the third shape, so the two reasons collapse into one answer rather
// than into two that happen to match: neither granted nor bound.
const neither = openContract(registry, forbidden_and_absent_contract);
expectIdentical("a refused-and-absent open differed from the others", refused, neither);
_ = logging.write("protocol-denied: refusal is indistinguishable from absence\n");
// 5. The control again, after the refusals: the registrar is still opening
// what it should, so what steps 2-4 saw was policy and not a registry
// that had wedged.
const again = openContract(registry, granted_contract);
if (again.reply.status != 0 or !again.capability) fail("the granted contract stopped opening");
_ = logging.write("protocol-denied: ok\n");
}
pub fn main(startup: process.Init) void {
const role = startup.arguments.get(1) orelse return; // bare (ramdisk sweep): stay silent
if (std.mem.eql(u8, role, "run")) run();
}
@@ -399,6 +399,22 @@ fn run() void {
if (!process.subscribeExits(ticker)) fail("subscribing an endpoint we created to exit events was refused"); if (!process.subscribeExits(ticker)) fail("subscribing an endpoint we created to exit events was refused");
_ = logging.write("protocol-registry: foreign timer and exit binding refused\n"); _ = logging.write("protocol-registry: foreign timer and exit binding refused\n");
// 9b. Receiving is the same privilege, and it was the one member of the family
// left unguarded. Every process holds a sendable handle to the registrar's
// mailbox — `fs_resolve` installs one for anyone who asks — and a stranger
// that could *dequeue* there would not merely evade the grants this fixture
// checks: it would take the provider endpoints that ride `bind` requests
// straight out of the queue, and answer other clients' opens in the
// registrar's name. Sending to it stays legal; receiving on it must not be.
// A refusal comes back as a negative errno in the length register, which
// is the whole point: the call returns instead of parking us on someone
// else's queue, where a success would have blocked until a request it was
// never ours to see arrived.
var stolen: [8]u8 = undefined;
const theft = ipc.replyWait(registry, stolen[0..0], &stolen, null);
if (theft.len <= ~@as(usize, 0) - 4095) fail("receiving on the registry's endpoint was allowed");
_ = logging.write("protocol-registry: foreign receive refused\n");
// 10. The handle-table storm again, aimed at a **harness-run service** this // 10. The handle-table storm again, aimed at a **harness-run service** this
// time. Step 4 covers PID 1, which runs its own hand-written loop; every // time. Step 4 covers PID 1, which runs its own hand-written loop; every
// other service in the system — the VFS, the display compositor, the device // other service in the system — the VFS, the display compositor, the device