a73x

d1572590

refactor: spell the host grammar once, free the key path once

a73x   2026-09-01 13:23

Commit message
refactor: spell the host grammar once, free the key path once

hosts.zig exports the two spelling prefixes every writer and reader
now goes through, so spellingCap derives its bound instead of leaning
on both prefixes measuring seven bytes. xdg.KeyResolution.deinit
states the borrow/own split once; Target.fromSpec keeps its hand-run
frees because it hands the .default path onward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/cli/main.zig
Old New
@@ -473,16 +473,11 @@ fn run(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8) !u8
473 }; 473 };
474 // Resolve `--key`, then `MUX_KEY_FILE`, then the default path, matching 474 // Resolve `--key`, then `MUX_KEY_FILE`, then the default path, matching
475 // direct QUIC clients. Only QUIC-enabled startup requires a key. 475 // direct QUIC clients. Only QUIC-enabled startup requires a key.
476 var key_owned: ?[]const u8 = null; 476 const key_res = try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)));
477 defer if (key_owned) |p| alloc.free(p); 477 defer key_res.deinit(alloc);
478 const key_path = switch (try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)))) { 478 const key_path = switch (key_res) {
479 .given => |g| g, 479 .given, .default => |p| p,
480 .default => |p| blk: {
481 key_owned = p;
482 break :blk p;
483 },
484 .missing => |p| { 480 .missing => |p| {
485 defer alloc.free(p);
486 std.debug.print( 481 std.debug.print(
487 "mux d: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n", 482 "mux d: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n",
488 .{p}, 483 .{p},
src/cli/mux_main.zig
Old New
@@ -176,8 +176,8 @@ const ClientArguments = struct {
176 /// target so `parseArgs` can reject multiple transports. An empty 176 /// target so `parseArgs` can reject multiple transports. An empty
177 /// `quic://` target is invalid immediately. 177 /// `quic://` target is invalid immediately.
178 pub fn positional(self: *ClientArguments, word: []const u8) bool { 178 pub fn positional(self: *ClientArguments, word: []const u8) bool {
179 if (std.mem.startsWith(u8, word, "quic://")) { 179 if (std.mem.startsWith(u8, word, hosts.quic_prefix)) {
180 const host_port = word["quic://".len..]; 180 const host_port = word[hosts.quic_prefix.len..];
181 if (host_port.len == 0) return false; 181 if (host_port.len == 0) return false;
182 self._quic = host_port; 182 self._quic = host_port;
183 } else self._host = word; 183 } else self._host = word;
@@ -276,10 +276,7 @@ pub fn main(args: []const [:0]const u8) !u8 {
276 switch (parsed) { 276 switch (parsed) {
277 .quic => |q| { 277 .quic => |q| {
278 const res = try xdg.resolveKeyPath(alloc, q.key); 278 const res = try xdg.resolveKeyPath(alloc, q.key);
279 defer switch (res) { 279 defer res.deinit(alloc);
280 .given => {},
281 .default, .missing => |p| alloc.free(p),
282 };
283 const key_path = switch (res) { 280 const key_path = switch (res) {
284 .given, .default => |p| p, 281 .given, .default => |p| p,
285 .missing => |p| { 282 .missing => |p| {
@@ -332,8 +329,8 @@ pub fn main(args: []const [:0]const u8) !u8 {
332 /// Return true when the hosts file includes the local socket but no process is 329 /// Return true when the hosts file includes the local socket but no process is
333 /// currently answering on it. 330 /// currently answering on it.
334 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool { 331 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool {
335 var buf: [std.fs.max_path_bytes + "--sock ".len]u8 = undefined; 332 var buf: [std.fs.max_path_bytes + hosts.sock_prefix.len]u8 = undefined;
336 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false; 333 const line = std.fmt.bufPrint(&buf, hosts.sock_prefix ++ "{s}", .{sock}) catch return false;
337 return h.has(line) and !sockpath.answers(sock); 334 return h.has(line) and !sockpath.answers(sock);
338 } 335 }
339 336
src/cli/muxa.zig
Old New
@@ -1010,6 +1010,10 @@ fn openQuicConn(
1010 // so every client selects the same credential. 1010 // so every client selects the same credential.
1011 const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env))) catch |e| 1011 const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env))) catch |e|
1012 return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) }; 1012 return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) };
1013 // `key_path` is read here and nowhere after: `Key.load` copies the bytes
1014 // it needs and the connection keeps no path. Freeing on the way out is a
1015 // no-op under this arena, and keeps the site correct under any allocator.
1016 defer res.deinit(alloc);
1013 const key_path = switch (res) { 1017 const key_path = switch (res) {
1014 .given, .default => |p| p, 1018 .given, .default => |p| p,
1015 // Include the missing path so callers know which credential to create. 1019 // Include the missing path so callers know which credential to create.
src/client/client.zig
Old New
@@ -1046,11 +1046,11 @@ pub fn wallSpelling(out: []u8, target: Target, name: []const u8) SpellingError![
1046 const written = switch (target) { 1046 const written = switch (target) {
1047 // The sock spelling carries its own flag INSIDE the string — one 1047 // The sock spelling carries its own flag INSIDE the string — one
1048 // argv element per tile is the grammar, not two. 1048 // argv element per tile is the grammar, not two.
1049 .sock => |path| std.fmt.bufPrint(out, "--sock {s}#{s}", .{ path, name }), 1049 .sock => |path| std.fmt.bufPrint(out, hosts.sock_prefix ++ "{s}#{s}", .{ path, name }),
1050 .via => return error.NoSpelling, 1050 .via => return error.NoSpelling,
1051 // `host_port` is what the user typed after `quic://`, port and 1051 // `host_port` is what the user typed after the quic prefix, port and
1052 // all, so it is written back out unexamined. 1052 // all, so it is written back out unexamined.
1053 .quic => |q| std.fmt.bufPrint(out, "quic://{s}#{s}", .{ q.host_port, name }), 1053 .quic => |q| std.fmt.bufPrint(out, hosts.quic_prefix ++ "{s}#{s}", .{ q.host_port, name }),
1054 // The bare-HOST form: the wall re-runs the ssh→QUIC handoff from 1054 // The bare-HOST form: the wall re-runs the ssh→QUIC handoff from
1055 // the host word, exactly as this client did. 1055 // the host word, exactly as this client did.
1056 .hand => |h| std.fmt.bufPrint(out, "{s}#{s}", .{ h.host, name }), 1056 .hand => |h| std.fmt.bufPrint(out, "{s}#{s}", .{ h.host, name }),
@@ -1058,16 +1058,19 @@ pub fn wallSpelling(out: []u8, target: Target, name: []const u8) SpellingError![
1058 return written catch error.NoSpace; 1058 return written catch error.NoSpace;
1059 } 1059 }
1060 1060
1061 /// The longest spelling this target can produce. Both grammar prefixes 1061 /// The longest spelling this target can produce. Derived from the longer of
1062 /// ("--sock ", "quic://") are seven bytes, so one number covers them. 1062 /// the two grammar prefixes rather than from a number: the bare-HOST form
1063 /// has no prefix at all, so the widest case is always one of those two, and
1064 /// a cap taken from them cannot fall short when a literal is re-spelled.
1063 pub fn spellingCap(target: Target) usize { 1065 pub fn spellingCap(target: Target) usize {
1066 const prefix_max = @max(hosts.sock_prefix.len, hosts.quic_prefix.len);
1064 const operand: usize = switch (target) { 1067 const operand: usize = switch (target) {
1065 .sock => |path| path.len, 1068 .sock => |path| path.len,
1066 .via => 0, 1069 .via => 0,
1067 .quic => |q| q.host_port.len, 1070 .quic => |q| q.host_port.len,
1068 .hand => |h| h.host.len, 1071 .hand => |h| h.host.len,
1069 }; 1072 };
1070 return "--sock ".len + operand + 1 + proto.session_name_max; 1073 return prefix_max + operand + 1 + proto.session_name_max;
1071 } 1074 }
1072 1075
1073 // The grid a birth asks for: `main.DaemonArguments`'s own default, the size `mux d start` 1076 // The grid a birth asks for: `main.DaemonArguments`'s own default, the size `mux d start`
src/client/hosts.zig
Old New
@@ -29,15 +29,22 @@ pub fn hasBadSpelling(word: []const u8) bool {
29 return false; 29 return false;
30 } 30 }
31 31
32 /// The two prefixes that ARE the grammar. Every writer and every reader —
33 /// argv, the file, the picker, a tile's label — spells them through these,
34 /// so changing a literal (or its length) moves all of them at once instead
35 /// of leaving one site matching a prefix nothing writes any more.
36 pub const sock_prefix = "--sock ";
37 pub const quic_prefix = "quic://";
38
32 pub fn parse(line: []const u8) ParseError!Spec { 39 pub fn parse(line: []const u8) ParseError!Spec {
33 for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; 40 for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte;
34 if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession; 41 if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession;
35 if (std.mem.startsWith(u8, line, "--sock ")) { 42 if (std.mem.startsWith(u8, line, sock_prefix)) {
36 const p = line["--sock ".len..]; 43 const p = line[sock_prefix.len..];
37 return if (p.len == 0) error.EmptySpec else .{ .sock = p }; 44 return if (p.len == 0) error.EmptySpec else .{ .sock = p };
38 } 45 }
39 if (std.mem.startsWith(u8, line, "quic://")) { 46 if (std.mem.startsWith(u8, line, quic_prefix)) {
40 const h = line["quic://".len..]; 47 const h = line[quic_prefix.len..];
41 return if (h.len == 0) error.EmptySpec else .{ .quic = h }; 48 return if (h.len == 0) error.EmptySpec else .{ .quic = h };
42 } 49 }
43 if (line.len == 0) return error.EmptySpec; 50 if (line.len == 0) return error.EmptySpec;
@@ -49,7 +56,7 @@ pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Alloca
49 56
50 /// Every mouth's rule: a host starting with a dash is a mistyped flag. 57 /// Every mouth's rule: a host starting with a dash is a mistyped flag.
51 pub fn flagLike(target: []const u8) bool { 58 pub fn flagLike(target: []const u8) bool {
52 return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, "--sock "); 59 return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, sock_prefix);
53 } 60 }
54 61
55 /// Both `--sock` dialects reach one spelling: one parser, one line format. 62 /// Both `--sock` dialects reach one spelling: one parser, one line format.
@@ -62,7 +69,7 @@ pub fn spellingFromArgv(
62 // A trailing `--sock` names no path: a usage mistake, reported as 69 // A trailing `--sock` names no path: a usage mistake, reported as
63 // one rather than read off the end of argv. 70 // one rather than read off the end of argv.
64 if (i + 1 >= args.len) return error.MissingSockPath; 71 if (i + 1 >= args.len) return error.MissingSockPath;
65 return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 }; 72 return .{ .spelling = try std.fmt.allocPrint(alloc, sock_prefix ++ "{s}", .{args[i + 1]}), .consumed = 2 };
66 } 73 }
67 // A wall takes hosts, and no host starts with a dash. Left to fall through, 74 // A wall takes hosts, and no host starts with a dash. Left to fall through,
68 // `mux hosts add -A box` becomes a host named `-A` that fails to resolve far 75 // `mux hosts add -A box` becomes a host named `-A` that fails to resolve far
src/tui/wall_host.zig
Old New
@@ -406,9 +406,9 @@ pub fn applyHostList(w: Wall, hi: usize) void {
406 /// `mux hosts` prints have to read like the one that would have named it. 406 /// `mux hosts` prints have to read like the one that would have named it.
407 pub fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 { 407 pub fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 {
408 return switch (target) { 408 return switch (target) {
409 .sock => |p| try std.fmt.allocPrint(alloc, "--sock {s}", .{p}), 409 .sock => |p| try std.fmt.allocPrint(alloc, hosts.sock_prefix ++ "{s}", .{p}),
410 .hand => |h| try alloc.dupe(u8, h.host), 410 .hand => |h| try alloc.dupe(u8, h.host),
411 .quic => |q| try std.fmt.allocPrint(alloc, "quic://{s}", .{q.host_port}), 411 .quic => |q| try std.fmt.allocPrint(alloc, hosts.quic_prefix ++ "{s}", .{q.host_port}),
412 // `--via` has no form in that grammar — an arbitrary command is not 412 // `--via` has no form in that grammar — an arbitrary command is not
413 // an address — so the label is honest and is not a spelling. 413 // an address — so the label is honest and is not a spelling.
414 .via => |c| try std.fmt.allocPrint(alloc, "--via {s}", .{c}), 414 .via => |c| try std.fmt.allocPrint(alloc, "--via {s}", .{c}),
src/xdg.zig
Old New
@@ -25,6 +25,22 @@ pub const KeyResolution = union(enum) {
25 /// (the agent answers in JSON) and both need the path; the hub folds 25 /// (the agent answers in JSON) and both need the path; the hub folds
26 /// it into a bare MissingKey. 26 /// it into a bare MissingKey.
27 missing: []const u8, 27 missing: []const u8,
28
29 /// Release what this resolution OWNS: nothing for `.given`, which
30 /// borrows from argv or the environment, and the path for the two
31 /// XDG-derived arms. The borrow/own split is stated once here so a
32 /// caller never re-derives which arm allocated and frees the wrong one.
33 ///
34 /// Only for callers that keep the path no longer than the resolution.
35 /// A caller that hands the `.default` path onward as owned memory —
36 /// `client.Target.fromSpec` stores it in the target it returns — must
37 /// take that arm apart itself instead.
38 pub fn deinit(self: KeyResolution, alloc: std.mem.Allocator) void {
39 switch (self) {
40 .given => {},
41 .default, .missing => |p| alloc.free(p),
42 }
43 }
28 }; 44 };
29 45
30 /// ONE owner: a drift here would mean two binaries disagreeing about which 46 /// ONE owner: a drift here would mean two binaries disagreeing about which