a73x

src/server/shellint.zig

Ref:   Size: 28.2 KiB   History

//! Shell integration: OSC 133 marks injected at spawn. The daemon forks the
//! session shell itself, so injection is env + argv at spawn time — no
//! rc-file edits, ever. Detection is by shell basename; unknown shells get
//! nothing and the session runs on the pgid/settle fallbacks.
const std = @import("std");
const xdg = @import("xdg");
const server_os = @import("server_os");

/// One copy for both shells: a second would drift, silently, in one of
/// them. `local code=$?` must stay FIRST: any line above it clobbers $?.
const precmd_fn =
    \\_mux_precmd() {
    \\  local code=$?
    \\  [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
    \\  _mux_ran=""
    \\  printf '\e]133;A\a'
    \\}
    \\
;

/// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv: zsh
/// looks for it under $ZDOTDIR and the shim directory has none, so a config kept
/// there — PATH edits, and anything else zsh reads for non-interactive shells —
/// stops being read for the session. The .zshrc is handed back below, which is
/// the common case; the fix for the rest is a .zshenv shim that restores
/// ZDOTDIR, the way ghostty's does.
pub const zsh_zshrc =
    \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR
    \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc.
    \\if [[ -n "$MUX_ORIG_ZDOTDIR" ]]; then
    \\  export ZDOTDIR="$MUX_ORIG_ZDOTDIR"
    \\  unset MUX_ORIG_ZDOTDIR
    \\else
    \\  unset ZDOTDIR
    \\fi
    \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc"
    \\autoload -Uz add-zsh-hook
    \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; }
    \\
++ precmd_fn ++
    \\add-zsh-hook preexec _mux_preexec
    \\add-zsh-hook precmd _mux_precmd
    \\
;

/// The DEBUG trap here silently REPLACES any the session already had — bash
/// allows exactly one, and bash-preexec, atuin and iTerm2's integration each
/// install one. mux wins and the other goes quiet, with no diagnostic anywhere.
/// Coexisting means detecting bash-preexec and registering with it instead;
/// this version does not.
pub const bash_init =
    \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file;
    \\# sources the user's normal rc first so their config still runs.
    \\[[ -f "$HOME/.bashrc" ]] && source "$HOME/.bashrc"
    \\_mux_ran=""
    \\_mux_preexec() {
    \\  [[ -n "$COMP_LINE" ]] && return
    \\  [[ "$BASH_COMMAND" == _mux_precmd* ]] && return
    \\  # PROMPT_COMMAND's own members run between the command and the next
    \\  # prompt, and the DEBUG trap fires for every one of them. Counting
    \\  # them as commands re-arms _mux_ran on every idle cycle, so the next
    \\  # prompt reports `D;0` for a command nobody ran — which is how a
    \\  # session on this box reported a successful command roughly once a
    \\  # second while sitting at an untouched prompt.
    \\  #
    \\  # Membership is bash-preexec's check and is exact. "${PROMPT_COMMAND[@]}"
    \\  # deliberately covers both spellings: a scalar expands as the single
    \\  # word it is, so this needs no type test and, unlike `declare -p`, no
    \\  # subshell in a path that runs before every command. A member that is
    \\  # itself compound (`a; b`) fires DEBUG once per simple command and so
    \\  # will not match, and a typed command whose text is character-for-
    \\  # character a member gets suppressed. Both are bash-preexec's
    \\  # limitations too, and both are quieter than the bug they replace.
    \\  local _mux_c
    \\  for _mux_c in "${PROMPT_COMMAND[@]}"; do
    \\    [[ "$BASH_COMMAND" == "$_mux_c" ]] && return
    \\  done
    \\  _mux_ran=1
    \\  printf '\e]133;C\a'
    \\}
    \\
++ precmd_fn ++
    \\# Prepended, never appended: _mux_precmd has to see the command's own
    \\# $?, and any member running ahead of it would have overwritten it.
    \\#
    \\# Type-aware because bash 5.1 made PROMPT_COMMAND an array and the
    \\# distributions took it up — Arch's /etc/bash.bashrc appends one under
    \\# any xterm* TERM, which is exactly what the daemon sets. A string assignment
    \\# onto an array lands on element 0 and folds a member into a compound,
    \\# which is precisely the shape the membership check above cannot match.
    \\if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
    \\  PROMPT_COMMAND=(_mux_precmd "${PROMPT_COMMAND[@]}")
    \\else
    \\  PROMPT_COMMAND="_mux_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
    \\fi
    \\# LAST, and the ordering is load-bearing rather than tidy: a trap armed
    \\# before the assignment above fires ON that assignment, so _mux_ran was
    \\# already set when the first prompt ran and every session opened by
    \\# reporting a command that never ran. Measured, not reasoned about —
    \\# {C:PROMPT_COMMAND=...}{D;0}{A} was the first thing bash ever said.
    \\trap '_mux_preexec' DEBUG
    \\
;

pub const fish_conf =
    \\# mux shell integration (fish): OSC 133 marks, via vendor_conf.d.
    \\function _mux_preexec --on-event fish_preexec
    \\    printf '\e]133;C\a'
    \\end
    \\function _mux_postexec --on-event fish_postexec
    \\    printf '\e]133;D;%s\a' $status
    \\end
    \\function _mux_prompt --on-event fish_prompt
    \\    printf '\e]133;A\a'
    \\end
    \\
;

pub const Kind = enum { zsh, bash, fish, other };

pub fn detect(shell_path: []const u8) Kind {
    const base = std.fs.path.basename(shell_path);
    if (std.mem.eql(u8, base, "zsh")) return .zsh;
    if (std.mem.eql(u8, base, "bash")) return .bash;
    if (std.mem.eql(u8, base, "fish")) return .fish;
    return .other;
}

pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };

/// Everything the spawn needs: the argv to exec and env pairs to set in
/// the child. The shim directory must outlive the spawn (paths point into
/// it).
pub const Injection = struct {
    /// Extra argv AFTER the shell path (bash --init-file <shim>); empty
    /// for env-only injections (zsh, fish) and for .other.
    extra_argv: []const [:0]const u8,
    env: []const EnvPair,
    /// The shim directory, set EXACTLY when this call created one — an unknown
    /// shell writes nothing and reports null. The caller deletes it at teardown,
    /// so a path reported but never created is a cleanup claiming work it did
    /// not do, and one created but not reported is litter in the runtime
    /// directory. Reported from the one place that knows, rather than re-derived
    /// by the caller from a second `detect` of the same shell.
    dir: ?[]const u8 = null,
};

/// The empty injection: what a shell with no scripts gets, and what a
/// failed `install` degrades to. Nothing to exec, nothing to export,
/// nothing on disk to remove.
pub const no_injection: Injection = .{ .extra_argv = &.{}, .env = &.{}, .dir = null };

/// Degraded, never fatal: without marks a session runs on pgid and
/// settle fallbacks.
pub fn install(
    arena: std.mem.Allocator,
    parent_dir: []const u8,
    shell_path: []const u8,
) Injection {
    // What a SIGKILLed predecessor left under its own pid, reaped by the
    // first daemon since to make one of these here.
    xdg.reapDeadPid(parent_dir, "mux-shellint-");
    // The pid keeps two daemons sharing one runtime directory legible in a
    // listing; the random half is not decoration. `parent_dir` is the socket's
    // directory, a shared `/tmp` when `$XDG_RUNTIME_DIR` is unset, and a pid is
    // guessable: another user could pre-create the exact name as a symlink to a
    // directory of theirs, and the shim files the session shell then SOURCES
    // would land through it.
    //
    // What closes that is `prepare` creating the directory EXCLUSIVELY — an
    // existing name, symlink or not, fails the call. The random half only makes
    // the attempt expensive to aim, and is not the defence on its own. It also
    // ends the mundane collision, where a predecessor SIGKILLed before teardown
    // left its name behind for a later daemon drawing the same pid.
    const dir = std.fmt.allocPrint(
        arena,
        "{s}/mux-shellint-{d}-{x:0>12}",
        .{ parent_dir, server_os.getpid(), std.crypto.random.int(u48) },
    ) catch {
        std.debug.print(
            "mux d: shell integration unavailable (out of memory naming the shim " ++
                "directory under {s}); the session runs without command marks\n",
            .{parent_dir},
        );
        return no_injection;
    };
    return prepare(arena, dir, shell_path) catch |err| {
        std.debug.print(
            "mux d: shell integration unavailable ({s}: {t}); " ++
                "the session runs without command marks\n",
            .{ dir, err },
        );
        return no_injection;
    };
}

