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);
|
||||
|
||||
// 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