a73x

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