a73x

src/client/askpass.zig

Ref:   Size: 42.0 KiB   History

//! ssh's prompts, and the one place mux can answer them. ssh reads passwords
//! and the host-key question from `/dev/tty` — a read no fd mux sets can reach,
//! and under a wall's alternate screen one nobody can see.
//! `SSH_ASKPASS_REQUIRE=force` turns each into an exec whose stdout is the
//! answer, so the prompt becomes BYTES.
//!
//! Both ends of that trip live here: `Listener` is the client's, one socket per
//! process, and `helperMain` is what `mux askpass` runs. Nothing here paints or
//! spawns, so the whole carriage drives from a test with no ssh and no wall.
const std = @import("std");
// `serve_mod` and not `serve`: Listener has its own `serve` method, and
// inside the struct the bare name is ambiguous.
const dial = @import("dial");
const serve_mod = @import("serve");
const xdg = @import("xdg");
const client_os = @import("client_os");

/// Env var naming the socket. The mode word for the helper, too: ssh execs
/// its helper with the prompt as argv[1] and nothing else, so there is no
/// word to give it, and this variable exists in exactly one process tree —
/// the ssh a wall dial spawned.
pub const sock_env = "MUX_ASKPASS_SOCK";

/// ssh's longest ordinary question is the host-key one, ~220 bytes with a
/// fingerprint in it. Cut, never refused: a prompt the user cannot read
/// whole is still a prompt they can answer.
pub const prompt_max = 512;

/// A passphrase, not a document.
pub const answer_max = 256;

/// How many declined dials are remembered. A pid the ring has evicted reads
/// `declined` false, which costs one more prompt — the failure a caller
/// cannot see is a tile parked forever, so the ring errs the other way.
pub const decline_ring = 8;

/// How many helpers may be waiting to be served. A separate number from
/// the ring above, which it briefly shared by accident: one counts refused
/// dials, this one counts sshs queued behind a popup.
const backlog = 8;

/// The reply's first byte. An EMPTY answer is a real answer — a key whose
/// passphrase is empty, an Enter on a question the user means to leave
/// blank — so "declined" cannot be spelled by an empty line, which is the
/// whole reason there is a tag at all.
const reply_answer = '+';
const reply_decline = '-';

/// What OpenSSH 8.4+ puts in the helper's environment to say what it is asking
/// for. An exact signal, so there is no guessing: matching `assword` against the
/// text would paint a server-authored prompt's answer in the clear whenever the
/// server spelled it differently.
pub const prompt_env = "SSH_ASKPASS_PROMPT";

/// The three things ssh can want, and the one byte the wire carries to say
/// which. A value that is neither of ssh's two words is a SECRET: the
/// mistake that hides an answer is cheaper than the one that paints it.
pub const Kind = enum(u8) {
    secret = 's',
    /// The host-key question and every other yes/no. Painted in the clear,
    /// because the user is comparing a fingerprint.
    confirm = 'c',
    /// Not a question: "Confirm user presence for key ..." — the FIDO touch
    /// notifier. ssh SIGTERMs the helper when the touch lands, so this box
    /// takes no answer and closes when the peer hangs up.
    notice = 'n',

    pub fn of(env: ?[]const u8) Kind {
        const v = env orelse return .secret;
        if (std.mem.eql(u8, v, "confirm")) return .confirm;
        if (std.mem.eql(u8, v, "none")) return .notice;
        return .secret;
    }

    fn ofTag(b: u8) Kind {
        return switch (b) {
            @intFromEnum(Kind.confirm) => .confirm,
            @intFromEnum(Kind.notice) => .notice,
            else => .secret,
        };
    }
};

/// How long `serve` gives a peer to finish its request line, and the slice
/// it re-checks the peer on while the box is up. A connected peer that
/// never speaks would otherwise park the accept thread for the wall's life,
/// and every later prompt behind it.
const request_ms_default: i32 = 5000;
const watch_ns: u64 = 200 * std.time.ns_per_ms;

/// One pending prompt, attributed to the ssh child that raised it.
pub const Prompt = struct {
    text: [prompt_max]u8 = undefined,
    text_len: usize = 0,
    kind: Kind = .secret,
    /// The dial this prompt belongs to: the ancestor of the connecting
    /// helper that is THIS process's child, which is the ssh a wall dial
    /// spawned (`dialOwner`). 0 when the walk found none — attribution is
    /// a convenience, never a condition for answering.
    ssh_pid: std.posix.pid_t = 0,

    pub fn slice(self: *const Prompt) []const u8 {
        return self.text[0..self.text_len];
    }
};

/// The doorbell a prompt rings: `SessionPoll.Hooks`, for its reason.
pub const Hooks = struct {
    ctx: *anyopaque,
    wake: *const fn (*anyopaque) void,
};

