//! 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; } /// 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. 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. 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 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. /// 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 "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); }