summaryrefslogtreecommitdiff
path: root/src/shell_bin.zig
blob: 1090bb2bd4548151078d723473e33dfa01e3e93d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
//! Turning the name of a shell into something a freshly forked child can exec,
//! and into the argv that hands that shell its prompt marks.
//!
//! Native-shell side, like temp_file.zig and message.zig, and for the same
//! reason: it touches the filesystem, and the core does not. The core carries
//! only the NAME (Pardes.shellBin, what the Shell builtin was given); which
//! family that is, what to write for it, where to write it and where the
//! binary actually lives all live here, and both frontends call it rather than
//! keeping a copy each. Nothing here imports the core, which is also what lets
//! it be its own std-only test module.
//!
//! Each host owns one `PromptRcs` for its lifetime. Its files are private
//! `mkstemp` names, completely written and closed before resolve can expose
//! them to a child. Concurrent launches therefore share neither a pathname nor
//! an inode, and a shell can never source another user's predictable /tmp file.
//!
//! ALL OF THIS RUNS IN THE PARENT. Between fork and exec a process may not
//! allocate, and a $PATH search does — which is the same reason the exec is
//! `execv` on an absolute path and never `execvp`. So the lookup is a handful
//! of `access` calls over the directories a shell actually lives in, done
//! before the fork, into a caller buffer that the child then inherits through
//! its copy of the stack.
const std = @import("std");
const builtin = @import("builtin");

const libc = std.c;
const X_OK: c_int = 1;

extern "c" fn mkstemp(template: [*:0]u8) c_int;
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;

// ------------------------------------------------- the GUI launch's PATH

/// Bounded storage for the composed PATH. /etc/paths and /etc/paths.d hold ten
/// directories on a stock machine and a handful more with third-party
/// packages; 4 KiB is not a limit anyone will meet, and a fixed buffer keeps
/// this callable from a host that has not built an allocator yet.
const path_capacity = 4096;
const max_path_files = 64;

/// macOS: give the PROCESS the PATH a login session would have, but only when
/// it plainly has not got one.
///
/// A GUI launch — Finder, the Dock, `open(1)` — inherits launchd's
/// environment, and launchd's PATH is `/usr/bin:/bin:/usr/sbin:/sbin`. Nothing
/// else: no /opt/homebrew/bin, no /usr/local/bin. A launch from a terminal
/// inherits the shell's PATH and is fine. That difference is the whole bug,
/// and it is why it reads as intermittent — the same build finds `yazi` when
/// you start it from a terminal and cannot find it when you start it from the
/// Dock.
///
/// macOS's own answer is /usr/libexec/path_helper, which reads /etc/paths and
/// /etc/paths.d. LOGIN shells run it and non-login shells do not, and pardes
/// spawns non-login shells deliberately (see `resolve`) — so a pane cannot fix
/// this for itself. Nor should it: one environ is inherited by every pty shell
/// pardes forks, every `/bin/sh -c` filter, and every language server the LSP
/// client spawns, and `binOf` searching a launchd PATH is a rust-analyzer that
/// is never found. Fixing the process fixes all of them at once.
///
/// ONLY when every entry already in PATH is a system directory. That is the
/// test for "nobody configured this". path_helper appends pre-existing entries
/// AFTER the system set, so running it over a real session's PATH would demote
/// a version manager's shims behind /usr/bin and quietly change which `node`
/// runs. A configured PATH is left exactly as it is; the launchd case is
/// unambiguous and is the only one touched.
pub fn adoptSystemPath() void {
    if (comptime builtin.os.tag != .macos) return;
    var buf: [path_capacity]u8 = undefined;
    var len: usize = 0;
    collectSystemPath(&buf, &len);
    if (len == 0) return;
    const system = buf[0..len];

    const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else "";
    if (!allEntriesWithin(current, system)) return;
    if (std.mem.eql(u8, current, system)) return;

    var out: [path_capacity:0]u8 = undefined;
    if (len >= out.len) return;
    @memcpy(out[0..len], system);
    out[len] = 0;
    _ = setenv("PATH", out[0..len :0].ptr, 1);
}

