349 lines
17 KiB
Zig
349 lines
17 KiB
Zig
//! protocol-conformance-test — P4a's evidence that `envelope.Define` gives every
|
|
//! provider the reserved verbs, uniformly and without the provider writing a line
|
|
//! for them (docs/security-track-plan.md P4a;
|
|
//! docs/os-development/protocol-namespace.md). One binary, one role, driven by
|
|
//! the `protocol-conformance` kernel case:
|
|
//!
|
|
//! - `protocol-conformance-test run` — for each contract it can reach:
|
|
//! 1. `describe` — the reserved verb 0 — is answered, and the answer names
|
|
//! *that* protocol: the name it was opened under, the version its module
|
|
//! declares, and the number of verbs its module declares. No provider in
|
|
//! the system implements `describe`; the generated dispatch answers it out
|
|
//! of the specification, which is exactly the claim being checked;
|
|
//! 2. a verb number no protocol in the system defines answers `-ENOSYS`, and
|
|
//! carries no capability. That is the other half of the same generated
|
|
//! dispatch: a provider does not have to reject strangers, it gets the
|
|
//! rejection for free and every provider gives the same one;
|
|
//! 3. `describe` again, after the refusal — a refused verb is an *answer*,
|
|
//! not a wedged service, so the channel is still good afterwards.
|
|
//!
|
|
//! **The set it checks is read, never hardcoded.** The fixture asks `/protocol`
|
|
//! for its own listing (`readdir`, which the namespace publishes on purpose) and
|
|
//! walks what it finds, so the case cannot drift from what this boot actually
|
|
//! bound. What it opens is bounded by P3: the manifest names this binary against
|
|
//! exactly the two contracts its scenario boots, and an ungranted name is absent
|
|
//! for it like any other client's.
|
|
//!
|
|
//! **What it covers, and what it cannot — the honest list.** The scenario boots
|
|
//! the registry, the input service, and the compositor, so `input` and `display`
|
|
//! are checked end to end over real IPC. The other six contracts in the table
|
|
//! are not asked here, and the reason is the provider, not the protocol:
|
|
//!
|
|
//! - `vfs` — the FAT server, which needs a mounted volume behind the whole USB
|
|
//! storage chain (the `fat-mount` scenario);
|
|
//! - `block` — the usb-storage driver, which the device manager spawns after
|
|
//! enumerating an xHCI bus (the `usb-storage` scenario);
|
|
//! - `scanout` — the virtio-gpu driver, which needs an emulated virtio-gpu the
|
|
//! default harness does not attach (the `virtio-gpu` scenario);
|
|
//! - `device-manager`, `power` and `usb-transfer` — the three P4b rebased. All
|
|
//! three come with the device manager: it *is* the first, it spawns the
|
|
//! discovery service that binds the second, and the xHCI driver it spawns
|
|
//! binds the third. So booting a provider for any one of them means booting
|
|
//! the whole driver tree here.
|
|
//!
|
|
//! That is the reason this scenario stays at two providers rather than five or
|
|
//! eight. It is not only the cost of booting half the system to send two
|
|
//! packets: this fixture takes **one snapshot** of `/protocol` and checks what
|
|
//! is in it, so a scenario whose bound set depends on how far a driver tree got
|
|
//! by that instant would make the case's own summary a boot race. What proves
|
|
//! the six instead is the scenarios that already drive them end to end —
|
|
//! `fat-mount`, `usb-storage`, `virtio-gpu`, and for the P4b three the
|
|
//! `device-list`, `driver-restart`, `pci-scan`, `usb-*`, `power-button` and
|
|
//! `orderly-shutdown` cases, every one of which is a live conversation over
|
|
//! these wires.
|
|
//!
|
|
//! All six sit in the table below regardless, so a scenario that binds one gets
|
|
//! it conformance-checked without this file being edited — and every run prints,
|
|
//! by name, the ones it found no provider for.
|
|
//!
|
|
//! The registry itself — PID 1 serving `/protocol` — is the one vfs backend
|
|
//! deliberately NOT dispatched through the generated table (it reads a
|
|
//! stranger's packet by hand, `system/services/init/init.zig`), so `describe` is
|
|
//! not asked of it and nothing here claims it.
|
|
//!
|
|
//! Prints `protocol-conformance: ok` on success, or a `protocol-conformance:
|
|
//! 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 block_protocol = @import("block-protocol");
|
|
const device_manager_protocol = @import("device-manager-protocol");
|
|
const display_protocol = @import("display-protocol");
|
|
const input_protocol = @import("input-protocol");
|
|
const power_protocol = @import("power-protocol");
|
|
const scanout_protocol = @import("scanout-protocol");
|
|
const usb_transfer_protocol = @import("usb-transfer-protocol");
|
|
const vfs_protocol = @import("vfs-protocol");
|
|
|
|
// --- what conformance means, per contract -----------------------------------
|
|
|
|
/// One contract this fixture knows how to check, and what the answer must say.
|
|
/// Every field is read off the protocol module itself, so the expectation is the
|
|
/// contract's own definition rather than a number copied beside it — a version
|
|
/// bump or a new verb updates this table by recompiling.
|
|
const Contract = struct {
|
|
name: []const u8,
|
|
version: u32,
|
|
/// How many verbs the module declares — `describe` reports it, so it is
|
|
/// checked. The reserved verbs are not counted: they are the envelope's.
|
|
operations: u32,
|
|
/// Whether this scenario boots a provider for it. A required contract that
|
|
/// is missing, unreachable or non-conforming fails the case; the rest are
|
|
/// checked when some other scenario happens to bind them.
|
|
required: bool,
|
|
};
|
|
|
|
fn contractOf(comptime Protocol: type, required: bool) Contract {
|
|
return .{
|
|
.name = Protocol.protocol_name,
|
|
.version = Protocol.version,
|
|
.operations = @typeInfo(Protocol.Operation).@"enum".fields.len,
|
|
.required = required,
|
|
};
|
|
}
|
|
|
|
/// The protocols built on `envelope.Define`. A name listed by `/protocol` that
|
|
/// is absent from here is reported and left alone rather than probed: a
|
|
/// hand-numbered provider would read operation 0 as one of its own verbs, so
|
|
/// asking it for `describe` would *do* something. Only `ps2-bus` is still in
|
|
/// that state today.
|
|
const contracts = [_]Contract{
|
|
contractOf(input_protocol.Protocol, true), // the input fan-out service
|
|
contractOf(display_protocol.Protocol, true), // the compositor
|
|
contractOf(vfs_protocol.Protocol, false), // the FAT server — needs a volume
|
|
contractOf(block_protocol.Protocol, false), // usb-storage — needs the xHCI chain
|
|
contractOf(scanout_protocol.Protocol, false), // virtio-gpu — needs the device
|
|
// The three P4b rebased. Each needs the device manager (and, for the last
|
|
// two, what the device manager starts), which is more than this scenario
|
|
// boots — see the header.
|
|
contractOf(device_manager_protocol.Protocol, false),
|
|
contractOf(power_protocol.Protocol, false), // the discovery service
|
|
contractOf(usb_transfer_protocol.Protocol, false), // the xHCI bus driver
|
|
};
|
|
|
|
/// A verb number no protocol in the system defines, and none plausibly will: far
|
|
/// above the reserved range, so it is unambiguously a protocol verb, and far
|
|
/// above any protocol's verb count, so the generated dispatch has nothing to
|
|
/// match it against. The answer must be `-ENOSYS` at every provider.
|
|
const stranger_operation: u32 = envelope.first_protocol_operation + 4096;
|
|
|
|
fn fail(step: []const u8) noreturn {
|
|
_ = logging.write("protocol-conformance: FAIL ");
|
|
_ = logging.write(step);
|
|
_ = logging.write("\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
fn report(comptime format: []const u8, arguments: anytype) void {
|
|
var line: [192]u8 = undefined;
|
|
_ = logging.write(std.fmt.bufPrint(&line, format, arguments) catch return);
|
|
}
|
|
|
|
// --- reading the namespace ---------------------------------------------------
|
|
|
|
/// The cadence every client in the tree spends finding a service.
|
|
const resolve_attempts: u32 = 200;
|
|
const resolve_retry_ms: u64 = 20;
|
|
|
|
/// 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. Patient, because the harness starts the
|
|
/// registrar and this fixture together and a first resolve can land before init
|
|
/// has mounted `/protocol` at all.
|
|
fn registryEndpoint() ?ipc.Handle {
|
|
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;
|
|
}
|
|
|
|
/// One `readdir(cursor)` at the registry, into `into`. Null at end of directory
|
|
/// or on any failure — the caller is walking a listing, and both mean "stop".
|
|
///
|
|
/// The listing is what makes this test un-driftable: `/protocol` publishes what
|
|
/// is bound (protocol-namespace.md — the tree stays diagnosable), so the set
|
|
/// under test is the set this boot actually produced.
|
|
fn entryAt(registry: ipc.Handle, cursor: u64, into: []u8) ?[]u8 {
|
|
var packet: [vfs_protocol.message_maximum]u8 = undefined;
|
|
const framed = vfs_protocol.Protocol.encodeRequest(.readdir, 0, .{ .cursor = cursor }, &.{}, &packet) orelse return null;
|
|
|
|
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
|
const got = ipc.callCap(registry, framed, &reply, null) catch return null;
|
|
// A readdir owes no capability; one that arrived anyway is a handle slot.
|
|
if (got.cap) |handle| _ = ipc.close(handle);
|
|
|
|
const answer = reply[0..got.len];
|
|
const status = envelope.statusOf(answer) orelse return null;
|
|
if (status.status != 0) return null;
|
|
const entry = vfs_protocol.Protocol.decodeReply(.readdir, answer) orelse return null;
|
|
if (entry.name_len == 0) return null; // end of directory
|
|
const text = vfs_protocol.Protocol.replyTail(.readdir, answer);
|
|
const length = @min(@as(usize, entry.name_len), @min(text.len, into.len));
|
|
@memcpy(into[0..length], text[0..length]);
|
|
return into[0..length];
|
|
}
|
|
|
|
/// The listing, taken once so every later question is asked of one observation
|
|
/// rather than of a namespace that may have moved underneath it.
|
|
const maximum_listed: usize = 32;
|
|
var listed_names: [maximum_listed][channel.name_maximum]u8 = undefined;
|
|
var listed_lengths: [maximum_listed]usize = undefined;
|
|
var listed_count: usize = 0;
|
|
|
|
fn listedName(index: usize) []const u8 {
|
|
return listed_names[index][0..listed_lengths[index]];
|
|
}
|
|
|
|
fn takeListing(registry: ipc.Handle) void {
|
|
listed_count = 0;
|
|
var cursor: u64 = 0;
|
|
while (cursor < maximum_listed) : (cursor += 1) {
|
|
const name = entryAt(registry, cursor, &listed_names[listed_count]) orelse return;
|
|
listed_lengths[listed_count] = name.len;
|
|
listed_count += 1;
|
|
}
|
|
}
|
|
|
|
/// Whether `/protocol` currently lists `name`.
|
|
fn listed(registry: ipc.Handle, name: []const u8) bool {
|
|
var cursor: u64 = 0;
|
|
while (cursor < maximum_listed) : (cursor += 1) {
|
|
var scratch: [channel.name_maximum]u8 = undefined;
|
|
const entry = entryAt(registry, cursor, &scratch) orelse return false;
|
|
if (std.mem.eql(u8, entry, 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 listing a boot race
|
|
/// rather than an observation.
|
|
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);
|
|
}
|
|
report("protocol-conformance: FAIL /protocol never listed {s}\n", .{name});
|
|
process.exit(1);
|
|
}
|
|
|
|
// --- the assertions ----------------------------------------------------------
|
|
|
|
/// The three checks, against one open channel. Every failure is fatal: the point
|
|
/// of the case is that these hold at *every* provider, so one that does not is
|
|
/// not a degraded result, it is the regression.
|
|
fn conform(link: channel.Channel, contract: Contract) void {
|
|
var buffer: [envelope.packet_maximum]u8 = undefined;
|
|
|
|
// 1. The reserved verb no provider implements. `describe` is answered from
|
|
// the specification by the generated dispatch, so what comes back is the
|
|
// contract's own identity — checked field by field against the module
|
|
// this fixture compiled against.
|
|
const described = link.describe(&buffer) orelse fail("describe was not answered");
|
|
if (!std.mem.eql(u8, described.name, contract.name)) fail("describe named a different protocol");
|
|
if (described.description.version != contract.version) fail("describe answered the wrong version");
|
|
if (described.description.operation_count != contract.operations) fail("describe counted the wrong number of verbs");
|
|
|
|
// 2. A number no protocol wears. Nothing in the provider looks at it; the
|
|
// dispatch table finds no handler and refuses, identically everywhere.
|
|
var into: [envelope.packet_maximum]u8 = undefined;
|
|
const answered = link.call(.{ .operation = stranger_operation }, &.{}, &into) orelse
|
|
fail("a stranger verb was not answered at all");
|
|
if (answered.status.status != -envelope.ENOSYS) fail("a stranger verb did not answer -ENOSYS");
|
|
if (answered.status.len != 0) fail("a refused verb promised a payload");
|
|
if (answered.capability) |handle| {
|
|
_ = ipc.close(handle);
|
|
fail("a refused verb handed back a capability");
|
|
}
|
|
|
|
// 3. A refusal is an answer, not a wedge — so the same channel still works.
|
|
const again = link.describe(&buffer) orelse fail("the provider stopped answering after a refused verb");
|
|
if (!std.mem.eql(u8, again.name, contract.name)) fail("describe changed its answer after a refused verb");
|
|
|
|
report("protocol-conformance: {s} v{d} describes itself ({d} verbs), verb {d} -> -ENOSYS\n", .{
|
|
contract.name,
|
|
contract.version,
|
|
contract.operations,
|
|
stranger_operation,
|
|
});
|
|
}
|
|
|
|
fn contractIndex(name: []const u8) ?usize {
|
|
for (contracts, 0..) |contract, index| {
|
|
if (std.mem.eql(u8, contract.name, name)) return index;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn run() void {
|
|
const registry = registryEndpoint() orelse fail("resolve /protocol");
|
|
|
|
// Every contract this scenario is supposed to be able to check must be bound
|
|
// before the listing is taken, or the case would assert nothing on a slow
|
|
// boot instead of failing on a broken one.
|
|
for (contracts) |contract| {
|
|
if (contract.required) awaitListed(registry, contract.name);
|
|
}
|
|
|
|
takeListing(registry);
|
|
if (listed_count == 0) fail("/protocol listed nothing at all");
|
|
report("protocol-conformance: /protocol lists {d} contract(s)\n", .{listed_count});
|
|
|
|
var checked = [_]bool{false} ** contracts.len;
|
|
for (0..listed_count) |index| {
|
|
const name = listedName(index);
|
|
const found = contractIndex(name) orelse {
|
|
// Not a lie of omission: named on serial, with the reason.
|
|
report("protocol-conformance: {s} skipped — not built on envelope.Define yet\n", .{name});
|
|
continue;
|
|
};
|
|
const contract = contracts[found];
|
|
const link = channel.Channel.connect(name) orelse {
|
|
// P3 is in force: an ungranted name is absent for this binary, and
|
|
// that is a manifest fact, not a failure — unless the scenario is
|
|
// supposed to have granted it.
|
|
if (contract.required) fail("a contract this fixture is granted would not open");
|
|
report("protocol-conformance: {s} skipped — not granted to this fixture\n", .{name});
|
|
continue;
|
|
};
|
|
conform(link, contract);
|
|
link.close();
|
|
checked[found] = true;
|
|
}
|
|
|
|
// The vacuity guard, and the honest tail: a required contract that went
|
|
// unchecked fails the case, and every other one this fixture knows how to
|
|
// check but found no provider for is named, so the coverage is legible on
|
|
// serial rather than inferred from what is absent.
|
|
var count: usize = 0;
|
|
for (contracts, 0..) |contract, index| {
|
|
if (checked[index]) {
|
|
count += 1;
|
|
continue;
|
|
}
|
|
if (contract.required) fail("a contract this scenario boots was never conformance-checked");
|
|
report("protocol-conformance: {s} not bound in this scenario — no provider to ask\n", .{contract.name});
|
|
}
|
|
report("protocol-conformance: {d} provider(s) answered the reserved verbs identically\n", .{count});
|
|
_ = logging.write("protocol-conformance: 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();
|
|
}
|