/// The wall's end: one socket, one accept thread, one prompt at a time.
/// Serialized BY CONSTRUCTION — the accept thread serves a connection to
/// completion before accepting the next, so a second ssh waits in the backlog.
/// That is also the whole fairness policy: accept order.
pub const Listener = struct {
    /// The state one prompt moves through. `shown` exists so a doorbell the
    /// keyboard rings twice opens one popup: `take` is the transition, not
    /// a read.
    const Phase = enum { idle, pending, shown, done };

    alloc: std.mem.Allocator,
    path: []const u8,
    /// The listening socket and the identity of the file it was bound to.
    /// The id is what keeps `retire` from unlinking a SUCCESSOR's socket:
    /// the name has this client's pid in it, and a pid comes round again.
    bound: serve_mod.Bound,
    /// How `stop` reaches a thread parked in `poll`. Closing the listening
    /// fd under an accept is not defined to wake it; a byte here is.
    stop_r: std.posix.fd_t,
    stop_w: std.posix.fd_t,
    thread: ?std.Thread = null,
    wake: Hooks,

    mu: std.Thread.Mutex = .{},
    cv: std.Thread.Condition = .{},
    running: bool = true,
    phase: Phase = .idle,
    prompt: Prompt = .{},
    answer_buf: [answer_max]u8 = undefined,
    answer_len: usize = 0,
    was_declined: bool = false,
    ring: [decline_ring]std.posix.pid_t = @splat(0),
    ring_at: usize = 0,
    /// A field rather than the constant, `HandoffTarget.deadline_ms`'s
    /// shape: a test that has to prove the deadline exists must not spend
    /// it.
    request_ms: i32 = request_ms_default,

    /// `runtime_dir` is where the socket goes; the pid in the name is what
    /// makes it this client's and not another's.
    pub fn start(alloc: std.mem.Allocator, runtime_dir: []const u8, wake: Hooks) !*Listener {
        const path = try std.fmt.allocPrint(
            alloc,
            "{s}/mux-ask-{d}.sock",
            .{ runtime_dir, client_os.getpid() },
        );
        errdefer alloc.free(path);
        // Every wall that died by signal — a closed terminal window, a kill
        // — left its socket here, and `retire` never ran for it. This wall
        // is the first since to look, and asks the OS which owners are gone.
        xdg.reapDeadPid(runtime_dir, "mux-ask-");
        // `clobber_own`: a client that died without unlinking left a file,
        // and a pid comes round again. Nothing else may own this name — it
        // has our pid in it. CLOEXEC because this process spawns the ssh
        // that the prompt is FOR, and every command run inside a session
        // below it; none of them may hold the socket that answers.
        var bound = try serve_mod.bind(path, .{
            .policy = .clobber_own,
            .backlog = backlog,
            .cloexec = true,
        });
        errdefer _ = bound.close(path);
        const bell = try std.posix.pipe2(.{ .CLOEXEC = true });
        errdefer {
            std.posix.close(bell[0]);
            std.posix.close(bell[1]);
        }
        const self = try alloc.create(Listener);
        errdefer alloc.destroy(self);
        self.* = .{
            .alloc = alloc,
            .path = path,
            .bound = bound,
            .stop_r = bell[0],
            .stop_w = bell[1],
            .wake = wake,
        };
        self.thread = try std.Thread.spawn(.{}, acceptLoop, .{self});
        return self;
    }

    /// Unlinks and joins. A helper still waiting is answered with a decline,
    /// because an ssh blocked on a socket nobody will ever read is a dial
    /// that never ends.
    pub fn stop(self: *Listener) void {
        self.retire();
        if (self.thread) |t| t.join();
        std.posix.close(self.bound.fd);
        std.posix.close(self.stop_r);
        std.posix.close(self.stop_w);
        self.alloc.free(self.path);
        self.alloc.destroy(self);
    }

    /// The half of `stop` a process about to `exit` may run.
    pub fn retire(self: *Listener) void {
        // The name leaves the filesystem and a waiting helper is declined;
        // nothing is joined, closed or freed. The wall ends in `std.posix.exit`
        // with detached pumps live, one of which may be inside `declined` on
        // this object — a free there is a use-after-free.
        self.mu.lock();
        self.running = false;
        self.cv.broadcast();
        self.mu.unlock();
        _ = std.posix.write(self.stop_w, "x") catch {};
        // The guard without the close: a successor Listener in a process
        // that got our pid back binds this same name, and unlinking by name
        // here would take ITS socket and leave that client's ssh prompting
        // at nothing.
        _ = self.bound.unlinkIfOurs(self.path);
    }

    /// The keyboard's side. Copies the pending prompt out and marks it
    /// shown; false when there is nothing waiting.
    pub fn take(self: *Listener, out: *Prompt) bool {
        self.mu.lock();
        defer self.mu.unlock();
        if (self.phase != .pending) return false;
        out.* = self.prompt;
        self.phase = .shown;
        return true;
    }

    pub fn answer(self: *Listener, text: []const u8) void {
        self.finish(text, false);
    }

    pub fn decline(self: *Listener) void {
        self.finish("", true);
    }

    fn finish(self: *Listener, text: []const u8, declining: bool) void {
        self.mu.lock();
        defer self.mu.unlock();
        // Not `.pending`: an answer for a prompt nobody showed is a driver
        // bug, and unblocking the helper with it would hide the bug behind
        // a working login.
        if (self.phase != .shown) return;
        const n = @min(text.len, answer_max);
        @memcpy(self.answer_buf[0..n], text[0..n]);
        self.answer_len = n;
        self.was_declined = declining;
        // pid 0 is the failed walk, and remembering it would park every
        // tile whose attribution failed.
        if (declining and self.prompt.ssh_pid != 0) {
            self.ring[self.ring_at % decline_ring] = self.prompt.ssh_pid;
            self.ring_at += 1;
            // The dial ends HERE, before the helper is released: a refused
            // askpass is not a refused login to OpenSSH. A helper that exits
            // non-zero is read as the EMPTY password, so ssh tries it and asks
            // again up to `NumberOfPasswordPrompts` — one Esc, three prompts.
            // Killing our own child makes one Esc one refusal.
            std.posix.kill(self.prompt.ssh_pid, std.posix.SIG.TERM) catch {};
        }
        self.phase = .done;
        self.cv.signal();
    }

    /// The pump's "stop redialing" test: a dial whose prompt the user
    /// refused must not come straight back with the same question.
    pub fn declined(self: *Listener, ssh_pid: std.posix.pid_t) bool {
        if (ssh_pid == 0) return false;
        self.mu.lock();
        defer self.mu.unlock();
        for (self.ring) |p| if (p == ssh_pid) return true;
        return false;
    }

    fn acceptLoop(self: *Listener) void {
        while (true) {
            var pfds = [_]std.posix.pollfd{
                .{ .fd = self.bound.fd, .events = std.posix.POLL.IN, .revents = 0 },
                .{ .fd = self.stop_r, .events = std.posix.POLL.IN, .revents = 0 },
            };
            _ = std.posix.poll(&pfds, -1) catch return;
            if (pfds[1].revents != 0) return;
            if (pfds[0].revents & std.posix.POLL.IN == 0) continue;
            const c = std.posix.accept(self.bound.fd, null, null, std.posix.SOCK.CLOEXEC) catch continue;
            self.serve(c);
            std.posix.close(c);
            self.mu.lock();
            const go = self.running;
            self.mu.unlock();
            if (!go) return;
        }
    }

    fn serve(self: *Listener, c: std.posix.socket_t) void {
        const cred = client_os.peerCred(c) orelse return;
        // Both checks are `client_os.peerCred`'s to explain.
        if (cred.uid != client_os.geteuid()) return;
        var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, client_os.getpid(), client_os.parentOf) };
        var raw: [prompt_max + 1]u8 = undefined;
        // Bounded, because an accept thread parked in `read` is every later
        // prompt of this wall parked behind it — and `stop`'s join with it.
        const got = readLine(c, &raw, self.request_ms) orelse return;
        if (got == 0) return;
        p.kind = Kind.ofTag(raw[0]);
        // Again on this side: the helper folds before it sends, and this is
        // the end that paints. See `foldControl`.
        p.text_len = foldControl(&p.text, raw[1..got]);
        self.mu.lock();
        if (!self.running) {
            self.mu.unlock();
            return;
        }
        self.prompt = p;
        self.phase = .pending;
        self.mu.unlock();
        self.wake.wake(self.wake.ctx);
        const abandoned = self.awaitAnswer(c);

        var reply: [answer_max + 2]u8 = undefined;
        var len: usize = 0;
        self.mu.lock();
        // A stop mid-prompt declines: the popup is gone with the wall that
        // painted it, and ssh is owed an answer either way. A peer that has
        // gone gets nothing — there is nobody to answer.
        const refused = self.was_declined or self.phase != .done;
        reply[0] = if (refused) reply_decline else reply_answer;
        if (!refused) {
            @memcpy(reply[1 .. 1 + self.answer_len], self.answer_buf[0..self.answer_len]);
            len = self.answer_len;
        }
        // The wall's copy dies with the prompt it answered: this buffer
        // outlives the box on screen, and a core dump is a file.
        @memset(&self.answer_buf, 0);
        self.answer_len = 0;
        self.phase = .idle;
        self.mu.unlock();
        reply[1 + len] = '\n';
        if (!abandoned) _ = writeAll(c, reply[0 .. len + 2]);
        @memset(&reply, 0);
    }

    /// Blocks until the keyboard answers, the wall stops, or the PEER goes. True
    /// when it was the peer: ssh SIGTERMs its notifier helper when the touch
    /// lands, and dismissing the leftover box would decline a live dial.
    fn awaitAnswer(self: *Listener, c: std.posix.socket_t) bool {
        while (true) {
            self.mu.lock();
            const open = self.running and (self.phase == .pending or self.phase == .shown);
            if (!open) {
                self.mu.unlock();
                return false;
            }
            self.cv.timedWait(&self.mu, watch_ns) catch {};
            const still = self.running and (self.phase == .pending or self.phase == .shown);
            self.mu.unlock();
            if (!still) return false;
            if (!peerGone(c)) continue;
            self.mu.lock();
            if (self.phase == .pending or self.phase == .shown) self.phase = .idle;
            self.mu.unlock();
            // The box on screen is showing a question nobody is waiting on.
            self.wake.wake(self.wake.ctx);
            return true;
        }
    }

    /// Whether the box on screen still has an ssh behind it.
    pub fn showing(self: *Listener) bool {
        // The keyboard asks every pass: `serve` puts the phase back to idle
        // on a hangup, and the popup has to follow it.
        self.mu.lock();
        defer self.mu.unlock();
        return self.phase == .pending or self.phase == .shown;
    }
};

