src/xdg.zig
Ref: Size: 24.8 KiB History
//! XDG-derived paths shared by every mode, key file creation, and the reaping
//! of what a dead process left under a pid-named entry. The `*From`
//! variants are PURE — environment handed in, nothing read — because that is
//! what makes them testable without setenv, which Zig tests cannot safely do.
//! The un-suffixed wrappers are one line each.
const std = @import("std");
/// The one place the default key location is spelled; mux d keygen writes
/// it and every binary's key resolution reads it.
pub fn keyPath(alloc: std.mem.Allocator) ![]const u8 {
return keyPathFrom(alloc, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"));
}
/// Where a `quic://` dial's key came from, so each binary can spell its
/// own refusal around the path it actually looked at.
pub const KeyResolution = union(enum) {
/// `--key` or `$MUX_KEY_FILE`. BORROWED from argv/env — do not free —
/// and deliberately unchecked: naming a key explicitly is the user
/// asserting it, and a wrong path fails at the dial with its own
/// error rather than being second-guessed here.
given: []const u8,
/// The XDG default, which exists. Owned by the caller.
default: []const u8,
/// The XDG default, which does not. Owned by the caller, and carried
/// out rather than printed: `mux` and `mux a` word this differently
/// (the agent answers in JSON) and both need the path; the hub folds
/// it into a bare MissingKey.
missing: []const u8,
/// Release what this resolution OWNS: nothing for `.given`, which
/// borrows from argv or the environment, and the path for the two
/// XDG-derived arms. The borrow/own split is stated once here so a
/// caller never re-derives which arm allocated and frees the wrong one.
///
/// Only for callers that keep the path no longer than the resolution.
/// A caller that hands the `.default` path onward as owned memory —
/// `client.Target.fromSpec` stores it in the target it returns — must
/// take that arm apart itself instead.
pub fn deinit(self: KeyResolution, alloc: std.mem.Allocator) void {
switch (self) {
.given => {},
.default, .missing => |p| alloc.free(p),
}
}
};
/// ONE owner: a drift here would mean two binaries disagreeing about which
/// key a `quic://` target authenticates with.
pub fn resolveKeyPath(alloc: std.mem.Allocator, given: ?[]const u8) !KeyResolution {
return resolveKeyPathFrom(alloc, given, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"));
}
/// The pure twin, for the daemon: `Server` is handed its environment
/// because tests cannot setenv.
pub fn resolveKeyPathFrom(alloc: std.mem.Allocator, given: ?[]const u8, xdg_config_home: ?[]const u8, home: ?[]const u8) !KeyResolution {
if (given) |g| return .{ .given = g };
const p = try keyPathFrom(alloc, xdg_config_home, home);
std.fs.cwd().access(p, .{}) catch return .{ .missing = p };
return .{ .default = p };
}
/// The environment spelling of `--key`, and the ONE place it is spelled:
/// two binaries reading different variable names is a drift only a user
/// hitting it would ever notice. The parses stay pure by taking its VALUE.
pub const key_env = "MUX_KEY_FILE";
/// `--key` beats `$MUX_KEY_FILE`: the flag is the more specific intent.
/// An empty spelling of either is unset, not a key at the empty path.
pub fn pickKey(flag: ?[]const u8, env: ?[]const u8) ?[]const u8 {
const k = flag orelse env orelse return null;
return if (k.len == 0) null else k;
}
/// An empty XDG spelling is unset, not the root: `XDG_STATE_HOME=`
/// would otherwise put the hosts file at `/mux/hosts`.
pub fn pathFrom(alloc: std.mem.Allocator, xdg_dir: ?[]const u8, home: ?[]const u8, home_sub: []const u8, tail: []const u8) ![]const u8 {
if (xdg_dir) |d| if (d.len > 0)
return std.fmt.allocPrint(alloc, "{s}/mux/{s}", .{ d, tail });
const h = home orelse return error.NoHome;
return std.fmt.allocPrint(alloc, "{s}/{s}/mux/{s}", .{ h, home_sub, tail });
}
/// The state directory's share of `pathFrom`, read from the real
/// environment.
pub fn statePath(alloc: std.mem.Allocator, tail: []const u8) ![]const u8 {
return pathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME"), ".local/state", tail);
}
pub fn keyPathFrom(alloc: std.mem.Allocator, xdg_config_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
return pathFrom(alloc, xdg_config_home, home, ".config", "key");
}
/// The one daemon log on the box, `$XDG_STATE_HOME/mux/muxd.log`. Every
/// writer opens it O_APPEND and none truncates it: a detached daemon's
/// stderr (main.zig's fork) and the wall's auto-start note below share it,
/// and one daemon per socket path means several may be writing at once.
pub fn logPath(alloc: std.mem.Allocator) ![]const u8 {
return statePath(alloc, "muxd.log");
}
pub fn logPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
return pathFrom(alloc, xdg_state_home, home, ".local/state", "muxd.log");
}
/// Append one line to the daemon log from a process that is NOT a daemon.
/// The one caller is the wall about to auto-start a daemon: the reason its
/// dial failed belongs beside the lines that daemon is about to write,
/// because a deleted socket and the second daemon that took its path are
/// one story and were in no file at all (issue 04b3019d). O_APPEND, like
/// the fork's own open of this file: one log serves every daemon on the
/// box. Best effort — a log that cannot be opened is not a reason to
/// refuse the user a shell, so the caller ignores the error.
pub fn appendLogLine(alloc: std.mem.Allocator, line: []const u8) !void {
const path = try logPath(alloc);
defer alloc.free(path);
try appendLogLineTo(path, line);
}
/// The append without the environment, so a test can aim it at a
/// directory of its own (tests cannot setenv).
pub fn appendLogLineTo(path: []const u8, line: []const u8) !void {
if (std.fs.path.dirname(path)) |dir| try std.fs.cwd().makePath(dir);
// O_APPEND rather than a seek-then-write: a daemon is writing this
// file at the same time, and only the append flag makes the two
// interleave by line rather than overwrite each other.
const f: std.fs.File = .{ .handle = try std.posix.open(path, .{
.ACCMODE = .WRONLY,
.CREAT = true,
.APPEND = true,
.CLOEXEC = true,
}, 0o600) };
defer f.close();
try f.writeAll(line);
}
/// Where `mux HOST` remembers the last announce. A host containing a path
/// separator is refused; the caller attaches uncached rather than failing.
pub fn hostCachePath(alloc: std.mem.Allocator, host: []const u8) ![]const u8 {
return hostCachePathFrom(alloc, host, std.posix.getenv("XDG_CACHE_HOME"), std.posix.getenv("HOME"));
}
pub fn hostCachePathFrom(
alloc: std.mem.Allocator,
host: []const u8,
xdg_cache_home: ?[]const u8,
home: ?[]const u8,
) ![]const u8 {
// Checked before the environment, so the refusal does not depend on
// which of the two spellings the caller's box happens to take.
if (std.mem.indexOfScalar(u8, host, '/') != null) return error.UncacheableHost;
const tail = try std.fmt.allocPrint(alloc, "hosts/{s}", .{host});
defer alloc.free(tail);
return pathFrom(alloc, xdg_cache_home, home, ".cache", tail);
}
/// Create `dir` and everything above it, then tighten `dir` itself to 0700 —
/// what the directories under the user's OWN HOME want.
///
/// It adopts a directory already there and follows symlinks, both right under
/// `~` (an existing `~/.config/mux` must not make `keygen` refuse) and wrong
/// anywhere a stranger can create entries. Those callers want
/// `makeNewPrivateDir`.
pub fn makePrivateDir(dir: []const u8) !void {
try std.fs.cwd().makePath(dir);
// `makePath` leaves 0755, which exposes that a file exists and what it is
// called. Only THIS component is tightened — the parents are the user's own
// business. `.iterate = true` is not optional: `Dir.chmod` fchmods the
// directory's fd, which is opened O_PATH without it.
var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
defer d.close();
try d.chmod(0o700);
}
/// `makePrivateDir`'s 0700 policy where the parent is NOT ours. So it refuses an
/// existing entry rather than adopting it: one pre-created as a symlink would
/// take the chmod to the link's TARGET. The mode goes to `mkdir` so the
/// directory is never briefly 0755, and the chmod after undoes the umask.
pub fn makeNewPrivateDir(dir: []const u8) !void {
std.posix.mkdir(dir, 0o700) catch |err| switch (err) {
// Not ours. Named separately from the other errors because it is
// the only one a caller can act on: whatever is there belongs to
// somebody else, and the answer is to do without, never to use it.
error.PathAlreadyExists => return error.DirExists,
else => |e| return e,
};
var d = try std.fs.cwd().openDir(dir, .{ .iterate = true, .no_follow = true });
defer d.close();
try d.chmod(0o700);
}
/// Removes every entry of `parent` named `<prefix><pid>…` whose pid no
/// process holds. Owners unlink these on the way out — a daemon its agent
/// and shim directories, a wall its prompt socket — and a SIGKILL or a
/// closed terminal window is a way out that runs nothing, so the successor
/// creating the next such entry is what asks the OS whether each owner is
/// still there. A live pid is left alone even when it is no longer a mux:
/// deleting under a stranger is worse than one stale name. Best effort
/// throughout — a parent that cannot be read reaps nothing.
pub fn reapDeadPid(parent: []const u8, prefix: []const u8) void {
var d = std.fs.cwd().openDir(parent, .{ .iterate = true }) catch return;
defer d.close();
var it = d.iterate();
while (it.next() catch null) |entry| {
if (!std.mem.startsWith(u8, entry.name, prefix)) continue;
const rest = entry.name[prefix.len..];
var n: usize = 0;
while (n < rest.len and std.ascii.isDigit(rest[n])) n += 1;
// At least one digit, and the run must end at the name's own
// separator: `mux-agent-abc` and `mux-agent-12x` are somebody
// else's files, not a pid we can ask about.
if (n == 0) continue;
if (n < rest.len and rest[n] != '-' and rest[n] != '.') continue;
// Parsed as the type `kill` takes, so a digit run too long to BE a
// pid leaves with every other name we cannot ask about. Widening
// first and narrowing at the call would panic on the cast instead,
// in a function whose contract is to reap nothing it cannot judge.
const pid = std.fmt.parseInt(std.posix.pid_t, rest[0..n], 10) catch continue;
// `kill(pid, 0)`: ESRCH — `error.ProcessNotFound` — is the ONE answer
// that means the pid is gone. Every other answer keeps the entry:
// EPERM says alive-but-not-ours, and an errno neither this Zig nor
// this kernel version has a name for says the OS would not answer,
// which is not evidence of death. Deleting on "don't know" would
// remove a live daemon's agent socket out from under it. A live
// pid's entry stays even when it is no longer a mux.
const alive = if (std.posix.kill(pid, 0)) true else |err| err != error.ProcessNotFound;
if (alive) continue;
d.deleteTree(entry.name) catch {};
}
}
/// The same, for callers holding the path of the FILE that is going to
/// live there. A `path` with no directory component is a no-op.
pub fn makePrivateParent(path: []const u8) !void {
const dir = std.fs.path.dirname(path) orelse return;
try makePrivateDir(dir);
}
/// 32 random bytes at `path`, mode 0600, parent directories created and
/// the immediate parent tightened to 0700.
/// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both
/// counts, so overwriting silently would delete a credential.
pub fn writeNewKey(path: []const u8) !void {
try makePrivateParent(path);
const f = std.fs.cwd().createFile(path, .{
.exclusive = true,
.mode = 0o600,
}) catch |err| switch (err) {
error.PathAlreadyExists => return error.KeyExists,
else => |e| return e,
};
defer f.close();
var key: [32]u8 = undefined;
std.crypto.random.bytes(&key);
try f.writeAll(&key);
}
test "keyPathFrom: XDG_CONFIG_HOME wins, HOME is the fallback, empty is unset" {
const a = std.testing.allocator;
const explicit = try keyPathFrom(a, "/tmp/cfg", "/home/u");
defer a.free(explicit);
try std.testing.expectEqualStrings("/tmp/cfg/mux/key", explicit);
const fallback = try keyPathFrom(a, null, "/home/u");
defer a.free(fallback);
try std.testing.expectEqualStrings("/home/u/.config/mux/key", fallback);
// Empty XDG var means unset, per the basedir spec.
const empty = try keyPathFrom(a, "", "/home/u");
defer a.free(empty);
try std.testing.expectEqualStrings("/home/u/.config/mux/key", empty);
try std.testing.expectError(error.NoHome, keyPathFrom(a, null, null));
}
test "pickKey: the flag wins, and empty is unset either way" {
try std.testing.expectEqualStrings("/flag", pickKey("/flag", "/env").?);
try std.testing.expectEqualStrings("/env", pickKey(null, "/env").?);
try std.testing.expectEqualStrings("/flag", pickKey("/flag", null).?);
try std.testing.expectEqual(@as(?[]const u8, null), pickKey(null, null));
// An empty environment variable is unset, per the basedir spec's rule
// and common sense.
try std.testing.expectEqual(@as(?[]const u8, null), pickKey(null, ""));
// An empty --key does NOT fall through to the environment: the flag
// was named, so it is the answer, and the answer is nothing.
try std.testing.expectEqual(@as(?[]const u8, null), pickKey("", "/env"));
}
test "resolveKeyPathFrom: a named key is taken as named, an absent default is `missing`" {
const a = std.testing.allocator;
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
// Named, so unchecked — and answerable with no HOME to build a default
// under, which is why the daemon may name a key on a box that has none.
const given = try resolveKeyPathFrom(a, "/flag", null, null);
try std.testing.expectEqualStrings("/flag", given.given);
const absent = try resolveKeyPathFrom(a, null, tmp.path(), null);
defer a.free(absent.missing);
var buf: [256]u8 = undefined;
try std.testing.expectEqualStrings(
try std.fmt.bufPrint(&buf, "{s}/mux/key", .{tmp.path()}),
absent.missing,
);
// Same path, different arm once the file is there: every caller's
// refusal names the path it looked at, so the two must not diverge.
try makePrivateParent(absent.missing);
try writeNewKey(absent.missing);
const present = try resolveKeyPathFrom(a, null, tmp.path(), null);
defer a.free(present.default);
try std.testing.expectEqualStrings(absent.missing, present.default);
try std.testing.expectError(error.NoHome, resolveKeyPathFrom(a, null, null, null));
}
test "logPathFrom: same shape against XDG_STATE_HOME" {
const a = std.testing.allocator;
const explicit = try logPathFrom(a, "/tmp/state", "/home/u");
defer a.free(explicit);
try std.testing.expectEqualStrings("/tmp/state/mux/muxd.log", explicit);
const fallback = try logPathFrom(a, null, "/home/u");
defer a.free(fallback);
try std.testing.expectEqualStrings("/home/u/.local/state/mux/muxd.log", fallback);
}
test "hostCachePathFrom: same shape against XDG_CACHE_HOME, and refuses a host with a separator" {
const a = std.testing.allocator;
const explicit = try hostCachePathFrom(a, "box", "/tmp/cache", "/home/u");
defer a.free(explicit);
try std.testing.expectEqualStrings("/tmp/cache/mux/hosts/box", explicit);
const fallback = try hostCachePathFrom(a, "box", null, "/home/u");
defer a.free(fallback);
try std.testing.expectEqualStrings("/home/u/.cache/mux/hosts/box", fallback);
const empty = try hostCachePathFrom(a, "box", "", "/home/u");
defer a.free(empty);
try std.testing.expectEqualStrings("/home/u/.cache/mux/hosts/box", empty);
try std.testing.expectError(error.NoHome, hostCachePathFrom(a, "box", null, null));
// `a/b` would name `.../hosts/a/b`, which is a different host than the
// one asked for — and `..` in that position names a file outside the
// cache entirely. Refused; the caller attaches cold instead.
try std.testing.expectError(error.UncacheableHost, hostCachePathFrom(a, "a/b", "/tmp/cache", "/home/u"));
try std.testing.expectError(error.UncacheableHost, hostCachePathFrom(a, "../k", null, "/home/u"));
}
test "appendLogLineTo: creates the log's directory, appends behind what is there, and never truncates" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [280]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/state/mux/muxd.log", .{tmp.path()});
// The wall's note is the FIRST line in a fresh state dir: the daemon it
// is about to start has not created the directory yet.
try appendLogLineTo(path, "mux: auto-starting a daemon on /r/mux.sock: the dial said FileNotFound\n");
// A daemon's own line lands between two of ours; the second note must
// follow it, not overwrite from offset zero.
try appendLogLineTo(path, "mux d: socket /r/mux.sock: bound\n");
try appendLogLineTo(path, "mux: auto-starting a daemon on /r/mux.sock: the dial said ConnectionRefused\n");
const got = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096);
defer std.testing.allocator.free(got);
try std.testing.expectEqualStrings(
"mux: auto-starting a daemon on /r/mux.sock: the dial said FileNotFound\n" ++
"mux d: socket /r/mux.sock: bound\n" ++
"mux: auto-starting a daemon on /r/mux.sock: the dial said ConnectionRefused\n",
got,
);
const st = try std.fs.cwd().statFile(path);
try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777)));
}
test "makePrivateParent: a path with no directory part is a no-op" {
// The branch neither caller's tests reach: nothing to create, nothing
// to tighten, and crucially no error — a bare filename must not make
// the write that follows it refuse.
try makePrivateParent("bare-name-no-dir");
}
test "makeNewPrivateDir: creates 0700, and refuses anything already there" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const dir = try std.fmt.bufPrint(&buf, "{s}/shim", .{tmp.path()});
try makeNewPrivateDir(dir);
var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
defer d.close();
const st = try d.stat();
try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
// The whole difference from makePrivateDir: a second call does not
// succeed by adopting the first call's directory. A caller that treated
// DirExists as "fine, it exists" would have re-opened the hole this
// function exists to close.
try std.testing.expectError(error.DirExists, makeNewPrivateDir(dir));
// Any kind of entry, not just a directory: a regular file and a symlink
// are the two an attacker plants, and both must read as taken.
var fbuf: [128]u8 = undefined;
const file = try std.fmt.bufPrint(&fbuf, "{s}/plain", .{tmp.path()});
try std.fs.cwd().writeFile(.{ .sub_path = file, .data = "x" });
try std.testing.expectError(error.DirExists, makeNewPrivateDir(file));
var lbuf: [128]u8 = undefined;
const link = try std.fmt.bufPrint(&lbuf, "{s}/link", .{tmp.path()});
try std.posix.symlink(dir, link);
try std.testing.expectError(error.DirExists, makeNewPrivateDir(link));
// Parents are NOT created, which is the other half of "one mkdir": a
// caller whose parent is missing hears about it rather than having a
// tree built for it under a directory it does not control.
var nbuf: [128]u8 = undefined;
const nested = try std.fmt.bufPrint(&nbuf, "{s}/missing/leaf", .{tmp.path()});
try std.testing.expectError(error.FileNotFound, makeNewPrivateDir(nested));
}
test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/sub/key", .{tmp.path()});
try writeNewKey(path);
const st = try std.fs.cwd().statFile(path);
try std.testing.expectEqual(@as(u64, 32), st.size);
// mode() carries type bits; mask to permissions.
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
const fst = try f.stat();
try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
// The directory holding it is 0700: a 0755 parent leaks the existence
// and the name of a key file even though the key itself stays 0600.
var dbuf: [128]u8 = undefined;
const dir = try std.fmt.bufPrint(&dbuf, "{s}/sub", .{tmp.path()});
var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
defer d.close();
const dst = try d.stat();
try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777)));
var first: [32]u8 = undefined;
try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&first, 0));
// Refusal leaves the file byte-identical: a credential is never silently
// replaced. Re-opened by PATH, because a kept fd follows the INODE — an
// implementation that unlinked and rewrote would still pass through `f`.
try std.testing.expectError(error.KeyExists, writeNewKey(path));
const f2 = try std.fs.cwd().openFile(path, .{});
defer f2.close();
var second: [32]u8 = undefined;
try std.testing.expectEqual(@as(usize, 32), try f2.preadAll(&second, 0));
try std.testing.expectEqualSlices(u8, &first, &second);
}
// 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());
}
test "reapDeadPid: a dead owner's entry goes; a live owner's, a stranger's and another prefix's stay" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
const dead = try testtmp.deadPid();
var b0: [48]u8 = undefined;
var b1: [48]u8 = undefined;
var b2: [48]u8 = undefined;
var b3: [48]u8 = undefined;
const dead_dir = try std.fmt.bufPrint(&b0, "mux-agent-{d}-abc", .{dead});
const dead_sock = try std.fmt.bufPrint(&b1, "mux-ask-{d}.sock", .{dead});
const ours = try std.fmt.bufPrint(&b2, "mux-agent-{d}-abc", .{std.c.getpid()});
const not_a_pid = try std.fmt.bufPrint(&b3, "mux-agent-{d}x", .{dead});
// A directory with something in it, so a plain rmdir would not do.
try tmp.dir.makePath(dead_dir);
var inner: [64]u8 = undefined;
try tmp.dir.writeFile(.{ .sub_path = try std.fmt.bufPrint(&inner, "{s}/agent-0.sock", .{dead_dir}), .data = "" });
try tmp.dir.writeFile(.{ .sub_path = dead_sock, .data = "" });
try tmp.dir.makePath(ours);
// A pid that is alive but is not a mux — pid 1 — keeps its entry. The
// liveness question is `kill(pid, 0)`, which answers for every process
// this uid may signal and EPERM for the ones it may not; both are alive,
// and pid 1 is the EPERM case for every unprivileged run of this suite.
// ESRCH is the only answer that removes anything, so an errno with no
// name in this Zig — `error.Unexpected` — keeps the entry too. That case
// has no cheap fixture: no signal this test can send produces it.
try tmp.dir.makePath("mux-agent-1-abc"); // pid 1 is alive in every pid namespace
try tmp.dir.makePath("mux-agent-abc");
// Digits that no pid can hold. `kill` takes an i32, so this is a name
// to leave alone exactly like `mux-agent-abc`; reading it as a wide
// integer and narrowing at the call would abort the whole reap here.
try tmp.dir.makePath("mux-agent-3000000000-x");
try tmp.dir.makePath(not_a_pid);
reapDeadPid(tmp.path(), "mux-agent-");
try std.testing.expectError(error.FileNotFound, tmp.dir.access(dead_dir, .{}));
try tmp.dir.access(dead_sock, .{}); // another prefix: not this reaper's
try tmp.dir.access(ours, .{});
try tmp.dir.access("mux-agent-1-abc", .{});
try tmp.dir.access("mux-agent-abc", .{});
try tmp.dir.access("mux-agent-3000000000-x", .{});
try tmp.dir.access(not_a_pid, .{});
reapDeadPid(tmp.path(), "mux-ask-");
try std.testing.expectError(error.FileNotFound, tmp.dir.access(dead_sock, .{}));
// An unreadable parent reaps nothing and says nothing.
reapDeadPid("/nonexistent/parent", "mux-agent-");
}