/// Creates `dir` EXCLUSIVELY: adopting one is a symlink attack.
pub fn prepare(
    arena: std.mem.Allocator,
    dir: []const u8,
    shell_path: []const u8,
) !Injection {
    // Everything written below `dir` then goes through path components this
    // process made. An entry already there is `error.DirExists` and the
    // session does without marks. All returned slices come from `arena`,
    // which must live as long as the daemon.
    switch (detect(shell_path)) {
        .zsh => {
            try xdg.makeNewPrivateDir(dir);
            // An injection lands whole or leaves nothing: `dir` only reaches
            // `Injection.dir` on the success return, so any failure between here
            // and there must take the directory back out with it.
            errdefer std.fs.cwd().deleteTree(dir) catch {};
            const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" });
            try writeFilePrivate(rc_path, zsh_zshrc);
            var env: std.ArrayList(EnvPair) = .empty;
            const dir_z = try arena.dupeZ(u8, dir);
            try env.append(arena, .{ .key = "ZDOTDIR", .value = dir_z });
            // Only when the daemon itself carried one: exporting an empty
            // ZDOTDIR would break zsh's fallback to $HOME (spec footnote).
            if (std.posix.getenv("ZDOTDIR")) |orig| {
                try env.append(arena, .{
                    .key = "MUX_ORIG_ZDOTDIR",
                    .value = try arena.dupeZ(u8, orig),
                });
            }
            return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena), .dir = dir };
        },
        .bash => {
            try xdg.makeNewPrivateDir(dir);
            // See the zsh arm above: an injection either lands whole or
            // leaves nothing for teardown to guess about.
            errdefer std.fs.cwd().deleteTree(dir) catch {};
            const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" });
            try writeFilePrivate(init_path, bash_init);
            const init_z = try arena.dupeZ(u8, init_path);
            const argv = try arena.alloc([:0]const u8, 2);
            argv[0] = "--init-file";
            argv[1] = init_z;
            return .{ .extra_argv = argv, .env = &.{}, .dir = dir };
        },
        .fish => {
            // The root first and on its own, because it is the only
            // component whose parent is a directory strangers can write
            // to: once it exists, 0700 and ours, the two levels below it
            // are being created somewhere nobody else can reach.
            try xdg.makeNewPrivateDir(dir);
            // `dir`, not `vendor`: it's the root `Injection.dir` would have
            // named below, and it's what teardown would otherwise be left
            // to guess about — deleteTree on it takes the nested vendor
            // directory with it.
            errdefer std.fs.cwd().deleteTree(dir) catch {};
            try xdg.makeNewPrivateDir(try std.fs.path.join(arena, &.{ dir, "fish" }));
            const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" });
            try xdg.makeNewPrivateDir(vendor);
            const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" });
            try writeFilePrivate(conf_path, fish_conf);
            const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share";
            const merged = try std.fmt.allocPrintSentinel(arena, "{s}:{s}", .{ dir, orig }, 0);
            return .{
                .extra_argv = &.{},
                .env = try arena.dupe(EnvPair, &.{.{ .key = "XDG_DATA_DIRS", .value = merged }}),
                // The vendor directory nests UNDER `dir`, and `dir` is what
                // teardown removes: deleting the leaf would leave the two
                // directories above it behind.
                .dir = dir,
            };
        },
        .other => return no_injection,
    }
}

/// 0600 here; the exclusively-created 0700 directory
/// (`xdg.makeNewPrivateDir`, never the adopting variant) keeps a
/// stranger in the shared parent out.
fn writeFilePrivate(path: []const u8, contents: []const u8) !void {
    const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 });
    defer f.close();
    try f.writeAll(contents);
}