/// The helper's end: `mux askpass`. One line out, one back, and the answer on
/// `out` — ssh's own stdin-side pipe, so a byte here that is not the answer is a
/// byte ssh tries to log in with. Every failure is exit 1 with NOTHING written,
/// which ssh reads as a refused prompt.
pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 {
    // Through `dial`, so `MUX_ASKPASS_SOCK` naming something that is not a
    // socket is this function's exit 1 on both systems rather than a panic
    // inside ssh's password helper on one of them.
    const stream = dial.dial(sock) catch return 1;
    defer stream.close();
    var line: [prompt_max + 2]u8 = undefined;
    line[0] = @intFromEnum(kind);
    // The wire is one line each way, and ssh's own prompts are multi-line
    // on some builds (the host-key question). Folded, not split: the popup
    // wraps it to the width anyway — and the same fold is what stops a
    // server-authored prompt carrying an escape sequence onto the screen.
    const n = foldControl(line[1 .. prompt_max + 1], prompt);
    line[n + 1] = '\n';
    if (!writeAll(stream.handle, line[0 .. n + 2])) return 1;
    var reply: [answer_max + 2]u8 = undefined;
    // No deadline here, unlike the listener's read: this wait IS the user
    // reading the question, and ssh is content to wait on its helper.
    const got = readLine(stream.handle, &reply, -1) orelse return 1;
    if (got == 0 or reply[0] != reply_answer) return 1;
    const wrote = writeAll(out_fd, reply[1..got]) and writeAll(out_fd, "\n");
    // The helper is a whole process holding one password. It exits in a
    // microsecond, but zeroing is one line and a core dump is a file.
    @memset(&reply, 0);
    return if (wrote) 0 else 1;
}

