danos/test/system/services/protocol-registry-test/protocol-registry-test.zig

463 lines
26 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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");
var forged: [envelope.post_maximum]u8 = undefined;
const packet = power_protocol.Protocol.encodeEvent(.power_button, 0, .{}, &forged) orelse
fail("frame a forged power event");
if (!ipc.send(registry, packet)) 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();
}