a73x

2a032b17

feat: the Darwin runtime dir is XDG_RUNTIME_DIR, else /tmp/mux-UID checked like tmux does

a73x   2026-09-03 17:17

Commit message
feat: the Darwin runtime dir is XDG_RUNTIME_DIR, else /tmp/mux-UID checked like tmux does

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

src/sockpath.zig
Old New
@@ -33,27 +33,72 @@ 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( 36 std.debug.print(no_runtime_dir, .{prefix});
37 "{s}: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n",
38 .{prefix},
39 );
40 return null; 37 return null;
41 }, 38 },
42 else => |e| return e, 39 else => |e| return e,
43 }; 40 };
44 } 41 }
45 42
43 /// The sentence `defaultOrExplain` prints when there is no runtime
44 /// directory. Two spellings because the two OSes fail for different
45 /// reasons: Linux has nothing to fall back to, while Darwin has a fallback
46 /// that can be refused, and a user who reads "XDG_RUNTIME_DIR is unset" on
47 /// a Mac would go and set a variable that was never the problem.
48 const no_runtime_dir = if (builtin.os.tag == .linux)
49 "{s}: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n"
50 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";
52
46 /// The directory the default daemon socket and every per-wall socket live 53 /// The directory the default daemon socket and every per-wall socket live
47 /// in, or null. On Linux that is `$XDG_RUNTIME_DIR` and there is NO 54 /// in, or null. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
48 /// fallback: a guess cannot make two binaries agree on one daemon, so the 55 /// every isolated rig (make e2e, soak, a hand rig) keeps its sockets apart
49 /// caller names it with --sock. Another OS spells its own default here, 56 /// from the user's. Linux has NO fallback: a guess cannot make two binaries
50 /// once, so the daemon, the client and the askpass listener agree by 57 /// agree on one daemon, so the caller names it with --sock. Darwin falls
51 /// construction. 58 /// back to /tmp/mux-<uid>, created 0700 and checked on every ask — the
59 /// spec's three candidates ($TMPDIR, ~/Library/Caches, ~/.local/state) all
60 /// overflow sun_path at the longest name mux creates (dir + 68 + pid
61 /// digits against 103; measured 2026-09-03, docs/decisions.md), and /tmp
62 /// is sticky and world-writable, so the per-uid directory is what carries
63 /// the privacy, as tmux's /tmp/tmux-UID does.
52 pub fn runtimeDir() ?[]const u8 { 64 pub fn runtimeDir() ?[]const u8 {
53 return switch (builtin.os.tag) { 65 const env = std.posix.getenv("XDG_RUNTIME_DIR");
54 .linux => std.posix.getenv("XDG_RUNTIME_DIR"), 66 if (builtin.os.tag == .linux) return env;
55 else => @compileError("mux has no default runtime directory for " ++ @tagName(builtin.os.tag)), 67 const uid = std.posix.geteuid();
56 }; 68 const dir = runtimeDirFrom(env, uid, &darwin_dir_buf) orelse return null;
69 // 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
71 // directory of another mode is not making a privacy mistake.
72 if (env == null and !ensureOwnedDir(dir, uid)) return null;
73 return dir;
74 }
75
76 /// Process-wide because the returned slice outlives the call and this
77 /// module allocates nothing. Every writer formats the same uid into it, so
78 /// two threads racing here write identical bytes.
79 var darwin_dir_buf: [32]u8 = undefined;
80
81 /// `runtimeDir` with its environment named rather than read, because Zig
82 /// tests cannot setenv and the fallback's spelling is a decision a Linux
83 /// test still has to be able to state.
84 fn runtimeDirFrom(env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8 {
85 if (env) |e| return e;
86 if (builtin.os.tag == .linux) return null;
87 return std.fmt.bufPrint(buf, "/tmp/mux-{d}", .{uid}) catch null;
88 }
89
90 /// 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
92 /// the same reason: in a sticky world-writable /tmp, another uid can plant
93 /// a symlink or a loose directory at our name before we get there, and a
94 /// socket bound through either is theirs to connect to.
95 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;
97 // 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
99 // passes every other line here and still hands the socket away.
100 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, std.posix.AT.SYMLINK_NOFOLLOW) catch return false;
101 return std.posix.S.ISDIR(st.mode) and st.uid == uid and (st.mode & 0o777) == 0o700;
57 } 102 }
58 103
59 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { 104 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
@@ -251,6 +296,57 @@ test "PathId: names the file it was taken from, not the path, and not a successo
251 try std.testing.expect(!id.stillAt(sock_path)); 296 try std.testing.expect(!id.stillAt(sock_path));
252 } 297 }
253 298
299 test "runtimeDirFrom: the env var wins on every OS, and Darwin falls back to /tmp/mux-UID" {
300 var buf: [32]u8 = undefined;
301
302 // Named, so no OS invents its own place for the sockets a rig has
303 // already told it about: make e2e, soak and every hand rig point this
304 // at a directory of their own and expect both sides to agree.
305 try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom("/run/user/7", 501, &buf).?);
306
307 if (builtin.os.tag == .linux) {
308 // No guess. Two binaries that each guessed would disagree, and the
309 // client would start a second daemon beside the one already up.
310 try std.testing.expect(runtimeDirFrom(null, 501, &buf) == null);
311 } else {
312 try std.testing.expectEqualStrings("/tmp/mux-501", runtimeDirFrom(null, 501, &buf).?);
313 }
314 }
315
316 test "ensureOwnedDir: creates 0700, accepts its own creation, refuses a symlink and a group-readable dir" {
317 const testtmp = @import("testtmp");
318 var tmp = try testtmp.TmpDir.make();
319 defer tmp.cleanup();
320
321 var b: [std.fs.max_path_bytes]u8 = undefined;
322 const uid = std.posix.geteuid();
323 const fresh = try std.fmt.bufPrint(&b, "{s}/rt", .{tmp.path()});
324 try std.testing.expect(ensureOwnedDir(fresh, uid));
325 const st = try std.posix.fstatat(std.posix.AT.FDCWD, fresh, 0);
326 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
327
328 // The second ask is the one every later `mux` makes: a check, not a
329 // mkdir, and it must not read its own directory as somebody else's.
330 try std.testing.expect(ensureOwnedDir(fresh, uid));
331
332 // A directory another uid could read the socket names out of is
333 // refused rather than reused. chmod after the mkdir, so the answer
334 // does not depend on the umask the suite happens to run under.
335 var b2: [std.fs.max_path_bytes]u8 = undefined;
336 const loose = try std.fmt.bufPrint(&b2, "{s}/loose", .{tmp.path()});
337 try std.posix.mkdir(loose, 0o750);
338 try std.posix.fchmodat(std.posix.AT.FDCWD, loose, 0o750, 0);
339 try std.testing.expect(!ensureOwnedDir(loose, uid));
340
341 // And the planted symlink, which is the whole reason for NOFOLLOW: it
342 // points at a directory this very test just proved good, so every
343 // check but the link check says yes.
344 var b3: [std.fs.max_path_bytes]u8 = undefined;
345 const link = try std.fmt.bufPrint(&b3, "{s}/link", .{tmp.path()});
346 try std.posix.symlink(fresh, link);
347 try std.testing.expect(!ensureOwnedDir(link, uid));
348 }
349
254 // Forces semantic analysis of every pub decl under `zig build test`, so an 350 // Forces semantic analysis of every pub decl under `zig build test`, so an
255 // unreferenced decl must at least compile (the silent-module-loss hazard, 351 // unreferenced decl must at least compile (the silent-module-loss hazard,
256 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 352 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.