/// One prompt's bytes, made safe to paint: every control byte becomes a
/// space. Returns how many were written.
pub fn foldControl(dst: []u8, src: []const u8) usize {
    // `handoff.Reason`'s rule on ssh's other channel: a prompt is painted INSIDE
    // a wall's alternate screen, so an escape in one moves a cursor or fakes a
    // row in somebody's tile. The text is not always ssh's — a
    // keyboard-interactive prompt is the SERVER's wording, unsanitized. Run on
    // BOTH ends, because the end that paints is the end that must not trust.
    const n = @min(src.len, dst.len);
    for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch;
    return n;
}

/// Bytes up to the first '\n', which is dropped; anything past `buf.len` is
/// dropped too. Null is an EOF with nothing at all — a peer that hung up
/// before saying anything, which is not a prompt and not an answer.
fn readLine(fd: std.posix.fd_t, buf: []u8, timeout_ms: i32) ?usize {
    var len: usize = 0;
    var seen = false;
    while (true) {
        if (timeout_ms >= 0) {
            var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
            const ready = std.posix.poll(&pfd, timeout_ms) catch return null;
            if (ready == 0) return null;
        }
        var one: [1]u8 = undefined;
        const n = std.posix.read(fd, &one) catch return null;
        if (n == 0) return if (seen) len else null;
        seen = true;
        if (one[0] == '\n') return len;
        if (len < buf.len) {
            buf[len] = one[0];
            len += 1;
        }
    }
}

