merge: security track group 2 — the protocol namespace replaces ServiceId

# Conflicts:
#	docs/security-track-plan.md
This commit is contained in:
Daniel Samson 2026-08-01 04:45:14 +01:00
commit b004b9c3eb
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
73 changed files with 3925 additions and 364 deletions

View File

@ -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"; 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/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"); 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 // 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 // production set only. The userspace test fixtures under /test join in only
// for a test build which the QEMU harness signals by passing // for a test build which the QEMU harness signals by passing
@ -333,6 +338,8 @@ pub fn build(b: *std.Build) void {
"process-test", "process-test",
"thread-test", // the multi-threaded fixture (its package sets .threaded) "thread-test", // the multi-threaded fixture (its package sets .threaded)
"user-memory-test", // aims deliberately bad user pointers at the checked copy layer "user-memory-test", // aims deliberately bad user pointers at the checked copy layer
"protocol-registry-test", // drives the registrar: ungranted bind, collision, restart
"protocol-denied-test", // restriction stage one: an ungranted open answers as absence
}) |fixture| { }) |fixture| {
const package = b.lazyDependency(fixture, .{}) orelse const package = b.lazyDependency(fixture, .{}) orelse
@panic("a test fixture package is missing under test/system/services"); @panic("a test fixture package is missing under test/system/services");

View File

@ -75,6 +75,8 @@
.@"process-test" = .{ .path = "test/system/services/process-test", .lazy = true }, .@"process-test" = .{ .path = "test/system/services/process-test", .lazy = true },
.@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true }, .@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true },
.@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true }, .@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true },
.@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true },
.@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true },
// See `zig fetch --save <url>` for a command-line interface for adding dependencies. // See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{ //.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding // // When updating this field to a new URL, be sure to delete the corresponding

View File

@ -87,13 +87,13 @@ rest of the system hasn't had to face:
│ (ResourceKind.memory = [base, height*pitch], write-combining hint, │ (ResourceKind.memory = [base, height*pitch], write-combining hint,
│ plus DisplayInfo{width, height, pitch, format, refresh_hz}) │ 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) │ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB)
│ mmap(cacheable) a BACK buffer of the same geometry │ mmap(cacheable) a BACK buffer of the same geometry
│ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid) │ 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 │ 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) │ 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) drawing clients (v1) surface clients (deferred)
display commands: display surfaces: display commands: display surfaces:

View File

@ -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 The bootstrap problem — how a channel is first established — is the subject of
[protocol-namespace.md](../os-development/protocol-namespace.md): a protocol is [protocol-namespace.md](../os-development/protocol-namespace.md): a protocol is
resolved by name and the channel arrives as a capability. (The mechanism this resolved by name and the channel arrives as a capability. (The mechanism it
replaces, `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers, replaced — `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers —
is retired by that design.) is gone: both syscalls and the enum were deleted when the registry landed, and
their syscall numbers are left vacant.)
### Interrupts are signals ### Interrupts are signals
@ -169,6 +170,27 @@ signal mechanism:
- **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a - **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a
timer-bit signal — the timed wait: a service arms a deadline and keeps timer-bit signal — the timed wait: a service arms a deadline and keeps
serving, instead of blocking in sleep. 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**, - **The universal ping**: a **zero-length request is the liveness probe**,
answered with a zero-length reply by the service harness itself answered with a zero-length reply by the service harness itself
(`service.run`). No protocol's requests start at length zero, so the (`service.run`). No protocol's requests start at length zero, so the

View File

@ -161,15 +161,26 @@ Aligned to the node-kind table in the file-system hierarchy
| 4 | symbolic link | | 4 | symbolic link |
| 5 | fifo | | 5 | fifo |
| 6 | socket | | 6 | socket |
| 7 | protocol |
Clients should map unknown values to *regular* rather than reject — the Clients should map unknown values to *regular* rather than reject — the
table can grow. Kind 6 (`socket`) keeps its wire value but is retired from table can grow. Kind 6 (`socket`) keeps its wire value but is retired from
the design — a named rendezvous point is exactly what a `protocol` node is, the design — a named rendezvous point is exactly what a `protocol` node is,
planned as value 7 with the protocol namespace landed as value 7 with the protocol namespace
(docs/os-development/protocol-namespace.md). `character_device` (stream (docs/os-development/protocol-namespace.md). `character_device` (stream
semantics — the tty/console shape) and `block_device` (raw sector-addressed semantics — the tty/console shape) and `block_device` (raw sector-addressed
volumes, reserved) remain part of the design. volumes, reserved) remain part of the design.
## An open reply may carry a capability
`open` rides `ipc_call`, whose reply direction can hand back an endpoint
capability alongside the `Reply` header. A file backend never uses it — FAT
answers with a node id and nothing else — but a **synthetic** backend does:
opening a `protocol` node returns the provider's endpoint, and possession of
that endpoint *is* the channel. The convention is per-backend, not
per-operation, so a client that opens an ordinary file simply receives no
capability, exactly as before.
## Lifetimes and trust ## Lifetimes and trust
Open-node ids live in the backend. A client that dies without closing leaks Open-node ids live in the backend. A client that dies without closing leaks
@ -188,8 +199,8 @@ What a non-Zig implementation may rely on, and what it must not:
- Operation values, flag bits, `NodeKind` values, and struct layouts are - Operation values, flag bits, `NodeKind` values, and struct layouts are
**append-only and frozen once shipped**. The unit test in **append-only and frozen once shipped**. The unit test in
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry` `library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry`
size, `NodeKind` 01, `Operation` values 0, 4 and 5); this page is the size, `NodeKind` 01 and 67, `Operation` values 0, 4 and 5); this page is
full record of the frozen values. the full record of the frozen values.
- The 256-byte message ceiling is a property of the current IPC transport, - The 256-byte message ceiling is a property of the current IPC transport,
not a promise; clients should read `maximum_payload`-shaped limits from the not a promise; clients should read `maximum_payload`-shaped limits from the
reply lengths they actually get (loop-until-done), not hard-code 224. reply lengths they actually get (loop-until-done), not hard-code 224.

View File

@ -231,8 +231,8 @@ Two consequences of neutrality bind on later work:
- **Cross-firmware surfaces are named by domain, not firmware.** System power is - **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 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 service binds it, on ARM a PSCI/mailbox service binds the same
`ServiceId.power`, and subscribers never learn the difference. `/protocol/power`, and subscribers never learn the difference.
- **Identity must widen before the fdt service exists.** `DeviceDescriptor`'s - **Identity must widen before the fdt service exists.** `DeviceDescriptor`'s
8-byte `hid` holds an EISA id but cannot hold an FDT `compatible` string 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 (`"brcm,bcm2835-aux-uart"`); the identity field grows before the ARM path can

View File

@ -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. ([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 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` 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** module and a contract named `/protocol/power`; on x86 the **acpi service**
registers it, and on ARM a PSCI/mailbox service will register the *same* id. binds it, and on ARM a PSCI/mailbox service will bind the *same* name.
Subscribers call `ipc.lookup(.power)` and never learn which firmware they Subscribers open `/protocol/power` and never learn which firmware they
are on — the neutrality the whole [discovery](discovery.md) migration exists to are on — the neutrality the whole [discovery](discovery.md) migration exists to
preserve, carried one layer up into a running-system surface. preserve, carried one layer up into a running-system surface.

View File

@ -1,7 +1,9 @@
# The protocol namespace # The protocol namespace
*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. Not yet implemented — *Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. P1P3 of the
the migration plan at the end is the work list.* migration plan at the end have landed (the envelope, the registry and the
`ServiceId` flag-day, and restriction stage one); P4 and P5 are the remaining
work list.*
How a program finds, connects to, and is restricted from the things it talks to. How a program finds, connects to, and is restricted from the things it talks to.
Three ideas, kept deliberately separate: Three ideas, kept deliberately separate:

View File

@ -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; true` where a binary spawns threads) and get packed into the initial-ramdisk;
new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a
`library/kernel` wrapper; test services live beside the code they exercise and `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 ## How to verify along the way

View File

