//! 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 the same build as `bin/pardes` installed a second time, at a path that //! shares nothing below the install prefix, so identity is compared at that //! prefix — see samePardesExecutable. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; // std.c has getenv but neither setter; the tests below need both extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn unsetenv(name: [*:0]const u8) c_int; const darwin = switch (builtin.os.tag) { .macos, .ios, .tvos, .watchos, .visionos => true, else => false, }; /// This module is only as portable as its two ingredients: a way to name the /// executable and parent of an arbitrary pid, and unix sockets. const supported = builtin.os.tag == .linux or darwin; /// `sun_path` is 108 bytes on linux and 104 on darwin, and it is the hard /// limit on this whole feature: a path that does not fit is not a socket /// address, it is a truncated one pointing somewhere else. Taken from the /// struct so that the buffers, the fit checks and the memcpy below cannot /// disagree with the kernel or with each other. const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; /// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of /// `/proc//exe`; `PROC_PIDTBSDINFO` carries the parent pid that linux /// spells `PPid:`. Both are same-uid readable, which is the only permission /// an ancestor walk through one's own processes needs. const PROC_PIDTBSDINFO: c_int = 3; const proc_bsdinfo = extern struct { flags: u32, status: u32, xstatus: u32, pid: u32, ppid: u32, /// uids, gids, comm, name, the tty and the start time: filled by the /// kernel and unread here, but the call fails unless the buffer is the /// whole 136-byte record. rest: [116]u8, }; extern "c" fn proc_pidpath(pid: c_int, buffer: *anyopaque, buffersize: u32) c_int; extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; /// Linux opens sockets CLOEXEC in one call; darwin has to set it afterwards. /// The gap is a race only against a fork on another thread, and both callers /// are past that: `listen` runs before the first pane exists, and `acceptLine` /// runs on a thread of its own long after spawning has settled. fn setCloexec(fd: c_int) void { const FD_CLOEXEC: c_int = 1; _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); } /// The longest command line this protocol carries or accepts. `Look ` plus a /// PATH_MAX path fits with room over; anything longer cannot have come from /// the client and is dropped rather than truncated into a different command. 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: *[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 install prefix a program directory belongs to. `bin/pardes` and /// `pardes.app/Contents/MacOS/pardes` are one build installed twice and share /// no directory at all, so comparing dirnames says they are strangers; both /// reduce to the prefix, and so does everything else — a directory that is /// neither wrapper is its own prefix, which leaves the same-directory rule /// below exactly as strict as it was. fn installPrefix(dir: []const u8) []const u8 { const macos_dir = "/Contents/MacOS"; if (std.mem.endsWith(u8, dir, macos_dir)) { const app = dir[0 .. dir.len - macos_dir.len]; if (std.mem.endsWith(u8, app, ".app")) return std.fs.path.dirname(app) orelse app; } const bin = "/bin"; if (std.mem.endsWith(u8, dir, bin)) return dir[0 .. dir.len - bin.len]; return dir; } /// The tty, SDL and macOS builds are sibling frontends of the same program. /// Their installed names differ only by `-gui` (and, for cross builds, share /// the same `-os-arch` tail), or not at all when one of them is the app bundle /// — so any of them must recognise any other as an outer pardes. Requiring the /// same install prefix retains the executable-identity check: an unrelated /// ancestor merely named `pardes` is not enough. fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool { const a = stripDeleted(a_raw); const b = stripDeleted(b_raw); if (std.mem.eql(u8, a, b)) return true; const a_dir = installPrefix(std.fs.path.dirname(a) orelse return false); const b_dir = installPrefix(std.fs.path.dirname(b) orelse return false); if (!std.mem.eql(u8, a_dir, b_dir)) return false; return sameFamily(std.fs.path.basename(a), std.fs.path.basename(b)); } /// What is left of a family name after the frontend part: `` for `pardes` and /// `pardes-gui`, `-linux-aarch64` for the cross-built spellings of both. Null /// when the name is not in the family at all — `not-pardes` and `pardesfoo` /// are other programs. fn familyTail(name: []const u8) ?[]const u8 { const rest = if (std.mem.startsWith(u8, name, "pardes-gui")) name["pardes-gui".len..] else if (std.mem.startsWith(u8, name, "pardes")) name["pardes".len..] else return null; // `pardesfoo` shares a prefix and nothing else. A tail is a tail or empty. if (rest.len != 0 and rest[0] != '-') return null; return rest; } /// Two family names for the same build, given that they already share an /// install prefix. The tails have to agree — a linux binary and an x86_64 one /// in the same directory are two builds — unless one of them has no tail at /// all, which is the untagged name the default build and, unavoidably, the app /// bundle both produce: CFBundleExecutable is a fixed string, so the bundled /// copy of `pardes-macos-aarch64` is called `pardes` and nothing in the name /// records what it was. Loosening it that far is safe because the prefix /// already had to match, and a foreign-arch ancestor cannot be running here. fn sameFamily(a: []const u8, b: []const u8) bool { if (std.mem.eql(u8, a, b)) return true; const a_tail = familyTail(a) orelse return false; const b_tail = familyTail(b) orelse return false; return a_tail.len == 0 or b_tail.len == 0 or std.mem.eql(u8, a_tail, b_tail); } /// The `PPid:` field of a /proc//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; var pid = libc.getppid(); var hops: usize = 0; while (pid > 1 and hops < 64) : (hops += 1) { 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 three 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 three fields. Both /// spellings refuse to follow a symlink, which is the point of asking. const DirFacts = struct { mode: u32, uid: libc.uid_t }; 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, 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: [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)", )); 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 app bundle is the same build as the binary installed beside it" { // What `pardes foo.zig` typed into the bundle's own shell has to resolve: // the ancestor is zig-out/pardes.app/..., this process is zig-out/bin/..., // and nothing below zig-out is shared. try std.testing.expect(samePardesExecutable( "/work/zig-out/pardes.app/Contents/MacOS/pardes", "/work/zig-out/bin/pardes", )); // ...and the SDL sibling, which reaches it by the name rule instead. try std.testing.expect(samePardesExecutable( "/work/zig-out/pardes.app/Contents/MacOS/pardes", "/work/zig-out/bin/pardes-gui", )); // The case this machine actually produces: `zig build` installs the tty // binary under its os-arch tail, and 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", )); // A different install is still a different program, however alike the // paths look — this is the whole point of comparing anything at all. try std.testing.expect(!samePardesExecutable( "/work/zig-out/pardes.app/Contents/MacOS/pardes", "/opt/zig-out/bin/pardes", )); // The wrapper is only transparent when it IS the wrapper: `Contents/MacOS` // under something that is not a bundle keeps its own directory. try std.testing.expect(!samePardesExecutable( "/work/zig-out/pardes/Contents/MacOS/pardes", "/work/zig-out/bin/pardes", )); // Nothing here may loosen the rule for two unrelated programs that merely // sit in a bin and a bundle of the same tree. try std.testing.expect(!samePardesExecutable( "/work/zig-out/other.app/Contents/MacOS/other", "/work/zig-out/bin/pardes", )); } test "the ancestor walk reads this process's own parent" { // The one thing a hand-written `struct proc_bsdinfo` gets wrong silently: // a field ordering that puts something else where ppid should be still // returns a plausible number. getppid knows the answer, so compare. // // Also the only check that libproc answers us at all — every caller of // outer() treats a failure as "no outer instance", which is exactly what a // permission problem would look like. if (comptime !supported) return error.SkipZigTest; try std.testing.expectEqual(libc.getppid(), parentOf(libc.getpid()).?); // ...and that the walk terminates rather than spinning on pid 1's parent. try std.testing.expect(parentOf(1) == null or parentOf(1).? <= 1); var buf: [4096]u8 = undefined; const exe = exeOf(libc.getpid(), &buf).?; try std.testing.expect(exe.len > 0); try std.testing.expect(exe[0] == '/'); // The test binary is not a pardes, so the walk must come back empty rather // than matching some ancestor by accident. try std.testing.expect(outer() == null); } extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; extern "c" fn rmdir(path: [*:0]const u8) c_int; test "a Look line survives the socket round trip" { // Everything the protocol actually does, against a real kernel: bind, // chmod, connect, write, accept, read, and the one-verb filter. The pure // functions above cannot see any of it, and every primitive here is // spelled differently on the two platforms this now supports. if (comptime !supported) return error.SkipZigTest; // A private directory of our own. Not the developer's real state dir: this // binds a socket named after a pid that is the TEST's, and sweep() unlinks // what it finds beside it. var tmpl: [64:0]u8 = undefined; _ = std.fmt.bufPrintSentinel(&tmpl, "/tmp/pardes-nested-XXXXXX", .{}, 0) catch unreachable; if (mkdtemp(&tmpl) == null) return error.SkipZigTest; const dir = std.mem.sliceTo(&tmpl, 0); defer _ = rmdir(tmpl[0..dir.len :0]); var xdg_buf: [4096:0]u8 = undefined; const xdg0 = if (libc.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&xdg_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null; defer { if (xdg0) |v| { _ = setenv("XDG_RUNTIME_DIR", v, 1); } else _ = unsetenv("XDG_RUNTIME_DIR"); } _ = setenv("XDG_RUNTIME_DIR", tmpl[0..dir.len :0], 1); const fd = listen(); try std.testing.expect(fd >= 0); defer unlisten(fd); // Sent to our own pid, which is the pid listen() named the socket after. // The client closes as it returns, and the line is already queued, so the // single-threaded accept below finds a complete connection waiting — no // thread and no timeout needed to prove the protocol. try std.testing.expect(sendLook(libc.getpid(), "/etc/hosts", 42)); var buf: [max_line]u8 = undefined; try std.testing.expectEqualStrings("Look /etc/hosts:42", acceptLine(fd, &buf).?); // ...and without a line number, which is the directory and image case. try std.testing.expect(sendLook(libc.getpid(), "/etc", 0)); try std.testing.expectEqualStrings("Look /etc", acceptLine(fd, &buf).?); // The socket takes one verb. Anything else is dropped rather than run, so // the next Look is what comes back — proving the filter skipped it without // dropping the connection after it. try std.testing.expect(writeLine(libc.getpid(), "Exec rm -rf /\n")); try std.testing.expect(sendLook(libc.getpid(), "/etc/passwd", 0)); try std.testing.expectEqualStrings("Look /etc/passwd", acceptLine(fd, &buf).?); // A path that cannot be one line is not escaped, it is refused. try std.testing.expect(!sendLook(libc.getpid(), "/etc/ho\nsts", 0)); } /// sendLook with the framing bypassed, so a test can put something on the wire /// that the client would never send. fn writeLine(pid: libc.pid_t, line: []const u8) bool { var path_buf: [sun_path_len]u8 = undefined; const sock = socketPath(&path_buf, pid) orelse return false; var addr: libc.sockaddr.un = .{ .path = @splat(0) }; @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return false; defer _ = libc.close(fd); if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; return libc.write(fd, line.ptr, line.len) == @as(isize, @intCast(line.len)); } test "the sweep only recognises its own socket names" { try std.testing.expectEqual(@as(libc.pid_t, 7), sweepPid("pardes-7.sock").?); try std.testing.expectEqual(@as(libc.pid_t, 4194304), sweepPid("pardes-4194304.sock").?); 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); }