/// Whether the peer has closed its end. A zero-length PEEK is the only
/// answer that means it: `POLL.IN` also fires on bytes we never asked for.
fn peerGone(c: std.posix.socket_t) bool {
    var pfd = [_]std.posix.pollfd{.{ .fd = c, .events = std.posix.POLL.IN, .revents = 0 }};
    const ready = std.posix.poll(&pfd, 0) catch return true;
    if (ready == 0) return false;
    if (pfd[0].revents & (std.posix.POLL.ERR | std.posix.POLL.NVAL) != 0) return true;
    var b: [1]u8 = undefined;
    const n = std.posix.recv(c, &b, std.posix.MSG.PEEK) catch return true;
    return n == 0;
}

fn writeAll(fd: std.posix.fd_t, bytes: []const u8) bool {
    var off: usize = 0;
    while (off < bytes.len) {
        const n = std.posix.write(fd, bytes[off..]) catch return false;
        if (n == 0) return false;
        off += n;
    }
    return true;
}

/// How far up the tree the walk goes. ssh execs its helper directly, so
/// production is one step; the slack is for a shell in between.
const ancestor_max = 8;

/// The ssh THIS process spawned that is behind `peer`: the ancestor whose
/// parent is us.
fn dialOwner(
    peer: std.posix.pid_t,
    me: std.posix.pid_t,
    parent: *const fn (std.posix.pid_t) std.posix.pid_t,
) std.posix.pid_t {
    // Not simply the helper's parent: under ProxyJump the INNER ssh inherits
    // `SSH_ASKPASS` and prompts through it, so the chain is helper → inner ssh →
    // outer ssh → us. An attribution that breaks on one extra fork returns 0
    // exactly when a wall needs a name. The INVARIANT is the far end: the ssh a
    // dial spawned is a child of this process, and nothing else is.
    var at = peer;
    var steps: usize = 0;
    while (at > 0 and steps < ancestor_max) : (steps += 1) {
        const up = parent(at);
        if (up == me) return at;
        at = up;
    }
    return 0;
}

// ---- tests ----

const testtmp = @import("testtmp");

/// A wake that counts, so a test can assert the doorbell rang at all.
const Counter = struct {
    n: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
    fn bump(ctx: *anyopaque) void {
        const self: *Counter = @ptrCast(@alignCast(ctx));
        _ = self.n.fetchAdd(1, .release);
    }
    fn hooks(self: *Counter) Hooks {
        return .{ .ctx = self, .wake = bump };
    }
};

/// Waits for the accept thread to park a prompt, so no test spins on a
/// race it cannot see. Returns false on the 2 s budget.
fn awaitPrompt(l: *Listener, out: *Prompt) bool {
    var waited: usize = 0;
    while (waited < 2000) : (waited += 5) {
        if (l.take(out)) return true;
        std.Thread.sleep(5 * std.time.ns_per_ms);
    }
    return false;
}

const HelperRun = struct {
    code: u8 = 0,
    out_path: []const u8,
    prompt: []const u8,
    sock: []const u8,
    kind: Kind = .secret,
    fn go(self: *HelperRun) void {
        const f = std.fs.cwd().createFile(self.out_path, .{}) catch return;
        defer f.close();
        self.code = helperMain(self.prompt, self.sock, self.kind, f.handle);
    }
};

fn helperStdout(alloc: std.mem.Allocator, path: []const u8) ![]u8 {
    return std.fs.cwd().readFileAlloc(alloc, path, 4096);
}

test "askpass.Listener: a helper's line reaches take, and answer reaches the helper" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);
    var run: HelperRun = .{
        .out_path = out_path,
        .prompt = "box's password: ",
        .sock = l.path,
    };
    const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});

    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    try std.testing.expectEqualStrings("box's password: ", p.slice());
    l.answer("s3cret");
    th.join();

    const got = try helperStdout(alloc, out_path);
    defer alloc.free(got);
    try std.testing.expectEqualStrings("s3cret\n", got);
    try std.testing.expectEqual(@as(u8, 0), run.code);
    try std.testing.expect(counter.n.load(.acquire) >= 1);
}

test "askpass.Listener: decline gives the helper exit 1 and no stdout" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);
    var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path };
    const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});

    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    l.decline();
    th.join();

    const got = try helperStdout(alloc, out_path);
    defer alloc.free(got);
    // ssh logs in with whatever this fd carried, so "nothing" is the claim,
    // not "something short".
    try std.testing.expectEqualStrings("", got);
    try std.testing.expectEqual(@as(u8, 1), run.code);
}

test "askpass.Listener: an EMPTY answer is an answer, not a decline" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);
    var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path };
    const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});

    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    // Enter on an empty line. A key with no passphrase asks anyway, and the
    // user who answers nothing has answered.
    l.answer("");
    th.join();

    const got = try helperStdout(alloc, out_path);
    defer alloc.free(got);
    try std.testing.expectEqualStrings("\n", got);
    try std.testing.expectEqual(@as(u8, 0), run.code);
}

