From ba996acfcad1698adbf4a1834fe50e73b1c6cab9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 19 Sep 2026 23:28:22 -0300 Subject: Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web) Directories, binaries, build options (-D9ns, -D9proc), step names, module name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT, docs and test scripts. Browser assets move to web/static. Co-Authored-By: Claude Fable 5.1 --- introspect/test/adv_core_hostile.py | 1018 ----------------------------- introspect/test/adv_core_hostile.sh | 10 - introspect/test/adv_introspect_hostile.py | 999 ---------------------------- introspect/test/adv_introspect_hostile.sh | 10 - introspect/test/adv_linux_probe.py | 421 ------------ introspect/test/adv_linux_probe.sh | 10 - introspect/test/adversarial.sh | 19 - introspect/test/debug.sh | 84 --- 8 files changed, 2571 deletions(-) delete mode 100755 introspect/test/adv_core_hostile.py delete mode 100755 introspect/test/adv_core_hostile.sh delete mode 100755 introspect/test/adv_introspect_hostile.py delete mode 100755 introspect/test/adv_introspect_hostile.sh delete mode 100755 introspect/test/adv_linux_probe.py delete mode 100755 introspect/test/adv_linux_probe.sh delete mode 100755 introspect/test/adversarial.sh delete mode 100755 introspect/test/debug.sh (limited to 'introspect/test') diff --git a/introspect/test/adv_core_hostile.py b/introspect/test/adv_core_hostile.py deleted file mode 100755 index 56ef5a3..0000000 --- a/introspect/test/adv_core_hostile.py +++ /dev/null @@ -1,1018 +0,0 @@ -#!/usr/bin/env python3 -"""Hostile raw-9P2000 client aimed at the introspect *core* (stdlib only). - -Complements adv_introspect_hostile.py in this directory (framing, tags, scratch, floods) -with attacks on the freestanding engine's own paths: the /vars tree and its -comptime renderers, snapshot slots, the static tree, the fid table at its -configured maximum, directory-read offsets, msize 24, the ctl staging rule, -and the demo's debug providers driven as black boxes. - -Usage: - adv_core_hostile.py --server zig-out/bin/introspect # spawns it on a temp unix socket - adv_core_hostile.py --socket PATH # attacks a running server - -Exit status is non-zero if any check fails or the server dies. -""" -import argparse -import os -import signal -import struct -import subprocess -import sys -import tempfile -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import adv_introspect_hostile as base # noqa: E402 -from adv_introspect_hostile import ( # noqa: E402 - NOTAG, Tversion, Tflush, Rflush, Twalk, Rwalk, Topen, Ropen, Rcreate, - Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove, Tstat, Rstat, Twstat, Rwstat, - Rerror, OREAD, OWRITE, ORDWR, OEXEC, OTRUNC, ORCLOSE, DMDIR, - Nine, frame, s16, mkstat, parse_stat, ok, healthy, expect_dead, -) - -MAX_FIDS = 32768 # demo/main.zig cfg.max_fids -SNAPSHOT_SLOTS = 8 # demo/main.zig cfg.snapshot_slots (per connection) -SCRATCH_BUDGET = 512 << 20 -SCRATCH_MAX_FILE = 64 << 20 - - -def records(d): - """Splits a directory read into (name, raw-record) pairs.""" - out = [] - while d: - n, = struct.unpack_from("/name.""" - c.walk_ok(0, 40, [b"threads"]) - c.open(40, OREAD) - d = c.read_all(40) - c.clunk(40) - for name, _ in records(d): - if c.path_read([b"threads", name, b"name"], fid=41) == b"worker": - return name - return None - - -# --------------------------------------------------------------------------- /vars - - -def attack_vars(path): - print("# /vars: deep walks, hostile names, renderer edge cases, hostile writes") - c = Nine(path) - c.session(1 << 20) - deep = [b"vars", b"state", b"f", b"last_job", b"f", b"id", b".", b"..", b"id", b".", b"..", b"id", b".", b"..", b"id", b"value"] - assert len(deep) == 16 - ok("16-element walk deep into /vars/state/f/... succeeds", c.walk_ok(0, 1, deep) == 16) - rt, _, _ = c.open(1, OREAD) - ok("deep walk lands on a readable value file", rt == Ropen, rt) - c.clunk(1) - up = [b"vars", b"state", b"f", b"inner"] if False else [b"vars", b"state", b"f", b"last_job"] + [b".."] * 12 - n = c.walk_ok(0, 1, up) - ok("12 x '..' from inside /vars climbs to the root and stays there", n == 16, n) - rt, st = c.stat(1) - ok("fid after the climb is the root directory", rt == Rstat and st["qid"][2] == 0xFF << 56, st) - c.clunk(1) - # names that are hex/decimal edge cases or otherwise hostile: never anything but Rerror/partial walk - for nm in (b"0", b"-1", b"0x", b"0x0", b"state\x00", b"State", b" state", b"state ", b"a" * 255, b"a" * 65535, b"\xff\xfe", b"..\x00", b"f", b"value"): - n = c.walk_ok(0, 1, [b"vars", nm]) - ok(f"walk /vars/{nm[:12]!r}{'...' if len(nm) > 12 else ''} is a partial walk (1)", n == 1, n) - ok(" and newfid stays unbound", c.err(Tclunk, struct.pack(" state -> /vars -> /", len(q) == 4 and q[3][2] == 0xFF << 56 and (q[1][0] & 0x80), q) - c.clunk(1) - c.clunk(2) - # every file under /vars/state reads; raw reads beyond @sizeOf are empty - size = int(c.path_read([b"vars", b"state", b"size"])) - ok("/vars/state/size is a number", size > 0, size) - raw = c.path_read([b"vars", b"state", b"raw"]) - ok("/vars/state/raw has exactly @sizeOf bytes", raw is not None and len(raw) == size, (len(raw) if raw else raw, size)) - c.walk_ok(0, 1, [b"vars", b"state", b"raw"]) - c.open(1, OREAD) - rt, d = c.read(1, size, 100) - ok("raw read at offset @sizeOf is empty", rt == Rread and d == b"", (rt, d)) - rt, d = c.read(1, size - 1, 100) - ok("raw read at @sizeOf-1 returns one byte", rt == Rread and len(d) == 1, (rt, d)) - rt, d = c.read(1, (1 << 64) - 1, 100) - ok("raw read at 2^64-1 is empty", rt == Rread and d == b"") - rt, d = c.read(1, 0, 0xFFFFFFFF) - ok("raw read with count 2^32-1 is clamped", rt == Rread and len(d) == size, (rt, len(d) if d else d)) - rt, st = c.stat(1) - ok("raw stat length is @sizeOf and mode 0444", rt == Rstat and st["length"] == size and st["mode"] == 0o444, st) - ok("raw is read-only", c.err(Twrite, struct.pack(" the refused one now opens; clunk via Tremove (denied) also frees the slot - c.clunk(100) - rt, _, _ = c.open(100 + opened, OREAD) - ok("after one clunk the refused open succeeds", rt == Ropen, rt) - ok("remove of an open dynamic file is denied", c.err(Tremove, struct.pack("/stack/x is 'not a directory'", c.walk_ok(0, 1, [b"threads", tid, b"stack"]) == 3 and c.err(Twalk, struct.pack("/stack is 'not a directory'", c.err(Twalk, struct.pack("/../..//name walks", c.walk_ok(0, 1, [b"threads", tid, b"..", b"..", b"threads", tid, b"name"]) == 7) - c.clunk(1) - c.walk_ok(0, 1, [b"threads", tid]) - ok("create under /threads/ is denied", c.err(base.Tcreate, struct.pack(" is denied", c.err(Twstat, struct.pack(" is denied", c.err(Tremove, struct.pack(" reads @sizeOf bytes", rt == Rread and len(d) == size, (rt, len(d) if d else d)) - rt, d = c.read(1, 0, 0xFFFFFFFF) - ok("/mem read with count 2^32-1 is clamped and answered", rt in (Rread, Rerror), rt) - c.clunk(1) - hexd = c.path_read([b"hex", hx]) - ok("/hex/ is a hexdump", hexd is not None and len(hexd) > 64, hexd[:40] if hexd else hexd) - # a value written through /mem must render, not trap: corrupt the phase enum and read /vars/state/value - phase_addr = int(c.path_read([b"vars", b"state", b"f", b"phase", b"addr"]), 16) - c.walk_ok(0, 1, [b"mem", b"%x" % phase_addr]) - c.open(1, OWRITE) - rt, _, _ = c.write(1, 0, b"\xee") - ok("write a corrupt enum byte through /mem", rt == Rwrite, rt) - c.clunk(1) - v = c.path_read([b"vars", b"state", b"value"]) - ok("/vars/state/value renders the corrupt enum as a number instead of trapping", v is not None and b"phase: 238" in v, v) - pv = c.path_read([b"vars", b"state", b"f", b"phase", b"value"]) - ok("/vars/state/f/phase/value renders 238", pv == b"238", pv) - c.walk_ok(0, 1, [b"vars", b"state", b"f", b"phase", b"value"]) - c.open(1, OWRITE) - rt, _, _ = c.write(1, 0, b"idle") - ok("the enum can be repaired through /vars", rt == Rwrite, rt) - c.clunk(1) - # /panic: ctl refuses reads and garbage; message/stack read - ok("/panic/message reads (empty, no panic)", c.path_read([b"panic", b"message"]) == b"") - ok("/panic/stack reads", c.path_read([b"panic", b"stack"]) is not None) - c.walk_ok(0, 1, [b"panic", b"ctl"]) - ok("/panic/ctl refuses OREAD", c.err(Topen, struct.pack("= 1, (len(held), err)) - for f in held: - c.clunk(f) - ok("after clunking, /addr opens again", c.path_read([b"addr", b"1000"]) is not None) - # Tversion with debug files open (hexdumps of the exposed state: mapped memory) - base_addr = int(addr, 16) if addr else 0 - opened = 0 - for i in range(4): - c.walk_ok(0, 300 + i, [b"hex", b"%x" % (base_addr + i)]) - opened += c.open(300 + i, OREAD)[0] == Ropen - ok("four /hex snapshots open", opened == 4, opened) - rt, _, _ = c.version(65536) - ok("Tversion with debug snapshots open", rt == base.Rversion) - c.attach() - ok("/hex still opens after the reset", c.path_read([b"hex", b"%x" % base_addr]) is not None) - ok("/hex of unmapped memory is an Rerror at open, not a crash", c.walk_ok(0, 1, [b"hex", b"3000"]) == 2 and c.open(1, OREAD)[0] == Rerror) - c.clunk(1) - c.close() - ok("server healthy after debug provider attacks", healthy(path)) - - -# --------------------------------------------------------------------------- fids at the maximum - - -def flood(c, ids, names): - """Pipelines one Twalk per id and counts the Rwalk replies; returns (ok_count, error_count, seconds).""" - got = [0, 0] - dead = [False] - - def reader(): - try: - for _ in ids: - rt, _, _ = c.recv_frame() - if rt == Rwalk: - got[0] += 1 - else: - got[1] += 1 - except (EOFError, OSError): - dead[0] = True - - t = threading.Thread(target=reader) - t.start() - t0 = time.time() - body = b"".join(frame(Twalk, i & 0xFFFE, struct.pack(" the handler's writer fails - rt2, d = c.read(1, 0, 100) - rt3, st5 = c.stat(1) - ok("an over-long result is an Rerror and the previous result survives", rt == Rerror and d == b"y" * 100 and st5["length"] == 60000, (rt, d[:10] if d else d, st5["length"])) - c.clunk(1) - c.close() - ok("server healthy after ctl attacks", healthy(path)) - - -# --------------------------------------------------------------------------- fid state machine on scratch - - -def attack_fid_states(path): - print("# fid state machine on /scratch") - c = Nine(path) - c.session() - tag = os.urandom(3).hex().encode() - root = b"fs-" + tag - c.walk_ok(0, 1, [b"scratch"]) - c.create(1, root, DMDIR | 0o755, OREAD) - c.clunk(1) - S = [b"scratch", root] - c.walk_ok(0, 1, S) - rt, _, _ = c.create(1, b"f", 0o644, ORDWR) - ok("create f", rt == Rcreate) - ok("open of an open fid is 'file already open'", c.err(Topen, struct.pack("> 20} MiB fill the {SCRATCH_BUDGET >> 20} MiB budget exactly", made == count, made) - print(f" filled the budget in {time.time() - t0:.1f}s") - c.walk_ok(0, 1, S) - c.create(1, b"one-more", 0o644, OWRITE) - ok("one more byte is 'no space left on device'", c.err(Twrite, struct.pack(" [--fast] (part of zig build introspect-adv) -# Spawns the server on a temporary unix socket and attacks it with -# introspect/test/adv_core_hostile.py (Python 3 stdlib). Exit 1 on any failure. -set -u -INTROSPECT=$(realpath "${1:?path to introspect}") -shift -command -v python3 >/dev/null || { echo "SKIP: python3 missing"; exit 0; } -exec python3 "$(dirname "$0")/adv_core_hostile.py" --server "$INTROSPECT" "$@" diff --git a/introspect/test/adv_introspect_hostile.py b/introspect/test/adv_introspect_hostile.py deleted file mode 100755 index 7dbb5c0..0000000 --- a/introspect/test/adv_introspect_hostile.py +++ /dev/null @@ -1,999 +0,0 @@ -#!/usr/bin/env python3 -"""Hostile raw-9P2000 client for the introspect server (stdlib only). - -Usage: - adv_introspect_hostile.py --server zig-out/bin/introspect # spawns it on a temp unix socket - adv_introspect_hostile.py --socket PATH # attacks a running server - -Every attack is followed by a "server still healthy" probe on a fresh connection. -Exit status is non-zero if any check fails, the server dies, or a probe hangs. -""" -import argparse -import os -import signal -import socket -import struct -import subprocess -import sys -import tempfile -import threading -import time - -NOTAG = 0xFFFF -NOFID = 0xFFFFFFFF -Tversion, Rversion, Tauth, Rauth, Tattach, Rattach, Rerror = 100, 101, 102, 103, 104, 105, 107 -Tflush, Rflush, Twalk, Rwalk, Topen, Ropen, Tcreate, Rcreate = 108, 109, 110, 111, 112, 113, 114, 115 -Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove = 116, 117, 118, 119, 120, 121, 122, 123 -Tstat, Rstat, Twstat, Rwstat = 124, 125, 126, 127 -OREAD, OWRITE, ORDWR, OEXEC, OTRUNC, ORCLOSE = 0, 1, 2, 3, 0x10, 0x40 -DMDIR, DMAPPEND, DMEXCL = 0x80000000, 0x40000000, 0x20000000 -NAMES = {v: k for k, v in globals().items() if k[:1] in "TR" and isinstance(v, int) and 100 <= v <= 127} - -FAILS = [] -PASSES = 0 - - -def ok(name, cond, detail=""): - global PASSES - if cond: - PASSES += 1 - print(f"ok - {name}") - else: - FAILS.append(name) - print(f"FAIL - {name} {detail}") - - -def s16(b): - return struct.pack(" connection closed - c.raw(frame(Twalk, 1, struct.pack("0 - ok("walk to file", c.walk_ok(0, 1, [b"build", b"target"]) == 2) - ok("walk from file fails 'not a directory'", c.err(Twalk, struct.pack(" 0) - ok("read dir at bad offset", c.err(Tread, struct.pack(" 6: - return - if c.walk_ok(0, 9, names) != len(names): - ok("walk " + b"/".join(names).decode(), False) - return - rt, st = c.stat(9) - if st["mode"] & DMDIR: - c.open(9, OREAD) - d = c.read_all(9, 512) - c.clunk(9) - while d: - n, = struct.unpack_from(" 0: - ok("server process still running", proc.poll() is None, proc.poll()) - finally: - if proc is not None: - proc.send_signal(signal.SIGTERM) - try: - _, err = proc.communicate(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - _, err = proc.communicate() - lines = [ln for ln in err.decode("utf-8", "replace").splitlines() if "connection ended" not in ln and "read: " not in ln] - if lines: - print("# server stderr (filtered):") - for ln in lines[:40]: - print(" " + ln) - if tmp: - try: - os.unlink(path) - os.rmdir(tmp) - except OSError: - pass - print(f"# {PASSES} passed, {len(FAILS)} failed") - for f in FAILS: - print("# FAIL " + f) - sys.exit(1 if FAILS else 0) - - -if __name__ == "__main__": - main() diff --git a/introspect/test/adv_introspect_hostile.sh b/introspect/test/adv_introspect_hostile.sh deleted file mode 100755 index 3ee2ecb..0000000 --- a/introspect/test/adv_introspect_hostile.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# Adversarial raw-9P2000 client tests for the introspect server. -# Usage: bash introspect/test/adv_introspect_hostile.sh [--fast] (part of zig build introspect-adv) -# Spawns the server on a temporary unix socket and attacks it with -# introspect/test/adv_introspect_hostile.py (Python 3 stdlib). Exit 1 on any failure. -set -u -INTROSPECT=$(realpath "${1:?path to introspect}") -shift -command -v python3 >/dev/null || { echo "SKIP: python3 missing"; exit 0; } -exec python3 "$(dirname "$0")/adv_introspect_hostile.py" --server "$INTROSPECT" "$@" diff --git a/introspect/test/adv_linux_probe.py b/introspect/test/adv_linux_probe.py deleted file mode 100755 index 83de771..0000000 --- a/introspect/test/adv_linux_probe.py +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env python3 -"""Adversarial tests of the introspect Linux layer: probe loop, debug provider, -signal machinery. Raw 9P2000 over a unix socket, plus one 9player mount. -Usage: adv_linux_probe.py --player <9player> --server -Reuses the client of adv_introspect_hostile.py. Exit 1 on any failure. -""" -import argparse -import ctypes -import os -import signal -import socket -import struct -import subprocess -import sys -import tempfile -import threading -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from adv_introspect_hostile import ( # noqa: E402 - NOFID, NOTAG, OREAD, OWRITE, Nine, Rerror, Ropen, Rread, Rversion, Rwalk, Rwrite, - Tattach, Tread, Tversion, Twrite, frame, healthy, ok, parse_stat, s16) -import adv_introspect_hostile as hostile # noqa: E402 - -libc = ctypes.CDLL(None, use_errno=True) -SYS_tgkill = 234 if os.uname().machine == "x86_64" else 131 # aarch64: 131 - - -def tgkill(pid, tid, sig): - return libc.syscall(SYS_tgkill, pid, tid, sig) - - -class Srv: - def __init__(self, server, extra=()): - self.tmp = tempfile.mkdtemp(prefix="advlin.") - self.path = os.path.join(self.tmp, "sock") - self.proc = subprocess.Popen([server, "--unix", self.path, *extra], stderr=subprocess.PIPE) - for _ in range(200): - if os.path.exists(self.path): - break - time.sleep(0.02) - self.pid = self.proc.pid - - def alive(self): - return self.proc.poll() is None - - def stop(self): - if self.proc.poll() is None: - self.proc.send_signal(signal.SIGTERM) - try: - self.proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self.proc.kill() - self.proc.wait() - err = self.proc.stderr.read().decode("utf-8", "replace") - try: - os.unlink(self.path) - except OSError: - pass - try: - os.rmdir(self.tmp) - except OSError: - pass - return err - - -def client(path, timeout=10): - c = Nine(path, timeout=timeout) - c.session() - return c - - -def rd(c, names, fid=50, offset=0, count=8192): - """walk+open+one read; returns (rtype-or-tag, data-or-error-string).""" - if c.walk_ok(0, fid, names) != len(names): - c.clunk(fid) - return "walkfail", None - rt, _, rb = c.open(fid, OREAD) - if rt != Ropen: - c.clunk(fid) - return "openfail", rb - rt, d = c.read(fid, offset, count) - c.clunk(fid) - if rt == Rerror: - n, = struct.unpack_from("/maps (read in 4 KiB pieces)", via == real, f"{len(via)} vs {len(real)}") - maps = real.decode() - for tag in ("[stack]", "[vdso]", "[heap]"): - m = [ln for ln in maps.splitlines() if ln.endswith(tag)] - if not m: - continue - lo = int(m[0].split("-")[0], 16) + 0x100 - ok(f"/addr of {tag} renders ? (never handed to std)", rd(c, [b"addr", b"%x" % lo])[1] == b"?\n?\n?\n") - ok(f"/hex of {tag} dumps", rd(c, [b"hex", b"%x" % lo])[0] == Rread) - m = [ln for ln in maps.splitlines() if ln.endswith("[stack]")][0] - hi = int(m.split()[0].split("-")[1], 16) - rt, d = rd(c, [b"mem", b"%x" % (hi - 16)], count=4096) - ok("read across the end of a mapping is a short read of 16 bytes", rt == Rread and len(d) == 16, (rt, d and len(d))) - rt, d = rd(c, [b"hex", b"%x" % (hi - 16)]) - ok("hexdump across the end of a mapping stops at the boundary", rt == Rread and d.count(b"\n") == 1, (rt, d)) - code = [ln for ln in maps.splitlines() if "r-xp" in ln and "introspect" in ln][0] - clo = int(code.split("-")[0], 16) - ok("write into read-only code is an error, not a fault", wr(c, [b"mem", b"%x" % (clo + 0x100)], b"\xcc") == ("err", "i/o error")) - ok("server alive after memory attacks", s.alive() and healthy(s.path)) - # a big write over the demo's own globals (state onwards) must not fault the server path - addr = rd(c, [b"vars", b"state", b"addr"])[1].decode().strip()[2:] - # (it zeroes the demo's own globals, this connection's state included, so - # the reply may never come; the server as a whole must keep working) - wr(c, [b"mem", addr.encode()], b"\x00" * 65536) - time.sleep(0.3) - ok("server alive and serving after a 64 KiB overwrite of its own globals", s.alive() and healthy(s.path)) - s.stop() - - -def attack_signals(server): - print("# signal machinery") - s = Srv(server) - c = client(s.path, timeout=8) - w = thread_by_name(c, s.pid, b"worker") - probe = thread_by_name(c, s.pid, b"introspect") - ok("worker and probe threads found by name", bool(w and probe), (w, probe)) - names = sorted(open(f"/proc/{s.pid}/task/{t}/comm").read().strip() for t in os.listdir(f"/proc/{s.pid}/task")) - ok("thread names are introspect, introspect, worker", names == ["introspect", "introspect", "worker"], names) - - # 4 clients hammer stacks of every thread while trap/continue interleave - errs, count = [], [0] - stop = threading.Event() - - def hammer(k): - try: - cc = client(s.path, timeout=8) - while not stop.is_set(): - for t in (w, probe, str(s.pid).encode()): - rt, d = rd(cc, [b"threads", t, b"stack"], fid=10 + k) - count[0] += 1 - if rt in ("walkfail", "openfail") or (rt == "err" and "i/o" not in d): - errs.append((t, rt, d)) - except Exception as e: # noqa: BLE001 - errs.append(repr(e)) - - ths = [threading.Thread(target=hammer, args=(k,)) for k in range(4)] - for t in ths: - t.start() - rounds_ok = True - for _ in range(5): - wr(c, [b"runtime", b"ctl"], b"trap") - time.sleep(0.15) - rounds_ok &= ls(c, [b"breakpoints"]) == [w] - rounds_ok &= b"workerLoop" in (rd(c, [b"breakpoints", w, b"stack"])[1] or b"") - rounds_ok &= rd(c, [b"threads", w, b"stack"])[0] == Rread # capture of a paused thread - rounds_ok &= wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == Rwrite - rounds_ok &= wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == "walkfail" # twice: gone - stop.set() - for t in ths: - t.join() - ok("trap/inspect/continue rounds while 4 clients capture stacks", rounds_ok) - ok(f"{count[0]} concurrent captures without a wrong answer", count[0] > 50 and not errs, errs[:3]) - - # SIGTRAP from outside (tgkill, not int3): parks without corrupting the thread - ok("tgkill SIGTRAP to the worker", tgkill(s.pid, int(w), signal.SIGTRAP) == 0) - time.sleep(0.3) - ok("worker listed under /breakpoints after tgkill", ls(c, [b"breakpoints"]) == [w]) - ok("its stack names workerLoop", b"workerLoop" in (rd(c, [b"breakpoints", w, b"stack"])[1] or b"")) - t1 = ticks(c) - time.sleep(0.3) - ok("ticks frozen while parked", ticks(c) == t1) - ok("continue after tgkill", wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == Rwrite) - time.sleep(0.4) - ok("ticks advance after continue (no instruction skipped)", ticks(c) > t1) - - # SIGTRAP on the probe thread itself: stepped over, the server keeps serving - ok("tgkill SIGTRAP to the probe thread", tgkill(s.pid, int(probe), signal.SIGTRAP) == 0) - time.sleep(0.2) - ok("server serves after a SIGTRAP on its own thread", healthy(s.path)) - ok("probe thread not parked", ls(c, [b"breakpoints"]) == []) - # process-directed SIGTRAP lands on some thread; whichever it is, it is resumable - os.kill(s.pid, signal.SIGTRAP) - time.sleep(0.3) - ok("alive after kill -TRAP ", s.alive() and healthy(s.path)) - for t in ls(c, [b"breakpoints"]): - ok(f"thread {t.decode()} parked by kill -TRAP resumes", wr(c, [b"breakpoints", t, b"ctl"], b"continue")[0] == Rwrite) - ok("continue on a never-paused tid does not walk", wr(c, [b"breakpoints", w, b"ctl"], b"continue")[0] == "walkfail") - ok("a bogus tid does not walk", c.walk_ok(0, 31, [b"threads", b"999999"]) == 1) - - # panic: held, inspectable, capture of the held thread works, trap meanwhile harmless, continue aborts - wr(c, [b"runtime", b"ctl"], b"panic") - time.sleep(0.3) - ok("panic message published", rd(c, [b"panic", b"message"])[1] == b"demo panic requested over 9p") - ok("panic stack names workerLoop", b"workerLoop" in rd(c, [b"panic", b"stack"])[1]) - ok("capture of the held panicking thread answers", rd(c, [b"threads", w, b"stack"])[0] == Rread) - ok("trap request while a panic is held is harmless", wr(c, [b"runtime", b"ctl"], b"trap")[0] == Rwrite and s.alive()) - ok("panic continue", wr(c, [b"panic", b"ctl"], b"continue")[0] == Rwrite) - ok("second panic continue is an error", wr(c, [b"panic", b"ctl"], b"continue") == ("err", "file does not exist")) - time.sleep(1.0) - ok("process aborted after continue", not s.alive() and s.proc.poll() not in (0, None), s.proc.poll()) - s.stop() - - s = Srv(server, ["--no-hold"]) - c = client(s.path, timeout=5) - wr(c, [b"runtime", b"ctl"], b"panic") - time.sleep(1.0) - ok("--no-hold: panic aborts at once", not s.alive() and s.proc.poll() not in (0, None), s.proc.poll()) - s.stop() - - s = Srv(server) - c = client(s.path, timeout=5) - wr(c, [b"runtime", b"ctl"], b"trap") - time.sleep(0.3) - ok("worker parked", ls(c, [b"breakpoints"]) != []) - path = s.path - s.proc.send_signal(signal.SIGTERM) - try: - rc = s.proc.wait(timeout=5) - except subprocess.TimeoutExpired: - rc = None - ok("SIGTERM with a parked thread exits promptly", rc is not None, rc) - ok("SIGTERM unlinks the unix socket (clean stop path)", not os.path.exists(path)) - s.stop() - - -def attack_probe(player, server): - print("# probe loop and admission") - s = Srv(server) - c0 = cpu_ticks(s.pid) - time.sleep(5.0) - ok("0 CPU ticks over 5 s idle", cpu_ticks(s.pid) - c0 == 0, cpu_ticks(s.pid) - c0) - f0 = fds(s.pid) - for i in range(1000): - so = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - so.connect(s.path) - if i % 3 == 0: - so.sendall(frame(Tversion, NOTAG, struct.pack(" (part of zig build introspect-adv) -# Exit 1 on any failure; SKIP (exit 0) without python3. -set -u -PLAYER=$(realpath "${1:?path to 9player}") -INTROSPECT=$(realpath "${2:?path to introspect}") -command -v python3 >/dev/null || { echo "SKIP: python3 missing"; exit 0; } -exec python3 "$(dirname "$0")/adv_linux_probe.py" --player "$PLAYER" --server "$INTROSPECT" diff --git a/introspect/test/adversarial.sh b/introspect/test/adversarial.sh deleted file mode 100755 index 918bb9a..0000000 --- a/introspect/test/adversarial.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# Runs every introspect adversarial suite in sequence: hostile raw-9P clients -# against the demo (framing, tags, floods; the core's /vars, snapshots, fids) -# and the Linux layer through one 9player mount (memory, signals, poll loop). -# Usage: bash introspect/test/adversarial.sh <9player> [--fast] -# `zig build introspect-adv` runs the same suites as separate steps. -set -u -PLAYER=${1:?path to 9player} -INTROSPECT=${2:?path to introspect} -shift 2 -HERE=$(cd "$(dirname "$0")" && pwd) -status=0 -for suite in adv_introspect_hostile adv_core_hostile; do - echo "### $suite" - if bash "$HERE/$suite.sh" "$INTROSPECT" "$@"; then echo "### $suite: ok"; else echo "### $suite: FAILED"; status=1; fi -done -echo "### adv_linux_probe" -if bash "$HERE/adv_linux_probe.sh" "$PLAYER" "$INTROSPECT"; then echo "### adv_linux_probe: ok"; else echo "### adv_linux_probe: FAILED"; status=1; fi -exit $status diff --git a/introspect/test/debug.sh b/introspect/test/debug.sh deleted file mode 100755 index c3be406..0000000 --- a/introspect/test/debug.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# End-to-end test of the introspect debug facilities through 9player: -# threads and stacks, address resolution, memory, exposed values, breakpoints, panics. -# Usage: bash introspect/test/debug.sh <9player> (zig build introspect-debug-itest) -set -u -PLAYER=$(realpath "${1:?path to 9player}") -INTROSPECT=$(realpath "${2:?path to introspect}") -TMP=$(mktemp -d /tmp/9pdbg.XXXXXX) -M=/mnt/9p -FAILED=0; PASSED=0 -SRV= - -cleanup() { [ -n "$SRV" ] && kill "$SRV" 2>/dev/null; rm -rf "$TMP"; } -trap cleanup EXIT -if ! unshare -Urm true 2>/dev/null || [ ! -c /dev/fuse ]; then echo "SKIP: namespaces or /dev/fuse unavailable"; 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; } -run_in() { timeout 60 "$PLAYER" --unix "$SOCK" -- sh -c "$1" 2>"$TMP/stderr"; } - -SOCK=$TMP/dbg.sock -"$INTROSPECT" --unix "$SOCK" >"$TMP/server.log" 2>&1 & -SRV=$! -for _ in $(seq 1 100); do [ -S "$SOCK" ] && break; sleep 0.05; done -[ -S "$SOCK" ] || { echo "server did not start"; cat "$TMP/server.log"; exit 1; } - -echo "# threads" -expect_eq "threads listed" "yes" "$(run_in "[ \$(ls $M/threads | wc -l) -ge 2 ] && echo yes")" -WORKER=$(run_in "for t in $M/threads/*; do if grep -q '^worker' \$t/name 2>/dev/null; then basename \$t; fi; done | head -1") -expect_eq "worker thread found by name" "yes" "$([ -n "$WORKER" ] && echo yes)" -STACK=$(run_in "cat $M/threads/$WORKER/stack") -expect_contains "worker stack names workerLoop" "workerLoop" "$STACK" -expect_contains "worker stack has source locations" "demo/main.zig:" "$STACK" -expect_contains "worker regs" "0x" "$(run_in "cat $M/threads/$WORKER/regs | head -3")" -expect_eq "own (server) thread stack works" "yes" "$(run_in "for t in $M/threads/*; do cat \$t/stack >/dev/null 2>&1 || echo bad; done; echo yes")" - -echo "# addresses and memory" -FRAME=$(printf '%s\n' "$STACK" | grep -oE '0x[0-9a-f]+' | head -1) -expect_contains "addr resolves a stack frame to the demo source" "demo/main.zig" "$(run_in "cat $M/addr/${FRAME#0x}")" -expect_contains "addr of garbage is an error, not a crash" "No such file" "$(run_in "cat $M/addr/zzz 2>&1")" -expect_contains "mem/maps readable" "r-xp" "$(run_in "head -c 4000 $M/mem/maps")" -STATE_ADDR=$(run_in "cat $M/vars/state/addr") -expect_contains "hexdump of the exposed state" " " "$(run_in "head -2 $M/hex/${STATE_ADDR#0x}")" -expect_eq "raw bytes of the state match its size" "$(run_in "cat $M/vars/state/size")" "$(run_in "cat $M/mem/${STATE_ADDR#0x} | head -c \$(cat $M/vars/state/size) | wc -c")" -expect_eq "reading unmapped memory is an error, not a crash" "no" "$(run_in "cat $M/mem/8 >/dev/null 2>&1 && echo yes || echo no")" - -echo "# exposed values" -T1=$(run_in "cat $M/vars/state/f/ticks/value"); sleep 0.4; T2=$(run_in "cat $M/vars/state/f/ticks/value") -expect_eq "ticks is numeric" "num" "$(printf '%s' "$T1" | grep -Eq '^[0-9]+$' && echo num)" -expect_eq "ticks advance" "yes" "$([ "$T2" -gt "$T1" ] 2>/dev/null && echo yes)" -expect_contains "rendered struct value" "ticks" "$(run_in "cat $M/vars/state/value")" -expect_contains "type name" "State" "$(run_in "cat $M/vars/state/type")" -T3=$(run_in "echo 5 > $M/vars/state/f/ticks/value && cat $M/vars/state/f/ticks/value") -expect_eq "writing a scalar changes the live variable" "yes" "$([ "$T3" -lt "$T2" ] 2>/dev/null && echo yes)" - -echo "# breakpoints" -expect_eq "no breakpoints initially" "" "$(run_in "ls $M/breakpoints")" -run_in "echo trap > $M/runtime/ctl" >/dev/null; sleep 0.6 -PAUSED=$(run_in "ls $M/breakpoints | head -1") -expect_eq "worker paused at @breakpoint()" "$WORKER" "$PAUSED" -expect_contains "paused stack names workerLoop" "workerLoop" "$(run_in "cat $M/breakpoints/$WORKER/stack 2>&1")" -P1=$(run_in "cat $M/vars/state/f/ticks/value"); sleep 0.4; P2=$(run_in "cat $M/vars/state/f/ticks/value") -expect_eq "ticks frozen while paused" "$P1" "$P2" -run_in "echo continue > $M/breakpoints/$WORKER/ctl" >/dev/null; sleep 0.4 -expect_eq "breakpoint list empty after continue" "" "$(run_in "ls $M/breakpoints")" -P3=$(run_in "cat $M/vars/state/f/ticks/value") -expect_eq "ticks advance after continue" "yes" "$([ "$P3" -gt "$P2" ] 2>/dev/null && echo yes)" - -echo "# panic" -expect_eq "no panic recorded" "" "$(run_in "cat $M/panic/message")" -run_in "echo panic > $M/runtime/ctl" >/dev/null; sleep 0.6 -expect_contains "panic message published" "demo panic" "$(run_in "cat $M/panic/message")" -expect_contains "panic stack names the worker" "workerLoop" "$(run_in "cat $M/panic/stack")" -expect_eq "server still alive while holding the panic" "yes" "$(kill -0 $SRV 2>/dev/null && echo yes)" -run_in "echo continue > $M/panic/ctl" >/dev/null 2>&1 -for _ in $(seq 1 50); do kill -0 $SRV 2>/dev/null || break; sleep 0.1; done -if kill -0 $SRV 2>/dev/null; then fail "server exits after panic continue"; else wait $SRV; RC=$?; SRV=; expect_eq "server exit status is non-zero after the panic" "yes" "$([ $RC -ne 0 ] && echo yes)"; fi -expect_contains "default panic output reached stderr" "demo panic" "$(cat "$TMP/server.log")" - -echo -echo "passed=$PASSED failed=$FAILED" -[ "$FAILED" -eq 0 ] -- cgit v1.3