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:
+103
-8
@@ -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 (®ions, 0..) |*region, i| {
|
||||
region.* = .{ .base = @as(u64, i) * spacing, .pages = 1, .kind = .usable };
|
||||
}
|
||||
|
||||
var holes: [3]platform.AddressRange = undefined;
|
||||
const found = platform.largestHolesBelow4G(®ions, 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);
|
||||
|
||||
Reference in New Issue
Block a user