diff --git a/docs/bounds-track-plan.md b/docs/bounds-track-plan.md index 73f1fcf..bd4c2f9 100644 --- a/docs/bounds-track-plan.md +++ b/docs/bounds-track-plan.md @@ -39,7 +39,7 @@ that cannot safely run in user space.** | Step | What | State | |---|---|---| -| D1 | `device_transfer(device_id, task_id)` — the holder gives a device away | not started | +| D1 | `device_transfer(device_id, task_id)` — the holder gives a device away | **done** — syscall 54; a move, not a copy | | D2 | Adversarial case: a process handed nothing is refused, on a held device and a free one | not started | | D3 | The manager claims the seeded devices at boot, before any driver is spawned | not started | | D4 | `usb-xhci-bus` receives its controller in the `hello` reply instead of claiming argv[1] | not started | diff --git a/library/device/driver/driver.zig b/library/device/driver/driver.zig index b34af41..3a9633c 100644 --- a/library/device/driver/driver.zig +++ b/library/device/driver/driver.zig @@ -46,6 +46,25 @@ pub fn enumerate(buffer: []DeviceDescriptor) usize { return sc.systemCall2(.device_enumerate, @intFromPtr(buffer.ptr), buffer.len); } +/// Why a `transfer` failed. `NotHeld` is the interesting one — it means the caller tried +/// to give away a device it does not have, which is the whole rule. +pub const TransferError = error{ NoSuchDevice, NotHeld, NoSuchTask, Refused }; + +/// Give device `id` to task `to`. **A move, not a copy** — a claim is exclusive, so the +/// caller stops holding it. This is how the device manager hands a driver the device it +/// matched, replacing first-come-first-served claiming with policy +/// (docs/os-development/device-authority.md). +pub fn transfer(id: u64, to: u32) TransferError!void { + const r = sc.systemCall2(.device_transfer, id, to); + if (!failed(r)) return; + return switch (errnoOf(r)) { + abi.ENODEV => error.NoSuchDevice, + abi.EPERM => error.NotHeld, + abi.ESRCH => error.NoSuchTask, + else => error.Refused, + }; +} + /// 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 diff --git a/system/abi.zig b/system/abi.zig index d8f2157..138fe2d 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -85,6 +85,7 @@ pub const SystemCall = enum(u64) { dma_bind = 51, // dma_bind(device_id, region_handle) -> 0/-errno: map a DMA-region capability into the claimed device's IOMMU domain (idempotent). The caller must own the device and hold the handle dma_unbind = 52, // dma_unbind(device_id, region_handle) -> 0/-errno: unmap a previously bound region from the device's domain and invalidate handle_close = 53, // handle_close(handle) -> 0/-errno: drop one capability handle and free its table slot (endpoints, shared-memory, DMA regions) + device_transfer = 54, // device_transfer(device_id, task_id) -> 0/-errno: give a device you hold to another task. A MOVE, not a copy — a claim is exclusive (-ENODEV no such device, -EPERM you do not hold it, -ESRCH no such task) _, }; diff --git a/system/kernel/devices-broker.zig b/system/kernel/devices-broker.zig index cc67ddd..6dc7bdc 100644 --- a/system/kernel/devices-broker.zig +++ b/system/kernel/devices-broker.zig @@ -323,6 +323,42 @@ pub const ClaimError = error{ AlreadyClaimed, // a live task already owns it }; +/// Why a `transfer` was refused. +pub const TransferError = error{ + NoSuchDevice, // no device with that id + NotHeld, // the caller does not hold it — you may only give away what you have +}; + +/// The errno a refused `transfer` returns to ring 3. (`ESRCH` — no such recipient — is +/// raised by the caller in system/kernel/process.zig, which is what can see the task +/// table.) +pub fn transferErrnoOf(e: TransferError) i64 { + return switch (e) { + error.NoSuchDevice => abi.ENODEV, + error.NotHeld => abi.EPERM, + }; +} + +/// Move device `id` from `from` to `to`. **A move, not a copy**: a claim is exclusive +/// (driver-model.md, invariant 1), so the giver stops holding it the moment the +/// receiver starts. +/// +/// This is the mechanism behind delegation — the device manager claims what firmware +/// discovery seeded and passes each device to the driver it matched, which replaces +/// first-come-first-served `device_claim` with policy +/// (docs/device-driver-development/device-manager.md). The kernel checks only that the +/// caller holds the device: *you may give away what you have*. It knows nothing about +/// which task is the manager, and needs to know nothing. +/// +/// Note this is deliberately NOT the M13 capability-passing path, which shares a handle +/// refcounted — a copy. Exclusivity cannot be expressed that way. +pub fn transfer(id: u64, from: u32, to: u32) TransferError!void { + if (id >= count) return error.NoSuchDevice; + const holder = claimed[@intCast(id)] orelse return error.NotHeld; + if (holder != from) return error.NotHeld; + claimed[@intCast(id)] = to; +} + /// The errno a refused `claim` returns to ring 3. (`ECONFINE` — the claim stood but /// the IOMMU would not confine the device — is raised by the caller in /// system/kernel/process.zig, which is what rolls the claim back.) diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 254abf5..c2d8923 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -249,6 +249,7 @@ fn system_call(state: *architecture.CpuState) void { .irq_bind => systemIrqBind(state), .irq_ack => systemIrqAck(state), .device_register => systemDeviceRegister(state), + .device_transfer => systemDeviceTransfer(state), .system_spawn => systemSpawn(state), .dma_alloc => systemDmaAlloc(state), .dma_free => systemDmaFree(state), @@ -440,6 +441,29 @@ fn systemDeviceClaim(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, 0); } +/// device_transfer(device_id, task_id) -> 0/-errno: give a device you hold to another +/// task. The mechanism behind delegation — the device manager claims what discovery +/// seeded and hands each device to the driver it matched, so assignment stops being +/// first-come-first-served (docs/os-development/device-authority.md). +/// +/// The kernel's whole rule is *you may give away what you hold*. It has no notion of +/// which task is the device manager, and deliberately gains none: a binary name in the +/// kernel is not something that cannot safely live in user space. +fn systemDeviceTransfer(state: *architecture.CpuState) void { + const device_id = architecture.systemCallArg(state, 0); + const task_id: u32 = @truncate(architecture.systemCallArg(state, 1)); + const flags = sync.enter(); + defer sync.leave(flags); + + // The recipient must exist, or the device would be moved to nobody and become + // unreachable for the rest of the boot — no path un-holds a device but task death. + if (scheduler.taskByIdLocked(task_id) == null) return failErr(state, ipc.ESRCH); + + devices_broker.transfer(device_id, scheduler.current().id, task_id) catch |e| + return failErr(state, devices_broker.transferErrnoOf(e)); + architecture.setSystemCallResult(state, 0); +} + /// mmio_map(device_id, resource_index) -> virtual_address: map a claimed device's MMIO window into /// this address space (strong-uncacheable) and return the register base address. /// The claim is the capability — a process can only map hardware it owns. diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 328390d..f4ea81e 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -261,6 +261,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { containmentTest(); } else if (eql(case, "apertures")) { apertureTest(); + } else if (eql(case, "device-transfer")) { + deviceTransferTest(boot_information); } else if (eql(case, "device-manager")) { deviceManagerTest(boot_information); } else if (eql(case, "protocol-registry")) { @@ -4020,6 +4022,63 @@ fn containmentTest() void { } /// A minimal child descriptor with one memory resource, for the containment test. +/// Delegation's mechanism. A claim is exclusive (driver-model.md, invariant 1), so +/// handing a device on is a **move**: the giver stops holding it the instant the +/// receiver starts. That is why this is not the M13 capability path, which shares a +/// handle refcounted. +/// +/// The rule the kernel enforces is the whole of it: *you may give away what you hold*. +/// It has no idea which task is the device manager and needs none +/// (docs/os-development/device-authority.md). +fn deviceTransferTest(boot_information: *const boot_handoff.BootInformation) void { + var buffer: [8]device_abi.DeviceDescriptor = undefined; + check("the device tree is seeded", devices_broker.enumerate(&buffer) >= 2); + + const image = bundledInit(boot_information) orelse { + check("initial_ramdisk carries /system/services/init", false); + result(); + return; + }; + const me = scheduler.currentId(); + const endpoint = ipcsync.createIpcEndpoint() orelse { + check("exit endpoint allocated", false); + result(); + return; + }; + const child = process.spawnProcessSupervised(image, 4, &.{"/system/services/init"}, me, endpoint) catch 0; + check("supervised child spawned", child != 0); + + // You may only give away what you hold — so an unheld device cannot be moved at all, + // which is what stops a transfer being a back door around claiming. + const unheld = if (devices_broker.transfer(0, me, child)) |_| false else |e| e == error.NotHeld; + check("an unheld device cannot be transferred", unheld); + + check("claimed device 0", claimOk(0, me)); + + // The move itself. + const moved = if (devices_broker.transfer(0, me, child)) |_| true else |_| false; + check("the holder may transfer", moved); + const holder = devices_broker.ownerOf(0) orelse 0; + check("the receiver holds it", holder == child); + check("the giver does not", holder != me); + + // Having given it away, the giver cannot give it again. This is the assertion that + // makes it a move rather than a copy. + const again = if (devices_broker.transfer(0, me, child)) |_| false else |e| e == error.NotHeld; + check("a former holder cannot transfer again", again); + + // Nor may a stranger move a device it never held. + const stranger = if (devices_broker.transfer(0, 9999, me)) |_| false else |e| e == error.NotHeld; + check("a stranger cannot transfer another task's device", stranger); + + const absent = if (devices_broker.transfer(9999, me, child)) |_| false else |e| e == error.NoSuchDevice; + check("a device that does not exist is refused", absent); + + devices_broker.releaseAllOwnedBy(child); + devices_broker.releaseAllOwnedBy(me); + result(); +} + /// 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 diff --git a/test/qemu_test.py b/test/qemu_test.py index 3f52528..c9516ce 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -1021,6 +1021,14 @@ CASES = [ {"name": "apertures", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # Delegation's mechanism (docs/os-development/device-authority.md). A claim is + # exclusive, so handing a device on is a MOVE: the giver stops holding it the + # instant the receiver starts - which is why this is not the M13 capability path, + # where a handle is shared refcounted. The kernel's whole rule is "you may give + # away what you hold"; it has no notion of which task is the device manager. + {"name": "device-transfer", + "expect": r"DANOS-TEST-RESULT: PASS", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # IRQ teardown: an exiting driver's line is masked and its slot cleared (so no # ISR notifies a freed endpoint), and a sibling owner sharing that endpoint # keeps its own binding. A long-running driver never reaches this teardown path.