5d9b51fe
refactor: the handoff recipe is an argv — ssh is exec'd, not a shell line
a73x 2026-08-29 00:35
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -204,11 +204,14 @@ QUIC, leaving no ssh process behind; the coordinates are cached under | |||
| 204 | `~/.cache/mux/hosts/`, so later attaches dial QUIC straight away and skip | 204 | `~/.cache/mux/hosts/`, so later attaches dial QUIC straight away and skip |
| 205 | ssh entirely. Where inbound UDP is blocked, the attach lands over ssh one | 205 | ssh entirely. Where inbound UDP is blocked, the attach lands over ssh one |
| 206 | deadline (~2s) later, saying so in a single line, and the session is the | 206 | deadline (~2s) later, saying so in a single line, and the session is the |
| 207 | same session either way. HOST **is** interpolated into a shell command, so | 207 | same session either way. HOST is one argv word handed to `ssh`, never a |
| 208 | it must be trusted: feeding it unreviewed from an inventory file or a | 208 | shell line: `mux` execs ssh itself, so nothing local expands or splits it. |
| 209 | cloud API is handing that source a shell. `--via` accepts any command that | 209 | |
| 210 | exposes the daemon's socket on stdio, for custom socket paths or | 210 | `--via CMD` accepts any command that exposes the daemon's socket on stdio, |
| 211 | transports. | 211 | for custom socket paths or transports. CMD is split on whitespace into |
| 212 | argv and exec'd directly — no shell, so no quoting, no variables, no | ||
| 213 | pipelines, and a command that needs any of those is not supported: put it | ||
| 214 | in a script and name the script. | ||
| 212 | 215 | ||
| 213 | If the link drops, the client reconnects and resumes by itself; a bare | 216 | If the link drops, the client reconnects and resumes by itself; a bare |
| 214 | `Ctrl-\` gives up waiting — while reconnecting there is no session to | 217 | `Ctrl-\` gives up waiting — while reconnecting there is no session to |
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,8 @@ | |||
| 1 | //! `mux` with no mode letter — the client. `mux [--sock PATH]` attaches | 1 | //! `mux` with no mode letter — the client. `mux [--sock PATH]` attaches |
| 2 | //! to the local daemon; | 2 | //! to the local daemon; |
| 3 | //! `mux --via CMD` attaches over CMD's stdio instead (any command that | 3 | //! `mux --via CMD` attaches over CMD's stdio instead (any command that |
| 4 | //! exposes a session socket as a byte pipe, e.g. `ssh host mux d proxy`); | 4 | //! exposes a session socket as a byte pipe, e.g. `ssh host mux d proxy`, |
| 5 | //! split on whitespace and exec'd — no shell, so no quoting or pipelines); | ||
| 5 | //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC | 6 | //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC |
| 6 | //! coordinates and carries the session only if the QUIC dial does not. | 7 | //! coordinates and carries the session only if the QUIC dial does not. |
| 7 | //! | 8 | //! |
| @@ -45,6 +46,7 @@ const usage = | |||
| 45 | \\ quic://HOST[:PORT] (PORT defaults to 4433) uses --key FILE, | 46 | \\ quic://HOST[:PORT] (PORT defaults to 4433) uses --key FILE, |
| 46 | \\ MUX_KEY_FILE, or ~/.config/mux/key; the daemon must be running with a | 47 | \\ MUX_KEY_FILE, or ~/.config/mux/key; the daemon must be running with a |
| 47 | \\ matching --quic and key | 48 | \\ matching --quic and key |
| 49 | \\ --via CMD is argv words, exec'd directly: no shell, no quoting | ||
| 48 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | 50 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed |
| 49 | \\ [--session NAME] attaches to (or creates) a named session instead of | 51 | \\ [--session NAME] attaches to (or creates) a named session instead of |
| 50 | \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' | 52 | \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' |
| @@ -370,8 +372,8 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 370 | return wallview.runAttach(alloc, .{ | 372 | return wallview.runAttach(alloc, .{ |
| 371 | .hand = .{ | 373 | .hand = .{ |
| 372 | .host = h.name, | 374 | .host = h.name, |
| 373 | .ssh_cmd = r.ssh_cmd, | 375 | .ssh_argv = r.ssh_argv, |
| 374 | .start_cmd = r.start_cmd, | 376 | .start_argv = r.start_argv, |
| 375 | .cache_path = r.cache_path, | 377 | .cache_path = r.cache_path, |
| 376 | .idle_ms = h.idle_ms, | 378 | .idle_ms = h.idle_ms, |
| 377 | // The entry dial: `mux HOST` is the user asking, in person, | 379 | // The entry dial: `mux HOST` is the user asking, in person, |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -186,13 +186,13 @@ pub const HandoffTarget = struct { | |||
| 186 | /// The word the user typed. ssh's business entirely (aliases, `user@`, | 186 | /// The word the user typed. ssh's business entirely (aliases, `user@`, |
| 187 | /// ProxyJump); the QUIC dial uses `handoff.dialHost(host)`. | 187 | /// ProxyJump); the QUIC dial uses `handoff.dialHost(host)`. |
| 188 | host: []const u8, | 188 | host: []const u8, |
| 189 | /// `ssh <host> mux d endpoint`, prebuilt by mux_main — it has the | 189 | /// `ssh <host> mux d endpoint` as argv, prebuilt by mux_main — it has |
| 190 | /// allocator, and it builds this once for the whole session. | 190 | /// the allocator, and it builds this once for the whole session. |
| 191 | ssh_cmd: []const u8, | 191 | ssh_argv: []const []const u8, |
| 192 | /// `ssh <host> mux d start`, from the same `handoff.recipeFor` call. | 192 | /// `ssh <host> mux d start`, from the same `handoff.recipeFor` call. |
| 193 | /// Empty is "nothing to start": the dial either finds a daemon or does | 193 | /// Empty is "nothing to start": the dial either finds a daemon or does |
| 194 | /// not. | 194 | /// not. |
| 195 | start_cmd: []const u8 = "", | 195 | start_argv: []const []const u8 = &.{}, |
| 196 | /// Where the last announce is remembered. Null means never cache (an | 196 | /// Where the last announce is remembered. Null means never cache (an |
| 197 | /// uncacheable host, or no resolvable cache directory): every attach is | 197 | /// uncacheable host, or no resolvable cache directory): every attach is |
| 198 | /// then cold, which costs time and stays correct. | 198 | /// then cold, which costs time and stays correct. |
| @@ -211,7 +211,7 @@ pub const HandoffTarget = struct { | |||
| 211 | /// a live session's stderr into the alternate screen and corrupt the | 211 | /// a live session's stderr into the alternate screen and corrupt the |
| 212 | /// paint, to say what the [reconnecting] banner is already saying. | 212 | /// paint, to say what the [reconnecting] banner is already saying. |
| 213 | /// | 213 | /// |
| 214 | /// START a daemon that was not there, via `start_cmd`. `mux d endpoint` | 214 | /// START a daemon that was not there, via `start_argv`. `mux d endpoint` |
| 215 | /// no longer does it: a wall polls every listed host once a second, and | 215 | /// no longer does it: a wall polls every listed host once a second, and |
| 216 | /// a poll that starts daemons undoes a `mux d stop` a second after it is | 216 | /// a poll that starts daemons undoes a `mux d stop` a second after it is |
| 217 | /// typed. | 217 | /// typed. |
| @@ -274,10 +274,16 @@ pub const Transport = struct { | |||
| 274 | qout: std.ArrayList(u8) = .empty, | 274 | qout: std.ArrayList(u8) = .empty, |
| 275 | alloc: std.mem.Allocator = undefined, | 275 | alloc: std.mem.Allocator = undefined, |
| 276 | 276 | ||
| 277 | /// One `/bin/sh -c` child with its stdio piped: what both `--via` and | 277 | /// One exec'd child with its stdio piped: what both `--via` and the |
| 278 | /// the handoff's coordination ssh need, spelled once. | 278 | /// handoff's coordination ssh need, spelled once. argv, never a shell |
| 279 | fn spawnPipe(alloc: std.mem.Allocator, cmd: []const u8) !std.process.Child { | 279 | /// line — the product runs `ssh` and the user's own `--via` program, |
| 280 | var child = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc); | 280 | /// and neither is worth a shell's expansions between us and it. |
| 281 | /// | ||
| 282 | /// `argv` need not outlive the call: `std.process.Child` copies it into | ||
| 283 | /// its own arena before the fork. "spawnPipe: the child is exec'd from | ||
| 284 | /// a copy" is that claim, asserted. | ||
| 285 | fn spawnPipe(alloc: std.mem.Allocator, argv: []const []const u8) !std.process.Child { | ||
| 286 | var child = std.process.Child.init(argv, alloc); | ||
| 281 | child.stdin_behavior = .Pipe; | 287 | child.stdin_behavior = .Pipe; |
| 282 | child.stdout_behavior = .Pipe; | 288 | child.stdout_behavior = .Pipe; |
| 283 | // Inherited, not piped: ssh's diagnostics (auth failure, unknown | 289 | // Inherited, not piped: ssh's diagnostics (auth failure, unknown |
| @@ -289,6 +295,19 @@ pub const Transport = struct { | |||
| 289 | return child; | 295 | return child; |
| 290 | } | 296 | } |
| 291 | 297 | ||
| 298 | /// `--via CMD` is argv WORDS: mux execs the command, so a quote, a | ||
| 299 | /// variable or a pipeline in CMD is bytes rather than syntax. The words | ||
| 300 | /// borrow from `cmd`; only the pointer array is allocated. | ||
| 301 | fn viaArgv(alloc: std.mem.Allocator, cmd: []const u8) ![]const []const u8 { | ||
| 302 | var words: std.ArrayList([]const u8) = .empty; | ||
| 303 | errdefer words.deinit(alloc); | ||
| 304 | var it = std.mem.tokenizeAny(u8, cmd, " \t\r\n"); | ||
| 305 | while (it.next()) |w| try words.append(alloc, w); | ||
| 306 | // `std.process.Child` would spawn argv[0] out of an empty slice. | ||
| 307 | if (words.items.len == 0) return error.EmptyViaCommand; | ||
| 308 | return words.toOwnedSlice(alloc); | ||
| 309 | } | ||
| 310 | |||
| 292 | /// The transport a piped child IS: its stdio is the wire, and the child | 311 | /// The transport a piped child IS: its stdio is the wire, and the child |
| 293 | /// itself is what has to be owned and reaped. | 312 | /// itself is what has to be owned and reaped. |
| 294 | fn pipeTransport(child: std.process.Child) Transport { | 313 | fn pipeTransport(child: std.process.Child) Transport { |
| @@ -342,7 +361,11 @@ pub const Transport = struct { | |||
| 342 | const addr = try quic.parseAddr(alloc, q.host_port); | 361 | const addr = try quic.parseAddr(alloc, q.host_port); |
| 343 | return quicTransport(alloc, addr, key, q.idle_ms, q.deadline_ms, carry, abort_fd); | 362 | return quicTransport(alloc, addr, key, q.idle_ms, q.deadline_ms, carry, abort_fd); |
| 344 | }, | 363 | }, |
| 345 | .via => |cmd| return pipeTransport(try spawnPipe(alloc, cmd)), | 364 | .via => |cmd| { |
| 365 | const argv = try viaArgv(alloc, cmd); | ||
| 366 | defer alloc.free(argv); | ||
| 367 | return pipeTransport(try spawnPipe(alloc, argv)); | ||
| 368 | }, | ||
| 346 | .sock => |path| { | 369 | .sock => |path| { |
| 347 | const stream = try std.net.connectUnixSocket(path); | 370 | const stream = try std.net.connectUnixSocket(path); |
| 348 | return .{ | 371 | return .{ |
| @@ -386,7 +409,7 @@ pub const Transport = struct { | |||
| 386 | } else |_| {} // no cache yet, or one we cannot use: cold path | 409 | } else |_| {} // no cache yet, or one we cannot use: cold path |
| 387 | } | 410 | } |
| 388 | 411 | ||
| 389 | var child = try spawnPipe(alloc, h.ssh_cmd); | 412 | var child = try spawnPipe(alloc, h.ssh_argv); |
| 390 | errdefer { | 413 | errdefer { |
| 391 | _ = child.kill() catch {}; | 414 | _ = child.kill() catch {}; |
| 392 | } | 415 | } |
| @@ -467,7 +490,7 @@ pub const Transport = struct { | |||
| 467 | // `announceFailed` is that "ssh worked, the announce did not" | 490 | // `announceFailed` is that "ssh worked, the announce did not" |
| 468 | // shape: a start is worth trying only when the far end was | 491 | // shape: a start is worth trying only when the far end was |
| 469 | // reached and had nothing to say. | 492 | // reached and had nothing to say. |
| 470 | if (!h.asked or h.start_cmd.len == 0 or !announceFailed(err)) return err; | 493 | if (!h.asked or h.start_argv.len == 0 or !announceFailed(err)) return err; |
| 471 | // The remote's refusal, told apart from ssh's own by the code | 494 | // The remote's refusal, told apart from ssh's own by the code |
| 472 | // it exited with: `mux d endpoint` refuses an empty box with 1, | 495 | // it exited with: `mux d endpoint` refuses an empty box with 1, |
| 473 | // and ssh reports its OWN failures as 255 while passing a | 496 | // and ssh reports its OWN failures as 255 while passing a |
| @@ -483,8 +506,8 @@ pub const Transport = struct { | |||
| 483 | // A start that did not take is reported as the announce failure | 506 | // A start that did not take is reported as the announce failure |
| 484 | // it began as: `mux d start` has already said its own piece on | 507 | // it began as: `mux d start` has already said its own piece on |
| 485 | // stderr, and a second guess over the top of it would be worse. | 508 | // stderr, and a second guess over the top of it would be worse. |
| 486 | if (!runStart(alloc, h.start_cmd, h.quiet)) return err; | 509 | if (!runStart(alloc, h.start_argv, h.quiet)) return err; |
| 487 | child.* = try spawnPipe(alloc, h.ssh_cmd); | 510 | child.* = try spawnPipe(alloc, h.ssh_argv); |
| 488 | // ONCE. A box that cannot hold a daemon costs this attach one | 511 | // ONCE. A box that cannot hold a daemon costs this attach one |
| 489 | // extra round trip; a loop would cost it the afternoon. | 512 | // extra round trip; a loop would cost it the afternoon. |
| 490 | return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd); | 513 | return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd); |
| @@ -533,8 +556,8 @@ pub const Transport = struct { | |||
| 533 | } | 556 | } |
| 534 | 557 | ||
| 535 | /// Waits: nothing may dial the far end again until it has a verdict. | 558 | /// Waits: nothing may dial the far end again until it has a verdict. |
| 536 | fn runStart(alloc: std.mem.Allocator, cmd: []const u8, quiet: bool) bool { | 559 | fn runStart(alloc: std.mem.Allocator, argv: []const []const u8, quiet: bool) bool { |
| 537 | var c = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc); | 560 | var c = std.process.Child.init(argv, alloc); |
| 538 | // stdout and stderr inherited, like `spawnPipe`'s ssh: the progress | 561 | // stdout and stderr inherited, like `spawnPipe`'s ssh: the progress |
| 539 | // and the verdict are the user's only account of a wait they are | 562 | // and the verdict are the user's only account of a wait they are |
| 540 | // sitting through. stdin is NOT — ssh would eat the keystrokes the | 563 | // sitting through. stdin is NOT — ssh would eat the keystrokes the |
| @@ -864,7 +887,7 @@ pub const OpenFailure = struct { | |||
| 864 | /// | 887 | /// |
| 865 | /// Not derived, and it cannot be: PATH_MAX would bound the key and socket | 888 | /// Not derived, and it cannot be: PATH_MAX would bound the key and socket |
| 866 | /// paths, but the longest operands are argv strings — a `--via` command | 889 | /// paths, but the longest operands are argv strings — a `--via` command |
| 867 | /// line, a handoff's `ssh_cmd`, a `host_port` — whose only ceiling is | 890 | /// line, a handoff's ssh argv, a `host_port` — whose only ceiling is |
| 868 | /// ARG_MAX, and sizing for that would put a megabyte on the stack to | 891 | /// ARG_MAX, and sizing for that would put a megabyte on the stack to |
| 869 | /// print one line. So this is a chosen number, picked to put truncation | 892 | /// print one line. So this is a chosen number, picked to put truncation |
| 870 | /// out of reach of any real command, and it is `failedMsg`'s truncation | 893 | /// out of reach of any real command, and it is `failedMsg`'s truncation |
| @@ -1345,6 +1368,69 @@ test "Transport.open: a --via target yields a pipe, a --sock target an fd" { | |||
| 1345 | try std.testing.expect(s.link == .fd); | 1368 | try std.testing.expect(s.link == .fd); |
| 1346 | } | 1369 | } |
| 1347 | 1370 | ||
| 1371 | test "--via: the words reach the program verbatim — no shell splits, expands or quotes them" { | ||
| 1372 | const alloc = std.testing.allocator; | ||
| 1373 | var tmp = try TmpDir.make(); | ||
| 1374 | defer tmp.cleanup(); | ||
| 1375 | |||
| 1376 | // The oracle is the CHILD's own view of its argv. A shell between us | ||
| 1377 | // and it would eat the `;`, expand `$HOME` and drop the quotes, and | ||
| 1378 | // every one of those would show up here as a different byte. | ||
| 1379 | const prog = try std.fmt.allocPrint(alloc, "{s}/echoargs", .{tmp.path()}); | ||
| 1380 | defer alloc.free(prog); | ||
| 1381 | try std.fs.cwd().writeFile(.{ | ||
| 1382 | .sub_path = prog, | ||
| 1383 | .data = "#!/bin/sh\nfor a in \"$@\"; do printf '<%s>' \"$a\"; done\n", | ||
| 1384 | .flags = .{ .mode = 0o755 }, | ||
| 1385 | }); | ||
| 1386 | |||
| 1387 | const cmd = try std.fmt.allocPrint(alloc, "{s} a;b $HOME 'q'", .{prog}); | ||
| 1388 | defer alloc.free(cmd); | ||
| 1389 | var v = try Transport.open(alloc, .{ .via = cmd }, null, -1); | ||
| 1390 | defer v.close(); | ||
| 1391 | |||
| 1392 | // To EOF: the child writes one arg per printf, so a single read sees | ||
| 1393 | // only the first word and would pass on a shell that ate the rest. | ||
| 1394 | var buf: [512]u8 = undefined; | ||
| 1395 | var got: usize = 0; | ||
| 1396 | while (true) { | ||
| 1397 | const n = std.posix.read(v.conn.r, buf[got..]) catch 0; | ||
| 1398 | if (n == 0) break; | ||
| 1399 | got += n; | ||
| 1400 | } | ||
| 1401 | try std.testing.expectEqualStrings("<a;b><$HOME><'q'>", buf[0..got]); | ||
| 1402 | } | ||
| 1403 | |||
| 1404 | test "--via: a command of nothing but blanks names no program" { | ||
| 1405 | try std.testing.expectError( | ||
| 1406 | error.EmptyViaCommand, | ||
| 1407 | Transport.viaArgv(std.testing.allocator, " \t "), | ||
| 1408 | ); | ||
| 1409 | } | ||
| 1410 | |||
| 1411 | test "spawnPipe: the child is exec'd from a copy — an argv freed after spawn still ran" { | ||
| 1412 | // The lifetime `Transport.open`'s `--via` arm depends on: it frees the | ||
| 1413 | // argv the moment spawn returns. If std ever kept the slice instead of | ||
| 1414 | // duplicating it, that free would be a use-after-free nothing else here | ||
| 1415 | // would catch — the child would have exec'd correctly already. | ||
| 1416 | const alloc = std.testing.allocator; | ||
| 1417 | const argv = try alloc.alloc([]const u8, 2); | ||
| 1418 | argv[0] = "/bin/echo"; | ||
| 1419 | argv[1] = "copied"; | ||
| 1420 | var child = try Transport.spawnPipe(alloc, argv); | ||
| 1421 | alloc.free(argv); | ||
| 1422 | // Reuse the freed pages before reading, so a std that kept the pointer | ||
| 1423 | // is reading somebody else's bytes rather than its own stale ones. | ||
| 1424 | const churn = try alloc.alloc([]const u8, 2); | ||
| 1425 | @memset(churn, "xxxxxxx"); | ||
| 1426 | alloc.free(churn); | ||
| 1427 | |||
| 1428 | var buf: [64]u8 = undefined; | ||
| 1429 | const n = try std.posix.read(child.stdout.?.handle, &buf); | ||
| 1430 | try std.testing.expectEqualStrings("copied\n", buf[0..n]); | ||
| 1431 | _ = try child.kill(); | ||
| 1432 | } | ||
| 1433 | |||
| 1348 | test "handoff: endpoint-none rides the open pipe with no deadline paid" { | 1434 | test "handoff: endpoint-none rides the open pipe with no deadline paid" { |
| 1349 | // A fake `ssh HOST mux d endpoint` that announces `none`. What this pins | 1435 | // A fake `ssh HOST mux d endpoint` that announces `none`. What this pins |
| 1350 | // is Transport.open's DECISION — the announce-less remote gets a | 1436 | // is Transport.open's DECISION — the announce-less remote gets a |
| @@ -1362,7 +1448,7 @@ test "handoff: endpoint-none rides the open pipe with no deadline paid" { | |||
| 1362 | const t0 = std.time.milliTimestamp(); | 1448 | const t0 = std.time.milliTimestamp(); |
| 1363 | var t = try Transport.open(alloc, .{ .hand = .{ | 1449 | var t = try Transport.open(alloc, .{ .hand = .{ |
| 1364 | .host = "fake", | 1450 | .host = "fake", |
| 1365 | .ssh_cmd = "printf 'endpoint none\\n'; cat >/dev/null", | 1451 | .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" }, |
| 1366 | .cache_path = null, | 1452 | .cache_path = null, |
| 1367 | .deadline_ms = 200, | 1453 | .deadline_ms = 200, |
| 1368 | } }, &carry, std.posix.STDIN_FILENO); | 1454 | } }, &carry, std.posix.STDIN_FILENO); |
| @@ -1408,7 +1494,7 @@ test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { | |||
| 1408 | const t0 = std.time.milliTimestamp(); | 1494 | const t0 = std.time.milliTimestamp(); |
| 1409 | var t = try Transport.open(alloc, .{ .hand = .{ | 1495 | var t = try Transport.open(alloc, .{ .hand = .{ |
| 1410 | .host = "127.0.0.1", | 1496 | .host = "127.0.0.1", |
| 1411 | .ssh_cmd = "printf 'endpoint 1 " ++ ("ab" ** 32) ++ "\\n'; cat >/dev/null", | 1497 | .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint 1 " ++ ("ab" ** 32) ++ "\\n'; cat >/dev/null" }, |
| 1412 | .cache_path = null, | 1498 | .cache_path = null, |
| 1413 | .deadline_ms = 300, | 1499 | .deadline_ms = 300, |
| 1414 | } }, &carry, std.posix.STDIN_FILENO); | 1500 | } }, &carry, std.posix.STDIN_FILENO); |
| @@ -1488,7 +1574,7 @@ test "handoff: the announce wait still answers the abort key" { | |||
| 1488 | const t0 = std.time.milliTimestamp(); | 1574 | const t0 = std.time.milliTimestamp(); |
| 1489 | try std.testing.expectError(error.UserAbort, Transport.open(alloc, .{ .hand = .{ | 1575 | try std.testing.expectError(error.UserAbort, Transport.open(alloc, .{ .hand = .{ |
| 1490 | .host = "fake", | 1576 | .host = "fake", |
| 1491 | .ssh_cmd = "sleep 2", | 1577 | .ssh_argv = &.{ "/bin/sh", "-c", "sleep 2" }, |
| 1492 | .cache_path = null, | 1578 | .cache_path = null, |
| 1493 | .deadline_ms = 200, | 1579 | .deadline_ms = 200, |
| 1494 | } }, null, std.posix.STDIN_FILENO)); | 1580 | } }, null, std.posix.STDIN_FILENO)); |
| @@ -1513,7 +1599,7 @@ test "handoff: a first attach leaves stdin to ssh while the announce is pending | |||
| 1513 | 1599 | ||
| 1514 | var t = try Transport.open(alloc, .{ .hand = .{ | 1600 | var t = try Transport.open(alloc, .{ .hand = .{ |
| 1515 | .host = "fake", | 1601 | .host = "fake", |
| 1516 | .ssh_cmd = "printf 'endpoint none\\n'; cat >/dev/null", | 1602 | .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" }, |
| 1517 | .cache_path = null, | 1603 | .cache_path = null, |
| 1518 | .deadline_ms = 200, | 1604 | .deadline_ms = 200, |
| 1519 | } }, &carry, std.posix.STDIN_FILENO); | 1605 | } }, &carry, std.posix.STDIN_FILENO); |
| @@ -1542,7 +1628,7 @@ test "handoff: abort_fd -1 means no abort channel — fd 0 is never read" { | |||
| 1542 | // sibling test above proves it fires within the same window). | 1628 | // sibling test above proves it fires within the same window). |
| 1543 | var t = try Transport.open(alloc, .{ .hand = .{ | 1629 | var t = try Transport.open(alloc, .{ .hand = .{ |
| 1544 | .host = "fake", | 1630 | .host = "fake", |
| 1545 | .ssh_cmd = "printf 'endpoint none\\n'; cat >/dev/null", | 1631 | .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" }, |
| 1546 | .cache_path = null, | 1632 | .cache_path = null, |
| 1547 | .deadline_ms = 200, | 1633 | .deadline_ms = 200, |
| 1548 | } }, &carry, -1); | 1634 | } }, &carry, -1); |
| @@ -1601,8 +1687,8 @@ test "openHandoff: a HandoffTarget nobody configured starts nothing" { | |||
| 1601 | 1687 | ||
| 1602 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 1688 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ |
| 1603 | .host = "fake", | 1689 | .host = "fake", |
| 1604 | .ssh_cmd = ssh_cmd, | 1690 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, |
| 1605 | .start_cmd = start_cmd, | 1691 | .start_argv = &.{ "/bin/sh", "-c", start_cmd }, |
| 1606 | .cache_path = null, | 1692 | .cache_path = null, |
| 1607 | .deadline_ms = 200, | 1693 | .deadline_ms = 200, |
| 1608 | } }, &carry, std.posix.STDIN_FILENO)); | 1694 | } }, &carry, std.posix.STDIN_FILENO)); |
| @@ -1637,8 +1723,8 @@ test "openHandoff: the dial a user ASKED for starts the daemon it did not find, | |||
| 1637 | 1723 | ||
| 1638 | var t = try Transport.open(alloc, .{ .hand = .{ | 1724 | var t = try Transport.open(alloc, .{ .hand = .{ |
| 1639 | .host = "fake", | 1725 | .host = "fake", |
| 1640 | .ssh_cmd = ssh_cmd, | 1726 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, |
| 1641 | .start_cmd = start_cmd, | 1727 | .start_argv = &.{ "/bin/sh", "-c", start_cmd }, |
| 1642 | .cache_path = null, | 1728 | .cache_path = null, |
| 1643 | .deadline_ms = 200, | 1729 | .deadline_ms = 200, |
| 1644 | .asked = true, | 1730 | .asked = true, |
| @@ -1669,8 +1755,8 @@ test "openHandoff: a dial nobody asked for reports the failure and starts nothin | |||
| 1669 | 1755 | ||
| 1670 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 1756 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ |
| 1671 | .host = "fake", | 1757 | .host = "fake", |
| 1672 | .ssh_cmd = ssh_cmd, | 1758 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, |
| 1673 | .start_cmd = start_cmd, | 1759 | .start_argv = &.{ "/bin/sh", "-c", start_cmd }, |
| 1674 | .cache_path = null, | 1760 | .cache_path = null, |
| 1675 | .deadline_ms = 200, | 1761 | .deadline_ms = 200, |
| 1676 | .asked = false, | 1762 | .asked = false, |
| @@ -1705,8 +1791,8 @@ test "openHandoff: an ssh still alive after its stdout closed keeps its own exit | |||
| 1705 | 1791 | ||
| 1706 | var t = try Transport.open(alloc, .{ .hand = .{ | 1792 | var t = try Transport.open(alloc, .{ .hand = .{ |
| 1707 | .host = "fake", | 1793 | .host = "fake", |
| 1708 | .ssh_cmd = ssh_cmd, | 1794 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, |
| 1709 | .start_cmd = start_cmd, | 1795 | .start_argv = &.{ "/bin/sh", "-c", start_cmd }, |
| 1710 | .cache_path = null, | 1796 | .cache_path = null, |
| 1711 | .deadline_ms = 200, | 1797 | .deadline_ms = 200, |
| 1712 | .asked = true, | 1798 | .asked = true, |
| @@ -1737,8 +1823,8 @@ test "openHandoff: a start that does not help is tried once — a second announc | |||
| 1737 | 1823 | ||
| 1738 | const h: HandoffTarget = .{ | 1824 | const h: HandoffTarget = .{ |
| 1739 | .host = "fake", | 1825 | .host = "fake", |
| 1740 | .ssh_cmd = ssh_cmd, | 1826 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, |
| 1741 | .start_cmd = start_cmd, | 1827 | .start_argv = &.{ "/bin/sh", "-c", start_cmd }, |
| 1742 | .cache_path = null, | 1828 | .cache_path = null, |
| 1743 | .deadline_ms = 200, | 1829 | .deadline_ms = 200, |
| 1744 | .asked = true, | 1830 | .asked = true, |
| @@ -1791,8 +1877,8 @@ test "openHandoff: ssh's own failure is not a box without a daemon — no start, | |||
| 1791 | 1877 | ||
| 1792 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 1878 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ |
| 1793 | .host = "fake", | 1879 | .host = "fake", |
| 1794 | .ssh_cmd = ssh_cmd, | 1880 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, |
| 1795 | .start_cmd = start_cmd, | 1881 | .start_argv = &.{ "/bin/sh", "-c", start_cmd }, |
| 1796 | .cache_path = null, | 1882 | .cache_path = null, |
| 1797 | .deadline_ms = 200, | 1883 | .deadline_ms = 200, |
| 1798 | .asked = true, | 1884 | .asked = true, |
| @@ -1837,8 +1923,8 @@ test "runStart: a caller that owns a screen gets a quiet start; one that does no | |||
| 1837 | 1923 | ||
| 1838 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 1924 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ |
| 1839 | .host = "fake", | 1925 | .host = "fake", |
| 1840 | .ssh_cmd = ssh_cmd, | 1926 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, |
| 1841 | .start_cmd = start_cmd, | 1927 | .start_argv = &.{ "/bin/sh", "-c", start_cmd }, |
| 1842 | .cache_path = null, | 1928 | .cache_path = null, |
| 1843 | .deadline_ms = 200, | 1929 | .deadline_ms = 200, |
| 1844 | .asked = true, | 1930 | .asked = true, |
| @@ -1941,7 +2027,7 @@ test "openFailure: a handoff separates a missing announce from an ssh that never | |||
| 1941 | var buf: [open_err_len]u8 = undefined; | 2027 | var buf: [open_err_len]u8 = undefined; |
| 1942 | const h: Target = .{ .hand = .{ | 2028 | const h: Target = .{ .hand = .{ |
| 1943 | .host = "box", | 2029 | .host = "box", |
| 1944 | .ssh_cmd = "ssh box mux d endpoint", | 2030 | .ssh_argv = &.{ "/bin/sh", "-c", "ssh box mux d endpoint" }, |
| 1945 | .cache_path = null, | 2031 | .cache_path = null, |
| 1946 | } }; | 2032 | } }; |
| 1947 | 2033 | ||
| @@ -2124,7 +2210,7 @@ test "client: a target spells itself back as one wall argument per session" { | |||
| 2124 | "vm1#work", | 2210 | "vm1#work", |
| 2125 | try wallSpelling(&buf, .{ .hand = .{ | 2211 | try wallSpelling(&buf, .{ .hand = .{ |
| 2126 | .host = "vm1", | 2212 | .host = "vm1", |
| 2127 | .ssh_cmd = "ssh vm1 mux d endpoint", | 2213 | .ssh_argv = &.{ "/bin/sh", "-c", "ssh vm1 mux d endpoint" }, |
| 2128 | .cache_path = null, | 2214 | .cache_path = null, |
| 2129 | } }, "work"), | 2215 | } }, "work"), |
| 2130 | ); | 2216 | ); |
| @@ -2141,8 +2227,8 @@ test "client: every spelling this writes, the wall grammar reads back the same" | |||
| 2141 | .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" }, | 2227 | .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" }, |
| 2142 | // `user@host`: nothing inside it parses, which is what keeps ssh's | 2228 | // `user@host`: nothing inside it parses, which is what keeps ssh's |
| 2143 | // own config working — and the wall must not start parsing it now. | 2229 | // own config working — and the wall must not start parsing it now. |
| 2144 | .{ Target{ .hand = .{ .host = "ubuntu@sandbox-a609d8", .ssh_cmd = "x", .cache_path = null } }, "build" }, | 2230 | .{ Target{ .hand = .{ .host = "ubuntu@sandbox-a609d8", .ssh_argv = &.{ "/bin/sh", "-c", "x" }, .cache_path = null } }, "build" }, |
| 2145 | .{ Target{ .hand = .{ .host = "vm1", .ssh_cmd = "x", .cache_path = null } }, "0" }, | 2231 | .{ Target{ .hand = .{ .host = "vm1", .ssh_argv = &.{ "/bin/sh", "-c", "x" }, .cache_path = null } }, "0" }, |
| 2146 | // The port rides through untouched, and so does its absence. | 2232 | // The port rides through untouched, and so does its absence. |
| 2147 | .{ Target{ .quic = .{ .host_port = "box:8787", .key_path = "/k" } }, "work" }, | 2233 | .{ Target{ .quic = .{ .host_port = "box:8787", .key_path = "/k" } }, "work" }, |
| 2148 | .{ Target{ .quic = .{ .host_port = "box", .key_path = "/k" } }, "0" }, | 2234 | .{ Target{ .quic = .{ .host_port = "box", .key_path = "/k" } }, "0" }, |
| @@ -2218,7 +2304,7 @@ test "hydratedCreates: a saved LOCAL line may create the session; every remote s | |||
| 2218 | } })); | 2304 | } })); |
| 2219 | try std.testing.expect(!hydratedCreates(.{ .hand = .{ | 2305 | try std.testing.expect(!hydratedCreates(.{ .hand = .{ |
| 2220 | .host = "box", | 2306 | .host = "box", |
| 2221 | .ssh_cmd = "ssh box mux d endpoint", | 2307 | .ssh_argv = &.{ "/bin/sh", "-c", "ssh box mux d endpoint" }, |
| 2222 | .cache_path = null, | 2308 | .cache_path = null, |
| 2223 | } })); | 2309 | } })); |
| 2224 | // `.via` has no wall spelling at all, so it can never come off the | 2310 | // `.via` has no wall spelling at all, so it can never come off the |
| @@ -2390,7 +2476,7 @@ test "listSessions: a poll that failed still reports the login it paid for" { | |||
| 2390 | var link: std.meta.Tag(Link) = .quic; | 2476 | var link: std.meta.Tag(Link) = .quic; |
| 2391 | try std.testing.expectError(error.Transport, listSessions(alloc, .{ .hand = .{ | 2477 | try std.testing.expectError(error.Transport, listSessions(alloc, .{ .hand = .{ |
| 2392 | .host = "nowhere", | 2478 | .host = "nowhere", |
| 2393 | .ssh_cmd = "exit 255", | 2479 | .ssh_argv = &.{ "/bin/sh", "-c", "exit 255" }, |
| 2394 | .cache_path = null, | 2480 | .cache_path = null, |
| 2395 | .asked = false, | 2481 | .asked = false, |
| 2396 | } }, &out, 200, &link)); | 2482 | } }, &out, 200, &link)); |
src/client/handoff.zig
| Old | New | ||
|---|---|---|---|
| @@ -168,30 +168,49 @@ pub fn dialHost(host: []const u8) []const u8 { | |||
| 168 | /// What a bare-HOST target needs before it can be dialed: the coordination | 168 | /// What a bare-HOST target needs before it can be dialed: the coordination |
| 169 | /// command, the command that starts a daemon there, and the cache path. | 169 | /// command, the command that starts a daemon there, and the cache path. |
| 170 | pub const Recipe = struct { | 170 | pub const Recipe = struct { |
| 171 | ssh_cmd: []const u8, | 171 | ssh_argv: []const []const u8, |
| 172 | /// What a client runs when the ssh line found no daemon, and only on a | 172 | /// What a client runs when the ssh line found no daemon, and only on a |
| 173 | /// dial the user ASKED for (`client.HandoffTarget.asked`): reading a | 173 | /// dial the user ASKED for (`client.HandoffTarget.asked`): reading a |
| 174 | /// box must never start one. | 174 | /// box must never start one. |
| 175 | start_cmd: []const u8, | 175 | start_argv: []const []const u8, |
| 176 | /// null means attach UNCACHED — an uncacheable host (a separator in | 176 | /// null means attach UNCACHED — an uncacheable host (a separator in |
| 177 | /// the name) or no resolvable cache directory. Always cold, never | 177 | /// the name) or no resolvable cache directory. Always cold, never |
| 178 | /// wrong; the rule lives here rather than at each call site. | 178 | /// wrong; the rule lives here rather than at each call site. |
| 179 | cache_path: ?[]const u8, | 179 | cache_path: ?[]const u8, |
| 180 | 180 | ||
| 181 | pub fn deinit(self: Recipe, alloc: std.mem.Allocator) void { | 181 | pub fn deinit(self: Recipe, alloc: std.mem.Allocator) void { |
| 182 | alloc.free(self.ssh_cmd); | 182 | freeArgv(alloc, self.ssh_argv); |
| 183 | alloc.free(self.start_cmd); | 183 | freeArgv(alloc, self.start_argv); |
| 184 | if (self.cache_path) |c| alloc.free(c); | 184 | if (self.cache_path) |c| alloc.free(c); |
| 185 | } | 185 | } |
| 186 | }; | 186 | }; |
| 187 | 187 | ||
| 188 | /// Every word owned, including the ones that came in as literals: mixed | ||
| 189 | /// ownership inside one argv is a free that is right for five elements and | ||
| 190 | /// a corruption for the sixth. | ||
| 191 | pub fn dupeArgv(alloc: std.mem.Allocator, argv: []const []const u8) ![]const []const u8 { | ||
| 192 | const out = try alloc.alloc([]const u8, argv.len); | ||
| 193 | var made: usize = 0; | ||
| 194 | errdefer { | ||
| 195 | for (out[0..made]) |w| alloc.free(w); | ||
| 196 | alloc.free(out); | ||
| 197 | } | ||
| 198 | while (made < argv.len) : (made += 1) out[made] = try alloc.dupe(u8, argv[made]); | ||
| 199 | return out; | ||
| 200 | } | ||
| 201 | |||
| 202 | pub fn freeArgv(alloc: std.mem.Allocator, argv: []const []const u8) void { | ||
| 203 | for (argv) |w| alloc.free(w); | ||
| 204 | alloc.free(argv); | ||
| 205 | } | ||
| 206 | |||
| 188 | /// ONE owner for the ssh line's shape. | 207 | /// ONE owner for the ssh line's shape. |
| 189 | fn sshLine( | 208 | fn sshArgv( |
| 190 | alloc: std.mem.Allocator, | 209 | alloc: std.mem.Allocator, |
| 191 | host: []const u8, | 210 | host: []const u8, |
| 192 | batch: bool, | 211 | batch: bool, |
| 193 | remote: []const u8, | 212 | remote: []const u8, |
| 194 | ) ![]const u8 { | 213 | ) ![]const []const u8 { |
| 195 | // Only the last word differs between the coordination command and the | 214 | // Only the last word differs between the coordination command and the |
| 196 | // start command, and a drift between the two spellings would start a | 215 | // start command, and a drift between the two spellings would start a |
| 197 | // daemon somewhere other than where the attach then looks for it. | 216 | // daemon somewhere other than where the attach then looks for it. |
| @@ -209,19 +228,30 @@ fn sshLine( | |||
| 209 | // per dead host, serially, and a poll thread holds a stale tile for the | 228 | // per dead host, serially, and a poll thread holds a stale tile for the |
| 210 | // whole stall with no abort fd that could interrupt it. The interactive | 229 | // whole stall with no abort fd that could interrupt it. The interactive |
| 211 | // attach keeps ssh's own patience — that wait is the user's to abandon. | 230 | // attach keeps ssh's own patience — that wait is the user's to abandon. |
| 212 | const batch_opt: []const u8 = if (batch) "-o BatchMode=yes -o ConnectTimeout=5 " else ""; | 231 | const batch_opt: []const []const u8 = if (batch) |
| 213 | // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd | 232 | &.{ "-o", "BatchMode=yes", "-o", "ConnectTimeout=5" } |
| 214 | // runs this through a non-login, non-interactive shell that never | 233 | else |
| 215 | // sources the profile putting ~/.local/bin (make install's target) on | 234 | &.{}; |
| 216 | // PATH — without it a `mux` the user can run by hand is invisible here. | 235 | // ONE argv word, and it is the only one any shell ever reads: ssh joins |
| 217 | // APPENDED, deliberately: a fallback place to look, never a shadow over | 236 | // everything past the host and hands it to the REMOTE user's shell, |
| 218 | // whatever `mux` the remote PATH already resolves (or, under the e2e ssh | 237 | // which is what expands `$PATH` here. Ours does not — the client execs |
| 219 | // shim, over the binary under test). | 238 | // this argv — so nothing in the word has to survive a local round of |
| 220 | return std.fmt.allocPrint( | 239 | // quote removal. |
| 221 | alloc, | 240 | // |
| 222 | "ssh {s}{s} 'PATH=\"$PATH:$HOME/.local/bin\" {s}'", | 241 | // sshd runs that remote shell non-login and non-interactive, so it |
| 223 | .{ batch_opt, host, remote }, | 242 | // never sources the profile putting ~/.local/bin (make install's |
| 224 | ); | 243 | // target) on PATH, and without the suffix a `mux` the user can run by |
| 244 | // hand is invisible here. APPENDED, deliberately: a fallback place to | ||
| 245 | // look, never a shadow over whatever `mux` the remote PATH already | ||
| 246 | // resolves (or, under the e2e ssh shim, over the binary under test). | ||
| 247 | const word = try std.fmt.allocPrint(alloc, "PATH=\"$PATH:$HOME/.local/bin\" {s}", .{remote}); | ||
| 248 | defer alloc.free(word); | ||
| 249 | var argv: [7][]const u8 = undefined; | ||
| 250 | argv[0] = "ssh"; | ||
| 251 | @memcpy(argv[1 .. 1 + batch_opt.len], batch_opt); | ||
| 252 | argv[1 + batch_opt.len] = host; | ||
| 253 | argv[2 + batch_opt.len] = word; | ||
| 254 | return dupeArgv(alloc, argv[0 .. 3 + batch_opt.len]); | ||
| 225 | } | 255 | } |
| 226 | 256 | ||
| 227 | /// ONE owner for the handoff recipe: `mux HOST` and a `mux web` HOST tile | 257 | /// ONE owner for the handoff recipe: `mux HOST` and a `mux web` HOST tile |
| @@ -230,13 +260,13 @@ fn sshLine( | |||
| 230 | /// commands. Building it here (rather than in client.zig) is what keeps | 260 | /// commands. Building it here (rather than in client.zig) is what keeps |
| 231 | /// the client free of XDG and of allocating a command line. | 261 | /// the client free of XDG and of allocating a command line. |
| 232 | pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe { | 262 | pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe { |
| 233 | const cmd = try sshLine(alloc, host, batch, "mux d endpoint"); | 263 | const cmd = try sshArgv(alloc, host, batch, "mux d endpoint"); |
| 234 | errdefer alloc.free(cmd); | 264 | errdefer freeArgv(alloc, cmd); |
| 235 | const start = try sshLine(alloc, host, batch, "mux d start"); | 265 | const start = try sshArgv(alloc, host, batch, "mux d start"); |
| 236 | errdefer alloc.free(start); | 266 | errdefer freeArgv(alloc, start); |
| 237 | return .{ | 267 | return .{ |
| 238 | .ssh_cmd = cmd, | 268 | .ssh_argv = cmd, |
| 239 | .start_cmd = start, | 269 | .start_argv = start, |
| 240 | .cache_path = xdg.hostCachePath(alloc, host) catch null, | 270 | .cache_path = xdg.hostCachePath(alloc, host) catch null, |
| 241 | }; | 271 | }; |
| 242 | } | 272 | } |
| @@ -433,35 +463,53 @@ test "announce: every shape of junk is a named error" { | |||
| 433 | } | 463 | } |
| 434 | } | 464 | } |
| 435 | 465 | ||
| 466 | fn expectArgv(want: []const []const u8, got: []const []const u8) !void { | ||
| 467 | try std.testing.expectEqual(want.len, got.len); | ||
| 468 | for (want, got) |w, g| try std.testing.expectEqualStrings(w, g); | ||
| 469 | } | ||
| 470 | |||
| 436 | test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" { | 471 | test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" { |
| 437 | const r = try recipeFor(std.testing.allocator, "user@box", false); | 472 | const r = try recipeFor(std.testing.allocator, "user@box", false); |
| 438 | defer r.deinit(std.testing.allocator); | 473 | defer r.deinit(std.testing.allocator); |
| 439 | // APPENDED, not prepended: this adds a place to look when `mux` is | 474 | // APPENDED, not prepended: this adds a place to look when `mux` is |
| 440 | // nowhere on the remote PATH; it must never let a stale ~/.local/bin | 475 | // nowhere on the remote PATH; it must never let a stale ~/.local/bin |
| 441 | // shadow a `mux` the PATH already resolves. | 476 | // shadow a `mux` the PATH already resolves. |
| 442 | try std.testing.expectEqualStrings( | 477 | // |
| 443 | "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint'", | 478 | // The remote command is ONE word, unquoted: no local shell strips |
| 444 | r.ssh_cmd, | 479 | // anything off it, so the quotes a shell line needed would arrive at |
| 445 | ); | 480 | // sshd as literal bytes. |
| 481 | try expectArgv(&.{ | ||
| 482 | "ssh", | ||
| 483 | "user@box", | ||
| 484 | "PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint", | ||
| 485 | }, r.ssh_argv); | ||
| 446 | } | 486 | } |
| 447 | 487 | ||
| 448 | test "recipeFor: the start command is the ssh line with `mux d start` — only a dial the user asked for may start a daemon" { | 488 | test "recipeFor: the start command is the ssh line with `mux d start` — only a dial the user asked for may start a daemon" { |
| 449 | const alloc = std.testing.allocator; | 489 | const alloc = std.testing.allocator; |
| 450 | const asking = try recipeFor(alloc, "user@box", false); | 490 | const asking = try recipeFor(alloc, "user@box", false); |
| 451 | defer asking.deinit(alloc); | 491 | defer asking.deinit(alloc); |
| 452 | try std.testing.expectEqualStrings( | 492 | try expectArgv(&.{ |
| 453 | "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" mux d start'", | 493 | "ssh", |
| 454 | asking.start_cmd, | 494 | "user@box", |
| 455 | ); | 495 | "PATH=\"$PATH:$HOME/.local/bin\" mux d start", |
| 496 | }, asking.start_argv); | ||
| 456 | // The batch flag travels with the recipe, so the start line carries it | 497 | // The batch flag travels with the recipe, so the start line carries it |
| 457 | // too: a poller's recipe never starts anything, but `mux hosts` and the | 498 | // too: a poller's recipe never starts anything, but `mux hosts` and the |
| 458 | // wall build both strings from the same call. | 499 | // wall build both argvs from the same call. |
| 459 | const quiet = try recipeFor(alloc, "gate", true); | 500 | const quiet = try recipeFor(alloc, "gate", true); |
| 460 | defer quiet.deinit(alloc); | 501 | defer quiet.deinit(alloc); |
| 461 | try std.testing.expectEqualStrings( | 502 | // `-o` and its value are two words, the way ssh's own getopt reads |
| 462 | "ssh -o BatchMode=yes -o ConnectTimeout=5 gate 'PATH=\"$PATH:$HOME/.local/bin\" mux d start'", | 503 | // them and the way the suite's ssh shim skips them. |
| 463 | quiet.start_cmd, | 504 | try expectArgv(&.{ |
| 464 | ); | 505 | "ssh", |
| 506 | "-o", | ||
| 507 | "BatchMode=yes", | ||
| 508 | "-o", | ||
| 509 | "ConnectTimeout=5", | ||
| 510 | "gate", | ||
| 511 | "PATH=\"$PATH:$HOME/.local/bin\" mux d start", | ||
| 512 | }, quiet.start_argv); | ||
| 465 | } | 513 | } |
| 466 | 514 | ||
| 467 | test "recipeFor: a batch recipe cannot prompt, an interactive one still can" { | 515 | test "recipeFor: a batch recipe cannot prompt, an interactive one still can" { |
| @@ -471,11 +519,11 @@ test "recipeFor: a batch recipe cannot prompt, an interactive one still can" { | |||
| 471 | for ([_][]const u8{ "user@box", "gate" }) |h| { | 519 | for ([_][]const u8{ "user@box", "gate" }) |h| { |
| 472 | const quiet = try recipeFor(alloc, h, true); | 520 | const quiet = try recipeFor(alloc, h, true); |
| 473 | defer quiet.deinit(alloc); | 521 | defer quiet.deinit(alloc); |
| 474 | try std.testing.expect(std.mem.indexOf(u8, quiet.ssh_cmd, "-o BatchMode=yes") != null); | 522 | try std.testing.expect(argvWord(quiet.ssh_argv, "BatchMode=yes") != null); |
| 475 | // Before the host word, where ssh reads its options. | 523 | // Before the host word, where ssh reads its options. |
| 476 | try std.testing.expect( | 524 | try std.testing.expect( |
| 477 | std.mem.indexOf(u8, quiet.ssh_cmd, "-o BatchMode=yes").? < | 525 | argvWord(quiet.ssh_argv, "BatchMode=yes").? < |
| 478 | std.mem.indexOf(u8, quiet.ssh_cmd, h).?, | 526 | argvWord(quiet.ssh_argv, h).?, |
| 479 | ); | 527 | ); |
| 480 | // The other half of "nobody is sitting in front of this": a | 528 | // The other half of "nobody is sitting in front of this": a |
| 481 | // blackholed host — dropped SYNs, no RST — leaves ssh in the | 529 | // blackholed host — dropped SYNs, no RST — leaves ssh in the |
| @@ -484,20 +532,25 @@ test "recipeFor: a batch recipe cannot prompt, an interactive one still can" { | |||
| 484 | // returns. So `mux hosts` appears to hang per dead host, serially, | 532 | // returns. So `mux hosts` appears to hang per dead host, serially, |
| 485 | // and a poll thread carries a stale tile for the whole stall with | 533 | // and a poll thread carries a stale tile for the whole stall with |
| 486 | // no abort fd to interrupt it. | 534 | // no abort fd to interrupt it. |
| 487 | try std.testing.expect(std.mem.indexOf(u8, quiet.ssh_cmd, "-o ConnectTimeout=5") != null); | 535 | try std.testing.expect(argvWord(quiet.ssh_argv, "ConnectTimeout=5") != null); |
| 488 | try std.testing.expect( | 536 | try std.testing.expect( |
| 489 | std.mem.indexOf(u8, quiet.ssh_cmd, "-o ConnectTimeout=5").? < | 537 | argvWord(quiet.ssh_argv, "ConnectTimeout=5").? < |
| 490 | std.mem.indexOf(u8, quiet.ssh_cmd, h).?, | 538 | argvWord(quiet.ssh_argv, h).?, |
| 491 | ); | 539 | ); |
| 492 | const asking = try recipeFor(alloc, h, false); | 540 | const asking = try recipeFor(alloc, h, false); |
| 493 | defer asking.deinit(alloc); | 541 | defer asking.deinit(alloc); |
| 494 | try std.testing.expect(std.mem.indexOf(u8, asking.ssh_cmd, "BatchMode") == null); | 542 | try std.testing.expect(argvWord(asking.ssh_argv, "BatchMode=yes") == null); |
| 495 | // An attach a user is waiting on keeps ssh's own patience: a slow | 543 | // An attach a user is waiting on keeps ssh's own patience: a slow |
| 496 | // link is theirs to abandon, and Ctrl-\ already ends the wait. | 544 | // link is theirs to abandon, and Ctrl-\ already ends the wait. |
| 497 | try std.testing.expect(std.mem.indexOf(u8, asking.ssh_cmd, "ConnectTimeout") == null); | 545 | try std.testing.expect(argvWord(asking.ssh_argv, "ConnectTimeout=5") == null); |
| 498 | } | 546 | } |
| 499 | } | 547 | } |
| 500 | 548 | ||
| 549 | fn argvWord(argv: []const []const u8, want: []const u8) ?usize { | ||
| 550 | for (argv, 0..) |w, i| if (std.mem.eql(u8, w, want)) return i; | ||
| 551 | return null; | ||
| 552 | } | ||
| 553 | |||
| 501 | test "dialHost: the LAST @ wins, which is where ssh splits" { | 554 | test "dialHost: the LAST @ wins, which is where ssh splits" { |
| 502 | try std.testing.expectEqualStrings("box", dialHost("ubuntu@box")); | 555 | try std.testing.expectEqualStrings("box", dialHost("ubuntu@box")); |
| 503 | try std.testing.expectEqualStrings("box", dialHost("box")); | 556 | try std.testing.expectEqualStrings("box", dialHost("box")); |
src/client/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -114,8 +114,8 @@ fn resolveTile( | |||
| 114 | break :blk .{ | 114 | break :blk .{ |
| 115 | .hand = .{ | 115 | .hand = .{ |
| 116 | .host = hd, | 116 | .host = hd, |
| 117 | .ssh_cmd = r.ssh_cmd, | 117 | .ssh_argv = r.ssh_argv, |
| 118 | .start_cmd = r.start_cmd, | 118 | .start_argv = r.start_argv, |
| 119 | .cache_path = r.cache_path, | 119 | .cache_path = r.cache_path, |
| 120 | .idle_ms = idle_ms, | 120 | .idle_ms = idle_ms, |
| 121 | // The hub is never the ask: a tile redials for as long as | 121 | // The hub is never the ask: a tile redials for as long as |
| @@ -175,8 +175,8 @@ fn copyTarget(arena: std.mem.Allocator, t: client.Target) !client.Target { | |||
| 175 | } }, | 175 | } }, |
| 176 | .hand => |h| .{ .hand = .{ | 176 | .hand => |h| .{ .hand = .{ |
| 177 | .host = try arena.dupe(u8, h.host), | 177 | .host = try arena.dupe(u8, h.host), |
| 178 | .ssh_cmd = try arena.dupe(u8, h.ssh_cmd), | 178 | .ssh_argv = try handoff.dupeArgv(arena, h.ssh_argv), |
| 179 | .start_cmd = try arena.dupe(u8, h.start_cmd), | 179 | .start_argv = try handoff.dupeArgv(arena, h.start_argv), |
| 180 | .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null, | 180 | .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null, |
| 181 | .deadline_ms = h.deadline_ms, | 181 | .deadline_ms = h.deadline_ms, |
| 182 | .idle_ms = h.idle_ms, | 182 | .idle_ms = h.idle_ms, |
| @@ -1133,7 +1133,7 @@ pub fn serveConn( | |||
| 1133 | error.BadSession => .{ .bad_request, "bad session name after '#'\n" }, | 1133 | error.BadSession => .{ .bad_request, "bad session name after '#'\n" }, |
| 1134 | error.EmptySpec => .{ .bad_request, "empty target\n" }, | 1134 | error.EmptySpec => .{ .bad_request, "empty target\n" }, |
| 1135 | error.BadByte => .{ .bad_request, "control byte in target\n" }, | 1135 | error.BadByte => .{ .bad_request, "control byte in target\n" }, |
| 1136 | error.ShellMeta => .{ .bad_request, "shell metacharacter in host: the tile runs this spelling through /bin/sh\n" }, | 1136 | error.ShellMeta => .{ .bad_request, "shell metacharacter in host: a tile spelling names a machine, not a command\n" }, |
| 1137 | error.MissingKey => .{ .bad_request, "no key for quic:// target (mux d keygen, or MUX_KEY_FILE)\n" }, | 1137 | error.MissingKey => .{ .bad_request, "no key for quic:// target (mux d keygen, or MUX_KEY_FILE)\n" }, |
| 1138 | error.SockPathTooLong => .{ .bad_request, "socket path too long\n" }, | 1138 | error.SockPathTooLong => .{ .bad_request, "socket path too long\n" }, |
| 1139 | // The tile is NOT live: addTile rolls back on a | 1139 | // The tile is NOT live: addTile rolls back on a |
src/tui/wall_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -55,8 +55,8 @@ pub fn resolveHost( | |||
| 55 | break :blk .{ | 55 | break :blk .{ |
| 56 | .hand = .{ | 56 | .hand = .{ |
| 57 | .host = h, | 57 | .host = h, |
| 58 | .ssh_cmd = r.ssh_cmd, | 58 | .ssh_argv = r.ssh_argv, |
| 59 | .start_cmd = r.start_cmd, | 59 | .start_argv = r.start_argv, |
| 60 | .cache_path = r.cache_path, | 60 | .cache_path = r.cache_path, |
| 61 | .idle_ms = idle_ms, | 61 | .idle_ms = idle_ms, |
| 62 | // A host line is a listing, not an attach anyone waited | 62 | // A host line is a listing, not an attach anyone waited |
| @@ -101,8 +101,8 @@ pub fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Ta | |||
| 101 | const r = try handoff.recipeFor(alloc, h.host, true); | 101 | const r = try handoff.recipeFor(alloc, h.host, true); |
| 102 | return .{ .hand = .{ | 102 | return .{ .hand = .{ |
| 103 | .host = h.host, | 103 | .host = h.host, |
| 104 | .ssh_cmd = r.ssh_cmd, | 104 | .ssh_argv = r.ssh_argv, |
| 105 | .start_cmd = r.start_cmd, | 105 | .start_argv = r.start_argv, |
| 106 | .cache_path = r.cache_path, | 106 | .cache_path = r.cache_path, |
| 107 | .idle_ms = h.idle_ms, | 107 | .idle_ms = h.idle_ms, |
| 108 | .asked = false, | 108 | .asked = false, |
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -576,13 +576,13 @@ test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cann | |||
| 576 | const alloc = arena.allocator(); | 576 | const alloc = arena.allocator(); |
| 577 | 577 | ||
| 578 | // Two spellings, because the poll target is built per host and a shared | 578 | // Two spellings, because the poll target is built per host and a shared |
| 579 | // one would point every stripe at the first host's ssh line. | 579 | // one would point every stripe at the first host's ssh argv. |
| 580 | for ([_][]const u8{ "box", "user@gate" }) |spelling| { | 580 | for ([_][]const u8{ "box", "user@gate" }) |spelling| { |
| 581 | const spec = try wall_host.resolveHost(alloc, spelling, null, 30_000); | 581 | const spec = try wall_host.resolveHost(alloc, spelling, null, 30_000); |
| 582 | try std.testing.expect(std.mem.indexOf(u8, spec.poll_target.hand.ssh_cmd, "BatchMode=yes") != null); | 582 | try std.testing.expect(hasWord(spec.poll_target.hand.ssh_argv, "BatchMode=yes")); |
| 583 | try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host); | 583 | try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host); |
| 584 | // The attach the user sees must still be able to ask for a password. | 584 | // The attach the user sees must still be able to ask for a password. |
| 585 | try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null); | 585 | try std.testing.expect(!hasWord(spec.target.hand.ssh_argv, "BatchMode=yes")); |
| 586 | // Neither door narrates a fallback: both are dialled under a wall | 586 | // Neither door narrates a fallback: both are dialled under a wall |
| 587 | // that owns the alternate screen. | 587 | // that owns the alternate screen. |
| 588 | try std.testing.expect(!spec.target.hand.asked); | 588 | try std.testing.expect(!spec.target.hand.asked); |
| @@ -631,3 +631,8 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi | |||
| 631 | wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0); | 631 | wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0); |
| 632 | try std.testing.expectEqual(@as(usize, 1), one.items.len); | 632 | try std.testing.expectEqual(@as(usize, 1), one.items.len); |
| 633 | } | 633 | } |
| 634 | |||
| 635 | fn hasWord(argv: []const []const u8, want: []const u8) bool { | ||
| 636 | for (argv) |w| if (std.mem.eql(u8, w, want)) return true; | ||
| 637 | return false; | ||
| 638 | } | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -170,8 +170,8 @@ done | |||
| 170 | # one of those and adds a convergence point would be pinning a fact every | 170 | # one of those and adds a convergence point would be pinning a fact every |
| 171 | # leg above already establishes. | 171 | # leg above already establishes. |
| 172 | 172 | ||
| 173 | [ "$OK_COUNT" = "89" ] || { | 173 | [ "$OK_COUNT" = "90" ] || { |
| 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 89 —" | 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 90 —" |
| 175 | echo " a scenario was added (update the pin) or silently lost" | 175 | echo " a scenario was added (update the pin) or silently lost" |
| 176 | exit 1 | 176 | exit 1 |
| 177 | } | 177 | } |
test/e2e_01_boot.sh
| Old | New | ||
|---|---|---|---|
| @@ -171,9 +171,9 @@ rm_swept "$OUT.a" "$OUT.b" | |||
| 171 | 171 | ||
| 172 | # --- M6: the same protocol over an arbitrary byte pipe. `mux d proxy` is a | 172 | # --- M6: the same protocol over an arbitrary byte pipe. `mux d proxy` is a |
| 173 | # frame-agnostic stdio<->socket pump; if the session works through it, the | 173 | # frame-agnostic stdio<->socket pump; if the session works through it, the |
| 174 | # transport really is a swap. The string is one shell word here and is split | 174 | # transport really is a swap. `--via` splits the string on whitespace into |
| 175 | # by the /bin/sh -c that mux spawns, so $MUX d must contain no spaces — it is | 175 | # argv and execs it, so $MUX and $SOCK must contain no spaces — they are the |
| 176 | # the build tree's artifact path, which does not. | 176 | # build tree's artifact path and this suite's socket, which do not. |
| 177 | # | 177 | # |
| 178 | # XDG_RUNTIME_DIR is pointed at nothing so the test cannot pass by environment | 178 | # XDG_RUNTIME_DIR is pointed at nothing so the test cannot pass by environment |
| 179 | # luck: if --via ever silently fell back to the default socket path, that path | 179 | # luck: if --via ever silently fell back to the default socket path, that path |
| @@ -190,8 +190,14 @@ rm_swept "$OUT.via" | |||
| 190 | # --- M10: a --via command that dies before the first frame stops claiming | 190 | # --- M10: a --via command that dies before the first frame stops claiming |
| 191 | # a connection existed. ssh's own stderr still passes through untouched. | 191 | # a connection existed. ssh's own stderr still passes through untouched. |
| 192 | # No convergence: no session was ever established, so there is no grid to match. | 192 | # No convergence: no session was ever established, so there is no grid to match. |
| 193 | # A program, not a shell line: `--via` execs its words, so "die at once" | ||
| 194 | # is a script and not a `sh -c` the client would have had to parse. | ||
| 195 | VIADEAD="${TMPDIR:-/tmp}/mux-e2e-viadead-$$.sh" | ||
| 196 | defer_rm "$VIADEAD" | ||
| 197 | printf '#!/bin/sh\nexit 127\n' > "$VIADEAD" | ||
| 198 | chmod +x "$VIADEAD" | ||
| 193 | set +e | 199 | set +e |
| 194 | "$MUX" --via "sh -c 'exit 127'" > "$OUT.via" 2>&1 | 200 | "$MUX" --via "$VIADEAD" > "$OUT.via" 2>&1 |
| 195 | VRC=$? | 201 | VRC=$? |
| 196 | set -e | 202 | set -e |
| 197 | [ "$VRC" = "1" ] || { echo "e2e FAIL: dead --via exit $VRC, want 1"; exit 1; } | 203 | [ "$VRC" = "1" ] || { echo "e2e FAIL: dead --via exit $VRC, want 1"; exit 1; } |
| @@ -209,14 +215,15 @@ ok "--via failure says what happened" | |||
| 209 | # the failure this test exists to catch. | 215 | # the failure this test exists to catch. |
| 210 | # No convergence: the transport died first, so no session and no grid. | 216 | # No convergence: the transport died first, so no session and no grid. |
| 211 | set +e | 217 | set +e |
| 212 | timeout 10 "$MUX" --via "exit 7" < /dev/null > "$OUT.dead" 2>&1 | 218 | printf '#!/bin/sh\nexit 7\n' > "$VIADEAD" |
| 219 | timeout 10 "$MUX" --via "$VIADEAD" < /dev/null > "$OUT.dead" 2>&1 | ||
| 213 | RC=$? | 220 | RC=$? |
| 214 | set -e | 221 | set -e |
| 215 | [ "$RC" -eq 1 ] || { | 222 | [ "$RC" -eq 1 ] || { |
| 216 | echo "e2e FAIL: dead first transport exited $RC (want 1; 124 means it hung retrying)" | 223 | echo "e2e FAIL: dead first transport exited $RC (want 1; 124 means it hung retrying)" |
| 217 | cat "$OUT.dead"; exit 1; | 224 | cat "$OUT.dead"; exit 1; |
| 218 | } | 225 | } |
| 219 | # M10 reworded this one: `--via "exit 7"` is the same shape as the scenario | 226 | # M10 reworded this one: a --via program that exits 7 is the same shape as the scenario |
| 220 | # above — a transport command that died before carrying a frame — so it now | 227 | # above — a transport command that died before carrying a frame — so it now |
| 221 | # gets the honest message. What this scenario is FOR is the exit code above | 228 | # gets the honest message. What this scenario is FOR is the exit code above |
| 222 | # (1, never 124); the diagnostic is asserted so the exit is not a silent one. | 229 | # (1, never 124); the diagnostic is asserted so the exit is not a silent one. |
test/e2e_02_predict.sh
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,24 @@ SOCK7="${TMPDIR:-/tmp}/muxd-e2e-raw-$$.sock" | |||
| 12 | defer_sock "$SOCK7" | 12 | defer_sock "$SOCK7" |
| 13 | PWSH="${TMPDIR:-/tmp}/mux-e2e-pw-$$.sh" | 13 | PWSH="${TMPDIR:-/tmp}/mux-e2e-pw-$$.sh" |
| 14 | defer_rm "$PWSH" | 14 | defer_rm "$PWSH" |
| 15 | # The delayed link is a PIPELINE, and `--via CMD` is argv words the client | ||
| 16 | # execs with no shell anywhere: the pipeline has to live in a script `mux` | ||
| 17 | # runs, not in a string `mux` would have had to parse. | ||
| 18 | DVIA5="${TMPDIR:-/tmp}/mux-e2e-dvia5-$$.sh" | ||
| 19 | defer_rm "$DVIA5" | ||
| 20 | DVIA7="${TMPDIR:-/tmp}/mux-e2e-dvia7-$$.sh" | ||
| 21 | defer_rm "$DVIA7" | ||
| 22 | # DELAY_MS is read from the environment the client was given, so the delay | ||
| 23 | # stays each scenario's to set. | ||
| 24 | cat > "$DVIA5" <<EOF | ||
| 25 | #!/bin/sh | ||
| 26 | $DELAYPIPE | $MUX d proxy --sock $SOCK5 | $DELAYPIPE | ||
| 27 | EOF | ||
| 28 | cat > "$DVIA7" <<EOF | ||
| 29 | #!/bin/sh | ||
| 30 | $DELAYPIPE | $MUX d proxy --sock $SOCK7 | $DELAYPIPE | ||
| 31 | EOF | ||
| 32 | chmod +x "$DVIA5" "$DVIA7" | ||
| 15 | # M12 pty scenarios. Each needs a daemon whose grid size it owns: tp2 | 33 | # M12 pty scenarios. Each needs a daemon whose grid size it owns: tp2 |
| 16 | # resizes the grid twice and tp1's session is a scrollback-generating | 34 | # resizes the grid twice and tp1's session is a scrollback-generating |
| 17 | # wrapper, so neither can share the long-lived /bin/sh daemon. | 35 | # wrapper, so neither can share the long-lived /bin/sh daemon. |
| @@ -66,7 +84,7 @@ D5PID=$DPID | |||
| 66 | set +e | 84 | set +e |
| 67 | { sleep 2; printf 'z'; sleep 4; printf '\034\034'; } | \ | 85 | { sleep 2; printf 'z'; sleep 4; printf '\034\034'; } | \ |
| 68 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | 86 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ |
| 69 | --via "$DELAYPIPE | $MUX d proxy --sock $SOCK5 | $DELAYPIPE" \ | 87 | --via "$DVIA5" \ |
| 70 | > "$OUT.p1" 2> "$OUT.p1.err" & | 88 | > "$OUT.p1" 2> "$OUT.p1.err" & |
| 71 | P1PID=$! | 89 | P1PID=$! |
| 72 | defer_kill "$P1PID" | 90 | defer_kill "$P1PID" |
| @@ -119,7 +137,7 @@ set +e | |||
| 119 | { sleep 2; for c in b u r s t; do printf '%s' "$c"; sleep 0.25; done; \ | 137 | { sleep 2; for c in b u r s t; do printf '%s' "$c"; sleep 0.25; done; \ |
| 120 | sleep 4; printf '\034\034'; } | \ | 138 | sleep 4; printf '\034\034'; } | \ |
| 121 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | 139 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ |
| 122 | --via "$DELAYPIPE | $MUX d proxy --sock $SOCK5 | $DELAYPIPE" \ | 140 | --via "$DVIA5" \ |
| 123 | > "$OUT.pb" 2> "$OUT.pb.err" | 141 | > "$OUT.pb" 2> "$OUT.pb.err" |
| 124 | RC=$? | 142 | RC=$? |
| 125 | set -e | 143 | set -e |
| @@ -247,7 +265,7 @@ set +e | |||
| 247 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 1; done; \ | 265 | { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 1; done; \ |
| 248 | printf '\000'; sleep 1; printf 'j'; sleep 2.5; printf '\034\034'; } | \ | 266 | printf '\000'; sleep 1; printf 'j'; sleep 2.5; printf '\034\034'; } | \ |
| 249 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ | 267 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \ |
| 250 | --via "$DELAYPIPE | $MUX d proxy --sock $SOCK7 | $DELAYPIPE" \ | 268 | --via "$DVIA7" \ |
| 251 | > "$OUT.rw" 2> "$OUT.rw.err" | 269 | > "$OUT.rw" 2> "$OUT.rw.err" |
| 252 | RC=$? | 270 | RC=$? |
| 253 | set -e | 271 | set -e |
| @@ -333,15 +351,15 @@ D5PID=$DPID | |||
| 333 | set +e | 351 | set +e |
| 334 | { sleep 2; printf 'p'; sleep 5; printf 'q'; sleep 4; printf '\034\034'; } | \ | 352 | { sleep 2; printf 'p'; sleep 5; printf 'q'; sleep 4; printf '\034\034'; } | \ |
| 335 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 60 "$MUX" \ | 353 | DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 60 "$MUX" \ |
| 336 | --via "$DELAYPIPE | $MUX d proxy --sock $SOCK5 | $DELAYPIPE" \ | 354 | --via "$DVIA5" \ |
| 337 | > "$OUT.pr" 2> "$OUT.pr.err" & | 355 | > "$OUT.pr" 2> "$OUT.pr.err" & |
| 338 | PRPID=$! | 356 | PRPID=$! |
| 339 | defer_kill "$PRPID" | 357 | defer_kill "$PRPID" |
| 340 | set -e | 358 | set -e |
| 341 | # Kill the transport a tenth of a second after the keystroke, while its | 359 | # Kill the transport a tenth of a second after the keystroke, while its |
| 342 | # prediction is still outstanding — the round trip is 600ms, so it cannot | 360 | # prediction is still outstanding — the round trip is 600ms, so it cannot |
| 343 | # have been judged yet. By comm+pid: the client's own argv contains the | 361 | # have been judged yet. By comm+pid: a pattern kill on the socket path |
| 344 | # --via string, so a pattern kill would take out the client under test. | 362 | # would take out the delayed-link script that names it too. |
| 345 | sleep 2.1 | 363 | sleep 2.1 |
| 346 | PP=$(proxy_pid "$SOCK5") | 364 | PP=$(proxy_pid "$SOCK5") |
| 347 | [ -n "$PP" ] || { | 365 | [ -n "$PP" ] || { |
test/e2e_04_handoff.sh
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,13 @@ SSHIM_DIR="${TMPDIR:-/tmp}/muxd-e2e-sshim-$$" | |||
| 14 | defer_rm "$SSHIM_DIR" | 14 | defer_rm "$SSHIM_DIR" |
| 15 | SSHIM_PIDLOG="$SSHIM_DIR/pids" | 15 | SSHIM_PIDLOG="$SSHIM_DIR/pids" |
| 16 | export SSHIM_PIDLOG | 16 | export SSHIM_PIDLOG |
| 17 | # What the OS saw, not what the client claims: one line per shim run, | ||
| 18 | # `<own comm> <parent comm>`, taken BEFORE the exec below replaces this | ||
| 19 | # process. `mux` execs the ssh argv itself, so both words are pinned — a | ||
| 20 | # client that went back to running the recipe through `/bin/sh -c` would | ||
| 21 | # name a shell in one of them. | ||
| 22 | SSHIM_COMMLOG="$SSHIM_DIR/comms" | ||
| 23 | export SSHIM_COMMLOG | ||
| 17 | HRUN="${TMPDIR:-/tmp}/mux-e2e-hrun-$$" | 24 | HRUN="${TMPDIR:-/tmp}/mux-e2e-hrun-$$" |
| 18 | defer_rm "$HRUN" | 25 | defer_rm "$HRUN" |
| 19 | HRUN2="${TMPDIR:-/tmp}/mux-e2e-hrun2-$$" | 26 | HRUN2="${TMPDIR:-/tmp}/mux-e2e-hrun2-$$" |
| @@ -69,9 +76,11 @@ HDEADPORT=$(( 16000 + ($$ % 4000) )) | |||
| 69 | # dial gains a flag. | 76 | # dial gains a flag. |
| 70 | mkdir -p "$SSHIM_DIR" "$HRUN" "$HRUN2" | 77 | mkdir -p "$SSHIM_DIR" "$HRUN" "$HRUN2" |
| 71 | : > "$SSHIM_PIDLOG" | 78 | : > "$SSHIM_PIDLOG" |
| 79 | : > "$SSHIM_COMMLOG" | ||
| 72 | ssh_shim_head "$SSHIM_DIR/ssh" | 80 | ssh_shim_head "$SSHIM_DIR/ssh" |
| 73 | cat >> "$SSHIM_DIR/ssh" <<'SHIM' | 81 | cat >> "$SSHIM_DIR/ssh" <<'SHIM' |
| 74 | echo $$ >> "${SSHIM_PIDLOG:?}" | 82 | echo $$ >> "${SSHIM_PIDLOG:?}" |
| 83 | printf '%s %s\n' "$(cat /proc/$$/comm)" "$(cat /proc/$PPID/comm)" >> "${SSHIM_COMMLOG:?}" | ||
| 75 | shift | 84 | shift |
| 76 | exec /bin/sh -c "$*" | 85 | exec /bin/sh -c "$*" |
| 77 | SHIM | 86 | SHIM |
| @@ -117,6 +126,16 @@ HSHIMS=$(wc -l < "$SSHIM_PIDLOG") | |||
| 117 | echo "e2e FAIL: cold handoff ran $HSHIMS ssh invocations, want exactly 3" | 126 | echo "e2e FAIL: cold handoff ran $HSHIMS ssh invocations, want exactly 3" |
| 118 | echo " (mux d endpoint on an empty box, mux d start, mux d endpoint)" | 127 | echo " (mux d endpoint on an empty box, mux d start, mux d endpoint)" |
| 119 | cat "$SSHIM_PIDLOG"; exit 1; } | 128 | cat "$SSHIM_PIDLOG"; exit 1; } |
| 129 | # Asked of /proc, not of the client: every one of those three children was | ||
| 130 | # `ssh` itself, forked straight off `mux` with no shell in between. The | ||
| 131 | # claim is the whole point of the argv recipe — a `/bin/sh -c` line would | ||
| 132 | # put `sh` in one column or the other, and on a box whose sh exec-optimises | ||
| 133 | # it is the PARENT column that keeps it honest. | ||
| 134 | HBADCOMM=$(grep -cv '^ssh mux$' "$SSHIM_COMMLOG" || true) | ||
| 135 | [ "$HBADCOMM" -eq 0 ] || { | ||
| 136 | echo "e2e FAIL: the handoff spawned something other than ssh straight off mux:" | ||
| 137 | cat "$SSHIM_COMMLOG"; exit 1; } | ||
| 138 | ok "the handoff's child IS ssh, forked off mux with no shell between" | ||
| 120 | # The LAST, not the first: the first two are the refusal and the start, and | 139 | # The LAST, not the first: the first two are the refusal and the start, and |
| 121 | # both are already dead by construction. The ssh whose death proves QUIC | 140 | # both are already dead by construction. The ssh whose death proves QUIC |
| 122 | # took over is the one that carried the announce. | 141 | # took over is the one that carried the announce. |
test/e2e_lib.sh
| Old | New | ||
|---|---|---|---|
| @@ -755,9 +755,8 @@ assert_stopped() { | |||
| 755 | # The transport child for a given socket: a `mux` whose own first two words | 755 | # The transport child for a given socket: a `mux` whose own first two words |
| 756 | # are `d proxy`, on that path. The word test is positional and not a match | 756 | # are `d proxy`, on that path. The word test is positional and not a match |
| 757 | # anywhere in the line, because one binary means the CLIENT is a `mux` too | 757 | # anywhere in the line, because one binary means the CLIENT is a `mux` too |
| 758 | # and its argv carries the whole `--via ... d proxy ...` string — a pattern | 758 | # and the delayed-link scripts name the same socket — a pattern match here |
| 759 | # match here takes out the very client under test, and so would `pkill -f | 759 | # takes out the very client under test, and so would `pkill -f proxy`. |
| 760 | # proxy`. | ||
| 761 | proxy_pid() { | 760 | proxy_pid() { |
| 762 | ps -eo pid,comm,args | | 761 | ps -eo pid,comm,args | |
| 763 | awk -v s="$1" '$2=="mux" && $4=="d" && $5=="proxy" && index($0,s) {print $1}' | | 762 | awk -v s="$1" '$2=="mux" && $4=="d" && $5=="proxy" && index($0,s) {print $1}' | |