test "detect goes by basename" {
    try std.testing.expectEqual(Kind.zsh, detect("/usr/bin/zsh"));
    try std.testing.expectEqual(Kind.zsh, detect("zsh"));
    try std.testing.expectEqual(Kind.bash, detect("/bin/bash"));
    try std.testing.expectEqual(Kind.fish, detect("/usr/local/bin/fish"));
    // The fallbacks: a POSIX sh and anything exotic get no injection at
    // all, and the session runs on pgid + settle exactly as before.
    try std.testing.expectEqual(Kind.other, detect("/bin/sh"));
    try std.testing.expectEqual(Kind.other, detect("/usr/bin/nu"));
    // Only the last component decides. A "bash" directory on the way there
    // must not make a dash session look like a bash one and get handed a
    // --init-file it does not understand.
    try std.testing.expectEqual(Kind.other, detect("/opt/bash/bin/dash"));
}

/// Every test that touches the filesystem wants a real, writable,
/// disposable directory and the string naming it. No socket is bound
/// here, so std's tmpDir (and its long .zig-cache path) is fine —
/// testtmp exists for sun_path, which this module never touches.
const TmpPath = struct {
    tmp: std.testing.TmpDir,
    dir: []const u8,

    fn make() !TmpPath {
        var tmp = std.testing.tmpDir(.{});
        errdefer tmp.cleanup();
        const dir = try tmp.dir.realpathAlloc(std.testing.allocator, ".");
        return .{ .tmp = tmp, .dir = dir };
    }

    fn deinit(self: *TmpPath) void {
        std.testing.allocator.free(self.dir);
        self.tmp.cleanup();
    }
};

test "prepare zsh writes the shim and sets ZDOTDIR" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
    const inj = try prepare(arena.allocator(), shim, "/usr/bin/zsh");

    // zsh is an env-only injection: the shell is exec'd with no extra argv
    // and finds the shim because ZDOTDIR points at it.
    try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
    try std.testing.expect(inj.env.len >= 1);
    try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key);
    try std.testing.expectEqualStrings(shim, inj.env[0].value);
    // A directory was created, so it is reported — this is the value the
    // daemon deletes at teardown, and nothing else tells it what to delete.
    try std.testing.expectEqualStrings(shim, inj.dir.?);

    // ZDOTDIR names the directory; the file zsh will source is the .zshrc
    // inside it, which is the artifact worth asserting on.
    const rc_path = try std.fs.path.join(arena.allocator(), &.{ inj.env[0].value, ".zshrc" });
    const rc = try std.fs.cwd().readFileAlloc(std.testing.allocator, rc_path, 8192);
    defer std.testing.allocator.free(rc);
    // The two halves that make it work: the command-end mark carries the exit
    // code, and the hooks are registered.
    try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null);
    try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null);
    // ...and the shim hands control back to the user's own rc, which is the
    // difference between integration and hijacking their shell.
    try std.testing.expect(std.mem.indexOf(u8, rc, ".zshrc\"") != null);
}

test "prepare bash returns --init-file argv" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
    const inj = try prepare(arena.allocator(), shim, "/bin/bash");

    // bash has no ZDOTDIR equivalent, so the shim arrives on the command
    // line instead — and nothing goes into the environment.
    try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len);
    try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]);
    try std.testing.expectEqual(@as(usize, 0), inj.env.len);
    try std.testing.expectEqualStrings(shim, inj.dir.?);

    const script = try std.fs.cwd().readFileAlloc(std.testing.allocator, inj.extra_argv[1], 8192);
    defer std.testing.allocator.free(script);
    try std.testing.expect(std.mem.indexOf(u8, script, "PROMPT_COMMAND") != null);
    try std.testing.expect(std.mem.indexOf(u8, script, "trap '_mux_preexec' DEBUG") != null);
    // --init-file REPLACES ~/.bashrc, so the shim sourcing it is what keeps
    // the user's shell theirs. Its absence would be silent.
    try std.testing.expect(std.mem.indexOf(u8, script, "$HOME/.bashrc") != null);
}