/// Everything a native shell must do TO THE PROCESS before it forks its first
/// pane, in the order it has to happen, handing back the prompt files those
/// forks will borrow.
///
/// Four hosts performed this ritual by hand and the copies had already
/// diverged. detached/server.zig forks bash through `resolve` exactly like its
/// siblings and never set BASH_SILENCE_DEPRECATION_WARNING, so every pane in a
/// detached session on macOS opened with Apple's zsh-migration banner printed
/// across the top of it — and nobody noticed, because the three hosts anyone
/// looks at daily all had the line. That is the failure mode of a four-line
/// ritual written four times.
///
/// The ORDER is the content here. `adoptSystemPath` has to precede the fork
/// because the child inherits the environ; the setenv has to precede bash
/// because bash reads it at startup and the rc file is already too late; and
/// the rc files have to be complete on disk before any child can be handed a
/// path to one.
pub fn prepareForFork() PromptRcs {
    adoptSystemPath();
    if (comptime builtin.os.tag.isDarwin())
        _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
    return PromptRcs.init();
}

/// /etc/paths, then every file in /etc/paths.d in NAME ORDER, which is the
/// order path_helper reads them in and therefore the order the directories
/// take precedence in.
fn collectSystemPath(buf: []u8, len: *usize) void {
    var file_buf: [path_capacity]u8 = undefined;
    if (readSmall("/etc/paths", &file_buf)) |body| appendLines(buf, len, body);

    const io = std.Io.Threaded.global_single_threaded.io();
    var dir = std.Io.Dir.cwd().openDir(io, "/etc/paths.d", .{ .iterate = true }) catch return;
    defer dir.close(io);

    // readdir order is undefined and path_helper's is not, so the names are
    // collected and sorted before any of them is read.
    var names: [max_path_files][256]u8 = undefined;
    var name_lens: [max_path_files]usize = undefined;
    var count: usize = 0;
    var it = dir.iterate();
    while (count < names.len) {
        const entry = (it.next(io) catch break) orelse break;
        if (entry.kind == .directory) continue;
        if (entry.name.len == 0 or entry.name.len > names[count].len) continue;
        @memcpy(names[count][0..entry.name.len], entry.name);
        name_lens[count] = entry.name.len;
        count += 1;
    }
    var order: [max_path_files]usize = undefined;
    for (0..count) |i| order[i] = i;
    std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan);

    var path_buf: [512]u8 = undefined;
    for (order[0..count]) |i| {
        const name = names[i][0..name_lens[i]];
        const path = std.fmt.bufPrintSentinel(&path_buf, "/etc/paths.d/{s}", .{name}, 0) catch continue;
        if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body);
    }
}

const Names = struct {
    names: *const [max_path_files][256]u8,
    lens: *const [max_path_files]usize,

    fn lessThan(self: Names, a: usize, b: usize) bool {
        return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt;
    }
};

/// One directory per line, blanks and whitespace ignored — the format both
/// files use and the only thing path_helper reads out of them.
fn appendLines(buf: []u8, len: *usize, body: []const u8) void {
    var lines = std.mem.splitScalar(u8, body, '\n');
    while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r"));
}

/// Append `entry` unless it is already present. Dedup preserves the FIRST
/// occurrence, which is what makes the order above mean precedence.
fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void {
    if (entry.len == 0) return;
    if (hasEntry(buf[0..len.*], entry)) return;
    const separator: usize = if (len.* == 0) 0 else 1;
    if (len.* + separator + entry.len > buf.len) return;
    if (separator == 1) {
        buf[len.*] = ':';
        len.* += 1;
    }
    @memcpy(buf[len.*..][0..entry.len], entry);
    len.* += entry.len;
}

