From 1ff0991452a1ce45fe287a514fb0bded80c36a73 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:48:03 +0100 Subject: [PATCH 1/4] =?UTF-8?q?library:=20the=20envelope=20=E2=80=94=20one?= =?UTF-8?q?=20packet=20shape=20for=20every=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A protocol is now defined through envelope.Define rather than beside it. Every packet begins with the same 16-byte prefix: a Header of {operation, target} for a request or event, a Status for a reply. The header is folded into each protocol's own layout, never stacked on top of it — Define walks a spec's types and refuses one that carries a Header or Status field, so the rule cannot be missed by reading. Operations number from 16, leaving describe, enumerate, subscribe and unsubscribe reserved and answerable the same way everywhere; describe the envelope answers itself, and an unknown verb is -ENOSYS without a provider writing a line. The size floor becomes something protocols can compile against: 256 bytes for a call, 64 for a push, matching the kernel's own limits, checked at the Define call with the prefix counted in. A protocol that outgrows either fails the build with the reason and the remedy — shrink, or move the bulk to shared memory, because packets never fragment. Events carry the header too and number in their own space, so appending an operation can never renumber a shipped event. Beside it, the client half of the model: a Channel is what opening a /protocol name yields, wrapping the endpoint capability so a program holds a conversation rather than a handle. Nothing speaks through either yet — the vfs protocol gains NodeKind.protocol and the convention that an open reply may carry a capability, and that is the whole runtime change. Suite 107/107, unchanged: P1 converts nothing. --- docs/file-system-development/vfs-protocol.md | 17 +- docs/security-track-plan.md | 2 +- library/kernel/build.zig | 32 +- library/kernel/channel.zig | 220 +++++ library/kernel/file-system.zig | 1 + library/protocol/build.zig | 8 + library/protocol/envelope/envelope.zig | 915 +++++++++++++++++++ library/protocol/vfs/vfs-protocol.zig | 20 +- 8 files changed, 1209 insertions(+), 6 deletions(-) create mode 100644 library/kernel/channel.zig create mode 100644 library/protocol/envelope/envelope.zig diff --git a/docs/file-system-development/vfs-protocol.md b/docs/file-system-development/vfs-protocol.md index b65c3b9..8d0f818 100644 --- a/docs/file-system-development/vfs-protocol.md +++ b/docs/file-system-development/vfs-protocol.md @@ -161,15 +161,26 @@ Aligned to the node-kind table in the file-system hierarchy | 4 | symbolic link | | 5 | fifo | | 6 | socket | +| 7 | protocol | Clients should map unknown values to *regular* rather than reject — the table can grow. Kind 6 (`socket`) keeps its wire value but is retired from the design — a named rendezvous point is exactly what a `protocol` node is, -planned as value 7 with the protocol namespace +landed as value 7 with the protocol namespace (docs/os-development/protocol-namespace.md). `character_device` (stream semantics — the tty/console shape) and `block_device` (raw sector-addressed volumes, reserved) remain part of the design. +## An open reply may carry a capability + +`open` rides `ipc_call`, whose reply direction can hand back an endpoint +capability alongside the `Reply` header. A file backend never uses it — FAT +answers with a node id and nothing else — but a **synthetic** backend does: +opening a `protocol` node returns the provider's endpoint, and possession of +that endpoint *is* the channel. The convention is per-backend, not +per-operation, so a client that opens an ordinary file simply receives no +capability, exactly as before. + ## Lifetimes and trust Open-node ids live in the backend. A client that dies without closing leaks @@ -188,8 +199,8 @@ What a non-Zig implementation may rely on, and what it must not: - Operation values, flag bits, `NodeKind` values, and struct layouts are **append-only and frozen once shipped**. The unit test in `library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry` - size, `NodeKind` 0–1, `Operation` values 0, 4 and 5); this page is the - full record of the frozen values. + size, `NodeKind` 0–1 and 6–7, `Operation` values 0, 4 and 5); this page is + the full record of the frozen values. - The 256-byte message ceiling is a property of the current IPC transport, not a promise; clients should read `maximum_payload`-shaped limits from the reply lengths they actually get (loop-until-done), not hard-code 224. diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index e88ed5d..ea53744 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -35,7 +35,7 @@ the logging/USB-lifecycle track). - [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] **merge** group 1 → main, push (f3bc23c, 2026-07-31) -- [ ] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` +- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107) - [ ] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups) - [ ] **P3** — open grants: `protocol.csv` enforcement, denial test - [ ] **merge** group 2 → main, push diff --git a/library/kernel/build.zig b/library/kernel/build.zig index d5d4c6c..6025802 100644 --- a/library/kernel/build.zig +++ b/library/kernel/build.zig @@ -52,7 +52,7 @@ pub fn build(b: *std.Build) void { .{ .name = "time", .module = time }, }, }); - _ = b.addModule("file-system", .{ + const file_system = b.addModule("file-system", .{ .root_source_file = b.path("file-system.zig"), .imports = &.{ .{ .name = "abi", .module = abi }, @@ -61,6 +61,19 @@ pub fn build(b: *std.Build) void { .{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") }, }, }); + // The channel is the L1 concept made concrete (docs/os-development/communication.md): + // it needs the namespace (file-system, to resolve a /protocol name) and the + // transport (ipc) both, which is why it lives here rather than in a protocol + // module — those import nothing. + _ = b.addModule("channel", .{ + .root_source_file = b.path("channel.zig"), + .imports = &.{ + .{ .name = "ipc", .module = ipc }, + .{ .name = "file-system", .module = file_system }, + .{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") }, + .{ .name = "envelope", .module = protocol.module("envelope") }, + }, + }); _ = b.addModule("memory", .{ .root_source_file = b.path("memory/memory.zig"), .imports = &.{ @@ -101,4 +114,21 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(kernel_tests).step); } + + // channel needs its whole import set to compile at all; only its framing is + // host-runnable (the syscall seams are x86_64-only, and unreferenced from + // the tests), so that is what it tests. + const channel_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("channel.zig"), + .target = b.resolveTargetQuery(.{}), + .imports = &.{ + .{ .name = "ipc", .module = ipc }, + .{ .name = "file-system", .module = file_system }, + .{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") }, + .{ .name = "envelope", .module = protocol.module("envelope") }, + }, + }), + }); + test_step.dependOn(&b.addRunArtifact(channel_tests).step); } diff --git a/library/kernel/channel.zig b/library/kernel/channel.zig new file mode 100644 index 0000000..bcf9cf5 --- /dev/null +++ b/library/kernel/channel.zig @@ -0,0 +1,220 @@ +//! `Channel` — layer L1 of the communication stack +//! (docs/os-development/communication.md) made concrete. A program holds a +//! channel that speaks a protocol; it does not hold a raw handle and marshal +//! bytes at one. The channel is the answer to "who am I talking to", decided +//! once at establishment, so nothing after that ever routes a party again: +//! every packet's `target` addresses an *object* within the peer already chosen. +//! +//! **Possession of the Channel is the connection.** There is no connect step, no +//! session id, no reconnect handshake — the endpoint capability inside is the +//! whole of the relationship, and it cannot be forged, only handed over. Which +//! also means a channel is a resource: `close` it, or it occupies a handle-table +//! slot for the life of the process. +//! +//! **A dead provider surfaces as `-EPEER`, and the recovery is to re-open.** +//! When the process on the other end exits, the kernel fails calls on its +//! endpoint rather than blocking forever; `call` returns null. The client does +//! not repair the channel — it discards it and opens the name again, which +//! reaches whatever instance the registry now points at. The restart story +//! falls out of the naming layer for free; no protocol needs a reconnect verb. +//! +//! `open` resolves a `/protocol/` path through the kernel VFS router and +//! takes the provider's endpoint from the open reply's capability. **No registry +//! is mounted at `/protocol` yet** — init grows one in P2 +//! (docs/os-development/protocol-namespace.md) — so `open` cannot succeed today; +//! it is here so the client half of the conversation is written once, against +//! the shape the registry will answer with. + +const std = @import("std"); +const ipc = @import("ipc"); +const file_system = @import("file-system"); +const vfs_protocol = @import("vfs-protocol"); +const envelope = @import("envelope"); + +/// Longest `/protocol/...` path this client marshals. The registry's names are +/// short by construction (a contract leaf, not a file path), and the buffer is +/// on the stack of whoever opens. +pub const path_maximum: usize = 224; + +/// What a `call` came back with: the provider's status, the reply payload (the +/// bytes after the `Status`, in the caller's own buffer), and any capability the +/// reply carried. +pub const Response = struct { + status: envelope.Status, + payload: []u8, + capability: ?ipc.Handle, + + /// Whether the provider answered success. A negative status is its refusal + /// (`-ENOSYS` for a verb it does not implement, and so on). + pub fn succeeded(self: Response) bool { + return self.status.status == 0; + } +}; + +/// An open conversation with one provider, speaking one protocol. +pub const Channel = struct { + /// The provider's endpoint. Sending into it is the only thing this handle + /// can do — an endpoint is a mailbox owned by its creator, and that + /// direction never reverses. + endpoint: ipc.Handle, + + /// Adopt an endpoint that arrived some other way — a capability delivered + /// in a reply, or one a supervisor wired in at spawn time (P5). The channel + /// takes ownership of the handle. + pub fn adopt(endpoint: ipc.Handle) Channel { + return .{ .endpoint = endpoint }; + } + + /// Establish a channel by name: resolve `/protocol/` to the registry + /// backend, `open` the contract there, and take the provider's endpoint from + /// the reply's capability. Null if the path does not resolve, the registry + /// refuses (an ungranted name is refused *as* not-found), or the reply + /// carries no capability. + /// + /// The path is spoken exactly once, here. Everything afterwards is integers + /// in the packet header. + pub fn open(path: []const u8) ?Channel { + var relative: [path_maximum]u8 = undefined; + const route = file_system.fsResolve(path, 0, &relative) orelse return null; + // A protocol name must land on a mounted backend. The kernel-served + // `/system` tree answers with a node token and knows nothing of + // channels, so that route is simply the wrong path. + const backend = switch (route) { + .kernel => return null, + .backend => |b| b, + }; + // The registry handle is deduplicated by the kernel across resolves and + // shared with every other user of that mount, so it is not ours to + // close — only the provider endpoint below belongs to this channel. + const name = relative[0..backend.path_len]; + + var request: [vfs_protocol.message_maximum]u8 = undefined; + const header = vfs_protocol.Request{ + .operation = .open, + .node = 0, + .offset = 0, + .len = @intCast(name.len), + .flags = 0, + }; + if (vfs_protocol.request_size + name.len > request.len) return null; + @memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header)); + @memcpy(request[vfs_protocol.request_size..][0..name.len], name); + + var reply: [vfs_protocol.message_maximum]u8 = undefined; + const answer = ipc.callCap(backend.handle, request[0 .. vfs_protocol.request_size + name.len], &reply, null) catch return null; + if (answer.len < vfs_protocol.reply_size) return null; + const decoded = std.mem.bytesToValue(vfs_protocol.Reply, reply[0..vfs_protocol.reply_size]); + if (decoded.status != 0) return null; + // The capability *is* the channel — an open that succeeds without one + // was answered by a file backend, which does not speak protocols. + const provider = answer.cap orelse return null; + return .{ .endpoint = provider }; + } + + /// Send one request packet and block for the reply: `[Header][request]` out, + /// `[Status][reply]` back. `request` is the bytes *after* the header — the + /// protocol's fixed part plus any tail — because the header is this call's + /// to lay down. The reply's payload lands in `into`. + /// + /// Null means the transport failed, which today means one of: a dead + /// provider (`-EPEER` — discard this channel and `open` the name again), an + /// oversized packet, or a bad handle. A provider that answered *and refused* + /// is not a failure here: it comes back with a negative `Response.status`. + pub fn call(self: Channel, header: envelope.Header, request: []const u8, into: []u8) ?Response { + return self.callCapability(header, request, into, null); + } + + /// As `call`, handing the provider a capability with the request — the only + /// direction-crossing move kernel-ipc offers, and how `subscribe` delivers + /// the subscriber's own endpoint. + pub fn callCapability( + self: Channel, + header: envelope.Header, + request: []const u8, + into: []u8, + capability: ?ipc.Handle, + ) ?Response { + var packet: [envelope.packet_maximum]u8 = undefined; + const framed = frame(header, request, &packet) orelse return null; + + var reply: [envelope.packet_maximum]u8 = undefined; + const answer = ipc.callCap(self.endpoint, framed, &reply, capability) catch return null; + const status = envelope.statusOf(reply[0..answer.len]) orelse return null; + const available = @min(answer.len - envelope.prefix_size, @as(usize, status.len)); + const taken = @min(available, into.len); + @memcpy(into[0..taken], reply[envelope.prefix_size..][0..taken]); + return .{ .status = status, .payload = into[0..taken], .capability = answer.cap }; + } + + /// Push one event packet and return immediately — no reply owed, and a slow + /// or dead peer can never stall the sender. Bounded by `post_maximum`: an + /// event that does not fit is refused here rather than split, because a + /// packet is never fragmented. + pub fn send(self: Channel, header: envelope.Header, payload: []const u8) bool { + var packet: [envelope.post_maximum]u8 = undefined; + const framed = frame(header, payload, &packet) orelse return false; + return ipc.send(self.endpoint, framed); + } + + /// Ask the provider what it is: the reserved `describe` verb, answered by + /// every protocol built through `envelope.Define`. The name and version come + /// back in `into`, which the returned `Described` borrows. + pub fn describe(self: Channel, into: []u8) ?envelope.Described { + var request: [envelope.packet_maximum]u8 = undefined; + const packet = envelope.encodeDescribe(&request) orelse return null; + + var reply: [envelope.packet_maximum]u8 = undefined; + const answer = ipc.callCap(self.endpoint, packet, &reply, null) catch return null; + const taken = @min(answer.len, into.len); + @memcpy(into[0..taken], reply[0..taken]); + return envelope.decodeDescribe(into[0..taken]); + } + + /// Drop the provider's endpoint and free the handle-table slot. The + /// conversation is over the moment the capability is gone — there is nothing + /// else holding it open. + pub fn close(self: Channel) void { + _ = ipc.close(self.endpoint); + } +}; + +/// Lay a packet down: the folded header first, then the protocol's bytes. Null +/// when it would not fit the buffer — the same rule as `envelope`'s framing, +/// applied where the buffer is the transport's, not the protocol's. +fn frame(header: envelope.Header, body: []const u8, buffer: []u8) ?[]u8 { + const total = envelope.prefix_size + body.len; + if (total > buffer.len) return null; + @memcpy(buffer[0..envelope.prefix_size], std.mem.asBytes(&header)); + @memcpy(buffer[envelope.prefix_size..][0..body.len], body); + return buffer[0..total]; +} + +// --- tests ------------------------------------------------------------------ +// +// The syscall half cannot run on the host, and there is no registry to reach +// until P2 — so what is testable here is the framing, which is the part with +// arithmetic in it. + +const testing = std.testing; + +test "a framed packet is the header followed by the protocol's bytes" { + var buffer: [envelope.packet_maximum]u8 = undefined; + const header = envelope.Header{ .operation = envelope.first_protocol_operation, .target = 9 }; + const packet = frame(header, "body", &buffer).?; + + try testing.expectEqual(envelope.prefix_size + "body".len, packet.len); + const decoded = envelope.headerOf(packet).?; + try testing.expectEqual(envelope.first_protocol_operation, decoded.operation); + try testing.expectEqual(@as(u64, 9), decoded.target); + try testing.expectEqualStrings("body", packet[envelope.prefix_size..]); +} + +test "framing refuses a packet that would not fit rather than truncating it" { + var post: [envelope.post_maximum]u8 = undefined; + const header = envelope.Header{ .operation = envelope.first_protocol_operation }; + const body = [_]u8{0} ** (envelope.post_maximum - envelope.prefix_size); + const one_too_many = body ++ [_]u8{0}; + + try testing.expect(frame(header, &body, &post) != null); + try testing.expect(frame(header, &one_too_many, &post) == null); +} diff --git a/library/kernel/file-system.zig b/library/kernel/file-system.zig index 5f243ae..f23b37c 100644 --- a/library/kernel/file-system.zig +++ b/library/kernel/file-system.zig @@ -39,6 +39,7 @@ fn kindFromWire(value: u32) Kind { @intFromEnum(Kind.symbolic_link) => .symbolic_link, @intFromEnum(Kind.fifo) => .fifo, @intFromEnum(Kind.socket) => .socket, + @intFromEnum(Kind.protocol) => .protocol, else => .regular, }; } diff --git a/library/protocol/build.zig b/library/protocol/build.zig index 27a88e6..a167066 100644 --- a/library/protocol/build.zig +++ b/library/protocol/build.zig @@ -3,6 +3,10 @@ //! every conversation depend on the contract by name; neither reaches into the //! other's files. Pure flat wire types: no protocol module imports anything. //! +//! One module here is not a protocol but the shape the others are written in: +//! +//! envelope : the packet prefix + comptime Define (docs/os-development/protocol-namespace.md) +//! //! vfs-protocol : the VFS server <-> the file layer (unistd/stdio) //! input-protocol : the input fan-out service <-> sources + subscribers //! block-protocol : a filesystem <-> a block driver (usb-storage) @@ -16,6 +20,9 @@ const std = @import("std"); pub fn build(b: *std.Build) void { for ([_]struct { name: []const u8, root: []const u8 }{ + // Not a protocol, hence not `-protocol`: the envelope is what a + // protocol is defined *through*. + .{ .name = "envelope", .root = "envelope/envelope.zig" }, .{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" }, .{ .name = "input-protocol", .root = "input/input-protocol.zig" }, .{ .name = "block-protocol", .root = "block/block-protocol.zig" }, @@ -32,6 +39,7 @@ pub fn build(b: *std.Build) void { // its aggregate test step. const test_step = b.step("test", "Run the protocol unit tests"); for ([_][]const u8{ + "envelope/envelope.zig", // framing round trips, verb numbering, dispatch, the floors "vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values "display/display-protocol.zig", // pack(): native pixel encoding per format }) |root| { diff --git a/library/protocol/envelope/envelope.zig b/library/protocol/envelope/envelope.zig new file mode 100644 index 0000000..2611f80 --- /dev/null +++ b/library/protocol/envelope/envelope.zig @@ -0,0 +1,915 @@ +//! The envelope — the fixed prefix every danos packet begins with, and the +//! comptime `Define` that builds a protocol out of it. Layer L2 of +//! [communication.md](../../../docs/os-development/communication.md); the +//! authoritative description is +//! [protocol-namespace.md](../../../docs/os-development/protocol-namespace.md). +//! +//! This is the one module in the protocol domain that is not itself a protocol: +//! it is the shape every protocol is expressed in. A protocol module hands +//! `Define` its verbs and events and gets back numbered operations (never +//! colliding with the reserved range), typed encode/decode helpers, a +//! provider-side dispatch table that answers `describe` on its own — and, the +//! point of the exercise, compile-time proof that none of its packets can +//! exceed the transport floor. Errors that used to surface as runtime +//! truncation are compile errors, and "packets never fragment" is enforced at +//! the source rather than by review. +//! +//! **The prefix is folded, never stacked.** A `request`, `reply`, or `payload` +//! type names the bytes that follow the prefix — never the whole packet. The +//! verb and the object being addressed live in the prefix, so a protocol type +//! carries neither an `operation` field of its own nor a nested `Header`; +//! `rejectStacking` refuses one at compile time, and every size check adds +//! `prefix_size` exactly once. + +const std = @import("std"); + +// --- the prefix ------------------------------------------------------------- + +/// Every packet a danos protocol transmits begins with this header — requests +/// on the synchronous call path, event packets on the asynchronous push path. +/// A reply spends the same 16 bytes on `Status` instead. +pub const Header = extern struct { + /// The verb. Values below `first_protocol_operation` are the reserved + /// universal verbs, which mean the same thing in every protocol. + operation: u32, + _padding: u32 = 0, + /// **Object** addressing within the peer, never party addressing: which of + /// the peer's objects this packet operates on — a volume, a layer, a node, + /// a device. `0` addresses the provider itself, and a protocol with no + /// objects never uses the field. *Which* party is at the other end was + /// decided once, when the channel was opened, and *who sent this* is the + /// kernel-stamped badge; neither is ever written here, which is what keeps + /// the source unforgeable. + target: u64 = 0, +}; + +/// Every reply begins with this. `len` counts the bytes that follow: the +/// reply's fixed part plus whatever variable tail the operation defines. +pub const Status = extern struct { + status: i32, // 0, or a negative errno + _padding: u32 = 0, + len: u32 = 0, + _padding2: u32 = 0, +}; + +/// The fixed prefix every packet spends — `Header` on a request or an event, +/// `Status` on a reply. One constant, because the two are deliberately the same +/// width: the packet budget does not depend on the direction. +pub const prefix_size: usize = @sizeOf(Header); + +comptime { + if (@sizeOf(Header) != 16 or @sizeOf(Status) != 16) + @compileError("the envelope prefix is 16 bytes in both directions"); +} + +// --- the reserved verbs ----------------------------------------------------- + +/// Reserved verbs, answered by every provider. `Define` numbers a protocol's +/// own verbs from `first_protocol_operation`, so no protocol can reach in here. +pub const operation_describe: u32 = 0; // -> protocol name, version, target kinds +pub const operation_enumerate: u32 = 1; // -> the current targets, one per reply page +pub const operation_subscribe: u32 = 2; // capability = the subscriber's endpoint +pub const operation_unsubscribe: u32 = 3; +pub const first_protocol_operation: u32 = 16; + +/// The `describe` reply's fixed part, followed inline by `name_len` bytes of the +/// protocol's name. This is the version handshake: the version is asked for +/// once, at connect time, rather than re-carried by every packet out of a +/// 256-byte budget. +pub const Description = extern struct { + version: u32, + operation_count: u32, + event_count: u32, + name_len: u32, +}; + +/// Longest protocol name a `describe` reply can carry. +pub const name_maximum: usize = packet_maximum - prefix_size - @sizeOf(Description); + +// --- the transport floor ---------------------------------------------------- + +/// The packet budget every protocol may assume on *any* transport. These are +/// the kernel-ipc transport's limits — `MESSAGE_MAXIMUM` and `POST_MAXIMUM` in +/// system/kernel/ipc-synchronous.zig — restated here because the kernel keeps +/// them private and a protocol has to compile against something. A fatter +/// transport raises its own ceiling; the floor does not move, so a protocol +/// that fits here fits everywhere (communication.md: ceilings are transport +/// properties, the floor is the protocol's contract). +pub const packet_maximum: usize = 256; // one request or one reply (ipc_call) +pub const post_maximum: usize = 64; // one event packet (ipc_send) + +/// Whether a request or reply whose fixed part is `T` fits the call floor once +/// the prefix is counted. The folded rule in one line: `prefix_size` is added +/// exactly once, because `T` describes only what follows it. Exported so the +/// rule itself is testable — `Define` enforces it as a compile error. +pub fn fitsPacket(comptime T: type) bool { + return prefix_size + @sizeOf(T) <= packet_maximum; +} + +/// The same, against the much smaller push floor an event packet lives within. +pub fn fitsPost(comptime T: type) bool { + return prefix_size + @sizeOf(T) <= post_maximum; +} + +// --- reply statuses the envelope itself produces ----------------------------- + +/// Continued from the kernel's danos-native errno numbering +/// (system/kernel/ipc-synchronous.zig, which ends at `EPERM` = 9), so a client +/// reads one vocabulary whether the number came from the kernel or a provider. +/// Positive here, sent negated in `Status.status`, as the kernel spells it. +pub const ENOSYS: i32 = 10; // this protocol has no such operation +pub const EPROTO: i32 = 11; // malformed packet: shorter than the verb it names + +// --- framing ---------------------------------------------------------------- + +/// The header of a received packet, or null when it is too short to have one. +pub fn headerOf(packet: []const u8) ?Header { + if (packet.len < prefix_size) return null; + return std.mem.bytesToValue(Header, packet[0..prefix_size]); +} + +/// The status of a received reply, or null when it is too short to have one. +pub fn statusOf(packet: []const u8) ?Status { + if (packet.len < prefix_size) return null; + return std.mem.bytesToValue(Status, packet[0..prefix_size]); +} + +/// Frame a bare `describe` request. Protocol-independent: the reserved verbs +/// are asked the same way of every provider. +pub fn encodeDescribe(buffer: []u8) ?[]u8 { + const header = Header{ .operation = operation_describe }; + return frame(std.mem.asBytes(&header), &.{}, &.{}, buffer); +} + +/// A decoded `describe` reply: the fixed part, plus the name that follows it. +pub const Described = struct { + description: Description, + name: []const u8, +}; + +/// Decode a `describe` reply packet. Null if it failed, was truncated, or is +/// not a description at all. +pub fn decodeDescribe(packet: []const u8) ?Described { + const status = statusOf(packet) orelse return null; + if (status.status != 0) return null; + const body = packet[prefix_size..]; + if (body.len < @sizeOf(Description)) return null; + const description = std.mem.bytesToValue(Description, body[0..@sizeOf(Description)]); + const name = body[@sizeOf(Description)..]; + if (name.len < description.name_len) return null; + return .{ .description = description, .name = name[0..description.name_len] }; +} + +/// The single framing point: prefix, then the fixed part, then the variable +/// tail, contiguous in one buffer. Null when the packet would not fit — a +/// packet is never split, so not fitting is a failure, not a continuation. +fn frame(prefix: []const u8, fixed: []const u8, tail: []const u8, buffer: []u8) ?[]u8 { + const total = prefix.len + fixed.len + tail.len; + if (total > buffer.len) return null; + @memcpy(buffer[0..prefix.len], prefix); + @memcpy(buffer[prefix.len..][0..fixed.len], fixed); + @memcpy(buffer[prefix.len + fixed.len ..][0..tail.len], tail); + return buffer[0..total]; +} + +/// The bytes of a fixed part — empty for `void`, which is how an operation says +/// "nothing but the verb". +fn bytesOf(comptime T: type, value: *const T) []const u8 { + if (@sizeOf(T) == 0) return &.{}; + return @as([*]const u8, @ptrCast(value))[0..@sizeOf(T)]; +} + +/// Read a fixed part out of a packet body. A zero-sized part always succeeds +/// (there is nothing to be short of); anything else needs its full width. +fn valueOf(comptime T: type, body: []const u8) ?T { + if (@sizeOf(T) == 0) return @as(T, undefined); + if (body.len < @sizeOf(T)) return null; + return std.mem.bytesToValue(T, body[0..@sizeOf(T)]); +} + +// --- the specification ------------------------------------------------------ + +/// One verb of a protocol. `request` and `reply` describe the bytes *after* the +/// prefix; either may be `void`, meaning the verb (and its target) says it all. +pub const OperationSpecification = struct { + name: []const u8, + request: type = void, + reply: type = void, +}; + +/// One event a provider pushes to its subscribers. `payload` is the bytes after +/// the `Header`, and the whole packet must fit the push floor. +pub const EventSpecification = struct { + name: []const u8, + payload: type = void, +}; + +/// What `Define` is given: the contract, whole. +pub const Specification = struct { + /// The contract's name — the same word as its `/protocol/` leaf and + /// its `library/protocol/` module. + name: []const u8, + version: u32, + operations: []const OperationSpecification = &.{}, + events: []const EventSpecification = &.{}, +}; + +const reserved_names = [_][]const u8{ "describe", "enumerate", "subscribe", "unsubscribe" }; + +fn isReservedName(comptime name: []const u8) bool { + for (reserved_names) |reserved| { + if (std.mem.eql(u8, reserved, name)) return true; + } + return false; +} + +/// Refuse a protocol type that carries the prefix inside itself. The header is +/// folded into every packet, so a type that also holds one would send it twice +/// and re-invent per-protocol addressing — the mistake the envelope exists to +/// prevent. +fn rejectStacking(comptime protocol: []const u8, comptime verb: []const u8, comptime T: type) void { + switch (@typeInfo(T)) { + .@"struct" => |info| for (info.fields) |field| { + if (field.type == Header or field.type == Status) @compileError(std.fmt.comptimePrint( + "protocol '{s}', verb '{s}': the envelope prefix is folded, not stacked — " ++ + "drop the {s} field '{s}' and use the packet's own Header.operation / Header.target", + .{ protocol, verb, @typeName(field.type), field.name }, + )); + }, + else => {}, + } +} + +fn nullDefault(comptime T: type) *const anyopaque { + const empty: ?T = null; + return @ptrCast(&empty); +} + +// --- Define ----------------------------------------------------------------- + +/// Build a protocol from its specification. Everything below happens at compile +/// time; the generated type is what both sides of the conversation import. +/// +/// ```zig +/// pub const Protocol = envelope.Define(.{ +/// .name = "display", +/// .version = 1, +/// .operations = &.{ +/// .{ .name = "configure_layer", .request = ConfigureLayer, .reply = void }, +/// .{ .name = "blit", .request = Blit, .reply = void }, +/// }, +/// .events = &.{ +/// .{ .name = "layer_lost", .payload = LayerLost }, +/// }, +/// }); +/// ``` +/// +/// Refused at compile time, each with the protocol, the verb, and the numbers +/// named in the message: +/// +/// - a request or reply that does not fit `packet_maximum` once `prefix_size` +/// is added (`.request = extern struct { bytes: [241]u8 }` — 241 + 16 = 257); +/// - an event payload that does not fit `post_maximum` the same way +/// (`.payload = extern struct { bytes: [49]u8 }` — 49 + 16 = 65); +/// - a type that stacks the prefix instead of folding it (a `Header` field); +/// - a verb named after a reserved one, or named twice. +/// +/// A variable tail is bounded at *run* time instead, by `encodeRequest` and its +/// siblings, because only the caller knows how long it is. +pub fn Define(comptime specification: Specification) type { + comptime { + if (specification.name.len == 0) @compileError("a protocol needs a name"); + if (specification.name.len > name_maximum) @compileError(std.fmt.comptimePrint( + "protocol '{s}': the name is {d} bytes, and a describe reply carries at most {d}", + .{ specification.name, specification.name.len, name_maximum }, + )); + + for (specification.operations, 0..) |operation, index| { + if (isReservedName(operation.name)) @compileError(std.fmt.comptimePrint( + "protocol '{s}': '{s}' is a reserved universal verb — the envelope already answers it", + .{ specification.name, operation.name }, + )); + for (specification.operations[0..index]) |earlier| { + if (std.mem.eql(u8, earlier.name, operation.name)) @compileError(std.fmt.comptimePrint( + "protocol '{s}': operation '{s}' is declared twice", + .{ specification.name, operation.name }, + )); + } + rejectStacking(specification.name, operation.name, operation.request); + rejectStacking(specification.name, operation.name, operation.reply); + if (!fitsPacket(operation.request)) @compileError(std.fmt.comptimePrint( + "protocol '{s}', operation '{s}': the request is {d} bytes and the header {d}, " ++ + "over the {d}-byte call floor — packets never fragment, so this has to shrink " ++ + "or move its bulk to shared memory", + .{ specification.name, operation.name, @sizeOf(operation.request), prefix_size, packet_maximum }, + )); + if (!fitsPacket(operation.reply)) @compileError(std.fmt.comptimePrint( + "protocol '{s}', operation '{s}': the reply is {d} bytes and the status header {d}, " ++ + "over the {d}-byte call floor", + .{ specification.name, operation.name, @sizeOf(operation.reply), prefix_size, packet_maximum }, + )); + } + + for (specification.events, 0..) |event, index| { + for (specification.events[0..index]) |earlier| { + if (std.mem.eql(u8, earlier.name, event.name)) @compileError(std.fmt.comptimePrint( + "protocol '{s}': event '{s}' is declared twice", + .{ specification.name, event.name }, + )); + } + rejectStacking(specification.name, event.name, event.payload); + if (!fitsPost(event.payload)) @compileError(std.fmt.comptimePrint( + "protocol '{s}', event '{s}': the payload is {d} bytes and the header {d}, " ++ + "over the {d}-byte push floor — an event carries the header too, so it is the " ++ + "payload that has to give", + .{ specification.name, event.name, @sizeOf(event.payload), prefix_size, post_maximum }, + )); + } + } + + return struct { + pub const protocol_name: []const u8 = specification.name; + pub const version: u32 = specification.version; + + /// What a provider sizes its receive and reply buffers to. A packet's + /// fixed part may be far smaller, but any caller may send up to the + /// floor and a short buffer truncates rather than refuses. + pub const message_maximum: usize = packet_maximum; + + /// The widest packet this protocol's fixed parts can actually produce, + /// prefix included — a diagnostic, and what a test pins. + pub const request_maximum: usize = widest(specification.operations, .request); + pub const reply_maximum: usize = widest(specification.operations, .reply); + pub const event_maximum: usize = blk: { + var widest_event: usize = prefix_size; + for (specification.events) |event| widest_event = @max(widest_event, prefix_size + @sizeOf(event.payload)); + break :blk widest_event; + }; + + /// This protocol's verbs, numbered from `first_protocol_operation` in + /// declaration order. + pub const Operation = numbered(specification.operations, "name"); + + /// This protocol's events, numbered from `first_protocol_operation` in + /// their **own** space. Events travel only provider → subscriber over + /// `ipc_send` and operations only client → provider over `ipc_call`, so + /// the direction already tells the two apart; separate spaces mean + /// appending an operation can never renumber a shipped event. + pub const Event = numbered(specification.events, "name"); + + /// The bytes after the `Header` on a request for `operation`. + pub fn RequestOf(comptime operation: Operation) type { + return specification.operations[indexOf(@intFromEnum(operation))].request; + } + + /// The bytes after the `Status` on the reply to `operation`. + pub fn ReplyOf(comptime operation: Operation) type { + return specification.operations[indexOf(@intFromEnum(operation))].reply; + } + + /// The bytes after the `Header` on an `event` packet. + pub fn PayloadOf(comptime event: Event) type { + return specification.events[indexOf(@intFromEnum(event))].payload; + } + + // --- client side ---------------------------------------------------- + + /// Frame `[Header][request][tail]`. `tail` is the variable part (a path, + /// write bytes); pass `&.{}` when the verb has none. Null if the packet + /// would exceed the buffer or the call floor. + pub fn encodeRequest( + comptime operation: Operation, + target: u64, + request: RequestOf(operation), + tail: []const u8, + buffer: []u8, + ) ?[]u8 { + const header = Header{ .operation = @intFromEnum(operation), .target = target }; + const packet = frame(std.mem.asBytes(&header), bytesOf(RequestOf(operation), &request), tail, buffer) orelse return null; + return if (packet.len > packet_maximum) null else packet; + } + + /// Frame `[Status][reply][tail]` — the provider's answer, for a provider + /// that composes its own reply rather than using `Provider.dispatch`. + pub fn encodeReply( + comptime operation: Operation, + status: i32, + reply: ReplyOf(operation), + tail: []const u8, + buffer: []u8, + ) ?[]u8 { + const fixed = bytesOf(ReplyOf(operation), &reply); + const head = Status{ .status = status, .len = @intCast(fixed.len + tail.len) }; + const packet = frame(std.mem.asBytes(&head), fixed, tail, buffer) orelse return null; + return if (packet.len > packet_maximum) null else packet; + } + + /// Frame `[Header][payload]` for an asynchronous push. Null if it would + /// exceed the buffer or the push floor — an event that does not fit is + /// dropped at the source, never split. + pub fn encodeEvent( + comptime event: Event, + target: u64, + payload: PayloadOf(event), + buffer: []u8, + ) ?[]u8 { + const header = Header{ .operation = @intFromEnum(event), .target = target }; + const packet = frame(std.mem.asBytes(&header), bytesOf(PayloadOf(event), &payload), &.{}, buffer) orelse return null; + return if (packet.len > post_maximum) null else packet; + } + + /// Which of this protocol's verbs a packet names — null for a reserved + /// verb, or for a number this protocol does not define. + pub fn operationOf(packet: []const u8) ?Operation { + const header = headerOf(packet) orelse return null; + const index = header.operation -% first_protocol_operation; + if (header.operation < first_protocol_operation or index >= specification.operations.len) return null; + return @enumFromInt(header.operation); + } + + /// Which of this protocol's events a pushed packet carries. + pub fn eventOf(packet: []const u8) ?Event { + const header = headerOf(packet) orelse return null; + const index = header.operation -% first_protocol_operation; + if (header.operation < first_protocol_operation or index >= specification.events.len) return null; + return @enumFromInt(header.operation); + } + + /// The fixed request part of a packet already known to name `operation`. + pub fn decodeRequest(comptime operation: Operation, packet: []const u8) ?RequestOf(operation) { + if (packet.len < prefix_size) return null; + return valueOf(RequestOf(operation), packet[prefix_size..]); + } + + /// The bytes after the fixed request part — empty when there are none. + pub fn requestTail(comptime operation: Operation, packet: []const u8) []const u8 { + const start = prefix_size + @sizeOf(RequestOf(operation)); + return if (packet.len <= start) &.{} else packet[start..]; + } + + /// The fixed reply part of a reply packet. Null on a short packet; the + /// caller checks `statusOf(packet).status` for the provider's verdict. + pub fn decodeReply(comptime operation: Operation, packet: []const u8) ?ReplyOf(operation) { + if (packet.len < prefix_size) return null; + return valueOf(ReplyOf(operation), packet[prefix_size..]); + } + + /// The bytes after the fixed reply part, clipped to what `Status.len` + /// says actually arrived. + pub fn replyTail(comptime operation: Operation, packet: []const u8) []const u8 { + const status = statusOf(packet) orelse return &.{}; + const start = prefix_size + @sizeOf(ReplyOf(operation)); + const end = @min(packet.len, prefix_size + @as(usize, status.len)); + return if (end <= start) &.{} else packet[start..end]; + } + + /// The payload of a pushed packet already known to carry `event`. + pub fn decodeEvent(comptime event: Event, packet: []const u8) ?PayloadOf(event) { + if (packet.len < prefix_size) return null; + return valueOf(PayloadOf(event), packet[prefix_size..]); + } + + // --- provider side -------------------------------------------------- + + /// This protocol's dispatch table, bound to the provider's own state + /// type. `describe` is answered here, from the specification; every verb + /// this provider left null answers `-ENOSYS`, which is what makes the + /// reserved verbs mean the same thing at every provider in the system. + /// + /// ```zig + /// const Serve = Protocol.Provider(*Server); + /// const handlers = Serve.Handlers{ .blit = onBlit, .configure_layer = onConfigureLayer }; + /// const reply_len = Serve.dispatch(server, handlers, message, sender, capability, reply); + /// ``` + /// + /// A handler returns the number of `answer.tail()` bytes it wrote, or a + /// negative errno. + pub fn Provider(comptime Context: type) type { + return struct { + /// A reserved verb a provider chooses to implement itself. + /// `enumerate` writes its targets into the tail; `subscribe` + /// takes the subscriber's endpoint from `invocation.capability`. + pub const ReservedHandler = *const fn (Context, Invocation(void), Answer(void)) isize; + + /// One optional handler per verb, named exactly as the verb, + /// plus the reserved verbs the envelope cannot answer alone. + pub const Handlers = handlerTable(Context); + + /// Answer one received packet: writes `[Status][reply][tail]` + /// into `reply` and returns its length. Zero means the reply + /// buffer could not even hold a status, so nothing was written. + pub fn dispatch( + context: Context, + handlers: Handlers, + packet: []const u8, + sender: u32, + capability: ?usize, + reply: []u8, + ) usize { + if (reply.len < prefix_size) return 0; + const header = headerOf(packet) orelse return refuse(reply, -EPROTO); + const body = packet[prefix_size..]; + + if (header.operation == operation_describe) return describeInto(reply); + + inline for (specification.operations, 0..) |operation, index| { + if (header.operation == first_protocol_operation + index) { + return invoke( + Context, + operation.request, + operation.reply, + @field(handlers, operation.name), + context, + header.target, + body, + sender, + capability, + reply, + ); + } + } + + const reserved: ?ReservedHandler = switch (header.operation) { + operation_enumerate => handlers.enumerate, + operation_subscribe => handlers.subscribe, + operation_unsubscribe => handlers.unsubscribe, + else => null, + }; + return invoke(Context, void, void, reserved, context, header.target, body, sender, capability, reply); + } + }; + } + + // Shared by the protocol verbs and the reserved ones: decode, hand the + // handler a typed invocation, stamp the status. One place, so a reserved + // verb and a protocol verb behave identically. + fn invoke( + comptime Context: type, + comptime RequestType: type, + comptime ReplyType: type, + handler: ?*const fn (Context, Invocation(RequestType), Answer(ReplyType)) isize, + context: Context, + target: u64, + body: []const u8, + sender: u32, + capability: ?usize, + reply: []u8, + ) usize { + const call = handler orelse return refuse(reply, -ENOSYS); + const request = valueOf(RequestType, body) orelse return refuse(reply, -EPROTO); + if (reply.len < prefix_size + @sizeOf(ReplyType)) return refuse(reply, -EPROTO); + const produced = call(context, .{ + .target = target, + .request = request, + .tail = body[@min(@sizeOf(RequestType), body.len)..], + .sender = sender, + .capability = capability, + }, .{ .buffer = reply[prefix_size..] }); + if (produced < 0) return refuse(reply, @intCast(produced)); + return succeed(reply, @sizeOf(ReplyType) + @as(usize, @intCast(produced))); + } + + fn describeInto(reply: []u8) usize { + const description = Description{ + .version = specification.version, + .operation_count = specification.operations.len, + .event_count = specification.events.len, + .name_len = specification.name.len, + }; + const total = @sizeOf(Description) + specification.name.len; + if (reply.len < prefix_size + total) return refuse(reply, -EPROTO); + @memcpy(reply[prefix_size..][0..@sizeOf(Description)], std.mem.asBytes(&description)); + @memcpy(reply[prefix_size + @sizeOf(Description) ..][0..specification.name.len], specification.name); + return succeed(reply, total); + } + + // "describe" is answered by the envelope, so it is the one reserved verb + // with no slot in the table. + const implementable_reserved = [_][]const u8{ "enumerate", "subscribe", "unsubscribe" }; + + fn handlerTable(comptime Context: type) type { + const count = specification.operations.len + implementable_reserved.len; + var names: [count][]const u8 = undefined; + var types: [count]type = undefined; + var attributes: [count]std.builtin.Type.StructField.Attributes = undefined; + for (specification.operations, 0..) |operation, index| { + const Handler = *const fn (Context, Invocation(operation.request), Answer(operation.reply)) isize; + names[index] = operation.name; + types[index] = ?Handler; + attributes[index] = .{ .default_value_ptr = nullDefault(Handler) }; + } + const Reserved = *const fn (Context, Invocation(void), Answer(void)) isize; + for (implementable_reserved, 0..) |name, offset| { + const slot = specification.operations.len + offset; + names[slot] = name; + types[slot] = ?Reserved; + attributes[slot] = .{ .default_value_ptr = nullDefault(Reserved) }; + } + const frozen_names = names; + const frozen_types = types; + const frozen_attributes = attributes; + return @Struct(.auto, null, &frozen_names, &frozen_types, &frozen_attributes); + } + }; +} + +// --- the provider's view of one packet -------------------------------------- + +/// What a provider's handler is given. +pub fn Invocation(comptime RequestType: type) type { + return struct { + /// Object addressing within this provider — the packet's `Header.target`. + target: u64, + /// The fixed request part, already decoded. + request: RequestType, + /// The bytes after it: a path, write data, a name. + tail: []const u8, + /// The kernel-stamped badge of the caller. The only source identity + /// there is — no protocol defines a sender field — so per-client state + /// is keyed on this. + sender: u32, + /// A capability the call carried: a subscriber's endpoint, a DMA + /// region. Only the synchronous call path can move one. + capability: ?usize, + }; +} + +/// Where a provider's handler writes its answer. The `Status` in front of it is +/// the dispatcher's to stamp — a handler never writes its own. +pub fn Answer(comptime ReplyType: type) type { + return struct { + buffer: []u8, + + const fixed_size = @sizeOf(ReplyType); + + /// Write the fixed reply part. A `void` reply writes nothing. + pub fn set(self: @This(), reply: ReplyType) void { + if (fixed_size == 0) return; + @memcpy(self.buffer[0..fixed_size], bytesOf(ReplyType, &reply)); + } + + /// Room for the variable tail; the handler returns how much of it it used. + pub fn tail(self: @This()) []u8 { + return self.buffer[fixed_size..]; + } + }; +} + +fn refuse(reply: []u8, status: i32) usize { + const head = Status{ .status = status, .len = 0 }; + @memcpy(reply[0..prefix_size], std.mem.asBytes(&head)); + return prefix_size; +} + +fn succeed(reply: []u8, len: usize) usize { + const head = Status{ .status = 0, .len = @intCast(len) }; + @memcpy(reply[0..prefix_size], std.mem.asBytes(&head)); + return prefix_size + len; +} + +/// The widest `[prefix][fixed]` over a set of operations, on one side. +fn widest(comptime operations: []const OperationSpecification, comptime side: enum { request, reply }) usize { + var found: usize = prefix_size; + for (operations) |operation| { + const size = switch (side) { + .request => @sizeOf(operation.request), + .reply => @sizeOf(operation.reply), + }; + found = @max(found, prefix_size + size); + } + return found; +} + +/// An enum over `specifications`, tagged by their `name` field, numbered from +/// `first_protocol_operation` in declaration order. +fn numbered(comptime specifications: anytype, comptime field: []const u8) type { + var names: [specifications.len][]const u8 = undefined; + var values: [specifications.len]u32 = undefined; + for (specifications, 0..) |specification, index| { + names[index] = @field(specification, field); + values[index] = first_protocol_operation + index; + } + const frozen_names = names; + const frozen_values = values; + return @Enum(u32, .exhaustive, &frozen_names, &frozen_values); +} + +/// A verb's position in its declaration list, from its wire number. +fn indexOf(comptime operation: u32) usize { + return operation - first_protocol_operation; +} + +// --- tests ------------------------------------------------------------------ + +const testing = std.testing; + +const Produce = extern struct { count: u32, flags: u32 = 0 }; +const Produced = extern struct { total: u64 }; +const Changed = extern struct { kind: u32, value: u32 }; + +const Sample = Define(.{ + .name = "sample", + .version = 3, + .operations = &.{ + .{ .name = "produce", .request = Produce, .reply = Produced }, + .{ .name = "reset" }, + }, + .events = &.{ + .{ .name = "changed", .payload = Changed }, + }, +}); + +test "the prefix is 16 bytes in both directions" { + try testing.expectEqual(@as(usize, 16), @sizeOf(Header)); + try testing.expectEqual(@as(usize, 16), @sizeOf(Status)); + try testing.expectEqual(@as(usize, 16), prefix_size); + try testing.expectEqual(@as(usize, 256), packet_maximum); + try testing.expectEqual(@as(usize, 64), post_maximum); +} + +test "verb numbering skips the reserved range" { + try testing.expectEqual(@as(u32, 16), first_protocol_operation); + try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Operation.produce)); + try testing.expectEqual(@as(u32, 17), @intFromEnum(Sample.Operation.reset)); + // Events are numbered in their own space, so appending an operation can + // never renumber a shipped event. + try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Event.changed)); + for ([_]u32{ operation_describe, operation_enumerate, operation_subscribe, operation_unsubscribe }) |reserved| { + try testing.expect(reserved < first_protocol_operation); + } +} + +test "request round trip, header folded and tail carried" { + var buffer: [packet_maximum]u8 = undefined; + const packet = Sample.encodeRequest(.produce, 42, .{ .count = 7 }, "tail bytes", &buffer).?; + try testing.expectEqual(prefix_size + @sizeOf(Produce) + "tail bytes".len, packet.len); + + const header = headerOf(packet).?; + try testing.expectEqual(@as(u32, 16), header.operation); + try testing.expectEqual(@as(u64, 42), header.target); + try testing.expectEqual(Sample.Operation.produce, Sample.operationOf(packet).?); + + const request = Sample.decodeRequest(.produce, packet).?; + try testing.expectEqual(@as(u32, 7), request.count); + try testing.expectEqualStrings("tail bytes", Sample.requestTail(.produce, packet)); +} + +test "reply round trip" { + var buffer: [packet_maximum]u8 = undefined; + const packet = Sample.encodeReply(.produce, 0, .{ .total = 99 }, "more", &buffer).?; + const status = statusOf(packet).?; + try testing.expectEqual(@as(i32, 0), status.status); + try testing.expectEqual(@as(u32, @sizeOf(Produced) + "more".len), status.len); + try testing.expectEqual(@as(u64, 99), Sample.decodeReply(.produce, packet).?.total); + try testing.expectEqualStrings("more", Sample.replyTail(.produce, packet)); +} + +test "a void request and reply carry nothing but the verb" { + var buffer: [packet_maximum]u8 = undefined; + const packet = Sample.encodeRequest(.reset, 0, {}, &.{}, &buffer).?; + try testing.expectEqual(prefix_size, packet.len); + try testing.expectEqual(Sample.Operation.reset, Sample.operationOf(packet).?); + try testing.expectEqual(@as(usize, 0), Sample.requestTail(.reset, packet).len); +} + +test "event round trip within the push floor" { + var buffer: [post_maximum]u8 = undefined; + const packet = Sample.encodeEvent(.changed, 0, .{ .kind = 1, .value = 2 }, &buffer).?; + try testing.expectEqual(prefix_size + @sizeOf(Changed), packet.len); + try testing.expect(packet.len <= post_maximum); + try testing.expectEqual(Sample.Event.changed, Sample.eventOf(packet).?); + try testing.expectEqual(@as(u32, 2), Sample.decodeEvent(.changed, packet).?.value); +} + +// A provider over a trivial context, to drive the generated dispatch table. +const Counter = struct { + total: u64 = 0, + + fn onProduce(self: *Counter, invocation: Invocation(Produce), answer: Answer(Produced)) isize { + self.total += invocation.request.count; + answer.set(.{ .total = self.total }); + const note = "counted"; + @memcpy(answer.tail()[0..note.len], note); + return note.len; + } +}; + +const CounterProvider = Sample.Provider(*Counter); + +test "dispatch reaches a handler and stamps the status" { + var counter = Counter{}; + const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce }; + + var request: [packet_maximum]u8 = undefined; + const packet = Sample.encodeRequest(.produce, 0, .{ .count = 5 }, &.{}, &request).?; + var reply: [packet_maximum]u8 = undefined; + const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply); + + const answered = reply[0..len]; + try testing.expectEqual(@as(i32, 0), statusOf(answered).?.status); + try testing.expectEqual(@as(u64, 5), Sample.decodeReply(.produce, answered).?.total); + try testing.expectEqualStrings("counted", Sample.replyTail(.produce, answered)); +} + +test "describe is answered by the envelope, not the provider" { + var counter = Counter{}; + const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce }; + + var request: [packet_maximum]u8 = undefined; + const packet = encodeDescribe(&request).?; + var reply: [packet_maximum]u8 = undefined; + const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply); + + const described = decodeDescribe(reply[0..len]).?; + try testing.expectEqualStrings("sample", described.name); + try testing.expectEqual(@as(u32, 3), described.description.version); + try testing.expectEqual(@as(u32, 2), described.description.operation_count); + try testing.expectEqual(@as(u32, 1), described.description.event_count); +} + +test "an unimplemented or unknown verb answers -ENOSYS" { + var counter = Counter{}; + const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce }; + var reply: [packet_maximum]u8 = undefined; + + // A verb this protocol declares but this provider left null. + var request: [packet_maximum]u8 = undefined; + const declared = Sample.encodeRequest(.reset, 0, {}, &.{}, &request).?; + var len = CounterProvider.dispatch(&counter, handlers, declared, 3, null, &reply); + try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status); + + // A number no verb of this protocol wears. + const stranger = Header{ .operation = first_protocol_operation + 900 }; + len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&stranger), 3, null, &reply); + try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status); + + // A reserved verb the provider does not implement answers the same way. + const enumerate = Header{ .operation = operation_enumerate }; + len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&enumerate), 3, null, &reply); + try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status); +} + +test "a truncated packet answers -EPROTO" { + var counter = Counter{}; + const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce }; + var reply: [packet_maximum]u8 = undefined; + + // Names `produce`, but stops before the request it promises. + const header = Header{ .operation = @intFromEnum(Sample.Operation.produce) }; + const len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&header), 3, null, &reply); + try testing.expectEqual(@as(i32, -EPROTO), statusOf(reply[0..len]).?.status); +} + +// The size rule, exercised directly. `Define` turns exactly these predicates +// into compile errors, which a test cannot catch — so the predicate is what the +// test pins, and the boundary protocol below proves the compile-time half from +// the other side. The negative example, spelled out: giving `Define` an +// operation with `.request = extern struct { bytes: [241]u8 }`, or an event with +// `.payload = extern struct { bytes: [49]u8 }`, fails to compile with the +// protocol, the verb, and the two numbers named in the message. +test "the floor counts the header once, and the boundary is exact" { + try testing.expect(fitsPacket(extern struct { bytes: [240]u8 })); + try testing.expect(!fitsPacket(extern struct { bytes: [241]u8 })); + try testing.expect(fitsPost(extern struct { bytes: [48]u8 })); + try testing.expect(!fitsPost(extern struct { bytes: [49]u8 })); + try testing.expect(fitsPacket(void)); + try testing.expect(fitsPost(void)); +} + +const WidestRequest = extern struct { bytes: [packet_maximum - prefix_size]u8 }; +const WidestEvent = extern struct { bytes: [post_maximum - prefix_size]u8 }; + +// A protocol sitting exactly on both floors. That this compiles at all is the +// positive half of the compile-time check. +const Boundary = Define(.{ + .name = "boundary", + .version = 1, + .operations = &.{.{ .name = "fill", .request = WidestRequest, .reply = WidestRequest }}, + .events = &.{.{ .name = "filled", .payload = WidestEvent }}, +}); + +test "a protocol may sit exactly on the floor" { + try testing.expectEqual(packet_maximum, Boundary.request_maximum); + try testing.expectEqual(packet_maximum, Boundary.reply_maximum); + try testing.expectEqual(post_maximum, Boundary.event_maximum); + + var buffer: [packet_maximum]u8 = undefined; + const packet = Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0xAB) }, &.{}, &buffer).?; + try testing.expectEqual(packet_maximum, packet.len); + try testing.expectEqual(@as(u8, 0xAB), Boundary.decodeRequest(.fill, packet).?.bytes[239]); + + // One byte of tail past the floor is refused at run time, not truncated. + try testing.expect(Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0) }, "x", &buffer) == null); + + var post: [post_maximum]u8 = undefined; + const event = Boundary.encodeEvent(.filled, 0, .{ .bytes = @splat(1) }, &post).?; + try testing.expectEqual(post_maximum, event.len); +} + +test "a protocol's own sizes are reported prefix-included" { + try testing.expectEqual(prefix_size + @sizeOf(Produce), Sample.request_maximum); + try testing.expectEqual(prefix_size + @sizeOf(Produced), Sample.reply_maximum); + try testing.expectEqual(prefix_size + @sizeOf(Changed), Sample.event_maximum); + try testing.expectEqual(packet_maximum, Sample.message_maximum); + try testing.expectEqualStrings("sample", Sample.protocol_name); +} diff --git a/library/protocol/vfs/vfs-protocol.zig b/library/protocol/vfs/vfs-protocol.zig index fe48fb6..c22293a 100644 --- a/library/protocol/vfs/vfs-protocol.zig +++ b/library/protocol/vfs/vfs-protocol.zig @@ -13,8 +13,17 @@ //! written against has retired — path routing moved into the kernel (system/kernel/vfs.zig, //! fs_resolve) — but the protocol module outlived it. +//! **An `open` reply may carry a capability.** The vfs `open` request rides +//! `ipc_call`, and the reply direction of a call can hand back an endpoint +//! (`ipc.callCap`'s `Reply.cap`). A file backend never uses it — FAT answers +//! with a node id and nothing else — but a *synthetic* backend does: opening a +//! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint, +//! which is the channel (docs/os-development/protocol-namespace.md). The +//! convention is per-backend, not per-operation: a client that did not ask a +//! synthetic backend simply gets no capability back, exactly as today. + pub const Operation = enum(u32) { - open, // open(path) -> node id + open, // open(path) -> node id (a synthetic backend may reply with a capability instead) close, // close(node) read, // read(node, offset, len) -> bytes write, // write(node, offset, bytes) -> count @@ -44,6 +53,12 @@ pub const NodeKind = enum(u32) { symbolic_link = 4, fifo = 5, socket = 6, + /// A node that names a *contract*, not a file: opening it establishes a + /// channel to whatever process currently provides that protocol, delivered + /// as an endpoint capability in the reply rather than a node id. This is + /// what lives under `/protocol`; `readdir` lists these like any other node, + /// so the tree stays browsable for diagnosis. + protocol = 7, }; /// One directory entry, returned by `readdir`: a fixed header followed inline in @@ -109,6 +124,9 @@ test "protocol struct sizes and node kinds" { const std = @import("std"); try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular)); try std.testing.expectEqual(@as(u32, 1), @intFromEnum(NodeKind.directory)); + // Appended with the protocol namespace; every earlier value keeps its own. + try std.testing.expectEqual(@as(u32, 6), @intFromEnum(NodeKind.socket)); + try std.testing.expectEqual(@as(u32, 7), @intFromEnum(NodeKind.protocol)); try std.testing.expectEqual(@as(usize, 16), @sizeOf(DirectoryEntry)); // The appended operations keep the original values. try std.testing.expectEqual(@as(u32, 0), @intFromEnum(Operation.open)); From 1379b699f3426fb3cbe4ae3cd60151429084e7e2 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:39:07 +0100 Subject: [PATCH 2/4] init: /protocol replaces the ServiceId registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A protocol is reached by name now, not by a compile-time integer. Init is PID 1 and already knows which binary it started, so init serves /protocol as a vfs backend: bind claims a contract with the provider's endpoint attached, open answers with that endpoint as the reply's capability, and readdir lists what is bound with the task and binary behind it. The kernel reserves the prefix — nothing may mount over it, under it, or unmount it — and ServiceId, ipc_register and ipc_lookup are gone, their syscall numbers left vacant. A bind is authorized by who the caller *is*: the kernel-stamped binary together with the supervising task's identity, matched against /system/configuration/protocol.csv. Identity, not spelling — spawn is ungated, so an attacker can run any bundled binary, and a name-only rule would have let it launder grants through an init of its own making. A name a live process holds is refused to everyone else; a dead one's is released. Three review rounds against a hostile ring-3 process found what 108 green tests could not, because the suite contains no attacker. Publishing init's supervision endpoint as the registry put PID 1's mailbox in every process's hands, where two forged bytes reached the shutdown path: privileged traffic is now believed only from the task that holds the contract it speaks for. A capability arriving on a request outlived every path that ignored it, one handle per call until the table was full — in init, and in the harness ten services share — so the arriving capability is owned by the turn and released unless a handler says otherwise. And the kernel let anyone holding an endpoint handle aim signals, timers, exit notices and interrupts at it: binding now requires having created it. Suite 108/108. The new protocol-registry case asserts eleven properties, each one an attack that must fail. --- build.zig | 6 + build.zig.zon | 1 + docs/device-driver-development/display.md | 4 +- docs/device-driver-development/ipc.md | 28 +- docs/os-development/discovery.md | 4 +- docs/os-development/power.md | 6 +- docs/os-development/threading-plan.md | 2 +- docs/os-development/threading.md | 14 +- docs/os-development/vdso.md | 6 +- docs/security-track-plan.md | 22 +- library/client/build.zig | 6 + library/client/display/display-client.zig | 9 +- library/client/input/input-client.zig | 10 +- library/device/block/block.zig | 9 +- library/device/build.zig | 7 + library/device/driver/driver.zig | 3 +- library/device/usb/usb.zig | 11 +- library/kernel/build.zig | 9 +- library/kernel/channel.zig | 201 ++++- library/kernel/ipc.zig | 65 +- library/kernel/process.zig | 12 + library/kernel/service.zig | 66 +- library/protocol/envelope/envelope.zig | 9 + library/protocol/power/power-protocol.zig | 8 +- library/protocol/vfs/vfs-protocol.zig | 9 + system/abi.zig | 33 +- system/configuration/protocol.csv | 70 ++ system/drivers/pci-bus/pci-bus.zig | 4 +- system/drivers/ps2-bus/build.zig | 10 +- system/drivers/ps2-bus/keyboard.zig | 7 +- system/drivers/ps2-bus/mouse.zig | 7 +- system/drivers/ps2-bus/ps2-bus.zig | 38 +- system/drivers/usb-storage/usb-storage.zig | 13 +- system/drivers/usb-xhci-bus/build.zig | 7 +- system/drivers/usb-xhci-bus/usb-xhci-bus.zig | 55 +- system/drivers/virtio-gpu/build.zig | 4 +- system/drivers/virtio-gpu/virtio-gpu.zig | 9 +- system/kernel/ipc-synchronous.zig | 115 ++- system/kernel/irq.zig | 4 +- system/kernel/process.zig | 91 ++- system/kernel/tests.zig | 133 +++- system/kernel/vfs.zig | 33 + system/services/acpi/acpi.zig | 31 +- system/services/acpi/build.zig | 4 +- .../device-manager/device-manager.zig | 16 +- system/services/display/build.zig | 4 +- system/services/display/display.zig | 30 +- system/services/fat/fat.zig | 17 +- system/services/init/build.zig | 4 +- system/services/init/init.zig | 728 ++++++++++++++++-- system/services/input/build.zig | 2 +- system/services/input/input.zig | 40 +- system/services/logger/logger.zig | 4 +- test/qemu_test.py | 27 + test/system/services/crash-test/build.zig | 2 +- .../system/services/crash-test/crash-test.zig | 3 +- test/system/services/device-list/build.zig | 2 +- .../services/device-list/device-list.zig | 3 +- test/system/services/process-test/build.zig | 2 +- .../services/process-test/process-test.zig | 14 +- .../services/protocol-registry-test/build.zig | 15 + .../protocol-registry-test/build.zig.zon | 17 + .../protocol-registry-test.zig | 444 +++++++++++ .../services/shared-memory-client/build.zig | 2 +- .../shared-memory-client.zig | 3 +- .../shared-memory-server.zig | 9 +- 66 files changed, 2191 insertions(+), 392 deletions(-) create mode 100644 system/configuration/protocol.csv create mode 100644 test/system/services/protocol-registry-test/build.zig create mode 100644 test/system/services/protocol-registry-test/build.zig.zon create mode 100644 test/system/services/protocol-registry-test/protocol-registry-test.zig diff --git a/build.zig b/build.zig index 49b83ec..493ce99 100644 --- a/build.zig +++ b/build.zig @@ -311,6 +311,11 @@ pub fn build(b: *std.Build) void { const init_csv_source = if (diagnose) "system/configuration/init-diagnose.csv" else "system/configuration/init.csv"; bundled_list.append(b.allocator, .{ .path = "system/configuration/devices.csv", .binary = b.path("system/configuration/devices.csv") }) catch @panic("OOM"); bundled_list.append(b.allocator, .{ .path = "system/configuration/init.csv", .binary = b.path(init_csv_source) }) catch @panic("OOM"); + // The protocol grants: who may claim which name under /protocol + // (docs/os-development/protocol-namespace.md). init reads it beside init.csv, + // out of the same read-only initrd, before it spawns anything — the registrar + // has to know its policy before the first provider asks. + bundled_list.append(b.allocator, .{ .path = "system/configuration/protocol.csv", .binary = b.path("system/configuration/protocol.csv") }) catch @panic("OOM"); // A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the // production set only. The userspace test fixtures under /test join in only // for a test build — which the QEMU harness signals by passing @@ -333,6 +338,7 @@ pub fn build(b: *std.Build) void { "process-test", "thread-test", // the multi-threaded fixture (its package sets .threaded) "user-memory-test", // aims deliberately bad user pointers at the checked copy layer + "protocol-registry-test", // drives the registrar: ungranted bind, collision, restart }) |fixture| { const package = b.lazyDependency(fixture, .{}) orelse @panic("a test fixture package is missing under test/system/services"); diff --git a/build.zig.zon b/build.zig.zon index c6a19b9..5c01bad 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -75,6 +75,7 @@ .@"process-test" = .{ .path = "test/system/services/process-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 }, + .@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true }, // See `zig fetch --save ` for a command-line interface for adding dependencies. //.example = .{ // // When updating this field to a new URL, be sure to delete the corresponding diff --git a/docs/device-driver-development/display.md b/docs/device-driver-development/display.md index 05b6b54..faf06ec 100644 --- a/docs/device-driver-development/display.md +++ b/docs/device-driver-development/display.md @@ -87,13 +87,13 @@ rest of the system hasn't had to face: │ (ResourceKind.memory = [base, height*pitch], write-combining hint, │ plus DisplayInfo{width, height, pitch, format, refresh_hz}) ▼ - display service (system/services/display/, ServiceId.display) ← the compositor + display service (system/services/display/, /protocol/display) ← the compositor │ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB) │ mmap(cacheable) a BACK buffer of the same geometry │ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid) │ loop: composite dirty layers → back buffer → present dirty rects → front │ backend is an INTERNAL interface: {gop-fb} at boot; {virtio-gpu} on hot-attach (v2) - ▼ reached by name (ipc_lookup); clients drive it over the display protocol + ▼ reached by name (open /protocol/display); clients drive it over the display protocol ┌────────────────────────────────────┬──────────────────────────────────────┐ drawing clients (v1) surface clients (deferred) display commands: display surfaces: diff --git a/docs/device-driver-development/ipc.md b/docs/device-driver-development/ipc.md index 86274c4..2ad3381 100644 --- a/docs/device-driver-development/ipc.md +++ b/docs/device-driver-development/ipc.md @@ -114,9 +114,10 @@ unforgeable source addressing, a property a network's source field lacks. The bootstrap problem — how a channel is first established — is the subject of [protocol-namespace.md](../os-development/protocol-namespace.md): a protocol is -resolved by name and the channel arrives as a capability. (The mechanism this -replaces, `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers, -is retired by that design.) +resolved by name and the channel arrives as a capability. (The mechanism it +replaced — `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers — +is gone: both syscalls and the enum were deleted when the registry landed, and +their syscall numbers are left vacant.) ### Interrupts are signals @@ -169,6 +170,27 @@ signal mechanism: - **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a timer-bit signal — the timed wait: a service arms a deadline and keeps serving, instead of blocking in sleep. +- **Kernel notifications go only to your own endpoint.** `signal_bind`, + `timer_bind`, `process_subscribe`, `irq_bind`, `msi_bind`, and spawn's exit + endpoint all *nominate where the kernel will speak*, and all of them refuse an + endpoint the caller did not create (`-EPERM`; the check is `ipc.ownedBy`, + normalized to the process, so any thread may nominate an endpoint a sibling + created). Holding a handle is not enough, because holding a handle is cheap: + `fs_resolve` installs a mounted backend's capability in *any* caller's table, + so every process holds a handle to PID 1's mailbox. Without the rule, "bind + init's endpoint, then signal yourself" is a genuine, kernel-stamped `terminate` + badge in PID 1's queue — a shutdown a receiver has no way to disbelieve — and + timers, which carry no identity at all, multiply any loop that re-arms on its + own landing. +- **A capability that arrives belongs to the turn.** The kernel installs a sent + capability in the receiver's table whatever the message's length or kind, so a + receive loop must dispose of one on *every* path — the ping, the notification, + the malformed request. The service harness (`service.run`) and PID 1 both hold + it in an `ipc.Arrival`, released by a `defer`, and a handler that means to keep + it says `take()`: forgetting closes, keeping is explicit. The reverse + arrangement leaks a handle-table slot per request, and thirty-two unauthorized + zero-length pings then end a service's ability to accept any capability — + no subscribe, no shared-memory handover — for the rest of the boot. - **The universal ping**: a **zero-length request is the liveness probe**, answered with a zero-length reply by the service harness itself (`service.run`). No protocol's requests start at length zero, so the diff --git a/docs/os-development/discovery.md b/docs/os-development/discovery.md index 4e3fae2..75845c7 100644 --- a/docs/os-development/discovery.md +++ b/docs/os-development/discovery.md @@ -231,8 +231,8 @@ Two consequences of neutrality bind on later work: - **Cross-firmware surfaces are named by domain, not firmware.** System power is a [`power`](power.md) protocol, not an "ACPI events" protocol: on x86 the acpi - service registers it, on ARM a PSCI/mailbox service registers the same - `ServiceId.power`, and subscribers never learn the difference. + service binds it, on ARM a PSCI/mailbox service binds the same + `/protocol/power`, and subscribers never learn the difference. - **Identity must widen before the fdt service exists.** `DeviceDescriptor`'s 8-byte `hid` holds an EISA id but cannot hold an FDT `compatible` string (`"brcm,bcm2835-aux-uart"`); the identity field grows before the ARM path can diff --git a/docs/os-development/power.md b/docs/os-development/power.md index 06df35b..968328f 100644 --- a/docs/os-development/power.md +++ b/docs/os-development/power.md @@ -15,9 +15,9 @@ Where the events come from is firmware-specific — on x86 they ride the ACPI SC ([acpi.md](acpi.md)); on a Raspberry Pi they would come from PSCI or a mailbox. What subscribers want is not: *the lid closed* means the same thing regardless of who noticed. So the surface is **domain-named**. There is a `power-protocol` -module and a well-known `ServiceId.power = 5`; on x86 the **acpi service** -registers it, and on ARM a PSCI/mailbox service will register the *same* id. -Subscribers call `ipc.lookup(.power)` and never learn which firmware they +module and a contract named `/protocol/power`; on x86 the **acpi service** +binds it, and on ARM a PSCI/mailbox service will bind the *same* name. +Subscribers open `/protocol/power` and never learn which firmware they are on — the neutrality the whole [discovery](discovery.md) migration exists to preserve, carried one layer up into a running-system surface. diff --git a/docs/os-development/threading-plan.md b/docs/os-development/threading-plan.md index 09180e6..153278a 100644 --- a/docs/os-development/threading-plan.md +++ b/docs/os-development/threading-plan.md @@ -27,7 +27,7 @@ packages whose build.zig calls `build_support.userBinary` (with `.threaded = true` where a binary spawns threads) and get packed into the initial-ramdisk; new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a `library/kernel` wrapper; test services live beside the code they exercise and -register a `ServiceId` if they must be looked up. +bind a `/protocol/test/...` name if they must be reachable. ## How to verify along the way diff --git a/docs/os-development/threading.md b/docs/os-development/threading.md index 5b7d3a7..ab004ac 100644 --- a/docs/os-development/threading.md +++ b/docs/os-development/threading.md @@ -270,13 +270,15 @@ stays single-threaded and lean. - *Handles do not cross threads.* The handle table lives on the `Task` ([scheduler.zig](../../system/kernel/scheduler.zig)), so a handle number is meaningful only to the thread that created it — thread A's endpoint handle `3` is not thread B's. - A thread that needs to reach an endpoint another thread owns looks it up - (`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint. - This is how the display's mouse-listener thread reaches the compositor loop's endpoint - to poke it awake (docs/display.md). + A thread that needs to reach an endpoint another thread owns opens the name + (`channel.openEndpoint("display")`) to install its **own** handle to the same + underlying endpoint — an ordinary client open, with no special mechanism for the + fact that the provider happens to be this process. This is how the display's + mouse-listener thread reaches the compositor loop's endpoint to poke it awake + (docs/display.md). - *IPC syscalls that touch shared kernel state now serialize under the big kernel lock.* - `create_ipc_endpoint`/`ipc_register`/`ipc_lookup` allocate from the kernel heap and - mutate the global service registry, endpoint refcounts, and handle tables. Those paths + `create_ipc_endpoint` allocates from the kernel heap and + mutates endpoint refcounts and handle tables. Those paths were unlocked because a single-threaded process could not race itself; a multi-threaded one can, from two cores at once. They now take `sync.enter()` like `call`/`reply_wait`/ `send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel diff --git a/docs/os-development/vdso.md b/docs/os-development/vdso.md index 21b6cbe..24b6b7a 100644 --- a/docs/os-development/vdso.md +++ b/docs/os-development/vdso.md @@ -137,15 +137,15 @@ Grouped as `abi.zig` groups them: | process | `danos_exit`, `danos_yield`, `danos_sleep`, `danos_spawn`, `danos_process_enumerate`, `danos_process_kill`, `danos_process_exit_reason`, `danos_process_subscribe`, `danos_process_signal`, `danos_signal_bind` | | threads | `danos_thread_spawn`, `danos_thread_exit`, `danos_current_core`, `danos_futex_wait`, `danos_futex_wake`, `danos_thread_self`, `danos_thread_join`, `danos_set_thread_pointer` | | memory | `danos_mmap`, `danos_munmap`, `danos_dma_alloc`, `danos_dma_free`, `danos_shared_memory_create`, `danos_shared_memory_map`, `danos_shared_memory_physical` | -| ipc | `danos_endpoint_create`, `danos_ipc_register`, `danos_ipc_lookup`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` | +| ipc | `danos_endpoint_create`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` (naming is not a syscall: a provider binds its contract at the registry and a client resolves `/protocol/` — see [protocol-namespace.md](protocol-namespace.md)) | | devices | `danos_device_enumerate`, `danos_device_claim`, `danos_device_register`, `danos_mmio_map`, `danos_irq_bind`, `danos_irq_ack`, `danos_msi_bind`, `danos_io_read`, `danos_io_write` | | time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` | | diagnostics | `danos_debug_write` (leveled, kernel-stamped records), `danos_klog_read`, `danos_klog_status` | | filesystem naming | `danos_fs_resolve`, `danos_fs_node`, `danos_fs_mount`, `danos_fs_unmount` (naming only — file DATA still crosses the vfs-protocol IPC, see below) | The constants that ride alongside the calls — mmap protection bits, DMA -flags, notification badge bits, `ExitReason`, `Signal`, well-known service -ids, `page_size`, the IPC message maximum — move to the public header too: +flags, notification badge bits, `ExitReason`, `Signal`, `page_size`, the IPC +message maximum — move to the public header too: they are wire values a Rust program needs verbatim. What stays private in `abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall` numbers and the trap convention. diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index ea53744..98b7c15 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -36,7 +36,7 @@ the logging/USB-lifecycle track). - [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] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107) -- [ ] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups) +- [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 - [ ] **merge** group 2 → main, push - [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input) @@ -87,6 +87,22 @@ that implements it. `init` for init's own children, `kernel` for harness-spawned fixtures), not in extra init.csv columns — today every post-path init.csv field is argv, and overloading that is ambiguous. init parses both files. + *(P2 spelling: the supervisor column carries the binary exactly as the + kernel stamped it, so init's own children say `/system/services/init` and + the drivers say `/system/services/device-manager`; `kernel` stays a bare + word because a kernel task has no binary. A trailing `*` on any field + matches a subtree, which is how decision 4's `/test/` rule is expressed.)* + *(Clarification, 2026-08-01: the supervisor column names **the authorized + supervising task, matched by identity** — the binary is how the row spells + it, but init checks the task id. `kernel` is satisfied only by supervisor + id 0 (which only the kernel confers — user `system_spawn` always stamps the + caller); init's own path only by this init's task id; any other path only by + a task init spawned itself or one the kernel spawned. Matching the supervisor + by *name* alone is defeated by a laundering deputy — an attacker runs its own + instance of `/system/services/init`, has that spawn `/system/services/input`, + 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 + the laundered chain still roots at the real PID 1.)* 4. **Test fixtures bind under `/protocol/test/...`**, granted to any binary whose path starts `/test/` — the subtree-scoping rule from the design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId @@ -115,6 +131,10 @@ that implements it. process-test) either spawn init first or move to init.csv-driven scenario boots — resolved per-case in P2 with the suite as the arbiter. + *(P2 resolution: init gained a `registry` argv role — it mounts + `/protocol`, reads the grants, and starts no services — and each affected + case calls `spawnRegistry(rd)` before its own providers. Every case keeps + its own spawn set, so no scenario had to be re-shaped.)* 10. **The capsule-staleness caveat is documented, not fixed.** On-volume edits to `/system/configuration/*.csv` do not reach the initrd copy the loader boots (capsule shadows tree). Same drift exists today with diff --git a/library/client/build.zig b/library/client/build.zig index 96e4b01..0c1f5f0 100644 --- a/library/client/build.zig +++ b/library/client/build.zig @@ -12,10 +12,15 @@ pub fn build(b: *std.Build) void { const ipc = kernel.module("ipc"); const time = kernel.module("time"); + // Every client reaches its service by name now: resolve `/protocol/`, + // open it, and take the provider's endpoint out of the reply + // (docs/os-development/protocol-namespace.md). + const channel = kernel.module("channel"); _ = b.addModule("display-client", .{ .root_source_file = b.path("display/display-client.zig"), .imports = &.{ + .{ .name = "channel", .module = channel }, .{ .name = "ipc", .module = ipc }, .{ .name = "time", .module = time }, .{ .name = "display-protocol", .module = protocol.module("display-protocol") }, @@ -24,6 +29,7 @@ pub fn build(b: *std.Build) void { _ = b.addModule("input-client", .{ .root_source_file = b.path("input/input-client.zig"), .imports = &.{ + .{ .name = "channel", .module = channel }, .{ .name = "ipc", .module = ipc }, .{ .name = "time", .module = time }, .{ .name = "input-protocol", .module = protocol.module("input-protocol") }, diff --git a/library/client/display/display-client.zig b/library/client/display/display-client.zig index 0971444..0f2c106 100644 --- a/library/client/display/display-client.zig +++ b/library/client/display/display-client.zig @@ -1,9 +1,10 @@ //! User-space display client: talk to the display service (query the mode, and — from D3 //! — create layers, draw, and present) without hand-rolling the IPC. The `runtime.block` -//! shape: a cached `.display` lookup with a boot-race retry, then extern-struct request/ +//! shape: a cached `/protocol/display` open with a boot-race retry, then extern-struct request/ //! reply marshalling. See system/services/display/ and docs/display.md. const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const time = @import("time"); const display_protocol = @import("display-protocol"); @@ -19,13 +20,13 @@ pub const Info = struct { /// The service endpoint, looked up once and cached. var handle: ?ipc.Handle = null; -/// Look up the display service, retrying while it comes up (a client races its -/// registration at boot). Returns the endpoint, or null if it never appears. +/// Open `/protocol/display`, retrying while it comes up (a client races the +/// service's bind at boot). Returns the endpoint, or null if it never appears. fn service() ?ipc.Handle { if (handle) |h| return h; var attempts: usize = 0; while (attempts < 100) : (attempts += 1) { - if (ipc.lookup(.display)) |h| { + if (channel.openEndpoint("display")) |h| { handle = h; return h; } diff --git a/library/client/input/input-client.zig b/library/client/input/input-client.zig index cff95fe..f0818df 100644 --- a/library/client/input/input-client.zig +++ b/library/client/input/input-client.zig @@ -22,7 +22,7 @@ //! } const std = @import("std"); -const abi = @import("abi"); +const channel = @import("channel"); const ipc = @import("ipc"); const time = @import("time"); const input_protocol = @import("input-protocol"); @@ -44,13 +44,13 @@ pub const device_mouse = input_protocol.device_mouse; pub const device_joystick = input_protocol.device_joystick; pub const device_all = input_protocol.device_all; -/// Look up the input service, retrying while it is still coming up. Both a subscriber and -/// a source race the service's registration at boot, so both wait for it here rather than -/// failing. Returns the service endpoint handle, or null if it never appears. +/// Open `/protocol/input`, retrying while it is still coming up. Both a subscriber and +/// a source race the service's bind at boot, so both wait for it here rather than +/// failing. Returns the provider's endpoint handle, or null if it never appears. fn lookupService() ?ipc.Handle { var attempts: usize = 0; while (attempts < 100) : (attempts += 1) { - if (ipc.lookup(.input)) |handle| return handle; + if (channel.openEndpoint("input")) |handle| return handle; time.sleepMillis(50); } return null; diff --git a/library/device/block/block.zig b/library/device/block/block.zig index 7a746ff..b2e702c 100644 --- a/library/device/block/block.zig +++ b/library/device/block/block.zig @@ -8,6 +8,7 @@ //! limit — the same handoff usb-storage uses toward the controller. const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const time = @import("time"); const block_protocol = @import("block-protocol"); @@ -66,14 +67,14 @@ pub const Device = struct { } }; -/// One lookup attempt, no waiting — for a server that retries on its own +/// One open attempt, no waiting — for a server that retries on its own /// timer (the fat service) instead of blocking its harness in here. pub fn tryOpen() ?Device { - if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle }; + if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle }; return null; } -/// Look up the block device, retrying generously while the USB storage chain +/// Open `/protocol/block`, retrying generously while the USB storage chain /// (controller reset, enumeration, mass-storage bring-up) comes up. pub fn open() ?Device { // Patient: the whole USB storage chain (firmware discovery, xHCI reset and @@ -84,7 +85,7 @@ pub fn open() ?Device { // completed at ~24 s); a machine whose stick genuinely failed setup should // not sit a further minute pretending otherwise. while (attempts < 600) : (attempts += 1) { - if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle }; + if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle }; time.sleepMillis(50); } return null; diff --git a/library/device/build.zig b/library/device/build.zig index 6107331..1be56f1 100644 --- a/library/device/build.zig +++ b/library/device/build.zig @@ -14,6 +14,10 @@ pub fn build(b: *std.Build) void { const system_call = kernel.module("system-call"); const ipc = kernel.module("ipc"); const time = kernel.module("time"); + // A driver finds the bus it attaches to by name — `/protocol/device-manager`, + // `/protocol/usb-transfer`, `/protocol/block` + // (docs/os-development/protocol-namespace.md). + const channel = kernel.module("channel"); // The devices sub-project's public interface (the flat wire types), // importable by user space, unlike the kernel-internal device model it @@ -55,6 +59,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("driver/driver.zig"), .imports = &.{ .{ .name = "abi", .module = abi }, + .{ .name = "channel", .module = channel }, .{ .name = "device-abi", .module = device_abi }, .{ .name = "system-call", .module = system_call }, .{ .name = "ipc", .module = ipc }, @@ -81,6 +86,7 @@ pub fn build(b: *std.Build) void { _ = b.addModule("usb", .{ .root_source_file = b.path("usb/usb.zig"), .imports = &.{ + .{ .name = "channel", .module = channel }, .{ .name = "ipc", .module = ipc }, .{ .name = "time", .module = time }, .{ .name = "usb-transfer-protocol", .module = protocol.module("usb-transfer-protocol") }, @@ -92,6 +98,7 @@ pub fn build(b: *std.Build) void { _ = b.addModule("block", .{ .root_source_file = b.path("block/block.zig"), .imports = &.{ + .{ .name = "channel", .module = channel }, .{ .name = "ipc", .module = ipc }, .{ .name = "time", .module = time }, .{ .name = "block-protocol", .module = protocol.module("block-protocol") }, diff --git a/library/device/driver/driver.zig b/library/device/driver/driver.zig index 4a63f9b..470bef7 100644 --- a/library/device/driver/driver.zig +++ b/library/device/driver/driver.zig @@ -7,6 +7,7 @@ const std = @import("std"); const abi = @import("abi"); const device_abi = @import("device-abi"); const sc = @import("system-call"); +const channel = @import("channel"); const ipc = @import("ipc"); const time = @import("time"); const device_manager_protocol = @import("device-manager-protocol"); @@ -170,7 +171,7 @@ const lookup_pause_ms: u64 = 20; pub fn hello(role: Role, device_id: u64) ?ipc.Handle { var attempts: u32 = 0; const manager = while (attempts < lookup_attempts) : (attempts += 1) { - if (ipc.lookup(.device_manager)) |handle| break handle; + if (channel.openEndpoint("device-manager")) |handle| break handle; time.sleepMillis(lookup_pause_ms); } else { std.log.info("no device manager to hello", .{}); diff --git a/library/device/usb/usb.zig b/library/device/usb/usb.zig index 7a7b6ab..fadb96d 100644 --- a/library/device/usb/usb.zig +++ b/library/device/usb/usb.zig @@ -16,6 +16,7 @@ //! the service harness drops buffered-message payloads — see service.zig). const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const time = @import("time"); const usb_transfer_protocol = @import("usb-transfer-protocol"); @@ -130,13 +131,15 @@ pub const Device = struct { } }; -/// Look up the USB bus and open the device with the assigned id, handing over a -/// freshly created endpoint for asynchronous interrupt reports. Retries while the -/// bus is still coming up (a class driver races the bus driver at boot). +/// Open `/protocol/usb-transfer` and, on that channel, open the device with the +/// assigned id, handing over a freshly created endpoint for asynchronous interrupt +/// reports. Retries while the bus is still coming up (a class driver races the bus +/// driver at boot). Two opens, deliberately: the first names the contract, the +/// second names an object within it. pub fn open(device_id: u64) ?Device { var attempts: usize = 0; const bus = while (attempts < 100) : (attempts += 1) { - if (ipc.lookup(.usb_bus)) |handle| break handle; + if (channel.openEndpoint("usb-transfer")) |handle| break handle; time.sleepMillis(20); } else return null; diff --git a/library/kernel/build.zig b/library/kernel/build.zig index 6025802..081d3ec 100644 --- a/library/kernel/build.zig +++ b/library/kernel/build.zig @@ -65,10 +65,11 @@ pub fn build(b: *std.Build) void { // it needs the namespace (file-system, to resolve a /protocol name) and the // transport (ipc) both, which is why it lives here rather than in a protocol // module — those import nothing. - _ = b.addModule("channel", .{ + const channel = b.addModule("channel", .{ .root_source_file = b.path("channel.zig"), .imports = &.{ .{ .name = "ipc", .module = ipc }, + .{ .name = "time", .module = time }, .{ .name = "file-system", .module = file_system }, .{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") }, .{ .name = "envelope", .module = protocol.module("envelope") }, @@ -83,10 +84,13 @@ pub fn build(b: *std.Build) void { .{ .name = "thread", .module = thread }, }, }); + // The harness binds the service's contract name at startup, which is a + // conversation with the registry — hence channel (and time, for the patience + // a provider that beat init to the mount needs). _ = b.addModule("service", .{ .root_source_file = b.path("service.zig"), .imports = &.{ - .{ .name = "abi", .module = abi }, + .{ .name = "channel", .module = channel }, .{ .name = "ipc", .module = ipc }, .{ .name = "process", .module = process }, }, @@ -124,6 +128,7 @@ pub fn build(b: *std.Build) void { .target = b.resolveTargetQuery(.{}), .imports = &.{ .{ .name = "ipc", .module = ipc }, + .{ .name = "time", .module = time }, .{ .name = "file-system", .module = file_system }, .{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") }, .{ .name = "envelope", .module = protocol.module("envelope") }, diff --git a/library/kernel/channel.zig b/library/kernel/channel.zig index bcf9cf5..07b966f 100644 --- a/library/kernel/channel.zig +++ b/library/kernel/channel.zig @@ -19,14 +19,14 @@ //! falls out of the naming layer for free; no protocol needs a reconnect verb. //! //! `open` resolves a `/protocol/` path through the kernel VFS router and -//! takes the provider's endpoint from the open reply's capability. **No registry -//! is mounted at `/protocol` yet** — init grows one in P2 -//! (docs/os-development/protocol-namespace.md) — so `open` cannot succeed today; -//! it is here so the client half of the conversation is written once, against -//! the shape the registry will answer with. +//! takes the provider's endpoint from the open reply's capability. The registry +//! answering it is init, PID 1, which mounts `/protocol` before it spawns anyone +//! (docs/os-development/protocol-namespace.md); `bind` below is the other half — +//! how a provider claims the name in the first place. const std = @import("std"); const ipc = @import("ipc"); +const time = @import("time"); const file_system = @import("file-system"); const vfs_protocol = @import("vfs-protocol"); const envelope = @import("envelope"); @@ -36,6 +36,15 @@ const envelope = @import("envelope"); /// on the stack of whoever opens. pub const path_maximum: usize = 224; +/// Where the protocol namespace is rooted — the one path prefix in the system +/// that names contracts rather than files. Spelled once, here, so no caller +/// builds it by hand (docs/file-system-development/file-system-hierarchy.md). +pub const root: []const u8 = "/protocol"; + +/// Longest contract name — the part after `/protocol/`. Short by construction: +/// a leaf like `display`, or a subtree leaf like `test/shared-memory`. +pub const name_maximum: usize = 64; + /// What a `call` came back with: the provider's status, the reply payload (the /// bytes after the `Status`, in the caller's own buffer), and any capability the /// reply carried. @@ -74,41 +83,13 @@ pub const Channel = struct { /// The path is spoken exactly once, here. Everything afterwards is integers /// in the packet header. pub fn open(path: []const u8) ?Channel { - var relative: [path_maximum]u8 = undefined; - const route = file_system.fsResolve(path, 0, &relative) orelse return null; - // A protocol name must land on a mounted backend. The kernel-served - // `/system` tree answers with a node token and knows nothing of - // channels, so that route is simply the wrong path. - const backend = switch (route) { - .kernel => return null, - .backend => |b| b, - }; - // The registry handle is deduplicated by the kernel across resolves and - // shared with every other user of that mount, so it is not ours to - // close — only the provider endpoint below belongs to this channel. - const name = relative[0..backend.path_len]; + return .{ .endpoint = openPath(path) orelse return null }; + } - var request: [vfs_protocol.message_maximum]u8 = undefined; - const header = vfs_protocol.Request{ - .operation = .open, - .node = 0, - .offset = 0, - .len = @intCast(name.len), - .flags = 0, - }; - if (vfs_protocol.request_size + name.len > request.len) return null; - @memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header)); - @memcpy(request[vfs_protocol.request_size..][0..name.len], name); - - var reply: [vfs_protocol.message_maximum]u8 = undefined; - const answer = ipc.callCap(backend.handle, request[0 .. vfs_protocol.request_size + name.len], &reply, null) catch return null; - if (answer.len < vfs_protocol.reply_size) return null; - const decoded = std.mem.bytesToValue(vfs_protocol.Reply, reply[0..vfs_protocol.reply_size]); - if (decoded.status != 0) return null; - // The capability *is* the channel — an open that succeeds without one - // was answered by a file backend, which does not speak protocols. - const provider = answer.cap orelse return null; - return .{ .endpoint = provider }; + /// Establish a channel by contract name — `open` with `/protocol/` supplied, + /// which is how every caller in the system spells it. + pub fn connect(name: []const u8) ?Channel { + return .{ .endpoint = openEndpoint(name) orelse return null }; } /// Send one request packet and block for the reply: `[Header][request]` out, @@ -178,6 +159,140 @@ pub const Channel = struct { } }; +// --- the namespace: resolving, opening, and claiming a contract name --------- + +/// Where a `/protocol/...` path routed: the registry's endpoint, plus the path +/// rewritten mount-relative (`/display` for `/protocol/display`). The handle is +/// deduplicated by the kernel across resolves and shared with every other user +/// of that mount, so it is never ours to close. +const Registry = struct { + handle: ipc.Handle, + relative: [path_maximum]u8, + relative_len: usize, + + fn path(self: *const Registry) []const u8 { + return self.relative[0..self.relative_len]; + } +}; + +/// Route `path` to whatever backend serves it. Null when nothing is mounted +/// there — under `/protocol` that means the registry is not up yet, which is a +/// *retry*, not a refusal. A kernel-served route (the read-only `/system` tree) +/// is the wrong path, not a channel, and is refused here. +fn reach(path: []const u8) ?Registry { + var out: Registry = .{ .handle = 0, .relative = undefined, .relative_len = 0 }; + const route = file_system.fsResolve(path, 0, &out.relative) orelse return null; + switch (route) { + .kernel => return null, + .backend => |b| { + out.handle = b.handle; + out.relative_len = b.path_len; + return out; + }, + } +} + +/// One vfs-protocol round trip at a backend: fixed header, inline payload, and +/// an optional capability in each direction. +fn transact( + handle: ipc.Handle, + operation: vfs_protocol.Operation, + payload: []const u8, + send_capability: ?ipc.Handle, +) ?struct { reply: vfs_protocol.Reply, capability: ?ipc.Handle } { + var request: [vfs_protocol.message_maximum]u8 = undefined; + if (vfs_protocol.request_size + payload.len > request.len) return null; + const header = vfs_protocol.Request{ + .operation = operation, + .node = 0, + .offset = 0, + .len = @intCast(payload.len), + .flags = 0, + }; + @memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header)); + @memcpy(request[vfs_protocol.request_size..][0..payload.len], payload); + + var reply: [vfs_protocol.message_maximum]u8 = undefined; + const answer = ipc.callCap(handle, request[0 .. vfs_protocol.request_size + payload.len], &reply, send_capability) catch return null; + if (answer.len < vfs_protocol.reply_size) return null; + return .{ + .reply = std.mem.bytesToValue(vfs_protocol.Reply, reply[0..vfs_protocol.reply_size]), + .capability = answer.cap, + }; +} + +/// Resolve an absolute `/protocol/...` path and take the provider's endpoint out +/// of the open reply's capability. +fn openPath(path: []const u8) ?ipc.Handle { + const registry = reach(path) orelse return null; + const answered = transact(registry.handle, .open, registry.path(), null) orelse return null; + if (answered.reply.status != 0) return null; + // The capability *is* the channel — an open that succeeds without one was + // answered by a file backend, which does not speak protocols. + return answered.capability; +} + +/// The provider's raw endpoint behind `/protocol/`. The transitional form, +/// for the clients that still marshal their protocol's bytes by hand; P4 moves +/// them onto `Channel` proper and this shrinks back to `connect`. +/// +/// Null covers both "no such contract" and "you may not have it" — deliberately +/// the same answer (protocol-namespace.md: enforcement is absence), and also +/// "the registry is not mounted yet", which is why every caller retries. +pub fn openEndpoint(name: []const u8) ?ipc.Handle { + var path: [path_maximum]u8 = undefined; + const full = join(name, &path) orelse return null; + return openPath(full); +} + +/// Claim `/protocol/` for `endpoint`: the registry records the name +/// against this process and hands the endpoint to whoever opens it afterwards. +/// The endpoint rides the call as its capability, the one direction-crossing +/// move kernel-ipc offers. +/// +/// Three-valued on purpose. **Null** is "the registry could not be reached" — +/// it is not mounted yet, which happens when a provider starts before init has +/// finished coming up, and the answer is to retry. A **value** is the registry's +/// verdict and is final: 0 bound, `-EPERM` this binary is not granted that name, +/// `-EBUSY` a live provider already holds it. +pub fn bind(name: []const u8, endpoint: ipc.Handle) ?i32 { + const registry = reach(root) orelse return null; + const answered = transact(registry.handle, .bind, name, endpoint) orelse return null; + return answered.reply.status; +} + +/// How long a provider keeps offering itself before giving up. The registry is +/// init, which mounts `/protocol` before it spawns anyone, so in a normal boot +/// the first try lands; a provider the kernel test harness starts may well beat +/// init to the mount, which is what the patience is for. Four seconds of 20 ms +/// tries — the same cadence every client in the tree spends finding a service. +const bind_attempts: u32 = 200; +const bind_retry_ms: u64 = 20; + +/// `bind`, waiting out a registry that is not mounted yet. Only unreachability +/// is retried: a registry that *answered* has decided, and asking again cannot +/// change its mind. True when the name is ours. +pub fn bindPatiently(name: []const u8, endpoint: ipc.Handle) bool { + var attempt: u32 = 0; + while (attempt < bind_attempts) : (attempt += 1) { + if (bind(name, endpoint)) |status| return status == 0; + time.sleepMillis(bind_retry_ms); + } + return false; +} + +/// `/protocol/` + `name`, in the caller's buffer. Null if the name is empty or +/// longer than the namespace admits. +fn join(name: []const u8, buffer: []u8) ?[]u8 { + if (name.len == 0 or name.len > name_maximum) return null; + const total = root.len + 1 + name.len; + if (total > buffer.len) return null; + @memcpy(buffer[0..root.len], root); + buffer[root.len] = '/'; + @memcpy(buffer[root.len + 1 ..][0..name.len], name); + return buffer[0..total]; +} + /// Lay a packet down: the folded header first, then the protocol's bytes. Null /// when it would not fit the buffer — the same rule as `envelope`'s framing, /// applied where the buffer is the transport's, not the protocol's. @@ -209,6 +324,14 @@ test "a framed packet is the header followed by the protocol's bytes" { try testing.expectEqualStrings("body", packet[envelope.prefix_size..]); } +test "a contract name joins the namespace root exactly once" { + var buffer: [path_maximum]u8 = undefined; + try testing.expectEqualStrings("/protocol/display", join("display", &buffer).?); + try testing.expectEqualStrings("/protocol/test/shared-memory", join("test/shared-memory", &buffer).?); + try testing.expect(join("", &buffer) == null); + try testing.expect(join("x" ** (name_maximum + 1), &buffer) == null); +} + test "framing refuses a packet that would not fit rather than truncating it" { var post: [envelope.post_maximum]u8 = undefined; const header = envelope.Header{ .operation = envelope.first_protocol_operation }; diff --git a/library/kernel/ipc.zig b/library/kernel/ipc.zig index 83c62ba..ba1d6be 100644 --- a/library/kernel/ipc.zig +++ b/library/kernel/ipc.zig @@ -29,10 +29,10 @@ pub fn createIpcEndpoint() ?Handle { return if (failed(r)) null else r; } -/// Publish endpoint `h` under a well-known service id so other processes find it. -pub fn register(id: abi.ServiceId, h: Handle) bool { - return !failed(sc.systemCall2(.ipc_register, @intFromEnum(id), h)); -} +// `register`/`lookup` lived here — the two wrappers over the flat ServiceId +// registry. Naming is not a system call any more: a provider binds its contract +// name at the registry and a client resolves and opens `/protocol/`, both +// through `channel` (docs/os-development/protocol-namespace.md). /// Drop a capability handle (endpoint, shared-memory, or DMA-region) and free its table /// slot. A forwarding hop closes a cap it passed on; a binder closes a DMA-region cap @@ -42,13 +42,6 @@ pub fn close(h: Handle) bool { return !failed(sc.systemCall1(.handle_close, h)); } -/// Find the endpoint published under `id`, installing a handle to it in this -/// process. -pub fn lookup(id: abi.ServiceId) ?Handle { - const r = sc.systemCall1(.ipc_lookup, @intFromEnum(id)); - return if (failed(r)) null else r; -} - pub const CallError = error{Failed}; /// The result of a capability-passing `callCap`: the reply length, and the handle of @@ -178,6 +171,56 @@ pub const Received = struct { } }; +/// A capability that arrived with one turn of a receive loop, and the ownership +/// rule for it: **the turn owns it until a handler takes it, and closes whatever +/// is left.** +/// +/// The kernel installs a sent capability in the receiver's handle table whenever +/// the caller attached one, *independent of the message's length or kind* +/// (system/kernel/ipc-synchronous.zig `replyWait`), so every path out of a loop +/// has to dispose of one — including the paths that never look at the message. +/// The table is thirty-two slots, and `ipc_call` does not dedupe, so a client +/// looping on `callCap(server, &.{}, endpoint)` spends one slot per call: about +/// thirty-two zero-length pings and the service can never accept another +/// capability, which means no subscribe and no shared-memory handover, for the +/// rest of the boot. It is unauthenticated and it is two lines to write. +/// +/// So ownership is structural rather than a close per branch — the per-branch +/// version has already failed twice in this tree, in PID 1's ping path and in +/// every `service.run` callback that simply ignored its capability argument. +/// Written this way, forgetting **closes**, and *keeping* a capability is the +/// thing a handler has to say out loud: +/// +/// ```zig +/// var arrived: ipc.Arrival = .{ .handle = got.cap }; +/// defer arrived.release(); // every exit path, including `continue` +/// ... +/// const kept = arrived.take().?; // claimed: mine to hold or close +/// ``` +pub const Arrival = struct { + handle: ?Handle = null, + + /// Look without claiming — a handler that may still refuse wants no close of + /// its own on the refusal paths. + pub fn peek(self: *const Arrival) ?Handle { + return self.handle; + } + + /// Claim ownership: from here the capability is the taker's to keep or close, + /// and the turn will not touch it. + pub fn take(self: *Arrival) ?Handle { + defer self.handle = null; + return self.handle; + } + + /// Close whatever nobody claimed. Idempotent, so it is safe as a `defer` next + /// to any number of `take`s. + pub fn release(self: *Arrival) void { + if (self.handle) |handle| _ = close(handle); + self.handle = null; + } +}; + /// Server side of IPC_ReplyWait: deliver `reply` to the client last received (if any, /// optionally handing it `send_cap`), then block until the next request arrives in /// `receive`. Returns its length, the sender badge, and any capability the request diff --git a/library/kernel/process.zig b/library/kernel/process.zig index edffd6c..c341189 100644 --- a/library/kernel/process.zig +++ b/library/kernel/process.zig @@ -142,6 +142,18 @@ pub fn subscribeExits(endpoint: usize) bool { /// snapshot buffer without importing `abi` itself. pub const ProcessDescriptor = abi.ProcessDescriptor; +/// The calling task's own kernel id — its row in the process table, and the value +/// every other process sees as this one's `supervisor` after it spawns them. For a +/// single-threaded program that is its process id; in a threaded one it is the +/// calling thread's id (`Thread.getCurrentId` is the same system call, named for +/// the threading vocabulary). Ids are monotonic and never reused +/// (system/kernel/process.zig), which is what makes comparing one an identity +/// test where comparing a *name* is only a resemblance test — the registrar in +/// init leans on exactly that. +pub fn taskId() u32 { + return @intCast(sc.systemCall0(.thread_self)); +} + /// Give up the rest of this quantum. pub fn yield() void { _ = sc.systemCall0(.yield); diff --git a/library/kernel/service.zig b/library/kernel/service.zig index 0749733..cfde3cd 100644 --- a/library/kernel/service.zig +++ b/library/kernel/service.zig @@ -6,13 +6,19 @@ //! loop chose, never on a hijacked stack — the whole reason signals are //! messages. //! +//! One rule a service author does have to know, and it is stated on +//! `Callbacks.on_message`: **a capability that arrives belongs to the turn** — +//! the loop closes it unless the callback claims it with `take()`. Forgetting is +//! therefore safe, and keeping is explicit; the opposite arrangement quietly +//! spends a handle-table slot per request. +//! //! The liveness probe: a **zero-length request is the universal ping**, answered //! with a zero-length reply by the harness itself. No protocol's requests start //! at length zero, so the encoding cannot collide, and there is nothing for a //! service author to implement — a wedged service simply fails to answer, which //! is the diagnosis (see docs/ipc.md). -const abi = @import("abi"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); @@ -22,10 +28,22 @@ pub const Callbacks = struct { /// Return false to abort startup (the process exits). init: ?*const fn (endpoint: ipc.Handle) bool = null, /// One protocol request from `sender` (a task id): write the reply into - /// `reply`, return its length. `capability` is the handle the request - /// carried, if any (M13 cap passing — how a subscriber hands over its - /// endpoint). The zero-length ping never reaches this. - on_message: *const fn (message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize, + /// `reply`, return its length. The zero-length ping never reaches this. + /// + /// `arrived` is the capability the request carried (M13 cap passing — how a + /// subscriber hands over its endpoint), and it comes with **an ownership + /// rule: the turn owns it, and a handler that wants to keep it must say so + /// with `take()`.** Whatever is left when this returns, the loop closes. + /// `peek()` reads it without claiming, which is what a handler that may + /// still refuse wants — no close of its own on the refusal paths. + /// + /// The rule is stated here, in the contract, because the alternative has + /// failed in practice: an implementation that simply ignored a `?ipc.Handle` + /// argument leaked a handle table slot per request, and every operation + /// except a subscribe ignores it. Thirty-two such requests — zero-length + /// pings will do, and they need no authorization — and the service can never + /// accept another capability for the rest of the boot. See `ipc.Arrival`. + on_message: *const fn (message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize, /// A notification that is not a signal — a subscribed exit event, a bound /// IRQ, a timer landing. The raw badge; decode with the ipc helpers. on_notification: ?*const fn (badge: u64) void = null, @@ -35,19 +53,25 @@ pub const Callbacks = struct { /// the return itself — never put *necessary* work here (iron rule 1: a kill /// arrives with no warning; this is for graceful extras only). on_terminate: ?*const fn () void = null, - /// Publish the endpoint under a well-known service id at startup. - service: ?abi.ServiceId = null, + /// The contract this service provides: a name under `/protocol`, mirroring + /// the `library/protocol/` module that defines the wire format — a program + /// imports `display-protocol` and the provider binds `"display"` + /// (docs/os-development/protocol-namespace.md). Bound at startup, before + /// `init` runs, so the service is reachable the moment it serves. A refusal + /// (not granted, or a live provider already holds the name) aborts startup. + service: ?[]const u8 = null, }; -/// Run the service: create and (optionally) register the endpoint, bind signals -/// to it, call `init`, then serve until `terminate` arrives — at which point the -/// loop returns and main's return is the clean exit the supervisor reads as -/// `ExitReason.exited`. `maximum_message` sizes the receive and reply buffers -/// (a service passes its protocol's message maximum). +/// Run the service: create the endpoint, bind it under the service's contract +/// name (if it has one), bind signals to it, call `init`, then serve until +/// `terminate` arrives — at which point the loop returns and main's return is +/// the clean exit the supervisor reads as `ExitReason.exited`. +/// `maximum_message` sizes the receive and reply buffers (a service passes its +/// protocol's message maximum). pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void { const endpoint = ipc.createIpcEndpoint() orelse return; - if (callbacks.service) |id| { - if (!ipc.register(id, endpoint)) return; + if (callbacks.service) |name| { + if (!channel.bindPatiently(name, endpoint)) return; } _ = process.bindSignals(endpoint); if (callbacks.init) |initialise| { @@ -59,6 +83,16 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void { var receive: [maximum_message]u8 = undefined; while (true) { const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null); + // Whatever capability came with this turn is the turn's, and the turn + // closes it unless a callback claims it (`ipc.Arrival`). Structural + // rather than a close per branch, because the branches are exactly what + // gets forgotten: the ping's `continue` below, and every `on_message` + // that has no use for a capability — which is every operation but a + // subscribe. A `defer` in a loop body runs on `continue` and on the + // `return` that ends the loop, so this covers all four exits. + var arrived: ipc.Arrival = .{ .handle = got.cap }; + defer arrived.release(); + if (got.isNotification()) { reply_len = 0; // nothing owed for a notification if (process.signalsFrom(got.badge)) |signals| { @@ -76,8 +110,8 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void { } if (got.len == 0) { reply_len = 0; // the universal ping: a zero-length reply, from the harness - continue; + continue; // any capability it carried goes out through the turn's `defer` } - reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), got.cap); + reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived); } } diff --git a/library/protocol/envelope/envelope.zig b/library/protocol/envelope/envelope.zig index 2611f80..b82b14a 100644 --- a/library/protocol/envelope/envelope.zig +++ b/library/protocol/envelope/envelope.zig @@ -119,6 +119,15 @@ pub fn fitsPost(comptime T: type) bool { /// Positive here, sent negated in `Status.status`, as the kernel spells it. pub const ENOSYS: i32 = 10; // this protocol has no such operation pub const EPROTO: i32 = 11; // malformed packet: shorter than the verb it names +pub const EBUSY: i32 = 12; // the thing asked for is held by someone still alive + +/// Restated from the kernel's half of the numbering, because a provider refuses +/// too and userspace has no other place to read these from: `ENOENT` is "no such +/// name", `EPERM` "not permitted". The protocol registry answers an ungranted +/// bind with the second and a name a live provider already holds with `EBUSY`. +pub const ENOENT: i32 = 4; +pub const ENOSPC: i32 = 5; +pub const EPERM: i32 = 9; // --- framing ---------------------------------------------------------------- diff --git a/library/protocol/power/power-protocol.zig b/library/protocol/power/power-protocol.zig index 183f3fe..8df00bc 100644 --- a/library/protocol/power/power-protocol.zig +++ b/library/protocol/power/power-protocol.zig @@ -1,7 +1,9 @@ //! The power protocol (docs/power.md): system power's domain-named surface, -//! registered under `ServiceId.power`. On x86 the acpi service serves it; on -//! ARM a PSCI/mailbox service will register the same id — subscribers never -//! learn which firmware they are on (docs/discovery.md — firmware neutrality). +//! bound at `/protocol/power`. On x86 the acpi service provides it; on ARM a +//! PSCI/mailbox service will bind the same name — subscribers never learn which +//! firmware they are on (docs/discovery.md — firmware neutrality), which is the +//! whole point of naming the contract rather than the provider +//! (docs/os-development/protocol-namespace.md). //! The vfs-protocol pattern: extern-struct messages, a version, reserved fields. /// The protocol version a client states nowhere yet — reserved for the day a diff --git a/library/protocol/vfs/vfs-protocol.zig b/library/protocol/vfs/vfs-protocol.zig index c22293a..67db6f7 100644 --- a/library/protocol/vfs/vfs-protocol.zig +++ b/library/protocol/vfs/vfs-protocol.zig @@ -40,6 +40,12 @@ pub const Operation = enum(u32) { // rename: the payload is the old path, a single 0x00 separator, then the new // path. Same-directory rename only (the router requires both under one mount). rename, // rename(old\0new payload) -> status + // Appended for the protocol namespace (P2). The registry is a synthetic + // backend mounted at /protocol: `open` establishes a channel and `readdir` + // lists the bound names like any directory, so those two verbs need nothing + // new — but *claiming* a name does. A file backend refuses it, alongside the + // router verbs it does not implement either; only the registry implements it. + bind, // bind(name payload, capability = the provider's endpoint) -> status }; /// The type of a filesystem node, aligned to the node-kind table @@ -132,4 +138,7 @@ test "protocol struct sizes and node kinds" { try std.testing.expectEqual(@as(u32, 0), @intFromEnum(Operation.open)); try std.testing.expectEqual(@as(u32, 4), @intFromEnum(Operation.status)); try std.testing.expectEqual(@as(u32, 5), @intFromEnum(Operation.readdir)); + try std.testing.expectEqual(@as(u32, 10), @intFromEnum(Operation.rename)); + // The registry's claim verb, appended last with the protocol namespace. + try std.testing.expectEqual(@as(u32, 11), @intFromEnum(Operation.bind)); } diff --git a/system/abi.zig b/system/abi.zig index b623c11..ce63bec 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -1,6 +1,6 @@ //! The **private kernel ↔ runtime** ABI: the raw system_call contract — the call //! numbers, `mmap` protection flags, the page size those calls work in, and the IPC -//! name-registry ids and notification bit. Shared by the kernel dispatcher +//! notification bits. Shared by the kernel dispatcher //! (system/kernel/process.zig) and the user-space runtime library (library/runtime/), //! so the two can never drift. //! @@ -33,8 +33,13 @@ pub const SystemCall = enum(u64) { mmap = 4, // mmap(len, prot) -> base: grant zeroed, page-aligned user pages munmap = 5, // munmap(base, len): release pages from a prior mmap create_ipc_endpoint = 6, // create_ipc_endpoint() -> handle: a new IPC endpoint - ipc_register = 7, // ipc_register(service_id, handle): publish an endpoint by well-known id - ipc_lookup = 8, // ipc_lookup(service_id) -> handle: find a published endpoint + // 7 and 8 were ipc_register/ipc_lookup — the flat ServiceId name registry, + // retired with the protocol namespace (docs/os-development/protocol-namespace.md). + // A service now binds its name at the registry (init, over /protocol) and a + // client resolves and opens that path; neither is a system call any more. The + // numbers stay vacant rather than being reused: every other entry is + // position-fixed by an explicit value, so a hole costs nothing and a reused + // number would silently mean two things across a rebuild boundary. ipc_call = 9, // ipc_call(h, message, len, reply, cap) -> reply_len: send + block for reply ipc_reply_wait = 10, // ipc_reply_wait(h, reply, len, receive, cap) -> receive_len (+badge in rdx) device_enumerate = 11, // device_enumerate(buffer, maximum) -> count: snapshot the device table @@ -284,23 +289,11 @@ pub const KlogStatus = extern struct { boot_unix_seconds: u64, // wall-clock time of boot (RTC anchor) }; -/// Well-known IPC service ids for the bootstrap name registry (create_ipc_endpoint + -/// ipc_register/ipc_lookup). Small integers, so no string interning is needed -/// during bring-up. The VFS server registers under `vfs`; clients look it up. -pub const ServiceId = enum(u32) { - vfs = 1, // RETIRED: the router moved into the kernel (fs_resolve); the slot stays reserved - input = 2, - ps2_bus = 3, // the 8042 owner; child device drivers attach here for raw bytes - device_manager = 4, // the tree, the matcher, the supervisor (docs/device-manager.md) - power = 5, // system power: events (button, lid, battery) + shutdown (docs/power.md; domain-named per docs/discovery.md — the acpi service registers it on x86, a PSCI service will on ARM) - usb_bus = 6, // the xHCI host-controller driver's transfer endpoint; USB class drivers look it up and `callCap`-open their device to get a private per-device transfer channel (docs/driver-model.md) - block = 7, // a block-device driver (USB mass storage today): read/write of fixed-size blocks, the storage a filesystem sits on - fat = 8, // the FAT filesystem server; the VFS mounts it and forwards paths under its mount point (/volumes/usb) to it - display = 9, // the display service: owns the framebuffer, composites a layer stack, presents frames (docs/display.md) - shared_memory_test = 10, // the shared-memory test server (V2): a client passes it a shared-memory capability, it maps + verifies (docs/display-v2.md) - scanout = 11, // a native scanout driver (virtio-gpu): the compositor finds it here to upgrade off the GOP framebuffer (docs/display-v2.md) - _, -}; +// The `ServiceId` enum lived here: a flat, compile-time list of well-known +// service ids backed by a 16-slot kernel table. It is gone with the protocol +// namespace — names are strings resolved under `/protocol` at run time, so a +// third-party program can introduce a contract the ABI never heard of, and the +// registrar (init) decides who may claim one. /// Protection flags for `mmap` (matching the usual C bit values). pub const prot_read: u64 = 1; diff --git a/system/configuration/protocol.csv b/system/configuration/protocol.csv new file mode 100644 index 0000000..dc80ef1 --- /dev/null +++ b/system/configuration/protocol.csv @@ -0,0 +1,70 @@ +# /system/configuration/protocol.csv — who may claim, and who may reach, a name +# under /protocol (docs/os-development/protocol-namespace.md). +# +# init is the registrar: it serves /protocol, and every bind is checked against +# this file. It is AUTHORITATIVE — a name no row grants cannot be bound, and a +# missing file means nothing may be bound at all. +# +# '#' starts a comment (whole-line or trailing); blank lines are ignored. +# Whitespace around a field is trimmed, so columns may be padded. Four +# comma-separated fields per row: +# +# binary the claimant's binary path, exactly as the kernel stamped it at +# spawn (argv[0]) — unforgeable, read from the process records +# supervisor the authorized supervising TASK, written as the binary it runs — +# the path init was started as for its own services, the device +# manager's path for the drivers it starts. The one word that is not +# a path is 'kernel', because a kernel task has no binary; that is +# what the test harness's direct spawns look like. +# Matched by IDENTITY, not by spelling. Name alone is not identity — +# spawn is ungated, so a hostile process can start a granted binary +# itself and inherit its grants; and it can equally start its own +# instance of the *supervisor's* binary and have that spawn the +# granted one, at which point both names read correctly (the +# laundering deputy). So init also asks which task the supervisor +# is: 'kernel' means supervisor id 0, which only the kernel can +# 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 +# monotonic and never reused, so an id cannot be borrowed. +# permission bind (provide this contract) | open (speak to it) +# name the contract, relative to /protocol +# +# 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. +# The milestone that turns them into refusals is P3 (docs/security-track-plan.md). +# +# binary supervisor permission name + +# --- the services init spawns from init.csv --------------------------------- +/system/services/input, /system/services/init, bind, input +/system/services/device-manager, /system/services/init, bind, device-manager +/system/services/fat, /system/services/init, bind, vfs +/system/services/display, /system/services/init, bind, display + +# The discovery service ships under one neutral name per firmware (docs/discovery.md); +# on x86 it is the acpi service, and what it provides is the power contract. +/system/services/discovery, /system/services/device-manager, bind, power + +# --- the drivers, which the device manager spawns --------------------------- +/system/drivers/ps2-bus, /system/services/device-manager, bind, ps2-bus +/system/drivers/usb-xhci-bus, /system/services/device-manager, bind, usb-transfer +/system/drivers/usb-storage, /system/services/device-manager, bind, block +/system/drivers/virtio-gpu, /system/services/device-manager, bind, scanout + +# --- the same providers when the kernel test harness starts them directly --- +# A scenario boot spawns its own providers instead of letting init do it +# (docs/security-track-plan.md, decision 9), so the same binaries appear with +# 'kernel' as the supervisor. Nothing else changes: the binary must still match. +/system/services/input, kernel, bind, input +/system/services/device-manager, kernel, bind, device-manager +/system/services/fat, kernel, bind, vfs +/system/services/display, kernel, bind, display +/system/services/discovery, kernel, bind, power + +# --- test fixtures ---------------------------------------------------------- +# The subtree rule, dogfooded: anything installed under /test may claim anything +# under /protocol/test, and nothing above it — whether the harness spawned it or +# another fixture did. +/test/*, kernel, bind, test/* +/test/*, /test/*, bind, test/* diff --git a/system/drivers/pci-bus/pci-bus.zig b/system/drivers/pci-bus/pci-bus.zig index 39239cd..0501f7d 100644 --- a/system/drivers/pci-bus/pci-bus.zig +++ b/system/drivers/pci-bus/pci-bus.zig @@ -245,11 +245,11 @@ fn registerAndReport(bus: u64, dev: u64, function: u64, class_triple: u32) void }; } -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = message; _ = reply; _ = sender; - _ = capability; + _ = arrived; // nothing here takes a capability: the harness closes what arrives return 0; } diff --git a/system/drivers/ps2-bus/build.zig b/system/drivers/ps2-bus/build.zig index 56733ae..0fe719b 100644 --- a/system/drivers/ps2-bus/build.zig +++ b/system/drivers/ps2-bus/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const ps2_bus_exe = build_support.userBinary(b, .{ .name = "ps2-bus", .root_source_file = b.path("ps2-bus.zig"), - .imports = &.{ "acpi-ids", "driver", "ipc", "logging", "memory", "process", "service", "time" }, + .imports = &.{ "acpi-ids", "channel", "driver", "ipc", "logging", "memory", "process", "service", "time" }, }); b.installArtifact(ps2_bus_exe); @@ -17,8 +17,8 @@ pub fn build(b: *std.Build) void { .name = "ps2-keyboard", .root_source_file = b.path("keyboard.zig"), .imports = &.{ - "acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory", - "process", "time", "xkeyboard-config", + "acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc", + "logging", "memory", "process", "time", "xkeyboard-config", }, }); b.installArtifact(ps2_keyboard_exe); @@ -27,8 +27,8 @@ pub fn build(b: *std.Build) void { .name = "ps2-mouse", .root_source_file = b.path("mouse.zig"), .imports = &.{ - "acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory", - "process", "time", + "acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc", "logging", + "memory", "process", "time", }, }); b.installArtifact(ps2_mouse_exe); diff --git a/system/drivers/ps2-bus/keyboard.zig b/system/drivers/ps2-bus/keyboard.zig index 602195b..b2ffc8f 100644 --- a/system/drivers/ps2-bus/keyboard.zig +++ b/system/drivers/ps2-bus/keyboard.zig @@ -16,6 +16,7 @@ const std = @import("std"); const device = @import("driver"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const time = @import("time"); @@ -27,12 +28,12 @@ const ps2 = @import("ps2-library.zig"); const scancode = @import("scancode.zig"); const input_protocol = @import("input-protocol"); -/// Look up the ps2-bus service, retrying while the bus (which spawned us before -/// registering) is still coming up. +/// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before +/// binding) is still coming up. fn lookupBus() ?ipc.Handle { var attempts: usize = 0; while (attempts < 100) : (attempts += 1) { - if (ipc.lookup(.ps2_bus)) |handle| return handle; + if (channel.openEndpoint("ps2-bus")) |handle| return handle; time.sleepMillis(50); } return null; diff --git a/system/drivers/ps2-bus/mouse.zig b/system/drivers/ps2-bus/mouse.zig index 1dc416a..155c283 100644 --- a/system/drivers/ps2-bus/mouse.zig +++ b/system/drivers/ps2-bus/mouse.zig @@ -16,6 +16,7 @@ const std = @import("std"); const device = @import("driver"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const time = @import("time"); @@ -26,12 +27,12 @@ const ps2 = @import("ps2-library.zig"); const mouse_packet = @import("mouse-packet.zig"); const input_protocol = @import("input-protocol"); -/// Look up the ps2-bus service, retrying while the bus (which spawned us before -/// registering) is still coming up. +/// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before +/// binding) is still coming up. fn lookupBus() ?ipc.Handle { var attempts: usize = 0; while (attempts < 100) : (attempts += 1) { - if (ipc.lookup(.ps2_bus)) |handle| return handle; + if (channel.openEndpoint("ps2-bus")) |handle| return handle; time.sleepMillis(50); } return null; diff --git a/system/drivers/ps2-bus/ps2-bus.zig b/system/drivers/ps2-bus/ps2-bus.zig index efe7349..6e21666 100644 --- a/system/drivers/ps2-bus/ps2-bus.zig +++ b/system/drivers/ps2-bus/ps2-bus.zig @@ -11,6 +11,7 @@ //! - irq 0xc len 0x1 const std = @import("std"); const device = @import("driver"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const service = @import("service"); @@ -62,7 +63,13 @@ var port_device_types = [_]?ps2.DeviceType{ null, null }; /// Handle a child driver's `AttachRequest`: record the endpoint capability it /// passed as the forwarding target for the port whose device matches its type. /// Writes an `AttachReply` into `out` and returns its length. -fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize { +/// A child driver's AttachRequest. The endpoint it hands over arrives under the +/// same ownership rule the service harness states (`ipc.Arrival`): the turn owns +/// it, and only the path that records it in `port_endpoints` says `take`. Every +/// refusal here simply returns, and the loop closes what arrived — otherwise a +/// stranger (this is a named contract, reachable by anyone) spends one of this +/// driver's thirty-two handle slots per malformed attach. +fn handleAttach(message: []const u8, out: []u8, arrived: *ipc.Arrival) usize { const reply = struct { fn write(buffer: []u8, status: ps2.AttachStatus) usize { const header = ps2.AttachReply{ .status = @intFromEnum(status) }; @@ -73,12 +80,17 @@ fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize { if (message.len < @sizeOf(ps2.AttachRequest)) return reply.write(out, .invalid_request); const request = std.mem.bytesToValue(ps2.AttachRequest, message[0..@sizeOf(ps2.AttachRequest)]); - const endpoint = got.cap orelse return reply.write(out, .missing_endpoint); + const endpoint = arrived.peek() orelse return reply.write(out, .missing_endpoint); for (&port_device_types, 0..) |maybe_type, port_index| { const device_type = maybe_type orelse continue; if (@intFromEnum(device_type) != request.device_type) continue; - port_endpoints[port_index] = endpoint; + // Claimed. A re-attach supersedes the previous driver's endpoint, and the + // one it displaces is closed: the slot holds exactly one reference. + if (port_endpoints[port_index]) |previous| { + if (previous != endpoint) _ = ipc.close(previous); + } + port_endpoints[port_index] = arrived.take(); std.log.info("{s} driver attached", .{@tagName(device_type)}); return reply.write(out, .ok); } @@ -213,15 +225,16 @@ pub fn main() void { return; }; - // The endpoint the child drivers attach to and IRQ1 wakes. Registered under a - // well-known id so the children can find it, the way input subscribers find - // the input service. + // The endpoint the child drivers attach to and IRQ1 wakes. Bound as the + // `ps2-bus` contract so the children can find it by name, the way input + // subscribers find the input service. This driver runs its own loop rather + // than the service harness, so it binds by hand — same call the harness makes. const endpoint = ipc.createIpcEndpoint() orelse { _ = logging.write("/system/drivers/ps2-bus: no endpoint\n"); return; }; - if (!ipc.register(.ps2_bus, endpoint)) { - _ = logging.write("/system/drivers/ps2-bus: register failed\n"); + if (!channel.bindPatiently("ps2-bus", endpoint)) { + _ = logging.write("/system/drivers/ps2-bus: could not bind /protocol/ps2-bus\n"); return; } @@ -273,6 +286,13 @@ pub fn main() void { var receive: [@sizeOf(ps2.AttachRequest)]u8 = undefined; while (true) { const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null); + // The turn owns whatever capability arrived and closes it unless + // `handleAttach` claims it (`ipc.Arrival`) — the kernel installs one + // whatever the message's length or kind, so this covers the notification + // path and every refusal below it. + var arrived: ipc.Arrival = .{ .handle = got.cap }; + defer arrived.release(); + if (got.isNotification()) { reply_len = 0; if (got.isMessage() or got.isChildExit()) continue; // nothing sends us these @@ -301,6 +321,6 @@ pub fn main() void { } continue; } - reply_len = handleAttach(receive[0..got.len], got, &reply_buffer); + reply_len = handleAttach(receive[0..got.len], &reply_buffer, &arrived); } } diff --git a/system/drivers/usb-storage/usb-storage.zig b/system/drivers/usb-storage/usb-storage.zig index 70b7c57..6bb5f75 100644 --- a/system/drivers/usb-storage/usb-storage.zig +++ b/system/drivers/usb-storage/usb-storage.zig @@ -146,17 +146,18 @@ fn initialise(endpoint: ipc.Handle) bool { /// Serve the block protocol: geometry, and whole-block read/write to/from the /// caller's DMA buffer (named by physical address). -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = sender; if (message.len < block_protocol.request_size) return 0; const request = std.mem.bytesToValue(block_protocol.Request, message[0..block_protocol.request_size]); switch (request.operation) { @intFromEnum(block_protocol.Operation.attach) => { - // The filesystem's DMA buffer: forward its capability to the controller so - // the device can reach it, then release our copy (the binding holds a ref). - const handle = capability orelse return writeReply(reply, .{ .status = -1, .block_size = 0, .block_count = 0 }); + // The filesystem's DMA buffer: forward its capability to the controller + // so the device can reach it. Never claimed — the binding holds its own + // reference, so our copy is the turn's to close, on this path and on the + // refusal above it alike. + const handle = arrived.peek() orelse return writeReply(reply, .{ .status = -1, .block_size = 0, .block_count = 0 }); const ok = device.attachDma(handle); - _ = ipc.close(handle); return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = 0, .block_count = 0 }); }, @intFromEnum(block_protocol.Operation.geometry) => { @@ -202,7 +203,7 @@ pub fn main(init: process.Init) void { return; }; service.run(block_protocol.message_maximum, .{ - .service = .block, + .service = "block", .init = initialise, .on_message = onMessage, }); diff --git a/system/drivers/usb-xhci-bus/build.zig b/system/drivers/usb-xhci-bus/build.zig index 773a3b4..8183a6a 100644 --- a/system/drivers/usb-xhci-bus/build.zig +++ b/system/drivers/usb-xhci-bus/build.zig @@ -10,9 +10,10 @@ pub fn build(b: *std.Build) void { .name = "usb-xhci-bus", .root_source_file = b.path("usb-xhci-bus.zig"), .imports = &.{ - "device-manager-protocol", "driver", "input-client", "ipc", "logging", "memory", - "mmio", "pci", "process", "service", "time", "usb-abi", "usb-ids", - "usb-transfer-protocol", + "channel", "device-manager-protocol", "driver", "input-client", + "ipc", "logging", "memory", "mmio", + "pci", "process", "service", "time", + "usb-abi", "usb-ids", "usb-transfer-protocol", }, }); b.installArtifact(exe); diff --git a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig index 5249d05..22e46ce 100644 --- a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig +++ b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig @@ -15,6 +15,7 @@ const std = @import("std"); const device = @import("driver"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const service = @import("service"); @@ -68,19 +69,25 @@ const Open = struct { }; var opens = [_]Open{.{}} ** 16; -fn recordOpen(device_token: u64, report_endpoint: usize) void { +/// Remember (or replace) the endpoint that reports for `device_token`. Returns +/// whether the table kept the handle — false means the caller still owns it and +/// must dispose of it. A re-open supersedes the previous endpoint, and the one +/// it displaced is closed here: the table holds exactly one reference per slot. +fn recordOpen(device_token: u64, report_endpoint: usize) bool { for (&opens) |*open| { if (open.used and open.device_token == device_token) { + if (open.report_endpoint != report_endpoint) _ = ipc.close(open.report_endpoint); open.report_endpoint = report_endpoint; - return; + return true; } } for (&opens) |*open| { if (!open.used) { open.* = .{ .used = true, .device_token = device_token, .report_endpoint = report_endpoint }; - return; + return true; } } + return false; // table full: not kept } fn reportEndpointFor(device_token: u64) ?usize { @@ -97,6 +104,22 @@ var controller_id: u64 = device_manager_protocol.no_device; /// manager reads as "meant to stop" — a missing assignment is not a crash loop. fn initialise(endpoint: ipc.Handle) bool { service_endpoint = endpoint; + + // The transfer contract, bound by hand rather than through the harness's + // `.service`, because **losing it is not fatal here**. One machine can carry + // several xHCI controllers and the driver model spawns one process per + // controller, so several processes provide the same contract for different + // hardware — and `/protocol` holds exactly one name, deliberately (addressing + // lives inside the protocol, never in the path). Whoever binds first is the + // one clients reach by name; a later instance still owns its controller, + // enumerates its bus, and reports its children to the device manager, so it + // keeps running. **Known gap:** a class driver behind a second controller + // cannot reach it — the transfer protocol has no controller field for + // `target`, and the fix is either one process multiplexing every controller + // or the spawner wiring the child's channel (P5), not a second name. + if (!channel.bindPatiently("usb-transfer", endpoint)) + _ = logging.write("/system/drivers/usb-xhci-bus: /protocol/usb-transfer is another controller's; serving mine unnamed\n"); + if (!device.claim(controller_id)) { std.log.info("unable to claim controller device {d}", .{controller_id}); return false; @@ -475,28 +498,29 @@ fn reportInterface(manager: ipc.Handle, port: u32, interface: library.InterfaceI /// Serve the USB transfer protocol: a class driver opens its device, then issues /// control / interrupt-subscribe / bulk requests against it. -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = sender; if (message.len < 4) return 0; const operation = std.mem.readInt(u32, message[0..4], .little); return switch (operation) { - @intFromEnum(usb_transfer_protocol.Operation.open) => handleOpen(message, reply, capability), + @intFromEnum(usb_transfer_protocol.Operation.open) => handleOpen(message, reply, arrived), @intFromEnum(usb_transfer_protocol.Operation.control) => handleControl(message, reply), @intFromEnum(usb_transfer_protocol.Operation.interrupt_subscribe) => handleSubscribe(message, reply), @intFromEnum(usb_transfer_protocol.Operation.bulk) => handleBulk(message, reply), - @intFromEnum(usb_transfer_protocol.Operation.dma_attach) => handleDmaAttach(message, reply, capability), + @intFromEnum(usb_transfer_protocol.Operation.dma_attach) => handleDmaAttach(message, reply, arrived), else => 0, }; } /// dma_attach: bind the class driver's DMA-region capability into the controller's IOMMU /// domain, so the controller may DMA to the physical addresses inside that buffer. The -/// binding holds its own kernel reference, so the forwarded capability is closed here. -fn handleDmaAttach(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize { +/// binding holds its own kernel reference, so this never claims the arriving handle — +/// the turn's `defer` in the harness is the close, on the failure paths as well as this +/// one. +fn handleDmaAttach(message: []const u8, reply: []u8, arrived: *ipc.Arrival) usize { if (message.len < @sizeOf(usb_transfer_protocol.DmaAttachRequest)) return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 }); - const handle = capability orelse return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 }); + const handle = arrived.peek() orelse return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 }); const ok = device.dmaBind(controller_id, handle); - _ = ipc.close(handle); return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = if (ok) 0 else -1 }); } @@ -509,13 +533,17 @@ fn writeReply(reply: []u8, value: anytype) usize { /// open: resolve the assigned device id to an interface, remember the caller's /// endpoint (for interrupt reports), and answer with a device token + the /// interface's endpoints so the class driver need not re-read the config. -fn handleOpen(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize { +fn handleOpen(message: []const u8, reply: []u8, arrived: *ipc.Arrival) usize { if (message.len < @sizeOf(usb_transfer_protocol.OpenRequest)) return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 }); const request = std.mem.bytesToValue(usb_transfer_protocol.OpenRequest, message[0..@sizeOf(usb_transfer_protocol.OpenRequest)]); const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 }); const found = engine.findInterface(request.device_id) orelse return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 }); - if (capability) |endpoint| recordOpen(request.device_id, endpoint); + // The report endpoint is claimed only if the open table actually keeps it; + // a full table leaves it to the turn to close. + if (arrived.peek()) |endpoint| { + if (recordOpen(request.device_id, endpoint)) _ = arrived.take(); + } var open_reply = usb_transfer_protocol.OpenReply{ .status = 0, @@ -673,7 +701,8 @@ pub fn main(init: process.Init) void { return; }; service.run(usb_transfer_protocol.message_maximum, .{ - .service = .usb_bus, + // No `.service`: the contract is bound inside `initialise`, where losing + // it to another controller's driver is survivable rather than fatal. .init = initialise, .on_message = onMessage, .on_notification = onNotification, diff --git a/system/drivers/virtio-gpu/build.zig b/system/drivers/virtio-gpu/build.zig index 0d20b33..74e2ba5 100644 --- a/system/drivers/virtio-gpu/build.zig +++ b/system/drivers/virtio-gpu/build.zig @@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void { .name = "virtio-gpu", .root_source_file = b.path("virtio-gpu.zig"), .imports = &.{ - "display-protocol", "driver", "ipc", "logging", "memory", "mmio", "pci", "process", - "scanout-protocol", "service", "time", + "channel", "display-protocol", "driver", "ipc", "logging", "memory", "mmio", "pci", + "process", "scanout-protocol", "service", "time", }, }); b.installArtifact(exe); diff --git a/system/drivers/virtio-gpu/virtio-gpu.zig b/system/drivers/virtio-gpu/virtio-gpu.zig index fa1bcec..4f5af7c 100644 --- a/system/drivers/virtio-gpu/virtio-gpu.zig +++ b/system/drivers/virtio-gpu/virtio-gpu.zig @@ -15,6 +15,7 @@ const std = @import("std"); const device = @import("driver"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const service = @import("service"); @@ -475,7 +476,7 @@ fn presentFull() bool { fn announce() void { var tries: u32 = 0; const display = while (tries < 50) : (tries += 1) { - if (ipc.lookup(.display)) |h| break h; + if (channel.openEndpoint("display")) |h| break h; time.sleepMillis(20); } else { std.log.info("no display service to announce to (scanout-only)", .{}); @@ -507,9 +508,9 @@ fn scanoutStatus(reply: []u8, ok: bool) usize { /// The `.scanout` service: the compositor drives present / mode queries here. The pixels are /// already in the shared surface, so a present is a transfer-to-host + fenced flush; a mode /// change just re-points the scanout rectangle (the surface is sized to the largest mode). -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = sender; - _ = capability; + _ = arrived; // nothing here takes a capability: the harness closes what arrives if (message.len < scanout_protocol.request_size) return 0; const request = std.mem.bytesToValue(scanout_protocol.Request, message[0..scanout_protocol.request_size]); switch (request.operation) { @@ -547,7 +548,7 @@ pub fn main(init: process.Init) void { return; }; service.run(256, .{ - .service = .scanout, + .service = "scanout", .init = initialise, .on_message = onMessage, }); diff --git a/system/kernel/ipc-synchronous.zig b/system/kernel/ipc-synchronous.zig index 34cd43d..6fe3647 100644 --- a/system/kernel/ipc-synchronous.zig +++ b/system/kernel/ipc-synchronous.zig @@ -40,16 +40,13 @@ const Task = scheduler.Task; pub const MESSAGE_MAXIMUM: usize = 256; pub const maximum_handles = scheduler.ipc_maximum_handles; -// The name registry is indexed directly by ServiceId, so this must exceed the -// largest id (currently fat = 8). Sized with headroom for new services. -pub const maximum_services = 16; /// Errno-style failures, returned as `-value` in the system_call result register. pub const EBADF: i64 = 1; // bad handle pub const E2BIG: i64 = 2; // message exceeds MESSAGE_MAXIMUM pub const EFAULT: i64 = 3; // buffer unmapped / out of the user half -pub const ENOENT: i64 = 4; // no such registered service -pub const ENOSPC: i64 = 5; // handle table or registry full +pub const ENOENT: i64 = 4; // no such name +pub const ENOSPC: i64 = 5; // handle table full pub const ENOMEM: i64 = 6; // out of memory pub const EPEER: i64 = 7; // peer died before replying (its process exited or was killed) pub const ESRCH: i64 = 8; // no such process (process_kill of an unknown/dead id) @@ -90,12 +87,21 @@ const PostSlot = struct { const user_half_end: u64 = user_memory.user_half_end; /// A rendezvous endpoint. Allocated from the kernel heap; referenced by handle -/// (per process) and/or by a registry slot, counted by `refcount`. +/// (per process) and by whoever a capability was passed to, counted by `refcount`. pub const Endpoint = struct { refcount: u32 = 1, + /// Next in the list of every live endpoint. Endpoints are otherwise reachable + /// only through the handle tables that name them, and the death path has to + /// find a dying task's endpoints without one — see `live_endpoints`. + next_live: ?*Endpoint = null, // The task that created it. When that task dies, the endpoint is marked `dead` so a caller // gets -EPEER instead of blocking forever on a service that will never reply again (V6). owner: u32 = 0, + // The *process* that created it — `owner`'s leader, snapshotted at creation so the + // answer survives the creating thread. `owner` alone cannot answer "is this mine?" + // for a threaded service, and the question has to be answerable after that thread is + // gone; see `ownedBy`. + owner_leader: u32 = 0, dead: bool = false, // Callers blocked in `call`, awaiting receive, in FIFO order (threaded via // Task.next; each such task is .blocked and in no scheduler queue). @@ -115,29 +121,78 @@ pub const Endpoint = struct { post_tail: u16 = 0, }; +/// Every live endpoint, singly linked through `next_live`. The list exists for +/// exactly one purpose: the death path must mark a dying task's endpoints dead, +/// and a handle table only answers the other question (which endpoints does this +/// task *hold*). Mutated under the big kernel lock, like every other IPC global. +var live_endpoints: ?*Endpoint = null; + pub fn createIpcEndpoint() ?*Endpoint { + const creator = scheduler.current(); const endpoint = heap.allocator().create(Endpoint) catch return null; - endpoint.* = .{ .owner = scheduler.currentId() }; + endpoint.* = .{ .owner = creator.id, .owner_leader = creator.leader, .next_live = live_endpoints }; + live_endpoints = endpoint; return endpoint; } -/// A task is dying: kill the endpoints it registered as services. Mark each `dead` (so a later -/// `call` returns -EPEER rather than blocking on a reply that will never come), wake anyone -/// already parked sending to it with that error, and vacate its registry slot. Only *registered* -/// endpoints are reachable from here; unregistered ones drop with the task's handle table. The -/// caller holds the big kernel lock (this runs on the death path). See docs/display-v2.md (V6). +/// Whether `t` may have the kernel post **notifications** — signals, timer +/// landings, exit notices, interrupts — into `endpoint`: whether the endpoint is +/// its process's own. +/// +/// Holding a *handle* to an endpoint is not ownership of it. `fs_resolve` +/// installs a mounted backend's capability in any caller's table +/// (`installHandleDeduped`), and any capability may be passed along a call, so a +/// sendable handle means only "you may talk to this". A kernel notification is +/// different in kind: it makes the kernel speak *into* someone else's mailbox +/// with a badge that receiver cannot distinguish from one it asked for — a +/// genuine signal badge, a genuine timer landing. That is how a forged +/// `terminate` reached PID 1's shutdown path: the attacker aimed **its own** +/// signal delivery at init's endpoint with `signal_bind` and then signalled +/// itself, and every bit the kernel stamped was authentic. Refusing the *bind* +/// is the only place the distinction still exists. +/// +/// Threads: ownership is the **process's**, not the task's, so any thread may +/// bind an endpoint a sibling created — the same normalization `process_signal` +/// and `process_kill` perform when they resolve a member to its leader. The +/// creating task's own id is honoured too, which is what keeps kernel tasks +/// (leader 0) from being treated as one process. +pub fn ownedBy(endpoint: *const Endpoint, t: *const Task) bool { + if (endpoint.owner == t.id) return true; + return t.leader != 0 and endpoint.owner_leader == t.leader; +} + +/// Unlink a freed endpoint from the live list. O(n) in the number of live +/// endpoints, which is tens. +fn forgetEndpoint(endpoint: *Endpoint) void { + var link = &live_endpoints; + while (link.*) |current| { + if (current == endpoint) { + link.* = current.next_live; + return; + } + link = ¤t.next_live; + } +} + +/// A task is dying: kill every endpoint it created. Mark each `dead` (so a later +/// `call` returns -EPEER rather than blocking on a reply that will never come) and wake +/// anyone already parked sending to it with that error. This is what makes a provider's +/// death visible to the clients holding its capability — the naming layer's restart +/// story (a client re-resolves on -EPEER) rests on it, as does the VFS router's lazy +/// unmount of a backend that died. The endpoint object itself lives until the last +/// handle naming it drops. The caller holds the big kernel lock (this runs on the death +/// path). See docs/display-v2.md (V6). pub fn killOwnedEndpointsLocked(task_id: u32) void { - for (®istry) |*slot| { - const endpoint = slot.* orelse continue; - if (endpoint.owner != task_id) continue; + var current = live_endpoints; + while (current) |endpoint| { + current = endpoint.next_live; + if (endpoint.owner != task_id or endpoint.dead) continue; endpoint.dead = true; while (dequeueSender(endpoint)) |sender| { sender.ipc_status = -EPEER; sender.ipc_received_cap = abi.no_cap; scheduler.readyLocked(sender); } - slot.* = null; - dropRef(endpoint); } } @@ -147,6 +202,7 @@ pub fn dropRef(endpoint: *Endpoint) void { if (endpoint.refcount > 1) { endpoint.refcount -= 1; } else { + forgetEndpoint(endpoint); heap.allocator().destroy(endpoint); } } @@ -633,22 +689,9 @@ fn dropEntry(entry: scheduler.HandleObject) void { } } -var registry: [maximum_services]?*Endpoint = .{null} ** maximum_services; - -/// Publish `endpoint` under well-known `id` (takes a reference). Returns 0 or -errno. -pub fn register(id: u32, endpoint: *Endpoint) i64 { - if (id >= maximum_services) return -ENOENT; - if (registry[id]) |old| dropRef(old); - endpoint.refcount += 1; - registry[id] = endpoint; - return 0; -} - -/// Find the endpoint published under `id`, taking a reference for the caller to -/// install in its handle table. Null if nothing is registered there. -pub fn lookup(id: u32) ?*Endpoint { - if (id >= maximum_services) return null; - const endpoint = registry[id] orelse return null; - endpoint.refcount += 1; - return endpoint; -} +// The flat `ServiceId` registry lived here — a 16-slot table any process could +// write, indexed by a compile-time enum. Naming is user-space's job now: init +// serves `/protocol` and decides who may claim a name +// (docs/os-development/protocol-namespace.md). The kernel keeps only what is +// genuinely kernel work — moving capabilities and telling clients their provider +// died (`killOwnedEndpointsLocked`). diff --git a/system/kernel/irq.zig b/system/kernel/irq.zig index bd04fd4..88f385b 100644 --- a/system/kernel/irq.zig +++ b/system/kernel/irq.zig @@ -47,8 +47,8 @@ pub const maximum_gsi = 24; var bound: [maximum_gsi]?*ipc_sync.Endpoint = .{null} ** maximum_gsi; /// Task that owns each binding. Teardown is keyed on *this*, not on the endpoint -/// pointer: an endpoint can be shared between processes (ipc_register/ipc_lookup hand -/// out extra references), so "every GSI pointing at this endpoint" is not the same +/// pointer: an endpoint can be shared between processes (a capability passed in a message +/// hands out extra references), so "every GSI pointing at this endpoint" is not the same /// set as "every GSI this process bound", and releasing the former on exit would mask /// a live sibling's device line. var bound_owner: [maximum_gsi]u32 = .{0} ** maximum_gsi; diff --git a/system/kernel/process.zig b/system/kernel/process.zig index fc0d668..22ca4af 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -229,8 +229,6 @@ fn system_call(state: *architecture.CpuState) void { .mmap => systemMmap(state), .munmap => systemMunmap(state), .create_ipc_endpoint => systemCreateIpcEndpoint(state), - .ipc_register => systemIpcRegister(state), - .ipc_lookup => systemIpcLookup(state), .ipc_call => systemIpcCall(state), .ipc_reply_wait => systemIpcReplyWait(state), .ipc_send => systemIpcSend(state), @@ -320,36 +318,6 @@ fn systemCreateIpcEndpoint(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, @intCast(h)); } -/// ipc_register(service_id, handle): publish the caller's endpoint under a -/// well-known id so other processes can find it. -fn systemIpcRegister(state: *architecture.CpuState) void { - // Under the big kernel lock: mutates the global service registry and endpoint - // refcounts, which threads of the same (or another) process can race. - const flags = sync.enter(); - defer sync.leave(flags); - const id: u32 = @truncate(architecture.systemCallArg(state, 0)); - const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF); - architecture.setSystemCallResult(state, @bitCast(ipc.register(id, endpoint))); -} - -/// ipc_lookup(service_id) -> handle: find a published endpoint and install a -/// handle to it in the caller. -fn systemIpcLookup(state: *architecture.CpuState) void { - // Under the big kernel lock: reads the global registry, takes an endpoint reference, - // and installs a handle — all racy against concurrent threads (this is the path the - // display's mouse-listener thread takes to reach the compositor endpoint). - const flags = sync.enter(); - defer sync.leave(flags); - const id: u32 = @truncate(architecture.systemCallArg(state, 0)); - const endpoint = ipc.lookup(id) orelse return failErr(state, ipc.ENOENT); - const h = ipc.installHandle(scheduler.current(), endpoint); - if (h < 0) { - ipc.dropRef(endpoint); - return failErr(state, ipc.ENOSPC); - } - architecture.setSystemCallResult(state, @intCast(h)); -} - /// ipc_call(handle, message_ptr, message_len, reply_ptr, reply_cap) -> reply_len. /// Blocks until the server replies; the trap frame lives on this task's kernel /// stack, so it survives the block and receives the result on resume. @@ -989,10 +957,17 @@ fn systemSpawn(state: *architecture.CpuState) void { if (len == 0 or len > scheduler.maximum_task_name or ptr >= user_half_end or ptr + len > user_half_end) return fail(state); if (arguments_len > maximum_argument_bytes) return fail(state); if (arguments_len != 0 and (arguments_ptr >= user_half_end or arguments_ptr + arguments_len > user_half_end)) return fail(state); + // The exit endpoint is a notification binding like signal_bind's and + // timer_bind's, so it obeys the same rule: the caller's own mailbox, never a + // stranger's. Otherwise any process could have the kernel post child-exit + // badges into PID 1 by spawning throwaway children against init's endpoint. const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap) null - else - ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); + else block: { + const endpoint = ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); + if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); + break :block endpoint; + }; const image = ramdisk_image orelse return fail(state); const rd = initial_ramdisk.Reader.init(image) orelse return fail(state); @@ -1040,11 +1015,15 @@ fn systemThreadSpawn(state: *architecture.CpuState) void { if (t.address_space == 0) return fail(state); // kernel tasks own no address space to share if (entry == 0 or entry >= user_half_end) return fail(state); if (stack_top == 0 or stack_top > user_half_end) return fail(state); - // The endpoint the thread notifies on exit (how join waits), or none. + // The endpoint the thread notifies on exit (how join waits), or none — the + // caller's own, like every other notification binding. const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap) null - else - ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); + else block: { + const endpoint = ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); + if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); + break :block endpoint; + }; const tid = spawnThreadSupervised(t.address_space, entry, stack_top, arg, t.priority, t.id, exit_endpoint, t.leader); if (tid == -ipc.ESRCH) return failErr(state, ipc.ESRCH); // dying group admits no member if (tid < 0) return fail(state); @@ -1538,13 +1517,20 @@ const exit_subscriber_capacity = 8; const ExitSubscriber = struct { endpoint: *ipc.Endpoint, owner: u32 }; var exit_subscribers: [exit_subscriber_capacity]?ExitSubscriber = .{null} ** exit_subscriber_capacity; -/// process_subscribe(endpoint): subscribe the caller's endpoint to published exit -/// events. Ungated, like process_enumerate — what is running (and dying) is not a -/// secret between cooperating processes. -ENOSPC when the table is full. +/// process_subscribe(endpoint): subscribe the **caller's own** endpoint to +/// published exit events. *Which* deaths one may hear of is ungated, like +/// process_enumerate — what is running (and dying) is not a secret between +/// cooperating processes. *Whose mailbox* they land in is not: the endpoint must +/// be the caller's (`ipc.ownedBy`), or any process could aim the firehose at a +/// stranger — filling PID 1's mailbox with exit notices it reads as its own +/// children's, and spending the eight-slot table so the services that need +/// deaths (the VFS's handle sweep) cannot subscribe at all. -EPERM otherwise, +/// -ENOSPC when the table is full. fn systemProcessSubscribe(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); + if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); const flags = sync.enter(); defer sync.leave(flags); for (&exit_subscribers) |*slot| { @@ -1561,10 +1547,19 @@ fn systemProcessSubscribe(state: *architecture.CpuState) void { /// IRQ-as-IPC pattern a fourth time (docs/process-lifecycle.md). Replacing a /// binding drops the old reference; signals that pended while unbound are /// delivered immediately on bind, coalesced into one notification. +/// +/// The endpoint must be the caller's own (`ipc.ownedBy`), or `signal_bind` +/// becomes a signal *forgery* primitive: `process_signal` is deliberately loose +/// about the target (a task may always signal itself) because the delivery point +/// was assumed to be the target's own mailbox. Aim it elsewhere and a stranger +/// signalling itself makes the kernel stamp a genuine `terminate` badge into +/// somebody else's queue — which is a shutdown request PID 1 has no way to +/// disbelieve. fn systemSignalBind(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); + if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); const flags = sync.enter(); defer sync.leave(flags); if (t.signal_endpoint) |raw| ipc.dropRef(@ptrCast(@alignCast(raw))); @@ -1633,11 +1628,19 @@ fn timerSweepLocked() void { } } -/// timer_bind(endpoint, ms): arm a one-shot timer. -ENOSPC when the table is full. +/// timer_bind(endpoint, ms): arm a one-shot timer on an endpoint of the caller's +/// own (`ipc.ownedBy`; -EPERM otherwise). A timer landing carries no identity — +/// that is the whole reason a service may keep exactly one in flight — so a +/// timer armed on someone else's endpoint is indistinguishable from one they +/// armed themselves, and a loop that re-arms on every landing (init's heartbeat) +/// multiplies: N forged timers leave N+1 self-perpetuating beats. The +/// sixteen-slot table is a shared resource on top of that. -ENOSPC when it is +/// full. fn systemTimerBind(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); + if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); const ms = architecture.systemCallArg(state, 1); const flags = sync.enter(); defer sync.leave(flags); @@ -1677,12 +1680,17 @@ fn ownedGsi(t: *scheduler.Task, device_id: u64, resource_index: u64) ?u32 { /// irq_bind(device_id, resource_index, endpoint) -> 0/-1: deliver that device's IRQ to the /// endpoint as an asynchronous IPC notification. The driver then blocks in /// IPC_ReplyWait and is woken by the ISR; see system/kernel/irq.zig for the cycle. +/// Two gates, both necessary: the device must be *claimed* by the caller +/// (`ownedGsi`), and the endpoint must be the caller's own (`ipc.ownedBy`) — a +/// claim entitles a driver to its own interrupts, not to post them into a +/// stranger's mailbox. fn systemIrqBind(state: *architecture.CpuState) void { const t = scheduler.current(); if (t.address_space == 0) return fail(state); const gsi = ownedGsi(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1)) orelse return fail(state); const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 2)) orelse return fail(state); + if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); const flags = sync.enter(); defer sync.leave(flags); @@ -1703,6 +1711,7 @@ fn systemMsiBind(state: *architecture.CpuState) void { const owner = devices_broker.ownerOf(device_id) orelse return fail(state); if (owner != t.id) return fail(state); // not claimed by this process const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF); + if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); // interrupts land in your own mailbox const flags = sync.enter(); defer sync.leave(flags); diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index d783a1c..62fc4f9 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -246,6 +246,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { containmentTest(); } else if (eql(case, "device-manager")) { deviceManagerTest(boot_information); + } else if (eql(case, "protocol-registry")) { + protocolRegistryTest(boot_information); } else if (eql(case, "reboot")) { rebootTest(); } else { @@ -2209,7 +2211,7 @@ fn processKillTest(boot_information: *const BootInformation) void { while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue; - spinner = process.spawnProcessSupervised(item.blob, 4, &.{ "process-test", "spinner" }, me, endpoint) catch 0; + spinner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "spinner" }, me, endpoint) catch 0; break; } check("process-test spawned as the supervised spinner victim", spinner != 0); @@ -2395,13 +2397,14 @@ fn signalsTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); // the parent system_spawns its children by name + _ = spawnRegistry(rd); // the service child binds /protocol/test/process process.write_count = 0; var runner: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue; - runner = process.spawnProcessSupervised(item.blob, 4, &.{ "process-test", "signal-run" }, scheduler.currentId(), null) catch 0; + runner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "signal-run" }, scheduler.currentId(), null) catch 0; break; } check("signal-run parent spawned", runner != 0); @@ -2443,13 +2446,14 @@ fn driverRestartTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); // the manager system_spawns drivers by name + _ = spawnRegistry(rd); // the drivers bind their contracts process.write_count = 0; var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-restart" }, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-restart" }, scheduler.currentId(), null) catch 0; break; } check("device-manager spawned in test-restart mode", manager != 0); @@ -2482,12 +2486,13 @@ fn usbReportTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the xhci driver binds /protocol/usb-transfer var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-usb-restart" }, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0; break; } check("device-manager spawned in test-usb-restart mode", manager != 0); @@ -2514,12 +2519,13 @@ fn deviceListTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the fixture opens /protocol/device-manager var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-usb-restart" }, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0; break; } check("device-manager spawned in test-usb-restart mode", manager != 0); @@ -2548,13 +2554,14 @@ fn pciCapsTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the manager binds /protocol/device-manager // Plain mode — no restart drill, whose kill would race the fixture's claim. var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0; break; } check("device-manager spawned", manager != 0); @@ -2582,12 +2589,13 @@ fn iommuFaultTest(boot_information: *const BootInformation) void { check("IOMMU enabled for the enforcement test", iommu.enabled()); process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the manager binds /protocol/device-manager var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0; break; } check("device-manager spawned", manager != 0); @@ -2623,12 +2631,13 @@ fn pciScanTest(boot_information: *const BootInformation) void { check("the kernel seeded no PCI functions (the walk retired)", brokerPciCount(&buffer) == 0); process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the manager binds /protocol/device-manager var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-pci-restart" }, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-pci-restart" }, scheduler.currentId(), null) catch 0; break; } check("device-manager spawned (test-pci-restart mode)", manager != 0); @@ -2776,12 +2785,13 @@ fn acpiReportTest(boot_information: *const BootInformation) void { return; }; process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the manager and the acpi service bind theirs var spawned = false; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - _ = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0; + _ = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0; spawned = true; break; } @@ -2819,7 +2829,7 @@ fn acpiParseTest(boot_information: *const BootInformation) void { while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "discovery")) continue; - _ = process.spawnProcessSupervised(item.blob, 4, &.{ "discovery", "1" }, scheduler.currentId(), null) catch 0; + _ = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "1" }, scheduler.currentId(), null) catch 0; spawned = true; break; } @@ -2854,7 +2864,7 @@ fn supervisionTest(boot_information: *const BootInformation) void { while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue; - started = if (process.spawnProcess(item.blob, 4, &.{ "process-test", "run" })) true else |_| false; + started = if (process.spawnProcess(item.blob, 4, &.{ item.name, "run" })) true else |_| false; break; } check("process-test spawned as the user-space supervisor", started); @@ -2996,6 +3006,9 @@ fn inputTest(boot_information: *const BootInformation) void { process.write_count = 0; process.write_from_user = false; + // init (the registry, below) reads its manifests through the kernel VFS. + process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the input service binds /protocol/input _ = spawnNamed(rd, "input"); // the fan-out service _ = spawnNamed(rd, "input-source"); // a synthetic keyboard publishing events _ = spawnNamed(rd, "input-test"); // the subscriber whose "ok" line is the marker @@ -3037,6 +3050,10 @@ fn displayServiceTest(boot_information: *const BootInformation) void { return; }; + // init (the registry) reads its manifests through the kernel VFS. + process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the compositor binds /protocol/display + // Spawn the compositor and hand it the core. Its own serial heartbeats — `display: // online WxH` and `display: presented frame 0` — are what the harness matches (it // reads serial directly, like the fault cases). We don't poll for them in-kernel: a @@ -3073,6 +3090,9 @@ fn displayCursorTest(boot_information: *const BootInformation) void { return; }; + // init (the registry, below) reads its manifests through the kernel VFS. + process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // input and display bind theirs if (!spawnNamed(rd, "input")) { log("display-cursor: could not spawn the input service\n", .{}); result(); @@ -3113,6 +3133,9 @@ fn displayDemoTest(boot_information: *const BootInformation) void { return; }; + // init (the registry, below) reads its manifests through the kernel VFS. + process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the compositor binds /protocol/display if (!spawnNamed(rd, "display")) { log("display-demo: could not spawn the display service\n", .{}); result(); @@ -3148,6 +3171,9 @@ fn sharedMemoryTest(boot_information: *const BootInformation) void { return; }; + // init (the registry, below) reads its manifests through the kernel VFS. + process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the server binds /protocol/test/shared-memory if (!spawnNamed(rd, "shared-memory-server")) { log("shared-memory: could not spawn shared-memory-server\n", .{}); result(); @@ -3185,12 +3211,13 @@ fn virtioGpuTest(boot_information: *const BootInformation) void { // from the kernel device tree, spawns pci-bus, and matches the virtio-gpu class triple to // spawn our driver with the function's device id as argv[1]. process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the driver binds /protocol/scanout var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0; break; } if (manager == 0) { @@ -3226,12 +3253,13 @@ fn displayNativeTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // display, the manager, and the driver bind theirs var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0; break; } if (manager == 0) { @@ -3270,12 +3298,13 @@ fn displayReattachTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // display and the restarted driver bind theirs var manager: u32 = 0; var i: u32 = 0; while (i < rd.count) : (i += 1) { const item = rd.entry(i) orelse continue; if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue; - manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-scanout-restart" }, scheduler.currentId(), null) catch 0; + manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-scanout-restart" }, scheduler.currentId(), null) catch 0; break; } if (manager == 0) { @@ -3625,6 +3654,21 @@ fn threadTestMarkerCase(boot_information: *const BootInformation, case_name: []c result(); } +/// Bring up the protocol namespace for a scenario that spawns its providers +/// itself. `/protocol` is served by init, PID 1 — but a scenario case wants the +/// naming layer without init's whole service list underneath it, so init is +/// started in its `registry` role: it mounts `/protocol`, reads the grants, and +/// spawns nothing (docs/os-development/protocol-namespace.md; the plan's +/// decision 9). Providers retry their bind, so racing the mount is survivable — +/// but calling this first makes the race rare. +/// +/// The caller must have published the initial ramdisk already +/// (`process.setInitialRamdisk`): init reads its manifests out of it, and every +/// `/protocol` resolve goes through the same kernel VFS. +fn spawnRegistry(rd: initial_ramdisk.Reader) bool { + return spawnNamedWithArg(rd, "init", "registry"); +} + fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool { var i: u32 = 0; while (i < rd.count) : (i += 1) { @@ -3745,6 +3789,60 @@ fn childDescriptor(hid: []const u8, start: u64, len: u64) device_abi.DeviceDescr /// match `pci-bus`, and spawn it (with the bridge id as its argument) — and the spawned /// pci-bus must reach its own live marker. It uses no special privilege — the same /// `device_enumerate` any process could call. +/// P2 — the registrar (docs/os-development/protocol-namespace.md). Bring up +/// `/protocol` (init in its registry role) and hand the fixture the core: it +/// asserts that an ungranted bind is refused, that the kernel's reserved prefix +/// holds, that a name a live provider holds cannot be taken, and that killing a +/// provider makes its channel fail while re-resolving the same name reaches the +/// restarted instance. +/// +/// It doubles as the security case for PID 1's shared mailbox, since resolving +/// `/protocol` hands every process a sendable handle to it: a forged power +/// payload, a redirected terminate signal, a timer or exit subscription armed on +/// a foreign endpoint, and capability-carrying ping storms against both PID 1 and +/// a harness-run service. Those assertions kill the boot when they regress rather +/// than printing anything, which is the strongest form available here. +/// +/// The fixture's `protocol-registry: ok` is the marker; each step also prints its +/// own line, which the harness's ordered regex reads. +fn protocolRegistryTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: protocol-registry\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; + }; + + // The fixture spawns its own providers by name, so the ramdisk must be + // published; init then mounts /protocol over the same kernel VFS. + process.setInitialRamdisk(image); + check("registry (init) spawned", spawnRegistry(rd)); + check("protocol-registry-test spawned", spawnNamedWithArg(rd, "protocol-registry-test", "run")); + + const pass_marker = "protocol-registry: ok"; + const fail_marker = "protocol-registry: 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 registry contract failed", !saw_fail); + check("the fixture completed every registry assertion", saw_pass); + result(); +} + fn deviceManagerTest(boot_information: *const BootInformation) void { log("DANOS-TEST-BEGIN: device-manager\n", .{}); if (boot_information.initial_ramdisk_len == 0) { @@ -3764,6 +3862,7 @@ fn deviceManagerTest(boot_information: *const BootInformation) void { // all, it's because the manager discovered the PCI host bridge, matched, and // spawned it. process.setInitialRamdisk(image); + _ = spawnRegistry(rd); // the manager binds /protocol/device-manager process.write_count = 0; process.write_from_user = false; @@ -3845,9 +3944,9 @@ fn hpetDeviceId() ?u64 { /// 2. After `releaseOwner` for the binding's owner, that same entry is masked again. /// /// And one property that can only be checked from kernel state: a *different* owner's -/// binding on the same endpoint survives. Endpoints are shared (ipc_register hands out -/// references), so teardown keyed on the endpoint pointer rather than the owning task -/// would mask a live sibling driver's device line. +/// binding on the same endpoint survives. Endpoints are shared (a capability passed in a +/// message hands out extra references), so teardown keyed on the endpoint pointer rather +/// than the owning task would mask a live sibling driver's device line. fn irqFreeTest() void { log("DANOS-TEST-BEGIN: irqfree\n", .{}); diff --git a/system/kernel/vfs.zig b/system/kernel/vfs.zig index 830c3f1..4cdca03 100644 --- a/system/kernel/vfs.zig +++ b/system/kernel/vfs.zig @@ -168,6 +168,11 @@ fn installMount(prefix: []const u8, kind: MountKind, backend: ?*ipc.Endpoint, re var slot: ?*Mount = null; for (&mounts) |*m| { if (m.used and std.mem.eql(u8, m.prefixSlice(), prefix)) { + // ...except the protocol namespace. Remount-replace is how a + // restarted FAT retakes /volumes/usb; letting it retake /protocol + // would hand the whole naming layer to whoever asked second. + // First mount wins, and init (PID 1) is always first. + if (std.mem.eql(u8, prefix, protocol_root)) return; if (m.backend) |old| ipc.dropRef(old); slot = m; break; @@ -346,6 +351,30 @@ fn isInitrdCarveOut(prefix: []const u8) bool { return false; } +/// The protocol namespace's root — a reserved prefix, like the initrd trees. +/// Init (PID 1) mounts the registry here once at boot and the prefix then +/// refuses everything: a second mount at it, any mount *under* it (which would +/// shadow one contract), and its unmount. That is the whole kernel-side residue +/// of the naming layer — the registrar authority itself never leaves init +/// (docs/os-development/protocol-namespace.md). +const protocol_root = "/protocol"; + +fn protocolBound() bool { + for (&mounts) |*m| { + if (m.used and std.mem.eql(u8, m.prefixSlice(), protocol_root)) return true; + } + return false; +} + +/// Whether mounting at `prefix` would touch the protocol namespace. Exactly +/// `/protocol` is allowed once — while nothing holds it; anything under it, +/// ever, is refused. +fn refusesProtocolMount(prefix: []const u8) bool { + const relative = underMount(prefix, protocol_root) orelse return false; + if (relative.len != 1) return true; // strictly under /protocol: never + return protocolBound(); // /protocol itself: first mount wins +} + /// Mount `backend` at `prefix` with an optional backend-side `rewrite` prefix. /// The endpoint reference is taken by the caller (process.zig bumps it); refuses /// shadowing or replacing the initrd trees (/system, /test) — except the two @@ -353,6 +382,7 @@ fn isInitrdCarveOut(prefix: []const u8) bool { pub fn mountBackend(prefix: []const u8, backend: *ipc.Endpoint, rewrite: []const u8) bool { if (!isAbsolute(prefix) or prefix.len < 2 or prefix.len > maximum_prefix) return false; if (rewrite.len > maximum_rewrite) return false; + if (refusesProtocolMount(prefix)) return false; // the registry's prefix is claimed once for (&mounts) |*m| { // the initrd trees are not shadowable (carve-outs aside) if (m.used and m.kind == .kernel_initrd and underMount(prefix, m.prefixSlice()) != null) { if (!isInitrdCarveOut(prefix)) return false; @@ -363,6 +393,9 @@ pub fn mountBackend(prefix: []const u8, backend: *ipc.Endpoint, rewrite: []const } pub fn unmount(prefix: []const u8) bool { + // Unmounting /protocol would delete the naming layer for everyone; nobody + // may, init included. The mount lasts the boot. + if (std.mem.eql(u8, prefix, protocol_root)) return false; for (&mounts) |*m| { if (m.used and m.kind == .backend and std.mem.eql(u8, m.prefixSlice(), prefix)) { if (m.backend) |endpoint| ipc.dropRef(endpoint); diff --git a/system/services/acpi/acpi.zig b/system/services/acpi/acpi.zig index fe5f92b..f56978e 100644 --- a/system/services/acpi/acpi.zig +++ b/system/services/acpi/acpi.zig @@ -12,6 +12,7 @@ const std = @import("std"); const device = @import("driver"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const service = @import("service"); @@ -189,14 +190,32 @@ pub fn main(init: process.Init) void { readFadt(fadt); s5_valid = readSleepS5(&persistent_namespace); + // Every name this service needs, resolved before it becomes a provider — see + // `manager_channel`. Best-effort, as it has always been: a standalone + // bring-up with no device manager still serves power. + manager_channel = channel.openEndpoint("device-manager"); + service.run(power_protocol.message_maximum, .{ - .service = .power, + .service = "power", .init = onInit, .on_message = onMessage, .on_notification = onNotification, }); } +/// The device manager's channel, opened **before** this service binds its own +/// contract — deliberately, and load-bearing. +/// +/// init is the registrar, and init is also this service's one subscriber: the +/// moment `power` is bound, init calls us to subscribe. init has a single thread, +/// so while it is blocked in that call it cannot answer anyone — including us. If +/// we opened a name after binding, the two could cross: init blocked calling us, +/// us blocked asking init to resolve a name, neither ever replying. Resolving +/// everything we need first makes that impossible, because after the bind this +/// service only ever talks to the device manager (which never calls init) and +/// then parks in the harness loop, where init's subscribe lands. +var manager_channel: ?ipc.Handle = null; + // Static so the harness callbacks (which run after main's stack frame is gone) // can reach the namespace and interpreter. var persistent_namespace: aml.Namespace = undefined; @@ -209,7 +228,7 @@ fn onInit(endpoint: ipc.Handle) bool { registered_count = 0; walkDevices(persistent_namespace.root, &global_interpreter); - const manager = ipc.lookup(.device_manager); + const manager = manager_channel; var i: usize = 0; while (i < registered_count) : (i += 1) { const entry = registered[i]; @@ -433,15 +452,17 @@ fn onNotification(badge: u64) void { /// The `.power` protocol: subscribe (endpoint as the call's capability), /// shutdown (PID 1 only). Device discovery uses a different endpoint (the /// device manager's), so nothing here handles ChildAdded. -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { if (message.len < 1) return 0; switch (message[0]) { @intFromEnum(power_protocol.Operation.subscribe) => { + // The subscriber's endpoint is claimed only when a slot takes it; + // a full table refuses and the turn closes what arrived. var status: i32 = -1; - if (capability) |handle| { + if (arrived.peek() != null) { for (&subscribers, 0..) |*slot, si| { if (slot.* == null) { - slot.* = handle; + slot.* = arrived.take(); subscriber_tasks[si] = sender; status = 0; break; diff --git a/system/services/acpi/build.zig b/system/services/acpi/build.zig index a2ce806..96fb107 100644 --- a/system/services/acpi/build.zig +++ b/system/services/acpi/build.zig @@ -14,8 +14,8 @@ pub fn build(b: *std.Build) void { .name = "discovery", .root_source_file = b.path("acpi.zig"), .imports = &.{ - "acpi-ids", "aml", "device-manager-protocol", "driver", "ipc", "logging", "memory", - "power-protocol", "process", "service", "time", + "acpi-ids", "aml", "channel", "device-manager-protocol", "driver", "ipc", "logging", + "memory", "power-protocol", "process", "service", "time", }, }); b.installArtifact(exe); diff --git a/system/services/device-manager/device-manager.zig b/system/services/device-manager/device-manager.zig index ef06411..9d7e6e3 100644 --- a/system/services/device-manager/device-manager.zig +++ b/system/services/device-manager/device-manager.zig @@ -395,13 +395,13 @@ fn initialise(endpoint: ipc.Handle) bool { return true; } -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { if (message.len < 1) return 0; switch (message[0]) { @intFromEnum(device_manager_protocol.Operation.child_added) => return onChildAdded(message, reply, sender), @intFromEnum(device_manager_protocol.Operation.child_removed) => return onChildRemoved(message, reply, sender), @intFromEnum(device_manager_protocol.Operation.enumerate) => return onEnumerate(reply), - @intFromEnum(device_manager_protocol.Operation.subscribe) => return onSubscribe(reply, capability), + @intFromEnum(device_manager_protocol.Operation.subscribe) => return onSubscribe(reply, arrived), @intFromEnum(device_manager_protocol.Operation.hello) => {}, else => return 0, } @@ -532,13 +532,15 @@ fn onEnumerate(reply: []u8) usize { return offset; } -/// An application subscribed: its endpoint arrived as the call's capability. -fn onSubscribe(reply: []u8, capability: ?ipc.Handle) usize { +/// An application subscribed: its endpoint arrived as the call's capability. The +/// table taking a slot is what claims it (`take`); a full table refuses and lets +/// the turn close it, so a subscribe storm cannot spend the handle table too. +fn onSubscribe(reply: []u8, arrived: *ipc.Arrival) usize { var status: i32 = -1; - if (capability) |handle| { + if (arrived.peek() != null) { for (&subscribers) |*slot| { if (slot.* == null) { - slot.* = handle; + slot.* = arrived.take(); // claimed: the table holds it from here status = 0; break; } @@ -566,7 +568,7 @@ pub fn main(init: process.Init) void { test_scanout_restart_mode = std.mem.eql(u8, mode, "test-scanout-restart"); } service.run(device_manager_protocol.message_maximum, .{ - .service = .device_manager, + .service = "device-manager", .init = initialise, .on_message = onMessage, .on_notification = onNotification, diff --git a/system/services/display/build.zig b/system/services/display/build.zig index 7e32c5d..1185086 100644 --- a/system/services/display/build.zig +++ b/system/services/display/build.zig @@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void { .name = "display", .root_source_file = b.path("display.zig"), .imports = &.{ - "display-client", "display-protocol", "driver", "input-client", "ipc", "logging", - "memory", "scanout-protocol", "service", "thread", "time", + "channel", "display-client", "display-protocol", "driver", "input-client", "ipc", + "logging", "memory", "scanout-protocol", "service", "thread", "time", }, .threaded = true, // real atomics/TLS (docs/threading.md) }); diff --git a/system/services/display/display.zig b/system/services/display/display.zig index 5370db7..cdae4e2 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -16,6 +16,7 @@ //! (docs/display-v2.md). const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const input = @import("input-client"); const Thread = @import("thread").Thread; @@ -307,11 +308,17 @@ fn verifyNativePresent() void { /// present channel, switch the backend to virtio-gpu, and queue a full-screen repaint. The /// present is deferred to a timer (see `service_endpoint`) so it happens after this reply /// unblocks the driver and it starts serving `.scanout`. -fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, capability: ?ipc.Handle, reply: []u8) usize { - const cap = capability orelse return fail(reply); +/// The surface arrives as the call's capability, and the harness's ownership rule +/// applies: nothing here claims it, so the turn closes it on every path. Safe +/// because a **mapping holds its own kernel reference** (system/kernel/process.zig +/// `systemSharedMemoryMap`) — the pixels stay ours after the handle naming them +/// goes, and a driver that dies and re-announces no longer costs a handle slot +/// per restart. +fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, arrived: *ipc.Arrival, reply: []u8) usize { + const cap = arrived.peek() orelse return fail(reply); if (width == 0 or height == 0 or stride < width) return fail(reply); const mapped = memory.sharedMap(cap) orelse return fail(reply); - const scanout = ipc.lookup(.scanout) orelse return fail(reply); + const scanout = channel.openEndpoint("scanout") orelse return fail(reply); // A second announce means the driver died and was restarted (V6): re-attach to its fresh // scanout. (The previous shared mapping leaks — there is no shared_memory_unmap syscall yet — but the // frames are the dead driver's, reclaimed on its exit; a handful across a crash is benign.) @@ -506,10 +513,13 @@ fn mouseListener(width: u32, height: u32) void { return; }; // Our own handle to the compositor's endpoint. IPC handles are per-thread, so we - // cannot reuse the main thread's service handle — we look the service up to install a - // handle in this thread's table. A poke posted here wakes the compositor loop parked - // in replyWait (docs/threading.md: handles do not cross threads). - cursor_channel.poke_endpoint = ipc.lookup(.display) orelse { + // cannot reuse the main thread's — this thread resolves and opens + // `/protocol/display` exactly like any other client would, once at startup, and + // gets its own handle. There is no special mechanism for reaching yourself: the + // registry does not know or care that the provider is this process. A poke posted + // here wakes the compositor loop parked in replyWait (docs/threading.md: handles + // do not cross threads). + cursor_channel.poke_endpoint = channel.openEndpoint("display") orelse { _ = logging.write("display: mouse listener could not reach the compositor endpoint\n"); return; }; @@ -613,7 +623,7 @@ fn fail(reply: []u8) usize { return writeReply(reply, .{ .status = -1 }); } -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = sender; if (message.len < display_protocol.request_size) return fail(reply); const request = std.mem.bytesToValue(display_protocol.Request, message[0..display_protocol.request_size]); @@ -657,7 +667,7 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han return ok(reply); }, @intFromEnum(display_protocol.Operation.attach_scanout) => { - return attachScanout(request.x, request.width, request.height, request.colour, request.y, capability, reply); + return attachScanout(request.x, request.width, request.height, request.colour, request.y, arrived, reply); }, @intFromEnum(display_protocol.Operation.set_mode) => { if (!backend.setMode(request.width, request.height)) return fail(reply); @@ -696,7 +706,7 @@ fn onNotification(badge: u64) void { pub fn main() void { service.run(display_protocol.message_maximum, .{ - .service = .display, + .service = "display", .init = initialise, .on_message = onMessage, .on_notification = onNotification, diff --git a/system/services/fat/fat.zig b/system/services/fat/fat.zig index eac1a41..93e53e3 100644 --- a/system/services/fat/fat.zig +++ b/system/services/fat/fat.zig @@ -222,8 +222,14 @@ fn handleOpen(out: []u8, path: []const u8, flags: u32, sender: u32) usize { return writeReply(out, .{ .status = 0, .node = index }, &.{}); } -fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handle) usize { - _ = capability; +/// The vfs protocol has no operation that takes a capability, so `arrived` is +/// never claimed here — which, under the harness's ownership rule, means the +/// loop closes whatever a caller attached. That is the point of the rule: this +/// callback used to discard a `?ipc.Handle` and every request carrying one — a +/// legal thing for any client to do — spent a slot of the VFS server's +/// thirty-two until it could accept no capability at all. +fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize { + _ = arrived; if (!mounted) return fail(out); // storage not up (yet): fail politely, clients retry if (message.len < vfs_protocol.request_size) return fail(out); const request = std.mem.bytesToValue(vfs_protocol.Request, message[0..vfs_protocol.request_size]); @@ -305,13 +311,16 @@ fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handl return writeReply(out, .{ .status = 0 }, &.{}); }, // A backend is never itself a mount target. - .mount, .unmount => return fail(out), + // Router verbs, and the registry's claim verb: a file backend answers + // none of them (docs/os-development/protocol-namespace.md — only init + // implements `bind`). + .mount, .unmount, .bind => return fail(out), } } pub fn main() void { service.run(vfs_protocol.message_maximum, .{ - .service = .fat, + .service = "vfs", .init = initialise, .on_message = onMessage, .on_notification = onNotification, diff --git a/system/services/init/build.zig b/system/services/init/build.zig index a27be23..2c483af 100644 --- a/system/services/init/build.zig +++ b/system/services/init/build.zig @@ -11,8 +11,8 @@ pub fn build(b: *std.Build) void { .name = "init", .root_source_file = b.path("init.zig"), .imports = &.{ - "csv", "file-system", "ipc", "logging", "memory", "power-protocol", - "process", "time", + "csv", "envelope", "file-system", "ipc", "logging", "memory", "power-protocol", + "process", "time", "vfs-protocol", }, }); // init reads the same `serial` flag the kernel does: its liveness heartbeat diff --git a/system/services/init/init.zig b/system/services/init/init.zig index 013dca5..91057b4 100644 --- a/system/services/init/init.zig +++ b/system/services/init/init.zig @@ -16,6 +16,28 @@ //! power-button event runs the stop sequence over its children in reverse order //! before asking the power service to enter S5 — lifecycle (M17) and events (M21) //! composing into a clean poweroff. +//! +//! P2: init is also the **registrar** — it serves `/protocol`, the namespace where +//! a program finds everything it talks to +//! (docs/os-development/protocol-namespace.md). It is the natural home: it already +//! spawns the services and already holds the supervision link to each, so it is the +//! process that *knows* which binary is which. Registry traffic rides the same +//! endpoint as supervision, because one thread can only wait in one place — the +//! loop below answers vfs `open`/`readdir`/`bind` alongside signals, timers, power +//! events, and children's deaths. +//! +//! Which sets the security posture of this file. `fs_resolve` installs a mounted +//! backend's endpoint capability in *any* caller's handle table, so sharing the +//! mailbox means **every ring-3 process can send into PID 1**. Two rules follow, +//! and both are structural here rather than remembered per branch: +//! +//! - **Privileged action requires an attested sender.** What arrives is a +//! 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 +//! registrar attests a caller's supervision by task id (`supervisorSatisfies`). +//! - **A capability that arrives is closed unless it is claimed** (`Arrival`), +//! because PID 1's thirty-two handle slots are a resource an unauthenticated +//! caller would otherwise be able to spend. const std = @import("std"); const ipc = @import("ipc"); @@ -24,6 +46,8 @@ const time = @import("time"); const memory = @import("memory"); const logging = @import("logging"); const power_protocol = @import("power-protocol"); +const vfs_protocol = @import("vfs-protocol"); +const envelope = @import("envelope"); const build_options = @import("build_options"); const fs = @import("file-system"); const csv = @import("csv"); @@ -73,17 +97,10 @@ var supervision_endpoint: ipc.Handle = 0; /// uses for /system/configuration/devices.csv. A missing file means no services (the no-ramdisk /// isolation test): loud, but not fatal. fn loadServices() void { - var file = fs.open("/system/configuration/init.csv", .{}) orelse { + const used = readConfiguration("/system/configuration/init.csv", &init_csv) orelse { _ = logging.write("/system/services/init: /system/configuration/init.csv missing — no services started\n"); return; }; - defer file.close(); - var used: usize = 0; - while (used < init_csv.len) { - const n = file.read(init_csv[used..]) orelse break; - if (n == 0) break; - used += n; - } var lines = std.mem.splitScalar(u8, init_csv[0..used], '\n'); while (lines.next()) |line| { const body = csv.stripComment(line); @@ -107,11 +124,500 @@ fn loadServices() void { } } +/// Read a whole configuration file into `into`, returning the byte count. Both of +/// init's manifests live in the initial ramdisk the kernel serves directly, so this +/// works before any filesystem service exists. A file that fills the buffer exactly +/// is reported: a manifest silently losing its last rows is a policy change nobody +/// asked for, and the symptom (one service refused a name) points nowhere near it. +fn readConfiguration(path: []const u8, into: []u8) ?usize { + var file = fs.open(path, .{}) orelse return null; + defer file.close(); + var used: usize = 0; + while (used < into.len) { + const n = file.read(into[used..]) orelse break; + if (n == 0) break; + used += n; + } + if (used == into.len) std.log.info("{s} filled the read buffer — rows past {d} bytes are lost", .{ path, used }); + return used; +} + /// Give up restarting a service after this many crashes — a crash-loop cap, so a service /// that faults immediately on every spawn doesn't respawn forever. const maximum_restarts = 3; -pub fn main() void { +// --- the registry: /protocol ------------------------------------------------ + +/// Longest contract name the namespace admits (`display`, `test/shared-memory`) +/// and the most that may be bound at once. Both static, like everything else +/// init holds. +const maximum_name = 64; +const maximum_bindings = 16; +const maximum_grants = 48; + +/// 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 +/// listing answers "who serves this?" with. +const Binding = struct { + used: bool = false, + name: [maximum_name]u8 = undefined, + name_len: usize = 0, + endpoint: ipc.Handle = 0, + task: u32 = 0, + binary: [64]u8 = undefined, + binary_len: usize = 0, + + fn nameSlice(self: *const Binding) []const u8 { + return self.name[0..self.name_len]; + } + fn binarySlice(self: *const Binding) []const u8 { + return self.binary[0..self.binary_len]; + } +}; + +var bindings: [maximum_bindings]Binding = .{Binding{}} ** maximum_bindings; + +/// What a grant row permits: claiming a name, or reaching one. `open` rows are +/// 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). +const Permission = enum { bind, open }; + +/// One row of `/system/configuration/protocol.csv`. Every field may end in `*`, +/// which matches any tail — the subtree scoping the design doc describes, and +/// what lets one row grant the whole `/test/` family its `test/...` names. +const Grant = struct { + binary: []const u8 = "", + supervisor: []const u8 = "", + permission: Permission = .bind, + name: []const u8 = "", +}; + +/// 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. +var protocol_csv: [8192]u8 = undefined; +var grants: [maximum_grants]Grant = .{Grant{}} ** maximum_grants; +var grant_count: usize = 0; + +/// Parse `/system/configuration/protocol.csv` — the grant manifest. Separate from +/// init.csv because every field there after the path is argv, and overloading that +/// would be ambiguous; separate *files* also means a grant exists for binaries init +/// never spawns (the drivers, which the device manager owns). +fn loadGrants() void { + const used = readConfiguration("/system/configuration/protocol.csv", &protocol_csv) orelse { + _ = logging.write("/system/services/init: /system/configuration/protocol.csv missing — no protocol may be bound\n"); + return; + }; + var lines = std.mem.splitScalar(u8, protocol_csv[0..used], '\n'); + while (lines.next()) |line| { + const body = csv.stripComment(line); + if (body.len == 0) continue; + if (grant_count >= grants.len) { + _ = logging.write("/system/services/init: /system/configuration/protocol.csv has more rows than the table holds\n"); + break; + } + var it = csv.fields(body); + const binary = it.next() orelse continue; + const supervisor = it.next() orelse continue; + const permission = it.next() orelse continue; + const name = it.next() orelse continue; + if (binary.len == 0 or supervisor.len == 0 or name.len == 0) continue; + const kind: Permission = if (std.mem.eql(u8, permission, "bind")) + .bind + else if (std.mem.eql(u8, permission, "open")) + .open + else + continue; // an unreadable row grants nothing rather than something wrong + grants[grant_count] = .{ .binary = binary, .supervisor = supervisor, .permission = kind, .name = name }; + grant_count += 1; + } +} + +/// Match a manifest field against a value: exact, or a trailing `*` matching any +/// tail. The wildcard is how a subtree is granted whole (`/test/*` for every test +/// fixture, `test/*` for every name they may claim). +fn matches(pattern: []const u8, value: []const u8) bool { + if (pattern.len != 0 and pattern[pattern.len - 1] == '*') { + const prefix = pattern[0 .. pattern.len - 1]; + return value.len >= prefix.len and std.mem.eql(u8, value[0..prefix.len], prefix); + } + return std.mem.eql(u8, pattern, value); +} + +/// A snapshot of the kernel's process records — the only identity in the system +/// that cannot be forged, because the kernel stamps it at spawn. Refreshed per +/// authorization; binds are rare, so the copy costs nothing that matters. +var process_table: [64]process.ProcessDescriptor = undefined; +var process_count: usize = 0; +var process_truncated = false; + +fn refreshProcessTable() void { + const total = process.processes(&process_table); + process_count = @min(total, process_table.len); + process_truncated = total > process_table.len; +} + +/// Whether task `id` is still alive, as the last snapshot saw it. A snapshot that +/// did not fit answers "alive" for anything it did not list: refusing a bind is +/// recoverable, stealing a live provider's name is not. +fn taskAlive(id: u32) bool { + return descriptorOf(id) != null or process_truncated; +} + +fn descriptorOf(id: u32) ?*const process.ProcessDescriptor { + for (process_table[0..process_count]) |*descriptor| { + if (descriptor.id == id) return descriptor; + } + return null; +} + +fn nameOf(descriptor: *const process.ProcessDescriptor) []const u8 { + const length = @min(@as(usize, descriptor.name_length), descriptor.name.len); + return descriptor.name[0..length]; +} + +/// The name a kernel task answers to in a grant row. Kernel tasks carry no +/// binary, so the manifest spells the harness's parentage `kernel`. +const kernel_supervisor = "kernel"; + +/// init's own task id, read once at startup. Ids are monotonic and never reused +/// (system/kernel/process.zig), so an id comparison is an *identity* test where a +/// name comparison is only a resemblance test — the whole basis of the +/// attestation below. +var own_task: u32 = 0; + +/// Whether `id` is a process THIS init spawned: a lookup in its own child table, +/// which is the one record of "I started that one" nobody else can write. +fn spawnedByUs(id: u32) bool { + if (id == 0) return false; + for (child_ids[0..service_count]) |child| { + if (child == id) return true; + } + return false; +} + +/// Who is asking, attested by the kernel: the caller's binary, and the **task** +/// that spawned it — an id, not a name. +/// +/// A name alone is not identity: `spawn` is ungated, so a hostile process can +/// start a granted binary itself and would inherit its grants. Neither is the +/// supervisor's *name* enough, and this is the trap the first cut fell into — +/// init and the device manager are ordinary bundled binaries, so an attacker +/// spawns its own `/system/services/init` and lets that instance spawn +/// `/system/services/input`. Both kernel-stamped names then match the grant row +/// exactly, and walking to the root of the chain does not help either: the +/// laundered chain still roots at the real PID 1. What refuses it is asking +/// *which task* the supervisor is, and only accepting one init can vouch for. +const Identity = struct { + /// The caller's binary path, exactly as the kernel stamped it at spawn. + binary: []const u8, + /// The supervising task's id. 0 means the kernel spawned the caller, which + /// no ring-3 process can arrange: every `system_spawn` stamps the caller as + /// the child's supervisor (system/kernel/process.zig `systemSpawn`). + supervisor_task: u32, + /// The supervising task's kernel-stamped binary — the grant row's supervisor + /// column is matched against this, and the refusal log prints it. `kernel` + /// when there is no supervising task. + supervisor_binary: []const u8, + /// Whether init can vouch for how the supervising task came to exist: it is + /// this init, a process this init spawned, or a process the KERNEL spawned. + /// A supervisor init cannot vouch for satisfies no row, however well its + /// name reads — that is the laundering deputy's refusal. + supervisor_vouched: bool, +}; + +/// The process a task belongs to. A thread resolves to its leader: threads share +/// a binary (a thread's own record is named `thread`), and the supervision link +/// that matters is the process's. +fn leaderOf(descriptor: *const process.ProcessDescriptor) *const process.ProcessDescriptor { + if (descriptor.leader == descriptor.id) return descriptor; + return descriptorOf(descriptor.leader) orelse descriptor; +} + +/// Resolve the badge on a request into an identity, one hop up the supervision +/// chain in the kernel's records — one hop is enough because the hop is attested +/// by id (see `supervisorSatisfies`), and every id in the chain init accepts is +/// one init or the kernel created. +fn identify(task: u32) ?Identity { + const caller = descriptorOf(task) orelse return null; + const leader = leaderOf(caller); + if (leader.supervisor == 0) return .{ + .binary = nameOf(leader), + .supervisor_task = 0, + .supervisor_binary = kernel_supervisor, + .supervisor_vouched = true, // the kernel is the root of trust, not a claimant + }; + // The supervising *task* may be a worker thread of the supervising process; + // its process is what the manifest names and what init recorded at spawn. + const supervisor = leaderOf(descriptorOf(leader.supervisor) orelse return null); // unattestable: refuse + return .{ + .binary = nameOf(leader), + .supervisor_task = supervisor.id, + .supervisor_binary = nameOf(supervisor), + .supervisor_vouched = supervisor.id == own_task or + spawnedByUs(supervisor.id) or + supervisor.supervisor == 0, + }; +} + +/// Whether the caller's supervising task satisfies a grant row's supervisor +/// column. The column names *the authorized supervising task*, matched by +/// identity — the binary it must be, plus proof that this instance of that +/// binary is the authorized one: +/// +/// - `kernel` is satisfied only by a genuinely kernel-spawned caller +/// (supervisor id 0). A ring-3 process cannot manufacture that: user +/// `system_spawn` always stamps the caller (system/kernel/process.zig). +/// - init's own binary is satisfied only when the supervising task IS this +/// init (`own_task`). +/// - any other binary — the device manager, a test fixture spawning another — +/// is satisfied only when the supervising task is one init spawned itself +/// (its own child table) or one the kernel spawned. Everything init and the +/// kernel start is therefore reachable; a chain that passes through a +/// process *neither* of them started is not. +fn supervisorSatisfies(column: []const u8, identity: Identity) bool { + if (std.mem.eql(u8, column, kernel_supervisor)) return identity.supervisor_task == 0; + if (identity.supervisor_task == 0) return false; // a kernel task answers to no binary column + if (!matches(column, identity.supervisor_binary)) return false; + return identity.supervisor_vouched; +} + +/// Whether `identity` is granted `permission` on `name`. +fn granted(identity: Identity, permission: Permission, name: []const u8) bool { + for (grants[0..grant_count]) |grant| { + if (grant.permission != permission) continue; + if (!matches(grant.binary, identity.binary)) continue; + if (!supervisorSatisfies(grant.supervisor, identity)) continue; + if (!matches(grant.name, name)) continue; + return true; + } + return false; +} + +fn findBinding(name: []const u8) ?*Binding { + for (&bindings) |*binding| { + if (binding.used and std.mem.eql(u8, binding.nameSlice(), name)) return binding; + } + return null; +} + +/// Release a binding: the provider's endpoint capability goes back to the handle +/// table, and the name is free for the next claimant. init's own cached power +/// channel goes with it — a closed handle number is reused by the next capability +/// that arrives, and a stale copy would quietly aim the shutdown call at a +/// stranger. So does the authorized power *task*: nothing may speak for a +/// contract nobody holds. +fn releaseBinding(binding: *Binding) void { + if (std.mem.eql(u8, binding.nameSlice(), power_contract)) { + power_endpoint = null; + power_task = null; + power_pending = false; + } + _ = ipc.close(binding.endpoint); + binding.* = .{}; +} + +/// Drop every name a dead process held. Called when a supervised child dies (so +/// the restarted instance can bind again) and whenever a bind finds the current +/// owner gone — providers init does not supervise need the second path. +fn unbindTask(task: u32) void { + for (&bindings) |*binding| { + if (binding.used and binding.task == task) { + std.log.info("/protocol/{s} released ({s} is gone)", .{ binding.nameSlice(), binding.binarySlice() }); + releaseBinding(binding); + } + } +} + +/// A contract name as the namespace spells it: the mount-relative path a resolve +/// hands us ("/display") and the name a bind sends ("display") are the same thing +/// with and without a leading slash, so one normaliser serves both. Empty or +/// longer than the namespace admits is not a name. +fn contractName(raw: []const u8) ?[]const u8 { + const name = if (raw.len != 0 and raw[0] == '/') raw[1..] else raw; + if (name.len == 0 or name.len > maximum_name) return null; + return name; +} + +/// The provider's endpoint that will ride the *next* reply, when the request was +/// an `open` that found its contract. +var pending_capability: ?ipc.Handle = null; + +/// The ownership rule for a capability that arrives with a turn of the loop — +/// **the turn owns it until a handler takes it, and closes whatever is left** — +/// lives in `ipc.Arrival`, next to `replyWait`, because it is not PID 1's rule: +/// the service harness every other service runs (library/kernel/service.zig) had +/// the identical hole and now states the identical contract. +const Arrival = ipc.Arrival; + +/// The one contract init is itself a client of. It never resolves the name — it +/// *is* the registry, so it reads its own table; the binding is what hands it the +/// channel. +const power_contract = "power"; + +/// Set when `power` is bound: init subscribes to it on the next turn of the loop, +/// never inside the bind — the provider is blocked on our reply until then, so +/// calling it here would deadlock the pair. +var power_pending = false; +var power_endpoint: ?ipc.Handle = null; + +/// The one task authorized to deliver power events: whoever holds the `power` +/// binding. Recorded at the bind and cleared with the binding, so a provider that +/// dies and rebinds re-derives it with no further ceremony. +/// +/// This is the *authentication* for the shutdown path. init's registry endpoint +/// is its supervision endpoint, and `fs_resolve("/protocol")` installs a sendable +/// handle to it in any caller's table — so after P2 every ring-3 process can post +/// into PID 1's mailbox. A power event may therefore never be believed on the +/// strength of its payload; it is believed because the kernel stamped the +/// sender's task id on it and that id is the provider's. +var power_task: ?u32 = null; + +/// Whether the heartbeat's re-arming timer is running. A timer landing carries no +/// identity, so the loop cannot tell one timer from another — which means exactly +/// one may ever be in flight, or every landing re-arms and the beat doubles. (It +/// did: two beats a second is enough extra chatter to cut a driver's echoed line +/// in half on the shared serial stream.) So the deferred power subscribe borrows +/// the heartbeat's tick when there is one, and arms its own only when there is not. +var heartbeat_running = false; + +/// Answer one registry request. Writes a vfs-protocol reply into `reply` and +/// returns its length; a capability the reply must carry lands in +/// `pending_capability`. `arrived` is the capability the *request* carried, owned +/// by the turn — nothing here has to close it, only `bind` has to claim it. +fn serveRegistry(request_bytes: []const u8, reply: []u8, sender: u32, arrived: *Arrival) usize { + if (request_bytes.len < vfs_protocol.request_size) + return answer(reply, -envelope.EPROTO, 0, 0); + // The header is read field by field rather than reinterpreted whole: the + // operation is an enum on the wire and the bytes come from anyone at all, so + // a value outside it must be a refusal, never a decoded enum. + const operation = std.mem.readInt(u32, request_bytes[0..4], .little); + const cursor = std.mem.readInt(u64, request_bytes[16..24], .little); + const declared = std.mem.readInt(u32, request_bytes[24..28], .little); + const payload_len = @min(@as(usize, declared), request_bytes.len - vfs_protocol.request_size); + const payload = request_bytes[vfs_protocol.request_size..][0..payload_len]; + + if (operation == @intFromEnum(vfs_protocol.Operation.bind)) + return answer(reply, onBind(sender, payload, arrived), 0, 0); + // 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 + // 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.readdir)) return onReaddir(reply, cursor); + // Everything else a filesystem answers is meaningless here: `/protocol` holds + // contracts, not bytes. + return answer(reply, -envelope.ENOSYS, 0, 0); +} + +/// Lay down a vfs reply header (and say how many payload bytes follow it). +fn answer(reply: []u8, status: i32, node: u64, payload_len: usize) usize { + const header = vfs_protocol.Reply{ .status = status, .node = node, .len = @intCast(payload_len) }; + @memcpy(reply[0..vfs_protocol.reply_size], std.mem.asBytes(&header)); + return vfs_protocol.reply_size + payload_len; +} + +/// `bind(name, capability = the provider's endpoint)`. The capability is the +/// point of the call, so a bind without one is malformed. Every refusal below +/// simply returns: the endpoint stays the turn's, and the turn closes it — which +/// is why there is not one `ipc.close` on the way out of any of the six of them. +/// The success path is the only one that says anything about ownership, because +/// it is the only one that keeps the capability. +fn onBind(sender: u32, raw_name: []const u8, arrived: *Arrival) i32 { + if (arrived.peek() == null) return -envelope.EPROTO; + const name = contractName(raw_name) orelse return -envelope.ENOENT; + refreshProcessTable(); + const identity = identify(sender) orelse return -envelope.EPERM; + if (!granted(identity, .bind, name)) { + std.log.info("refused bind of /protocol/{s} by {s} (pid {d}, supervisor {s} pid {d})", .{ + name, + identity.binary, + sender, + identity.supervisor_binary, + identity.supervisor_task, + }); + return -envelope.EPERM; + } + if (findBinding(name)) |existing| { + // Collision is an error — never last-writer-wins — unless the incumbent + // is dead, which is how a restarted provider retakes its own name. + if (taskAlive(existing.task)) { + std.log.info("refused bind of /protocol/{s}: held by {s} (pid {d})", .{ name, existing.binarySlice(), existing.task }); + return -envelope.EBUSY; + } + releaseBinding(existing); + } + const slot = for (&bindings) |*binding| { + if (!binding.used) break binding; + } else return -envelope.ENOSPC; + + // Claimed: the binding owns the endpoint from here, and `releaseBinding` is + // what closes it. + const endpoint = arrived.take().?; + slot.* = .{ .used = true, .endpoint = endpoint, .task = sender }; + @memcpy(slot.name[0..name.len], name); + slot.name_len = name.len; + const binary_len = @min(identity.binary.len, slot.binary.len); + @memcpy(slot.binary[0..binary_len], identity.binary[0..binary_len]); + slot.binary_len = binary_len; + + // Provenance, at the moment it becomes true: name -> pid -> binary path. + std.log.info("/protocol/{s} -> pid {d} {s}", .{ name, sender, slot.binarySlice() }); + if (std.mem.eql(u8, name, power_contract)) { + power_endpoint = endpoint; + // The bind is also the authentication: whoever holds `power` is the one + // task whose power events init will act on (see `onPowerEvent`). + power_task = sender; + power_pending = true; + // Wake ourselves once the reply has gone out; the subscribe call cannot + // happen while the power service is still blocked on it. The heartbeat's + // tick is that wake when it is running — see `heartbeat_running`. + if (!heartbeat_running) _ = time.timerOnce(supervision_endpoint, 1); + } + return 0; +} + +/// `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. +fn onOpen(reply: []u8, raw_name: []const u8) usize { + 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); + pending_capability = binding.endpoint; + return answer(reply, 0, 0, 0); +} + +/// `readdir(cursor)` — the namespace, browsable. One entry per turn, as the vfs +/// protocol lists any directory: kind `protocol`, the contract's name, and the +/// provider's task id in `size`, so a plain listing answers "who serves this?". +fn onReaddir(reply: []u8, cursor: u64) usize { + var index: u64 = 0; + for (&bindings) |*binding| { + if (!binding.used) continue; + if (index != cursor) { + index += 1; + continue; + } + const name = binding.nameSlice(); + const entry = vfs_protocol.DirectoryEntry{ + .kind = @intFromEnum(vfs_protocol.NodeKind.protocol), + .name_len = @intCast(name.len), + .size = binding.task, + }; + const total = vfs_protocol.directory_entry_size + name.len; + if (vfs_protocol.reply_size + total > reply.len) return answer(reply, -envelope.EPROTO, 0, 0); + @memcpy(reply[vfs_protocol.reply_size..][0..vfs_protocol.directory_entry_size], std.mem.asBytes(&entry)); + @memcpy(reply[vfs_protocol.reply_size + vfs_protocol.directory_entry_size ..][0..name.len], name); + return answer(reply, 0, 0, total); + } + return answer(reply, 0, 0, 0); // end of directory +} + +pub fn main(startup: process.Init) void { + // `registry` is the scenario mode: serve /protocol and nothing else. The + // kernel test harness spawns its own providers directly, so it wants the + // naming layer up without init's whole service list underneath it + // (docs/security-track-plan.md, decision 9). + const registry_only = if (startup.arguments.get(1)) |role| std.mem.eql(u8, role, "registry") else false; + // Prove the heap end to end: allocate through the runtime allocator (which // mmaps pages from the kernel and carves them with the free list), write into // that heap buffer (exercising the widened debug_write bounds check), and @@ -128,65 +634,175 @@ pub fn main() void { // One endpoint carries everything init waits on: children's exit // notifications (they are spawned supervised against it), init's own - // signals, and power events it subscribes to. All arrive in the loop below. + // signals, power events it subscribes to, and — since PID 1 is the registrar + // — every /protocol request. One thread can wait in one place, so they share + // a mailbox and the loop below tells them apart. supervision_endpoint = ipc.createIpcEndpoint() orelse { _ = logging.write("/system/services/init: no endpoint\n"); return; }; _ = process.bindSignals(supervision_endpoint); + // Our own id, before anything can ask us a question. It is half of the + // registrar's authority: a grant row naming init as the supervisor is + // satisfied by *this* task and no other instance of this binary + // (`supervisorSatisfies`). + own_task = process.taskId(); + + // The namespace goes up BEFORE anything is spawned, so a service's first + // bind lands rather than retrying. The kernel reserves the prefix: this + // mount is the only one it will ever hold. + loadGrants(); + if (!fs.mount("/protocol", supervision_endpoint)) { + _ = logging.write("/system/services/init: /protocol already mounted — not the registrar\n"); + } + // Load the service list, then bring each up supervised so init can stop them // cleanly. Best-effort and silent: each service announces its own readiness, // and with no /system/configuration/init.csv (an isolation test) the loop starts nothing. - loadServices(); - for (services[0..service_count], 0..) |*service, i| { - if (process.spawnSupervised(service.path, service.arguments(), supervision_endpoint)) |id| child_ids[i] = id; + if (!registry_only) { + loadServices(); + for (services[0..service_count], 0..) |*service, i| { + if (process.spawnSupervised(service.path, service.arguments(), supervision_endpoint)) |id| child_ids[i] = id; + } } - // Subscribe to power events (retry: the power service registers well after - // init starts). Best-effort — without it, a `terminate` signal still - // triggers the same shutdown path. - subscribePower(); - // A re-arming timer drives the liveness heartbeat — proof PID 1 is alive (the // init test's marker) and a -Dserial diagnostic. It is a serial/test-build-only // concern: a flashable (serial-off) image runs a purely event-driven PID 1 that // wakes only for real work (signals, power events, children's exits), never for a // periodic beat. `build_options.serial` is comptime, so the heartbeat — its timer // and the handler below — folds away entirely when serial is off. - if (build_options.serial) _ = time.timerOnce(supervision_endpoint, 1000); + heartbeat_running = build_options.serial and !registry_only; + if (heartbeat_running) _ = time.timerOnce(supervision_endpoint, 1000); - var receive: [power_protocol.message_maximum]u8 = undefined; + var receive: [vfs_protocol.message_maximum]u8 = undefined; + var reply_buffer: [vfs_protocol.message_maximum]u8 = undefined; + var reply_len: usize = 0; + var reply_capability: ?ipc.Handle = null; while (true) { - const got = ipc.replyWait(supervision_endpoint, &.{}, &receive, null); - if (process.signalsFrom(got.badge)) |signals| { - if (signals.has(.terminate)) shutDown(); - continue; + const got = ipc.replyWait(supervision_endpoint, reply_buffer[0..reply_len], &receive, reply_capability); + reply_len = 0; // nothing owed until this turn's request says otherwise + reply_capability = null; + + // Whatever capability came with this turn is the turn's, and the turn + // closes it unless a handler claims it. Structural on purpose — see + // `Arrival`; it is what keeps a zero-length call from spending a handle + // slot of PID 1's per call. + var arrived: Arrival = .{ .handle = got.cap }; + defer arrived.release(); + + if (got.isNotification()) { + // Every badge on this branch is stamped by the KERNEL, and a stranger + // cannot stamp one: `ipc_send` — the only way a ring-3 process puts + // something in this mailbox with no reply owed — sets exactly + // `notify_badge_bit | notify_message_bit` and fills the low bits with + // the sender's own task id (system/kernel/ipc-synchronous.zig, + // `sendLocked`). + // + // That is a statement about `ipc_send`, and on its own it proved far + // too little: an attacker does not use `ipc_send` to forge a signal, + // it asks the kernel to deliver a real one *here*. `fs_resolve` + // hands any process a sendable handle to this endpoint, and + // `signal_bind`/`timer_bind`/`process_subscribe`/spawn's exit + // endpoint all used to accept any handle the caller held — so a + // stranger could point its own signal delivery at PID 1 and signal + // itself, and the terminate badge landing here was genuine in every + // bit. What makes these branches trustworthy is therefore in the + // KERNEL, not in this comment: binding a kernel notification to an + // endpoint now requires *owning* that endpoint (`ipc.ownedBy`), so a + // signal here comes only from our supervisor or our own group, a + // timer landing only from a timer we armed, and a child-exit notice + // only from a child we spawned. The buffered-message branch below is + // the one still carrying a stranger's bytes, and it is the one that + // authenticates its sender. + if (process.signalsFrom(got.badge)) |signals| { + if (signals.has(.terminate)) shutDown(); + continue; + } + if (got.isTimer()) { + // The pending power subscription rides any timer landing: by the + // time one arrives, the bind's reply has left and the power + // service is serving again. + if (power_pending) { + power_pending = false; + subscribePower(); + } + if (heartbeat_running) { + _ = logging.write("/system/services/init: heartbeat\n"); + _ = time.timerOnce(supervision_endpoint, 1000); + } + continue; + } + if (got.isMessage()) { + // A buffered message: the only thing here an anonymous stranger + // can put in front of PID 1. Authenticated by sender, never by + // payload — see `onPowerEvent`. + onPowerEvent(got.senderTaskId(), receive[0..got.len]); + continue; + } + if (got.isChildExit()) { + restartChild(got.childProcessId()); + continue; + } + continue; // anything else: keep waiting } - if (build_options.serial and got.isTimer()) { - _ = logging.write("/system/services/init: heartbeat\n"); - _ = time.timerOnce(supervision_endpoint, 1000); - continue; - } - if (got.isMessage() and got.len >= 2 and receive[0] == @intFromEnum(power_protocol.Operation.event)) { - // A power event (the only buffered messages init receives). - if (receive[1] == @intFromEnum(power_protocol.Event.power_button)) shutDown(); - continue; - } - if (got.isChildExit()) { - restartChild(got.childProcessId()); - continue; - } - // Anything else: keep waiting. - if (got.isNotification()) continue; + // The universal ping, answered by the empty reply. A ping may still carry + // a capability — the kernel installs one regardless of length — and this + // `continue` disposes of it through the turn's `defer`, which is exactly + // what it failed to do when the close lived in the branches. + if (got.len == 0) continue; + reply_len = serveRegistry(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived); + reply_capability = pending_capability; + pending_capability = null; } } +/// A buffered message claiming to be a power event. +/// +/// **Privileged control traffic is authenticated by sender, never by content.** +/// init's registry endpoint is its supervision endpoint, and `fs_resolve` installs +/// a sendable handle to any mount's backend in *any* caller's table +/// (system/kernel/process.zig), so after P2 every ring-3 process holds a handle it +/// can `ipc_send` into. Two payload bytes were once enough to reach `shutDown()` +/// from here — which stops every service and parks PID 1 in its final sleep, +/// destroying the registry for the rest of the boot, and does it for any process +/// that cares to ask. +/// +/// The sender's task id is the fix, because it is not the sender's to choose: the +/// kernel stamps it into the badge's low bits as it copies the message into the +/// ring. Init is the registry, so it knows exactly which task holds `power`, and +/// that task alone is believed. A provider that dies and rebinds moves the +/// authorization with the binding; a name nothing holds authorizes nobody. Task +/// ids are never reused, so even a dead provider's id cannot be inherited. +/// +/// (One task, not one process: the ACPI service is single-threaded and publishes +/// from the same task that bound the name. A threaded provider would want its +/// leader compared instead — which is a change to make when one appears, not a +/// looser rule to leave lying around for it.) +fn onPowerEvent(sender: u32, payload: []const u8) void { + const authorized = power_task orelse { + std.log.info("ignored a power event from pid {d}: nothing holds /protocol/power", .{sender}); + return; + }; + if (sender != authorized) { + std.log.info("ignored a power event from pid {d}: /protocol/power is pid {d}", .{ sender, authorized }); + return; + } + if (payload.len < 2) return; + if (payload[0] != @intFromEnum(power_protocol.Operation.event)) return; + if (payload[1] == @intFromEnum(power_protocol.Event.power_button)) shutDown(); +} + /// A supervised boot service died. Find which one and restart it — unless it exited /// cleanly (it chose to stop, e.g. a driver with no hardware) or has hit the crash-loop /// cap. Reclaiming the dead process is already the kernel's job (docs/process-lifecycle.md /// iron rule 1); init only decides whether to bring it back. fn restartChild(id: u32) void { + // Whatever it served, it serves no longer: the name goes back before the + // replacement asks for it, so the restarted instance binds rather than + // colliding with its own corpse. + unbindTask(id); if (shutting_down) return; // deaths during the stop sequence are expected, not crashes for (services[0..service_count], 0..) |*service, i| { if (child_ids[i] != id) continue; @@ -209,22 +825,26 @@ fn restartChild(id: u32) void { // An untracked child (e.g. the log-flush one-shot): nothing to restart. } -/// Look up the power service and subscribe our endpoint (handed over as the -/// call's capability) so events arrive as buffered messages here. +/// Subscribe our endpoint (handed over as the call's capability) to the power +/// service, so events arrive as buffered messages here. init is the registry, so +/// it never resolves `/protocol/power` — it reads its own table, which is also +/// what makes this reachable at all: the subscription is armed by the bind that +/// put the endpoint there. +/// +/// **This is the only place PID 1 blocks on another process, and it is the one +/// hazard the registrar has.** One thread serves both the namespace and this +/// call, so while it is outstanding init answers nobody: if the callee were +/// itself blocked asking init to resolve a name, the pair would never move. Two +/// things keep that from happening — the call is deferred to the next turn of +/// the loop (so the provider has its bind reply and is on its way to +/// `replyWait`), and the power provider resolves every name it needs *before* it +/// binds (system/services/acpi/acpi.zig, `manager_channel`). Any future service +/// init calls owes the same discipline. fn subscribePower() void { - var handle: ?ipc.Handle = null; - var tries: u32 = 0; - while (handle == null and tries < 200) : (tries += 1) { - handle = ipc.lookup(.power); - if (handle == null) time.sleepMillis(20); - } - // A missing power service is not fatal — init proceeds to its heartbeat and - // a `terminate` signal still drives shutdown. Silent so the no-ramdisk init - // test's heartbeat marker is the next line written. - const h = handle orelse return; + const handle = power_endpoint orelse return; const request = power_protocol.Subscribe{}; var reply: [power_protocol.message_maximum]u8 = undefined; - _ = ipc.callCap(h, std.mem.asBytes(&request), &reply, supervision_endpoint) catch {}; + _ = ipc.callCap(handle, std.mem.asBytes(&request), &reply, supervision_endpoint) catch {}; } /// The stop sequence: persist the log while storage is still up, then terminate @@ -242,7 +862,7 @@ fn shutDown() void { i -= 1; if (child_ids[i] != 0) process.stop(child_ids[i], 2000, supervision_endpoint); } - if (ipc.lookup(.power)) |h| { + if (power_endpoint) |h| { const request = power_protocol.Shutdown{}; var reply: [power_protocol.message_maximum]u8 = undefined; _ = ipc.call(h, std.mem.asBytes(&request), &reply) catch {}; diff --git a/system/services/input/build.zig b/system/services/input/build.zig index 3e6756d..8c33a3d 100644 --- a/system/services/input/build.zig +++ b/system/services/input/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const exe = build_support.userBinary(b, .{ .name = "input", .root_source_file = b.path("input.zig"), - .imports = &.{ "input-client", "input-protocol", "ipc", "logging", "process", "service" }, + .imports = &.{ "channel", "input-client", "input-protocol", "ipc", "logging", "process", "service" }, }); b.installArtifact(exe); } diff --git a/system/services/input/input.zig b/system/services/input/input.zig index 3f06358..74ced4b 100644 --- a/system/services/input/input.zig +++ b/system/services/input/input.zig @@ -1,5 +1,5 @@ //! system/services/input — the user-space input service. Shipped in the initial_ramdisk, -//! spawned as a ring-3 process, and published under the well-known `input` service id. It +//! spawned as a ring-3 process, and bound at `/protocol/input`. It //! is the fan-out point between **sources** (keyboard, mouse, and joystick/gamepad drivers) //! and **subscribers** (any program that wants input): a source `publish`es an //! `InputEvent`, and the service pushes it to every subscriber whose interest mask includes @@ -20,6 +20,7 @@ //! handle and `ipc.send`s each event to it. const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const service = @import("service"); @@ -58,7 +59,13 @@ fn pruneDeadSubscribers() void { break; } } - if (!alive) sub.* = .{}; + // The slot owns the endpoint capability it was handed, so reclaiming the + // slot closes it — otherwise a process that subscribes and dies costs a + // handle-table slot that never comes back. + if (!alive) { + _ = ipc.close(sub.endpoint); + sub.* = .{}; + } } } @@ -84,10 +91,13 @@ fn broadcast(event: input_protocol.InputEvent) void { } } -/// Handle one request. `got` carries the sender badge (a task id) and, for subscribe, the -/// subscriber's endpoint capability in `got.cap`. Writes a `Reply` into `out` and returns -/// its length. -fn handle(message: []const u8, got: ipc.Received, out: []u8) usize { +/// Handle one request. `got` carries the sender badge (a task id); `arrived` carries the +/// capability the request came with, under the same ownership rule the service harness +/// states (`ipc.Arrival`): **it belongs to the turn, and only a handler that means to keep +/// it says `take`.** Everything else here — a short message, a `publish`, a subscribe that +/// finds the table full — simply returns, and the loop closes what arrived. Writes a +/// `Reply` into `out` and returns its length. +fn handle(message: []const u8, got: ipc.Received, out: []u8, arrived: *ipc.Arrival) usize { const reply = struct { fn write(buffer: []u8, status: i32) usize { const header = input_protocol.Reply{ .status = status }; @@ -101,11 +111,12 @@ fn handle(message: []const u8, got: ipc.Received, out: []u8) usize { switch (@as(input_protocol.Operation, @enumFromInt(request.operation))) { .subscribe => { - const endpoint = got.cap orelse return reply.write(out, -1); // no endpoint passed + const endpoint = arrived.peek() orelse return reply.write(out, -1); // no endpoint passed // A zero mask means "everything" (a subscriber that named no class still wants input). const mask = if (request.device_mask == 0) input_protocol.device_all else request.device_mask; pruneDeadSubscribers(); if (!addSubscriber(endpoint, @intCast(got.badge), mask)) return reply.write(out, -1); // table full + _ = arrived.take(); // claimed: the subscriber table holds it until that task dies return reply.write(out, 0); }, .publish => { @@ -120,8 +131,8 @@ pub fn main() void { _ = logging.write("/system/services/input: no endpoint\n"); return; }; - if (!ipc.register(.input, endpoint)) { - _ = logging.write("/system/services/input: register failed\n"); + if (!channel.bindPatiently("input", endpoint)) { + _ = logging.write("/system/services/input: could not bind /protocol/input\n"); return; } _ = logging.write("/system/services/input: ready\n"); @@ -131,12 +142,21 @@ pub fn main() void { var receive: [input_protocol.request_size]u8 = undefined; while (true) { const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null); + // Whatever capability came with this turn is the turn's, and the turn closes it + // unless `handle` claims it (`ipc.Arrival`). The kernel installs a sent capability + // whatever the message's length or kind, so this covers the notification + // `continue` and every refusal inside `handle` — otherwise about thirty-two + // capability-carrying calls, which need no authorization at all, exhaust this + // service's handle table and no further subscribe can ever land. + var arrived: ipc.Arrival = .{ .handle = got.cap }; + defer arrived.release(); + // Only synchronous client requests (subscribe/publish) arrive here; nothing sends // this service asynchronous messages, so a notification wake would be spurious. if (got.isNotification()) { reply_len = 0; continue; } - reply_len = handle(receive[0..got.len], got, &reply_buffer); + reply_len = handle(receive[0..got.len], got, &reply_buffer, &arrived); } } diff --git a/system/services/logger/logger.zig b/system/services/logger/logger.zig index 0495404..3881b3f 100644 --- a/system/services/logger/logger.zig +++ b/system/services/logger/logger.zig @@ -99,11 +99,11 @@ fn initialise(harness_endpoint: ipc.Handle) bool { } /// The logger serves no protocol; the ping is answered by the harness. -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = message; _ = reply; _ = sender; - _ = capability; + _ = arrived; // nothing here takes a capability: the harness closes what arrives return 0; } diff --git a/test/qemu_test.py b/test/qemu_test.py index d294005..d7817d0 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -846,6 +846,33 @@ CASES = [ {"name": "input", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # The protocol registry (docs/os-development/protocol-namespace.md): init serves + # /protocol, and the fixture drives the registrar's whole contract — an ungranted + # bind refused (-EPERM), the kernel's reserved prefix holding against mount and + # unmount, a live owner's name refused (-EBUSY), and a killed provider's channel + # failing while a re-resolve reaches the restarted instance. Each step prints its + # own line, so a failure says which rule broke, not merely that one did. + # Three of the steps are the registrar's security contract, and each fails + # catastrophically rather than quietly if it regresses: a forged power event + # posted to PID 1's mailbox must not shut the machine down (a regression ends + # the boot), capability-carrying zero-length pings must not consume PID 1's + # handle table (a regression makes every later bind impossible), and a granted + # binary spawned by an unauthorized task must be refused while the same binary + # spawned by an authorized one is not (the laundering deputy). + {"name": "protocol-registry", + "expect": r"(?s)(?=.*protocol-registry: ungranted bind refused)" + r"(?=.*protocol-registry: /protocol reserved)" + r"(?=.*protocol-registry: forged power event ignored)" + r"(?=.*protocol-registry: capability-carrying pings did not exhaust the registrar)" + r"(?=.*protocol-registry: collision refused)" + r"(?=.*protocol-registry: dead channel refused)" + r"(?=.*protocol-registry: restarted provider reached)" + r"(?=.*protocol-registry: laundering deputy refused)" + r"(?=.*protocol-registry: foreign signal binding refused)" + r"(?=.*protocol-registry: foreign timer and exit binding refused)" + r"(?=.*protocol-registry: capability-carrying pings did not exhaust the harness)" + r"(?=.*DANOS-TEST-RESULT: PASS)", + "fail": r"DANOS-TEST-RESULT: FAIL|protocol-registry: FAIL"}, # 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 # -> driver-up (the spawned pci-bus logs " functions found"). diff --git a/test/system/services/crash-test/build.zig b/test/system/services/crash-test/build.zig index 67f2380..27cbc60 100644 --- a/test/system/services/crash-test/build.zig +++ b/test/system/services/crash-test/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const exe = build_support.userBinary(b, .{ .name = "crash-test", .root_source_file = b.path("crash-test.zig"), - .imports = &.{ "device-manager-protocol", "driver", "ipc", "logging", "process", "time" }, + .imports = &.{ "channel", "device-manager-protocol", "driver", "ipc", "logging", "process", "time" }, }); b.installArtifact(exe); } diff --git a/test/system/services/crash-test/crash-test.zig b/test/system/services/crash-test/crash-test.zig index f2db091..40fb952 100644 --- a/test/system/services/crash-test/crash-test.zig +++ b/test/system/services/crash-test/crash-test.zig @@ -7,6 +7,7 @@ //! binary), it exits silently so it cannot derange other tests. const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const time = @import("time"); @@ -29,7 +30,7 @@ pub fn main(init: process.Init) void { var manager: ?ipc.Handle = null; var tries: u32 = 0; while (manager == null and tries < 100) : (tries += 1) { - manager = ipc.lookup(.device_manager); + manager = channel.openEndpoint("device-manager"); if (manager == null) time.sleepMillis(20); } const h = manager orelse return; diff --git a/test/system/services/device-list/build.zig b/test/system/services/device-list/build.zig index 0be8299..821d42d 100644 --- a/test/system/services/device-list/build.zig +++ b/test/system/services/device-list/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const exe = build_support.userBinary(b, .{ .name = "device-list", .root_source_file = b.path("device-list.zig"), - .imports = &.{ "device-manager-protocol", "ipc", "logging", "time" }, + .imports = &.{ "channel", "device-manager-protocol", "ipc", "logging", "time" }, }); b.installArtifact(exe); } diff --git a/test/system/services/device-list/device-list.zig b/test/system/services/device-list/device-list.zig index ee0cbe2..6df4d9c 100644 --- a/test/system/services/device-list/device-list.zig +++ b/test/system/services/device-list/device-list.zig @@ -5,6 +5,7 @@ //! device_* system call. const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const time = @import("time"); const logging = @import("logging"); @@ -19,7 +20,7 @@ pub fn main() void { var manager: ?ipc.Handle = null; var tries: u32 = 0; while (manager == null and tries < 200) : (tries += 1) { - manager = ipc.lookup(.device_manager); + manager = channel.openEndpoint("device-manager"); if (manager == null) time.sleepMillis(20); } const h = manager orelse { diff --git a/test/system/services/process-test/build.zig b/test/system/services/process-test/build.zig index 7f8b636..c15dc6a 100644 --- a/test/system/services/process-test/build.zig +++ b/test/system/services/process-test/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const exe = build_support.userBinary(b, .{ .name = "process-test", .root_source_file = b.path("process-test.zig"), - .imports = &.{ "ipc", "logging", "process", "service", "time" }, + .imports = &.{ "channel", "ipc", "logging", "process", "service", "time" }, }); b.installArtifact(exe); } diff --git a/test/system/services/process-test/process-test.zig b/test/system/services/process-test/process-test.zig index 0f1677d..07f7e2c 100644 --- a/test/system/services/process-test/process-test.zig +++ b/test/system/services/process-test/process-test.zig @@ -17,6 +17,7 @@ //! binary bare), it exits silently so it cannot derange other tests' output. const std = @import("std"); +const channel = @import("channel"); const ipc = @import("ipc"); const process = @import("process"); const service = @import("service"); @@ -55,9 +56,9 @@ fn awaitChildExit(endpoint: ipc.Handle) u32 { /// The harness-run child of the signals test: echoes requests, logs the two /// signals it handles. Terminate makes run() return, and returning from main is /// the clean exit the parent reads as ExitReason.exited. -fn echo(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn echo(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = sender; - _ = capability; + _ = arrived; const n = @min(message.len, reply.len); @memcpy(reply[0..n], message[0..n]); return n; @@ -82,10 +83,10 @@ fn signalRun() void { var service_handle: ?ipc.Handle = null; var tries: u32 = 0; while (service_handle == null and tries < 200) : (tries += 1) { - service_handle = ipc.lookup(.input); + service_handle = channel.openEndpoint("test/process"); if (service_handle == null) time.sleepMillis(20); } - const h = service_handle orelse fail("service child never registered"); + const h = service_handle orelse fail("service child never bound its contract"); // The universal ping: a zero-length call answered zero-length by the harness. var reply: [16]u8 = undefined; @@ -125,9 +126,10 @@ pub fn main(init: process.Init) void { while (true) time.sleepMillis(500); } if (std.mem.eql(u8, role, "service")) { - // Borrowed well-known id: the input service is not part of this scenario. + // A fixture's own contract, under the /protocol/test subtree every + // /test/ binary is granted (docs/os-development/protocol-namespace.md). service.run(64, .{ - .service = .input, + .service = "test/process", .on_message = echo, .on_reload = onReload, .on_terminate = onTerminate, diff --git a/test/system/services/protocol-registry-test/build.zig b/test/system/services/protocol-registry-test/build.zig new file mode 100644 index 0000000..95c1af4 --- /dev/null +++ b/test/system/services/protocol-registry-test/build.zig @@ -0,0 +1,15 @@ +//! The protocol-registry-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-registry-test", + .root_source_file = b.path("protocol-registry-test.zig"), + .imports = &.{ "channel", "envelope", "file-system", "ipc", "logging", "power-protocol", "process", "service", "time" }, + }); + b.installArtifact(exe); +} diff --git a/test/system/services/protocol-registry-test/build.zig.zon b/test/system/services/protocol-registry-test/build.zig.zon new file mode 100644 index 0000000..09a9197 --- /dev/null +++ b/test/system/services/protocol-registry-test/build.zig.zon @@ -0,0 +1,17 @@ +.{ + .name = .protocol_registry_test, + .version = "0.0.0", + .fingerprint = 0x76dd7e3bd3b6e2cd, // 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 vocabulary the registry refuses with; + // power-protocol: the exact event bytes the forged-shutdown probe posts. + .protocol = .{ .path = "../../../../library/protocol" }, + }, + .paths = .{""}, +} diff --git a/test/system/services/protocol-registry-test/protocol-registry-test.zig b/test/system/services/protocol-registry-test/protocol-registry-test.zig new file mode 100644 index 0000000..cfb01e6 --- /dev/null +++ b/test/system/services/protocol-registry-test/protocol-registry-test.zig @@ -0,0 +1,444 @@ +//! protocol-registry-test — the registrar's own test fixture +//! (docs/os-development/protocol-namespace.md). One binary, four roles picked by +//! argv, driven by the `protocol-registry` kernel case: +//! +//! - `protocol-registry-test provider ` — binds `/protocol/test/registry` +//! and answers every request with its one-byte ``, so a client can tell +//! *which instance* it reached. +//! - `protocol-registry-test deputy` / `... launder` — the two halves of the +//! laundering-deputy probe (assertion 7 below). +//! - `protocol-registry-test run` — the driver, and the assertions: +//! 1. a bind of a name this binary is not granted is refused, `-EPERM`; +//! 2. the kernel's reserved prefix holds: nothing may mount at or under +//! `/protocol`, and nobody may unmount it; +//! 3. a **forged power event** posted straight into the registry endpoint +//! does not shut the machine down — the registrar answers privileged +//! control traffic on the sender's kernel-stamped identity, never on two +//! bytes of payload. The system surviving to the next line is the +//! assertion, and it is the strongest one available: a regression takes +//! the whole boot down; +//! 4. zero-length calls carrying a capability do not consume PID 1's handle +//! table — after sixty-four of them a legitimate bind still succeeds; +//! 5. a bind colliding with a **live** owner is refused, `-EBUSY`; +//! 6. killing the provider makes the cached channel fail (`-EPEER`), and a +//! client that re-resolves reaches the **restarted** instance — the +//! restart story falling out of the naming layer, with no reconnect verb +//! anywhere; +//! 7. the laundering deputy is refused: the *same binary* under the *same +//! supervisor name* binds when its supervisor is a task the kernel +//! started, and is refused one hop further down, where the supervisor is +//! a task nobody authorized. The pair is the point — one alone would pass +//! for reasons that have nothing to do with the rule under test; +//! 8. a **forged terminate signal** does not reach PID 1 either: the kernel +//! refuses to bind this process's signal delivery to an endpoint it does +//! not own, so "bind init's endpoint, then signal yourself" — which needs +//! no forgery at all, only two ungated syscalls — cannot happen; +//! 9. the same for the other kernel notifications: a timer and an exit +//! subscription may only be armed on one's own endpoint; +//! 10. the handle-table storm of assertion 4, aimed at a **harness-run** +//! service instead of PID 1 — the loop every other service in the system +//! runs — after which a capability-passing operation still works. +//! +//! Assertions 8–10 each pair a refusal with a control (the same operation on an +//! endpoint this process created, which must succeed), because a refusal alone +//! would pass just as happily against a kernel that refused everything. +//! +//! Prints `protocol-registry: ok` on success, or a `protocol-registry: 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 power_protocol = @import("power-protocol"); +const process = @import("process"); +const service = @import("service"); +const time = @import("time"); + +/// The contract this fixture's provider role claims — under `/protocol/test`, +/// the subtree every `/test/` binary is granted. +const contract = "test/registry"; + +/// Where the deputy and the laundered grandchild report their bind verdicts. The +/// driver binds it and listens; both children reach it by name like any client. +const verdict_contract = "test/verdict"; + +/// The name the deputy claims (it may — its supervisor is the kernel-started +/// driver) and the one its own child attempts (it may not — its supervisor is +/// the deputy, a task neither init nor the kernel started). +const deputy_contract = "test/deputy"; +const laundered_contract = "test/laundered"; + +/// A contract this fixture is deliberately NOT granted. It belongs to the +/// display service, and no manifest row names this binary against it. +const forbidden = "display"; + +fn fail(step: []const u8) noreturn { + _ = logging.write("protocol-registry: FAIL "); + _ = logging.write(step); + _ = logging.write("\n"); + process.exit(1); +} + +// --- the provider role ------------------------------------------------------ + +/// Which instance this is, echoed to every caller. The point of the whole +/// exercise: a client cannot tell instances apart by the name it opened, so the +/// provider says so itself. +var mark: u8 = '?'; + +/// The provider's one operation, plus `handover_operation` — the capability-passing +/// verb the harness-exhaustion probe needs. Claiming the arriving capability with +/// `take` and *using* it is the point: it proves the harness handed over a working +/// handle, which a service whose table is full could not have been given. +fn answer(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { + _ = sender; + if (message.len != 0 and message[0] == handover_operation) { + if (arrived.take()) |handed| { + _ = ipc.send(handed, &[_]u8{mark}); // speak on it, then it is ours to close + _ = ipc.close(handed); + } + } + if (reply.len == 0) return 0; + reply[0] = mark; + return 1; +} + +/// The one request byte the provider treats as "here is an endpoint, use it". +const handover_operation: u8 = 's'; + +// --- the laundering-deputy roles -------------------------------------------- + +/// One child's bind verdict, posted to the driver over `test/verdict`. Sent +/// asynchronously (no reply owed) so a child can report and exit without the +/// driver having to be waiting at that instant. +const Verdict = extern struct { + /// `deputy` or `launder` — which of the two spoke. + role: u8, + _reserved: [3]u8 = .{0} ** 3, + /// The registry's answer: 0 bound, or a negative errno. + status: i32, +}; + +const role_deputy: u8 = 'd'; +const role_launder: u8 = 'l'; + +/// Report a verdict to the driver, best-effort: the driver has a deadline, and a +/// child that cannot reach it must not hang the boot. +fn report(role: u8, status: i32) void { + const verdict = Verdict{ .role = role, .status = status }; + var attempts: u32 = 0; + while (attempts < 200) : (attempts += 1) { + if (channel.openEndpoint(verdict_contract)) |endpoint| { + defer _ = ipc.close(endpoint); + if (ipc.send(endpoint, std.mem.asBytes(&verdict))) return; + } + time.sleepMillis(20); + } +} + +/// The middle of the laundered chain: a granted binary whose own supervisor is +/// the kernel-started driver. It binds — that is the control, and it is what +/// makes the refusal below mean something — then starts an instance of *itself* +/// and stays alive while that instance asks for a name, so the registry judges a +/// live, fully attestable chain rather than an orphan. +fn deputy() void { + const spare = ipc.createIpcEndpoint() orelse return; + report(role_deputy, verdictOf(deputy_contract, spare)); + + const exits = ipc.createIpcEndpoint() orelse return; + const child = process.spawnSupervised("protocol-registry-test", &.{"launder"}, exits) orelse return; + awaitExit(exits, child); +} + +/// The laundered grandchild. Its binary is granted, and its supervisor's binary +/// is granted as a supervisor — every *name* in the row matches. The one thing +/// that does not is which task its supervisor is: nobody authorized this deputy, +/// so nothing it spawns inherits a name. +fn launder() void { + const spare = ipc.createIpcEndpoint() orelse return; + report(role_launder, verdictOf(laundered_contract, spare)); +} + +// --- the driver role -------------------------------------------------------- + +/// Ask whoever is on the other end of `endpoint` which instance they are. Null +/// if the call failed — which, after a provider dies, is `-EPEER`. +fn instanceOf(endpoint: ipc.Handle) ?u8 { + var reply: [8]u8 = undefined; + const n = ipc.call(endpoint, "?", &reply) catch return null; + if (n != 1) return null; + return reply[0]; +} + +/// Open `/protocol/` until the instance marked `wanted` answers, +/// re-resolving each time — exactly the recovery a client performs when its +/// provider dies. Returns the live endpoint. Handles from a failed attempt are +/// closed: a 32-slot table does not survive four hundred leaks. +fn reach(wanted: u8) ?ipc.Handle { + var attempts: u32 = 0; + while (attempts < 400) : (attempts += 1) { + if (channel.openEndpoint(contract)) |endpoint| { + if (instanceOf(endpoint)) |seen| { + if (seen == wanted) return endpoint; + } + _ = ipc.close(endpoint); + } + time.sleepMillis(20); + } + return null; +} + +/// Block until child `id`'s exit notification lands on `endpoint`. +fn awaitExit(endpoint: ipc.Handle, id: u32) void { + var scratch: [8]u8 = undefined; + var guard: u32 = 0; + while (guard < 1000) : (guard += 1) { + const got = ipc.replyWait(endpoint, scratch[0..0], &scratch, null); + if (got.isChildExit() and got.childProcessId() == id) return; + } + fail("the provider's exit notification never arrived"); +} + +/// One bind, retried only while the registry is *unreachable* — a registry that +/// answered has decided. Null means it never answered, which the caller reports +/// in its own words: "not mounted yet" and "stopped serving mid-test" are the +/// same silence and very different findings. +fn verdictWithin(name: []const u8, endpoint: ipc.Handle) ?i32 { + var attempts: u32 = 0; + while (attempts < 200) : (attempts += 1) { + if (channel.bind(name, endpoint)) |status| return status; + time.sleepMillis(20); + } + return null; +} + +/// The registry answers, whatever it answers — the first bind also waits out a +/// registry that has not mounted `/protocol` yet. +fn verdictOf(name: []const u8, endpoint: ipc.Handle) i32 { + return verdictWithin(name, endpoint) orelse fail("the registry never came up"); +} + +/// The registry's endpoint, obtained the way every process obtains it: resolve +/// `/protocol`. `fs_resolve` installs the backend's capability in ANY caller's +/// table, and the registrar's backend endpoint is PID 1's *supervision* endpoint +/// — the same mailbox its signals, timers, child deaths and power events land in. +/// That is precisely why what arrives there may not be believed on its content. +fn registryEndpoint() ?ipc.Handle { + var relative: [channel.path_maximum]u8 = undefined; + const route = file_system.fsResolve(channel.root, 0, &relative) orelse return null; + return switch (route) { + .kernel => null, + .backend => |backend| backend.handle, + }; +} + +/// Wait for both children of the laundering probe to report. Taken together +/// rather than one at a time because the two are independent processes and the +/// order they reach the driver is not the driver's business. +fn awaitVerdicts(endpoint: ipc.Handle) struct { deputy: i32, launder: i32 } { + var scratch: [64]u8 = undefined; + var from_deputy: ?i32 = null; + var from_launder: ?i32 = null; + var guard: u32 = 0; + while (guard < 2000) : (guard += 1) { + const got = ipc.replyWait(endpoint, scratch[0..0], &scratch, null); + if (got.isMessage() and got.len >= @sizeOf(Verdict)) { + const verdict = std.mem.bytesToValue(Verdict, scratch[0..@sizeOf(Verdict)]); + if (verdict.role == role_deputy) from_deputy = verdict.status; + if (verdict.role == role_launder) from_launder = verdict.status; + } + if (from_deputy) |d| { + if (from_launder) |l| return .{ .deputy = d, .launder = l }; + } + } + fail("the deputy chain never reported its bind verdicts"); +} + +fn run() void { + // A spare endpoint to offer the registry. Every bind below is meant to be + // refused, so it is never actually claimed by anyone. + const spare = ipc.createIpcEndpoint() orelse fail("create an endpoint to offer"); + + // 1. A name this binary is not granted. Refusal is `-EPERM` at the bind, not + // silence: the caller *is* the provider, and telling it its manifest is + // wrong is not a leak. + const ungranted = verdictOf(forbidden, spare); + if (ungranted != -envelope.EPERM) fail("an ungranted bind was not refused with -EPERM"); + _ = logging.write("protocol-registry: ungranted bind refused\n"); + + // 2. The kernel's residual rule: `/protocol` is claimed once, by PID 1, and + // the prefix is closed for the boot. Shadowing one contract by mounting + // under it is refused too, and so is deleting the namespace outright. + if (file_system.mount("/protocol", spare)) fail("a second mount at /protocol was allowed"); + if (file_system.mount("/protocol/display", spare)) fail("a mount under /protocol was allowed"); + if (file_system.fsUnmount("/protocol")) fail("unmounting /protocol was allowed"); + _ = logging.write("protocol-registry: /protocol reserved\n"); + + // 3. A forged power event. Resolving `/protocol` hands this process — any + // process — a sendable handle to PID 1's mailbox, and the bytes below are + // byte-for-byte what the real power service publishes when the button is + // pressed. If the registrar believed payloads, this single `send` would + // run the whole stop sequence and park PID 1 in its final sleep: every + // service terminated, the registry gone for the rest of the boot, and no + // way back. So the assertion is that the machine is still here afterwards + // — the strongest one available, because a regression does not fail this + // line, it takes the entire boot down with it. + const registry = registryEndpoint() orelse fail("resolve /protocol"); + const forged = power_protocol.EventMessage{ .event = @intFromEnum(power_protocol.Event.power_button) }; + if (!ipc.send(registry, std.mem.asBytes(&forged))) fail("post a forged power event"); + const still_serving = verdictWithin(forbidden, spare) orelse + fail("the registrar went silent after a forged power event — it acted on it"); + if (still_serving != -envelope.EPERM) fail("the registry misanswered after a forged power event"); + _ = logging.write("protocol-registry: forged power event ignored\n"); + + // 4. Sixty-four zero-length calls, each carrying a capability. The kernel + // installs a sent capability in the receiver's table whatever the message + // length, so every one of these hands PID 1 a handle — and PID 1 has + // thirty-two slots. A registrar whose ping path returns without closing + // runs out before this loop is half done, after which nothing can ever + // bind again and the machine cannot recover for the rest of the boot. + var storm: u32 = 0; + while (storm < 64) : (storm += 1) { + var scratch: [8]u8 = undefined; + _ = ipc.callCap(registry, "", &scratch, spare) catch {}; + } + // The proof is a legitimate bind landing afterwards. This one doubles as + // the driver's listening post for step 7. + const verdicts = ipc.createIpcEndpoint() orelse fail("create the verdict endpoint"); + const after_storm = verdictWithin(verdict_contract, verdicts) orelse + fail("the registrar went silent after capability-carrying pings"); + if (after_storm != 0) fail("a legitimate bind failed after capability-carrying pings"); + _ = logging.write("protocol-registry: capability-carrying pings did not exhaust the registrar\n"); + + // 5. A live owner's name is not for the taking. Collision is an error, never + // last-writer-wins — otherwise any process could impersonate any service. + const exits = ipc.createIpcEndpoint() orelse fail("create the exit endpoint"); + const first = process.spawnSupervised("protocol-registry-test", &.{ "provider", "1" }, exits) orelse + fail("spawn the first provider"); + const one = reach('1') orelse fail("the first provider never bound its contract"); + const collision = verdictOf(contract, spare); + if (collision != -envelope.EBUSY) fail("a collision with a live owner was not refused with -EBUSY"); + _ = logging.write("protocol-registry: collision refused\n"); + + // 6. The restart story. Kill the provider: the cached channel dies with it + // (the kernel fails calls on a dead endpoint rather than blocking + // forever), and re-resolving the same name reaches whatever instance the + // registry now points at. No reconnect verb, no client-side repair. + if (!process.kill(first)) fail("kill the first provider"); + awaitExit(exits, first); + if (instanceOf(one) != null) fail("a call on a dead provider's channel succeeded"); + _ = logging.write("protocol-registry: dead channel refused\n"); + + _ = process.spawnSupervised("protocol-registry-test", &.{ "provider", "2" }, exits) orelse + fail("spawn the replacement provider"); + const two = reach('2') orelse fail("re-resolving never reached the restarted provider"); + _ = logging.write("protocol-registry: restarted provider reached\n"); + + // 7. The laundering deputy. Two binds by the *same binary*, whose grant rows + // read identically — same claimant path, same supervisor path — differing + // only in which task the supervisor is: + // + // kernel -> this driver -> deputy the deputy binds (control) + // kernel -> this driver -> deputy -> ... the grandchild does not + // + // Nobody authorized the deputy to be a supervisor, so nothing it spawns + // inherits a name — which is the whole reason a supervisor is attested by + // task id and not by the string the kernel stamped on it. The control + // matters as much as the refusal: without it this step would pass just as + // happily against a registrar that refused everything. + const deputy_exits = ipc.createIpcEndpoint() orelse fail("create the deputy's exit endpoint"); + _ = process.spawnSupervised("protocol-registry-test", &.{"deputy"}, deputy_exits) orelse + fail("spawn the deputy"); + const chain = awaitVerdicts(verdicts); + if (chain.deputy != 0) fail("a granted binary spawned by an AUTHORIZED task was refused"); + if (chain.launder != -envelope.EPERM) fail("a granted binary spawned by an UNAUTHORIZED task was not refused"); + _ = logging.write("protocol-registry: laundering deputy refused\n"); + + // 8. A forged terminate SIGNAL. Step 3 proved a *payload* cannot reach PID 1's + // shutdown path; this is the same destination by the other road, and the + // interesting one, because nothing here is forged at all. `signal_bind` + // nominates where a process's own signals are delivered, and `process_signal` + // lets any task signal itself — both correct in isolation, and together a + // shutdown primitive the moment the delivery point may be somebody else's + // endpoint. `fs_resolve` hands every process a handle to PID 1's, so all it + // would take is: bind, then signal yourself. The badge the kernel stamps is + // genuine, which is exactly why init cannot filter it and the kernel has to. + // + // The control comes first and matters as much as the refusal: an endpoint we + // created is accepted, so what the second line refuses is *foreignness* and + // not signals-in-general. If the second line ever succeeds again it also + // steals our own binding, so the terminate below lands in PID 1 and takes the + // boot with it — the assertion is the machine still being here. + const signals = ipc.createIpcEndpoint() orelse fail("create the signal endpoint"); + if (!process.bindSignals(signals)) fail("binding signals to an endpoint we created was refused"); + if (process.bindSignals(registry)) fail("binding signals to the registry's endpoint was allowed"); + if (!process.sendSignal(process.taskId(), .terminate)) fail("post ourselves a terminate signal"); + const after_signal = verdictWithin(forbidden, spare) orelse + fail("the registrar went silent after a redirected terminate signal — it acted on it"); + if (after_signal != -envelope.EPERM) fail("the registry misanswered after a redirected terminate signal"); + _ = logging.write("protocol-registry: foreign signal binding refused\n"); + + // 9. The same rule for the other two kernel notifications. A timer landing + // carries no identity — no sender, no cookie — so a service cannot tell a + // timer it armed from one a stranger armed on its endpoint; init re-arms its + // heartbeat on every landing, so N smuggled timers leave N+1 beats running + // forever. Exit subscriptions are the same shape with a smaller blast radius + // (an eight-slot table, and a firehose of deaths aimed at a stranger). + // Control first again, and for the timer the control's *landing* is the proof + // the refusal is about ownership rather than a timer syscall that just fails. + const ticker = ipc.createIpcEndpoint() orelse fail("create the timer endpoint"); + if (!time.timerOnce(ticker, 1)) fail("arming a timer on an endpoint we created was refused"); + if (time.timerOnce(registry, 1)) fail("arming a timer on the registry's endpoint was allowed"); + var tick: [8]u8 = undefined; + if (!ipc.replyWait(ticker, tick[0..0], &tick, null).isTimer()) fail("our own timer did not land"); + if (process.subscribeExits(registry)) fail("subscribing the registry's endpoint to exit events was allowed"); + 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"); + + // 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 + // other service in the system — the VFS, the display compositor, the device + // manager, every driver — runs `library/kernel/service.zig`, and that loop + // had the identical hole: the zero-length ping answered without closing what + // the ping carried, and callbacks handed a bare handle they had no use for. + // The provider role below runs that same harness, so it stands in for all of + // them. Sixty-four capability-carrying pings is twice its thirty-two slots. + var storm2: u32 = 0; + while (storm2 < 64) : (storm2 += 1) { + var scratch: [8]u8 = undefined; + _ = ipc.callCap(two, "", &scratch, spare) catch {}; + } + // The proof is a *capability-passing* operation still working: the kernel + // installs the sent handle in the receiver's table before the service sees + // the request, so against an exhausted service the call fails outright. And + // the provider answers on the handed-over endpoint, so the handle it claimed + // is a working one and not merely a number. + const handover = ipc.createIpcEndpoint() orelse fail("create the handover endpoint"); + var handover_reply: [8]u8 = undefined; + const served = ipc.callCap(two, &[_]u8{handover_operation}, &handover_reply, handover) catch + fail("a capability-passing call failed after capability-carrying pings — the harness leaked them"); + if (served.len != 1 or handover_reply[0] != '2') fail("the harness-run provider misanswered after capability-carrying pings"); + var echoed: [8]u8 = undefined; + const back = ipc.replyWait(handover, echoed[0..0], &echoed, null); + if (!back.isMessage() or back.len != 1 or echoed[0] != '2') fail("the provider never spoke on the endpoint it was handed"); + _ = logging.write("protocol-registry: capability-carrying pings did not exhaust the harness\n"); + + _ = logging.write("protocol-registry: 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, "provider")) { + const label = startup.arguments.get(2) orelse "?"; + if (label.len != 0) mark = label[0]; + service.run(64, .{ .service = contract, .on_message = answer }); + return; // terminate arrived, or the bind was refused; either way we are done + } + if (std.mem.eql(u8, role, "deputy")) return deputy(); + if (std.mem.eql(u8, role, "launder")) return launder(); + if (std.mem.eql(u8, role, "run")) run(); +} diff --git a/test/system/services/shared-memory-client/build.zig b/test/system/services/shared-memory-client/build.zig index a6a72b3..95c67f5 100644 --- a/test/system/services/shared-memory-client/build.zig +++ b/test/system/services/shared-memory-client/build.zig @@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void { const exe = build_support.userBinary(b, .{ .name = "shared-memory-client", .root_source_file = b.path("shared-memory-client.zig"), - .imports = &.{ "ipc", "logging", "memory", "time" }, + .imports = &.{ "channel", "ipc", "logging", "memory", "time" }, }); b.installArtifact(exe); } diff --git a/test/system/services/shared-memory-client/shared-memory-client.zig b/test/system/services/shared-memory-client/shared-memory-client.zig index a6efd47..4d6b3c2 100644 --- a/test/system/services/shared-memory-client/shared-memory-client.zig +++ b/test/system/services/shared-memory-client/shared-memory-client.zig @@ -5,6 +5,7 @@ //! capability-passing path. +const channel = @import("channel"); const ipc = @import("ipc"); const time = @import("time"); const memory = @import("memory"); @@ -19,7 +20,7 @@ fn expected(i: usize) u8 { fn lookupServer() ?ipc.Handle { var attempts: usize = 0; while (attempts < 100) : (attempts += 1) { - if (ipc.lookup(.shared_memory_test)) |h| return h; + if (channel.openEndpoint("test/shared-memory")) |h| return h; time.sleepMillis(50); } return null; diff --git a/test/system/services/shared-memory-server/shared-memory-server.zig b/test/system/services/shared-memory-server/shared-memory-server.zig index 57d1b8f..eb29d2c 100644 --- a/test/system/services/shared-memory-server/shared-memory-server.zig +++ b/test/system/services/shared-memory-server/shared-memory-server.zig @@ -1,5 +1,5 @@ //! test/system/services/shared-memory-server — the receiving half of the shared-memory test (docs/display-v2.md V2). -//! It registers under `ServiceId.shared_memory_test`; when `shared-memory-client` calls it carrying a +//! It binds `/protocol/test/shared-memory`; when `shared-memory-client` calls it carrying a //! shared-memory capability, it `shared_memory_map`s that capability and checks the client's pattern //! is visible through the mapping — proving the two processes share the same physical pages //! (not a copy). On success it prints `shared-memory: shared 4096 bytes ok`, the test's marker. @@ -16,11 +16,12 @@ fn expected(i: usize) u8 { return @truncate(i *% 7 +% 3); } -fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize { +fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize { _ = message; _ = reply; _ = sender; - const cap = capability orelse { + // Peeked, not claimed: the mapping survives the handle, so the turn closes it. + const cap = arrived.peek() orelse { _ = logging.write("shared-memory: shared FAILED (no capability)\n"); return 0; }; @@ -40,5 +41,5 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han } pub fn main() void { - service.run(64, .{ .service = .shared_memory_test, .on_message = onMessage }); + service.run(64, .{ .service = "test/shared-memory", .on_message = onMessage }); } From 0fbd2c8f12fcbd400bfb943ab79d926d19a9ec36 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:44:15 +0100 Subject: [PATCH 3/4] init: a protocol you were not granted does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. A supervise grant 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. --- build.zig | 1 + build.zig.zon | 1 + docs/os-development/protocol-namespace.md | 6 +- docs/security-track-plan.md | 69 ++++- system/configuration/protocol.csv | 97 ++++++- system/kernel/process.zig | 9 +- system/kernel/tests.zig | 55 ++++ system/services/init/init.zig | 119 +++++++- test/qemu_test.py | 18 +- .../services/protocol-denied-test/build.zig | 15 + .../protocol-denied-test/build.zig.zon | 19 ++ .../protocol-denied-test.zig | 269 ++++++++++++++++++ .../protocol-registry-test.zig | 16 ++ 13 files changed, 670 insertions(+), 24 deletions(-) create mode 100644 test/system/services/protocol-denied-test/build.zig create mode 100644 test/system/services/protocol-denied-test/build.zig.zon create mode 100644 test/system/services/protocol-denied-test/protocol-denied-test.zig diff --git a/build.zig b/build.zig index 493ce99..aac809e 100644 --- a/build.zig +++ b/build.zig @@ -339,6 +339,7 @@ pub fn build(b: *std.Build) void { "thread-test", // the multi-threaded fixture (its package sets .threaded) "user-memory-test", // aims deliberately bad user pointers at the checked copy layer "protocol-registry-test", // drives the registrar: ungranted bind, collision, restart + "protocol-denied-test", // restriction stage one: an ungranted open answers as absence }) |fixture| { const package = b.lazyDependency(fixture, .{}) orelse @panic("a test fixture package is missing under test/system/services"); diff --git a/build.zig.zon b/build.zig.zon index 5c01bad..3313887 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -76,6 +76,7 @@ .@"thread-test" = .{ .path = "test/system/services/thread-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-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true }, // See `zig fetch --save ` for a command-line interface for adding dependencies. //.example = .{ // // When updating this field to a new URL, be sure to delete the corresponding diff --git a/docs/os-development/protocol-namespace.md b/docs/os-development/protocol-namespace.md index ff3ed92..f7e0c02 100644 --- a/docs/os-development/protocol-namespace.md +++ b/docs/os-development/protocol-namespace.md @@ -1,7 +1,9 @@ # The protocol namespace -*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. Not yet implemented — -the migration plan at the end is the work list.* +*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. P1–P3 of the +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. Three ideas, kept deliberately separate: diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index 98b7c15..38fbec6 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -31,13 +31,39 @@ the logging/USB-lifecycle track). ## 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] **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] **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] **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 - [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input) - [ ] **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 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.)* + *(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 binary whose path starts `/test/` — the subtree-scoping rule from the 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 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 vfs, block, display, scanout, input — the modules whose shapes map diff --git a/system/configuration/protocol.csv b/system/configuration/protocol.csv index dc80ef1..57fee6f 100644 --- a/system/configuration/protocol.csv +++ b/system/configuration/protocol.csv @@ -1,9 +1,17 @@ # /system/configuration/protocol.csv — who may claim, and who may reach, a name # under /protocol (docs/os-development/protocol-namespace.md). # -# init is the registrar: it serves /protocol, and every bind is checked against -# this file. It is AUTHORITATIVE — a name no row grants cannot be bound, and a -# missing file means nothing may be bound at all. +# init is the registrar: it serves /protocol, and every bind AND every open is +# checked against this file. It is AUTHORITATIVE — a name no row grants cannot be +# 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. # 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 # task init spawned itself or one the kernel spawned. Task ids are # 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 # # 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. -# The milestone that turns them into refusals is P3 (docs/security-track-plan.md). +# 'supervise' exists because attestation is one hop deep and the driver tree is +# 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 @@ -68,3 +84,72 @@ # another fixture did. /test/*, kernel, 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 diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 22ca4af..4104808 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -332,7 +332,14 @@ fn systemIpcCall(state: *architecture.CpuState) void { /// 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). 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 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); diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 62fc4f9..9d91dd3 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -248,6 +248,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { deviceManagerTest(boot_information); } else if (eql(case, "protocol-registry")) { protocolRegistryTest(boot_information); + } else if (eql(case, "protocol-denied")) { + protocolDeniedTest(boot_information); } else if (eql(case, "reboot")) { rebootTest(); } else { @@ -3843,6 +3845,59 @@ fn protocolRegistryTest(boot_information: *const BootInformation) void { 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 { log("DANOS-TEST-BEGIN: device-manager\n", .{}); if (boot_information.initial_ramdisk_len == 0) { diff --git a/system/services/init/init.zig b/system/services/init/init.zig index 91057b4..fcef455 100644 --- a/system/services/init/init.zig +++ b/system/services/init/init.zig @@ -35,6 +35,12 @@ //! 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 //! 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`), //! because PID 1's thirty-two handle slots are a resource an unauthenticated //! caller would otherwise be able to spend. @@ -153,7 +159,7 @@ const maximum_restarts = 3; /// init holds. const maximum_name = 64; const maximum_bindings = 16; -const maximum_grants = 48; +const maximum_grants = 64; /// 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 @@ -177,10 +183,26 @@ const Binding = struct { var bindings: [maximum_bindings]Binding = .{Binding{}} ** maximum_bindings; -/// What a grant row permits: claiming a name, or reaching one. `open` rows are -/// 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). -const Permission = enum { bind, open }; +/// What a grant row permits. +/// +/// - `bind` — claim the name, i.e. provide the contract. +/// - `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 `*`, /// 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 -/// path, its own format documentation, and grows again with the open grants. -var protocol_csv: [8192]u8 = undefined; +/// path, a row per client per contract it reaches, and its own format +/// 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 grant_count: usize = 0; @@ -225,6 +248,8 @@ fn loadGrants() void { .bind else if (std.mem.eql(u8, permission, "open")) .open + else if (std.mem.eql(u8, permission, "supervise")) + .supervise else continue; // an unreadable row grants nothing rather than something wrong 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; } +/// 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 { for (&bindings) |*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 // 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. - 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); // Everything else a filesystem answers is meaningless here: `/protocol` holds // 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. -/// A name nothing has bound is `-ENOENT`; in P3 an ungranted one becomes the same -/// answer, because absence and refusal are deliberately indistinguishable. -fn onOpen(reply: []u8, raw_name: []const u8) usize { +/// +/// **A refusal and an absence are the same answer, and that is the whole point.** +/// 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 binding = findBinding(name) orelse return answer(reply, -envelope.ENOENT, 0, 0); - pending_capability = binding.endpoint; + refreshProcessTable(); + 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); } diff --git a/test/qemu_test.py b/test/qemu_test.py index d7817d0..ed37da5 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -869,10 +869,26 @@ CASES = [ r"(?=.*protocol-registry: restarted provider reached)" r"(?=.*protocol-registry: laundering deputy 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"(?=.*DANOS-TEST-RESULT: PASS)", "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 # bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn # -> driver-up (the spawned pci-bus logs " functions found"). diff --git a/test/system/services/protocol-denied-test/build.zig b/test/system/services/protocol-denied-test/build.zig new file mode 100644 index 0000000..472938a --- /dev/null +++ b/test/system/services/protocol-denied-test/build.zig @@ -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); +} diff --git a/test/system/services/protocol-denied-test/build.zig.zon b/test/system/services/protocol-denied-test/build.zig.zon new file mode 100644 index 0000000..7d2102b --- /dev/null +++ b/test/system/services/protocol-denied-test/build.zig.zon @@ -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 = .{""}, +} diff --git a/test/system/services/protocol-denied-test/protocol-denied-test.zig b/test/system/services/protocol-denied-test/protocol-denied-test.zig new file mode 100644 index 0000000..7e0942b --- /dev/null +++ b/test/system/services/protocol-denied-test/protocol-denied-test.zig @@ -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(); +} diff --git a/test/system/services/protocol-registry-test/protocol-registry-test.zig b/test/system/services/protocol-registry-test/protocol-registry-test.zig index cfb01e6..14ede1e 100644 --- a/test/system/services/protocol-registry-test/protocol-registry-test.zig +++ b/test/system/services/protocol-registry-test/protocol-registry-test.zig @@ -399,6 +399,22 @@ fn run() void { 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"); + // 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 // 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 From 3e6e21bf0aa91e1d9a2997074548ea2b899fea41 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:44:57 +0100 Subject: [PATCH 4/4] docs: security track group 2 merged --- docs/security-track-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index 38fbec6..6cba676 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -64,7 +64,7 @@ group boundary. 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 +- [x] **merge** group 2 → main, push - [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input) - [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer) - [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers