a73x

63b9dff2

feat: key resolution --key > MUX_KEY_FILE > ~/.config/mux/key, both binaries

a73x   2026-08-09 13:00

Commit message
feat: key resolution --key > MUX_KEY_FILE > ~/.config/mux/key, both binaries

docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -686,7 +686,7 @@ git commit -m "feat: port 4433 is the default on both ends of quic://"
686 - Modify: `src/main.zig` (`Usage` ~47, `parseArgs` ~123, `usageExit` ~136, `run()` ~204–239, tests ~395–410) 686 - Modify: `src/main.zig` (`Usage` ~47, `parseArgs` ~123, `usageExit` ~136, `run()` ~204–239, tests ~395–410)
687 - Modify: `src/mux_main.zig` (`ParseResult` ~25–36, parse ~100–104, `main` ~134–145, tests that mention `quic_without_key`) 687 - Modify: `src/mux_main.zig` (`ParseResult` ~25–36, parse ~100–104, `main` ~134–145, tests that mention `quic_without_key`)
688 688
689 - [ ] **Step 1: Write the failing parse tests** 689 - [x] **Step 1: Write the failing parse tests**
690 690
691 `src/main.zig`: in the existing `--quic and --key are both or neither` test, the line 691 `src/main.zig`: in the existing `--quic and --key are both or neither` test, the line
692 692
@@ -720,12 +720,12 @@ becomes (and retitle the test `"parseArgs: --key without --quic is refused; --qu
720 720
721 Keep the tests proving `--key` and `MUX_KEY_FILE` still arrive in `.quic.key` when given. 721 Keep the tests proving `--key` and `MUX_KEY_FILE` still arrive in `.quic.key` when given.
722 722
723 - [ ] **Step 2: Run to verify failure** 723 - [x] **Step 2: Run to verify failure**
724 724
725 Run: `make test` 725 Run: `make test`
726 Expected: the edited tests fail against current behavior. 726 Expected: the edited tests fail against current behavior.
727 727
728 - [ ] **Step 3: Implement — muxd side** 728 - [x] **Step 3: Implement — muxd side**
729 729
730 `src/main.zig`: 730 `src/main.zig`:
731 - Delete `quic_without_key` from `Usage` and its `usageExit` arm. 731 - Delete `quic_without_key` from `Usage` and its `usageExit` arm.
@@ -764,7 +764,7 @@ fn envKey() ?[]const u8 {
764 764
765 **Note the wire-in:** `defer` on `default_key` must NOT free before `Key.load` uses `key_path` — the code above keeps `default_key` alive for the whole scope; do not "clean it up" into an early free. 765 **Note the wire-in:** `defer` on `default_key` must NOT free before `Key.load` uses `key_path` — the code above keeps `default_key` alive for the whole scope; do not "clean it up" into an early free.
766 766
767 - [ ] **Step 4: Implement — mux side** 767 - [x] **Step 4: Implement — mux side**
768 768
769 `src/mux_main.zig`: 769 `src/mux_main.zig`:
770 - Remove `.quic_without_key` from `ParseResult` and its arm in `main`'s switch. 770 - Remove `.quic_without_key` from `ParseResult` and its arm in `main`'s switch.
@@ -808,12 +808,12 @@ fn envKey() ?[]const u8 {
808 808
809 Add `const xdg = @import("xdg");` at the top. Update the usage text's key line: `quic://HOST[:PORT] uses --key FILE, MUX_KEY_FILE, or ~/.config/mux/key`. 809 Add `const xdg = @import("xdg");` at the top. Update the usage text's key line: `quic://HOST[:PORT] uses --key FILE, MUX_KEY_FILE, or ~/.config/mux/key`.
810 810
811 - [ ] **Step 5: Run tests, expect pass** 811 - [x] **Step 5: Run tests, expect pass**
812 812
813 Run: `make test` 813 Run: `make test`
814 Expected: pass. 814 Expected: pass.
815 815
816 - [ ] **Step 6: e2e — the refusals still refuse, hermetically** 816 - [x] **Step 6: e2e — the refusals still refuse, hermetically**
817 817
818 The Task 3 hermetic `XDG_CONFIG_HOME` is what keeps `refuse 2 --quic "127.0.0.1:$QPORT"` (e2e.sh:379) honest — there is a keygen'd key at the default path by now, so that refusal would stop refusing! Move the keygen scenario BELOW the refusal block, or point the refusals at a second empty config home. Do the latter — it keeps scenario order free: 818 The Task 3 hermetic `XDG_CONFIG_HOME` is what keeps `refuse 2 --quic "127.0.0.1:$QPORT"` (e2e.sh:379) honest — there is a keygen'd key at the default path by now, so that refusal would stop refusing! Move the keygen scenario BELOW the refusal block, or point the refusals at a second empty config home. Do the latter — it keeps scenario order free:
819 819
@@ -837,11 +837,11 @@ Also delete e2e.sh:380's `refuse 2 --key "$QKEY"`? **No** — `--key` without `-
837 Run: `make build && make e2e` 837 Run: `make build && make e2e`
838 Expected: pass. 838 Expected: pass.
839 839
840 - [ ] **Step 7: Mutation check (resolution order)** 840 - [x] **Step 7: Mutation check (resolution order)**
841 841
842 In muxd's `run()`, swap resolution to `envKey() orelse o.key` (env beats flag). `make test` must fail — if no unit test catches it, ADD one before proceeding: mux_main's parse test already pins `--key` beating env via `parseEnv(&.{ "mux", "quic://a:1", "--key", "/k" }, "/env")` expecting `/k`; write the equivalent if absent. Restore. 842 In muxd's `run()`, swap resolution to `envKey() orelse o.key` (env beats flag). `make test` must fail — if no unit test catches it, ADD one before proceeding: mux_main's parse test already pins `--key` beating env via `parseEnv(&.{ "mux", "quic://a:1", "--key", "/k" }, "/env")` expecting `/k`; write the equivalent if absent. Restore.
843 843
844 - [ ] **Step 8: Commit** 844 - [x] **Step 8: Commit**
845 845
846 ```bash 846 ```bash
847 git add src/main.zig src/mux_main.zig test/e2e.sh 847 git add src/main.zig src/mux_main.zig test/e2e.sh
src/main.zig
Old New
@@ -21,6 +21,22 @@ const usage =
21 \\ 21 \\
22 ; 22 ;
23 23
24 /// `--key` beats `MUX_KEY_FILE` beats the default path: the more specific
25 /// the statement of intent, the higher it sits. Pure and separate from
26 /// `run` so the ORDER is testable on its own — `run` needs a daemon, and an
27 /// order that quietly inverted would otherwise only show up as a daemon
28 /// authenticating with the wrong key.
29 fn pickKey(flag: ?[]const u8, env: ?[]const u8, default_if_present: ?[]const u8) ?[]const u8 {
30 return flag orelse env orelse default_if_present;
31 }
32
33 /// MUX_KEY_FILE, with "set but empty" read as unset — an empty path could
34 /// only ever be a mistake, and Key.load would blame a confusing "".
35 fn envKey() ?[]const u8 {
36 const v = std.posix.getenv("MUX_KEY_FILE") orelse return null;
37 return if (v.len == 0) null else v;
38 }
39
24 /// The daemon's name for the shared default; see `quic.default_idle_ms` 40 /// The daemon's name for the shared default; see `quic.default_idle_ms`
25 /// for what the number means and why it lives there. 41 /// for what the number means and why it lives there.
26 const default_quic_idle_ms: u32 = quic.default_idle_ms; 42 const default_quic_idle_ms: u32 = quic.default_idle_ms;
@@ -37,9 +53,11 @@ const Opts = struct {
37 cols: u16 = 80, 53 cols: u16 = 80,
38 rows: u16 = 24, 54 rows: u16 = 24,
39 vt: bool = false, 55 vt: bool = false,
40 /// `--quic` and `--key` are both-or-neither, enforced here, which is
41 /// what makes `quic != null` licence to unwrap `key`.
42 quic: ?[]const u8 = null, 56 quic: ?[]const u8 = null,
57 /// Null does NOT mean "no key": it means the command line named none,
58 /// and `run` still has MUX_KEY_FILE and the default path to try. Only
59 /// `--key` without `--quic` is settled here, because that one has no
60 /// reading that makes it sensible.
43 key: ?[]const u8 = null, 61 key: ?[]const u8 = null,
44 quic_idle_ms: u32 = default_quic_idle_ms, 62 quic_idle_ms: u32 = default_quic_idle_ms,
45 }; 63 };
@@ -54,7 +72,6 @@ const Usage = union(enum) {
54 missing_value: []const u8, 72 missing_value: []const u8,
55 /// The flag whose value would not parse as the number it wants. 73 /// The flag whose value would not parse as the number it wants.
56 bad_number: []const u8, 74 bad_number: []const u8,
57 quic_without_key,
58 key_without_quic, 75 key_without_quic,
59 }; 76 };
60 77
@@ -129,10 +146,10 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
129 } 146 }
130 } 147 }
131 148
132 // Both or neither. A key with nowhere to listen is as much a mistake as 149 // A key with nowhere to listen is a mistake parse can see the whole of.
133 // a listener with nothing to authenticate against — and there is no 150 // The mirror case is NOT one: `--quic` with no `--key` may still be
134 // unauthenticated mode to fall back to, so neither can be a default. 151 // answered by MUX_KEY_FILE or the default key path, neither of which
135 if (o.quic != null and o.key == null) return .{ .err = .quic_without_key }; 152 // parse is allowed to look at, so it defers to `run`.
136 if (o.key != null and o.quic == null) return .{ .err = .key_without_quic }; 153 if (o.key != null and o.quic == null) return .{ .err = .key_without_quic };
137 154
138 return .{ .ok = o }; 155 return .{ .ok = o };
@@ -145,10 +162,6 @@ fn usageExit(u: Usage) u8 {
145 .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }), 162 .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }),
146 .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }), 163 .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }),
147 .bad_number => |f| std.debug.print("muxd: {s} needs a positive number\n{s}", .{ f, usage }), 164 .bad_number => |f| std.debug.print("muxd: {s} needs a positive number\n{s}", .{ f, usage }),
148 .quic_without_key => std.debug.print(
149 "muxd: --quic needs --key; there is no unauthenticated mode\n",
150 .{},
151 ),
152 .key_without_quic => std.debug.print( 165 .key_without_quic => std.debug.print(
153 "muxd: --key without --quic has nothing to listen on; name both or neither\n", 166 "muxd: --key without --quic has nothing to listen on; name both or neither\n",
154 .{}, 167 .{},
@@ -240,22 +253,38 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
240 ); 253 );
241 return 1; 254 return 1;
242 }; 255 };
243 quic_key = quic.Key.load(o.key.?) catch |err| switch (err) { 256 // --key, then MUX_KEY_FILE, then the default path if it exists.
257 // The default is only reached for by a daemon that was asked for
258 // QUIC, so an absent one is a message rather than a silent skip —
259 // and the message can always name the path because it is resolved
260 // whether or not it is the one chosen.
261 const dflt = try xdg.keyPath(alloc);
262 defer alloc.free(dflt);
263 const dflt_if_present: ?[]const u8 =
264 if (std.fs.cwd().access(dflt, .{})) |_| dflt else |_| null;
265 const key_path = pickKey(o.key, envKey(), dflt_if_present) orelse {
266 std.debug.print(
267 "muxd: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n",
268 .{dflt},
269 );
270 return 2;
271 };
272 quic_key = quic.Key.load(key_path) catch |err| switch (err) {
244 error.KeyFileMissing => { 273 error.KeyFileMissing => {
245 std.debug.print("muxd: no such key file: {s}\n", .{o.key.?}); 274 std.debug.print("muxd: no such key file: {s}\n", .{key_path});
246 return 1; 275 return 1;
247 }, 276 },
248 error.KeyFilePermissive => { 277 error.KeyFilePermissive => {
249 std.debug.print( 278 std.debug.print(
250 "muxd: {s} is readable by group or other; chmod 600 it\n", 279 "muxd: {s} is readable by group or other; chmod 600 it\n",
251 .{o.key.?}, 280 .{key_path},
252 ); 281 );
253 return 1; 282 return 1;
254 }, 283 },
255 error.KeyFileMalformed => { 284 error.KeyFileMalformed => {
256 std.debug.print( 285 std.debug.print(
257 "muxd: {s} is not a key: want 32 raw bytes or 64 hex characters\n", 286 "muxd: {s} is not a key: want 32 raw bytes or 64 hex characters\n",
258 .{o.key.?}, 287 .{key_path},
259 ); 288 );
260 return 1; 289 return 1;
261 }, 290 },
@@ -436,15 +465,20 @@ test "parseArgs: subcommands and their existing flags" {
436 try std.testing.expect(parse(&.{ "muxd", "run", "--wat" }).err == .unknown_arg); 465 try std.testing.expect(parse(&.{ "muxd", "run", "--wat" }).err == .unknown_arg);
437 } 466 }
438 467
439 test "parseArgs: --quic and --key are both or neither" { 468 test "parseArgs: --key without --quic is refused; --quic alone defers to main" {
440 const both = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433", "--key", "/k" }); 469 const both = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433", "--key", "/k" });
441 try std.testing.expect(both == .ok); 470 try std.testing.expect(both == .ok);
442 try std.testing.expectEqualStrings("0.0.0.0:4433", both.ok.quic.?); 471 try std.testing.expectEqualStrings("0.0.0.0:4433", both.ok.quic.?);
443 try std.testing.expectEqualStrings("/k", both.ok.key.?); 472 try std.testing.expectEqualStrings("/k", both.ok.key.?);
444 473
445 // Naming one is not a request to guess the other: there is no 474 // --quic without --key is no longer a parse error: main resolves
446 // unauthenticated listener and no default key path. 475 // MUX_KEY_FILE and the default path, and parse cannot see either.
447 try std.testing.expect(parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433" }).err == .quic_without_key); 476 const deferred = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433" });
477 try std.testing.expect(deferred == .ok);
478 try std.testing.expect(deferred.ok.key == null);
479
480 // A key with nowhere to listen is still a mistake with no reading that
481 // makes it sensible, and parse can see the whole of it.
448 try std.testing.expect(parse(&.{ "muxd", "run", "--key", "/k" }).err == .key_without_quic); 482 try std.testing.expect(parse(&.{ "muxd", "run", "--key", "/k" }).err == .key_without_quic);
449 483
450 // Neither is the ordinary case and must stay silent. 484 // Neither is the ordinary case and must stay silent.
@@ -566,3 +600,11 @@ test "parseArgs: keygen takes no flags" {
566 try std.testing.expect(r.ok.cmd == .keygen); 600 try std.testing.expect(r.ok.cmd == .keygen);
567 try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg); 601 try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg);
568 } 602 }
603
604 test "pickKey: --key beats MUX_KEY_FILE beats the default path" {
605 try std.testing.expectEqualStrings("/flag", pickKey("/flag", "/env", "/dflt").?);
606 try std.testing.expectEqualStrings("/env", pickKey(null, "/env", "/dflt").?);
607 try std.testing.expectEqualStrings("/dflt", pickKey(null, null, "/dflt").?);
608 // Nothing named anywhere is the triad-message case, not a silent skip.
609 try std.testing.expect(pickKey(null, null, null) == null);
610 }
src/mux_main.zig
Old New
@@ -5,12 +5,14 @@
5 const std = @import("std"); 5 const std = @import("std");
6 const client = @import("client"); 6 const client = @import("client");
7 const build_options = @import("build_options"); 7 const build_options = @import("build_options");
8 const xdg = @import("xdg");
8 9
9 const usage = 10 const usage =
10 \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]] 11 \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]]
11 \\ HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH) 12 \\ HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH)
12 \\ quic://HOST[:PORT] (PORT defaults to 4433) needs --key FILE (or 13 \\ quic://HOST[:PORT] (PORT defaults to 4433) uses --key FILE,
13 \\ MUX_KEY_FILE); muxd must be running with a matching --quic and --key 14 \\ MUX_KEY_FILE, or ~/.config/mux/key; muxd must be running with a
15 \\ matching --quic and key
14 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed 16 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
15 \\ --version prints the version 17 \\ --version prints the version
16 \\ 18 \\
@@ -28,10 +30,6 @@ const ParseResult = union(enum) {
28 /// A direct QUIC attach. The key is resolved in main, where the 30 /// A direct QUIC attach. The key is resolved in main, where the
29 /// environment can be consulted. 31 /// environment can be consulted.
30 quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32 }, 32 quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32 },
31 /// `quic://` named without a key anywhere. Its own result rather than a
32 /// usage error: nothing is misspelled, something is missing, and the
33 /// message that helps says which.
34 quic_without_key,
35 /// `--version`: not a transport at all, so it short-circuits the rest of 33 /// `--version`: not a transport at all, so it short-circuits the rest of
36 /// the parse rather than being reconciled with it. 34 /// the parse rather than being reconciled with it.
37 version, 35 version,
@@ -107,8 +105,12 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult {
107 if (quic) |hp| { 105 if (quic) |hp| {
108 // --key wins over the environment; the environment exists so a 106 // --key wins over the environment; the environment exists so a
109 // shell can set it once rather than repeating it per invocation. 107 // shell can set it once rather than repeating it per invocation.
110 const k = key orelse env_key orelse return .quic_without_key; 108 // Neither being set is not a refusal: main has a default path to
111 if (k.len == 0) return .quic_without_key; 109 // try, and parse is not allowed to look at the filesystem.
110 var k = key orelse env_key;
111 if (k) |kk| {
112 if (kk.len == 0) k = null;
113 }
112 return .{ .quic = .{ .host_port = hp, .key = k, .idle_ms = idle_ms } }; 114 return .{ .quic = .{ .host_port = hp, .key = k, .idle_ms = idle_ms } };
113 } 115 }
114 // A key with no quic:// has nothing to authenticate and is ignored 116 // A key with no quic:// has nothing to authenticate and is ignored
@@ -147,18 +149,27 @@ pub fn main() !u8 {
147 ); 149 );
148 return 2; 150 return 2;
149 }, 151 },
150 .quic_without_key => { 152 .quic => |q| {
151 std.debug.print( 153 var key_owned: ?[]const u8 = null;
152 "mux: quic:// needs --key FILE (or {s}); there is no unauthenticated mode\n", 154 defer if (key_owned) |p| alloc.free(p);
153 .{key_env}, 155 const key_path = q.key orelse blk: {
154 ); 156 const p = try xdg.keyPath(alloc);
155 return 2; 157 key_owned = p;
158 std.fs.cwd().access(p, .{}) catch {
159 std.debug.print(
160 "mux: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n",
161 .{p},
162 );
163 return 2;
164 };
165 break :blk p;
166 };
167 return client.attach(alloc, null, null, .{
168 .host_port = q.host_port,
169 .key_path = key_path,
170 .idle_ms = q.idle_ms,
171 });
156 }, 172 },
157 .quic => |q| return client.attach(alloc, null, null, .{
158 .host_port = q.host_port,
159 .key_path = q.key.?,
160 .idle_ms = q.idle_ms,
161 }),
162 .host => |h| { 173 .host => |h| {
163 // muxd on the far side exposes the session over its stdio; ssh 174 // muxd on the far side exposes the session over its stdio; ssh
164 // carries the bytes and nothing here knows the difference. 175 // carries the bytes and nothing here knows the difference.
@@ -263,11 +274,17 @@ test "parseArgs: quic:// is a transport like any other" {
263 try std.testing.expect(parse(&.{ "mux", "quic://", "--key", "/k" }) == .usage_error); 274 try std.testing.expect(parse(&.{ "mux", "quic://", "--key", "/k" }) == .usage_error);
264 } 275 }
265 276
266 test "parseArgs: a quic attach without a key is refused, by whichever route" { 277 test "parseArgs: a quic attach without a key defers to main, which resolves it" {
267 // No --key and no environment. 278 // No --key and no environment: not a refusal any more. main has a
268 try std.testing.expect(parse(&.{ "mux", "quic://a:1" }) == .quic_without_key); 279 // default path to try and parse cannot see the filesystem.
269 // An environment variable set to nothing is not a key. 280 const q = parse(&.{ "mux", "quic://a:1" });
270 try std.testing.expect(parseEnv(&.{ "mux", "quic://a:1" }, "") == .quic_without_key); 281 try std.testing.expect(q == .quic);
282 try std.testing.expect(q.quic.key == null);
283
284 // Empty env var means unset, same as an empty --key would be nonsense.
285 const empty_env = parseEnv(&.{ "mux", "quic://a:1" }, "");
286 try std.testing.expect(empty_env == .quic);
287 try std.testing.expect(empty_env.quic.key == null);
271 288
272 // The environment supplies it when the flag does not... 289 // The environment supplies it when the flag does not...
273 const e = parseEnv(&.{ "mux", "quic://a:1" }, "/env.key"); 290 const e = parseEnv(&.{ "mux", "quic://a:1" }, "/env.key");
test/e2e.sh
Old New
@@ -104,8 +104,9 @@ cleanup() {
104 "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ 104 "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \
105 "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \ 105 "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \
106 "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \ 106 "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \
107 "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY.wrong" 107 "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY.wrong" \
108 rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" 108 "$OUT.nokey" "$SOCK4.nokey"
109 rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" "${NOKEY_CFG:-}"
109 } 110 }
110 trap cleanup EXIT INT TERM 111 trap cleanup EXIT INT TERM
111 112
@@ -385,13 +386,20 @@ rm -f "$OUT.m7b"
385 386
386 # A refusal that leaves a socket behind has already started a session, which 387 # A refusal that leaves a socket behind has already started a session, which
387 # is the failure this ordering exists to prevent. 388 # is the failure this ordering exists to prevent.
389 # Takes the config home to run under as an explicit second argument, rather
390 # than relying on a `XDG_CONFIG_HOME=... refuse ...` prefix: POSIX says an
391 # assignment prefixing a FUNCTION call may outlive it, so on a dash /bin/sh
392 # that form could leak into every later scenario. Naming it per call site
393 # also makes each refusal say which key world it is refusing in, which is
394 # the thing that silently changes once a default key path exists.
388 refuse() { 395 refuse() {
389 _want="$1"; shift 396 _want="$1"; _cfg="$2"; shift 2
390 set +e 397 set +e
391 # Timed out rather than trusted to exit: every case here is a refusal, so 398 # Timed out rather than trusted to exit: every case here is a refusal, so
392 # a regression that ACCEPTS one would otherwise run a daemon forever and 399 # a regression that ACCEPTS one would otherwise run a daemon forever and
393 # hang the suite instead of failing it. 124 is a distinguishable answer. 400 # hang the suite instead of failing it. 124 is a distinguishable answer.
394 timeout 10 "$MUXD" run --sock "$SOCK4" --shell /bin/sh "$@" > "$OUT.q" 2>&1 401 env XDG_CONFIG_HOME="$_cfg" timeout 10 "$MUXD" run --sock "$SOCK4" \
402 --shell /bin/sh "$@" > "$OUT.q" 2>&1
395 _rc=$? 403 _rc=$?
396 set -e 404 set -e
397 [ "$_rc" -eq "$_want" ] || { 405 [ "$_rc" -eq "$_want" ] || {
@@ -402,11 +410,8 @@ refuse() {
402 } 410 }
403 # One line of complaint plus the usage block, and not a stack trace: a 411 # One line of complaint plus the usage block, and not a stack trace: a
404 # Zig panic runs to dozens of lines and names a source file, which is 412 # Zig panic runs to dozens of lines and names a source file, which is
405 # what this is guarding against. Derived from the usage text rather than 413 # what this is guarding against.
406 # written as a constant, so adding a subcommand moves the bound with it 414 #
407 # instead of silently spending headroom a later one would need. The
408 # margin is 2 because a refusal is the complaint plus usage; the
409 # smallest Debug panic is ~20 lines, well clear of it.
410 # Two bounds, because each misses what the other catches. The relative 415 # Two bounds, because each misses what the other catches. The relative
411 # one is measured from the no-args usage path, which is NOT one of the 416 # one is measured from the no-args usage path, which is NOT one of the
412 # paths it bounds: if usage printing ever ballooned, the bound would 417 # paths it bounds: if usage printing ever ballooned, the bound would
@@ -426,15 +431,26 @@ cp "$QKEY" "$QKEY.bad"
426 chmod 644 "$QKEY.bad" 431 chmod 644 "$QKEY.bad"
427 432
428 # Both or neither, and a usage mistake exits 2 like every other one. 433 # Both or neither, and a usage mistake exits 2 like every other one.
429 refuse 2 --quic "127.0.0.1:$QPORT" 434 # The no-key refusal must not find the suite's own keygen'd key, so it runs
430 refuse 2 --key "$QKEY" 435 # against a config home that has never had one written to it.
431 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 0 436 NOKEY_CFG="${TMPDIR:-/tmp}/mux-e2e-nokey-$$"
432 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms soon 437 refuse 2 "$NOKEY_CFG" --quic "127.0.0.1:$QPORT"
438 refuse 2 "$XDG_CONFIG_HOME" --key "$QKEY"
439 refuse 2 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 0
440 refuse 2 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms soon
433 # Refusals that are about the world rather than the spelling exit 1. 441 # Refusals that are about the world rather than the spelling exit 1.
434 refuse 1 --quic "127.0.0.1:" --key "$QKEY" 442 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:" --key "$QKEY"
435 refuse 1 --quic "localhost:$QPORT" --key "$QKEY" 443 refuse 1 "$XDG_CONFIG_HOME" --quic "localhost:$QPORT" --key "$QKEY"
436 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.bad" 444 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY.bad"
437 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.missing" 445 refuse 1 "$XDG_CONFIG_HOME" --quic "127.0.0.1:$QPORT" --key "$QKEY.missing"
446
447 # The key-missing message must name the way out, not just the absence.
448 env XDG_CONFIG_HOME="$NOKEY_CFG" "$MUXD" run --sock "$SOCK4.nokey" \
449 --quic "127.0.0.1:$QPORT" 2> "$OUT.nokey" || true
450 grep -q "muxd keygen" "$OUT.nokey" || {
451 echo "e2e FAIL: key-missing message does not name keygen"; cat "$OUT.nokey"; exit 1; }
452 rm -rf "$NOKEY_CFG"
453 echo "e2e OK: no key anywhere is refused, and says how to make one"
438 454
439 # The accepted case. 455 # The accepted case.
440 "$MUXD" run --sock "$SOCK4" --shell /bin/sh \ 456 "$MUXD" run --sock "$SOCK4" --shell /bin/sh \