diff options
Diffstat (limited to 'src/nested.zig')
| -rw-r--r-- | src/nested.zig | 743 |
1 files changed, 0 insertions, 743 deletions
diff --git a/src/nested.zig b/src/nested.zig deleted file mode 100644 index 1997d8e4..00000000 --- a/src/nested.zig +++ /dev/null @@ -1,743 +0,0 @@ -//! A pardes launched inside a pardes hands its file to the outer one. -//! -//! Every top-level instance listens on `<dir>/pardes-<pid>.sock`, where `<dir>` -//! 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-<name>.sock` -/// rather than `pardes-<pid>.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/<pid>/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 — which forks EVERY pane shell in the session, because the daemon -/// owns them now (`host_io.zig`) — has no other thread to race with, since it -/// services its pane ptys from the same `poll(2)` that accepts its frontends. -/// -/// CLOEXEC matters MORE for that last one than it did when a frontend forked -/// the shells: a pane shell is long-lived and arbitrary, and an inherited -/// listener would keep the session's socket bound long after the session -/// 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; -} - -/// `<dir>/pardes-<pid>.sock`. `<pid>` 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/<pid>/status blob. Deliberately NOT field 4 of -/// /proc/<pid>/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-<pid>.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 <path>[:<line>]` 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/<pid>/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); -} |
