src/client/handoff.zig
Ref: Size: 60.6 KiB History
//! The ssh→QUIC handoff's shared vocabulary: the announce line
//! `mux d endpoint` prints and `mux` parses, the per-host cache that
//! remembers it, and the step table `next` that orders a handoff out of them.
//! Pure by design — no sockets, no processes — so it tests without a daemon.
const std = @import("std");
// Only for the private-parent discipline the cache file shares with the
// key file. No XDG resolution happens here: writeCache is handed a path.
const xdg = @import("xdg");
/// The QUIC attach budget for one attempt, warm and cold alike.
///
/// SILENCE is what it bounds, and silence is the common case: a wrong PSK
/// against a live listener never answers, because mutual auth means the
/// listener does not reply to a peer it cannot authenticate, and a blackholed
/// UDP port is the same. Nothing else ends those dials.
///
/// 2000 fixes the trade. Below it: a handshake is 3.1x RTT — every fresh
/// Initial costs a Retry round trip — so this covers RTT to ~645ms, past any
/// terrestrial link. Above it: there is no negative caching of "UDP blocked",
/// so every fallback attach on such a network pays this in full. 1000 would
/// halve the tax and halve the RTT ceiling, abandoning paths that work.
pub const deadline_ms: u32 = 2000;
/// The PSK's length in bytes. The same 32 as `quic.Key`, spelled again rather
/// than imported so this module stays free of the C stack. A drift between the
/// two does not compile: the dial site converts by value into a fixed array.
pub const key_len = 32;
/// What a `mux d endpoint` announce carries.
pub const Endpoint = struct {
port: u16,
key: [key_len]u8,
};
/// The announce for "no coordinates" — no usable key, no bind, no reply.
/// An explicit negative rather than silence: the daemon side sends
/// nothing unprompted, so a client waiting for a line that is never
/// coming cannot tell that from a slow ssh.
pub const announce_none = "endpoint none\n";
/// The longest line `formatAnnounce` can produce: `endpoint ` + 5 digits
/// + ` ` + the hexed key + `\n`. Callers size their buffers from this.
pub const announce_max_len = 9 + 5 + 1 + key_len * 2 + 1;
pub const ParseError = error{
AnnounceMissingPrefix,
AnnounceMissingPort,
AnnouncePortInvalid,
AnnouncePortZero,
AnnounceMissingKey,
AnnounceKeyLength,
AnnounceKeyNotHex,
AnnounceTrailingJunk,
};
pub const ReadLineError = error{
/// The stream ended before a newline arrived.
UnterminatedLine,
/// `buf` filled with no newline in it.
LineTooLong,
};
/// How much of one stderr line is kept. Wide enough for ssh's longest
/// ordinary complaint (`ssh: connect to host <name> port <n>: No route to
/// host`) and narrow enough to sit on a picker row beside a spelling.
pub const reason_max = 120;
/// The small set of outcomes an SSH announce can have for an automatic
/// reconnect. A missing diagnostic is deliberately `unknown`: an exit code
/// or EOF does not prove that authentication was refused.
pub const FailureClass = enum {
authentication_refused,
unknown,
};
/// Classify only diagnostics whose wording is characteristic of OpenSSH
/// refusing authentication. Keep this conservative: in particular, exit
/// status 255 and a bare announce EOF are not authentication evidence.
pub fn classifyReason(reason: []const u8) FailureClass {
const line = std.mem.trim(u8, reason, " \t");
const denial = "permission denied (";
if (startsWithIgnoreCase(line, denial) and completeDenial(line[denial.len..])) return .authentication_refused;
if (std.ascii.indexOfIgnoreCase(line, ": " ++ denial)) |colon| {
const prefix = std.mem.trim(u8, line[0..colon], " \t");
if (validUserHostPrefix(prefix) and completeDenial(line[colon + 2 + denial.len ..])) return .authentication_refused;
}
// These server-side messages are only trusted with OpenSSH's framing;
// the phrase by itself could have come from a remote banner or command.
if (startsWithIgnoreCase(line, "received disconnect") and
(std.ascii.indexOfIgnoreCase(line, "too many authentication failures") != null or
std.ascii.indexOfIgnoreCase(line, "no more authentication methods to try") != null)) return .authentication_refused;
return .unknown;
}
fn validUserHostPrefix(prefix: []const u8) bool {
const at = std.mem.indexOfScalar(u8, prefix, '@') orelse return false;
if (at == 0 or at + 1 == prefix.len) return false;
const user = prefix[0..at];
const host = prefix[at + 1 ..];
for (user) |c| if (c == ':' or c == ' ' or c == '\t') return false;
for (host) |c| if (c == ' ' or c == '\t') return false;
return true;
}
fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
return haystack.len >= needle.len and std.ascii.indexOfIgnoreCase(haystack[0..needle.len], needle) != null;
}
fn completeDenial(methods: []const u8) bool {
return methods.len >= 2 and methods[methods.len - 2] == ')' and methods[methods.len - 1] == '.';
}
/// The last COMPLETE line a stderr pipe carried, kept across the pipe's
/// fragmentation. A struct rather than a buffer the reader appends to: ssh says
/// several things and dies on the last, and a caller wants one sentence.
pub const Reason = struct {
buf: [reason_max]u8 = undefined,
len: usize = 0,
/// Bytes still accumulating toward the next line end. Separate from
/// `buf` so a dial that fails mid-sentence quotes the last finished
/// line rather than half of the one ssh was still writing.
tail: [reason_max]u8 = undefined,
tail_len: usize = 0,
pub fn feed(self: *Reason, bytes: []const u8) void {
for (bytes) |c| {
// '\n' and '\r' EACH end a line, and an empty one ends
// nothing: a remote on a pty writes `\r\n`, and treating the
// pair as two line ends would clear the reason on exactly the
// hosts whose ssh allocated a tty.
if (c == '\n' or c == '\r') {
if (self.tail_len > 0) {
@memcpy(self.buf[0..self.tail_len], self.tail[0..self.tail_len]);
self.len = self.tail_len;
self.tail_len = 0;
}
continue;
}
// PRINTABLE ASCII only: this string is painted into a picker row
// RAW, never through the VT engine, so any other byte could move a
// cursor in somebody else's tile. The high half goes too — xterm
// honours UTF-8-encoded C1 (`\xc2\x9b` is CSI), so stopping at 0x7f
// lets an escape through in a second spelling.
if (c < 0x20 or c > 0x7e) continue;
// Cut, not wrapped: the head of an ssh diagnostic is the part
// that names the cause.
if (self.tail_len == reason_max) continue;
self.tail[self.tail_len] = c;
self.tail_len += 1;
}
}
/// "" until a line has finished; the caller's own wording stands.
pub fn slice(self: *const Reason) []const u8 {
return self.buf[0..self.len];
}
pub fn clear(self: *Reason) void {
self.len = 0;
self.tail_len = 0;
}
};
/// What one dial left behind for whoever has somewhere to show it: two facts
/// with one lifetime, since a failed dial is reported in ssh's own words AND
/// attributed to the ssh that said them.
pub const Dial = struct {
reason: Reason = .{},
/// The coordination ssh this dial spawned, 0 when it spawned none.
/// Whoever answers that ssh's prompts is keyed on it: a prompt the
/// user refused belongs to one dial, not to the host forever.
ssh_pid: std.posix.pid_t = 0,
};
pub const CacheError = error{
CacheMissing,
CachePermissive,
CacheMalformed,
};
/// `endpoint <port> <64 lowercase hex chars>\n` into `buf`. One writer and two
/// readers — the ssh pipe and the cache file, which stores this exact line — so
/// a cache written by one version and read by another agrees or fails loudly.
pub fn formatAnnounce(buf: []u8, ep: Endpoint) ![]const u8 {
// The writer refuses what the reader refuses: `endpoint_reply` carries 0 as
// "could not", and the caller owes it a translation into `announce_none`.
// Refusing here puts the failure on the line that forgot, not on another box.
if (ep.port == 0) return error.AnnouncePortZero;
// `{x}` on a byte slice is per-byte lowercase hex — 64 characters for
// 32 bytes, leading zeros and all. Verified against 0.15.2 rather than
// assumed: a formatter that took the key as one big number would drop
// leading zeros and emit a short line for one key in 256.
return std.fmt.bufPrint(buf, "endpoint {d} {x}\n", .{ ep.port, &ep.key });
}
/// The announce line back into an `Endpoint`, or null for `endpoint none`, with
/// or without its trailing newline. Deliberately no stricter than its parts:
/// nothing but this module's writer produces these lines, a looser reader
/// cannot admit anything a dial would not reject, and every call site bounds
/// the input by `announce_max_len`.
pub fn parseAnnounce(line: []const u8) ParseError!?Endpoint {
const prefix = "endpoint ";
// Exactly one trailing newline, and the \r that may sit in front of it.
// Not a general trim: junk after the key must stay visible as junk.
var body = line;
if (body.len > 0 and body[body.len - 1] == '\n') {
body = body[0 .. body.len - 1];
if (body.len > 0 and body[body.len - 1] == '\r') body = body[0 .. body.len - 1];
}
if (!std.mem.startsWith(u8, body, prefix)) return error.AnnounceMissingPrefix;
const rest = body[prefix.len..];
if (std.mem.eql(u8, rest, "none")) return null;
const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse {
return if (rest.len == 0) error.AnnounceMissingPort else error.AnnounceMissingKey;
};
const port = std.fmt.parseInt(u16, rest[0..sp], 10) catch return error.AnnouncePortInvalid;
// The daemon's "could not" is `endpoint none`. A zero here would be a
// failure wearing a dialable shape: the client would spend its whole
// deadline on a port that cannot exist, and the fallback line it then
// printed would name port 0 to the user as if that were an address.
if (port == 0) return error.AnnouncePortZero;
const key_tok = rest[sp + 1 ..];
// Checked before the length, so an otherwise-good line with something
// appended reports what is actually wrong with it.
if (std.mem.indexOfScalar(u8, key_tok, ' ') != null) return error.AnnounceTrailingJunk;
if (key_tok.len != key_len * 2) return error.AnnounceKeyLength;
var ep: Endpoint = .{ .port = port, .key = undefined };
_ = std.fmt.hexToBytes(&ep.key, key_tok) catch return error.AnnounceKeyNotHex;
return ep;
}
/// Splits where ssh splits: ssh takes everything before the LAST `@` as
/// the user name.
pub fn dialHost(host: []const u8) []const u8 {
const at = std.mem.lastIndexOfScalar(u8, host, '@') orelse return host;
return host[at + 1 ..];
}
/// What a bare-HOST target needs before it can be dialed: the coordination
/// command, the same command for a dial the user asked for, and the cache
/// path.
pub const Recipe = struct {
ssh_argv: []const []const u8,
/// What a dial the user ASKED for runs INSTEAD of `ssh_argv`
/// (`client.HandoffTarget.asked`): the same line with `--start`, so the
/// remote ensures a daemon and announces in one run. Reading a box
/// spells the bare verb and can therefore never start one.
asked_argv: []const []const u8,
/// null means attach UNCACHED — an uncacheable host (a separator in
/// the name) or no resolvable cache directory. Always cold, never
/// wrong; the rule lives here rather than at each call site.
cache_path: ?[]const u8,
pub fn deinit(self: Recipe, alloc: std.mem.Allocator) void {
// Exactly once, by the arena that built it: the wall hands one recipe to
// every Tile of a host and both fronts ALIAS its argvs, so a per-Tile
// free would be a double one.
freeArgv(alloc, self.ssh_argv);
freeArgv(alloc, self.asked_argv);
if (self.cache_path) |c| alloc.free(c);
}
};
/// Every word owned, including the ones that came in as literals: mixed
/// ownership inside one argv is a free that is right for five elements and
/// a corruption for the sixth.
pub fn dupeArgv(alloc: std.mem.Allocator, argv: []const []const u8) ![]const []const u8 {
const out = try alloc.alloc([]const u8, argv.len);
var made: usize = 0;
errdefer {
for (out[0..made]) |w| alloc.free(w);
alloc.free(out);
}
while (made < argv.len) : (made += 1) out[made] = try alloc.dupe(u8, argv[made]);
return out;
}
pub fn freeArgv(alloc: std.mem.Allocator, argv: []const []const u8) void {
for (argv) |w| alloc.free(w);
alloc.free(argv);
}
/// The one spelling of the ~/.local/bin fallback. `sshArgv` uses it as an
/// assignment prefix on a single command; the upgrade words use it as a
/// statement (`;`-terminated), because an assignment before `a && b` binds
/// to `a` alone and the commands after the `&&`s would search the bare PATH.
const local_bin_append = "PATH=\"$PATH:$HOME/.local/bin\"";
/// ONE owner for the ssh line's shape.
fn sshArgv(
alloc: std.mem.Allocator,
host: []const u8,
batch: bool,
remote: []const u8,
) ![]const []const u8 {
// Only the remote WORD differs between the reading command and the asking
// one; a drift would start a daemon somewhere the attach does not look.
//
// BatchMode is for the recipes NOBODY is sitting in front of: ssh prompts on
// /dev/tty, and a poll running every second under a full-screen wall would
// ask forever, over the panes. ConnectTimeout rides with it — a blackholed
// host sits in the kernel's TCP retry schedule for two minutes, which
// `client.listSessions`' own budget starts too late to bound.
const batch_opt: []const []const u8 = if (batch)
&.{ "-o", "BatchMode=yes", "-o", "ConnectTimeout=5" }
else
&.{};
// ONE argv word, read by the REMOTE user's shell — which is what expands
// `$PATH` here. Ours does not, so nothing in it survives a local quote
// round. sshd runs that shell non-login, so it never sources the profile
// putting ~/.local/bin on PATH. APPENDED: a fallback place to look, never a
// shadow over whatever `mux` the remote PATH already resolves.
const word = try std.fmt.allocPrint(alloc, local_bin_append ++ " {s}", .{remote});
defer alloc.free(word);
return sshArgvWord(alloc, host, batch_opt, word);
}
/// The assembly half of `sshArgv`: `word` is the finished remote word,
/// PATH fallback already spelled. Split out so the upgrade words — compound
/// statements whose fallback must be a statement, not an assignment prefix —
/// share the argv shape without a second spelling of it.
fn sshArgvWord(
alloc: std.mem.Allocator,
host: []const u8,
batch_opt: []const []const u8,
word: []const u8,
) ![]const []const u8 {
var argv: [7][]const u8 = undefined;
argv[0] = "ssh";
@memcpy(argv[1 .. 1 + batch_opt.len], batch_opt);
argv[1 + batch_opt.len] = host;
argv[2 + batch_opt.len] = word;
return dupeArgv(alloc, argv[0 .. 3 + batch_opt.len]);
}
/// ONE owner for the handoff recipe: `mux HOST` and a `mux web` tile build the
/// identical thing, and two spellings would point them at different remote
/// commands. Here rather than client.zig, which stays free of XDG.
pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe {
const cmd = try sshArgv(alloc, host, batch, "mux d endpoint");
errdefer freeArgv(alloc, cmd);
const asked = try sshArgv(alloc, host, batch, "mux d endpoint --start");
errdefer freeArgv(alloc, asked);
return .{
.ssh_argv = cmd,
.asked_argv = asked,
.cache_path = xdg.hostCachePath(alloc, host) catch null,
};
}
/// The remote-upgrade preflight: one ssh whose LINE COUNT is the verdict.
/// `&&` stops at the first answerless step, so one line means no mux on the
/// remote PATH, two means mux with no daemon, three means a daemon is up —
/// and line one is `uname -m`, checked before any bytes move, because the
/// push streams the local machine's own image. The bare endpoint verb, never
/// `--start`: a read must not start a daemon (the invariant `recipeFor`'s
/// polls live by), and an upgrade preflight is a read.
pub fn upgradePreflightArgv(alloc: std.mem.Allocator, host: []const u8) ![]const []const u8 {
const word = local_bin_append ++ "; uname -m && command -v mux && mux d endpoint";
return sshArgvWord(alloc, host, &.{}, word);
}
/// The push: land the streamed image beside the installed mux, run it once,
/// then rename over it. Atomic on purpose — a connection dropped mid-stream
/// must never leave a truncated binary at the installed path, and the running
/// daemon keeps its old inode undisturbed. The `--version` between `chmod`
/// and `mv` is the verdict the preflight's `uname -m` cannot give: an
/// architecture check cannot see CPU feature levels or a libc, and a
/// CPU-native image pushed at a weaker box dies SIGILL on every ssh dial
/// AFTER the rename — silently, because the crash is the remote end of every
/// probe (found on a live box, 2026-09-01). A candidate that cannot answer
/// is removed and its exit code relayed, so a refused push leaves the box
/// exactly as it was found. `target` came off the remote's own `command -v`;
/// a single quote in it means something is lying, and this refuses rather
/// than escapes.
pub fn upgradePushArgv(
alloc: std.mem.Allocator,
host: []const u8,
target: []const u8,
) error{ BadTargetPath, OutOfMemory }![]const []const u8 {
if (std.mem.indexOfScalar(u8, target, '\'') != null) return error.BadTargetPath;
const word = try std.fmt.allocPrint(
alloc,
local_bin_append ++ "; cat > '{s}.new' && chmod 755 '{s}.new'" ++
" && '{s}.new' --version >/dev/null && mv '{s}.new' '{s}'" ++
" || {{ s=$?; rm -f '{s}.new'; exit $s; }}",
.{ target, target, target, target, target, target },
);
defer alloc.free(word);
return sshArgvWord(alloc, host, &.{}, word);
}
/// The trigger: the freshly pushed binary is now the installed mux, and
/// running IT as `d upgrade` makes it the candidate — the daemon-side version
/// rule, manifest and serving check need nothing new from here.
pub fn upgradeTriggerArgv(
alloc: std.mem.Allocator,
host: []const u8,
allow_same: bool,
) ![]const []const u8 {
return sshArgv(alloc, host, false, if (allow_same)
"mux d upgrade --allow-same-version"
else
"mux d upgrade");
}
/// The announce line for `ep` at `path`: mode 0600, parents created, immediate
/// parent tightened to 0700 — the key travels in this file. OVERWRITES, unlike
/// `xdg.writeNewKey`: everything here is re-derivable from one ssh.
pub fn writeCache(path: []const u8, ep: Endpoint) !void {
// The same discipline the key file gets: the file's own 0600 hides the key,
// but a 0755 directory still publishes which hosts this user attaches to.
try xdg.makePrivateParent(path);
var buf: [announce_max_len]u8 = undefined;
const line = try formatAnnounce(&buf, ep);
const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600, .truncate = true });
defer f.close();
// createFile's mode applies at creation only. A cache file that
// somehow already exists with looser bits would keep them, and
// readCache would then refuse it forever — a permanently cold host
// with no visible cause. Make 0600 a post-condition instead.
try f.chmod(0o600);
try f.writeAll(line);
}
/// The endpoint remembered at `path`. A group- or other-readable file is
/// refused before its contents are read, as `quic.Key.load` refuses a
/// permissive key: the key is in here. Three errors, because every caller can
/// only attach cold — `CacheMissing` stays separate from `CachePermissive` and
/// `CacheMalformed` because it is the ordinary first run.
pub fn readCache(path: []const u8) !Endpoint {
const f = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
error.FileNotFound => return error.CacheMissing,
else => |e| return e,
};
defer f.close();
const st = try f.stat();
if (st.mode & 0o077 != 0) return error.CachePermissive;
var buf: [announce_max_len + 1]u8 = undefined;
const n = try f.readAll(&buf);
if (n > announce_max_len) return error.CacheMalformed;
// Every way the contents can be wrong arrives as ONE error: the granular
// `Announce*` names are for the ssh-pipe path, where a caller can report
// what a daemon said. A cache has one decision — use it or refetch.
const ep = parseAnnounce(buf[0..n]) catch return error.CacheMalformed;
// Nobody writes `endpoint none` here: there are no coordinates to
// remember, so the cold path simply leaves the cache alone. A file
// holding it was written by something else.
return ep orelse error.CacheMalformed;
}
// ------------------------------------------------------------ the policy
// `client.Transport.openHandoff` performs what these steps name, and owns
// every effect. Each row below is one named test in this file.
/// One effect for the driver to perform, or the transport it ends on.
pub const Step = union(enum) {
/// Dial these coordinates: `client.Transport.openQuicEndpoint`.
dial_quic: Endpoint,
/// Run the coordination ssh: `spawnPipe` on the asking argv when the
/// user asked and there is one, on the reading argv otherwise.
spawn_ssh,
/// Read the announce line off that child's stdout.
read_announce,
/// Remember these coordinates under `cache_write_mu`. A write that
/// fails costs a cold attach and nothing else, so its outcome is
/// always `.done`.
write_cache: Endpoint,
/// Terminal: the transport the last dial returned. The driver kills
/// the coordination ssh, if it started one.
use_quic,
/// Terminal: the live ssh child IS the session. True asks for the fallback
/// line. Not a leftover: through `ssh -J gate box` the announced UDP port is
/// unreachable BY CONSTRUCTION, so the pipe is that host's only session.
use_pipe: bool,
/// Terminal: the error the driver recorded for the step that failed.
fail,
};
/// What performing a step produced. One vocabulary for all of them: which
/// arms a step can answer with is the table's business, and an arm no
/// phase expects is a driver bug rather than an input.
pub const Outcome = union(enum) {
/// `dial_quic` connected, or `spawn_ssh` forked and exec'd.
ok,
/// The abort key landed inside a dial's wait.
user_abort,
/// `dial_quic` never came up, or `spawn_ssh` could not start.
failed,
/// `read_announce`: coordinates.
announced: Endpoint,
/// `read_announce`: `endpoint none`, the remote saying it has none.
none,
/// `read_announce`: no line at all — a dead pipe, junk, an abort.
announce_failed,
/// `write_cache`, always.
done,
};
/// How far the handoff has got. The driver builds one from its target and
/// its cache read, then hands it to `next` and touches nothing in it.
pub const State = struct {
/// The coordinates the cache held; null when there is nothing usable
/// to try before ssh.
cached: ?Endpoint,
/// Whether a USER asked for this dial — `client.HandoffTarget.asked`.
/// Here it decides one thing: whether the fallback says so out loud.
asked: bool,
/// Whether there is a cache file to write. Distinct from `cached`: a
/// path holding nothing usable is a cold attach that still caches.
has_cache: bool,
/// The coordinates a warm dial spent its whole budget on and got
/// silence back from. Null until one has.
failed_on: ?Endpoint = null,
/// The announce being decided on. `write_cache` sits between reading
/// one and choosing what to do with it, so it has to outlive that
/// step; the phase alone cannot carry it.
announced: ?Endpoint = null,
phase: enum { init, warm_dial, ssh, announce, cache, cold_dial } = .init,
};
/// The next step, given what the last one produced; `null` is the first call.
/// An (outcome, phase) pair no row covers is `unreachable`: the driver is the
/// only caller and answers each step from a fixed set, so a pair outside the
/// table is a bug in the loop rather than input to judge.
pub fn next(s: *State, o: ?Outcome) Step {
const outcome = o orelse {
std.debug.assert(s.phase == .init);
if (s.cached) |ep| {
s.phase = .warm_dial;
return .{ .dial_quic = ep };
}
s.phase = .ssh;
return .spawn_ssh;
};
switch (s.phase) {
.init => unreachable,
.warm_dial => switch (outcome) {
.ok => return .use_quic,
.user_abort => return .fail,
.failed => {
s.failed_on = s.cached;
s.phase = .ssh;
return .spawn_ssh;
},
else => unreachable,
},
.ssh => switch (outcome) {
.ok => {
s.phase = .announce;
return .read_announce;
},
.failed => return .fail,
else => unreachable,
},
.announce => switch (outcome) {
.none => return .{ .use_pipe = false },
.announce_failed => return .fail,
.announced => |ep| {
if (s.has_cache) {
s.announced = ep;
s.phase = .cache;
return .{ .write_cache = ep };
}
return afterAnnounce(s, ep);
},
else => unreachable,
},
.cache => switch (outcome) {
.done => return afterAnnounce(s, s.announced.?),
else => unreachable,
},
.cold_dial => switch (outcome) {
.ok => return .use_quic,
.user_abort => return .fail,
.failed => return .{ .use_pipe = s.asked },
else => unreachable,
},
}
}
/// What a fresh announce is worth, once it is safely cached.
fn afterAnnounce(s: *State, ep: Endpoint) Step {
if (s.failed_on) |dead| {
if (std.meta.eql(dead, ep)) return .{ .use_pipe = s.asked };
}
s.phase = .cold_dial;
return .{ .dial_quic = ep };
}
test "Reason: a line split across three feeds is still one line" {
// The pipe is what fragments: ssh writes a sentence and the reader
// sees whatever one `read` happened to hold. A reason that only
// survived a whole-line read would be the truncated half of a real
// diagnostic, which reads as mux's own words.
var r: Reason = .{};
r.feed("ssh: connect to host ");
r.feed("10.255.255.1 port 22: ");
r.feed("No route to host\n");
try std.testing.expectEqualStrings(
"ssh: connect to host 10.255.255.1 port 22: No route to host",
r.slice(),
);
}
test "Reason: two lines keep the LAST one" {
// ssh narrates before it fails ("Warning: Permanently added..."), so
// the first line it says is rarely the reason it died.
var r: Reason = .{};
r.feed("Warning: Permanently added 'box' to the list of known hosts.\n");
r.feed("box: Permission denied (publickey).\n");
try std.testing.expectEqualStrings("box: Permission denied (publickey).", r.slice());
}
test "Reason: a CRLF pair does not put an empty line between the two halves" {
// A remote on a pty ends its lines `\r\n`. Taking `\n` as a second
// line end would leave the reason empty on exactly the hosts whose
// ssh allocated a tty.
var r: Reason = .{};
r.feed("bad host\r\n");
try std.testing.expectEqualStrings("bad host", r.slice());
}
test "Reason: a bare CR ends a line too, so an overwritten one is not glued to the next" {
// `mux d endpoint: starting` narrates with a CR and no LF — a dot per
// interval, then an up-line. Counting only `\n` would hand the picker
// one run-on line built out of every progress step, with the sentence
// that actually failed buried in the middle of it.
var r: Reason = .{};
r.feed("starting\rgave up\n");
try std.testing.expectEqualStrings("gave up", r.slice());
}
test "Reason: a partial line is not the reason until its newline arrives" {
// The dial can fail with bytes still unterminated in the pipe. Half a
// sentence quoted as the cause is worse than the error name it
// replaces, so it is not the reason until ssh has finished saying it.
var r: Reason = .{};
r.feed("still typ");
try std.testing.expectEqualStrings("", r.slice());
r.feed("ing\n");
try std.testing.expectEqualStrings("still typing", r.slice());
}
test "Reason: control bytes are dropped, so nothing ssh says can move a cursor" {
// This string is painted into a picker row inside a full-screen wall.
// A byte below 0x20 (or DEL) that reached the terminal would move the
// cursor or start a sequence in the middle of somebody else's tile.
var r: Reason = .{};
r.feed("a\tb\x07c\x7fd\n");
try std.testing.expectEqualStrings("abcd", r.slice());
}
test "Reason: the high half is dropped, so a C1 escape has no second spelling" {
// The row is painted RAW — no VT engine between this string and the
// terminal — and C1 has a two-byte UTF-8 form that xterm honours in
// UTF-8 mode: `\xc2\x9b` is CSI, which would open a sequence inside
// somebody else's tile from a machine the user merely listed.
var r: Reason = .{};
r.feed("down \xc2\x9b31mred\xc2\x9b0m now\n");
try std.testing.expectEqualStrings("down 31mred0m now", r.slice());
}
test "Reason: an over-long line keeps its first reason_max bytes" {
// A remote can print anything. The cap is the struct's whole storage,
// so the row's width — not the far side — decides how much is kept.
var r: Reason = .{};
r.feed("x" ** (reason_max + 40));
r.feed("\n");
try std.testing.expectEqual(@as(usize, reason_max), r.slice().len);
try std.testing.expectEqualStrings("x" ** reason_max, r.slice());
}
test "Reason: an empty feed changes nothing" {
// poll can report readable on a pipe whose write end just closed, and
// the read that follows returns 0. That is EOF, not a new reason.
var r: Reason = .{};
r.feed("kept\n");
r.feed("");
try std.testing.expectEqualStrings("kept", r.slice());
}
test "Reason: clear forgets the last line and the partial one behind it" {
// A poll that succeeded says the host is reachable; the sentence that
// explained the last failure must not outlive it on the row.
var r: Reason = .{};
r.feed("gone\nhalf");
r.clear();
try std.testing.expectEqualStrings("", r.slice());
r.feed(" a line\n");
try std.testing.expectEqualStrings(" a line", r.slice());
}
test "classifyReason: only conservative OpenSSH authentication diagnostics stop retry" {
try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("Permission denied (publickey,password)."));
try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("Permission denied (keyboard-interactive)."));
try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("xanderle@127.0.0.1: Permission denied (gssapi-with-mic,hostbased)."));
try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("Received disconnect: Too Many Authentication Failures"));
try std.testing.expectEqual(FailureClass.unknown, classifyReason("No supported authentication methods available"));
try std.testing.expectEqual(FailureClass.unknown, classifyReason("ssh: connect to host box port 22: No route to host"));
try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: authentication failed maintenance notice"));
try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: Too many authentication failures"));
try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: Permission denied (publickey)."));
try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: user@host: Permission denied (publickey)."));
try std.testing.expectEqual(FailureClass.unknown, classifyReason(""));
// OpenSSH's generic exit status is not enough to identify auth refusal.
try std.testing.expectEqual(FailureClass.unknown, classifyReason("255"));
}
test "classifyReason: fragmented stderr keeps a canonical denial terminal" {
var reason: Reason = .{};
reason.feed("xanderle@127.0.0.1: Permission denied (public");
reason.feed("key,password).\n");
try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason(reason.slice()));
}
test "announce: format → parse round-trip, with and without the newline" {
var buf: [announce_max_len]u8 = undefined;
const ep: Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
const line = try formatAnnounce(&buf, ep);
try std.testing.expectEqualStrings(
"endpoint 4433 " ++ ("ab" ** 32) ++ "\n",
line,
);
const back = (try parseAnnounce(line)).?;
try std.testing.expectEqual(ep.port, back.port);
try std.testing.expectEqualSlices(u8, &ep.key, &back.key);
// The same line with the newline already stripped, which is what the
// client's announce reader hands back.
const stripped = (try parseAnnounce(line[0 .. line.len - 1])).?;
try std.testing.expectEqual(ep.port, stripped.port);
try std.testing.expectEqualSlices(u8, &ep.key, &stripped.key);
// A \r\n line: ssh is not the only thing that could carry this.
var crlf_buf: [announce_max_len + 1]u8 = undefined;
@memcpy(crlf_buf[0 .. line.len - 1], line[0 .. line.len - 1]);
crlf_buf[line.len - 1] = '\r';
crlf_buf[line.len] = '\n';
const crlf = (try parseAnnounce(crlf_buf[0 .. line.len + 1])).?;
try std.testing.expectEqual(ep.port, crlf.port);
// The longest line the grammar can produce, into a buffer sized by the
// constant that bounds it. A drift there fails on whichever port happens to
// be five digits, on someone else's box; here it is one assertion.
var max_buf: [announce_max_len]u8 = undefined;
const max_line = try formatAnnounce(&max_buf, .{ .port = 65535, .key = [_]u8{0xAB} ** 32 });
try std.testing.expectEqual(@as(usize, announce_max_len), max_line.len);
}
test "announce: a key with leading zero bytes still hexes to 64 chars" {
// A formatter that treats the key as a number rather than as bytes
// drops the leading zeros and produces a short line that parses as
// malformed on the other side — for one key in 256, at random.
var key = [_]u8{0} ** 32;
key[31] = 0x0f;
var buf: [announce_max_len]u8 = undefined;
const line = try formatAnnounce(&buf, .{ .port = 1, .key = key });
try std.testing.expectEqualStrings(
"endpoint 1 " ++ ("00" ** 31) ++ "0f\n",
line,
);
const back = (try parseAnnounce(line)).?;
try std.testing.expectEqualSlices(u8, &key, &back.key);
}
test "announce: the WRITER refuses port 0, where the mistake is still local" {
// `endpoint_reply` carries 0 as "could not", so the reader must turn it
// into `endpoint none` rather than a line. Refusing at the WRITER means a
// caller that forgets fails on its own line, not on the client.
var buf: [announce_max_len]u8 = undefined;
try std.testing.expectError(
ParseError.AnnouncePortZero,
formatAnnounce(&buf, .{ .port = 0, .key = [_]u8{0xAB} ** 32 }),
);
}
test "announce: `endpoint none` parses as null, not as a failure" {
try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce(announce_none));
try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce("endpoint none"));
}
test "announce: every shape of junk is a named error" {
const hex64 = "ab" ** 32;
const cases = .{
.{ "", ParseError.AnnounceMissingPrefix },
.{ "4433 " ++ hex64 ++ "\n", ParseError.AnnounceMissingPrefix },
.{ "endpoints 4433 " ++ hex64 ++ "\n", ParseError.AnnounceMissingPrefix },
.{ "endpoint \n", ParseError.AnnounceMissingPort },
.{ "endpoint 4433\n", ParseError.AnnounceMissingKey },
// Port 0 is the daemon's "could not" leaking out in a dialable
// shape. The wire spelling for that is `endpoint none`; a zero here
// means somebody built the line from an endpoint_reply without
// checking it, and dialing port 0 would fail far from the cause.
.{ "endpoint 0 " ++ hex64 ++ "\n", ParseError.AnnouncePortZero },
.{ "endpoint 65536 " ++ hex64 ++ "\n", ParseError.AnnouncePortInvalid },
.{ "endpoint http " ++ hex64 ++ "\n", ParseError.AnnouncePortInvalid },
.{ "endpoint 4433 " ++ ("ab" ** 31) ++ "a\n", ParseError.AnnounceKeyLength },
.{ "endpoint 4433 " ++ hex64 ++ "a\n", ParseError.AnnounceKeyLength },
.{ "endpoint 4433 " ++ ("ab" ** 31) ++ "zz\n", ParseError.AnnounceKeyNotHex },
.{ "endpoint 4433 " ++ hex64 ++ " extra\n", ParseError.AnnounceTrailingJunk },
};
inline for (cases) |c| {
try std.testing.expectError(c[1], parseAnnounce(c[0]));
}
}
fn expectArgv(want: []const []const u8, got: []const []const u8) !void {
try std.testing.expectEqual(want.len, got.len);
for (want, got) |w, g| try std.testing.expectEqualStrings(w, g);
}
test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" {
const r = try recipeFor(std.testing.allocator, "user@box", false);
defer r.deinit(std.testing.allocator);
// APPENDED, not prepended: a place to look when `mux` is nowhere on the
// remote PATH, never a shadow over one it already resolves. The remote
// command is ONE word — no local shell strips anything off it.
try expectArgv(&.{
"ssh",
"user@box",
"PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint",
}, r.ssh_argv);
}
test "recipeFor: the asked command is the ssh line with `mux d endpoint --start` — one run that starts what is missing and announces" {
const alloc = std.testing.allocator;
const asking = try recipeFor(alloc, "user@box", false);
defer asking.deinit(alloc);
// The SAME verb as `ssh_argv`, one flag apart, and that is the whole
// of the design: the announce this run answers with is the retry the
// client used to spend a third ssh on.
try expectArgv(&.{
"ssh",
"user@box",
"PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint --start",
}, asking.asked_argv);
// The batch flag travels with the recipe, so the asked line carries it
// too: a poller's recipe never asks, but `mux hosts` and the wall build
// both argvs from the same call.
const quiet = try recipeFor(alloc, "gate", true);
defer quiet.deinit(alloc);
// `-o` and its value are two words, the way ssh's own getopt reads
// them and the way the suite's ssh shim skips them.
try expectArgv(&.{
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
"gate",
"PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint --start",
}, quiet.asked_argv);
}
test "upgradePreflightArgv: one run, three answers, and the line count is the verdict" {
const argv = try upgradePreflightArgv(std.testing.allocator, "user@box");
defer freeArgv(std.testing.allocator, argv);
// STATEMENT-form PATH, not the assignment-prefix `recipeFor` uses: an
// assignment before `uname` would bind to `uname` alone, and the two
// commands after the `&&`s — the ones that actually need ~/.local/bin —
// would search the bare non-login PATH. The bare endpoint verb, never
// `--start`: a preflight that started daemons would undo every remote
// `mux d stop` the moment someone upgraded.
try expectArgv(&.{
"ssh",
"user@box",
"PATH=\"$PATH:$HOME/.local/bin\"; uname -m && command -v mux && mux d endpoint",
}, argv);
}
test "upgradePushArgv: the candidate must run on the remote before it replaces the install" {
const argv = try upgradePushArgv(std.testing.allocator, "user@box", "/home/u/.local/bin/mux");
defer freeArgv(std.testing.allocator, argv);
// `.new`, then the candidate EXECUTED, then `mv`: the rename is atomic, so
// a dropped connection never leaves a truncated binary at the installed
// path — and the execution is the verdict `uname -m` cannot give, because
// an architecture check cannot see CPU feature levels or a libc. A
// CPU-native desktop image pushed at an older x86_64 box died SIGILL on
// every ssh dial (2026-09-01) — with zero output, because the crash was
// the remote end of every probe. The failure arm removes the candidate
// and relays the true exit code, so the driver's "nothing was replaced"
// stays a fact and the box is left exactly as the push found it.
try expectArgv(&.{
"ssh",
"user@box",
"PATH=\"$PATH:$HOME/.local/bin\"; cat > '/home/u/.local/bin/mux.new'" ++
" && chmod 755 '/home/u/.local/bin/mux.new'" ++
" && '/home/u/.local/bin/mux.new' --version >/dev/null" ++
" && mv '/home/u/.local/bin/mux.new' '/home/u/.local/bin/mux'" ++
" || { s=$?; rm -f '/home/u/.local/bin/mux.new'; exit $s; }",
}, argv);
}
test "upgradePushArgv: a quote in the target is refused, not escaped" {
// The path came off the remote's own `command -v` — a quote in it means
// something is lying, and quoting games on a remote shell are not a
// fight worth winning.
try std.testing.expectError(
error.BadTargetPath,
upgradePushArgv(std.testing.allocator, "box", "/tmp/it's/mux"),
);
}
test "upgradeTriggerArgv: the freshly installed mux is the candidate, flag and all" {
const bare = try upgradeTriggerArgv(std.testing.allocator, "user@box", false);
defer freeArgv(std.testing.allocator, bare);
try expectArgv(&.{
"ssh",
"user@box",
"PATH=\"$PATH:$HOME/.local/bin\" mux d upgrade",
}, bare);
const same = try upgradeTriggerArgv(std.testing.allocator, "user@box", true);
defer freeArgv(std.testing.allocator, same);
try expectArgv(&.{
"ssh",
"user@box",
"PATH=\"$PATH:$HOME/.local/bin\" mux d upgrade --allow-same-version",
}, same);
}
test "recipeFor: a batch recipe cannot prompt, an interactive one still can" {
const alloc = std.testing.allocator;
// The poller's and `mux hosts`'s recipe. Two hosts, because the flag
// travels with the recipe and not with the process.
for ([_][]const u8{ "user@box", "gate" }) |h| {
const quiet = try recipeFor(alloc, h, true);
defer quiet.deinit(alloc);
try std.testing.expect(argvWord(quiet.ssh_argv, "BatchMode=yes") != null);
// Before the host word, where ssh reads its options.
try std.testing.expect(
argvWord(quiet.ssh_argv, "BatchMode=yes").? <
argvWord(quiet.ssh_argv, h).?,
);
// The other half of "nobody is sitting in front of this": a blackholed
// host leaves ssh in the kernel's TCP retry schedule for two minutes, and
// `client.listSessions` starts its budget only AFTER the open returns.
try std.testing.expect(argvWord(quiet.ssh_argv, "ConnectTimeout=5") != null);
try std.testing.expect(
argvWord(quiet.ssh_argv, "ConnectTimeout=5").? <
argvWord(quiet.ssh_argv, h).?,
);
const asking = try recipeFor(alloc, h, false);
defer asking.deinit(alloc);
try std.testing.expect(argvWord(asking.ssh_argv, "BatchMode=yes") == null);
// An attach a user is waiting on keeps ssh's own patience: a slow
// link is theirs to abandon, and Ctrl-\ already ends the wait.
try std.testing.expect(argvWord(asking.ssh_argv, "ConnectTimeout=5") == null);
}
}
fn argvWord(argv: []const []const u8, want: []const u8) ?usize {
for (argv, 0..) |w, i| if (std.mem.eql(u8, w, want)) return i;
return null;
}
test "dialHost: the LAST @ wins, which is where ssh splits" {
try std.testing.expectEqualStrings("box", dialHost("ubuntu@box"));
try std.testing.expectEqualStrings("box", dialHost("box"));
try std.testing.expectEqualStrings("c", dialHost("a@b@c"));
}
test "cache: round-trips, 0600 in a 0700 directory, and overwrites" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts/box", .{tmp.path()});
const first: Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
try writeCache(path, first);
const read_back = try readCache(path);
try std.testing.expectEqual(first.port, read_back.port);
try std.testing.expectEqualSlices(u8, &first.key, &read_back.key);
// The key is in this file, so it is held to the key file's standard.
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
const fst = try f.stat();
try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
// 0755 would not expose the key but would expose which hosts this user
// attaches to, by name.
var dbuf: [128]u8 = undefined;
const dir = try std.fmt.bufPrint(&dbuf, "{s}/hosts", .{tmp.path()});
var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
defer d.close();
const dst = try d.stat();
try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777)));
// A cache is the latest truth: the second write wins, and leaves no
// tail of the first behind.
const second: Endpoint = .{ .port = 9, .key = [_]u8{0x01} ** 32 };
try writeCache(path, second);
const again = try readCache(path);
try std.testing.expectEqual(second.port, again.port);
try std.testing.expectEqualSlices(u8, &second.key, &again.key);
}
test "cache: a looser file standing in the cache's place is re-tightened" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts/box", .{tmp.path()});
// A 0644 file already sitting where the cache goes: an old umask, a
// restored backup, somebody's hand-written probe.
try std.fs.cwd().makePath(std.fs.path.dirname(path).?);
try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "stale\n" });
{
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
try f.chmod(0o644);
}
const ep: Endpoint = .{ .port = 4433, .key = [_]u8{0xCD} ** 32 };
try writeCache(path, ep);
// `createFile`'s `.mode` applies at creation only, so writing over the file
// does NOT re-tighten it: without the chmod a 0644 survives, `readCache`
// refuses the cache it just wrote, and the host is permanently cold.
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
const st = try f.stat();
// Compared as an octal STRING, unlike its neighbours' expectEqual on
// the raw bits. Those print "expected 384, found 420" when they catch
// something, and nobody reads file modes in decimal; this one prints
// "expected 600, found 644", which is the bug said out loud.
var mode_buf: [8]u8 = undefined;
try std.testing.expectEqualStrings(
"600",
try std.fmt.bufPrint(&mode_buf, "{o}", .{st.mode & 0o777}),
);
// Said the whole way through rather than stopping at the mode: the
// point is not the bits, it is that the cache is usable afterwards.
const back = try readCache(path);
try std.testing.expectEqual(ep.port, back.port);
try std.testing.expectEqualSlices(u8, &ep.key, &back.key);
}
test "cache: refuses a permissive file, a missing one, and `endpoint none`" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts/box", .{tmp.path()});
var missing_buf: [128]u8 = undefined;
const missing = try std.fmt.bufPrint(&missing_buf, "{s}/hosts/absent", .{tmp.path()});
try std.testing.expectError(CacheError.CacheMissing, readCache(missing));
try writeCache(path, .{ .port = 4433, .key = [_]u8{0xAB} ** 32 });
{
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
try f.chmod(0o644);
}
try std.testing.expectError(CacheError.CachePermissive, readCache(path));
// Nobody writes `endpoint none` to a cache — there is nothing to
// remember — so a file holding it is a file somebody else wrote.
try std.fs.cwd().writeFile(.{ .sub_path = path, .data = announce_none });
{
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
try f.chmod(0o600);
}
try std.testing.expectError(CacheError.CacheMalformed, readCache(path));
// Content that is not the grammar at all reports the SAME error: the
// granular names exist for the ssh-pipe path, and a cache has one decision.
// Two vocabularies for one condition means every caller learns both.
try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "garbage\n" });
{
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
try f.chmod(0o600);
}
try std.testing.expectError(CacheError.CacheMalformed, readCache(path));
}
/// One step as a word. A failing row prints the step a reader can compare,
/// not a struct dump; the endpoint shows its port and the first byte of its
/// key, which is enough to tell two announces apart.
fn stepStr(buf: []u8, st: Step) ![]const u8 {
return switch (st) {
.dial_quic => |ep| std.fmt.bufPrint(buf, "dial_quic {d}/{x:0>2}", .{ ep.port, ep.key[0] }),
.spawn_ssh => std.fmt.bufPrint(buf, "spawn_ssh", .{}),
.read_announce => std.fmt.bufPrint(buf, "read_announce", .{}),
.write_cache => |ep| std.fmt.bufPrint(buf, "write_cache {d}/{x:0>2}", .{ ep.port, ep.key[0] }),
.use_quic => std.fmt.bufPrint(buf, "use_quic", .{}),
.use_pipe => |say| std.fmt.bufPrint(buf, "use_pipe({s})", .{if (say) "line" else "silent"}),
.fail => std.fmt.bufPrint(buf, "fail", .{}),
};
}
fn expectStep(want: []const u8, got: Step) !void {
var buf: [64]u8 = undefined;
try std.testing.expectEqualStrings(want, try stepStr(&buf, got));
}
// Two announces differing in BOTH halves, so a row that compares only ports
// and a row that compares only keys are equally wrong. One note over the
// pair: neither endpoint means anything without the other.
fn epA() Endpoint {
return .{ .port = 4433, .key = [_]u8{0xab} ** key_len };
}
fn epB() Endpoint {
return .{ .port = 5000, .key = [_]u8{0xcd} ** key_len };
}
test "handoff step: a usable cache is dialled before anything else runs" {
var s: State = .{ .cached = epA(), .asked = false, .has_cache = true };
try expectStep("dial_quic 4433/ab", next(&s, null));
}
test "handoff step: nothing cached starts at the ssh run" {
var s: State = .{ .cached = null, .asked = false, .has_cache = true };
try expectStep("spawn_ssh", next(&s, null));
}
test "handoff step: a warm dial that connected IS the session" {
var s: State = .{ .cached = epA(), .asked = false, .has_cache = true, .phase = .warm_dial };
try expectStep("use_quic", next(&s, .ok));
}
test "handoff step: the abort key inside the warm dial asked to stop, not to try the next thing" {
var s: State = .{ .cached = epA(), .asked = true, .has_cache = true, .phase = .warm_dial };
try expectStep("fail", next(&s, .user_abort));
}
test "handoff step: a warm dial that failed hands the question to ssh, and remembers what went silent" {
var s: State = .{ .cached = epA(), .asked = false, .has_cache = true, .phase = .warm_dial };
try expectStep("spawn_ssh", next(&s, .failed));
// Remembered here or nowhere: by the time the announce comes back the cache
// holds what ssh just said. `!= null` before the compare, not a `.?`: an
// unwrap panics, taking the binary and every later test down with it.
try std.testing.expect(s.failed_on != null and std.meta.eql(s.failed_on.?, epA()));
}
test "handoff step: an ssh that could not start ends the handoff" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .ssh };
try expectStep("fail", next(&s, .failed));
}
test "handoff step: the ssh that came up is read for its announce" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .ssh };
try expectStep("read_announce", next(&s, .ok));
}
test "handoff step: `endpoint none` is the ssh pipe, and says nothing about it" {
// Even for a user who ASKED: no coordinates were ever in play, so
// there is nothing to report as unreachable.
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .announce };
try expectStep("use_pipe(silent)", next(&s, .none));
}
test "handoff step: an announce that never arrived ends the handoff" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .announce };
try expectStep("fail", next(&s, .announce_failed));
}
test "handoff step: an announce is cached BEFORE it is dialled" {
// The coordinates are true whether or not UDP can carry them. A client
// that cached only what it reached would leave every jump-host route
// permanently cold.
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .announce };
try expectStep("write_cache 5000/cd", next(&s, .{ .announced = epB() }));
}
test "handoff step: a host with nowhere to cache dials the announce straight away" {
var s: State = .{ .cached = null, .asked = true, .has_cache = false, .phase = .announce };
try expectStep("dial_quic 5000/cd", next(&s, .{ .announced = epB() }));
}
test "handoff step: the announce that was just cached is the one dialled" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cache, .announced = epB() };
try expectStep("dial_quic 5000/cd", next(&s, .done));
}
test "handoff step: coordinates a warm dial already proved silent are not dialled twice" {
for ([_]bool{ false, true }) |asked| {
var s: State = .{
.cached = epA(),
.asked = asked,
.has_cache = true,
.phase = .cache,
.announced = epA(),
.failed_on = epA(),
};
try expectStep(if (asked) "use_pipe(line)" else "use_pipe(silent)", next(&s, .done));
}
}
test "handoff step: an announce that moved either half of the endpoint is dialled again" {
const moved = [_]Endpoint{
epB(),
.{ .port = 4433, .key = [_]u8{0xcd} ** key_len },
.{ .port = 5000, .key = [_]u8{0xab} ** key_len },
};
const want = [_][]const u8{ "dial_quic 5000/cd", "dial_quic 4433/cd", "dial_quic 5000/ab" };
for (moved, want) |ep, w| {
var s: State = .{
.cached = epA(),
.asked = true,
.has_cache = true,
.phase = .cache,
.announced = ep,
.failed_on = epA(),
};
try expectStep(w, next(&s, .done));
}
}
test "handoff step: a cold dial that connected IS the session" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cold_dial };
try expectStep("use_quic", next(&s, .ok));
}
test "handoff step: the abort key inside the cold dial ends the handoff" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cold_dial };
try expectStep("fail", next(&s, .user_abort));
}
test "handoff step: a cold dial that failed is the ssh pipe, and only the user who asked hears why" {
// A reconnect re-runs this recipe forever against dropped UDP: one
// line per retry would scroll a live session's stderr into the
// alternate screen to say what the reconnecting banner already says.
for ([_]bool{ false, true }) |asked| {
var s: State = .{ .cached = null, .asked = asked, .has_cache = true, .phase = .cold_dial };
try expectStep(if (asked) "use_pipe(line)" else "use_pipe(silent)", next(&s, .failed));
}
}
/// Drives `next` the way `client.Transport.openHandoff` does, answering each
/// effect from `answers` in order, and renders the whole trace. The trace is
/// the assertion: a policy that fires one dial too many shows up as an extra
/// word, not as a count somebody remembered to write down.
fn walk(s: *State, answers: []const Outcome, buf: []u8) ![]const u8 {
var w: usize = 0;
var ai: usize = 0;
var step = next(s, null);
while (true) {
if (w > 0) {
@memcpy(buf[w..][0..2], ", ");
w += 2;
}
w += (try stepStr(buf[w..], step)).len;
switch (step) {
.use_quic, .use_pipe, .fail => return buf[0..w],
else => {},
}
// Rendered rather than indexed past the end: a machine that asks
// for more effects than the walk predicted is the answer, and it
// belongs in the diff instead of in a bounds panic.
if (ai >= answers.len) {
@memcpy(buf[w..][0..8], ", asked?");
return buf[0 .. w + 8];
}
step = next(s, answers[ai]);
ai += 1;
}
}
fn expectWalk(want: []const u8, s: *State, answers: []const Outcome) !void {
var buf: [512]u8 = undefined;
try std.testing.expectEqualStrings(want, try walk(s, answers, &buf));
}
test "handoff walk: a warm cache that answers is one dial and nothing else" {
// No ssh at all: the whole point of caching the announce.
var s: State = .{ .cached = epA(), .asked = true, .has_cache = true };
try expectWalk("dial_quic 4433/ab, use_quic", &s, &.{.ok});
}
test "handoff walk: a warm miss whose refetch names the SAME endpoint pays one deadline, not two" {
// The case this table was built for: where the announced UDP port cannot be
// reached, the warm dial spends the budget and ssh answers with the very
// coordinates that went silent — dialling them again spends it twice.
// EXACTLY ONE `dial_quic` in this trace.
var s: State = .{ .cached = epA(), .asked = true, .has_cache = true };
try expectWalk(
"dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 4433/ab, use_pipe(line)",
&s,
&.{ .failed, .ok, .{ .announced = epA() }, .done },
);
}
test "handoff walk: a warm miss whose refetch moved EITHER half of the endpoint dials again" {
// A daemon that restarted took a fresh ephemeral port; one that was
// re-keyed kept its port. Both are a live host the client can still
// reach, and neither is the endpoint that went silent — so a compare
// that looked at ports alone would strand every re-keyed daemon on ssh.
const moved = [_]Endpoint{
epB(),
.{ .port = 4433, .key = [_]u8{0xcd} ** key_len },
.{ .port = 5000, .key = [_]u8{0xab} ** key_len },
};
const traces = [_][]const u8{
"dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 5000/cd, dial_quic 5000/cd, use_quic",
"dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 4433/cd, dial_quic 4433/cd, use_quic",
"dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 5000/ab, dial_quic 5000/ab, use_quic",
};
for (moved, traces) |ep, want| {
var s: State = .{ .cached = epA(), .asked = true, .has_cache = true };
try expectWalk(want, &s, &.{ .failed, .ok, .{ .announced = ep }, .done, .ok });
}
}
test "handoff walk: a cold attach fetches, caches, then dials what it fetched" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true };
try expectWalk(
"spawn_ssh, read_announce, write_cache 4433/ab, dial_quic 4433/ab, use_quic",
&s,
&.{ .ok, .{ .announced = epA() }, .done, .ok },
);
}
test "handoff walk: a cold attach whose dial cannot get through ends on the pipe it already holds" {
for ([_]bool{ false, true }) |asked| {
var s: State = .{ .cached = null, .asked = asked, .has_cache = true };
try expectWalk(
if (asked)
"spawn_ssh, read_announce, write_cache 4433/ab, dial_quic 4433/ab, use_pipe(line)"
else
"spawn_ssh, read_announce, write_cache 4433/ab, dial_quic 4433/ab, use_pipe(silent)",
&s,
&.{ .ok, .{ .announced = epA() }, .done, .failed },
);
}
}
test "handoff walk: `endpoint none` ends at the pipe without dialling anything" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true };
try expectWalk("spawn_ssh, read_announce, use_pipe(silent)", &s, &.{ .ok, .none });
}
test "handoff walk: an ssh that announces nothing at all ends the handoff" {
var s: State = .{ .cached = null, .asked = true, .has_cache = true };
try expectWalk("spawn_ssh, read_announce, fail", &s, &.{ .ok, .announce_failed });
}
// 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());
}