diff options
Diffstat (limited to 'src/shell_bin.zig')
| -rw-r--r-- | src/shell_bin.zig | 221 |
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 |
