diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-20 01:47:28 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-20 01:47:29 -0300 |
| commit | 66f2e492c348677ab3050f5e378b9eb4c04c98ce (patch) | |
| tree | cf06b32309eace8eb573925e9cf14e3dfc27bd66 /9proc/test | |
| parent | 65209217b5b68f56bc0bd5bc6c4dce33911ded59 (diff) | |
| download | cloud9-66f2e492c348677ab3050f5e378b9eb4c04c98ce.tar.gz cloud9-66f2e492c348677ab3050f5e378b9eb4c04c98ce.zip | |
9proc core becomes a backend of cloud9.fs; engine gains optional features
9proc's own fid table, walk loop and dir-read engine are replaced by
cloud9.fs.Server; the tree (static, vars, providers) is served through the
engine's Req/Reply contract with node ids that keep the old qid scheme.
Providers may answer later by returning error.Again (parked in the engine,
retried each step, Tflush -> EINTR); no new files are exposed.
Engine (backward compatible, all opt-in via Backend.features / Options):
create, remove, wstat, reference accounting for backends that count
handles, a salted fid index, name_capacity 0 (names from getattr),
Reply.ename for backend-chosen error text, Attr.path/version/atime.
Engine-level error strings and the 217-byte msize floor now apply to 9proc;
tests updated accordingly.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9proc/test')
| -rwxr-xr-x | 9proc/test/adv_9proc_hostile.py | 125 | ||||
| -rwxr-xr-x | 9proc/test/adv_core_hostile.py | 158 |
2 files changed, 152 insertions, 131 deletions
diff --git a/9proc/test/adv_9proc_hostile.py b/9proc/test/adv_9proc_hostile.py index 934e757..dfa0d20 100755 --- a/9proc/test/adv_9proc_hostile.py +++ b/9proc/test/adv_9proc_hostile.py @@ -21,6 +21,22 @@ import time NOTAG = 0xFFFF NOFID = 0xFFFFFFFF + +# The Rerror strings of the conditions cloud9.fs (the file-server engine the +# core is a backend of) decides itself; the tree's own refusals keep the +# Plan 9 strings ("file does not exist", "bad command", ...). +MSIZE_MIN = 217 # one full Rwalk must fit +E_UNKNOWN_FID = "fid unknown or out of range" +E_FID_IN_USE = "fid already in use" +E_TOO_MANY_FIDS = "Too many open files in system" +E_BAD_USE = "bad use of fid" # I/O on an unopened fid; a walk or clone from an open one +E_ALREADY_OPEN = "file already open for I/O" # open or create on an open fid +E_BAD_OFFSET = "bad offset in directory read" +E_PERM = "permission denied" # the engine's own refusals: dirs for write, OEXEC, static trees +E_WSTAT = "wstat prohibited" # engine-owned stat fields, or a length on a directory +E_ILLEGAL_NAME = "illegal name" # names of creates and renames +E_INVAL = "Invalid argument" # a reply that cannot fit msize; a dir read count below one record +E_INTERRUPTED = "Interrupted system call" 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 @@ -303,37 +319,31 @@ def attack_framing(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): + # Tversion msize below the engine's floor (one full Rwalk must fit): no Rversion + for ms in (0, 1, 23, 24, 64, MSIZE_MIN - 1): 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() + # the floor itself is negotiable, and everything that fits is served at it c = Nine(path) - rt, ms, ver = c.version(64) - ok("Tversion msize 64 accepted", rt == Rversion and ms == 64, f"{rt} {ms} {ver}") + rt, ms, ver = c.version(MSIZE_MIN) + ok(f"Tversion msize {MSIZE_MIN} accepted", rt == Rversion and ms == MSIZE_MIN and ver == b"9P2000", 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 + ok("attach at the floor", rt == Rattach, rt) 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") + ok("stat of the root at the floor fits", rt == Rstat, f"{rt} {rb!r}") + rt, _, rb = c.walk(0, 1, [b"."] * 16) + ok("16-element walk at the floor fits exactly", rt == Rwalk, f"{rt} {rb!r}") + c.clunk(1) rt, _, _ = c.walk(0, 1, [b"README"]) - ok("1-element walk at msize 64", rt == Rwalk, rt) + ok("1-element walk at the floor", rt == Rwalk, rt) rt, _, _ = c.open(1, OREAD) - ok("open at msize 64", rt == Ropen, rt) + ok("open at the floor", 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}") + ok(f"read at the floor returns <= {MSIZE_MIN - 11} bytes", rt == Rread and 0 < len(d) <= MSIZE_MIN - 11, f"{rt} {d!r}") rt, _, _ = c.clunk(1) - ok("clunk at msize 64 still works", rt == Rclunk, rt) + ok("clunk at the floor still works", rt == Rclunk, rt) c.close() # huge msize is clamped to the server's max (1 MiB) c = Nine(path) @@ -413,11 +423,11 @@ def attack_walk(path): # 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") + ok("partial walk does not bind newfid", c.err(Tclunk, struct.pack("<I", 1)) == E_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") + ok("newfid unbound after partial walk through file", c.err(Tclunk, struct.pack("<I", 1)) == E_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") @@ -431,10 +441,10 @@ def attack_walk(path): 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") + ok("walk to a fid in use", c.err(Twalk, struct.pack("<IIH", 0, 1, 0)) == E_FID_IN_USE) + ok("walk from unknown fid", c.err(Twalk, struct.pack("<IIH", 999, 2, 0)) == E_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("attach twice same fid", c.err(Tattach, struct.pack("<II", 0, NOFID) + s16(b"u") + s16(b"")) == E_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)) @@ -458,20 +468,20 @@ def attack_io(path): 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") + ok("read on unopened fid", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == E_BAD_USE) + ok("write on unopened fid", c.err(Twrite, struct.pack("<IQI", 2, 0, 1) + b"x") == E_BAD_USE) + ok("read unknown fid", c.err(Tread, struct.pack("<IQI", 555, 0, 10)) == E_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 for write is refused by the engine", c.err(Topen, struct.pack("<IB", 3, OWRITE)) == E_PERM) 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") + ok("read dir at bad offset", c.err(Tread, struct.pack("<IQI", 3, 3, 8192)) == E_BAD_OFFSET) + ok("read dir at 2^64-1 is bad offset", c.err(Tread, struct.pack("<IQI", 3, (1 << 64) - 1, 8192)) == E_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 @@ -527,12 +537,12 @@ def attack_scratch(path): 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") + ok("create name with '/'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a/b") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create '.'", c.err(Tcreate, struct.pack("<I", 1) + s16(b".") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create '..'", c.err(Tcreate, struct.pack("<I", 1) + s16(b"..") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create empty name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create NUL name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a\x00b") + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) + ok("create 256-byte name", c.err(Tcreate, struct.pack("<I", 1) + s16(b"a" * 256) + struct.pack("<IB", 0o644, OWRITE)) == E_ILLEGAL_NAME) rt, _, _ = c.create(1, b"b" * 255, 0o644, OWRITE) ok("create 255-byte name ok", rt == Rcreate, rt) rt, st = c.stat(1) @@ -566,25 +576,28 @@ def attack_scratch(path): 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") + ok("read on OWRITE fid", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == E_BAD_USE) c.clunk(1) - # wstat with everything set to the current values: no-op + # wstat with name, mode, mtime and length equal to the current values: no-op fresh(1, [b"f"]) rt, st = c.stat(1) + same = mkstat(name=st["name"], mode=st["mode"], mtime=st["mtime"], length=st["length"]) + rt, _, _ = c.wstat(1, same) + ok("wstat with name/mode/mtime/length 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}") + # the engine owns type, dev, qid, atime and the owner names: naming them at all is refused, even unchanged 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}") + ok("wstat naming the engine-owned fields is 'wstat prohibited' even when equal", c.err(Twstat, struct.pack("<I", 1) + s16(full)) == E_WSTAT) # 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 changing qid.path", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(qpath=12345))) == E_WSTAT) + ok("wstat changing uid", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(uid=b"root"))) == E_WSTAT) + ok("wstat DMDIR on a file", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(mode=DMDIR | 0o755))) == E_WSTAT) + ok("wstat rename to '.'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"."))) == E_ILLEGAL_NAME) + ok("wstat rename to '..'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b".."))) == E_ILLEGAL_NAME) + ok("wstat rename to 'a/b'", c.err(Twstat, struct.pack("<I", 1) + s16(mkstat(name=b"a/b"))) == E_ILLEGAL_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) @@ -595,18 +608,18 @@ def attack_scratch(path): 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") + ok("fid clunked by failed remove", c.err(Tstat, struct.pack("<I", 0)) == E_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") + ok("fid clunked by failed remove of /scratch", c.err(Tclunk, struct.pack("<I", 1)) == E_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") + ok("clunk unknown fid", c.err(Tclunk, struct.pack("<I", 1)) == E_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") + ok("fid clunked after failed remove", c.err(Tclunk, struct.pack("<I", 1)) == E_UNKNOWN_FID) # a fid on a removed file: everything but stat/clunk fails cleanly fresh(1, [b"F"]) fresh(2, [b"F"]) @@ -667,7 +680,9 @@ def attack_scratch(path): # 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 + rt, d = c.read(1, 0, 8192) + first = struct.unpack_from("<H", d)[0] + 2 + rt, d = c.read(1, 0, first) # exactly one record (the engine never splits one) fresh(2, [b"weird", b"inner"]) c.remove(2) fresh(2, [b"weird"]) @@ -700,7 +715,7 @@ def attack_scratch(path): 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") + ok("fids gone after Tversion (0)", c.err(Tstat, struct.pack("<I", 0)) == E_UNKNOWN_FID) c.attach() # cleanup: remove everything under root c.walk_ok(0, 1, S) diff --git a/9proc/test/adv_core_hostile.py b/9proc/test/adv_core_hostile.py index 464876f..febda0d 100755 --- a/9proc/test/adv_core_hostile.py +++ b/9proc/test/adv_core_hostile.py @@ -4,7 +4,7 @@ Complements adv_9proc_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, +configured maximum, directory-read offsets, the msize floor, the ctl staging rule, and the demo's debug providers driven as black boxes. Usage: @@ -30,6 +30,8 @@ from adv_9proc_hostile import ( # noqa: E402 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, + MSIZE_MIN, E_UNKNOWN_FID, E_FID_IN_USE, E_TOO_MANY_FIDS, E_BAD_USE, E_ALREADY_OPEN, E_BAD_OFFSET, + E_PERM, E_WSTAT, E_INVAL, ) MAX_FIDS = 32768 # demo/main.zig cfg.max_fids @@ -88,7 +90,7 @@ def attack_vars(path): 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") + ok(" and newfid stays unbound", c.err(Tclunk, struct.pack("<I", 1)) == E_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) @@ -151,7 +153,7 @@ def attack_vars(path): 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") + ok("read on the write-only value fid is 'file not open'", c.err(Tread, struct.pack("<IQI", 1, 0, 10)) == E_BAD_USE) 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"]) @@ -200,9 +202,9 @@ def attack_vars(path): 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"open {p} for write is refused by the engine", c.err(Topen, struct.pack("<IB", 1, OWRITE)) == E_PERM) 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") + ok(f" and the fid was clunked", c.err(Tclunk, struct.pack("<I", 1)) == E_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() @@ -249,7 +251,7 @@ def attack_snapshots(path): 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") + ok("the refused fid is still unopened (read is 'file not open')", c.err(Tread, struct.pack("<IQI", 100 + opened, 0, 10)) == E_BAD_USE) # every held snapshot is still readable and consistent at offset 1 for i in range(opened): rt, d = c.read(100 + i, 0, 8192) @@ -278,11 +280,11 @@ def attack_snapshots(path): 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") + # an open fid cannot be cloned; a fresh walk to the same file takes no slot and is unopened + ok("clone of an open dynamic fid is refused", c.err(Twalk, struct.pack("<IIH", 60, 61, 0)) == E_BAD_USE) + c.walk_ok(0, 61, dyn[0]) + ok("the fresh fid is not open", c.err(Tread, struct.pack("<IQI", 61, 0, 10)) == E_BAD_USE) + ok("the fresh fid 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) @@ -310,12 +312,13 @@ def attack_static(path): 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"wstat of {p} with all don't-care is a no-op the engine answers itself", c.wstat(1, mkstat())[0] == Rwstat) + ok(f"open {p} ORDWR is refused by the engine", c.err(Topen, struct.pack("<IB", 1, ORDWR)) == E_PERM) + ok(f"open {p} OEXEC is refused by the engine", c.err(Topen, struct.pack("<IB", 1, OEXEC)) == E_PERM) + ok(f"open {p} OREAD", c.open(1, OREAD)[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") + ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == E_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() @@ -324,7 +327,7 @@ def attack_static(path): 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") + ok(f" and clunked", c.err(Tclunk, struct.pack("<I", 1)) == E_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") @@ -563,11 +566,11 @@ def attack_fid_table(path): 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("the next fid is 'too many fids'", c.err(Twalk, struct.pack("<IIH", 0, 7, 0)) == E_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"")) == E_TOO_MANY_FIDS) + ok("an existing id is 'fid in use'", c.err(Twalk, struct.pack("<IIH", 0, ids[12345], 0)) == E_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") + ok("an unknown fid at the limit is 'unknown fid'", c.err(Tstat, struct.pack("<I", 7)) == E_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] @@ -588,12 +591,12 @@ def attack_fid_table(path): 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") + ok("clunk of a clunked fid is 'unknown fid'", c.err(Tclunk, struct.pack("<I", ids[100])) == E_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") + ok("still 'too many fids' at the limit", c.err(Twalk, struct.pack("<IIH", 0, n + 1, 0)) == E_TOO_MANY_FIDS) rt, _, _ = c.version(65536) ok("Tversion after the fid churn", rt == base.Rversion) c.attach() @@ -635,63 +638,66 @@ def attack_flush(path): ok("server healthy after the flush storm", healthy(path)) -# --------------------------------------------------------------------------- msize 24 +# --------------------------------------------------------------------------- msize floor -def attack_msize24(path): - print("# msize 24: everything that fits is served, everything else is an Rerror that fits") +def attack_msize_floor(path): + print(f"# msize floor: below {MSIZE_MIN} the connection dies; at {MSIZE_MIN} everything that fits is served") + for ms in (24, 64, MSIZE_MIN - 1): + c = Nine(path) + rt, _, _ = c.version(ms) + ok(f"Tversion msize {ms}: closed", rt is None or expect_dead(c), rt) + c.close() 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, ms, ver = c.version(MSIZE_MIN) + ok(f"Tversion {MSIZE_MIN}", rt == base.Rversion and ms == MSIZE_MIN, (rt, ms)) + rt, _, _ = c.attach(uname=b"u") + ok("Tattach at the floor", rt == base.Rattach, rt) + rt, st = c.stat(0) + ok("Tstat of the root fits", rt == Rstat and st["name"] == b"/", (rt, st)) + rt, _, rb = c.walk(0, 1, [b"."] * 16) + ok("a full 16-element Rwalk is exactly the floor", rt == Rwalk and struct.unpack_from("<H", rb)[0] == 16, rt) + c.clunk(1) + # an Rstat that cannot fit is an Rerror, not a dead connection: a long name in /scratch + long_name = b"m" * 180 # Tcreate (18 + 180 bytes) fits; the Rstat (61 + 180) does not + c.walk_ok(0, 1, [b"scratch"]) + rt, _, _ = c.create(1, long_name, 0o644, OREAD) + ok("create a long name at the floor", rt == Rcreate, rt) + e = c.err(Tstat, struct.pack("<I", 1)) + ok("Tstat that does not fit is 'Invalid argument'", e == E_INVAL, e) + ok("remove it", c.remove(1)[0] == Rremove) + rt, _, _ = c.walk(0, 1, [b"build"]) 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)) + ok("Topen at the floor", rt == Ropen, rt) + rt, d = c.read(1, 0, 4096) # count clamped to msize - 11 + ok("a directory read at the floor yields whole records", rt == Rread and 0 < len(d) <= MSIZE_MIN - 11 and records(d), (rt, len(d) if d else d)) + e = c.err(Tread, struct.pack("<IQI", 1, 1, 4096)) + ok("dir read at offset 1 is a bad offset", e == E_BAD_OFFSET, e) rt, _, _ = c.clunk(1) - for nm in (b"vars", b"state", b"f", b"ticks", b"value"): # each Twalk <= 24 bytes + ok("Tclunk at the floor", rt == Rclunk, rt) + for nm in (b"vars", b"state", b"f", b"ticks", b"value"): 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) + ok("open a writable value at the floor", rt == Ropen, rt) + rt, _, _ = c.write(1, 0, b"5") + ok("Twrite of one byte at the floor", 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) + ok("empty write to a value at the floor is 'bad value'", e == "bad value", e) rt, _, _ = c.clunk(1) - ok("clunk at msize 24", rt == Rclunk, rt) + ok("clunk at the floor", 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 + # frames larger than the negotiated msize 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.version(MSIZE_MIN) + c.raw(frame(base.Tattach, 1, struct.pack("<II", 0, base.NOFID) + s16(b"u" * 210) + s16(b""))) + ok("an over-long Tattach at the floor: connection closed", expect_dead(c)) c.close() - ok("server healthy after msize-24 attacks", healthy(path)) + ok("server healthy after msize-floor attacks", healthy(path)) # --------------------------------------------------------------------------- directory offsets @@ -727,15 +733,15 @@ def attack_dir_offsets(path): 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) + ok(f"{p}: offset boundary+1 is 'bad offset'", e == 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) + ok(f"{p}: offset boundary-1 is 'bad offset'", e == 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) + ok(f"{p}: re-reading an earlier boundary is 'bad offset'", e == 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)) + e = c.err(Tread, struct.pack("<IQI", 1, 0, r0 - 1)) + ok(f"{p}: count one short of a record is refused (no split)", e == E_INVAL, e) 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) @@ -817,17 +823,17 @@ def attack_fid_states(path): 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") + ok("open of an open fid is 'file already open for I/O'", c.err(Topen, struct.pack("<IB", 1, OREAD)) == E_ALREADY_OPEN) + ok("walk with names from an open fid is 'bad use of fid'", c.err(Twalk, struct.pack("<IIH", 1, 2, 1) + s16(b".")) == E_BAD_USE) + ok("create on an open fid is 'file already open for I/O'", c.err(base.Tcreate, struct.pack("<I", 1) + s16(b"g") + struct.pack("<IB", 0o644, OWRITE)) == E_ALREADY_OPEN) + ok("clone of an open fid is refused", c.err(Twalk, struct.pack("<IIH", 1, 2, 0)) == E_BAD_USE) + ok("a fresh walk reaches the open file", c.walk_ok(0, 2, S + [b"f"]) == 3) + ok("the fresh fid is not open", c.err(Tread, struct.pack("<IQI", 2, 0, 10)) == E_BAD_USE) rt, _, _ = c.open(2, OREAD) - ok("the clone opens independently", rt == Ropen) + ok("the fresh fid 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) + ok("the second fid 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) @@ -982,7 +988,7 @@ def main(): attack_dir_offsets(path) attack_ctl(path) attack_fid_states(path) - attack_msize24(path) + attack_msize_floor(path) attack_flush(path) attack_fid_table(path) if not args.fast: |
