63b9dff2
feat: key resolution --key > MUX_KEY_FILE > ~/.config/mux/key, both binaries
a73x 2026-08-09 13:00
Commit message
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 \ |