f2163b2a
fix: one owner for the no-runtime-directory sentence, and a 0700 the umask cannot mask
a73x 2026-09-03 17:30
Commit message
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -960,10 +960,13 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 960 | 960 | ||
| 961 | const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) { | 961 | const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) { |
| 962 | // Runtime path resolution failures use the same JSON shape as other | 962 | // Runtime path resolution failures use the same JSON shape as other |
| 963 | // agent-mode failures. | 963 | // agent-mode failures. The `error` is this mode's own word, but the |
| 964 | // DETAIL is `sockpath`'s one sentence: `mux a` had its own copy, | ||
| 965 | // and on a Mac whose /tmp/mux-<uid> is the thing at fault that copy | ||
| 966 | // sent the reader off to set a variable that was already correct. | ||
| 964 | error.NoRuntimeDir => return fail( | 967 | error.NoRuntimeDir => return fail( |
| 965 | "no default socket path", | 968 | "no default socket path", |
| 966 | "XDG_RUNTIME_DIR is unset; name the socket with --sock", | 969 | sockpath.no_runtime_dir_reason, |
| 967 | ), | 970 | ), |
| 968 | else => |e| return e, | 971 | else => |e| return e, |
| 969 | }; | 972 | }; |
src/sockpath.zig
| Old | New | ||
|---|---|---|---|
| @@ -33,22 +33,26 @@ pub fn tooLong(prefix: []const u8, path: []const u8) bool { | |||
| 33 | pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const u8 { | 33 | pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const u8 { |
| 34 | return defaultSockPath(alloc) catch |err| switch (err) { | 34 | return defaultSockPath(alloc) catch |err| switch (err) { |
| 35 | error.NoRuntimeDir => { | 35 | error.NoRuntimeDir => { |
| 36 | std.debug.print(no_runtime_dir, .{prefix}); | 36 | std.debug.print("{s}: {s}\n", .{ prefix, no_runtime_dir_reason }); |
| 37 | return null; | 37 | return null; |
| 38 | }, | 38 | }, |
| 39 | else => |e| return e, | 39 | else => |e| return e, |
| 40 | }; | 40 | }; |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | /// The sentence `defaultOrExplain` prints when there is no runtime | 43 | /// Why there is no default socket path, with no program prefix and no |
| 44 | /// directory. Two spellings because the two OSes fail for different | 44 | /// trailing newline, so the one sentence serves both the humans' |
| 45 | /// reasons: Linux has nothing to fall back to, while Darwin has a fallback | 45 | /// `{prefix}: {reason}` line and `mux a`'s JSON `detail`. Two spellings |
| 46 | /// that can be refused, and a user who reads "XDG_RUNTIME_DIR is unset" on | 46 | /// because the two OSes fail for different reasons: Linux has nothing to |
| 47 | /// a Mac would go and set a variable that was never the problem. | 47 | /// fall back to, while Darwin has a fallback that can be refused, and a |
| 48 | const no_runtime_dir = if (builtin.os.tag == .linux) | 48 | /// Mac user who reads "XDG_RUNTIME_DIR is unset" would go and set a |
| 49 | "{s}: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n" | 49 | /// variable that was never the problem. Public because `muxa.zig` is the |
| 50 | /// other caller and a second copy of this sentence is how the two drifted | ||
| 51 | /// apart in the first place. | ||
| 52 | pub const no_runtime_dir_reason = if (builtin.os.tag == .linux) | ||
| 53 | "XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)" | ||
| 50 | else | 54 | else |
| 51 | "{s}: no runtime directory: XDG_RUNTIME_DIR is unset and /tmp/mux-<uid> is not a 0700 directory owned by you (name a socket with --sock)\n"; | 55 | "no runtime directory: XDG_RUNTIME_DIR is unset and /tmp/mux-<uid> is not a 0700 directory owned by you (name a socket with --sock)"; |
| 52 | 56 | ||
| 53 | /// The directory the default daemon socket and every per-wall socket live | 57 | /// The directory the default daemon socket and every per-wall socket live |
| 54 | /// in, or null. `$XDG_RUNTIME_DIR` wins on every OS, because that is how | 58 | /// in, or null. `$XDG_RUNTIME_DIR` wins on every OS, because that is how |
| @@ -65,7 +69,7 @@ pub fn runtimeDir() ?[]const u8 { | |||
| 65 | const env = std.posix.getenv("XDG_RUNTIME_DIR"); | 69 | const env = std.posix.getenv("XDG_RUNTIME_DIR"); |
| 66 | if (builtin.os.tag == .linux) return env; | 70 | if (builtin.os.tag == .linux) return env; |
| 67 | const uid = std.posix.geteuid(); | 71 | const uid = std.posix.geteuid(); |
| 68 | const dir = runtimeDirFrom(env, uid, &darwin_dir_buf) orelse return null; | 72 | const dir = runtimeDirFrom(builtin.os.tag, env, uid, &darwin_dir_buf) orelse return null; |
| 69 | // Only the FALLBACK is checked. `$XDG_RUNTIME_DIR` is the user's own | 73 | // Only the FALLBACK is checked. `$XDG_RUNTIME_DIR` is the user's own |
| 70 | // statement of where their sockets go, and a rig that points it at a | 74 | // statement of where their sockets go, and a rig that points it at a |
| 71 | // directory of another mode is not making a privacy mistake. | 75 | // directory of another mode is not making a privacy mistake. |
| @@ -78,22 +82,35 @@ pub fn runtimeDir() ?[]const u8 { | |||
| 78 | /// two threads racing here write identical bytes. | 82 | /// two threads racing here write identical bytes. |
| 79 | var darwin_dir_buf: [32]u8 = undefined; | 83 | var darwin_dir_buf: [32]u8 = undefined; |
| 80 | 84 | ||
| 81 | /// `runtimeDir` with its environment named rather than read, because Zig | 85 | /// `runtimeDir` with its environment and its OS named rather than read. |
| 82 | /// tests cannot setenv and the fallback's spelling is a decision a Linux | 86 | /// Named environment because Zig tests cannot setenv; named OS because a |
| 83 | /// test still has to be able to state. | 87 | /// `builtin.os.tag` branch is comptime-eliminated, so the Darwin spelling |
| 84 | fn runtimeDirFrom(env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8 { | 88 | /// would go unasserted in a Linux gate and a typo in the format string |
| 89 | /// would ship green. | ||
| 90 | fn runtimeDirFrom(os: std.Target.Os.Tag, env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8 { | ||
| 85 | if (env) |e| return e; | 91 | if (env) |e| return e; |
| 86 | if (builtin.os.tag == .linux) return null; | 92 | if (os == .linux) return null; |
| 87 | return std.fmt.bufPrint(buf, "/tmp/mux-{d}", .{uid}) catch null; | 93 | return std.fmt.bufPrint(buf, "/tmp/mux-{d}", .{uid}) catch null; |
| 88 | } | 94 | } |
| 89 | 95 | ||
| 90 | /// True when PATH is a directory this uid owns with mode 0700 and no | 96 | /// True when PATH is a directory this uid owns with mode 0700 and no |
| 91 | /// symlink in the last step — the check tmux makes of /tmp/tmux-UID, for | 97 | /// symlink in the last step. tmux checks /tmp/tmux-UID for the same |
| 92 | /// the same reason: in a sticky world-writable /tmp, another uid can plant | 98 | /// reason and this is the stricter rule — tmux asks only that no OTHER |
| 93 | /// a symlink or a loose directory at our name before we get there, and a | 99 | /// bit is set, this asks for exactly 0700 — because in a sticky |
| 94 | /// socket bound through either is theirs to connect to. | 100 | /// world-writable /tmp another uid can plant a symlink or a loose |
| 101 | /// directory at our name before we get there, and a socket bound through | ||
| 102 | /// either is theirs to connect to. | ||
| 95 | fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool { | 103 | fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool { |
| 96 | std.posix.mkdir(path, 0o700) catch |e| if (e != error.PathAlreadyExists) return false; | 104 | if (std.posix.mkdir(path, 0o700)) |_| { |
| 105 | // mkdir's mode argument is masked by the umask, so a user carrying | ||
| 106 | // owner bits in theirs (0177, say) would get a 0600 directory that | ||
| 107 | // the exact-0700 check below then refuses on this run and every | ||
| 108 | // later one. chmod is not masked. Only the directory this call | ||
| 109 | // just created is set: one that was already there keeps whatever | ||
| 110 | // mode it has and is refused if that is wrong, because repairing | ||
| 111 | // it silently would hide another uid's plant rather than report it. | ||
| 112 | std.posix.fchmodat(std.posix.AT.FDCWD, path, 0o700, 0) catch return false; | ||
| 113 | } else |e| if (e != error.PathAlreadyExists) return false; | ||
| 97 | // NOFOLLOW: the stat has to describe the name we will bind under, not | 114 | // NOFOLLOW: the stat has to describe the name we will bind under, not |
| 98 | // whatever it points at. A symlink to a directory this uid does own | 115 | // whatever it points at. A symlink to a directory this uid does own |
| 99 | // passes every other line here and still hands the socket away. | 116 | // passes every other line here and still hands the socket away. |
| @@ -299,18 +316,21 @@ test "PathId: names the file it was taken from, not the path, and not a successo | |||
| 299 | test "runtimeDirFrom: the env var wins on every OS, and Darwin falls back to /tmp/mux-UID" { | 316 | test "runtimeDirFrom: the env var wins on every OS, and Darwin falls back to /tmp/mux-UID" { |
| 300 | var buf: [32]u8 = undefined; | 317 | var buf: [32]u8 = undefined; |
| 301 | 318 | ||
| 302 | // Named, so no OS invents its own place for the sockets a rig has | 319 | // The env var wins on BOTH, so no OS invents its own place for the |
| 303 | // already told it about: make e2e, soak and every hand rig point this | 320 | // sockets a rig has already told it about: make e2e, soak and every |
| 304 | // at a directory of their own and expect both sides to agree. | 321 | // hand rig point this at a directory of their own and expect both |
| 305 | try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom("/run/user/7", 501, &buf).?); | 322 | // sides to agree. |
| 306 | 323 | try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom(.linux, "/run/user/7", 501, &buf).?); | |
| 307 | if (builtin.os.tag == .linux) { | 324 | try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom(.macos, "/run/user/7", 501, &buf).?); |
| 308 | // No guess. Two binaries that each guessed would disagree, and the | 325 | |
| 309 | // client would start a second daemon beside the one already up. | 326 | // Linux does not guess. Two binaries that each guessed would disagree, |
| 310 | try std.testing.expect(runtimeDirFrom(null, 501, &buf) == null); | 327 | // and the client would start a second daemon beside the one already up. |
| 311 | } else { | 328 | try std.testing.expect(runtimeDirFrom(.linux, null, 501, &buf) == null); |
| 312 | try std.testing.expectEqualStrings("/tmp/mux-501", runtimeDirFrom(null, 501, &buf).?); | 329 | |
| 313 | } | 330 | // And the Darwin spelling is asserted HERE, in the Linux gate, because |
| 331 | // the OS is an argument rather than a comptime branch: a typo in the | ||
| 332 | // format string is caught by CI rather than by the first Mac to run it. | ||
| 333 | try std.testing.expectEqualStrings("/tmp/mux-501", runtimeDirFrom(.macos, null, 501, &buf).?); | ||
| 314 | } | 334 | } |
| 315 | 335 | ||
| 316 | test "ensureOwnedDir: creates 0700, accepts its own creation, refuses a symlink and a group-readable dir" { | 336 | test "ensureOwnedDir: creates 0700, accepts its own creation, refuses a symlink and a group-readable dir" { |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -1866,10 +1866,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1866 | 1866 | ||
| 1867 | // ssh's prompts get somewhere to go, on a TERMINAL only: without one | 1867 | // ssh's prompts get somewhere to go, on a TERMINAL only: without one |
| 1868 | // there is no popup, so ssh keeps its prompts and fails them visibly. | 1868 | // there is no popup, so ssh keeps its prompts and fails them visibly. |
| 1869 | // The runtime directory and no fallback: a shared /tmp socket would be | 1869 | // `sockpath.runtimeDir` is the one place that names the directory, so |
| 1870 | // a password prompt any local user could answer. `sockpath.runtimeDir` | 1870 | // the popup lands beside the daemon socket on whatever OS this is — |
| 1871 | // is the one place that names it, so the popup lands beside the daemon | 1871 | // and what keeps this from being a password prompt any other local |
| 1872 | // socket on whatever OS this is. | 1872 | // user could answer is that directory's privacy, which each OS buys |
| 1873 | // differently. On Linux it is $XDG_RUNTIME_DIR with no fallback at | ||
| 1874 | // all, so there is no /tmp path to share. On Darwin the fallback is | ||
| 1875 | // /tmp/mux-<uid>, inside a world-writable directory, and what carries | ||
| 1876 | // the guarantee there is `sockpath.runtimeDir` refusing that name | ||
| 1877 | // unless it is a directory this uid owns at mode 0700. | ||
| 1873 | var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined; | 1878 | var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 1874 | if (is_tty) { | 1879 | if (is_tty) { |
| 1875 | if (sockpath.runtimeDir()) |rt| { | 1880 | if (sockpath.runtimeDir()) |rt| { |