280 lines
14 KiB
Zig
280 lines
14 KiB
Zig
//! 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's envelope `Status`, decoded — compared field by field as well
|
|
/// as byte for byte, so a failure says *which* field diverged.
|
|
status: envelope.Status = .{ .status = 0, .len = 0 },
|
|
/// The node id the reply carried, or null when it carried no reply body at
|
|
/// all. A refusal has none; the open of a contract carries a zero, because
|
|
/// the capability is the whole answer.
|
|
node: ?u64 = null,
|
|
|
|
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 folded header, the verb's own
|
|
/// fixed part, then the contract name as the packet's tail. Names go bare
|
|
/// (`input`, not `/input`) — the registrar normalises both, and bare is what
|
|
/// `bind` sends.
|
|
fn transact(
|
|
registry: ipc.Handle,
|
|
comptime operation: vfs_protocol.Operation,
|
|
request: vfs_protocol.Protocol.RequestOf(operation),
|
|
name: []const u8,
|
|
) ?Answer {
|
|
var packet: [vfs_protocol.message_maximum]u8 = undefined;
|
|
const framed = vfs_protocol.Protocol.encodeRequest(operation, 0, request, name, &packet) orelse return null;
|
|
|
|
var answer: Answer = .{};
|
|
const got = ipc.callCap(registry, framed, &answer.packet, null) catch return null;
|
|
answer.length = got.len;
|
|
answer.capability = got.cap != null;
|
|
answer.status = envelope.statusOf(answer.bytes()) orelse return null;
|
|
answer.node = if (operation == .open) blk: {
|
|
const opened = vfs_protocol.Protocol.decodeReply(.open, answer.bytes()) orelse break :blk null;
|
|
// A short reply decodes as garbage rather than absence, so the promised
|
|
// length is what says whether a body is there at all.
|
|
break :blk if (answer.status.len < @sizeOf(vfs_protocol.Opened)) null else opened.node;
|
|
} else null;
|
|
// 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, .{ .flags = 0 }, name) 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 = cursor }, "") orelse return false;
|
|
if (answer.status.status != 0) return false;
|
|
const entry = vfs_protocol.Protocol.decodeReply(.readdir, answer.bytes()) orelse return false;
|
|
if (entry.name_len == 0) return false; // end of directory
|
|
const text = vfs_protocol.Protocol.replyTail(.readdir, answer.bytes());
|
|
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.status.status != absent.status.status) fail(step); // the errno
|
|
if (!nodesMatch(refused.node, absent.node)) fail(step); // the node id an open would return
|
|
if (refused.status.len != absent.status.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
|
|
}
|
|
|
|
/// Two node ids agree when both are absent or both are the same value. A refusal
|
|
/// carries none at all now — the envelope sends a bare `Status` — so "no node"
|
|
/// is itself one of the observations that has to match.
|
|
fn nodesMatch(one: ?u64, other: ?u64) bool {
|
|
if (one) |a| {
|
|
return if (other) |b| a == b else false;
|
|
}
|
|
return other == null;
|
|
}
|
|
|
|
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.status.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.status.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.status.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();
|
|
}
|