From c37402891ab44359d00b4631fd55ea4d16418757 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:25:43 +0100 Subject: [PATCH] =?UTF-8?q?test:=20block-range=20=E2=80=94=20the=20discrim?= =?UTF-8?q?ination=20fixture=20for=20range=20confinement=20(V2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A process acquires a block channel the way a filesystem does (consumer-hello the device manager), confines ITSELF to blocks [1,3), then proves the clamp and the gate: volume-relative LBA 0 maps inside the range and reads; a read reaching past the range is refused; geometry reports the confined size; and a confined caller can no longer call define_range (no widening, no escape). It gates on argv so the ramdisk sweep leaves it silent in other boots, and coexists with fat (ranges are per-badge). Discrimination (verified by reverting usb-storage to pre-clamp f1bdce2~1): the unconfined read still succeeds but define_range returns ENOSYS, so the fixture cannot arm confinement and the case fails — exactly the property the clamp adds. With the clamp: block-range 1/1. --- build.zig | 1 + build.zig.zon | 1 + system/kernel/tests.zig | 27 ++++ test/qemu_test.py | 16 ++ .../block-range-test/block-range-test.zig | 148 ++++++++++++++++++ .../services/block-range-test/build.zig | 19 +++ .../services/block-range-test/build.zig.zon | 16 ++ 7 files changed, 228 insertions(+) create mode 100644 test/system/services/block-range-test/block-range-test.zig create mode 100644 test/system/services/block-range-test/build.zig create mode 100644 test/system/services/block-range-test/build.zig.zon diff --git a/build.zig b/build.zig index a3dc24d..f8e6d6c 100644 --- a/build.zig +++ b/build.zig @@ -343,6 +343,7 @@ pub fn build(b: *std.Build) void { "protocol-denied-test", // restriction stage one: an ungranted open answers as absence "protocol-conformance-test", // the reserved verbs, asked of every provider the boot bound "device-authority-test", // the attacker: a process handed no device, asserting what it cannot do + "block-range-test", // confines itself to a block sub-range, then proves it cannot cross or widen it }) |fixture| { const package = b.lazyDependency(fixture, .{}) orelse @panic("a test fixture package is missing under test/system/services"); diff --git a/build.zig.zon b/build.zig.zon index 6f7adc4..0198bf7 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -80,6 +80,7 @@ .@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true }, .@"protocol-conformance-test" = .{ .path = "test/system/services/protocol-conformance-test", .lazy = true }, .@"device-authority-test" = .{ .path = "test/system/services/device-authority-test", .lazy = true }, + .@"block-range-test" = .{ .path = "test/system/services/block-range-test", .lazy = true }, // See `zig fetch --save ` for a command-line interface for adding dependencies. //.example = .{ // // When updating this field to a new URL, be sure to delete the corresponding diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 5f5a05f..2c323b4 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -265,6 +265,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { deviceTransferTest(boot_information); } else if (eql(case, "device-authority")) { deviceAuthorityTest(boot_information); + } else if (eql(case, "block-range")) { + blockRangeTest(boot_information); } else if (eql(case, "device-manager")) { deviceManagerTest(boot_information); } else if (eql(case, "protocol-registry")) { @@ -3034,6 +3036,31 @@ fn fatMountTest(boot_information: *const BootInformation) void { result(); } +/// Per-sender range confinement (V2a, docs/volume-manager-plan.md): boot the +/// full tree so the USB storage chain is up, then spawn block-range-test, which +/// acquires the block channel, confines ITSELF to a sub-range, and asserts it +/// cannot read past that range or widen it. The fixture's markers are the +/// assertion (the QEMU expect regex matches them); this only boots and spawns. +fn blockRangeTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: block-range\n", .{}); + if (boot_information.initial_ramdisk_len == 0) { + check("bootloader handed over the initial_ramdisk", false); + result(); + return; + } + const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len]; + const rd = initial_ramdisk.Reader.init(ramdisk) orelse { + check("initial_ramdisk image is valid", false); + result(); + return; + }; + process.setInitialRamdisk(ramdisk); + const spawned = if (process.spawnBundled("/system/services/init")) true else |_| false; + check("init spawned (boots the USB storage chain)", spawned); + check("block-range-test spawned", spawnNamedWithArg(rd, "block-range-test", "run")); + result(); +} + fn bootServiceTreeTest(boot_information: *const BootInformation, comptime label: []const u8) void { log("DANOS-TEST-BEGIN: " ++ label ++ "\n", .{}); if (boot_information.initial_ramdisk_len == 0) { diff --git a/test/qemu_test.py b/test/qemu_test.py index 3786022..4754310 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -1234,6 +1234,22 @@ CASES = [ {"name": "device-authority", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # Per-sender range confinement (V2a, docs/volume-manager-plan.md): a process + # confines ITSELF to a block sub-range (as the volume manager confines a + # filesystem), then proves it cannot read past the range nor widen it. The + # security assertions are named explicitly so the case cannot pass without + # them; a confined read crossing the range must be REFUSED and a confined + # define_range must be REFUSED. Against pre-clamp usb-storage the define_range + # verb does not exist, so the fixture fails to arm confinement at all. + {"name": "block-range", + "smp": 4, + "timeout": 150, + "expect": r"(?s)(?=.*block-range: ok in-range-read)" + r"(?=.*block-range: ok out-of-range-refused)" + r"(?=.*block-range: ok geometry-is-confined)" + r"(?=.*block-range: ok confined-cannot-redefine)" + r"(?=.*block-range: VERDICT done)", + "fail": r"block-range: FAILED|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. diff --git a/test/system/services/block-range-test/block-range-test.zig b/test/system/services/block-range-test/block-range-test.zig new file mode 100644 index 0000000..f5ab054 --- /dev/null +++ b/test/system/services/block-range-test/block-range-test.zig @@ -0,0 +1,148 @@ +//! block-range-test — the discrimination fixture for per-sender range +//! confinement (V2a, docs/volume-manager-plan.md). It gets a block channel the +//! way a filesystem does (consumer-hello the device manager for the mass-storage +//! provider), then proves the two properties the clamp exists for: +//! +//! 1. an UNCONFINED caller may define a range on its own badge (the volume +//! manager is unconfined — this stands in for it); +//! 2. once confined, a transfer PAST the range is refused, and the volume +//! relative LBA 0 maps inside the range (the clamp translates + bounds); +//! 3. a CONFINED caller may NOT call define_range again (the gate — a +//! filesystem cannot widen its own range or escape). +//! +//! Against pre-clamp usb-storage the verb does not exist, so (1) already fails — +//! which is exactly the discrimination: the fixture cannot even arm confinement, +//! let alone see a transfer refused for crossing it. +//! +//! It coexists with the FAT service in the same boot: ranges are per-badge, so +//! confining THIS process touches nothing fat does on its own channel. + +const std = @import("std"); +const channel = @import("channel"); +const device_manager_protocol = @import("device-manager-protocol"); +const driver = @import("driver"); +const ipc = @import("ipc"); +const block = @import("block"); +const memory = @import("memory"); +const logging = @import("logging"); +const process = @import("process"); +const time = @import("time"); +const envelope = @import("envelope"); + +fn verdict(ok: bool, name: []const u8) void { + _ = logging.write("block-range: "); + _ = logging.write(if (ok) "ok " else "FAILED "); + _ = logging.write(name); + _ = logging.write("\n"); +} + +/// The mass-storage provider's block channel, via the device manager's tree — +/// the same lineage acquisition the FAT service uses (block is not a name). +fn acquireBlock() ?block.Device { + var tries: u32 = 0; + const manager = while (tries < 200) : (tries += 1) { + if (channel.openEndpoint("device-manager")) |h| break h; + time.sleepMillis(20); + } else return null; + + // The whole USB storage chain (enumeration, bring-up) takes a few seconds to + // appear in the manager's tree, so retry the enumerate-and-hello with a pause + // between rounds — 500 x 20 ms ~ 10 s, well within the case timeout. + const Entry = device_manager_protocol.ChildEntry; + var attempt: u32 = 0; + while (attempt < 500) : (attempt += 1) { + var start: u64 = 0; + while (true) { + const enumerate = envelope.Header{ .operation = envelope.operation_enumerate, .target = start }; + var reply: [device_manager_protocol.message_maximum]u8 = undefined; + const length = ipc.call(manager, std.mem.asBytes(&enumerate), &reply) catch break; + const status = envelope.statusOf(reply[0..length]) orelse break; + if (status.status != 0) break; + const carried = @min(@as(usize, status.len), length -| envelope.prefix_size); + const tail = reply[envelope.prefix_size..][0..carried]; + const count = tail.len / @sizeOf(Entry); + if (count == 0) break; + var index: usize = 0; + while (index < count) : (index += 1) { + const entry = std.mem.bytesToValue(Entry, tail[index * @sizeOf(Entry) ..][0..@sizeOf(Entry)]); + if (entry.device_id == device_manager_protocol.no_device) continue; + if ((entry.identity >> 16) & 0xff != 0x08 or (entry.identity >> 8) & 0xff != 0x06) continue; + const exchanged = driver.helloOn(manager, .consumer, entry.device_id, null, true) orelse break; + const provider = exchanged.channel orelse continue; + return .{ .endpoint = provider }; + } + start += count; + } + time.sleepMillis(20); + } + return null; +} + +pub fn main(init: process.Init) void { + // Bundled fixtures are swept up and spawned bare on every boot; stay silent + // unless the kernel test explicitly runs us, or we would contend for the + // block channel and print markers into unrelated cases. + const arg = init.arguments.get(1) orelse return; + if (!std.mem.eql(u8, arg, "run")) return; + + const device = acquireBlock() orelse { + verdict(false, "acquire-block"); + return; + }; + const geometry = device.geometry() orelse { + verdict(false, "geometry"); + return; + }; + // Need at least a few blocks to carve a range out of; every FAT image is far + // larger, so this only guards a nonsense device. + if (geometry.block_count < 4) { + verdict(false, "device-too-small"); + return; + } + + // A one-block DMA buffer for the positive-control read. Shareable so it can be + // attached under an enforcing IOMMU (a no-op success otherwise). + const bounce = memory.dmaAlloc(512, memory.dma_coherent | memory.dma_shareable) orelse { + verdict(false, "dma-alloc"); + return; + }; + if (bounce.handle) |handle| { + if (!device.attach(handle)) { + verdict(false, "attach"); + return; + } + _ = ipc.close(handle); + } + + // Baseline: an unconfined read of block 0 succeeds — so a later refusal is + // the clamp, not a broken read path. + verdict(device.read(0, 1, bounce.physical), "unconfined-read"); + + const me = process.taskId(); + + // (1) An unconfined caller confines itself to blocks [1, 3). Against pre-clamp + // usb-storage this verb does not exist and the call fails here. + if (!device.defineRange(me, 1, 2)) { + verdict(false, "define-range"); + return; + } + verdict(true, "define-range"); + + // (2) Confined now: volume-relative LBA 0 maps to device block 1 (inside the + // range) and succeeds; LBA 2 would reach device block 3, past the 2-block + // range, and must be refused. + verdict(device.read(0, 1, bounce.physical), "in-range-read"); + verdict(!device.read(2, 1, bounce.physical), "out-of-range-refused"); + + // Geometry now reports the CONFINED size, not the device's. + const confined = device.geometry() orelse { + verdict(false, "confined-geometry"); + return; + }; + verdict(confined.block_count == 2, "geometry-is-confined"); + + // (3) The gate: a confined caller cannot define_range — no widening, no escape. + verdict(!device.defineRange(me, 0, geometry.block_count), "confined-cannot-redefine"); + + _ = logging.write("block-range: VERDICT done\n"); +} diff --git a/test/system/services/block-range-test/build.zig b/test/system/services/block-range-test/build.zig new file mode 100644 index 0000000..6ba85e1 --- /dev/null +++ b/test/system/services/block-range-test/build.zig @@ -0,0 +1,19 @@ +//! The block-range-test fixture as a binary package (docs/build-packages-plan.md): +//! this file names the binary and EXACTLY the modules its source imports — +//! build-support resolves each name from the domains this zon declares. + +const std = @import("std"); +const build_support = @import("build-support"); + +pub fn build(b: *std.Build) void { + const exe = build_support.userBinary(b, .{ + .name = "block-range-test", + .root_source_file = b.path("block-range-test.zig"), + .imports = &.{ + "block", "channel", "device-manager-protocol", "driver", + "envelope", "ipc", "logging", "memory", + "process", "time", + }, + }); + b.installArtifact(exe); +} diff --git a/test/system/services/block-range-test/build.zig.zon b/test/system/services/block-range-test/build.zig.zon new file mode 100644 index 0000000..d655fcf --- /dev/null +++ b/test/system/services/block-range-test/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .block_range_test, + .version = "0.0.0", + .fingerprint = 0xa7f2045ed72783c3, // Changing this has security and trust implications. + .minimum_zig_version = "0.16.0", + .dependencies = .{ + // build-support supplies the shared recipe; kernel is implicit in every + // binary. device (block, driver) and protocol (device-manager-protocol, + // envelope) are the homes of this fixture's remaining imports. + .@"build-support" = .{ .path = "../../../../build-support" }, + .kernel = .{ .path = "../../../../library/kernel" }, + .device = .{ .path = "../../../../library/device" }, + .protocol = .{ .path = "../../../../library/protocol" }, + }, + .paths = .{""}, +}