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
+65 -5
View File
@@ -22,14 +22,47 @@ inline fn failed(r: usize) bool {
return r > ~@as(usize, 0) - 4095;
}
/// The errno inside a failed return. Only meaningful when `failed(r)`.
inline fn errnoOf(r: usize) i64 {
return -@as(i64, @bitCast(r));
}
// The envelope restates the kernel's errno numbering by hand, because the
// `protocol` package deliberately depends on nothing (so it cannot import `abi`).
// This module is one of the few that can see both halves, so it is where they are
// held together: drift becomes a compile error here rather than a driver reporting
// the wrong reason for a refusal. Anything linking a driver compiles this.
comptime {
if (envelope.ENOENT != abi.ENOENT) @compileError("envelope.ENOENT has drifted from abi.ENOENT");
if (envelope.ENOSPC != abi.ENOSPC) @compileError("envelope.ENOSPC has drifted from abi.ENOSPC");
if (envelope.EPERM != abi.EPERM) @compileError("envelope.EPERM has drifted from abi.EPERM");
if (envelope.ENOSYS != abi.ENOSYS) @compileError("envelope.ENOSYS has drifted from abi.ENOSYS");
if (envelope.EPROTO != abi.EPROTO) @compileError("envelope.EPROTO has drifted from abi.EPROTO");
if (envelope.EBUSY != abi.EBUSY) @compileError("envelope.EBUSY has drifted from abi.EBUSY");
}
/// Copy up to `buffer.len` device descriptors into `buffer`; returns the total count.
pub fn enumerate(buffer: []DeviceDescriptor) usize {
return sc.systemCall2(.device_enumerate, @intFromPtr(buffer.ptr), buffer.len);
}
/// Take exclusive ownership of device `id`. Returns false if taken or invalid.
pub fn claim(id: u64) bool {
return !failed(sc.systemCall1(.device_claim, id));
/// Why a `claim` failed. Worth distinguishing: `AlreadyClaimed` means back off and
/// let the owner have it, `NoSuchDevice` means this id is stale and the caller should
/// re-enumerate, and `NotConfined` means the machine could not place the device under
/// IOMMU translation — the claim was rolled back, and that one is a fault report, not
/// a retry. `Refused` is an errno this library does not know a name for.
pub const ClaimError = error{ NoSuchDevice, AlreadyClaimed, NotConfined, Refused };
/// Take exclusive ownership of device `id`.
pub fn claim(id: u64) ClaimError!void {
const r = sc.systemCall1(.device_claim, id);
if (!failed(r)) return;
return switch (errnoOf(r)) {
abi.ENODEV => error.NoSuchDevice,
abi.EBUSY => error.AlreadyClaimed,
abi.ECONFINE => error.NotConfined,
else => error.Refused,
};
}
/// Map resource `resource_index` (which must be an MMIO window) of claimed device
@@ -56,11 +89,38 @@ pub const no_pci_class = device_abi.no_pci_class;
/// a bus driver may only subdivide what it already owns. `descriptor.id` and `descriptor.parent`
/// are ignored. A device with no resources at all is fine — a USB device is reached
/// through its controller, not by MMIO.
pub fn register(parent_id: u64, descriptor: *const DeviceDescriptor) ?u64 {
pub fn register(parent_id: u64, descriptor: *const DeviceDescriptor) RegisterError!u64 {
const r = sc.systemCall2(.device_register, parent_id, @intFromPtr(descriptor));
return if (failed(r)) null else r;
if (!failed(r)) return r;
return switch (errnoOf(r)) {
abi.ENOSPC => error.TableFull,
abi.ENODEV => error.NoSuchParent,
abi.EPERM => error.NotYourParent,
abi.E2BIG => error.TooManyResources,
abi.ECHILDREN => error.ParentFull,
abi.ERANGE => error.NotContained,
abi.EFAULT => error.BadDescriptor,
else => error.Refused,
};
}
/// Why a `register` failed. These are not interchangeable and a bus driver should
/// say which one it hit: `ParentFull` and `TableFull` are different ceilings with
/// different fixes, and `NotContained` is not a ceiling at all — it means the child
/// resource escaped the window the parent actually owns. Reporting all of them as
/// one refusal is what made an AMD desktop boot with no USB and no storage, and gave
/// no way to tell which of three causes it was (docs/fixed-bounds-audit.md).
pub const RegisterError = error{
TableFull, // the kernel's device table is full, machine-wide
ParentFull, // this parent already holds as many children as it can
NoSuchParent, // no device with that id
NotYourParent, // that device exists but this process has not claimed it
TooManyResources, // the descriptor declares more resources than one device may hold
NotContained, // a resource escapes the parent's window
BadDescriptor, // the descriptor pointer did not read back
Refused, // an errno this library does not know a name for
};
/// Bind resource `resource_index` (which must be an IRQ) of claimed device `device_id` to
/// `endpoint`. From then on the interrupt arrives as an asynchronous notification:
/// `ipc.replyWait` on that endpoint returns with the high bit set in `badge` and the