5cada44f
feat(shellint): OSC 133 marks injected at spawn — ZDOTDIR shim, --init-file, vendor_conf.d
a73x 2026-08-13 17:10
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -195,6 +195,16 @@ pub fn build(b: *std.Build) void { | |||
| 195 | cmd_mod.addImport("engine", engine_mod); | 195 | cmd_mod.addImport("engine", engine_mod); |
| 196 | cmd_mod.addImport("protocol", protocol_mod); | 196 | cmd_mod.addImport("protocol", protocol_mod); |
| 197 | 197 | ||
| 198 | // Shell integration: the OSC 133 mark scripts and what a spawn must add | ||
| 199 | // to hand them to a shell. A leaf on purpose — it writes files and reads | ||
| 200 | // the environment, and knows nothing of ptys, servers or the protocol, | ||
| 201 | // so its tests need no daemon and no socket. | ||
| 202 | const shellint_mod = b.createModule(.{ | ||
| 203 | .root_source_file = b.path("src/shellint.zig"), | ||
| 204 | .target = target, | ||
| 205 | .optimize = optimize, | ||
| 206 | }); | ||
| 207 | |||
| 198 | // The replay core: snapshot/delta application and the resume | 208 | // The replay core: snapshot/delta application and the resume |
| 199 | // coordinates, shared by the CLI client, the wasm core, and the | 209 | // coordinates, shared by the CLI client, the wasm core, and the |
| 200 | // server's test fixtures. Engine plus protocol and nothing else, and | 210 | // server's test fixtures. Engine plus protocol and nothing else, and |
| @@ -247,6 +257,7 @@ pub fn build(b: *std.Build) void { | |||
| 247 | server_mod.addImport("protocol", protocol_mod); | 257 | server_mod.addImport("protocol", protocol_mod); |
| 248 | server_mod.addImport("delta", delta_mod); | 258 | server_mod.addImport("delta", delta_mod); |
| 249 | server_mod.addImport("cmd", cmd_mod); | 259 | server_mod.addImport("cmd", cmd_mod); |
| 260 | server_mod.addImport("shellint", shellint_mod); | ||
| 250 | server_mod.addImport("replica", replica_mod); | 261 | server_mod.addImport("replica", replica_mod); |
| 251 | server_mod.addImport("sockpath", sockpath_mod); | 262 | server_mod.addImport("sockpath", sockpath_mod); |
| 252 | // Both: the listener it owns, and the vocabulary it names directly | 263 | // Both: the listener it owns, and the vocabulary it names directly |
| @@ -533,7 +544,7 @@ pub fn build(b: *std.Build) void { | |||
| 533 | b.installArtifact(webhub_exe); | 544 | b.installArtifact(webhub_exe); |
| 534 | 545 | ||
| 535 | const test_step = b.step("test", "Run unit tests"); | 546 | const test_step = b.step("test", "Run unit tests"); |
| 536 | // delta_mod, cmd_mod, and sockpath_mod sit BEFORE server_mod, | 547 | // delta_mod, cmd_mod, shellint_mod and sockpath_mod sit BEFORE server_mod, |
| 537 | // deliberately: their tests are seconds-long and socket-free, while a | 548 | // deliberately: their tests are seconds-long and socket-free, while a |
| 538 | // regression in any of them can wedge a server test that waits on a | 549 | // regression in any of them can wedge a server test that waits on a |
| 539 | // client forever — and a wedged step prints nothing at all. Failing | 550 | // client forever — and a wedged step prints nothing at all. Failing |
| @@ -544,7 +555,7 @@ pub fn build(b: *std.Build) void { | |||
| 544 | // absence here was a live hazard recorded in decisions.md — muxd's | 555 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 545 | // entrypoint could grow tests that silently never ran, exactly as | 556 | // entrypoint could grow tests that silently never ran, exactly as |
| 546 | // mux_main.zig's five did before it was added. | 557 | // mux_main.zig's five did before it was added. |
| 547 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod}) |mod| { | 558 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, shellint_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod}) |mod| { |
| 548 | const t = b.addTest(.{ .root_module = mod }); | 559 | const t = b.addTest(.{ .root_module = mod }); |
| 549 | t.use_llvm = true; | 560 | t.use_llvm = true; |
| 550 | t.use_lld = true; | 561 | t.use_lld = true; |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -436,11 +436,22 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { | |||
| 436 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); | 436 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); |
| 437 | defer alloc.free(shell_z); | 437 | defer alloc.free(shell_z); |
| 438 | 438 | ||
| 439 | // Read from the DAEMON's environment, necessarily: muxd forks the | ||
| 440 | // session shell, so by the time anyone could pass a flag through a | ||
| 441 | // client the shell has been running for a while. `MUX_SHELL_INTEGRATION=0` | ||
| 442 | // and nothing else — any other value, including unset, means on. | ||
| 443 | const shell_integration = !std.mem.eql( | ||
| 444 | u8, | ||
| 445 | std.posix.getenv("MUX_SHELL_INTEGRATION") orelse "", | ||
| 446 | "0", | ||
| 447 | ); | ||
| 448 | |||
| 439 | var srv = Server.init(alloc, .{ | 449 | var srv = Server.init(alloc, .{ |
| 440 | .sock_path = sock_path, | 450 | .sock_path = sock_path, |
| 441 | .shell = shell_z, | 451 | .shell = shell_z, |
| 442 | .cols = o.cols, | 452 | .cols = o.cols, |
| 443 | .rows = o.rows, | 453 | .rows = o.rows, |
| 454 | .shell_integration = shell_integration, | ||
| 444 | }) catch |err| switch (err) { | 455 | }) catch |err| switch (err) { |
| 445 | // All of these mean "that path is not ours to take", and all | 456 | // All of these mean "that path is not ours to take", and all |
| 446 | // are ordinary operator mistakes rather than daemon bugs: say | 457 | // are ordinary operator mistakes rather than daemon bugs: say |
src/pty.zig
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,13 @@ pub const Pty = struct { | |||
| 24 | return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv }); | 24 | return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv }); |
| 25 | } | 25 | } |
| 26 | 26 | ||
| 27 | /// One variable to set in the child. Spelled here rather than imported | ||
| 28 | /// so this module stays a leaf: a pty knows how to hand a child an | ||
| 29 | /// environment, and deliberately does not know that shell integration | ||
| 30 | /// is what currently wants one. The daemon maps its own pairs onto | ||
| 31 | /// these — one loop, and the layering stays the right way up. | ||
| 32 | pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 }; | ||
| 33 | |||
| 27 | pub const SpawnArgvOptions = struct { | 34 | pub const SpawnArgvOptions = struct { |
| 28 | cols: u16, | 35 | cols: u16, |
| 29 | rows: u16, | 36 | rows: u16, |
| @@ -34,6 +41,9 @@ pub const Pty = struct { | |||
| 34 | /// The e2e fixture uses this to keep predict stats out of the | 41 | /// The e2e fixture uses this to keep predict stats out of the |
| 35 | /// capture, matching the suite's `.err` sibling convention. | 42 | /// capture, matching the suite's `.err` sibling convention. |
| 36 | stderr_fd: ?std.posix.fd_t = null, | 43 | stderr_fd: ?std.posix.fd_t = null, |
| 44 | /// Set in the child between fork and exec, after TERM. Injection's | ||
| 45 | /// door: the daemon's env is the only source of a child's env. | ||
| 46 | env: []const EnvPair = &.{}, | ||
| 37 | }; | 47 | }; |
| 38 | 48 | ||
| 39 | /// The one child-setup path: everything that has to be true of a process | 49 | /// The one child-setup path: everything that has to be true of a process |
| @@ -60,6 +70,11 @@ pub const Pty = struct { | |||
| 60 | // Child. xterm-256color: ghostty-vt understands more, but this | 70 | // Child. xterm-256color: ghostty-vt understands more, but this |
| 61 | // terminfo exists everywhere the shell will look. | 71 | // terminfo exists everywhere the shell will look. |
| 62 | _ = c.setenv("TERM", "xterm-256color", 1); | 72 | _ = c.setenv("TERM", "xterm-256color", 1); |
| 73 | // After TERM so a caller could override it, and before the | ||
| 74 | // signal work so the environment is settled whatever follows. | ||
| 75 | // Overwrite (1): the daemon's own value for a name it was | ||
| 76 | // handed is not the one it means the child to see. | ||
| 77 | for (opts.env) |kv| _ = c.setenv(kv.key.ptr, kv.value.ptr, 1); | ||
| 63 | 78 | ||
| 64 | // Ctrl-C must work in the session, and without this it does not. | 79 | // Ctrl-C must work in the session, and without this it does not. |
| 65 | // A non-interactive shell sets SIGINT and SIGQUIT to SIG_IGN for | 80 | // A non-interactive shell sets SIGINT and SIGQUIT to SIG_IGN for |
| @@ -359,6 +374,26 @@ test "Pty: spawnArgv applies the requested winsize" { | |||
| 359 | try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null); | 374 | try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null); |
| 360 | } | 375 | } |
| 361 | 376 | ||
| 377 | test "Pty: spawnArgv env pairs reach the child" { | ||
| 378 | // The child prints the variable rather than being asked about it: an | ||
| 379 | // exported name that the exec'd process cannot read is the failure | ||
| 380 | // this guards, so the assertion has to come from inside the child. | ||
| 381 | var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf 'env-%s' \"$MUX_T\"" }; | ||
| 382 | var pty = try Pty.spawnArgv(.{ | ||
| 383 | .cols = 80, | ||
| 384 | .rows = 24, | ||
| 385 | .argv = &argv, | ||
| 386 | .env = &.{.{ .key = "MUX_T", .value = "ok" }}, | ||
| 387 | }); | ||
| 388 | defer pty.deinit(); | ||
| 389 | |||
| 390 | var out = try readUntil(std.testing.allocator, &pty, "env-ok", 5000); | ||
| 391 | defer out.deinit(std.testing.allocator); | ||
| 392 | // "env-" alone would appear for an unset variable too, which is exactly | ||
| 393 | // the broken case; the value is what makes this an assertion. | ||
| 394 | try std.testing.expect(std.mem.indexOf(u8, out.items, "env-ok") != null); | ||
| 395 | } | ||
| 396 | |||
| 362 | test "Pty: spawnArgv redirects stderr off the pty when asked" { | 397 | test "Pty: spawnArgv redirects stderr off the pty when asked" { |
| 363 | const pipe = try std.posix.pipe(); | 398 | const pipe = try std.posix.pipe(); |
| 364 | defer std.posix.close(pipe[0]); | 399 | defer std.posix.close(pipe[0]); |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ const Pty = @import("pty").Pty; | |||
| 11 | const proto = @import("protocol"); | 11 | const proto = @import("protocol"); |
| 12 | const DeltaTracker = @import("delta").DeltaTracker; | 12 | const DeltaTracker = @import("delta").DeltaTracker; |
| 13 | const cmdmod = @import("cmd"); | 13 | const cmdmod = @import("cmd"); |
| 14 | const shellint = @import("shellint"); | ||
| 14 | // Test-only consumer (applyFrame): the tests replay daemon frames through | 15 | // Test-only consumer (applyFrame): the tests replay daemon frames through |
| 15 | // the production client's replay core rather than a hand-rolled twin. | 16 | // the production client's replay core rather than a hand-rolled twin. |
| 16 | const replica_mod = @import("replica"); | 17 | const replica_mod = @import("replica"); |
| @@ -315,6 +316,15 @@ pub const Server = struct { | |||
| 315 | /// 0 means the session has never said anything, which no amount of | 316 | /// 0 means the session has never said anything, which no amount of |
| 316 | /// elapsed silence should be read as a command having finished. | 317 | /// elapsed silence should be read as a command having finished. |
| 317 | last_pty_ms: i64 = 0, | 318 | last_pty_ms: i64 = 0, |
| 319 | /// Holds every string the shell-integration injection handed the spawn: | ||
| 320 | /// the shim directory's path, the argv, the env pairs. An arena because | ||
| 321 | /// they are allocated once, in init, and freed once, together. | ||
| 322 | shellint_arena: std.heap.ArenaAllocator, | ||
| 323 | /// The shim directory to remove at teardown, or null when nothing was | ||
| 324 | /// written — integration off, or a shell this daemon has no scripts for. | ||
| 325 | /// Distinct from "the arena is empty": only a directory that exists is | ||
| 326 | /// one we are responsible for deleting. | ||
| 327 | shellint_dir: ?[]const u8 = null, | ||
| 318 | stats: Stats = .{}, | 328 | stats: Stats = .{}, |
| 319 | 329 | ||
| 320 | pub const Options = struct { | 330 | pub const Options = struct { |
| @@ -322,6 +332,12 @@ pub const Server = struct { | |||
| 322 | shell: [:0]const u8, | 332 | shell: [:0]const u8, |
| 323 | cols: u16 = 80, | 333 | cols: u16 = 80, |
| 324 | rows: u16 = 24, | 334 | rows: u16 = 24, |
| 335 | /// Inject the OSC 133 mark scripts into the session shell. On by | ||
| 336 | /// default: marks are what make an exit code knowable, and every | ||
| 337 | /// fallback below them is a guess. `muxd run` turns it off for | ||
| 338 | /// `MUX_SHELL_INTEGRATION=0`, and a shell shellint has no scripts | ||
| 339 | /// for is unaffected either way. | ||
| 340 | shell_integration: bool = true, | ||
| 325 | }; | 341 | }; |
| 326 | 342 | ||
| 327 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { | 343 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { |
| @@ -332,7 +348,60 @@ pub const Server = struct { | |||
| 332 | const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); | 348 | const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); |
| 333 | errdefer eng.deinit(); | 349 | errdefer eng.deinit(); |
| 334 | 350 | ||
| 335 | var pty = try Pty.spawn(.{ .cols = opts.cols, .rows = opts.rows, .shell = opts.shell }); | 351 | // Shell integration, decided and written before the fork: whatever |
| 352 | // the child is going to be told has to exist on disk by the time it | ||
| 353 | // execs, and a failure here is still cheap — no process yet. | ||
| 354 | var shellint_arena = std.heap.ArenaAllocator.init(alloc); | ||
| 355 | errdefer shellint_arena.deinit(); | ||
| 356 | var shellint_dir: ?[]const u8 = null; | ||
| 357 | var injection: shellint.Injection = .{ .extra_argv = &.{}, .env = &.{} }; | ||
| 358 | if (opts.shell_integration) { | ||
| 359 | const a = shellint_arena.allocator(); | ||
| 360 | // Beside the socket: that directory is already private, already | ||
| 361 | // runtime-appropriate and already per-user, which is three | ||
| 362 | // properties the shims need and none of them are ours to | ||
| 363 | // re-derive. The pid keeps two daemons sharing one socket | ||
| 364 | // directory out of each other's shims. | ||
| 365 | const parent = std.fs.path.dirname(opts.sock_path) orelse "."; | ||
| 366 | const dir = try std.fmt.allocPrint( | ||
| 367 | a, | ||
| 368 | "{s}/mux-shellint-{d}", | ||
| 369 | .{ parent, std.os.linux.getpid() }, | ||
| 370 | ); | ||
| 371 | injection = try shellint.prepare(a, dir, opts.shell); | ||
| 372 | // Only a shell we actually wrote scripts for leaves a directory | ||
| 373 | // behind. Recording the path unconditionally would make teardown | ||
| 374 | // delete-tree a path nothing ever created — harmless today, and | ||
| 375 | // exactly the kind of "the cleanup claims work it did not do" | ||
| 376 | // this project has been burned by. | ||
| 377 | if (shellint.detect(opts.shell) != .other) shellint_dir = dir; | ||
| 378 | } | ||
| 379 | // shellint speaks its own EnvPair so it can stay a leaf, and so can | ||
| 380 | // pty; the daemon is the one place that knows about both, so the | ||
| 381 | // mapping lives here. The arena outlives the spawn, as spawnArgv | ||
| 382 | // requires of anything it reads in the child. | ||
| 383 | const env = try shellint_arena.allocator().alloc(Pty.EnvPair, injection.env.len); | ||
| 384 | for (injection.env, env) |src, *dst| { | ||
| 385 | dst.* = .{ .key = src.key, .value = src.value }; | ||
| 386 | } | ||
| 387 | // argv is the shell plus whatever the injection adds, null-terminated | ||
| 388 | // for execve. With no extra argv and no env this is byte-identical to | ||
| 389 | // the old `Pty.spawn` call, which is what keeps a /bin/sh session | ||
| 390 | // exactly the session it was before shell integration existed. | ||
| 391 | const argv = try shellint_arena.allocator().allocSentinel( | ||
| 392 | ?[*:0]const u8, | ||
| 393 | 1 + injection.extra_argv.len, | ||
| 394 | null, | ||
| 395 | ); | ||
| 396 | argv[0] = opts.shell.ptr; | ||
| 397 | for (injection.extra_argv, argv[1..]) |src, *dst| dst.* = src.ptr; | ||
| 398 | |||
| 399 | var pty = try Pty.spawnArgv(.{ | ||
| 400 | .cols = opts.cols, | ||
| 401 | .rows = opts.rows, | ||
| 402 | .argv = argv.ptr, | ||
| 403 | .env = env, | ||
| 404 | }); | ||
| 336 | errdefer pty.deinit(); | 405 | errdefer pty.deinit(); |
| 337 | 406 | ||
| 338 | // Random rather than a counter or a timestamp: nothing on disk | 407 | // Random rather than a counter or a timestamp: nothing on disk |
| @@ -352,6 +421,8 @@ pub const Server = struct { | |||
| 352 | .sock_path = opts.sock_path, | 421 | .sock_path = opts.sock_path, |
| 353 | .path_id = path_id, | 422 | .path_id = path_id, |
| 354 | .epoch = epoch, | 423 | .epoch = epoch, |
| 424 | .shellint_arena = shellint_arena, | ||
| 425 | .shellint_dir = shellint_dir, | ||
| 355 | }; | 426 | }; |
| 356 | } | 427 | } |
| 357 | 428 | ||
| @@ -403,6 +474,12 @@ pub const Server = struct { | |||
| 403 | } | 474 | } |
| 404 | self.tracker.deinit(self.alloc); | 475 | self.tracker.deinit(self.alloc); |
| 405 | self.pty.deinit(); | 476 | self.pty.deinit(); |
| 477 | // After the pty, so the shell is gone before the files it was | ||
| 478 | // reading are: a shim removed out from under a live shell would be | ||
| 479 | // a session that half-sourced its own integration. Best-effort — | ||
| 480 | // a daemon that cannot tidy /tmp must still exit. | ||
| 481 | if (self.shellint_dir) |dir| std.fs.cwd().deleteTree(dir) catch {}; | ||
| 482 | self.shellint_arena.deinit(); | ||
| 406 | self.eng.deinit(); | 483 | self.eng.deinit(); |
| 407 | } | 484 | } |
| 408 | 485 | ||
| @@ -4820,6 +4897,162 @@ test "Server: OSC 133 marks reach attached clients as cmd_state pushes" { | |||
| 4820 | try std.testing.expect(returned.seq > 0); | 4897 | try std.testing.expect(returned.seq > 0); |
| 4821 | } | 4898 | } |
| 4822 | 4899 | ||
| 4900 | // --------------------------------------------------------------------------- | ||
| 4901 | // Shell integration, end to end. Everything above this line proves the daemon | ||
| 4902 | // can read marks; these two prove a real shell EMITS them, which is the only | ||
| 4903 | // version of the claim that matters in a session. The chain under test is | ||
| 4904 | // injection -> shell -> pty -> engine -> tracker -> wire, and no part of it is | ||
| 4905 | // stubbed. | ||
| 4906 | // --------------------------------------------------------------------------- | ||
| 4907 | |||
| 4908 | /// Pump until a cmd_state push says a command returned, and hand back what it | ||
| 4909 | /// said. Deliberately tolerant of what arrives first: an integrated shell | ||
| 4910 | /// emits a bare `A` for its opening prompt and a `C` when the command starts, | ||
| 4911 | /// so several cmd_state frames precede the interesting one and a test that | ||
| 4912 | /// insisted on the first would be asserting the shell's prompt timing. | ||
| 4913 | fn awaitReturn( | ||
| 4914 | alloc: std.mem.Allocator, | ||
| 4915 | srv: *Server, | ||
| 4916 | fd: std.posix.fd_t, | ||
| 4917 | budget_ms: i64, | ||
| 4918 | ) !?proto.CmdState { | ||
| 4919 | const deadline = std.time.milliTimestamp() + budget_ms; | ||
| 4920 | while (std.time.milliTimestamp() < deadline) { | ||
| 4921 | _ = try srv.pumpOnce(5); | ||
| 4922 | var pfd = [_]std.posix.pollfd{ | ||
| 4923 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 4924 | }; | ||
| 4925 | if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue; | ||
| 4926 | const frame = (try proto.readFrame(alloc, fd)) orelse return null; | ||
| 4927 | defer frame.deinit(alloc); | ||
| 4928 | if (frame.type != .cmd_state) continue; | ||
| 4929 | const st = try proto.decodeCmdState(frame.payload); | ||
| 4930 | if (st.phase == .returned) return st; | ||
| 4931 | } | ||
| 4932 | return null; | ||
| 4933 | } | ||
| 4934 | |||
| 4935 | /// Run one command through a real shell with integration injected, and | ||
| 4936 | /// return the daemon's verdict on it. `shell` must exist; the callers | ||
| 4937 | /// skip rather than fail when it does not. | ||
| 4938 | fn runIntegrated(alloc: std.mem.Allocator, shell: [:0]const u8, tag: []const u8) !proto.CmdState { | ||
| 4939 | var tmp = try TmpDir.make(); | ||
| 4940 | defer tmp.cleanup(); | ||
| 4941 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/{s}.sock", .{ tmp.path(), tag }); | ||
| 4942 | defer alloc.free(sock_path); | ||
| 4943 | |||
| 4944 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = shell }); | ||
| 4945 | defer srv.deinit(); | ||
| 4946 | |||
| 4947 | // The shim really was written where the daemon says it was: if this | ||
| 4948 | // directory were missing, the shell would start fine and simply emit | ||
| 4949 | // nothing, and the timeout below would report "no marks" without ever | ||
| 4950 | // saying why. | ||
| 4951 | try std.testing.expect(srv.shellint_dir != null); | ||
| 4952 | try std.fs.cwd().access(srv.shellint_dir.?, .{}); | ||
| 4953 | |||
| 4954 | const c = try std.net.connectUnixSocket(sock_path); | ||
| 4955 | defer c.close(); | ||
| 4956 | try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); | ||
| 4957 | |||
| 4958 | // `true` because it is a builtin in both shells and exits 0 without | ||
| 4959 | // output: the only thing the pty carries back for it is the marks and | ||
| 4960 | // the echo of the line, so a `returned` push cannot have come from | ||
| 4961 | // anywhere else. | ||
| 4962 | try proto.writeFrame(c.handle, .input, "true\n"); | ||
| 4963 | |||
| 4964 | // 10s is a budget for a loaded box sourcing somebody's real rc files, | ||
| 4965 | // not an expectation — the shells here answer in tens of milliseconds. | ||
| 4966 | return (try awaitReturn(alloc, &srv, c.handle, 10_000)) orelse error.NoReturnedPush; | ||
| 4967 | } | ||
| 4968 | |||
| 4969 | test "Server: bash sessions emit marks with no rc-file edits" { | ||
| 4970 | const alloc = std.testing.allocator; | ||
| 4971 | std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest; | ||
| 4972 | |||
| 4973 | const st = try runIntegrated(alloc, "/bin/bash", "bash"); | ||
| 4974 | // marks, not pgid: the fallbacks could also notice a command ending, so | ||
| 4975 | // this field is what separates "integration works" from "the heuristic | ||
| 4976 | // covered for it". | ||
| 4977 | try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism); | ||
| 4978 | // And the exit code, which no fallback can produce at all. | ||
| 4979 | try std.testing.expectEqual(@as(?u8, 0), st.exit_code); | ||
| 4980 | } | ||
| 4981 | |||
| 4982 | test "Server: zsh sessions emit marks with no rc-file edits" { | ||
| 4983 | const alloc = std.testing.allocator; | ||
| 4984 | // Both spellings, because the box that has zsh does not always agree | ||
| 4985 | // with the box that had it last. | ||
| 4986 | const shell: [:0]const u8 = blk: { | ||
| 4987 | for ([_][:0]const u8{ "/usr/bin/zsh", "/bin/zsh" }) |p| { | ||
| 4988 | std.fs.cwd().access(p, .{}) catch continue; | ||
| 4989 | break :blk p; | ||
| 4990 | } | ||
| 4991 | return error.SkipZigTest; | ||
| 4992 | }; | ||
| 4993 | |||
| 4994 | const st = try runIntegrated(alloc, shell, "zsh"); | ||
| 4995 | try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism); | ||
| 4996 | try std.testing.expectEqual(@as(?u8, 0), st.exit_code); | ||
| 4997 | } | ||
| 4998 | |||
| 4999 | test "Server: the shim directory is private, and teardown takes it with it" { | ||
| 5000 | const alloc = std.testing.allocator; | ||
| 5001 | std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest; | ||
| 5002 | |||
| 5003 | var tmp = try TmpDir.make(); | ||
| 5004 | defer tmp.cleanup(); | ||
| 5005 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/shim.sock", .{tmp.path()}); | ||
| 5006 | defer alloc.free(sock_path); | ||
| 5007 | |||
| 5008 | // Copied out of the server's arena before deinit frees it: the whole | ||
| 5009 | // point of this test is to ask a question after the server is gone. | ||
| 5010 | var dir_buf: [256]u8 = undefined; | ||
| 5011 | var dir: []const u8 = undefined; | ||
| 5012 | { | ||
| 5013 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/bash" }); | ||
| 5014 | defer srv.deinit(); | ||
| 5015 | dir = try std.fmt.bufPrint(&dir_buf, "{s}", .{srv.shellint_dir.?}); | ||
| 5016 | |||
| 5017 | // Beside the socket, not somewhere world-readable: the file the | ||
| 5018 | // session shell is about to source is a file that runs code as this | ||
| 5019 | // user, so 0700 on the directory is part of the contract. | ||
| 5020 | try std.testing.expectEqualStrings(std.fs.path.dirname(sock_path).?, std.fs.path.dirname(dir).?); | ||
| 5021 | var d = try std.fs.cwd().openDir(dir, .{ .iterate = true }); | ||
| 5022 | defer d.close(); | ||
| 5023 | const st = try d.stat(); | ||
| 5024 | try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777))); | ||
| 5025 | } | ||
| 5026 | |||
| 5027 | // A daemon that left its shims behind would litter the runtime directory | ||
| 5028 | // once per session, and nothing else in the system would ever notice. | ||
| 5029 | try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dir, .{})); | ||
| 5030 | } | ||
| 5031 | |||
| 5032 | test "Server: an unknown shell is not injected into at all — no directory, no shim" { | ||
| 5033 | const alloc = std.testing.allocator; | ||
| 5034 | |||
| 5035 | var tmp = try TmpDir.make(); | ||
| 5036 | defer tmp.cleanup(); | ||
| 5037 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/plain.sock", .{tmp.path()}); | ||
| 5038 | defer alloc.free(sock_path); | ||
| 5039 | |||
| 5040 | // Integration is ON, and /bin/sh still gets nothing: this is what keeps | ||
| 5041 | // every other test in this file describing the session it always did. | ||
| 5042 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | ||
| 5043 | defer srv.deinit(); | ||
| 5044 | try std.testing.expectEqual(@as(?[]const u8, null), srv.shellint_dir); | ||
| 5045 | |||
| 5046 | // Nothing was written beside the socket either — the absence is on disk, | ||
| 5047 | // not merely in a field the teardown consults. | ||
| 5048 | var d = try std.fs.cwd().openDir(tmp.path(), .{ .iterate = true }); | ||
| 5049 | defer d.close(); | ||
| 5050 | var it = d.iterate(); | ||
| 5051 | while (try it.next()) |entry| { | ||
| 5052 | try std.testing.expect(!std.mem.startsWith(u8, entry.name, "mux-shellint-")); | ||
| 5053 | } | ||
| 5054 | } | ||
| 5055 | |||
| 4823 | test "Server: status_req is answered on an attached client and on a bare observer" { | 5056 | test "Server: status_req is answered on an attached client and on a bare observer" { |
| 4824 | const alloc = std.testing.allocator; | 5057 | const alloc = std.testing.allocator; |
| 4825 | 5058 | ||
src/shellint.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,285 @@ | |||
| 1 | //! Shell integration: OSC 133 marks injected at spawn. muxd forks the | ||
| 2 | //! session shell itself, so injection is env + argv at spawn time — no | ||
| 3 | //! rc-file edits, ever. Detection is by shell basename; unknown shells get | ||
| 4 | //! nothing and the session runs on the pgid/settle fallbacks. | ||
| 5 | const std = @import("std"); | ||
| 6 | |||
| 7 | pub const zsh_zshrc = | ||
| 8 | \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR | ||
| 9 | \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc. | ||
| 10 | \\if [[ -n "$MUX_ORIG_ZDOTDIR" ]]; then | ||
| 11 | \\ export ZDOTDIR="$MUX_ORIG_ZDOTDIR" | ||
| 12 | \\ unset MUX_ORIG_ZDOTDIR | ||
| 13 | \\else | ||
| 14 | \\ unset ZDOTDIR | ||
| 15 | \\fi | ||
| 16 | \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc" | ||
| 17 | \\autoload -Uz add-zsh-hook | ||
| 18 | \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; } | ||
| 19 | \\_mux_precmd() { | ||
| 20 | \\ local code=$? | ||
| 21 | \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code" | ||
| 22 | \\ _mux_ran="" | ||
| 23 | \\ printf '\e]133;A\a' | ||
| 24 | \\} | ||
| 25 | \\add-zsh-hook preexec _mux_preexec | ||
| 26 | \\add-zsh-hook precmd _mux_precmd | ||
| 27 | \\ | ||
| 28 | ; | ||
| 29 | |||
| 30 | pub const bash_init = | ||
| 31 | \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file; | ||
| 32 | \\# sources the user's normal rc first so their config still runs. | ||
| 33 | \\[[ -f "$HOME/.bashrc" ]] && source "$HOME/.bashrc" | ||
| 34 | \\_mux_ran="" | ||
| 35 | \\_mux_preexec() { | ||
| 36 | \\ [[ -n "$COMP_LINE" ]] && return | ||
| 37 | \\ [[ "$BASH_COMMAND" == _mux_precmd* ]] && return | ||
| 38 | \\ _mux_ran=1 | ||
| 39 | \\ printf '\e]133;C\a' | ||
| 40 | \\} | ||
| 41 | \\_mux_precmd() { | ||
| 42 | \\ local code=$? | ||
| 43 | \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code" | ||
| 44 | \\ _mux_ran="" | ||
| 45 | \\ printf '\e]133;A\a' | ||
| 46 | \\} | ||
| 47 | \\trap '_mux_preexec' DEBUG | ||
| 48 | \\PROMPT_COMMAND="_mux_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}" | ||
| 49 | \\ | ||
| 50 | ; | ||
| 51 | |||
| 52 | pub const fish_conf = | ||
| 53 | \\# mux shell integration (fish): OSC 133 marks, via vendor_conf.d. | ||
| 54 | \\function _mux_preexec --on-event fish_preexec | ||
| 55 | \\ printf '\e]133;C\a' | ||
| 56 | \\end | ||
| 57 | \\function _mux_postexec --on-event fish_postexec | ||
| 58 | \\ printf '\e]133;D;%s\a' $status | ||
| 59 | \\end | ||
| 60 | \\function _mux_prompt --on-event fish_prompt | ||
| 61 | \\ printf '\e]133;A\a' | ||
| 62 | \\end | ||
| 63 | \\ | ||
| 64 | ; | ||
| 65 | |||
| 66 | pub const Kind = enum { zsh, bash, fish, other }; | ||
| 67 | |||
| 68 | pub fn detect(shell_path: []const u8) Kind { | ||
| 69 | const base = std.fs.path.basename(shell_path); | ||
| 70 | if (std.mem.eql(u8, base, "zsh")) return .zsh; | ||
| 71 | if (std.mem.eql(u8, base, "bash")) return .bash; | ||
| 72 | if (std.mem.eql(u8, base, "fish")) return .fish; | ||
| 73 | return .other; | ||
| 74 | } | ||
| 75 | |||
| 76 | pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 }; | ||
| 77 | |||
| 78 | /// Everything the spawn needs: the argv to exec and env pairs to set in | ||
| 79 | /// the child. `dir` must outlive the spawn (paths point into it). | ||
| 80 | pub const Injection = struct { | ||
| 81 | /// Extra argv AFTER the shell path (bash --init-file <shim>); empty | ||
| 82 | /// for env-only injections (zsh, fish) and for .other. | ||
| 83 | extra_argv: []const [:0]const u8, | ||
| 84 | env: []const EnvPair, | ||
| 85 | }; | ||
| 86 | |||
| 87 | /// Prepare shim files under `dir` (created private, 0700) for `shell_path` | ||
| 88 | /// and return what spawn must add. All returned slices are allocated from | ||
| 89 | /// `arena` — hand it an arena that lives as long as the daemon. | ||
| 90 | pub fn prepare( | ||
| 91 | arena: std.mem.Allocator, | ||
| 92 | dir: []const u8, | ||
| 93 | shell_path: []const u8, | ||
| 94 | ) !Injection { | ||
| 95 | switch (detect(shell_path)) { | ||
| 96 | .zsh => { | ||
| 97 | try makeDirPrivate(dir); | ||
| 98 | const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" }); | ||
| 99 | try writeFilePrivate(rc_path, zsh_zshrc); | ||
| 100 | var env: std.ArrayList(EnvPair) = .empty; | ||
| 101 | const dir_z = try arena.dupeZ(u8, dir); | ||
| 102 | try env.append(arena, .{ .key = "ZDOTDIR", .value = dir_z }); | ||
| 103 | // Only when the daemon itself carried one: exporting an empty | ||
| 104 | // ZDOTDIR would break zsh's fallback to $HOME (spec footnote). | ||
| 105 | if (std.posix.getenv("ZDOTDIR")) |orig| { | ||
| 106 | try env.append(arena, .{ | ||
| 107 | .key = "MUX_ORIG_ZDOTDIR", | ||
| 108 | .value = try arena.dupeZ(u8, orig), | ||
| 109 | }); | ||
| 110 | } | ||
| 111 | return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena) }; | ||
| 112 | }, | ||
| 113 | .bash => { | ||
| 114 | try makeDirPrivate(dir); | ||
| 115 | const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" }); | ||
| 116 | try writeFilePrivate(init_path, bash_init); | ||
| 117 | const init_z = try arena.dupeZ(u8, init_path); | ||
| 118 | const argv = try arena.alloc([:0]const u8, 2); | ||
| 119 | argv[0] = "--init-file"; | ||
| 120 | argv[1] = init_z; | ||
| 121 | return .{ .extra_argv = argv, .env = &.{} }; | ||
| 122 | }, | ||
| 123 | .fish => { | ||
| 124 | const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" }); | ||
| 125 | try makeDirPrivate(vendor); | ||
| 126 | const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" }); | ||
| 127 | try writeFilePrivate(conf_path, fish_conf); | ||
| 128 | const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share"; | ||
| 129 | const merged = try std.fmt.allocPrintSentinel(arena, "{s}:{s}", .{ dir, orig }, 0); | ||
| 130 | return .{ | ||
| 131 | .extra_argv = &.{}, | ||
| 132 | .env = try arena.dupe(EnvPair, &.{.{ .key = "XDG_DATA_DIRS", .value = merged }}), | ||
| 133 | }; | ||
| 134 | }, | ||
| 135 | .other => return .{ .extra_argv = &.{}, .env = &.{} }, | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | /// makePath plus the 0700 tightening the key file's parent gets, and for | ||
| 140 | /// the same reason: the shim's contents are 0600 either way, but a 0755 | ||
| 141 | /// directory publishes that this daemon exists and what it named its | ||
| 142 | /// files. Only the last component is tightened — the runtime directory on | ||
| 143 | /// the way there is not ours to re-permission. | ||
| 144 | fn makeDirPrivate(dir: []const u8) !void { | ||
| 145 | try std.fs.cwd().makePath(dir); | ||
| 146 | // `.iterate = true` is not optional: Dir.chmod fchmods the directory's | ||
| 147 | // own fd, and without it that fd is opened O_PATH, which fchmod refuses. | ||
| 148 | var d = try std.fs.cwd().openDir(dir, .{ .iterate = true }); | ||
| 149 | defer d.close(); | ||
| 150 | try d.chmod(0o700); | ||
| 151 | } | ||
| 152 | |||
| 153 | fn writeFilePrivate(path: []const u8, contents: []const u8) !void { | ||
| 154 | const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 }); | ||
| 155 | defer f.close(); | ||
| 156 | try f.writeAll(contents); | ||
| 157 | } | ||
| 158 | |||
| 159 | test "detect goes by basename" { | ||
| 160 | try std.testing.expectEqual(Kind.zsh, detect("/usr/bin/zsh")); | ||
| 161 | try std.testing.expectEqual(Kind.zsh, detect("zsh")); | ||
| 162 | try std.testing.expectEqual(Kind.bash, detect("/bin/bash")); | ||
| 163 | try std.testing.expectEqual(Kind.fish, detect("/usr/local/bin/fish")); | ||
| 164 | // The fallbacks: a POSIX sh and anything exotic get no injection at | ||
| 165 | // all, and the session runs on pgid + settle exactly as before. | ||
| 166 | try std.testing.expectEqual(Kind.other, detect("/bin/sh")); | ||
| 167 | try std.testing.expectEqual(Kind.other, detect("/usr/bin/nu")); | ||
| 168 | // A directory whose name matches must not be mistaken for the shell: | ||
| 169 | // basename of a trailing-slash path is the last component, not "". | ||
| 170 | try std.testing.expectEqual(Kind.other, detect("/opt/bash/bin/dash")); | ||
| 171 | } | ||
| 172 | |||
| 173 | /// The three prepare tests all want a real, writable, disposable directory | ||
| 174 | /// and the string naming it. No socket is bound here, so std's tmpDir (and | ||
| 175 | /// its long .zig-cache path) is fine — testtmp exists for sun_path, which | ||
| 176 | /// this module never touches. | ||
| 177 | const TmpPath = struct { | ||
| 178 | tmp: std.testing.TmpDir, | ||
| 179 | dir: []const u8, | ||
| 180 | |||
| 181 | fn make() !TmpPath { | ||
| 182 | var tmp = std.testing.tmpDir(.{}); | ||
| 183 | errdefer tmp.cleanup(); | ||
| 184 | const dir = try tmp.dir.realpathAlloc(std.testing.allocator, "."); | ||
| 185 | return .{ .tmp = tmp, .dir = dir }; | ||
| 186 | } | ||
| 187 | |||
| 188 | fn deinit(self: *TmpPath) void { | ||
| 189 | std.testing.allocator.free(self.dir); | ||
| 190 | self.tmp.cleanup(); | ||
| 191 | } | ||
| 192 | }; | ||
| 193 | |||
| 194 | test "prepare zsh writes the shim and sets ZDOTDIR" { | ||
| 195 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 196 | defer arena.deinit(); | ||
| 197 | var t = try TmpPath.make(); | ||
| 198 | defer t.deinit(); | ||
| 199 | |||
| 200 | const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" }); | ||
| 201 | const inj = try prepare(arena.allocator(), shim, "/usr/bin/zsh"); | ||
| 202 | |||
| 203 | // zsh is an env-only injection: the shell is exec'd with no extra argv | ||
| 204 | // and finds the shim because ZDOTDIR points at it. | ||
| 205 | try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len); | ||
| 206 | try std.testing.expect(inj.env.len >= 1); | ||
| 207 | try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key); | ||
| 208 | try std.testing.expectEqualStrings(shim, inj.env[0].value); | ||
| 209 | |||
| 210 | // ZDOTDIR names the directory; the file zsh will source is the .zshrc | ||
| 211 | // inside it, which is the artifact worth asserting on. | ||
| 212 | const rc_path = try std.fs.path.join(arena.allocator(), &.{ inj.env[0].value, ".zshrc" }); | ||
| 213 | const rc = try std.fs.cwd().readFileAlloc(std.testing.allocator, rc_path, 8192); | ||
| 214 | defer std.testing.allocator.free(rc); | ||
| 215 | // The two halves that make it work: the D mark carries the exit code, | ||
| 216 | // and the hooks are actually registered. | ||
| 217 | try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null); | ||
| 218 | try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null); | ||
| 219 | // ...and the shim hands control back to the user's own rc, which is the | ||
| 220 | // difference between integration and hijacking their shell. | ||
| 221 | try std.testing.expect(std.mem.indexOf(u8, rc, ".zshrc\"") != null); | ||
| 222 | } | ||
| 223 | |||
| 224 | test "prepare bash returns --init-file argv" { | ||
| 225 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 226 | defer arena.deinit(); | ||
| 227 | var t = try TmpPath.make(); | ||
| 228 | defer t.deinit(); | ||
| 229 | |||
| 230 | const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" }); | ||
| 231 | const inj = try prepare(arena.allocator(), shim, "/bin/bash"); | ||
| 232 | |||
| 233 | // bash has no ZDOTDIR equivalent, so the shim arrives on the command | ||
| 234 | // line instead — and nothing goes into the environment. | ||
| 235 | try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len); | ||
| 236 | try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]); | ||
| 237 | try std.testing.expectEqual(@as(usize, 0), inj.env.len); | ||
| 238 | |||
| 239 | const script = try std.fs.cwd().readFileAlloc(std.testing.allocator, inj.extra_argv[1], 8192); | ||
| 240 | defer std.testing.allocator.free(script); | ||
| 241 | try std.testing.expect(std.mem.indexOf(u8, script, "PROMPT_COMMAND") != null); | ||
| 242 | try std.testing.expect(std.mem.indexOf(u8, script, "trap '_mux_preexec' DEBUG") != null); | ||
| 243 | // --init-file REPLACES ~/.bashrc, so the shim sourcing it is what keeps | ||
| 244 | // the user's shell theirs. Its absence would be silent. | ||
| 245 | try std.testing.expect(std.mem.indexOf(u8, script, "$HOME/.bashrc") != null); | ||
| 246 | } | ||
| 247 | |||
| 248 | test "prepare other injects nothing" { | ||
| 249 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 250 | defer arena.deinit(); | ||
| 251 | var t = try TmpPath.make(); | ||
| 252 | defer t.deinit(); | ||
| 253 | |||
| 254 | const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" }); | ||
| 255 | const inj = try prepare(arena.allocator(), shim, "/bin/sh"); | ||
| 256 | try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len); | ||
| 257 | try std.testing.expectEqual(@as(usize, 0), inj.env.len); | ||
| 258 | // Not merely empty: an unknown shell must leave no trace on disk, so a | ||
| 259 | // /bin/sh session is byte-identical to one from before this module. | ||
| 260 | try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(shim, .{})); | ||
| 261 | } | ||
| 262 | |||
| 263 | test "prepare zsh: the shim directory is 0700 and the rc file 0600" { | ||
| 264 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 265 | defer arena.deinit(); | ||
| 266 | var t = try TmpPath.make(); | ||
| 267 | defer t.deinit(); | ||
| 268 | |||
| 269 | const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" }); | ||
| 270 | _ = try prepare(arena.allocator(), shim, "/usr/bin/zsh"); | ||
| 271 | |||
| 272 | // makePath alone leaves 0755. What lands here is a file the session | ||
| 273 | // shell sources — anyone who can write it can run code as this user — | ||
| 274 | // so the permissions are part of the contract, not decoration. | ||
| 275 | var d = try std.fs.cwd().openDir(shim, .{ .iterate = true }); | ||
| 276 | defer d.close(); | ||
| 277 | const dst = try d.stat(); | ||
| 278 | try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777))); | ||
| 279 | |||
| 280 | const rc_path = try std.fs.path.join(arena.allocator(), &.{ shim, ".zshrc" }); | ||
| 281 | const f = try std.fs.cwd().openFile(rc_path, .{}); | ||
| 282 | defer f.close(); | ||
| 283 | const fst = try f.stat(); | ||
| 284 | try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777))); | ||
| 285 | } | ||