test "askpass.Listener: two helpers are served one at a time, in accept order" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    const out_a = try std.fmt.allocPrint(alloc, "{s}/a", .{tmp.path()});
    defer alloc.free(out_a);
    var first: HelperRun = .{ .out_path = out_a, .prompt = "first: ", .sock = l.path };
    const th_a = try std.Thread.spawn(.{}, HelperRun.go, .{&first});

    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    try std.testing.expectEqualStrings("first: ", p.slice());

    const out_b = try std.fmt.allocPrint(alloc, "{s}/b", .{tmp.path()});
    defer alloc.free(out_b);
    var second: HelperRun = .{ .out_path = out_b, .prompt = "second: ", .sock = l.path };
    const th_b = try std.Thread.spawn(.{}, HelperRun.go, .{&second});

    // The second helper is connected and waiting in the backlog, and the
    // popup on screen is still the first one's: a second `take` here would
    // be a popup that changed its question under the user's fingers.
    std.Thread.sleep(100 * std.time.ns_per_ms);
    var q: Prompt = .{};
    try std.testing.expect(!l.take(&q));

    l.answer("one");
    th_a.join();
    try std.testing.expect(awaitPrompt(l, &q));
    try std.testing.expectEqualStrings("second: ", q.slice());
    l.answer("two");
    th_b.join();

    const a = try helperStdout(alloc, out_a);
    defer alloc.free(a);
    const b = try helperStdout(alloc, out_b);
    defer alloc.free(b);
    try std.testing.expectEqualStrings("one\n", a);
    try std.testing.expectEqualStrings("two\n", b);
}

/// A process tree as a table, so the walk can be driven over shapes no
/// test could arrange with real forks.
const FakeTree = struct {
    // helper 100 <- sh 99 <- sh 98 <- ssh 97 <- us 7 <- init 1
    const rows = [_][2]std.posix.pid_t{
        .{ 100, 99 }, .{ 99, 98 }, .{ 98, 97 }, .{ 97, 7 }, .{ 7, 1 }, .{ 1, 0 },
    };
    fn parent(pid: std.posix.pid_t) std.posix.pid_t {
        for (rows) |r| if (r[0] == pid) return r[1];
        return 0;
    }
    // A cycle no real tree has, for the bound below.
    fn ring(pid: std.posix.pid_t) std.posix.pid_t {
        return if (pid == 1) 2 else 1;
    }
};

test "askpass: a helper two shells below the ssh we spawned is still that ssh's" {
    // The plain case is one step; a ProxyJump is two, and a test's stand-in adds
    // a shell. An attribution that breaks on one extra fork answers 0 exactly
    // where a wall needs a name — so the walk climbs to OUR child.
    try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent));
    // The direct case, unchanged.
    try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent));
    // A peer that is not ours at all: someone else's process on the same
    // box. Answering a pid here would park a tile on a stranger's decline.
    try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(100, 4242, FakeTree.parent));
    // A tree that loops must end the walk rather than the process.
    try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring));
}

test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);
    var run: HelperRun = .{ .out_path = out_path, .prompt = "who: ", .sock = l.path };
    const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});

    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    // The helper here is a THREAD, so the peer IS this process and no
    // ancestor of it is our child. 0, and the prompt is still answered:
    // attribution is a convenience, never a condition for answering.
    try std.testing.expectEqual(@as(std.posix.pid_t, 0), p.ssh_pid);
    l.answer("x");
    th.join();
    const got = try helperStdout(alloc, out_path);
    defer alloc.free(got);
    try std.testing.expectEqualStrings("x\n", got);
}

test "askpass.Listener: a decline ends the dial's ssh, it does not just refuse the prompt" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    // A live process standing in for the dial's ssh, because the claim is
    // about the OS and not about a flag: an Esc that only refused the
    // prompt would leave OpenSSH trying the empty password and asking
    // again, three times against a real sshd.
    var ssh = std.process.Child.init(&.{ "/bin/sleep", "30" }, alloc);
    try ssh.spawn();
    l.mu.lock();
    l.prompt.ssh_pid = ssh.id;
    l.phase = .shown;
    l.mu.unlock();
    l.decline();

    const term = try ssh.wait();
    switch (term) {
        .Signal => |sig| try std.testing.expectEqual(@as(u32, std.posix.SIG.TERM), sig),
        else => {
            std.debug.print("the declined dial's ssh was not signalled: {any}\n", .{term});
            return error.TestUnexpectedResult;
        },
    }
}

