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