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