@ -270,13 +270,15 @@ stays single-threaded and lean.
- *Handles do not cross threads.* The handle table lives on the `Task` - *Handles do not cross threads.* The handle table lives on the `Task`
([scheduler.zig](../../system/kernel/scheduler.zig)), so a handle number is meaningful ([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. 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 A thread that needs to reach an endpoint another thread owns opens the name
(`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint. (`channel.openEndpoint("display")`) to install its **own** handle to the same
This is how the display's mouse-listener thread reaches the compositor loop's endpoint underlying endpoint — an ordinary client open, with no special mechanism for the
to poke it awake (docs/display.md). 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.* - *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 `create_ipc_endpoint` allocates from the kernel heap and
mutate the global service registry, endpoint refcounts, and handle tables. Those paths mutates endpoint refcounts and handle tables. Those paths
were unlocked because a single-threaded process could not race itself; a multi-threaded 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`/ 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 `send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel

View File

@ -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` | | 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` | | 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` | | 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/<name>` — 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` | | 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` | | time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` |
| diagnostics | `danos_debug_write` (leveled, kernel-stamped records), `danos_klog_read`, `danos_klog_status` | | 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) | | 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 The constants that ride alongside the calls — mmap protection bits, DMA
flags, notification badge bits, `ExitReason`, `Signal`, well-known service flags, notification badge bits, `ExitReason`, `Signal`, `page_size`, the IPC
ids, `page_size`, the IPC message maximum — move to the public header too: message maximum — move to the public header too:
they are wire values a Rust program needs verbatim. What stays private in 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` `abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
numbers and the trap convention. numbers and the trap convention.

View File

@ -36,10 +36,10 @@ plain `main` checkout always tells the truth about where the work is.**
| | | | | |
|---|---| |---|---|
| Working on | **P3** — green at 109/109, adversarial review running, not yet committed | | Working on | **P4a** — protocol rebase onto `envelope.Define` |
| Branch carrying it | `feat/security-group-2` (pushed to origin) | | Branch carrying it | `feat/security-group-3` (cut next) |
| On `main` | through the group 1 merge (`f3bc23c`): Phase 0, PM, H1 | | On `main` | Phase 0, PM, H1, P1, P2, P3 — groups 1 and 2 merged |
| Committed on the branch, awaiting the group 2 merge | P1 (`1ff0991`), P2 (`1379b69`) | | Awaiting merge | nothing |
| Suite | 109 cases, all passing | | Suite | 109 cases, all passing |
| Last updated | 2026-08-01 | | Last updated | 2026-08-01 |
@ -64,7 +64,7 @@ group boundary.
the manifest gained a third permission, `supervise`, which names an authorized the manifest gained a third permission, `supervise`, which names an authorized
supervising task per contract and is deliberately **open-only**, leaving P2's supervising task per contract and is deliberately **open-only**, leaving P2's
bind attestation and every refusal it makes untouched (suite 109/109) bind attestation and every refusal it makes untouched (suite 109/109)
- [ ] **merge** group 2 → main, push - [x] **merge** group 2 → main, push
- [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input) - [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input)
- [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer) - [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer)
- [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers - [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers

View File

@ -12,10 +12,15 @@ pub fn build(b: *std.Build) void {
const ipc = kernel.module("ipc"); const ipc = kernel.module("ipc");
const time = kernel.module("time"); const time = kernel.module("time");
// Every client reaches its service by name now: resolve `/protocol/<name>`,
// 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", .{ _ = b.addModule("display-client", .{
.root_source_file = b.path("display/display-client.zig"), .root_source_file = b.path("display/display-client.zig"),
.imports = &.{ .imports = &.{
.{ .name = "channel", .module = channel },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
.{ .name = "display-protocol", .module = protocol.module("display-protocol") }, .{ .name = "display-protocol", .module = protocol.module("display-protocol") },
@ -24,6 +29,7 @@ pub fn build(b: *std.Build) void {
_ = b.addModule("input-client", .{ _ = b.addModule("input-client", .{
.root_source_file = b.path("input/input-client.zig"), .root_source_file = b.path("input/input-client.zig"),
.imports = &.{ .imports = &.{
.{ .name = "channel", .module = channel },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
.{ .name = "input-protocol", .module = protocol.module("input-protocol") }, .{ .name = "input-protocol", .module = protocol.module("input-protocol") },

View File

@ -1,9 +1,10 @@
//! User-space display client: talk to the display service (query the mode, and from D3 //! 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` //! 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. //! reply marshalling. See system/services/display/ and docs/display.md.
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const display_protocol = @import("display-protocol"); const display_protocol = @import("display-protocol");
@ -19,13 +20,13 @@ pub const Info = struct {
/// The service endpoint, looked up once and cached. /// The service endpoint, looked up once and cached.
var handle: ?ipc.Handle = null; var handle: ?ipc.Handle = null;
/// Look up the display service, retrying while it comes up (a client races its /// Open `/protocol/display`, retrying while it comes up (a client races the
/// registration at boot). Returns the endpoint, or null if it never appears. /// service's bind at boot). Returns the endpoint, or null if it never appears.
fn service() ?ipc.Handle { fn service() ?ipc.Handle {
if (handle) |h| return h; if (handle) |h| return h;
var attempts: usize = 0; var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) { while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.display)) |h| { if (channel.openEndpoint("display")) |h| {
handle = h; handle = h;
return h; return h;
} }

View File

@ -22,7 +22,7 @@
//! } //! }
const std = @import("std"); const std = @import("std");
const abi = @import("abi"); const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const input_protocol = @import("input-protocol"); 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_joystick = input_protocol.device_joystick;
pub const device_all = input_protocol.device_all; pub const device_all = input_protocol.device_all;
/// Look up the input service, retrying while it is still coming up. Both a subscriber and /// Open `/protocol/input`, 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 /// a source race the service's bind at boot, so both wait for it here rather than
/// failing. Returns the service endpoint handle, or null if it never appears. /// failing. Returns the provider's endpoint handle, or null if it never appears.
fn lookupService() ?ipc.Handle { fn lookupService() ?ipc.Handle {
var attempts: usize = 0; var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) { while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.input)) |handle| return handle; if (channel.openEndpoint("input")) |handle| return handle;
time.sleepMillis(50); time.sleepMillis(50);
} }
return null; return null;

View File

@ -8,6 +8,7 @@
//! limit the same handoff usb-storage uses toward the controller. //! limit the same handoff usb-storage uses toward the controller.
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const block_protocol = @import("block-protocol"); 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. /// timer (the fat service) instead of blocking its harness in here.
pub fn tryOpen() ?Device { pub fn tryOpen() ?Device {
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle }; if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
return null; 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. /// (controller reset, enumeration, mass-storage bring-up) comes up.
pub fn open() ?Device { pub fn open() ?Device {
// Patient: the whole USB storage chain (firmware discovery, xHCI reset and // 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 // completed at ~24 s); a machine whose stick genuinely failed setup should
// not sit a further minute pretending otherwise. // not sit a further minute pretending otherwise.
while (attempts < 600) : (attempts += 1) { while (attempts < 600) : (attempts += 1) {
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle }; if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
time.sleepMillis(50); time.sleepMillis(50);
} }
return null; return null;

View File

@ -14,6 +14,10 @@ pub fn build(b: *std.Build) void {
const system_call = kernel.module("system-call"); const system_call = kernel.module("system-call");
const ipc = kernel.module("ipc"); const ipc = kernel.module("ipc");
const time = kernel.module("time"); 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), // The devices sub-project's public interface (the flat wire types),
// importable by user space, unlike the kernel-internal device model it // 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"), .root_source_file = b.path("driver/driver.zig"),
.imports = &.{ .imports = &.{
.{ .name = "abi", .module = abi }, .{ .name = "abi", .module = abi },
.{ .name = "channel", .module = channel },
.{ .name = "device-abi", .module = device_abi }, .{ .name = "device-abi", .module = device_abi },
.{ .name = "system-call", .module = system_call }, .{ .name = "system-call", .module = system_call },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
@ -81,6 +86,7 @@ pub fn build(b: *std.Build) void {
_ = b.addModule("usb", .{ _ = b.addModule("usb", .{
.root_source_file = b.path("usb/usb.zig"), .root_source_file = b.path("usb/usb.zig"),
.imports = &.{ .imports = &.{
.{ .name = "channel", .module = channel },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
.{ .name = "usb-transfer-protocol", .module = protocol.module("usb-transfer-protocol") }, .{ .name = "usb-transfer-protocol", .module = protocol.module("usb-transfer-protocol") },
@ -92,6 +98,7 @@ pub fn build(b: *std.Build) void {
_ = b.addModule("block", .{ _ = b.addModule("block", .{
.root_source_file = b.path("block/block.zig"), .root_source_file = b.path("block/block.zig"),
.imports = &.{ .imports = &.{
.{ .name = "channel", .module = channel },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
.{ .name = "block-protocol", .module = protocol.module("block-protocol") }, .{ .name = "block-protocol", .module = protocol.module("block-protocol") },

View File

@ -7,6 +7,7 @@ const std = @import("std");
const abi = @import("abi"); const abi = @import("abi");
const device_abi = @import("device-abi"); const device_abi = @import("device-abi");
const sc = @import("system-call"); const sc = @import("system-call");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const device_manager_protocol = @import("device-manager-protocol"); 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 { pub fn hello(role: Role, device_id: u64) ?ipc.Handle {
var attempts: u32 = 0; var attempts: u32 = 0;
const manager = while (attempts < lookup_attempts) : (attempts += 1) { 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); time.sleepMillis(lookup_pause_ms);
} else { } else {
std.log.info("no device manager to hello", .{}); std.log.info("no device manager to hello", .{});

View File

@ -16,6 +16,7 @@
//! the service harness drops buffered-message payloads see service.zig). //! the service harness drops buffered-message payloads see service.zig).
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const usb_transfer_protocol = @import("usb-transfer-protocol"); 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 /// Open `/protocol/usb-transfer` and, on that channel, open the device with the
/// freshly created endpoint for asynchronous interrupt reports. Retries while the /// assigned id, handing over a freshly created endpoint for asynchronous interrupt
/// bus is still coming up (a class driver races the bus driver at boot). /// 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 { pub fn open(device_id: u64) ?Device {
var attempts: usize = 0; var attempts: usize = 0;
const bus = while (attempts < 100) : (attempts += 1) { 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); time.sleepMillis(20);
} else return null; } else return null;

View File

@ -52,7 +52,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "time", .module = time }, .{ .name = "time", .module = time },
}, },
}); });
_ = b.addModule("file-system", .{ const file_system = b.addModule("file-system", .{
.root_source_file = b.path("file-system.zig"), .root_source_file = b.path("file-system.zig"),
.imports = &.{ .imports = &.{
.{ .name = "abi", .module = abi }, .{ .name = "abi", .module = abi },
@ -61,6 +61,20 @@ pub fn build(b: *std.Build) void {
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") }, .{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
}, },
}); });
// The channel is the L1 concept made concrete (docs/os-development/communication.md):
// it needs the namespace (file-system, to resolve a /protocol name) and the
// transport (ipc) both, which is why it lives here rather than in a protocol
// module those import nothing.
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") },
},
});
_ = b.addModule("memory", .{ _ = b.addModule("memory", .{
.root_source_file = b.path("memory/memory.zig"), .root_source_file = b.path("memory/memory.zig"),
.imports = &.{ .imports = &.{
@ -70,10 +84,13 @@ pub fn build(b: *std.Build) void {
.{ .name = "thread", .module = thread }, .{ .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", .{ _ = b.addModule("service", .{
.root_source_file = b.path("service.zig"), .root_source_file = b.path("service.zig"),
.imports = &.{ .imports = &.{
.{ .name = "abi", .module = abi }, .{ .name = "channel", .module = channel },
.{ .name = "ipc", .module = ipc }, .{ .name = "ipc", .module = ipc },
.{ .name = "process", .module = process }, .{ .name = "process", .module = process },
}, },
@ -101,4 +118,22 @@ pub fn build(b: *std.Build) void {
}); });
test_step.dependOn(&b.addRunArtifact(kernel_tests).step); test_step.dependOn(&b.addRunArtifact(kernel_tests).step);
} }
// channel needs its whole import set to compile at all; only its framing is
// host-runnable (the syscall seams are x86_64-only, and unreferenced from
// the tests), so that is what it tests.
const channel_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("channel.zig"),
.target = b.resolveTargetQuery(.{}),
.imports = &.{
.{ .name = "ipc", .module = ipc },
.{ .name = "time", .module = time },
.{ .name = "file-system", .module = file_system },
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
.{ .name = "envelope", .module = protocol.module("envelope") },
},
}),
});
test_step.dependOn(&b.addRunArtifact(channel_tests).step);
} }

343
library/kernel/channel.zig Normal file
View File

@ -0,0 +1,343 @@
//! `Channel` layer L1 of the communication stack
//! (docs/os-development/communication.md) made concrete. A program holds a
//! channel that speaks a protocol; it does not hold a raw handle and marshal
//! bytes at one. The channel is the answer to "who am I talking to", decided
//! once at establishment, so nothing after that ever routes a party again:
//! every packet's `target` addresses an *object* within the peer already chosen.
//!
//! **Possession of the Channel is the connection.** There is no connect step, no
//! session id, no reconnect handshake the endpoint capability inside is the
//! whole of the relationship, and it cannot be forged, only handed over. Which
//! also means a channel is a resource: `close` it, or it occupies a handle-table
//! slot for the life of the process.
//!
//! **A dead provider surfaces as `-EPEER`, and the recovery is to re-open.**
//! When the process on the other end exits, the kernel fails calls on its
//! endpoint rather than blocking forever; `call` returns null. The client does
//! not repair the channel it discards it and opens the name again, which
//! reaches whatever instance the registry now points at. The restart story
//! falls out of the naming layer for free; no protocol needs a reconnect verb.
//!
//! `open` resolves a `/protocol/<name>` path through the kernel VFS router and
//! 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");
/// Longest `/protocol/...` path this client marshals. The registry's names are
/// short by construction (a contract leaf, not a file path), and the buffer is
/// on the stack of whoever opens.
pub const path_maximum: usize = 224;
/// 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.
pub const Response = struct {
status: envelope.Status,
payload: []u8,
capability: ?ipc.Handle,
/// Whether the provider answered success. A negative status is its refusal
/// (`-ENOSYS` for a verb it does not implement, and so on).
pub fn succeeded(self: Response) bool {
return self.status.status == 0;
}
};
/// An open conversation with one provider, speaking one protocol.
pub const Channel = struct {
/// The provider's endpoint. Sending into it is the only thing this handle
/// can do an endpoint is a mailbox owned by its creator, and that
/// direction never reverses.
endpoint: ipc.Handle,
/// Adopt an endpoint that arrived some other way a capability delivered
/// in a reply, or one a supervisor wired in at spawn time (P5). The channel
/// takes ownership of the handle.
pub fn adopt(endpoint: ipc.Handle) Channel {
return .{ .endpoint = endpoint };
}
/// Establish a channel by name: resolve `/protocol/<name>` to the registry
/// backend, `open` the contract there, and take the provider's endpoint from
/// the reply's capability. Null if the path does not resolve, the registry
/// refuses (an ungranted name is refused *as* not-found), or the reply
/// carries no capability.
///
/// The path is spoken exactly once, here. Everything afterwards is integers
/// in the packet header.
pub fn open(path: []const u8) ?Channel {
return .{ .endpoint = openPath(path) orelse return null };
}
/// 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,
/// `[Status][reply]` back. `request` is the bytes *after* the header the
/// protocol's fixed part plus any tail because the header is this call's
/// to lay down. The reply's payload lands in `into`.
///
/// Null means the transport failed, which today means one of: a dead
/// provider (`-EPEER` discard this channel and `open` the name again), an
/// oversized packet, or a bad handle. A provider that answered *and refused*
/// is not a failure here: it comes back with a negative `Response.status`.
pub fn call(self: Channel, header: envelope.Header, request: []const u8, into: []u8) ?Response {
return self.callCapability(header, request, into, null);
}
/// As `call`, handing the provider a capability with the request the only
/// direction-crossing move kernel-ipc offers, and how `subscribe` delivers
/// the subscriber's own endpoint.
pub fn callCapability(
self: Channel,
header: envelope.Header,
request: []const u8,
into: []u8,
capability: ?ipc.Handle,
) ?Response {
var packet: [envelope.packet_maximum]u8 = undefined;
const framed = frame(header, request, &packet) orelse return null;
var reply: [envelope.packet_maximum]u8 = undefined;
const answer = ipc.callCap(self.endpoint, framed, &reply, capability) catch return null;
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
const available = @min(answer.len - envelope.prefix_size, @as(usize, status.len));
const taken = @min(available, into.len);
@memcpy(into[0..taken], reply[envelope.prefix_size..][0..taken]);
return .{ .status = status, .payload = into[0..taken], .capability = answer.cap };
}
/// Push one event packet and return immediately no reply owed, and a slow
/// or dead peer can never stall the sender. Bounded by `post_maximum`: an
/// event that does not fit is refused here rather than split, because a
/// packet is never fragmented.
pub fn send(self: Channel, header: envelope.Header, payload: []const u8) bool {
var packet: [envelope.post_maximum]u8 = undefined;
const framed = frame(header, payload, &packet) orelse return false;
return ipc.send(self.endpoint, framed);
}
/// Ask the provider what it is: the reserved `describe` verb, answered by
/// every protocol built through `envelope.Define`. The name and version come
/// back in `into`, which the returned `Described` borrows.
pub fn describe(self: Channel, into: []u8) ?envelope.Described {
var request: [envelope.packet_maximum]u8 = undefined;
const packet = envelope.encodeDescribe(&request) orelse return null;
var reply: [envelope.packet_maximum]u8 = undefined;
const answer = ipc.callCap(self.endpoint, packet, &reply, null) catch return null;
const taken = @min(answer.len, into.len);
@memcpy(into[0..taken], reply[0..taken]);
return envelope.decodeDescribe(into[0..taken]);
}
/// Drop the provider's endpoint and free the handle-table slot. The
/// conversation is over the moment the capability is gone there is nothing
/// else holding it open.
pub fn close(self: Channel) void {
_ = ipc.close(self.endpoint);
}
};
// --- 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/<name>`. 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/<name>` 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.
fn frame(header: envelope.Header, body: []const u8, buffer: []u8) ?[]u8 {
const total = envelope.prefix_size + body.len;
if (total > buffer.len) return null;
@memcpy(buffer[0..envelope.prefix_size], std.mem.asBytes(&header));
@memcpy(buffer[envelope.prefix_size..][0..body.len], body);
return buffer[0..total];
}
// --- tests ------------------------------------------------------------------
//
// The syscall half cannot run on the host, and there is no registry to reach
// until P2 so what is testable here is the framing, which is the part with
// arithmetic in it.
const testing = std.testing;
test "a framed packet is the header followed by the protocol's bytes" {
var buffer: [envelope.packet_maximum]u8 = undefined;
const header = envelope.Header{ .operation = envelope.first_protocol_operation, .target = 9 };
const packet = frame(header, "body", &buffer).?;
try testing.expectEqual(envelope.prefix_size + "body".len, packet.len);
const decoded = envelope.headerOf(packet).?;
try testing.expectEqual(envelope.first_protocol_operation, decoded.operation);
try testing.expectEqual(@as(u64, 9), decoded.target);
try testing.expectEqualStrings("body", packet[envelope.prefix_size..]);
}
test "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 };
const body = [_]u8{0} ** (envelope.post_maximum - envelope.prefix_size);
const one_too_many = body ++ [_]u8{0};
try testing.expect(frame(header, &body, &post) != null);
try testing.expect(frame(header, &one_too_many, &post) == null);
}

View File

@ -39,6 +39,7 @@ fn kindFromWire(value: u32) Kind {
@intFromEnum(Kind.symbolic_link) => .symbolic_link, @intFromEnum(Kind.symbolic_link) => .symbolic_link,
@intFromEnum(Kind.fifo) => .fifo, @intFromEnum(Kind.fifo) => .fifo,
@intFromEnum(Kind.socket) => .socket, @intFromEnum(Kind.socket) => .socket,
@intFromEnum(Kind.protocol) => .protocol,
else => .regular, else => .regular,
}; };
} }

View File

@ -29,10 +29,10 @@ pub fn createIpcEndpoint() ?Handle {
return if (failed(r)) null else r; return if (failed(r)) null else r;
} }
/// Publish endpoint `h` under a well-known service id so other processes find it. // `register`/`lookup` lived here the two wrappers over the flat ServiceId
pub fn register(id: abi.ServiceId, h: Handle) bool { // registry. Naming is not a system call any more: a provider binds its contract
return !failed(sc.systemCall2(.ipc_register, @intFromEnum(id), h)); // name at the registry and a client resolves and opens `/protocol/<name>`, both
} // through `channel` (docs/os-development/protocol-namespace.md).
/// Drop a capability handle (endpoint, shared-memory, or DMA-region) and free its table /// 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 /// 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)); 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}; pub const CallError = error{Failed};
/// The result of a capability-passing `callCap`: the reply length, and the handle of /// 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, /// 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 /// 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 /// `receive`. Returns its length, the sender badge, and any capability the request

View File

@ -142,6 +142,18 @@ pub fn subscribeExits(endpoint: usize) bool {
/// snapshot buffer without importing `abi` itself. /// snapshot buffer without importing `abi` itself.
pub const ProcessDescriptor = abi.ProcessDescriptor; 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. /// Give up the rest of this quantum.
pub fn yield() void { pub fn yield() void {
_ = sc.systemCall0(.yield); _ = sc.systemCall0(.yield);

View File

@ -6,13 +6,19 @@
//! loop chose, never on a hijacked stack the whole reason signals are //! loop chose, never on a hijacked stack the whole reason signals are
//! messages. //! 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 //! 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 //! 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 //! 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 //! service author to implement a wedged service simply fails to answer, which
//! is the diagnosis (see docs/ipc.md). //! is the diagnosis (see docs/ipc.md).
const abi = @import("abi"); const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
@ -22,10 +28,22 @@ pub const Callbacks = struct {
/// Return false to abort startup (the process exits). /// Return false to abort startup (the process exits).
init: ?*const fn (endpoint: ipc.Handle) bool = null, init: ?*const fn (endpoint: ipc.Handle) bool = null,
/// One protocol request from `sender` (a task id): write the reply into /// One protocol request from `sender` (a task id): write the reply into
/// `reply`, return its length. `capability` is the handle the request /// `reply`, return its length. The zero-length ping never reaches this.
/// carried, if any (M13 cap passing how a subscriber hands over its ///
/// endpoint). The zero-length ping never reaches this. /// `arrived` is the capability the request carried (M13 cap passing how a
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize, /// 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 /// 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. /// IRQ, a timer landing. The raw badge; decode with the ipc helpers.
on_notification: ?*const fn (badge: u64) void = null, 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 /// the return itself never put *necessary* work here (iron rule 1: a kill
/// arrives with no warning; this is for graceful extras only). /// arrives with no warning; this is for graceful extras only).
on_terminate: ?*const fn () void = null, on_terminate: ?*const fn () void = null,
/// Publish the endpoint under a well-known service id at startup. /// The contract this service provides: a name under `/protocol`, mirroring
service: ?abi.ServiceId = null, /// 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 /// Run the service: create the endpoint, bind it under the service's contract
/// to it, call `init`, then serve until `terminate` arrives at which point the /// name (if it has one), bind signals to it, call `init`, then serve until
/// loop returns and main's return is the clean exit the supervisor reads as /// `terminate` arrives at which point the loop returns and main's return is
/// `ExitReason.exited`. `maximum_message` sizes the receive and reply buffers /// the clean exit the supervisor reads as `ExitReason.exited`.
/// (a service passes its protocol's message maximum). /// `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 { pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
const endpoint = ipc.createIpcEndpoint() orelse return; const endpoint = ipc.createIpcEndpoint() orelse return;
if (callbacks.service) |id| { if (callbacks.service) |name| {
if (!ipc.register(id, endpoint)) return; if (!channel.bindPatiently(name, endpoint)) return;
} }
_ = process.bindSignals(endpoint); _ = process.bindSignals(endpoint);
if (callbacks.init) |initialise| { if (callbacks.init) |initialise| {
@ -59,6 +83,16 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
var receive: [maximum_message]u8 = undefined; var receive: [maximum_message]u8 = undefined;
while (true) { while (true) {
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null); 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()) { if (got.isNotification()) {
reply_len = 0; // nothing owed for a notification reply_len = 0; // nothing owed for a notification
if (process.signalsFrom(got.badge)) |signals| { if (process.signalsFrom(got.badge)) |signals| {
@ -76,8 +110,8 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
} }
if (got.len == 0) { if (got.len == 0) {
reply_len = 0; // the universal ping: a zero-length reply, from the harness 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);
} }
} }

View File

@ -3,6 +3,10 @@
//! every conversation depend on the contract by name; neither reaches into the //! every conversation depend on the contract by name; neither reaches into the
//! other's files. Pure flat wire types: no protocol module imports anything. //! other's files. Pure flat wire types: no protocol module imports anything.
//! //!
//! One module here is not a protocol but the shape the others are written in:
//!
//! envelope : the packet prefix + comptime Define (docs/os-development/protocol-namespace.md)
//!
//! vfs-protocol : the VFS server <-> the file layer (unistd/stdio) //! vfs-protocol : the VFS server <-> the file layer (unistd/stdio)
//! input-protocol : the input fan-out service <-> sources + subscribers //! input-protocol : the input fan-out service <-> sources + subscribers
//! block-protocol : a filesystem <-> a block driver (usb-storage) //! block-protocol : a filesystem <-> a block driver (usb-storage)
@ -16,6 +20,9 @@ const std = @import("std");
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
for ([_]struct { name: []const u8, root: []const u8 }{ for ([_]struct { name: []const u8, root: []const u8 }{
// Not a protocol, hence not `-protocol`: the envelope is what a
// protocol is defined *through*.
.{ .name = "envelope", .root = "envelope/envelope.zig" },
.{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" }, .{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" },
.{ .name = "input-protocol", .root = "input/input-protocol.zig" }, .{ .name = "input-protocol", .root = "input/input-protocol.zig" },
.{ .name = "block-protocol", .root = "block/block-protocol.zig" }, .{ .name = "block-protocol", .root = "block/block-protocol.zig" },
@ -32,6 +39,7 @@ pub fn build(b: *std.Build) void {
// its aggregate test step. // its aggregate test step.
const test_step = b.step("test", "Run the protocol unit tests"); const test_step = b.step("test", "Run the protocol unit tests");
for ([_][]const u8{ for ([_][]const u8{
"envelope/envelope.zig", // framing round trips, verb numbering, dispatch, the floors
"vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values "vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values
"display/display-protocol.zig", // pack(): native pixel encoding per format "display/display-protocol.zig", // pack(): native pixel encoding per format
}) |root| { }) |root| {

View File

@ -0,0 +1,924 @@
//! The envelope the fixed prefix every danos packet begins with, and the
//! comptime `Define` that builds a protocol out of it. Layer L2 of
//! [communication.md](../../../docs/os-development/communication.md); the
//! authoritative description is
//! [protocol-namespace.md](../../../docs/os-development/protocol-namespace.md).
//!
//! This is the one module in the protocol domain that is not itself a protocol:
//! it is the shape every protocol is expressed in. A protocol module hands
//! `Define` its verbs and events and gets back numbered operations (never
//! colliding with the reserved range), typed encode/decode helpers, a
//! provider-side dispatch table that answers `describe` on its own and, the
//! point of the exercise, compile-time proof that none of its packets can
//! exceed the transport floor. Errors that used to surface as runtime
//! truncation are compile errors, and "packets never fragment" is enforced at
//! the source rather than by review.
//!
//! **The prefix is folded, never stacked.** A `request`, `reply`, or `payload`
//! type names the bytes that follow the prefix never the whole packet. The
//! verb and the object being addressed live in the prefix, so a protocol type
//! carries neither an `operation` field of its own nor a nested `Header`;
//! `rejectStacking` refuses one at compile time, and every size check adds
//! `prefix_size` exactly once.
const std = @import("std");
// --- the prefix -------------------------------------------------------------
/// Every packet a danos protocol transmits begins with this header requests
/// on the synchronous call path, event packets on the asynchronous push path.
/// A reply spends the same 16 bytes on `Status` instead.
pub const Header = extern struct {
/// The verb. Values below `first_protocol_operation` are the reserved
/// universal verbs, which mean the same thing in every protocol.
operation: u32,
_padding: u32 = 0,
/// **Object** addressing within the peer, never party addressing: which of
/// the peer's objects this packet operates on a volume, a layer, a node,
/// a device. `0` addresses the provider itself, and a protocol with no
/// objects never uses the field. *Which* party is at the other end was
/// decided once, when the channel was opened, and *who sent this* is the
/// kernel-stamped badge; neither is ever written here, which is what keeps
/// the source unforgeable.
target: u64 = 0,
};
/// Every reply begins with this. `len` counts the bytes that follow: the
/// reply's fixed part plus whatever variable tail the operation defines.
pub const Status = extern struct {
status: i32, // 0, or a negative errno
_padding: u32 = 0,
len: u32 = 0,
_padding2: u32 = 0,
};
/// The fixed prefix every packet spends `Header` on a request or an event,
/// `Status` on a reply. One constant, because the two are deliberately the same
/// width: the packet budget does not depend on the direction.
pub const prefix_size: usize = @sizeOf(Header);
comptime {
if (@sizeOf(Header) != 16 or @sizeOf(Status) != 16)
@compileError("the envelope prefix is 16 bytes in both directions");
}
// --- the reserved verbs -----------------------------------------------------
/// Reserved verbs, answered by every provider. `Define` numbers a protocol's
/// own verbs from `first_protocol_operation`, so no protocol can reach in here.
pub const operation_describe: u32 = 0; // -> protocol name, version, target kinds
pub const operation_enumerate: u32 = 1; // -> the current targets, one per reply page
pub const operation_subscribe: u32 = 2; // capability = the subscriber's endpoint
pub const operation_unsubscribe: u32 = 3;
pub const first_protocol_operation: u32 = 16;
/// The `describe` reply's fixed part, followed inline by `name_len` bytes of the
/// protocol's name. This is the version handshake: the version is asked for
/// once, at connect time, rather than re-carried by every packet out of a
/// 256-byte budget.
pub const Description = extern struct {
version: u32,
operation_count: u32,
event_count: u32,
name_len: u32,
};
/// Longest protocol name a `describe` reply can carry.
pub const name_maximum: usize = packet_maximum - prefix_size - @sizeOf(Description);
// --- the transport floor ----------------------------------------------------
/// The packet budget every protocol may assume on *any* transport. These are
/// the kernel-ipc transport's limits `MESSAGE_MAXIMUM` and `POST_MAXIMUM` in
/// system/kernel/ipc-synchronous.zig restated here because the kernel keeps
/// them private and a protocol has to compile against something. A fatter
/// transport raises its own ceiling; the floor does not move, so a protocol
/// that fits here fits everywhere (communication.md: ceilings are transport
/// properties, the floor is the protocol's contract).
pub const packet_maximum: usize = 256; // one request or one reply (ipc_call)
pub const post_maximum: usize = 64; // one event packet (ipc_send)
/// Whether a request or reply whose fixed part is `T` fits the call floor once
/// the prefix is counted. The folded rule in one line: `prefix_size` is added
/// exactly once, because `T` describes only what follows it. Exported so the
/// rule itself is testable `Define` enforces it as a compile error.
pub fn fitsPacket(comptime T: type) bool {
return prefix_size + @sizeOf(T) <= packet_maximum;
}
/// The same, against the much smaller push floor an event packet lives within.
pub fn fitsPost(comptime T: type) bool {
return prefix_size + @sizeOf(T) <= post_maximum;
}
// --- reply statuses the envelope itself produces -----------------------------
/// Continued from the kernel's danos-native errno numbering
/// (system/kernel/ipc-synchronous.zig, which ends at `EPERM` = 9), so a client
/// reads one vocabulary whether the number came from the kernel or a provider.
/// Positive here, sent negated in `Status.status`, as the kernel spells it.
pub const ENOSYS: i32 = 10; // this protocol has no such operation
pub const EPROTO: i32 = 11; // malformed packet: shorter than the verb it names
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 ----------------------------------------------------------------
/// The header of a received packet, or null when it is too short to have one.
pub fn headerOf(packet: []const u8) ?Header {
if (packet.len < prefix_size) return null;
return std.mem.bytesToValue(Header, packet[0..prefix_size]);
}
/// The status of a received reply, or null when it is too short to have one.
pub fn statusOf(packet: []const u8) ?Status {
if (packet.len < prefix_size) return null;
return std.mem.bytesToValue(Status, packet[0..prefix_size]);
}
/// Frame a bare `describe` request. Protocol-independent: the reserved verbs
/// are asked the same way of every provider.
pub fn encodeDescribe(buffer: []u8) ?[]u8 {
const header = Header{ .operation = operation_describe };
return frame(std.mem.asBytes(&header), &.{}, &.{}, buffer);
}
/// A decoded `describe` reply: the fixed part, plus the name that follows it.
pub const Described = struct {
description: Description,
name: []const u8,
};
/// Decode a `describe` reply packet. Null if it failed, was truncated, or is
/// not a description at all.
pub fn decodeDescribe(packet: []const u8) ?Described {
const status = statusOf(packet) orelse return null;
if (status.status != 0) return null;
const body = packet[prefix_size..];
if (body.len < @sizeOf(Description)) return null;
const description = std.mem.bytesToValue(Description, body[0..@sizeOf(Description)]);
const name = body[@sizeOf(Description)..];
if (name.len < description.name_len) return null;
return .{ .description = description, .name = name[0..description.name_len] };
}
/// The single framing point: prefix, then the fixed part, then the variable
/// tail, contiguous in one buffer. Null when the packet would not fit a
/// packet is never split, so not fitting is a failure, not a continuation.
fn frame(prefix: []const u8, fixed: []const u8, tail: []const u8, buffer: []u8) ?[]u8 {
const total = prefix.len + fixed.len + tail.len;
if (total > buffer.len) return null;
@memcpy(buffer[0..prefix.len], prefix);
@memcpy(buffer[prefix.len..][0..fixed.len], fixed);
@memcpy(buffer[prefix.len + fixed.len ..][0..tail.len], tail);
return buffer[0..total];
}
/// The bytes of a fixed part empty for `void`, which is how an operation says
/// "nothing but the verb".
fn bytesOf(comptime T: type, value: *const T) []const u8 {
if (@sizeOf(T) == 0) return &.{};
return @as([*]const u8, @ptrCast(value))[0..@sizeOf(T)];
}
/// Read a fixed part out of a packet body. A zero-sized part always succeeds
/// (there is nothing to be short of); anything else needs its full width.
fn valueOf(comptime T: type, body: []const u8) ?T {
if (@sizeOf(T) == 0) return @as(T, undefined);
if (body.len < @sizeOf(T)) return null;
return std.mem.bytesToValue(T, body[0..@sizeOf(T)]);
}
// --- the specification ------------------------------------------------------
/// One verb of a protocol. `request` and `reply` describe the bytes *after* the
/// prefix; either may be `void`, meaning the verb (and its target) says it all.
pub const OperationSpecification = struct {
name: []const u8,
request: type = void,
reply: type = void,
};
/// One event a provider pushes to its subscribers. `payload` is the bytes after
/// the `Header`, and the whole packet must fit the push floor.
pub const EventSpecification = struct {
name: []const u8,
payload: type = void,
};
/// What `Define` is given: the contract, whole.
pub const Specification = struct {
/// The contract's name the same word as its `/protocol/<name>` leaf and
/// its `library/protocol/` module.
name: []const u8,
version: u32,
operations: []const OperationSpecification = &.{},
events: []const EventSpecification = &.{},
};
const reserved_names = [_][]const u8{ "describe", "enumerate", "subscribe", "unsubscribe" };
fn isReservedName(comptime name: []const u8) bool {
for (reserved_names) |reserved| {
if (std.mem.eql(u8, reserved, name)) return true;
}
return false;
}
/// Refuse a protocol type that carries the prefix inside itself. The header is
/// folded into every packet, so a type that also holds one would send it twice
/// and re-invent per-protocol addressing the mistake the envelope exists to
/// prevent.
fn rejectStacking(comptime protocol: []const u8, comptime verb: []const u8, comptime T: type) void {
switch (@typeInfo(T)) {
.@"struct" => |info| for (info.fields) |field| {
if (field.type == Header or field.type == Status) @compileError(std.fmt.comptimePrint(
"protocol '{s}', verb '{s}': the envelope prefix is folded, not stacked — " ++
"drop the {s} field '{s}' and use the packet's own Header.operation / Header.target",
.{ protocol, verb, @typeName(field.type), field.name },
));
},
else => {},
}
}
fn nullDefault(comptime T: type) *const anyopaque {
const empty: ?T = null;
return @ptrCast(&empty);
}
// --- Define -----------------------------------------------------------------
/// Build a protocol from its specification. Everything below happens at compile
/// time; the generated type is what both sides of the conversation import.
///
/// ```zig
/// pub const Protocol = envelope.Define(.{
/// .name = "display",
/// .version = 1,
/// .operations = &.{
/// .{ .name = "configure_layer", .request = ConfigureLayer, .reply = void },
/// .{ .name = "blit", .request = Blit, .reply = void },
/// },
/// .events = &.{
/// .{ .name = "layer_lost", .payload = LayerLost },
/// },
/// });
/// ```
///
/// Refused at compile time, each with the protocol, the verb, and the numbers
/// named in the message:
///
/// - a request or reply that does not fit `packet_maximum` once `prefix_size`
/// is added (`.request = extern struct { bytes: [241]u8 }` 241 + 16 = 257);
/// - an event payload that does not fit `post_maximum` the same way
/// (`.payload = extern struct { bytes: [49]u8 }` 49 + 16 = 65);
/// - a type that stacks the prefix instead of folding it (a `Header` field);
/// - a verb named after a reserved one, or named twice.
///
/// A variable tail is bounded at *run* time instead, by `encodeRequest` and its
/// siblings, because only the caller knows how long it is.
pub fn Define(comptime specification: Specification) type {
comptime {
if (specification.name.len == 0) @compileError("a protocol needs a name");
if (specification.name.len > name_maximum) @compileError(std.fmt.comptimePrint(
"protocol '{s}': the name is {d} bytes, and a describe reply carries at most {d}",
.{ specification.name, specification.name.len, name_maximum },
));
for (specification.operations, 0..) |operation, index| {
if (isReservedName(operation.name)) @compileError(std.fmt.comptimePrint(
"protocol '{s}': '{s}' is a reserved universal verb — the envelope already answers it",
.{ specification.name, operation.name },
));
for (specification.operations[0..index]) |earlier| {
if (std.mem.eql(u8, earlier.name, operation.name)) @compileError(std.fmt.comptimePrint(
"protocol '{s}': operation '{s}' is declared twice",
.{ specification.name, operation.name },
));
}
rejectStacking(specification.name, operation.name, operation.request);
rejectStacking(specification.name, operation.name, operation.reply);
if (!fitsPacket(operation.request)) @compileError(std.fmt.comptimePrint(
"protocol '{s}', operation '{s}': the request is {d} bytes and the header {d}, " ++
"over the {d}-byte call floor — packets never fragment, so this has to shrink " ++
"or move its bulk to shared memory",
.{ specification.name, operation.name, @sizeOf(operation.request), prefix_size, packet_maximum },
));
if (!fitsPacket(operation.reply)) @compileError(std.fmt.comptimePrint(
"protocol '{s}', operation '{s}': the reply is {d} bytes and the status header {d}, " ++
"over the {d}-byte call floor",
.{ specification.name, operation.name, @sizeOf(operation.reply), prefix_size, packet_maximum },
));
}
for (specification.events, 0..) |event, index| {
for (specification.events[0..index]) |earlier| {
if (std.mem.eql(u8, earlier.name, event.name)) @compileError(std.fmt.comptimePrint(
"protocol '{s}': event '{s}' is declared twice",
.{ specification.name, event.name },
));
}
rejectStacking(specification.name, event.name, event.payload);
if (!fitsPost(event.payload)) @compileError(std.fmt.comptimePrint(
"protocol '{s}', event '{s}': the payload is {d} bytes and the header {d}, " ++
"over the {d}-byte push floor — an event carries the header too, so it is the " ++
"payload that has to give",
.{ specification.name, event.name, @sizeOf(event.payload), prefix_size, post_maximum },
));
}
}
return struct {
pub const protocol_name: []const u8 = specification.name;
pub const version: u32 = specification.version;
/// What a provider sizes its receive and reply buffers to. A packet's
/// fixed part may be far smaller, but any caller may send up to the
/// floor and a short buffer truncates rather than refuses.
pub const message_maximum: usize = packet_maximum;
/// The widest packet this protocol's fixed parts can actually produce,
/// prefix included a diagnostic, and what a test pins.
pub const request_maximum: usize = widest(specification.operations, .request);
pub const reply_maximum: usize = widest(specification.operations, .reply);
pub const event_maximum: usize = blk: {
var widest_event: usize = prefix_size;
for (specification.events) |event| widest_event = @max(widest_event, prefix_size + @sizeOf(event.payload));
break :blk widest_event;
};
/// This protocol's verbs, numbered from `first_protocol_operation` in
/// declaration order.
pub const Operation = numbered(specification.operations, "name");
/// This protocol's events, numbered from `first_protocol_operation` in
/// their **own** space. Events travel only provider subscriber over
/// `ipc_send` and operations only client provider over `ipc_call`, so
/// the direction already tells the two apart; separate spaces mean
/// appending an operation can never renumber a shipped event.
pub const Event = numbered(specification.events, "name");
/// The bytes after the `Header` on a request for `operation`.
pub fn RequestOf(comptime operation: Operation) type {
return specification.operations[indexOf(@intFromEnum(operation))].request;
}
/// The bytes after the `Status` on the reply to `operation`.
pub fn ReplyOf(comptime operation: Operation) type {
return specification.operations[indexOf(@intFromEnum(operation))].reply;
}
/// The bytes after the `Header` on an `event` packet.
pub fn PayloadOf(comptime event: Event) type {
return specification.events[indexOf(@intFromEnum(event))].payload;
}
// --- client side ----------------------------------------------------
/// Frame `[Header][request][tail]`. `tail` is the variable part (a path,
/// write bytes); pass `&.{}` when the verb has none. Null if the packet
/// would exceed the buffer or the call floor.
pub fn encodeRequest(
comptime operation: Operation,
target: u64,
request: RequestOf(operation),
tail: []const u8,
buffer: []u8,
) ?[]u8 {
const header = Header{ .operation = @intFromEnum(operation), .target = target };
const packet = frame(std.mem.asBytes(&header), bytesOf(RequestOf(operation), &request), tail, buffer) orelse return null;
return if (packet.len > packet_maximum) null else packet;
}
/// Frame `[Status][reply][tail]` the provider's answer, for a provider
/// that composes its own reply rather than using `Provider.dispatch`.
pub fn encodeReply(
comptime operation: Operation,
status: i32,
reply: ReplyOf(operation),
tail: []const u8,
buffer: []u8,
) ?[]u8 {
const fixed = bytesOf(ReplyOf(operation), &reply);
const head = Status{ .status = status, .len = @intCast(fixed.len + tail.len) };
const packet = frame(std.mem.asBytes(&head), fixed, tail, buffer) orelse return null;
return if (packet.len > packet_maximum) null else packet;
}
/// Frame `[Header][payload]` for an asynchronous push. Null if it would
/// exceed the buffer or the push floor an event that does not fit is
/// dropped at the source, never split.
pub fn encodeEvent(
comptime event: Event,
target: u64,
payload: PayloadOf(event),
buffer: []u8,
) ?[]u8 {
const header = Header{ .operation = @intFromEnum(event), .target = target };
const packet = frame(std.mem.asBytes(&header), bytesOf(PayloadOf(event), &payload), &.{}, buffer) orelse return null;
return if (packet.len > post_maximum) null else packet;
}
/// Which of this protocol's verbs a packet names null for a reserved
/// verb, or for a number this protocol does not define.
pub fn operationOf(packet: []const u8) ?Operation {
const header = headerOf(packet) orelse return null;
const index = header.operation -% first_protocol_operation;
if (header.operation < first_protocol_operation or index >= specification.operations.len) return null;
return @enumFromInt(header.operation);
}
/// Which of this protocol's events a pushed packet carries.
pub fn eventOf(packet: []const u8) ?Event {
const header = headerOf(packet) orelse return null;
const index = header.operation -% first_protocol_operation;
if (header.operation < first_protocol_operation or index >= specification.events.len) return null;
return @enumFromInt(header.operation);
}
/// The fixed request part of a packet already known to name `operation`.
pub fn decodeRequest(comptime operation: Operation, packet: []const u8) ?RequestOf(operation) {
if (packet.len < prefix_size) return null;
return valueOf(RequestOf(operation), packet[prefix_size..]);
}
/// The bytes after the fixed request part empty when there are none.
pub fn requestTail(comptime operation: Operation, packet: []const u8) []const u8 {
const start = prefix_size + @sizeOf(RequestOf(operation));
return if (packet.len <= start) &.{} else packet[start..];
}
/// The fixed reply part of a reply packet. Null on a short packet; the
/// caller checks `statusOf(packet).status` for the provider's verdict.
pub fn decodeReply(comptime operation: Operation, packet: []const u8) ?ReplyOf(operation) {
if (packet.len < prefix_size) return null;
return valueOf(ReplyOf(operation), packet[prefix_size..]);
}
/// The bytes after the fixed reply part, clipped to what `Status.len`
/// says actually arrived.
pub fn replyTail(comptime operation: Operation, packet: []const u8) []const u8 {
const status = statusOf(packet) orelse return &.{};
const start = prefix_size + @sizeOf(ReplyOf(operation));
const end = @min(packet.len, prefix_size + @as(usize, status.len));
return if (end <= start) &.{} else packet[start..end];
}
/// The payload of a pushed packet already known to carry `event`.
pub fn decodeEvent(comptime event: Event, packet: []const u8) ?PayloadOf(event) {
if (packet.len < prefix_size) return null;
return valueOf(PayloadOf(event), packet[prefix_size..]);
}
// --- provider side --------------------------------------------------
/// This protocol's dispatch table, bound to the provider's own state
/// type. `describe` is answered here, from the specification; every verb
/// this provider left null answers `-ENOSYS`, which is what makes the
/// reserved verbs mean the same thing at every provider in the system.
///
/// ```zig
/// const Serve = Protocol.Provider(*Server);
/// const handlers = Serve.Handlers{ .blit = onBlit, .configure_layer = onConfigureLayer };
/// const reply_len = Serve.dispatch(server, handlers, message, sender, capability, reply);
/// ```
///
/// A handler returns the number of `answer.tail()` bytes it wrote, or a
/// negative errno.
pub fn Provider(comptime Context: type) type {
return struct {
/// A reserved verb a provider chooses to implement itself.
/// `enumerate` writes its targets into the tail; `subscribe`
/// takes the subscriber's endpoint from `invocation.capability`.
pub const ReservedHandler = *const fn (Context, Invocation(void), Answer(void)) isize;
/// One optional handler per verb, named exactly as the verb,
/// plus the reserved verbs the envelope cannot answer alone.
pub const Handlers = handlerTable(Context);
/// Answer one received packet: writes `[Status][reply][tail]`
/// into `reply` and returns its length. Zero means the reply
/// buffer could not even hold a status, so nothing was written.
pub fn dispatch(
context: Context,
handlers: Handlers,
packet: []const u8,
sender: u32,
capability: ?usize,
reply: []u8,
) usize {
if (reply.len < prefix_size) return 0;
const header = headerOf(packet) orelse return refuse(reply, -EPROTO);
const body = packet[prefix_size..];
if (header.operation == operation_describe) return describeInto(reply);
inline for (specification.operations, 0..) |operation, index| {
if (header.operation == first_protocol_operation + index) {
return invoke(
Context,
operation.request,
operation.reply,
@field(handlers, operation.name),
context,
header.target,
body,
sender,
capability,
reply,
);
}
}
const reserved: ?ReservedHandler = switch (header.operation) {
operation_enumerate => handlers.enumerate,
operation_subscribe => handlers.subscribe,
operation_unsubscribe => handlers.unsubscribe,
else => null,
};
return invoke(Context, void, void, reserved, context, header.target, body, sender, capability, reply);
}
};
}
// Shared by the protocol verbs and the reserved ones: decode, hand the
// handler a typed invocation, stamp the status. One place, so a reserved
// verb and a protocol verb behave identically.
fn invoke(
comptime Context: type,
comptime RequestType: type,
comptime ReplyType: type,
handler: ?*const fn (Context, Invocation(RequestType), Answer(ReplyType)) isize,
context: Context,
target: u64,
body: []const u8,
sender: u32,
capability: ?usize,
reply: []u8,
) usize {
const call = handler orelse return refuse(reply, -ENOSYS);
const request = valueOf(RequestType, body) orelse return refuse(reply, -EPROTO);
if (reply.len < prefix_size + @sizeOf(ReplyType)) return refuse(reply, -EPROTO);
const produced = call(context, .{
.target = target,
.request = request,
.tail = body[@min(@sizeOf(RequestType), body.len)..],
.sender = sender,
.capability = capability,
}, .{ .buffer = reply[prefix_size..] });
if (produced < 0) return refuse(reply, @intCast(produced));
return succeed(reply, @sizeOf(ReplyType) + @as(usize, @intCast(produced)));
}
fn describeInto(reply: []u8) usize {
const description = Description{
.version = specification.version,
.operation_count = specification.operations.len,
.event_count = specification.events.len,
.name_len = specification.name.len,
};
const total = @sizeOf(Description) + specification.name.len;
if (reply.len < prefix_size + total) return refuse(reply, -EPROTO);
@memcpy(reply[prefix_size..][0..@sizeOf(Description)], std.mem.asBytes(&description));
@memcpy(reply[prefix_size + @sizeOf(Description) ..][0..specification.name.len], specification.name);
return succeed(reply, total);
}
// "describe" is answered by the envelope, so it is the one reserved verb
// with no slot in the table.
const implementable_reserved = [_][]const u8{ "enumerate", "subscribe", "unsubscribe" };
fn handlerTable(comptime Context: type) type {
const count = specification.operations.len + implementable_reserved.len;
var names: [count][]const u8 = undefined;
var types: [count]type = undefined;
var attributes: [count]std.builtin.Type.StructField.Attributes = undefined;
for (specification.operations, 0..) |operation, index| {
const Handler = *const fn (Context, Invocation(operation.request), Answer(operation.reply)) isize;
names[index] = operation.name;
types[index] = ?Handler;
attributes[index] = .{ .default_value_ptr = nullDefault(Handler) };
}
const Reserved = *const fn (Context, Invocation(void), Answer(void)) isize;
for (implementable_reserved, 0..) |name, offset| {
const slot = specification.operations.len + offset;
names[slot] = name;
types[slot] = ?Reserved;
attributes[slot] = .{ .default_value_ptr = nullDefault(Reserved) };
}
const frozen_names = names;
const frozen_types = types;
const frozen_attributes = attributes;
return @Struct(.auto, null, &frozen_names, &frozen_types, &frozen_attributes);
}
};
}
// --- the provider's view of one packet --------------------------------------
/// What a provider's handler is given.
pub fn Invocation(comptime RequestType: type) type {
return struct {
/// Object addressing within this provider the packet's `Header.target`.
target: u64,
/// The fixed request part, already decoded.
request: RequestType,
/// The bytes after it: a path, write data, a name.
tail: []const u8,
/// The kernel-stamped badge of the caller. The only source identity
/// there is no protocol defines a sender field so per-client state
/// is keyed on this.
sender: u32,
/// A capability the call carried: a subscriber's endpoint, a DMA
/// region. Only the synchronous call path can move one.
capability: ?usize,
};
}
/// Where a provider's handler writes its answer. The `Status` in front of it is
/// the dispatcher's to stamp a handler never writes its own.
pub fn Answer(comptime ReplyType: type) type {
return struct {
buffer: []u8,
const fixed_size = @sizeOf(ReplyType);
/// Write the fixed reply part. A `void` reply writes nothing.
pub fn set(self: @This(), reply: ReplyType) void {
if (fixed_size == 0) return;
@memcpy(self.buffer[0..fixed_size], bytesOf(ReplyType, &reply));
}
/// Room for the variable tail; the handler returns how much of it it used.
pub fn tail(self: @This()) []u8 {
return self.buffer[fixed_size..];
}
};
}
fn refuse(reply: []u8, status: i32) usize {
const head = Status{ .status = status, .len = 0 };
@memcpy(reply[0..prefix_size], std.mem.asBytes(&head));
return prefix_size;
}
fn succeed(reply: []u8, len: usize) usize {
const head = Status{ .status = 0, .len = @intCast(len) };
@memcpy(reply[0..prefix_size], std.mem.asBytes(&head));
return prefix_size + len;
}
/// The widest `[prefix][fixed]` over a set of operations, on one side.
fn widest(comptime operations: []const OperationSpecification, comptime side: enum { request, reply }) usize {
var found: usize = prefix_size;
for (operations) |operation| {
const size = switch (side) {
.request => @sizeOf(operation.request),
.reply => @sizeOf(operation.reply),
};
found = @max(found, prefix_size + size);
}
return found;
}
/// An enum over `specifications`, tagged by their `name` field, numbered from
/// `first_protocol_operation` in declaration order.
fn numbered(comptime specifications: anytype, comptime field: []const u8) type {
var names: [specifications.len][]const u8 = undefined;
var values: [specifications.len]u32 = undefined;
for (specifications, 0..) |specification, index| {
names[index] = @field(specification, field);
values[index] = first_protocol_operation + index;
}
const frozen_names = names;
const frozen_values = values;
return @Enum(u32, .exhaustive, &frozen_names, &frozen_values);
}
/// A verb's position in its declaration list, from its wire number.
fn indexOf(comptime operation: u32) usize {
return operation - first_protocol_operation;
}
// --- tests ------------------------------------------------------------------
const testing = std.testing;
const Produce = extern struct { count: u32, flags: u32 = 0 };
const Produced = extern struct { total: u64 };
const Changed = extern struct { kind: u32, value: u32 };
const Sample = Define(.{
.name = "sample",
.version = 3,
.operations = &.{
.{ .name = "produce", .request = Produce, .reply = Produced },
.{ .name = "reset" },
},
.events = &.{
.{ .name = "changed", .payload = Changed },
},
});
test "the prefix is 16 bytes in both directions" {
try testing.expectEqual(@as(usize, 16), @sizeOf(Header));
try testing.expectEqual(@as(usize, 16), @sizeOf(Status));
try testing.expectEqual(@as(usize, 16), prefix_size);
try testing.expectEqual(@as(usize, 256), packet_maximum);
try testing.expectEqual(@as(usize, 64), post_maximum);
}
test "verb numbering skips the reserved range" {
try testing.expectEqual(@as(u32, 16), first_protocol_operation);
try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Operation.produce));
try testing.expectEqual(@as(u32, 17), @intFromEnum(Sample.Operation.reset));
// Events are numbered in their own space, so appending an operation can
// never renumber a shipped event.
try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Event.changed));
for ([_]u32{ operation_describe, operation_enumerate, operation_subscribe, operation_unsubscribe }) |reserved| {
try testing.expect(reserved < first_protocol_operation);
}
}
test "request round trip, header folded and tail carried" {
var buffer: [packet_maximum]u8 = undefined;
const packet = Sample.encodeRequest(.produce, 42, .{ .count = 7 }, "tail bytes", &buffer).?;
try testing.expectEqual(prefix_size + @sizeOf(Produce) + "tail bytes".len, packet.len);
const header = headerOf(packet).?;
try testing.expectEqual(@as(u32, 16), header.operation);
try testing.expectEqual(@as(u64, 42), header.target);
try testing.expectEqual(Sample.Operation.produce, Sample.operationOf(packet).?);
const request = Sample.decodeRequest(.produce, packet).?;
try testing.expectEqual(@as(u32, 7), request.count);
try testing.expectEqualStrings("tail bytes", Sample.requestTail(.produce, packet));
}
test "reply round trip" {
var buffer: [packet_maximum]u8 = undefined;
const packet = Sample.encodeReply(.produce, 0, .{ .total = 99 }, "more", &buffer).?;
const status = statusOf(packet).?;
try testing.expectEqual(@as(i32, 0), status.status);
try testing.expectEqual(@as(u32, @sizeOf(Produced) + "more".len), status.len);
try testing.expectEqual(@as(u64, 99), Sample.decodeReply(.produce, packet).?.total);
try testing.expectEqualStrings("more", Sample.replyTail(.produce, packet));
}
test "a void request and reply carry nothing but the verb" {
var buffer: [packet_maximum]u8 = undefined;
const packet = Sample.encodeRequest(.reset, 0, {}, &.{}, &buffer).?;
try testing.expectEqual(prefix_size, packet.len);
try testing.expectEqual(Sample.Operation.reset, Sample.operationOf(packet).?);
try testing.expectEqual(@as(usize, 0), Sample.requestTail(.reset, packet).len);
}
test "event round trip within the push floor" {
var buffer: [post_maximum]u8 = undefined;
const packet = Sample.encodeEvent(.changed, 0, .{ .kind = 1, .value = 2 }, &buffer).?;
try testing.expectEqual(prefix_size + @sizeOf(Changed), packet.len);
try testing.expect(packet.len <= post_maximum);
try testing.expectEqual(Sample.Event.changed, Sample.eventOf(packet).?);
try testing.expectEqual(@as(u32, 2), Sample.decodeEvent(.changed, packet).?.value);
}
// A provider over a trivial context, to drive the generated dispatch table.
const Counter = struct {
total: u64 = 0,
fn onProduce(self: *Counter, invocation: Invocation(Produce), answer: Answer(Produced)) isize {
self.total += invocation.request.count;
answer.set(.{ .total = self.total });
const note = "counted";
@memcpy(answer.tail()[0..note.len], note);
return note.len;
}
};
const CounterProvider = Sample.Provider(*Counter);
test "dispatch reaches a handler and stamps the status" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var request: [packet_maximum]u8 = undefined;
const packet = Sample.encodeRequest(.produce, 0, .{ .count = 5 }, &.{}, &request).?;
var reply: [packet_maximum]u8 = undefined;
const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply);
const answered = reply[0..len];
try testing.expectEqual(@as(i32, 0), statusOf(answered).?.status);
try testing.expectEqual(@as(u64, 5), Sample.decodeReply(.produce, answered).?.total);
try testing.expectEqualStrings("counted", Sample.replyTail(.produce, answered));
}
test "describe is answered by the envelope, not the provider" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var request: [packet_maximum]u8 = undefined;
const packet = encodeDescribe(&request).?;
var reply: [packet_maximum]u8 = undefined;
const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply);
const described = decodeDescribe(reply[0..len]).?;
try testing.expectEqualStrings("sample", described.name);
try testing.expectEqual(@as(u32, 3), described.description.version);
try testing.expectEqual(@as(u32, 2), described.description.operation_count);
try testing.expectEqual(@as(u32, 1), described.description.event_count);
}
test "an unimplemented or unknown verb answers -ENOSYS" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var reply: [packet_maximum]u8 = undefined;
// A verb this protocol declares but this provider left null.
var request: [packet_maximum]u8 = undefined;
const declared = Sample.encodeRequest(.reset, 0, {}, &.{}, &request).?;
var len = CounterProvider.dispatch(&counter, handlers, declared, 3, null, &reply);
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
// A number no verb of this protocol wears.
const stranger = Header{ .operation = first_protocol_operation + 900 };
len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&stranger), 3, null, &reply);
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
// A reserved verb the provider does not implement answers the same way.
const enumerate = Header{ .operation = operation_enumerate };
len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&enumerate), 3, null, &reply);
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
}
test "a truncated packet answers -EPROTO" {
var counter = Counter{};
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
var reply: [packet_maximum]u8 = undefined;
// Names `produce`, but stops before the request it promises.
const header = Header{ .operation = @intFromEnum(Sample.Operation.produce) };
const len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&header), 3, null, &reply);
try testing.expectEqual(@as(i32, -EPROTO), statusOf(reply[0..len]).?.status);
}
// The size rule, exercised directly. `Define` turns exactly these predicates
// into compile errors, which a test cannot catch so the predicate is what the
// test pins, and the boundary protocol below proves the compile-time half from
// the other side. The negative example, spelled out: giving `Define` an
// operation with `.request = extern struct { bytes: [241]u8 }`, or an event with
// `.payload = extern struct { bytes: [49]u8 }`, fails to compile with the
// protocol, the verb, and the two numbers named in the message.
test "the floor counts the header once, and the boundary is exact" {
try testing.expect(fitsPacket(extern struct { bytes: [240]u8 }));
try testing.expect(!fitsPacket(extern struct { bytes: [241]u8 }));
try testing.expect(fitsPost(extern struct { bytes: [48]u8 }));
try testing.expect(!fitsPost(extern struct { bytes: [49]u8 }));
try testing.expect(fitsPacket(void));
try testing.expect(fitsPost(void));
}
const WidestRequest = extern struct { bytes: [packet_maximum - prefix_size]u8 };
const WidestEvent = extern struct { bytes: [post_maximum - prefix_size]u8 };
// A protocol sitting exactly on both floors. That this compiles at all is the
// positive half of the compile-time check.
const Boundary = Define(.{
.name = "boundary",
.version = 1,
.operations = &.{.{ .name = "fill", .request = WidestRequest, .reply = WidestRequest }},
.events = &.{.{ .name = "filled", .payload = WidestEvent }},
});
test "a protocol may sit exactly on the floor" {
try testing.expectEqual(packet_maximum, Boundary.request_maximum);
try testing.expectEqual(packet_maximum, Boundary.reply_maximum);
try testing.expectEqual(post_maximum, Boundary.event_maximum);
var buffer: [packet_maximum]u8 = undefined;
const packet = Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0xAB) }, &.{}, &buffer).?;
try testing.expectEqual(packet_maximum, packet.len);
try testing.expectEqual(@as(u8, 0xAB), Boundary.decodeRequest(.fill, packet).?.bytes[239]);
// One byte of tail past the floor is refused at run time, not truncated.
try testing.expect(Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0) }, "x", &buffer) == null);
var post: [post_maximum]u8 = undefined;
const event = Boundary.encodeEvent(.filled, 0, .{ .bytes = @splat(1) }, &post).?;
try testing.expectEqual(post_maximum, event.len);
}
test "a protocol's own sizes are reported prefix-included" {
try testing.expectEqual(prefix_size + @sizeOf(Produce), Sample.request_maximum);
try testing.expectEqual(prefix_size + @sizeOf(Produced), Sample.reply_maximum);
try testing.expectEqual(prefix_size + @sizeOf(Changed), Sample.event_maximum);
try testing.expectEqual(packet_maximum, Sample.message_maximum);
try testing.expectEqualStrings("sample", Sample.protocol_name);
}

View File

@ -1,7 +1,9 @@
//! The power protocol (docs/power.md): system power's domain-named surface, //! The power protocol (docs/power.md): system power's domain-named surface,
//! registered under `ServiceId.power`. On x86 the acpi service serves it; on //! bound at `/protocol/power`. On x86 the acpi service provides it; on ARM a
//! ARM a PSCI/mailbox service will register the same id subscribers never //! PSCI/mailbox service will bind the same name subscribers never learn which
//! learn which firmware they are on (docs/discovery.md firmware neutrality). //! 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 vfs-protocol pattern: extern-struct messages, a version, reserved fields.
/// The protocol version a client states nowhere yet reserved for the day a /// The protocol version a client states nowhere yet reserved for the day a

View File

@ -13,8 +13,17 @@
//! written against has retired path routing moved into the kernel (system/kernel/vfs.zig, //! written against has retired path routing moved into the kernel (system/kernel/vfs.zig,
//! fs_resolve) but the protocol module outlived it. //! fs_resolve) but the protocol module outlived it.
//! **An `open` reply may carry a capability.** The vfs `open` request rides
//! `ipc_call`, and the reply direction of a call can hand back an endpoint
//! (`ipc.callCap`'s `Reply.cap`). A file backend never uses it FAT answers
//! with a node id and nothing else but a *synthetic* backend does: opening a
//! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint,
//! which is the channel (docs/os-development/protocol-namespace.md). The
//! convention is per-backend, not per-operation: a client that did not ask a
//! synthetic backend simply gets no capability back, exactly as today.
pub const Operation = enum(u32) { pub const Operation = enum(u32) {
open, // open(path) -> node id open, // open(path) -> node id (a synthetic backend may reply with a capability instead)
close, // close(node) close, // close(node)
read, // read(node, offset, len) -> bytes read, // read(node, offset, len) -> bytes
write, // write(node, offset, bytes) -> count write, // write(node, offset, bytes) -> count
@ -31,6 +40,12 @@ pub const Operation = enum(u32) {
// rename: the payload is the old path, a single 0x00 separator, then the new // 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). // path. Same-directory rename only (the router requires both under one mount).
rename, // rename(old\0new payload) -> status 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 /// The type of a filesystem node, aligned to the node-kind table
@ -44,6 +59,12 @@ pub const NodeKind = enum(u32) {
symbolic_link = 4, symbolic_link = 4,
fifo = 5, fifo = 5,
socket = 6, socket = 6,
/// A node that names a *contract*, not a file: opening it establishes a
/// channel to whatever process currently provides that protocol, delivered
/// as an endpoint capability in the reply rather than a node id. This is
/// what lives under `/protocol`; `readdir` lists these like any other node,
/// so the tree stays browsable for diagnosis.
protocol = 7,
}; };
/// One directory entry, returned by `readdir`: a fixed header followed inline in /// One directory entry, returned by `readdir`: a fixed header followed inline in
@ -109,9 +130,15 @@ test "protocol struct sizes and node kinds" {
const std = @import("std"); const std = @import("std");
try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular)); try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular));
try std.testing.expectEqual(@as(u32, 1), @intFromEnum(NodeKind.directory)); try std.testing.expectEqual(@as(u32, 1), @intFromEnum(NodeKind.directory));
// Appended with the protocol namespace; every earlier value keeps its own.
try std.testing.expectEqual(@as(u32, 6), @intFromEnum(NodeKind.socket));
try std.testing.expectEqual(@as(u32, 7), @intFromEnum(NodeKind.protocol));
try std.testing.expectEqual(@as(usize, 16), @sizeOf(DirectoryEntry)); try std.testing.expectEqual(@as(usize, 16), @sizeOf(DirectoryEntry));
// The appended operations keep the original values. // The appended operations keep the original values.
try std.testing.expectEqual(@as(u32, 0), @intFromEnum(Operation.open)); 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, 4), @intFromEnum(Operation.status));
try std.testing.expectEqual(@as(u32, 5), @intFromEnum(Operation.readdir)); 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));
} }

