a73x

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
fix: one owner for the no-runtime-directory sentence, and a 0700 the umask cannot mask

`mux a` carried its own copy of the refusal, which on a Mac would send a
reader off to set XDG_RUNTIME_DIR when the fault is /tmp/mux-<uid>; it now
prints sockpath's sentence, and the Linux bytes are unchanged. mkdir's mode
argument is masked by the umask, so the fresh directory is chmod'ed to 0700
rather than trusted; an existing one is still refused, never repaired.
`runtimeDirFrom` takes the OS as an argument so the Darwin spelling is
asserted in the Linux gate instead of being comptime-eliminated out of it.
The wall's askpass comment said the runtime directory has no fallback,
which is now true only on Linux.

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

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| {