src/cli/mux_main.zig
Ref: Size: 55.6 KiB History
//! Client mode for `mux`. `--sock PATH` attaches to a local daemon, `--via CMD`
//! uses the command's stdio without a shell, and a bare host uses the
//! SSH-to-QUIC handoff.
//!
//! Bare `mux` opens the wall the layout file names: one pane per leaf, in
//! the tree they were arranged in, and nothing else — a session born by
//! anyone else on a listed daemon is on no wall until a picker adds it.
//! Naming a transport opens the same wall zoomed on a pane for that
//! daemon's session `0`, adding it to both files if they did not have it.
//! This module handles argument validation, local daemon startup, and the
//! `hosts` subcommand before handing connections to the wall.
const std = @import("std");
const client = @import("client");
const proto = @import("term").protocol;
const build_options = @import("build_options");
const xdg = @import("xdg");
const spawn = @import("spawn");
const handoff = @import("client").handoff;
const sockpath = @import("sockpath");
const wall = @import("wall");
const hosts = @import("client").hosts;
const layoutfile = @import("client").layoutfile;
const cliflags = @import("cliflags");
const client_os = @import("client_os");
const TmpDir = @import("testtmp").TmpDir;
/// Root help page for the binary. It lists the mode words first, then documents
/// the default client mode; named modes provide their own `--help` pages.
const usage =
\\usage: mux [TARGET ...] attach, or the wall (this page)
\\ mux d VERB ... the daemon: run start stop stats dump proxy
\\ endpoint keygen upgrade (`mux d --help`)
\\ mux a VERB ... the agent surface, one JSON object per verb
\\ (`mux a --help`)
\\ mux web [TARGET ...] the browser hub (`mux web --help`)
\\
\\ mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]]
\\ HOST attaches over ssh and hands off to QUIC when the daemon offers it
\\ (`mux` must be on HOST's PATH; cached coordinates make later attaches
\\ skip ssh entirely)
\\ quic://HOST[:PORT] (PORT defaults to 4433) uses --key FILE,
\\ MUX_KEY_FILE, or ~/.config/mux/key; the daemon must be running with a
\\ matching --quic and key
\\ --via CMD is argv words, exec'd directly: no shell, no quoting
\\ [--quic-idle-ms N] tunes how fast a dead link is noticed
\\ [--session NAME] attaches to (or creates) a named session instead of
\\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/'
\\ -A forwards this client's ssh-agent into the session, like ssh -A:
\\ whoever typed last is whose agent signs, and only while attached
\\ --version prints the version, --help prints this
\\
\\ mux the wall: the panes your layout file names
\\ mux -A the same wall, entered on the local session with the
\\ agent armed — bare `mux` carries no agent
\\ mux hosts list the daemons you can browse, with their live session counts
\\ mux hosts add SPELLING record a daemon without opening it
\\ mux hosts rm SPELLING take one off (its sessions keep running)
\\
\\ SPELLING names a DAEMON — HOST, quic://HOST[:PORT], or --sock PATH
\\ (one argument, or two as in `mux web`) — and never a session: `#NAME`
\\ is refused, because a host line names a machine and the layout file
\\ is what names sessions.
\\
\\ On the wall, `Ctrl-\ 1-9` focuses a pane and types into it,
\\ `Ctrl-\ n/p` walk the panes, `Ctrl-\ h/j/k/l` moves between them,
\\ `Ctrl-\ c` and `Ctrl-\ |/-` create a session on the focused pane's
\\ daemon, `Ctrl-\ x` takes the focused pane off the wall (the session
\\ keeps running), `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode,
\\ `Ctrl-\ w` zooms out, `Ctrl-\ d` leaves.
\\
\\ `Ctrl-\ s` opens the picker, which is how a session joins the wall:
\\ every DAEMON with what its last poll said, Enter for that daemon's
\\ sessions, Enter again to add one as a pane. j/k or the arrows move,
\\ 1-9 pick a row, c starts a new session, x forgets a host or ends a
\\ session, a adds a host by spelling, Esc backs out.
\\
;
/// A successfully parsed connection specification.
///
/// Keeping parsing separate from printing and process exit makes it easy to
/// test. Invalid usage, conflicting transports, `--help`, and `--version`
/// are returned as `ParseError` values instead.
const ConnectionSpec = union(enum) {
/// Attach through a Unix socket or a command supplied with `--via`.
/// If both are null, use the default local socket.
///
/// An empty session name preserves the original wire encoding for the
/// default session; see `encodeAttachNamed`.
attach: struct {
sock: ?[]const u8 = null,
via: ?[]const u8 = null,
session: []const u8 = "",
agent: bool = false,
},
/// Connect to a bare hostname through SSH.
///
/// `main` builds the SSH command because doing so requires an allocator.
/// The resulting handoff uses QUIC, so its idle timeout is retained here.
host: struct {
name: []const u8,
idle_ms: u32,
session: []const u8 = "",
agent: bool = false,
},
/// Connect directly to a `quic://HOST[:PORT]` endpoint.
///
/// `key` is optional here because `main` can resolve the default key using
/// the environment and filesystem.
quic: struct {
host_port: []const u8,
key: ?[]const u8,
idle_ms: u32,
session: []const u8 = "",
agent: bool = false,
},
};
/// Extend the shared CLI errors with the case where more than one transport is
/// specified.
const ParseError = cliflags.ParseError || error{Conflict};
/// ssh-agent frame for `SSH_AGENTC_REQUEST_IDENTITIES`, the same request used by
/// `ssh-add -l`. Any protocol reply proves that an agent is present. Agent
/// forwarding remains byte-transparent outside this client-side probe.
const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 };
/// Maximum wait for the agent probe. The daemon immediately closes a forwarded
/// agent socket when no client is offering an agent; a slow reply may still come
/// from a real agent or hardware token.
const agent_probe_ms = 500;
/// Probe whether `path` leads to an ssh-agent by sending an identities request.
/// A connect alone is insufficient inside a mux session because the daemon
/// accepts the socket before checking for an offering client. EOF means no
/// agent is available; a timeout is treated as reachable because real agents
/// and hardware tokens may reply slowly.
fn agentReachable(path: []const u8) bool {
const fd = client.connectAgent(path) orelse return false;
defer std.posix.close(fd);
// Through `client_os.sendNoSig` because this probe runs before the client
// installs signal handling and the peer may already have closed the socket;
// the operation's contract is that a closed peer comes back as an error
// rather than as a signal.
_ = client_os.sendNoSig(fd, &agent_request_identities) catch return false;
var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true;
if (ready == 0) return true;
var reply: [1]u8 = undefined;
// EOF after a successful connect is how the daemon reports that no attached
// client is offering an agent.
const n = std.posix.read(fd, &reply) catch return false;
return n != 0;
}
/// Build the diagnostic from `proto.session_env` so it stays synchronized with
/// the environment variable set for sessions.
const self_attach_refusal =
"mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n";
/// Raw client arguments. Field names and types define flag syntax; `parseArgs`
/// performs cross-field and transport validation.
const ClientArguments = struct {
sock: ?[]const u8 = null,
via: ?[]const u8 = null,
key: ?[]const u8 = null,
/// Optional so validation applies only to an explicitly supplied name. An
/// empty string is reserved for the wire encoding of the default session.
session: ?proto.SessionName = null,
quic_idle_ms: client.IdleMs = .{},
agent: bool = false,
/// Leading underscores exclude these parser bookkeeping fields from flag
/// generation. They are populated by `positional`.
_host: ?[]const u8 = null,
_quic: ?[]const u8 = null,
_targets: u8 = 0,
pub const aliases = .{.{ "-A", "agent" }};
/// Parse a bare hostname or `quic://...` target. Count every positional
/// target so `parseArgs` can reject multiple transports. An empty
/// `quic://` target is invalid immediately.
pub fn positional(self: *ClientArguments, word: []const u8) bool {
if (std.mem.startsWith(u8, word, hosts.quic_prefix)) {
const host_port = word[hosts.quic_prefix.len..];
if (host_port.len == 0) return false;
self._quic = host_port;
} else self._host = word;
self._targets += 1;
return true;
}
};
comptime {
cliflags.assertDocumented(ClientArguments, usage, &.{});
}
fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ConnectionSpec {
var o: ClientArguments = .{};
try cliflags.parseStrict(ClientArguments, &o, args[1..]);
// At most one socket, via command, hostname, or QUIC endpoint may be named.
// Counting positional targets also detects two bare host arguments.
const named: u8 = @as(u8, @intFromBool(o.sock != null)) +
@intFromBool(o.via != null) + o._targets;
if (named > 1) return error.Conflict;
// Session selection and agent forwarding apply to every transport.
const session = if (o.session) |n| n.name else "";
if (o._quic) |host_port| {
// A missing explicit key is valid because `main` can resolve the
// environment or default path using the filesystem.
return .{ .quic = .{
.host_port = host_port,
.key = xdg.pickKey(o.key, env_key),
.idle_ms = o.quic_idle_ms.ms,
.session = session,
.agent = o.agent,
} };
}
// Ignore key configuration for non-QUIC transports. In particular, an
// exported `MUX_KEY_FILE` must not break local or SSH connections.
if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } };
return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } };
}
/// Run the client mode. Unlike named modes, it receives the complete argv from
/// the top-level dispatcher.
pub fn main(args: []const [:0]const u8) !u8 {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer if (gpa.deinit() == .leak)
std.debug.print("mux: LEAK: allocations outlived deinit\n", .{});
const alloc = gpa.allocator();
// Dispatch `hosts` before transport parsing because it operates on the
// state file and never opens a session.
if (args.len > 1 and std.mem.eql(u8, args[1], "hosts"))
return hostsMain(alloc, args[2..], null);
// Bare `mux` opens the wall, while an explicit default socket opens an
// attachment even though both would produce the same `ConnectionSpec`.
if (args.len == 1) return wallOfHosts(alloc);
const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) {
error.Conflict => {
std.debug.print(
"mux: name one transport: HOST, --sock, --via or quic://\n{s}",
.{usage},
);
return 2;
},
else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version),
};
// Validate `-A` before dialing. Advertising an unavailable agent would make
// the daemon route agent requests to a client that cannot answer them.
const wants_agent = switch (parsed) {
.host => |h| h.agent,
.quic => |q| q.agent,
.attach => |at| at.agent,
};
if (wants_agent) {
const sock = std.posix.getenv(proto.agent_sock_env) orelse "";
if (!agentReachable(sock)) {
if (sock.len == 0) {
std.debug.print(
"mux: -A: " ++ proto.agent_sock_env ++ " is not set — no ssh-agent to forward\n",
.{},
);
} else {
std.debug.print(
"mux: -A: no ssh-agent answering at {s}\n",
.{sock},
);
}
return 2;
}
}
switch (parsed) {
.quic => |q| {
const res = try xdg.resolveKeyPath(alloc, q.key);
defer res.deinit(alloc);
const key_path = switch (res) {
.given, .default => |p| p,
.missing => |p| {
std.debug.print(
"mux: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n",
.{p},
);
return 2;
},
};
return wall.runAttach(alloc, .{ .quic = .{
.host_port = q.host_port,
.key_path = key_path,
.idle_ms = q.idle_ms,
} }, q.session, q.key, q.idle_ms, q.agent, build_options.version);
},
.host => |h| {
// The handoff recipe uses SSH to obtain coordinates and as a
// fallback transport. A cached endpoint can skip SSH entirely.
const r = try handoff.recipeFor(alloc, h.name, false);
defer r.deinit(alloc);
// A direct `mux HOST` invocation may start the remote daemon and
// report fallback progress. The host picker's Enter path does the
// same.
var target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true);
// Relay SSH stderr for this foreground attach; no alternate-screen
// UI is active yet.
target.narrate = true;
return wall.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent, build_options.version);
},
.attach => |t| {
if (t.via) |cmd| return wall.runAttach(
alloc,
.{ .via = cmd },
t.session,
null,
client.quic_idle_ms_default,
t.agent,
build_options.version,
);
const sock_path = if (t.sock) |s|
try alloc.dupe(u8, s)
else
try sockpath.defaultOrExplain(alloc, "mux") orelse return 1;
defer alloc.free(sock_path);
return attachLocal(alloc, sock_path, t.session, t.agent);
},
}
}
/// The reason the local socket needs a start — the dial's own error — when
/// the hosts file lists it and nothing answers on it; null otherwise. The
/// reason rather than a bool because `startLocalDaemon` writes it down.
fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) ?anyerror {
var buf: [std.fs.max_path_bytes + hosts.sock_prefix.len]u8 = undefined;
const line = std.fmt.bufPrint(&buf, hosts.sock_prefix ++ "{s}", .{sock}) catch return null;
if (!h.has(line)) return null;
return sockpath.probe(sock);
}
/// Attach through a local Unix socket, used by both `mux --sock PATH` and the
/// empty-hosts-file fallback.
fn attachLocal(
alloc: std.mem.Allocator,
sock_path: []const u8,
session: []const u8,
agent: bool,
) !u8 {
// Reject attaching a shell to the same session it already occupies. This
// check runs after resolving the default socket path and applies only to a
// direct client attach; wall-created tiles do not call this function.
if (wall.showsSelf(
.{ .sock = sock_path },
session,
std.posix.getenv(proto.sock_env),
std.posix.getenv(proto.session_env),
)) {
std.debug.print("{s}", .{self_attach_refusal});
return 2;
}
if (sockpath.probe(sock_path)) |why| {
if (!try startLocalDaemon(alloc, sock_path, why)) return 1;
}
return wall.runAttach(
alloc,
.{ .sock = sock_path },
session,
null,
client.quic_idle_ms_default,
agent,
build_options.version,
);
}
/// Start a detached local daemon by executing this binary as
/// `mux d start -d --sock PATH`. Inherited stdio preserves daemon diagnostics.
///
/// `why` is what the dial answered, and it goes into the daemon log FIRST:
/// `FileNotFound` is a path with nothing at it, `ConnectionRefused` a
/// socket file nobody is listening on. When a running daemon's socket file
/// is deleted, this is the line that says a second daemon was started on
/// the path, when, and that the path was empty rather than dead at the
/// time — none of which the 2026-09-04 log could say (issue 04b3019d).
fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8, why: anyerror) !bool {
const note = try std.fmt.allocPrint(
alloc,
"mux: auto-starting a daemon on {s}: the dial said {s}\n",
.{ sock_path, @errorName(why) },
);
defer alloc.free(note);
xdg.appendLogLine(alloc, note) catch {};
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
const argv = [_][]const u8{ spawn.selfExe(&exe_buf), "d", "start", "-d", "--sock", sock_path };
var child = std.process.Child.init(&argv, alloc);
const term = child.spawnAndWait() catch |err| {
std.debug.print("mux: could not run {s}: {s}\n", .{ argv[0], @errorName(err) });
return false;
};
return term == .Exited and term.Exited == 0;
}
/// Parsed host arguments for `mux hosts add`. Every positional word is a host
/// spelling; this command defines no independent flags.
const HostsArguments = struct {
_argv: hosts.Argv,
pub fn positional(self: *HostsArguments, w: []const u8) bool {
return self._argv.positional(w);
}
pub fn extra(self: *HostsArguments, rest: []const [:0]const u8) usize {
return self._argv.extra(rest);
}
};
comptime {
cliflags.assertDocumented(HostsArguments, usage, &.{});
}
/// Open the daemon wall. An empty hosts file falls back to the local socket.
/// Resolved host data uses an arena because `wall.run` does not return on
/// success.
fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
const path = try hosts.statePath(arena);
const h = hosts.load(arena, path) catch |err| return refuseFile(arena, "mux", path, err);
if (h.lines.items.len == 0) {
// On first use, attach to the default local daemon. `wall.runAttach`
// records the socket only after the connection succeeds.
const sock_path = try sockpath.defaultOrExplain(arena, "mux") orelse return 1;
return attachLocal(alloc, sock_path, "", false);
}
// Restart a listed local daemon after reboot when its persistent hosts-file
// entry remains but its socket is inactive. If startup fails, wall polling
// continues and can discover a daemon started by another process.
if (sockpath.defaultSockPath(arena) catch null) |sock| {
if (localNeedsStart(&h, sock)) |why| _ = try startLocalDaemon(alloc, sock, why);
}
const key = std.posix.getenv(xdg.key_env);
const specs = try arena.alloc(wall.HostSpec, h.lines.items.len);
for (specs, h.lines.items) |*s, line| {
// Fail the entire wall when a persisted host cannot be parsed; silently
// omitting an explicitly configured daemon would misrepresent the file.
s.* = wall.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) });
return 2;
};
}
return wall.run(arena, specs, .{ .key = key, .own_version = build_options.version });
}
/// Timeout for a daemon's session-count reply. SSH setup is intentionally not
/// covered because it may wait for interactive authentication.
const hosts_list_ms = 2000;
/// `mux hosts [add|rm SPELLING...]`; a null `state_path` is the real file.
fn hostsMain(
alloc: std.mem.Allocator,
args: []const [:0]const u8,
state_path: ?[]const u8,
) !u8 {
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
const path = state_path orelse try hosts.statePath(arena);
if (args.len == 0) return hostsList(arena, path, std.posix.STDOUT_FILENO);
// Help and version take precedence over verb validation, matching the
// shared flag parser and avoiding a misleading unknown-verb diagnostic.
for (args) |a| {
if (cliflags.isHelp(a)) return cliflags.help(usage);
if (cliflags.isVersion(a)) return cliflags.version("mux", build_options.version);
}
const adding = std.mem.eql(u8, args[0], "add");
if (!adding and !std.mem.eql(u8, args[0], "rm")) {
std.debug.print("mux hosts: add or rm, not '{s}'\n{s}", .{ args[0], usage });
return 2;
}
return hostsEdit(arena, adding, args[1..], path);
}
/// Print each configured host with its current live-session count. Counts come
/// from the daemon because the hosts file contains no session state.
fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) !u8 {
// Load lines verbatim so invalid hand-edited entries remain visible and can
// be passed back to `mux hosts rm`. This read-only operation does not risk
// rewriting unrecognized content.
const lines = hosts.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err);
const key = std.posix.getenv(xdg.key_env);
for (lines.items) |line| {
const spec = wall.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
printRow(out_fd, line, "\t[bad host: {s}]\n", .{hosts.reason(err)});
continue;
};
var out: [proto.sessions_reply_max]u8 = undefined;
const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null, null) catch |err| {
// Propagate local allocation failure rather than reporting the
// remote daemon as unreachable.
if (err == error.OutOfMemory) return err;
printRow(out_fd, line, "\t[unreachable]\n", .{});
continue;
};
printRow(out_fd, line, "\t{d}\n", .{countSessions(list)});
}
return 0;
}
/// Print one hosts-file line verbatim followed by its formatted status.
fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void {
// Write the host separately because lines may be up to one MiB while the
// status buffer is small. Truncating a line would make it impossible to
// copy that exact value into `mux hosts rm`.
proto.writeAllFd(fd, line) catch return;
printOut(fd, fmt, args);
}
/// Write requested listing output to the supplied stdout-like descriptor;
/// diagnostics remain on stderr.
fn printOut(fd: std.posix.fd_t, comptime fmt: []const u8, args: anytype) void {
// An explicit descriptor lets tests capture output without interfering
// with the test runner's stdout protocol.
var buf: [512]u8 = undefined;
const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
proto.writeAllFd(fd, s) catch {};
}
/// Report a hosts-file failure. Parse errors print the invalid lines and return
/// exit code 2; I/O and allocation failures return exit code 1.
fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err: anyerror) u8 {
std.debug.print("{s}: {s}: {s}\n", .{ who, path, hosts.reason(err) });
// Classify the original error before reading the file again. Otherwise an
// allocation failure in a file that also contains invalid syntax could be
// misreported as a user-correctable parse error.
if (!hosts.isParse(err)) return 1;
const lines = hosts.loadLines(arena, path) catch return 2;
for (lines.items) |l| {
_ = hosts.parse(l) catch std.debug.print(" {s}\n", .{l});
}
return 2;
}
/// Count the session names in a `sessions_reply`, with or without a trailing
/// newline. `proto.sessionsIter` skips blank lines and anything that is not a
/// spellable name, so the number printed beside a host is the number of
/// sessions a wall would actually put on screen for it.
fn countSessions(list: []const u8) usize {
var n: usize = 0;
var it = proto.sessionsIter(list);
while (it.next()) |_| n += 1;
return n;
}
/// Add or remove host spellings in one state-file update. Neither operation
/// connects to a daemon.
fn hostsEdit(
arena: std.mem.Allocator,
adding: bool,
args: []const [:0]const u8,
path: []const u8,
) !u8 {
const verb = if (adding) "add" else "rm";
var spellings: std.ArrayList([]const u8) = .empty;
if (adding) {
// Validate new entries while their command-line spelling is available
// so diagnostics can identify the exact invalid target.
var o = HostsArguments{ ._argv = .{ .alloc = arena } };
const outcome = cliflags.parse(HostsArguments, &o, args);
// Prefer the host parser's specific recorded error over the generic
// unknown-argument result returned when its hook rejects a word.
if (o._argv.err) |e| {
if (e.err == error.OutOfMemory) return e.err;
std.debug.print("mux hosts add: '{s}': {s}\n", .{ e.word, hosts.reason(e.err) });
return 2;
}
switch (outcome) {
.ok => {},
.help => return cliflags.help(usage),
.version => return cliflags.version("mux", build_options.version),
.missing_value, .bad_value => {
std.debug.print("{s}", .{usage});
return 2;
},
.unknown_arg => |a| {
std.debug.print("mux hosts add: takes hosts, not flags: '{s}'\n{s}", .{ a, usage });
return 2;
},
}
spellings = o._argv.list;
} else {
// Parse only argv grouping for `rm`; do not validate host syntax. This
// allows removal of hand-edited lines that the current grammar rejects.
var i: usize = 0;
while (i < args.len) : (i += 1) {
const n = hosts.spellingFromArgv(arena, args, i) catch |err| switch (err) {
error.MissingSockPath => {
std.debug.print("mux hosts rm: '--sock' names no path\n", .{});
return 2;
},
error.FlagLikeTarget => {
std.debug.print("mux hosts rm: '{s}' is a flag, not a host\n", .{args[i]});
return 2;
},
else => |e| return e,
};
i += n.consumed - 1;
try spellings.append(arena, n.spelling);
}
}
if (spellings.items.len == 0) {
std.debug.print("mux hosts {s}: name at least one host\n", .{verb});
return 2;
}
if (adding) return hostsAdd(arena, spellings.items, path);
// Apply all removals in one read-modify-write so an I/O error cannot leave
// only a prefix of the requested changes committed.
const gone = try arena.alloc(bool, spellings.items.len);
@memset(gone, false);
hosts.forgetMany(arena, path, spellings.items, gone) catch |err| {
std.debug.print("mux hosts rm: {s}: {s}\n", .{ path, hosts.reason(err) });
return 1;
};
var rc: u8 = 0;
for (spellings.items, gone) |s, g| {
// A missing entry makes the command nonzero so scripts do not mistake a
// differently spelled host for a successful removal. Other removals
// still apply.
if (!g) {
std.debug.print("mux hosts rm: not on the wall: {s}\n", .{s});
rc = 1;
}
}
// The panes go with the daemon. A layout leaf is `HOST#SESSION` and its
// host part must be a line of the hosts file, so a leaf left behind here
// refuses the WHOLE layout at the next start — one `mux hosts rm box`
// and the user's other four panes are gone with a printed line they did
// not ask for. The sessions themselves keep running; this is the file,
// not the daemon. `wall_picker.pickForget` is the same edit typed from
// inside a wall, made there by the wall's own save.
if (layoutBeside(arena, path)) |lp| {
if (layoutfile.forgetHosts(arena, lp, spellings.items, "mux hosts")) |_| {} else |err| {
// Not a failure of the removal: the hosts file is already
// written and the daemon is already off the wall. Said, because
// the next `mux` will refuse a layout this could not repair.
std.debug.print("mux hosts rm: {s}: {s}\n", .{ lp, hosts.reason(err) });
}
}
// `forgetMany` matches exact lines. On failure, print the file so a
// hand-edited entry can be copied byte for byte.
if (rc != 0) showFile(arena, path);
return rc;
}
/// The layout file beside a given hosts file. Both are `xdg.statePath` names
/// in one directory (`hosts.statePath`, `hosts.layoutPath`), so the sibling
/// is the layout for THIS hosts file — including the tmp-directory pair a
/// test drives, which an `XDG_STATE_HOME` lookup here would miss.
fn layoutBeside(arena: std.mem.Allocator, hosts_path: []const u8) ?[]const u8 {
const dir = std.fs.path.dirname(hosts_path) orelse return null;
return std.fs.path.join(arena, &.{ dir, "layout" }) catch null;
}
/// Print the hosts file verbatim for use with an exact-match removal.
fn showFile(arena: std.mem.Allocator, path: []const u8) void {
const lines = hosts.loadLines(arena, path) catch return;
for (lines.items) |l| std.debug.print(" {s}\n", .{l});
}
/// Validate every spelling before saving all additions in one write, preventing
/// partial updates when validation or I/O fails.
fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []const u8) !u8 {
for (spellings) |s| {
const spec = hosts.parse(s) catch |err| {
std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(err) });
return 2;
};
// Reject Unix socket paths that cannot fit in `sun_path`; such an entry
// could never be dialed successfully.
if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2;
}
// Refuse to extend a file containing unrecognized entries because saving it
// would legitimize or alter content the parser did not understand.
var h = hosts.load(arena, path) catch |err|
return refuseFile(arena, "mux hosts add", path, err);
// Adding an existing host is idempotent.
for (spellings) |s| _ = try h.add(arena, s);
hosts.save(&h, path) catch |err| {
std.debug.print("mux hosts add: {s}: {s}\n", .{ path, hosts.reason(err) });
return 1;
};
return 0;
}
/// Test helper using the sentinel-terminated argv shape consumed by `parseArgs`.
fn parse(comptime argv: []const [:0]const u8) ParseError!ConnectionSpec {
return parseArgs(argv, null);
}
/// The same, with `MUX_KEY_FILE` set to `env`.
fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseError!ConnectionSpec {
return parseArgs(argv, env);
}
test "parseArgs: `mux run --resume-fd N` is rejected because bare `run` is a host" {
// The dispatcher treats `run` as a hostname, leaving `--resume-fd` as an
// unknown client flag. This complements the dispatcher-level test.
try std.testing.expectError(error.Usage, parse(&.{ "mux", "run", "--resume-fd", "5" }));
}
test "parseArgs: no arguments means the default local socket" {
const r = try parse(&.{"mux"});
try std.testing.expect(r == .attach);
try std.testing.expect(r.attach.sock == null);
try std.testing.expect(r.attach.via == null);
}
test "parseArgs: --sock and --via each name their transport" {
const s = try parse(&.{ "mux", "--sock", "/tmp/x.sock" });
try std.testing.expect(s == .attach);
try std.testing.expectEqualStrings("/tmp/x.sock", s.attach.sock.?);
try std.testing.expect(s.attach.via == null);
const v = try parse(&.{ "mux", "--via", "ssh box mux d proxy" });
try std.testing.expect(v == .attach);
try std.testing.expectEqualStrings("ssh box mux d proxy", v.attach.via.?);
try std.testing.expect(v.attach.sock == null);
}
test "parseArgs: a bare word is a host to hop to" {
const h = try parse(&.{ "mux", "vm1" });
try std.testing.expect(h == .host);
try std.testing.expectEqualStrings("vm1", h.host.name);
// Use the documented literal so this test detects an accidental change to
// the parser's default constant.
try std.testing.expectEqual(@as(u32, 15_000), h.host.idle_ms);
// Preserve `user@host` as an opaque SSH hostname so SSH configuration such
// as aliases, ports, and ProxyJump continues to apply.
const u = try parse(&.{ "mux", "ubuntu@sandbox-9b70e9" });
try std.testing.expect(u == .host);
try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host.name);
}
test "parseArgs: naming two transports is a conflict, however it is spelled" {
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "--sock", "/tmp/x.sock" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/tmp/x.sock", "vm1" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "--via", "ssh box mux d proxy" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/a", "--via", "c" }));
// Two positional targets conflict just like two different transports.
// Repeating a flag is distinct: the shared parser keeps its last value.
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "vm2" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "quic://b:2" }));
const s2 = try parse(&.{ "mux", "--sock", "/a", "--sock", "/b" });
try std.testing.expectEqualStrings("/b", s2.attach.sock.?);
const v2 = try parse(&.{ "mux", "--via", "ssh a", "--via", "ssh b" });
try std.testing.expectEqualStrings("ssh b", v2.attach.via.?);
}
test "hosts: add refuses a session by name, rm reports an unlisted host, the file keeps add order" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [256]u8 = undefined;
// Use the production directory shape and verify that `add` creates a
// missing parent directory on first use.
const path = try std.fmt.bufPrint(&buf, "{s}/mux/hosts", .{tmp.path()});
// Reject a session suffix before writing any state.
try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "box#build" }, path));
// A flag-shaped word is not a host, and `--sock PATH`'s two words are
// one spelling.
try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "-A" }, path));
try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "box" }, path));
try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "--sock", "/tmp/x.sock" }, path));
// A missing removal target returns nonzero so scripts can detect a spelling
// mismatch.
try std.testing.expectEqual(@as(u8, 1), try hostsMain(alloc, &[_][:0]const u8{ "rm", "nowhere" }, path));
var h = try hosts.load(alloc, path);
defer h.deinit(alloc);
try std.testing.expectEqual(@as(usize, 2), h.lines.items.len);
try std.testing.expectEqualStrings("box", h.lines.items[0]);
try std.testing.expectEqualStrings("--sock /tmp/x.sock", h.lines.items[1]);
try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box" }, path));
var after = try hosts.load(alloc, path);
defer after.deinit(alloc);
try std.testing.expectEqual(@as(usize, 1), after.lines.items.len);
try std.testing.expectEqualStrings("--sock /tmp/x.sock", after.lines.items[0]);
// Neither verb is a subcommand this program has.
try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{"list"}, path));
// `test/e2e_09_hosts.sh` covers `mux hosts --help`; invoking it here would
// write the help page to the test runner's stdout protocol.
}
test "hosts: a line the file cannot hold exits 2 wherever the file is read" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
// Include a hand-edited invalid entry to verify that `rm` can repair state
// the current grammar cannot parse.
try hosts.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n");
// Both adding and loading reject the invalid file with exit code 2. Removal
// reads verbatim, repairs the file, and then additions may resume.
try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box#old" }, path));
try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
}
test "hosts rm: the removed daemon's panes leave the layout beside the hosts file" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
var lbuf: [256]u8 = undefined;
const lpath = try std.fmt.bufPrint(&lbuf, "{s}/layout", .{tmp.path()});
try hosts.saveBytes(path, "box\n--sock /tmp/a.sock\n");
// Two hosts, three panes: the removal must take the two that name `box`
// and leave the third exactly where it was.
try hosts.saveBytes(lpath,
\\mux-layout 1
\\beside 0
\\ leaf 60 box#0
\\ stacked 40
\\ leaf 50 --sock /tmp/a.sock#work
\\ leaf 50 box#two
\\
);
try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box" }, path));
const after = try std.fs.cwd().readFileAlloc(alloc, lpath, 4096);
defer alloc.free(after);
// What the next `mux` reads: one leaf, and a file it does not refuse.
try std.testing.expect(std.mem.indexOf(u8, after, "--sock /tmp/a.sock#work") != null);
try std.testing.expect(std.mem.indexOf(u8, after, "box#") == null);
}
test "hosts list: a line the grammar refuses is named, not a refusal of the listing" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [512]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
var buf2: [512]u8 = undefined;
const dead = try std.fmt.bufPrint(&buf2, "{s}/absent.sock", .{tmp.path()});
var line_buf: [1024]u8 = undefined;
try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "box#old\n--sock {s}\n", .{dead}));
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
var out_buf: [512]u8 = undefined;
const out_path = try std.fmt.bufPrint(&out_buf, "{s}/out", .{tmp.path()});
const out = try std.fs.createFileAbsolute(out_path, .{});
const rc = try hostsList(arena, path, out.handle);
out.close();
// Listing must still display an invalid line so it can be repaired.
try std.testing.expectEqual(@as(u8, 0), rc);
const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 4096);
defer alloc.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "box#old\t[bad host: names a session") != null);
// An invalid row must not prevent subsequent valid hosts from being polled.
try std.testing.expect(std.mem.indexOf(u8, text, "\t[unreachable]") != null);
}
test "refuseFile: the exit code names whose fault it was, and a bad line in the file does not change it" {
// Exit code 2 identifies correctable syntax, while exit code 1 identifies
// an operational failure. Classification must use the original error even
// when the file also contains an invalid line.
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [512]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
try hosts.saveBytes(path, "box#old\nbox\n");
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
try std.testing.expectEqual(@as(u8, 2), refuseFile(arena, "t", path, error.HasSession));
try std.testing.expectEqual(@as(u8, 1), refuseFile(arena, "t", path, error.OutOfMemory));
try std.testing.expectEqual(@as(u8, 1), refuseFile(arena, "t", path, error.AccessDenied));
}
test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" {
// `hosts.loadLines` accepts one-MiB lines and `mux hosts rm` matches exact
// bytes, so listing must preserve the complete line needed for repair.
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [512]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
// Use an invalid host to avoid dialing; this isolates formatting of a line
// longer than the fixed status buffer.
var long: [900]u8 = undefined;
@memset(&long, 'h');
long[899] = '#';
var line_buf: [1024]u8 = undefined;
try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long}));
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
var out_buf: [512]u8 = undefined;
const out_path = try std.fmt.bufPrint(&out_buf, "{s}/out", .{tmp.path()});
const out = try std.fs.createFileAbsolute(out_path, .{});
const rc = try hostsList(arena, path, out.handle);
out.close();
try std.testing.expectEqual(@as(u8, 0), rc);
const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 8192);
defer alloc.free(text);
// The output must contain the entire line and its status suffix.
try std.testing.expect(std.mem.startsWith(u8, text, &long));
try std.testing.expect(std.mem.endsWith(
u8,
text,
"\t[bad host: names a session after '#': a host line names a daemon; the layout names sessions]\n",
));
}
test "wall: a LISTED local daemon that nothing answers on is one this wall starts" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [256]u8 = undefined;
const sock = try std.fmt.bufPrintZ(&buf, "{s}/muxd.sock", .{tmp.path()});
var line_buf: [512]u8 = undefined;
const line = try std.fmt.bufPrint(&line_buf, "--sock {s}", .{sock});
var h: hosts.Hosts = .{};
defer h.deinit(alloc);
// Include multiple entries with the local socket later in the file to show
// that its position does not affect startup detection.
try std.testing.expect(try h.add(alloc, "box"));
try std.testing.expect(try h.add(alloc, line));
// The state file contains the socket, but no process is listening yet —
// and the answer names WHY, since that is what the log line says.
try std.testing.expectEqual(@as(?anyerror, error.FileNotFound), localNeedsStart(&h, sock));
const addr = try std.net.Address.initUnix(sock);
var listener = try addr.listen(.{});
try std.testing.expectEqual(@as(?anyerror, null), localNeedsStart(&h, sock));
listener.deinit();
// The socket file the dead listener left is a different reason.
try std.testing.expectEqual(@as(?anyerror, error.ConnectionRefused), localNeedsStart(&h, sock));
// A wall of only remote hosts starts nothing, however dead they are.
var remote: hosts.Hosts = .{};
defer remote.deinit(alloc);
try std.testing.expect(try remote.add(alloc, "box"));
try std.testing.expectEqual(@as(?anyerror, null), localNeedsStart(&remote, sock));
}
test "hosts: a session count is the daemon's lines, not its bytes" {
try std.testing.expectEqual(@as(usize, 0), countSessions(""));
try std.testing.expectEqual(@as(usize, 1), countSessions("0\n"));
// No trailing newline, and a blank line, are the same two sessions.
try std.testing.expectEqual(@as(usize, 2), countSessions("0\nwork"));
try std.testing.expectEqual(@as(usize, 2), countSessions("0\n\nwork\n"));
}
test "hosts: a count shows sessions a wall could paint, not a daemon's lines" {
// `mux hosts` prints this number beside a host. A daemon that answered
// with an over-long line or a line holding a space named no session the
// wall can attach to, so the row must not advertise one.
try std.testing.expectEqual(@as(usize, 1), countSessions("0\n" ++ ("x" ** 1056)));
try std.testing.expectEqual(@as(usize, 2), countSessions("0\nhas space\nwork"));
}
test "parseArgs: unknown flags and valueless flags are usage errors" {
try std.testing.expectError(error.Usage, parse(&.{ "mux", "--wat" }));
try std.testing.expectError(error.Usage, parse(&.{ "mux", "-x" }));
// Missing flag values must not be reinterpreted as hostnames. Keep every
// value-taking client flag in this table so newly added flags require a
// corresponding test update.
inline for (.{ "--sock", "--via", "--key", "--quic-idle-ms", "--session" }) |flag| {
try std.testing.expectError(error.Usage, parse(&.{ "mux", flag }));
}
}
test "parseArgs: --help is the usage someone asked for, wherever it sits" {
try std.testing.expectError(error.Help, parse(&.{ "mux", "--help" }));
try std.testing.expectError(error.Help, parse(&.{ "mux", "-h" }));
try std.testing.expectError(error.Help, parse(&.{ "mux", "vm1", "--help" }));
// Help takes precedence even where a value is missing or another argument
// is invalid.
try std.testing.expectError(error.Help, parse(&.{ "mux", "--sock", "--help" }));
try std.testing.expectError(error.Help, parse(&.{ "mux", "--wat", "--help" }));
}
test "-A rides every transport spelling" {
try std.testing.expect((try parse(&.{ "mux", "-A", "somehost" })).host.agent);
try std.testing.expect((try parse(&.{ "mux", "-A", "--sock", "/tmp/x.sock" })).attach.agent);
try std.testing.expect((try parse(&.{ "mux", "quic://h:1", "-A" })).quic.agent);
try std.testing.expect(!(try parse(&.{ "mux", "somehost" })).host.agent);
// Verify both the short alias and the generated long spelling.
try std.testing.expect((try parse(&.{ "mux", "--agent", "--sock", "/tmp/x.sock" })).attach.agent);
// Bare `mux` bypasses parsing and opens the wall without forwarding. Adding
// `-A` produces a default-local attach with agent forwarding enabled.
const armed = try parse(&.{ "mux", "-A" });
try std.testing.expect(armed.attach.agent);
try std.testing.expect(armed.attach.sock == null);
try std.testing.expect(armed.attach.via == null);
}
test "parseArgs: quic:// is a transport like any other" {
const q = try parse(&.{ "mux", "quic://box:4433", "--key", "/k" });
try std.testing.expect(q == .quic);
try std.testing.expectEqualStrings("box:4433", q.quic.host_port);
try std.testing.expectEqualStrings("/k", q.quic.key.?);
// Compare with the documented literal rather than the source constant so
// the test detects an accidental default change.
try std.testing.expectEqual(@as(u32, 15_000), q.quic.idle_ms);
// A QUIC target conflicts with every other explicitly named transport,
// regardless of argument order.
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--sock", "/x" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/x", "quic://a:1", "--key", "/k" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--via", "ssh h" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "vm1" }));
try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "quic://b:2", "--key", "/k" }));
// The scheme with nothing after it names no host.
try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://", "--key", "/k" }));
}
test "parseArgs: a quic attach without a key defers to main, which resolves it" {
// Without an explicit or environment key, parsing defers default-path
// resolution to `main`.
const q = try parse(&.{ "mux", "quic://a:1" });
try std.testing.expect(q == .quic);
try std.testing.expect(q.quic.key == null);
// Empty env var means unset, same as an empty --key would be nonsense.
const empty_env = try parseEnv(&.{ "mux", "quic://a:1" }, "");
try std.testing.expect(empty_env == .quic);
try std.testing.expect(empty_env.quic.key == null);
// The environment supplies it when the flag does not...
const e = try parseEnv(&.{ "mux", "quic://a:1" }, "/env.key");
try std.testing.expect(e == .quic);
try std.testing.expectEqualStrings("/env.key", e.quic.key.?);
// ...and the flag wins when both are there, because it is the more
// specific statement of intent.
const both = try parseEnv(&.{ "mux", "quic://a:1", "--key", "/flag.key" }, "/env.key");
try std.testing.expectEqualStrings("/flag.key", both.quic.key.?);
// Ignore `MUX_KEY_FILE` for non-QUIC targets so an exported value cannot
// break an ordinary local attach.
try std.testing.expect((try parseEnv(&.{"mux"}, "/env.key")) == .attach);
try std.testing.expect((try parse(&.{ "mux", "--key", "/k" })) == .attach);
try std.testing.expect((try parse(&.{ "mux", "--key", "/k", "vm1" })) == .host);
}
test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" {
const t = try parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" });
try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms);
// SSH handoff can end in QUIC, so a bare host must retain the configured
// idle timeout instead of accepting and discarding the flag.
const h = try parse(&.{ "mux", "vm1", "--quic-idle-ms", "1500" });
try std.testing.expect(h == .host);
try std.testing.expectEqual(@as(u32, 1500), h.host.idle_ms);
try std.testing.expectError(error.Usage, parse(&.{ "mux", "vm1", "--quic-idle-ms", "0" }));
try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }));
try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }));
try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }));
// The bare-flag/missing-value case is covered once, for every
// value-taking flag, by the valueless-flags sweep above.
}
test "parseArgs: --version wins wherever it appears" {
try std.testing.expectError(error.Version, parse(&.{ "mux", "--version" }));
try std.testing.expectError(error.Version, parse(&.{ "mux", "--sock", "/x", "--version" }));
// Version takes precedence over conflicts and syntax errors elsewhere on
// the command line.
try std.testing.expectError(error.Version, parse(&.{ "mux", "vm1", "--sock", "/x", "--version" }));
try std.testing.expectError(error.Version, parse(&.{ "mux", "--wat", "--version" }));
}
test "parseArgs: --session rides every transport spelling" {
const s = try parse(&.{ "mux", "--session", "b", "--sock", "/tmp/x.sock" });
try std.testing.expectEqualStrings("b", s.attach.session);
const h = try parse(&.{ "mux", "somehost", "--session", "b" });
try std.testing.expectEqualStrings("b", h.host.session);
const q = try parse(&.{ "mux", "quic://h:1", "--session", "b" });
try std.testing.expectEqualStrings("b", q.quic.session);
}
test "parseArgs: a bad --session is a usage error, not a wire experiment" {
try std.testing.expectError(error.Usage, parse(&.{ "mux", "--session", "has space" }));
}
test "parseArgs: no --session means the empty wire name (older-daemon compat)" {
const s = try parse(&.{"mux"});
try std.testing.expectEqualStrings("", s.attach.session);
}
// Ensure every public declaration is semantically analyzed during tests;
// `std.meta.declarations` does not include private declarations.
test {
std.testing.refAllDeclsRecursive(@This());
}
/// Test server that either returns a valid ssh-agent reply or immediately
/// closes like a mux session with no forwarded agent. A thread is required
/// because the probe performs a write/read round trip.
const AgentStub = struct {
listener: *std.net.Server,
answer: bool,
/// Set only after receiving the exact identities request used by
/// `ssh-add -l`, proving that the probe sent the expected bytes.
asked: bool = false,
fn run(self: *AgentStub) void {
const conn = self.listener.accept() catch return;
defer std.posix.close(conn.stream.handle);
if (!self.answer) return;
var buf: [64]u8 = undefined;
const n = std.posix.read(conn.stream.handle, &buf) catch return;
// Compare with a literal because `agent_request_identities` itself is
// under test; reusing it here could not detect incorrect bytes.
self.asked = std.mem.eql(u8, buf[0..n], &[_]u8{ 0, 0, 0, 1, 11 });
if (!self.asked) return;
// `SSH_AGENT_IDENTITIES_ANSWER` containing zero keys. The probe checks
// for a protocol response, not for a nonempty key list.
const reply = [_]u8{ 0, 0, 0, 5, 12, 0, 0, 0, 0 };
_ = std.posix.write(conn.stream.handle, &reply) catch {};
}
};
test "agentReachable: an agent answers; a socket that hangs up is not one" {
// Cover a live agent, a mux agent socket with no offering client, a stale
// socket path, and an absent environment value. Only the live agent should
// allow `-A`.
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()});
{
const addr = try std.net.Address.initUnix(sock);
var listener = try addr.listen(.{});
defer listener.deinit();
var stub = AgentStub{ .listener = &listener, .answer = true };
const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub});
const reachable = agentReachable(sock);
th.join();
try std.testing.expect(reachable);
try std.testing.expect(stub.asked);
}
std.fs.deleteFileAbsolute(sock) catch {};
// Inside a session, `SSH_AUTH_SOCK` names the daemon. The connection can
// succeed even though the daemon closes it after finding no offering
// client, which is why the probe must exchange a request.
{
const addr = try std.net.Address.initUnix(sock);
var listener = try addr.listen(.{});
defer listener.deinit();
var stub = AgentStub{ .listener = &listener, .answer = false };
const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub});
defer th.join();
try std.testing.expect(!agentReachable(sock));
}
std.fs.deleteFileAbsolute(sock) catch {};
// Treat silence as reachable and immediate EOF as unavailable. A slow or
// wedged real agent may time out, while the daemon's no-offerer response is
// an immediate close.
{
const addr = try std.net.Address.initUnix(sock);
var listener = try addr.listen(.{});
defer listener.deinit();
try std.testing.expect(agentReachable(sock));
}
// A stale socket file still exists after its agent dies, so the probe must
// connect rather than merely checking the path.
try std.fs.accessAbsolute(sock, .{});
try std.testing.expect(!agentReachable(sock));
// Also cover an empty path and a path with no socket.
std.fs.deleteFileAbsolute(sock) catch {};
try std.testing.expect(!agentReachable(sock));
try std.testing.expect(!agentReachable(""));
}