d1c39002
feat: the wall answers ssh's prompts in a popup
a73x 2026-08-30 10:59
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -245,7 +245,7 @@ const mod_table = [_]ModSpec{ | |||
| 245 | // must be drivable by anything holding a transport — the CLI client | 245 | // must be drivable by anything holding a transport — the CLI client |
| 246 | // today, a wall tile from the wall's phase-3 convergence on — and it | 246 | // today, a wall tile from the wall's phase-3 convergence on — and it |
| 247 | // names no transport type for exactly that reason (see its header). | 247 | // names no transport type for exactly that reason (see its header). |
| 248 | .{ .name = "interact", .path = "src/tui/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "keymap", "paint", "select" } }, | 248 | .{ .name = "interact", .path = "src/tui/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "keymap", "paint", "select", "askpass" } }, |
| 249 | // quic and quic_server both: the listener it owns, and the vocabulary | 249 | // quic and quic_server both: the listener it owns, and the vocabulary |
| 250 | // it names directly (the key it loads, the idle default it falls back | 250 | // it names directly (the key it loads, the idle default it falls back |
| 251 | // to). xdg is for endpoint_req's lazy bind — the default key path, | 251 | // to). xdg is for endpoint_req's lazy bind — the default key path, |
| @@ -291,7 +291,7 @@ const mod_table = [_]ModSpec{ | |||
| 291 | // typed-at session; the overlay machinery itself is interact's, shared — | 291 | // typed-at session; the overlay machinery itself is interact's, shared — |
| 292 | // and phase 3 promotes the tile into that core rather than growing a | 292 | // and phase 3 promotes the tile into that core rather than growing a |
| 293 | // second copy of it. | 293 | // second copy of it. |
| 294 | .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 294 | .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout", "askpass", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 295 | // ---- layer 5 ---- | 295 | // ---- layer 5 ---- |
| 296 | // hosts owns the host grammar and the state file, so argv is parsed by | 296 | // hosts owns the host grammar and the state file, so argv is parsed by |
| 297 | // the SAME rules the CLI's own `mux hosts add` is — one grammar, not | 297 | // the SAME rules the CLI's own `mux hosts add` is — one grammar, not |
src/client/askpass.zig
| Old | New | ||
|---|---|---|---|
| @@ -374,10 +374,10 @@ pub const Listener = struct { | |||
| 374 | } | 374 | } |
| 375 | } | 375 | } |
| 376 | 376 | ||
| 377 | /// Whether the box on screen still has an ssh behind it. The keyboard | 377 | /// Whether the box on screen still has an ssh behind it. |
| 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 { | 378 | pub fn showing(self: *Listener) bool { |
| 379 | // The keyboard asks every pass: `serve` puts the phase back to idle | ||
| 380 | // on a hangup, and the popup has to follow it. | ||
| 381 | self.mu.lock(); | 381 | self.mu.lock(); |
| 382 | defer self.mu.unlock(); | 382 | defer self.mu.unlock(); |
| 383 | return self.phase == .pending or self.phase == .shown; | 383 | return self.phase == .pending or self.phase == .shown; |
| @@ -417,19 +417,18 @@ pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std. | |||
| 417 | 417 | ||
| 418 | /// One prompt's bytes, made safe to paint: every control byte becomes a | 418 | /// One prompt's bytes, made safe to paint: every control byte becomes a |
| 419 | /// space. Returns how many were written. | 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 { | 420 | pub fn foldControl(dst: []u8, src: []const u8) usize { |
| 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 | ||
| 424 | // in one moves a cursor, sets a mode or fakes a row in somebody's tile. | ||
| 425 | // And the text is not always ssh's: a keyboard-interactive prompt is | ||
| 426 | // the SERVER's wording handed to the helper as argv[1], which OpenSSH | ||
| 427 | // does not sanitize on this path. | ||
| 428 | // | ||
| 429 | // Run on BOTH ends — the helper before it sends, the listener on what | ||
| 430 | // it received — because the wire is a socket any same-uid peer can | ||
| 431 | // reach, and the end that paints is the end that must not trust. | ||
| 433 | const n = @min(src.len, dst.len); | 432 | 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; | 433 | for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch; |
| 435 | return n; | 434 | return n; |
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -28,6 +28,7 @@ const client_core = @import("client_core"); | |||
| 28 | // Named `paint_mod` because paintOverlay holds a local ArrayList called | 28 | // Named `paint_mod` because paintOverlay holds a local ArrayList called |
| 29 | // `paint`, which a container-level `paint` would collide with. | 29 | // `paint`, which a container-level `paint` would collide with. |
| 30 | const paint_mod = @import("paint"); | 30 | const paint_mod = @import("paint"); |
| 31 | const askpass = @import("askpass"); | ||
| 31 | const select = @import("select"); | 32 | const select = @import("select"); |
| 32 | // The command prefix byte. Owned by keymap because the dialler in client.zig | 33 | // The command prefix byte. Owned by keymap because the dialler in client.zig |
| 33 | // watches for it with no session and no terminal in the picture. | 34 | // watches for it with no session and no terminal in the picture. |
| @@ -122,6 +123,13 @@ pub const PrefixFilter = struct { | |||
| 122 | pick_forget, | 123 | pick_forget, |
| 123 | pick_add_open, | 124 | pick_add_open, |
| 124 | pick_close, | 125 | pick_close, |
| 126 | /// ssh asked something and the user answered. Borrows the filter's | ||
| 127 | /// buffer until the next feed, as `add_tile` does. An EMPTY answer | ||
| 128 | /// is an answer: a key whose passphrase is empty is a real key. | ||
| 129 | ask_answer: []const u8, | ||
| 130 | /// Esc or Ctrl-C on a prompt. ssh reads it as a refusal and gives | ||
| 131 | /// up, which is what the user just said. | ||
| 132 | ask_decline, | ||
| 125 | }; | 133 | }; |
| 126 | 134 | ||
| 127 | pub const Out = struct { forward: []const u8, action: Action }; | 135 | pub const Out = struct { forward: []const u8, action: Action }; |
| @@ -162,6 +170,47 @@ pub const PrefixFilter = struct { | |||
| 162 | /// shell. The driver paints; this owns which key means what. | 170 | /// shell. The driver paints; this owns which key means what. |
| 163 | picking: bool = false, | 171 | picking: bool = false, |
| 164 | 172 | ||
| 173 | /// ssh is waiting on an answer. Over EVERYTHING, `picking` and its | ||
| 174 | /// editor included: a prompt is not a mode the user chose, it arrived, | ||
| 175 | /// and it arrives while whatever was on screen is still on screen. Every | ||
| 176 | /// byte is the popup's, which is `picking`'s rule for `picking`'s | ||
| 177 | /// reason — a `j` typed at a password box must not reach a shell. | ||
| 178 | asking: bool = false, | ||
| 179 | /// What ssh said it was asking for. The filter carries it to the | ||
| 180 | /// painter and decides nothing: a secret is starred, a confirmation is | ||
| 181 | /// read back by the user, and a notice takes no answer at all. | ||
| 182 | kind: askpass.Kind = .secret, | ||
| 183 | /// Its OWN buffer, not `line`: a prompt can arrive while the picker's | ||
| 184 | /// spelling editor holds a half-typed host, and sharing one buffer | ||
| 185 | /// would eat that line to answer a question about another machine. | ||
| 186 | ask_line: [askpass.answer_max]u8 = undefined, | ||
| 187 | ask_len: usize = 0, | ||
| 188 | |||
| 189 | pub fn askLine(self: *const PrefixFilter) []const u8 { | ||
| 190 | return self.ask_line[0..self.ask_len]; | ||
| 191 | } | ||
| 192 | |||
| 193 | /// Whether the answer is painted behind stars. | ||
| 194 | pub fn masks(self: *const PrefixFilter) bool { | ||
| 195 | return self.kind == .secret; | ||
| 196 | } | ||
| 197 | |||
| 198 | pub fn askOpen(self: *PrefixFilter, kind: askpass.Kind) void { | ||
| 199 | self.asking = true; | ||
| 200 | self.kind = kind; | ||
| 201 | self.ask_len = 0; | ||
| 202 | @memset(&self.ask_line, 0); | ||
| 203 | } | ||
| 204 | |||
| 205 | /// Called by the driver once the answer has been handed on. | ||
| 206 | pub fn askClose(self: *PrefixFilter) void { | ||
| 207 | // Which is what makes the zeroing a definite point: `feed` cannot | ||
| 208 | // do it — the action it returns borrows this buffer. | ||
| 209 | self.asking = false; | ||
| 210 | self.ask_len = 0; | ||
| 211 | @memset(&self.ask_line, 0); | ||
| 212 | } | ||
| 213 | |||
| 165 | pub fn promptLine(self: *const PrefixFilter) []const u8 { | 214 | pub fn promptLine(self: *const PrefixFilter) []const u8 { |
| 166 | return self.line[0..self.line_len]; | 215 | return self.line[0..self.line_len]; |
| 167 | } | 216 | } |
| @@ -202,6 +251,39 @@ pub const PrefixFilter = struct { | |||
| 202 | var opened = false; | 251 | var opened = false; |
| 203 | var editing = false; | 252 | var editing = false; |
| 204 | for (buf, 0..) |b, i| { | 253 | for (buf, 0..) |b, i| { |
| 254 | // FIRST, and above the picker's own layer: ssh is blocked on | ||
| 255 | // this answer, and a byte that reached a session while a | ||
| 256 | // password box was up would be typed into a shell in the clear. | ||
| 257 | if (self.asking) { | ||
| 258 | switch (b) { | ||
| 259 | '\r', '\n' => { | ||
| 260 | self.asking = false; | ||
| 261 | return .{ .forward = buf[0..kept], .action = .{ .ask_answer = self.ask_line[0..self.ask_len] } }; | ||
| 262 | }, | ||
| 263 | // The Esc that heads an arrow key takes its tail with | ||
| 264 | // it, the way the spelling editor's does: the chunk | ||
| 265 | // ends here, so `[A` never reaches a shell. A prompt is | ||
| 266 | // not a popup you browse, so there is no key it has to | ||
| 267 | // stay open for. | ||
| 268 | 0x1b, 0x03 => { | ||
| 269 | self.asking = false; | ||
| 270 | return .{ .forward = buf[0..kept], .action = .ask_decline }; | ||
| 271 | }, | ||
| 272 | 0x7f, 0x08 => self.ask_len -|= 1, | ||
| 273 | // High bytes too, unlike the spelling editor's line: a | ||
| 274 | // password is bytes and ssh takes any of them, so a | ||
| 275 | // UTF-8 one must be typeable. They are never an Esc | ||
| 276 | // tail. The star count stays per byte, which is a | ||
| 277 | // character count only for ASCII — and a star count is | ||
| 278 | // feedback, not an inventory. | ||
| 279 | 0x20...0x7e, 0x80...0xff => if (self.ask_len < askpass.answer_max) { | ||
| 280 | self.ask_line[self.ask_len] = b; | ||
| 281 | self.ask_len += 1; | ||
| 282 | }, | ||
| 283 | else => {}, | ||
| 284 | } | ||
| 285 | continue; | ||
| 286 | } | ||
| 205 | if (self.prompting) { | 287 | if (self.prompting) { |
| 206 | switch (b) { | 288 | switch (b) { |
| 207 | // Submit and cancel both end the read: an Esc that is | 289 | // Submit and cancel both end the read: an Esc that is |
| @@ -2235,6 +2317,112 @@ test "interact: an unknown command key is swallowed with its prefix" { | |||
| 2235 | try std.testing.expectEqualStrings("d", next.forward); | 2317 | try std.testing.expectEqualStrings("d", next.forward); |
| 2236 | } | 2318 | } |
| 2237 | 2319 | ||
| 2320 | test "asking: printable bytes accumulate into the answer" { | ||
| 2321 | var f: PrefixFilter = .{}; | ||
| 2322 | f.askOpen(.secret); | ||
| 2323 | var chunk = "hunter2".*; | ||
| 2324 | const out = f.feed(&chunk); | ||
| 2325 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 2326 | try std.testing.expectEqualStrings("hunter2", f.askLine()); | ||
| 2327 | } | ||
| 2328 | |||
| 2329 | test "asking: Enter answers with the line, and backspace unspells it" { | ||
| 2330 | var f: PrefixFilter = .{}; | ||
| 2331 | f.askOpen(.secret); | ||
| 2332 | var typed = "hunterX\x7f2\r".*; | ||
| 2333 | const out = f.feed(&typed); | ||
| 2334 | switch (out.action) { | ||
| 2335 | .ask_answer => |a| try std.testing.expectEqualStrings("hunter2", a), | ||
| 2336 | else => { | ||
| 2337 | std.debug.print("Enter on a prompt gave .{s}, not the line\n", .{@tagName(out.action)}); | ||
| 2338 | return error.TestUnexpectedResult; | ||
| 2339 | }, | ||
| 2340 | } | ||
| 2341 | // Closed on the answer: the next byte is the session's again. | ||
| 2342 | try std.testing.expect(!f.asking); | ||
| 2343 | } | ||
| 2344 | |||
| 2345 | test "asking: an EMPTY Enter is an answer, not a cancel" { | ||
| 2346 | // A key with no passphrase asks anyway, and ssh takes "" as the try. | ||
| 2347 | // Reporting `.none` here would leave ssh blocked on a socket forever | ||
| 2348 | // with the popup already gone. | ||
| 2349 | var f: PrefixFilter = .{}; | ||
| 2350 | f.askOpen(.secret); | ||
| 2351 | var enter = "\r".*; | ||
| 2352 | const out = f.feed(&enter); | ||
| 2353 | switch (out.action) { | ||
| 2354 | .ask_answer => |a| try std.testing.expectEqualStrings("", a), | ||
| 2355 | else => { | ||
| 2356 | std.debug.print("an empty Enter gave .{s}, not an empty answer\n", .{@tagName(out.action)}); | ||
| 2357 | return error.TestUnexpectedResult; | ||
| 2358 | }, | ||
| 2359 | } | ||
| 2360 | } | ||
| 2361 | |||
| 2362 | test "asking: Esc and Ctrl-C both decline, and Esc eats an arrow's tail" { | ||
| 2363 | var f: PrefixFilter = .{}; | ||
| 2364 | f.askOpen(.confirm); | ||
| 2365 | var esc = "\x1b[A".*; | ||
| 2366 | const out = f.feed(&esc); | ||
| 2367 | try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action); | ||
| 2368 | // The `[A` left with the Esc. Forwarded, it would arrive at whatever | ||
| 2369 | // session had the focus as a cursor key nobody pressed. | ||
| 2370 | try std.testing.expectEqualStrings("", out.forward); | ||
| 2371 | |||
| 2372 | f.askOpen(.confirm); | ||
| 2373 | var ctrlc = "\x03".*; | ||
| 2374 | try std.testing.expectEqual(PrefixFilter.Action.ask_decline, f.feed(&ctrlc).action); | ||
| 2375 | try std.testing.expect(!f.asking); | ||
| 2376 | } | ||
| 2377 | |||
| 2378 | test "asking: NO byte reaches a session while ssh is waiting" { | ||
| 2379 | // The whole reason the mode eats prose: the bytes being typed at a | ||
| 2380 | // password box are a password, and one that reached a shell would be | ||
| 2381 | // in that shell's history in the clear. | ||
| 2382 | var f: PrefixFilter = .{}; | ||
| 2383 | f.askOpen(.secret); | ||
| 2384 | var chunk = "abc\x03".*; | ||
| 2385 | const out = f.feed(&chunk); | ||
| 2386 | try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action); | ||
| 2387 | try std.testing.expectEqual(@as(usize, 0), out.forward.len); | ||
| 2388 | } | ||
| 2389 | |||
| 2390 | test "asking: a prompt over the picker's editor keeps the half-typed spelling" { | ||
| 2391 | // ssh does not wait for the picker to close, so the two layers are live | ||
| 2392 | // at once. Sharing one buffer would answer the password prompt and lose | ||
| 2393 | // the host the user was halfway through naming. | ||
| 2394 | var f: PrefixFilter = .{}; | ||
| 2395 | var open_editor = "\x1csa--sock /run/a".*; | ||
| 2396 | _ = f.feed(&open_editor); | ||
| 2397 | try std.testing.expectEqualStrings("--sock /run/a", f.promptLine()); | ||
| 2398 | |||
| 2399 | f.askOpen(.secret); | ||
| 2400 | var pw = "s3cret\r".*; | ||
| 2401 | const out = f.feed(&pw); | ||
| 2402 | switch (out.action) { | ||
| 2403 | .ask_answer => |a| try std.testing.expectEqualStrings("s3cret", a), | ||
| 2404 | else => return error.TestUnexpectedResult, | ||
| 2405 | } | ||
| 2406 | // ...and the editor is still open, still holding what was typed at it. | ||
| 2407 | try std.testing.expect(f.prompting); | ||
| 2408 | try std.testing.expectEqualStrings("--sock /run/a", f.promptLine()); | ||
| 2409 | var rest = ".sock\r".*; | ||
| 2410 | const done = f.feed(&rest); | ||
| 2411 | switch (done.action) { | ||
| 2412 | .add_tile => |t| try std.testing.expectEqualStrings("--sock /run/a.sock", t), | ||
| 2413 | else => return error.TestUnexpectedResult, | ||
| 2414 | } | ||
| 2415 | } | ||
| 2416 | |||
| 2417 | test "asking: an answer longer than the cap is cut, never overruns" { | ||
| 2418 | var f: PrefixFilter = .{}; | ||
| 2419 | f.askOpen(.secret); | ||
| 2420 | var long: [askpass.answer_max + 8]u8 = @splat('x'); | ||
| 2421 | const out = f.feed(&long); | ||
| 2422 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 2423 | try std.testing.expectEqual(askpass.answer_max, f.askLine().len); | ||
| 2424 | } | ||
| 2425 | |||
| 2238 | test "interact: bytes with no prefix pass through untouched" { | 2426 | test "interact: bytes with no prefix pass through untouched" { |
| 2239 | var f: PrefixFilter = .{}; | 2427 | var f: PrefixFilter = .{}; |
| 2240 | var chunk = "hello\x1b[A".*; | 2428 | var chunk = "hello\x1b[A".*; |
src/tui/wall_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,7 @@ const client = @import("client"); | |||
| 10 | const hosts = @import("hosts"); | 10 | const hosts = @import("hosts"); |
| 11 | const handoff = @import("handoff"); | 11 | const handoff = @import("handoff"); |
| 12 | const interact = @import("interact"); | 12 | const interact = @import("interact"); |
| 13 | const askpass = @import("askpass"); | ||
| 13 | const wall_host = @import("wall_host.zig"); | 14 | const wall_host = @import("wall_host.zig"); |
| 14 | const wall_layout = @import("wall_layout.zig"); | 15 | const wall_layout = @import("wall_layout.zig"); |
| 15 | const wv = @import("wallview.zig"); | 16 | const wv = @import("wallview.zig"); |
| @@ -177,6 +178,115 @@ pub const PickerAuto = struct { | |||
| 177 | } | 178 | } |
| 178 | }; | 179 | }; |
| 179 | 180 | ||
| 181 | /// How many rows of ssh's question the box will show. Past it the question | ||
| 182 | /// is cut: a prompt that filled the terminal would leave nowhere to type. | ||
| 183 | pub const ask_rows_max: usize = 4; | ||
| 184 | |||
| 185 | /// The prompt box's rendered rows: the question, wrapped, and the answer | ||
| 186 | /// line under it. Rendered once and read twice — by the writer and by the | ||
| 187 | /// tests — so the box on the screen is the box the claims are about. | ||
| 188 | pub const AskBody = struct { | ||
| 189 | text: [ask_rows_max + 1][picker_row_max]u8 = undefined, | ||
| 190 | lens: [ask_rows_max + 1]usize = [_]usize{0} ** (ask_rows_max + 1), | ||
| 191 | n: usize = 0, | ||
| 192 | |||
| 193 | pub fn row(self: *const AskBody, i: usize) []const u8 { | ||
| 194 | return self.text[i][0..self.lens[i]]; | ||
| 195 | } | ||
| 196 | }; | ||
| 197 | |||
| 198 | /// Pure, so the whole box — the wrap, the cut, the stars — is assertable | ||
| 199 | /// with no terminal anywhere near it. | ||
| 200 | pub fn askRows(body: *AskBody, prompt: []const u8, answer: []const u8, kind: askpass.Kind, cols: u16) void { | ||
| 201 | const w = @min(@as(usize, cols), picker_row_max); | ||
| 202 | // One space of margin each side, the picker's own inset. | ||
| 203 | const room = w -| 2; | ||
| 204 | body.n = 0; | ||
| 205 | var rest = prompt; | ||
| 206 | while (body.n < ask_rows_max and rest.len > 0 and room > 0) { | ||
| 207 | const take = @min(rest.len, room); | ||
| 208 | body.text[body.n][0] = ' '; | ||
| 209 | @memcpy(body.text[body.n][1..][0..take], rest[0..take]); | ||
| 210 | body.lens[body.n] = take + 1; | ||
| 211 | body.n += 1; | ||
| 212 | rest = rest[take..]; | ||
| 213 | } | ||
| 214 | // A notice is not a question — "Confirm user presence for key ..." — | ||
| 215 | // and ssh takes no answer for it: the box goes away when the touch | ||
| 216 | // lands and the helper is killed. An input line under it would invite | ||
| 217 | // an answer nothing would ever read. | ||
| 218 | if (kind == .notice) return; | ||
| 219 | // The cursor is drawn, not placed: the terminal's own caret is hidden | ||
| 220 | // for as long as a popup owns the screen, and a box with no visible | ||
| 221 | // insertion point reads as one that is not listening. | ||
| 222 | var line: [picker_row_max]u8 = undefined; | ||
| 223 | var n: usize = 0; | ||
| 224 | for ("> ") |c| { | ||
| 225 | line[n] = c; | ||
| 226 | n += 1; | ||
| 227 | } | ||
| 228 | // Stars per BYTE, and the count is deliberate: it is the only feedback | ||
| 229 | // a typist gets that the box took the key, and ssh is not going to | ||
| 230 | // echo it back. | ||
| 231 | const shown = @min(answer.len, w -| 4); | ||
| 232 | const mask = kind == .secret; | ||
| 233 | for (answer[answer.len - shown ..]) |c| { | ||
| 234 | line[n] = if (mask) '*' else c; | ||
| 235 | n += 1; | ||
| 236 | } | ||
| 237 | line[n] = '_'; | ||
| 238 | n += 1; | ||
| 239 | const at = @min(body.n, ask_rows_max); | ||
| 240 | @memcpy(body.text[at][0..n], line[0..n]); | ||
| 241 | body.lens[at] = n; | ||
| 242 | body.n = at + 1; | ||
| 243 | } | ||
| 244 | |||
| 245 | /// ssh's question, over everything: the picker included, because a prompt | ||
| 246 | /// ARRIVES and the box that owns the keyboard is the one that came last. | ||
| 247 | pub fn paintAsk(shared: *Shared, prompt: []const u8, prefix: *const interact.PrefixFilter) void { | ||
| 248 | if (!shared.is_tty) return; | ||
| 249 | // Set before the lock and read under it, `paintPicker`'s ordering. | ||
| 250 | shared.ask_open.store(true, .release); | ||
| 251 | shared.paint_mu.lock(); | ||
| 252 | defer shared.paint_mu.unlock(); | ||
| 253 | const cols = shared.size.cols; | ||
| 254 | const rows = shared.size.rows; | ||
| 255 | const w: u16 = @min(cols, @as(u16, picker_row_max)); | ||
| 256 | const left: u16 = (cols -| w) / 2; | ||
| 257 | var body: AskBody = .{}; | ||
| 258 | askRows(&body, prompt, prefix.askLine(), prefix.kind, w); | ||
| 259 | const cramped = cols < picker_min_cols or rows < picker_min_rows; | ||
| 260 | const want: u16 = @intCast(@min(@as(usize, rows), body.n + 1)); | ||
| 261 | const height: u16 = if (cramped) 1 else want; | ||
| 262 | const top: u16 = (rows -| height) / 2; | ||
| 263 | var out: [picker_frame_max]u8 = undefined; | ||
| 264 | var fbs = std.io.fixedBufferStream(&out); | ||
| 265 | const wr = fbs.writer(); | ||
| 266 | wr.writeAll("\x1b[?25l") catch return; | ||
| 267 | // No host on the header: this client cannot say which tile's ssh is | ||
| 268 | // asking without reading a pump's own transport, and ssh's prompts | ||
| 269 | // name the box themselves (`user@host's password:`). | ||
| 270 | pickerLine(wr, top, left, w, " ssh"); | ||
| 271 | if (!cramped) { | ||
| 272 | var r: u16 = 0; | ||
| 273 | while (r < body.n and top + 1 + r < rows) : (r += 1) | ||
| 274 | pickerLine(wr, top + 1 + r, left, w, body.row(r)); | ||
| 275 | } | ||
| 276 | // The picker's screen is gone under this box, so its next paint owes a | ||
| 277 | // frame however unchanged its rows are. | ||
| 278 | shared.picker_stamp = 0; | ||
| 279 | proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {}; | ||
| 280 | } | ||
| 281 | |||
| 282 | /// Whether this action is the prompt box's. | ||
| 283 | pub fn isAskAction(a: interact.PrefixFilter.Action) bool { | ||
| 284 | return switch (a) { | ||
| 285 | .ask_answer, .ask_decline => true, | ||
| 286 | else => false, | ||
| 287 | }; | ||
| 288 | } | ||
| 289 | |||
| 180 | /// Whether this action is the picker's. The popup answers every one of | 290 | /// Whether this action is the picker's. The popup answers every one of |
| 181 | /// them, so the arms below it in `run` name them only to stay exhaustive. | 291 | /// them, so the arms below it in `run` name them only to stay exhaustive. |
| 182 | pub fn isPickAction(a: interact.PrefixFilter.Action) bool { | 292 | pub fn isPickAction(a: interact.PrefixFilter.Action) bool { |
src/tui/wall_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ | |||
| 6 | const std = @import("std"); | 6 | const std = @import("std"); |
| 7 | const proto = @import("protocol"); | 7 | const proto = @import("protocol"); |
| 8 | const client = @import("client"); | 8 | const client = @import("client"); |
| 9 | const handoff = @import("handoff"); | ||
| 9 | const interact = @import("interact"); | 10 | const interact = @import("interact"); |
| 10 | const wv = @import("wallview.zig"); | 11 | const wv = @import("wallview.zig"); |
| 11 | const EndReason = wv.EndReason; | 12 | const EndReason = wv.EndReason; |
| @@ -56,7 +57,7 @@ pub fn tilePaintBegin(ctx: ?*anyopaque) bool { | |||
| 56 | // box the keyboard had just drawn — and `picker_stamp` suppresses the | 57 | // box the keyboard had just drawn — and `picker_stamp` suppresses the |
| 57 | // identical repaint that would have repaired it, so the damage sticks | 58 | // identical repaint that would have repaired it, so the damage sticks |
| 58 | // until a key changes the frame. | 59 | // until a key changes the frame. |
| 59 | if (t.shared.picker_open.load(.acquire)) { | 60 | if (wv.popupOpen(t.shared)) { |
| 60 | t.shared.paint_mu.unlock(); | 61 | t.shared.paint_mu.unlock(); |
| 61 | return false; | 62 | return false; |
| 62 | } | 63 | } |
| @@ -247,19 +248,46 @@ fn drainWake(t: *const Tile) void { | |||
| 247 | wv.drainBell(t.wake_r); | 248 | wv.drainBell(t.wake_r); |
| 248 | } | 249 | } |
| 249 | 250 | ||
| 251 | /// Every wall dial's ssh sends its prompts to this client's popup. | ||
| 252 | pub fn askOn(target: client.Target, shared: *const Shared) client.Target { | ||
| 253 | // ONE place, so the two exclusions hold by construction rather than by | ||
| 254 | // a call somebody has to remember: the entry attach opens on the main | ||
| 255 | // thread before any listener exists, and a poller never comes through | ||
| 256 | // here at all — it spells `BatchMode` and asks nothing. | ||
| 257 | var out = target; | ||
| 258 | if (out != .hand) return out; | ||
| 259 | const l = shared.prompts orelse return out; | ||
| 260 | out.hand.ask_sock = l.path; | ||
| 261 | out.hand.ask_exe = shared.prompt_exe; | ||
| 262 | return out; | ||
| 263 | } | ||
| 264 | |||
| 250 | fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport { | 265 | fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport { |
| 251 | var target = target_in; | 266 | var target = askOn(target_in, t.shared); |
| 252 | var backoff_ms: u64 = 0; | 267 | var backoff_ms: u64 = 0; |
| 253 | // `gone` as well as `running`: a tile forgotten while it is retrying a | 268 | // `gone` as well as `running`: a tile forgotten while it is retrying a |
| 254 | // dead host must stop retrying, not keep a thread and a backoff alive | 269 | // dead host must stop retrying, not keep a thread and a backoff alive |
| 255 | // for a tile that is no longer on the wall. | 270 | // for a tile that is no longer on the wall. |
| 256 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | 271 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { |
| 257 | // No reason kept: a tile that cannot dial says `connecting` and | 272 | // The reason is not kept — a tile that cannot dial says |
| 258 | // keeps trying, and the sentence ssh gave belongs to the picker | 273 | // `connecting`, and the sentence ssh gave belongs to the picker |
| 259 | // row, which polls the same host on its own interval — ten seconds | 274 | // row, which polls the same host on its own interval — ten seconds |
| 260 | // for exactly this host, since a failed `.hand` poll answers | 275 | // for exactly this host, since a failed `.hand` poll answers |
| 261 | // `.pipe` and `pollDelayMs` stretches those. | 276 | // `.pipe` and `pollDelayMs` stretches those. The PID is: it is the |
| 262 | if (client.Transport.open(alloc, target, null, -1, null)) |tr| return tr else |_| {} | 277 | // only thing that tells a dial the user refused from a box that is |
| 278 | // merely down. | ||
| 279 | var d: handoff.Dial = .{}; | ||
| 280 | if (client.Transport.open(alloc, target, null, -1, &d)) |tr| return tr else |_| {} | ||
| 281 | // A refused prompt is an answer, and retrying is arguing with it: | ||
| 282 | // without this the user who pressed Esc is asked again every two | ||
| 283 | // seconds for as long as the wall is up. Another go is a birth | ||
| 284 | // from the picker, which is a thing they can choose to do. | ||
| 285 | if (t.shared.prompts) |l| { | ||
| 286 | if (l.declined(d.ssh_pid)) { | ||
| 287 | endWith(t, .declined, 1); | ||
| 288 | return null; | ||
| 289 | } | ||
| 290 | } | ||
| 263 | // An ask buys ONE attempt. Every retry below is the wall's own | 291 | // An ask buys ONE attempt. Every retry below is the wall's own |
| 264 | // idea: the asking word per backoff would restart a daemon for as | 292 | // idea: the asking word per backoff would restart a daemon for as |
| 265 | // long as the tile lives, and a fallback line per backoff would | 293 | // long as the tile lives, and a fallback line per backoff would |
src/tui/wall_test_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ const std = @import("std"); | |||
| 3 | const proto = @import("protocol"); | 3 | const proto = @import("protocol"); |
| 4 | const client = @import("client"); | 4 | const client = @import("client"); |
| 5 | const interact = @import("interact"); | 5 | const interact = @import("interact"); |
| 6 | const layout = @import("layout"); | ||
| 6 | const fixture = @import("wall_test_harness.zig"); | 7 | const fixture = @import("wall_test_harness.zig"); |
| 7 | const wall_host = @import("wall_host.zig"); | 8 | const wall_host = @import("wall_host.zig"); |
| 8 | const wall_picker = @import("wall_picker.zig"); | 9 | const wall_picker = @import("wall_picker.zig"); |
| @@ -591,3 +592,163 @@ test "hostState: an unreachable host says what ssh said, cut to the row's buffer | |||
| 591 | h.poll.reachable.store(true, .release); | 592 | h.poll.reachable.store(true, .release); |
| 592 | try std.testing.expectEqualStrings("no sessions", wall_picker.hostState(&wide, &h)); | 593 | try std.testing.expectEqualStrings("no sessions", wall_picker.hostState(&wide, &h)); |
| 593 | } | 594 | } |
| 595 | |||
| 596 | test "askRows: a masked answer shows one star per byte and never the bytes" { | ||
| 597 | var body: wall_picker.AskBody = .{}; | ||
| 598 | wall_picker.askRows(&body, "e2e@box's password: ", "hunter2", .secret, 60); | ||
| 599 | try std.testing.expectEqual(@as(usize, 2), body.n); | ||
| 600 | try std.testing.expectEqualStrings(" e2e@box's password: ", body.row(0)); | ||
| 601 | // The count is the feedback: ssh will not echo, and a box that showed | ||
| 602 | // nothing at all reads as one that is not listening. | ||
| 603 | try std.testing.expectEqualStrings("> *******_", body.row(1)); | ||
| 604 | // The claim this whole box exists to keep. | ||
| 605 | try std.testing.expect(std.mem.indexOf(u8, body.row(1), "hunter2") == null); | ||
| 606 | } | ||
| 607 | |||
| 608 | test "askRows: a host-key answer is shown, because the user is comparing it" { | ||
| 609 | var body: wall_picker.AskBody = .{}; | ||
| 610 | wall_picker.askRows(&body, "Are you sure you want to continue connecting (yes/no)? ", "yes", .confirm, 60); | ||
| 611 | try std.testing.expectEqualStrings("> yes_", body.row(body.n - 1)); | ||
| 612 | } | ||
| 613 | |||
| 614 | test "askRows: a NOTICE has no input line, because ssh is not asking anything" { | ||
| 615 | // "Confirm user presence for key ..." is ssh telling the user to touch | ||
| 616 | // a key, and it takes no answer: ssh kills the helper when the touch | ||
| 617 | // lands. An input line under it would invite one nothing would read. | ||
| 618 | var body: wall_picker.AskBody = .{}; | ||
| 619 | wall_picker.askRows(&body, "Confirm user presence for key ED25519-SK SHA256:xyz", "", .notice, 60); | ||
| 620 | try std.testing.expectEqual(@as(usize, 1), body.n); | ||
| 621 | try std.testing.expectEqualStrings(" Confirm user presence for key ED25519-SK SHA256:xyz", body.row(0)); | ||
| 622 | } | ||
| 623 | |||
| 624 | test "askRows: a long question wraps to the width and stops at the cap" { | ||
| 625 | var body: wall_picker.AskBody = .{}; | ||
| 626 | var long: [600]u8 = @splat('q'); | ||
| 627 | wall_picker.askRows(&body, &long, "", .confirm, 42); | ||
| 628 | // Four rows of question plus the answer row: a prompt that filled the | ||
| 629 | // terminal would leave nowhere to type. | ||
| 630 | try std.testing.expectEqual(wall_picker.ask_rows_max + 1, body.n); | ||
| 631 | for (0..wall_picker.ask_rows_max) |i| { | ||
| 632 | // One space of margin, 40 of question: the row never exceeds the | ||
| 633 | // box, and `pickerLine` pads the rest. | ||
| 634 | try std.testing.expectEqual(@as(usize, 41), body.row(i).len); | ||
| 635 | } | ||
| 636 | try std.testing.expectEqualStrings("> _", body.row(wall_picker.ask_rows_max)); | ||
| 637 | } | ||
| 638 | |||
| 639 | test "paintAsk: replayed into an engine, the box is centred and the answer is stars" { | ||
| 640 | const alloc = std.testing.allocator; | ||
| 641 | const cols: u16 = 160; | ||
| 642 | const rows: u16 = 20; | ||
| 643 | var screen = try WallScreen.init(alloc, cols, rows); | ||
| 644 | defer screen.deinit(); | ||
| 645 | // Off-origin in both axes, `paintPicker`'s reason: a fixture on a | ||
| 646 | // terminal the box happens to fill is blind to every centring mistake. | ||
| 647 | var bg: [cols]u8 = undefined; | ||
| 648 | @memset(&bg, '.'); | ||
| 649 | var r: u16 = 0; | ||
| 650 | while (r < rows) : (r += 1) { | ||
| 651 | var cup: [16]u8 = undefined; | ||
| 652 | screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable); | ||
| 653 | screen.eng.feed(&bg); | ||
| 654 | } | ||
| 655 | var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true }; | ||
| 656 | var prefix: interact.PrefixFilter = .{}; | ||
| 657 | prefix.askOpen(.secret); | ||
| 658 | var typed = "hunter2".*; | ||
| 659 | _ = prefix.feed(&typed); | ||
| 660 | wall_picker.paintAsk(&shared, "e2e@box's password: ", &prefix); | ||
| 661 | screen.drain(); | ||
| 662 | const dump = try screen.eng.dumpPlain(alloc); | ||
| 663 | defer alloc.free(dump); | ||
| 664 | |||
| 665 | // The GRID, not the frame: a paint that reached a cell by another CUP | ||
| 666 | // form or a wider pad is invisible to a grep over the bytes. | ||
| 667 | try std.testing.expect(std.mem.indexOf(u8, dump, "e2e@box's password:") != null); | ||
| 668 | try std.testing.expect(std.mem.indexOf(u8, dump, "*******_") != null); | ||
| 669 | // The one thing that must never be on a screen anyone can scroll back. | ||
| 670 | try std.testing.expect(std.mem.indexOf(u8, dump, "hunter2") == null); | ||
| 671 | // ...and every tile is held off the terminal while it is up. | ||
| 672 | try std.testing.expect(wv.popupOpen(&shared)); | ||
| 673 | const w: u16 = @intCast(wall_picker.picker_row_max); | ||
| 674 | const left: u16 = (cols - w) / 2; | ||
| 675 | const height: u16 = 3; | ||
| 676 | const top: u16 = (rows - height) / 2; | ||
| 677 | var i: u16 = 0; | ||
| 678 | while (i < rows) : (i += 1) { | ||
| 679 | const l = WallScreen.line(dump, i) orelse return error.NoSuchRow; | ||
| 680 | if (i >= top and i < top + height) { | ||
| 681 | try std.testing.expectEqualStrings(bg[0..left], l[0..left]); | ||
| 682 | try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]); | ||
| 683 | } else { | ||
| 684 | try std.testing.expectEqualStrings(&bg, l); | ||
| 685 | } | ||
| 686 | } | ||
| 687 | } | ||
| 688 | |||
| 689 | test "closeAsk: the picker comes back when the prompt box leaves, whichever end ended it" { | ||
| 690 | // The claim n1 found missing on one of the two ends. `relayout` CLEARS | ||
| 691 | // the screen, so a picker still open under the box owes a repaint — | ||
| 692 | // and a prompt CAN arrive over an open picker, which is why the answer | ||
| 693 | // has a buffer of its own. Without the repaint, `picking` is true on a | ||
| 694 | // blank screen until the next key. | ||
| 695 | const alloc = std.testing.allocator; | ||
| 696 | const cols: u16 = 100; | ||
| 697 | const rows: u16 = 30; | ||
| 698 | var screen = try WallScreen.init(alloc, cols, rows); | ||
| 699 | defer screen.deinit(); | ||
| 700 | var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true }; | ||
| 701 | shared.tree = layout.Tree.init(alloc); | ||
| 702 | shared.flat_alloc = alloc; | ||
| 703 | defer shared.tree.deinit(); | ||
| 704 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 705 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 706 | |||
| 707 | var table = [_]Host{ | ||
| 708 | fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"), | ||
| 709 | fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"), | ||
| 710 | }; | ||
| 711 | for (&table) |*h| { | ||
| 712 | fixture.setList(h, ""); | ||
| 713 | h.applied = true; | ||
| 714 | } | ||
| 715 | var tiles: [1]Tile = undefined; | ||
| 716 | var present = [_]bool{false}; | ||
| 717 | var live: usize = 0; | ||
| 718 | const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table); | ||
| 719 | |||
| 720 | // A notice over an open picker, which is exactly the pair that hangs | ||
| 721 | // up on its own: ssh kills the notifier helper when the touch lands. | ||
| 722 | var prefix: interact.PrefixFilter = .{}; | ||
| 723 | prefix.picking = true; | ||
| 724 | prefix.askOpen(.notice); | ||
| 725 | shared.ask_open.store(true, .release); | ||
| 726 | screen.drain(); | ||
| 727 | |||
| 728 | wv.closeAsk(w, &shared, &prefix, 0); | ||
| 729 | screen.drain(); | ||
| 730 | const dump = try screen.eng.dumpPlain(alloc); | ||
| 731 | defer alloc.free(dump); | ||
| 732 | try std.testing.expect(!prefix.asking); | ||
| 733 | try std.testing.expect(!shared.ask_open.load(.acquire)); | ||
| 734 | // The GRID, not the frame: the box is gone and the rows the user was | ||
| 735 | // choosing from are back on it. | ||
| 736 | try std.testing.expect(std.mem.indexOf(u8, dump, "hosts") != null); | ||
| 737 | try std.testing.expect(std.mem.indexOf(u8, dump, "/tmp/b.sock") != null); | ||
| 738 | |||
| 739 | // ...and with no picker under it, nothing is painted in its place: the | ||
| 740 | // wall is what the screen goes back to. `closeAsk` leaves `picking` | ||
| 741 | // alone — the picker is not the box's to close — so the test says | ||
| 742 | // which wall this second half is. | ||
| 743 | prefix.picking = false; | ||
| 744 | prefix.askOpen(.secret); | ||
| 745 | shared.ask_open.store(true, .release); | ||
| 746 | wall_picker.paintAsk(&shared, "box's password: ", &prefix); | ||
| 747 | screen.drain(); | ||
| 748 | wv.closeAsk(w, &shared, &prefix, 0); | ||
| 749 | screen.drain(); | ||
| 750 | const after = try screen.eng.dumpPlain(alloc); | ||
| 751 | defer alloc.free(after); | ||
| 752 | try std.testing.expect(std.mem.indexOf(u8, after, "/tmp/b.sock") == null); | ||
| 753 | try std.testing.expect(std.mem.indexOf(u8, after, "password") == null); | ||
| 754 | } | ||
src/tui/wall_test_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ const proto = @import("protocol"); | |||
| 4 | const client = @import("client"); | 4 | const client = @import("client"); |
| 5 | const interact = @import("interact"); | 5 | const interact = @import("interact"); |
| 6 | const TmpDir = @import("testtmp").TmpDir; | 6 | const TmpDir = @import("testtmp").TmpDir; |
| 7 | const askpass = @import("askpass"); | ||
| 7 | const fixture = @import("wall_test_harness.zig"); | 8 | const fixture = @import("wall_test_harness.zig"); |
| 8 | const wall_pump = @import("wall_pump.zig"); | 9 | const wall_pump = @import("wall_pump.zig"); |
| 9 | const wv = @import("wallview.zig"); | 10 | const wv = @import("wallview.zig"); |
| @@ -568,3 +569,53 @@ test "the cursor sleeps in the focused tile, whoever painted last" { | |||
| 568 | wall_pump.tilePaintEnd(&tiles[0]); | 569 | wall_pump.tilePaintEnd(&tiles[0]); |
| 569 | try std.testing.expectEqual(@as(usize, 0), fixture.readAvail(p[0], &buf).len); | 570 | try std.testing.expectEqual(@as(usize, 0), fixture.readAvail(p[0], &buf).len); |
| 570 | } | 571 | } |
| 572 | |||
| 573 | test "askOn: a wall dial carries the popup's socket, and every other target is untouched" { | ||
| 574 | const alloc = std.testing.allocator; | ||
| 575 | var tmp = try TmpDir.make(); | ||
| 576 | defer tmp.cleanup(); | ||
| 577 | const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 578 | defer std.posix.close(pipe[0]); | ||
| 579 | defer std.posix.close(pipe[1]); | ||
| 580 | var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 581 | shared.kb_w = pipe[1]; | ||
| 582 | |||
| 583 | const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } }; | ||
| 584 | // No listener: the OLD behaviour, and the visible one — ssh reads | ||
| 585 | // /dev/tty. A piped `mux` and a box with no runtime dir are both here. | ||
| 586 | const bare = wall_pump.askOn(hand, &shared); | ||
| 587 | try std.testing.expect(bare.hand.ask_sock == null); | ||
| 588 | try std.testing.expect(bare.hand.askpassFor() == null); | ||
| 589 | |||
| 590 | const l = try askpass.Listener.start(alloc, tmp.path(), .{ | ||
| 591 | .ctx = &shared, | ||
| 592 | .wake = struct { | ||
| 593 | fn nop(_: *anyopaque) void {} | ||
| 594 | }.nop, | ||
| 595 | }); | ||
| 596 | defer l.stop(); | ||
| 597 | shared.prompts = l; | ||
| 598 | shared.prompt_exe = "/opt/mux"; | ||
| 599 | const armed = wall_pump.askOn(hand, &shared); | ||
| 600 | const ask = armed.hand.askpassFor() orelse return error.TestUnexpectedResult; | ||
| 601 | try std.testing.expectEqualStrings(l.path, ask.sock); | ||
| 602 | try std.testing.expectEqualStrings("/opt/mux", ask.exe); | ||
| 603 | |||
| 604 | // A socket or a `--via` dial spawns no ssh, so there is nothing to | ||
| 605 | // point anywhere — and a target arm added later must not silently | ||
| 606 | // acquire a field this one cannot use. | ||
| 607 | const sock = wall_pump.askOn(.{ .sock = "/tmp/d.sock" }, &shared); | ||
| 608 | try std.testing.expect(sock == .sock); | ||
| 609 | } | ||
| 610 | |||
| 611 | test "askpassFor: a socket with no binary to carry it is no askpass at all" { | ||
| 612 | // SSH_ASKPASS pointing at nothing makes ssh FAIL every prompt rather | ||
| 613 | // than ask one, which is worse than the tty read this replaces. | ||
| 614 | const half = client.HandoffTarget{ | ||
| 615 | .host = "box", | ||
| 616 | .ssh_argv = &.{"ssh"}, | ||
| 617 | .cache_path = null, | ||
| 618 | .ask_sock = "/run/ask.sock", | ||
| 619 | }; | ||
| 620 | try std.testing.expect(half.askpassFor() == null); | ||
| 621 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,8 @@ const proto = @import("protocol"); | |||
| 22 | const client = @import("client"); | 22 | const client = @import("client"); |
| 23 | const hosts = @import("hosts"); | 23 | const hosts = @import("hosts"); |
| 24 | const handoff = @import("handoff"); | 24 | const handoff = @import("handoff"); |
| 25 | const askpass = @import("askpass"); | ||
| 26 | const spawn = @import("spawn"); | ||
| 25 | const proxy = @import("proxy"); | 27 | const proxy = @import("proxy"); |
| 26 | const Engine = @import("engine").Engine; | 28 | const Engine = @import("engine").Engine; |
| 27 | const paint = @import("paint"); | 29 | const paint = @import("paint"); |
| @@ -62,6 +64,10 @@ pub const State = enum { | |||
| 62 | reconnecting, | 64 | reconnecting, |
| 63 | exited, | 65 | exited, |
| 64 | refused, | 66 | refused, |
| 67 | /// The user pressed Esc at ssh's prompt. A state and not a notice: the | ||
| 68 | /// tile stays on the wall wearing the reason, the way a lost one does, | ||
| 69 | /// and the wall it is on is still there to birth from again. | ||
| 70 | declined, | ||
| 65 | 71 | ||
| 66 | pub fn word(s: State) []const u8 { | 72 | pub fn word(s: State) []const u8 { |
| 67 | return switch (s) { | 73 | return switch (s) { |
| @@ -69,6 +75,7 @@ pub const State = enum { | |||
| 69 | .up => "up", | 75 | .up => "up", |
| 70 | .reconnecting => "reconnecting", | 76 | .reconnecting => "reconnecting", |
| 71 | .exited => "exited", | 77 | .exited => "exited", |
| 78 | .declined => "prompt declined", | ||
| 72 | .refused => "refused", | 79 | .refused => "refused", |
| 73 | }; | 80 | }; |
| 74 | } | 81 | } |
| @@ -95,7 +102,7 @@ pub const max_tiles: usize = 32; | |||
| 95 | /// means for the WALL. `State` narrates the same events on a label bar; | 102 | /// means for the WALL. `State` narrates the same events on a label bar; |
| 96 | /// this is the machine-readable half, and they are separate because a cold | 103 | /// this is the machine-readable half, and they are separate because a cold |
| 97 | /// loss has no label word of its own and an exit carries a code. | 104 | /// loss has no label word of its own and an exit carries a code. |
| 98 | pub const EndReason = enum(u8) { none, exited, taken, refused, lost, no_thread }; | 105 | pub const EndReason = enum(u8) { none, exited, taken, refused, lost, no_thread, declined }; |
| 99 | 106 | ||
| 100 | pub const Shared = struct { | 107 | pub const Shared = struct { |
| 101 | running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), | 108 | running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), |
| @@ -200,8 +207,69 @@ pub const Shared = struct { | |||
| 200 | /// The popup's last frame, so an unchanged one is not rewritten. | 207 | /// The popup's last frame, so an unchanged one is not rewritten. |
| 201 | /// Keyboard-thread only, like the picker itself. | 208 | /// Keyboard-thread only, like the picker itself. |
| 202 | picker_stamp: u64 = 0, | 209 | picker_stamp: u64 = 0, |
| 210 | /// True while ssh's prompt box is on the terminal. `picker_open`'s | ||
| 211 | /// twin, for its reason and at the same gates — and a second flag | ||
| 212 | /// rather than a mode of the first, because a prompt ARRIVES and can | ||
| 213 | /// arrive with the picker already up. | ||
| 214 | ask_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), | ||
| 215 | /// Where this wall answers ssh's prompts, and the binary ssh execs to | ||
| 216 | /// ask. Null is "ssh keeps its own prompts": a piped `mux` has no | ||
| 217 | /// popup to answer with, and a box with no `$XDG_RUNTIME_DIR` has | ||
| 218 | /// nowhere private to put the socket — a prompt on a shared /tmp is | ||
| 219 | /// one any local user could answer. | ||
| 220 | /// | ||
| 221 | /// Named for what arrives on it, not `ask`: `Tile.ask` is the end-key's | ||
| 222 | /// own arm, and two fields called `ask` in one file are a reader's trap. | ||
| 223 | prompts: ?*askpass.Listener = null, | ||
| 224 | prompt_exe: []const u8 = "", | ||
| 203 | }; | 225 | }; |
| 204 | 226 | ||
| 227 | /// Opens the prompt box on whatever ssh is asking, if anything is. False | ||
| 228 | /// when there is nothing to show or a box is already up: `take` is the | ||
| 229 | /// transition, so a doorbell rung twice opens one popup. | ||
| 230 | fn takeAsk(shared: *Shared, prefix: *interact.PrefixFilter, out: *askpass.Prompt) bool { | ||
| 231 | if (prefix.asking) return false; | ||
| 232 | const l = shared.prompts orelse return false; | ||
| 233 | if (!l.take(out)) return false; | ||
| 234 | prefix.askOpen(out.kind); | ||
| 235 | return true; | ||
| 236 | } | ||
| 237 | |||
| 238 | /// The prompt box gives the terminal back — whichever end ended it. | ||
| 239 | pub fn closeAsk( | ||
| 240 | w: Wall, | ||
| 241 | shared: *Shared, | ||
| 242 | prefix: *interact.PrefixFilter, | ||
| 243 | picker_sel: usize, | ||
| 244 | ) void { | ||
| 245 | // ONE function because the two ends had drifted: `relayout` CLEARS the | ||
| 246 | // screen, so a picker still open under the box owes a repaint, and the | ||
| 247 | // hangup path did not know it. A prompt can arrive over an open picker | ||
| 248 | // — that is why the answer has a buffer of its own — so a notice whose | ||
| 249 | // helper hung up left `picking` true on a blank screen until the next | ||
| 250 | // key. | ||
| 251 | // | ||
| 252 | // The answer buffer goes here and nowhere else: `feed` cannot zero it, | ||
| 253 | // because the action it returned borrows it. | ||
| 254 | prefix.askClose(); | ||
| 255 | shared.ask_open.store(false, .release); | ||
| 256 | // `relayout` bumps `repaint_gen`, which is the only thing that redraws | ||
| 257 | // a tile whose session said nothing while the box was up. | ||
| 258 | wall_layout.relayout(w, shared.sel); | ||
| 259 | if (!prefix.picking) return; | ||
| 260 | var foot: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; | ||
| 261 | const back = wall_picker.pickerRepaint(&foot, prefix, true); | ||
| 262 | wall_picker.paintPicker(shared, w.hosts, picker_sel, back.line); | ||
| 263 | } | ||
| 264 | |||
| 265 | /// Whether a popup owns the terminal. | ||
| 266 | pub fn popupOpen(shared: *const Shared) bool { | ||
| 267 | // Every tile paint asks: a rect redrawn under either box would erase | ||
| 268 | // what the user is reading, and the close bumps `repaint_gen` to bring | ||
| 269 | // the rects back. | ||
| 270 | return shared.picker_open.load(.acquire) or shared.ask_open.load(.acquire); | ||
| 271 | } | ||
| 272 | |||
| 205 | /// The rect a paint used, in the only terms a click can be resolved in. | 273 | /// The rect a paint used, in the only terms a click can be resolved in. |
| 206 | /// | 274 | /// |
| 207 | /// Absolute rows are counted from the oldest row the DAEMON still retains | 275 | /// Absolute rows are counted from the oldest row the DAEMON still retains |
| @@ -555,7 +623,7 @@ pub fn labelText( | |||
| 555 | /// when `label_rows` is nonzero: a one-tile wall owns every row and has no | 623 | /// when `label_rows` is nonzero: a one-tile wall owns every row and has no |
| 556 | /// bar to draw. | 624 | /// bar to draw. |
| 557 | pub fn paintLabelLocked(t: *Tile) void { | 625 | pub fn paintLabelLocked(t: *Tile) void { |
| 558 | if (t.shared.picker_open.load(.acquire)) return; | 626 | if (popupOpen(t.shared)) return; |
| 559 | // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in | 627 | // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in |
| 560 | // a capture, and must not depend on a font. The unfocused marker is | 628 | // a capture, and must not depend on a font. The unfocused marker is |
| 561 | // the same width, so labels do not shift as the focus moves. | 629 | // the same width, so labels do not shift as the focus moves. |
| @@ -622,7 +690,7 @@ pub fn tileBanner(t: *Tile, text: []const u8) void { | |||
| 622 | // Under the lock, for `tilePaintBegin`'s reason: read before it, a | 690 | // Under the lock, for `tilePaintBegin`'s reason: read before it, a |
| 623 | // banner that lost the race for `paint_mu` lands on a box row, and | 691 | // banner that lost the race for `paint_mu` lands on a box row, and |
| 624 | // `picker_stamp` suppresses the repaint that would repair it. | 692 | // `picker_stamp` suppresses the repaint that would repair it. |
| 625 | if (t.shared.picker_open.load(.acquire)) return; | 693 | if (popupOpen(t.shared)) return; |
| 626 | paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left); | 694 | paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left); |
| 627 | } | 695 | } |
| 628 | 696 | ||
| @@ -654,6 +722,11 @@ pub fn ringKeyboard(shared: *const Shared) void { | |||
| 654 | _ = std.posix.write(shared.kb_w, "\x00") catch {}; | 722 | _ = std.posix.write(shared.kb_w, "\x00") catch {}; |
| 655 | } | 723 | } |
| 656 | 724 | ||
| 725 | /// The same bell behind `askpass.Hooks`' runtime pair. | ||
| 726 | fn ringKeyboardCtx(ctx: *anyopaque) void { | ||
| 727 | ringKeyboard(@as(*const Shared, @ptrCast(@alignCast(ctx)))); | ||
| 728 | } | ||
| 729 | |||
| 657 | /// Both ends of both bells are non-blocking, which is what makes this safe | 730 | /// Both ends of both bells are non-blocking, which is what makes this safe |
| 658 | /// on a pipe nobody has rung. | 731 | /// on a pipe nobody has rung. |
| 659 | pub fn drainBell(fd: std.posix.fd_t) void { | 732 | pub fn drainBell(fd: std.posix.fd_t) void { |
| @@ -1220,6 +1293,10 @@ pub fn endAction( | |||
| 1220 | // the local daemon, so a wall reaching for a new session on its own | 1293 | // the local daemon, so a wall reaching for a new session on its own |
| 1221 | // machine is exactly the second case. | 1294 | // machine is exactly the second case. |
| 1222 | const gone_msg: ?[]const u8 = switch (reason) { | 1295 | const gone_msg: ?[]const u8 = switch (reason) { |
| 1296 | // Whatever the tile had been: the user REFUSED this dial, and a | ||
| 1297 | // wall that retried anyway would ask the same question every two | ||
| 1298 | // seconds for as long as it stayed up. | ||
| 1299 | .declined => "mux: prompt declined", | ||
| 1223 | .refused => "mux: cannot create a new session (daemon full?)", | 1300 | .refused => "mux: cannot create a new session (daemon full?)", |
| 1224 | .lost => if (t.ever_up.load(.acquire)) | 1301 | .lost => if (t.ever_up.load(.acquire)) |
| 1225 | null | 1302 | null |
| @@ -1281,6 +1358,9 @@ pub fn endAction( | |||
| 1281 | // ...and the one cause that IS knowable, which no daemon had a | 1358 | // ...and the one cause that IS knowable, which no daemon had a |
| 1282 | // part in. | 1359 | // part in. |
| 1283 | .no_thread => .{ .code = 1, .msg = "mux: could not start a thread for this session" }, | 1360 | .no_thread => .{ .code = 1, .msg = "mux: could not start a thread for this session" }, |
| 1361 | // Said in the user's own terms: they pressed Esc at a password | ||
| 1362 | // box, and no ssh diagnostic describes that better than they do. | ||
| 1363 | .declined => .{ .code = 1, .msg = "mux: prompt declined" }, | ||
| 1284 | }, | 1364 | }, |
| 1285 | }; | 1365 | }; |
| 1286 | // A session the PICKER started on a wall that is still there ends as a | 1366 | // A session the PICKER started on a wall that is still there ends as a |
| @@ -1590,6 +1670,34 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1590 | shared.kb_w = kb[1]; | 1670 | shared.kb_w = kb[1]; |
| 1591 | shared.sel = 0; | 1671 | shared.sel = 0; |
| 1592 | 1672 | ||
| 1673 | // ssh's prompts get somewhere to go, on a TERMINAL only: without one | ||
| 1674 | // there is no popup to answer with, so ssh keeps its own prompts and | ||
| 1675 | // fails them onto the row with its reason — half one's path, and a | ||
| 1676 | // visible one rather than a wait nobody can see. | ||
| 1677 | // | ||
| 1678 | // `$XDG_RUNTIME_DIR` and no fallback, for `sockpath`'s reason turned up | ||
| 1679 | // one notch: a guess lands in a shared /tmp, and a socket there is a | ||
| 1680 | // password prompt any local user could answer. | ||
| 1681 | var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 1682 | if (is_tty) { | ||
| 1683 | if (std.posix.getenv("XDG_RUNTIME_DIR")) |rt| { | ||
| 1684 | if (askpass.Listener.start(alloc, rt, .{ | ||
| 1685 | .ctx = &shared, | ||
| 1686 | .wake = ringKeyboardCtx, | ||
| 1687 | })) |l| { | ||
| 1688 | shared.prompts = l; | ||
| 1689 | // The image that is RUNNING, resolved through /proc, and | ||
| 1690 | // never a PATH walk: ssh execs this as its helper, and a | ||
| 1691 | // helper found by name is whichever mux the environment | ||
| 1692 | // happens to have (`spawn.selfExe`). | ||
| 1693 | shared.prompt_exe = spawn.selfExe(&ask_exe_buf); | ||
| 1694 | } else |_| {} | ||
| 1695 | } | ||
| 1696 | } | ||
| 1697 | // Before the raw-mode teardown below, so the socket is gone whichever | ||
| 1698 | // way this function leaves. | ||
| 1699 | defer if (shared.prompts) |l| l.stop(); | ||
| 1700 | |||
| 1593 | // Allocated at CAPACITY, not at length: the pump threads are detached | 1701 | // Allocated at CAPACITY, not at length: the pump threads are detached |
| 1594 | // and hold `*Tile` for the wall's whole life, so the array may never | 1702 | // and hold `*Tile` for the wall's whole life, so the array may never |
| 1595 | // move. `live` is how much of it is real. | 1703 | // move. `live` is how much of it is real. |
| @@ -1702,6 +1810,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1702 | // popup reopened by reflex is where it was left. Keyboard-thread | 1810 | // popup reopened by reflex is where it was left. Keyboard-thread |
| 1703 | // state, like the focus. | 1811 | // state, like the focus. |
| 1704 | var picker_sel: usize = 0; | 1812 | var picker_sel: usize = 0; |
| 1813 | // The prompt on screen, kept because the box is repainted on every | ||
| 1814 | // keystroke and the question does not come round again. | ||
| 1815 | var ask_prompt: askpass.Prompt = .{}; | ||
| 1705 | // Whether the picker on screen is one the WALL opened, not the user: | 1816 | // Whether the picker on screen is one the WALL opened, not the user: |
| 1706 | // an empty wall opens it once, and an Esc there has to be able to | 1817 | // an empty wall opens it once, and an Esc there has to be able to |
| 1707 | // leave the empty line showing rather than be re-opened over. | 1818 | // leave the empty line showing rather than be re-opened over. |
| @@ -1832,6 +1943,22 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1832 | }, | 1943 | }, |
| 1833 | } | 1944 | } |
| 1834 | } | 1945 | } |
| 1946 | // A box whose ssh has GONE, before a box that is arriving: the | ||
| 1947 | // FIDO notifier's helper is SIGTERMed the moment the touch lands, | ||
| 1948 | // and what is on screen then is a question nobody is waiting on. | ||
| 1949 | // Closing it here rather than on a key is the whole point — the | ||
| 1950 | // user should not have to dismiss it, and dismissing it would put | ||
| 1951 | // a live dial's pid in the decline ring. | ||
| 1952 | if (input.prefix.asking) { | ||
| 1953 | if (shared.prompts) |l| { | ||
| 1954 | if (!l.showing()) closeAsk(w, &shared, &input.prefix, picker_sel); | ||
| 1955 | } | ||
| 1956 | } | ||
| 1957 | // Every pass, not only on the bell: `endedTile`'s reason twice | ||
| 1958 | // over — one drained ring can carry two pumps' news — and a prompt | ||
| 1959 | // is an ssh already blocked on the answer. | ||
| 1960 | if (takeAsk(&shared, &input.prefix, &ask_prompt)) | ||
| 1961 | wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix); | ||
| 1835 | // Ends first, lists second: a session that exited is its pump's | 1962 | // Ends first, lists second: a session that exited is its pump's |
| 1836 | // news and arrives at once, while a list is up to a poll behind. | 1963 | // news and arrives at once, while a list is up to a poll behind. |
| 1837 | // Reading the list first would vanish the tile the exit code is on. | 1964 | // Reading the list first would vanish the tile the exit code is on. |
| @@ -1919,6 +2046,41 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1919 | 2046 | ||
| 1920 | const z = shared.sel; | 2047 | const z = shared.sel; |
| 1921 | const cmd = input.prefix.feed(b[0..n]); | 2048 | const cmd = input.prefix.feed(b[0..n]); |
| 2049 | // Over EVERYTHING, the picker included: ssh is blocked on this | ||
| 2050 | // answer, and the box that owns the keyboard is the one that | ||
| 2051 | // arrived last. Nothing is forwarded — the filter keeps every byte | ||
| 2052 | // while `asking`, which is what stops a password reaching a shell. | ||
| 2053 | if (input.prefix.asking or wall_picker.isAskAction(cmd.action)) { | ||
| 2054 | // A NOTICE takes no answer, whichever key ended it: ssh asked | ||
| 2055 | // nothing — "Confirm user presence for key ..." — and a decline | ||
| 2056 | // recorded here would park a tile whose dial is still going. | ||
| 2057 | // The box just leaves; the helper is ssh's to kill. | ||
| 2058 | const answerable = ask_prompt.kind != .notice; | ||
| 2059 | switch (cmd.action) { | ||
| 2060 | .ask_answer => |line| if (answerable) { | ||
| 2061 | if (shared.prompts) |l| l.answer(line); | ||
| 2062 | }, | ||
| 2063 | .ask_decline => if (answerable) { | ||
| 2064 | if (shared.prompts) |l| l.decline(); | ||
| 2065 | }, | ||
| 2066 | else => {}, | ||
| 2067 | } | ||
| 2068 | if (input.prefix.asking) { | ||
| 2069 | wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix); | ||
| 2070 | continue; | ||
| 2071 | } | ||
| 2072 | // ssh asks in sequence — a host key, then a password — and the | ||
| 2073 | // next helper is already waiting in the backlog. Reopening | ||
| 2074 | // BEFORE the close saves the user a screen that flashes back to | ||
| 2075 | // the wall between two halves of one login; `askOpen` takes the | ||
| 2076 | // answer buffer back the way `closeAsk` would have. | ||
| 2077 | if (takeAsk(&shared, &input.prefix, &ask_prompt)) { | ||
| 2078 | wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix); | ||
| 2079 | continue; | ||
| 2080 | } | ||
| 2081 | closeAsk(w, &shared, &input.prefix, picker_sel); | ||
| 2082 | continue; | ||
| 2083 | } | ||
| 1922 | // The picker is a MODE: while it is open every key is the popup's, | 2084 | // The picker is a MODE: while it is open every key is the popup's, |
| 1923 | // so neither the empty wall's arms below nor the tiles' ever see | 2085 | // so neither the empty wall's arms below nor the tiles' ever see |
| 1924 | // one — and the popup is the one thing that works on a wall with | 2086 | // one — and the popup is the one thing that works on a wall with |
| @@ -2086,6 +2248,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2086 | .pick_add_open, | 2248 | .pick_add_open, |
| 2087 | .pick_close, | 2249 | .pick_close, |
| 2088 | => {}, | 2250 | => {}, |
| 2251 | // ssh's own keys, answered above this switch for the reason | ||
| 2252 | // the picker's are: the box is the only thing on the screen | ||
| 2253 | // that a keystroke can be for. | ||
| 2254 | .ask_answer, .ask_decline => {}, | ||
| 2089 | .detach => { | 2255 | .detach => { |
| 2090 | // The slot goes back to the daemon before this process | 2256 | // The slot goes back to the daemon before this process |
| 2091 | // does — the pump writes the frame, because a Transport | 2257 | // does — the pump writes the frame, because a Transport |
| @@ -2200,6 +2366,11 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2200 | } | 2366 | } |
| 2201 | 2367 | ||
| 2202 | shared.running.store(false, .release); | 2368 | shared.running.store(false, .release); |
| 2369 | // The socket leaves the filesystem here rather than in the defer above, | ||
| 2370 | // which this function's `exit` never reaches. `retire` and not `stop`: | ||
| 2371 | // a detached pump may be inside `declined` on it, and a free in the | ||
| 2372 | // window before the exit is a use-after-free. | ||
| 2373 | if (shared.prompts) |l| l.retire(); | ||
| 2203 | // Taken and never released: no tile paints across the restore. The | 2374 | // Taken and never released: no tile paints across the restore. The |
| 2204 | // pump threads are detached and die with the process; joining them | 2375 | // pump threads are detached and die with the process; joining them |
| 2205 | // could wait on a blocked readFrame forever. | 2376 | // could wait on a blocked readFrame forever. |