//! A pardes launched inside a pardes hands its file to the outer one.
//!
//! Every top-level instance listens on `
/pardes-.sock`, where ``
//! is `$XDG_RUNTIME_DIR` or, when the session has none, `~/.local/state/pardes`
//! created 0700. NOT /tmp: this socket takes a command line and runs it, and a
//! world-writable directory means both that somebody else can plant a listener
//! at a pid we are about to guess and that a file they planted under the sticky
//! bit cannot be unlinked, so bind fails and the feature goes quietly off.
//!
//! An instance that finds an ancestor process running the same executable
//! resolves its positional argument, writes ONE line — `Look /abs/path` — to
//! that ancestor's socket and exits silently; the outer pardes runs the line
//! through executeBuiltinLine and opens a pane for it. The wire format is a
//! builtin command line because that is a language pardes already speaks: no
//! serialization, nothing to version. The receive side still filters it down
//! to `Look `, because executeBuiltinLine dispatches ANY builtin and this
//! socket sits at a path anyone can derive from a pid — `Exec …` arriving here
//! is not something this protocol is allowed to say.
//!
//! 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 named `pardes`, like the tty frontend, wherever the bundle is installed.
//! Executable identity therefore comes from the family name rather than its
//! path — see samePardesExecutable.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
// 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;
/// THE SOCKET CONVENTIONS BELOW ARE SHARED, and the ones marked `pub` are
/// shared with src/detached/server.zig — a second unix socket in the same
/// per-user directory, under a different name (`pardes-detached-.sock`
/// rather than `pardes-.sock`). They were copied into that file when it
/// landed; one directory vetted by two different predicates is exactly the
/// divergence the reasoning here is meant to prevent, so there is one of each.
pub 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. The detached
/// transport needs the second alone, and the same answer.
pub 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.
pub const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len;
/// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of
/// `/proc//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 every caller
/// is past that: `listen` runs before the first pane exists, `acceptLine` runs
/// on a thread of its own long after spawning has settled, and the detached
/// session forks nothing at all (its ptys live in its frontends).
///
/// CLOEXEC still matters for both: a `--detach` session is long-lived, and an
/// inherited listener would keep its socket bound long after it ended — the
/// same shape as the inherited lock fd that once held a flock forever.
pub 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.
pub const max_line = 4200;
/// Where the sockets live. `$XDG_RUNTIME_DIR` first — a per-user 0700 tmpfs
/// the login session already cleans up — else `~/.local/state/pardes`, which
/// is per-user for the same reason a home directory is. NEVER /tmp: these
/// sockets take a command line, or keystrokes into a live editor. Asked by the
/// client (to derive the path), by the listener (to create and vet it), by the
/// sweeper (to scan it) and by the detached transport (all three, for its own
/// name), so it is written once.
pub 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;
return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{std.mem.span(home)}, 0) catch null;
}
/// `/pardes-.sock`. `` is the LISTENING instance's own pid, so
/// 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: *[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;
}
/// Normalize the kernel suffix left on a running executable after its file is
/// replaced. `zig build` does this routinely while an outer session is live.
fn stripDeleted(link: []const u8) []const u8 {
const suffix = " (deleted)";
return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link;
}
/// 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. Paths are
/// deliberately ignored: the GUI may be installed system-wide while the tty
/// frontend is installed in the user's bin directory.
fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool {
const a = stripDeleted(a_raw);
const b = stripDeleted(b_raw);
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`, `pardesfoo`, and
/// helper binaries such as `pardes-snap` 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;
if (rest.len == 0) return rest;
// Build names have exactly `-os-arch` after the frontend. Validating both
// fields keeps sibling installs flexible without mistaking pardes-snap,
// pardes-perf, and the other helper executables for editor frontends.
if (rest[0] != '-') return null;
var fields = std.mem.splitScalar(u8, rest[1..], '-');
const os = fields.next() orelse return null;
const arch = fields.next() orelse return null;
if (fields.next() != null) return null;
if (std.meta.stringToEnum(std.Target.Os.Tag, os) == null) return null;
if (std.meta.stringToEnum(std.Target.Cpu.Arch, arch) == null) return null;
return rest;
}
/// Two executable names in the same family. The tails have to agree — a linux
/// binary and an x86_64 one 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. A foreign-arch ancestor cannot be running here.
fn sameFamily(a: []const u8, b: []const u8) bool {
const a_tail = familyTail(a) orelse return false;
const b_tail = familyTail(b) orelse return false;
if (std.mem.eql(u8, a, b)) return true;
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//status blob. Deliberately NOT field 4 of
/// /proc//stat: that field is positional after `comm`, and a comm may
/// contain spaces and parentheses — a process named `sh (a b)` shifts every
/// field after it and the parse silently reads the wrong number.
fn parsePPid(status: []const u8) ?libc.pid_t {
var lines = std.mem.splitScalar(u8, status, '\n');
while (lines.next()) |line| {
if (!std.mem.startsWith(u8, line, "PPid:")) continue;
return std.fmt.parseInt(libc.pid_t, std.mem.trim(u8, line["PPid:".len..], " \t\r"), 10) catch null;
}
return null;
}
/// The pid in a `pardes-.sock` filename, for the startup sweep. Strictly
/// digits: parseInt alone would take `pardes-+7.sock` and `pardes--7.sock`,
/// and the sweep unlinks what this answers about.
fn sweepPid(name: []const u8) ?libc.pid_t {
if (!std.mem.startsWith(u8, name, "pardes-") or !std.mem.endsWith(u8, name, ".sock")) return null;
const digits = name["pardes-".len .. name.len - ".sock".len];
if (digits.len == 0) return null;
for (digits) |ch| if (!std.ascii.isDigit(ch)) return null;
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 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 !supported) return null;
var self_buf: [4096]u8 = undefined;
const self_exe = exeOf(libc.getpid(), &self_buf) orelse return null;
// A process harness may deliberately launch a fresh top-level pardes from
// inside another one. Its pid is a process-tree boundary, not an opt-out
// for the new session itself: pane shells below the child still detect it.
// This is what lets the snapshot harness exercise nested launches while
// the harness happens to be running in a real pardes pane.
const boundary = if (libc.getenv("PARDES_NESTED_BOUNDARY_PID")) |raw|
std.fmt.parseInt(libc.pid_t, std.mem.span(raw), 10) catch 0
else
0;
var pid = libc.getppid();
var hops: usize = 0;
while (pid > 1 and hops < 64) : (hops += 1) {
if (pid == boundary) return null;
var buf: [4096]u8 = undefined;
if (exeOf(pid, &buf)) |exe| if (samePardesExecutable(exe, self_exe)) return pid;
pid = parentOf(pid) orelse return null;
}
return null;
}
/// Hand `Look [:]` to the pardes listening as `pid` and say
/// whether it landed. False for every failure — no socket file, nobody
/// accepting, a path that does not fit — because an outer instance that
/// cannot be reached (an older build, a stale path) must never cost the
/// 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 !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
// too — the receive side trims a trailing one. Unsendable, not escaped:
// the caller falls through and opens the file in its own session.
if (std.mem.indexOfAny(u8, path, "\r\n") != null) return false;
var cmd_buf: [max_line]u8 = undefined;
const cmd = (if (line > 0)
std.fmt.bufPrint(&cmd_buf, "Look {s}:{d}\n", .{ path, line })
else
std.fmt.bufPrint(&cmd_buf, "Look {s}\n", .{path})) catch return false;
// 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, 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;
while (off < cmd.len) {
const n = libc.write(fd, cmd.ptr + off, cmd.len - off);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return false;
}
if (n == 0) return false;
off += @intCast(n);
}
return true;
}
/// The two things `ensureSocketDir` has to know about a path, from whichever
/// call the platform actually offers. Darwin has fstatat and no statx; on
/// linux std.c.fstatat is `void` — glibc hides it behind a versioned symbol
/// std cannot name — so linux asks statx for the same fields. Both spellings
/// refuse to follow a symlink, which is the point of asking.
///
/// `pub` for the detached transport, which vets the same directory and also
/// vets the SOCKET FILE with it (src/detached/server.zig `vetted`): `mode`
/// carries the type bits, so one call answers "is this a socket, ours, and
/// private" as well as it answers it for a directory.
pub const DirFacts = struct { mode: u32, uid: libc.uid_t };
pub fn statNoFollow(path: [:0]const u8) ?DirFacts {
if (comptime darwin) {
var st: libc.Stat = undefined;
if (libc.fstatat(libc.AT.FDCWD, path, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return null;
return .{ .mode = st.mode, .uid = st.uid };
} else {
const linux = std.os.linux;
var stx: linux.Statx = undefined;
const want: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true };
if (libc.statx(linux.AT.FDCWD, path, linux.AT.SYMLINK_NOFOLLOW, want, &stx) != 0) return null;
return .{ .mode = stx.mode, .uid = stx.uid };
}
}
/// Create the socket directory if it is missing and refuse it unless it is a
/// directory WE own with nothing granted to group or other. A planted path is
/// the whole attack on a socket that runs commands — or, for the detached
/// transport that shares this, on one that carries keystrokes into a live
/// editor — and $XDG_RUNTIME_DIR passes this untouched (the login session
/// already makes it 0700).
pub fn ensureSocketDir(dir: [:0]const u8) bool {
// mkdir -p, because the HOME branch is three levels deep and a machine
// 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: [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;
partial[i] = 0;
_ = libc.mkdir(partial[0..i :0], 0o700);
partial[i] = '/';
}
_ = libc.mkdir(dir, 0o700);
// A symlink where the directory should be is exactly the plant this
// guards against, so the stat above it does not follow one.
const st = statNoFollow(dir) orelse return false;
const IFMT: u32 = 0o170000;
const IFDIR: u32 = 0o040000;
if (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
/// rather than quit runs no defer, so its file outlives it; harmless by
/// construction (bind unlinks first, a client's connect is refused) but it is
/// our own litter and the snapshot suite alone leaves ~90 behind per run.
/// Bounded: one readdir of a directory only we write to, one kill(0) each.
fn sweep(dir: [:0]const u8) void {
const d = libc.opendir(dir) orelse return;
defer _ = libc.closedir(d);
const me = libc.getpid();
while (libc.readdir(d)) |ent| {
const pid = sweepPid(std.mem.sliceTo(&ent.name, 0)) orelse continue;
if (pid == me) continue;
// 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: [sun_path_len]u8 = undefined;
_ = libc.unlink(socketPath(&pbuf, pid) orelse continue);
}
}
/// Bind and listen so nested instances can find us; -1 if anything fails, and
/// a pardes without a socket is simply one whose children open their own UI.
/// The path is always this process's own, so nobody outside holds a buffer of
/// it — the shells each kept one and passed it back to be unlinked, which is a
/// way for the two spellings to go out of step and for no other reason.
///
/// CLOEXEC matters more here than on any other fd in the program: pane shells
/// are forked with forkpty and inherit everything open, and an orphaned bash
/// 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 !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);
// 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) };
@memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
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);
return -1;
}
// Owner-only, and BEFORE listen(2), which is the moment anyone could
// connect: the directory is already private, this is the second wall.
_ = libc.chmod(path, 0o600);
if (libc.listen(fd, 8) != 0) {
_ = libc.close(fd);
return -1;
}
return fd;
}
/// 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
/// unsupported build hand it.
pub fn unlisten(fd: c_int) void {
if (fd < 0) return;
_ = libc.close(fd);
var path_buf: [sun_path_len]u8 = undefined;
if (socketPath(&path_buf, libc.getpid())) |path| _ = libc.unlink(path);
}
/// Block until a nested instance sends a `Look` line, and return it inside
/// `buf`. Null only when the listening fd itself is gone — teardown closed it,
/// or it was never a socket — because anything else (EMFILE, ECONNABORTED)
/// would otherwise kill the listener thread for the life of the process while
/// the socket stayed bound, and every later launch would exit 0 having done
/// nothing. Every accepted connection is CLOEXEC for the reason the listener
/// is.
pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 {
if (comptime !supported) return null;
while (true) {
const conn = libc.accept(fd, null, null);
if (conn < 0) {
switch (libc.errno(conn)) {
.INTR => continue,
// the fd went away or never was one: nothing will ever arrive
.BADF, .INVAL, .NOTSOCK => return null,
// transient. Sleep first: EMFILE persists until some other fd
// is freed, and a bare `continue` would spin a core on it.
else => {
var ts: libc.timespec = .{ .sec = 0, .nsec = 100 * std.time.ns_per_ms };
_ = libc.nanosleep(&ts, null);
continue;
},
}
}
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
// short line immediately, so a second is already generous.
const tv: libc.timeval = .{ .sec = 1, .usec = 0 };
_ = libc.setsockopt(conn, libc.SOL.SOCKET, libc.SO.RCVTIMEO, &tv, @sizeOf(libc.timeval));
var len: usize = 0;
while (len < buf.len) {
const n = libc.read(conn, buf.ptr + len, buf.len - len);
if (n < 0 and libc.errno(n) == .INTR) continue;
if (n <= 0) break; // EOF, or the receive timeout expired
len += @intCast(n);
if (std.mem.indexOfScalar(u8, buf[0..len], '\n') != null) break;
}
const end = std.mem.indexOfScalar(u8, buf[0..len], '\n') orelse len;
// a full buffer with no newline is an overlong line: drop it whole
// rather than run its truncation as some other command
if (end == buf.len) continue;
const line = std.mem.trimEnd(u8, buf[0..end], "\r");
// one verb (see the file header): this socket may open things, and
// that is all it may do
if (!std.mem.startsWith(u8, line, "Look ")) continue;
return line;
}
}
test "socket path: XDG first, then a private dir under HOME, never /tmp" {
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.
var xdg_buf: [4096:0]u8 = undefined;
var home_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;
const home0 = if (libc.getenv("HOME")) |v| std.fmt.bufPrintSentinel(&home_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");
if (home0) |v| {
_ = setenv("HOME", v, 1);
} else _ = unsetenv("HOME");
}
_ = setenv("XDG_RUNTIME_DIR", "/run/user/1000", 1);
try std.testing.expectEqualStrings("/run/user/1000/pardes-4242.sock", socketPath(&buf, 4242).?);
_ = 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 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");
try std.testing.expect(socketPath(&buf, 4242) == null);
}
test "a rebuilt binary still matches its own running instance" {
// `zig build` under a live pardes: the outer's exe link gains the suffix,
// the new process's does not, and before this the two stopped comparing
// equal — every nested launch opened a second UI.
try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes (deleted)"));
try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes"));
try std.testing.expectEqualStrings("", stripDeleted(" (deleted)"));
// only a SUFFIX, and only the whole one
try std.testing.expectEqualStrings("/x (deleted) y", stripDeleted("/x (deleted) y"));
try std.testing.expectEqualStrings("/x (delete)", stripDeleted("/x (delete)"));
}
test "tty and GUI sibling executables recognise each other" {
try std.testing.expect(samePardesExecutable(
"/work/zig-out/bin/pardes",
"/work/zig-out/bin/pardes-gui",
));
try std.testing.expect(samePardesExecutable(
"/work/zig-out/bin/pardes-linux-aarch64",
"/work/zig-out/bin/pardes-gui-linux-aarch64 (deleted)",
));
// Installation paths do not define the family. This is the ordinary
// system-GUI/user-TTY pairing and the reason this comparison uses names.
try std.testing.expect(samePardesExecutable(
"/home/who/.local/bin/pardes",
"/usr/bin/pardes-gui",
));
try std.testing.expect(!samePardesExecutable(
"/work/zig-out/bin/pardes-linux-aarch64",
"/work/zig-out/bin/pardes-gui-linux-x86_64",
));
try std.testing.expect(!samePardesExecutable(
"/work/zig-out/bin/not-pardes",
"/work/zig-out/bin/not-pardes-gui",
));
try std.testing.expect(!samePardesExecutable(
"/one/bin/not-pardes",
"/two/bin/not-pardes",
));
try std.testing.expect(!samePardesExecutable(
"/work/zig-out/bin/pardes-snap",
"/usr/bin/pardes",
));
}
test "the app bundle is in the same executable family" {
// 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 the bundle carries the same build
// under the one name CFBundleExecutable can spell.
try std.testing.expect(samePardesExecutable(
"/work/zig-out/pardes.app/Contents/MacOS/pardes",
"/work/zig-out/bin/pardes-macos-aarch64",
));
// Installation location does not matter here either.
try std.testing.expect(samePardesExecutable(
"/work/zig-out/pardes.app/Contents/MacOS/pardes",
"/opt/zig-out/bin/pardes",
));
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").?);
try std.testing.expect(sweepPid("pardes-.sock") == null);
try std.testing.expect(sweepPid("pardes-7.sockx") == null);
try std.testing.expect(sweepPid("pardes-7") == null);
try std.testing.expect(sweepPid("bus") == null);
try std.testing.expect(sweepPid("pardes-osc133.bash") == null);
// parseInt alone would take these, and the sweep UNLINKS what it answers
try std.testing.expect(sweepPid("pardes-+7.sock") == null);
try std.testing.expect(sweepPid("pardes--7.sock") == null);
try std.testing.expect(sweepPid("pardes- 7.sock") == null);
}
test "PPid comes off the status field, not a comm-shifted stat line" {
// the comm here contains a space AND parentheses — the exact shape that
// breaks `field 4 of /proc//stat`
const status = "Name:\tsh (a b)\nUmask:\t0022\nState:\tS (sleeping)\n" ++
"Tgid:\t1234\nNgid:\t0\nPid:\t1234\nPPid:\t991\nTracerPid:\t0\n";
try std.testing.expectEqual(@as(libc.pid_t, 991), parsePPid(status).?);
try std.testing.expectEqual(@as(libc.pid_t, 0), parsePPid("PPid:\t0\n").?);
try std.testing.expect(parsePPid("Name:\tinit\nTracerPid:\t0\n") == null);
try std.testing.expect(parsePPid("PPid:\tnotanumber\n") == null);
// a truncated read must not answer from a half line
try std.testing.expect(parsePPid("Name:\tsh\nPPi") == null);
}