test "prepare fish writes vendor_conf.d and prepends to XDG_DATA_DIRS" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    // No fish binary needed: what `prepare` owes fish is a file at the path
    // fish looks in and a data dir pointing there, and both are checkable
    // on any box. The e2e that needs the real shell skips where it is absent.
    const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
    const inj = try prepare(arena.allocator(), shim, "/usr/bin/fish");

    try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
    try std.testing.expectEqual(@as(usize, 1), inj.env.len);
    try std.testing.expectEqualStrings("XDG_DATA_DIRS", inj.env[0].key);
    // The shim root, not the vendor directory nested inside it: teardown
    // removes what it is given, and the leaf would strand two levels.
    try std.testing.expectEqualStrings(shim, inj.dir.?);
    // Prepended, not replaced: fish still has to find its own completions
    // and functions, so clobbering the list would break the shell to
    // integrate with it.
    try std.testing.expect(std.mem.startsWith(u8, inj.env[0].value, shim));
    try std.testing.expect(inj.env[0].value.len > shim.len + 1);
    try std.testing.expectEqual(@as(u8, ':'), inj.env[0].value[shim.len]);

    // fish reads vendor_conf.d from `$XDG_DATA_DIRS/fish/vendor_conf.d`, so
    // the nesting under the shim directory is the part that has to be right.
    const conf_path = try std.fs.path.join(
        arena.allocator(),
        &.{ shim, "fish", "vendor_conf.d", "mux.fish" },
    );
    const conf = try std.fs.cwd().readFileAlloc(std.testing.allocator, conf_path, 8192);
    defer std.testing.allocator.free(conf);
    try std.testing.expect(std.mem.indexOf(u8, conf, "--on-event fish_preexec") != null);
    try std.testing.expect(std.mem.indexOf(u8, conf, "--on-event fish_postexec") != null);
    try std.testing.expect(std.mem.indexOf(u8, conf, "133;D;%s") != null);
}

test "prepare other injects nothing" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
    const inj = try prepare(arena.allocator(), shim, "/bin/sh");
    try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
    try std.testing.expectEqual(@as(usize, 0), inj.env.len);
    // Not merely empty: an unknown shell must leave no trace on disk, so a
    // /bin/sh session is byte-identical to one from before this module.
    try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(shim, .{}));
    // And it says so, which is the half teardown reads: a reported path
    // here would have the daemon delete-tree a directory nothing created.
    try std.testing.expectEqual(@as(?[]const u8, null), inj.dir);
}

test "install names the shim directory after the daemon and degrades in place" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    // The naming rule, asserted where it now lives: the caller hands over a
    // parent and gets back a directory under it named for this daemon, which
    // is what keeps two daemons sharing one runtime directory out of each
    // other's shims.
    const inj = install(arena.allocator(), t.dir, "/bin/bash");
    var want: [512]u8 = undefined;
    const prefix = try std.fmt.bufPrint(
        &want,
        "{s}/mux-shellint-{d}-",
        .{ t.dir, server_os.getpid() },
    );
    // A prefix, not the whole name: the pid is followed by 12 hex digits of
    // randomness, and the two halves answer different questions — the pid
    // says which daemon a directory belongs to, the random part is what an
    // attacker who can create entries in the parent cannot guess.
    try std.testing.expect(std.mem.startsWith(u8, inj.dir.?, prefix));
    try std.testing.expectEqual(prefix.len + 12, inj.dir.?.len);
    try std.fs.cwd().access(inj.dir.?, .{});

    // Twice in ONE process, so the pid is identical and only the random half
    // can differ. This is the pinned oddity going away: a second daemon that
    // drew a predecessor's pid used to find the leftover directory sitting
    // there and lose its marks to it.
    const again = install(arena.allocator(), t.dir, "/bin/bash");
    try std.testing.expect(std.mem.startsWith(u8, again.dir.?, prefix));
    try std.testing.expect(!std.mem.eql(u8, inj.dir.?, again.dir.?));

    // An unwritable parent is the degraded path, and it is NOT an error: a
    // session without marks still runs, so `install` returns the empty
    // injection and the daemon starts. (The diagnostic goes to stderr; what
    // is asserted here is that nothing propagates and nothing is claimed.)
    const blocked = try std.fs.path.join(arena.allocator(), &.{ t.dir, "file-not-a-dir" });
    const f = try std.fs.cwd().createFile(blocked, .{});
    f.close();
    const degraded = install(arena.allocator(), blocked, "/bin/bash");
    try std.testing.expectEqual(@as(usize, 0), degraded.extra_argv.len);
    try std.testing.expectEqual(@as(usize, 0), degraded.env.len);
    try std.testing.expectEqual(@as(?[]const u8, null), degraded.dir);
}

