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));