From 0a15af8d98771180e32402e68ea844f9f377cefb Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 02:34:49 -0300 Subject: a pardes launched inside pardes hands its file to the outer one --- src/nested.zig | 361 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/nested.zig (limited to 'src/nested.zig') diff --git a/src/nested.zig b/src/nested.zig new file mode 100644 index 00000000..6e70bdc9 --- /dev/null +++ b/src/nested.zig @@ -0,0 +1,361 @@ +//! 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, so the walk there is +//! proc_pidinfo(PROC_PIDTBSDINFO) for `pbi_ppid` plus a `pbi_comm` compare — +//! a 16-byte truncated name, which is a weaker identity than an exe path — +//! and neither SOCK_CLOEXEC nor accept4 exists, so the socket half needs two +//! extra fcntl(FD_CLOEXEC) calls. Its `sockaddr.un.path` is 104 bytes, not +//! 108: the `[108]u8` buffers and the unguarded memcpys below are sized for +//! linux and a port has to re-derive them from `@FieldType`. None of it 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; +} + +/// A `/proc//exe` readlink with the kernel's `" (deleted)"` suffix taken +/// off. `zig build` replaces the binary under a running pardes — that is the +/// daily loop in this repo — and from that moment the OUTER instance's exe +/// link reads `/path/to/pardes (deleted)` while the freshly built child's +/// reads `/path/to/pardes`. Comparing them raw made every nested launch after +/// a rebuild open a second full-screen UI inside the pane. +pub 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 `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. +pub 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. +pub 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 THIS executable, or null. Identity +/// is `readlink("/proc//exe")` against our own, not a name: a name match +/// would call every `vim pardes.zig` an outer pardes. 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 std.mem.eql(u8, stripDeleted(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. +/// +/// 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 listenAt(path: [:0]const u8) 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 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; +} + +/// 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; + // ...and a cap on the reads themselves, because the timeout is PER + // read and a peer dribbling one byte under it would otherwise stretch + // to buf.len seconds. One line is one or two reads. + var reads: usize = 0; + while (len < buf.len and reads < 64) : (reads += 1) { + 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; + _ = 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 "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); +} -- cgit v1.3