test "prepare zsh: a failure after the directory exists leaves no orphan" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    // The failure has to land AFTER the directory exists, and the shim root is
    // created exclusively — so nothing can be planted inside it in advance. An
    // allocator that refuses its first request fails at the same step.
    const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
    var failing = std.testing.FailingAllocator.init(arena.allocator(), .{ .fail_index = 0 });

    try std.testing.expectError(
        error.OutOfMemory,
        prepare(failing.allocator(), shim, "/usr/bin/zsh"),
    );
    // The errdefer takes the whole directory with it rather than leaving
    // an orphan for teardown to never hear about (Injection.dir is the
    // only thing teardown deletes, and a failed prepare never returns one).
    try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(shim, .{}));
}

test "prepare refuses a shim path it did not create, and writes nothing through it" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    // The attack in three lines: on a box with no XDG_RUNTIME_DIR another user
    // can create an entry at the name this daemon is about to pick. A symlink is
    // the costly version — `makePath` tolerates it, the chmod re-modes the
    // TARGET, and the .zshrc lands somewhere this daemon does not own.
    const victim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "victim" });
    try std.fs.cwd().makePath(victim);
    var vd = try std.fs.cwd().openDir(victim, .{ .iterate = true });
    defer vd.close();
    try vd.chmod(0o755);
    const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
    try std.posix.symlink(victim, shim);

    // mkdir neither follows the link nor adopts what is there.
    try std.testing.expectError(error.DirExists, prepare(arena.allocator(), shim, "/usr/bin/zsh"));

    // Nothing was written through the link, and the target's mode is the
    // one its owner chose — the two halves of the clobber, asserted apart
    // because a fix that only stopped one of them would look like a fix.
    try std.testing.expectError(
        error.FileNotFound,
        vd.access(".zshrc", .{}),
    );
    const st = try vd.stat();
    try std.testing.expectEqual(@as(u32, 0o755), @as(u32, @intCast(st.mode & 0o777)));

    // ...and the link itself survives: a refusal that deleted what it found
    // would be the same trespass by another name. (The errdefer that removes
    // a half-built shim is armed AFTER the create, which is what makes this
    // hold.)
    var link_buf: [std.fs.max_path_bytes]u8 = undefined;
    try std.testing.expectEqualStrings(victim, try std.fs.cwd().readLink(shim, &link_buf));

    // A plain pre-existing directory is refused on the same grounds: `dir`
    // is this daemon's to create or to do without, never to adopt.
    const taken = try std.fs.path.join(arena.allocator(), &.{ t.dir, "taken" });
    try std.fs.cwd().makePath(taken);
    try std.testing.expectError(error.DirExists, prepare(arena.allocator(), taken, "/bin/bash"));
    try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(
        try std.fs.path.join(arena.allocator(), &.{ taken, "bash-init.sh" }),
        .{},
    ));
}

test "prepare zsh: the shim directory is 0700 and the rc file 0600" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();

    const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
    _ = try prepare(arena.allocator(), shim, "/usr/bin/zsh");

    // makePath alone leaves 0755. What lands here is a file the session
    // shell sources — anyone who can write it can run code as this user —
    // so the permissions are part of the contract, not decoration.
    var d = try std.fs.cwd().openDir(shim, .{ .iterate = true });
    defer d.close();
    const dst = try d.stat();
    try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777)));

    const rc_path = try std.fs.path.join(arena.allocator(), &.{ shim, ".zshrc" });
    const f = try std.fs.cwd().openFile(rc_path, .{});
    defer f.close();
    const fst = try f.stat();
    try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
}

// 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 "install reaps a dead daemon's shim directory and leaves a live daemon's" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    var t = try TmpPath.make();
    defer t.deinit();
    const testtmp = @import("testtmp");
    const dead = try testtmp.deadPid();
    const left = try std.fmt.allocPrint(arena.allocator(), "{s}/mux-shellint-{d}-000000000000", .{ t.dir, dead });
    try std.fs.cwd().makePath(left);
    const live = try std.fmt.allocPrint(arena.allocator(), "{s}/mux-shellint-1-000000000000", .{t.dir});
    try std.fs.cwd().makePath(live);

    const inj = install(arena.allocator(), t.dir, "/bin/bash");
    try std.testing.expect(inj.dir != null);
    try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(left, .{}));
    try std.fs.cwd().access(live, .{});
}