test "askpass.Listener: a declined pid parks, an untouched one does not" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    // Driven through the same door the pump asks at, with the prompt state
    // set by hand: the ring is what a redial reads, and the pid it is keyed
    // on comes from a dial, not from this socket.
    l.mu.lock();
    l.prompt.ssh_pid = 4242;
    l.phase = .shown;
    l.mu.unlock();
    l.decline();
    try std.testing.expect(l.declined(4242));
    try std.testing.expect(!l.declined(4243));
    // A dial whose attribution failed is not every dial: pid 0 must never
    // park a tile.
    try std.testing.expect(!l.declined(0));
}

test "askpass.Listener: a peer that says nothing does not park the next prompt" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    // Registered FIRST so it runs LAST: the silent peer below must be
    // closed before this join, or a broken deadline wedges the runner
    // instead of failing — and a wedged test step prints nothing at all.
    defer l.stop();
    l.request_ms = 150;

    // Connected, and then nothing: no tag, no text, no newline. The accept
    // thread serves one connection to completion, so without a deadline
    // this is every later prompt of this wall parked behind one peer.
    const mute = try std.net.connectUnixSocket(l.path);
    defer mute.close();

    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);
    var run: HelperRun = .{ .out_path = out_path, .prompt = "after: ", .sock = l.path };
    const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    try std.testing.expectEqualStrings("after: ", p.slice());
    l.answer("x");
    th.join();
}

test "askpass.Listener: a helper that hangs up closes the box behind it" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    // The FIDO notifier's shape: ssh runs the helper to say "touch your
    // key" and SIGTERMs it the moment the touch lands. A box left standing
    // after that is one the user must dismiss for no reason — and
    // dismissing it would record a LIVE dial's pid as declined.
    const peer = try std.net.connectUnixSocket(l.path);
    _ = try peer.write("nConfirm user presence for key ED25519-SK\n");
    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    try std.testing.expectEqual(Kind.notice, p.kind);
    try std.testing.expect(l.showing());
    peer.close();

    var waited: usize = 0;
    while (waited < 3000 and l.showing()) : (waited += 25)
        std.Thread.sleep(25 * std.time.ns_per_ms);
    try std.testing.expect(!l.showing());
    // The doorbell rang, which is what makes the keyboard look.
    try std.testing.expect(counter.n.load(.acquire) >= 2);
    // Nothing was declined: the ssh behind that notifier is alive and its
    // dial is still going.
    try std.testing.expect(!l.declined(p.ssh_pid));
}

test "askpass.helperMain: a socket nobody is listening on is a refused prompt" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    const sock = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()});
    defer alloc.free(sock);
    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);
    const f = try std.fs.cwd().createFile(out_path, .{});
    defer f.close();
    try std.testing.expectEqual(@as(u8, 1), helperMain("password: ", sock, .secret, f.handle));
    const got = try helperStdout(alloc, out_path);
    defer alloc.free(got);
    try std.testing.expectEqualStrings("", got);
}

test "askpass: a server-authored prompt cannot move a cursor" {
    // The rule `handoff.Reason` states for ssh's stderr ("control bytes are
    // dropped, so nothing ssh says can move a cursor"), on the channel that
    // is not even ssh's own words: a keyboard-interactive prompt is the
    // SERVER's text, and it is painted inside a wall's alternate screen.
    var buf: [64]u8 = undefined;
    const n = foldControl(&buf, "\x1b[2J\x1b[1;1HEnter\x07 code:\x7f ");
    try std.testing.expectEqualStrings(" [2J [1;1HEnter  code:  ", buf[0..n]);
    // Every byte survives as SOMETHING: a prompt shortened by a filter is a
    // prompt the user reads half of.
    try std.testing.expectEqual("\x1b[2J\x1b[1;1HEnter\x07 code:\x7f ".len, n);
    // High bytes are not control bytes: a UTF-8 prompt paints.
    const m = foldControl(&buf, "clé:");
    try std.testing.expectEqualStrings("clé:", buf[0..m]);
}

test "askpass.Listener: control bytes a peer sends direct are folded too" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();
    // Not through `helperMain`: the socket is reachable by any same-uid
    // peer, and the end that paints is the end that must not trust.
    const stream = try std.net.connectUnixSocket(l.path);
    defer stream.close();
    // Hand-written wire: the tag byte, then the text. `q` is no tag ssh
    // has, so this also pins the unknown-tag default.
    _ = try stream.write("qboom\x1b[31m: \n");
    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    try std.testing.expectEqualStrings("boom [31m: ", p.slice());
    try std.testing.expectEqual(Kind.secret, p.kind);
    l.decline();
}

