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
+82 -40
View File
@@ -615,17 +615,78 @@ var boot_memory_regions: []const boot_handoff.MemoryRegion = &.{};
/// I/O-APIC region, the high one from 4 GiB (or the end of RAM above it) to
/// the 46-bit line. Coarse, mechanical, and AML-free — available at boot no
/// matter what later moved to user space.
pub const AddressRange = struct { base: u64, end: u64 };
/// The largest holes below 4 GiB in a firmware memory map: the ranges the firmware
/// described *nothing* in, which is where a PCI BAR may legitimately live. Fills `out`
/// (keeping the largest, replacing the smallest held so far), ignores holes shorter
/// than `minimum`, and returns how many entries are non-empty; entries the caller
/// reads may be empty and are skipped by `end > base`.
///
/// **It never copies the map, and that is the point.** The firmware chooses how many
/// descriptors its map has — commonly 60–200 on a real machine, 15–25 under OVMF. The
/// previous version copied the sub-4 GiB entries into a fixed `[64]` array and
/// `continue`d past the rest, which did not merely lose them: a region absent from the
/// walk is a region this function concludes is *free*, so a large enough map yields an
/// "aperture" lying over live RAM. `device_register` containment would then admit a
/// child BAR covering kernel memory, and its claimant could `mmio_map` it. A bound
/// whose overflow hands out authority is not a limit; the fix is not a bigger array.
///
/// Pure, allocation-free, and linear-ish in the map (each inner pass consumes at least
/// one region, and it runs once at boot).
pub fn largestHolesBelow4G(
regions: []const boot_handoff.MemoryRegion,
minimum: u64,
out: []AddressRange,
) usize {
const limit: u64 = 1 << 32;
for (out) |*hole| hole.* = .{ .base = 0, .end = 0 };
var cursor: u64 = 0;
while (cursor < limit) {
// Step over every described region covering the cursor. Regions may overlap
// and chain, so repeat until the cursor stops moving.
var moved = true;
while (moved) {
moved = false;
for (regions) |region| {
if (region.base >= limit) continue;
const end = @min(region.base + region.pages * 4096, limit);
if (region.base <= cursor and end > cursor) {
cursor = end;
moved = true;
}
}
}
if (cursor >= limit) break;
// The cursor now sits in a hole; it runs to the next described base, or to
// 4 GiB if nothing is described above it.
var next: u64 = limit;
for (regions) |region| {
if (region.base >= limit) continue;
if (region.base > cursor and region.base < next) next = region.base;
}
if (next - cursor >= minimum and out.len != 0) {
var smallest: usize = 0;
for (out, 0..) |hole, i| {
if (hole.end - hole.base < out[smallest].end - out[smallest].base) smallest = i;
}
if (next - cursor > out[smallest].end - out[smallest].base)
out[smallest] = .{ .base = cursor, .end = next };
}
cursor = next;
}
var found: usize = 0;
for (out) |hole| {
if (hole.end > hole.base) found += 1;
}
return found;
}
fn addBridgeApertures(bridge: *device_model.Device) void {
// Below 4 GiB the described regions are sparse (RAM low, firmware flash
// and tables high), so the holes are the *gaps between* them — a single
// "after the last region" rule dies on OVMF's flash at the very top.
// Sort-merge the described ranges, then keep the three largest gaps
// (resource slots are bounded at 8 per device; ECAM + bus range + 3 + the
// high aperture fits). Above 4 GiB one aperture runs from the end of the
// described space to the 46-bit line.
const Range = struct { base: u64, end: u64 };
var below: [64]Range = undefined;
var below_count: usize = 0;
var high_end: u64 = 1 << 32;
for (boot_memory_regions) |region| {
const end = region.base + region.pages * 4096;
@@ -636,37 +697,18 @@ fn addBridgeApertures(bridge: *device_model.Device) void {
// kernel image, the tables, the ramdisk all live there). Bring-up
// trust: only the bridge's claimant can register into the aperture.
if (region.kind == .usable and end > high_end) high_end = end;
if (region.base >= (1 << 32) or below_count == below.len) continue;
below[below_count] = .{ .base = region.base, .end = @min(end, 1 << 32) };
below_count += 1;
}
// Insertion sort by base (the map is small and this runs once at boot).
for (1..below_count) |i| {
const key = below[i];
var j = i;
while (j > 0 and below[j - 1].base > key.base) : (j -= 1) below[j] = below[j - 1];
below[j] = key;
}
// Walk the sorted ranges, collecting inter-region gaps of at least 1 MiB.
var gaps: [3]Range = .{Range{ .base = 0, .end = 0 }} ** 3;
var cursor: u64 = 0;
var index: usize = 0;
while (index <= below_count) : (index += 1) {
const gap_end = if (index == below_count) (1 << 32) else below[index].base;
if (gap_end > cursor and gap_end - cursor >= (1 << 20)) {
// Keep the three largest, replacing the smallest kept so far.
var smallest: usize = 0;
for (gaps, 0..) |gap, gi| {
if (gap.end - gap.base < gaps[smallest].end - gaps[smallest].base) smallest = gi;
}
if (gap_end - cursor > gaps[smallest].end - gaps[smallest].base) {
gaps[smallest] = .{ .base = cursor, .end = gap_end };
}
}
if (index < below_count and below[index].end > cursor) cursor = below[index].end;
}
for (gaps) |gap| {
if (gap.end > gap.base) _ = bridge.addResource(.memory, gap.base, gap.end - gap.base);
// Three holes below 4 GiB. This three is not a guess about hardware: a
// `DeviceDescriptor` carries `maximum_device_resources` (8) resources, and the
// bridge spends them on ECAM + bus range + these + the high aperture. That cap is
// a wire struct in the kernel↔user ABI, so widening it is a separate change —
// docs/bounds-track-plan.md, phase 4. Keeping *fewer* holes is fail-closed: it
// refuses BARs, it never admits one.
var holes: [3]AddressRange = undefined;
_ = largestHolesBelow4G(boot_memory_regions, 1 << 20, &holes);
for (holes) |hole| {
if (hole.end > hole.base) _ = bridge.addResource(.memory, hole.base, hole.end - hole.base);
}
_ = bridge.addResource(.memory, high_end, (@as(u64, 1) << 46) - high_end);
}