kernel: a refusal names its rule, and two bounds stop failing open

An AMD Ryzen booted to a working compositor with no USB and no storage,
and the log said only "register refused". A tree-wide audit of every
compile-time ceiling followed: 235 of them, 139 on quantities the machine
or a file decides rather than us, 5 documented anywhere, 171 silent when
reached. docs/fixed-bounds-audit.md has the inventory.

Errno attribution. The errno space was split between the kernel and the
envelope, free to drift; it is now one list in system/abi.zig, restated on
both sides, with a comptime check in library/device/driver where the two
halves are visible. device_register's six refusals and device_claim's three
are distinct codes, so a bus driver can say which rule stopped it, and
BadParent splits into NoSuchParent and NotYourParent. pci-bus reconciles
found against registered instead of counting refused functions as found.

Idempotency ordering. The child cap was checked before the identity match,
so a restarted bus was refused its own devices — the supervision restart the
system leans on ratcheted toward a degraded machine. A re-registration
consumes no slot and is now admitted first.

IOMMU fail-closed. confineDevice returned success for a device id past the
confinement table, leaving the device outside every domain while the caller
believed it confined — unreachable only while ids stop at 64, which both the
inventory move and a hardware-reported domain count would change. It refuses
now, and the coupling to the broker's device cap is a comptime assert rather
than a sentence in a comment.

PCI apertures. The bridge's MMIO apertures are derived from the holes in the
firmware memory map, and the derivation copied sub-4 GiB entries into a
fixed [64] array and skipped the rest. A skipped region is not merely lost:
the gap finder concludes it is free, so a real machine's 60-200 entry map
yields an aperture over live RAM, and containment then admits a child BAR
covering kernel memory. Rewritten to walk the map in place, with the hole
finder extracted as a pure function and driven by a synthetic 100-entry map
in a new test case. Both new tests were verified to fail on the old code.

parameters.zig gains the rationale it was missing and loses a stale sentence
pointing at the wrong file; vdso.md documents the errno space, including
EPEER, which had no written meaning anywhere.

docs/os-development/bounds.md is how a ceiling is declared from here.
docs/bounds-track-plan.md is the plan to remove the ones we invented.

