summaryrefslogtreecommitdiff
path: root/9proc/test/adv_9proc_hostile.py
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 /9proc/test/adv_9proc_hostile.py
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 '9proc/test/adv_9proc_hostile.py')
-rwxr-xr-x9proc/test/adv_9proc_hostile.py999
1 files changed, 999 insertions, 0 deletions
diff --git a/9proc/test/adv_9proc_hostile.py b/9proc/test/adv_9proc_hostile.py
new file mode 100755
index 0000000..934e757
--- /dev/null
+++ b/9proc/test/adv_9proc_hostile.py
@@ -0,0 +1,999 @@
+#!/usr/bin/env python3
+"""Hostile raw-9P2000 client for the 9proc-demo server (stdlib only).
+
+Usage:
+ adv_9proc_hostile.py --server zig-out/bin/9proc-demo # spawns it on a temp unix socket
+ adv_9proc_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()