//! 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 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.
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;
/// 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. 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 {
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: *[108]u8, pid: libc.pid_t) ?[:0]const u8 {
var dir_buf: [108: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 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.
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;
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);
}
/// 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;
}
/// The pid of the nearest ancestor running a pardes executable, or null.
/// Identity is `readlink("/proc//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
/// spin on a surprising one.
pub fn outer() ?libc.pid_t {
if (comptime builtin.os.tag != .linux) 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)]);
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;
}
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 builtin.os.tag != .linux) 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;
var path_buf: [108]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);
if (fd < 0) return false;
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;
}
/// 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, and $XDG_RUNTIME_DIR
/// passes this untouched (the login session already makes it 0700).
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: [108: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);
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;
}
/// 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: [108]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 builtin.os.tag != .linux) return -1;
var dir_buf: [108:0]u8 = undefined;
const dir = socketDir(&dir_buf) orelse return -1;
if (!ensureSocketDir(dir)) return -1;
sweep(dir);
var path_buf: [108]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);
if (fd < 0) return -1;
_ = 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
/// non-linux build hand it.
pub fn unlisten(fd: c_int) void {
if (fd < 0) return;
_ = libc.close(fd);
var path_buf: [108]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 builtin.os.tag != .linux) return null;
while (true) {
const conn = libc.accept4(fd, null, null, libc.SOCK.CLOEXEC);
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);
// 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: [108]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 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);
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)",
));
try std.testing.expect(!samePardesExecutable(
"/usr/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-x86_64",
));
try std.testing.expect(!samePardesExecutable(
"/work/zig-out/bin/not-pardes",
"/work/zig-out/bin/not-pardes-gui",
));
}
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);
}