fn hasEntry(list: []const u8, entry: []const u8) bool {
    var it = std.mem.tokenizeScalar(u8, list, ':');
    while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true;
    return false;
}

/// Whether `candidate` holds nothing `list` does not. An empty candidate is
/// within any list: a process with no PATH at all is the launchd case too.
fn allEntriesWithin(candidate: []const u8, list: []const u8) bool {
    var it = std.mem.tokenizeScalar(u8, candidate, ':');
    while (it.next()) |entry| if (!hasEntry(list, entry)) return false;
    return true;
}

fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 {
    const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0));
    if (fd < 0) return null;
    defer _ = libc.close(fd);
    var off: usize = 0;
    while (off < buf.len) {
        const n = libc.read(fd, buf[off..].ptr, buf.len - off);
        if (n < 0) {
            if (libc.errno(n) == .INTR) continue;
            return null;
        }
        if (n == 0) break;
        off += @intCast(n);
    }
    return buf[0..off];
}

test "the launchd PATH is replaced and a configured one is left alone" {
    var buf: [256]u8 = undefined;
    var len: usize = 0;
    appendEntry(&buf, &len, "/usr/bin");
    appendEntry(&buf, &len, "/bin");
    appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first
    appendEntry(&buf, &len, "");
    try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]);

    // Exactly the launchd default, in any order: nothing here is a choice.
    try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin"));
    try std.testing.expect(allEntriesWithin("", "/usr/bin"));
    // One entry nobody could have inherited by accident, and the whole PATH is
    // off limits — reordering it behind /usr/bin is how a version manager stops
    // deciding which `node` runs.
    try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin"));
    try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin"));
}

test "the composed system path is the real one, in path_helper's order" {
    if (comptime builtin.os.tag != .macos) return;
    var buf: [path_capacity]u8 = undefined;
    var len: usize = 0;
    collectSystemPath(&buf, &len);
    const composed = buf[0..len];
    // /etc/paths exists on every mac and leads with these.
    try std.testing.expect(hasEntry(composed, "/usr/bin"));
    try std.testing.expect(hasEntry(composed, "/bin"));
    // ...and its entries come before anything /etc/paths.d contributes, which
    // is the precedence the order encodes.
    try std.testing.expect(std.mem.startsWith(u8, composed, "/usr/local/bin:"));
    // No duplicates: /etc/paths.d files routinely repeat a system directory.
    var seen = std.mem.tokenizeScalar(u8, composed, ':');
    var index: usize = 0;
    while (seen.next()) |entry| : (index += 1) {
        var rest = std.mem.tokenizeScalar(u8, composed, ':');
        var matches: usize = 0;
        while (rest.next()) |other| if (std.mem.eql(u8, other, entry)) {
            matches += 1;
        };
        try std.testing.expectEqual(@as(usize, 1), matches);
    }
}

/// Prompt integration, per shell FAMILY rather than per binary: pardes hides
/// prompt rows, moves the cursor by clicking one, and tells a command's output
/// from the line that asked for it, and all three read the OSC 133 marks a
/// shell has to be talked into emitting. Every family needs different words
/// for the same four marks and a different way to be handed them, so the
/// binary a pane is about to exec picks one of these and there is nothing to
/// configure.
pub const ShellRc = enum { bash, fish, none };

/// Which family a shell binary belongs to, by the BASENAME's prefix — the
/// whole heuristic. A prefix and not an exact match because a real system
/// spells them `bash`, `/usr/bin/bash`, `bash-5.2`, `fish-3.7`, and pinning
/// exact names would mean a list to maintain against other people's packaging.
/// It costs a false positive on a program called `fishing`, which is a shell
/// nobody has.
///
/// `none` is not a failure: it execs the binary plain and the pane works, it
/// just has no prompt marks, so prompts are not hidden and a click on one does
/// not move the shell's cursor. Everything else about the pane is unaffected.
///
/// ponytail: two families and a fallback. zsh is the obvious third and is NOT
/// here because it is shaped differently — it has no `--rcfile`, so it needs a
/// whole ZDOTDIR directory staged with a .zshrc that re-sources the user's,
/// plus an env var set before exec. Add it when someone runs zsh in pardes and
/// misses prompt hiding; the rc text itself is four lines (precmd/preexec).
pub fn shellRc(bin: []const u8) ShellRc {
    const slash = std.mem.lastIndexOfScalar(u8, bin, '/');
    const base = if (slash) |s| bin[s + 1 ..] else bin;
    if (std.mem.startsWith(u8, base, "bash")) return .bash;
    if (std.mem.startsWith(u8, base, "fish")) return .fish;
    return .none;
}

