volume-manager: the id-path deriver + volumes.csv override (S2)

NEW volume-map.zig: idString(identity) renders a volume's content identity into
its stable mount id-string — gpt-<32hex>, fat-<8hex>, mbr-<sig>-<index> — the
token whose default mount path is /volumes/<id>, 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>). 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).
This commit is contained in:
Daniel Samson
2026-08-09 23:55:08 +01:00
parent 3fb8a9b96f
commit 5bfdb75e12
2 changed files with 148 additions and 0 deletions
+11
View File
@@ -40,4 +40,15 @@ pub fn build(b: *std.Build) void {
}), }),
}); });
test_step.dependOn(&b.addRunArtifact(filesystem_map_tests).step); 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);
} }
@@ -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/<id>`. 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/<id>`. 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/<id>` 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
}