f6013e14
fix: the default socket path refuses to be guessed
a73x 2026-08-23 05:56
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -306,10 +306,29 @@ pub fn main() !u8 { | |||
| 306 | .ok => |o| o, | 306 | .ok => |o| o, |
| 307 | }; | 307 | }; |
| 308 | 308 | ||
| 309 | // The same row that exempts `--version` and `keygen` from the sun_path | ||
| 310 | // bound below exempts them from HAVING a path: the default can now | ||
| 311 | // refuse (sockpath.defaultSockPath), and a version string must never | ||
| 312 | // fail on the environment. So a verb that touches no socket gets no | ||
| 313 | // path rather than a path it must first survive resolving. | ||
| 314 | const uses_socket = specForCmd(o.cmd).uses_socket; | ||
| 309 | const sock_path = if (o.sock) |s| | 315 | const sock_path = if (o.sock) |s| |
| 310 | try alloc.dupe(u8, s) | 316 | try alloc.dupe(u8, s) |
| 317 | else if (!uses_socket) | ||
| 318 | try alloc.dupe(u8, "") | ||
| 311 | else | 319 | else |
| 312 | try sockpath.defaultSockPath(alloc); | 320 | sockpath.defaultSockPath(alloc) catch |err| switch (err) { |
| 321 | // Named in this binary's own words, like the sun_path bound | ||
| 322 | // below: sockpath owns the rule, each binary owns the wording. | ||
| 323 | error.NoRuntimeDir => { | ||
| 324 | std.debug.print( | ||
| 325 | "muxd: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", | ||
| 326 | .{}, | ||
| 327 | ); | ||
| 328 | return 1; | ||
| 329 | }, | ||
| 330 | else => |e| return e, | ||
| 331 | }; | ||
| 313 | defer alloc.free(sock_path); | 332 | defer alloc.free(sock_path); |
| 314 | 333 | ||
| 315 | // The sun_path bound (sockpath.max_sun_path). Checked here, once, | 334 | // The sun_path bound (sockpath.max_sun_path). Checked here, once, |
| @@ -320,7 +339,6 @@ pub fn main() !u8 { | |||
| 320 | // order — neither touches the socket, and neither should be refused | 339 | // order — neither touches the socket, and neither should be refused |
| 321 | // over it. Which verbs those are is their rows' business, not this | 340 | // over it. Which verbs those are is their rows' business, not this |
| 322 | // line's. | 341 | // line's. |
| 323 | const uses_socket = specForCmd(o.cmd).uses_socket; | ||
| 324 | if (uses_socket and sock_path.len > sockpath.max_sun_path) { | 342 | if (uses_socket and sock_path.len > sockpath.max_sun_path) { |
| 325 | std.debug.print( | 343 | std.debug.print( |
| 326 | "muxd: socket path too long ({d} bytes, max {d}): {s}\n", | 344 | "muxd: socket path too long ({d} bytes, max {d}): {s}\n", |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -392,7 +392,16 @@ pub fn main() !u8 { | |||
| 392 | const sock_path = if (t.sock) |s| | 392 | const sock_path = if (t.sock) |s| |
| 393 | try alloc.dupe(u8, s) | 393 | try alloc.dupe(u8, s) |
| 394 | else | 394 | else |
| 395 | try sockpath.defaultSockPath(alloc); | 395 | sockpath.defaultSockPath(alloc) catch |err| switch (err) { |
| 396 | error.NoRuntimeDir => { | ||
| 397 | std.debug.print( | ||
| 398 | "mux: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", | ||
| 399 | .{}, | ||
| 400 | ); | ||
| 401 | return 1; | ||
| 402 | }, | ||
| 403 | else => |e| return e, | ||
| 404 | }; | ||
| 396 | defer alloc.free(sock_path); | 405 | defer alloc.free(sock_path); |
| 397 | 406 | ||
| 398 | // Before the PATH search, before auto-start, before the dial: | 407 | // Before the PATH search, before auto-start, before the dial: |
src/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -1034,7 +1034,15 @@ pub fn main() !u8 { | |||
| 1034 | return dispatch(alloc, &conn, o, deadline); | 1034 | return dispatch(alloc, &conn, o, deadline); |
| 1035 | } | 1035 | } |
| 1036 | 1036 | ||
| 1037 | const sock_path = if (o.sock) |s| s else try sockpath.defaultSockPath(alloc); | 1037 | const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) { |
| 1038 | // An agent reads replies, not stderr, so this one refuses through | ||
| 1039 | // the same JSON shape as every other muxa failure. | ||
| 1040 | error.NoRuntimeDir => return fail( | ||
| 1041 | "no default socket path", | ||
| 1042 | "XDG_RUNTIME_DIR is unset; name the socket with --sock", | ||
| 1043 | ), | ||
| 1044 | else => |e| return e, | ||
| 1045 | }; | ||
| 1038 | 1046 | ||
| 1039 | // Refused by name, before connecting: connect would bounce a too-long | 1047 | // Refused by name, before connecting: connect would bounce a too-long |
| 1040 | // path off the kernel with a generic error, and the path is the whole | 1048 | // path off the kernel with a generic error, and the path is the whole |
src/sockpath.zig
| Old | New | ||
|---|---|---|---|
| @@ -19,11 +19,25 @@ pub const max_sun_path = 107; | |||
| 19 | /// part of a socket path's identity too: it is what makes two binaries | 19 | /// part of a socket path's identity too: it is what makes two binaries |
| 20 | /// started with no `--sock` land on the SAME daemon, so it lives here with | 20 | /// started with no `--sock` land on the SAME daemon, so it lives here with |
| 21 | /// the bound rather than once per binary. | 21 | /// the bound rather than once per binary. |
| 22 | /// | ||
| 23 | /// Which is why there is no fallback when `$XDG_RUNTIME_DIR` is unset. | ||
| 24 | /// A guessed `/tmp/muxd-<uid>.sock` used to stand in, and it broke the | ||
| 25 | /// one property above: a tmux server started before logind exported the | ||
| 26 | /// variable hands every pane an environment without it, so panes went to | ||
| 27 | /// /tmp while the daemon a pane had started owned the runtime dir — one | ||
| 28 | /// uid, one box, two daemons, and `muxd stop` reporting nothing there. | ||
| 29 | /// A default that cannot make two binaries agree is not a default, so | ||
| 30 | /// this refuses and the caller names the path. | ||
| 22 | pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { | 31 | pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { |
| 23 | if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| { | 32 | return sockPathFrom(alloc, std.posix.getenv("XDG_RUNTIME_DIR")); |
| 24 | return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir}); | 33 | } |
| 25 | } | 34 | |
| 26 | return std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); | 35 | /// `defaultSockPath` with its environment named rather than read, because |
| 36 | /// Zig tests cannot setenv and this is the one decision here a test has to | ||
| 37 | /// be able to state. | ||
| 38 | fn sockPathFrom(alloc: std.mem.Allocator, xdg_runtime: ?[]const u8) ![]const u8 { | ||
| 39 | const dir = xdg_runtime orelse return error.NoRuntimeDir; | ||
| 40 | return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir}); | ||
| 27 | } | 41 | } |
| 28 | 42 | ||
| 29 | /// A socket file's identity at the moment it was bound, so teardown can | 43 | /// A socket file's identity at the moment it was bound, so teardown can |
| @@ -102,6 +116,17 @@ pub fn claim(path: []const u8) !void { | |||
| 102 | }; | 116 | }; |
| 103 | } | 117 | } |
| 104 | 118 | ||
| 119 | test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" { | ||
| 120 | const alloc = std.testing.allocator; | ||
| 121 | |||
| 122 | // The refusal, not a guess — the field incident is on defaultSockPath. | ||
| 123 | try std.testing.expectError(error.NoRuntimeDir, sockPathFrom(alloc, null)); | ||
| 124 | |||
| 125 | const found = try sockPathFrom(alloc, "/run/user/1000"); | ||
| 126 | defer alloc.free(found); | ||
| 127 | try std.testing.expectEqualStrings("/run/user/1000/muxd.sock", found); | ||
| 128 | } | ||
| 129 | |||
| 105 | test "PathId: names the file it was taken from, not the path, and not a successor" { | 130 | test "PathId: names the file it was taken from, not the path, and not a successor" { |
| 106 | const testtmp = @import("testtmp"); | 131 | const testtmp = @import("testtmp"); |
| 107 | var tmp = try testtmp.TmpDir.make(); | 132 | var tmp = try testtmp.TmpDir.make(); |
src/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -485,10 +485,10 @@ test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" { | |||
| 485 | try std.fs.cwd().makePath(std.fs.path.dirname(log).?); | 485 | try std.fs.cwd().makePath(std.fs.path.dirname(log).?); |
| 486 | try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed }); | 486 | try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed }); |
| 487 | 487 | ||
| 488 | // The attach shape must leave it alone. Without this, one `mux` over | 488 | // The attach shape must leave it alone. Without this, one `mux --sock` |
| 489 | // ssh — no XDG_RUNTIME_DIR, so a /tmp socket nobody happens to be | 489 | // naming a path nobody happens to be serving zeroes the log of the |
| 490 | // serving — zeroes the log of the interactive daemon still writing to | 490 | // interactive daemon still writing to it, and the operator reads a |
| 491 | // it, and the operator reads a hole where the crash was. | 491 | // hole where the crash was. |
| 492 | try std.testing.expectError(error.NeverAnswered, ensureDaemon( | 492 | try std.testing.expectError(error.NeverAnswered, ensureDaemon( |
| 493 | std.testing.allocator, | 493 | std.testing.allocator, |
| 494 | stub, | 494 | stub, |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -7873,6 +7873,37 @@ assert_stopped "$SOCK51" "$D51PID" "in-band size" "$OUT.inbstop" | |||
| 7873 | D51PID="" | 7873 | D51PID="" |
| 7874 | ok "a resize is reported in-band to an app that turned on mode 2048" | 7874 | ok "a resize is reported in-band to an app that turned on mode 2048" |
| 7875 | 7875 | ||
| 7876 | # --- The default socket path refuses to be guessed. Field incident: a tmux | ||
| 7877 | # server started before logind exported XDG_RUNTIME_DIR handed every pane an | ||
| 7878 | # environment without it, so a pane's `muxd stop` looked at the /tmp guess | ||
| 7879 | # while the daemon a pane had started owned $XDG_RUNTIME_DIR/muxd.sock — one | ||
| 7880 | # uid, one box, two daemons. `env -u` rather than the harness environment: | ||
| 7881 | # on a box that HAS the variable the unset case is otherwise never taken. | ||
| 7882 | # No convergence: nothing gets far enough to attach. | ||
| 7883 | for BIN in "$MUXD stats" "$MUX"; do | ||
| 7884 | # shellcheck disable=SC2086 # the two-word muxd form is deliberate | ||
| 7885 | if env -u XDG_RUNTIME_DIR $BIN > "$OUT.nort" 2>&1; then | ||
| 7886 | echo "e2e FAIL: '$BIN' with no XDG_RUNTIME_DIR did not refuse"; exit 1 | ||
| 7887 | fi | ||
| 7888 | grep -q 'XDG_RUNTIME_DIR is unset' "$OUT.nort" || { | ||
| 7889 | echo "e2e FAIL: '$BIN' refusal does not name the variable:"; cat "$OUT.nort"; exit 1; } | ||
| 7890 | grep -q -- '--sock' "$OUT.nort" || { | ||
| 7891 | echo "e2e FAIL: '$BIN' refusal does not say how to fix it:"; cat "$OUT.nort"; exit 1; } | ||
| 7892 | done | ||
| 7893 | # muxa answers agents in JSON, so its refusal has to arrive as a reply. | ||
| 7894 | env -u XDG_RUNTIME_DIR "$MUXA" status > "$OUT.nort.a" 2>&1 && { | ||
| 7895 | echo "e2e FAIL: muxa with no XDG_RUNTIME_DIR did not refuse"; exit 1; } | ||
| 7896 | grep -q '"error":"no default socket path"' "$OUT.nort.a" || { | ||
| 7897 | echo "e2e FAIL: muxa refusal is not a JSON error:"; cat "$OUT.nort.a"; exit 1; } | ||
| 7898 | # The socketless verbs must survive the same environment: --version is how an | ||
| 7899 | # operator checks whether the scp landed, and it owes nothing to a socket. | ||
| 7900 | env -u XDG_RUNTIME_DIR "$MUXD" --version | grep -q '^muxd 0\.' || { | ||
| 7901 | echo "e2e FAIL: muxd --version needs XDG_RUNTIME_DIR"; exit 1; } | ||
| 7902 | env -u XDG_RUNTIME_DIR "$MUX" --version | grep -q '^mux 0\.' || { | ||
| 7903 | echo "e2e FAIL: mux --version needs XDG_RUNTIME_DIR"; exit 1; } | ||
| 7904 | rm -f "$OUT.nort" "$OUT.nort.a" | ||
| 7905 | ok "no XDG_RUNTIME_DIR: the default path is refused by name, --version is not" | ||
| 7906 | |||
| 7876 | # --- an offerer that cannot answer is hung up on, and ssh falls through ---- | 7907 | # --- an offerer that cannot answer is hung up on, and ssh falls through ---- |
| 7877 | # | 7908 | # |
| 7878 | # An agent_offer is a declaration, not a capability (decisions.md | 7909 | # An agent_offer is a declaration, not a capability (decisions.md |
| @@ -8089,14 +8120,19 @@ DPID="" | |||
| 8089 | # grid as the text `cat -v` made of it; the client's replica carries the | 8120 | # grid as the text `cat -v` made of it; the client's replica carries the |
| 8090 | # same rows and would add nothing. | 8121 | # same rows and would add nothing. |
| 8091 | # | 8122 | # |
| 8092 | # The 66th is the mute offerer, and no convergence point for the 58th's | 8123 | # The 66th is the refused default socket path, and no convergence point: |
| 8124 | # nothing attaches, so there is no grid on either side — it asserts on an | ||
| 8125 | # exit code and on a message naming the variable, because the guess it | ||
| 8126 | # replaced also exited nonzero and only the wording tells them apart. | ||
| 8127 | # | ||
| 8128 | # The 67th is the mute offerer, and no convergence point for the 58th's | ||
| 8093 | # reason: its subject is whether ssh in the session got an answer, and how | 8129 | # reason: its subject is whether ssh in the session got an answer, and how |
| 8094 | # fast, which is a side channel no grid carries. Last in the file because | 8130 | # fast, which is a side channel no grid carries. Last in the file because |
| 8095 | # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that | 8131 | # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that |
| 8096 | # has to CONT before it kills is cheaper to reason about with nothing | 8132 | # has to CONT before it kills is cheaper to reason about with nothing |
| 8097 | # after it. | 8133 | # after it. |
| 8098 | [ "$OK_COUNT" = "66" ] || { | 8134 | [ "$OK_COUNT" = "67" ] || { |
| 8099 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 66 —" | 8135 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 67 —" |
| 8100 | echo " a scenario was added (update the pin) or silently lost" | 8136 | echo " a scenario was added (update the pin) or silently lost" |
| 8101 | exit 1 | 8137 | exit 1 |
| 8102 | } | 8138 | } |