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:
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user