a73x

a5fb22e2

feat: ssh's prompt is a line on a socket, and mux askpass carries it

a73x   2026-08-30 10:58

Commit message
feat: ssh's prompt is a line on a socket, and mux askpass carries it

ssh reads its password, passphrase and host-key questions from /dev/tty,
which under a wall's alternate screen is a read nobody can see and nobody
can answer. SSH_ASKPASS_REQUIRE=force turns each of them into an exec whose
stdout is the answer, so the prompt becomes bytes.

Both ends live here: a Listener the client owns (one socket, one accept
thread, one prompt at a time, serialized by construction rather than by a
rule) and the helperMain that `mux askpass` will run. Neither paints and
neither spawns, so the whole carriage is drivable by a test with no pty, no
ssh and no wall in the picture.

The reply carries a one-byte tag rather than the brief's empty-line
convention: an empty ANSWER is a real answer — a key whose passphrase is
empty, an Enter meant to be blank — so "declined" needed a spelling of its
own, and the test named for the passphrase case is what says so.

build.zig
Old New
@@ -178,6 +178,11 @@ const mod_table = [_]ModSpec{
178 // makePrivateParent only — the 0700-parent discipline the cache file 178 // makePrivateParent only — the 0700-parent discipline the cache file
179 // shares with the key file; xdg is a leaf, so no cycle. 179 // shares with the key file; xdg is a leaf, so no cycle.
180 .{ .name = "handoff", .path = "src/client/handoff.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} }, 180 .{ .name = "handoff", .path = "src/client/handoff.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} },
181 // Both ends of ssh's prompt carriage: the client's listening socket and
182 // the `mux askpass` helper ssh execs. No terminal and no spawn — the
183 // wall paints the popup and ssh does the exec — so the whole protocol
184 // is drivable by a test with neither in the picture.
185 .{ .name = "askpass", .path = "src/client/askpass.zig", .layer = 1, .test_imports = &.{"testtmp"} },
181 // Row-level change tracking behind the delta stream. Engine plus 186 // Row-level change tracking behind the delta stream. Engine plus
182 // protocol and nothing else — no daemon, no clients — so the tracker's 187 // protocol and nothing else — no daemon, no clients — so the tracker's
183 // own tests drive it with an engine and no socket in sight. 188 // own tests drive it with an engine and no socket in sight.
@@ -809,12 +814,12 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
809 /// they carry the argument parsers — a test that is never built is not a 814 /// they carry the argument parsers — a test that is never built is not a
810 /// test (decisions.md). 815 /// test (decisions.md).
811 const test_order = [_][]const u8{ 816 const test_order = [_][]const u8{
812 "script", "select", "protocol", "client_core", "interact", "engine", "pty", 817 "script", "select", "protocol", "client_core", "interact", "engine", "pty",
813 "delta", "cmd", "hosts", "upgrade", "shellint", "replica", "keymap", 818 "delta", "cmd", "hosts", "askpass", "upgrade", "shellint", "replica",
814 "webhub", "wallview", "sockpath", "agent_main", "server", "client", "proxy", 819 "keymap", "webhub", "wallview", "sockpath", "agent_main", "server", "client",
815 "client_main", "quic", "quic_server", "daemon_main", "testtmp", "predict", "rawmode", 820 "proxy", "client_main", "quic", "quic_server", "daemon_main", "testtmp", "predict",
816 "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", "render", 821 "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout",
817 "ptyclient", "hub_main", "wsclient", "cliflags", "mux", 822 "render", "ptyclient", "hub_main", "wsclient", "cliflags", "mux",
818 }; 823 };
819 824
820 comptime { 825 comptime {
docscheck.budget
Old New
@@ -1,6 +1,7 @@
1 client_core_wasm_check.zig 0 1 askpass.zig 0
2 client_core.zig 0
3 client.zig 0 2 client.zig 0
3 client_core.zig 0
4 client_core_wasm_check.zig 0
4 cmd.zig 0 5 cmd.zig 0
5 delta.zig 0 6 delta.zig 0
6 docscheck.zig 0 7 docscheck.zig 0
@@ -12,49 +13,49 @@ interact.zig 0
12 keymap.zig 0 13 keymap.zig 0
13 layout.zig 761 14 layout.zig 761
14 main.zig 0 15 main.zig 0
15 muxa.zig 0 16 mux.zig 0
16 mux_main.zig 0 17 mux_main.zig 0
18 muxa.zig 0
17 paint.zig 0 19 paint.zig 0
18 predict.zig 0 20 predict.zig 0
19 protocol.zig 0 21 protocol.zig 0
20 proxy.zig 0 22 proxy.zig 0
21 pty.zig 0 23 pty.zig 0
24 quic.zig 0
22 quic_client.zig 0 25 quic_client.zig 0
23 quic_server.zig 0 26 quic_server.zig 0
24 quic.zig 0
25 replica.zig 0 27 replica.zig 0
26 select.zig 0 28 select.zig 0
27 server.zig 0 29 server.zig 0
28 shellint.zig 0 30 server_agent.zig 0
29 sockpath.zig 0 31 server_sessions.zig 0
30 spawn.zig 0 32 server_test_agent.zig 0
31 testtmp.zig 0
32 upgrade.zig 269
33 wallview.zig 0
34 wasm_core.zig 0
35 webhub_main.zig 0
36 webhub.zig 0
37 xdg.zig 0
38 server_test_harness.zig 0
39 server_test_attach.zig 0 33 server_test_attach.zig 0
34 server_test_await.zig 0
35 server_test_clipboard.zig 0
40 server_test_deliver.zig 0 36 server_test_deliver.zig 0
37 server_test_harness.zig 0
41 server_test_modes.zig 0 38 server_test_modes.zig 0
42 server_test_session.zig 0
43 server_test_quic.zig 0 39 server_test_quic.zig 0
44 server_test_await.zig 0 40 server_test_session.zig 0
45 server_test_clipboard.zig 0
46 server_test_agent.zig 0
47 server_test_upgrade.zig 0 41 server_test_upgrade.zig 0
48 server_agent.zig 0 42 shellint.zig 0
49 server_sessions.zig 0 43 sockpath.zig 0
44 spawn.zig 0
45 testtmp.zig 0
46 upgrade.zig 269
50 wall_host.zig 0 47 wall_host.zig 0
48 wall_layout.zig 1432
51 wall_picker.zig 0 49 wall_picker.zig 0
52 wall_pump.zig 0 50 wall_pump.zig 0
53 wall_layout.zig 1432
54 wall_test_harness.zig 0 51 wall_test_harness.zig 0
55 wall_test_host.zig 0 52 wall_test_host.zig 0
53 wall_test_layout.zig 0
56 wall_test_picker.zig 0 54 wall_test_picker.zig 0
57 wall_test_pump.zig 0 55 wall_test_pump.zig 0
58 wall_test_layout.zig 0
59 wall_test_wall.zig 0 56 wall_test_wall.zig 0
60 mux.zig 0 57 wallview.zig 0
58 wasm_core.zig 0
59 webhub.zig 0
60 webhub_main.zig 0
61 xdg.zig 0
src/client/askpass.zig
Old New
@@ -0,0 +1,1018 @@
1 //! ssh's prompts, and the one place mux can answer them.
2 //!
3 //! ssh reads passwords, passphrases and the host-key question from
4 //! `/dev/tty` — a read no fd mux sets can reach, and under a wall's
5 //! alternate screen a read nobody can even see. `SSH_ASKPASS_REQUIRE=force`
6 //! turns each of those reads into an exec of `$SSH_ASKPASS "<prompt>"`
7 //! whose stdout is the answer, so the prompt becomes BYTES, and bytes
8 //! travel. This module is both ends of that trip: `Listener` is the
9 //! client's, one socket per client process, and `helperMain` is what
10 //! `mux askpass` runs.
11 //!
12 //! Nothing here paints and nothing here spawns: the wall owns the popup and
13 //! ssh owns the exec, which is what lets the whole carriage be driven by a
14 //! test with no pty, no ssh and no wall in the picture.
15 const std = @import("std");
16
17 /// Env var naming the socket. The mode word for the helper, too: ssh execs
18 /// its helper with the prompt as argv[1] and nothing else, so there is no
19 /// word to give it, and this variable exists in exactly one process tree —
20 /// the ssh a wall dial spawned.
21 pub const sock_env = "MUX_ASKPASS_SOCK";
22
23 /// ssh's longest ordinary question is the host-key one, ~220 bytes with a
24 /// fingerprint in it. Cut, never refused: a prompt the user cannot read
25 /// whole is still a prompt they can answer.
26 pub const prompt_max = 512;
27
28 /// A passphrase, not a document.
29 pub const answer_max = 256;
30
31 /// How many declined dials are remembered. A pid the ring has evicted reads
32 /// `declined` false, which costs one more prompt — the failure a caller
33 /// cannot see is a tile parked forever, so the ring errs the other way.
34 pub const decline_ring = 8;
35
36 /// How many helpers may be waiting to be served. A separate number from
37 /// the ring above, which it briefly shared by accident: one counts refused
38 /// dials, this one counts sshs queued behind a popup.
39 const backlog = 8;
40
41 /// The reply's first byte. An EMPTY answer is a real answer — a key whose
42 /// passphrase is empty, an Enter on a question the user means to leave
43 /// blank — so "declined" cannot be spelled by an empty line, which is the
44 /// whole reason there is a tag at all.
45 const reply_answer = '+';
46 const reply_decline = '-';
47
48 /// What OpenSSH 8.4+ puts in the helper's environment to say what it is
49 /// asking for. An exact signal, which is why there is no guessing here:
50 /// matching `assword` against the text would call a server-authored
51 /// keyboard-interactive prompt a secret only when the server happened to
52 /// spell it that way, and paint the answer in the clear when it did not.
53 pub const prompt_env = "SSH_ASKPASS_PROMPT";
54
55 /// The three things ssh can want, and the one byte the wire carries to say
56 /// which. A value that is neither of ssh's two words is a SECRET: the
57 /// mistake that hides an answer is cheaper than the one that paints it.
58 pub const Kind = enum(u8) {
59 secret = 's',
60 /// The host-key question and every other yes/no. Painted in the clear,
61 /// because the user is comparing a fingerprint.
62 confirm = 'c',
63 /// Not a question: "Confirm user presence for key ..." — the FIDO touch
64 /// notifier. ssh SIGTERMs the helper when the touch lands, so this box
65 /// takes no answer and closes when the peer hangs up.
66 notice = 'n',
67
68 pub fn of(env: ?[]const u8) Kind {
69 const v = env orelse return .secret;
70 if (std.mem.eql(u8, v, "confirm")) return .confirm;
71 if (std.mem.eql(u8, v, "none")) return .notice;
72 return .secret;
73 }
74
75 fn ofTag(b: u8) Kind {
76 return switch (b) {
77 @intFromEnum(Kind.confirm) => .confirm,
78 @intFromEnum(Kind.notice) => .notice,
79 else => .secret,
80 };
81 }
82 };
83
84 /// How long `serve` gives a peer to finish its request line, and the slice
85 /// it re-checks the peer on while the box is up. A connected peer that
86 /// never speaks would otherwise park the accept thread for the wall's life,
87 /// and every later prompt behind it.
88 const request_ms_default: i32 = 5000;
89 const watch_ns: u64 = 200 * std.time.ns_per_ms;
90
91 /// One pending prompt, attributed to the ssh child that raised it.
92 pub const Prompt = struct {
93 text: [prompt_max]u8 = undefined,
94 text_len: usize = 0,
95 kind: Kind = .secret,
96 /// pid of the ssh whose helper connected: `SO_PEERCRED` names the
97 /// helper, and the helper's parent IS ssh, which execs it directly.
98 /// 0 when the walk failed — attribution is a convenience, never a
99 /// condition for answering.
100 ssh_pid: std.posix.pid_t = 0,
101
102 pub fn slice(self: *const Prompt) []const u8 {
103 return self.text[0..self.text_len];
104 }
105 };
106
107 /// The doorbell a prompt rings: `SessionPoll.Hooks`, for its reason.
108 pub const Hooks = struct {
109 ctx: *anyopaque,
110 wake: *const fn (*anyopaque) void,
111 };
112
113 /// The wall's end: one socket, one accept thread, one prompt at a time.
114 ///
115 /// Serialized BY CONSTRUCTION rather than by a rule — the accept thread
116 /// serves a connection to completion before it accepts the next, so a
117 /// second ssh asking while a popup is up waits in the listen backlog. That
118 /// is also the whole of the fairness policy: accept order.
119 pub const Listener = struct {
120 /// The state one prompt moves through. `shown` exists so a doorbell the
121 /// keyboard rings twice opens one popup: `take` is the transition, not
122 /// a read.
123 const Phase = enum { idle, pending, shown, done };
124
125 alloc: std.mem.Allocator,
126 path: []const u8,
127 fd: std.posix.socket_t,
128 /// How `stop` reaches a thread parked in `poll`. Closing the listening
129 /// fd under an accept is not defined to wake it; a byte here is.
130 stop_r: std.posix.fd_t,
131 stop_w: std.posix.fd_t,
132 thread: ?std.Thread = null,
133 wake: Hooks,
134
135 mu: std.Thread.Mutex = .{},
136 cv: std.Thread.Condition = .{},
137 running: bool = true,
138 phase: Phase = .idle,
139 prompt: Prompt = .{},
140 answer_buf: [answer_max]u8 = undefined,
141 answer_len: usize = 0,
142 was_declined: bool = false,
143 ring: [decline_ring]std.posix.pid_t = @splat(0),
144 ring_at: usize = 0,
145 /// A field rather than the constant, `HandoffTarget.deadline_ms`'s
146 /// shape: a test that has to prove the deadline exists must not spend
147 /// it.
148 request_ms: i32 = request_ms_default,
149
150 /// `runtime_dir` is where the socket goes; the pid in the name is what
151 /// makes it this client's and not another's.
152 pub fn start(alloc: std.mem.Allocator, runtime_dir: []const u8, wake: Hooks) !*Listener {
153 const path = try std.fmt.allocPrint(
154 alloc,
155 "{s}/mux-ask-{d}.sock",
156 .{ runtime_dir, std.os.linux.getpid() },
157 );
158 errdefer alloc.free(path);
159 // A client that died without unlinking left a file, and a pid comes
160 // round again. Nothing else may own this name: it has our pid in it.
161 std.fs.cwd().deleteFile(path) catch {};
162 const addr = try std.net.Address.initUnix(path);
163 const fd = try std.posix.socket(
164 std.posix.AF.UNIX,
165 std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC,
166 0,
167 );
168 errdefer std.posix.close(fd);
169 try std.posix.bind(fd, &addr.any, addr.getOsSockLen());
170 try std.posix.listen(fd, backlog);
171 const bell = try std.posix.pipe2(.{ .CLOEXEC = true });
172 errdefer {
173 std.posix.close(bell[0]);
174 std.posix.close(bell[1]);
175 }
176 const self = try alloc.create(Listener);
177 errdefer alloc.destroy(self);
178 self.* = .{
179 .alloc = alloc,
180 .path = path,
181 .fd = fd,
182 .stop_r = bell[0],
183 .stop_w = bell[1],
184 .wake = wake,
185 };
186 self.thread = try std.Thread.spawn(.{}, acceptLoop, .{self});
187 return self;
188 }
189
190 /// Unlinks and joins. A helper still waiting is answered with a decline,
191 /// because an ssh blocked on a socket nobody will ever read is a dial
192 /// that never ends.
193 pub fn stop(self: *Listener) void {
194 self.retire();
195 if (self.thread) |t| t.join();
196 std.posix.close(self.fd);
197 std.posix.close(self.stop_r);
198 std.posix.close(self.stop_w);
199 self.alloc.free(self.path);
200 self.alloc.destroy(self);
201 }
202
203 /// The half of `stop` a process about to `exit` may run.
204 pub fn retire(self: *Listener) void {
205 // The name leaves the filesystem and a waiting helper is declined;
206 // nothing is joined, closed or freed. Split because the wall ends
207 // in `std.posix.exit` with detached pump threads still live, one of
208 // which may be inside `declined` on this very object — a free in
209 // that window is a use-after-free, while a socket left on disk is a
210 // file the next client of this pid finds on its own name.
211 self.mu.lock();
212 self.running = false;
213 self.cv.broadcast();
214 self.mu.unlock();
215 _ = std.posix.write(self.stop_w, "x") catch {};
216 std.fs.cwd().deleteFile(self.path) catch {};
217 }
218
219 /// The keyboard's side. Copies the pending prompt out and marks it
220 /// shown; false when there is nothing waiting.
221 pub fn take(self: *Listener, out: *Prompt) bool {
222 self.mu.lock();
223 defer self.mu.unlock();
224 if (self.phase != .pending) return false;
225 out.* = self.prompt;
226 self.phase = .shown;
227 return true;
228 }
229
230 pub fn answer(self: *Listener, text: []const u8) void {
231 self.finish(text, false);
232 }
233
234 pub fn decline(self: *Listener) void {
235 self.finish("", true);
236 }
237
238 fn finish(self: *Listener, text: []const u8, declining: bool) void {
239 self.mu.lock();
240 defer self.mu.unlock();
241 // Not `.pending`: an answer for a prompt nobody showed is a driver
242 // bug, and unblocking the helper with it would hide the bug behind
243 // a working login.
244 if (self.phase != .shown) return;
245 const n = @min(text.len, answer_max);
246 @memcpy(self.answer_buf[0..n], text[0..n]);
247 self.answer_len = n;
248 self.was_declined = declining;
249 // pid 0 is the failed walk, and remembering it would park every
250 // tile whose attribution failed.
251 if (declining and self.prompt.ssh_pid != 0) {
252 self.ring[self.ring_at % decline_ring] = self.prompt.ssh_pid;
253 self.ring_at += 1;
254 // The dial ends HERE, before the helper is released, because a
255 // refused askpass is not a refused login to OpenSSH: a helper
256 // that exits non-zero on a password or passphrase prompt is
257 // read as the EMPTY password (`read_passphrase`, flags 0), ssh
258 // tries it, the server says no, and ssh asks again up to
259 // `NumberOfPasswordPrompts`. Measured against this box's sshd:
260 // one Esc, three prompts. Killing the ssh this prompt belongs
261 // to — our own child, by `dialOwner` — makes one Esc one
262 // refusal, which is what the wall says it does.
263 std.posix.kill(self.prompt.ssh_pid, std.posix.SIG.TERM) catch {};
264 }
265 self.phase = .done;
266 self.cv.signal();
267 }
268
269 /// The pump's "stop redialing" test: a dial whose prompt the user
270 /// refused must not come straight back with the same question.
271 pub fn declined(self: *Listener, ssh_pid: std.posix.pid_t) bool {
272 if (ssh_pid == 0) return false;
273 self.mu.lock();
274 defer self.mu.unlock();
275 for (self.ring) |p| if (p == ssh_pid) return true;
276 return false;
277 }
278
279 fn acceptLoop(self: *Listener) void {
280 while (true) {
281 var pfds = [_]std.posix.pollfd{
282 .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 },
283 .{ .fd = self.stop_r, .events = std.posix.POLL.IN, .revents = 0 },
284 };
285 _ = std.posix.poll(&pfds, -1) catch return;
286 if (pfds[1].revents != 0) return;
287 if (pfds[0].revents & std.posix.POLL.IN == 0) continue;
288 const c = std.posix.accept(self.fd, null, null, std.posix.SOCK.CLOEXEC) catch continue;
289 self.serve(c);
290 std.posix.close(c);
291 self.mu.lock();
292 const go = self.running;
293 self.mu.unlock();
294 if (!go) return;
295 }
296 }
297
298 fn serve(self: *Listener, c: std.posix.socket_t) void {
299 const cred = peerCred(c) orelse return;
300 // The 0700 runtime directory is the boundary, and mux takes
301 // `$XDG_RUNTIME_DIR` as found rather than verifying it. On a box
302 // where that directory is not private, this line is what stops
303 // another local user raising a prompt on this wall and reading the
304 // answer the user types into it.
305 if (cred.uid != std.os.linux.geteuid()) return;
306 var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, std.os.linux.getpid(), parentOf) };
307 var raw: [prompt_max + 1]u8 = undefined;
308 // Bounded, because an accept thread parked in `read` is every later
309 // prompt of this wall parked behind it — and `stop`'s join with it.
310 const got = readLine(c, &raw, self.request_ms) orelse return;
311 if (got == 0) return;
312 p.kind = Kind.ofTag(raw[0]);
313 // Again on this side: the helper folds before it sends, and this is
314 // the end that paints. See `foldControl`.
315 p.text_len = foldControl(&p.text, raw[1..got]);
316 self.mu.lock();
317 if (!self.running) {
318 self.mu.unlock();
319 return;
320 }
321 self.prompt = p;
322 self.phase = .pending;
323 self.mu.unlock();
324 self.wake.wake(self.wake.ctx);
325 const abandoned = self.awaitAnswer(c);
326
327 var reply: [answer_max + 2]u8 = undefined;
328 var len: usize = 0;
329 self.mu.lock();
330 // A stop mid-prompt declines: the popup is gone with the wall that
331 // painted it, and ssh is owed an answer either way. A peer that has
332 // gone gets nothing — there is nobody to answer.
333 const refused = self.was_declined or self.phase != .done;
334 reply[0] = if (refused) reply_decline else reply_answer;
335 if (!refused) {
336 @memcpy(reply[1 .. 1 + self.answer_len], self.answer_buf[0..self.answer_len]);
337 len = self.answer_len;
338 }
339 // The wall's copy dies with the prompt it answered: this buffer
340 // outlives the box on screen, and a core dump is a file.
341 @memset(&self.answer_buf, 0);
342 self.answer_len = 0;
343 self.phase = .idle;
344 self.mu.unlock();
345 reply[1 + len] = '\n';
346 if (!abandoned) _ = writeAll(c, reply[0 .. len + 2]);
347 @memset(&reply, 0);
348 }
349
350 /// Blocks until the keyboard answers, the wall stops, or the PEER goes.
351 /// True when it was the peer: ssh SIGTERMs its notifier helper the
352 /// moment the touch lands, and a box left standing after ssh has moved
353 /// on is one the user has to dismiss for no reason — and dismissing it
354 /// would record a live dial's pid as declined.
355 fn awaitAnswer(self: *Listener, c: std.posix.socket_t) bool {
356 while (true) {
357 self.mu.lock();
358 const open = self.running and (self.phase == .pending or self.phase == .shown);
359 if (!open) {
360 self.mu.unlock();
361 return false;
362 }
363 self.cv.timedWait(&self.mu, watch_ns) catch {};
364 const still = self.running and (self.phase == .pending or self.phase == .shown);
365 self.mu.unlock();
366 if (!still) return false;
367 if (!peerGone(c)) continue;
368 self.mu.lock();
369 if (self.phase == .pending or self.phase == .shown) self.phase = .idle;
370 self.mu.unlock();
371 // The box on screen is showing a question nobody is waiting on.
372 self.wake.wake(self.wake.ctx);
373 return true;
374 }
375 }
376
377 /// Whether the box on screen still has an ssh behind it. The keyboard
378 /// asks every pass: `serve` sets the phase back to idle on a hangup,
379 /// and the popup has to follow.
380 pub fn showing(self: *Listener) bool {
381 self.mu.lock();
382 defer self.mu.unlock();
383 return self.phase == .pending or self.phase == .shown;
384 }
385 };
386
387 /// The helper's end: `mux askpass`. One line out, one line back, and the
388 /// answer on `out` — which is ssh's own stdin-side pipe, so a byte written
389 /// here that is not the answer is a byte ssh tries to log in with.
390 ///
391 /// Every failure is exit 1 with NOTHING written: ssh reads a non-zero exit
392 /// as a refused prompt, which is the only honest report of a wall that
393 /// never answered.
394 pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 {
395 const stream = std.net.connectUnixSocket(sock) catch return 1;
396 defer stream.close();
397 var line: [prompt_max + 2]u8 = undefined;
398 line[0] = @intFromEnum(kind);
399 // The wire is one line each way, and ssh's own prompts are multi-line
400 // on some builds (the host-key question). Folded, not split: the popup
401 // wraps it to the width anyway — and the same fold is what stops a
402 // server-authored prompt carrying an escape sequence onto the screen.
403 const n = foldControl(line[1 .. prompt_max + 1], prompt);
404 line[n + 1] = '\n';
405 if (!writeAll(stream.handle, line[0 .. n + 2])) return 1;
406 var reply: [answer_max + 2]u8 = undefined;
407 // No deadline here, unlike the listener's read: this wait IS the user
408 // reading the question, and ssh is content to wait on its helper.
409 const got = readLine(stream.handle, &reply, -1) orelse return 1;
410 if (got == 0 or reply[0] != reply_answer) return 1;
411 const wrote = writeAll(out_fd, reply[1..got]) and writeAll(out_fd, "\n");
412 // The helper is a whole process holding one password. It exits in a
413 // microsecond, but zeroing is one line and a core dump is a file.
414 @memset(&reply, 0);
415 return if (wrote) 0 else 1;
416 }
417
418 /// One prompt's bytes, made safe to paint: every control byte becomes a
419 /// space. Returns how many were written.
420 ///
421 /// The rule `handoff.Reason` already states for ssh's stderr, applied to
422 /// the other channel ssh has: a prompt is painted INSIDE a wall's
423 /// alternate screen with the box's own attribute, so an escape sequence in
424 /// one moves a cursor, sets a mode or fakes a row in somebody's tile. And
425 /// the text is not always ssh's: a keyboard-interactive prompt is the
426 /// SERVER's wording handed to the helper as argv[1], which OpenSSH does
427 /// not sanitize on this path.
428 ///
429 /// Run on BOTH ends — the helper before it sends, the listener on what it
430 /// received — because the wire is a socket any same-uid peer can reach,
431 /// and the end that paints is the end that must not trust.
432 pub fn foldControl(dst: []u8, src: []const u8) usize {
433 const n = @min(src.len, dst.len);
434 for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch;
435 return n;
436 }
437
438 /// Bytes up to the first '\n', which is dropped; anything past `buf.len` is
439 /// dropped too. Null is an EOF with nothing at all — a peer that hung up
440 /// before saying anything, which is not a prompt and not an answer.
441 fn readLine(fd: std.posix.fd_t, buf: []u8, timeout_ms: i32) ?usize {
442 var len: usize = 0;
443 var seen = false;
444 while (true) {
445 if (timeout_ms >= 0) {
446 var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
447 const ready = std.posix.poll(&pfd, timeout_ms) catch return null;
448 if (ready == 0) return null;
449 }
450 var one: [1]u8 = undefined;
451 const n = std.posix.read(fd, &one) catch return null;
452 if (n == 0) return if (seen) len else null;
453 seen = true;
454 if (one[0] == '\n') return len;
455 if (len < buf.len) {
456 buf[len] = one[0];
457 len += 1;
458 }
459 }
460 }
461
462 /// Whether the peer has closed its end. A zero-length PEEK is the only
463 /// answer that means it: `POLL.IN` also fires on bytes we never asked for.
464 fn peerGone(c: std.posix.socket_t) bool {
465 var pfd = [_]std.posix.pollfd{.{ .fd = c, .events = std.posix.POLL.IN, .revents = 0 }};
466 const ready = std.posix.poll(&pfd, 0) catch return true;
467 if (ready == 0) return false;
468 if (pfd[0].revents & (std.posix.POLL.ERR | std.posix.POLL.NVAL) != 0) return true;
469 var b: [1]u8 = undefined;
470 const n = std.posix.recv(c, &b, std.posix.MSG.PEEK) catch return true;
471 return n == 0;
472 }
473
474 fn writeAll(fd: std.posix.fd_t, bytes: []const u8) bool {
475 var off: usize = 0;
476 while (off < bytes.len) {
477 const n = std.posix.write(fd, bytes[off..]) catch return false;
478 if (n == 0) return false;
479 off += n;
480 }
481 return true;
482 }
483
484 const Ucred = extern struct {
485 pid: std.posix.pid_t,
486 uid: std.posix.uid_t,
487 gid: std.posix.gid_t,
488 };
489
490 /// How far up the tree the walk goes. ssh execs its helper directly, so
491 /// production is one step; the slack is for a shell in between.
492 const ancestor_max = 8;
493
494 /// Who is on the other end, or null when the kernel will not say.
495 fn peerCred(c: std.posix.socket_t) ?Ucred {
496 var buf: [@sizeOf(Ucred)]u8 = undefined;
497 std.posix.getsockopt(c, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, &buf) catch return null;
498 return std.mem.bytesToValue(Ucred, &buf);
499 }
500
501 /// The ssh THIS process spawned that is behind `peer`: the ancestor whose
502 /// parent is us.
503 fn dialOwner(
504 peer: std.posix.pid_t,
505 me: std.posix.pid_t,
506 parent: *const fn (std.posix.pid_t) std.posix.pid_t,
507 ) std.posix.pid_t {
508 // Not simply the helper's parent, which is what ssh execing its helper
509 // directly makes it. A shell can sit in between — a test's stand-in
510 // does, and so does a real ssh whose ProxyCommand is one — and an
511 // attribution that breaks on one extra fork returns 0 exactly when a
512 // wall needs a name. What is INVARIANT is the other end: the ssh a
513 // dial spawned is a child of this process, and nothing else on the
514 // path is.
515 var at = peer;
516 var steps: usize = 0;
517 while (at > 0 and steps < ancestor_max) : (steps += 1) {
518 const up = parent(at);
519 if (up == me) return at;
520 at = up;
521 }
522 return 0;
523 }
524
525 /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by
526 /// counting spaces: field 2 is the executable's name, unquoted, and a
527 /// program free to call itself `a b) c` is a program free to move every
528 /// field after it.
529 fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
530 if (pid <= 0) return 0;
531 var path_buf: [64]u8 = undefined;
532 const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return 0;
533 var stat_buf: [512]u8 = undefined;
534 const f = std.fs.cwd().openFile(path, .{}) catch return 0;
535 defer f.close();
536 const n = f.read(&stat_buf) catch return 0;
537 const text = stat_buf[0..n];
538 const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return 0;
539 var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' ');
540 _ = it.next() orelse return 0; // the run state
541 const ppid = it.next() orelse return 0;
542 return std.fmt.parseInt(std.posix.pid_t, ppid, 10) catch 0;
543 }
544
545 // ---- tests ----
546
547 const testtmp = @import("testtmp");
548
549 /// A wake that counts, so a test can assert the doorbell rang at all.
550 const Counter = struct {
551 n: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
552 fn bump(ctx: *anyopaque) void {
553 const self: *Counter = @ptrCast(@alignCast(ctx));
554 _ = self.n.fetchAdd(1, .release);
555 }
556 fn hooks(self: *Counter) Hooks {
557 return .{ .ctx = self, .wake = bump };
558 }
559 };
560
561 /// Waits for the accept thread to park a prompt, so no test spins on a
562 /// race it cannot see. Returns false on the 2 s budget.
563 fn awaitPrompt(l: *Listener, out: *Prompt) bool {
564 var waited: usize = 0;
565 while (waited < 2000) : (waited += 5) {
566 if (l.take(out)) return true;
567 std.Thread.sleep(5 * std.time.ns_per_ms);
568 }
569 return false;
570 }
571
572 const HelperRun = struct {
573 code: u8 = 0,
574 out_path: []const u8,
575 prompt: []const u8,
576 sock: []const u8,
577 kind: Kind = .secret,
578 fn go(self: *HelperRun) void {
579 const f = std.fs.cwd().createFile(self.out_path, .{}) catch return;
580 defer f.close();
581 self.code = helperMain(self.prompt, self.sock, self.kind, f.handle);
582 }
583 };
584
585 fn helperStdout(alloc: std.mem.Allocator, path: []const u8) ![]u8 {
586 return std.fs.cwd().readFileAlloc(alloc, path, 4096);
587 }
588
589 test "askpass.Listener: a helper's line reaches take, and answer reaches the helper" {
590 const alloc = std.testing.allocator;
591 var tmp = try testtmp.TmpDir.make();
592 defer tmp.cleanup();
593 var counter: Counter = .{};
594 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
595 defer l.stop();
596
597 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
598 defer alloc.free(out_path);
599 var run: HelperRun = .{
600 .out_path = out_path,
601 .prompt = "box's password: ",
602 .sock = l.path,
603 };
604 const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
605
606 var p: Prompt = .{};
607 try std.testing.expect(awaitPrompt(l, &p));
608 try std.testing.expectEqualStrings("box's password: ", p.slice());
609 l.answer("s3cret");
610 th.join();
611
612 const got = try helperStdout(alloc, out_path);
613 defer alloc.free(got);
614 try std.testing.expectEqualStrings("s3cret\n", got);
615 try std.testing.expectEqual(@as(u8, 0), run.code);
616 try std.testing.expect(counter.n.load(.acquire) >= 1);
617 }
618
619 test "askpass.Listener: decline gives the helper exit 1 and no stdout" {
620 const alloc = std.testing.allocator;
621 var tmp = try testtmp.TmpDir.make();
622 defer tmp.cleanup();
623 var counter: Counter = .{};
624 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
625 defer l.stop();
626
627 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
628 defer alloc.free(out_path);
629 var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path };
630 const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
631
632 var p: Prompt = .{};
633 try std.testing.expect(awaitPrompt(l, &p));
634 l.decline();
635 th.join();
636
637 const got = try helperStdout(alloc, out_path);
638 defer alloc.free(got);
639 // ssh logs in with whatever this fd carried, so "nothing" is the claim,
640 // not "something short".
641 try std.testing.expectEqualStrings("", got);
642 try std.testing.expectEqual(@as(u8, 1), run.code);
643 }
644
645 test "askpass.Listener: an EMPTY answer is an answer, not a decline" {
646 const alloc = std.testing.allocator;
647 var tmp = try testtmp.TmpDir.make();
648 defer tmp.cleanup();
649 var counter: Counter = .{};
650 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
651 defer l.stop();
652
653 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
654 defer alloc.free(out_path);
655 var run: HelperRun = .{ .out_path = out_path, .prompt = "passphrase: ", .sock = l.path };
656 const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
657
658 var p: Prompt = .{};
659 try std.testing.expect(awaitPrompt(l, &p));
660 // Enter on an empty line. A key with no passphrase asks anyway, and the
661 // user who answers nothing has answered.
662 l.answer("");
663 th.join();
664
665 const got = try helperStdout(alloc, out_path);
666 defer alloc.free(got);
667 try std.testing.expectEqualStrings("\n", got);
668 try std.testing.expectEqual(@as(u8, 0), run.code);
669 }
670
671 test "askpass.Listener: two helpers are served one at a time, in accept order" {
672 const alloc = std.testing.allocator;
673 var tmp = try testtmp.TmpDir.make();
674 defer tmp.cleanup();
675 var counter: Counter = .{};
676 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
677 defer l.stop();
678
679 const out_a = try std.fmt.allocPrint(alloc, "{s}/a", .{tmp.path()});
680 defer alloc.free(out_a);
681 var first: HelperRun = .{ .out_path = out_a, .prompt = "first: ", .sock = l.path };
682 const th_a = try std.Thread.spawn(.{}, HelperRun.go, .{&first});
683
684 var p: Prompt = .{};
685 try std.testing.expect(awaitPrompt(l, &p));
686 try std.testing.expectEqualStrings("first: ", p.slice());
687
688 const out_b = try std.fmt.allocPrint(alloc, "{s}/b", .{tmp.path()});
689 defer alloc.free(out_b);
690 var second: HelperRun = .{ .out_path = out_b, .prompt = "second: ", .sock = l.path };
691 const th_b = try std.Thread.spawn(.{}, HelperRun.go, .{&second});
692
693 // The second helper is connected and waiting in the backlog, and the
694 // popup on screen is still the first one's: a second `take` here would
695 // be a popup that changed its question under the user's fingers.
696 std.Thread.sleep(100 * std.time.ns_per_ms);
697 var q: Prompt = .{};
698 try std.testing.expect(!l.take(&q));
699
700 l.answer("one");
701 th_a.join();
702 try std.testing.expect(awaitPrompt(l, &q));
703 try std.testing.expectEqualStrings("second: ", q.slice());
704 l.answer("two");
705 th_b.join();
706
707 const a = try helperStdout(alloc, out_a);
708 defer alloc.free(a);
709 const b = try helperStdout(alloc, out_b);
710 defer alloc.free(b);
711 try std.testing.expectEqualStrings("one\n", a);
712 try std.testing.expectEqualStrings("two\n", b);
713 }
714
715 /// A process tree as a table, so the walk can be driven over shapes no
716 /// test could arrange with real forks.
717 const FakeTree = struct {
718 // helper 100 <- sh 99 <- sh 98 <- ssh 97 <- us 7 <- init 1
719 const rows = [_][2]std.posix.pid_t{
720 .{ 100, 99 }, .{ 99, 98 }, .{ 98, 97 }, .{ 97, 7 }, .{ 7, 1 }, .{ 1, 0 },
721 };
722 fn parent(pid: std.posix.pid_t) std.posix.pid_t {
723 for (rows) |r| if (r[0] == pid) return r[1];
724 return 0;
725 }
726 // A cycle no real tree has, for the bound below.
727 fn ring(pid: std.posix.pid_t) std.posix.pid_t {
728 return if (pid == 1) 2 else 1;
729 }
730 };
731
732 test "askpass: a helper two shells below the ssh we spawned is still that ssh's" {
733 // ssh execs its helper directly, so production is one step. A stand-in
734 // — and a real ssh whose ProxyCommand is a shell — puts more in
735 // between, and an attribution that breaks on one extra fork answers 0
736 // exactly where a wall needs a name. The invariant is the far end: the
737 // ssh a dial spawned is OUR child, and nothing else on the path is.
738 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent));
739 // The direct case, unchanged.
740 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent));
741 // A peer that is not ours at all: someone else's process on the same
742 // box. Answering a pid here would park a tile on a stranger's decline.
743 try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(100, 4242, FakeTree.parent));
744 // A tree that loops must end the walk rather than the process.
745 try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring));
746 }
747
748 test "askpass.parentOf: the field it reads is the one the OS calls ppid" {
749 // The reader, against the OS itself rather than against a fixture —
750 // `/proc/<pid>/stat` field 4 is positional, and a comment claiming
751 // which field that is cannot fail.
752 try std.testing.expectEqual(std.os.linux.getppid(), parentOf(std.os.linux.getpid()));
753 try std.testing.expectEqual(@as(std.posix.pid_t, 0), parentOf(0));
754 }
755
756 test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" {
757 const alloc = std.testing.allocator;
758 var tmp = try testtmp.TmpDir.make();
759 defer tmp.cleanup();
760 var counter: Counter = .{};
761 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
762 defer l.stop();
763
764 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
765 defer alloc.free(out_path);
766 var run: HelperRun = .{ .out_path = out_path, .prompt = "who: ", .sock = l.path };
767 const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
768
769 var p: Prompt = .{};
770 try std.testing.expect(awaitPrompt(l, &p));
771 // The helper here is a THREAD, so the peer IS this process and no
772 // ancestor of it is our child. 0, and the prompt is still answered:
773 // attribution is a convenience, never a condition for answering.
774 try std.testing.expectEqual(@as(std.posix.pid_t, 0), p.ssh_pid);
775 l.answer("x");
776 th.join();
777 const got = try helperStdout(alloc, out_path);
778 defer alloc.free(got);
779 try std.testing.expectEqualStrings("x\n", got);
780 }
781
782 test "askpass.Listener: a decline ends the dial's ssh, it does not just refuse the prompt" {
783 const alloc = std.testing.allocator;
784 var tmp = try testtmp.TmpDir.make();
785 defer tmp.cleanup();
786 var counter: Counter = .{};
787 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
788 defer l.stop();
789
790 // A live process standing in for the dial's ssh, because the claim is
791 // about the OS and not about a flag: an Esc that only refused the
792 // prompt would leave OpenSSH trying the empty password and asking
793 // again, three times against a real sshd.
794 var ssh = std.process.Child.init(&.{ "/bin/sleep", "30" }, alloc);
795 try ssh.spawn();
796 l.mu.lock();
797 l.prompt.ssh_pid = ssh.id;
798 l.phase = .shown;
799 l.mu.unlock();
800 l.decline();
801
802 const term = try ssh.wait();
803 switch (term) {
804 .Signal => |sig| try std.testing.expectEqual(@as(u32, std.posix.SIG.TERM), sig),
805 else => {
806 std.debug.print("the declined dial's ssh was not signalled: {any}\n", .{term});
807 return error.TestUnexpectedResult;
808 },
809 }
810 }
811
812 test "askpass.Listener: a declined pid parks, an untouched one does not" {
813 const alloc = std.testing.allocator;
814 var tmp = try testtmp.TmpDir.make();
815 defer tmp.cleanup();
816 var counter: Counter = .{};
817 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
818 defer l.stop();
819
820 // Driven through the same door the pump asks at, with the prompt state
821 // set by hand: the ring is what a redial reads, and the pid it is keyed
822 // on comes from a dial, not from this socket.
823 l.mu.lock();
824 l.prompt.ssh_pid = 4242;
825 l.phase = .shown;
826 l.mu.unlock();
827 l.decline();
828 try std.testing.expect(l.declined(4242));
829 try std.testing.expect(!l.declined(4243));
830 // A dial whose attribution failed is not every dial: pid 0 must never
831 // park a tile.
832 try std.testing.expect(!l.declined(0));
833 }
834
835 test "askpass.Listener: a peer that says nothing does not park the next prompt" {
836 const alloc = std.testing.allocator;
837 var tmp = try testtmp.TmpDir.make();
838 defer tmp.cleanup();
839 var counter: Counter = .{};
840 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
841 // Registered FIRST so it runs LAST: the silent peer below must be
842 // closed before this join, or a broken deadline wedges the runner
843 // instead of failing — and a wedged test step prints nothing at all.
844 defer l.stop();
845 l.request_ms = 150;
846
847 // Connected, and then nothing: no tag, no text, no newline. The accept
848 // thread serves one connection to completion, so without a deadline
849 // this is every later prompt of this wall parked behind one peer.
850 const mute = try std.net.connectUnixSocket(l.path);
851 defer mute.close();
852
853 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
854 defer alloc.free(out_path);
855 var run: HelperRun = .{ .out_path = out_path, .prompt = "after: ", .sock = l.path };
856 const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
857 var p: Prompt = .{};
858 try std.testing.expect(awaitPrompt(l, &p));
859 try std.testing.expectEqualStrings("after: ", p.slice());
860 l.answer("x");
861 th.join();
862 }
863
864 test "askpass.Listener: a helper that hangs up closes the box behind it" {
865 const alloc = std.testing.allocator;
866 var tmp = try testtmp.TmpDir.make();
867 defer tmp.cleanup();
868 var counter: Counter = .{};
869 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
870 defer l.stop();
871
872 // The FIDO notifier's shape: ssh runs the helper to say "touch your
873 // key" and SIGTERMs it the moment the touch lands. A box left standing
874 // after that is one the user must dismiss for no reason — and
875 // dismissing it would record a LIVE dial's pid as declined.
876 const peer = try std.net.connectUnixSocket(l.path);
877 _ = try peer.write("nConfirm user presence for key ED25519-SK\n");
878 var p: Prompt = .{};
879 try std.testing.expect(awaitPrompt(l, &p));
880 try std.testing.expectEqual(Kind.notice, p.kind);
881 try std.testing.expect(l.showing());
882 peer.close();
883
884 var waited: usize = 0;
885 while (waited < 3000 and l.showing()) : (waited += 25)
886 std.Thread.sleep(25 * std.time.ns_per_ms);
887 try std.testing.expect(!l.showing());
888 // The doorbell rang, which is what makes the keyboard look.
889 try std.testing.expect(counter.n.load(.acquire) >= 2);
890 // Nothing was declined: the ssh behind that notifier is alive and its
891 // dial is still going.
892 try std.testing.expect(!l.declined(p.ssh_pid));
893 }
894
895 test "askpass.helperMain: a socket nobody is listening on is a refused prompt" {
896 const alloc = std.testing.allocator;
897 var tmp = try testtmp.TmpDir.make();
898 defer tmp.cleanup();
899 const sock = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()});
900 defer alloc.free(sock);
901 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
902 defer alloc.free(out_path);
903 const f = try std.fs.cwd().createFile(out_path, .{});
904 defer f.close();
905 try std.testing.expectEqual(@as(u8, 1), helperMain("password: ", sock, .secret, f.handle));
906 const got = try helperStdout(alloc, out_path);
907 defer alloc.free(got);
908 try std.testing.expectEqualStrings("", got);
909 }
910
911 test "askpass: a server-authored prompt cannot move a cursor" {
912 // The rule `handoff.Reason` states for ssh's stderr ("control bytes are
913 // dropped, so nothing ssh says can move a cursor"), on the channel that
914 // is not even ssh's own words: a keyboard-interactive prompt is the
915 // SERVER's text, and it is painted inside a wall's alternate screen.
916 var buf: [64]u8 = undefined;
917 const n = foldControl(&buf, "\x1b[2J\x1b[1;1HEnter\x07 code:\x7f ");
918 try std.testing.expectEqualStrings(" [2J [1;1HEnter code: ", buf[0..n]);
919 // Every byte survives as SOMETHING: a prompt shortened by a filter is a
920 // prompt the user reads half of.
921 try std.testing.expectEqual("\x1b[2J\x1b[1;1HEnter\x07 code:\x7f ".len, n);
922 // High bytes are not control bytes: a UTF-8 prompt paints.
923 const m = foldControl(&buf, "clé:");
924 try std.testing.expectEqualStrings("clé:", buf[0..m]);
925 }
926
927 test "askpass.Listener: control bytes a peer sends direct are folded too" {
928 const alloc = std.testing.allocator;
929 var tmp = try testtmp.TmpDir.make();
930 defer tmp.cleanup();
931 var counter: Counter = .{};
932 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
933 defer l.stop();
934 // Not through `helperMain`: the socket is reachable by any same-uid
935 // peer, and the end that paints is the end that must not trust.
936 const stream = try std.net.connectUnixSocket(l.path);
937 defer stream.close();
938 // Hand-written wire: the tag byte, then the text. `q` is no tag ssh
939 // has, so this also pins the unknown-tag default.
940 _ = try stream.write("qboom\x1b[31m: \n");
941 var p: Prompt = .{};
942 try std.testing.expect(awaitPrompt(l, &p));
943 try std.testing.expectEqualStrings("boom [31m: ", p.slice());
944 try std.testing.expectEqual(Kind.secret, p.kind);
945 l.decline();
946 }
947
948 test "askpass.helperMain: a prompt's newlines fold, so one prompt is one line" {
949 const alloc = std.testing.allocator;
950 var tmp = try testtmp.TmpDir.make();
951 defer tmp.cleanup();
952 var counter: Counter = .{};
953 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
954 defer l.stop();
955
956 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
957 defer alloc.free(out_path);
958 var run: HelperRun = .{
959 .out_path = out_path,
960 // ssh's host-key question arrives with the fingerprint on its own
961 // line on some builds. Split at the newline, the second half would
962 // read as the ANSWER to the first.
963 .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)? ",
964 .sock = l.path,
965 };
966 const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
967
968 var p: Prompt = .{};
969 try std.testing.expect(awaitPrompt(l, &p));
970 try std.testing.expect(std.mem.indexOfScalar(u8, p.slice(), '\n') == null);
971 try std.testing.expect(std.mem.endsWith(u8, p.slice(), "(yes/no)? "));
972 l.answer("yes");
973 th.join();
974 }
975
976 test "askpass.Kind: ssh's own variable says what it is asking for, and an unknown value is a secret" {
977 // The exact signal, in place of matching `assword` against the text: a
978 // keyboard-interactive prompt is the SERVER's wording, and a substring
979 // rule paints its answer in the clear whenever the server spells the
980 // question its own way.
981 try std.testing.expectEqual(Kind.secret, Kind.of(null));
982 try std.testing.expectEqual(Kind.confirm, Kind.of("confirm"));
983 try std.testing.expectEqual(Kind.notice, Kind.of("none"));
984 // A value neither of ssh's words: the mistake that hides an answer is
985 // cheaper than the one that paints it.
986 try std.testing.expectEqual(Kind.secret, Kind.of("something-openssh-adds-in-2027"));
987 }
988
989 test "askpass.Listener: the kind ssh named rides the wire, one byte ahead of the text" {
990 const alloc = std.testing.allocator;
991 var tmp = try testtmp.TmpDir.make();
992 defer tmp.cleanup();
993 var counter: Counter = .{};
994 const l = try Listener.start(alloc, tmp.path(), counter.hooks());
995 defer l.stop();
996 const out_path = try std.fmt.allocPrint(alloc, "{s}/out", .{tmp.path()});
997 defer alloc.free(out_path);
998
999 for ([_]struct { k: Kind, text: []const u8 }{
1000 .{ .k = .secret, .text = "box's password: " },
1001 .{ .k = .confirm, .text = "Are you sure you want to continue connecting (yes/no)? " },
1002 .{ .k = .notice, .text = "Confirm user presence for key ED25519-SK SHA256:xyz" },
1003 }) |c| {
1004 var run: HelperRun = .{ .out_path = out_path, .prompt = c.text, .sock = l.path, .kind = c.k };
1005 const th = try std.Thread.spawn(.{}, HelperRun.go, .{&run});
1006 var p: Prompt = .{};
1007 try std.testing.expect(awaitPrompt(l, &p));
1008 try std.testing.expectEqual(c.k, p.kind);
1009 // The tag is carriage, not content: the text arrives whole.
1010 try std.testing.expectEqualStrings(c.text, p.slice());
1011 l.answer("x");
1012 th.join();
1013 }
1014 }
1015
1016 test {
1017 std.testing.refAllDeclsRecursive(@This());
1018 }