const bash_rc =
    \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
    \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]'
    \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
    \\trap 'printf "\e]133;C\a"' DEBUG
    \\
;

/// fish is handed this with `-C`, which runs AFTER config.fish — and it has to,
/// because the first thing it does is copy the user's own `fish_prompt` to call
/// it from the middle of ours. Loaded any earlier it would copy the default and
/// silently replace whatever the user actually configured.
///
/// The other half is why there is no `source ~/.config/fish/config.fish` line
/// the way the bash rc sources .bashrc: bash is being started with `--rcfile`,
/// which REPLACES its startup file, so the rc has to put it back. `-C` adds to
/// fish's startup instead of standing in for it.
///
/// C and D come off fish's own `fish_preexec`/`fish_postexec` events rather
/// than being spliced into the prompt, which is what bash's DEBUG trap is
/// working around.
const fish_rc =
    \\functions -c fish_prompt __pardes_user_prompt
    \\function fish_prompt
    \\    printf '\e]133;A;cl=line\a'
    \\    __pardes_user_prompt
    \\    printf '\e]133;B\a'
    \\end
    \\function __pardes_preexec --on-event fish_preexec
    \\    printf '\e]133;C\a'
    \\end
    \\function __pardes_postexec --on-event fish_postexec
    \\    printf '\e]133;D\a'
    \\end
    \\
;

const rc_path_capacity = 64;

/// The two complete, private prompt files a native host lends to every shell
/// it spawns. No allocation and no global name: moving this value is safe
/// because it stores lengths, never pointers into its own buffers.
pub const PromptRcs = struct {
    bash_path: [rc_path_capacity:0]u8 = @splat(0),
    bash_len: u8 = 0,
    fish_path: [rc_path_capacity:0]u8 = @splat(0),
    fish_len: u8 = 0,
    fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0),
    fish_command_len: u8 = 0,

    pub fn init() PromptRcs {
        var rcs: PromptRcs = .{};
        rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc);
        rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc);
        if (rcs.fishPath()) |path| {
            const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch {
                _ = libc.unlink(path.ptr);
                rcs.fish_len = 0;
                return rcs;
            };
            rcs.fish_command_len = @intCast(command.len);
        }
        return rcs;
    }

    pub fn deinit(rcs: *PromptRcs) void {
        if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr);
        if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr);
        rcs.bash_len = 0;
        rcs.fish_len = 0;
        rcs.fish_command_len = 0;
    }

    fn bashPath(rcs: *const PromptRcs) ?[:0]const u8 {
        if (rcs.bash_len == 0) return null;
        return rcs.bash_path[0..rcs.bash_len :0];
    }

    fn fishPath(rcs: *const PromptRcs) ?[:0]const u8 {
        if (rcs.fish_len == 0) return null;
        return rcs.fish_path[0..rcs.fish_len :0];
    }

    fn fishCommand(rcs: *const PromptRcs) ?[:0]const u8 {
        if (rcs.fish_command_len == 0) return null;
        return rcs.fish_command[0..rcs.fish_command_len :0];
    }
};

