summaryrefslogtreecommitdiff
path: root/src/nested.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/nested.zig')
-rw-r--r--src/nested.zig414
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").?);