src/sockpath.zig
Ref: Size: 24.3 KiB History
//! The socket path's identity and the right to bind it: who owns a path before
//! a daemon starts, and whether the file there at teardown is still the one
//! that daemon created. One half refuses to steal a live daemon's path, the
//! other refuses to delete a successor's socket. Nothing here knows a Server
//! exists; a path is all it takes.
const std = @import("std");
const builtin = @import("builtin");
/// The usable bytes of `sockaddr_un.sun_path`: the field less the NUL.
/// Derived from the kernel's own struct rather than spelled — 108 on
/// Linux, 104 on the BSDs — and private, because every binary that once
/// re-compared it grew its own wording for the same refusal.
const max_sun_path = @sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1;
/// The refusal, for whoever is about to BIND. Everyone else dials and
/// reads the kernel's own `NameTooLong`.
pub fn tooLong(prefix: []const u8, path: []const u8) bool {
// Named rather than bounced off a connect: the bind would fail with a
// generic error, and the path is the whole story. `mux a` answers in
// JSON and keeps its own wording.
if (path.len <= max_sun_path) return false;
std.debug.print(
"{s}: socket path too long ({d} bytes, max {d}): {s}\n",
.{ prefix, path.len, max_sun_path, path },
);
return true;
}
/// `defaultSockPath`, or null having printed the ONE sentence that names
/// `--sock` as the way out. The daemon and the client printed it byte for
/// byte in two places; only the program word differs, so only that is a
/// parameter.
pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const u8 {
return defaultSockPath(alloc) catch |err| switch (err) {
error.NoRuntimeDir => {
std.debug.print("{s}: {s}\n", .{ prefix, no_runtime_dir_reason });
return null;
},
else => |e| return e,
};
}
/// Why there is no default socket path, with no program prefix and no
/// trailing newline, so the one sentence serves both the humans'
/// `{prefix}: {reason}` line and `mux a`'s JSON `detail`. Two spellings
/// because the two OSes fail for different reasons: Linux has nothing to
/// fall back to, while Darwin has a fallback that can be refused, and a
/// Mac user who reads "XDG_RUNTIME_DIR is unset" would go and set a
/// variable that was never the problem. Public because `muxa.zig` is the
/// other caller and a second copy of this sentence is how the two drifted
/// apart in the first place.
pub const no_runtime_dir_reason = if (builtin.os.tag == .linux)
"XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)"
else
"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)";
/// The directory the default daemon socket and every per-wall socket live
/// in, or null. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
/// every isolated rig (make e2e, soak, a hand rig) keeps its sockets apart
/// from the user's. Linux has NO fallback: a guess cannot make two binaries
/// agree on one daemon, so the caller names it with --sock. Darwin falls
/// back to /tmp/mux-<uid>, created 0700 and checked on every ask — the
/// spec's three candidates ($TMPDIR, ~/Library/Caches, ~/.local/state) all
/// overflow sun_path at the longest name mux creates (dir + 68 + pid
/// digits against 103; measured 2026-09-03, docs/decisions.md), and /tmp
/// is sticky and world-writable, so the per-uid directory is what carries
/// the privacy, as tmux's /tmp/tmux-UID does.
pub fn runtimeDir() ?[]const u8 {
// The two OSes this builds for are named here rather than left as a
// linux-or-everything-else branch. `src/os/`'s roots already refuse a
// third tag at comptime, so a build for one cannot get this far today;
// this keeps the promise local, so that the day a third arm lands the
// compiler asks what its runtime directory is instead of silently
// handing it Darwin's /tmp fallback.
switch (builtin.os.tag) {
.linux, .macos => {},
else => @compileError("sockpath.runtimeDir: name this OS's runtime directory rule"),
}
const env = std.posix.getenv("XDG_RUNTIME_DIR");
if (builtin.os.tag == .linux) return env;
const uid = std.posix.geteuid();
const dir = runtimeDirFrom(builtin.os.tag, env, uid, &darwin_dir_buf) orelse return null;
// Only the FALLBACK is checked. `$XDG_RUNTIME_DIR` is the user's own
// statement of where their sockets go, and a rig that points it at a
// directory of another mode is not making a privacy mistake.
if (env == null and !ensureOwnedDir(dir, uid)) return null;
return dir;
}
/// Process-wide because the returned slice outlives the call and this
/// module allocates nothing. Every writer formats the same uid into it, so
/// two threads racing here write identical bytes.
var darwin_dir_buf: [32]u8 = undefined;
/// `runtimeDir` with its environment and its OS named rather than read.
/// Named environment because Zig tests cannot setenv; named OS because a
/// `builtin.os.tag` branch is comptime-eliminated, so the Darwin spelling
/// would go unasserted in a Linux gate and a typo in the format string
/// would ship green.
fn runtimeDirFrom(os: std.Target.Os.Tag, env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8 {
if (env) |e| return e;
// `.macos` by name, not "not linux". The fallback is a Darwin rule with
// a Darwin reason (sun_path leaves no room for $TMPDIR or
// ~/Library/Caches), so a tag that is neither gets no directory rather
// than a borrowed one. `runtimeDir` refuses such a tag at comptime; this
// is the same answer for the callers that name the OS themselves.
return switch (os) {
.macos => std.fmt.bufPrint(buf, "/tmp/mux-{d}", .{uid}) catch null,
else => null,
};
}
/// True when PATH is a directory this uid owns with mode 0700 and no
/// symlink in the last step. tmux checks /tmp/tmux-UID for the same
/// reason and this is the stricter rule — tmux asks only that no OTHER
/// bit is set, this asks for exactly 0700 — because in a sticky
/// world-writable /tmp another uid can plant a symlink or a loose
/// directory at our name before we get there, and a socket bound through
/// either is theirs to connect to.
fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool {
if (std.posix.mkdir(path, 0o700)) |_| {
// mkdir's mode argument is masked by the umask, so a user carrying
// owner bits in theirs (0177, say) would get a 0600 directory that
// the exact-0700 check below then refuses on this run and every
// later one. chmod is not masked. Only the directory this call
// just created is set: one that was already there keeps whatever
// mode it has and is refused if that is wrong, because repairing
// it silently would hide another uid's plant rather than report it.
std.posix.fchmodat(std.posix.AT.FDCWD, path, 0o700, 0) catch return false;
} else |e| if (e != error.PathAlreadyExists) return false;
// NOFOLLOW: the stat has to describe the name we will bind under, not
// whatever it points at. A symlink to a directory this uid does own
// passes every other line here and still hands the socket away.
const st = std.posix.fstatat(std.posix.AT.FDCWD, path, std.posix.AT.SYMLINK_NOFOLLOW) catch return false;
return std.posix.S.ISDIR(st.mode) and st.uid == uid and (st.mode & 0o777) == 0o700;
}
pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
return sockPathFrom(alloc, runtimeDir());
}
/// `defaultSockPath` with its environment named rather than read, because
/// Zig tests cannot setenv and this is the one decision here a test has to
/// be able to state.
fn sockPathFrom(alloc: std.mem.Allocator, xdg_runtime: ?[]const u8) ![]const u8 {
const dir = xdg_runtime orelse return error.NoRuntimeDir;
return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
}
/// A socket file's identity when it was bound, so teardown can tell our socket
/// from one that replaced it. The PATH's dev+ino, never the listening
/// descriptor's: a bound socket's fd lives in sockfs while the path resolves to
/// an ordinary inode, so comparing the two can never be equal.
pub const PathId = struct {
dev: u64,
ino: u64,
/// The path, not the descriptor: teardown re-reads it through this
/// same lens, and the two lenses do not compare.
pub fn of(path: []const u8) !PathId {
const st = try std.posix.fstatat(std.posix.AT.FDCWD, path, 0);
return .{ .dev = @intCast(st.dev), .ino = @intCast(st.ino) };
}
/// A path that cannot be stat'd is not ours: gone, or something we
/// may not identify, and either way callers want "leave it".
pub fn stillAt(self: PathId, path: []const u8) bool {
const pst = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch return false;
return pst.dev == self.dev and pst.ino == self.ino;
}
};
/// Connect to a path that is supposed to BE a unix socket, with the one
/// disagreement between the two kernels settled here instead of at each
/// caller. Every dial in this product that names a path a USER typed comes
/// through this: `dial.dial`, `proxy.pump`, `answers` below.
///
/// Linux answers a connect to anything at the path that is not a socket with
/// ECONNREFUSED — measured 2026-09-03 for a regular file, a directory, a
/// symlink to a file, a fifo and /dev/null, all five identical. Darwin answers
/// ENOTSOCK for the same five, and `std.posix.connect` maps that to
/// `unreachable`, because for a LOCAL fd it can only mean the caller passed
/// something that is not a socket. Our fd always is; on Darwin the errno is
/// about the far end. So `mux --sock notes.txt` aborted on a Mac where it
/// refuses on Linux, and so did `mux d proxy`, `mux d stop` and every dial
/// underneath the wall.
///
/// The stat therefore comes first, and a non-socket is reported as
/// ConnectionRefused — Linux's own answer, so nothing on Linux moves and
/// Darwin says what Linux says. A path the stat cannot read at all falls
/// through to the connect, which names it (ENOENT for a missing path, and a
/// dangling symlink reads as missing on both).
pub fn connectSocket(path: []const u8) !std.net.Stream {
if (std.posix.fstatat(std.posix.AT.FDCWD, path, 0)) |st| {
if (!std.posix.S.ISSOCK(st.mode)) return error.ConnectionRefused;
} else |_| {}
return std.net.connectUnixSocket(path);
}
/// Whether anything LISTENS at `path` now: a read, so every connect
/// error is a no. The decision needing the errno is `claim`.
pub fn answers(path: []const u8) bool {
return probe(path) == null;
}
/// `answers` with the reason kept: null when something listens, otherwise
/// the connect's own error. `FileNotFound` is a path with nothing at it and
/// `ConnectionRefused` a socket file nobody is listening on — and which of
/// the two a wall saw the moment it auto-started a daemon is the one fact
/// that dates a deleted socket against the second daemon that took its
/// path (issue 145807a2, 2026-09-04). `answers` stays the bool every
/// caller reads; this is for the one that writes the reason down.
pub fn probe(path: []const u8) ?anyerror {
const s = connectSocket(path) catch |err| return err;
s.close();
return null;
}
/// What `claim` found, for the daemon's log: a start that cleared a dead
/// daemon's leftover is a different event from one that found nothing, and
/// the log used to say neither.
pub const Claimed = enum { free, cleared_leftover };
/// Make the socket path ours to bind, or refuse it. Without this, daemons
/// started against one path each unlink and bind fresh: every one keeps running
/// with its sessions intact, but only the newest is reachable and the rest are
/// stranded holding shells nobody can get back to.
///
/// So: unlink only what is a socket *and* answers ECONNREFUSED.
/// - nothing there → bind, nothing to clean up.
/// - not a socket → refuse; `--sock notes.txt` must not eat the file.
/// - something answers → a live daemon owns this path. Refuse.
/// - a dead socket file → ours to clear.
/// - anything else → propagate; a path we cannot positively call a
/// dead daemon's leftover is not ours to delete.
///
/// The STAT comes first, and the order is load-bearing rather than a
/// preference: the two kernels disagree about what connecting to a path that
/// is not a socket means. Linux answers ECONNREFUSED, the same errno a dead
/// socket gives, so the stat was what separated them. Darwin answers ENOTSOCK,
/// which `std.posix.connect` treats as a programming error about the local fd
/// and hits `unreachable` on — a panic, in a daemon, over a file the user
/// named. Asking the stat first means the connect is only ever made to
/// something that IS a socket, and neither kernel has a surprise there.
pub fn claim(path: []const u8) !Claimed {
const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) {
// Nothing at the path, or a symlink to nothing: free as far as this
// function can tell, and `bind` gets the last word on the entry.
error.FileNotFound => return .free,
else => |e| return e,
};
if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket;
if (std.net.connectUnixSocket(path)) |live| {
live.close();
return error.DaemonAlreadyRunning;
} else |err| switch (err) {
error.FileNotFound => return .free, // vanished under us; path is free
// A socket file nobody is listening on: a dead daemon's leftover,
// which the stat above has already confirmed is a socket.
error.ConnectionRefused => {},
// Every other errno names a path this process cannot positively call a
// dead daemon's leftover, so it propagates BY NAME and the daemon's log
// says which one refused the bind.
else => |e| return e,
}
std.fs.cwd().deleteFile(path) catch |err| switch (err) {
// Someone else cleared it first. The path is free either way,
// which is the only thing this function was after.
error.FileNotFound => {},
else => |e| return e,
};
return .cleared_leftover;
}
test "sockpath.max_sun_path is the kernel's field less its NUL, not a number of ours" {
try std.testing.expectEqual(@sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1, max_sun_path);
// And on the one OS mux runs on today the derivation must still land on
// the number the comments and the e2e scripts reason about. Without
// this line the assertion above is a tautology, true of any expression
// the constant is spelled with.
if (builtin.os.tag == .linux) try std.testing.expectEqual(107, max_sun_path);
}
test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" {
const alloc = std.testing.allocator;
// The refusal, not a guess — the field incident is on defaultSockPath.
try std.testing.expectError(error.NoRuntimeDir, sockPathFrom(alloc, null));
const found = try sockPathFrom(alloc, "/run/user/1000");
defer alloc.free(found);
try std.testing.expectEqualStrings("/run/user/1000/muxd.sock", found);
}
test "answers: a live listener, a stale socket file, and a path with nothing on it" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [64]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/a.sock", .{tmp.path()});
// Nothing there at all: the common case, and the one every caller acts
// on by starting a daemon.
try std.testing.expect(!answers(path));
const addr = try std.net.Address.initUnix(path);
var listener = try addr.listen(.{});
try std.testing.expect(answers(path));
// The socket FILE outliving its daemon is not an answer. `claim` reads
// this as "mine to clear"; a true here would refuse every start after
// a daemon that died without unlinking.
listener.deinit();
try std.testing.expect(!answers(path));
try std.fs.cwd().access(path, .{});
// The reason `answers` swallowed, for the auto-start's log line: a dead
// socket file is ECONNREFUSED, and only after the claim clears it is
// the path simply absent.
try std.testing.expectEqual(@as(?anyerror, error.ConnectionRefused), probe(path));
try std.testing.expectEqual(Claimed.cleared_leftover, try claim(path));
try std.testing.expectEqual(@as(?anyerror, error.FileNotFound), probe(path));
try std.testing.expectEqual(Claimed.free, try claim(path));
// A path longer than `sun_path` is a no as flatly as a missing one:
// nothing is listening there and nothing could be. A yes here would
// tell the client's attach a daemon was already up and send it
// straight to a dial, instead of to the one binder's refusal.
try std.testing.expect(!answers("/" ++ "x" ** 200));
}
test "connectSocket: a path that is not a socket is refused, never a panic" {
// The regression a Mac found. On Linux this passes with or without the
// stat, because the kernel answers ECONNREFUSED for a regular file just
// as it does for a dead socket. On Darwin the same connect answers
// ENOTSOCK, which `std.posix.connect` maps to `unreachable` — so without
// the guard this test does not fail, it ABORTS the test binary, and every
// caller that names a path a user typed aborts with it.
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = "mux must not connect to this" });
var buf: [64]u8 = undefined;
const file = try std.fmt.bufPrint(&buf, "{s}/notes.txt", .{tmp.path()});
// The errno is Linux's own for this path, so the wording every caller
// already prints for "nothing is listening" is what a Mac user sees too.
try std.testing.expectError(error.ConnectionRefused, connectSocket(file));
try std.testing.expect(!answers(file));
// A DIRECTORY is the same story and the same errno on both, and it is
// what `--sock` pointed at a state directory looks like.
try std.testing.expect(!answers(tmp.path()));
// And the file is still there: this is a read, and nothing in the
// refusal path may touch what it refused.
var back: [64]u8 = undefined;
const f = try std.fs.cwd().openFile(file, .{});
defer f.close();
try std.testing.expectEqualStrings(
"mux must not connect to this",
back[0..try f.readAll(&back)],
);
}
test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" {
// chmod does not bite root, so the connect would succeed and the test
// would assert the opposite of what it is named for.
if (std.posix.geteuid() == 0) return error.SkipZigTest;
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [64]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/mode0.sock", .{tmp.path()});
const addr = try std.net.Address.initUnix(path);
var listener = try addr.listen(.{});
defer listener.deinit();
try std.posix.fchmodat(std.posix.AT.FDCWD, path, 0, 0);
// A live daemon this process may not reach is one it cannot report as
// up: `forkDaemon` reads a yes here as `already_running` and exits 0
// having started nothing.
try std.testing.expect(!answers(path));
// And the same path is NOT a licence to unlink: only ECONNREFUSED on
// a socket file is, and everything else keeps its errno so the bind
// refusal names it.
try std.testing.expectError(error.AccessDenied, claim(path));
}
test "PathId: names the file it was taken from, not the path, and not a successor" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [64]u8 = undefined;
const sock_path = try std.fmt.bufPrint(&buf, "{s}/id.sock", .{tmp.path()});
const addr = try std.net.Address.initUnix(sock_path);
var listener = try addr.listen(.{});
const id = try PathId.of(sock_path);
// A live bound socket answers for itself, which is why this type takes the
// PATH's dev+ino: a guard comparing the LISTENING DESCRIPTOR against the
// path's inode is never equal, so the daemon silently stops unlinking.
try std.testing.expect(id.stillAt(sock_path));
// Nothing at the path: the stat fails, and the answer callers need is
// "not ours" rather than an error to handle. The catch arm, which no
// test reached before this one.
listener.deinit();
try std.fs.cwd().deleteFile(sock_path);
try std.testing.expect(!id.stillAt(sock_path));
// A successor binds the same NAME. Same path, different file, and
// deleting it would strand the daemon that owns it — the field incident
// the whole module exists for, in one line.
const addr2 = try std.net.Address.initUnix(sock_path);
var successor = try addr2.listen(.{});
defer successor.deinit();
try std.testing.expect(!id.stillAt(sock_path));
}
test "runtimeDirFrom: the env var wins on every OS, and Darwin falls back to /tmp/mux-UID" {
var buf: [32]u8 = undefined;
// The env var wins on BOTH, so no OS invents its own place for the
// sockets a rig has already told it about: make e2e, soak and every
// hand rig point this at a directory of their own and expect both
// sides to agree.
try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom(.linux, "/run/user/7", 501, &buf).?);
try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom(.macos, "/run/user/7", 501, &buf).?);
// Linux does not guess. Two binaries that each guessed would disagree,
// and the client would start a second daemon beside the one already up.
try std.testing.expect(runtimeDirFrom(.linux, null, 501, &buf) == null);
// And the Darwin spelling is asserted HERE, in the Linux gate, because
// the OS is an argument rather than a comptime branch: a typo in the
// format string is caught by CI rather than by the first Mac to run it.
try std.testing.expectEqualStrings("/tmp/mux-501", runtimeDirFrom(.macos, null, 501, &buf).?);
// A third OS gets NOTHING, rather than Darwin's directory because it is
// not Linux. `runtimeDir` refuses such a tag at comptime, so this is the
// answer for the callers that pass a tag rather than read `builtin`.
try std.testing.expect(runtimeDirFrom(.freebsd, null, 501, &buf) == null);
}
test "ensureOwnedDir: creates 0700, accepts its own creation, refuses a symlink and a group-readable dir" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var b: [std.fs.max_path_bytes]u8 = undefined;
const uid = std.posix.geteuid();
const fresh = try std.fmt.bufPrint(&b, "{s}/rt", .{tmp.path()});
try std.testing.expect(ensureOwnedDir(fresh, uid));
const st = try std.posix.fstatat(std.posix.AT.FDCWD, fresh, 0);
try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
// The second ask is the one every later `mux` makes: a check, not a
// mkdir, and it must not read its own directory as somebody else's.
try std.testing.expect(ensureOwnedDir(fresh, uid));
// A directory another uid could read the socket names out of is
// refused rather than reused. chmod after the mkdir, so the answer
// does not depend on the umask the suite happens to run under.
var b2: [std.fs.max_path_bytes]u8 = undefined;
const loose = try std.fmt.bufPrint(&b2, "{s}/loose", .{tmp.path()});
try std.posix.mkdir(loose, 0o750);
try std.posix.fchmodat(std.posix.AT.FDCWD, loose, 0o750, 0);
try std.testing.expect(!ensureOwnedDir(loose, uid));
// And the planted symlink, which is the whole reason for NOFOLLOW: it
// points at a directory this very test just proved good, so every
// check but the link check says yes.
var b3: [std.fs.max_path_bytes]u8 = undefined;
const link = try std.fmt.bufPrint(&b3, "{s}/link", .{tmp.path()});
try std.posix.symlink(fresh, link);
try std.testing.expect(!ensureOwnedDir(link, uid));
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}