summaryrefslogtreecommitdiff
path: root/introspect/test
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
committerGabriel Schneider <[email protected]>2026-09-19 23:28:22 -0300
commitba996acfcad1698adbf4a1834fe50e73b1c6cab9 (patch)
tree282ba00ce5b10d7416aecb9f2f0f0a439340a57d /introspect/test
parentb05abcba3ea09ea106ad28364c6e40a3ec31b890 (diff)
downloadcloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.tar.gz
cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.zip
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 <[email protected]>
Diffstat (limited to 'introspect/test')
-rwxr-xr-xintrospect/test/adv_core_hostile.py1018
-rwxr-xr-xintrospect/test/adv_core_hostile.sh10
-rwxr-xr-xintrospect/test/adv_introspect_hostile.py999
-rwxr-xr-xintrospect/test/adv_introspect_hostile.sh10
-rwxr-xr-xintrospect/test/adv_linux_probe.py421
-rwxr-xr-xintrospect/test/adv_linux_probe.sh10
-rwxr-xr-xintrospect/test/adversarial.sh19
-rwxr-xr-xintrospect/test/debug.sh84
8 files changed, 0 insertions, 2571 deletions
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("<H", d)
- out.append((parse_stat(d[:n + 2])["name"], d[:n + 2]))
- d = d[n + 2:]
- return out
-
-
-def qid_of_walk(rb):
- n, = struct.unpack_from("<H", rb)
- return [struct.unpack_from("<BIQ", rb, 2 + 13 * i) for i in range(n)]
-
-
-def worker_tid(c):
- """The tid of the demo's worker thread, via /threads/<tid>/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("<I", 1)) == "unknown fid")
- for nm in (b"0", b"F", b"f\x00", b"ticks", b"value ", b"raw\x00"):
- n = c.walk_ok(0, 1, [b"vars", b"state", nm])
- ok(f"walk /vars/state/{nm!r} is a partial walk (2)", n == 2, n)
- # . and .. on var files and directories
- ok("walk '.' from a var file is 'not a directory'", c.walk_ok(0, 1, [b"vars", b"state", b"value"]) == 3 and c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == "not a directory")
- ok("walk '..' from a var file is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"..")) == "not a directory")
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f"])
- rt, _, rb = c.walk(1, 2, [b".", b"..", b"..", b".."])
- q = qid_of_walk(rb) if rt == Rwalk else []
- ok("'.' and '..' through the var tree: f -> 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("<IQI", 1, 0, 1) + b"x") is not None)
- c.clunk(1)
- for leaf in (b"type", b"size", b"addr", b"value"):
- c.walk_ok(0, 1, [b"vars", b"state", leaf])
- e = c.err(Topen, struct.pack("<IB", 1, OWRITE))
- ok(f"/vars/state/{leaf.decode()} refuses OWRITE", e == "permission denied", e)
- e = c.err(Topen, struct.pack("<IB", 1, OREAD | OTRUNC))
- ok(f"/vars/state/{leaf.decode()} refuses OTRUNC", e == "permission denied", e)
- c.clunk(1)
- v = c.path_read([b"vars", b"state", b"value"])
- ok("/vars/state/value renders every field", v is not None and all(k in v for k in (b"ticks:", b"phase:", b"last_job:", b"id:", b"cost:")), v)
- ok("nested struct is indented", b"\n id: " in (v or b""), v)
- # dynamic file: read at offset 0 regenerates, offset 1 is the tail of the same snapshot
- c.walk_ok(0, 1, [b"vars", b"state", b"value"])
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 8192)
- rt2, d2 = c.read(1, 1, 8192)
- ok("value read at offset 1 is the tail of the snapshot", rt == Rread and rt2 == Rread and d2 == d[1:], (d, d2))
- rt3, d3 = c.read(1, len(d), 8192)
- ok("value read at the end is empty", rt3 == Rread and d3 == b"")
- rt4, d4 = c.read(1, (1 << 63) + 5, 10)
- ok("value read at 2^63+5 is empty", rt4 == Rread and d4 == b"")
- rt, st = c.stat(1)
- ok("value stat reports length 0 (dynamic)", rt == Rstat and st["length"] == 0, st)
- c.clunk(1)
- # hostile writes to scalar values: garbage, huge, negative, floats with exponents, NULs, empty
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"ticks", b"value"])
- rt, _, _ = c.open(1, OWRITE | OTRUNC)
- ok("open ticks/value OWRITE|OTRUNC", rt == Ropen, rt)
- for bad in (b"abc", b"99999999999999999999999", b"-1", b"1e3", b"", b" ", b"4\x002", b"1.5", b"0x", b"+", b"\xd9\xa1\xd9\xa2", b"12 34", b"0b102"):
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad)
- ok(f"write {bad!r} to u64 value is 'bad value'", e == "bad value", e)
- ok("read on the write-only value fid is 'file not open'", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == "file not open")
- for good, want in ((b" 4200 \n", 4200), (b"0x10", 16), (b"+7", 7), (b"0b1010", 10), (b"0o17", 15), (b"1_000", 1000), (b"18446744073709551615", (1 << 64) - 1)):
- rt, _, rb = c.write(1, (1 << 64) - 1, good) # offset is ignored for values
- got = c.path_read([b"vars", b"state", b"f", b"ticks", b"value"])
- try:
- gv = int(got)
- except (TypeError, ValueError):
- gv = None
- # the worker keeps incrementing (wrapping), so allow a small drift
- ok(f"write {good!r} stores {want}", rt == Rwrite and gv is not None and (gv - want) % (1 << 64) < 100_000, (rt, got))
- c.write(1, 0, b"1")
- c.clunk(1)
- # enum and float and u32 leaves
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"phase", b"value"])
- c.open(1, ORDWR)
- for bad in (b"trap\x00ped", b"IDLE", b"2", b"", b"idle extra", b"\x00idle\x00x", b"idl", b"idle\x00\x00x"):
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad)
- ok(f"enum write {bad!r} is 'bad value'", e == "bad value", e)
- rt, _, _ = c.write(1, 0, b"\n idle \x00")
- rt2, d = c.read(1, 0, 100)
- ok("enum write with surrounding whitespace/NUL is accepted", rt == Rwrite and d in (b"idle", b"working", b"trapped"), (rt, d))
- rt, st = c.stat(1)
- ok("enum value is 0644", rt == Rstat and st["mode"] == 0o644, st)
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"last_job", b"f", b"cost", b"value"])
- c.open(1, ORDWR)
- for bad in (b"abc", b"1.5.5", b"e5", b"", b"0x", b"1e", b"--1"):
- ok(f"float write {bad!r} is 'bad value'", c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad) == "bad value")
- for good in (b"1.5e3", b"-0x1p-2", b"1e999", b"nan", b"-0", b"2.5"):
- rt, _, _ = c.write(1, 0, good)
- rt2, d = c.read(1, 0, 100)
- ok(f"float write {good!r} accepted and renders ({d!r})", rt == Rwrite and rt2 == Rread and d != b"", (rt, d))
- c.write(1, 0, b"0")
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f", b"last_job", b"f", b"id", b"value"])
- c.open(1, OWRITE)
- ok("u32 write 4294967296 is 'bad value'", c.err(Twrite, struct.pack("<IQI", 1, 0, 10) + b"4294967296") == "bad value")
- ok("u32 write -0 is accepted as 0 (std.fmt.parseInt semantics)", c.write(1, 0, b"-0")[0] == Rwrite)
- ok("u32 write -1 is 'bad value'", c.err(Twrite, struct.pack("<IQI", 1, 0, 2) + b"-1") == "bad value")
- rt, _, _ = c.write(1, 0, b"4294967295")
- ok("u32 write 4294967295 accepted", rt == Rwrite)
- c.write(1, 0, b"0")
- c.clunk(1)
- # struct values and the f directory are not writable; the tree admits no create/remove/wstat
- for names in ([b"vars"], [b"vars", b"state"], [b"vars", b"state", b"f"], [b"vars", b"state", b"f", b"last_job"], [b"vars", b"state", b"f", b"last_job", b"f"]):
- c.walk_ok(0, 1, names)
- p = b"/".join(names).decode()
- ok(f"create in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"y"))) == "permission denied")
- ok(f"open {p} for write is 'is a directory'", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == "is a directory")
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok(f" and the fid was clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- for names in ([b"vars", b"state", b"value"], [b"vars", b"state", b"f", b"last_job", b"value"], [b"vars", b"state", b"type"]):
- c.walk_ok(0, 1, names)
- p = b"/".join(names).decode()
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=0))) == "permission denied")
- ok(f"{p} is not writable", c.err(Topen, struct.pack("<IB", 1, ORDWR)) == "permission denied")
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- # directory listing of /vars/state and of f/, exact record boundaries
- c.walk_ok(0, 1, [b"vars", b"state"])
- c.open(1, OREAD)
- d = c.read_all(1)
- names = [n for n, _ in records(d)]
- ok("/vars/state lists value,type,size,addr,raw,f", sorted(names) == sorted([b"value", b"type", b"size", b"addr", b"raw", b"f"]), names)
- c.clunk(1)
- c.walk_ok(0, 1, [b"vars", b"state", b"f"])
- c.open(1, OREAD)
- d = c.read_all(1)
- names = [n for n, _ in records(d)]
- ok("/vars/state/f lists the three fields", sorted(names) == [b"last_job", b"phase", b"ticks"], names)
- c.clunk(1)
- c.close()
- ok("server healthy after /vars attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- snapshot slots
-
-
-def attack_snapshots(path):
- print("# snapshot slots: exhaustion, hold, release by clunk and by Tversion; per-connection")
- dyn = [[b"runtime", b"pid"], [b"runtime", b"ppid"], [b"runtime", b"uptime"], [b"runtime", b"fn", b"now"], [b"runtime", b"fn", b"fib30"],
- [b"vars", b"state", b"value"], [b"vars", b"state", b"addr"], [b"vars", b"state", b"f", b"ticks", b"value"], [b"vars", b"state", b"f", b"phase", b"value"], [b"runtime", b"fn", b"uname"]]
- c = Nine(path)
- c.session()
- opened = 0
- err = None
- for i, names in enumerate(dyn):
- c.walk_ok(0, 100 + i, names)
- rt, _, rb = c.open(100 + i, OREAD)
- if rt == Ropen:
- opened += 1
- else:
- err = c.err.__self__ and rb
- n, = struct.unpack_from("<H", rb)
- err = rb[2:2 + n].decode()
- break
- ok(f"exactly {SNAPSHOT_SLOTS} dynamic files open per connection", opened == SNAPSHOT_SLOTS, opened)
- ok("the next open is 'too many open dynamic files'", err == "too many open dynamic files", err)
- ok("the refused fid is still unopened (read is 'file not open')", c.err(Tread, struct.pack("<IQI", 100 + opened, 0, 10)) == "file not open")
- # every held snapshot is still readable and consistent at offset 1
- for i in range(opened):
- rt, d = c.read(100 + i, 0, 8192)
- rt2, d2 = c.read(100 + i, 1, 8192)
- ok(f"held snapshot {i} reads and its offset-1 read is the tail", rt == Rread and rt2 == Rread and d2 == d[1:], (rt, rt2))
- # static and provider files need no slot
- ok("static file opens while slots are exhausted", c.path_read([b"build", b"zig_version"]) not in (None, b""))
- ok("/vars/state/type opens while slots are exhausted", c.path_read([b"vars", b"state", b"type"]) not in (None, b""))
- ok("/vars/state/raw opens while slots are exhausted", c.path_read([b"vars", b"state", b"raw"]) not in (None, b""))
- ok("scratch root lists while slots are exhausted", c.walk_ok(0, 50, [b"scratch"]) == 1 and c.open(50, OREAD)[0] == Ropen)
- c.clunk(50)
- # a second connection has its own slots
- c2 = Nine(path)
- c2.session()
- n2 = 0
- for i, names in enumerate(dyn[:SNAPSHOT_SLOTS]):
- c2.walk_ok(0, 100 + i, names)
- n2 += c2.open(100 + i, OREAD)[0] == Ropen
- ok("a second connection opens its own 8 dynamic files", n2 == SNAPSHOT_SLOTS, n2)
- c2.close()
- # clunk one -> 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("<I", 101)) == "permission denied")
- c.walk_ok(0, 60, dyn[0])
- rt, _, _ = c.open(60, OREAD)
- ok("the failed-remove fid's slot was released", rt == Ropen, rt)
- # a clone of an open dynamic fid takes no slot and is unopened
- rt, _, _ = c.walk(60, 61, [])
- ok("clone of an open dynamic fid is allowed", rt == Rwalk, rt)
- ok("the clone is not open", c.err(Tread, struct.pack("<IQI", 61, 0, 10)) == "file not open")
- ok("the clone cannot open (slots exhausted again)", c.err(Topen, struct.pack("<IB", 61, OREAD)) == "too many open dynamic files")
- # Tversion releases everything: 8 opens succeed again
- rt, ms, _ = c.version(65536)
- ok("mid-session Tversion", rt == base.Rversion)
- c.attach()
- n3 = 0
- for i, names in enumerate(dyn[:SNAPSHOT_SLOTS]):
- c.walk_ok(0, 100 + i, names)
- n3 += c.open(100 + i, OREAD)[0] == Ropen
- ok("after Tversion all 8 slots are free again", n3 == SNAPSHOT_SLOTS, n3)
- c.close()
- ok("server healthy after snapshot attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- the static tree
-
-
-def attack_static(path):
- print("# static tree: create/remove/wstat everywhere, '.'/'..' on files and provider roots")
- c = Nine(path)
- c.session()
- dirs = [[], [b"build"], [b"comptime"], [b"comptime", b"types"], [b"comptime", b"types", b"Qid"], [b"runtime"], [b"runtime", b"fn"]]
- for names in dirs:
- p = "/" + b"/".join(names).decode()
- ok(f"walk {p}", c.walk_ok(0, 1, names) == len(names))
- ok(f"create in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok(f"mkdir in {p} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"d") + struct.pack("<IB", DMDIR | 0o755, OREAD)) == "permission denied")
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mtime=1))) == "permission denied")
- ok(f"wstat of {p} with all don't-care is denied too", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat())) == "permission denied")
- ok(f"open {p} ORDWR is 'is a directory'", c.err(Topen, struct.pack("<IB", 1, ORDWR)) == "is a directory")
- ok(f"open {p} OEXEC works like OREAD", c.open(1, OEXEC)[0] == Ropen)
- ok(f"write to open {p} is 'is a directory'", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") is not None)
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- files = [[b"README"], [b"build", b"time"], [b"comptime", b"decls"], [b"comptime", b"types", b"Qid", b"fields"], [b"runtime", b"pid"], [b"runtime", b"fn", b"fib30"], [b"runtime", b"ctl"]]
- for names in files:
- p = "/" + b"/".join(names).decode()
- ok(f"walk {p}", c.walk_ok(0, 1, names) == len(names))
- ok(f"'.' from {p} is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == "not a directory")
- ok(f"'..' from {p} is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"..")) == "not a directory")
- ok(f"wstat of {p} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=0))) == "permission denied")
- ok(f"remove {p} is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- if names[-1] != b"ctl":
- c.walk_ok(0, 1, names)
- ok(f"open {p} OWRITE is denied", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == "permission denied")
- ok(f"open {p} OREAD|OTRUNC is denied", c.err(Topen, struct.pack("<IB", 1, OREAD | OTRUNC)) == "permission denied")
- ok(f"open {p} ORCLOSE alone reads (no removal on clunk)", c.open(1, OREAD | ORCLOSE)[0] == Ropen and c.read(1, 0, 10)[0] == Rread)
- c.clunk(1)
- ok(f"{p} still exists after ORCLOSE clunk", c.walk_ok(0, 1, names) == len(names))
- c.clunk(1)
- # '.' and '..' on provider roots: '.' is the same qid, '..' is the server root
- for prov in (b"scratch", b"threads", b"addr", b"mem", b"hex", b"breakpoints", b"panic"):
- n = c.walk_ok(0, 1, [prov])
- rt, _, rb = c.walk(1, 2, [b".", b".."])
- q = qid_of_walk(rb) if rt == Rwalk else []
- rt2, st = c.stat(1)
- ok(f"/{prov.decode()}: '.' keeps the qid and '..' reaches the root", n == 1 and len(q) == 2 and q[0][2] == st["qid"][2] and q[1][2] == 0xFF << 56, (n, q))
- ok(f"/{prov.decode()}: root stat name is the mount name", rt2 == Rstat and st["name"] == prov, st)
- ok(f"/{prov.decode()}: rename of the provider root is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"other"))) == "permission denied")
- ok(f"/{prov.decode()}: remove of the provider root is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- c.clunk(2)
- # qid path spaces do not collide: static tag 0xFF, vars 0xFE, providers 0..n
- seen = {}
- for names in dirs + files + [[b"vars"], [b"vars", b"state"], [b"vars", b"state", b"value"], [b"scratch"], [b"threads"], [b"panic", b"message"], [b"mem", b"maps"]]:
- c.walk_ok(0, 1, names)
- rt, st = c.stat(1)
- c.clunk(1)
- key = st["qid"][2]
- ok(f"qid path of /{b'/'.join(names).decode()} is unique", key not in seen, (key, seen.get(key)))
- seen[key] = names
- # walks through the whole tree with 16 elements of '..' never leave the root
- ok("16 x '..' from root stays at root", c.walk_ok(0, 1, [b".."] * 16) == 16)
- rt, st = c.stat(1)
- ok(" and it is the root", rt == Rstat and st["qid"][2] == 0xFF << 56)
- c.clunk(1)
- c.close()
- ok("server healthy after static attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- debug providers as black boxes
-
-
-def attack_debug_providers(path):
- print("# debug providers: hostile names, offsets, writes; the server must answer or Rerror, never die")
- c = Nine(path, timeout=15)
- c.session()
- tid = worker_tid(c)
- ok("worker thread found under /threads", tid is not None, tid)
- hostile = [b"0", b"0x", b"0x0", b"-1", b"+1", b"00", b"ffffffffffffffff", b"0xffffffffffffffff", b"1" * 13, b"1" * 12, b"zzz", b"1e5", b" 1", b"1 ", b"0x0000000000000001", b"8", b"0x7fffffffffff", b"ffffffffffff", b"\x00", b"." * 3, b"a" * 255]
- for tree in (b"addr", b"hex", b"mem"):
- for nm in hostile:
- n = c.walk_ok(0, 1, [tree, nm])
- if n == 2:
- rt, _, rb = c.open(1, OREAD)
- if rt == Ropen:
- rt2, d = c.read(1, 0, 4096)
- rt3, d3 = c.read(1, (1 << 64) - 1, 4096)
- rt4, d4 = c.read(1, (1 << 63), 4096)
- ok(f"/{tree.decode()}/{nm[:16]!r}: reads at 0, 2^63 and 2^64-1 are answered", rt2 in (Rread, Rerror) and rt3 in (Rread, Rerror) and rt4 in (Rread, Rerror), (rt2, rt3, rt4))
- else:
- ok(f"/{tree.decode()}/{nm[:16]!r}: open answered", rt == Rerror, rt)
- c.clunk(1)
- else:
- ok(f"/{tree.decode()}/{nm[:16]!r}: walk refused or partial", n in (1, None), n)
- for nm in hostile + [b"1", b"4294967295", b"4294967296", b"99999999999", b"0" + (tid or b"1")]:
- n = c.walk_ok(0, 1, [b"threads", nm])
- if n == 2:
- for leaf in (b"name", b"stat", b"stack", b"regs"):
- rt, _, _ = c.walk(1, 2, [leaf])
- if rt == Rwalk:
- rt, _, _ = c.open(2, OREAD)
- if rt == Ropen:
- c.read(2, 0, 8192)
- c.clunk(2)
- c.clunk(1)
- ok(f"/threads/{nm!r} walked (a live tid) and its files answered", True)
- else:
- ok(f"/threads/{nm!r}: walk refused or partial", n in (1, None), n)
- n = c.walk_ok(0, 1, [b"breakpoints", nm])
- ok(f"/breakpoints/{nm!r}: walk refused (nothing paused)", n in (1, None), n)
- # files under a thread: '.'/'..' and walking through them
- if tid:
- ok("/threads/<tid>/stack/x is 'not a directory'", c.walk_ok(0, 1, [b"threads", tid, b"stack"]) == 3 and c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"x")) == "not a directory")
- ok("'..' from /threads/<tid>/stack is 'not a directory'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"..")) == "not a directory")
- c.clunk(1)
- ok("/threads/<tid>/../../<tid>/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/<tid> is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok("wstat of /threads/<tid> is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"y"))) == "permission denied")
- ok("remove of /threads/<tid> is denied", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- stack = c.path_read([b"threads", tid, b"stack"])
- ok("worker stack reads", stack is not None and b"workerLoop" in stack, stack)
- # thread stack at offset 1 is the tail (dynamic snapshot in the provider)
- c.walk_ok(0, 1, [b"threads", tid, b"stack"])
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 65000)
- rt2, d2 = c.read(1, 1, 65000)
- ok("thread stack offset-1 read is the tail of the same snapshot", rt == Rread and rt2 == Rread and d2 == d[1:], (len(d), len(d2)))
- c.clunk(1)
- # /mem: read of unmapped memory is an error, write of unmapped memory is an error; writes at wild offsets too
- c.walk_ok(0, 1, [b"mem", b"8"])
- rt, _, _ = c.open(1, ORDWR)
- ok("/mem/8 opens", rt == Ropen, rt)
- rt, d = c.read(1, 0, 16)
- ok("read of unmapped memory is an Rerror", rt == Rerror, rt)
- rt, _, _ = c.write(1, 0, b"x")
- ok("write to unmapped memory is an Rerror", rt == Rerror, rt)
- rt, _, _ = c.write(1, (1 << 64) - 9, b"x")
- ok("write at a wrapping offset is answered", rt in (Rerror, Rwrite), rt)
- rt, _, _ = c.write(1, 0, b"")
- ok("empty write to /mem is answered", rt in (Rerror, Rwrite), rt)
- c.clunk(1)
- addr = c.path_read([b"vars", b"state", b"addr"])
- ok("/vars/state/addr reads", addr is not None and addr.startswith(b"0x"), addr)
- if addr:
- hx = addr[2:]
- size = int(c.path_read([b"vars", b"state", b"size"]))
- c.walk_ok(0, 1, [b"mem", hx])
- c.open(1, OREAD)
- rt, d = c.read(1, 0, size)
- ok("/mem/<state addr> 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/<state addr> 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("<IB", 1, OREAD)) == "permission denied")
- c.clunk(1)
- c.walk_ok(0, 1, [b"panic", b"ctl"])
- rt, _, _ = c.open(1, OWRITE)
- ok("/panic/ctl opens OWRITE", rt == Ropen, rt)
- for bad in (b"garbage", b"", b"continue please", b"\x00continue"):
- rt, _, _ = c.write(1, 0, bad)
- ok(f"/panic/ctl write {bad!r} without a panic is an Rerror", rt == Rerror, rt)
- c.clunk(1)
- # /breakpoints is empty; the debug providers refuse create/wstat/remove
- for prov in (b"threads", b"addr", b"mem", b"hex", b"breakpoints", b"panic"):
- c.walk_ok(0, 1, [prov])
- ok(f"create in /{prov.decode()} is denied", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"x") + struct.pack("<IB", 0o644, OWRITE)) == "permission denied")
- ok(f"wstat of /{prov.decode()} is denied", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mtime=5))) == "permission denied")
- c.open(1, OREAD)
- d = c.read_all(1)
- ok(f"/{prov.decode()} lists", d is not None)
- c.clunk(1)
- # the provider's snapshot pool (shared by every connection) recovers after exhaustion
- held = []
- err = None
- for i in range(16):
- names = [b"addr", b"%x" % (0x1000 + i)]
- c.walk_ok(0, 200 + i, names)
- rt, _, rb = c.open(200 + i, OREAD)
- if rt == Ropen:
- held.append(200 + i)
- else:
- n, = struct.unpack_from("<H", rb)
- err = rb[2:2 + n].decode()
- break
- ok("debug provider snapshot pool exhausts with an Rerror", err is not None and len(held) >= 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("<IIH", 0, fid, len(names)) + b"".join(s16(n) for n in names)) for i, fid in enumerate(ids))
- c.raw(body)
- t.join(120)
- return got[0], got[1], time.time() - t0, dead[0] or t.is_alive()
-
-
-def attack_fid_table(path):
- print(f"# fid table: {MAX_FIDS} fids, adversarial ids, clunk all, reuse")
- c = Nine(path, timeout=60)
- c.session()
- n = MAX_FIDS - 1 # fid 0 is the attach
- ids = []
- for i in range(n):
- k = i % 3
- ids.append(i * 8192 + 1 if k == 0 else (0x80000000 | i) if k == 1 else 0xFFFFFFFE - i)
- assert len(set(ids)) == n and 0 not in ids
- good, bad, dt, dead = flood(c, ids, [b"scratch"])
- ok(f"{n} walks with adversarial fid numbers all succeed", good == n and bad == 0 and not dead, (good, bad, dead))
- print(f" {n} clones (provider handles) in {dt:.2f}s")
- ok("the next fid is 'too many fids'", c.err(Twalk, struct.pack("<IIH", 0, 7, 0)) == "too many fids")
- ok("attach at the limit is 'too many fids'", c.err(base.Tattach, struct.pack("<II", 7, base.NOFID) + s16(b"u") + s16(b"")) == "too many fids")
- ok("an existing id is 'fid in use'", c.err(Twalk, struct.pack("<IIH", 0, ids[12345], 0)) == "fid in use")
- ok("a self-walk at the limit works", c.walk_ok(ids[5], ids[5], [b".."]) == 1)
- ok("an unknown fid at the limit is 'unknown fid'", c.err(Tstat, struct.pack("<I", 7)) == "unknown fid")
- # clunk all, pipelined, in a hostile order (every third first, then the rest reversed)
- order = ids[::3] + ids[1::3][::-1] + ids[2::3][::-1]
- got = [0]
- dead = [False]
-
- def reader():
- try:
- for _ in order:
- rt, _, _ = c.recv_frame()
- got[0] += rt == Rclunk
- except (EOFError, OSError):
- dead[0] = True
-
- t = threading.Thread(target=reader)
- t.start()
- t0 = time.time()
- c.raw(b"".join(frame(Tclunk, i & 0xFFFE, struct.pack("<I", fid)) for i, fid in enumerate(order)))
- t.join(120)
- ok(f"{n} clunks all answered", got[0] == n and not dead[0] and not t.is_alive(), (got[0], dead[0]))
- print(f" {n} clunks in {time.time() - t0:.2f}s")
- ok("clunk of a clunked fid is 'unknown fid'", c.err(Tclunk, struct.pack("<I", ids[100])) == "unknown fid")
- # reuse: the whole table is available again with dense ids
- good, bad, dt, dead = flood(c, list(range(1, n + 1)), [])
- ok(f"{n} clones with dense ids after the churn all succeed", good == n and bad == 0 and not dead, (good, bad, dead))
- print(f" {n} clones (reuse) in {dt:.2f}s")
- ok("still 'too many fids' at the limit", c.err(Twalk, struct.pack("<IIH", 0, n + 1, 0)) == "too many fids")
- rt, _, _ = c.version(65536)
- ok("Tversion after the fid churn", rt == base.Rversion)
- c.attach()
- good, bad, dt, dead = flood(c, list(range(1, 1001)), [b"scratch"])
- ok("1000 clones after Tversion", good == 1000 and bad == 0, (good, bad))
- c.close()
- ok("server healthy after the fid table attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- flush storm
-
-
-def attack_flush(path):
- print("# Tflush storm")
- c = Nine(path, timeout=30)
- c.session()
- n = 2000
- blob = b"".join(frame(Tflush, i & 0xFFFE, struct.pack("<H", (i * 7919) & 0xFFFF)) for i in range(n))
- got = [0]
-
- def reader():
- try:
- for _ in range(n):
- rt, _, _ = c.recv_frame()
- got[0] += rt == Rflush
- except (EOFError, OSError):
- pass
-
- t = threading.Thread(target=reader)
- t.start()
- c.raw(blob)
- t.join(60)
- ok(f"{n} pipelined Tflush (random oldtags, including own tag) all Rflush", got[0] == n, got[0])
- rt, tag, _ = c.call(Tflush, struct.pack("<H", 5), 5)
- ok("Tflush of its own tag is Rflush", rt == Rflush and tag == 5, (rt, tag))
- rt, st = c.stat(0)
- ok("a normal request after the storm works", rt == Rstat, rt)
- c.close()
- ok("server healthy after the flush storm", healthy(path))
-
-
-# --------------------------------------------------------------------------- msize 24
-
-
-def attack_msize24(path):
- print("# msize 24: everything that fits is served, everything else is an Rerror that fits")
- c = Nine(path)
- rt, ms, ver = c.version(24)
- ok("Tversion 24", rt == base.Rversion and ms == 24, (rt, ms))
- rt, _, _ = c.attach(uname=b"u") # 20 bytes; Rattach is 20
- ok("Tattach at msize 24", rt == base.Rattach, rt)
- e = c.err(Tstat, struct.pack("<I", 0))
- ok("Tstat: Rerror truncated to 15 bytes ('reply too large')", e == "reply too large", e)
- rt, _, rb = c.walk(0, 1, [b"build"]) # Twalk 24, Rwalk 22
- ok("Twalk of one 5-byte name", rt == Rwalk, rt)
- e = c.err(Twalk, struct.pack("<IIH", 0, 2, 2) + s16(b".") + s16(b".")) # 23 bytes; Rwalk would be 35
- ok("Twalk of two names cannot be answered: 'reply too large'", e == "reply too large", e)
- ok("newfid unbound after the refused walk", c.err(Tclunk, struct.pack("<I", 2)) == "unknown fid")
- rt, _, _ = c.open(1, OREAD) # Ropen 24
- ok("Topen at msize 24", rt == Ropen, rt)
- rt, d = c.read(1, 0, 4096) # count clamped to msize - iohdrsz = 0
- ok("Tread of a directory at msize 24 answers an empty Rread (no split record)", rt == Rread and d == b"", (rt, d))
- e = c.err(Tread, struct.pack("<IQI", 1, 1, 4096))
- ok("dir read at offset 1 is 'bad offset'", e == "bad offset", e)
- rt, _, _ = c.clunk(1)
- ok("Tclunk at msize 24", rt == Rclunk, rt)
- rt, _, _ = c.walk(0, 1, [b"vars"]) # Twalk 23
- rt, _, _ = c.walk(1, 1, [b"state"]) # 23
- rt, _, _ = c.walk(1, 1, [b"value"]) # 23
- ok("walk to /vars/state/value in 3 self-walks", rt == Rwalk, rt)
- rt, _, _ = c.open(1, OREAD)
- ok("open a dynamic file at msize 24", rt == Ropen, rt)
- rt, d = c.read(1, 0, 4096)
- ok("read of a dynamic file at msize 24 is an empty Rread", rt == Rread and d == b"", (rt, d))
- rt, _, _ = c.clunk(1)
- for nm in (b"vars", b"state", b"f", b"ticks", b"value"): # each Twalk <= 24 bytes
- rt, _, _ = c.walk(0 if nm == b"vars" else 1, 1, [nm])
- ok("walk to /vars/state/f/ticks/value in 5 self-walks", rt == Rwalk, rt)
- rt, _, _ = c.open(1, OWRITE)
- ok("open a writable value at msize 24", rt == Ropen, rt)
- rt, _, _ = c.write(1, 0, b"5") # Twrite 24, Rwrite 11
- ok("Twrite of one byte at msize 24", rt == Rwrite, rt)
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, 0) + b"")
- ok("empty write to a value at msize 24 is 'bad value'", e == "bad value", e)
- rt, _, _ = c.clunk(1)
- ok("clunk at msize 24", rt == Rclunk, rt)
- rt, ms, _ = c.version(65536)
- ok("renegotiate a big msize on the same connection", rt == base.Rversion and ms == 65536, (rt, ms))
- c.attach()
- ok("normal service resumes", c.path_read([b"build", b"zig_version"]) not in (None, b""))
- c.close()
- # frames larger than 24 after negotiating 24 kill the connection
- c = Nine(path)
- c.version(24)
- c.raw(frame(base.Tattach, 1, struct.pack("<II", 0, base.NOFID) + s16(b"longer-name") + s16(b"")))
- ok("a 30-byte Tattach at msize 24: connection closed", expect_dead(c))
- c.close()
- ok("server healthy after msize-24 attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- directory offsets
-
-
-def attack_dir_offsets(path):
- print("# directory reads: exact record boundaries vs off by one")
- c = Nine(path)
- c.session()
- tag = os.urandom(3).hex().encode()
- root = b"do-" + tag
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, root, DMDIR | 0o755, OREAD)
- c.clunk(1)
- for nm in (b"alpha", b"beta-with-a-longer-name", b"g"):
- c.walk_ok(0, 1, [b"scratch", root])
- c.create(1, nm, 0o644, OWRITE)
- c.clunk(1)
- for names in ([], [b"vars", b"state"], [b"comptime", b"types"], [b"scratch", root], [b"threads"], [b"panic"]):
- p = "/" + b"/".join(names).decode()
- c.walk_ok(0, 1, names)
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 65000)
- recs = records(d)
- if len(recs) < 2:
- ok(f"{p}: at least two entries", False, len(recs))
- c.clunk(1)
- continue
- r0 = len(recs[0][1])
- r1 = len(recs[1][1])
- rt, d0 = c.read(1, 0, r0)
- ok(f"{p}: count = first record length returns exactly that record", rt == Rread and d0 == recs[0][1], (rt, len(d0) if d0 else d0, r0))
- rt, d1 = c.read(1, r0, r1)
- ok(f"{p}: read at the record boundary returns the next record", rt == Rread and d1 == recs[1][1], (rt, len(d1) if d1 else d1))
- e = c.err(Tread, struct.pack("<IQI", 1, r0 + r1 + 1, 65000))
- ok(f"{p}: offset boundary+1 is 'bad offset'", e == "bad offset", e)
- e = c.err(Tread, struct.pack("<IQI", 1, r0 + r1 - 1, 65000))
- ok(f"{p}: offset boundary-1 is 'bad offset'", e == "bad offset", e)
- e = c.err(Tread, struct.pack("<IQI", 1, r0, 65000))
- ok(f"{p}: re-reading an earlier boundary is 'bad offset'", e == "bad offset", e)
- rt, rest = c.read(1, r0 + r1, 65000)
- ok(f"{p}: after a bad offset the good boundary still continues", rt == Rread and rest == d[r0 + r1:], rt)
- rt, dd = c.read(1, 0, r0 - 1)
- ok(f"{p}: count one short of a record returns nothing (no split)", rt == Rread and dd == b"", (rt, dd))
- rt, dd = c.read(1, 0, 65000)
- ok(f"{p}: offset 0 restarts and yields the same bytes", rt == Rread and dd == d)
- rt, dd = c.read(1, len(d), 65000)
- ok(f"{p}: read at the end is empty", rt == Rread and dd == b"")
- rt, dd = c.read(1, len(d), 65000)
- ok(f"{p}: read at the end twice is empty twice", rt == Rread and dd == b"")
- c.clunk(1)
- for nm in (b"alpha", b"beta-with-a-longer-name", b"g"):
- c.walk_ok(0, 1, [b"scratch", root, nm])
- c.remove(1)
- c.walk_ok(0, 1, [b"scratch", root])
- ok("cleanup of the directory-offset test root", c.remove(1)[0] == Rremove)
- c.close()
- ok("server healthy after directory offset attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- ctl staging
-
-
-def attack_ctl(path):
- print("# ctl: a failed command leaves the previous result, length and qid version untouched")
- c = Nine(path)
- c.session(1 << 20)
- c.walk_ok(0, 1, [b"runtime", b"ctl"])
- c.open(1, ORDWR)
- rt, _, _ = c.write(1, 0, b"echo persist")
- rt, st = c.stat(1)
- v = st["qid"][1]
- ok("echo persist", st["length"] == 7, st)
- for bad in (b"nope", b"fib 94", b"add 1", b"", b"\x00", b"echo\x00hidden"):
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, len(bad)) + bad)
- rt, d = c.read(1, 0, 100)
- rt2, st2 = c.stat(1)
- ok(f"after failed {bad!r}: result still 'persist'", d == b"persist", d)
- ok(f"after failed {bad!r}: length 7 and qid version unchanged", st2["length"] == 7 and st2["qid"][1] == v, (st2["length"], st2["qid"][1], v))
- # a second connection sees the same result and version
- c2 = Nine(path)
- c2.session()
- ok("other connection reads the surviving result", c2.path_read([b"runtime", b"ctl"]) == b"persist")
- c2.walk_ok(0, 1, [b"runtime", b"ctl"])
- rt, st3 = c2.stat(1)
- ok("other connection sees the same version", st3["qid"][1] == v, (st3["qid"][1], v))
- c2.close()
- # a successful command bumps the version and replaces the result; an empty result is a result
- rt, _, _ = c.write(1, 0, b"echo")
- rt, st4 = c.stat(1)
- rt, d = c.read(1, 0, 100)
- ok("echo with no argument yields an empty result with a new version", d == b"" and st4["length"] == 0 and st4["qid"][1] != v, (d, st4))
- e = c.err(Twrite, struct.pack("<IQI", 1, 0, 4) + b"nope")
- rt, d = c.read(1, 0, 100)
- ok("a failure after an empty result keeps it empty", d == b"", d)
- # the largest result: echo of a 60000-byte line
- big = b"echo " + b"y" * 60000
- rt, _, _ = c.write(1, 0, big)
- d = c.read_all(1)
- ok("60000-byte ctl result round-trips", rt == Rwrite and d == b"y" * 60000, (rt, len(d) if d else d))
- rt, _, _ = c.write(1, 0, b"echo " + b"z" * 70000) # exceeds ctl_bytes (64 KiB) -> 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("<IB", 1, OREAD)) == "file already open")
- ok("walk with names from an open fid is 'file already open'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == "file already open")
- ok("create on an open fid is 'file already open'", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) == "file already open")
- rt, _, _ = c.walk(1, 2, [])
- ok("clone of an open fid is allowed", rt == Rwalk)
- ok("the clone is not open", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == "file not open")
- rt, _, _ = c.open(2, OREAD)
- ok("the clone opens independently", rt == Ropen)
- c.write(1, 0, b"data")
- rt, d = c.read(2, 0, 10)
- ok("the clone sees the write", rt == Rread and d == b"data", d)
- rt, _, _ = c.wstat(2, mkstat(name=b"renamed"))
- ok("wstat through an open fid works", rt == Rwstat)
- rt, _, _ = c.remove(1)
- ok("remove through the open writer fid", rt == Rremove)
- rt, d = c.read(2, 0, 10)
- ok("the other open fid still reads the removed file", rt == Rread and d == b"data", d)
- ok("stat of the removed file still answers", c.stat(2)[0] == Rstat)
- ok("open of a removed file through a new walk is impossible (not found)", c.walk_ok(0, 3, S + [b"renamed"]) == 2)
- c.clunk(2)
- # newfid == fid walks on unopened fids rebind; on the same fid with a failing later element they do nothing
- c.walk_ok(0, 3, S)
- rt, _, rb = c.walk(3, 3, [b"..", root, b"nope"])
- n = struct.unpack_from("<H", rb)[0] if rt == Rwalk else None
- ok("partial self-walk returns 2 and leaves the fid where it was", n == 2 and c.stat(3)[1]["name"] == root, (n,))
- rt, _, _ = c.walk(3, 3, [b"..", b".."])
- rt, st = c.stat(3)
- ok("self-walk with names rebinds the fid", rt == Rstat and st["qid"][2] == 0xFF << 56, st)
- c.clunk(3)
- # Tversion while a removed-but-held file exists: nothing leaks, the server keeps serving
- c.walk_ok(0, 4, S)
- c.create(4, b"held", 0o644, OWRITE)
- c.write(4, 0, b"x" * 1000)
- c.walk_ok(0, 5, S + [b"held"])
- c.remove(5)
- c.version(65536)
- c.attach()
- ok("after Tversion the removed file is gone", c.walk_ok(0, 1, S + [b"held"]) == 2)
- c.clunk(1)
- c.walk_ok(0, 1, S)
- rt, _, _ = c.remove(1)
- ok("cleanup", rt == Rremove, rt)
- c.close()
- ok("server healthy after fid state attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- scratch budget
-
-
-def attack_scratch_budget(path):
- print("# scratch: the global budget after grow/truncate/rename/remove/failed writes")
- c = Nine(path, timeout=120)
- c.session()
- tag = os.urandom(3).hex().encode()
- root = b"bg-" + tag
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, root, DMDIR | 0o755, OREAD)
- c.clunk(1)
- S = [b"scratch", root]
- per = SCRATCH_MAX_FILE
- count = SCRATCH_BUDGET // per
- t0 = time.time()
- made = 0
- for i in range(count):
- c.walk_ok(0, 1, S)
- rt, _, _ = c.create(1, b"big%d" % i, 0o644, OWRITE)
- rt, _, _ = c.wstat(1, mkstat(length=per))
- c.clunk(1)
- if rt != Rwstat:
- break
- made += 1
- ok(f"{count} files of {per >> 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("<IQI", 1, 0, 1) + b"x") == "no space left on device")
- ok("a failed write leaves the file empty", c.stat(1)[1]["length"] == 0)
- ok("a zero-length write at a huge offset is still fine", c.write(1, (1 << 60), b"")[0] == Rwrite)
- ok("wstat length 1 is 'no space left on device'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=1))) == "no space left on device")
- # rename does not charge; truncate releases exactly its size
- c.walk_ok(0, 2, S + [b"big0"])
- ok("rename of a full file is fine", c.wstat(2, mkstat(name=b"big0-r"))[0] == Rwstat)
- ok("still no space after the rename", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") == "no space left on device")
- ok("truncate big0-r to 1 MiB", c.wstat(2, mkstat(length=1 << 20))[0] == Rwstat)
- c.clunk(2)
- rt, _, _ = c.wstat(1, mkstat(length=per - (1 << 20)))
- ok("exactly the released amount is writable again", rt == Rwstat, rt)
- ok("and not one byte more", c.err(Twrite, struct.pack("<IQI", 1, per - (1 << 20), 1) + b"x") == "no space left on device")
- ok("nor via wstat", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=per - (1 << 20) + 1))) == "no space left on device")
- # overwriting existing bytes costs nothing
- ok("overwrite inside the file is fine", c.write(1, 0, b"y" * 4096)[0] == Rwrite)
- ok("overwrite at the very end is fine", c.write(1, per - (1 << 20) - 4096, b"y" * 4096)[0] == Rwrite)
- # OTRUNC of a full file releases; remove of a held file releases only on the last clunk
- c.walk_ok(0, 3, S + [b"big1"])
- ok("OTRUNC releases", c.open(3, OWRITE | OTRUNC)[0] == Ropen and c.stat(3)[1]["length"] == 0)
- ok("the released space is writable", c.wstat(1, mkstat(length=per))[0] == Rwstat)
- ok("a full file writes into itself", c.write(1, per - 10, b"0123456789")[0] == Rwrite)
- ok("but not past the per-file cap", c.err(Twrite, struct.pack("<IQI", 1, per - 1, 2) + b"xy") == "no space left on device")
- c.clunk(3)
- c.clunk(1)
- # refill the remaining 63 MiB so the budget is exactly full again
- c.walk_ok(0, 7, S)
- c.create(7, b"fill", 0o644, OWRITE)
- ok("the rest of the budget fills exactly", c.wstat(7, mkstat(length=per - (1 << 20)))[0] == Rwstat)
- ok("and is full again", c.err(Twrite, struct.pack("<IQI", 7, per - (1 << 20), 1) + b"x") == "no space left on device")
- c.clunk(7)
- c.walk_ok(0, 4, S + [b"big2"])
- c.walk_ok(0, 5, S + [b"big2"])
- c.open(5, OREAD)
- c.remove(4)
- c.walk_ok(0, 6, S)
- c.create(6, b"after-remove", 0o644, OWRITE)
- ok("space of a removed-but-held file is not released yet", c.err(Twrite, struct.pack("<IQI", 6, 0, 1) + b"x") == "no space left on device")
- c.clunk(5)
- ok("the last clunk releases it", c.write(6, 0, b"x")[0] == Rwrite)
- c.clunk(6)
- # cleanup
- c.walk_ok(0, 1, S)
- c.open(1, OREAD)
- names = [n for n, _ in records(c.read_all(1))]
- c.clunk(1)
- for nm in names:
- if c.walk_ok(0, 1, S + [nm]) == 3:
- c.remove(1)
- c.walk_ok(0, 1, S)
- ok("cleanup removed the budget test root", c.remove(1)[0] == Rremove, names)
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, b"post-" + tag, 0o644, OWRITE)
- ok("the whole budget is back: a 64 MiB file fits", c.wstat(1, mkstat(length=per))[0] == Rwstat)
- c.remove(1)
- c.close()
- ok("server healthy after budget attacks", healthy(path))
-
-
-# --------------------------------------------------------------------------- main
-
-
-def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--server")
- ap.add_argument("--socket")
- ap.add_argument("--fast", action="store_true", help="skip the 512 MiB scratch budget fill")
- args = ap.parse_args()
- proc = None
- tmp = None
- if args.server:
- tmp = tempfile.mkdtemp(prefix="adv9pcore.")
- path = os.path.join(tmp, "sock")
- proc = subprocess.Popen([os.path.abspath(args.server), "--unix", path], stderr=subprocess.PIPE)
- for _ in range(200):
- if os.path.exists(path):
- break
- time.sleep(0.02)
- elif args.socket:
- path = args.socket
- else:
- ap.error("--server or --socket")
- try:
- attack_vars(path)
- attack_snapshots(path)
- attack_static(path)
- attack_debug_providers(path)
- attack_dir_offsets(path)
- attack_ctl(path)
- attack_fid_states(path)
- attack_msize24(path)
- attack_flush(path)
- attack_fid_table(path)
- if not args.fast:
- attack_scratch_budget(path)
- if proc is not None:
- 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"# {base.PASSES} passed, {len(base.FAILS)} failed")
- for f in base.FAILS:
- print("# FAIL " + f)
- sys.exit(1 if base.FAILS else 0)
-
-
-if __name__ == "__main__":
- main()
diff --git a/introspect/test/adv_core_hostile.sh b/introspect/test/adv_core_hostile.sh
deleted file mode 100755
index bf4f812..0000000
--- a/introspect/test/adv_core_hostile.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-# Adversarial raw-9P2000 client tests aimed at the introspect core.
-# Usage: bash introspect/test/adv_core_hostile.sh <introspect> [--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("<H", len(b)) + b
-
-
-def frame(typ, tag, body):
- return struct.pack("<IBH", 7 + len(body), typ, tag) + body
-
-
-def mkstat(name=b"", uid=b"", gid=b"", muid=b"", typ=0xFFFF, dev=0xFFFFFFFF, qtype=0xFF, qvers=0xFFFFFFFF,
- qpath=0xFFFFFFFFFFFFFFFF, mode=0xFFFFFFFF, atime=0xFFFFFFFF, mtime=0xFFFFFFFF,
- length=0xFFFFFFFFFFFFFFFF):
- body = struct.pack("<HIBIQIIIQ", typ, dev, qtype, qvers, qpath, mode, atime, mtime, length)
- body += s16(name) + s16(uid) + s16(gid) + s16(muid)
- return struct.pack("<H", len(body)) + body
-
-
-def parse_stat(b):
- n, = struct.unpack_from("<H", b, 0)
- typ, dev, qtype, qvers, qpath, mode, atime, mtime, length = struct.unpack_from("<HIBIQIIIQ", b, 2)
- off = 2 + 2 + 4 + 13 + 4 + 4 + 4 + 8
- strs = []
- for _ in range(4):
- ln, = struct.unpack_from("<H", b, off)
- strs.append(b[off + 2:off + 2 + ln])
- off += 2 + ln
- assert off == n + 2, (off, n)
- return dict(type=typ, dev=dev, qid=(qtype, qvers, qpath), mode=mode, atime=atime, mtime=mtime,
- length=length, name=strs[0], uid=strs[1], gid=strs[2], muid=strs[3])
-
-
-class Nine:
- """One raw 9P connection; every call returns (type, tag, body) or raises."""
-
- def __init__(self, path, timeout=5.0):
- self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- self.s.settimeout(timeout)
- self.s.connect(path)
- self.tag = 1
- self.buf = b""
-
- def close(self):
- try:
- self.s.close()
- except OSError:
- pass
-
- def raw(self, data):
- try:
- self.s.sendall(data)
- except OSError:
- pass # the server may already have closed; the caller checks with expect_dead()
-
- def recv_frame(self):
- while len(self.buf) < 4:
- d = self.s.recv(65536)
- if not d:
- raise EOFError("server closed")
- self.buf += d
- size, = struct.unpack_from("<I", self.buf)
- while len(self.buf) < size:
- d = self.s.recv(65536)
- if not d:
- raise EOFError("server closed")
- self.buf += d
- f, self.buf = self.buf[:size], self.buf[size:]
- typ, tag = struct.unpack_from("<BH", f, 4)
- return typ, tag, f[7:]
-
- def call(self, typ, body, tag=None):
- if tag is None:
- tag = self.tag
- self.tag = (self.tag + 1) & 0xFFFE
- try:
- self.raw(frame(typ, tag, body))
- rt, rtag, rb = self.recv_frame()
- except (EOFError, OSError):
- return None, None, b""
- return rt, rtag, rb
-
- def expect(self, typ, body, want, tag=None):
- rt, rtag, rb = self.call(typ, body, tag)
- return rt == want, rt, rb
-
- def err(self, typ, body):
- rt, _, rb = self.call(typ, body)
- if rt != Rerror:
- return None
- n, = struct.unpack_from("<H", rb)
- return rb[2:2 + n].decode("utf-8", "replace")
-
- # conveniences
- def version(self, msize=65536, version=b"9P2000"):
- rt, rtag, rb = self.call(Tversion, struct.pack("<I", msize) + s16(version), NOTAG)
- if rt != Rversion:
- return rt, None, None
- ms, = struct.unpack_from("<I", rb)
- n, = struct.unpack_from("<H", rb, 4)
- return rt, ms, rb[6:6 + n]
-
- def attach(self, fid=0, uname=b"hostile", aname=b""):
- return self.call(Tattach, struct.pack("<II", fid, NOFID) + s16(uname) + s16(aname))
-
- def walk(self, fid, newfid, names):
- b = struct.pack("<IIH", fid, newfid, len(names)) + b"".join(s16(n) for n in names)
- return self.call(Twalk, b)
-
- def walk_ok(self, fid, newfid, names):
- rt, _, rb = self.walk(fid, newfid, names)
- if rt != Rwalk:
- return None
- n, = struct.unpack_from("<H", rb)
- return n
-
- def open(self, fid, mode):
- return self.call(Topen, struct.pack("<IB", fid, mode))
-
- def create(self, fid, name, perm, mode):
- return self.call(Tcreate, struct.pack("<I", fid) + s16(name) + struct.pack("<IB", perm, mode))
-
- def read(self, fid, offset, count):
- rt, _, rb = self.call(Tread, struct.pack("<IQI", fid, offset, count))
- if rt != Rread:
- return rt, rb
- n, = struct.unpack_from("<I", rb)
- return rt, rb[4:4 + n]
-
- def write(self, fid, offset, data):
- return self.call(Twrite, struct.pack("<IQI", fid, offset, len(data)) + data)
-
- def clunk(self, fid):
- return self.call(Tclunk, struct.pack("<I", fid))
-
- def remove(self, fid):
- return self.call(Tremove, struct.pack("<I", fid))
-
- def stat(self, fid):
- rt, _, rb = self.call(Tstat, struct.pack("<I", fid))
- if rt != Rstat:
- return rt, rb
- n, = struct.unpack_from("<H", rb)
- return rt, parse_stat(rb[2:2 + n])
-
- def wstat(self, fid, st):
- return self.call(Twstat, struct.pack("<I", fid) + s16(st))
-
- def read_all(self, fid, chunk=8192):
- out = b""
- while True:
- rt, d = self.read(fid, len(out), chunk)
- if rt != Rread:
- return None
- if not d:
- return out
- out += d
-
- def path_read(self, names, fid=77):
- if self.walk_ok(0, fid, names) != len(names):
- return None
- rt, _, _ = self.open(fid, OREAD)
- if rt != Ropen:
- self.clunk(fid)
- return None
- d = self.read_all(fid)
- self.clunk(fid)
- return d
-
- def session(self, msize=65536):
- rt, ms, _ = self.version(msize)
- assert rt == Rversion, rt
- rt, _, _ = self.attach()
- assert rt == Rattach, rt
- return ms
-
-
-def healthy(path):
- """Fresh connection; the tree must still answer and /build/zig_version must be non-empty."""
- try:
- c = Nine(path, timeout=5.0)
- c.session()
- d = c.path_read([b"build", b"zig_version"])
- c.close()
- return bool(d)
- except Exception as e: # noqa: BLE001
- print(f" probe failed: {e!r}")
- return False
-
-
-def expect_dead(c):
- """The server must close the connection (EOF) rather than answer or hang."""
- try:
- c.s.settimeout(5.0)
- d = c.s.recv(4096)
- return d == b""
- except socket.timeout:
- return False
- except OSError:
- return True
-
-
-def rss_kb(pid):
- try:
- with open(f"/proc/{pid}/status") as f:
- for line in f:
- if line.startswith("VmRSS:"):
- return int(line.split()[1])
- except OSError:
- return -1
- return -1
-
-
-def threads(pid):
- try:
- return len(os.listdir(f"/proc/{pid}/task"))
- except OSError:
- return -1
-
-
-# --------------------------------------------------------------------------- attacks
-
-
-def attack_framing(path):
- print("# framing")
- c = Nine(path)
- c.raw(os.urandom(64))
- ok("garbage bytes: connection closed", expect_dead(c))
- c.close()
- for size in (0, 1, 6, 7, 0xFFFFFFFF, (1 << 20) + 1):
- c = Nine(path)
- c.raw(struct.pack("<I", size) + b"\x64\xff\xff" + b"\x00" * 16)
- ok(f"frame size {size}: connection closed", expect_dead(c))
- c.close()
- # exactly 7 bytes claiming size 7 with a bogus type
- c = Nine(path)
- c.raw(struct.pack("<IBH", 7, 0xEE, 1))
- ok("size-7 frame with unknown type: closed", expect_dead(c))
- c.close()
- # Terror (type 106) is reserved
- c = Nine(path)
- c.raw(frame(106, 1, b""))
- ok("Terror frame: closed", expect_dead(c))
- c.close()
- # an R-type sent to the server
- c = Nine(path)
- c.raw(frame(Rversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000")))
- ok("R-message sent to server: closed", expect_dead(c))
- c.close()
- # half a frame then disconnect
- c = Nine(path)
- c.raw(frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000"))[:9])
- c.close()
- # request before Tversion
- c = Nine(path)
- c.raw(frame(Tattach, 1, struct.pack("<II", 0, NOFID) + s16(b"u") + s16(b"")))
- ok("Tattach before Tversion: closed", expect_dead(c))
- c.close()
- # Tversion with a tag other than NOTAG
- c = Nine(path)
- c.raw(frame(Tversion, 5, struct.pack("<I", 8192) + s16(b"9P2000")))
- ok("Tversion with tag 5: closed", expect_dead(c))
- c.close()
- # Tversion msize below the resource floor
- for ms in (0, 1, 23):
- c = Nine(path)
- rt, _, _ = c.version(ms)
- ok(f"Tversion msize {ms}: no Rversion (closed or Rerror)", rt in (None, Rerror) or expect_dead(c))
- c.close()
- # tiny msize 24 is negotiable (Rversion fits); Tattach cannot fit, so use msize 64 for the rest
- c = Nine(path)
- rt, ms, ver = c.version(24)
- ok("Tversion msize 24 accepted", rt == Rversion and ms == 24 and ver == b"9P2000", f"{rt} {ms} {ver}")
- c.close()
- c = Nine(path)
- rt, ms, ver = c.version(64)
- ok("Tversion msize 64 accepted", rt == Rversion and ms == 64, f"{rt} {ms} {ver}")
- rt, _, _ = c.attach(uname=b"u")
- ok("attach at msize 64", rt == Rattach, rt)
- # Rstat of the root is ~70 bytes and cannot fit: must be an Rerror, not a dead socket
- rt, rb = c.stat(0)
- ok("stat at msize 64 answers Rerror (reply does not fit), socket stays open", rt == Rerror, f"{rt} {rb!r}")
- # Twalk with 5 names is 37 bytes (fits); Rwalk with 5 qids is 74 bytes (does not)
- rt, _, rb = c.walk(0, 1, [b".", b".", b".", b".", b"."])
- ok("5-element walk at msize 64 answers Rerror, socket stays open", rt == Rerror, f"{rt} {rb!r}")
- ok("newfid not bound by the failed walk", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- rt, _, _ = c.walk(0, 1, [b"README"])
- ok("1-element walk at msize 64", rt == Rwalk, rt)
- rt, _, _ = c.open(1, OREAD)
- ok("open at msize 64", rt == Ropen, rt)
- rt, d = c.read(1, 0, 4096)
- ok("read at msize 64 returns <= 40 bytes", rt == Rread and 0 < len(d) <= 40, f"{rt} {d!r}")
- rt, _, _ = c.clunk(1)
- ok("clunk at msize 64 still works", rt == Rclunk, rt)
- c.close()
- # huge msize is clamped to the server's max (1 MiB)
- c = Nine(path)
- rt, ms, ver = c.version(0xFFFFFFFF)
- ok("Tversion msize 2^32-1 clamped to 1 MiB", rt == Rversion and ms == 1 << 20, f"{rt} {ms}")
- # a frame larger than the negotiated msize
- c.attach()
- c.raw(frame(Twrite, 1, struct.pack("<IQI", 0, 0, (1 << 20)) + b"x" * (1 << 20)))
- ok("frame larger than msize: closed", expect_dead(c))
- c.close()
- # unknown version string
- c = Nine(path)
- rt, ms, ver = c.version(8192, b"9P2001")
- ok("unknown version answered 'unknown'", rt == Rversion and ver == b"unknown", f"{rt} {ver}")
- rt, _, _ = c.attach()
- ok("request after unknown version: closed", rt is None or expect_dead(c))
- c.close()
- c = Nine(path)
- rt, ms, ver = c.version(8192, b"9P2000.L")
- ok("9P2000.L falls back to 9P2000", rt == Rversion and ver == b"9P2000", f"{rt} {ver}")
- c.close()
- ok("server healthy after framing attacks", healthy(path))
-
-
-def attack_tags(path):
- print("# tags and flush")
- c = Nine(path)
- c.session()
- # Tflush for a tag that was never used
- rt, tag, _ = c.call(Tflush, struct.pack("<H", 4242), 9)
- ok("Tflush of unknown oldtag is Rflush", rt == Rflush and tag == 9, rt)
- rt, _, _ = c.call(Tflush, struct.pack("<H", NOTAG), 10)
- ok("Tflush of NOTAG is Rflush", rt == Rflush, rt)
- # same tag twice in a row (sequential: fine)
- rt, _, _ = c.call(Tstat, struct.pack("<I", 0), 7)
- rt2, _, _ = c.call(Tstat, struct.pack("<I", 0), 7)
- ok("tag reuse after reply works", rt == Rstat and rt2 == Rstat)
- # two requests with the same tag pipelined: the server is synchronous so both get answered
- c.raw(frame(Tstat, 7, struct.pack("<I", 0)) + frame(Tstat, 7, struct.pack("<I", 0)))
- a = c.recv_frame()
- b = c.recv_frame()
- ok("pipelined duplicate tags: both answered in order", a[0] == Rstat and b[0] == Rstat and a[1] == 7 and b[1] == 7)
- # a request with NOTAG
- c.raw(frame(Tstat, NOTAG, struct.pack("<I", 0)))
- ok("non-version request with NOTAG: closed", expect_dead(c))
- c.close()
- # 100 pipelined requests in one send
- c = Nine(path)
- c.session()
- blob = b"".join(frame(Tstat, i, struct.pack("<I", 0)) for i in range(100))
- c.raw(blob)
- got = [c.recv_frame() for _ in range(100)]
- ok("100 pipelined Tstat all answered in order", all(g[0] == Rstat and g[1] == i for i, g in enumerate(got)))
- c.close()
- ok("server healthy after tag attacks", healthy(path))
-
-
-def attack_walk(path):
- print("# walk")
- c = Nine(path)
- c.session()
- # 17 names is a wire violation -> connection closed
- c.raw(frame(Twalk, 1, struct.pack("<IIH", 0, 1, 17) + s16(b"a") * 17))
- ok("Twalk with 17 names: closed", expect_dead(c))
- c.close()
- c = Nine(path)
- c.session()
- ok("Twalk with 16 names ('.' x16) succeeds", c.walk_ok(0, 1, [b"."] * 16) == 16)
- c.clunk(1)
- ok("walk '..' from root stays at root", c.walk_ok(0, 1, [b"..", b"..", b"build"]) == 3)
- c.clunk(1)
- ok("walk with '/' in name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"build/target")) is not None)
- ok("walk with empty name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"")) is not None)
- ok("walk with NUL name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"bui\x00ld")) is not None)
- ok("walk 300-byte name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"a" * 300)) is not None)
- ok("walk 60000-byte name fails", c.err(Twalk, struct.pack("<IIH", 0, 1, 1) + s16(b"a" * 60000)) is not None)
- # partial walk: newfid not bound
- n = c.walk_ok(0, 1, [b"build", b"nope", b"x"])
- ok("partial walk returns 1 qid", n == 1, n)
- ok("partial walk does not bind newfid", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # walk through a file
- n = c.walk_ok(0, 1, [b"build", b"target", b"x"])
- ok("walk through a file is partial (2)", n == 2, n)
- ok("newfid unbound after partial walk through file", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # walk from a file with nwname>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("<IIH", 1, 2, 1) + s16(b"x")) == "not a directory")
- # walk from an open fid
- c.open(1, OREAD)
- ok("walk from open fid with names fails", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b"x")) is not None)
- # clone (nwname 0) from open fid with newfid == fid must not silently close the fid
- rt, _, _ = c.walk(1, 1, [])
- rt2, d = c.read(1, 0, 100)
- ok("self-walk nwname=0 on open fid does not lose open state", rt == Rerror or (rt2 == Rread and d), f"{rt} {rt2}")
- c.clunk(1)
- # newfid in use
- c.walk_ok(0, 1, [])
- ok("walk to a fid in use", c.err(Twalk, struct.pack("<IIH", 0, 1, 0)) == "fid in use")
- ok("walk from unknown fid", c.err(Twalk, struct.pack("<IIH", 999, 2, 0)) == "unknown fid")
- # attach twice
- ok("attach twice same fid", c.err(Tattach, struct.pack("<II", 0, NOFID) + s16(b"u") + s16(b"")) == "fid in use")
- ok("auth is refused", c.err(Tauth, struct.pack("<I", 5) + s16(b"u") + s16(b"")) is not None)
- c.close()
- ok("server healthy after walk attacks", healthy(path))
-
-
-def attack_io(path):
- print("# open/read/write")
- c = Nine(path)
- ms = c.session()
- c.walk_ok(0, 1, [b"build", b"target"])
- rt, _, _ = c.open(1, OREAD)
- ok("open target", rt == Ropen)
- ok("open twice fails", c.err(Topen, struct.pack("<IB", 1, OREAD)) is not None)
- rt, d = c.read(1, 0, 0xFFFFFFFF)
- ok("read count 2^32-1 clamped", rt == Rread and 0 < len(d) < ms, f"{rt} {len(d) if d else d}")
- rt, d = c.read(1, (1 << 64) - 1, 100)
- ok("read at offset 2^64-1 returns empty", rt == Rread and d == b"", f"{rt} {d!r}")
- rt, d = c.read(1, (1 << 63), 100)
- ok("read at offset 2^63 returns empty", rt == Rread and d == b"")
- ok("write to read-only static file", c.err(Twrite, struct.pack("<IQI", 1, 0, 1) + b"x") is not None)
- c.clunk(1)
- # read on unopened fid
- c.walk_ok(0, 2, [b"README"])
- ok("read on unopened fid", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == "file not open")
- ok("write on unopened fid", c.err(Twrite, struct.pack("<IQI", 2, 0, 1) + b"x") == "file not open")
- ok("read unknown fid", c.err(Tread, struct.pack("<IQI", 555, 0, 10)) == "unknown fid")
- c.clunk(2)
- # directory: write/trunc/write on a dir
- c.walk_ok(0, 3, [b"build"])
- ok("open dir for write is 'is a directory'", c.err(Topen, struct.pack("<IB", 3, OWRITE)) == "is a directory")
- ok("open dir with OTRUNC is refused", c.err(Topen, struct.pack("<IB", 3, OREAD | OTRUNC)) is not None)
- rt, _, _ = c.open(3, OREAD)
- ok("write on open dir", c.err(Twrite, struct.pack("<IQI", 3, 0, 1) + b"x") is not None)
- rt, d = c.read(3, 0, 8192)
- ok("read dir", rt == Rread and len(d) > 0)
- ok("read dir at bad offset", c.err(Tread, struct.pack("<IQI", 3, 3, 8192)) == "bad offset")
- ok("read dir at 2^64-1 is bad offset", c.err(Tread, struct.pack("<IQI", 3, (1 << 64) - 1, 8192)) == "bad offset")
- rt, d2 = c.read(3, len(d), 8192)
- ok("read dir at end returns empty", rt == Rread and d2 == b"")
- # read of an open write-only file
- c.clunk(3)
- # dynamic file: second read after short read returns 0; nonzero offset works
- c.walk_ok(0, 4, [b"runtime", b"fn", b"uname"])
- c.open(4, OREAD)
- rt, d = c.read(4, 0, 8192)
- rt2, d2 = c.read(4, len(d), 8192)
- rt3, d3 = c.read(4, 1, 8192)
- ok("dynamic read then read-at-end is empty", rt == Rread and d and rt2 == Rread and d2 == b"")
- ok("dynamic read at offset 1 is the tail", rt3 == Rread and d3 == d[1:], f"{d!r} {d3!r}")
- c.clunk(4)
- # ctl
- c.walk_ok(0, 5, [b"runtime", b"ctl"])
- c.open(5, ORDWR)
- rt, _, _ = c.write(5, 0, b"add 9223372036854775807 1")
- rt2, d = c.read(5, 0, 100)
- ok("ctl add overflow wraps, no trap", rt == Rwrite and rt2 == Rread and d == b"-9223372036854775808", f"{rt} {d!r}")
- ok("ctl fib 94 rejected", c.err(Twrite, struct.pack("<IQI", 5, 0, 6) + b"fib 94") == "bad command")
- rt, _, _ = c.write(5, 0, b"fib 93")
- rt, d = c.read(5, 0, 100)
- ok("ctl fib 93", d == b"12200160415121876738", d)
- rt, st = c.stat(5)
- ok("ctl length is last result length", rt == Rstat and st["length"] == 20, st)
- ok("ctl bad command", c.err(Twrite, struct.pack("<IQI", 5, 0, 4) + b"nope") == "bad command")
- rt, st = c.stat(5)
- ok("ctl length unchanged after error", rt == Rstat and st["length"] == 20, st)
- rt, _, _ = c.write(5, 0, b"sleep-ms 99999999999999999999")
- ok("ctl sleep-ms huge number is a bad command (no trap)", rt == Rerror, rt)
- rt, _, _ = c.write(5, 0, b"echo " + b"\xff" * 1000)
- ok("ctl echo binary", rt == Rwrite)
- rt, _, _ = c.write(5, 0, b"")
- ok("ctl empty write is bad command", rt == Rerror)
- c.clunk(5)
- c.close()
- ok("server healthy after io attacks", healthy(path))
-
-
-def attack_scratch(path):
- print("# scratch")
- c = Nine(path)
- c.session()
- tag = os.urandom(4).hex().encode()
- root = b"h-" + tag
- c.walk_ok(0, 1, [b"scratch"])
- rt, _, _ = c.create(1, root, DMDIR | 0o755, OREAD)
- ok("mkdir test root", rt == Rcreate, rt)
- c.clunk(1)
- S = [b"scratch", root]
-
- def fresh(fid, extra=()):
- return c.walk_ok(0, fid, S + list(extra))
-
- fresh(1)
- ok("create name with '/'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a/b") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create '.'", c.err(Tcreate, struct.pack("<I", 1) + s16(b".") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create '..'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"..") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create empty name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create NUL name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a\x00b") + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- ok("create 256-byte name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a" * 256) + struct.pack("<IB", 0o644, OWRITE)) == "bad file name")
- rt, _, _ = c.create(1, b"b" * 255, 0o644, OWRITE)
- ok("create 255-byte name ok", rt == Rcreate, rt)
- rt, st = c.stat(1)
- ok("stat of 255-byte name round-trips", rt == Rstat and st["name"] == b"b" * 255)
- c.clunk(1)
- fresh(1)
- ok("create over existing name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"b" * 255) + struct.pack("<IB", 0o644, OWRITE)) == "file already exists")
- ok("mkdir over existing file", c.err(Tcreate, struct.pack("<I", 1) + s16(b"b" * 255) + struct.pack("<IB", DMDIR | 0o755, OREAD)) == "file already exists")
- ok("create DMDIR with OWRITE", c.err(Tcreate, struct.pack("<I", 1) + s16(b"dd") + struct.pack("<IB", DMDIR | 0o755, OWRITE)) is not None)
- ok("create DMDIR with OTRUNC", c.err(Tcreate, struct.pack("<I", 1) + s16(b"dd") + struct.pack("<IB", DMDIR | 0o755, OREAD | OTRUNC)) is not None)
- # create in a file
- rt, _, _ = c.create(1, b"f", 0o644, ORDWR)
- ok("create f (fid becomes open file)", rt == Rcreate)
- ok("create inside open fid", c.err(Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) is not None)
- c.clunk(1)
- fresh(1, [b"f"])
- ok("create inside a file is 'not a directory'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) == "not a directory")
- # writes: past the cap, at huge offsets
- rt, _, _ = c.open(1, OWRITE)
- ok("write at 64MiB-1 of 2 bytes is no space", c.err(Twrite, struct.pack("<IQI", 1, (64 << 20) - 1, 2) + b"xy") == "no space left on device")
- ok("write at 2^64-1 is no space", c.err(Twrite, struct.pack("<IQI", 1, (1 << 64) - 1, 1) + b"x") == "no space left on device")
- rt, _, _ = c.write(1, (1 << 64) - 1, b"")
- rt2, st = c.stat(1)
- ok("zero-length write at 2^64-1 does not extend the file", rt == Rwrite and st["length"] == 0, f"{rt} {st}")
- rt, _, _ = c.write(1, (64 << 20) - 1, b"x")
- rt2, st = c.stat(1)
- ok("write at 64MiB-1 of 1 byte allowed (file now 64 MiB)", rt == Rwrite and st["length"] == 64 << 20, f"{rt} {st}")
- st = mkstat(length=0)
- rt, _, _ = c.wstat(1, st)
- ok("truncate back to 0", rt == Rwstat)
- ok("wstat length 64MiB+1 is no space", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=(64 << 20) + 1))) == "no space left on device")
- ok("wstat length 2^64-2 is no space", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(length=(1 << 64) - 2))) == "no space left on device")
- # read on a write-only fid
- ok("read on OWRITE fid", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == "file not open")
- c.clunk(1)
- # wstat with everything set to the current values: no-op
- fresh(1, [b"f"])
- rt, st = c.stat(1)
- full = mkstat(name=st["name"], uid=st["uid"], gid=st["gid"], muid=st["muid"], typ=st["type"], dev=st["dev"],
- qtype=st["qid"][0], qvers=st["qid"][1], qpath=st["qid"][2], mode=st["mode"], atime=st["atime"],
- mtime=st["mtime"], length=st["length"])
- rt, _, _ = c.wstat(1, full)
- ok("wstat with everything equal to current is ok", rt == Rwstat, rt)
- rt, st2 = c.stat(1)
- ok("stat/wstat round trip fidelity", st2 == st, f"{st}\n{st2}")
- # wstat changing immutable fields
- ok("wstat changing qid.path", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(qpath=12345))) == "permission denied")
- ok("wstat changing uid", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(uid=b"root"))) == "permission denied")
- ok("wstat DMDIR on a file", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mode=DMDIR | 0o755))) == "permission denied")
- ok("wstat rename to '.'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"."))) == "bad file name")
- ok("wstat rename to '..'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b".."))) == "bad file name")
- ok("wstat rename to 'a/b'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"a/b"))) == "bad file name")
- ok("wstat rename to existing", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"b" * 255))) == "file already exists")
- rt, _, _ = c.wstat(1, mkstat(name=b"F"))
- rt2, st = c.stat(1)
- ok("rename differing only by case works", rt == Rwstat and st["name"] == b"F")
- rt, _, _ = c.wstat(1, mkstat(mtime=12345))
- rt2, st = c.stat(1)
- ok("wstat mtime is honoured", rt == Rwstat and st["mtime"] == 12345, st)
- c.clunk(1)
- # remove of root / scratch root / static
- ok("remove of attach root", c.err(Tremove, struct.pack("<I", 0)) == "permission denied")
- ok("fid clunked by failed remove", c.err(Tstat, struct.pack("<I", 0)) == "unknown fid")
- c.attach()
- c.walk_ok(0, 1, [b"scratch"])
- ok("remove of /scratch", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok("fid clunked by failed remove of /scratch", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- c.walk_ok(0, 1, [b"build", b"target"])
- ok("remove of static file", c.err(Tremove, struct.pack("<I", 1)) == "permission denied")
- ok("clunk unknown fid", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # remove non-empty dir; fid clunked
- fresh(1)
- ok("remove non-empty dir", c.err(Tremove, struct.pack("<I", 1)) == "directory not empty")
- ok("fid clunked after failed remove", c.err(Tclunk, struct.pack("<I", 1)) == "unknown fid")
- # a fid on a removed file: everything but stat/clunk fails cleanly
- fresh(1, [b"F"])
- fresh(2, [b"F"])
- rt, _, _ = c.remove(2)
- ok("remove F", rt == Rremove)
- ok("open removed file", c.err(Topen, struct.pack("<IB", 1, OREAD)) == "file does not exist")
- ok("walk .. from removed file", c.err(Twalk, struct.pack("<IIH", 1, 3, 1) + s16(b"..")) is not None)
- ok("wstat removed file", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"G"))) == "file does not exist")
- rt, st = c.stat(1)
- ok("stat removed file still answers", rt == Rstat)
- ok("remove removed file", c.err(Tremove, struct.pack("<I", 1)) == "file does not exist")
- # fid reuse after clunk
- fresh(1)
- c.clunk(1)
- ok("fid reusable after clunk", fresh(1) == 2)
- c.clunk(1)
- # ORCLOSE
- fresh(1)
- rt, _, _ = c.create(1, b"tmp", 0o644, OWRITE | ORCLOSE)
- c.clunk(1)
- ok("ORCLOSE removed the file on clunk", fresh(1, [b"tmp"]) == 2)
- # DMAPPEND ignores offset; OTRUNC on append file is ignored
- fresh(1)
- rt, _, _ = c.create(1, b"log", DMAPPEND | 0o644, OWRITE)
- c.write(1, 500, b"a")
- c.write(1, 0, b"b")
- c.clunk(1)
- fresh(1, [b"log"])
- rt, _, _ = c.open(1, OWRITE | OTRUNC)
- c.write(1, 0, b"c")
- c.clunk(1)
- fresh(1, [b"log"])
- c.open(1, OREAD)
- d = c.read_all(1)
- ok("append-only file", d == b"abc", d)
- rt, st = c.stat(1)
- ok("append qid bit", st["qid"][0] & 0x40 != 0)
- c.clunk(1)
- # DMEXCL: a second open must fail while the first is open
- fresh(1)
- rt, _, _ = c.create(1, b"lock", DMEXCL | 0o644, OWRITE)
- ok("create DMEXCL", rt == Rcreate)
- fresh(2, [b"lock"])
- e = c.err(Topen, struct.pack("<IB", 2, OREAD))
- ok("second open of DMEXCL file is refused while open", e is not None, e)
- c.clunk(1)
- rt, _, _ = c.open(2, OREAD)
- ok("DMEXCL file opens again after the first fid is clunked", rt == Ropen, rt)
- c.clunk(2)
- # mkdir with DMAPPEND|DMEXCL bits, then create inside it
- fresh(1)
- rt, _, _ = c.create(1, b"weird", DMDIR | DMAPPEND | DMEXCL | 0o755, OREAD)
- c.clunk(1)
- fresh(1, [b"weird"])
- rt, _, _ = c.create(1, b"inner", 0o644, OWRITE)
- ok("create inside DMDIR|DMAPPEND|DMEXCL dir works", rt == Rcreate, rt)
- c.clunk(1)
- # directory read across offsets while the directory changes
- fresh(1)
- c.open(1, OREAD)
- rt, d = c.read(1, 0, 120) # one or two records
- fresh(2, [b"weird", b"inner"])
- c.remove(2)
- fresh(2, [b"weird"])
- c.remove(2)
- fresh(2, [b"log"])
- c.remove(2)
- rt2, d2 = c.read(1, len(d), 8192)
- ok("dir read continues after entries were removed (no crash)", rt == Rread and rt2 == Rread)
- rt3, d3 = c.read(1, 0, 8192)
- ok("dir rewind after change lists current entries", rt3 == Rread)
- c.clunk(1)
- # perm inheritance: 0o777 file in 0o755 dir
- fresh(1)
- rt, _, _ = c.create(1, b"px", 0o777, OREAD)
- rt, st = c.stat(1)
- ok("create perm masked by parent (0o777 & 0o755 & 0o666)", st["mode"] == 0o644, oct(st["mode"]))
- c.clunk(1)
- # mode 0 file: open refused; chmod back via wstat
- fresh(1, [b"px"])
- c.wstat(1, mkstat(mode=0))
- ok("open mode-0 file refused", c.err(Topen, struct.pack("<IB", 1, OREAD)) == "permission denied")
- c.wstat(1, mkstat(mode=0o644))
- rt, _, _ = c.open(1, OREAD)
- ok("open after chmod", rt == Ropen)
- c.clunk(1)
- # Tversion mid-session resets fids (retained scratch nodes released)
- fresh(1, [b"px"])
- fresh(2, [b"px"])
- c.remove(2)
- rt, ms, _ = c.version(65536)
- ok("mid-session Tversion", rt == Rversion)
- ok("fids gone after Tversion", c.err(Tstat, struct.pack("<I", 1)) is not None)
- ok("fids gone after Tversion (0)", c.err(Tstat, struct.pack("<I", 0)) == "unknown fid")
- c.attach()
- # cleanup: remove everything under root
- c.walk_ok(0, 1, S)
- c.open(1, OREAD)
- d = c.read_all(1)
- names = []
- while d:
- n, = struct.unpack_from("<H", d)
- names.append(parse_stat(d[:n + 2])["name"])
- d = d[n + 2:]
- c.clunk(1)
- for nm in names:
- if c.walk_ok(0, 1, S + [nm]) == 3:
- c.remove(1)
- c.walk_ok(0, 1, S)
- rt, _, _ = c.remove(1)
- ok("cleanup removed test root", rt == Rremove, names)
- c.close()
- ok("server healthy after scratch attacks", healthy(path))
-
-
-def attack_many_fids(path):
- print("# many fids")
- c = Nine(path, timeout=30)
- c.session()
- n = 20000
- blob = b"".join(frame(Twalk, i & 0xFFFE, struct.pack("<IIH", 0, i + 1, 0)) for i in range(n))
- got = [0]
- dead = [False]
-
- def reader(): # a pipelining client must read concurrently or it deadlocks itself on socket buffers
- try:
- for _ in range(n):
- rt, _, _ = c.recv_frame()
- got[0] += rt == Rwalk
- except (EOFError, OSError):
- dead[0] = True
-
- t = threading.Thread(target=reader)
- t.start()
- t0 = time.time()
- c.raw(blob)
- t.join(60)
- ok("20000 clones answered", got[0] == n and not dead[0] and not t.is_alive(), f"got={got[0]} dead={dead[0]}")
- print(f" {n} clones in {time.time() - t0:.2f}s")
- rt, _, _ = c.version(65536)
- ok("Tversion after 20000 fids", rt == Rversion)
- c.close()
- ok("server healthy after fid flood", healthy(path))
-
-
-def attack_connections(path, pid, count=500):
- print(f"# {count} idle connections")
- before = rss_kb(pid)
- socks = []
- try:
- for _ in range(count):
- s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- s.connect(path) # blocking connect waits for backlog room instead of failing with EAGAIN
- s.settimeout(5)
- socks.append(s)
- except OSError as e:
- print(f" connect failed after {len(socks)}: {e!r}")
- time.sleep(1.0)
- mid = rss_kb(pid)
- th = threads(pid)
- print(f" rss before={before} KiB, with {len(socks)} idle conns={mid} KiB, threads={th}")
- ok(f"{count} idle connections accepted (or refused cleanly), server alive", healthy(path) and len(socks) == count, len(socks))
- # send one Tversion from each (no reply read), then half a frame, then close
- for s in socks:
- try:
- s.sendall(frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000")) + b"\x10\x00\x00")
- except OSError:
- pass
- for s in socks:
- s.close()
- time.sleep(1.0)
- after = rss_kb(pid)
- print(f" rss after close={after} KiB, threads={threads(pid)}")
- ok("server alive after mass disconnect", healthy(path))
- ok("thread count returned to baseline (<= 4)", threads(pid) <= 4, threads(pid))
- return before, mid, after
-
-
-def attack_concurrent(path, clients=8, ops=1000):
- print(f"# {clients} clients x {ops} mixed ops on /scratch")
- errors = []
- tag = os.urandom(3).hex().encode()
-
- def worker(k):
- try:
- c = Nine(path, timeout=30)
- c.session()
- me = b"c%d-%s" % (k, tag)
- for i in range(ops):
- op = i % 7
- if op == 0:
- c.walk_ok(0, 1, [b"scratch"])
- c.create(1, me, 0o644, OWRITE)
- c.write(1, 0, b"x" * (i % 500))
- c.clunk(1)
- elif op == 1:
- if c.walk_ok(0, 1, [b"scratch", me]) == 2:
- c.open(1, OREAD)
- c.read_all(1)
- c.clunk(1)
- elif op == 2:
- if c.walk_ok(0, 1, [b"scratch", me]) == 2:
- c.wstat(1, mkstat(name=me + b"-r"))
- c.clunk(1)
- elif op == 3:
- if c.walk_ok(0, 1, [b"scratch", me + b"-r"]) == 2:
- c.wstat(1, mkstat(length=7))
- c.clunk(1)
- elif op == 4:
- c.walk_ok(0, 1, [b"scratch"])
- c.open(1, OREAD)
- c.read_all(1, 300)
- c.clunk(1)
- elif op == 5:
- for nm in (me, me + b"-r"):
- if c.walk_ok(0, 1, [b"scratch", nm]) == 2:
- c.remove(1)
- else:
- c.clunk(1)
- else:
- if k % 2 == 0:
- c.version(65536)
- c.attach()
- else:
- c.walk_ok(0, 1, [b"runtime", b"ctl"])
- c.open(1, ORDWR)
- c.write(1, 0, b"add %d 1" % i)
- c.read(1, 0, 100)
- c.clunk(1)
- for nm in (me, me + b"-r"):
- if c.walk_ok(0, 1, [b"scratch", nm]) == 2:
- c.remove(1)
- else:
- c.clunk(1)
- c.close()
- except Exception as e: # noqa: BLE001
- errors.append((k, repr(e)))
-
- ts = [threading.Thread(target=worker, args=(k,)) for k in range(clients)]
- t0 = time.time()
- for t in ts:
- t.start()
- for t in ts:
- t.join(120)
- ok("concurrent clients finished without errors", not errors and all(not t.is_alive() for t in ts), errors)
- print(f" {clients * ops} ops in {time.time() - t0:.1f}s")
- ok("server healthy after concurrency", healthy(path))
-
-
-def attack_sleep(path):
- print("# sleep-ms must not block other clients")
- a = Nine(path, timeout=10)
- a.session()
- a.walk_ok(0, 1, [b"runtime", b"ctl"])
- a.open(1, OWRITE)
- a.raw(frame(Twrite, 3, struct.pack("<IQI", 1, 0, 13) + b"sleep-ms 3000"))
- t0 = time.time()
- b = Nine(path, timeout=10)
- b.session()
- d = b.path_read([b"build", b"zig_version"])
- dt = time.time() - t0
- ok("other client served during sleep-ms", bool(d) and dt < 1.0, f"{dt:.2f}s")
- rt, _, _ = a.recv_frame()
- ok("sleeper got Rwrite", rt == Rwrite)
- a.close()
- b.close()
-
-
-def attack_env(path):
- print("# runtime files")
- c = Nine(path)
- c.session()
- env = c.path_read([b"runtime", b"env"])
- ok("/runtime/env readable", env is not None)
- if env and any(k in env for k in (b"TOKEN", b"SECRET", b"KEY", b"PASS")):
- print(" note: /runtime/env exposes variables that look like secrets")
- for nm in (b"pid", b"ppid", b"uptime", b"argv", b"cwd", b"clients"):
- d = c.path_read([b"runtime", nm])
- ok(f"/runtime/{nm.decode()} readable", d is not None, d)
- for nm in (b"hostname", b"now", b"random", b"uname", b"fib30"):
- d = c.path_read([b"runtime", b"fn", nm])
- ok(f"/runtime/fn/{nm.decode()} readable and non-empty", bool(d), d)
- # random gives different values on each open
- r1 = c.path_read([b"runtime", b"fn", b"random"])
- r2 = c.path_read([b"runtime", b"fn", b"random"])
- ok("random differs across opens", r1 != r2)
- # stat of dynamic file reports 0, static reports real length
- c.walk_ok(0, 1, [b"runtime", b"pid"])
- rt, st = c.stat(1)
- ok("dynamic file length 0", st["length"] == 0 and st["mode"] == 0o444, st)
- c.clunk(1)
- c.walk_ok(0, 1, [b"README"])
- rt, st = c.stat(1)
- d = c.path_read([b"README"])
- ok("static file length matches content", st["length"] == len(d), (st["length"], len(d)))
- # every directory in the static tree: names in listing match walkable names, '.' and '..' absent
- def walk_tree(names, depth=0):
- if depth > 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("<H", d)
- e = parse_stat(d[:n + 2])
- d = d[n + 2:]
- if e["name"] in (b".", b"..", b""):
- ok("dir listing has no '.'/'..'/empty names", False, names)
- if names == [b"scratch"]:
- continue
- walk_tree(names + [e["name"]], depth + 1)
- else:
- c.clunk(9)
- walk_tree([])
- ok("entire static tree walkable", True)
- c.close()
-
-
-def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--server")
- ap.add_argument("--socket")
- ap.add_argument("--connections", type=int, default=500)
- ap.add_argument("--fast", action="store_true")
- args = ap.parse_args()
- proc = None
- tmp = None
- if args.server:
- tmp = tempfile.mkdtemp(prefix="adv9p.")
- path = os.path.join(tmp, "sock")
- proc = subprocess.Popen([os.path.abspath(args.server), "--unix", path], stderr=subprocess.PIPE)
- for _ in range(200):
- if os.path.exists(path):
- break
- time.sleep(0.02)
- pid = proc.pid
- elif args.socket:
- path = args.socket
- pid = -1
- else:
- ap.error("--server or --socket")
- try:
- rss0 = rss_kb(pid)
- attack_framing(path)
- attack_tags(path)
- attack_walk(path)
- attack_io(path)
- attack_scratch(path)
- attack_env(path)
- attack_sleep(path)
- attack_many_fids(path)
- if not args.fast:
- attack_connections(path, pid, args.connections)
- attack_concurrent(path)
- rss1 = rss_kb(pid)
- print(f"# rss start={rss0} KiB end={rss1} KiB threads={threads(pid)}")
- if pid > 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 <introspect> [--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 <introspect>
-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("<H", d)
- return "err", d[2:2 + n].decode()
- return rt, d
-
-
-def wr(c, names, data, fid=51, offset=0):
- if c.walk_ok(0, fid, names) != len(names):
- c.clunk(fid)
- return "walkfail", None
- rt, _, rb = c.open(fid, OWRITE)
- if rt != Ropen:
- c.clunk(fid)
- return "openfail", rb
- rt, _, rb = c.write(fid, offset, data)
- c.clunk(fid)
- if rt == Rerror:
- n, = struct.unpack_from("<H", rb)
- return "err", rb[2:2 + n].decode()
- return rt, rb
-
-
-def ls(c, names, fid=52):
- c.walk_ok(0, fid, names)
- c.open(fid, OREAD)
- out = c.read_all(fid) or b""
- c.clunk(fid)
- res, i = [], 0
- while i < len(out):
- n, = struct.unpack_from("<H", out, i)
- res.append(parse_stat(out[i:i + 2 + n])["name"])
- i += 2 + n
- return res
-
-
-def thread_by_name(c, pid, name):
- for t in ls(c, [b"threads"]):
- if rd(c, [b"threads", t, b"name"])[1] == name and int(t) != pid:
- return t
- return None
-
-
-def ticks(c):
- return int(rd(c, [b"vars", b"state", b"f", b"ticks", b"value"])[1])
-
-
-def cpu_ticks(pid):
- f = open(f"/proc/{pid}/stat").read().rsplit(")", 1)[1].split()
- return int(f[11]) + int(f[12])
-
-
-def fds(pid):
- return len(os.listdir(f"/proc/{pid}/fd"))
-
-
-def attack_memory(server):
- print("# memory endpoints")
- s = Srv(server)
- c = client(s.path, timeout=5)
- ok("/mem/0 is an error (not a null-pointer trap on the probe thread)", rd(c, [b"mem", b"0"]) == ("err", "i/o error"))
- ok("/hex/0 open is an error", rd(c, [b"hex", b"0"])[0] == "openfail")
- ok("/mem/0 write is an error", wr(c, [b"mem", b"0"], b"x") == ("err", "i/o error"))
- ok("/mem/1 at offset 2^64-1 (wraps to 0) is an error", rd(c, [b"mem", b"1"], offset=(1 << 64) - 1) == ("err", "i/o error"))
- ok("/mem/ffffffffffff near the top of the address space is an error", rd(c, [b"mem", b"ffffffffffff"], offset=(1 << 64) - 256) == ("err", "i/o error"))
- ok("/hex/ffffffffffff is an error", rd(c, [b"hex", b"ffffffffffff"])[0] == "openfail")
- ok("49-bit address does not walk", rd(c, [b"mem", b"1000000000000"])[0] == "walkfail")
- ok("/addr/0 renders ?", rd(c, [b"addr", b"0"])[1] == b"?\n?\n?\n")
- ok("server alive after the zero/wrap probes", s.alive() and healthy(s.path))
- # /mem/maps is the whole file, however long, byte for byte
- c.walk_ok(0, 60, [b"mem", b"maps"])
- c.open(60, OREAD)
- via = c.read_all(60, 4096)
- c.clunk(60)
- real = open(f"/proc/{s.pid}/maps", "rb").read()
- ok("/mem/maps equals /proc/<pid>/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 <pid>", 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("<I", 8192) + s16(b"9P2000")))
- so.recv(100)
- elif i % 3 == 1:
- so.sendall(b"\x10\x00\x00") # half a frame
- so.close()
- time.sleep(1.0)
- ok("no fd leak over 1000 connect/disconnect cycles", fds(s.pid) == f0, (f0, fds(s.pid)))
- held = [client(s.path, timeout=8) for _ in range(14)]
- pl = subprocess.Popen([player, "--unix", s.path, "--", "sh", "-c", "cat /mnt/9p/build/zig_version; echo; sleep 1000"],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- seen = pl.stdout.readline().strip()
- ok("9player mount alongside 14 attached clients", bool(seen), seen)
- time.sleep(1.0) # past evict_idle_ms: everyone is idle now
- slow = []
- for _ in range(40):
- so = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- so.settimeout(3)
- so.connect(s.path)
- slow.append(so)
- time.sleep(1.5)
- tv = frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000"))
- for k in range(6): # slowloris: one byte at a time
- for so in slow:
- try:
- so.send(tv[k:k + 1])
- except OSError:
- pass
- time.sleep(0.2)
- ok("attached clients are never evicted by a flood", all(h.path_read([b"build", b"zig_version"]) for h in held))
- ok("the 9player mount survives the flood", pl.poll() is None)
- pl2 = subprocess.run([player, "--unix", s.path, "--", "cat", "/mnt/9p/build/zig_version"], capture_output=True, timeout=30)
- ok("a new 9player mount is refused cleanly while the table is full", pl2.returncode != 0 or bool(pl2.stdout.strip()), pl2.stderr[-100:])
- for so in slow:
- so.close()
- time.sleep(0.5)
- # output backpressure: a client that never reads must not stall or spin the loop
- nr = client(s.path, timeout=5)
- nr.walk_ok(0, 5, [b"mem", b"maps"])
- nr.open(5, OREAD)
- nr.s.settimeout(1.0)
- try:
- for i in range(300):
- nr.s.sendall(frame(Tread, i & 0xFFFE, struct.pack("<IQI", 5, 0, 65536)))
- except OSError:
- pass # the server stopped reading it (backpressure): that is the point
- t0 = time.time()
- d = held[0].path_read([b"build", b"zig_version"])
- ok("other clients served while one never reads its replies", bool(d) and time.time() - t0 < 1.0, f"{time.time() - t0:.2f}s")
- c0 = cpu_ticks(s.pid)
- time.sleep(2.0)
- ok("no spin with pending output on a stalled client (<= 2 ticks in 2 s)", cpu_ticks(s.pid) - c0 <= 2, cpu_ticks(s.pid) - c0)
- nr.close()
- time.sleep(0.3)
- # two clients sleep at once: a third is still served, both sleepers get their answer on time
- for h in held[2:]:
- h.close()
- time.sleep(0.3)
-
- def sleeper(ms):
- a = client(s.path, timeout=8)
- a.walk_ok(0, 1, [b"runtime", b"ctl"])
- a.open(1, OWRITE)
- cmd = b"sleep-ms %d" % ms
- a.raw(frame(Twrite, 3, struct.pack("<IQI", 1, 0, len(cmd)) + cmd))
- return a
-
- t0 = time.time()
- a = sleeper(1500)
- b = sleeper(1500)
- d = held[1].path_read([b"build", b"zig_version"])
- ok("third client served during two concurrent sleep-ms", bool(d) and time.time() - t0 < 1.0, f"{time.time() - t0:.2f}s")
- ra = a.recv_frame()[0]
- rb = b.recv_frame()[0]
- dt = time.time() - t0
- ok("both sleepers answered after ~1.5 s, not serialized", ra == Rwrite and rb == Rwrite and dt < 2.5, f"{dt:.2f}s")
- a.close()
- b.close()
- ok("healthy after probe attacks", healthy(s.path))
- pl.terminate()
- pl.wait(timeout=10)
- s.stop()
-
- # --stdio: EOF ends the process with exit 0 and no allocator leak report
- p = subprocess.Popen([server, "--stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- p.stdin.write(frame(Tversion, NOTAG, struct.pack("<I", 8192) + s16(b"9P2000")))
- p.stdin.flush()
- h = p.stdout.read(4)
- n, = struct.unpack("<I", h)
- body = p.stdout.read(n - 4)
- ok("--stdio answers Tversion", body[0] == Rversion)
- p.stdin.close()
- try:
- rc = p.wait(timeout=5)
- except subprocess.TimeoutExpired:
- p.kill()
- rc = None
- err = p.stderr.read().decode("utf-8", "replace")
- ok("--stdio exits 0 on EOF", rc == 0, rc)
- ok("no leak report or stack trace on stderr at exit", "leaked" not in err and "in _start" not in err, err[-300:])
-
-
-def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--player", required=True)
- ap.add_argument("--server", required=True)
- args = ap.parse_args()
- attack_memory(args.server)
- attack_signals(args.server)
- if subprocess.run(["unshare", "-Urm", "true"], capture_output=True).returncode == 0 and os.path.exists("/dev/fuse"):
- attack_probe(args.player, args.server)
- else:
- print("# probe/admission: SKIP (namespaces or /dev/fuse unavailable)")
- print(f"# {hostile.PASSES} passed, {len(hostile.FAILS)} failed")
- sys.exit(1 if hostile.FAILS else 0)
-
-
-if __name__ == "__main__":
- main()
diff --git a/introspect/test/adv_linux_probe.sh b/introspect/test/adv_linux_probe.sh
deleted file mode 100755
index 3535aa9..0000000
--- a/introspect/test/adv_linux_probe.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-# Adversarial tests of the introspect Linux layer (probe loop, debug provider,
-# signal machinery) with raw 9P2000 over a unix socket and one 9player mount.
-# Usage: bash introspect/test/adv_linux_probe.sh <9player> <introspect> (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> <introspect> [--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> <introspect> (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 ]