diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 09:58:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch) | |
| tree | cc993ad239451adb6f23645ee5ca001802832ed0 /src/nested.zig | |
| parent | 38e9919a9ea9055538409b388d580c4e4c838434 (diff) | |
| download | pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip | |
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'src/nested.zig')
| -rw-r--r-- | src/nested.zig | 414 |
1 files changed, 338 insertions, 76 deletions
diff --git a/src/nested.zig b/src/nested.zig index 20c42dd0..a8fd021d 100644 --- a/src/nested.zig +++ b/src/nested.zig @@ -17,20 +17,69 @@ //! socket sits at a path anyone can derive from a pid — `Exec …` arriving here //! is not something this protocol is allowed to say. //! -//! Linux only. ponytail: darwin has no /proc, no SOCK_CLOEXEC and no accept4, -//! and its `sockaddr.un.path` is 104 bytes rather than the 108 every buffer -//! and unguarded memcpy below assumes. None of that is testable from here, so -//! detection is simply off: a pardes inside a pardes on macOS opens a second -//! session the way it always did. +//! Linux and darwin. The two differ in every primitive this needs and in none +//! of the design: /proc against libproc for the ancestor walk, SOCK_CLOEXEC +//! and accept4 against a plain socket plus an fcntl, and a `sun_path` of 108 +//! bytes against one of 104 — which is why no buffer below spells a number, +//! they are all sized from the field itself. Anywhere else the walk returns +//! null and a pardes inside a pardes opens a second session, as before. +//! +//! macOS also has a third executable in the family: the app bundle. Its binary +//! is the same build as `bin/pardes` installed a second time, at a path that +//! shares nothing below the install prefix, so identity is compared at that +//! prefix — see samePardesExecutable. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; -const linux = std.os.linux; // statx; referenced only on linux // std.c has getenv but neither setter; the tests below need both extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn unsetenv(name: [*:0]const u8) c_int; +const darwin = switch (builtin.os.tag) { + .macos, .ios, .tvos, .watchos, .visionos => true, + else => false, +}; + +/// This module is only as portable as its two ingredients: a way to name the +/// executable and parent of an arbitrary pid, and unix sockets. +const supported = builtin.os.tag == .linux or darwin; + +/// `sun_path` is 108 bytes on linux and 104 on darwin, and it is the hard +/// limit on this whole feature: a path that does not fit is not a socket +/// address, it is a truncated one pointing somewhere else. Taken from the +/// struct so that the buffers, the fit checks and the memcpy below cannot +/// disagree with the kernel or with each other. +const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; + +/// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of +/// `/proc/<pid>/exe`; `PROC_PIDTBSDINFO` carries the parent pid that linux +/// spells `PPid:`. Both are same-uid readable, which is the only permission +/// an ancestor walk through one's own processes needs. +const PROC_PIDTBSDINFO: c_int = 3; +const proc_bsdinfo = extern struct { + flags: u32, + status: u32, + xstatus: u32, + pid: u32, + ppid: u32, + /// uids, gids, comm, name, the tty and the start time: filled by the + /// kernel and unread here, but the call fails unless the buffer is the + /// whole 136-byte record. + rest: [116]u8, +}; +extern "c" fn proc_pidpath(pid: c_int, buffer: *anyopaque, buffersize: u32) c_int; +extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; + +/// Linux opens sockets CLOEXEC in one call; darwin has to set it afterwards. +/// The gap is a race only against a fork on another thread, and both callers +/// are past that: `listen` runs before the first pane exists, and `acceptLine` +/// runs on a thread of its own long after spawning has settled. +fn setCloexec(fd: c_int) void { + const FD_CLOEXEC: c_int = 1; + _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); +} + /// The longest command line this protocol carries or accepts. `Look ` plus a /// PATH_MAX path fits with room over; anything longer cannot have come from /// the client and is dropped rather than truncated into a different command. @@ -41,7 +90,7 @@ pub const max_line = 4200; /// is per-user for the same reason a home directory is. Asked by the client /// (to derive the path), by the listener (to create and vet it) and by the /// sweeper (to scan it), so it is written once. -fn socketDir(buf: *[108:0]u8) ?[:0]const u8 { +fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { if (libc.getenv("XDG_RUNTIME_DIR")) |x| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(x)}, 0) catch null; const home = libc.getenv("HOME") orelse return null; @@ -52,8 +101,8 @@ fn socketDir(buf: *[108:0]u8) ?[:0]const u8 { /// two pardes never collide and a nested child derives the exact path from the /// ancestor pid its tree walk found. The buffer is sun_path-sized: a longer /// path is not a socket address at all. -pub fn socketPath(buf: *[108]u8, pid: libc.pid_t) ?[:0]const u8 { - var dir_buf: [108:0]u8 = undefined; +pub fn socketPath(buf: *[sun_path_len]u8, pid: libc.pid_t) ?[:0]const u8 { + var dir_buf: [sun_path_len:0]u8 = undefined; const dir = socketDir(&dir_buf) orelse return null; // unsigned: {d} prints a leading '+' for a positive SIGNED int return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d}.sock", .{ dir, @as(u32, @intCast(pid)) }, 0) catch null; @@ -66,34 +115,69 @@ fn stripDeleted(link: []const u8) []const u8 { return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link; } -/// The tty and SDL builds are sibling frontends of the same program. Their -/// installed names differ only by `-gui` (and, for cross builds, share the -/// same `-os-arch` tail), so either one must recognise the other as an outer -/// pardes. Requiring the same directory retains the executable-identity check: -/// an unrelated ancestor merely named `pardes` is not enough. +/// The install prefix a program directory belongs to. `bin/pardes` and +/// `pardes.app/Contents/MacOS/pardes` are one build installed twice and share +/// no directory at all, so comparing dirnames says they are strangers; both +/// reduce to the prefix, and so does everything else — a directory that is +/// neither wrapper is its own prefix, which leaves the same-directory rule +/// below exactly as strict as it was. +fn installPrefix(dir: []const u8) []const u8 { + const macos_dir = "/Contents/MacOS"; + if (std.mem.endsWith(u8, dir, macos_dir)) { + const app = dir[0 .. dir.len - macos_dir.len]; + if (std.mem.endsWith(u8, app, ".app")) return std.fs.path.dirname(app) orelse app; + } + const bin = "/bin"; + if (std.mem.endsWith(u8, dir, bin)) return dir[0 .. dir.len - bin.len]; + return dir; +} + +/// The tty, SDL and macOS builds are sibling frontends of the same program. +/// Their installed names differ only by `-gui` (and, for cross builds, share +/// the same `-os-arch` tail), or not at all when one of them is the app bundle +/// — so any of them must recognise any other as an outer pardes. Requiring the +/// same install prefix retains the executable-identity check: an unrelated +/// ancestor merely named `pardes` is not enough. fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool { const a = stripDeleted(a_raw); const b = stripDeleted(b_raw); if (std.mem.eql(u8, a, b)) return true; - const a_dir = std.fs.path.dirname(a) orelse return false; - const b_dir = std.fs.path.dirname(b) orelse return false; + const a_dir = installPrefix(std.fs.path.dirname(a) orelse return false); + const b_dir = installPrefix(std.fs.path.dirname(b) orelse return false); if (!std.mem.eql(u8, a_dir, b_dir)) return false; - const a_name = std.fs.path.basename(a); - const b_name = std.fs.path.basename(b); - const gui = "pardes-gui"; - const tty = "pardes"; - const a_gui = std.mem.startsWith(u8, a_name, gui); - const b_gui = std.mem.startsWith(u8, b_name, gui); - if (a_gui == b_gui) return false; - const gui_name = if (a_gui) a_name else b_name; - const tty_name = if (a_gui) b_name else a_name; - if (!std.mem.startsWith(u8, tty_name, tty)) return false; - const gui_tail = gui_name[gui.len..]; - const tty_tail = tty_name[tty.len..]; - if ((gui_tail.len != 0 and gui_tail[0] != '-') or - (tty_tail.len != 0 and tty_tail[0] != '-')) return false; - return std.mem.eql(u8, gui_tail, tty_tail); + return sameFamily(std.fs.path.basename(a), std.fs.path.basename(b)); +} + +/// What is left of a family name after the frontend part: `` for `pardes` and +/// `pardes-gui`, `-linux-aarch64` for the cross-built spellings of both. Null +/// when the name is not in the family at all — `not-pardes` and `pardesfoo` +/// are other programs. +fn familyTail(name: []const u8) ?[]const u8 { + const rest = if (std.mem.startsWith(u8, name, "pardes-gui")) + name["pardes-gui".len..] + else if (std.mem.startsWith(u8, name, "pardes")) + name["pardes".len..] + else + return null; + // `pardesfoo` shares a prefix and nothing else. A tail is a tail or empty. + if (rest.len != 0 and rest[0] != '-') return null; + return rest; +} + +/// Two family names for the same build, given that they already share an +/// install prefix. The tails have to agree — a linux binary and an x86_64 one +/// in the same directory are two builds — unless one of them has no tail at +/// all, which is the untagged name the default build and, unavoidably, the app +/// bundle both produce: CFBundleExecutable is a fixed string, so the bundled +/// copy of `pardes-macos-aarch64` is called `pardes` and nothing in the name +/// records what it was. Loosening it that far is safe because the prefix +/// already had to match, and a foreign-arch ancestor cannot be running here. +fn sameFamily(a: []const u8, b: []const u8) bool { + if (std.mem.eql(u8, a, b)) return true; + const a_tail = familyTail(a) orelse return false; + const b_tail = familyTail(b) orelse return false; + return a_tail.len == 0 or b_tail.len == 0 or std.mem.eql(u8, a_tail, b_tail); } /// The `PPid:` field of a /proc/<pid>/status blob. Deliberately NOT field 4 of @@ -120,34 +204,70 @@ fn sweepPid(name: []const u8) ?libc.pid_t { return std.fmt.parseInt(libc.pid_t, digits, 10) catch null; } +/// Name the executable behind a pid, the way this OS spells it. +fn exeOf(pid: libc.pid_t, buf: *[4096]u8) ?[]const u8 { + switch (builtin.os.tag) { + .linux => { + var name: [64:0]u8 = undefined; + const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; + const n = libc.readlink(link, buf, buf.len); + if (n <= 0) return null; + return buf[0..@intCast(n)]; + }, + else => { + if (comptime !darwin) return null; + // Documented to want a PROC_PIDPATHINFO_MAXSIZE buffer, which is + // exactly this one, and to return the length it wrote. + const n = proc_pidpath(pid, buf, @intCast(buf.len)); + if (n <= 0) return null; + return buf[0..@intCast(n)]; + }, + } +} + +/// ...and its parent. +fn parentOf(pid: libc.pid_t) ?libc.pid_t { + switch (builtin.os.tag) { + .linux => { + var name: [64:0]u8 = undefined; + var buf: [4096]u8 = undefined; + const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null; + const fd = libc.open(status, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + const got = libc.read(fd, &buf, buf.len); + _ = libc.close(fd); + if (got <= 0) return null; + return parsePPid(buf[0..@intCast(got)]); + }, + else => { + if (comptime !darwin) return null; + var info: proc_bsdinfo = undefined; + const n = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, @sizeOf(proc_bsdinfo)); + // A short answer means the record this was compiled against is not + // the one the kernel filled, and `ppid` is then some other field. + if (n < @as(c_int, @sizeOf(proc_bsdinfo))) return null; + return @intCast(info.ppid); + }, + } +} + /// The pid of the nearest ancestor running a pardes executable, or null. -/// Identity is `readlink("/proc/<pid>/exe")` against our own; the tty `pardes` -/// and SDL `pardes-gui` siblings also match when they live in the same -/// directory. A name alone would call every unrelated `pardes` ancestor an -/// outer instance. The hop cap is not for /proc, which cannot loop, but because -/// the walk is driven by numbers read out of files and should not be able to +/// Identity is that ancestor's executable path against our own; the tty, SDL +/// and app-bundle siblings also match when they were installed together. A +/// name alone would call every unrelated `pardes` ancestor an outer instance. +/// The hop cap is not for the process tree, which cannot loop, but because the +/// walk is driven by numbers read out of the kernel and should not be able to /// spin on a surprising one. pub fn outer() ?libc.pid_t { - if (comptime builtin.os.tag != .linux) return null; + if (comptime !supported) return null; var self_buf: [4096]u8 = undefined; - const self_n = libc.readlink("/proc/self/exe", &self_buf, self_buf.len); - if (self_n <= 0) return null; - const self_exe = stripDeleted(self_buf[0..@intCast(self_n)]); + const self_exe = exeOf(libc.getpid(), &self_buf) orelse return null; var pid = libc.getppid(); var hops: usize = 0; while (pid > 1 and hops < 64) : (hops += 1) { - var name: [64:0]u8 = undefined; var buf: [4096]u8 = undefined; - const exe = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; - const n = libc.readlink(exe, &buf, buf.len); - if (n > 0 and samePardesExecutable(buf[0..@intCast(n)], self_exe)) return pid; - const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null; - const fd = libc.open(status, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return null; - const got = libc.read(fd, &buf, buf.len); - _ = libc.close(fd); - if (got <= 0) return null; - pid = parsePPid(buf[0..@intCast(got)]) orelse return null; + if (exeOf(pid, &buf)) |exe| if (samePardesExecutable(exe, self_exe)) return pid; + pid = parentOf(pid) orelse return null; } return null; } @@ -159,7 +279,7 @@ pub fn outer() ?libc.pid_t { /// caller its own launch. Writes and returns: the answer is a pane appearing /// on someone else's screen, and there is nothing to wait for. pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool { - if (comptime builtin.os.tag != .linux) return false; + if (comptime !supported) return false; // The protocol is one line, so a path with a line break IN it says // something else entirely: `we\nird.txt` arrived as `Look .../we` and the // outer instance opened a different file that happened to exist. \r goes @@ -172,12 +292,15 @@ pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool { else std.fmt.bufPrint(&cmd_buf, "Look {s}\n", .{path})) catch return false; - var path_buf: [108]u8 = undefined; + // sun_path-sized by construction, so `sock` cannot be longer than the + // field it is about to be copied into — socketPath returns null instead. + var path_buf: [sun_path_len]u8 = undefined; const sock = socketPath(&path_buf, pid) orelse return false; var addr: libc.sockaddr.un = .{ .path = @splat(0) }; @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return false; + setCloexec(fd); defer _ = libc.close(fd); if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; var off: usize = 0; @@ -202,7 +325,7 @@ fn ensureSocketDir(dir: [:0]const u8) bool { // without ~/.local/state would otherwise switch the feature off in // silence. Under $XDG_RUNTIME_DIR every prefix already exists and simply // EEXISTs, which is the ordinary case for the leaf too. - var partial: [108:0]u8 = undefined; + var partial: [sun_path_len:0]u8 = undefined; @memcpy(partial[0 .. dir.len + 1], dir[0 .. dir.len + 1]); for (1..dir.len) |i| { if (dir[i] != '/') continue; @@ -211,13 +334,16 @@ fn ensureSocketDir(dir: [:0]const u8) bool { partial[i] = '/'; } _ = libc.mkdir(dir, 0o700); - var stx: linux.Statx = undefined; - const want: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true }; - // NOFOLLOW: a symlink where the directory should be is exactly the plant - if (libc.statx(linux.AT.FDCWD, dir, linux.AT.SYMLINK_NOFOLLOW, want, &stx) != 0) return false; - if (!linux.S.ISDIR(stx.mode)) return false; - if (stx.uid != libc.getuid()) return false; - return stx.mode & 0o077 == 0; + // fstatat rather than statx: the same three answers, on both platforms, + // and not following the symlink is the point — one where the directory + // should be is exactly the plant this guards against. + var st: libc.Stat = undefined; + if (libc.fstatat(libc.AT.FDCWD, dir, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return false; + const IFMT: u32 = 0o170000; + const IFDIR: u32 = 0o040000; + if (@as(u32, st.mode) & IFMT != IFDIR) return false; + if (st.uid != libc.getuid()) return false; + return st.mode & 0o077 == 0; } /// Unlink the socket files of pardes processes that are gone. A pardes killed @@ -235,7 +361,7 @@ fn sweep(dir: [:0]const u8) void { // 0 = alive; EPERM = alive and someone else's. Only ESRCH is a corpse. const rc = libc.kill(pid, @enumFromInt(0)); if (rc == 0 or libc.errno(rc) != .SRCH) continue; - var pbuf: [108]u8 = undefined; + var pbuf: [sun_path_len]u8 = undefined; _ = libc.unlink(socketPath(&pbuf, pid) orelse continue); } } @@ -251,18 +377,20 @@ fn sweep(dir: [:0]const u8) void { /// holding this one would keep the socket bound long after we exit — the same /// shape as the inherited lock fd that once held a flock forever. pub fn listen() c_int { - if (comptime builtin.os.tag != .linux) return -1; - var dir_buf: [108:0]u8 = undefined; + if (comptime !supported) return -1; + var dir_buf: [sun_path_len:0]u8 = undefined; const dir = socketDir(&dir_buf) orelse return -1; if (!ensureSocketDir(dir)) return -1; sweep(dir); - var path_buf: [108]u8 = undefined; + // Fits by construction: socketPath writes into a sun_path-sized buffer and + // returns null rather than a truncated address. + var path_buf: [sun_path_len]u8 = undefined; const path = socketPath(&path_buf, libc.getpid()) orelse return -1; var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - if (path.len + 1 > addr.path.len) return -1; @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return -1; + setCloexec(fd); _ = libc.unlink(path); // pid reuse: a dead pardes' file would EADDRINUSE forever if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { _ = libc.close(fd); @@ -281,11 +409,11 @@ pub fn listen() c_int { /// Close the listener and take its file away. Guarded on the fd rather than on /// the path, so a bind that FAILED cannot unlink a path this process never /// created; anything else is a no-op, which is what --nested and every -/// non-linux build hand it. +/// unsupported build hand it. pub fn unlisten(fd: c_int) void { if (fd < 0) return; _ = libc.close(fd); - var path_buf: [108]u8 = undefined; + var path_buf: [sun_path_len]u8 = undefined; if (socketPath(&path_buf, libc.getpid())) |path| _ = libc.unlink(path); } @@ -297,9 +425,9 @@ pub fn unlisten(fd: c_int) void { /// nothing. Every accepted connection is CLOEXEC for the reason the listener /// is. pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { - if (comptime builtin.os.tag != .linux) return null; + if (comptime !supported) return null; while (true) { - const conn = libc.accept4(fd, null, null, libc.SOCK.CLOEXEC); + const conn = libc.accept(fd, null, null); if (conn < 0) { switch (libc.errno(conn)) { .INTR => continue, @@ -315,6 +443,7 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { } } defer _ = libc.close(conn); + setCloexec(conn); // A peer that connects and says nothing must not hold the listener: // this is a serial accept loop, and one silent connection used to // block every later launch until it let go. The client writes its one @@ -342,7 +471,7 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { } test "socket path: XDG first, then a private dir under HOME, never /tmp" { - var buf: [108]u8 = undefined; + var buf: [sun_path_len]u8 = undefined; // The environment is process-wide and every test in this binary shares it. // The last case below reaches the "no directory at all" branch by blanking // both variables, and without this every later test ran without a HOME. @@ -363,9 +492,11 @@ test "socket path: XDG first, then a private dir under HOME, never /tmp" { _ = unsetenv("XDG_RUNTIME_DIR"); _ = setenv("HOME", "/home/who", 1); try std.testing.expectEqualStrings("/home/who/.local/state/pardes/pardes-4242.sock", socketPath(&buf, 4242).?); - // sun_path is 108 bytes including the NUL, so a directory that long has no - // socket address at all — say so instead of binding a truncated one - _ = setenv("XDG_RUNTIME_DIR", "/" ++ ("x" ** 100), 1); + // sun_path holds the NUL, so a directory that fills it has no socket + // address at all — say so instead of binding a truncated one. Sized from + // the field: the limit is 108 on linux and 104 on darwin, and a literal + // here would test nothing on whichever platform it was not written for. + _ = setenv("XDG_RUNTIME_DIR", "/" ++ ("x" ** (sun_path_len - 8)), 1); try std.testing.expect(socketPath(&buf, 4242) == null); _ = unsetenv("XDG_RUNTIME_DIR"); _ = unsetenv("HOME"); @@ -407,6 +538,137 @@ test "tty and GUI sibling executables recognise each other" { )); } +test "the app bundle is the same build as the binary installed beside it" { + // What `pardes foo.zig` typed into the bundle's own shell has to resolve: + // the ancestor is zig-out/pardes.app/..., this process is zig-out/bin/..., + // and nothing below zig-out is shared. + try std.testing.expect(samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes", + )); + // ...and the SDL sibling, which reaches it by the name rule instead. + try std.testing.expect(samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes-gui", + )); + // The case this machine actually produces: `zig build` installs the tty + // binary under its os-arch tail, and build-app.sh copies the same build + // into a bundle where it can only be called `pardes`. + try std.testing.expect(samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes-macos-aarch64", + )); + // A different install is still a different program, however alike the + // paths look — this is the whole point of comparing anything at all. + try std.testing.expect(!samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/opt/zig-out/bin/pardes", + )); + // The wrapper is only transparent when it IS the wrapper: `Contents/MacOS` + // under something that is not a bundle keeps its own directory. + try std.testing.expect(!samePardesExecutable( + "/work/zig-out/pardes/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes", + )); + // Nothing here may loosen the rule for two unrelated programs that merely + // sit in a bin and a bundle of the same tree. + try std.testing.expect(!samePardesExecutable( + "/work/zig-out/other.app/Contents/MacOS/other", + "/work/zig-out/bin/pardes", + )); +} + +test "the ancestor walk reads this process's own parent" { + // The one thing a hand-written `struct proc_bsdinfo` gets wrong silently: + // a field ordering that puts something else where ppid should be still + // returns a plausible number. getppid knows the answer, so compare. + // + // Also the only check that libproc answers us at all — every caller of + // outer() treats a failure as "no outer instance", which is exactly what a + // permission problem would look like. + if (comptime !supported) return error.SkipZigTest; + try std.testing.expectEqual(libc.getppid(), parentOf(libc.getpid()).?); + // ...and that the walk terminates rather than spinning on pid 1's parent. + try std.testing.expect(parentOf(1) == null or parentOf(1).? <= 1); + + var buf: [4096]u8 = undefined; + const exe = exeOf(libc.getpid(), &buf).?; + try std.testing.expect(exe.len > 0); + try std.testing.expect(exe[0] == '/'); + // The test binary is not a pardes, so the walk must come back empty rather + // than matching some ancestor by accident. + try std.testing.expect(outer() == null); +} + +extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; +extern "c" fn rmdir(path: [*:0]const u8) c_int; + +test "a Look line survives the socket round trip" { + // Everything the protocol actually does, against a real kernel: bind, + // chmod, connect, write, accept, read, and the one-verb filter. The pure + // functions above cannot see any of it, and every primitive here is + // spelled differently on the two platforms this now supports. + if (comptime !supported) return error.SkipZigTest; + + // A private directory of our own. Not the developer's real state dir: this + // binds a socket named after a pid that is the TEST's, and sweep() unlinks + // what it finds beside it. + var tmpl: [64:0]u8 = undefined; + _ = std.fmt.bufPrintSentinel(&tmpl, "/tmp/pardes-nested-XXXXXX", .{}, 0) catch unreachable; + if (mkdtemp(&tmpl) == null) return error.SkipZigTest; + const dir = std.mem.sliceTo(&tmpl, 0); + defer _ = rmdir(tmpl[0..dir.len :0]); + + var xdg_buf: [4096:0]u8 = undefined; + const xdg0 = if (libc.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&xdg_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null; + defer { + if (xdg0) |v| { + _ = setenv("XDG_RUNTIME_DIR", v, 1); + } else _ = unsetenv("XDG_RUNTIME_DIR"); + } + _ = setenv("XDG_RUNTIME_DIR", tmpl[0..dir.len :0], 1); + + const fd = listen(); + try std.testing.expect(fd >= 0); + defer unlisten(fd); + + // Sent to our own pid, which is the pid listen() named the socket after. + // The client closes as it returns, and the line is already queued, so the + // single-threaded accept below finds a complete connection waiting — no + // thread and no timeout needed to prove the protocol. + try std.testing.expect(sendLook(libc.getpid(), "/etc/hosts", 42)); + var buf: [max_line]u8 = undefined; + try std.testing.expectEqualStrings("Look /etc/hosts:42", acceptLine(fd, &buf).?); + + // ...and without a line number, which is the directory and image case. + try std.testing.expect(sendLook(libc.getpid(), "/etc", 0)); + try std.testing.expectEqualStrings("Look /etc", acceptLine(fd, &buf).?); + + // The socket takes one verb. Anything else is dropped rather than run, so + // the next Look is what comes back — proving the filter skipped it without + // dropping the connection after it. + try std.testing.expect(writeLine(libc.getpid(), "Exec rm -rf /\n")); + try std.testing.expect(sendLook(libc.getpid(), "/etc/passwd", 0)); + try std.testing.expectEqualStrings("Look /etc/passwd", acceptLine(fd, &buf).?); + + // A path that cannot be one line is not escaped, it is refused. + try std.testing.expect(!sendLook(libc.getpid(), "/etc/ho\nsts", 0)); +} + +/// sendLook with the framing bypassed, so a test can put something on the wire +/// that the client would never send. +fn writeLine(pid: libc.pid_t, line: []const u8) bool { + var path_buf: [sun_path_len]u8 = undefined; + const sock = socketPath(&path_buf, pid) orelse return false; + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) return false; + defer _ = libc.close(fd); + if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; + return libc.write(fd, line.ptr, line.len) == @as(isize, @intCast(line.len)); +} + test "the sweep only recognises its own socket names" { try std.testing.expectEqual(@as(libc.pid_t, 7), sweepPid("pardes-7.sock").?); try std.testing.expectEqual(@as(libc.pid_t, 4194304), sweepPid("pardes-4194304.sock").?); |