View File

@ -1,6 +1,6 @@
//! The **private kernel runtime** ABI: the raw system_call contract the call //! 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 //! 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/), //! (system/kernel/process.zig) and the user-space runtime library (library/runtime/),
//! so the two can never drift. //! 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 mmap = 4, // mmap(len, prot) -> base: grant zeroed, page-aligned user pages
munmap = 5, // munmap(base, len): release pages from a prior mmap munmap = 5, // munmap(base, len): release pages from a prior mmap
create_ipc_endpoint = 6, // create_ipc_endpoint() -> handle: a new IPC endpoint 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 // 7 and 8 were ipc_register/ipc_lookup the flat ServiceId name registry,
ipc_lookup = 8, // ipc_lookup(service_id) -> handle: find a published endpoint // 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_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) 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 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) boot_unix_seconds: u64, // wall-clock time of boot (RTC anchor)
}; };
/// Well-known IPC service ids for the bootstrap name registry (create_ipc_endpoint + // The `ServiceId` enum lived here: a flat, compile-time list of well-known
/// ipc_register/ipc_lookup). Small integers, so no string interning is needed // service ids backed by a 16-slot kernel table. It is gone with the protocol
/// during bring-up. The VFS server registers under `vfs`; clients look it up. // namespace names are strings resolved under `/protocol` at run time, so a
pub const ServiceId = enum(u32) { // third-party program can introduce a contract the ABI never heard of, and the
vfs = 1, // RETIRED: the router moved into the kernel (fs_resolve); the slot stays reserved // registrar (init) decides who may claim one.
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)
_,
};
/// Protection flags for `mmap` (matching the usual C bit values). /// Protection flags for `mmap` (matching the usual C bit values).
pub const prot_read: u64 = 1; pub const prot_read: u64 = 1;

