From d59279422efd9a435e07b778152bb6b3057ea940 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:35:32 +0100 Subject: [PATCH] test: partitioned-image tool + shared-channel multi-volume proof (S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-volumes case proves multiple DEVICES; this proves multiple volumes on ONE device. make-partitioned-image.py lays several FAT32 partitions (each from make-fat-image) behind a classic MBR; the new partitioned-volume case attaches one such disk (two partitions, da7a0001 at lba 2048, da7a0002 at lba 83968) as a single usb-storage device. partition.allVolumes walks the table and the manager spawns a confined fat per partition on the SAME block channel, each clamped to its own LBA range by usb-storage's per-badge range table — so partition B's fat cannot read partition A's blocks. The case asserts two mount lines at two distinct non-zero base_lbas on one device; against the pre-uncap allVolumes (S3 step 2, capped to one partition) only da7a0001 mounts. No image is committed — the disk is generated per run. Full suite 130/130 (128 + two-volumes + partitioned-volume). --- test/qemu_test.py | 37 ++++++++++++-- tools/make-partitioned-image.py | 88 +++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tools/make-partitioned-image.py diff --git a/test/qemu_test.py b/test/qemu_test.py index 7db229c..d9b1677 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -837,6 +837,26 @@ CASES = [ r"(?=.*carries the system tree)" r"(?=.*data volume; mounted at /volumes/fat-da7a0001)", "fail": r"data volume; mounted at /volumes/fat-12345678|DANOS-TEST-RESULT: FAIL"}, + # S3 shared-channel multi-volume: ONE usb-storage device carrying an MBR with + # TWO FAT partitions (da7a0001 at lba 2048, da7a0002 at lba 83968). allVolumes + # walks the table and the manager spawns a confined fat per partition on the + # SAME block channel, each clamped to its own LBA range (usb-storage's + # per-badge range table) — the path a pair of single-volume sticks (the + # two-volumes case) does NOT exercise. The two mount lines sit at two DISTINCT + # non-zero base_lbas on one device. Against the pre-uncap allVolumes (S3 step + # 2, capped to one partition) only da7a0001 mounts, so the da7a0002 lookaheads + # fail. + {"name": "partitioned-volume", + "build_case": "fat-mount", + "smp": 4, + "timeout": 150, + "data_volume": {"partitions": [{"serial": "DA7A0001", "size_mib": 40}, + {"serial": "DA7A0002", "size_mib": 40}]}, + "expect": r"(?s)(?=.*volume 0x0*da7a0001 -> \S+ \(pid \d+\), lba 2048, )" + r"(?=.*volume 0x0*da7a0002 -> \S+ \(pid \d+\), lba 83968, )" + r"(?=.*fat: mounted /volumes/fat-da7a0001)" + r"(?=.*fat: mounted /volumes/fat-da7a0002)", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # Phase 2b: mkdir/unlink through the mount. Reuses the fat-mount build — the # fat-test client, after listing, makes a directory, writes+reads a file inside # it, then removes the file, exercising the whole VFS -> fat mutation path. @@ -1451,11 +1471,18 @@ def run_case(arch, case): if case.get("data_volume"): dv = case["data_volume"] data_img = os.path.join(WORK, "data-volume.img") - subprocess.run( - [sys.executable, os.path.join(REPO, "tools", "make-fat-image.py"), - "--serial", dv["serial"], "--label", dv.get("label", "DATAVOL"), - data_img, str(dv.get("size_mib", 64))], - check=True, stdout=subprocess.DEVNULL) + if dv.get("partitions"): + # One device, an MBR with several FAT partitions: several volumes share + # ONE block channel, each confined to its own LBA range. + gen = [sys.executable, os.path.join(REPO, "tools", "make-partitioned-image.py"), data_img] + for part in dv["partitions"]: + gen += [part["serial"], str(part.get("size_mib", 40))] + else: + # One device, one bare FAT volume. + gen = [sys.executable, os.path.join(REPO, "tools", "make-fat-image.py"), + "--serial", dv["serial"], "--label", dv.get("label", "DATAVOL"), + data_img, str(dv.get("size_mib", 64))] + subprocess.run(gen, check=True, stdout=subprocess.DEVNULL) cmd += [ "-drive", f"if=none,id=datausb,format=raw,file={data_img}", "-device", "usb-storage,bus=xhci.0,port=4,drive=datausb,removable=on,id=datastorage", diff --git a/tools/make-partitioned-image.py b/tools/make-partitioned-image.py new file mode 100644 index 0000000..3da57bc --- /dev/null +++ b/tools/make-partitioned-image.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Assemble an MBR-partitioned disk image from N FAT32 partitions — the danos +multi-volume test disk. + +Pure Python 3 stdlib (no mtools / parted). Each partition is a real FAT32 +filesystem produced by make-fat-image.py, laid out behind a classic MBR so the +danos partition prober (partition.allVolumes) walks the table and the volume +manager spawns one confined filesystem per partition — several volumes sharing +ONE block channel, each clamped to its own LBA range. That shared-channel, +per-partition path is what a single stick with two partitions exercises and a +pair of single-volume sticks does not. + + make-partitioned-image.py [ ]... + +Each partition is an empty FAT32 with the given volume serial (its /volumes/ +fat- content id). Partitions are 1-MiB aligned; the MBR marks each +type 0x0C (FAT32 LBA). At most four (an MBR holds four primaries). +""" + +import os +import struct +import subprocess +import sys +import tempfile + +SECTOR = 512 +ALIGN = 2048 # sectors (1 MiB) — standard partition alignment, and the gap the MBR sits in +MBR_TYPE_FAT32_LBA = 0x0C +MAX_PRIMARY_PARTITIONS = 4 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def align_up(sectors, to=ALIGN): + return (sectors + to - 1) // to * to + + +def main(argv): + if len(argv) < 4 or (len(argv) - 2) % 2 != 0: + sys.exit("usage: make-partitioned-image.py [ ]...") + out_path = argv[1] + specs = [(argv[i], int(argv[i + 1])) for i in range(2, len(argv), 2)] + if len(specs) > MAX_PRIMARY_PARTITIONS: + sys.exit(f"error: an MBR holds at most {MAX_PRIMARY_PARTITIONS} primary partitions") + + # Generate each partition's FAT32 image, then place it at its aligned start. + partitions = [] # (start_sector, sector_count, bytes) + cursor = ALIGN # leave the first 1 MiB for the MBR + alignment gap + with tempfile.TemporaryDirectory() as tmp: + for idx, (serial, size_mib) in enumerate(specs): + part_path = os.path.join(tmp, f"p{idx}.img") + subprocess.run( + [sys.executable, os.path.join(HERE, "make-fat-image.py"), + "--serial", serial, "--label", f"DATA{idx}", + part_path, str(size_mib)], + check=True, stdout=subprocess.DEVNULL) + with open(part_path, "rb") as handle: + data = handle.read() + count = len(data) // SECTOR + partitions.append((cursor, count, data)) + cursor = align_up(cursor + count) + + total_sectors = cursor + disk = bytearray(total_sectors * SECTOR) + # The MBR: a disk signature, one partition entry per FAT partition, 0x55AA. + # No boot code (this disk is data, never booted); danos's mount() sees the + # signature but no BPB at LBA 0 and takes the MBR-walk path. + struct.pack_into("