1528c9b6
refactor: one owner for "which file is the key"
a73x 2026-08-29 10:01
Commit message
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"); |