7ae9b7db
refactor: a local start execs THIS image; nothing walks PATH for a sibling
a73x 2026-08-28 22:34
Commit message
src/client/handoff.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | //! The ssh→QUIC handoff's shared vocabulary: the announce line `muxd | 1 | //! The ssh→QUIC handoff's shared vocabulary: the announce line |
| 2 | //! endpoint` prints and `mux` parses, the per-host cache file that | 2 | //! `mux d endpoint` prints and `mux` parses, the per-host cache file that |
| 3 | //! remembers it, and the strip that turns an ssh destination into a | 3 | //! remembers it, and the strip that turns an ssh destination into a |
| 4 | //! dialable host. Pure by design — no sockets, no processes — so the | 4 | //! dialable host. Pure by design — no sockets, no processes — so the |
| 5 | //! whole surface tests without a daemon. | 5 | //! whole surface tests without a daemon. |
| @@ -47,7 +47,7 @@ pub const deadline_ms: u32 = 2000; | |||
| 47 | /// spellings does not compile. | 47 | /// spellings does not compile. |
| 48 | pub const key_len = 32; | 48 | pub const key_len = 32; |
| 49 | 49 | ||
| 50 | /// What a `muxd endpoint` announce carries. | 50 | /// What a `mux d endpoint` announce carries. |
| 51 | pub const Endpoint = struct { | 51 | pub const Endpoint = struct { |
| 52 | port: u16, | 52 | port: u16, |
| 53 | key: [key_len]u8, | 53 | key: [key_len]u8, |
| @@ -89,7 +89,7 @@ pub const CacheError = error{ | |||
| 89 | 89 | ||
| 90 | /// `endpoint <port> <64 lowercase hex chars>\n` into `buf`. | 90 | /// `endpoint <port> <64 lowercase hex chars>\n` into `buf`. |
| 91 | /// | 91 | /// |
| 92 | /// One writer — `muxd endpoint` — and two readers: the client reading the | 92 | /// One writer — `mux d endpoint` — and two readers: the client reading the |
| 93 | /// ssh pipe, and the client reading its own cache file, which stores this | 93 | /// ssh pipe, and the client reading its own cache file, which stores this |
| 94 | /// exact line. One grammar, not two, so a cache written by one version and | 94 | /// exact line. One grammar, not two, so a cache written by one version and |
| 95 | /// read by another can only agree or fail loudly. | 95 | /// read by another can only agree or fail loudly. |
| @@ -213,9 +213,9 @@ fn sshLine( | |||
| 213 | // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd | 213 | // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd |
| 214 | // runs this through a non-login, non-interactive shell that never | 214 | // runs this through a non-login, non-interactive shell that never |
| 215 | // sources the profile putting ~/.local/bin (make install's target) on | 215 | // sources the profile putting ~/.local/bin (make install's target) on |
| 216 | // PATH — without it a muxd the user can run by hand is invisible here. | 216 | // PATH — without it a `mux` the user can run by hand is invisible here. |
| 217 | // APPENDED, deliberately: a fallback place to look, never a shadow over | 217 | // APPENDED, deliberately: a fallback place to look, never a shadow over |
| 218 | // whatever muxd the remote PATH already resolves (or, under the e2e ssh | 218 | // whatever `mux` the remote PATH already resolves (or, under the e2e ssh |
| 219 | // shim, over the binary under test). | 219 | // shim, over the binary under test). |
| 220 | return std.fmt.allocPrint( | 220 | return std.fmt.allocPrint( |
| 221 | alloc, | 221 | alloc, |
| @@ -224,15 +224,15 @@ fn sshLine( | |||
| 224 | ); | 224 | ); |
| 225 | } | 225 | } |
| 226 | 226 | ||
| 227 | /// ONE owner for the handoff recipe: `mux HOST` and a `muxweb` HOST tile | 227 | /// ONE owner for the handoff recipe: `mux HOST` and a `mux web` HOST tile |
| 228 | /// build the identical thing, and a drift between two spellings of the | 228 | /// build the identical thing, and a drift between two spellings of the |
| 229 | /// ssh line would quietly point the two binaries at different remote | 229 | /// ssh line would quietly point the two binaries at different remote |
| 230 | /// commands. Building it here (rather than in client.zig) is what keeps | 230 | /// commands. Building it here (rather than in client.zig) is what keeps |
| 231 | /// the client free of XDG and of allocating a command line. | 231 | /// 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 { | 232 | pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe { |
| 233 | const cmd = try sshLine(alloc, host, batch, "muxd endpoint"); | 233 | const cmd = try sshLine(alloc, host, batch, "mux d endpoint"); |
| 234 | errdefer alloc.free(cmd); | 234 | errdefer alloc.free(cmd); |
| 235 | const start = try sshLine(alloc, host, batch, "muxd start"); | 235 | const start = try sshLine(alloc, host, batch, "mux d start"); |
| 236 | errdefer alloc.free(start); | 236 | errdefer alloc.free(start); |
| 237 | return .{ | 237 | return .{ |
| 238 | .ssh_cmd = cmd, | 238 | .ssh_cmd = cmd, |
| @@ -436,21 +436,21 @@ test "announce: every shape of junk is a named error" { | |||
| 436 | test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" { | 436 | 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); | 437 | const r = try recipeFor(std.testing.allocator, "user@box", false); |
| 438 | defer r.deinit(std.testing.allocator); | 438 | defer r.deinit(std.testing.allocator); |
| 439 | // APPENDED, not prepended: this adds a place to look when muxd is | 439 | // 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 | 440 | // nowhere on the remote PATH; it must never let a stale ~/.local/bin |
| 441 | // shadow a muxd the PATH already resolves. | 441 | // shadow a `mux` the PATH already resolves. |
| 442 | try std.testing.expectEqualStrings( | 442 | try std.testing.expectEqualStrings( |
| 443 | "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" muxd endpoint'", | 443 | "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint'", |
| 444 | r.ssh_cmd, | 444 | r.ssh_cmd, |
| 445 | ); | 445 | ); |
| 446 | } | 446 | } |
| 447 | 447 | ||
| 448 | test "recipeFor: the start command is the ssh line with `muxd start` — only a dial the user asked for may start a daemon" { | 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" { |
| 449 | const alloc = std.testing.allocator; | 449 | const alloc = std.testing.allocator; |
| 450 | const asking = try recipeFor(alloc, "user@box", false); | 450 | const asking = try recipeFor(alloc, "user@box", false); |
| 451 | defer asking.deinit(alloc); | 451 | defer asking.deinit(alloc); |
| 452 | try std.testing.expectEqualStrings( | 452 | try std.testing.expectEqualStrings( |
| 453 | "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" muxd start'", | 453 | "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" mux d start'", |
| 454 | asking.start_cmd, | 454 | asking.start_cmd, |
| 455 | ); | 455 | ); |
| 456 | // The batch flag travels with the recipe, so the start line carries it | 456 | // The batch flag travels with the recipe, so the start line carries it |
| @@ -459,7 +459,7 @@ test "recipeFor: the start command is the ssh line with `muxd start` — only a | |||
| 459 | const quiet = try recipeFor(alloc, "gate", true); | 459 | const quiet = try recipeFor(alloc, "gate", true); |
| 460 | defer quiet.deinit(alloc); | 460 | defer quiet.deinit(alloc); |
| 461 | try std.testing.expectEqualStrings( | 461 | try std.testing.expectEqualStrings( |
| 462 | "ssh -o BatchMode=yes -o ConnectTimeout=5 gate 'PATH=\"$PATH:$HOME/.local/bin\" muxd start'", | 462 | "ssh -o BatchMode=yes -o ConnectTimeout=5 gate 'PATH=\"$PATH:$HOME/.local/bin\" mux d start'", |
| 463 | quiet.start_cmd, | 463 | quiet.start_cmd, |
| 464 | ); | 464 | ); |
| 465 | } | 465 | } |
src/client/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,12 +1,30 @@ | |||
| 1 | //! Get a daemon onto a socket path: probe, spawn detached, poll until it | 1 | //! Get a daemon onto a socket path: probe, spawn detached, poll until it |
| 2 | //! answers. `muxd start` is explicit and spelled out; the LOCAL client's | 2 | //! answers. `mux d start` is explicit and spelled out; the LOCAL client's |
| 3 | //! own entry (`mux` with no host) is the one attach that may still start | 3 | //! own entry (`mux` with no host) is the one attach that may still start |
| 4 | //! one, through `ensureForAttach` below. The remote verbs a client reaches | 4 | //! one, through `ensureForAttach` below. The remote verbs a client reaches |
| 5 | //! over ssh do not: reading a box must never create a session there. | 5 | //! over ssh do not: reading a box must never create a session there. |
| 6 | //! | ||
| 7 | //! What it spawns is `self_exe` — this image, by its own /proc link — and | ||
| 8 | //! never a name resolved against PATH. That is the end of the ambient-PATH | ||
| 9 | //! trap: an `execvp("muxd")` grades whatever release is installed, and did | ||
| 10 | //! (an e2e leg whose daemon had died ran a v0.0.1-10 with no agent code in | ||
| 11 | //! it and passed). | ||
| 6 | const std = @import("std"); | 12 | const std = @import("std"); |
| 7 | const xdg = @import("xdg"); | 13 | const xdg = @import("xdg"); |
| 8 | 14 | ||
| 9 | pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered }; | 15 | pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered }; |
| 16 | |||
| 17 | /// The binary every production start execs: the running image, named by the | ||
| 18 | /// kernel rather than looked up. It survives a rename or a delete under the | ||
| 19 | /// running daemon, because the link is to the inode and not to the path. | ||
| 20 | /// | ||
| 21 | /// The child execs rather than simply running the daemon in the fork, and | ||
| 22 | /// that is not a preference: `std.debug.MemoryAccessor` caches the pid it | ||
| 23 | /// reads memory through, so a forked child's first DebugAllocator stack | ||
| 24 | /// trace calls `process_vm_readv` on the parent, gets ESRCH, and panics on | ||
| 25 | /// `unreachable // own pid is always valid`. Measured, deterministic, and | ||
| 26 | /// invisible until the daemon has been up long enough to allocate. | ||
| 27 | pub const self_exe = "/proc/self/exe"; | ||
| 10 | pub const Ensured = enum { already_running, started }; | 28 | pub const Ensured = enum { already_running, started }; |
| 11 | 29 | ||
| 12 | /// How long a spawn gets to answer, for every caller. One number because a | 30 | /// How long a spawn gets to answer, for every caller. One number because a |
| @@ -109,15 +127,19 @@ pub fn ensureDaemon( | |||
| 109 | const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed; | 127 | const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed; |
| 110 | defer devnull.close(); | 128 | defer devnull.close(); |
| 111 | 129 | ||
| 112 | // argv for the child: exe run <forwarded...>, all null-terminated. | 130 | // argv for the child: mux d run <forwarded...>, all null-terminated. |
| 131 | // The mode word is spelled out so `ps` shows a daemon as a daemon — | ||
| 132 | // it is the only thing separating the long-lived process from the | ||
| 133 | // `mux d start` that spawned it. | ||
| 113 | const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed; | 134 | const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed; |
| 114 | defer alloc.free(exe_z); | 135 | defer alloc.free(exe_z); |
| 115 | const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 2, null) catch | 136 | const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch |
| 116 | return error.SpawnFailed; | 137 | return error.SpawnFailed; |
| 117 | defer alloc.free(argv); | 138 | defer alloc.free(argv); |
| 118 | argv[0] = exe_z.ptr; | 139 | argv[0] = "mux"; |
| 119 | argv[1] = "run"; | 140 | argv[1] = "d"; |
| 120 | for (run_args, 0..) |a, i| argv[i + 2] = a.ptr; | 141 | argv[2] = "run"; |
| 142 | for (run_args, 0..) |a, i| argv[i + 3] = a.ptr; | ||
| 121 | 143 | ||
| 122 | progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix}); | 144 | progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix}); |
| 123 | if (!progress.tty) progress.emit("\n"); | 145 | if (!progress.tty) progress.emit("\n"); |
| @@ -213,11 +235,10 @@ pub fn probe(sock_path: []const u8) bool { | |||
| 213 | return true; | 235 | return true; |
| 214 | } | 236 | } |
| 215 | 237 | ||
| 216 | /// The local entry's auto-start alone; `muxd start` owes a verdict and is | 238 | /// The local entry's auto-start alone, naming no binary: there is one, |
| 217 | /// deliberately not this. | 239 | /// and it is this. |
| 218 | pub fn ensureForAttach( | 240 | pub fn ensureForAttach( |
| 219 | alloc: std.mem.Allocator, | 241 | alloc: std.mem.Allocator, |
| 220 | exe: []const u8, | ||
| 221 | sock_path: []const u8, | 242 | sock_path: []const u8, |
| 222 | prefix: []const u8, | 243 | prefix: []const u8, |
| 223 | ) error{OutOfMemory}!bool { | 244 | ) error{OutOfMemory}!bool { |
| @@ -233,7 +254,7 @@ pub fn ensureForAttach( | |||
| 233 | }; | 254 | }; |
| 234 | _ = ensureDaemon( | 255 | _ = ensureDaemon( |
| 235 | alloc, | 256 | alloc, |
| 236 | exe, | 257 | self_exe, |
| 237 | &run_args, | 258 | &run_args, |
| 238 | sock_path, | 259 | sock_path, |
| 239 | progress, | 260 | progress, |
| @@ -247,40 +268,13 @@ pub fn ensureForAttach( | |||
| 247 | // std.debug.print rather than progress.emitFmt: an exe path can | 268 | // std.debug.print rather than progress.emitFmt: an exe path can |
| 248 | // run to max_path_bytes, and emitFmt's fixed buffer would drop | 269 | // run to max_path_bytes, and emitFmt's fixed buffer would drop |
| 249 | // the whole line rather than shorten it. | 270 | // the whole line rather than shorten it. |
| 250 | std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) }); | 271 | std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, self_exe, @errorName(err) }); |
| 251 | return false; | 272 | return false; |
| 252 | }, | 273 | }, |
| 253 | }; | 274 | }; |
| 254 | return true; | 275 | return true; |
| 255 | } | 276 | } |
| 256 | 277 | ||
| 257 | /// An empty PATH segment is skipped, not read as cwd: never exec a stray | ||
| 258 | /// `./muxd`. | ||
| 259 | pub fn findInPath( | ||
| 260 | alloc: std.mem.Allocator, | ||
| 261 | path_env: []const u8, | ||
| 262 | name: []const u8, | ||
| 263 | ) error{OutOfMemory}!?[]const u8 { | ||
| 264 | var it = std.mem.splitScalar(u8, path_env, ':'); | ||
| 265 | while (it.next()) |dir| { | ||
| 266 | // POSIX reads an empty segment as the current directory. Only the | ||
| 267 | // IMPLICIT cwd is refused: an explicit `.` still resolves against | ||
| 268 | // it, as execvp would. A typed entry is a choice someone made; a | ||
| 269 | // stray colon is invisible, and only the invisible one is a trap. | ||
| 270 | if (dir.len == 0) continue; | ||
| 271 | const candidate = try std.fs.path.join(alloc, &.{ dir, name }); | ||
| 272 | // access(X_OK) also succeeds on a searchable DIRECTORY named | ||
| 273 | // `name`, so a hit is not proof of an executable file. The exec | ||
| 274 | // that follows is what finally rejects it. | ||
| 275 | std.posix.access(candidate, std.posix.X_OK) catch { | ||
| 276 | alloc.free(candidate); | ||
| 277 | continue; | ||
| 278 | }; | ||
| 279 | return candidate; | ||
| 280 | } | ||
| 281 | return null; | ||
| 282 | } | ||
| 283 | |||
| 284 | // --------------------------------------------------------------------------- | 278 | // --------------------------------------------------------------------------- |
| 285 | 279 | ||
| 286 | const testtmp = @import("testtmp"); | 280 | const testtmp = @import("testtmp"); |
| @@ -430,6 +424,64 @@ test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" | |||
| 430 | _ = std.posix.waitpid(last_spawned_pid, 0); | 424 | _ = std.posix.waitpid(last_spawned_pid, 0); |
| 431 | } | 425 | } |
| 432 | 426 | ||
| 427 | test "ensureDaemon: the child execs the path it was HANDED, never a name off PATH" { | ||
| 428 | var tmp = try testtmp.TmpDir.make(); | ||
| 429 | defer tmp.cleanup(); | ||
| 430 | var pbuf: [160]u8 = undefined; | ||
| 431 | var sbuf: [160]u8 = undefined; | ||
| 432 | var lbuf: [160]u8 = undefined; | ||
| 433 | var rbuf: [160]u8 = undefined; | ||
| 434 | const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()}); | ||
| 435 | const sock = try std.fmt.bufPrint(&sbuf, "{s}/self.sock", .{tmp.path()}); | ||
| 436 | const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()}); | ||
| 437 | const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()}); | ||
| 438 | |||
| 439 | // `$0` is the kernel's answer to "which file did you exec": for a | ||
| 440 | // shebang script it is the script, whatever argv[0] the caller wrote. | ||
| 441 | // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so | ||
| 442 | // a spawn that searched PATH cannot pass this. `$*` pins the other half | ||
| 443 | // — the daemon is asked for `d run`, which is also what makes a daemon | ||
| 444 | // legible in `ps`. Production hands `self_exe` and nothing else, so the | ||
| 445 | // same mechanism cannot reach a sibling: `mux` used to hunt PATH for a | ||
| 446 | // `muxd`, and an e2e leg graded an installed v0.0.1-10 that way. | ||
| 447 | var script: [512]u8 = undefined; | ||
| 448 | try tmp.dir.writeFile(.{ | ||
| 449 | .sub_path = "stub.sh", | ||
| 450 | .data = try std.fmt.bufPrint(&script, | ||
| 451 | \\#!/bin/sh | ||
| 452 | \\printf '%s|%s' "$0" "$*" > "{s}" | ||
| 453 | \\exec sleep 30 | ||
| 454 | \\ | ||
| 455 | , .{seen}), | ||
| 456 | }); | ||
| 457 | const f = try tmp.dir.openFile("stub.sh", .{}); | ||
| 458 | try f.chmod(0o755); | ||
| 459 | f.close(); | ||
| 460 | |||
| 461 | try std.testing.expectError(error.NeverAnswered, ensureDaemon( | ||
| 462 | std.testing.allocator, | ||
| 463 | stub, | ||
| 464 | &.{}, | ||
| 465 | sock, | ||
| 466 | silentProgress(), | ||
| 467 | 400, | ||
| 468 | .{ .path = log, .truncate = true }, | ||
| 469 | )); | ||
| 470 | defer { | ||
| 471 | std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {}; | ||
| 472 | _ = std.posix.waitpid(last_spawned_pid, 0); | ||
| 473 | } | ||
| 474 | |||
| 475 | var got_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 476 | // Read through the failure rather than around it: a child that never | ||
| 477 | // ran the stub at all must fail as a MISMATCH naming what it became, | ||
| 478 | // not as a FileNotFound three frames inside std. | ||
| 479 | const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>"; | ||
| 480 | var want_buf: [200]u8 = undefined; | ||
| 481 | const want = try std.fmt.bufPrint(&want_buf, "{s}|d run", .{stub}); | ||
| 482 | try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n")); | ||
| 483 | } | ||
| 484 | |||
| 433 | test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" { | 485 | test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" { |
| 434 | var tmp = try testtmp.TmpDir.make(); | 486 | var tmp = try testtmp.TmpDir.make(); |
| 435 | defer tmp.cleanup(); | 487 | defer tmp.cleanup(); |
| @@ -492,107 +544,6 @@ test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" { | |||
| 492 | ); | 544 | ); |
| 493 | } | 545 | } |
| 494 | 546 | ||
| 495 | test "findInPath: first executable hit wins; non-executables are skipped" { | ||
| 496 | const alloc = std.testing.allocator; | ||
| 497 | var tmp = try testtmp.TmpDir.make(); | ||
| 498 | defer tmp.cleanup(); | ||
| 499 | |||
| 500 | var abuf: [280]u8 = undefined; | ||
| 501 | var bbuf: [280]u8 = undefined; | ||
| 502 | var cbuf: [280]u8 = undefined; | ||
| 503 | const dir_a = try std.fmt.bufPrint(&abuf, "{s}/a", .{tmp.path()}); | ||
| 504 | const dir_b = try std.fmt.bufPrint(&bbuf, "{s}/b", .{tmp.path()}); | ||
| 505 | const dir_c = try std.fmt.bufPrint(&cbuf, "{s}/c", .{tmp.path()}); | ||
| 506 | try std.fs.cwd().makePath(dir_a); | ||
| 507 | try std.fs.cwd().makePath(dir_b); | ||
| 508 | try std.fs.cwd().makePath(dir_c); | ||
| 509 | |||
| 510 | // a/muxd exists but is NOT executable; b/muxd is. The search must skip | ||
| 511 | // the first and return the second — access(X_OK) is the filter, not | ||
| 512 | // mere existence. | ||
| 513 | var pbuf: [560]u8 = undefined; | ||
| 514 | const not_exec = try std.fmt.bufPrint(&pbuf, "{s}/muxd", .{dir_a}); | ||
| 515 | (try std.fs.cwd().createFile(not_exec, .{ .mode = 0o600 })).close(); | ||
| 516 | var pbuf2: [560]u8 = undefined; | ||
| 517 | const exec = try std.fmt.bufPrint(&pbuf2, "{s}/muxd", .{dir_b}); | ||
| 518 | (try std.fs.cwd().createFile(exec, .{ .mode = 0o700 })).close(); | ||
| 519 | |||
| 520 | // c/muxd is executable too and sits AFTER b. Without it "first hit | ||
| 521 | // wins" is unpinned: with one executable in PATH, a search that kept | ||
| 522 | // the last match returns the same b/muxd a correct one does. | ||
| 523 | var pbuf3: [560]u8 = undefined; | ||
| 524 | const later = try std.fmt.bufPrint(&pbuf3, "{s}/muxd", .{dir_c}); | ||
| 525 | (try std.fs.cwd().createFile(later, .{ .mode = 0o700 })).close(); | ||
| 526 | |||
| 527 | var envbuf: [1200]u8 = undefined; | ||
| 528 | const path_env = try std.fmt.bufPrint(&envbuf, "{s}:{s}:{s}", .{ dir_a, dir_b, dir_c }); | ||
| 529 | |||
| 530 | const found = (try findInPath(alloc, path_env, "muxd")).?; | ||
| 531 | defer alloc.free(found); | ||
| 532 | try std.testing.expectEqualStrings(exec, found); | ||
| 533 | } | ||
| 534 | |||
| 535 | test "findInPath: nothing executable anywhere is null, not an error" { | ||
| 536 | const alloc = std.testing.allocator; | ||
| 537 | var tmp = try testtmp.TmpDir.make(); | ||
| 538 | defer tmp.cleanup(); | ||
| 539 | // A non-executable muxd, so the directory is searched and comes up | ||
| 540 | // empty rather than being empty — which is what the name claims. An | ||
| 541 | // absent file and a present-but-unrunnable one are different misses; | ||
| 542 | // this pins the one an operator actually hits (a downloaded binary | ||
| 543 | // nobody chmod'd). | ||
| 544 | var pbuf: [560]u8 = undefined; | ||
| 545 | const not_exec = try std.fmt.bufPrint(&pbuf, "{s}/muxd", .{tmp.path()}); | ||
| 546 | (try std.fs.cwd().createFile(not_exec, .{ .mode = 0o600 })).close(); | ||
| 547 | try std.testing.expectEqual( | ||
| 548 | @as(?[]const u8, null), | ||
| 549 | try findInPath(alloc, tmp.path(), "muxd"), | ||
| 550 | ); | ||
| 551 | } | ||
| 552 | |||
| 553 | test "findInPath: empty PATH segments are skipped, never read as cwd" { | ||
| 554 | const alloc = std.testing.allocator; | ||
| 555 | // POSIX reads an empty segment as the current directory. An attach | ||
| 556 | // must never execute a ./muxd it happens to be standing next to, so | ||
| 557 | // the helper skips them — an all-empty PATH finds nothing even when | ||
| 558 | // the cwd contains an executable by that name. | ||
| 559 | // | ||
| 560 | // Which is why the test STANDS in such a directory. The cwd is | ||
| 561 | // process-global and moving it is unpleasant, but from anywhere else | ||
| 562 | // this case cannot fail: with no ./muxd underfoot, a helper that | ||
| 563 | // dutifully searched the cwd would find nothing there and return the | ||
| 564 | // same null a correct one does. The bait is what makes the assertion | ||
| 565 | // an assertion. | ||
| 566 | var tmp = try testtmp.TmpDir.make(); | ||
| 567 | defer tmp.cleanup(); | ||
| 568 | var pbuf: [560]u8 = undefined; | ||
| 569 | const bait = try std.fmt.bufPrint(&pbuf, "{s}/muxd", .{tmp.path()}); | ||
| 570 | (try std.fs.cwd().createFile(bait, .{ .mode = 0o700 })).close(); | ||
| 571 | |||
| 572 | // Moving the cwd is safe here in a way the code cannot state: the test | ||
| 573 | // runner is sequential, each module gets its own test binary and so its | ||
| 574 | // own process, and no test after this one depends on the cwd. | ||
| 575 | var cwdbuf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 576 | const orig = try std.posix.getcwd(&cwdbuf); | ||
| 577 | try std.posix.chdir(tmp.path()); | ||
| 578 | // Runs before `tmp.cleanup()` (defers unwind in reverse), so the test | ||
| 579 | // is never standing in the directory it is deleting. Panics rather than | ||
| 580 | // swallowing: a cleanup that fails costs a stale tmp dir, but a failed | ||
| 581 | // chdir-BACK leaves every later relative path resolving somewhere | ||
| 582 | // nobody chose, so there is no honest way to keep going. | ||
| 583 | defer std.posix.chdir(orig) catch |err| | ||
| 584 | std.debug.panic("spawn test: could not chdir back to {s}: {t}", .{ orig, err }); | ||
| 585 | |||
| 586 | try std.testing.expectEqual( | ||
| 587 | @as(?[]const u8, null), | ||
| 588 | try findInPath(alloc, "::", "muxd"), | ||
| 589 | ); | ||
| 590 | try std.testing.expectEqual( | ||
| 591 | @as(?[]const u8, null), | ||
| 592 | try findInPath(alloc, "", "muxd"), | ||
| 593 | ); | ||
| 594 | } | ||
| 595 | |||
| 596 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 547 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
| 597 | // unreferenced decl must at least compile (the silent-module-loss hazard, | 548 | // unreferenced decl must at least compile (the silent-module-loss hazard, |
| 598 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | 549 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. |