test "askpass.helperMain: a prompt's newlines fold, so one prompt is one line" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();

    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);
    var run: HelperRun = .{
        .out_path = out_path,
        // ssh's host-key question arrives with the fingerprint on its own
        // line on some builds. Split at the newline, the second half would
        // read as the ANSWER to the first.
        .prompt = "The authenticity of host 'box' can't be established.\nED25519 key fingerprint is SHA256:xyz.\nAre you sure you want to continue connecting (yes/no)? ",
        .sock = l.path,
    };
    const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});

    var p: Prompt = .{};
    try std.testing.expect(awaitPrompt(l, &p));
    try std.testing.expect(std.mem.indexOfScalar(u8, p.slice(), '\n') == null);
    try std.testing.expect(std.mem.endsWith(u8, p.slice(), "(yes/no)? "));
    l.answer("yes");
    th.join();
}

test "askpass.Kind: ssh's own variable says what it is asking for, and an unknown value is a secret" {
    // The exact signal, in place of matching `assword` against the text: a
    // keyboard-interactive prompt is the SERVER's wording, and a substring
    // rule paints its answer in the clear whenever the server spells the
    // question its own way.
    try std.testing.expectEqual(Kind.secret, Kind.of(null));
    try std.testing.expectEqual(Kind.confirm, Kind.of("confirm"));
    try std.testing.expectEqual(Kind.notice, Kind.of("none"));
    // A value neither of ssh's words: the mistake that hides an answer is
    // cheaper than the one that paints it.
    try std.testing.expectEqual(Kind.secret, Kind.of("something-openssh-adds-in-2027"));
}

test "askpass.Listener: the kind ssh named rides the wire, one byte ahead of the text" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};
    const l = try Listener.start(alloc, tmp.path(), counter.hooks());
    defer l.stop();
    const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
    defer alloc.free(out_path);

    for ([_]struct { k: Kind, text: []const u8 }{
        .{ .k = .secret, .text = "box's password: " },
        .{ .k = .confirm, .text = "Are you sure you want to continue connecting (yes/no)? " },
        .{ .k = .notice, .text = "Confirm user presence for key ED25519-SK SHA256:xyz" },
    }) |c| {
        var run: HelperRun = .{ .out_path = out_path, .prompt = c.text, .sock = l.path, .kind = c.k };
        const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
        var p: Prompt = .{};
        try std.testing.expect(awaitPrompt(l, &p));
        try std.testing.expectEqual(c.k, p.kind);
        // The tag is carriage, not content: the text arrives whole.
        try std.testing.expectEqualStrings(c.text, p.slice());
        l.answer("x");
        th.join();
    }
}

test "askpass.Listener: retire takes only its own socket, never a successor's at the same pid-named path" {
    const alloc = std.testing.allocator;
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var counter: Counter = .{};

    // Pid reuse, in one process: the name is `mux-ask-<pid>.sock` in the
    // runtime dir, so a second Listener here IS the client that got our pid
    // back. The first one's retire runs as the wall exits, after the second
    // has bound — and it must not take the name out from under it.
    const old_l = try Listener.start(alloc, tmp.path(), counter.hooks());
    const successor = try Listener.start(alloc, tmp.path(), counter.hooks());
    try std.testing.expectEqualStrings(old_l.path, successor.path);
    // Copied: `stop` frees the Listener and its path, and the last assertion
    // is about the name AFTER both are gone.
    const name = try alloc.dupe(u8, successor.path);
    defer alloc.free(name);

    old_l.retire();

    // Asked of the filesystem, and then of the socket: a file at the name is
    // not enough, because the point is that the ssh this successor spawned
    // can still reach the client that will answer it.
    const st = try std.posix.fstatat(std.posix.AT.FDCWD, successor.path, 0);
    try std.testing.expect(std.posix.S.ISSOCK(st.mode));
    const probe = try std.net.connectUnixSocket(successor.path);
    probe.close();

    // retire closes nothing, so the first Listener's own descriptors are
    // still ours to release — `stop` does the join and the closes.
    old_l.stop();
    successor.stop();
    try std.testing.expectError(
        error.FileNotFound,
        std.posix.fstatat(std.posix.AT.FDCWD, name, 0),
    );
}

test {
    std.testing.refAllDeclsRecursive(@This());
}

test "askpass.Listener: start reaps the socket a wall that died by signal left, not a live wall's" {
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    const dead = try testtmp.deadPid();
    var nb: [48]u8 = undefined;
    const left = try std.fmt.bufPrint(&nb, "mux-ask-{d}.sock", .{dead});
    try tmp.dir.writeFile(.{ .sub_path = left, .data = "" });
    try tmp.dir.writeFile(.{ .sub_path = "mux-ask-1.sock", .data = "" });

    var c = Counter{};
    const l = try Listener.start(std.testing.allocator, tmp.path(), c.hooks());
    defer l.stop();
    try std.testing.expectError(error.FileNotFound, tmp.dir.access(left, .{}));
    try tmp.dir.access("mux-ask-1.sock", .{});
}