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, .{});
}