a73x

f4449add

fix: one guard for every dial at a path that may not be a socket

a73x   2026-09-03 20:08

Commit message
fix: one guard for every dial at a path that may not be a socket

claim stats before it connects; answers did not, and answers runs BEFORE
claim on three user-facing paths — mux --sock, mux d start -d --sock and
mux d endpoint --sock. So the panic that branch set out to fix was closed
in one of the two places that reach it, and a macOS user who mistyped
--sock still aborted. dial.dial, proxy.pump and mux d stop had the same
bare connect, and askpass reached one through MUX_ASKPASS_SOCK.

The rule now lives once, in sockpath.connectSocket, and those five call it.
Measured on both systems: Linux answers a connect to a regular file, a
directory, a symlink to a file, a fifo and /dev/null with ECONNREFUSED,
all five identical; Darwin answers ENOTSOCK for the same five, and
std.posix.connect maps that to unreachable because for a LOCAL fd it can
only mean the caller passed a non-socket. The stat comes first and a
non-socket is reported as ConnectionRefused, which is Linux's own answer —
so nothing on Linux moves and Darwin now says what Linux says, refusal
wording included.

dial gains a sockpath import and proxy its first import of ours; proxy's
invariant bans the wire contract, and sockpath carries none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

build.zig
Old New
@@ -195,7 +195,7 @@ const mod_table = [_]ModSpec{
195 // pump that knows nothing about the protocol it carries. `testtmp` is 195 // pump that knows nothing about the protocol it carries. `testtmp` is
196 // the one exception and does not weaken that — it hands its tests a 196 // the one exception and does not weaken that — it hands its tests a
197 // short directory to put a socket in and knows nothing about the bytes. 197 // short directory to put a socket in and knows nothing about the bytes.
198 .{ .name = "proxy", .path = "src/proxy.zig", .link_libc = true, .test_imports = &.{"testtmp"} }, 198 .{ .name = "proxy", .path = "src/proxy.zig", .link_libc = true, .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} },
199 // Reflection over a caller's options struct, so it imports nothing: the 199 // Reflection over a caller's options struct, so it imports nothing: the
200 // struct is the flag table and the parser learns it at comptime. 200 // struct is the flag table and the parser learns it at comptime.
201 .{ .name = "cliflags", .path = "src/cli/flags.zig" }, 201 .{ .name = "cliflags", .path = "src/cli/flags.zig" },
@@ -209,7 +209,7 @@ const mod_table = [_]ModSpec{
209 // is the attach encoders, `link` is the round trip's wait — an embedder 209 // is the attach encoders, `link` is the round trip's wait — an embedder
210 // reaches a daemon by linking those two and this, instead of the whole 210 // reaches a daemon by linking those two and this, instead of the whole
211 // client module. 211 // client module.
212 .{ .name = "dial", .path = "src/dial.zig", .link_libc = true, .imports = &.{ "term", "link" }, .quic_tests = true }, 212 .{ .name = "dial", .path = "src/dial.zig", .link_libc = true, .imports = &.{ "term", "link", "sockpath" }, .quic_tests = true },
213 // The live connection itself — fd, pipe or QUIC — and the one wait-for-a- 213 // The live connection itself — fd, pipe or QUIC — and the one wait-for-a-
214 // frame loop. `term` for frames, `quic` for the third arm; policy stays 214 // frame loop. `term` for frames, `quic` for the third arm; policy stays
215 // with the rows that import this one. 215 // with the rows that import this one.
src/cli/main.zig
Old New
@@ -655,7 +655,7 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
655 /// only for socket unlink. Both an already-absent daemon and a completed stop 655 /// only for socket unlink. Both an already-absent daemon and a completed stop
656 /// return zero, making the command idempotent for scripts. 656 /// return zero, making the command idempotent for scripts.
657 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 657 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
658 const stream = std.net.connectUnixSocket(sock_path) catch { 658 const stream = dial.dial(sock_path) catch {
659 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path}); 659 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path});
660 return 0; 660 return 0;
661 }; 661 };
src/client/askpass.zig
Old New
@@ -10,6 +10,7 @@
10 const std = @import("std"); 10 const std = @import("std");
11 // `serve_mod` and not `serve`: Listener has its own `serve` method, and 11 // `serve_mod` and not `serve`: Listener has its own `serve` method, and
12 // inside the struct the bare name is ambiguous. 12 // inside the struct the bare name is ambiguous.
13 const dial = @import("dial");
13 const serve_mod = @import("serve"); 14 const serve_mod = @import("serve");
14 const xdg = @import("xdg"); 15 const xdg = @import("xdg");
15 const client_os = @import("client_os"); 16 const client_os = @import("client_os");
@@ -384,7 +385,10 @@ pub const Listener = struct {
384 /// byte ssh tries to log in with. Every failure is exit 1 with NOTHING written, 385 /// byte ssh tries to log in with. Every failure is exit 1 with NOTHING written,
385 /// which ssh reads as a refused prompt. 386 /// which ssh reads as a refused prompt.
386 pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 { 387 pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 {
387 const stream = std.net.connectUnixSocket(sock) catch return 1; 388 // Through `dial`, so `MUX_ASKPASS_SOCK` naming something that is not a
389 // socket is this function's exit 1 on both systems rather than a panic
390 // inside ssh's password helper on one of them.
391 const stream = dial.dial(sock) catch return 1;
388 defer stream.close(); 392 defer stream.close();
389 var line: [prompt_max + 2]u8 = undefined; 393 var line: [prompt_max + 2]u8 = undefined;
390 line[0] = @intFromEnum(kind); 394 line[0] = @intFromEnum(kind);
src/dial.zig
Old New
@@ -4,19 +4,23 @@
4 //! 4 //!
5 //! Connecting a client to a daemon is the operation this product exists to 5 //! Connecting a client to a daemon is the operation this product exists to
6 //! perform, so it is a callable primitive rather than four lines every caller 6 //! perform, so it is a callable primitive rather than four lines every caller
7 //! writes again. It imports `term` for the attach encoders and `link` for the 7 //! writes again. It imports `term` for the attach encoders, `link` for the
8 //! one round trip's wait, and nothing else: an embedder that wants to reach a 8 //! one round trip's wait and `sockpath` for the connect itself, and nothing
9 //! daemon links this, `term` and `link`, not the client module's transports, 9 //! else: an embedder that wants to reach a daemon links those three, not the
10 //! hosts file and pane tree. 10 //! client module's transports, hosts file and pane tree. `sockpath` is here
11 //! rather than a bare `std.net.connectUnixSocket` because the two kernels
12 //! disagree about a path that is not a socket and one of them turns it into a
13 //! panic; `sockpath.connectSocket` says which and why.
11 const std = @import("std"); 14 const std = @import("std");
12 const proto = @import("term").protocol; 15 const proto = @import("term").protocol;
13 const link_mod = @import("link"); 16 const link_mod = @import("link");
17 const sockpath = @import("sockpath");
14 18
15 /// The connection alone, with no frame sent. What an observer verb, a probe 19 /// The connection alone, with no frame sent. What an observer verb, a probe
16 /// or a client resuming from a watermark wants: the first bytes on the 20 /// or a client resuming from a watermark wants: the first bytes on the
17 /// socket are then the caller's to choose. 21 /// socket are then the caller's to choose.
18 pub fn dial(sock_path: []const u8) !std.net.Stream { 22 pub fn dial(sock_path: []const u8) !std.net.Stream {
19 return std.net.connectUnixSocket(sock_path); 23 return sockpath.connectSocket(sock_path);
20 } 24 }
21 25
22 /// Dial and attach to the daemon's default session at this size. The 26 /// Dial and attach to the daemon's default session at this size. The
src/proxy.zig
Old New
@@ -2,7 +2,13 @@
2 //! socket. Deliberately frame-agnostic — the transport thesis is that if an 2 //! socket. Deliberately frame-agnostic — the transport thesis is that if an
3 //! opaque byte pipe carries the protocol, transport is a swap and not a 3 //! opaque byte pipe carries the protocol, transport is a swap and not a
4 //! redesign. Keep this file's import list empty of `protocol`. 4 //! redesign. Keep this file's import list empty of `protocol`.
5 //!
6 //! `sockpath` is the one module of ours here, and it carries no wire contract:
7 //! it owns the question "what is at this path", which the connect below has to
8 //! ask because the two kernels answer a non-socket differently and one of them
9 //! answers with a panic.
5 const std = @import("std"); 10 const std = @import("std");
11 const sockpath = @import("sockpath");
6 const TmpDir = @import("testtmp").TmpDir; 12 const TmpDir = @import("testtmp").TmpDir;
7 13
8 /// Make a hangup surface as EPIPE from write() instead of killing the 14 /// Make a hangup surface as EPIPE from write() instead of killing the
@@ -27,7 +33,7 @@ pub fn run(sock_path: []const u8) !u8 {
27 /// local daemon. Exits 0 when either side hangs up cleanly, 1 if the daemon 33 /// local daemon. Exits 0 when either side hangs up cleanly, 1 if the daemon
28 /// socket cannot be reached or a read or write fails. 34 /// socket cannot be reached or a read or write fails.
29 pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8) !u8 { 35 pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8) !u8 {
30 const stream = std.net.connectUnixSocket(sock_path) catch { 36 const stream = sockpath.connectSocket(sock_path) catch {
31 std.debug.print("mux d proxy: cannot connect to {s}\n", .{sock_path}); 37 std.debug.print("mux d proxy: cannot connect to {s}\n", .{sock_path});
32 return 1; 38 return 1;
33 }; 39 };
src/sockpath.zig
Old New
@@ -153,10 +153,37 @@ pub const PathId = struct {
153 } 153 }
154 }; 154 };
155 155
156 /// Connect to a path that is supposed to BE a unix socket, with the one
157 /// disagreement between the two kernels settled here instead of at each
158 /// caller. Every dial in this product that names a path a USER typed comes
159 /// through this: `dial.dial`, `proxy.pump`, `answers` below.
160 ///
161 /// Linux answers a connect to anything at the path that is not a socket with
162 /// ECONNREFUSED — measured 2026-09-03 for a regular file, a directory, a
163 /// symlink to a file, a fifo and /dev/null, all five identical. Darwin answers
164 /// ENOTSOCK for the same five, and `std.posix.connect` maps that to
165 /// `unreachable`, because for a LOCAL fd it can only mean the caller passed
166 /// something that is not a socket. Our fd always is; on Darwin the errno is
167 /// about the far end. So `mux --sock notes.txt` aborted on a Mac where it
168 /// refuses on Linux, and so did `mux d proxy`, `mux d stop` and every dial
169 /// underneath the wall.
170 ///
171 /// The stat therefore comes first, and a non-socket is reported as
172 /// ConnectionRefused — Linux's own answer, so nothing on Linux moves and
173 /// Darwin says what Linux says. A path the stat cannot read at all falls
174 /// through to the connect, which names it (ENOENT for a missing path, and a
175 /// dangling symlink reads as missing on both).
176 pub fn connectSocket(path: []const u8) !std.net.Stream {
177 if (std.posix.fstatat(std.posix.AT.FDCWD, path, 0)) |st| {
178 if (!std.posix.S.ISSOCK(st.mode)) return error.ConnectionRefused;
179 } else |_| {}
180 return std.net.connectUnixSocket(path);
181 }
182
156 /// Whether anything LISTENS at `path` now: a read, so every connect 183 /// Whether anything LISTENS at `path` now: a read, so every connect
157 /// error is a no. The decision needing the errno is `claim`. 184 /// error is a no. The decision needing the errno is `claim`.
158 pub fn answers(path: []const u8) bool { 185 pub fn answers(path: []const u8) bool {
159 const s = std.net.connectUnixSocket(path) catch return false; 186 const s = connectSocket(path) catch return false;
160 s.close(); 187 s.close();
161 return true; 188 return true;
162 } 189 }
@@ -264,6 +291,41 @@ test "answers: a live listener, a stale socket file, and a path with nothing on
264 try std.testing.expect(!answers("/" ++ "x" ** 200)); 291 try std.testing.expect(!answers("/" ++ "x" ** 200));
265 } 292 }
266 293
294 test "connectSocket: a path that is not a socket is refused, never a panic" {
295 // The regression a Mac found. On Linux this passes with or without the
296 // stat, because the kernel answers ECONNREFUSED for a regular file just
297 // as it does for a dead socket. On Darwin the same connect answers
298 // ENOTSOCK, which `std.posix.connect` maps to `unreachable` — so without
299 // the guard this test does not fail, it ABORTS the test binary, and every
300 // caller that names a path a user typed aborts with it.
301 const testtmp = @import("testtmp");
302 var tmp = try testtmp.TmpDir.make();
303 defer tmp.cleanup();
304 try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = "mux must not connect to this" });
305
306 var buf: [64]u8 = undefined;
307 const file = try std.fmt.bufPrint(&buf, "{s}/notes.txt", .{tmp.path()});
308
309 // The errno is Linux's own for this path, so the wording every caller
310 // already prints for "nothing is listening" is what a Mac user sees too.
311 try std.testing.expectError(error.ConnectionRefused, connectSocket(file));
312 try std.testing.expect(!answers(file));
313
314 // A DIRECTORY is the same story and the same errno on both, and it is
315 // what `--sock` pointed at a state directory looks like.
316 try std.testing.expect(!answers(tmp.path()));
317
318 // And the file is still there: this is a read, and nothing in the
319 // refusal path may touch what it refused.
320 var back: [64]u8 = undefined;
321 const f = try std.fs.cwd().openFile(file, .{});
322 defer f.close();
323 try std.testing.expectEqualStrings(
324 "mux must not connect to this",
325 back[0..try f.readAll(&back)],
326 );
327 }
328
267 test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" { 329 test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" {
268 // chmod does not bite root, so the connect would succeed and the test 330 // chmod does not bite root, so the connect would succeed and the test
269 // would assert the opposite of what it is named for. 331 // would assert the opposite of what it is named for.