a73x

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-");
}