summaryrefslogtreecommitdiff
path: root/src/shell_bin.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/shell_bin.zig')
-rw-r--r--src/shell_bin.zig221
1 files changed, 221 insertions, 0 deletions
diff --git a/src/shell_bin.zig b/src/shell_bin.zig
index 81ff90fa..1090bb2b 100644
--- a/src/shell_bin.zig
+++ b/src/shell_bin.zig
@@ -27,6 +27,227 @@ 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