//! 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 "", .exfat_serial => std.fmt.bufPrint(buf, "exfat-{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("exfat-da7a0001", idString(.{ .rung = .exfat_serial, .key = 0xDA7A0001 }, &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 }