Suite 114 -> 115.
This commit is contained in:
Daniel Samson
2026-08-08 11:09:54 +01:00
parent 7db5fba884
commit a86559648e
25 changed files with 1520 additions and 168 deletions
+103 -8
View File
@@ -51,6 +51,15 @@ fn check(name: []const u8, ok: bool) void {
}
}
/// `devices_broker.claim` reduced to a bool, for the `check` assertions below. The
/// broker returns `ClaimError` so ring 3 can tell "stale id" from "someone already
/// owns it" (the errno space in system/abi.zig); a test that only asserts the claim
/// succeeded does not care which, and the ones that do match the error directly.
fn claimOk(id: u64, owner: u32) bool {
devices_broker.claim(id, owner) catch return false;
return true;
}
/// Emit the overall result line the harness matches, then the done sentinel.
fn result() void {
log("DANOS-TEST-RESULT: {s} ({d} passed, {d} failed)\n", .{
@@ -250,6 +259,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
irqFreeTest();
} else if (eql(case, "containment")) {
containmentTest();
} else if (eql(case, "apertures")) {
apertureTest();
} else if (eql(case, "device-manager")) {
deviceManagerTest(boot_information);
} else if (eql(case, "protocol-registry")) {
@@ -1475,7 +1486,7 @@ fn ioPortTest() void {
check("discovered the acpi-tables I/O window", true);
const me = scheduler.current();
check("claimed the io_port device", devices_broker.claim(id, me.id));
check("claimed the io_port device", claimOk(id, me.id));
check("an in-range access resolves to port 0x64", process.resolveIoPort(me, id, found_res, 0x64, 1) == 0x64);
check("a 4-byte access at the last port is refused", process.resolveIoPort(me, id, found_res, 0xFFFF, 4) == null);
check("an out-of-range offset is refused", process.resolveIoPort(me, id, found_res, 0x10000, 1) == null);
@@ -2470,8 +2481,8 @@ fn claimReleaseTest(boot_information: *const BootInformation) void {
}
// The broker release in isolation.
check("device 0 claimed by owner 111", devices_broker.claim(0, 111));
check("device 1 claimed by owner 222", devices_broker.claim(1, 222));
check("device 0 claimed by owner 111", claimOk(0, 111));
check("device 1 claimed by owner 222", claimOk(1, 222));
devices_broker.releaseAllOwnedBy(111);
check("owner 111's claim is released", devices_broker.ownerOf(0) == null);
check("owner 222's claim survives", (devices_broker.ownerOf(1) orelse 0) == 222);
@@ -2493,7 +2504,7 @@ fn claimReleaseTest(boot_information: *const BootInformation) void {
};
const child = process.spawnProcessSupervised(image, 4, &.{"/system/services/init"}, me, endpoint) catch 0;
check("supervised child spawned", child != 0);
check("device 0 claimed on the child's behalf", devices_broker.claim(0, child));
check("device 0 claimed on the child's behalf", claimOk(0, child));
check("the kill is accepted", process.killProcess(me, child) == 0);
var badge: u64 = 0;
@@ -2501,7 +2512,7 @@ fn claimReleaseTest(boot_information: *const BootInformation) void {
_ = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the exit notification arrived", badge == abi.notify_badge_bit | abi.notify_exit_bit | child);
check("death released the child's claim", devices_broker.ownerOf(0) == null);
check("the device is claimable again", devices_broker.claim(0, me));
check("the device is claimable again", claimOk(0, me));
devices_broker.releaseAllOwnedBy(me);
result();
}
@@ -3765,8 +3776,8 @@ fn killThreadedGroupTest(boot_information: *const BootInformation) void {
// Claims for BOTH members: group death must release every member's claims
// before the supervisor hears anything — the worker's by the deferred
// (condemned) path.
check("device 0 claimed for the leader", devices_broker.claim(0, child));
check("device 1 claimed for the worker", devices_broker.claim(1, worker));
check("device 0 claimed for the leader", claimOk(0, child));
check("device 1 claimed for the worker", claimOk(1, worker));
scheduler.sleep(100); // let the worker really be running on another core
check("the supervisor's kill is accepted", process.killProcess(me, child) == 0);
const badge = awaitExitBadge(endpoint);
@@ -3954,7 +3965,7 @@ fn containmentTest() void {
result();
return;
}
check("claimed the parent device", devices_broker.claim(parent_id, me));
check("claimed the parent device", claimOk(parent_id, me));
defer devices_broker.releaseAllOwnedBy(me);
// A child whose window lies inside the parent's is accepted.
@@ -3976,10 +3987,94 @@ fn containmentTest() void {
check("re-registering an identical child returns the same id", again != 0 and again == good);
check("re-registering grew nothing", devices_broker.enumerate(&buffer) == before + 1);
// Fill the parent to its child cap with distinct children (same window, different
// identity — the match is on identity, so each is a new device).
var filled: u32 = 0;
var capped = false;
while (filled < 64) : (filled += 1) {
var name: [4]u8 = .{ 'k', 0, 0, 0 };
name[1] = '0' + @as(u8, @intCast(filled / 10));
name[2] = '0' + @as(u8, @intCast(filled % 10));
var extra = childDescriptor(name[0..3], parent_window.start, 0x20);
_ = devices_broker.register(parent_id, me, &extra) catch |err| {
capped = err == error.TooManyChildren;
break;
};
}
check("the parent reaches its child cap (TooManyChildren)", capped);
// The regression this ordering exists for: **a re-registration consumes no slot,
// so a full parent must not refuse one.** A crashed bus driver is restarted by its
// supervisor and re-registers everything it rediscovers; when the cap was checked
// before the identity match, the restart was refused its own devices and the
// machine degraded a little more on every crash.
const readmitted = devices_broker.register(parent_id, me, &fits) catch 0;
check("a full parent still re-admits an identical child", readmitted != 0 and readmitted == good);
// ...and the cap is genuinely still in force for anything new.
var novel = childDescriptor("knew", parent_window.start, 0x20);
const still_capped = if (devices_broker.register(parent_id, me, &novel)) |_| false else |err| err == error.TooManyChildren;
check("a full parent still refuses a new child", still_capped);
result();
}
/// A minimal child descriptor with one memory resource, for the containment test.
/// PCI host-bridge apertures are derived from the *holes* in the firmware memory map,
/// and a registered BAR must fall inside one. So the invariant is not "we find the
/// holes" but "an aperture never covers memory the firmware described" — an aperture
/// over RAM means `device_register` containment admits a child BAR over kernel memory,
/// and its claimant can `mmio_map` it.
///
/// The map's length is the firmware's choice: 60–200 descriptors on a real machine,
/// 15–25 under OVMF, which is why the suite never saw this. The derivation used to
/// copy sub-4 GiB entries into a fixed `[64]` array and skip the rest — and a skipped
/// region is not merely lost, it is one the gap finder concludes is *free*.
fn apertureTest() void {
// 100 described one-page regions, 2 MiB apart: 99 small holes between them, then
// one large hole from the last region up to 4 GiB. Under the old fixed array the
// 36 regions past the 64th vanished, so the "largest hole" ran from ~128 MiB to
// 4 GiB — straight across 36 regions the firmware had described.
const spacing: u64 = 2 << 20;
var regions: [100]boot_handoff.MemoryRegion = undefined;
for (&regions, 0..) |*region, i| {
region.* = .{ .base = @as(u64, i) * spacing, .pages = 1, .kind = .usable };
}
var holes: [3]platform.AddressRange = undefined;
const found = platform.largestHolesBelow4G(&regions, 1 << 20, &holes);
check("apertures were derived from a 100-entry map", found > 0);
var overlaps: usize = 0;
var largest: platform.AddressRange = .{ .base = 0, .end = 0 };
for (holes) |hole| {
if (hole.end <= hole.base) continue;
if (hole.end - hole.base > largest.end - largest.base) largest = hole;
for (regions) |region| {
const region_end = region.base + region.pages * 4096;
if (region.base < hole.end and region_end > hole.base) overlaps += 1;
}
}
check("no aperture overlaps described memory", overlaps == 0);
// The big hole is above the last described region, not across it.
const last_end = (regions.len - 1) * spacing + 4096;
check("the largest aperture starts after the last described region", largest.base == last_end);
check("the largest aperture runs to 4 GiB", largest.end == (1 << 32));
// A map the firmware describes nothing in is one whole hole; a map that describes
// everything has none. Neither may invent an aperture over something described.
var empty: [3]platform.AddressRange = undefined;
check("an empty map yields one hole", platform.largestHolesBelow4G(&.{}, 1 << 20, &empty) == 1);
check("that hole is the whole low space", empty[0].base == 0 and empty[0].end == (1 << 32));
const whole = [_]boot_handoff.MemoryRegion{.{ .base = 0, .pages = (1 << 32) / 4096, .kind = .usable }};
var none: [3]platform.AddressRange = undefined;
check("a fully described map yields no aperture", platform.largestHolesBelow4G(&whole, 1 << 20, &none) == 0);
result();
}
fn childDescriptor(hid: []const u8, start: u64, len: u64) device_abi.DeviceDescriptor {
var child = std.mem.zeroes(device_abi.DeviceDescriptor);
child.class = @intFromEnum(device_abi.DeviceClass.unknown);