a73x

1528c9b6

refactor: one owner for "which file is the key"

a73x   2026-08-29 10:01

Commit message
refactor: one owner for "which file is the key"

`mux d --quic` resolved --key / MUX_KEY_FILE / the default-if-present by
hand, and `Server.endpointPortFrom` did it a third time. Both now ask
`xdg.resolveKeyPath` — the owner every client already uses — so the
daemon and its clients cannot come to disagree about which file "the key"
names. `main.pickKey` (a wrapper over `xdg.pickKey` plus a fallback) is
gone; the server, a layer-0 `xdg` importer already, gets the pure twin
`xdg.resolveKeyPathFrom(alloc, given, xdg_config_home, home)`.

Every message is unchanged, in both spellings: the `--quic` triad line
still names the resolved default path, and endpoint_req still tells "no
key at PATH" apart from "no HOME to find one under".

One corner does change, toward the client's reading: `mux d --quic --key
P` on a box with neither HOME nor XDG_CONFIG_HOME used to fail with
NoHome (the default was resolved before the flag was consulted) and now
takes P. A named key is the user asserting it.

`main.pickKey`'s test dies with it — xdg.pickKey's test already owns
"flag beats env, empty is unset either way". Its other half, the
three-way, is now pinned in xdg.zig by "resolveKeyPathFrom: a named key
is taken as named, an absent default is `missing`": given without a HOME,
the same path as `.missing` then `.default` once the file exists, NoHome
with nothing to build under.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/cli/main.zig
Old New
@@ -36,13 +36,6 @@ const usage =
36 \\ 36 \\
37 ; 37 ;
38 38
39 /// `--key` beats `MUX_KEY_FILE` beats the default path — more specific
40 /// intent sits higher. Split out so the order is testable without a
41 /// daemon.
42 fn pickKey(flag: ?[]const u8, env: ?[]const u8, default_if_present: ?[]const u8) ?[]const u8 {
43 return xdg.pickKey(flag, env) orelse default_if_present;
44 }
45
46 const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; 39 const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade };
47 40
48 /// One row per verb. Adding a subcommand used to mean editing the usage 41 /// One row per verb. Adding a subcommand used to mean editing the usage
@@ -562,21 +555,27 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
562 ); 555 );
563 return 1; 556 return 1;
564 }; 557 };
565 // --key, then MUX_KEY_FILE, then the default path if it exists. 558 // --key, then MUX_KEY_FILE, then the default path if it exists —
566 // The default is only reached for by a daemon that was asked for 559 // `xdg.resolveKeyPath`, the same order and the same three-way answer
567 // QUIC, so an absent one is a message rather than a silent skip — 560 // every other binary's `quic://` gets. The default is only reached
568 // and the message can always name the path because it is resolved 561 // for by a daemon that was asked for QUIC, so an absent one is a
569 // whether or not it is the one chosen. 562 // message rather than a silent skip.
570 const dflt = try xdg.keyPath(alloc); 563 var key_owned: ?[]const u8 = null;
571 defer alloc.free(dflt); 564 defer if (key_owned) |p| alloc.free(p);
572 const dflt_if_present: ?[]const u8 = 565 const key_path = switch (try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)))) {
573 if (std.fs.cwd().access(dflt, .{})) |_| dflt else |_| null; 566 .given => |g| g,
574 const key_path = pickKey(o.key, std.posix.getenv(xdg.key_env), dflt_if_present) orelse { 567 .default => |p| blk: {
575 std.debug.print( 568 key_owned = p;
576 "mux d: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n", 569 break :blk p;
577 .{dflt}, 570 },
578 ); 571 .missing => |p| {
579 return 2; 572 defer alloc.free(p);
573 std.debug.print(
574 "mux d: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n",
575 .{p},
576 );
577 return 2;
578 },
580 }; 579 };
581 quic_key = quic.Key.load(key_path) catch |err| switch (err) { 580 quic_key = quic.Key.load(key_path) catch |err| switch (err) {
582 // The three the user can act on, in quic.zig's words — the one 581 // The three the user can act on, in quic.zig's words — the one
@@ -1097,10 +1096,9 @@ fn announceKey(alloc: std.mem.Allocator) ?quic.Key {
1097 } 1096 }
1098 1097
1099 /// What the key resolution decided and why, separated from the printing of 1098 /// What the key resolution decided and why, separated from the printing of
1100 /// it. `pickKey` above exists for the same reason and the daemon's 1099 /// it. The daemon's `endpointPortFrom` is this decision's other half: the
1101 /// `endpointPortFrom` is this decision's other half: the two must agree on 1100 /// two must agree on which file "the key" names, and an order that quietly
1102 /// which file "the key" names, and an order that quietly inverted would 1101 /// inverted would show up only as a client authenticating to nothing.
1103 /// otherwise show up only as a client authenticating to nothing.
1104 const KeyResult = union(enum) { 1102 const KeyResult = union(enum) {
1105 key: quic.Key, 1103 key: quic.Key,
1106 /// No MUX_KEY_FILE and no HOME to build a default under: there is not 1104 /// No MUX_KEY_FILE and no HOME to build a default under: there is not
@@ -1499,17 +1497,6 @@ test "parseArgs: keygen takes no flags" {
1499 try std.testing.expect(parse(&.{ "d", "keygen", "--sock", "/x" }).err == .unknown_arg); 1497 try std.testing.expect(parse(&.{ "d", "keygen", "--sock", "/x" }).err == .unknown_arg);
1500 } 1498 }
1501 1499
1502 test "pickKey: --key beats MUX_KEY_FILE beats the default path" {
1503 try std.testing.expectEqualStrings("/flag", pickKey("/flag", "/env", "/dflt").?);
1504 try std.testing.expectEqualStrings("/env", pickKey(null, "/env", "/dflt").?);
1505 try std.testing.expectEqualStrings("/dflt", pickKey(null, null, "/dflt").?);
1506 // Nothing named anywhere is the triad-message case, not a silent skip.
1507 try std.testing.expect(pickKey(null, null, null) == null);
1508 // Set but empty is unset, for the flag as well as the variable: an
1509 // empty path could only be a mistake, and Key.load would blame "".
1510 try std.testing.expectEqualStrings("/dflt", pickKey("", "", "/dflt").?);
1511 }
1512
1513 test "parseArgs: start takes run's flags" { 1500 test "parseArgs: start takes run's flags" {
1514 const r = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "--cols", "100" }); 1501 const r = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "--cols", "100" });
1515 try std.testing.expect(r == .ok); 1502 try std.testing.expect(r == .ok);
src/server/server.zig
Old New
@@ -1603,36 +1603,39 @@ pub const Server = struct {
1603 home: ?[]const u8, 1603 home: ?[]const u8,
1604 ) u16 { 1604 ) u16 {
1605 if (self.quicListener()) |q| return boundUdpPort(q); 1605 if (self.quicListener()) |q| return boundUdpPort(q);
1606 // "Set but empty" is unset, the same reading main.zig's envKey gives 1606 // The same three-way answer `mux d --quic` and every client get, so
1607 // it: an empty path could only ever be a mistake. 1607 // this cannot come to disagree with them about which file "the key"
1608 const env: ?[]const u8 = if (env_key) |v| 1608 // names. `xdg.pickKey` reads "set but empty" as unset.
1609 (if (v.len == 0) null else v)
1610 else
1611 null;
1612 var owned: ?[]const u8 = null; 1609 var owned: ?[]const u8 = null;
1613 defer if (owned) |p| self.alloc.free(p); 1610 defer if (owned) |p| self.alloc.free(p);
1614 const key_path = env orelse blk: { 1611 const key_path = switch (xdg.resolveKeyPathFrom(
1615 const dflt = xdg.keyPathFrom(self.alloc, xdg_config_home, home) catch break :blk null; 1612 self.alloc,
1616 owned = dflt; 1613 xdg.pickKey(null, env_key),
1617 break :blk if (std.fs.cwd().access(dflt, .{})) |_| dflt else |_| @as(?[]const u8, null); 1614 xdg_config_home,
1618 } orelse { 1615 home,
1619 // `owned` is still live: the defer above fires when this function 1616 ) catch {
1620 // returns, not when the block ends, so the path that was resolved 1617 // keyPathFrom refused, which means there was no HOME to build a
1621 // and found absent can be named. Its absence is a different 1618 // default under in the first place — a different failure from an
1622 // failure and says so — keyPathFrom refused, which means there 1619 // absent key, and it says so.
1623 // was no HOME to build a default under in the first place. 1620 std.debug.print(
1624 if (owned) |dflt| { 1621 "mux d: endpoint_req: no key to listen with and no HOME to find one under (run `mux d keygen`)\n",
1622 .{},
1623 );
1624 return 0;
1625 }) {
1626 .given => |g| g,
1627 .default => |p| blk: {
1628 owned = p;
1629 break :blk p;
1630 },
1631 .missing => |p| {
1632 owned = p;
1625 std.debug.print( 1633 std.debug.print(
1626 "mux d: endpoint_req: no key at {s} (run `mux d keygen`)\n", 1634 "mux d: endpoint_req: no key at {s} (run `mux d keygen`)\n",
1627 .{dflt}, 1635 .{p},
1628 ); 1636 );
1629 } else { 1637 return 0;
1630 std.debug.print( 1638 },
1631 "mux d: endpoint_req: no key to listen with and no HOME to find one under (run `mux d keygen`)\n",
1632 .{},
1633 );
1634 }
1635 return 0;
1636 }; 1639 };
1637 // The same refusals main.zig gives the --quic path, in the same 1640 // The same refusals main.zig gives the --quic path, in the same
1638 // words — literally the same, since `quic.keyRefusalBody` owns them 1641 // words — literally the same, since `quic.keyRefusalBody` owns them
src/xdg.zig
Old New
@@ -33,8 +33,14 @@ pub const KeyResolution = union(enum) {
33 /// ONE owner: a drift here would mean two binaries disagreeing about which 33 /// ONE owner: a drift here would mean two binaries disagreeing about which
34 /// key a `quic://` target authenticates with. 34 /// key a `quic://` target authenticates with.
35 pub fn resolveKeyPath(alloc: std.mem.Allocator, given: ?[]const u8) !KeyResolution { 35 pub fn resolveKeyPath(alloc: std.mem.Allocator, given: ?[]const u8) !KeyResolution {
36 return resolveKeyPathFrom(alloc, given, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"));
37 }
38
39 /// The pure twin, for the daemon: `Server` is handed its environment
40 /// because tests cannot setenv.
41 pub fn resolveKeyPathFrom(alloc: std.mem.Allocator, given: ?[]const u8, xdg_config_home: ?[]const u8, home: ?[]const u8) !KeyResolution {
36 if (given) |g| return .{ .given = g }; 42 if (given) |g| return .{ .given = g };
37 const p = try keyPath(alloc); 43 const p = try keyPathFrom(alloc, xdg_config_home, home);
38 std.fs.cwd().access(p, .{}) catch return .{ .missing = p }; 44 std.fs.cwd().access(p, .{}) catch return .{ .missing = p };
39 return .{ .default = p }; 45 return .{ .default = p };
40 } 46 }
@@ -206,6 +212,36 @@ test "pickKey: the flag wins, and empty is unset either way" {
206 try std.testing.expectEqual(@as(?[]const u8, null), pickKey("", "/env")); 212 try std.testing.expectEqual(@as(?[]const u8, null), pickKey("", "/env"));
207 } 213 }
208 214
215 test "resolveKeyPathFrom: a named key is taken as named, an absent default is `missing`" {
216 const a = std.testing.allocator;
217 const testtmp = @import("testtmp");
218 var tmp = try testtmp.TmpDir.make();
219 defer tmp.cleanup();
220
221 // Named, so unchecked — and answerable with no HOME to build a default
222 // under, which is why the daemon may name a key on a box that has none.
223 const given = try resolveKeyPathFrom(a, "/flag", null, null);
224 try std.testing.expectEqualStrings("/flag", given.given);
225
226 const absent = try resolveKeyPathFrom(a, null, tmp.path(), null);
227 defer a.free(absent.missing);
228 var buf: [256]u8 = undefined;
229 try std.testing.expectEqualStrings(
230 try std.fmt.bufPrint(&buf, "{s}/mux/key", .{tmp.path()}),
231 absent.missing,
232 );
233
234 // Same path, different arm once the file is there: every caller's
235 // refusal names the path it looked at, so the two must not diverge.
236 try makePrivateParent(absent.missing);
237 try writeNewKey(absent.missing);
238 const present = try resolveKeyPathFrom(a, null, tmp.path(), null);
239 defer a.free(present.default);
240 try std.testing.expectEqualStrings(absent.missing, present.default);
241
242 try std.testing.expectError(error.NoHome, resolveKeyPathFrom(a, null, null, null));
243 }
244
209 test "logPathFrom: same shape against XDG_STATE_HOME" { 245 test "logPathFrom: same shape against XDG_STATE_HOME" {
210 const a = std.testing.allocator; 246 const a = std.testing.allocator;
211 const explicit = try logPathFrom(a, "/tmp/state", "/home/u"); 247 const explicit = try logPathFrom(a, "/tmp/state", "/home/u");