f4449add
fix: one guard for every dial at a path that may not be a socket
a73x 2026-09-03 20:08
Commit message
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. |