a73x

5d9b51fe

refactor: the handoff recipe is an argv — ssh is exec'd, not a shell line

a73x   2026-08-29 00:35

Commit message
refactor: the handoff recipe is an argv — ssh is exec'd, not a shell line

`handoff.recipeFor` builds `ssh_argv`/`start_argv` instead of two command
strings, and `spawnPipe`/`runStart` take argv, so the client execs `ssh`
and `--via`'s own words with no `/bin/sh -c` between. `--via CMD` is split
on whitespace: the two e2e legs that spelled a pipeline or a builtin now
name a script, because that contract has nowhere to put one.

The claim asked of the OS rather than of the client: the handoff shim
records its own `comm` and its parent's before it execs, and the cold leg
pins both — `ssh` forked straight off `mux`.

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}' |