/// Create one private 0600 file and reveal its length only after the complete
/// write and close. Failure leaves no pathname for resolve to hand to a shell.
fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 {
    const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0;
    const fd = mkstemp(path.ptr);
    if (fd < 0) return 0;
    var off: usize = 0;
    while (off < contents.len) {
        const n = libc.write(fd, contents[off..].ptr, contents.len - off);
        if (n < 0) {
            if (libc.errno(n) == .INTR) continue;
            _ = libc.close(fd);
            _ = libc.unlink(path.ptr);
            return 0;
        }
        if (n == 0) {
            _ = libc.close(fd);
            _ = libc.unlink(path.ptr);
            return 0;
        }
        off += @intCast(n);
    }
    if (libc.close(fd) != 0) {
        _ = libc.unlink(path.ptr);
        return 0;
    }
    return @intCast(path.len);
}

test "shell family is the basename's prefix, and anything else runs unadorned" {
    try std.testing.expectEqual(ShellRc.fish, shellRc("fish"));
    try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/bin/fish"));
    try std.testing.expectEqual(ShellRc.fish, shellRc("/opt/homebrew/bin/fish"));
    try std.testing.expectEqual(ShellRc.bash, shellRc("bash"));
    try std.testing.expectEqual(ShellRc.bash, shellRc("/bin/bash"));
    // packaged with a version on the end, which is why this is a prefix
    try std.testing.expectEqual(ShellRc.bash, shellRc("/usr/bin/bash-5.2"));
    try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/local/bin/fish-3.7"));
    // a directory that merely CONTAINS the word is not the shell's name
    try std.testing.expectEqual(ShellRc.none, shellRc("/opt/fish/bin/nu"));
    // no marks, still a shell
    try std.testing.expectEqual(ShellRc.none, shellRc("/usr/bin/zsh"));
    try std.testing.expectEqual(ShellRc.none, shellRc("/bin/sh"));
    try std.testing.expectEqual(ShellRc.none, shellRc("nu"));
    try std.testing.expectEqual(ShellRc.none, shellRc(""));
}

/// The directories a shell binary is actually installed in. Not $PATH: see the
/// header. `/opt/homebrew` and `/opt/local` are where a mac keeps the shells
/// that did not ship with it, which is every shell anyone chooses on purpose.
const bin_dirs = [_][]const u8{
    "/usr/bin/",
    "/bin/",
    "/usr/local/bin/",
    "/opt/homebrew/bin/",
    "/opt/local/bin/",
    "/usr/sbin/",
};

/// Last resorts, in order, when the configured shell is not installed: the
/// shell pardes used to hardcode, then the one POSIX says exists. A pane that
/// opens with the wrong shell beats a pane whose child dies at exec and shows
/// nothing but an immediate EOF.
const fallbacks = [_][]const u8{
    if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash",
    "/bin/sh",
};

pub const Spawn = struct {
    path: [*:0]const u8,
    /// argv for execv. Shorter forms stop at their first null, which is what
    /// execv reads anyway, so one width covers all three families.
    argv: [4:null]?[*:0]const u8,
};

/// `bin` is whatever the Shell builtin was given — a bare name to look up, or
/// a path (anything with a `/`) to take at its word. `buf` holds the resolved
/// path for as long as the returned Spawn is used, which for a caller that is
/// about to fork means: until the child execs. `prompt_rcs` is host-lifetime
/// storage and must likewise remain alive through that exec.
pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptRcs) Spawn {
    const path = find(bin, buf) orelse fallback(buf);
    // the family comes off the path that will ACTUALLY be executed, not the
    // name that was asked for — `Shell sh` on a system where that is a symlink
    // to bash still has no `--rcfile` promise attached to it, and a resolved
    // /usr/bin/fish reads as fish whether it was reached by name or by path
    const marks: [2]?[*:0]const u8 = switch (shellRc(std.mem.span(path))) {
        .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null },
        // -C runs AFTER config.fish, which is the whole point (see fish_rc)
        .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null },
        .none => .{ null, null },
    };
    return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } };
}

fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 {
    if (bin.len == 0 or bin.len + 1 > buf.len) return null;
    if (std.mem.indexOfScalar(u8, bin, '/') != null) {
        @memcpy(buf[0..bin.len], bin);
        buf[bin.len] = 0;
        const p: [*:0]const u8 = @ptrCast(buf);
        return if (libc.access(p, X_OK) == 0) p else null;
    }
    for (bin_dirs) |dir| {
        if (dir.len + bin.len + 1 > buf.len) continue;
        @memcpy(buf[0..dir.len], dir);
        @memcpy(buf[dir.len..][0..bin.len], bin);
        buf[dir.len + bin.len] = 0;
        const p: [*:0]const u8 = @ptrCast(buf);
        if (libc.access(p, X_OK) == 0) return p;
    }
    return null;
}

fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 {
    for (fallbacks) |f| {
        @memcpy(buf[0..f.len], f);
        buf[f.len] = 0;
        const p: [*:0]const u8 = @ptrCast(buf);
        if (libc.access(p, X_OK) == 0) return p;
    }
    // nothing executable anywhere we know to look: exec will fail and the pane
    // will show an immediate EOF, which is the honest report of that machine.
    // buf already holds the last candidate, NUL and all.
    return @ptrCast(buf);
}

test "a path is taken at its word, a name is looked up, and both pick their own marks" {
    if (builtin.os.tag == .windows) return;
    var buf: [std.fs.max_path_bytes]u8 = undefined;
    var prompt_rcs = PromptRcs.init();
    defer prompt_rcs.deinit();

    // /bin/sh exists on every unix this builds for and is in no family, so it
    // pins the resolve-by-path arm AND the unadorned argv
    const sh = resolve("/bin/sh", &buf, &prompt_rcs);
    try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path));
    try std.testing.expect(sh.argv[1] == null);

    // a name with no slash is searched for; whatever it resolves to, it is a
    // bash and so carries --rcfile pointing at the rc the shells write
    const bash = resolve("bash", &buf, &prompt_rcs);
    try std.testing.expect(shellRc(std.mem.span(bash.path)) == .bash);
    try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?));
    try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?));

    // nothing is installed under this name, so the fallback answers — and the
    // fallback is a real executable, not the name that failed
    const missing = resolve("zznosuchshell", &buf, &prompt_rcs);
    try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path)));
    try std.testing.expect(libc.access(missing.path, X_OK) == 0);

    // an absolute path that does not exist falls back too, rather than being
    // handed to exec to fail on
    const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs);
    try std.testing.expect(libc.access(gone.path, X_OK) == 0);
}

test "prompt rc owners have private complete files and clean them up" {
    if (builtin.os.tag == .windows) return;
    var a = PromptRcs.init();
    defer a.deinit();
    var b = PromptRcs.init();
    defer b.deinit();
    const a_bash = a.bashPath() orelse return error.TempCreateFailed;
    const b_bash = b.bashPath() orelse return error.TempCreateFailed;
    const a_fish = a.fishPath() orelse return error.TempCreateFailed;
    try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash));
    const fish_command = a.fishCommand() orelse return error.MissingFishCommand;
    try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]);
    try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]);

    var buf: [bash_rc.len]u8 = undefined;
    const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY });
    if (fd < 0) return error.OpenFailed;
    defer _ = libc.close(fd);
    var len: usize = 0;
    while (len < buf.len) {
        const n = libc.read(fd, buf[len..].ptr, buf.len - len);
        if (n < 0) {
            if (libc.errno(n) == .INTR) continue;
            return error.ReadFailed;
        }
        if (n == 0) break;
        len += @intCast(n);
    }
    try std.testing.expectEqualStrings(bash_rc, buf[0..len]);

    var removed: [rc_path_capacity:0]u8 = @splat(0);
    @memcpy(removed[0..a_bash.len], a_bash);
    removed[a_bash.len] = 0;
    a.deinit();
    try std.testing.expect(libc.access(&removed, 0) < 0);
}