View File

@ -0,0 +1,155 @@
# /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 AND every open is
# checked against this file. It is AUTHORITATIVE — a name no row grants cannot be
# bound or reached, and a missing file means nothing may be bound or reached at
# all.
#
# A refused open is answered exactly as a name nobody bound is: -ENOENT, and no
# capability. That is not politeness, it is the model — the namespace IS the
# restriction, so what a process may not open simply does not exist for it, and
# there is no "permission denied" for it to tell apart from "no such contract".
# Which is why a missing row here shows up as a client retrying forever rather
# than as an error: check this file first, and `readdir /protocol` second.
#
# '#' starts a comment (whole-line or trailing); blank lines are ignored.
# Whitespace around a field is trimmed, so columns may be padded. Four
# 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) |
# supervise (stand in someone else's chain — see below)
# name the contract, relative to /protocol
#
# A trailing '*' on any field matches any tail — how a subtree is granted whole.
#
# 'supervise' exists because attestation is one hop deep and the driver tree is
# three: the device manager starts the PS/2 bus, and the bus starts the keyboard
# and mouse drivers. Init never met the bus, so it cannot vouch for it by
# acquaintance — and it must not vouch for it by name, or the laundering deputy
# walks straight in. A 'supervise' row is the manifest saying it: a task running
# this binary, under this supervisor, may be the supervising task an 'open' row
# names, for this contract and no other. It grants the delegate nothing itself,
# and it is deliberately open-only — a delegate may vouch for what its children
# REACH, never for what they CLAIM, so every bind refusal is untouched by it.
#
# binary supervisor permission name
# --- 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/*
# ============================================================================
# open — who may REACH each contract. One row per client per contract; a client
# with no row here simply finds the name absent, forever.
# ============================================================================
# --- init's own services ----------------------------------------------------
# fat reaches the block device behind the volume it mounts; the compositor
# reaches the scanout its driver announced, its own endpoint (the mouse-listener
# thread opens /protocol/display like any other client — threads share no
# handles), and the input stream that moves the cursor.
/system/services/fat, /system/services/init, open, block
/system/services/display, /system/services/init, open, scanout
/system/services/display, /system/services/init, open, display
/system/services/display, /system/services/init, open, input
/system/services/display-demo, /system/services/init, open, display
# --- the same two when the kernel test harness starts them directly ---------
/system/services/display, kernel, open, scanout
/system/services/display, kernel, open, display
/system/services/display, kernel, open, input
/system/services/display-demo, kernel, open, display
# --- the drivers, and the discovery service ---------------------------------
# Every driver says hello to the manager that started it — one row for the whole
# subtree, because that handshake is what being a driver means. The rest are per
# driver: the storage and HID class drivers talk to their controller, the HID
# drivers publish into the input stream, and the GPU driver announces its scanout
# to the compositor.
/system/drivers/*, /system/services/device-manager, open, device-manager
/system/services/discovery, /system/services/device-manager, open, device-manager
/system/drivers/usb-storage, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, input
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, usb-transfer
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, input
/system/drivers/virtio-gpu, /system/services/device-manager, open, display
# --- the PS/2 child drivers, one hop further down ---------------------------
# The keyboard and mouse drivers are started by the BUS driver, not by the
# device manager — the one three-deep chain in the tree. Init cannot vouch for
# the bus by acquaintance (it never started it), so the manifest authorizes it
# explicitly, and only for the two contracts its children need.
/system/drivers/ps2-bus, /system/services/device-manager, supervise, ps2-bus
/system/drivers/ps2-bus, /system/services/device-manager, supervise, input
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, ps2-bus
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, input
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, ps2-bus
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, input
# --- test fixtures ----------------------------------------------------------
# The /protocol/test subtree is theirs whole, the way the bind rows give it to
# them. Everything ABOVE that subtree is named one fixture at a time, so a
# fixture reaches a system contract only where a scenario needs it — which is
# what leaves the rest genuinely absent for the rest of them (the protocol-denied
# case asks for one it was not given, and is told there is no such thing).
/test/*, kernel, open, test/*
/test/*, /test/*, open, test/*
/test/*, kernel, open, device-manager
/test/*, /system/services/device-manager, open, device-manager
/test/system/services/input-source, kernel, open, input
/test/system/services/input-test, kernel, open, input
# The laundering-deputy probe (test/system/services/protocol-registry-test) runs
# a grandchild whose supervisor is a fixture nobody authorized — that is the
# point of it, and its bind must stay refused. It still has to report the verdict
# it got, so its reporting channel, and nothing else, is delegated.
/test/*, /test/*, supervise, test/verdict
Can't render this file because it contains an unexpected character in line 12 and column 15.

View File

@ -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; _ = message;
_ = reply; _ = reply;
_ = sender; _ = sender;
_ = capability; _ = arrived; // nothing here takes a capability: the harness closes what arrives
return 0; return 0;
} }

View File

@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const ps2_bus_exe = build_support.userBinary(b, .{ const ps2_bus_exe = build_support.userBinary(b, .{
.name = "ps2-bus", .name = "ps2-bus",
.root_source_file = b.path("ps2-bus.zig"), .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); b.installArtifact(ps2_bus_exe);
@ -17,8 +17,8 @@ pub fn build(b: *std.Build) void {
.name = "ps2-keyboard", .name = "ps2-keyboard",
.root_source_file = b.path("keyboard.zig"), .root_source_file = b.path("keyboard.zig"),
.imports = &.{ .imports = &.{
"acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory", "acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc",
"process", "time", "xkeyboard-config", "logging", "memory", "process", "time", "xkeyboard-config",
}, },
}); });
b.installArtifact(ps2_keyboard_exe); b.installArtifact(ps2_keyboard_exe);
@ -27,8 +27,8 @@ pub fn build(b: *std.Build) void {
.name = "ps2-mouse", .name = "ps2-mouse",
.root_source_file = b.path("mouse.zig"), .root_source_file = b.path("mouse.zig"),
.imports = &.{ .imports = &.{
"acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory", "acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc", "logging",
"process", "time", "memory", "process", "time",
}, },
}); });
b.installArtifact(ps2_mouse_exe); b.installArtifact(ps2_mouse_exe);

View File

@ -16,6 +16,7 @@
const std = @import("std"); const std = @import("std");
const device = @import("driver"); const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const time = @import("time"); const time = @import("time");
@ -27,12 +28,12 @@ const ps2 = @import("ps2-library.zig");
const scancode = @import("scancode.zig"); const scancode = @import("scancode.zig");
const input_protocol = @import("input-protocol"); const input_protocol = @import("input-protocol");
/// Look up the ps2-bus service, retrying while the bus (which spawned us before /// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before
/// registering) is still coming up. /// binding) is still coming up.
fn lookupBus() ?ipc.Handle { fn lookupBus() ?ipc.Handle {
var attempts: usize = 0; var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) { while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.ps2_bus)) |handle| return handle; if (channel.openEndpoint("ps2-bus")) |handle| return handle;
time.sleepMillis(50); time.sleepMillis(50);
} }
return null; return null;

View File

@ -16,6 +16,7 @@
const std = @import("std"); const std = @import("std");
const device = @import("driver"); const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const time = @import("time"); const time = @import("time");
@ -26,12 +27,12 @@ const ps2 = @import("ps2-library.zig");
const mouse_packet = @import("mouse-packet.zig"); const mouse_packet = @import("mouse-packet.zig");
const input_protocol = @import("input-protocol"); const input_protocol = @import("input-protocol");
/// Look up the ps2-bus service, retrying while the bus (which spawned us before /// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before
/// registering) is still coming up. /// binding) is still coming up.
fn lookupBus() ?ipc.Handle { fn lookupBus() ?ipc.Handle {
var attempts: usize = 0; var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) { while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.ps2_bus)) |handle| return handle; if (channel.openEndpoint("ps2-bus")) |handle| return handle;
time.sleepMillis(50); time.sleepMillis(50);
} }
return null; return null;

View File

@ -11,6 +11,7 @@
//! - irq 0xc len 0x1 //! - irq 0xc len 0x1
const std = @import("std"); const std = @import("std");
const device = @import("driver"); const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const service = @import("service"); 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 /// Handle a child driver's `AttachRequest`: record the endpoint capability it
/// passed as the forwarding target for the port whose device matches its type. /// passed as the forwarding target for the port whose device matches its type.
/// Writes an `AttachReply` into `out` and returns its length. /// 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 { const reply = struct {
fn write(buffer: []u8, status: ps2.AttachStatus) usize { fn write(buffer: []u8, status: ps2.AttachStatus) usize {
const header = ps2.AttachReply{ .status = @intFromEnum(status) }; 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); 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 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| { for (&port_device_types, 0..) |maybe_type, port_index| {
const device_type = maybe_type orelse continue; const device_type = maybe_type orelse continue;
if (@intFromEnum(device_type) != request.device_type) 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)}); std.log.info("{s} driver attached", .{@tagName(device_type)});
return reply.write(out, .ok); return reply.write(out, .ok);
} }
@ -213,15 +225,16 @@ pub fn main() void {
return; return;
}; };
// The endpoint the child drivers attach to and IRQ1 wakes. Registered under a // The endpoint the child drivers attach to and IRQ1 wakes. Bound as the
// well-known id so the children can find it, the way input subscribers find // `ps2-bus` contract so the children can find it by name, the way input
// the input service. // 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 { const endpoint = ipc.createIpcEndpoint() orelse {
_ = logging.write("/system/drivers/ps2-bus: no endpoint\n"); _ = logging.write("/system/drivers/ps2-bus: no endpoint\n");
return; return;
}; };
if (!ipc.register(.ps2_bus, endpoint)) { if (!channel.bindPatiently("ps2-bus", endpoint)) {
_ = logging.write("/system/drivers/ps2-bus: register failed\n"); _ = logging.write("/system/drivers/ps2-bus: could not bind /protocol/ps2-bus\n");
return; return;
} }
@ -273,6 +286,13 @@ pub fn main() void {
var receive: [@sizeOf(ps2.AttachRequest)]u8 = undefined; var receive: [@sizeOf(ps2.AttachRequest)]u8 = undefined;
while (true) { while (true) {
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null); 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()) { if (got.isNotification()) {
reply_len = 0; reply_len = 0;
if (got.isMessage() or got.isChildExit()) continue; // nothing sends us these if (got.isMessage() or got.isChildExit()) continue; // nothing sends us these
@ -301,6 +321,6 @@ pub fn main() void {
} }
continue; continue;
} }
reply_len = handleAttach(receive[0..got.len], got, &reply_buffer); reply_len = handleAttach(receive[0..got.len], &reply_buffer, &arrived);
} }
} }

View File

@ -146,17 +146,18 @@ fn initialise(endpoint: ipc.Handle) bool {
/// Serve the block protocol: geometry, and whole-block read/write to/from the /// Serve the block protocol: geometry, and whole-block read/write to/from the
/// caller's DMA buffer (named by physical address). /// 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; _ = sender;
if (message.len < block_protocol.request_size) return 0; if (message.len < block_protocol.request_size) return 0;
const request = std.mem.bytesToValue(block_protocol.Request, message[0..block_protocol.request_size]); const request = std.mem.bytesToValue(block_protocol.Request, message[0..block_protocol.request_size]);
switch (request.operation) { switch (request.operation) {
@intFromEnum(block_protocol.Operation.attach) => { @intFromEnum(block_protocol.Operation.attach) => {
// The filesystem's DMA buffer: forward its capability to the controller so // The filesystem's DMA buffer: forward its capability to the controller
// the device can reach it, then release our copy (the binding holds a ref). // so the device can reach it. Never claimed the binding holds its own
const handle = capability orelse return writeReply(reply, .{ .status = -1, .block_size = 0, .block_count = 0 }); // 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); const ok = device.attachDma(handle);
_ = ipc.close(handle);
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = 0, .block_count = 0 }); return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = 0, .block_count = 0 });
}, },
@intFromEnum(block_protocol.Operation.geometry) => { @intFromEnum(block_protocol.Operation.geometry) => {
@ -202,7 +203,7 @@ pub fn main(init: process.Init) void {
return; return;
}; };
service.run(block_protocol.message_maximum, .{ service.run(block_protocol.message_maximum, .{
.service = .block, .service = "block",
.init = initialise, .init = initialise,
.on_message = onMessage, .on_message = onMessage,
}); });

View File

@ -10,9 +10,10 @@ pub fn build(b: *std.Build) void {
.name = "usb-xhci-bus", .name = "usb-xhci-bus",
.root_source_file = b.path("usb-xhci-bus.zig"), .root_source_file = b.path("usb-xhci-bus.zig"),
.imports = &.{ .imports = &.{
"device-manager-protocol", "driver", "input-client", "ipc", "logging", "memory", "channel", "device-manager-protocol", "driver", "input-client",
"mmio", "pci", "process", "service", "time", "usb-abi", "usb-ids", "ipc", "logging", "memory", "mmio",
"usb-transfer-protocol", "pci", "process", "service", "time",
"usb-abi", "usb-ids", "usb-transfer-protocol",
}, },
}); });
b.installArtifact(exe); b.installArtifact(exe);

View File

@ -15,6 +15,7 @@
const std = @import("std"); const std = @import("std");
const device = @import("driver"); const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const service = @import("service"); const service = @import("service");
@ -68,19 +69,25 @@ const Open = struct {
}; };
var opens = [_]Open{.{}} ** 16; 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| { for (&opens) |*open| {
if (open.used and open.device_token == device_token) { 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; open.report_endpoint = report_endpoint;
return; return true;
} }
} }
for (&opens) |*open| { for (&opens) |*open| {
if (!open.used) { if (!open.used) {
open.* = .{ .used = true, .device_token = device_token, .report_endpoint = report_endpoint }; 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 { 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. /// manager reads as "meant to stop" a missing assignment is not a crash loop.
fn initialise(endpoint: ipc.Handle) bool { fn initialise(endpoint: ipc.Handle) bool {
service_endpoint = endpoint; 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)) { if (!device.claim(controller_id)) {
std.log.info("unable to claim controller device {d}", .{controller_id}); std.log.info("unable to claim controller device {d}", .{controller_id});
return false; 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 /// Serve the USB transfer protocol: a class driver opens its device, then issues
/// control / interrupt-subscribe / bulk requests against it. /// 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; _ = sender;
if (message.len < 4) return 0; if (message.len < 4) return 0;
const operation = std.mem.readInt(u32, message[0..4], .little); const operation = std.mem.readInt(u32, message[0..4], .little);
return switch (operation) { 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.control) => handleControl(message, reply),
@intFromEnum(usb_transfer_protocol.Operation.interrupt_subscribe) => handleSubscribe(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.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, else => 0,
}; };
} }
/// dma_attach: bind the class driver's DMA-region capability into the controller's IOMMU /// 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 /// 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. /// binding holds its own kernel reference, so this never claims the arriving handle
fn handleDmaAttach(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize { /// 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 }); 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); const ok = device.dmaBind(controller_id, handle);
_ = ipc.close(handle);
return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = if (ok) 0 else -1 }); 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 /// open: resolve the assigned device id to an interface, remember the caller's
/// endpoint (for interrupt reports), and answer with a device token + the /// endpoint (for interrupt reports), and answer with a device token + the
/// interface's endpoints so the class driver need not re-read the config. /// 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 }); 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 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 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 }); 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{ var open_reply = usb_transfer_protocol.OpenReply{
.status = 0, .status = 0,
@ -673,7 +701,8 @@ pub fn main(init: process.Init) void {
return; return;
}; };
service.run(usb_transfer_protocol.message_maximum, .{ 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, .init = initialise,
.on_message = onMessage, .on_message = onMessage,
.on_notification = onNotification, .on_notification = onNotification,

View File

@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
.name = "virtio-gpu", .name = "virtio-gpu",
.root_source_file = b.path("virtio-gpu.zig"), .root_source_file = b.path("virtio-gpu.zig"),
.imports = &.{ .imports = &.{
"display-protocol", "driver", "ipc", "logging", "memory", "mmio", "pci", "process", "channel", "display-protocol", "driver", "ipc", "logging", "memory", "mmio", "pci",
"scanout-protocol", "service", "time", "process", "scanout-protocol", "service", "time",
}, },
}); });
b.installArtifact(exe); b.installArtifact(exe);

View File

@ -15,6 +15,7 @@
const std = @import("std"); const std = @import("std");
const device = @import("driver"); const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const service = @import("service"); const service = @import("service");
@ -475,7 +476,7 @@ fn presentFull() bool {
fn announce() void { fn announce() void {
var tries: u32 = 0; var tries: u32 = 0;
const display = while (tries < 50) : (tries += 1) { const display = while (tries < 50) : (tries += 1) {
if (ipc.lookup(.display)) |h| break h; if (channel.openEndpoint("display")) |h| break h;
time.sleepMillis(20); time.sleepMillis(20);
} else { } else {
std.log.info("no display service to announce to (scanout-only)", .{}); 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 /// 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 /// 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). /// 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; _ = sender;
_ = capability; _ = arrived; // nothing here takes a capability: the harness closes what arrives
if (message.len < scanout_protocol.request_size) return 0; if (message.len < scanout_protocol.request_size) return 0;
const request = std.mem.bytesToValue(scanout_protocol.Request, message[0..scanout_protocol.request_size]); const request = std.mem.bytesToValue(scanout_protocol.Request, message[0..scanout_protocol.request_size]);
switch (request.operation) { switch (request.operation) {
@ -547,7 +548,7 @@ pub fn main(init: process.Init) void {
return; return;
}; };
service.run(256, .{ service.run(256, .{
.service = .scanout, .service = "scanout",
.init = initialise, .init = initialise,
.on_message = onMessage, .on_message = onMessage,
}); });

View File

@ -40,16 +40,13 @@ const Task = scheduler.Task;
pub const MESSAGE_MAXIMUM: usize = 256; pub const MESSAGE_MAXIMUM: usize = 256;
pub const maximum_handles = scheduler.ipc_maximum_handles; 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. /// Errno-style failures, returned as `-value` in the system_call result register.
pub const EBADF: i64 = 1; // bad handle pub const EBADF: i64 = 1; // bad handle
pub const E2BIG: i64 = 2; // message exceeds MESSAGE_MAXIMUM pub const E2BIG: i64 = 2; // message exceeds MESSAGE_MAXIMUM
pub const EFAULT: i64 = 3; // buffer unmapped / out of the user half pub const EFAULT: i64 = 3; // buffer unmapped / out of the user half
pub const ENOENT: i64 = 4; // no such registered service pub const ENOENT: i64 = 4; // no such name
pub const ENOSPC: i64 = 5; // handle table or registry full pub const ENOSPC: i64 = 5; // handle table full
pub const ENOMEM: i64 = 6; // out of memory 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 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) 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; const user_half_end: u64 = user_memory.user_half_end;
/// A rendezvous endpoint. Allocated from the kernel heap; referenced by handle /// 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 { pub const Endpoint = struct {
refcount: u32 = 1, 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 // 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). // gets -EPEER instead of blocking forever on a service that will never reply again (V6).
owner: u32 = 0, 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, dead: bool = false,
// Callers blocked in `call`, awaiting receive, in FIFO order (threaded via // Callers blocked in `call`, awaiting receive, in FIFO order (threaded via
// Task.next; each such task is .blocked and in no scheduler queue). // Task.next; each such task is .blocked and in no scheduler queue).
@ -115,29 +121,78 @@ pub const Endpoint = struct {
post_tail: u16 = 0, 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 { pub fn createIpcEndpoint() ?*Endpoint {
const creator = scheduler.current();
const endpoint = heap.allocator().create(Endpoint) catch return null; 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; return endpoint;
} }
/// A task is dying: kill the endpoints it registered as services. Mark each `dead` (so a later /// Whether `t` may have the kernel post **notifications** signals, timer
/// `call` returns -EPEER rather than blocking on a reply that will never come), wake anyone /// landings, exit notices, interrupts into `endpoint`: whether the endpoint is
/// already parked sending to it with that error, and vacate its registry slot. Only *registered* /// its process's own.
/// 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). /// 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 = &current.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 { pub fn killOwnedEndpointsLocked(task_id: u32) void {
for (&registry) |*slot| { var current = live_endpoints;
const endpoint = slot.* orelse continue; while (current) |endpoint| {
if (endpoint.owner != task_id) continue; current = endpoint.next_live;
if (endpoint.owner != task_id or endpoint.dead) continue;
endpoint.dead = true; endpoint.dead = true;
while (dequeueSender(endpoint)) |sender| { while (dequeueSender(endpoint)) |sender| {
sender.ipc_status = -EPEER; sender.ipc_status = -EPEER;
sender.ipc_received_cap = abi.no_cap; sender.ipc_received_cap = abi.no_cap;
scheduler.readyLocked(sender); scheduler.readyLocked(sender);
} }
slot.* = null;
dropRef(endpoint);
} }
} }
@ -147,6 +202,7 @@ pub fn dropRef(endpoint: *Endpoint) void {
if (endpoint.refcount > 1) { if (endpoint.refcount > 1) {
endpoint.refcount -= 1; endpoint.refcount -= 1;
} else { } else {
forgetEndpoint(endpoint);
heap.allocator().destroy(endpoint); heap.allocator().destroy(endpoint);
} }
} }
@ -633,22 +689,9 @@ fn dropEntry(entry: scheduler.HandleObject) void {
} }
} }
var registry: [maximum_services]?*Endpoint = .{null} ** maximum_services; // 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
/// Publish `endpoint` under well-known `id` (takes a reference). Returns 0 or -errno. // serves `/protocol` and decides who may claim a name
pub fn register(id: u32, endpoint: *Endpoint) i64 { // (docs/os-development/protocol-namespace.md). The kernel keeps only what is
if (id >= maximum_services) return -ENOENT; // genuinely kernel work moving capabilities and telling clients their provider
if (registry[id]) |old| dropRef(old); // died (`killOwnedEndpointsLocked`).
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;
}

View File

@ -47,8 +47,8 @@ pub const maximum_gsi = 24;
var bound: [maximum_gsi]?*ipc_sync.Endpoint = .{null} ** maximum_gsi; var bound: [maximum_gsi]?*ipc_sync.Endpoint = .{null} ** maximum_gsi;
/// Task that owns each binding. Teardown is keyed on *this*, not on the endpoint /// 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 /// pointer: an endpoint can be shared between processes (a capability passed in a message
/// out extra references), so "every GSI pointing at this endpoint" is not the same /// 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 /// set as "every GSI this process bound", and releasing the former on exit would mask
/// a live sibling's device line. /// a live sibling's device line.
var bound_owner: [maximum_gsi]u32 = .{0} ** maximum_gsi; var bound_owner: [maximum_gsi]u32 = .{0} ** maximum_gsi;

View File

@ -229,8 +229,6 @@ fn system_call(state: *architecture.CpuState) void {
.mmap => systemMmap(state), .mmap => systemMmap(state),
.munmap => systemMunmap(state), .munmap => systemMunmap(state),
.create_ipc_endpoint => systemCreateIpcEndpoint(state), .create_ipc_endpoint => systemCreateIpcEndpoint(state),
.ipc_register => systemIpcRegister(state),
.ipc_lookup => systemIpcLookup(state),
.ipc_call => systemIpcCall(state), .ipc_call => systemIpcCall(state),
.ipc_reply_wait => systemIpcReplyWait(state), .ipc_reply_wait => systemIpcReplyWait(state),
.ipc_send => systemIpcSend(state), .ipc_send => systemIpcSend(state),
@ -320,36 +318,6 @@ fn systemCreateIpcEndpoint(state: *architecture.CpuState) void {
architecture.setSystemCallResult(state, @intCast(h)); 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. /// 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 /// 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. /// stack, so it survives the block and receives the result on resume.
@ -364,7 +332,14 @@ fn systemIpcCall(state: *architecture.CpuState) void {
/// ipc_reply_wait(handle, reply_ptr, reply_len, receive_ptr, receive_cap) -> receive_len, /// ipc_reply_wait(handle, reply_ptr, reply_len, receive_ptr, receive_cap) -> receive_len,
/// with the sender's badge in the secondary result register (rdx). /// with the sender's badge in the secondary result register (rdx).
fn systemIpcReplyWait(state: *architecture.CpuState) void { fn systemIpcReplyWait(state: *architecture.CpuState) void {
const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); const t = scheduler.current();
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
// Receiving is the owner's privilege, the same rule the notification binders
// enforce: a sendable handle means only "you may talk to this". Anything
// else and a mount's backend endpoint which `fs_resolve` installs in every
// caller's table would let a stranger dequeue the requests meant for the
// server, taking the capabilities they carry and answering in its name.
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
var badge: u64 = 0; var badge: u64 = 0;
var received_cap: u64 = abi.no_cap; var received_cap: u64 = abi.no_cap;
const r = ipc.replyWait(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &badge, &received_cap); const r = ipc.replyWait(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &badge, &received_cap);
@ -989,10 +964,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 (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 > 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); 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) const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap)
null null
else else block: {
ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); 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 image = ramdisk_image orelse return fail(state);
const rd = initial_ramdisk.Reader.init(image) orelse return fail(state); const rd = initial_ramdisk.Reader.init(image) orelse return fail(state);
@ -1040,11 +1022,15 @@ fn systemThreadSpawn(state: *architecture.CpuState) void {
if (t.address_space == 0) return fail(state); // kernel tasks own no address space to share 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 (entry == 0 or entry >= user_half_end) return fail(state);
if (stack_top == 0 or stack_top > 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) const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap)
null null
else else block: {
ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF); 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); 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 == -ipc.ESRCH) return failErr(state, ipc.ESRCH); // dying group admits no member
if (tid < 0) return fail(state); if (tid < 0) return fail(state);
@ -1538,13 +1524,20 @@ const exit_subscriber_capacity = 8;
const ExitSubscriber = struct { endpoint: *ipc.Endpoint, owner: u32 }; const ExitSubscriber = struct { endpoint: *ipc.Endpoint, owner: u32 };
var exit_subscribers: [exit_subscriber_capacity]?ExitSubscriber = .{null} ** exit_subscriber_capacity; var exit_subscribers: [exit_subscriber_capacity]?ExitSubscriber = .{null} ** exit_subscriber_capacity;
/// process_subscribe(endpoint): subscribe the caller's endpoint to published exit /// process_subscribe(endpoint): subscribe the **caller's own** endpoint to
/// events. Ungated, like process_enumerate what is running (and dying) is not a /// published exit events. *Which* deaths one may hear of is ungated, like
/// secret between cooperating processes. -ENOSPC when the table is full. /// 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 { fn systemProcessSubscribe(state: *architecture.CpuState) void {
const t = scheduler.current(); const t = scheduler.current();
if (t.address_space == 0) return fail(state); if (t.address_space == 0) return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); 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(); const flags = sync.enter();
defer sync.leave(flags); defer sync.leave(flags);
for (&exit_subscribers) |*slot| { for (&exit_subscribers) |*slot| {
@ -1561,10 +1554,19 @@ fn systemProcessSubscribe(state: *architecture.CpuState) void {
/// IRQ-as-IPC pattern a fourth time (docs/process-lifecycle.md). Replacing a /// IRQ-as-IPC pattern a fourth time (docs/process-lifecycle.md). Replacing a
/// binding drops the old reference; signals that pended while unbound are /// binding drops the old reference; signals that pended while unbound are
/// delivered immediately on bind, coalesced into one notification. /// 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 { fn systemSignalBind(state: *architecture.CpuState) void {
const t = scheduler.current(); const t = scheduler.current();
if (t.address_space == 0) return fail(state); if (t.address_space == 0) return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); 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(); const flags = sync.enter();
defer sync.leave(flags); defer sync.leave(flags);
if (t.signal_endpoint) |raw| ipc.dropRef(@ptrCast(@alignCast(raw))); if (t.signal_endpoint) |raw| ipc.dropRef(@ptrCast(@alignCast(raw)));
@ -1633,11 +1635,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 { fn systemTimerBind(state: *architecture.CpuState) void {
const t = scheduler.current(); const t = scheduler.current();
if (t.address_space == 0) return fail(state); if (t.address_space == 0) return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); 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 ms = architecture.systemCallArg(state, 1);
const flags = sync.enter(); const flags = sync.enter();
defer sync.leave(flags); defer sync.leave(flags);
@ -1677,12 +1687,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 /// 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 /// 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. /// 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 { fn systemIrqBind(state: *architecture.CpuState) void {
const t = scheduler.current(); const t = scheduler.current();
if (t.address_space == 0) return fail(state); if (t.address_space == 0) return fail(state);
const gsi = ownedGsi(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1)) orelse const gsi = ownedGsi(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1)) orelse
return fail(state); return fail(state);
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 2)) 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(); const flags = sync.enter();
defer sync.leave(flags); defer sync.leave(flags);
@ -1703,6 +1718,7 @@ fn systemMsiBind(state: *architecture.CpuState) void {
const owner = devices_broker.ownerOf(device_id) orelse return fail(state); const owner = devices_broker.ownerOf(device_id) orelse return fail(state);
if (owner != t.id) return fail(state); // not claimed by this process 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); 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(); const flags = sync.enter();
defer sync.leave(flags); defer sync.leave(flags);

View File

@ -246,6 +246,10 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
containmentTest(); containmentTest();
} else if (eql(case, "device-manager")) { } else if (eql(case, "device-manager")) {
deviceManagerTest(boot_information); deviceManagerTest(boot_information);
} else if (eql(case, "protocol-registry")) {
protocolRegistryTest(boot_information);
} else if (eql(case, "protocol-denied")) {
protocolDeniedTest(boot_information);
} else if (eql(case, "reboot")) { } else if (eql(case, "reboot")) {
rebootTest(); rebootTest();
} else { } else {
@ -2209,7 +2213,7 @@ fn processKillTest(boot_information: *const BootInformation) void {
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) 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; break;
} }
check("process-test spawned as the supervised spinner victim", spinner != 0); check("process-test spawned as the supervised spinner victim", spinner != 0);
@ -2395,13 +2399,14 @@ fn signalsTest(boot_information: *const BootInformation) void {
}; };
process.setInitialRamdisk(image); // the parent system_spawns its children by name process.setInitialRamdisk(image); // the parent system_spawns its children by name
_ = spawnRegistry(rd); // the service child binds /protocol/test/process
process.write_count = 0; process.write_count = 0;
var runner: u32 = 0; var runner: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) 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; break;
} }
check("signal-run parent spawned", runner != 0); check("signal-run parent spawned", runner != 0);
@ -2443,13 +2448,14 @@ fn driverRestartTest(boot_information: *const BootInformation) void {
}; };
process.setInitialRamdisk(image); // the manager system_spawns drivers by name process.setInitialRamdisk(image); // the manager system_spawns drivers by name
_ = spawnRegistry(rd); // the drivers bind their contracts
process.write_count = 0; process.write_count = 0;
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
check("device-manager spawned in test-restart mode", manager != 0); check("device-manager spawned in test-restart mode", manager != 0);
@ -2482,12 +2488,13 @@ fn usbReportTest(boot_information: *const BootInformation) void {
}; };
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the xhci driver binds /protocol/usb-transfer
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
check("device-manager spawned in test-usb-restart mode", manager != 0); check("device-manager spawned in test-usb-restart mode", manager != 0);
@ -2514,12 +2521,13 @@ fn deviceListTest(boot_information: *const BootInformation) void {
}; };
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the fixture opens /protocol/device-manager
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
check("device-manager spawned in test-usb-restart mode", manager != 0); check("device-manager spawned in test-usb-restart mode", manager != 0);
@ -2548,13 +2556,14 @@ fn pciCapsTest(boot_information: *const BootInformation) void {
}; };
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
// Plain mode no restart drill, whose kill would race the fixture's claim. // Plain mode no restart drill, whose kill would race the fixture's claim.
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
check("device-manager spawned", manager != 0); check("device-manager spawned", manager != 0);
@ -2582,12 +2591,13 @@ fn iommuFaultTest(boot_information: *const BootInformation) void {
check("IOMMU enabled for the enforcement test", iommu.enabled()); check("IOMMU enabled for the enforcement test", iommu.enabled());
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
check("device-manager spawned", manager != 0); check("device-manager spawned", manager != 0);
@ -2623,12 +2633,13 @@ fn pciScanTest(boot_information: *const BootInformation) void {
check("the kernel seeded no PCI functions (the walk retired)", brokerPciCount(&buffer) == 0); check("the kernel seeded no PCI functions (the walk retired)", brokerPciCount(&buffer) == 0);
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
check("device-manager spawned (test-pci-restart mode)", manager != 0); check("device-manager spawned (test-pci-restart mode)", manager != 0);
@ -2776,12 +2787,13 @@ fn acpiReportTest(boot_information: *const BootInformation) void {
return; return;
}; };
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager and the acpi service bind theirs
var spawned = false; var spawned = false;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; spawned = true;
break; break;
} }
@ -2819,7 +2831,7 @@ fn acpiParseTest(boot_information: *const BootInformation) void {
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "discovery")) 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; spawned = true;
break; break;
} }
@ -2854,7 +2866,7 @@ fn supervisionTest(boot_information: *const BootInformation) void {
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) 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; break;
} }
check("process-test spawned as the user-space supervisor", started); check("process-test spawned as the user-space supervisor", started);
@ -2996,6 +3008,9 @@ fn inputTest(boot_information: *const BootInformation) void {
process.write_count = 0; process.write_count = 0;
process.write_from_user = false; 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"); // the fan-out service
_ = spawnNamed(rd, "input-source"); // a synthetic keyboard publishing events _ = spawnNamed(rd, "input-source"); // a synthetic keyboard publishing events
_ = spawnNamed(rd, "input-test"); // the subscriber whose "ok" line is the marker _ = spawnNamed(rd, "input-test"); // the subscriber whose "ok" line is the marker
@ -3037,6 +3052,10 @@ fn displayServiceTest(boot_information: *const BootInformation) void {
return; 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: // 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 // 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 // reads serial directly, like the fault cases). We don't poll for them in-kernel: a
@ -3073,6 +3092,9 @@ fn displayCursorTest(boot_information: *const BootInformation) void {
return; 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")) { if (!spawnNamed(rd, "input")) {
log("display-cursor: could not spawn the input service\n", .{}); log("display-cursor: could not spawn the input service\n", .{});
result(); result();
@ -3113,6 +3135,9 @@ fn displayDemoTest(boot_information: *const BootInformation) void {
return; 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")) { if (!spawnNamed(rd, "display")) {
log("display-demo: could not spawn the display service\n", .{}); log("display-demo: could not spawn the display service\n", .{});
result(); result();
@ -3148,6 +3173,9 @@ fn sharedMemoryTest(boot_information: *const BootInformation) void {
return; 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")) { if (!spawnNamed(rd, "shared-memory-server")) {
log("shared-memory: could not spawn shared-memory-server\n", .{}); log("shared-memory: could not spawn shared-memory-server\n", .{});
result(); result();
@ -3185,12 +3213,13 @@ fn virtioGpuTest(boot_information: *const BootInformation) void {
// from the kernel device tree, spawns pci-bus, and matches the virtio-gpu class triple to // 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]. // spawn our driver with the function's device id as argv[1].
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the driver binds /protocol/scanout
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
if (manager == 0) { if (manager == 0) {
@ -3226,12 +3255,13 @@ fn displayNativeTest(boot_information: *const BootInformation) void {
}; };
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // display, the manager, and the driver bind theirs
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
if (manager == 0) { if (manager == 0) {
@ -3270,12 +3300,13 @@ fn displayReattachTest(boot_information: *const BootInformation) void {
}; };
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // display and the restarted driver bind theirs
var manager: u32 = 0; var manager: u32 = 0;
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue; const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) 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; break;
} }
if (manager == 0) { if (manager == 0) {
@ -3625,6 +3656,21 @@ fn threadTestMarkerCase(boot_information: *const BootInformation, case_name: []c
result(); 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 { fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool {
var i: u32 = 0; var i: u32 = 0;
while (i < rd.count) : (i += 1) { while (i < rd.count) : (i += 1) {
@ -3745,6 +3791,113 @@ 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 /// 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 /// pci-bus must reach its own live marker. It uses no special privilege the same
/// `device_enumerate` any process could call. /// `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();
}
/// P3 restriction stage one (docs/os-development/protocol-namespace.md). The
/// registrar now checks `open` against `/system/configuration/protocol.csv`, and
/// a caller with no grant is told exactly what a caller asking for a name nobody
/// bound is told.
///
/// The scenario is the assertion's scaffolding: `/protocol` (init in its registry
/// role), the **input service** which binds a real contract the fixture is
/// deliberately not granted and the fixture. Without a live provider on the
/// forbidden name, "refused" and "not bound yet" would be the same observation
/// and the case would prove nothing; the fixture reads `/protocol`'s own listing
/// to confirm the name is there before it asks for it.
///
/// The fixture's `protocol-denied: ok` is the marker; each step prints its own
/// line, which the harness's ordered regex reads.
fn protocolDeniedTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-denied\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
check("registry (init) spawned", spawnRegistry(rd));
// The provider of the contract the fixture may NOT reach. It needs no
// hardware: it binds /protocol/input and waits for subscribers.
check("input service spawned", spawnNamed(rd, "input"));
check("protocol-denied-test spawned", spawnNamedWithArg(rd, "protocol-denied-test", "run"));
const pass_marker = "protocol-denied: ok";
const fail_marker = "protocol-denied: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("no step of the restriction contract failed", !saw_fail);
check("the fixture completed every restriction assertion", saw_pass);
result();
}
fn deviceManagerTest(boot_information: *const BootInformation) void { fn deviceManagerTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: device-manager\n", .{}); log("DANOS-TEST-BEGIN: device-manager\n", .{});
if (boot_information.initial_ramdisk_len == 0) { if (boot_information.initial_ramdisk_len == 0) {
@ -3764,6 +3917,7 @@ fn deviceManagerTest(boot_information: *const BootInformation) void {
// all, it's because the manager discovered the PCI host bridge, matched, and // all, it's because the manager discovered the PCI host bridge, matched, and
// spawned it. // spawned it.
process.setInitialRamdisk(image); process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
process.write_count = 0; process.write_count = 0;
process.write_from_user = false; process.write_from_user = false;
@ -3845,9 +3999,9 @@ fn hpetDeviceId() ?u64 {
/// 2. After `releaseOwner` for the binding's owner, that same entry is masked again. /// 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 /// 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 /// binding on the same endpoint survives. Endpoints are shared (a capability passed in a
/// references), so teardown keyed on the endpoint pointer rather than the owning task /// message hands out extra references), so teardown keyed on the endpoint pointer rather
/// would mask a live sibling driver's device line. /// than the owning task would mask a live sibling driver's device line.
fn irqFreeTest() void { fn irqFreeTest() void {
log("DANOS-TEST-BEGIN: irqfree\n", .{}); log("DANOS-TEST-BEGIN: irqfree\n", .{});

View File

@ -168,6 +168,11 @@ fn installMount(prefix: []const u8, kind: MountKind, backend: ?*ipc.Endpoint, re
var slot: ?*Mount = null; var slot: ?*Mount = null;
for (&mounts) |*m| { for (&mounts) |*m| {
if (m.used and std.mem.eql(u8, m.prefixSlice(), prefix)) { 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); if (m.backend) |old| ipc.dropRef(old);
slot = m; slot = m;
break; break;
@ -346,6 +351,30 @@ fn isInitrdCarveOut(prefix: []const u8) bool {
return false; 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. /// Mount `backend` at `prefix` with an optional backend-side `rewrite` prefix.
/// The endpoint reference is taken by the caller (process.zig bumps it); refuses /// The endpoint reference is taken by the caller (process.zig bumps it); refuses
/// shadowing or replacing the initrd trees (/system, /test) except the two /// 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 { 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 (!isAbsolute(prefix) or prefix.len < 2 or prefix.len > maximum_prefix) return false;
if (rewrite.len > maximum_rewrite) 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) 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 (m.used and m.kind == .kernel_initrd and underMount(prefix, m.prefixSlice()) != null) {
if (!isInitrdCarveOut(prefix)) return false; 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 { 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| { for (&mounts) |*m| {
if (m.used and m.kind == .backend and std.mem.eql(u8, m.prefixSlice(), prefix)) { if (m.used and m.kind == .backend and std.mem.eql(u8, m.prefixSlice(), prefix)) {
if (m.backend) |endpoint| ipc.dropRef(endpoint); if (m.backend) |endpoint| ipc.dropRef(endpoint);

View File

@ -12,6 +12,7 @@
const std = @import("std"); const std = @import("std");
const device = @import("driver"); const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const service = @import("service"); const service = @import("service");
@ -189,14 +190,32 @@ pub fn main(init: process.Init) void {
readFadt(fadt); readFadt(fadt);
s5_valid = readSleepS5(&persistent_namespace); 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.run(power_protocol.message_maximum, .{
.service = .power, .service = "power",
.init = onInit, .init = onInit,
.on_message = onMessage, .on_message = onMessage,
.on_notification = onNotification, .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) // Static so the harness callbacks (which run after main's stack frame is gone)
// can reach the namespace and interpreter. // can reach the namespace and interpreter.
var persistent_namespace: aml.Namespace = undefined; var persistent_namespace: aml.Namespace = undefined;
@ -209,7 +228,7 @@ fn onInit(endpoint: ipc.Handle) bool {
registered_count = 0; registered_count = 0;
walkDevices(persistent_namespace.root, &global_interpreter); walkDevices(persistent_namespace.root, &global_interpreter);
const manager = ipc.lookup(.device_manager); const manager = manager_channel;
var i: usize = 0; var i: usize = 0;
while (i < registered_count) : (i += 1) { while (i < registered_count) : (i += 1) {
const entry = registered[i]; const entry = registered[i];
@ -433,15 +452,17 @@ fn onNotification(badge: u64) void {
/// The `.power` protocol: subscribe (endpoint as the call's capability), /// The `.power` protocol: subscribe (endpoint as the call's capability),
/// shutdown (PID 1 only). Device discovery uses a different endpoint (the /// shutdown (PID 1 only). Device discovery uses a different endpoint (the
/// device manager's), so nothing here handles ChildAdded. /// 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; if (message.len < 1) return 0;
switch (message[0]) { switch (message[0]) {
@intFromEnum(power_protocol.Operation.subscribe) => { @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; var status: i32 = -1;
if (capability) |handle| { if (arrived.peek() != null) {
for (&subscribers, 0..) |*slot, si| { for (&subscribers, 0..) |*slot, si| {
if (slot.* == null) { if (slot.* == null) {
slot.* = handle; slot.* = arrived.take();
subscriber_tasks[si] = sender; subscriber_tasks[si] = sender;
status = 0; status = 0;
break; break;

View File

@ -14,8 +14,8 @@ pub fn build(b: *std.Build) void {
.name = "discovery", .name = "discovery",
.root_source_file = b.path("acpi.zig"), .root_source_file = b.path("acpi.zig"),
.imports = &.{ .imports = &.{
"acpi-ids", "aml", "device-manager-protocol", "driver", "ipc", "logging", "memory", "acpi-ids", "aml", "channel", "device-manager-protocol", "driver", "ipc", "logging",
"power-protocol", "process", "service", "time", "memory", "power-protocol", "process", "service", "time",
}, },
}); });
b.installArtifact(exe); b.installArtifact(exe);

View File

@ -395,13 +395,13 @@ fn initialise(endpoint: ipc.Handle) bool {
return true; 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; if (message.len < 1) return 0;
switch (message[0]) { switch (message[0]) {
@intFromEnum(device_manager_protocol.Operation.child_added) => return onChildAdded(message, reply, sender), @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.child_removed) => return onChildRemoved(message, reply, sender),
@intFromEnum(device_manager_protocol.Operation.enumerate) => return onEnumerate(reply), @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) => {}, @intFromEnum(device_manager_protocol.Operation.hello) => {},
else => return 0, else => return 0,
} }
@ -532,13 +532,15 @@ fn onEnumerate(reply: []u8) usize {
return offset; return offset;
} }
/// An application subscribed: its endpoint arrived as the call's capability. /// An application subscribed: its endpoint arrived as the call's capability. The
fn onSubscribe(reply: []u8, capability: ?ipc.Handle) usize { /// 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; var status: i32 = -1;
if (capability) |handle| { if (arrived.peek() != null) {
for (&subscribers) |*slot| { for (&subscribers) |*slot| {
if (slot.* == null) { if (slot.* == null) {
slot.* = handle; slot.* = arrived.take(); // claimed: the table holds it from here
status = 0; status = 0;
break; break;
} }
@ -566,7 +568,7 @@ pub fn main(init: process.Init) void {
test_scanout_restart_mode = std.mem.eql(u8, mode, "test-scanout-restart"); test_scanout_restart_mode = std.mem.eql(u8, mode, "test-scanout-restart");
} }
service.run(device_manager_protocol.message_maximum, .{ service.run(device_manager_protocol.message_maximum, .{
.service = .device_manager, .service = "device-manager",
.init = initialise, .init = initialise,
.on_message = onMessage, .on_message = onMessage,
.on_notification = onNotification, .on_notification = onNotification,

View File

@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
.name = "display", .name = "display",
.root_source_file = b.path("display.zig"), .root_source_file = b.path("display.zig"),
.imports = &.{ .imports = &.{
"display-client", "display-protocol", "driver", "input-client", "ipc", "logging", "channel", "display-client", "display-protocol", "driver", "input-client", "ipc",
"memory", "scanout-protocol", "service", "thread", "time", "logging", "memory", "scanout-protocol", "service", "thread", "time",
}, },
.threaded = true, // real atomics/TLS (docs/threading.md) .threaded = true, // real atomics/TLS (docs/threading.md)
}); });

View File

@ -16,6 +16,7 @@
//! (docs/display-v2.md). //! (docs/display-v2.md).
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const input = @import("input-client"); const input = @import("input-client");
const Thread = @import("thread").Thread; 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 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 /// present is deferred to a timer (see `service_endpoint`) so it happens after this reply
/// unblocks the driver and it starts serving `.scanout`. /// 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 { /// The surface arrives as the call's capability, and the harness's ownership rule
const cap = capability orelse return fail(reply); /// 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); if (width == 0 or height == 0 or stride < width) return fail(reply);
const mapped = memory.sharedMap(cap) orelse 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 // 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 // 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.) // 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; return;
}; };
// Our own handle to the compositor's endpoint. IPC handles are per-thread, so we // 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 // cannot reuse the main thread's this thread resolves and opens
// handle in this thread's table. A poke posted here wakes the compositor loop parked // `/protocol/display` exactly like any other client would, once at startup, and
// in replyWait (docs/threading.md: handles do not cross threads). // gets its own handle. There is no special mechanism for reaching yourself: the
cursor_channel.poke_endpoint = ipc.lookup(.display) orelse { // 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"); _ = logging.write("display: mouse listener could not reach the compositor endpoint\n");
return; return;
}; };
@ -613,7 +623,7 @@ fn fail(reply: []u8) usize {
return writeReply(reply, .{ .status = -1 }); 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; _ = sender;
if (message.len < display_protocol.request_size) return fail(reply); if (message.len < display_protocol.request_size) return fail(reply);
const request = std.mem.bytesToValue(display_protocol.Request, message[0..display_protocol.request_size]); 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); return ok(reply);
}, },
@intFromEnum(display_protocol.Operation.attach_scanout) => { @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) => { @intFromEnum(display_protocol.Operation.set_mode) => {
if (!backend.setMode(request.width, request.height)) return fail(reply); if (!backend.setMode(request.width, request.height)) return fail(reply);
@ -696,7 +706,7 @@ fn onNotification(badge: u64) void {
pub fn main() void { pub fn main() void {
service.run(display_protocol.message_maximum, .{ service.run(display_protocol.message_maximum, .{
.service = .display, .service = "display",
.init = initialise, .init = initialise,
.on_message = onMessage, .on_message = onMessage,
.on_notification = onNotification, .on_notification = onNotification,

View File

@ -222,8 +222,14 @@ fn handleOpen(out: []u8, path: []const u8, flags: u32, sender: u32) usize {
return writeReply(out, .{ .status = 0, .node = index }, &.{}); return writeReply(out, .{ .status = 0, .node = index }, &.{});
} }
fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handle) usize { /// The vfs protocol has no operation that takes a capability, so `arrived` is
_ = capability; /// 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 (!mounted) return fail(out); // storage not up (yet): fail politely, clients retry
if (message.len < vfs_protocol.request_size) return fail(out); if (message.len < vfs_protocol.request_size) return fail(out);
const request = std.mem.bytesToValue(vfs_protocol.Request, message[0..vfs_protocol.request_size]); 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 }, &.{}); return writeReply(out, .{ .status = 0 }, &.{});
}, },
// A backend is never itself a mount target. // 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 { pub fn main() void {
service.run(vfs_protocol.message_maximum, .{ service.run(vfs_protocol.message_maximum, .{
.service = .fat, .service = "vfs",
.init = initialise, .init = initialise,
.on_message = onMessage, .on_message = onMessage,
.on_notification = onNotification, .on_notification = onNotification,

View File

@ -11,8 +11,8 @@ pub fn build(b: *std.Build) void {
.name = "init", .name = "init",
.root_source_file = b.path("init.zig"), .root_source_file = b.path("init.zig"),
.imports = &.{ .imports = &.{
"csv", "file-system", "ipc", "logging", "memory", "power-protocol", "csv", "envelope", "file-system", "ipc", "logging", "memory", "power-protocol",
"process", "time", "process", "time", "vfs-protocol",
}, },
}); });
// init reads the same `serial` flag the kernel does: its liveness heartbeat // init reads the same `serial` flag the kernel does: its liveness heartbeat

View File

@ -16,6 +16,34 @@
//! power-button event runs the stop sequence over its children in reverse order //! 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) //! before asking the power service to enter S5 lifecycle (M17) and events (M21)
//! composing into a clean poweroff. //! 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`).
//! - **Absence is the enforcement.** P3: `open` consults the manifest with the
//! same attested identity a `bind` does, and a caller with no grant is told
//! exactly what a caller asking for a name nobody bound is told `-ENOENT`,
//! and no capability (`onOpen`). Restriction stage one of
//! docs/os-development/protocol-namespace.md: what a process cannot open does
//! not exist for it, so there is no "permission denied" to distinguish.
//! - **A capability that arrives is closed unless it is claimed** (`Arrival`),
//! because PID 1's thirty-two handle slots are a resource an unauthenticated
//! caller would otherwise be able to spend.
const std = @import("std"); const std = @import("std");
const ipc = @import("ipc"); const ipc = @import("ipc");
@ -24,6 +52,8 @@ const time = @import("time");
const memory = @import("memory"); const memory = @import("memory");
const logging = @import("logging"); const logging = @import("logging");
const power_protocol = @import("power-protocol"); const power_protocol = @import("power-protocol");
const vfs_protocol = @import("vfs-protocol");
const envelope = @import("envelope");
const build_options = @import("build_options"); const build_options = @import("build_options");
const fs = @import("file-system"); const fs = @import("file-system");
const csv = @import("csv"); const csv = @import("csv");
@ -73,17 +103,10 @@ var supervision_endpoint: ipc.Handle = 0;
/// uses for /system/configuration/devices.csv. A missing file means no services (the no-ramdisk /// uses for /system/configuration/devices.csv. A missing file means no services (the no-ramdisk
/// isolation test): loud, but not fatal. /// isolation test): loud, but not fatal.
fn loadServices() void { 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"); _ = logging.write("/system/services/init: /system/configuration/init.csv missing — no services started\n");
return; 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'); var lines = std.mem.splitScalar(u8, init_csv[0..used], '\n');
while (lines.next()) |line| { while (lines.next()) |line| {
const body = csv.stripComment(line); const body = csv.stripComment(line);
@ -107,11 +130,587 @@ 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 /// 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. /// that faults immediately on every spawn doesn't respawn forever.
const maximum_restarts = 3; 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 = 64;
/// One bound contract: the name, the provider's endpoint (a capability init
/// holds and hands to whoever opens the name), and the provenance a diagnostic
/// 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.
///
/// - `bind` claim the name, i.e. provide the contract.
/// - `open` reach the name, i.e. speak the contract to whoever provides it.
/// - `supervise` stand in a third task's supervision chain: a task running this
/// binary, under this supervisor, may be the supervising task named by an
/// `open` row for this contract. It grants the *delegate* nothing itself.
///
/// `supervise` exists because attestation is deliberately one hop deep
/// (`supervisorSatisfies`): init vouches only for tasks it or the kernel started.
/// The driver tree is deeper than that the device manager starts the PS/2 bus,
/// and the bus starts the keyboard and mouse drivers so without a way to say
/// "this task is an authorized supervisor", a legitimate grandchild would be
/// indistinguishable from a laundering deputy. Naming the delegate in the
/// manifest is what tells them apart, and it is the same shape as every other
/// row: a binary, the supervisor it must have, and the contract it concerns.
/// Deliberately `open`-only a delegate may vouch for what its children may
/// *reach*, never for what they may *claim* so the bind path's attestation is
/// exactly what P2 shipped and every refusal it makes still holds.
const Permission = enum { bind, open, supervise };
/// One row of `/system/configuration/protocol.csv`. Every field may end in `*`,
/// which matches any tail the subtree scoping the design doc describes, and
/// 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, a row per client per contract it reaches, and its own format
/// documentation which is most of the bytes, and is the point of the file.
var protocol_csv: [16384]u8 = undefined;
var grants: [maximum_grants]Grant = .{Grant{}} ** maximum_grants;
var grant_count: usize = 0;
/// 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 if (std.mem.eql(u8, permission, "supervise"))
.supervise
else
continue; // an unreadable row grants nothing rather than something wrong
grants[grant_count] = .{ .binary = binary, .supervisor = supervisor, .permission = kind, .name = name };
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;
}
/// Whether `identity` may reach `name` `granted(.open, )`, plus the one hop
/// `open` takes that `bind` does not (`Permission.supervise`).
///
/// The hop is needed because the driver tree is three deep and attestation is
/// one: the PS/2 keyboard driver's supervising task is the PS/2 bus driver,
/// which the device manager started, which init started. Init cannot vouch for
/// the bus by acquaintance it never met it so the manifest says so instead,
/// and says it per contract: `ps2-bus` may be the supervisor named in an `open`
/// grant for `ps2-bus` and for `input`, and for nothing else.
fn mayOpen(identity: Identity, name: []const u8) bool {
if (granted(identity, .open, name)) return true;
return delegatedOpen(identity, name);
}
/// The delegated `open`: the row's supervisor column names the caller's actual
/// supervising task by binary, that task is one init cannot vouch for directly,
/// and a `supervise` row authorizes it for exactly this contract.
///
/// The delegate itself is attested the ordinary way (`granted` strict
/// `supervisorSatisfies`), so the chain is still anchored one hop above it in
/// init or the kernel and the recursion stops there. Two hops of manifest, never
/// an unbounded walk a laundering deputy is refused at the first hop nobody
/// wrote a row for.
fn delegatedOpen(identity: Identity, name: []const u8) bool {
if (identity.supervisor_task == 0) return false; // a kernel-spawned caller needs no delegate
if (identity.supervisor_vouched) return false; // already answered by `granted` above
const delegate = identify(identity.supervisor_task) orelse return false;
if (!granted(delegate, .supervise, name)) return false;
for (grants[0..grant_count]) |grant| {
if (grant.permission != .open) continue;
if (!matches(grant.binary, identity.binary)) continue;
if (!matches(grant.supervisor, identity.supervisor_binary)) continue;
if (!matches(grant.name, name)) continue;
return true;
}
return false;
}
fn findBinding(name: []const u8) ?*Binding {
for (&bindings) |*binding| {
if (binding.used and std.mem.eql(u8, binding.nameSlice(), name)) return binding;
}
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, sender, payload);
if (operation == @intFromEnum(vfs_protocol.Operation.readdir)) return onReaddir(reply, cursor);
// Everything else a filesystem answers is meaningless here: `/protocol` holds
// contracts, not bytes.
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 refusal and an absence are the same answer, and that is the whole point.**
/// The namespace is the restriction (docs/os-development/protocol-namespace.md):
/// what a process may open is what exists for it, so "you may not have this" and
/// "there is no such thing" collapse into one reply `-ENOENT`, no payload, no
/// capability. A caller therefore has no oracle: it cannot use `open` to learn
/// that a contract it lacks is bound, and the reason this matters beyond
/// tidiness stage two's supervisor can refuse, stall for a human, or substitute
/// a fake without the child being able to tell which happened.
///
/// Indistinguishable is a claim about *work done*, not only about the bytes, so
/// both questions are asked on every open whatever the first one answers: the
/// process table is refreshed, the caller identified, the grants scanned and the
/// bindings scanned, and only then is the single verdict formed. Nothing here
/// logs, either `klog_read` is ungated (system/kernel/process.zig), so a line
/// written on one branch is a line the refused caller can read, and a serial line
/// costs milliseconds it could time. The operator's diagnosis is the pair the
/// namespace already publishes on purpose: `readdir` over `/protocol` says what is
/// bound, `/system/configuration/protocol.csv` says who may reach it, and the
/// client's own retry loop says which one it wanted.
///
/// (Not constant-time in the cryptographic sense, and not claimed to be: the two
/// scans stop at the row they match, and the optimiser is free to sink a pure
/// table walk past a branch that discards it. What is removed is the difference a
/// caller could actually measure or read a syscall on one branch and not the
/// other, a line in a world-readable log ring, or a serial write costing
/// milliseconds.)
fn onOpen(reply: []u8, sender: u32, raw_name: []const u8) usize {
const name = contractName(raw_name) orelse return answer(reply, -envelope.ENOENT, 0, 0);
refreshProcessTable();
const identity = identify(sender);
const permitted = if (identity) |who| mayOpen(who, name) else false;
const binding = findBinding(name);
if (!permitted) return answer(reply, -envelope.ENOENT, 0, 0);
const found = binding orelse return answer(reply, -envelope.ENOENT, 0, 0);
pending_capability = found.endpoint;
return answer(reply, 0, 0, 0);
}
/// `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 // 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 // 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 // that heap buffer (exercising the widened debug_write bounds check), and
@ -128,65 +727,175 @@ pub fn main() void {
// One endpoint carries everything init waits on: children's exit // One endpoint carries everything init waits on: children's exit
// notifications (they are spawned supervised against it), init's own // 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 { supervision_endpoint = ipc.createIpcEndpoint() orelse {
_ = logging.write("/system/services/init: no endpoint\n"); _ = logging.write("/system/services/init: no endpoint\n");
return; return;
}; };
_ = process.bindSignals(supervision_endpoint); _ = 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 // 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, // 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. // and with no /system/configuration/init.csv (an isolation test) the loop starts nothing.
loadServices(); if (!registry_only) {
for (services[0..service_count], 0..) |*service, i| { loadServices();
if (process.spawnSupervised(service.path, service.arguments(), supervision_endpoint)) |id| child_ids[i] = id; 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 // 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 // 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 // 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 // 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 // periodic beat. `build_options.serial` is comptime, so the heartbeat its timer
// and the handler below folds away entirely when serial is off. // 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) { while (true) {
const got = ipc.replyWait(supervision_endpoint, &.{}, &receive, null); const got = ipc.replyWait(supervision_endpoint, reply_buffer[0..reply_len], &receive, reply_capability);
if (process.signalsFrom(got.badge)) |signals| { reply_len = 0; // nothing owed until this turn's request says otherwise
if (signals.has(.terminate)) shutDown(); reply_capability = null;
continue;
// 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()) { // The universal ping, answered by the empty reply. A ping may still carry
_ = logging.write("/system/services/init: heartbeat\n"); // a capability the kernel installs one regardless of length and this
_ = time.timerOnce(supervision_endpoint, 1000); // `continue` disposes of it through the turn's `defer`, which is exactly
continue; // what it failed to do when the close lived in the branches.
} if (got.len == 0) continue;
if (got.isMessage() and got.len >= 2 and receive[0] == @intFromEnum(power_protocol.Operation.event)) { reply_len = serveRegistry(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived);
// A power event (the only buffered messages init receives). reply_capability = pending_capability;
if (receive[1] == @intFromEnum(power_protocol.Event.power_button)) shutDown(); pending_capability = null;
continue;
}
if (got.isChildExit()) {
restartChild(got.childProcessId());
continue;
}
// Anything else: keep waiting.
if (got.isNotification()) continue;
} }
} }
/// 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 /// 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 /// 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 /// 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. /// iron rule 1); init only decides whether to bring it back.
fn restartChild(id: u32) void { 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 if (shutting_down) return; // deaths during the stop sequence are expected, not crashes
for (services[0..service_count], 0..) |*service, i| { for (services[0..service_count], 0..) |*service, i| {
if (child_ids[i] != id) continue; if (child_ids[i] != id) continue;
@ -209,22 +918,26 @@ fn restartChild(id: u32) void {
// An untracked child (e.g. the log-flush one-shot): nothing to restart. // 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 /// Subscribe our endpoint (handed over as the call's capability) to the power
/// call's capability) so events arrive as buffered messages here. /// 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 { fn subscribePower() void {
var handle: ?ipc.Handle = null; const handle = power_endpoint orelse return;
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 request = power_protocol.Subscribe{}; const request = power_protocol.Subscribe{};
var reply: [power_protocol.message_maximum]u8 = undefined; 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 /// The stop sequence: persist the log while storage is still up, then terminate
@ -242,7 +955,7 @@ fn shutDown() void {
i -= 1; i -= 1;
if (child_ids[i] != 0) process.stop(child_ids[i], 2000, supervision_endpoint); 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{}; const request = power_protocol.Shutdown{};
var reply: [power_protocol.message_maximum]u8 = undefined; var reply: [power_protocol.message_maximum]u8 = undefined;
_ = ipc.call(h, std.mem.asBytes(&request), &reply) catch {}; _ = ipc.call(h, std.mem.asBytes(&request), &reply) catch {};

View File

@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{ const exe = build_support.userBinary(b, .{
.name = "input", .name = "input",
.root_source_file = b.path("input.zig"), .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); b.installArtifact(exe);
} }

View File

@ -1,5 +1,5 @@
//! system/services/input the user-space input service. Shipped in the initial_ramdisk, //! 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) //! 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 //! 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 //! `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. //! handle and `ipc.send`s each event to it.
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const service = @import("service"); const service = @import("service");
@ -58,7 +59,13 @@ fn pruneDeadSubscribers() void {
break; 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 /// Handle one request. `got` carries the sender badge (a task id); `arrived` carries the
/// subscriber's endpoint capability in `got.cap`. Writes a `Reply` into `out` and returns /// capability the request came with, under the same ownership rule the service harness
/// its length. /// states (`ipc.Arrival`): **it belongs to the turn, and only a handler that means to keep
fn handle(message: []const u8, got: ipc.Received, out: []u8) usize { /// 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 { const reply = struct {
fn write(buffer: []u8, status: i32) usize { fn write(buffer: []u8, status: i32) usize {
const header = input_protocol.Reply{ .status = status }; 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))) { switch (@as(input_protocol.Operation, @enumFromInt(request.operation))) {
.subscribe => { .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). // 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; const mask = if (request.device_mask == 0) input_protocol.device_all else request.device_mask;
pruneDeadSubscribers(); pruneDeadSubscribers();
if (!addSubscriber(endpoint, @intCast(got.badge), mask)) return reply.write(out, -1); // table full 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); return reply.write(out, 0);
}, },
.publish => { .publish => {
@ -120,8 +131,8 @@ pub fn main() void {
_ = logging.write("/system/services/input: no endpoint\n"); _ = logging.write("/system/services/input: no endpoint\n");
return; return;
}; };
if (!ipc.register(.input, endpoint)) { if (!channel.bindPatiently("input", endpoint)) {
_ = logging.write("/system/services/input: register failed\n"); _ = logging.write("/system/services/input: could not bind /protocol/input\n");
return; return;
} }
_ = logging.write("/system/services/input: ready\n"); _ = logging.write("/system/services/input: ready\n");
@ -131,12 +142,21 @@ pub fn main() void {
var receive: [input_protocol.request_size]u8 = undefined; var receive: [input_protocol.request_size]u8 = undefined;
while (true) { while (true) {
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null); 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 // Only synchronous client requests (subscribe/publish) arrive here; nothing sends
// this service asynchronous messages, so a notification wake would be spurious. // this service asynchronous messages, so a notification wake would be spurious.
if (got.isNotification()) { if (got.isNotification()) {
reply_len = 0; reply_len = 0;
continue; continue;
} }
reply_len = handle(receive[0..got.len], got, &reply_buffer); reply_len = handle(receive[0..got.len], got, &reply_buffer, &arrived);
} }
} }

View File

@ -99,11 +99,11 @@ fn initialise(harness_endpoint: ipc.Handle) bool {
} }
/// The logger serves no protocol; the ping is answered by the harness. /// 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; _ = message;
_ = reply; _ = reply;
_ = sender; _ = sender;
_ = capability; _ = arrived; // nothing here takes a capability: the harness closes what arrives
return 0; return 0;
} }

View File

@ -846,6 +846,49 @@ CASES = [
{"name": "input", {"name": "input",
"expect": r"DANOS-TEST-RESULT: PASS", "expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "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)(?=.*protocol-registry: foreign receive refused)"
r"(?=.*protocol-registry: capability-carrying pings did not exhaust the harness)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|protocol-registry: FAIL"},
# Restriction stage one (docs/os-development/protocol-namespace.md): the
# registrar checks `open` against /system/configuration/protocol.csv, and a
# caller with no grant gets the same answer as a caller naming a contract
# nobody bound. The scenario boots /protocol plus the input service, so the
# forbidden name is genuinely BOUND — the fixture reads the namespace listing
# to prove it — and then compares the refusal with an unbound name field by
# field: status, node, payload length, the whole reply packet, and the
# presence of a capability. All three failure shapes (refused-and-bound,
# granted-and-unbound, neither) must collapse into one answer.
{"name": "protocol-denied",
"expect": r"(?s)(?=.*protocol-denied: granted open succeeded)"
r"(?=.*protocol-denied: ungranted open refused as absent)"
r"(?=.*protocol-denied: refusal is indistinguishable from absence)"
r"(?=.*protocol-denied: ok)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|protocol-denied: FAIL"},
# Device manager: a ring-3 service enumerates /system/devices, matches the PCI host # Device manager: a ring-3 service enumerates /system/devices, matches the PCI host
# bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn # bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn
# -> driver-up (the spawned pci-bus logs "<N> functions found"). # -> driver-up (the spawned pci-bus logs "<N> functions found").

View File

@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{ const exe = build_support.userBinary(b, .{
.name = "crash-test", .name = "crash-test",
.root_source_file = b.path("crash-test.zig"), .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); b.installArtifact(exe);
} }

View File

@ -7,6 +7,7 @@
//! binary), it exits silently so it cannot derange other tests. //! binary), it exits silently so it cannot derange other tests.
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const time = @import("time"); const time = @import("time");
@ -29,7 +30,7 @@ pub fn main(init: process.Init) void {
var manager: ?ipc.Handle = null; var manager: ?ipc.Handle = null;
var tries: u32 = 0; var tries: u32 = 0;
while (manager == null and tries < 100) : (tries += 1) { while (manager == null and tries < 100) : (tries += 1) {
manager = ipc.lookup(.device_manager); manager = channel.openEndpoint("device-manager");
if (manager == null) time.sleepMillis(20); if (manager == null) time.sleepMillis(20);
} }
const h = manager orelse return; const h = manager orelse return;

View File

@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{ const exe = build_support.userBinary(b, .{
.name = "device-list", .name = "device-list",
.root_source_file = b.path("device-list.zig"), .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); b.installArtifact(exe);
} }

View File

@ -5,6 +5,7 @@
//! device_* system call. //! device_* system call.
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const logging = @import("logging"); const logging = @import("logging");
@ -19,7 +20,7 @@ pub fn main() void {
var manager: ?ipc.Handle = null; var manager: ?ipc.Handle = null;
var tries: u32 = 0; var tries: u32 = 0;
while (manager == null and tries < 200) : (tries += 1) { while (manager == null and tries < 200) : (tries += 1) {
manager = ipc.lookup(.device_manager); manager = channel.openEndpoint("device-manager");
if (manager == null) time.sleepMillis(20); if (manager == null) time.sleepMillis(20);
} }
const h = manager orelse { const h = manager orelse {

View File

@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{ const exe = build_support.userBinary(b, .{
.name = "process-test", .name = "process-test",
.root_source_file = b.path("process-test.zig"), .root_source_file = b.path("process-test.zig"),
.imports = &.{ "ipc", "logging", "process", "service", "time" }, .imports = &.{ "channel", "ipc", "logging", "process", "service", "time" },
}); });
b.installArtifact(exe); b.installArtifact(exe);
} }

View File

@ -17,6 +17,7 @@
//! binary bare), it exits silently so it cannot derange other tests' output. //! binary bare), it exits silently so it cannot derange other tests' output.
const std = @import("std"); const std = @import("std");
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const process = @import("process"); const process = @import("process");
const service = @import("service"); 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 /// 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 /// signals it handles. Terminate makes run() return, and returning from main is
/// the clean exit the parent reads as ExitReason.exited. /// 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; _ = sender;
_ = capability; _ = arrived;
const n = @min(message.len, reply.len); const n = @min(message.len, reply.len);
@memcpy(reply[0..n], message[0..n]); @memcpy(reply[0..n], message[0..n]);
return n; return n;
@ -82,10 +83,10 @@ fn signalRun() void {
var service_handle: ?ipc.Handle = null; var service_handle: ?ipc.Handle = null;
var tries: u32 = 0; var tries: u32 = 0;
while (service_handle == null and tries < 200) : (tries += 1) { 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); 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. // The universal ping: a zero-length call answered zero-length by the harness.
var reply: [16]u8 = undefined; var reply: [16]u8 = undefined;
@ -125,9 +126,10 @@ pub fn main(init: process.Init) void {
while (true) time.sleepMillis(500); while (true) time.sleepMillis(500);
} }
if (std.mem.eql(u8, role, "service")) { 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.run(64, .{
.service = .input, .service = "test/process",
.on_message = echo, .on_message = echo,
.on_reload = onReload, .on_reload = onReload,
.on_terminate = onTerminate, .on_terminate = onTerminate,

View File

@ -0,0 +1,15 @@
//! The protocol-denied-test fixture as a binary package (docs/build-packages-plan.md):
//! this file names the binary and EXACTLY the modules its source imports
//! build-support resolves each name from the domains this zon declares.
const std = @import("std");
const build_support = @import("build-support");
pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "protocol-denied-test",
.root_source_file = b.path("protocol-denied-test.zig"),
.imports = &.{ "channel", "envelope", "file-system", "ipc", "logging", "process", "time", "vfs-protocol" },
});
b.installArtifact(exe);
}

View File

@ -0,0 +1,19 @@
.{
.name = .protocol_denied_test,
.version = "0.0.0",
.fingerprint = 0xab37a6fa7116698f, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.dependencies = .{
// build-support supplies the shared recipe; kernel is implicit in
// every binary (the root shim + link script live there). The rest
// are exactly the homes of this binary's declared imports.
.@"build-support" = .{ .path = "../../../../build-support" },
.kernel = .{ .path = "../../../../library/kernel" },
// envelope: the errno the registrar answers a refused open with;
// vfs-protocol: the request and reply this fixture compares byte for
// byte, which is why it speaks the wire itself instead of using the
// Channel client.
.protocol = .{ .path = "../../../../library/protocol" },
},
.paths = .{""},
}

View File

@ -0,0 +1,269 @@
//! protocol-denied-test restriction stage one's own test fixture
//! (docs/os-development/protocol-namespace.md, "Restriction: per-process
//! namespaces, not ACLs"). One binary, one role, driven by the
//! `protocol-denied` kernel case:
//!
//! - `protocol-denied-test run` the driver, and the assertions:
//! 1. a contract this binary IS granted opens: the reply says success and
//! carries a capability the channel itself. The control comes first
//! and is repeated last, because every refusal below would read exactly
//! the same against a registrar that had simply stopped opening things;
//! 2. a contract this binary is NOT granted is refused and `/protocol`'s
//! own listing is read first to prove the name is genuinely BOUND, so
//! the refusal is a policy decision and not an accident of boot order;
//! 3. the refusal is **indistinguishable from a name that does not exist**.
//! The fixture asks for a name nothing ever bound and compares the two
//! answers field by field status, node, payload length, the whole
//! reply packet byte for byte, and the presence of a capability. That
//! collapse is the model, not a nicety: "permission denied" and "not
//! found" are one answer, so an open can never be used as an oracle for
//! what exists outside a process's view, and stage two's supervisor can
//! refuse, park for a human, or substitute a fake with the child unable
//! to tell which happened;
//! 4. the third shape neither granted nor bound answers identically
//! too, so all three collapse into one rather than two.
//!
//! **What this fixture deliberately does not claim.** Two channels are outside
//! what a ring-3 client can honestly assert:
//!
//! - *The registrar's log.* `klog_read` is ungated, so a line written on one
//! branch and not the other would be readable here but the ring carries
//! every task's output, so searching it for a contract name proves nothing
//! either way (the input service prints "input:" lines of its own). The
//! guarantee is made at the source instead: `onOpen` in
//! system/services/init/init.zig writes nothing on any branch, and says why.
//! - *Timing.* The difference worth measuring a syscall or a serial write on
//! one branch is milliseconds, but this fixture shares four cores and a
//! serial line with a booting system, so a measurement here would be noise
//! wearing an assertion's clothes. `onOpen` asks both questions on every
//! open, whatever the first one answers; that is the claim, and it is a claim
//! about the code, checked by reading it.
//!
//! Prints `protocol-denied: ok` on success, or a `protocol-denied: FAIL` line
//! naming the step. Spawned bare (the initial-ramdisk sweep starts every bundled
//! binary), it exits silently so it cannot derange other tests.
const std = @import("std");
const channel = @import("channel");
const envelope = @import("envelope");
const file_system = @import("file-system");
const ipc = @import("ipc");
const logging = @import("logging");
const process = @import("process");
const time = @import("time");
const vfs_protocol = @import("vfs-protocol");
/// The contract this fixture provides and then reaches under `/protocol/test`,
/// the subtree every `/test/` binary is granted. Binding it ourselves keeps the
/// granted case to one process: the registry does not know or care that the
/// provider on the other end of the channel is us.
const granted_contract = "test/denied-probe";
/// A contract this fixture is NOT granted and that the case makes sure IS bound:
/// the input service claims it, and only `input-source` and `input-test` are
/// named against it in /system/configuration/protocol.csv.
const forbidden_contract = "input";
/// A contract this fixture IS granted (the `test/*` subtree) and that nothing
/// ever binds. The comparison partner: refusal must look like this.
const absent_contract = "test/never-bound";
/// Neither granted nor bound. The third shape, so the collapse is into one
/// answer rather than two.
const forbidden_and_absent_contract = "display";
fn fail(step: []const u8) noreturn {
_ = logging.write("protocol-denied: FAIL ");
_ = logging.write(step);
_ = logging.write("\n");
process.exit(1);
}
// --- talking to the registrar directly --------------------------------------
//
// `channel.openEndpoint` folds every failure into null, which is exactly right
// for a client and useless here: the whole assertion is about the *shape* of the
// answer, so this fixture speaks the vfs protocol to the registry itself and
// keeps every byte that came back.
/// One `open` answer, kept whole.
const Answer = struct {
/// Bytes the registrar replied with the reply packet's length is itself a
/// channel, so it is compared like any other field.
length: usize = 0,
packet: [vfs_protocol.message_maximum]u8 = .{0} ** vfs_protocol.message_maximum,
/// Whether a capability rode the reply. The one field that actually matters
/// to a client: the capability IS the channel.
capability: bool = false,
/// The reply header, decoded compared field by field as well as byte for
/// byte, so a failure says *which* field diverged.
reply: vfs_protocol.Reply = .{ .status = 0, .node = 0, .len = 0 },
fn bytes(self: *const Answer) []const u8 {
return self.packet[0..self.length];
}
};
/// The registry's endpoint, obtained the way every process obtains it: resolve
/// `/protocol`. The handle is the kernel's, shared with every other user of the
/// mount, so it is never ours to close.
fn registryEndpoint() ?ipc.Handle {
// Patiently, for the same reason `bindPatiently` is patient: the kernel test
// harness starts the registrar and this fixture together, so a first resolve
// can land in the window before init has mounted `/protocol` at all. An
// absent mount is a boot race and worth waiting out; a registrar that
// answers has decided, and that answer is what the assertions below weigh.
var attempt: u32 = 0;
while (attempt < resolve_attempts) : (attempt += 1) {
var relative: [channel.path_maximum]u8 = undefined;
if (file_system.fsResolve(channel.root, 0, &relative)) |route| switch (route) {
.kernel => return null, // a kernel route means something other than the registry owns the name
.backend => |backend| return backend.handle,
};
time.sleepMillis(resolve_retry_ms);
}
return null;
}
/// The cadence `channel.bindPatiently` uses, for the same window.
const resolve_attempts: u32 = 200;
const resolve_retry_ms: u64 = 20;
/// One vfs-protocol request at the registry: the fixed header, then the contract
/// name inline. Names go bare (`input`, not `/input`) the registrar normalises
/// both, and bare is what `bind` sends.
fn transact(registry: ipc.Handle, operation: vfs_protocol.Operation, name: []const u8, cursor: u64) ?Answer {
var request: [vfs_protocol.message_maximum]u8 = undefined;
if (vfs_protocol.request_size + name.len > request.len) return null;
const header = vfs_protocol.Request{
.operation = operation,
.node = 0,
.offset = cursor,
.len = @intCast(name.len),
.flags = 0,
};
@memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header));
@memcpy(request[vfs_protocol.request_size..][0..name.len], name);
var answer: Answer = .{};
const got = ipc.callCap(
registry,
request[0 .. vfs_protocol.request_size + name.len],
&answer.packet,
null,
) catch return null;
if (got.len < vfs_protocol.reply_size) return null;
answer.length = got.len;
answer.capability = got.cap != null;
answer.reply = std.mem.bytesToValue(vfs_protocol.Reply, answer.packet[0..vfs_protocol.reply_size]);
// A capability we did not ask to keep is a handle slot spent; the assertions
// below only care that one arrived.
if (got.cap) |handle| _ = ipc.close(handle);
return answer;
}
/// `open(name)`, kept whole. Null only if the registry could not be reached at
/// all a registrar that answered has decided, and its decision is the subject.
fn openContract(registry: ipc.Handle, name: []const u8) Answer {
return transact(registry, .open, name, 0) orelse fail("the registry stopped answering");
}
/// Whether `/protocol` currently lists `name`. The namespace is browsable on
/// purpose (docs/os-development/protocol-namespace.md: `readdir` lists protocol
/// nodes like any others, so the tree stays diagnosable), and that is what lets
/// this fixture prove a refused name is really there without it, "refused"
/// and "not bound yet" would be the same observation and the test would assert
/// nothing.
fn listed(registry: ipc.Handle, name: []const u8) bool {
var cursor: u64 = 0;
while (cursor < 64) : (cursor += 1) {
const answer = transact(registry, .readdir, "", cursor) orelse return false;
if (answer.reply.status != 0 or answer.reply.len == 0) return false; // end of directory
const payload = answer.packet[vfs_protocol.reply_size..answer.length];
if (payload.len < vfs_protocol.directory_entry_size) return false;
const entry = std.mem.bytesToValue(vfs_protocol.DirectoryEntry, payload[0..vfs_protocol.directory_entry_size]);
const text = payload[vfs_protocol.directory_entry_size..];
const length = @min(@as(usize, entry.name_len), text.len);
if (std.mem.eql(u8, text[0..length], name)) return true;
}
return false;
}
/// Wait until `/protocol` lists `name` the providers this case needs come up
/// alongside the fixture, and racing them would make the assertions meaningless
/// rather than merely flaky.
fn awaitListed(registry: ipc.Handle, name: []const u8) void {
var attempts: u32 = 0;
while (attempts < 400) : (attempts += 1) {
if (listed(registry, name)) return;
time.sleepMillis(20);
}
_ = logging.write("protocol-denied: FAIL /protocol never listed ");
_ = logging.write(name);
_ = logging.write("\n");
process.exit(1);
}
/// Every caller-visible field of two answers, compared. `step` names the pair so
/// a failure says which comparison broke and in which field.
fn expectIdentical(step: []const u8, refused: Answer, absent: Answer) void {
if (refused.reply.status != absent.reply.status) fail(step); // the errno
if (refused.reply.node != absent.reply.node) fail(step); // the node id an open would return
if (refused.reply.len != absent.reply.len) fail(step); // payload bytes promised
if (refused.length != absent.length) fail(step); // reply packet length
if (refused.capability != absent.capability) fail(step); // the channel itself
if (!std.mem.eql(u8, refused.bytes(), absent.bytes())) fail(step); // and every byte of it
}
fn run() void {
const registry = registryEndpoint() orelse fail("resolve /protocol");
// Provide the granted contract ourselves. `bindPatiently` waits out a
// registry that has not mounted `/protocol` yet, which is the one thing
// worth retrying a registrar that answered has decided.
const provider = ipc.createIpcEndpoint() orelse fail("create the provider endpoint");
if (!channel.bindPatiently(granted_contract, provider)) fail("binding a granted contract was refused");
// Both names must be bound before anything is asked of them, or the
// comparison below would be between two boot races.
awaitListed(registry, granted_contract);
awaitListed(registry, forbidden_contract);
// 1. The control. A granted, bound contract opens: success, and the
// capability that IS the channel.
const allowed = openContract(registry, granted_contract);
if (allowed.reply.status != 0) fail("a granted open was refused");
if (!allowed.capability) fail("a granted open carried no channel");
_ = logging.write("protocol-denied: granted open succeeded\n");
// 2. The refusal. `input` is bound the listing above proved it and no
// manifest row names this binary against it.
const refused = openContract(registry, forbidden_contract);
if (refused.reply.status != -envelope.ENOENT) fail("an ungranted open did not answer -ENOENT");
if (refused.capability) fail("an ungranted open carried a channel");
_ = logging.write("protocol-denied: ungranted open refused as absent\n");
// 3. The point of the whole fixture. A name this binary IS granted and that
// nothing has ever bound, answered by the same registrar in the same
// breath and every caller-visible field of the two answers is the same.
const absent = openContract(registry, absent_contract);
expectIdentical("a refused open differed from a nonexistent name", refused, absent);
// 4. And the third shape, so the two reasons collapse into one answer rather
// than into two that happen to match: neither granted nor bound.
const neither = openContract(registry, forbidden_and_absent_contract);
expectIdentical("a refused-and-absent open differed from the others", refused, neither);
_ = logging.write("protocol-denied: refusal is indistinguishable from absence\n");
// 5. The control again, after the refusals: the registrar is still opening
// what it should, so what steps 2-4 saw was policy and not a registry
// that had wedged.
const again = openContract(registry, granted_contract);
if (again.reply.status != 0 or !again.capability) fail("the granted contract stopped opening");
_ = logging.write("protocol-denied: ok\n");
}
pub fn main(startup: process.Init) void {
const role = startup.arguments.get(1) orelse return; // bare (ramdisk sweep): stay silent
if (std.mem.eql(u8, role, "run")) run();
}

View File

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

View File

@ -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 = .{""},
}

View File

@ -0,0 +1,460 @@
//! 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 <mark>` binds `/protocol/test/registry`
//! and answers every request with its one-byte `<mark>`, 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 810 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/<contract>` 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");
// 9b. Receiving is the same privilege, and it was the one member of the family
// left unguarded. Every process holds a sendable handle to the registrar's
// mailbox `fs_resolve` installs one for anyone who asks and a stranger
// that could *dequeue* there would not merely evade the grants this fixture
// checks: it would take the provider endpoints that ride `bind` requests
// straight out of the queue, and answer other clients' opens in the
// registrar's name. Sending to it stays legal; receiving on it must not be.
// A refusal comes back as a negative errno in the length register, which
// is the whole point: the call returns instead of parking us on someone
// else's queue, where a success would have blocked until a request it was
// never ours to see arrived.
var stolen: [8]u8 = undefined;
const theft = ipc.replyWait(registry, stolen[0..0], &stolen, null);
if (theft.len <= ~@as(usize, 0) - 4095) fail("receiving on the registry's endpoint was allowed");
_ = logging.write("protocol-registry: foreign receive refused\n");
// 10. The handle-table storm again, aimed at a **harness-run service** this
// time. Step 4 covers PID 1, which runs its own hand-written loop; every
// other service in the system the VFS, the display compositor, the device
// 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();
}

View File

@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{ const exe = build_support.userBinary(b, .{
.name = "shared-memory-client", .name = "shared-memory-client",
.root_source_file = b.path("shared-memory-client.zig"), .root_source_file = b.path("shared-memory-client.zig"),
.imports = &.{ "ipc", "logging", "memory", "time" }, .imports = &.{ "channel", "ipc", "logging", "memory", "time" },
}); });
b.installArtifact(exe); b.installArtifact(exe);
} }

View File

@ -5,6 +5,7 @@
//! capability-passing path. //! capability-passing path.
const channel = @import("channel");
const ipc = @import("ipc"); const ipc = @import("ipc");
const time = @import("time"); const time = @import("time");
const memory = @import("memory"); const memory = @import("memory");
@ -19,7 +20,7 @@ fn expected(i: usize) u8 {
fn lookupServer() ?ipc.Handle { fn lookupServer() ?ipc.Handle {
var attempts: usize = 0; var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) { 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); time.sleepMillis(50);
} }
return null; return null;

View File

@ -1,5 +1,5 @@
//! test/system/services/shared-memory-server the receiving half of the shared-memory test (docs/display-v2.md V2). //! 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 //! 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 //! 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. //! (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); 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; _ = message;
_ = reply; _ = reply;
_ = sender; _ = 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"); _ = logging.write("shared-memory: shared FAILED (no capability)\n");
return 0; return 0;
}; };
@ -40,5 +41,5 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
} }
pub fn main() void { pub fn main() void {
service.run(64, .{ .service = .shared_memory_test, .on_message = onMessage }); service.run(64, .{ .service = "test/shared-memory", .on_message = onMessage });
} }