From bc67771bfd78ed7f3c26355d364d069f157b9cc7 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:58:47 +0100 Subject: [PATCH] block: reclaim range slots on death, and give confinement one controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the V2 boundary review confirmed: 1. The per-badge range table was never reclaimed. usb-storage receives exit notifications (via the subscriber watch), but onNotification handled only the timer and dropped child-exits, so a dead filesystem left its range slot used forever. The medium-removal lifecycle churns filesystems, so after maximum_ranges confine/die cycles define_range would return ENOSPC and no volume could be confined again until reboot. onNotification now frees the dead badge's slot, mirroring fat's open-node sweep. 2. define_range checked only that the CALLER was unconfined, never that it owned the target badge — so any unconfined opener could install a range for another live client and silently redirect its I/O. Confinement now has a single controller: the first unconfined party to define a range (the volume manager, which confines every filesystem before handing it a channel). Only the controller may thereafter; the slot releases on its death so a restarted manager re-takes it. This is the mechanism half; V3 adds the grant half (only the volume manager gets an unconfined channel). Neutral: block-range (the fixture is the sole definer -> controller), fat-mount, usb-report all green. End-to-end exercise of both lands in V3/V4 (the VM+filesystem relationship and the remount churn). --- system/drivers/usb-storage/usb-storage.zig | 31 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/system/drivers/usb-storage/usb-storage.zig b/system/drivers/usb-storage/usb-storage.zig index 1351378..84956ea 100644 --- a/system/drivers/usb-storage/usb-storage.zig +++ b/system/drivers/usb-storage/usb-storage.zig @@ -208,6 +208,14 @@ const maximum_ranges = 64; const Range = struct { used: bool = false, badge: u32 = 0, base: u64 = 0, count: u64 = 0 }; var ranges = [_]Range{.{}} ** maximum_ranges; +/// The one party allowed to confine others — the first unconfined caller to +/// define a range, which is the volume manager (it probes and confines every +/// filesystem before handing it a channel). Without this, any unconfined +/// opener could install a range for another live client's badge and silently +/// redirect its I/O. Released on the controller's death so a restarted volume +/// manager re-takes it. +var range_controller: ?u32 = null; + fn rangeFor(badge: u32) ?*Range { for (&ranges) |*r| { if (r.used and r.badge == badge) return r; @@ -264,7 +272,17 @@ fn onWrite(_: void, invocation: Invocation(block_protocol.Transfer), answer: Ans /// Refused if the CALLER is itself confined — a filesystem cannot widen its own /// range or confine anyone; only an unconfined party (the volume manager) may. fn onDefineRange(_: void, invocation: Invocation(block_protocol.DefineRange), _: Answer(void)) isize { - if (rangeFor(invocation.sender) != null) return -envelope.EPERM; + const sender = invocation.sender; + // A confined caller may never confine — no self-widening, no escape. + if (rangeFor(sender) != null) return -envelope.EPERM; + // Confinement has a single controller (the volume manager). Whoever defines + // the first range takes it; only they may thereafter, so a second unconfined + // opener cannot install a range for a badge it does not own. + if (range_controller) |c| { + if (sender != c) return -envelope.EPERM; + } else { + range_controller = sender; + } const request = invocation.request; const slot = rangeFor(request.badge) orelse free: { for (&ranges) |*r| { @@ -336,8 +354,17 @@ fn onNotification(badge: u64) void { if (got.isTimer()) { pollPresence(); _ = time.timerOnce(service_endpoint, presence_poll_ms); + return; + } + // A client died. The harness sweep (Serve.hooks) covers only the SUBSCRIBER + // table; the per-badge range table is ours to reclaim, or confine/die cycles + // (the medium-removal lifecycle) would exhaust it. A dead controller also + // releases confinement authority to its successor. + if (got.isChildExit()) { + const dead = got.childProcessId(); + if (rangeFor(dead)) |r| r.* = .{}; + if (range_controller == dead) range_controller = null; } - // Subscriber deaths are swept by the harness (Serve.hooks); nothing else here. } pub fn main(init: process.Init) void {