80dab7a2
refactor: one owner each for the sun_path bound and the key-picking rule
a73x 2026-08-13 13:58
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -317,6 +317,8 @@ pub fn build(b: *std.Build) void { | |||
| 317 | mux_mod.addImport("xdg", xdg_mod); | 317 | mux_mod.addImport("xdg", xdg_mod); |
| 318 | mux_mod.addImport("spawn", spawn_mod); | 318 | mux_mod.addImport("spawn", spawn_mod); |
| 319 | mux_mod.addImport("handoff", handoff_mod); | 319 | mux_mod.addImport("handoff", handoff_mod); |
| 320 | // The sun_path bound only; the client binds no socket itself. | ||
| 321 | mux_mod.addImport("sockpath", sockpath_mod); | ||
| 320 | 322 | ||
| 321 | // Test helpers, built as real binaries because that is how the suite | 323 | // Test helpers, built as real binaries because that is how the suite |
| 322 | // uses them: rawmode is a deterministic stand-in for an editor (nvim's | 324 | // uses them: rawmode is a deterministic stand-in for an editor (nvim's |
| @@ -377,6 +379,8 @@ pub fn build(b: *std.Build) void { | |||
| 377 | exe_mod.addImport("spawn", spawn_mod); | 379 | exe_mod.addImport("spawn", spawn_mod); |
| 378 | // `muxd endpoint` prints the announce line this module spells. | 380 | // `muxd endpoint` prints the announce line this module spells. |
| 379 | exe_mod.addImport("handoff", handoff_mod); | 381 | exe_mod.addImport("handoff", handoff_mod); |
| 382 | // The sun_path bound, checked before any verb acts on the path. | ||
| 383 | exe_mod.addImport("sockpath", sockpath_mod); | ||
| 380 | 384 | ||
| 381 | const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); | 385 | const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); |
| 382 | // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe | 386 | // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe |
| @@ -503,6 +507,8 @@ pub fn build(b: *std.Build) void { | |||
| 503 | webhub_main_mod.addImport("xdg", xdg_mod); | 507 | webhub_main_mod.addImport("xdg", xdg_mod); |
| 504 | // The HOST tile's recipe comes from the same owner mux_main uses. | 508 | // The HOST tile's recipe comes from the same owner mux_main uses. |
| 505 | webhub_main_mod.addImport("handoff", handoff_mod); | 509 | webhub_main_mod.addImport("handoff", handoff_mod); |
| 510 | // ...and the sun_path bound its --sock tiles are refused against. | ||
| 511 | webhub_main_mod.addImport("sockpath", sockpath_mod); | ||
| 506 | webhub_main_mod.addImport("build_options", version_opts.createModule()); | 512 | webhub_main_mod.addImport("build_options", version_opts.createModule()); |
| 507 | webhub_main_mod.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") }); | 513 | webhub_main_mod.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") }); |
| 508 | webhub_main_mod.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") }); | 514 | webhub_main_mod.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") }); |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ const build_options = @import("build_options"); | |||
| 11 | const xdg = @import("xdg"); | 11 | const xdg = @import("xdg"); |
| 12 | const spawn = @import("spawn"); | 12 | const spawn = @import("spawn"); |
| 13 | const handoff = @import("handoff"); | 13 | const handoff = @import("handoff"); |
| 14 | const sockpath = @import("sockpath"); | ||
| 14 | 15 | ||
| 15 | const usage = | 16 | const usage = |
| 16 | \\usage: | 17 | \\usage: |
| @@ -295,19 +296,19 @@ pub fn main() !u8 { | |||
| 295 | try defaultSockPath(alloc); | 296 | try defaultSockPath(alloc); |
| 296 | defer alloc.free(sock_path); | 297 | defer alloc.free(sock_path); |
| 297 | 298 | ||
| 298 | // sun_path is 108 bytes including the NUL. Checked here, once, before | 299 | // The sun_path bound (sockpath.max_sun_path). Checked here, once, |
| 299 | // any command acts: the alternative is a spawned daemon that can never | 300 | // before any command acts: the alternative is a spawned daemon that can |
| 300 | // answer and a 2s timeout story about a path that was doomed at parse. | 301 | // never answer and a 2s timeout story about a path doomed at parse. |
| 301 | // `--version` and `keygen` are dispatched from the switch below, i.e. | 302 | // `--version` and `keygen` are dispatched from the switch below, i.e. |
| 302 | // after this point, so they are exempted by their rows rather than by | 303 | // after this point, so they are exempted by their rows rather than by |
| 303 | // order — neither touches the socket, and neither should be refused | 304 | // order — neither touches the socket, and neither should be refused |
| 304 | // over it. Which verbs those are is their rows' business, not this | 305 | // over it. Which verbs those are is their rows' business, not this |
| 305 | // line's. | 306 | // line's. |
| 306 | const uses_socket = specForCmd(o.cmd).uses_socket; | 307 | const uses_socket = specForCmd(o.cmd).uses_socket; |
| 307 | if (uses_socket and sock_path.len > 107) { | 308 | if (uses_socket and sock_path.len > sockpath.max_sun_path) { |
| 308 | std.debug.print( | 309 | std.debug.print( |
| 309 | "muxd: socket path too long ({d} bytes, max 107): {s}\n", | 310 | "muxd: socket path too long ({d} bytes, max {d}): {s}\n", |
| 310 | .{ sock_path.len, sock_path }, | 311 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, |
| 311 | ); | 312 | ); |
| 312 | return 1; | 313 | return 1; |
| 313 | } | 314 | } |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ const build_options = @import("build_options"); | |||
| 9 | const xdg = @import("xdg"); | 9 | const xdg = @import("xdg"); |
| 10 | const spawn = @import("spawn"); | 10 | const spawn = @import("spawn"); |
| 11 | const handoff = @import("handoff"); | 11 | const handoff = @import("handoff"); |
| 12 | const sockpath = @import("sockpath"); | ||
| 12 | 13 | ||
| 13 | const usage = | 14 | const usage = |
| 14 | \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]] | 15 | \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]] |
| @@ -30,8 +31,12 @@ const ParseResult = union(enum) { | |||
| 30 | /// At most one of these is set; both null means the default local socket. | 31 | /// At most one of these is set; both null means the default local socket. |
| 31 | attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null }, | 32 | attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null }, |
| 32 | /// A bare hostname: the ssh recipe is built from it in main, where there | 33 | /// A bare hostname: the ssh recipe is built from it in main, where there |
| 33 | /// is an allocator to build it with. | 34 | /// is an allocator to build it with. `idle_ms` rides along because the |
| 34 | host: []const u8, | 35 | /// handoff ends in a QUIC link like any other — muxweb's HOST tiles |
| 36 | /// have always carried it, and mux dropping it on the floor made | ||
| 37 | /// `--quic-idle-ms` silently do nothing on exactly the spelling most | ||
| 38 | /// people use. | ||
| 39 | host: struct { name: []const u8, idle_ms: u32 }, | ||
| 35 | /// A direct QUIC attach. The key is resolved in main, where the | 40 | /// A direct QUIC attach. The key is resolved in main, where the |
| 36 | /// environment can be consulted. | 41 | /// environment can be consulted. |
| 37 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32 }, | 42 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32 }, |
| @@ -108,22 +113,20 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 108 | if (named > 1) return .conflict; | 113 | if (named > 1) return .conflict; |
| 109 | 114 | ||
| 110 | if (quic) |hp| { | 115 | if (quic) |hp| { |
| 111 | // --key wins over the environment; the environment exists so a | 116 | // Neither spelling being set is not a refusal: main has a default |
| 112 | // shell can set it once rather than repeating it per invocation. | 117 | // path to try, and parse is not allowed to look at the filesystem. |
| 113 | // Neither being set is not a refusal: main has a default path to | 118 | return .{ .quic = .{ |
| 114 | // try, and parse is not allowed to look at the filesystem. | 119 | .host_port = hp, |
| 115 | var k = key orelse env_key; | 120 | .key = xdg.pickKey(key, env_key), |
| 116 | if (k) |kk| { | 121 | .idle_ms = idle_ms, |
| 117 | if (kk.len == 0) k = null; | 122 | } }; |
| 118 | } | ||
| 119 | return .{ .quic = .{ .host_port = hp, .key = k, .idle_ms = idle_ms } }; | ||
| 120 | } | 123 | } |
| 121 | // A key with no quic:// has nothing to authenticate and is ignored | 124 | // A key with no quic:// has nothing to authenticate and is ignored |
| 122 | // rather than refused: unlike muxd, where --key without --quic means a | 125 | // rather than refused: unlike muxd, where --key without --quic means a |
| 123 | // listener was meant, here it is one env var away from being set for | 126 | // listener was meant, here it is one env var away from being set for |
| 124 | // every invocation in a shell, and refusing `mux --sock ...` because | 127 | // every invocation in a shell, and refusing `mux --sock ...` because |
| 125 | // MUX_KEY_FILE happens to be exported would be absurd. | 128 | // MUX_KEY_FILE happens to be exported would be absurd. |
| 126 | if (host) |h| return .{ .host = h }; | 129 | if (host) |h| return .{ .host = .{ .name = h, .idle_ms = idle_ms } }; |
| 127 | return .{ .attach = .{ .sock = sock, .via = via } }; | 130 | return .{ .attach = .{ .sock = sock, .via = via } }; |
| 128 | } | 131 | } |
| 129 | 132 | ||
| @@ -182,12 +185,13 @@ pub fn main() !u8 { | |||
| 182 | // warm attach dials from the cache and never spawns ssh at all. | 185 | // warm attach dials from the cache and never spawns ssh at all. |
| 183 | // handoff.recipeFor owns both pieces; muxweb builds its HOST | 186 | // handoff.recipeFor owns both pieces; muxweb builds its HOST |
| 184 | // tiles from the same call. | 187 | // tiles from the same call. |
| 185 | const r = try handoff.recipeFor(alloc, h); | 188 | const r = try handoff.recipeFor(alloc, h.name); |
| 186 | defer r.deinit(alloc); | 189 | defer r.deinit(alloc); |
| 187 | return client.attach(alloc, .{ .hand = .{ | 190 | return client.attach(alloc, .{ .hand = .{ |
| 188 | .host = h, | 191 | .host = h.name, |
| 189 | .ssh_cmd = r.ssh_cmd, | 192 | .ssh_cmd = r.ssh_cmd, |
| 190 | .cache_path = r.cache_path, | 193 | .cache_path = r.cache_path, |
| 194 | .idle_ms = h.idle_ms, | ||
| 191 | } }); | 195 | } }); |
| 192 | }, | 196 | }, |
| 193 | .attach => |t| { | 197 | .attach => |t| { |
| @@ -200,17 +204,17 @@ pub fn main() !u8 { | |||
| 200 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); | 204 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); |
| 201 | defer alloc.free(sock_path); | 205 | defer alloc.free(sock_path); |
| 202 | 206 | ||
| 203 | // The same 107-byte `sun_path` guard muxd applies (main.zig), for | 207 | // The same `sun_path` guard muxd applies (main.zig), for the |
| 204 | // the same reason and with the same constant. It sits before the | 208 | // same reason and off the same constant. It sits before the |
| 205 | // PATH search rather than at the connect because auto-start would | 209 | // PATH search rather than at the connect because auto-start would |
| 206 | // otherwise reach it first: mux finds muxd, spawns a child that | 210 | // otherwise reach it first: mux finds muxd, spawns a child that |
| 207 | // refuses the path instantly, and polls the full 2s into "daemon | 211 | // refuses the path instantly, and polls the full 2s into "daemon |
| 208 | // did not answer" — a timeout story about a path that was doomed | 212 | // did not answer" — a timeout story about a path that was doomed |
| 209 | // at parse. Refusing here costs nothing and says the real thing. | 213 | // at parse. Refusing here costs nothing and says the real thing. |
| 210 | if (sock_path.len > 107) { | 214 | if (sock_path.len > sockpath.max_sun_path) { |
| 211 | std.debug.print( | 215 | std.debug.print( |
| 212 | "mux: socket path too long ({d} bytes, max 107): {s}\n", | 216 | "mux: socket path too long ({d} bytes, max {d}): {s}\n", |
| 213 | .{ sock_path.len, sock_path }, | 217 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, |
| 214 | ); | 218 | ); |
| 215 | return 1; | 219 | return 1; |
| 216 | } | 220 | } |
| @@ -271,14 +275,18 @@ test "parseArgs: --sock and --via each name their transport" { | |||
| 271 | test "parseArgs: a bare word is a host to hop to" { | 275 | test "parseArgs: a bare word is a host to hop to" { |
| 272 | const h = parse(&.{ "mux", "vm1" }); | 276 | const h = parse(&.{ "mux", "vm1" }); |
| 273 | try std.testing.expect(h == .host); | 277 | try std.testing.expect(h == .host); |
| 274 | try std.testing.expectEqualStrings("vm1", h.host); | 278 | try std.testing.expectEqualStrings("vm1", h.host.name); |
| 279 | // Spelled out rather than written `client.quic_idle_ms_default` — see | ||
| 280 | // the quic:// test for why asserting against the parser's own constant | ||
| 281 | // could never catch the number changing. | ||
| 282 | try std.testing.expectEqual(@as(u32, 15_000), h.host.idle_ms); | ||
| 275 | 283 | ||
| 276 | // The user@host form is just as bare a word; nothing parses inside it, | 284 | // The user@host form is just as bare a word; nothing parses inside it, |
| 277 | // which is what lets ssh's own config (aliases, ports, ProxyJump) keep | 285 | // which is what lets ssh's own config (aliases, ports, ProxyJump) keep |
| 278 | // working untouched. | 286 | // working untouched. |
| 279 | const u = parse(&.{ "mux", "ubuntu@sandbox-9b70e9" }); | 287 | const u = parse(&.{ "mux", "ubuntu@sandbox-9b70e9" }); |
| 280 | try std.testing.expect(u == .host); | 288 | try std.testing.expect(u == .host); |
| 281 | try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host); | 289 | try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host.name); |
| 282 | } | 290 | } |
| 283 | 291 | ||
| 284 | test "parseArgs: naming two transports is a conflict, however it is spelled" { | 292 | test "parseArgs: naming two transports is a conflict, however it is spelled" { |
| @@ -354,6 +362,15 @@ test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" { | |||
| 354 | const t = parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" }); | 362 | const t = parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" }); |
| 355 | try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms); | 363 | try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms); |
| 356 | 364 | ||
| 365 | // A bare HOST ends in a QUIC link too, so the flag has to reach it — | ||
| 366 | // the .host result carried no idle_ms at all and the flag was accepted | ||
| 367 | // and then dropped, which is worse than refusing it. muxweb's HOST | ||
| 368 | // tiles were already right; this is mux catching up. | ||
| 369 | const h = parse(&.{ "mux", "vm1", "--quic-idle-ms", "1500" }); | ||
| 370 | try std.testing.expect(h == .host); | ||
| 371 | try std.testing.expectEqual(@as(u32, 1500), h.host.idle_ms); | ||
| 372 | try std.testing.expect(parse(&.{ "mux", "vm1", "--quic-idle-ms", "0" }) == .usage_error); | ||
| 373 | |||
| 357 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }) == .usage_error); | 374 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }) == .usage_error); |
| 358 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }) == .usage_error); | 375 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }) == .usage_error); |
| 359 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); | 376 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); |
src/sockpath.zig
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,12 @@ | |||
| 9 | //! takes. | 9 | //! takes. |
| 10 | const std = @import("std"); | 10 | const std = @import("std"); |
| 11 | 11 | ||
| 12 | /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the | ||
| 13 | /// last one belongs to the NUL. All three binaries refuse a longer path | ||
| 14 | /// by name before acting on it, each in its own words — the number is | ||
| 15 | /// the kernel's and belongs in one place, the wording is theirs. | ||
| 16 | pub const max_sun_path = 107; | ||
| 17 | |||
| 12 | /// A socket file's identity at the moment it was bound, so teardown can | 18 | /// A socket file's identity at the moment it was bound, so teardown can |
| 13 | /// tell our socket from one that replaced it. | 19 | /// tell our socket from one that replaced it. |
| 14 | /// | 20 | /// |
src/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const webhub = @import("webhub"); | |||
| 12 | const build_options = @import("build_options"); | 12 | const build_options = @import("build_options"); |
| 13 | const xdg = @import("xdg"); | 13 | const xdg = @import("xdg"); |
| 14 | const handoff = @import("handoff"); | 14 | const handoff = @import("handoff"); |
| 15 | const sockpath = @import("sockpath"); | ||
| 15 | 16 | ||
| 16 | const usage = | 17 | const usage = |
| 17 | \\usage: muxweb TARGET [TARGET ...] [--port N] | 18 | \\usage: muxweb TARGET [TARGET ...] [--port N] |
| @@ -48,14 +49,20 @@ const Parsed = struct { | |||
| 48 | const ParseResult = union(enum) { | 49 | const ParseResult = union(enum) { |
| 49 | serve: Parsed, | 50 | serve: Parsed, |
| 50 | version, | 51 | version, |
| 51 | usage_error, | ||
| 52 | }; | 52 | }; |
| 53 | 53 | ||
| 54 | /// A usage mistake is an ERROR, not a third result: it is the one outcome | ||
| 55 | /// with nothing to hand back, and saying so lets the single errdefer own | ||
| 56 | /// the tile list on every refusing path. Spelling it as a value meant a | ||
| 57 | /// `tiles.deinit` beside each of the seven `return .usage_error`s, which | ||
| 58 | /// is seven chances to forget one. | ||
| 59 | const ParseError = error{Usage} || std.mem.Allocator.Error; | ||
| 60 | |||
| 54 | fn parseArgs( | 61 | fn parseArgs( |
| 55 | alloc: std.mem.Allocator, | 62 | alloc: std.mem.Allocator, |
| 56 | args: []const [:0]const u8, | 63 | args: []const [:0]const u8, |
| 57 | env_key: ?[]const u8, | 64 | env_key: ?[]const u8, |
| 58 | ) !ParseResult { | 65 | ) ParseError!ParseResult { |
| 59 | var p = Parsed{ .tiles = .empty }; | 66 | var p = Parsed{ .tiles = .empty }; |
| 60 | errdefer p.tiles.deinit(alloc); | 67 | errdefer p.tiles.deinit(alloc); |
| 61 | var key: ?[]const u8 = null; | 68 | var key: ?[]const u8 = null; |
| @@ -64,6 +71,9 @@ fn parseArgs( | |||
| 64 | while (i < args.len) : (i += 1) { | 71 | while (i < args.len) : (i += 1) { |
| 65 | const a = args[i]; | 72 | const a = args[i]; |
| 66 | if (std.mem.eql(u8, a, "--version")) { | 73 | if (std.mem.eql(u8, a, "--version")) { |
| 74 | // The one non-error early return, so the one that still frees | ||
| 75 | // for itself: errdefer does not run on the way out with a | ||
| 76 | // result in hand. | ||
| 67 | p.tiles.deinit(alloc); | 77 | p.tiles.deinit(alloc); |
| 68 | return .version; | 78 | return .version; |
| 69 | } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | 79 | } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { |
| @@ -71,50 +81,33 @@ fn parseArgs( | |||
| 71 | try p.tiles.append(alloc, .{ .sock = args[i] }); | 81 | try p.tiles.append(alloc, .{ .sock = args[i] }); |
| 72 | } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { | 82 | } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { |
| 73 | i += 1; | 83 | i += 1; |
| 74 | p.port = std.fmt.parseInt(u16, args[i], 10) catch { | 84 | p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; |
| 75 | p.tiles.deinit(alloc); | 85 | // Port 0 asks the kernel to choose, and the hub prints the port |
| 76 | return .usage_error; | 86 | // it was asked for as the door to open — a door nobody could |
| 77 | }; | 87 | // find. Refused like `--quic-idle-ms 0` and for the same |
| 88 | // reason: the number inverts what typing it means. | ||
| 89 | if (p.port == 0) return error.Usage; | ||
| 78 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | 90 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { |
| 79 | i += 1; | 91 | i += 1; |
| 80 | key = args[i]; | 92 | key = args[i]; |
| 81 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | 93 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { |
| 82 | i += 1; | 94 | i += 1; |
| 83 | const n = std.fmt.parseInt(u32, args[i], 10) catch { | 95 | const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage; |
| 84 | p.tiles.deinit(alloc); | 96 | if (n == 0) return error.Usage; |
| 85 | return .usage_error; | ||
| 86 | }; | ||
| 87 | if (n == 0) { | ||
| 88 | p.tiles.deinit(alloc); | ||
| 89 | return .usage_error; | ||
| 90 | } | ||
| 91 | p.idle_ms = n; | 97 | p.idle_ms = n; |
| 92 | } else if (std.mem.startsWith(u8, a, "quic://")) { | 98 | } else if (std.mem.startsWith(u8, a, "quic://")) { |
| 93 | const hp = a["quic://".len..]; | 99 | const hp = a["quic://".len..]; |
| 94 | if (hp.len == 0) { | 100 | if (hp.len == 0) return error.Usage; |
| 95 | p.tiles.deinit(alloc); | ||
| 96 | return .usage_error; | ||
| 97 | } | ||
| 98 | try p.tiles.append(alloc, .{ .quic = hp }); | 101 | try p.tiles.append(alloc, .{ .quic = hp }); |
| 99 | } else if (a.len > 0 and a[0] != '-') { | 102 | } else if (a.len > 0 and a[0] != '-') { |
| 100 | try p.tiles.append(alloc, .{ .host = a }); | 103 | try p.tiles.append(alloc, .{ .host = a }); |
| 101 | } else { | 104 | } else { |
| 102 | p.tiles.deinit(alloc); | 105 | return error.Usage; |
| 103 | return .usage_error; | ||
| 104 | } | 106 | } |
| 105 | } | 107 | } |
| 106 | 108 | ||
| 107 | if (p.tiles.items.len == 0) { | 109 | if (p.tiles.items.len == 0) return error.Usage; |
| 108 | p.tiles.deinit(alloc); | 110 | p.key = xdg.pickKey(key, env_key); |
| 109 | return .usage_error; | ||
| 110 | } | ||
| 111 | // --key wins over the environment, exactly mux_main's rule; empty | ||
| 112 | // means unset either way. | ||
| 113 | var k = key orelse env_key; | ||
| 114 | if (k) |kk| { | ||
| 115 | if (kk.len == 0) k = null; | ||
| 116 | } | ||
| 117 | p.key = k; | ||
| 118 | return .{ .serve = p }; | 111 | return .{ .serve = p }; |
| 119 | } | 112 | } |
| 120 | 113 | ||
| @@ -126,17 +119,20 @@ pub fn main() !u8 { | |||
| 126 | const args = try std.process.argsAlloc(alloc); | 119 | const args = try std.process.argsAlloc(alloc); |
| 127 | defer std.process.argsFree(alloc, args); | 120 | defer std.process.argsFree(alloc, args); |
| 128 | 121 | ||
| 129 | var parsed = switch (try parseArgs(alloc, args, std.posix.getenv(client_key_env))) { | 122 | const result = parseArgs(alloc, args, std.posix.getenv(client_key_env)) catch |err| switch (err) { |
| 123 | error.Usage => { | ||
| 124 | std.debug.print("{s}", .{usage}); | ||
| 125 | return 2; | ||
| 126 | }, | ||
| 127 | else => |e| return e, | ||
| 128 | }; | ||
| 129 | var parsed = switch (result) { | ||
| 130 | .version => { | 130 | .version => { |
| 131 | var vbuf: [64]u8 = undefined; | 131 | var vbuf: [64]u8 = undefined; |
| 132 | const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable; | 132 | const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable; |
| 133 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | 133 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; |
| 134 | return 0; | 134 | return 0; |
| 135 | }, | 135 | }, |
| 136 | .usage_error => { | ||
| 137 | std.debug.print("{s}", .{usage}); | ||
| 138 | return 2; | ||
| 139 | }, | ||
| 140 | .serve => |p| p, | 136 | .serve => |p| p, |
| 141 | }; | 137 | }; |
| 142 | defer parsed.deinit(alloc); | 138 | defer parsed.deinit(alloc); |
| @@ -145,46 +141,45 @@ pub fn main() !u8 { | |||
| 145 | // mux_main uses — handoff.recipeFor and xdg.resolveKeyPath — so the | 141 | // mux_main uses — handoff.recipeFor and xdg.resolveKeyPath — so the |
| 146 | // two binaries cannot drift on what a bare HOST or a `quic://` means. | 142 | // two binaries cannot drift on what a bare HOST or a `quic://` means. |
| 147 | // Labels are the argv spellings verbatim. | 143 | // Labels are the argv spellings verbatim. |
| 144 | // | ||
| 145 | // An arena, because every string built here lives exactly as long as | ||
| 146 | // the hub does: the Targets are handed to every tile thread and the | ||
| 147 | // labels to every /tiles response, so nothing is ever freed early and | ||
| 148 | // the hand-rolled list of pointers-to-free was a lifetime nobody | ||
| 149 | // needed to track. Which resolutions borrow from argv and which | ||
| 150 | // allocate stops mattering: both end at the same deinit. | ||
| 151 | var arena_state = std.heap.ArenaAllocator.init(alloc); | ||
| 152 | defer arena_state.deinit(); | ||
| 153 | const arena = arena_state.allocator(); | ||
| 154 | |||
| 148 | var targets: std.ArrayList(client.Target) = .empty; | 155 | var targets: std.ArrayList(client.Target) = .empty; |
| 149 | defer targets.deinit(alloc); | ||
| 150 | var labels: std.ArrayList([]const u8) = .empty; | 156 | var labels: std.ArrayList([]const u8) = .empty; |
| 151 | defer labels.deinit(alloc); | ||
| 152 | var owned: std.ArrayList([]const u8) = .empty; | ||
| 153 | defer { | ||
| 154 | for (owned.items) |s| alloc.free(s); | ||
| 155 | owned.deinit(alloc); | ||
| 156 | } | ||
| 157 | 157 | ||
| 158 | for (parsed.tiles.items) |spec| switch (spec) { | 158 | for (parsed.tiles.items) |spec| switch (spec) { |
| 159 | .sock => |path| { | 159 | .sock => |path| { |
| 160 | if (path.len > 107) { | 160 | if (path.len > sockpath.max_sun_path) { |
| 161 | std.debug.print("muxweb: socket path too long ({d} bytes, max 107): {s}\n", .{ path.len, path }); | 161 | std.debug.print("muxweb: socket path too long ({d} bytes, max {d}): {s}\n", .{ |
| 162 | path.len, sockpath.max_sun_path, path, | ||
| 163 | }); | ||
| 162 | return 2; | 164 | return 2; |
| 163 | } | 165 | } |
| 164 | try targets.append(alloc, .{ .sock = path }); | 166 | try targets.append(arena, .{ .sock = path }); |
| 165 | try labels.append(alloc, path); | 167 | try labels.append(arena, path); |
| 166 | }, | 168 | }, |
| 167 | .host => |h| { | 169 | .host => |h| { |
| 168 | const r = try handoff.recipeFor(alloc, h); | 170 | const r = try handoff.recipeFor(arena, h); |
| 169 | try owned.append(alloc, r.ssh_cmd); | 171 | try targets.append(arena, .{ .hand = .{ |
| 170 | if (r.cache_path) |c| try owned.append(alloc, c); | ||
| 171 | try targets.append(alloc, .{ .hand = .{ | ||
| 172 | .host = h, | 172 | .host = h, |
| 173 | .ssh_cmd = r.ssh_cmd, | 173 | .ssh_cmd = r.ssh_cmd, |
| 174 | .cache_path = r.cache_path, | 174 | .cache_path = r.cache_path, |
| 175 | .idle_ms = parsed.idle_ms, | 175 | .idle_ms = parsed.idle_ms, |
| 176 | } }); | 176 | } }); |
| 177 | try labels.append(alloc, h); | 177 | try labels.append(arena, h); |
| 178 | }, | 178 | }, |
| 179 | .quic => |hp| { | 179 | .quic => |hp| { |
| 180 | const key_path = switch (try xdg.resolveKeyPath(alloc, parsed.key)) { | 180 | const key_path = switch (try xdg.resolveKeyPath(arena, parsed.key)) { |
| 181 | .given => |p| p, | 181 | .given, .default => |p| p, |
| 182 | .default => |p| blk: { | ||
| 183 | try owned.append(alloc, p); | ||
| 184 | break :blk p; | ||
| 185 | }, | ||
| 186 | .missing => |p| { | 182 | .missing => |p| { |
| 187 | defer alloc.free(p); | ||
| 188 | std.debug.print( | 183 | std.debug.print( |
| 189 | "muxweb: no key for quic://{s}: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", | 184 | "muxweb: no key for quic://{s}: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", |
| 190 | .{ hp, p }, | 185 | .{ hp, p }, |
| @@ -192,14 +187,13 @@ pub fn main() !u8 { | |||
| 192 | return 2; | 187 | return 2; |
| 193 | }, | 188 | }, |
| 194 | }; | 189 | }; |
| 195 | try targets.append(alloc, .{ .quic = .{ | 190 | try targets.append(arena, .{ .quic = .{ |
| 196 | .host_port = hp, | 191 | .host_port = hp, |
| 197 | .key_path = key_path, | 192 | .key_path = key_path, |
| 198 | .idle_ms = parsed.idle_ms, | 193 | .idle_ms = parsed.idle_ms, |
| 199 | } }); | 194 | } }); |
| 200 | const label = try std.fmt.allocPrint(alloc, "quic://{s}", .{hp}); | 195 | const label = try std.fmt.allocPrint(arena, "quic://{s}", .{hp}); |
| 201 | try owned.append(alloc, label); | 196 | try labels.append(arena, label); |
| 202 | try labels.append(alloc, label); | ||
| 203 | }, | 197 | }, |
| 204 | }; | 198 | }; |
| 205 | 199 | ||
| @@ -262,11 +256,31 @@ test "parse: three spellings become three tiles in argv order, port and key bind | |||
| 262 | 256 | ||
| 263 | test "parse: zero targets, bad flags, and flag-beats-env" { | 257 | test "parse: zero targets, bad flags, and flag-beats-env" { |
| 264 | const alloc = std.testing.allocator; | 258 | const alloc = std.testing.allocator; |
| 259 | // Every refusal arrives as error.Usage — and the testing allocator is | ||
| 260 | // the other half of this pin: a refusal that leaked the tile list | ||
| 261 | // would fail the test that provoked it, which is what the single | ||
| 262 | // errdefer now guarantees on all seven paths. | ||
| 263 | // | ||
| 265 | // No targets is a usage error, not an empty wall. | 264 | // No targets is a usage error, not an empty wall. |
| 266 | try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)) == .usage_error); | 265 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)); |
| 267 | // A flag with no value is a usage mistake, not a transport. | 266 | // A flag with no value is a usage mistake, not a transport. |
| 268 | try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)) == .usage_error); | 267 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)); |
| 269 | try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)) == .usage_error); | 268 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)); |
| 269 | // The refusals that had a tile on the list already, so the cleanup is | ||
| 270 | // load-bearing rather than theoretical. | ||
| 271 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--wat" }, null)); | ||
| 272 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "quic://" }, null)); | ||
| 273 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--quic-idle-ms", "0" }, null)); | ||
| 274 | // Port 0 means "kernel, you pick" — but the hub announces the port it | ||
| 275 | // was asked for, so the door it prints is not the door it opened. | ||
| 276 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "0" }, null)); | ||
| 277 | // ...and an ordinary port still binds, so the refusal is the zero and | ||
| 278 | // not the flag. | ||
| 279 | { | ||
| 280 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "1" }, null)).serve; | ||
| 281 | defer r.deinit(alloc); | ||
| 282 | try std.testing.expectEqual(@as(u16, 1), r.port); | ||
| 283 | } | ||
| 270 | // Env fills in when --key is absent; --key wins when both are set. | 284 | // Env fills in when --key is absent; --key wins when both are set. |
| 271 | { | 285 | { |
| 272 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve; | 286 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve; |
src/xdg.zig
| Old | New | ||
|---|---|---|---|
| @@ -41,6 +41,17 @@ pub fn resolveKeyPath(alloc: std.mem.Allocator, given: ?[]const u8) !KeyResoluti | |||
| 41 | return .{ .default = p }; | 41 | return .{ .default = p }; |
| 42 | } | 42 | } |
| 43 | 43 | ||
| 44 | /// Which spelling of the key path a command line meant: `--key` beats | ||
| 45 | /// `$MUX_KEY_FILE`, because the flag is the more specific statement of | ||
| 46 | /// intent, and an empty spelling of either means unset rather than a key | ||
| 47 | /// at the empty path. Both binaries' parsers call this — it lives beside | ||
| 48 | /// resolveKeyPath because it is the step before it, and a drift between | ||
| 49 | /// two copies would make them authenticate with different keys. | ||
| 50 | pub fn pickKey(flag: ?[]const u8, env: ?[]const u8) ?[]const u8 { | ||
| 51 | const k = flag orelse env orelse return null; | ||
| 52 | return if (k.len == 0) null else k; | ||
| 53 | } | ||
| 54 | |||
| 44 | pub fn keyPathFrom( | 55 | pub fn keyPathFrom( |
| 45 | alloc: std.mem.Allocator, | 56 | alloc: std.mem.Allocator, |
| 46 | xdg_config_home: ?[]const u8, | 57 | xdg_config_home: ?[]const u8, |
| @@ -153,6 +164,19 @@ test "keyPathFrom: XDG_CONFIG_HOME wins, HOME is the fallback, empty is unset" { | |||
| 153 | try std.testing.expectError(error.NoHome, keyPathFrom(a, null, null)); | 164 | try std.testing.expectError(error.NoHome, keyPathFrom(a, null, null)); |
| 154 | } | 165 | } |
| 155 | 166 | ||
| 167 | test "pickKey: the flag wins, and empty is unset either way" { | ||
| 168 | try std.testing.expectEqualStrings("/flag", pickKey("/flag", "/env").?); | ||
| 169 | try std.testing.expectEqualStrings("/env", pickKey(null, "/env").?); | ||
| 170 | try std.testing.expectEqualStrings("/flag", pickKey("/flag", null).?); | ||
| 171 | try std.testing.expectEqual(@as(?[]const u8, null), pickKey(null, null)); | ||
| 172 | // An empty environment variable is unset, per the basedir spec's rule | ||
| 173 | // and common sense. | ||
| 174 | try std.testing.expectEqual(@as(?[]const u8, null), pickKey(null, "")); | ||
| 175 | // An empty --key does NOT fall through to the environment: the flag | ||
| 176 | // was named, so it is the answer, and the answer is nothing. | ||
| 177 | try std.testing.expectEqual(@as(?[]const u8, null), pickKey("", "/env")); | ||
| 178 | } | ||
| 179 | |||
| 156 | test "logPathFrom: same shape against XDG_STATE_HOME" { | 180 | test "logPathFrom: same shape against XDG_STATE_HOME" { |
| 157 | const a = std.testing.allocator; | 181 | const a = std.testing.allocator; |
| 158 | const explicit = try logPathFrom(a, "/tmp/state", "/home/u"); | 182 | const explicit = try logPathFrom(a, "/tmp/state", "/home/u"); |