diff options
Diffstat (limited to '9player/docs/DESIGN.md')
| -rw-r--r-- | 9player/docs/DESIGN.md | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/9player/docs/DESIGN.md b/9player/docs/DESIGN.md new file mode 100644 index 0000000..b9b2fbe --- /dev/null +++ b/9player/docs/DESIGN.md @@ -0,0 +1,405 @@ +# 9player design + +`9player` mounts a 9P2000 file tree served over a Unix or TCP stream socket +into a **fresh mount namespace** and runs a program inside it. The program +(fish, bash, `claude`, anything) sees the 9P tree as ordinary files, without +root and without touching the host's mount table. + +## Why FUSE + +The kernel's own `9p` filesystem is not mountable inside an unprivileged user +namespace (it lacks `FS_USERNS_MOUNT`) and loading it needs root. FUSE has been +user-namespace mountable since Linux 4.18, and `/dev/fuse` is world read/write. +So 9player is a tiny FUSE server that speaks 9P2000 to the real server: + +``` + program (fish/bash/claude) 9player (parent) 9P server + in new user+mount namespace │ (introspect, + /mnt/9p ─── FUSE ───▶ kernel ──▶│ fuse.zig ──▶ bridge.zig ──▶ nine.zig ──▶ ramfs, ...) + │ (framing) (translation) (cloud9 Client) +``` + +No libfuse: `src/fuse.zig` implements the small subset of the kernel FUSE +protocol we need directly against `/usr/include/linux/fuse.h`. + +## Toolchain facts (Zig 0.16) + +* Zig 0.16.0 at `/usr/bin/zig`, std at `/usr/lib/zig/std`. **Grep the std + tree before assuming an API exists**; 0.16 moved a lot of process/fs code + behind `std.Io`. Raw Linux syscalls in `std.os.linux` (`fork`, `execve`, + `mount`, `unshare`, `waitpid`, `pipe2`, `socketpair`, `poll`, `read`, `write`, + `open`, `openat`, `getdents64`, `sigaction`, `kill`, `readlinkat`, `mkdirat`, + `symlinkat`) are the intended low-level path. They return `usize`; decode + with `std.os.linux.errno(rc)` (an `E` enum, `.SUCCESS` when ok). +* `std.posix.poll`, `std.posix.sigaction`, `std.posix.read`, `std.posix.kill` + exist. `std.posix.fork/execve/waitpid/pipe2/socketpair` do **not**. +* `pub fn main() !void` and `pub fn main(init: std.process.Init) !void` are + both supported. Prefer `main(init: std.process.Init)`; `init.gpa` is a + general purpose allocator, `init.arena` an arena, `init.minimal.args` the + argv (`toSlice(allocator)`), `init.minimal.environ.block` the envp block. +* No libc is linked. Do not use `std.c.*`. Hostname lookups are therefore out + of scope: `--tcp` takes IP literals only. +* 9player lives in the cloud9 repository as `cloud9/9player/` and is built by + the root `build.zig` through the fragment `9player/build.zig` (steps + `9player`, `9player-test`, `9player-itest`, `9player-adv`; toggle + `-D9player`). cloud9 itself is imported as module `cloud9` + (`@import("cloud9")`). Read `../src/client.zig`, `Server.zig`, `wire.zig` + and `../docs/design.md`. Its core is allocation-free and caller-driven: you + push bytes in, take results out. The demo 9P server the tests mount is the + sibling program `../introspect` (`zig build introspect`). +* Standalone module tests while other files are missing (from the cloud9 + root): + `zig test --dep cloud9 -Mroot=9player/src/<file>.zig -Mcloud9=src/root.zig`. +* Format everything with `zig fmt`. + +## Process model + +``` +9player [options] -- PROGRAM [ARGS...] +``` + +1. Parent parses args, probes that `/dev/fuse` exists, connects to the 9P + server, negotiates `version` and `attach`es (fid 0 = root). Connection + failures are reported before anything is forked. +2. Parent forks with a `socketpair` status channel. **Child**: + 1. `unshare(CLONE_NEWUSER | CLONE_NEWNS)`. + 2. Writes `/proc/self/setgroups` = `deny`, `/proc/self/uid_map` = + `"<uid> <uid> 1"`, `/proc/self/gid_map` = `"<gid> <gid> 1"` (same ids + inside as outside; the child creating the namespace holds full + capabilities in it until exec). + 3. `mount(NULL, "/", NULL, MS_REC|MS_PRIVATE, NULL)` so nothing propagates. + 4. Ensures the mountpoint exists (see below). + 5. Opens `/dev/fuse` (`O_RDWR|O_CLOEXEC`). The kernel refuses to mount a + fuse descriptor opened from a different user namespace than the mount + ("wrong user namespace for fuse device"), so this must happen here, not + in the parent. + 6. `mount("9player", mountpoint, "fuse", MS_NOSUID|MS_NODEV, + "fd=<fusefd>,rootmode=40000,user_id=<uid>,group_id=<gid>,max_read=<n>")`. + 7. Sends the fuse fd to the parent over the status socket (`SCM_RIGHTS`). + 8. `statx` of the mountpoint: this forces one GETATTR, which the parent + serves. Without it the kernel keeps the root inode's initial uid 0 + (unmapped in the namespace) and every create in the root gets `EACCES`. + 9. Sets `NINEPLAYER_MOUNT=<mountpoint>` in the environment. + 10. `execve` of PROGRAM with PATH search (implemented by hand; no libc). + Exec failures are reported through the `CLOEXEC` status socket + (errno + message); the parent prints them after the serve loop ends. +3. **Parent** receives the fuse fd, then runs the FUSE loop (`bridge.serve`) + until either the child exits (SIGCHLD via self-pipe) or the FUSE fd reports + `ENODEV` (last process in the namespace gone, mount destroyed). It then + closes the fuse fd and exits with the child's status (`128+sig` if + signalled). The self-pipe is also watched by the 9P session while a reply + is outstanding (`Session.stop_fd` → `error.Stopped`), so a server that + never answers cannot keep 9player alive after the child is gone; a 3 s + watchdog armed from the SIGCHLD handler is the last resort. +4. Signals in the parent: `SIGINT`/`SIGQUIT` ignored (the child owns the tty + and gets them itself); `SIGTERM`/`SIGHUP` forwarded to the child; + `SIGPIPE` ignored; `SIGCHLD` → self-pipe. + +The FUSE fd is shared with the child only until exec (CLOEXEC); the parent's +copy keeps the connection alive. + +### Mountpoint policy + +Default mountpoint: `/mnt/9p`. A relative `--mount` is resolved against cwd. + +* If the path is a directory: use it. +* Else try `mkdir`. If that fails with `EACCES`/`EPERM`/`EROFS` (the normal + case for `/mnt/9p` as a plain user), **shadow the parent directory**: + open an fd to the parent, mount a `tmpfs` over it, then recreate every + existing entry inside the tmpfs: directories → `mkdir` + bind mount from + `/proc/self/fd/<fd>/<name>`; symlinks → `readlinkat` + `symlink`; anything + else → empty regular file + bind mount. Then `mkdir` the target inside. + Refuse (with a clear message) if the parent has more than 4096 entries or + is `/`. This only affects the new namespace. +* Else fail with the errno and a hint to pass `--mount` an existing dir. + +## Module contracts + +### `src/fuse.zig` — kernel FUSE protocol (no policy) + +Extern structs mirroring `linux/fuse.h`, with `comptime` size asserts: +`InHeader` (40), `OutHeader` (16), `Attr` (88), `EntryOut` (128), +`AttrOut` (104), `GetattrIn` (16), `SetattrIn` (88), `OpenIn` (8), +`OpenOut` (16), `ReleaseIn` (24), `FlushIn` (24), `ReadIn` (40), +`WriteIn` (40), `WriteOut` (8), `CreateIn` (16), `MkdirIn` (8), +`RenameIn` (8), `Rename2In` (16), `ForgetIn` (8), `BatchForgetIn` (8), +`ForgetOne` (16), `FsyncIn` (16), `AccessIn` (8), `InterruptIn` (8), +`Kstatfs` (80), `StatfsOut` (80), `InitIn` (64), `InitOut` (64), +`Dirent` (24 header, name padded to 8), `LseekIn` (24). + +`pub const Opcode = enum(u32) { lookup = 1, forget = 2, getattr = 3, setattr = 4, +readlink = 5, symlink = 6, mknod = 8, mkdir = 9, unlink = 10, rmdir = 11, +rename = 12, link = 13, open = 14, read = 15, write = 16, statfs = 17, +release = 18, fsync = 20, setxattr = 21, getxattr = 22, listxattr = 23, +removexattr = 24, flush = 25, init = 26, opendir = 27, readdir = 28, +releasedir = 29, fsyncdir = 30, getlk = 31, setlk = 32, setlkw = 33, +access = 34, create = 35, interrupt = 36, bmap = 37, destroy = 38, +ioctl = 39, poll = 40, notify_reply = 41, batch_forget = 42, fallocate = 43, +readdirplus = 44, rename2 = 45, lseek = 46, copy_file_range = 47, +setupmapping = 48, removemapping = 49, syncfs = 50, tmpfile = 51, statx = 52, _ }` + +Constants: `kernel_version = 7`, `kernel_minor = 31` (what we answer; the +kernel adapts to the lower minor), `FOPEN_DIRECT_IO = 1`, `FOPEN_KEEP_CACHE = 2`, +`FOPEN_NONSEEKABLE = 4`, `FUSE_ASYNC_READ = 1`, `FUSE_MAX_PAGES = 1<<22`, +`FATTR_MODE=1, FATTR_UID=2, FATTR_GID=4, FATTR_SIZE=8, FATTR_ATIME=16, +FATTR_MTIME=32, FATTR_FH=64, FATTR_ATIME_NOW=128, FATTR_MTIME_NOW=256, +FATTR_LOCKOWNER=512, FATTR_CTIME=1024`. `root_id = 1`. + +I/O helpers (blocking fd, no allocation beyond the caller's buffer): + +```zig +pub const Request = struct { header: InHeader, body: []const u8 }; +/// One kernel request. Returns null on ENODEV (unmounted). Retries EINTR/EAGAIN/ENOENT. +pub fn readRequest(fd: i32, buf: []u8) !?Request; +/// Success reply: header + concatenated payload slices, single writev. +pub fn reply(fd: i32, unique: u64, payloads: []const []const u8) !void; +/// Error reply: negative errno. +pub fn replyError(fd: i32, unique: u64, err: std.os.linux.E) !void; +/// Append a fuse_dirent (8-byte padded) to `buf`; returns false if it doesn't fit. +pub fn addDirent(buf: []u8, used: *usize, ino: u64, off: u64, dtype: u32, name: []const u8) bool; +pub fn body(comptime T: type, req: Request) !*const T; // aligned copy-free view, checks size +pub fn nameAfter(comptime T: type, req: Request) ![]const u8; // NUL-terminated name after a struct +``` + +The request buffer must be ≥ `max_write + 4096`; 9player uses 1 MiB + 4 KiB. +Requests with an unknown/unsupported opcode get `ENOSYS`. + +### `src/nine.zig` — synchronous 9P2000 session on a blocking fd + +Thin, synchronous RPC layer over `cloud9.Client` (which is push/take, +non-blocking-agnostic). One outstanding request at a time (the FUSE loop is +single-threaded). Fids are allocated from a free list. + +```zig +pub const Address = union(enum) { unix: []const u8, tcp: struct { host: []const u8, port: u16 }, fd: i32 }; +pub const Session = struct { + pub const Error = error{ Nine, Protocol, Io, Closed, TooLarge, OutOfMemory }; + /// After error.Nine, `ename` holds the server's Rerror text (copied, bounded). + ename: [256]u8, ename_len: usize, + msize: u32, + + pub fn connect(gpa: std.mem.Allocator, address: Address, msize: u32) !Session; // socket+connect, version + pub fn deinit(s: *Session) void; + pub fn attach(s: *Session, fid: u32, uname: []const u8, aname: []const u8) Error!cloud9.Qid; + pub fn allocFid(s: *Session) u32; + pub fn freeFid(s: *Session, fid: u32) void; + /// Generic RPC. Result slices borrow the input buffer until the next call. + pub fn rpc(s: *Session, req: cloud9.Client.Request) Error!cloud9.Client.Result; + // Conveniences (all built on rpc): + pub fn walk(s, fid: u32, newfid: u32, names: []const []const u8) Error!Walk; // Walk = { nwqid, wqid[16] }; partial walk → error.Nine with ename "not found"-ish + pub fn clone(s, fid: u32) Error!u32; // allocFid + walk with no names + pub fn open(s, fid: u32, mode: u8) Error!Open; // Open = { qid, iounit } + pub fn create(s, fid: u32, name: []const u8, perm: u32, mode: u8) Error!Open; + pub fn read(s, fid: u32, offset: u64, buf: []u8) Error!usize; // chunks by maxRead/iounit; stops at short read + pub fn write(s, fid: u32, offset: u64, data: []const u8) Error!usize; // chunks; stops at short write + pub fn stat(s, fid: u32) Error!cloud9.Stat; // strings borrow the input buffer + pub fn wstat(s, fid: u32, st: cloud9.Stat) Error!void; + pub fn clunk(s, fid: u32) Error!void; // frees the fid even on error + pub fn remove(s, fid: u32) Error!void; // frees the fid even on error + pub fn errno(s: *const Session) std.os.linux.E; // map ename → errno (see below) +}; +pub const dontcare = cloud9.Stat{ .type = 0xFFFF, .dev = 0xFFFF_FFFF, .qid = .{ .type = 0xFF, .version = 0xFFFF_FFFF, .path = 0xFFFF_FFFF_FFFF_FFFF }, .mode = 0xFFFF_FFFF, .atime = 0xFFFF_FFFF, .mtime = 0xFFFF_FFFF, .length = 0xFFFF_FFFF_FFFF_FFFF, .name = "", .uid = "", .gid = "", .muid = "" }; +``` + +`connect`: for `.unix` and `.tcp` create a blocking `SOCK_STREAM|SOCK_CLOEXEC` +socket and connect (`TCP_NODELAY` on TCP); for `.fd` adopt it. Buffers of +`msize` bytes for in/out are heap allocated. Then submit `.version`, drain +output to the socket, read until `take()` yields the version result. The +negotiated msize is `result.version.msize`; if the server answered +`"unknown"`, fail with `error.Protocol`. + +`rpc`: submit, write all of `client.output()` (calling `wrote`), then loop: +`take()`; if null, `read` from the fd into a temp buffer and `push` (push +returns how much fit; the frame is at most msize so it always fits after a +`take`). If the fd returns 0 → `error.Closed`. If the client dies → +`error.Protocol`. A `.fail` result copies the ename and returns `error.Nine`. + +Rerror text → errno mapping (case-insensitive substring, in this order): +`"not exist"`, `"not found"`, `"no such"` → `ENOENT`; `"exists"` → `EEXIST`; +`"not empty"` → `ENOTEMPTY`; `"not a dir"` → `ENOTDIR`; +`"is a dir"` → `EISDIR`; `"permission"`, `"denied"` → `EACCES`; +`"read-only"`, `"read only"`, `"readonly"` → `EROFS`; `"no space"` → `ENOSPC`; +`"not allowed"`, `"not permitted"`, `"cannot"` → `EPERM`; +`"fid"` → `EBADF`; `"bad offset"`, `"invalid"`, `"bad "` → `EINVAL`; +`"busy"`, `"in use"` → `EBUSY`; `"too long"` → `ENAMETOOLONG`; +`"not supported"`, `"unsupported"` → `ENOTSUP`; otherwise `EIO`. + +### `src/bridge.zig` — FUSE ↔ 9P translation + +```zig +pub const Options = struct { + uid: u32, gid: u32, // reported owner of every file + attr_timeout_ns: u64 = 1e9, // attr/entry cache validity (0 = none) + direct_io: bool = true, // FOPEN_DIRECT_IO on every regular file + debug: bool = false, // trace to stderr +}; +/// Runs until the FUSE fd reports ENODEV or `stop_fd` becomes readable. +pub fn serve(gpa: std.mem.Allocator, fuse_fd: i32, nine: *nine.Session, root_fid: u32, stop_fd: i32, opts: Options) !void; +``` + +State: + +* `inodes: AutoHashMap(u64 /*nodeid*/, Inode{ fid: u32, qid: Qid, nlookup: u64 })`. + Node 1 is the root (`root_fid`, never forgotten). +* `by_qid: AutoHashMap(u64 /*qid.path*/, u64 /*nodeid*/)` so that repeated + lookups of the same file map to the same inode (the old fid is clunked and + the fresh one kept). Dedupe only merges when the qid type (dir bit) also + matches, so a server reusing a path across a file and a directory cannot + poison an inode. `ino` in attrs is `qid.path` (root, or anything carrying + the root's path: 1). +* `handles: AutoHashMap(u64 /*fh*/, Handle{ fid: u32, dir: ?DirList })`. + `DirList` is the entire directory read at first `READDIR` offset 0: + `[]Entry{ name: []u8, ino: u64, dtype: u32 }` with synthetic `.` and `..` + first. `READDIR` offsets are indices into that list; a `READDIR` at offset 0 + re-reads the directory (rewinddir). + +Op mapping (9P2000 has no symlinks, links, xattrs, locks, mknod): + +| FUSE | 9P | +|---|---| +| INIT | reply `InitOut{ major=7, minor=31, max_readahead=in.max_readahead, flags = FUSE_ASYNC_READ \| FUSE_ATOMIC_O_TRUNC \| FUSE_AUTO_INVAL_DATA \| FUSE_BIG_WRITES (plus FUSE_MAX_PAGES with max_pages=256 if offered), max_background=16, congestion_threshold=12, max_write=1 MiB, time_gran=1 }`. Atomic O_TRUNC matters: without it the kernel truncates via a separate SETATTR(size=0) that synthetic control files reject; with it `O_TRUNC` becomes 9P `OTRUNC` inside the open | +| LOOKUP(parent,name) | `walk(parent.fid → newfid, [name])`; `stat(newfid)`; dedupe by qid; `EntryOut` | +| FORGET / BATCH_FORGET | `nlookup -= n`; at 0 `clunk` and drop (no reply) | +| GETATTR | `stat(inode.fid)` → `AttrOut` | +| SETATTR | `stat` then `wstat` with a *dontcare* Stat: `FATTR_SIZE`→length; `FATTR_MODE`→`(old.mode & ~0o777) \| (mode & 0o777)`; `FATTR_MTIME`→mtime (`FATTR_MTIME_NOW` → now); `FATTR_ATIME` ignored; `FATTR_UID/GID` → `EPERM` unless unchanged; then `stat` again for the reply | +| OPEN | `clone(inode.fid)` then `open(newfid, mode)`; mode from `O_ACCMODE` (`oread/owrite/ordwr`), `O_TRUNC` → `otrunc`; reply `OpenOut{ fh, open_flags = FOPEN_DIRECT_IO }`; on failure clunk | +| OPENDIR | same with `oread`; `fh` with `dir = null` | +| READ | `read(fh.fid, offset, buf[0..min(size, 1 MiB)])`; reply data | +| WRITE | `write(fh.fid, offset, data)`; `WriteOut{ size = n }` | +| READDIR | fill `Dirent`s from the `DirList` starting at `offset`, up to `size` bytes | +| RELEASE / RELEASEDIR | `clunk(fh.fid)`; free DirList | +| FLUSH / FSYNC / FSYNCDIR | ok (no-op) | +| CREATE(parent,name,flags,mode) | `clone(parent)`; `create(fid, name, mode & 0o777, openmode)` → this fid is the **open** file; then `walk(parent → fid2, [name])` + `stat(fid2)` for the inode; reply `EntryOut ++ OpenOut` | +| MKDIR | `clone(parent)`; `create(fid, name, DMDIR \| (mode & 0o777), oread)`; `clunk`; then lookup as above | +| UNLINK / RMDIR | `walk(parent → tmp, [name])`; `remove(tmp)` | +| RENAME / RENAME2 | if `newdir != parent` → `EXDEV`; else `walk(parent → tmp, [oldname])`, `wstat(tmp, dontcare with .name = newname)`, `clunk`. 9P rename never replaces, POSIX does: when the target exists (and `RENAME_NOREPLACE` is not set) a directory target is removed first; a file target is parked under a temporary name, the rename retried, and the parked file removed only after success (restored on failure) | +| STATFS | constant `Kstatfs{ bsize = 4096, namelen = 255, frsize = 4096 }` | +| ACCESS | `ENOSYS` (kernel stops asking; the server enforces permissions on open) | +| READLINK, SYMLINK, LINK, MKNOD, *XATTR, *LK, IOCTL, POLL, BMAP, FALLOCATE, LSEEK, COPY_FILE_RANGE, TMPFILE, STATX | `ENOSYS` | +| INTERRUPT | ignored (reply nothing) | +| DESTROY | return from `serve` | + +Attr mapping from `cloud9.Stat`: `mode = (S_IFDIR if DMDIR else S_IFREG) | +(st.mode & 0o777)`; `nlink = 1`; `size = length`; `blocks = (length+511)/512`; +`blksize = 4096`; `atime/mtime/ctime = st.atime/st.mtime/st.mtime`; +`uid/gid = opts.uid/gid`. `Dirent.type` = `DT_DIR` (4) / `DT_REG` (8). + +Errors: `nine.Session.Error.Nine` → `nine.errno()`; `Closed`/`Protocol`/`Io` +→ `EIO` and, since the session is dead, `serve` returns `error.Closed` after +replying so 9player can report "9P server went away". + +With `direct_io` the kernel never trusts `length` for reads: synthetic files +that report length 0 (very common in 9P) still `cat` correctly, and reads run +until the server returns a short read. With `--no-direct-io` the bridge forces +`attr_timeout_ns = 0`, because a cached stale size truncates reads (observed +data loss on a 4 MiB copy otherwise). + +Hostile-server rules: directory listings are capped at 64 MiB (a server that +ignores read offsets otherwise loops forever); directory records with names +containing `/`, NUL, empty, `.`/`..` or longer than `FUSE_NAME_MAX` are dropped +rather than poisoning the whole READDIR reply; `length` near 2^64 is clamped +to `i64` max; the errno of a failing 9P call is latched before any cleanup +clunk overwrites the session's ename. + +### `src/ns.zig` — namespace and process plumbing + +```zig +pub const Spawn = struct { + argv: []const []const u8, // argv[0] is PATH-searched unless it contains '/' + envp: [*:null]const ?[*:0]const u8, // inherited environment + mountpoint: []const u8, // absolute + fuse_fd: i32, + uid: u32, gid: u32, + max_read: u32, +}; +pub const Child = struct { pid: i32 }; +/// fork; the child sets up the namespace, mounts, and execs. Returns once exec succeeded +/// (status pipe closed) or fails with the child's error (message on stderr). +pub fn spawn(gpa: std.mem.Allocator, s: Spawn) !Child; +pub fn ensureMountpoint(path: [:0]const u8) !void; // the shadowing logic, testable alone +pub fn resolveMountpoint(gpa, path: []const u8) ![:0]u8; // absolute, no trailing slash +pub fn findInPath(gpa, envp, name) ![:0]u8; +``` + +Also exports the signal plumbing used by `main.zig`: +`installSignals(child_pid_ptr: *i32) !i32` returning the SIGCHLD self-pipe +read end (used as `stop_fd` for `bridge.serve`), and +`waitChild(pid) !u8` → exit status (`128+sig` on signal death). + +### `src/main.zig` — CLI + +``` +Usage: 9player [options] -- PROGRAM [ARGS...] +Transport (exactly one): + --unix PATH Unix stream socket + --tcp IP:PORT TCP (IPv4/IPv6 literal) + --fd N already-connected inherited descriptor + --spawn CMD run CMD (via /bin/sh -c) with a socketpair on its stdin/stdout +Options: + --mount PATH mountpoint inside the new namespace (default /mnt/9p) + --uname NAME 9P user name (default $USER, else "none") + --aname NAME 9P tree to attach (default "") + --msize BYTES maximum 9P message size to request (default 131072, max 16 MiB) + --cache SECONDS attr/entry cache validity, may be fractional (default 1) + --no-direct-io let the kernel cache file pages (trusts stat length) + --debug trace FUSE and 9P operations on stderr + --help, --version +PROGRAM defaults to $SHELL (else /bin/sh). The mountpoint is exported as $NINEPLAYER_MOUNT. +``` + +Exit codes: child's status; 125 for 9player's own failures (usage, connect, +mount); 126/127 as usual for exec failures. + +### `../introspect/demo/main.zig` — demo 9P2000 server (binary `introspect`) + +The demo server is a separate program in this repository, built on the +introspect library; see `../introspect/docs/LIBRARY.md` for the library +contract (freestanding core, value renderers, Linux debug probe). The tree it serves keeps the paths the integration tests read +(`/build/*`, `/comptime/types/<T>/*`, `/comptime/decls`, `/runtime/fn/*`, +`/runtime/ctl`, `/runtime/{pid,ppid,uptime,argv,cwd,env,clients}`, +`/scratch/`) and adds `/vars`, `/threads`, `/addr`, `/mem`, `/hex`, +`/breakpoints` and `/panic`. + +## Integration test plan (`test/integration.sh`) + +Run by `zig build 9player-itest`; args: path to `9player`, path to `introspect`. +Everything under a temp dir. Skips (exit 0 with a notice) when +`unshare -Urm true` fails or `/dev/fuse` is missing. + +1. introspect on a Unix socket; `9player --unix … -- sh -c` scripts: + `cat /mnt/9p/build/zig_version` == `zig version`; `ls` listings; `stat` + sizes; `/runtime/fn/now` is numeric; `ctl` round trip; `/scratch`: create, + append (`>>`), overwrite, truncate, `mkdir -p a/b/c`, rename within dir, + `mv` across dirs fails with `EXDEV`-ish message, `rm`, `rmdir`, 1 MiB + random file round trip compared with `sha256sum`, `dd` with odd block + sizes, many small files, `find`, exit-status propagation (`exit 7` → 7), + `$NINEPLAYER_MOUNT` set, nested `9player` inside `9player`. +2. `--spawn "<introspect> --stdio"` variant. +3. `--tcp 127.0.0.1:<port>` variant. +4. If `/usr/lib/plan9/bin/ramfs` exists: `NAMESPACE=$tmp ramfs -s ramfs` + creates `$tmp/ramfs`; run the scratch battery against it. +5. `--mount` with an existing dir, with a relative path, and the default + `/mnt/9p` (exercises parent shadowing; verify `/mnt`'s other entries are + still visible inside). +6. Kill tests: 9player exits when the child exits; server death during use + yields `EIO`, not a hang. + +## Verification + +`zig build 9player-test` (unit), `zig build 9player-itest` (74 end-to-end +checks against introspect over unix/tcp/socketpair and against plan9port's +`ramfs`) and `zig build 9player-adv` (adversarial suites: a scriptable +hostile 9P server with ~30 misbehaviour modes, FUSE semantics through the +bridge, process/namespace/signal edge cases with 51 checks, and stress). The +suites that attack the introspect server itself (a hostile raw-9P client with +181 checks, the core, the Linux layer) moved with it to +`../introspect/test` (`zig build introspect-adv`). All pass in Debug and +ReleaseSafe. + +## Out of scope for v1 (documented, not hidden) + +* One 9P request in flight at a time: a 9P read that blocks (event files) + stalls the whole mount until it returns (but not past the child's exit). +* No 9P2000.u/.L: no symlinks, ownership, or extended attributes. +* No PID namespace, no `/proc` remount. `--tcp` needs an IP literal. +* Cross-directory rename returns `EXDEV` (9P2000 cannot move files). |
