diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-19 21:26:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-19 21:26:05 -0300 |
| commit | b05abcba3ea09ea106ad28364c6e40a3ec31b890 (patch) | |
| tree | 9170fac5e7e5d8bde108de34a182aaa9d6844117 /9player/test | |
| parent | ae310a207534b33b7321dd2b9f423a73b1969159 (diff) | |
| download | cloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.tar.gz cloud9-b05abcba3ea09ea106ad28364c6e40a3ec31b890.zip | |
Add 9player and introspect as programs beside the library
9player/: FUSE mount CLI that mounts a 9P2000 tree into a fresh user+mount
namespace and runs a program in it (no root, no libfuse, no libc).
introspect/: the 9P debug/introspection library (freestanding core, value
renderers, Linux probe with threads/stacks/memory/breakpoints/panics) and
its demo server. Each has its own build fragment; the root build.zig wires
them behind -D9player/-Dintrospect with namespaced steps (9player-itest,
introspect-check-freestanding, programs-test, ...) and exports the
introspect module for dependents. This is the layout for related programs.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9player/test')
| -rwxr-xr-x | 9player/test/adv_bridge_hostile.py | 487 | ||||
| -rwxr-xr-x | 9player/test/adv_bridge_hostile.sh | 181 | ||||
| -rwxr-xr-x | 9player/test/adv_bridge_semantics.sh | 205 | ||||
| -rwxr-xr-x | 9player/test/adv_bridge_stress.sh | 64 | ||||
| -rwxr-xr-x | 9player/test/adv_ns_process.sh | 202 | ||||
| -rwxr-xr-x | 9player/test/adversarial.sh | 15 | ||||
| -rwxr-xr-x | 9player/test/integration.sh | 160 |
7 files changed, 1314 insertions, 0 deletions
diff --git a/9player/test/adv_bridge_hostile.py b/9player/test/adv_bridge_hostile.py new file mode 100755 index 0000000..b842a1a --- /dev/null +++ b/9player/test/adv_bridge_hostile.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +"""A scriptable, hostile 9P2000 server on a Unix socket (stdlib only). + +Usage: adv_bridge_hostile.py SOCKET MODE + +Serves a tiny in-memory tree: + /f "hello world\\n" + /d/g "in d\\n" + /fids reading it returns the number of fids currently bound + /big 1 MiB of pseudo-random bytes +plus create/write/remove/wstat so the scratch battery can run in `ok` mode. + +MODE selects one misbehaviour (see MODES below). Everything not covered by +the mode behaves normally, so 9player gets through version/attach/stat(root). +""" +import os +import random +import socket +import struct +import sys +import time + +NOTAG = 0xFFFF +NOFID = 0xFFFFFFFF +QTDIR = 0x80 +DMDIR = 0x80000000 + +Tversion, Rversion = 100, 101 +Tauth, Rauth = 102, 103 +Tattach, Rattach = 104, 105 +Rerror = 107 +Tflush, Rflush = 108, 109 +Twalk, Rwalk = 110, 111 +Topen, Ropen = 112, 113 +Tcreate, Rcreate = 114, 115 +Tread, Rread = 116, 117 +Twrite, Rwrite = 118, 119 +Tclunk, Rclunk = 120, 121 +Tremove, Rremove = 122, 123 +Tstat, Rstat = 124, 125 +Twstat, Rwstat = 126, 127 + +MODES = """ +ok behave (qid paths are recycled LIFO after remove, like many servers) +trunc Rread on /f: send half the frame, then close +short_frame Rread on /f: frame whose size field is 3 +huge_frame Rread on /f: frame whose size field is msize+1 +wrong_tag Rread on /f: reply carries tag+1 +wrong_type Tstat on /f: answer with an Rwalk +rread_big Rread on /f: count = requested+1 +rwalk_many Twalk to f: nwqid = nwname+1 +rwalk_zero Twalk to nope: Rwalk nwqid=0 instead of Rerror +rstat_garbage Tstat on /f: random bytes as the stat +rstat_overlong Tstat on /f: inner stat size disagrees with outer +dir_split Tread on /: a stat record split across two Rreads +dir_forever Tread on /: ignore offset, always return the same records +qid_collide every file and dir shares qid.path 7 (root keeps its own) +qid_zero every qid.path is 0, including the root +name_slash / has an entry "a/b" +name_empty / has an entry "" +name_huge / has an entry with a 60000-byte name +name_dots / lists "." and ".." too +rerror_big Twalk to nope: Rerror with 65535 bytes of text +extra_reply Rread on /f: an unsolicited Rclunk (tag 9) precedes the real reply +never Tread on /f: never reply (hang) +close_mid Tread on /f: close the socket without replying +renegotiate Tread on /f: an unsolicited Rversion precedes the real reply +length_max Tstat on /f: length = 2**64-1 +iounit_one Ropen: iounit = 1 +rwrite_big Rwrite: count = requested+1 +msize_tiny Rversion msize = 64 +version_unknown Rversion "unknown" +rename_fail Twstat with a new name always fails "file already exists" +slow every reply delayed 20 ms (for interrupt tests) +""" + + +def s8(x): return struct.pack('<B', x) +def s16(x): return struct.pack('<H', x) +def s32(x): return struct.pack('<I', x) +def s64(x): return struct.pack('<Q', x) +def sstr(b): + if isinstance(b, str): + b = b.encode() + return s16(len(b)) + b + + +class Node: + def __init__(self, name, isdir, path, content=b''): + self.name = name + self.isdir = isdir + self.path = path + self.content = bytearray(content) + self.children = {} + self.mode = 0o755 if isdir else 0o644 + self.mtime = int(time.time()) + self.removed = False + + def qid(self, srv): + path = self.path + if srv.mode == 'qid_zero': + path = 0 + elif srv.mode == 'qid_collide' and self is not srv.root: + path = 7 + return s8(QTDIR if self.isdir else 0) + s32(1) + s64(path) + + def stat_bytes(self, srv, name=None, length=None): + if name is None: + name = self.name + if length is None: + length = 0 if self.isdir else len(self.content) + body = (s16(0) + s32(0) + self.qid(srv) + s32((DMDIR if self.isdir else 0) | self.mode) + + s32(self.mtime) + s32(self.mtime) + s64(length) + + sstr(name) + sstr('u') + sstr('g') + sstr('u')) + return s16(len(body)) + body + + +class Server: + def __init__(self, mode): + self.mode = mode + self.next_path = 100 + self.free_paths = [] + self.root = Node('', True, 1) + f = self.mk(self.root, 'f', False, b'hello world\n') + d = self.mk(self.root, 'd', True) + self.mk(d, 'g', False, b'in d\n') + self.mk(self.root, 'fids', False) + rnd = random.Random(4) + self.mk(self.root, 'big', False, bytes(rnd.getrandbits(8) for _ in range(1 << 20))) + self.fids = {} + self.msize = 8192 + self.ops = 0 + + def alloc_path(self): + if self.free_paths: + return self.free_paths.pop() + self.next_path += 1 + return self.next_path + + def mk(self, parent, name, isdir, content=b''): + n = Node(name, isdir, self.alloc_path(), content) + parent.children[name] = n + return n + + # -- framing --------------------------------------------------------- + def frame(self, typ, tag, body): + return s32(7 + len(body)) + s8(typ) + s16(tag) + body + + def err(self, tag, text): + return self.frame(Rerror, tag, sstr(text)) + + def serve(self, conn): + buf = b'' + while True: + if len(buf) >= 4: + n = struct.unpack('<I', buf[:4])[0] + if len(buf) >= n: + msg, buf = buf[:n], buf[n:] + out = self.handle(msg) + if out is None: + return # hang up / hang + if self.mode == 'slow': + time.sleep(0.02) + conn.sendall(out) + continue + data = conn.recv(65536) + if not data: + return + buf += data + + def handle(self, msg): + typ = msg[4] + tag = struct.unpack('<H', msg[5:7])[0] + b = msg[7:] + self.ops += 1 + r = Reader(b) + if typ == Tversion: + msize = r.u32() + ver = r.str() + self.msize = min(msize, 1 << 20) + self.fids = {} + if self.mode == 'msize_tiny': + self.msize = 64 + if self.mode == 'version_unknown': + return self.frame(Rversion, NOTAG, s32(self.msize) + sstr('unknown')) + return self.frame(Rversion, NOTAG, s32(self.msize) + sstr('9P2000')) + if typ == Tauth: + return self.err(tag, 'authentication not required') + if typ == Tattach: + fid = r.u32() + r.u32() + r.str() + r.str() + if fid in self.fids: + return self.err(tag, 'fid in use') + self.fids[fid] = [self.root, False] + return self.frame(Rattach, tag, self.root.qid(self)) + if typ == Tflush: + return self.frame(Rflush, tag, b'') + if typ == Twalk: + fid, newfid, nw = r.u32(), r.u32(), r.u16() + names = [r.str() for _ in range(nw)] + if fid not in self.fids: + return self.err(tag, 'unknown fid') + if newfid != fid and newfid in self.fids: + return self.err(tag, 'fid in use') + node = self.fids[fid][0] + qids = b'' + n = 0 + for name in names: + if not node.isdir: + break + if name == '..': + nxt = node # root's parent is itself; good enough + elif name in node.children: + nxt = node.children[name] + else: + break + node = nxt + qids += node.qid(self) + n += 1 + if n < nw and n == 0: + if self.mode == 'rwalk_zero' and names == ['nope']: + return self.frame(Rwalk, tag, s16(0)) + if self.mode == 'rerror_big' and names == ['nope']: + return self.err(tag, 'x' * 65535) + return self.err(tag, 'file does not exist') + if n == nw: + self.fids[newfid] = [node, False] + if self.mode == 'rwalk_many' and names == ['f']: + return self.frame(Rwalk, tag, s16(n + 1) + qids + node.qid(self)) + return self.frame(Rwalk, tag, s16(n) + qids) + if typ == Topen: + fid, mode = r.u32(), r.u8() + if fid not in self.fids: + return self.err(tag, 'unknown fid') + node = self.fids[fid][0] + if node.isdir and (mode & 3) != 0: + return self.err(tag, 'is a directory') + if mode & 0x10 and not node.isdir: + node.content = bytearray() + self.fids[fid][1] = True + iounit = 1 if self.mode == 'iounit_one' else 0 + return self.frame(Ropen, tag, node.qid(self) + s32(iounit)) + if typ == Tcreate: + fid = r.u32() + name = r.str() + perm = r.u32() + mode = r.u8() + if fid not in self.fids: + return self.err(tag, 'unknown fid') + parent = self.fids[fid][0] + if not parent.isdir: + return self.err(tag, 'not a directory') + if name in parent.children: + return self.err(tag, 'file already exists') + node = self.mk(parent, name, bool(perm & DMDIR)) + node.mode = perm & 0o777 + self.fids[fid] = [node, True] + return self.frame(Rcreate, tag, node.qid(self) + s32(0)) + if typ == Tread: + fid, off, count = r.u32(), r.u64(), r.u32() + if fid not in self.fids: + return self.err(tag, 'unknown fid') + node, opened = self.fids[fid] + if not opened: + return self.err(tag, 'fid not open') + if node.isdir: + return self.readdir(tag, node, off, count) + if node.name == 'fids': + data = ('%d\n' % len(self.fids)).encode() + data = data[off:off + count] + return self.frame(Rread, tag, s32(len(data)) + data) + data = bytes(node.content[off:off + count]) + if node is self.root.children.get('f'): + m = self.mode + if m == 'trunc': + fr = self.frame(Rread, tag, s32(len(data)) + data) + self.conn.sendall(fr[:len(fr) // 2]) + return None + if m == 'short_frame': + return s32(3) + s8(Rread) + s16(tag) + if m == 'huge_frame': + return s32(self.msize + 1) + s8(Rread) + s16(tag) + s32(len(data)) + data + if m == 'wrong_tag': + return self.frame(Rread, (tag + 1) & 0xFFFF, s32(len(data)) + data) + if m == 'rread_big': + data = b'x' * (count + 1) + return self.frame(Rread, tag, s32(len(data)) + data) + if m == 'extra_reply': + return self.frame(Rclunk, 9, b'') + self.frame(Rread, tag, s32(len(data)) + data) + if m == 'never': + time.sleep(3600) + return None + if m == 'close_mid': + return None + if m == 'renegotiate': + return self.frame(Rversion, NOTAG, s32(self.msize) + sstr('9P2000')) + self.frame(Rread, tag, s32(len(data)) + data) + return self.frame(Rread, tag, s32(len(data)) + data) + if typ == Twrite: + fid, off = r.u32(), r.u64() + data = r.data() + if fid not in self.fids: + return self.err(tag, 'unknown fid') + node, opened = self.fids[fid] + if not opened or node.isdir: + return self.err(tag, 'fid not open for write') + if node.name == 'fids': + return self.err(tag, 'permission denied') + if off > len(node.content): + node.content.extend(b'\0' * (off - len(node.content))) + node.content[off:off + len(data)] = data + node.mtime = int(time.time()) + n = len(data) + 1 if self.mode == 'rwrite_big' else len(data) + return self.frame(Rwrite, tag, s32(n)) + if typ == Tclunk: + fid = r.u32() + if fid not in self.fids: + return self.err(tag, 'unknown fid') + del self.fids[fid] + return self.frame(Rclunk, tag, b'') + if typ == Tremove: + fid = r.u32() + if fid not in self.fids: + return self.err(tag, 'unknown fid') + node = self.fids[fid][0] + del self.fids[fid] + if node is self.root: + return self.err(tag, 'cannot remove root') + if node.isdir and node.children: + return self.err(tag, 'directory not empty') + parent = self.find_parent(self.root, node) + if parent is not None: + del parent.children[node.name] + self.free_paths.append(node.path) + node.removed = True + return self.frame(Rremove, tag, b'') + if typ == Tstat: + fid = r.u32() + if fid not in self.fids: + return self.err(tag, 'unknown fid') + node = self.fids[fid][0] + if node is self.root.children.get('f'): + m = self.mode + if m == 'wrong_type': + return self.frame(Rwalk, tag, s16(0)) + if m == 'rstat_garbage': + junk = bytes([0xAB] * 60) + return self.frame(Rstat, tag, s16(len(junk)) + junk) + if m == 'rstat_overlong': + st = node.stat_bytes(self) + inner = st[2:] + return self.frame(Rstat, tag, s16(len(inner) + 5) + inner) + if m == 'length_max': + st = node.stat_bytes(self, length=2 ** 64 - 1) + return self.frame(Rstat, tag, s16(len(st)) + st) + st = node.stat_bytes(self) + return self.frame(Rstat, tag, s16(len(st)) + st) + if typ == Twstat: + fid = r.u32() + r.u16() + st = r.bytes(r.u16()) + if fid not in self.fids: + return self.err(tag, 'unknown fid') + node = self.fids[fid][0] + sr = Reader(st) + sr.u16(); sr.u32(); sr.bytes(13) + mode = sr.u32(); sr.u32(); mtime = sr.u32(); length = sr.u64() + name = sr.str() + if name and name != node.name: + if self.mode == 'rename_fail': + return self.err(tag, 'file already exists') + parent = self.find_parent(self.root, node) + if name in parent.children: + return self.err(tag, 'file already exists') + del parent.children[node.name] + node.name = name + parent.children[name] = node + if mode != 0xFFFFFFFF: + node.mode = mode & 0o777 + if mtime != 0xFFFFFFFF: + node.mtime = mtime + if length != 0xFFFFFFFFFFFFFFFF and not node.isdir: + if length < len(node.content): + del node.content[length:] + else: + node.content.extend(b'\0' * (length - len(node.content))) + return self.frame(Rwstat, tag, b'') + return self.err(tag, 'unsupported message') + + def find_parent(self, cur, node): + for c in cur.children.values(): + if c is node: + return cur + if c.isdir: + p = self.find_parent(c, node) + if p is not None: + return p + return None + + def readdir(self, tag, node, off, count): + recs = [] + if node is self.root: + m = self.mode + if m == 'name_slash': + recs.append(node.stat_bytes(self, name='a/b')) + if m == 'name_empty': + recs.append(node.stat_bytes(self, name='')) + if m == 'name_huge': + recs.append(node.stat_bytes(self, name='h' * 60000)) + if m == 'name_dots': + recs.append(node.stat_bytes(self, name='.')) + recs.append(node.stat_bytes(self, name='..')) + for c in node.children.values(): + recs.append(c.stat_bytes(self)) + blob = b''.join(recs) + if node is self.root and self.mode == 'dir_forever': + return self.frame(Rread, tag, s32(len(blob)) + blob) + if node is self.root and self.mode == 'dir_split': + # first read: up to the middle of the second record; second read: the rest + cut = len(recs[0]) + len(recs[1]) // 2 + if off == 0: + data = blob[:cut] + elif off == cut: + data = blob[cut:] + else: + data = b'' + return self.frame(Rread, tag, s32(len(data)) + data) + # 9P rule: offset 0 or previous offset+count; never split a record. + out = b'' + pos = 0 + for rec in recs: + if pos >= off and len(out) + len(rec) <= count: + out += rec + elif pos >= off: + break + pos += len(rec) + return self.frame(Rread, tag, s32(len(out)) + out) + + +class Reader: + def __init__(self, b): + self.b = b + self.i = 0 + + def bytes(self, n): + v = self.b[self.i:self.i + n] + self.i += n + return v + + def u8(self): return struct.unpack('<B', self.bytes(1))[0] + def u16(self): return struct.unpack('<H', self.bytes(2))[0] + def u32(self): return struct.unpack('<I', self.bytes(4))[0] + def u64(self): return struct.unpack('<Q', self.bytes(8))[0] + def str(self): return self.bytes(self.u16()).decode('utf-8', 'surrogateescape') + def data(self): return self.bytes(self.u32()) + + +def main(): + if len(sys.argv) != 3: + print(__doc__ + MODES) + sys.exit(2) + path, mode = sys.argv[1], sys.argv[2] + if mode not in [l.split()[0] for l in MODES.strip().splitlines()]: + print('unknown mode', mode) + sys.exit(2) + try: + os.unlink(path) + except FileNotFoundError: + pass + ls = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + ls.bind(path) + ls.listen(8) + while True: + conn, _ = ls.accept() + srv = Server(mode) + srv.conn = conn + try: + srv.serve(conn) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/9player/test/adv_bridge_hostile.sh b/9player/test/adv_bridge_hostile.sh new file mode 100755 index 0000000..f1ee292 --- /dev/null +++ b/9player/test/adv_bridge_hostile.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Hostile-server tests: 9player against 9player/test/adv_bridge_hostile.py in every +# misbehaviour mode. 9player must never crash (panic/segfault) and must turn +# each misbehaviour into an errno for the child. +# Usage: bash 9player/test/adv_bridge_hostile.sh <9player> <introspect> (introspect unused; part of zig build 9player-adv) +set -u +PLAYER=$(realpath "${1:?path to 9player}") +HERE=$(cd "$(dirname "$0")" && pwd) +SRV=$HERE/adv_bridge_hostile.py +TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-adv.XXXXXX") +M=/mnt/9p +FAILED=0 +PASSED=0 +SRVPID= + +cleanup() { [ -n "$SRVPID" ] && kill "$SRVPID" 2>/dev/null; pkill -f "adv_bridge_hostile.py $TMP" 2>/dev/null; rm -rf "$TMP"; } +trap cleanup EXIT + +if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: no user namespaces or /dev/fuse"; exit 0; fi + +pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; } +fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; } +expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; } +expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; } + +start_server() { # mode + [ -n "$SRVPID" ] && { kill "$SRVPID" 2>/dev/null; wait "$SRVPID" 2>/dev/null; } + SOCK=$TMP/$1.sock + rm -f "$SOCK" + python3 "$SRV" "$SOCK" "$1" >"$TMP/$1.srv.out" 2>&1 </dev/null & + SRVPID=$! + for _ in $(seq 1 100); do [ -S "$SOCK" ] && return 0; sleep 0.02; done + echo "server for $1 did not start"; cat "$TMP/$1.srv.out"; exit 1 +} + +# run MODE SCRIPT [extra 9player args...]: starts the server, runs 9player; sets OUT, RC, STDERR. +run() { + local mode=$1 script=$2; shift 2 + start_server "$mode" + OUT=$(timeout 30 "$PLAYER" --unix "$SOCK" "$@" -- sh -c "$script" 2>"$TMP/stderr") + RC=$? + STDERR=$(cat "$TMP/stderr") +} + +# 9player must not die of a signal or panic. RC 124 = timeout(1) fired. +no_crash() { # name + if [ "$RC" -ge 128 ] || [ "$RC" -eq 124 ]; then fail "$1: 9player exit $RC" "$STDERR"; return; fi + case "$STDERR" in *panic*|*"Segmentation"*|*"integer overflow"*|*"reached unreachable"*|*"index out of bounds"*) fail "$1: crash text in stderr" "$STDERR";; *) pass "$1: no crash (exit $RC)";; esac +} + +echo "# sanity: the hostile server behaves in 'ok' mode" +run ok "cat $M/f"; expect_eq "ok: cat f" "hello world" "$OUT" +run ok "cat $M/d/g"; expect_eq "ok: nested" "in d" "$OUT" +run ok "cat $M/nope 2>&1 | sed 's/.*: //'"; expect_eq "ok: ENOENT" "No such file or directory" "$OUT" +run ok "head -c 1048576 $M/big | wc -c | grep -q 1048576 && echo yes"; expect_eq "ok: 1 MiB read matches" "yes" "$OUT" + +echo "# unlink + recreate with a recycled qid.path" +run ok "echo 1 > $M/a; rm $M/a; echo 2 > $M/a; cat $M/a; rm $M/a"; expect_eq "qid reuse: new content, not stale" "2" "$OUT" +run ok "echo 1 > $M/x; rm $M/x; mkdir $M/x; stat -c %F $M/x; rmdir $M/x"; expect_eq "qid reuse: file→dir on the same path" "directory" "$OUT" + +echo "# fids do not grow with the number of operations" +loop='i=0; while [ $i -lt N ]; do echo hi > M/t; cat M/t >/dev/null; mkdir M/dd; rmdir M/dd; rm M/t; i=$((i+1)); done; cat M/fids' +run ok "$(echo "$loop" | sed "s|N|20|; s|M/|$M/|g")"; a=$OUT +run ok "$(echo "$loop" | sed "s|N|200|; s|M/|$M/|g")"; b=$OUT +expect_eq "fids after 20 == after 200 iterations ($a)" "$a" "$b" +floop='i=0; while [ $i -lt N ]; do cat M/nope 2>/dev/null; echo x > M/fids 2>/dev/null; mkdir M/f 2>/dev/null; rm M/d 2>/dev/null; mv M/f M/d 2>/dev/null; i=$((i+1)); done; cat M/fids' +run ok "$(echo "$floop" | sed "s|N|20|; s|M/|$M/|g")"; a=$OUT +run ok "$(echo "$floop" | sed "s|N|200|; s|M/|$M/|g")"; b=$OUT +expect_eq "fids after 20 == after 200 failing iterations ($a)" "$a" "$b" + +echo "# rename over an existing file must not lose the target when the rename fails" +run rename_fail "echo A > $M/a; echo B > $M/b; mv $M/a $M/b 2>/dev/null; echo mv=\$?; cat $M/b; cat $M/a" +expect_contains "rename_fail: mv reports failure" "mv=1" "$OUT" +expect_contains "rename_fail: target b still has its content" "B" "$OUT" +expect_contains "rename_fail: source a still has its content" "A" "$OUT" + +echo "# protocol violations on a data read must yield an error, not a crash" +for mode in trunc short_frame huge_frame wrong_tag rread_big extra_reply close_mid renegotiate rwrite_big; do + if [ "$mode" = rwrite_big ]; then script="dd if=/dev/zero of=$M/f bs=10 count=1 2>&1; echo status=\$?"; else script="cat $M/f 2>&1; echo status=\$?"; fi + run "$mode" "$script" + no_crash "$mode" + expect_contains "$mode: child sees an error" "status=1" "$OUT" + case "$OUT" in *"Input/output error"*|*"not connected"*) pass "$mode: EIO/ENOTCONN";; *) fail "$mode: errno text" "$OUT";; esac +done + +echo "# protocol violations on lookup/stat" +for mode in wrong_type rwalk_many rstat_garbage rstat_overlong; do + run "$mode" "stat -c %s $M/f 2>&1; echo status=\$?" + no_crash "$mode" + expect_contains "$mode: child sees an error" "status=1" "$OUT" +done +run rwalk_zero "cat $M/nope 2>&1; echo status=\$?; cat $M/f 2>&1" +no_crash "rwalk_zero" +expect_contains "rwalk_zero: child sees an error" "status=1" "$OUT" +run rerror_big "cat $M/nope 2>&1; echo status=\$?; cat $M/f" +no_crash "rerror_big" +expect_contains "rerror_big: child sees an error" "status=1" "$OUT" +expect_contains "rerror_big: session survives a 64 KiB Rerror" "hello world" "$OUT" + +echo "# hostile stat contents" +run length_max "stat -c '%s %b' $M/f 2>&1; echo status=\$?" +no_crash "length_max" +expect_contains "length_max: stat succeeds with a saturated block count" "status=0" "$OUT" +run iounit_one "cat $M/f; head -c 3000 $M/big | wc -c" +no_crash "iounit_one" +expect_contains "iounit_one: read still complete" "hello world" "$OUT" +expect_contains "iounit_one: 3000 bytes" "3000" "$OUT" + +echo "# hostile directory listings" +run dir_split "ls $M 2>&1; echo status=\$?; cat $M/f" +no_crash "dir_split" +expect_contains "dir_split: readdir fails with EIO" "Input/output error" "$OUT" +expect_contains "dir_split: session survives" "hello world" "$OUT" +run dir_forever "ls $M 2>&1 | tail -c 200; echo status=\$?; cat $M/f; grep VmRSS /proc/\$PPID/status" +no_crash "dir_forever" +expect_contains "dir_forever: infinite directory is cut off with EIO" "Input/output error" "$OUT" +expect_contains "dir_forever: session survives" "hello world" "$OUT" +for mode in name_slash name_empty name_huge name_dots; do + run "$mode" "ls -a $M | tr '\n' ' '; echo; cat $M/f" + no_crash "$mode" + expect_contains "$mode: listing still works" "big d f fids" "$OUT" + expect_contains "$mode: file readable" "hello world" "$OUT" + case "$mode" in + name_slash) expect_eq "$mode: slash entry dropped" "" "$(printf '%s' "$OUT" | grep -o 'a/b')";; + name_dots) expect_eq "$mode: exactly one . and one .." "1 1" "$(printf '%s %s' "$(printf '%s\n' "$OUT" | head -1 | tr ' ' '\n' | grep -c '^\.$')" "$(printf '%s\n' "$OUT" | head -1 | tr ' ' '\n' | grep -c '^\.\.$')")";; + esac +done + +echo "# qid collisions" +run qid_collide "cat $M/f; cat $M/d/g; ls $M/d; stat -c %i $M/f $M/d 2>&1; echo status=\$?" +no_crash "qid_collide" +expect_contains "qid_collide: reads work" "hello world" "$OUT" +run qid_zero "cat $M/f; ls $M | tr '\n' ' '; echo; cat $M/d/g; echo status=\$?" +no_crash "qid_zero" +expect_contains "qid_zero: file with root's qid.path is still a readable file" "hello world" "$OUT" +expect_contains "qid_zero: root still lists" "big d f fids" "$OUT" +expect_contains "qid_zero: nested file readable" "in d" "$OUT" + +echo "# version negotiation" +run version_unknown "echo ran" +expect_eq "version_unknown: 9player refuses (125)" "125" "$RC" +run msize_tiny "cat $M/f 2>&1; echo status=\$?" +no_crash "msize_tiny" + +echo "# a server that never replies" +start_server never +# SIGTERM is forwarded to the child; once the child is gone 9player must leave the +# pending 9P reply behind and exit even though the server stays silent. +timeout -s TERM 3 "$PLAYER" --unix "$SOCK" -- sh -c "cat $M/f; echo unreachable" >"$TMP/never.out" 2>"$TMP/never.err" & +TPID=$! +sleep 4 +if kill -0 "$TPID" 2>/dev/null; then + fail "never: SIGTERM did not end 9player while a reply was outstanding"; kill -9 "$TPID" +else + pass "never: SIGTERM ends 9player even while the server is silent" +fi +wait "$TPID" 2>/dev/null +# Without a signal the mount hangs (documented v1 limitation) until the server dies. +timeout 30 "$PLAYER" --unix "$SOCK" -- sh -c "cat $M/f; echo unreachable" >"$TMP/never.out" 2>"$TMP/never.err" & +TPID=$! +sleep 1.5 +if kill -0 "$TPID" 2>/dev/null; then + pass "never: mount hangs while the server is silent (documented v1 limitation)" + kill "$SRVPID"; wait "$SRVPID" 2>/dev/null; SRVPID= + for _ in $(seq 1 50); do kill -0 "$TPID" 2>/dev/null || break; sleep 0.1; done + if kill -0 "$TPID" 2>/dev/null; then fail "never: 9player still alive after its server died"; kill -9 "$TPID"; else pass "never: killing the server unblocks 9player"; fi +else + wait "$TPID"; fail "never: 9player exited early ($?)" "$(cat "$TMP/never.err")" +fi + +echo "# interrupting a slow read (INTERRUPT must not confuse reply matching)" +run slow "cat $M/big > /dev/null; cat $M/f; cat $M/fids" --msize 8192 +base=$(printf '%s\n' "$OUT" | tail -1) +run slow "(cat $M/big > /dev/null & sleep 0.3; kill -INT \$!; wait \$!) 2>/dev/null; cat $M/f; cat $M/fids" --msize 8192 +no_crash "slow" +expect_contains "slow: read after interrupted read works" "hello world" "$OUT" +expect_eq "slow: fids after an interrupted read == after a complete one ($base)" "$base" "$(printf '%s\n' "$OUT" | tail -1)" + +echo +echo "passed=$PASSED failed=$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/9player/test/adv_bridge_semantics.sh b/9player/test/adv_bridge_semantics.sh new file mode 100755 index 0000000..f13fd57 --- /dev/null +++ b/9player/test/adv_bridge_semantics.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# FUSE semantics through the bridge against introspect's /scratch tree. +# Usage: bash 9player/test/adv_bridge_semantics.sh <9player> <introspect> (part of zig build 9player-adv) +set -u +PLAYER=$(realpath "${1:?path to 9player}") +INTROSPECT=$(realpath "${2:?path to introspect}") +TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-sem.XXXXXX") +M=/mnt/9p +S=$M/scratch +FAILED=0 +PASSED=0 +SRVPID= +cleanup() { [ -n "$SRVPID" ] && kill "$SRVPID" 2>/dev/null; rm -rf "$TMP"; } +trap cleanup EXIT +if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: no user namespaces or /dev/fuse"; exit 0; fi + +pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; } +fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; } +expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; } +expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; } + +SOCK=$TMP/i.sock +"$INTROSPECT" --unix "$SOCK" >"$TMP/srv.out" 2>&1 & +SRVPID=$! +for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.02; done +# Each run is a fresh session; state persists in the server, so tests clean up after themselves. +run() { OUT=$(timeout 120 "$PLAYER" --unix "$SOCK" "${EXTRA[@]}" -- sh -c "$1" 2>"$TMP/stderr"); RC=$?; STDERR=$(cat "$TMP/stderr"); } +EXTRA=() +py() { run "python3 - <<'PYEOF' +$1 +PYEOF"; } + +echo "# open/create flags" +py " +import os, errno +p='$S/excl' +fd=os.open(p, os.O_CREAT|os.O_WRONLY, 0o644); os.write(fd, b'x'); os.close(fd) +try: + os.open(p, os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0o644); print('no error') +except OSError as e: print(errno.errorcode[e.errno]) +os.unlink(p) +fd=os.open('$S/ro', os.O_CREAT|os.O_RDONLY, 0o644); print(os.read(fd, 10)); os.close(fd) +print(os.path.exists('$S/ro')); os.unlink('$S/ro') +" +expect_eq "O_EXCL on an existing file is EEXIST" "EEXIST" "$(printf '%s\n' "$OUT" | sed -n 1p)" +expect_eq "create with O_RDONLY works and reads empty" $'b\'\'\nTrue' "$(printf '%s\n' "$OUT" | sed -n 2,3p)" + +run "mkdir -m 700 $S/m7 && stat -c %a $S/m7; chmod 755 $S/m7 && stat -c %a $S/m7; rmdir $S/m7" +expect_eq "mkdir -m 700 then chmod 755" $'700\n755' "$OUT" +run "echo x > $S/c && chmod 600 $S/c && stat -c %a $S/c; chmod 444 $S/c && stat -c %a $S/c; rm -f $S/c" +expect_eq "chmod on a file" $'600\n444' "$OUT" +run "echo x > $S/t && touch -d @1000000000 $S/t && stat -c %Y $S/t; touch $S/t && [ \$(stat -c %Y $S/t) -gt 1000000000 ] && echo now; rm $S/t" +expect_eq "utimes (explicit) and touch (now)" $'1000000000\nnow' "$OUT" +run "echo abc > $S/tr && truncate -s 10 $S/tr && stat -c %s $S/tr && od -An -c $S/tr | tr -s ' ' | tr -d '\n'; echo; rm $S/tr" +expect_eq "truncate to larger zero-fills" $'10\n a b c \\n \\0 \\0 \\0 \\0 \\0 \\0' "$OUT" +run "echo a > $S/ap && echo b >> $S/ap && echo c >> $S/ap && cat $S/ap | tr '\n' ' '; rm $S/ap" +expect_eq "shell append" "a b c " "$OUT" +py " +import os +p='$S/ap2' +f1=os.open(p, os.O_CREAT|os.O_WRONLY|os.O_APPEND, 0o644) +f2=os.open(p, os.O_WRONLY|os.O_APPEND) +os.write(f1, b'one '); os.write(f2, b'two '); os.write(f1, b'three') +os.close(f1); os.close(f2) +print(open(p).read()); os.unlink(p) +" +expect_eq "O_APPEND from two descriptors interleaves in order" "one two three" "$OUT" +run "echo 0123456789 > $S/tt && (echo X > $S/tt) && cat $S/tt && stat -c %s $S/tt; rm $S/tt" +expect_eq "O_TRUNC (atomic_o_trunc) truncates before write" $'X\n2' "$OUT" + +echo "# reads at the edges" +run "printf hello > $S/e; dd if=$S/e bs=1 skip=100 count=5 2>/dev/null | wc -c; head -c 0 $S/e | wc -c; dd if=/dev/null of=$S/e bs=1 count=0 conv=notrunc 2>/dev/null; cat $S/e; echo; rm $S/e" +expect_eq "read past EOF is 0 bytes; 0-byte read/write are no-ops" $'0\n0\nhello' "$OUT" +head -c 4194304 /dev/urandom >"$TMP/four" +SUM=$(sha256sum <"$TMP/four" | cut -d' ' -f1) +run "dd if=$TMP/four of=$S/four bs=4M status=none && dd if=$S/four bs=4M status=none | sha256sum | cut -d' ' -f1; stat -c %s $S/four; rm $S/four" +expect_eq "4 MiB single-request dd round trip" "$SUM"$'\n4194304' "$OUT" +py " +import os +p='$S/lseek' +open(p,'w').write('0123456789') +f=open(p,'rb'); f.seek(0, 2); print(f.tell()); f.seek(-3, 2); print(f.read()); f.close() +os.unlink(p) +" +expect_eq "lseek SEEK_END on a direct_io file" $'10\nb\'789\'' "$OUT" + +echo "# unlink of an open file" +py " +import os +p='$S/unl' +fd=os.open(p, os.O_CREAT|os.O_RDWR, 0o644) +os.write(fd, b'before') +os.unlink(p) +print(os.path.exists(p)) +os.lseek(fd, 0, 0); print(os.read(fd, 100)) +os.write(fd, b'-after'); os.lseek(fd, 0, 0); print(os.read(fd, 100)) +os.close(fd) +" +expect_eq "read/write through the fd after unlink" $'False\nb\'before\'\nb\'before-after\'' "$OUT" + +echo "# rename" +run "echo A > $S/ra; echo B > $S/rb; mv $S/ra $S/rb && cat $S/rb; ls $S | tr '\n' ' '; echo; rm $S/rb" +expect_eq "rename over an existing file replaces it, no leftovers" $'A\nrb ' "$OUT" +run "mkdir $S/rd1 && echo x > $S/rd1/f && mv $S/rd1 $S/rd2 && cat $S/rd2/f && ls $S/rd2; rm -r $S/rd2; ls $S | wc -l" +expect_eq "rename of a directory" $'x\nf\n0' "$OUT" +run "mkdir $S/e1 $S/e2 && mv -T $S/e1 $S/e2 && ls $S | tr '\n' ' '; rmdir $S/e2" +expect_eq "rename dir over an empty dir" "e2 " "$OUT" +run "mkdir $S/n1 $S/n2 && echo x > $S/n2/f && mv -T $S/n1 $S/n2 2>&1 | sed 's/.*: //'; rm -r $S/n1 $S/n2" +expect_eq "rename dir over a non-empty dir is ENOTEMPTY" "Directory not empty" "$OUT" +py " +import os +p='$S/same'; open(p,'w').write('x'); os.rename(p, p); print(open(p).read()); os.unlink(p) +" +expect_eq "rename onto itself is a no-op" "x" "$OUT" +py " +import os, ctypes, errno +libc = ctypes.CDLL(None, use_errno=True) +a, b = b'$S/nra', b'$S/nrb' +open(a,'w').write('A'); open(b,'w').write('B') +r = libc.renameat2(-100, a, -100, b, 1) # RENAME_NOREPLACE +print('rc', r, errno.errorcode.get(ctypes.get_errno())) +print(open(b).read()) +os.unlink(a); os.unlink(b) +" +expect_eq "RENAME_NOREPLACE keeps the target" $'rc -1 EEXIST\nB' "$OUT" + +echo "# directories" +run "mkdir $S/many && cd $S/many && i=0; while [ \$i -lt 5000 ]; do : > f\$i; i=\$((i+1)); done; ls | wc -l; ls -l | wc -l; grep VmRSS /proc/\$PPID/status | awk '{print \$2}' > $TMP/rss1; rm -f $S/many/*; rmdir $S/many; ls | wc -l; grep VmRSS /proc/\$PPID/status | awk '{print \$2}' > $TMP/rss2" +expect_eq "5000 entries: ls and ls -l" $'5000\n5001\n0' "$OUT" +r1=$(cat "$TMP/rss1"); r2=$(cat "$TMP/rss2") +if [ "$r2" -le $((r1 + 2048)) ]; then pass "RSS after cleanup ($r2 KiB) <= after listing ($r1 KiB)+2 MiB"; else fail "RSS grew after cleanup: $r1 -> $r2 KiB"; fi +py " +import os +d='$S/chg'; os.mkdir(d) +for i in range(50): open(f'{d}/a{i}','w').close() +it = os.scandir(d); first = next(it).name +for i in range(3000): open(f'{d}/b{i}','w').close() +rest = [e.name for e in it] +print(first[0], len(rest) >= 49, len(set(rest)) == len(rest)) +for n in os.listdir(d): os.unlink(f'{d}/{n}') +os.rmdir(d) +" +expect_eq "readdir of a directory that changes mid-iteration" "a True True" "$OUT" +run "ls $M/.. > /dev/null && echo ok; stat -c %i $M $M/. $M/scratch/..; cd $M/scratch && ls .. | grep -c scratch" +expect_eq ".. of the root and of a subdir" $'ok\n1\n1\n1\n1' "$OUT" +run "cd $M && find . -type d | wc -l && find . -type f | head -1 && find $S -type f | wc -l" +expect_contains "find -type works" "./README" "$OUT" +run "stat -f -c '%T %S %l' $M; df -P $M | tail -1 | awk '{print \$1}'; sync -f $M && echo synced; sync && echo synced2" +expect_eq "statfs, df, syncfs, sync" $'fuse 4096 255\n9player\nsynced\nsynced2' "$OUT" + +echo "# server refusals keep their errno through the error path" +run "echo x > $M/build/zig_version; a=\$?; mkdir $M/build/x 2>/dev/null; b=\$?; rm $M/README 2>/dev/null; c=\$?; rmdir $M/build 2>/dev/null; d=\$?; echo \$a\$b\$c\$d" 2>/dev/null +expect_eq "open-for-write / mkdir / rm / rmdir on read-only nodes all fail (errno preserved through error path)" "1111" "$(printf '%s\n' "$OUT" | tail -1)" + +echo "# unsupported operations fail cleanly" +run "echo x > $S/l1; ln $S/l1 $S/l2 2>&1 | sed 's/.*: //'; ln -s l1 $S/l3 2>&1 | sed 's/.*: //'; mkfifo $S/p 2>&1 | sed 's/.*: //'; ls $S | tr '\n' ' '; echo; rm $S/l1" +# The kernel turns ENOSYS from LINK into EPERM (fuse_link); symlink/mknod keep ENOSYS. +expect_eq "link/symlink/mknod fail cleanly" $'Operation not permitted\nFunction not implemented\nFunction not implemented\nl1 ' "$OUT" +run "echo x > $S/x1; setfattr -n user.a -v 1 $S/x1 2>&1 | sed 's/.*: //'; getfattr -n user.a $S/x1 2>&1 | sed 's/.*: //'; getfattr -d $S/x1 2>&1 | sed 's/.*: //'; rm $S/x1" +expect_eq "xattr ops are EOPNOTSUPP" $'Operation not supported\nOperation not supported\nOperation not supported' "$OUT" +py " +import os, fcntl, mmap, errno +p='$S/mm'; open(p,'w').write('mapme') +fd=os.open(p, os.O_RDWR) +fcntl.flock(fd, fcntl.LOCK_EX); fcntl.flock(fd, fcntl.LOCK_UN); fcntl.lockf(fd, fcntl.LOCK_EX); fcntl.lockf(fd, fcntl.LOCK_UN); print('locks ok') +try: + m = mmap.mmap(fd, 5); print('shared', bytes(m)); m.close() +except OSError as e: print('shared', errno.errorcode[e.errno]) +try: + m = mmap.mmap(fd, 5, flags=mmap.MAP_PRIVATE, prot=mmap.PROT_READ); print('private', bytes(m)); m.close() +except OSError as e: print('private', errno.errorcode[e.errno]) +os.close(fd); os.unlink(p) +" +expect_contains "flock/lockf work (local locks)" "locks ok" "$OUT" +case "$OUT" in *"shared ENODEV"*|*"shared b'mapme'"*) pass "shared mmap: clean result ($(printf '%s\n' "$OUT" | sed -n 2p))";; *) fail "shared mmap" "$OUT";; esac +expect_contains "private mmap reads the file" "private b'mapme'" "$OUT" + +echo "# tools" +mkdir -p "$TMP/tree/sub/deeper"; echo one > "$TMP/tree/a"; echo two > "$TMP/tree/sub/b"; head -c 70000 /dev/urandom > "$TMP/tree/sub/deeper/blob"; chmod 640 "$TMP/tree/a" +run "cp -a $TMP/tree $S/tree 2>&1; diff -r $TMP/tree $S/tree && echo same; stat -c %a $S/tree/a; cp -a $S/tree $TMP/back && diff -r $TMP/tree $TMP/back && echo back; rm -r $S/tree" +expect_eq "cp -a there and back" $'same\n640\nback' "$OUT" +run "cd $TMP && tar cf $S/t.tar tree && cd $S && mkdir tx && tar xf t.tar -C tx && diff -r $TMP/tree tx/tree && echo tar-ok; rm -r $S/tx $S/t.tar" +expect_eq "tar into and out of the mount" "tar-ok" "$OUT" +run "rsync -a $TMP/tree/ $S/rs/ && diff -r $TMP/tree $S/rs && echo rsync-ok; sleep 1.1; echo mod > $TMP/tree/a; rsync -a $TMP/tree/ $S/rs/ && cat $S/rs/a; rm -r $S/rs" +expect_eq "rsync -a twice" $'rsync-ok\nmod' "$OUT" +run "cd $S && mkdir repo && cd repo && git init -q . && git config user.email a@b && git config user.name n && echo hi > f && git add f && git commit -qm init && git log --oneline | wc -l && git status --porcelain | wc -l; cd $S && rm -rf repo; ls $S | wc -l" +expect_eq "git init/add/commit inside the mount" $'1\n0\n0' "$OUT" + +echo "# --no-direct-io" +EXTRA=(--no-direct-io) +run "cp $TMP/four $S/nd && cmp $TMP/four $S/nd && echo same; stat -c %s $S/nd; rm $S/nd" +expect_eq "no-direct-io: 4 MiB round trip" $'same\n4194304' "$OUT" +[ "$RC" -eq 0 ] || echo " stderr: $STDERR" +# Buffered writes are per-page without a writeback cache (kernel behaviour); the +# point here is only that a large buffered write is delivered intact. +EXTRA=(--no-direct-io) +head -c 262144 /dev/urandom > "$TMP/w" +WSUM=$(sha256sum <"$TMP/w" | cut -d' ' -f1) +run "cp $TMP/w $S/w && sha256sum < $S/w | cut -d' ' -f1; stat -c %s $S/w; rm $S/w" +expect_eq "no-direct-io: 256 KiB buffered write is intact" "$WSUM"$'\n262144' "$OUT" +EXTRA=() + +echo +echo "passed=$PASSED failed=$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/9player/test/adv_bridge_stress.sh b/9player/test/adv_bridge_stress.sh new file mode 100755 index 0000000..3d1203a --- /dev/null +++ b/9player/test/adv_bridge_stress.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Resource and concurrency stress through the bridge against introspect. +# Usage: bash 9player/test/adv_bridge_stress.sh <9player> <introspect> (~1-2 min; part of zig build 9player-adv) +set -u +PLAYER=$(realpath "${1:?path to 9player}") +INTROSPECT=$(realpath "${2:?path to introspect}") +TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-stress.XXXXXX") +M=/mnt/9p +S=$M/scratch +FAILED=0 +PASSED=0 +SRVPID= +cleanup() { [ -n "$SRVPID" ] && kill "$SRVPID" 2>/dev/null; rm -rf "$TMP"; } +trap cleanup EXIT +if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: no user namespaces or /dev/fuse"; exit 0; fi +pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; } +fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; } +expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; } + +SOCK=$TMP/i.sock +"$INTROSPECT" --unix "$SOCK" >"$TMP/srv.out" 2>&1 & +SRVPID=$! +for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.02; done +run() { OUT=$(timeout 600 "$PLAYER" --unix "$SOCK" -- sh -c "$1" 2>"$TMP/stderr"); RC=$?; STDERR=$(cat "$TMP/stderr"); } + +echo "# 100k+ 9P operations in one session; RSS must plateau" +# Each iteration: create+write+close, open+read+close, unlink, plus a failing lookup: ~15 RPCs. +run "rss() { grep VmRSS /proc/\$PPID/status | awk '{print \$2}'; } +i=0; while [ \$i -lt 8000 ]; do echo \$i > $S/s; cat $S/s > /dev/null; rm $S/s; cat $S/none 2>/dev/null; i=\$((i+1)); if [ \$i -eq 2000 ]; then rss; fi; done; rss; ls $S | wc -l" +r1=$(printf '%s\n' "$OUT" | sed -n 1p); r2=$(printf '%s\n' "$OUT" | sed -n 2p); left=$(printf '%s\n' "$OUT" | sed -n 3p) +expect_eq "scratch left clean" "0" "$left" +if [ -n "$r1" ] && [ -n "$r2" ] && [ "$r2" -le $((r1 + 4096)) ]; then pass "RSS at 2000 iterations = $r1 KiB, at 8000 = $r2 KiB"; else fail "RSS grows: $r1 -> $r2 KiB" "$STDERR"; fi +case "$STDERR" in *leak*) fail "allocator reported leaks" "$STDERR";; *) pass "no leak report from the debug allocator";; esac + +echo "# eight processes hammering the mount concurrently" +run "mkdir $S/par; for p in 1 2 3 4 5 6 7 8; do ( + d=$S/par/p\$p; mkdir \$d; i=0; bad=0 + while [ \$i -lt 300 ]; do + printf '%s-%s' \$p \$i > \$d/f\$((i % 7)); v=\$(cat \$d/f\$((i % 7))); [ \"\$v\" = \"\$p-\$i\" ] || bad=\$((bad+1)) + mkdir \$d/dd; rmdir \$d/dd; ls \$d > /dev/null; i=\$((i+1)) + done; rm -r \$d; echo \$p:\$bad ) & done; wait; ls $S/par | wc -l; rmdir $S/par" +expect_eq "all workers verified their own data" "1:0 2:0 3:0 4:0 5:0 6:0 7:0 8:0" "$(printf '%s\n' "$OUT" | grep ':' | sort | tr '\n' ' ' | sed 's/ $//')" +expect_eq "parallel tree fully removed" "0" "$(printf '%s\n' "$OUT" | grep -v ':')" + +echo "# a process killed mid-read and mid-write" +run "head -c 8000000 /dev/urandom > $S/kb; (cat $S/kb > /dev/null & sleep 0.05; kill -9 \$!; wait \$!) 2>/dev/null; (cat /dev/zero > $S/kw & sleep 0.05; kill -9 \$!; wait \$!) 2>/dev/null; sha256sum < $S/kb | cut -c1-8 > /dev/null && echo readable; [ -f $S/kw ] && echo written; rm $S/kb $S/kw; ls $S | wc -l" +expect_eq "survives SIGKILL mid-read/mid-write" $'readable\nwritten\n0' "$OUT" + +echo "# many open handles at once (fh counter, fid table)" +run "python3 - <<'EOF' +import os +d='$S/fh'; os.mkdir(d) +fds=[] +for i in range(1500): + fd=os.open(f'{d}/h{i%50}', os.O_CREAT|os.O_RDWR, 0o644); os.write(fd, b'z'); fds.append(fd) +for fd in fds: os.close(fd) +for i in range(50): os.unlink(f'{d}/h{i}') +os.rmdir(d); print('ok') +EOF" +expect_eq "1500 simultaneous handles" "ok" "$OUT" + +echo +echo "passed=$PASSED failed=$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/9player/test/adv_ns_process.sh b/9player/test/adv_ns_process.sh new file mode 100755 index 0000000..4ce0ae8 --- /dev/null +++ b/9player/test/adv_ns_process.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Adversarial regression tests for 9player/src/ns.zig and 9player/src/main.zig: process, +# namespace, signal and CLI handling. Real namespaces, real FUSE. +# Usage: bash 9player/test/adv_ns_process.sh <9player> <introspect> (part of zig build 9player-adv) +# Exit 0 on success (or when the machine cannot run the tests), 1 on failure. +set -u + +PLAYER=$(realpath "${1:?path to 9player}") +INTROSPECT=$(realpath "${2:?path to introspect}") +# Unix socket paths are limited to ~107 bytes; keep the temp dir short. +TMP=$(mktemp -d "${TMPDIR:-/tmp}/9padv.XXXXXX") +PIDS=() +FAILED=0 +PASSED=0 + +cleanup() { + for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done + rm -rf "$TMP" +} +trap cleanup EXIT + +if ! unshare -Urm true 2>/dev/null; then echo "SKIP: unprivileged user namespaces unavailable"; exit 0; fi +if [ ! -c /dev/fuse ]; then echo "SKIP: /dev/fuse missing"; exit 0; fi + +pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; } +fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; } +expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi; } +expect_contains() { case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac; } + +SOCK=$TMP/s +"$INTROSPECT" --unix "$SOCK" & +PIDS+=($!) +for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.05; done +[ -S "$SOCK" ] || { echo "introspect did not create $SOCK"; exit 1; } +MI_BEFORE=$(grep -v " $TMP" /proc/self/mountinfo | sort) + +TIMEOUT=$(command -v timeout) +run() { "$TIMEOUT" 60 "$PLAYER" --unix "$SOCK" "$@"; } + +echo "# CLI" +expect_eq "--help goes to stdout, exit 0" "Usage: 9player" "$(run --help 2>/dev/null | head -1 | cut -c1-14; )" +expect_eq "--help exit code" "0" "$("$PLAYER" --help >/dev/null 2>&1; echo $?)" +expect_eq "--version on stdout" "9player" "$("$PLAYER" --version 2>/dev/null | cut -d' ' -f1)" +expect_eq "single-dash typo is a usage error, not a program" "125" "$(run -mount /x -- true 2>/dev/null; echo $?)" +expect_contains "single-dash typo message" "unknown option -mount" "$(run -mount /x -- true 2>&1)" +expect_eq "--unix= empty is a usage error" "125" "$("$PLAYER" --unix= -- true 2>/dev/null; echo $?)" +expect_contains "--unix= message" "socket path" "$("$PLAYER" --unix= -- true 2>&1)" +expect_eq "--mount '' is a usage error" "125" "$(run --mount '' -- true 2>/dev/null; echo $?)" +expect_eq "--msize huge rejected" "125" "$(run --msize 4294967295 -- true 2>/dev/null; echo $?)" +expect_eq "--msize 16 MiB accepted" "ok" "$(run --msize 16777216 -- sh -c 'echo ok')" +expect_contains "empty program name is reported" "empty program name" "$(run -- '' 2>&1)" +expect_eq "empty program name exit" "125" "$(run -- '' 2>/dev/null; echo $?)" +expect_eq "empty \$SHELL falls back to /bin/sh" "0" "$(SHELL= run -- </dev/null >/dev/null 2>&1; echo $?)" +expect_eq "--fd with a closed descriptor fails early" "125" "$("$PLAYER" --fd 987 -- true 2>/dev/null; echo $?)" +expect_contains "--fd bad descriptor message" "--fd 987: EBADF" "$("$PLAYER" --fd 987 -- true 2>&1)" + +echo "# exec failures" +expect_eq "not found is 127" "127" "$(run -- no-such-program-9player 2>/dev/null; echo $?)" +expect_eq "PATH element that is a file: still 127" "127" "$(PATH=/etc/passwd run -- true 2>/dev/null; echo $?)" +expect_contains "PATH element that is a file: message" "exec true: E" "$(PATH=/etc/passwd run -- true 2>&1)" +printf '#!/bin/sh\necho no\n' >"$TMP/nx"; chmod 644 "$TMP/nx" +expect_eq "non-executable is 126" "126" "$(run -- "$TMP/nx" 2>/dev/null; echo $?)" +mkdir -p "$TMP/p1" "$TMP/p2"; cp "$TMP/nx" "$TMP/p1/prog"; printf '#!/bin/sh\necho right\n' >"$TMP/p2/prog"; chmod 755 "$TMP/p2/prog" +expect_eq "non-executable first in PATH, executable later" "right" "$(PATH=$TMP/p1:$TMP/p2 run -- prog)" +expect_eq "non-executable only in PATH is 126" "126" "$(PATH=$TMP/p1 run -- prog 2>/dev/null; echo $?)" +expect_eq "argv[0] preserved" "sh" "$(run -- sh -c 'echo $0')" +expect_eq "PATH unset uses default" "ok" "$(env -u PATH "$PLAYER" --unix "$SOCK" -- sh -c 'echo ok')" + +echo "# fd hygiene" +# 9player passes inherited descriptors through untouched, so compare with what a +# plain child of this script sees (the runner may itself hold extra fds). +FD_LIST='ls /proc/self/fd | grep -v "^3$" | sort -n | tr "\n" " " | sed "s/ $//"' +FD_BASE=$(sh -c "$FD_LIST") +expect_eq "no extra fds in the program (unix)" "$FD_BASE" "$(run -- sh -c "$FD_LIST")" +expect_eq "no extra fds in the program (spawn)" "$FD_BASE" "$("$TIMEOUT" 60 "$PLAYER" --spawn "$INTROSPECT --stdio" -- sh -c "$FD_LIST")" +expect_eq "--fd transport does not leak into the program" "0 1 2" "$(python3 - "$PLAYER" "$SOCK" <<'EOF' +import socket, subprocess, sys, os +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect(sys.argv[2]) +r = subprocess.run([sys.argv[1], "--fd", str(s.fileno()), "--", "sh", "-c", + 'ls /proc/self/fd | grep -v "^3$" | sort -n | tr "\n" " " | sed "s/ $//"'], + pass_fds=[s.fileno()], capture_output=True, text=True) +print(r.stdout.strip()) +EOF +)" + +echo "# signals" +expect_eq "SIGTERM forwarded" "143" "$(run -- sh -c 'kill -TERM $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)" +expect_eq "SIGHUP forwarded" "129" "$(run -- sh -c 'kill -HUP $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)" +expect_eq "SIGINT to 9player is ignored while the child lives" "still-here" "$(run -- sh -c 'kill -INT $PPID; sleep 0.3; echo still-here')" +# Ctrl-C from the tty must not kill the --spawn server (same process group). +expect_eq "Ctrl-C on the tty leaves the --spawn server alive" "ok" "$(timeout 30 python3 - "$PLAYER" "$INTROSPECT" <<'EOF' +import os, pty, sys, time, select +P, I = sys.argv[1], sys.argv[2] +prog = ["python3", "-c", """ +import os, signal, sys, time +signal.signal(signal.SIGINT, lambda *a: None) +m = os.environ['NINEPLAYER_MOUNT'] +open(m + '/build/optimize').read() +sys.stdin.readline() +try: + open(m + '/build/optimize').read(); print('ok', flush=True) +except Exception as e: + print('mount dead:', e, flush=True) +"""] +pid, fd = pty.fork() +if pid == 0: + os.execv(P, [P, "--spawn", I + " --stdio", "--"] + prog) +out = b"" +def rd(t): + global out + end = time.time() + t + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.1) + if r: + try: d = os.read(fd, 4096) + except OSError: return + if not d: return + out += d +rd(1.5); os.write(fd, b"\x03"); rd(0.7); os.write(fd, b"\n"); rd(3) +os.waitpid(pid, 0) +print(out.decode(errors="replace").replace("^C", "").strip().splitlines()[-1] if out.strip() else "no output") +EOF +)" +# The --spawn server dying mid-session is reaped (no zombie) and does not end the session. +OUT=$("$TIMEOUT" 60 "$PLAYER" --spawn "$INTROSPECT --stdio" -- sh -c 'srv=$(cat $NINEPLAYER_MOUNT/runtime/pid); kill -TERM $srv; sleep 0.5; st=$(ps -o stat= -p $srv 2>/dev/null); echo "${st:-gone}"; exit 5' 2>/dev/null); RC=$? +expect_eq "server death mid-session: exit status still the child's, server reaped (no zombie)" "5 gone" "$RC $OUT" +# A server that never answers: once the child is dead, SIGTERM must end 9player. +cat >"$TMP/hang.py" <<'EOF' +import struct, os, sys, time +def rd(n): + b = b"" + while len(b) < n: + c = os.read(0, n - len(b)) + if not c: sys.exit(0) + b += c + return b +while True: + size, = struct.unpack("<I", rd(4)); body = rd(size - 4) + typ, tag = struct.unpack("<BH", body[:3]) + if typ == 100: + msize, = struct.unpack("<I", body[3:7]); v = b"9P2000" + r = struct.pack("<BHI", 101, tag, msize) + struct.pack("<H", len(v)) + v + elif typ == 104: + r = struct.pack("<BH", 105, tag) + bytes([0x80]) + struct.pack("<IQ", 0, 0) + else: + time.sleep(3600) + os.write(1, struct.pack("<I", 4 + len(r)) + r) +EOF +"$PLAYER" --spawn "python3 $TMP/hang.py" -- true 2>/dev/null & +HP=$! +sleep 1; kill -TERM $HP +START=$(date +%s) +for _ in $(seq 1 100); do kill -0 $HP 2>/dev/null || break; sleep 0.1; done +if kill -0 $HP 2>/dev/null; then kill -KILL $HP; RC=hung; else wait $HP; RC=$?; fi +expect_eq "hung server: one SIGTERM ends 9player once the child is dead (watchdog)" "143" "$RC" +expect_eq "hung server: exit was prompt" "yes" "$([ $(( $(date +%s) - START )) -lt 8 ] && echo yes)" +pkill -f "$TMP/hang.py" 2>/dev/null + +echo "# child/parent protocol" +if command -v strace >/dev/null 2>&1 && strace -qq -e trace=none true 2>/dev/null; then + expect_eq "child killed before handoff" "125" "$(strace -f -qq -e trace=unshare -e inject=unshare:signal=KILL -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>/dev/null; echo $?)" + expect_contains "child killed before handoff: message" "child exited before reporting" "$(strace -f -qq -e trace=unshare -e inject=unshare:signal=KILL -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>&1)" + expect_eq "status handoff fails" "125" "$(strace -f -qq -e trace=sendmsg -e inject=sendmsg:error=EPIPE -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- true 2>/dev/null; echo $?)" + # recvmsg skipped (returns 1 without the fd): the child must be killed, not exec'd onto a dead mount. + OUT=$(strace -f -qq -e trace=recvmsg -e inject=recvmsg:retval=1:when=1 -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" -- sh -c 'echo child-ran' 2>&1; echo "rc=$?") + expect_contains "truncated fd handoff: child not exec'd" "rc=125" "$OUT" + expect_eq "truncated fd handoff: program never ran" "no" "$(case "$OUT" in *child-ran*) echo yes;; *) echo no;; esac)" + expect_contains "fuse mount failure is reported" "mount fuse: EPERM" "$(strace -f -qq -e trace=mount -e inject=mount:error=EPERM:when=2 -o /dev/null timeout 20 "$PLAYER" --unix "$SOCK" --mount "$TMP/mp" -- true 2>&1)" +else + echo "skip - strace unavailable (child failure injection)" +fi +expect_contains "fork failure is reported" "fork: E" "$(python3 -c " +import resource, os +resource.setrlimit(resource.RLIMIT_NPROC, (1, 1)) +os.execv('$PLAYER', ['$PLAYER', '--unix', '$SOCK', '--', 'true'])" 2>&1)" + +echo "# mountpoint policy" +ln -s /nonexistent "$TMP/dangling" +expect_contains "dangling symlink mountpoint" "dangling symlink" "$(run --mount "$TMP/dangling" -- true 2>&1)" +expect_eq "refuse to shadow / via /proc/self/root" "125" "$(run --mount /proc/self/root/x9p -- true 2>/dev/null; echo $?)" +expect_contains "refuse to shadow / via /proc/self/root: message" "refusing to shadow /" "$(run --mount /proc/self/root/x9p -- true 2>&1)" +expect_eq "refuse to shadow under /proc" "125" "$(run --mount /proc/self/fd/x9p -- true 2>/dev/null; echo $?)" +if [ "$(ls -A /usr/lib | wc -l)" -gt 4096 ]; then + expect_contains "parent with >4096 entries refused" "more than 4096 entries" "$(run --mount /usr/lib/x9p -- true 2>&1)" +else + echo "skip - no root-owned directory with >4096 entries" +fi +expect_eq "shadowed /run keeps its entries" "$(ls -A /run | sort | tr '\n' ' ')" "$(run --mount /run/x9p -- sh -c 'ls -A /run | grep -v "^x9p$" | sort | tr "\n" " "')" +expect_eq "mountpoint with spaces" "ok" "$(mkdir -p "$TMP/with space" && run --mount "$TMP/with space" -- sh -c '[ -f "$NINEPLAYER_MOUNT/README" ] && echo ok')" +expect_eq "mountpoint is a file" "125" "$(run --mount "$TMP/nx" -- true 2>/dev/null; echo $?)" + +echo "# leaks" +for i in $(seq 1 30); do run -- sh -c 'cat $NINEPLAYER_MOUNT/build/optimize >/dev/null' 2>/dev/null; done +BG=(); for i in $(seq 1 10); do ( run -- sh -c 'cat $NINEPLAYER_MOUNT/build/optimize >/dev/null' 2>/dev/null ) & BG+=($!); done; wait "${BG[@]}" # not a bare wait: that would also wait for the server +sleep 0.3 +expect_eq "no stray 9player processes" "" "$(pgrep -f "^$PLAYER " | tr '\n' ' ')" +expect_eq "no stray --stdio servers" "" "$(pgrep -f "$INTROSPECT --stdio" | tr '\n' ' ')" +expect_eq "host mount table untouched" "same" "$([ "$MI_BEFORE" = "$(grep -v " $TMP" /proc/self/mountinfo | sort)" ] && echo same || echo changed)" + +echo +echo "passed=$PASSED failed=$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/9player/test/adversarial.sh b/9player/test/adversarial.sh new file mode 100755 index 0000000..857af14 --- /dev/null +++ b/9player/test/adversarial.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Runs every 9player adversarial suite in sequence (hostile servers, FUSE +# semantics, process/namespace edge cases, stress). The suites aimed at the +# introspect server itself live in introspect/test (zig build introspect-adv). +# Usage: bash 9player/test/adversarial.sh <9player> <introspect> (zig build 9player-adv) +set -u +PLAYER=${1:?path to 9player} +INTROSPECT=${2:?path to introspect} +HERE=$(cd "$(dirname "$0")" && pwd) +status=0 +for suite in adv_ns_process adv_bridge_hostile adv_bridge_semantics adv_bridge_stress; do + echo "### $suite" + if bash "$HERE/$suite.sh" "$PLAYER" "$INTROSPECT"; then echo "### $suite: ok"; else echo "### $suite: FAILED"; status=1; fi +done +exit $status diff --git a/9player/test/integration.sh b/9player/test/integration.sh new file mode 100755 index 0000000..e12cb5f --- /dev/null +++ b/9player/test/integration.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Integration tests for 9player: real user+mount namespaces, real FUSE, real 9P servers. +# Usage: bash 9player/test/integration.sh <9player> <introspect> (zig build 9player-itest) +# Exit 0 on success (or when the machine cannot run the tests), 1 on failure. +set -u + +PLAYER=$(realpath "${1:?path to 9player}") +INTROSPECT=$(realpath "${2:?path to introspect}") +TMP=$(mktemp -d "${TMPDIR:-/tmp}/9player-itest.XXXXXX") +PIDS=() +FAILED=0 +PASSED=0 +M=/mnt/9p + +cleanup() { + for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done + rm -rf "$TMP" +} +trap cleanup EXIT + +if ! unshare -Urm true 2>/dev/null; then + echo "SKIP: unprivileged user namespaces unavailable"; exit 0 +fi +if [ ! -c /dev/fuse ]; then + echo "SKIP: /dev/fuse missing"; exit 0 +fi + +pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; } +fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; } +expect_eq() { # name expected actual + if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi +} +expect_contains() { # name needle haystack + case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac +} + +wait_socket() { # path + for _ in $(seq 1 100); do [ -S "$1" ] && return 0; sleep 0.05; done + return 1 +} + +# run_in "<shell script>" — run inside a namespace with the current transport ($TRANSPORT array). +run_in() { timeout 60 "$PLAYER" "${TRANSPORT[@]}" -- sh -c "$1" 2>"$TMP/stderr"; } + +# --- scratch battery: works against any writable 9P tree rooted at $1 (relative to mount) --- +scratch_battery() { # label scratchdir + local label=$1 S=$2 + + expect_eq "$label: create+append+read" $'hello\nworld' "$(run_in "echo hello > $M/$S/a && echo world >> $M/$S/a && cat $M/$S/a")" + expect_eq "$label: overwrite" "x" "$(run_in "echo x > $M/$S/a && cat $M/$S/a")" + expect_eq "$label: stat size after overwrite" "2" "$(run_in "stat -c %s $M/$S/a")" + expect_eq "$label: truncate" "0" "$(run_in "truncate -s 0 $M/$S/a && stat -c %s $M/$S/a")" + expect_eq "$label: truncate extend" "10" "$(run_in "truncate -s 10 $M/$S/a && stat -c %s $M/$S/a")" + expect_eq "$label: mkdir -p nested" "directory" "$(run_in "mkdir -p $M/$S/d1/d2/d3 && stat -c %F $M/$S/d1/d2/d3")" + expect_eq "$label: rename within dir" "moved" "$(run_in "echo moved > $M/$S/d1/d2/f && mv $M/$S/d1/d2/f $M/$S/d1/d2/g && cat $M/$S/d1/d2/g")" + # mv(1) silently falls back to copy+delete on EXDEV, so probe rename(2) directly. + expect_contains "$label: rename across dirs is EXDEV" "EXDEV" "$(run_in "python3 -c 'import os,errno +try: os.rename(\"$M/$S/d1/d2/g\", \"$M/$S/d1/g\") +except OSError as e: print(errno.errorcode[e.errno]) +'")" + expect_eq "$label: rm file" "gone" "$(run_in "rm $M/$S/d1/d2/g && [ ! -e $M/$S/d1/d2/g ] && echo gone")" + expect_eq "$label: rmdir non-empty fails" "1" "$(run_in "rmdir $M/$S/d1 2>/dev/null; echo \$?")" + expect_eq "$label: rmdir chain" "ok" "$(run_in "rmdir $M/$S/d1/d2/d3 $M/$S/d1/d2 $M/$S/d1 && echo ok")" + expect_eq "$label: ENOENT" "1" "$(run_in "cat $M/$S/nope 2>/dev/null; echo \$?")" + expect_eq "$label: ENOENT errno text" "No such file or directory" "$(run_in "cat $M/$S/nope 2>&1 | sed 's/.*: //'")" + + head -c 1048576 /dev/urandom >"$TMP/rand" + local sum; sum=$(sha256sum <"$TMP/rand" | cut -d' ' -f1) + expect_eq "$label: 1 MiB round trip (cp)" "$sum" "$(run_in "cp $TMP/rand $M/$S/big && sha256sum < $M/$S/big | cut -d' ' -f1")" + expect_eq "$label: 1 MiB size" "1048576" "$(run_in "stat -c %s $M/$S/big")" + expect_eq "$label: odd block sizes (dd bs=1000)" "$sum" "$(run_in "dd if=$M/$S/big of=$M/$S/big2 bs=1000 status=none && sha256sum < $M/$S/big2 | cut -d' ' -f1")" + expect_eq "$label: partial read at offset" "$(tail -c 12345 "$TMP/rand" | sha256sum | cut -d' ' -f1)" "$(run_in "tail -c 12345 $M/$S/big | sha256sum | cut -d' ' -f1")" + expect_eq "$label: many small files" "200" "$(run_in "mkdir $M/$S/many && for i in \$(seq 1 200); do echo \$i > $M/$S/many/f\$i; done; ls $M/$S/many | wc -l")" + expect_eq "$label: find count" "201" "$(run_in "find $M/$S/many | wc -l")" + expect_eq "$label: readdir contents" "f1 f100 f200" "$(run_in "cd $M/$S/many && ls f1 f100 f200 | tr '\n' ' ' | sed 's/ \$//'")" + expect_eq "$label: cleanup many" "0" "$(run_in "rm -r $M/$S/many $M/$S/big $M/$S/big2 $M/$S/a; ls $M/$S | wc -l")" +} + +# ============================================================================ +echo "# introspect over a Unix socket" +SOCK=$TMP/introspect.sock +"$INTROSPECT" --unix "$SOCK" & +PIDS+=($!) +wait_socket "$SOCK" || { echo "introspect did not create $SOCK"; exit 1; } +TRANSPORT=(--unix "$SOCK") + +expect_eq "zig_version" "$(zig version)" "$(run_in "cat $M/build/zig_version")" +expect_eq "mount exported" "$M" "$(run_in 'echo $NINEPLAYER_MOUNT')" +expect_contains "root listing" "build" "$(run_in "ls $M")" +expect_contains "root listing has scratch" "scratch" "$(run_in "ls $M")" +expect_eq "README size > 0" "yes" "$(run_in "[ \$(stat -c %s $M/README) -gt 0 ] && echo yes")" +expect_eq "README readable" "yes" "$(run_in "[ -s $M/README ] && head -c 1 $M/README >/dev/null && echo yes")" +expect_eq "fn/now numeric" "num" "$(run_in "cat $M/runtime/fn/now | grep -Eq '^[0-9]+\$' && echo num")" +expect_eq "fn listing from comptime" "yes" "$(run_in "ls $M/runtime/fn | grep -q hostname && echo yes")" +expect_eq "ctl round trip" "5" "$(run_in "echo 'add 2 3' > $M/runtime/ctl && cat $M/runtime/ctl")" +expect_eq "ctl echo" "hi there" "$(run_in "echo 'echo hi there' > $M/runtime/ctl && cat $M/runtime/ctl")" +expect_contains "comptime types" "Qid" "$(run_in "ls $M/comptime/types")" +expect_eq "comptime size of Qid" "16" "$(run_in "cat $M/comptime/types/Qid/size")" +expect_eq "runtime pid is server pid" "${PIDS[-1]}" "$(run_in "cat $M/runtime/pid")" +expect_eq "exit status propagates" "7" "$(run_in 'exit 7'; echo $?)" +expect_eq "mount is fuse" "yes" "$(run_in "grep -q \"^9player $M fuse\" /proc/mounts && echo yes")" +expect_eq "host /mnt entries still visible" "$(ls -A /mnt | sort | tr '\n' ' ')" "$(run_in "ls -A /mnt | grep -v '^9p\$' | sort | tr '\n' ' '")" +expect_eq "host mount table untouched" "no" "$(grep -q " $M " /proc/self/mountinfo && echo yes || echo no)" +scratch_battery "introspect" scratch + +echo "# nested 9player" +expect_eq "nested mount" "$(zig version)" "$(run_in "$PLAYER --unix $SOCK --mount $TMP/inner -- sh -c 'cat \$NINEPLAYER_MOUNT/build/zig_version'")" + +echo "# --mount variants" +mkdir -p "$TMP/mnt" +expect_eq "--mount existing dir" "ok" "$(timeout 60 "$PLAYER" --unix "$SOCK" --mount "$TMP/mnt" -- sh -c "[ -f $TMP/mnt/README ] && echo ok")" +expect_eq "--mount relative" "ok" "$(cd "$TMP" && timeout 60 "$PLAYER" --unix "$SOCK" --mount rel -- sh -c "[ -f $TMP/rel/README ] && echo ok")" +expect_eq "--mount missing under /" "125" "$(timeout 60 "$PLAYER" --unix "$SOCK" --mount /nonexistent-9player-dir -- true 2>/dev/null; echo $?)" + +echo "# lifecycle" +START=$(date +%s) +expect_eq "background grandchild does not block exit" "3" "$(run_in 'sleep 30 >/dev/null 2>&1 & exit 3'; echo $?)" +expect_eq "exit was prompt" "yes" "$([ $(( $(date +%s) - START )) -lt 10 ] && echo yes)" +expect_eq "SIGTERM forwarded" "143" "$(timeout 60 "$PLAYER" --unix "$SOCK" -- sh -c 'kill -TERM $PPID; sleep 5; echo alive' >/dev/null 2>&1; echo $?)" + +echo "# --spawn transport" +TRANSPORT=(--spawn "$INTROSPECT --stdio") +expect_eq "spawn: zig_version" "$(zig version)" "$(run_in "cat $M/build/zig_version")" +expect_eq "spawn: ctl" "7" "$(run_in "echo 'add 3 4' > $M/runtime/ctl && cat $M/runtime/ctl")" +expect_eq "spawn: stateful sequence in one session" 'hello world 12 moved 0' "$(run_in "cd $M/scratch && echo hello > a && echo world >> a && cat a && stat -c %s a && mkdir d && echo moved > d/f && mv d/f d/g && cat d/g && rm d/g && rmdir d && rm a && ls | wc -l" | tr '\n' ' ' | sed 's/ $//')" + +echo "# --tcp transport" +PORT=$(( 20000 + RANDOM % 20000 )) +"$INTROSPECT" --tcp "127.0.0.1:$PORT" & +PIDS+=($!) +sleep 0.3 +TRANSPORT=(--tcp "127.0.0.1:$PORT") +expect_eq "tcp: zig_version" "$(zig version)" "$(run_in "cat $M/build/zig_version")" +expect_eq "tcp: scratch" "tcp" "$(run_in "echo tcp > $M/scratch/t && cat $M/scratch/t && rm $M/scratch/t")" + +echo "# server death" +"$INTROSPECT" --unix "$TMP/dying.sock" & +DYING=$! +wait_socket "$TMP/dying.sock" +TRANSPORT=(--unix "$TMP/dying.sock") +OUT=$(run_in "cat $M/build/optimize >/dev/null && kill $DYING && sleep 0.3; cat $M/build/optimize 2>&1 >/dev/null | sed 's/.*: //'; echo status=\$?") +expect_contains "server death yields an error, not a hang" "status=0" "$OUT" +expect_eq "server death errno text" "yes" "$(case "$OUT" in *"Input/output error"*|*"Transport endpoint is not connected"*) echo yes;; *) echo "no: $OUT";; esac)" + +echo "# plan9port ramfs (independent 9P2000 implementation)" +if [ -x /usr/lib/plan9/bin/ramfs ]; then + mkdir -p "$TMP/p9ns" + NAMESPACE=$TMP/p9ns /usr/lib/plan9/bin/ramfs -s ramfs + wait_socket "$TMP/p9ns/ramfs" || echo "ramfs socket missing" + PIDS+=($(pgrep -f "9pserve -u unix!$TMP/p9ns/ramfs")) + TRANSPORT=(--unix "$TMP/p9ns/ramfs") + expect_eq "ramfs: mkdir scratch" "ok" "$(run_in "mkdir $M/scratch && echo ok")" + scratch_battery "ramfs" scratch +else + echo "skip - plan9port ramfs not installed" +fi + +echo +echo "passed=$PASSED failed=$FAILED" +[ "$FAILED" -eq 0 ] |
