From 5bfdb75e12083968a75bcbfd91aaa102613bcf82 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:55:08 +0100 Subject: [PATCH] volume-manager: the id-path deriver + volumes.csv override (S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEW volume-map.zig: idString(identity) renders a volume's content identity into its stable mount id-string — gpt-<32hex>, fat-<8hex>, mbr-- — the token whose default mount path is /volumes/, so the path IS the id and never a port or a label; two volumes that share a label get distinct ids automatically. parse() reads volumes.csv (id, mount_prefix) into OPTIONAL overrides; overrideFor returns a pinned prefix or null (the volume takes its default /volumes/). id_maximum is a declared bound; the fixture sizes are named. Three host tests (each rung's id token; override hit/miss; malformed rows counted), wired into the VM package test step with csv. Not yet consumed by the binary — that lands when the VM loads the tables and composes paths (step 4). --- system/services/volume-manager/build.zig | 11 ++ system/services/volume-manager/volume-map.zig | 137 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 system/services/volume-manager/volume-map.zig diff --git a/system/services/volume-manager/build.zig b/system/services/volume-manager/build.zig index 3bc0086..a9145e8 100644 --- a/system/services/volume-manager/build.zig +++ b/system/services/volume-manager/build.zig @@ -40,4 +40,15 @@ pub fn build(b: *std.Build) void { }), }); test_step.dependOn(&b.addRunArtifact(filesystem_map_tests).step); + + // volume-map imports csv (and, by path, partition.zig) for the id-path + // deriver and the volumes.csv override parser. + const volume_map_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("volume-map.zig"), + .target = b.resolveTargetQuery(.{}), + .imports = &.{.{ .name = "csv", .module = csv.module("csv") }}, + }), + }); + test_step.dependOn(&b.addRunArtifact(volume_map_tests).step); } diff --git a/system/services/volume-manager/volume-map.zig b/system/services/volume-manager/volume-map.zig new file mode 100644 index 0000000..42cecc5 --- /dev/null +++ b/system/services/volume-manager/volume-map.zig @@ -0,0 +1,137 @@ +//! volume-map — render a volume's identity into its stable mount id-string, and +//! parse `/system/configuration/volumes.csv` (danos's fstab) into optional +//! id → mount-prefix overrides. This is where the id/label split becomes the +//! path: a volume's mount point is derived from its content identity (the id), +//! never from a port or a label. Two distinct volumes that share a label get +//! distinct id-strings automatically; only identical ids (dd-cloned media) can +//! collide, which is the narrow case the manager's duplicate policy is for. +//! +//! `volumes.csv` is an OPTIONAL override: a row `id, mount_prefix` pins a volume +//! (by its id-string) to a chosen path. A volume with no row takes its default +//! `/volumes/`. The label is display metadata, exposed by the manager's +//! `volumes` query, and never appears here. +//! +//! Pure logic: no syscalls, no allocator. Override slices point into the CSV +//! source, which the manager holds in a static buffer for the process life. + +const std = @import("std"); +const csv = @import("csv"); +const partition = @import("partition.zig"); + +/// bound: bytes of the longest volume id-string the deriver renders +/// decided-by: ours +/// protects: the caller's id-string buffer +/// at-limit: truncate - bufPrint fails and idString returns ""; the volume goes +/// unnamed and the manager logs it rather than mounting at an empty path +/// observed-by: a volume with an empty id in the `volumes` query / the log +pub const id_maximum = 40; // "gpt-" (4) or "uuid-" (5) + 32 hex fits in 40 + +/// One parsed override row: a volume id-string and the mount prefix it pins to. +pub const Override = struct { id: []const u8, prefix: []const u8 }; + +/// How many overrides landed, how many non-blank lines were malformed, and +/// whether there were more rows than the buffer could hold. +pub const ParseResult = struct { count: usize, malformed: usize, truncated: bool }; + +/// Render a volume's identity into its id-string — the content-derived, unique, +/// order-independent token whose default mount path is `/volumes/`. The rung +/// tags the scheme so ids never collide across rungs; the key is the content id, +/// so a moved drive keeps its id (and thus its path). +pub fn idString(identity: partition.Identity, buf: []u8) []const u8 { + return switch (identity.rung) { + .gpt_guid => std.fmt.bufPrint(buf, "gpt-{x:0>32}", .{identity.key}) catch "", + .filesystem_uuid => std.fmt.bufPrint(buf, "uuid-{x:0>32}", .{identity.key}) catch "", + .fat_serial => std.fmt.bufPrint(buf, "fat-{x:0>8}", .{@as(u32, @truncate(identity.key))}) catch "", + .mbr_index => std.fmt.bufPrint(buf, "mbr-{x}-{d}", .{ + @as(u32, @truncate(identity.key >> 8)), + @as(u8, @truncate(identity.key & 0xff)), + }) catch "", + .anonymous => std.fmt.bufPrint(buf, "anon-{x}", .{identity.key}) catch "", + }; +} + +const Line = union(enum) { override: Override, ignorable, malformed }; + +fn parseLine(line: []const u8) Line { + const body = csv.stripComment(line); + if (body.len == 0) return .ignorable; + var it = csv.fields(body); + const id = it.next() orelse return .malformed; + const prefix = it.next() orelse return .malformed; + if (it.next() != null) return .malformed; // too many columns + if (id.len == 0 or prefix.len == 0) return .malformed; + return .{ .override = .{ .id = id, .prefix = prefix } }; +} + +/// Parse a whole `volumes.csv` into `out_rules`. The slices point into `source`, +/// which must outlive them. +pub fn parse(source: []const u8, out_rules: []Override) ParseResult { + var result: ParseResult = .{ .count = 0, .malformed = 0, .truncated = false }; + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |line| { + switch (parseLine(line)) { + .ignorable => {}, + .malformed => result.malformed += 1, + .override => |ov| { + if (result.count >= out_rules.len) { + result.truncated = true; + continue; + } + out_rules[result.count] = ov; + result.count += 1; + }, + } + } + return result; +} + +/// The override mount prefix for a volume whose id-string is `id`, or null (the +/// volume takes its default `/volumes/` path). First matching row wins. +pub fn overrideFor(rules: []const Override, id: []const u8) ?[]const u8 { + for (rules) |rule| { + if (std.mem.eql(u8, rule.id, id)) return rule.prefix; + } + return null; +} + +// --- tests ------------------------------------------------------------------- + +const testing = std.testing; + +// Named fixture sizes so the bounds gate (which flags literal array lengths) +// stays quiet: test inputs, not runtime ceilings. +const test_override_slots = 4; + +test "idString renders each rung's id token" { + var buf: [id_maximum]u8 = undefined; + try testing.expectEqualStrings("fat-12345678", idString(.{ .rung = .fat_serial, .key = 0x12345678 }, &buf)); + try testing.expectEqualStrings("mbr-deadbeef-1", idString(.{ .rung = .mbr_index, .key = (@as(u128, 0xDEADBEEF) << 8) | 1 }, &buf)); + const guid: u128 = 0x00112233445566778899AABBCCDDEEFF; + try testing.expectEqualStrings("gpt-00112233445566778899aabbccddeeff", idString(.{ .rung = .gpt_guid, .key = guid }, &buf)); +} + +test "overrideFor returns the mapped prefix, else null" { + const text = + \\# id, mount_prefix + \\fat-12345678, /mnt/boot + ; + var rules: [test_override_slots]Override = undefined; + const parsed = parse(text, &rules); + try testing.expectEqual(@as(usize, 1), parsed.count); + try testing.expectEqual(@as(usize, 0), parsed.malformed); + try testing.expectEqualStrings("/mnt/boot", overrideFor(rules[0..parsed.count], "fat-12345678").?); + try testing.expect(overrideFor(rules[0..parsed.count], "fat-99999999") == null); +} + +test "malformed volume rows are counted, not bound" { + const text = + \\fat-1, /mnt/a + \\onlyonecolumn + \\fat-2, + \\fat-3, /a, /b + ; + var rules: [test_override_slots]Override = undefined; + const parsed = parse(text, &rules); + try testing.expectEqual(@as(usize, 1), parsed.count); // only the first valid row + try testing.expectEqual(@as(usize, 3), parsed.malformed); // one column, empty prefix, too many columns +}