summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/monkey9p.py1958
1 files changed, 1958 insertions, 0 deletions
diff --git a/test/monkey9p.py b/test/monkey9p.py
new file mode 100644
index 00000000..4c1732e5
--- /dev/null
+++ b/test/monkey9p.py
@@ -0,0 +1,1958 @@
+#!/usr/bin/env python3
+"""pardes 9P monkey: random 9P operations against a throwaway detached session.
+
+ zig build monkey-9p -Dplatform=tty --prefix <scratch> -- [--seed N] [--steps M]
+ zig build monkey-9p -Dplatform=tty --prefix <scratch> -- --replay FILE
+ zig build monkey-9p -Dplatform=tty --prefix <scratch> -- --shrink FILE
+ python3 test/monkey9p.py <pardes> [same flags]
+
+A run starts `pardes --detach` with its own HOME, XDG dirs and a short
+runtime dir in /tmp, speaks raw 9P to it (test/ninep.py), and for each
+step asks a GENERATOR for one concrete operation, runs it, then runs every
+INVARIANT. Everything random comes from --seed; every operation it ran is
+written to <out>/runs/seed-N/ops.jsonl, concrete but for pane and column
+references ({p3}: the 4th serial of /index, mod its length, when the op
+runs, so a shrunk sequence still names live panes), and `--replay` runs
+the same requests again without the generator. A failure writes a bug record
+(seed, step, the replayable operations since that session began) to
+<out>/bugs/, shrinks it by delta debugging over replays unless
+--no-shrink, and the run carries on in a fresh session.
+
+Plug-in points: a generator is a function `(ctx, rng) -> op dict` under
+@generator(weight); an op kind is a function `(sess, op) -> Result` under
+@op_kind(name); an invariant is a function `(ctx, res) -> str | None`
+under @invariant, returning why it failed.
+
+Safety: the session never sees PARDES_*, NINE_MOUNT or NAMESPACE; every
+payload that could reach a shell is drawn from an alphabet with no path
+or shell metacharacters (shell_safe), Edit text from a grammar without
+sam's w/e/r/f/b/B/D/n/!/</>/| commands, and nothing is ever written,
+created or removed under /os or /src (the host filesystem). This process
+makes itself a child subreaper, so every shell the session starts is its
+descendant even after pardes dies, and teardown kills them all.
+"""
+import argparse
+import collections
+import ctypes
+import hashlib
+import json
+import os
+import random
+import re
+import select
+import shutil
+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 ninep import Client, string # noqa: E402
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DEFAULT_OUT = os.path.join(os.path.dirname(REPO), '.scratch', 'monkey9p')
+
+# 9P message types.
+TWALK, TOPEN, TCREATE, TREAD, TWRITE, TCLUNK, TREMOVE, TSTAT, TFLUSH = 110, 112, 114, 116, 118, 120, 122, 124, 108
+RERROR = 107
+OREAD, OWRITE, ORDWR, OTRUNC = 0, 1, 2, 16
+DMDIR = 0x80000000
+
+TIMEOUT = 5.0 # every reply, unless the op is a documented slow one
+SLOW_TIMEOUT = 30.0 # Grep, Find, Dump, Restore, Tty, huge writes
+HELD_WAIT = 0.1 # how long a read of a held file is let wait before Tflush
+MSIZE = 65536 + 24
+
+
+class Hang(Exception):
+ """A 9P reply did not come within its timeout."""
+
+
+class Dropped(Exception):
+ """The server closed the connection (or reset it)."""
+
+
+class Ended(Exception):
+ """pardes quit cleanly: exit 0, no crash file. Closing the session's last
+ pane quits it (fs.md: 'Closing the session's last pane quits pardes'),
+ and a terminal whose shell exits closes its pane, so this can follow any
+ step; the run starts a fresh session and carries on."""
+
+
+# --------------------------------------------------------------------------
+# Wire: raw 9P with per-request timeouts and Tflush for held reads.
+
+class Wire(Client):
+ def __init__(self, address, msize=MSIZE):
+ super().__init__(address, timeout=TIMEOUT, msize=msize)
+ self.next_fid = 100
+
+ def newfid(self):
+ self.next_fid += 1
+ return self.next_fid
+
+ def _send(self, kind, payload):
+ self.tag = (self.tag + 1) % 65535
+ packet = struct.pack('<IBH', 7 + len(payload), kind, self.tag) + payload
+ if len(packet) > self.msize:
+ raise ValueError(f'harness bug: a {len(packet)}-byte request exceeds msize {self.msize}')
+ try:
+ self.socket.sendall(packet)
+ except socket.timeout:
+ raise Hang('send did not drain within the timeout')
+ except OSError as why:
+ raise Dropped(f'send: {why}')
+ return self.tag
+
+ def _recv(self, timeout):
+ self.socket.settimeout(timeout)
+ try:
+ size, kind, tag = struct.unpack('<IBH', self.receive(7))
+ body = self.receive(size - 7)
+ except socket.timeout:
+ raise Hang(f'no reply within {timeout:g}s')
+ except (EOFError, ConnectionResetError, BrokenPipeError) as why:
+ raise Dropped(str(why))
+ except OSError as why:
+ if isinstance(why, socket.timeout):
+ raise Hang(f'no reply within {timeout:g}s')
+ raise Dropped(str(why))
+ return kind, tag, body
+
+ def call(self, kind, payload, timeout=TIMEOUT):
+ """(ok, body_or_error_text)."""
+ tag = self._send(kind, payload)
+ while True:
+ rkind, rtag, body = self._recv(timeout)
+ if rtag != tag:
+ continue # a late reply to a flushed request
+ if rkind == RERROR:
+ n = struct.unpack_from('<H', body)[0]
+ return False, body[2:2 + n].decode(errors='replace')
+ if rkind != kind + 1:
+ raise Dropped(f'expected reply {kind + 1}, received {rkind}')
+ return True, body
+
+ def call_held(self, kind, payload, wait=HELD_WAIT):
+ """A request the server may hold: after `wait`, flush it. Returns
+ (ok, body) for an answer, or (None, 'flushed')."""
+ tag = self._send(kind, payload)
+ self.socket.settimeout(None)
+ ready, _, _ = select.select([self.socket], [], [], wait)
+ if ready:
+ rkind, rtag, body = self._recv(TIMEOUT)
+ if rtag == tag:
+ if rkind == RERROR:
+ n = struct.unpack_from('<H', body)[0]
+ return False, body[2:2 + n].decode(errors='replace')
+ return True, body
+ ftag = self._send(TFLUSH, struct.pack('<H', tag))
+ answer = (None, 'flushed')
+ while True:
+ rkind, rtag, body = self._recv(TIMEOUT)
+ if rtag == tag:
+ if rkind == RERROR:
+ n = struct.unpack_from('<H', body)[0]
+ answer = (False, body[2:2 + n].decode(errors='replace'))
+ else:
+ answer = (True, body)
+ elif rtag == ftag:
+ return answer
+
+ # Convenience over call().
+ def walk_names(self, names, timeout=TIMEOUT):
+ """(fid or None, error)."""
+ fid = self.newfid()
+ old = 1
+ if not names:
+ ok, body = self.call(TWALK, struct.pack('<IIH', old, fid, 0), timeout)
+ return (fid, None) if ok else (None, body)
+ for start in range(0, len(names), 16):
+ part = names[start:start + 16]
+ ok, body = self.call(TWALK, struct.pack('<IIH', old, fid, len(part)) +
+ b''.join(string(n) for n in part), timeout)
+ if not ok:
+ if old != 1:
+ self.call(TCLUNK, struct.pack('<I', fid))
+ return None, body
+ if struct.unpack_from('<H', body)[0] != len(part):
+ if old != 1:
+ self.call(TCLUNK, struct.pack('<I', fid))
+ return None, 'walk stopped short'
+ old = fid
+ return fid, None
+
+ def clunk(self, fid):
+ return self.call(TCLUNK, struct.pack('<I', fid))
+
+ def read_all(self, fid, count=None, limit=1 << 22):
+ count = min(count or (self.msize - 11), self.msize - 11)
+ data = bytearray()
+ while len(data) < limit:
+ ok, body = self.call(TREAD, struct.pack('<IQI', fid, len(data), count))
+ if not ok:
+ raise OSError(body)
+ chunk = body[4:]
+ if not chunk:
+ break
+ data.extend(chunk)
+ return bytes(data)
+
+ def read_path(self, path):
+ fid, err = self.walk_names(split(path))
+ if fid is None:
+ raise FileNotFoundError(f'{path}: {err}')
+ try:
+ ok, body = self.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ if not ok:
+ raise OSError(f'{path}: {body}')
+ return self.read_all(fid)
+ finally:
+ self.clunk(fid)
+
+ def write_path(self, path, data):
+ """(ok, error) for one open, one write, one clunk."""
+ fid, err = self.walk_names(split(path))
+ if fid is None:
+ return False, err
+ try:
+ ok, body = self.call(TOPEN, struct.pack('<IB', fid, OWRITE))
+ if not ok:
+ return False, body
+ ok, body = self.call(TWRITE, struct.pack('<IQI', fid, 0, len(data)) + data)
+ return (True, None) if ok else (False, body)
+ finally:
+ self.clunk(fid)
+
+
+def split(path):
+ return [p.encode('latin-1') if isinstance(p, str) else p for p in path.split('/') if p]
+
+
+def dir_names(data):
+ names, offset = [], 0
+ while offset + 2 <= len(data):
+ size = struct.unpack_from('<H', data, offset)[0]
+ n = struct.unpack_from('<H', data, offset + 41)[0]
+ names.append(data[offset + 43:offset + 43 + n].decode('latin-1'))
+ offset += size + 2
+ return names
+
+
+# --------------------------------------------------------------------------
+# Session lifecycle.
+
+def _libc():
+ try:
+ return ctypes.CDLL(None, use_errno=True)
+ except OSError:
+ return None
+
+
+def become_subreaper():
+ """Orphaned shells re-parent to us, not init, so teardown finds them."""
+ libc = _libc()
+ if libc is not None:
+ libc.prctl(36, 1, 0, 0, 0) # PR_SET_CHILD_SUBREAPER
+
+
+def descendants(root):
+ parent = {}
+ for entry in os.listdir('/proc'):
+ if not entry.isdigit():
+ continue
+ try:
+ with open(f'/proc/{entry}/stat', 'rb') as f:
+ stat = f.read()
+ ppid = int(stat[stat.rindex(b')') + 2:].split()[1])
+ except (OSError, ValueError):
+ continue
+ parent.setdefault(ppid, []).append(int(entry))
+ out, stack = [], [root]
+ while stack:
+ for child in parent.get(stack.pop(), []):
+ out.append(child)
+ stack.append(child)
+ return out
+
+
+def tokened(token):
+ """Our own processes whose environment carries this session's token."""
+ found, needle = [], f'M9P_TOKEN={token}'.encode()
+ for entry in os.listdir('/proc'):
+ if not entry.isdigit():
+ continue
+ try:
+ with open(f'/proc/{entry}/environ', 'rb') as f:
+ if needle in f.read().split(b'\0'):
+ found.append(int(entry))
+ except OSError:
+ continue
+ return found
+
+
+def sweep(prefix):
+ """Kill every process whose M9P_TOKEN starts with `prefix`, until none."""
+ needle = f'M9P_TOKEN={prefix}'.encode()
+ for _ in range(100):
+ found = []
+ for entry in os.listdir('/proc'):
+ if not entry.isdigit() or int(entry) == os.getpid():
+ continue
+ try:
+ with open(f'/proc/{entry}/environ', 'rb') as f:
+ if any(v.startswith(needle) for v in f.read().split(b'\0')):
+ found.append(int(entry))
+ except OSError:
+ continue
+ if not found:
+ return
+ for pid in found:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ reap_zombies()
+ time.sleep(0.02)
+
+
+def reap_zombies():
+ while True:
+ try:
+ pid, _ = os.waitpid(-1, os.WNOHANG)
+ except ChildProcessError:
+ return
+ if pid == 0:
+ return
+
+
+RUNTIME_DIRS = [] # removed at exit, whatever ended the run
+
+
+class Session:
+ """One throwaway `pardes --detach`, its dirs, its connection(s)."""
+
+ counter = 0
+
+ def __init__(self, binary, run_dir, follower):
+ Session.counter += 1
+ self.binary = binary
+ self.follower_mode = follower
+ self.token = f'{os.getpid()}-{Session.counter}-{random.SystemRandom().randrange(1 << 30)}'
+ self.dir = os.path.join(run_dir, f'sess{Session.counter}')
+ self.home = os.path.join(self.dir, 'home')
+ self.runtime = tempfile.mkdtemp(prefix='m9p.', dir='/tmp')
+ RUNTIME_DIRS.append(self.runtime)
+ self.proc = None
+ self.wire = None
+ self.follower = None
+ self.handles = {}
+ self.dump_written = False
+ self.marker = None
+ self.window_base = 0
+ self.start()
+
+ def env(self):
+ env = {k: v for k, v in os.environ.items()
+ if not k.startswith('PARDES_') and k not in ('NINE_MOUNT', 'NAMESPACE')}
+ env.update(HOME=self.home, XDG_RUNTIME_DIR=self.runtime,
+ XDG_CONFIG_HOME=os.path.join(self.home, 'config'),
+ XDG_STATE_HOME=os.path.join(self.home, 'state'),
+ XDG_DATA_HOME=os.path.join(self.home, 'data'),
+ XDG_CACHE_HOME=os.path.join(self.home, 'cache'),
+ SHELL='/bin/sh', PARDES_NOTIME='1', M9P_TOKEN=self.token,
+ PARDES_DUMP=os.path.join(self.home, 'm9p.dump.zon'))
+ for language in ['RS', 'C', 'GO', 'TS', 'PY', 'ZIG']:
+ env['PARDES_LSP_' + language] = ''
+ return env
+
+ def start(self):
+ os.makedirs(self.home, exist_ok=True)
+ for name, text in FIXTURES.items():
+ with open(os.path.join(self.home, name), 'wb') as f:
+ f.write(text)
+ self.stderr = open(os.path.join(self.dir, 'stderr'), 'wb')
+ self.proc = subprocess.Popen([self.binary, '--detach=m9p', 'alpha.txt'], cwd=self.home,
+ env=self.env(), stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL, stderr=self.stderr,
+ start_new_session=True)
+ self.address = os.path.join(self.runtime, 'pardes-9p-m9p.sock')
+ self.connect(first=True)
+ # Two more panes, so one close does not end the session.
+ for name in (b'utf8.txt\n', b'crlf.txt\n'):
+ self.wire.write_path('/look', name)
+
+ def connect(self, first=False):
+ deadline = time.monotonic() + (15 if first else 10)
+ last = None
+ while time.monotonic() < deadline:
+ if self.proc.poll() is not None:
+ raise Dropped(f'pardes exited {self.proc.returncode} before its socket answered')
+ if os.path.exists(self.address):
+ try:
+ self.wire = Wire(self.address)
+ break
+ except OSError as why:
+ last = why
+ time.sleep(0.01)
+ else:
+ raise Hang(f'9P socket did not answer: {last}')
+ self.handles = {}
+ self.marker = None
+ if self.follower_mode:
+ self.follower = Follower(self.address)
+
+ def reconnect(self):
+ self.close_wires()
+ # A Restore hangs up and serves again: let the old socket go.
+ time.sleep(0.05)
+ self.connect()
+
+ def close_wires(self):
+ if self.follower is not None:
+ self.follower.stop()
+ self.follower = None
+ if self.wire is not None:
+ try:
+ self.wire.socket.close()
+ except OSError:
+ pass
+ self.wire = None
+
+ def alive(self):
+ return self.proc is not None and self.proc.poll() is None
+
+ def crash_text(self):
+ """The crash file's text (src/crash.zig) and stderr's tail."""
+ out = []
+ for base, _, files in os.walk(self.home):
+ if 'crashes' in files:
+ with open(os.path.join(base, 'crashes'), 'rb') as f:
+ out.append(f.read().decode(errors='replace'))
+ try:
+ self.stderr.flush()
+ with open(os.path.join(self.dir, 'stderr'), 'rb') as f:
+ text = f.read()
+ # The panic line leads; the frames under it can run long.
+ tail = (text if len(text) <= 6000 else text[:3000] + b'\n...\n' + text[-2000:]).decode(errors='replace')
+ if tail.strip():
+ out.append('stderr: ' + tail)
+ except OSError:
+ pass
+ return '\n'.join(out)
+
+ def has_crash_file(self):
+ for base, _, files in os.walk(os.path.join(self.home, 'config')):
+ if 'crashes' in files:
+ return True
+ return False
+
+ def close(self, keep=False):
+ """Kill pardes and everything it started; verify nothing survives."""
+ self.close_wires()
+ if self.proc is not None and self.proc.poll() is None:
+ self.proc.terminate()
+ try:
+ self.proc.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ self.proc.kill()
+ self.proc.wait()
+ survivors = []
+ for _ in range(100):
+ reap_zombies()
+ pids = set(tokened(self.token)) | set(descendants(os.getpid()))
+ pids.discard(os.getpid())
+ if not pids:
+ break
+ for pid in pids:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ survivors = pids
+ time.sleep(0.02)
+ else:
+ raise RuntimeError(f'processes survived teardown: {sorted(survivors)}')
+ reap_zombies()
+ self.stderr.close()
+ shutil.rmtree(self.runtime, ignore_errors=True)
+ if not keep:
+ shutil.rmtree(self.dir, ignore_errors=True)
+
+
+class Follower:
+ """A second connection holding `follow new` on /log, reading records."""
+
+ def __init__(self, address):
+ self.lines = []
+ self.cond = threading.Condition()
+ self.done = False
+ self.wire = Wire(address)
+ fid, err = self.wire.walk_names([b'log'])
+ ok, body = self.wire.call(TOPEN, struct.pack('<IB', fid, ORDWR))
+ ok, body = self.wire.call(TWRITE, struct.pack('<IQI', fid, 0, 11) + b'follow new\n')
+ if not ok:
+ raise OSError(f'follow new: {body}')
+ self.fid = fid
+ self.thread = threading.Thread(target=self.run, daemon=True)
+ self.thread.start()
+
+ def run(self):
+ offset = 0
+ try:
+ while not self.done:
+ tag = self.wire._send(TREAD, struct.pack('<IQI', self.fid, offset, 8192))
+ while True:
+ self.wire.socket.settimeout(None)
+ kind, rtag, body = self.wire._recv(None)
+ if rtag == tag:
+ break
+ if kind == RERROR:
+ break
+ chunk = body[4:]
+ if not chunk:
+ break
+ offset += len(chunk)
+ with self.cond:
+ self.lines.extend(chunk.decode(errors='replace').splitlines())
+ self.cond.notify_all()
+ except (Hang, Dropped, OSError, struct.error):
+ pass
+ with self.cond:
+ self.done = True
+ self.cond.notify_all()
+
+ def wait_for(self, needle, timeout=TIMEOUT):
+ deadline = time.monotonic() + timeout
+ with self.cond:
+ while True:
+ for i in range(len(self.lines) - 1, -1, -1):
+ if needle in self.lines[i]:
+ return i
+ left = deadline - time.monotonic()
+ if left <= 0 or self.done:
+ return None
+ self.cond.wait(left)
+
+ def stop(self):
+ self.done = True
+ try:
+ self.wire.socket.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ self.wire.socket.close()
+ self.thread.join(timeout=2)
+
+
+FIXTURES = {
+ 'alpha.txt': b'alpha beta gamma\nsecond line\n\nthird after a blank\n',
+ 'utf8.txt': 'naïve café 日本語 é \U0001f600\nline two\n'.encode(),
+ 'crlf.txt': b'one\r\ntwo\r\nthree\r\n',
+ 'bad.txt': b'hello \xff\xfe world\nlatin-1 caf\xe9\ntruncated \xe6\x97 here\n',
+ 'empty.txt': b'',
+ 'noeol.txt': b'no newline at the end',
+}
+
+
+# --------------------------------------------------------------------------
+# Results, registries.
+
+class Result:
+ def __init__(self):
+ self.requests = [] # (kind, path, ok, why)
+ self.hung_up = False # the op expected the server to hang up (Restore)
+ self.note = ''
+
+ def add(self, kind, path, ok, why=None):
+ self.requests.append((kind, path, ok, why))
+
+ def writes(self):
+ return [r for r in self.requests if r[0] in ('write', 'clunk-write', 'open-new', 'trunc')]
+
+
+GENERATORS = [] # (weight, fn)
+OPS = {} # name -> fn(sess, op) -> Result
+INVARIANTS = [] # fn(ctx, res) -> str | None
+
+
+def generator(weight):
+ def wrap(fn):
+ GENERATORS.append((weight, fn))
+ return fn
+ return wrap
+
+
+def op_kind(name):
+ def wrap(fn):
+ OPS[name] = fn
+ return fn
+ return wrap
+
+
+def invariant(fn):
+ INVARIANTS.append(fn)
+ return fn
+
+
+def host_path(path):
+ """Guard: nothing mutating ever touches the served host tree."""
+ parts = split(path)
+ return bool(parts) and parts[0] in (b'os', b'src')
+
+
+def is_held(path):
+ return (path.rstrip('/') == '/log' or path.endswith('/event') or
+ path.endswith('/pty/data') or path.endswith('/pty/run'))
+
+
+def slow(data):
+ return len(data) > 65536 or any(w in data for w in (b'Grep', b'Find', b'Dump', b'Restore', b'Tty', b'Edit'))
+
+
+# --------------------------------------------------------------------------
+# Op kinds: each takes a concrete op (what the ops log holds) and runs it.
+
+def dec(s):
+ return s.encode('latin-1')
+
+
+def enc(b):
+ return b.decode('latin-1')
+
+
+@op_kind('write')
+def op_write(sess, op):
+ """open(path, mode); write data in the given chunk sizes; clunk."""
+ res = Result()
+ path, data, mode = op['path'], dec(op['data']), op.get('mode', OWRITE)
+ if host_path(path):
+ res.note = 'refused by guard: host tree'
+ return res
+ w = sess.wire
+ fid, err = w.walk_names(split(path))
+ if fid is None:
+ res.add('walk', path, False, err)
+ return res
+ timeout = SLOW_TIMEOUT if slow(data) else TIMEOUT
+ ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode), timeout)
+ res.add(open_kind(path, mode), path, ok, None if ok else body)
+ allowances(res, path, data)
+ opened = ok
+ sent = data
+ if ok:
+ offset = 0
+ for size in chunking(op.get('chunks', 'whole'), len(data), w.msize - 23):
+ piece = data[offset:offset + size]
+ ok, body = w.call(TWRITE, struct.pack('<IQI', fid, op.get('offset', offset), len(piece)) + piece, timeout)
+ # A write on a fid opened for reading is 9P misuse, not a write
+ # the tree refused: the rule does not name it.
+ res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
+ offset += size
+ sent = data[:offset]
+ if not ok and op.get('stop_on_error', True):
+ break
+ ok, body = w.call(TCLUNK, struct.pack('<I', fid), timeout)
+ res.add('clunk-write' if mode & 3 else 'clunk', path, ok, None if ok else body)
+ # What the open holds when it closes: the last line written, if unended.
+ if opened and mode & 3 and sent and not sent.endswith(b'\n'):
+ res.add('close-runs', path, ok)
+ return res
+
+
+def allowances(res, path, data):
+ """Errs a successful write may log (fs.md): a look that finds nothing logs
+ one, per line, whether written to look, written back as an event record
+ (acme's xfid.c:842) or run as the Look builtin; an Edit block whose text
+ never ended runs, and fails, at the close."""
+ base = path.rsplit('/', 1)[-1]
+ lines = [line for line in data.split(b'\n') if line.strip()]
+ if base in ('look', 'event'):
+ n = len(lines)
+ elif base in ('exec', 'ctl', 'tagexec'):
+ n = sum(1 for line in lines if line.split()[:1] == [b'Look'])
+ if b'Edit' in data and len(lines) > 1:
+ n += 1
+ else:
+ return
+ if n:
+ res.add('allow', path, True, n)
+
+
+def open_kind(path, mode):
+ """An open the rule names: pane/new's (it makes a pane) and a truncation
+ ('every write or truncation the tree refused'); others are 'open'."""
+ if path.rstrip('/') == '/pane/new' and not mode & 3:
+ return 'open-new'
+ if mode & OTRUNC and mode & 3:
+ return 'trunc'
+ return 'open'
+
+
+def chunking(spec, n, cap):
+ if n == 0:
+ return [0]
+ if spec == 'bytes':
+ return [1] * n
+ if isinstance(spec, list):
+ out, total = [], 0
+ for s in spec:
+ s = max(1, min(s, cap, n - total))
+ out.append(s)
+ total += s
+ if total >= n:
+ return out
+ while total < n:
+ s = min(cap, n - total)
+ out.append(s)
+ total += s
+ return out
+ out, total = [], 0
+ while total < n:
+ s = min(cap, n - total)
+ out.append(s)
+ total += s
+ return out
+
+
+@op_kind('read')
+def op_read(sess, op):
+ """open(path, OREAD); read from offset in chunks of count until EOF or cap."""
+ res = Result()
+ path, count, offset = op['path'], op.get('count', 8192), op.get('offset', 0)
+ w = sess.wire
+ fid, err = w.walk_names(split(path))
+ if fid is None:
+ res.add('walk', path, False, err)
+ return res
+ mode = op.get('mode', OREAD)
+ ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode))
+ res.add(open_kind(path, mode), path, ok, None if ok else body)
+ if ok:
+ for _ in range(op.get('reads', 4)):
+ payload = struct.pack('<IQI', fid, offset, min(count, w.msize - 11))
+ if is_held(path):
+ ok, body = w.call_held(TREAD, payload)
+ else:
+ ok, body = w.call(TREAD, payload)
+ res.add('read', path, ok, body if ok is False else None)
+ if not ok or len(body) <= 4:
+ break
+ offset += len(body) - 4
+ ok, body = w.call(TCLUNK, struct.pack('<I', fid))
+ res.add('clunk', path, ok, None if ok else body)
+ return res
+
+
+@op_kind('stat')
+def op_stat(sess, op):
+ res = Result()
+ fid, err = sess.wire.walk_names(split(op['path']))
+ res.add('walk', op['path'], fid is not None, err)
+ if fid is not None:
+ ok, body = sess.wire.call(TSTAT, struct.pack('<I', fid))
+ res.add('stat', op['path'], ok, None if ok else body)
+ sess.wire.clunk(fid)
+ return res
+
+
+@op_kind('remove')
+def op_remove(sess, op):
+ res = Result()
+ if host_path(op['path']):
+ res.note = 'refused by guard: host tree'
+ return res
+ fid, err = sess.wire.walk_names(split(op['path']))
+ res.add('walk', op['path'], fid is not None, err)
+ if fid is not None:
+ ok, body = sess.wire.call(TREMOVE, struct.pack('<I', fid), SLOW_TIMEOUT)
+ res.add('remove', op['path'], ok, None if ok else body)
+ return res
+
+
+@op_kind('create')
+def op_create(sess, op):
+ res = Result()
+ if host_path(op['path']):
+ res.note = 'refused by guard: host tree'
+ return res
+ fid, err = sess.wire.walk_names(split(op['path']))
+ res.add('walk', op['path'], fid is not None, err)
+ if fid is not None:
+ ok, body = sess.wire.call(TCREATE, struct.pack('<I', fid) + string(dec(op['name'])) +
+ struct.pack('<IB', op.get('perm', 0o666), op.get('mode', OREAD)))
+ res.add('create', op['path'] + '/' + op['name'], ok, None if ok else body)
+ sess.wire.clunk(fid)
+ return res
+
+
+@op_kind('hold')
+def op_hold(sess, op):
+ """Open and keep a fid across later ops as handle op['h']."""
+ res = Result()
+ path, mode = op['path'], op.get('mode', OREAD)
+ if host_path(path) and mode & 3:
+ res.note = 'refused by guard: host tree'
+ return res
+ fid, err = sess.wire.walk_names(split(path))
+ if fid is None:
+ res.add('walk', path, False, err)
+ return res
+ ok, body = sess.wire.call(TOPEN, struct.pack('<IB', fid, mode))
+ res.add(open_kind(path, mode), path, ok, None if ok else body)
+ if ok:
+ sess.handles[op['h']] = [fid, path, mode, [0], b'']
+ else:
+ sess.wire.clunk(fid)
+ return res
+
+
+@op_kind('hwrite')
+def op_hwrite(sess, op):
+ res = Result()
+ h = sess.handles.get(op['h'])
+ if h is None:
+ res.note = 'no such handle'
+ return res
+ fid, path, mode, off, _ = h
+ data = dec(op['data'])[:sess.wire.msize - 23] # one Twrite
+ h[4] = data
+ allowances(res, path, data)
+ ok, body = sess.wire.call(TWRITE, struct.pack('<IQI', fid, off[0], len(data)) + data,
+ SLOW_TIMEOUT if slow(data) else TIMEOUT)
+ res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
+ if ok:
+ off[0] += len(data)
+ return res
+
+
+@op_kind('hread')
+def op_hread(sess, op):
+ res = Result()
+ h = sess.handles.get(op['h'])
+ if h is None:
+ res.note = 'no such handle'
+ return res
+ fid, path, _, off, _ = h
+ offset = op['offset'] if op.get('offset') is not None else off[0]
+ payload = struct.pack('<IQI', fid, offset, min(op.get('count', 8192), sess.wire.msize - 11))
+ ok, body = sess.wire.call_held(TREAD, payload) if is_held(path) else sess.wire.call(TREAD, payload)
+ res.add('read', path, ok, body if ok is False else None)
+ if ok:
+ off[0] = offset + len(body) - 4
+ return res
+
+
+@op_kind('clunk')
+def op_clunk(sess, op):
+ res = Result()
+ h = sess.handles.pop(op['h'], None)
+ if h is None:
+ res.note = 'no such handle'
+ return res
+ ok, body = sess.wire.call(TCLUNK, struct.pack('<I', h[0]))
+ res.add('clunk-write' if h[2] & 3 else 'clunk', h[1], ok, None if ok else body)
+ if h[2] & 3 and h[4] and not h[4].endswith(b'\n'):
+ res.add('close-runs', h[1], ok)
+ return res
+
+
+@op_kind('restore')
+def op_restore(sess, op):
+ """Write Restore [f] to /ctl: refused with an Rerror, or the server
+ hangs up and serves the dump's panes again (fs.md: a Restore hangs its
+ connection up)."""
+ res = Result()
+ data = dec(op['data'])
+ try:
+ ok, why = sess.wire.write_path('/ctl', data)
+ res.add('write', '/ctl', ok, why)
+ if ok:
+ # Taken: the hangup follows the reply.
+ try:
+ sess.wire.call(TSTAT, struct.pack('<I', 1), 2.0)
+ except (Dropped, Hang):
+ pass
+ res.hung_up = True
+ except Dropped:
+ res.add('write', '/ctl', True, None)
+ res.hung_up = True
+ if res.hung_up:
+ sess.reconnect()
+ return res
+
+
+@op_kind('host')
+def op_host(sess, op):
+ """A change on disk under the session's HOME: write or delete a file."""
+ res = Result()
+ name = op['name']
+ assert '/' not in name and name not in ('.', '..')
+ path = os.path.join(sess.home, name)
+ if op['action'] == 'rm':
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+ else:
+ with open(path, 'wb') as f:
+ f.write(dec(op['data']))
+ return res
+
+
+@op_kind('seq')
+def op_seq(sess, op):
+ """Several ops as one step (e.g. clearing the window)."""
+ res = Result()
+ for sub in op['ops']:
+ r = OPS[sub['op']](sess, sub)
+ res.requests.extend(r.requests)
+ res.hung_up |= r.hung_up
+ return res
+
+
+# --------------------------------------------------------------------------
+# Input: data pools and generators (deliberately simple; swap freely).
+
+SAFE = (b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
+ b'-_=+,:#^@%()\n\r\t\0')
+UNSAFE = set(b'/~$`;|&<>.\\*?![]{}\'"')
+
+
+def shell_safe(data):
+ """No path or shell metacharacters, whatever else it holds."""
+ return bytes(b for b in data if b not in UNSAFE)
+
+
+RUNES = ['é', 'é', '日本', '\U0001f600', 'Δ', ' ', '​', '\u0080', '\u009f']
+ODD = [b'', b'\n', b'\r\n', b'\0', b'\xff', b'\xfe\xff', b'\xe6\x97', b'\xc0\x80', b'\x1b[2J', b'\x7f', b'\t',
+ b'\n\n\n', b' ', b' \n']
+WORDS = [b'alpha', b'beta', b'foo', b'bar', b'x', b'hello', b'zz', b'0', b'1', b'-1', b'99999999999',
+ b'on', b'off', b'follow', b'follow new', b'lock', b'unlock', b'clean', b'dirty', b'show',
+ b'dot=addr', b'addr=dot', b'limit=addr', b'nomark', b'mark', b'get', b'put', b'del', b'delete',
+ b'name', b'cleartag', b'answer', b'answer -', b'size 80 24', b'size 20 6', b'size 5000 5000',
+ b'winsize 80 24', b'winsize 0 0', b'sig INT', b'sig TERM', b'sig HUP', b'sig KILL', b'sig BOGUS',
+ b'exec', b'Placement acme', b'Placement pardes', b'Wrap on', b'Wrap off']
+SHELL_LINES = [b'true', b'false', b'echo hi', b'echo m9p', b'sleep 1', b'ls', b'cat nonexist',
+ b'printf abc', b'exit 3', b'seq 1 2000', b'yes | head -n 3000', b'date', b'pwd', b'env | wc']
+DENY = {'Exit', 'Attach', 'Detach', 'Mount', 'Unmount', 'Tty9p', 'ClipYank', 'ClipYankMain', 'ClipPaste',
+ 'ClipPasteBefore', 'ClipReplace', 'Shell', 'ThemeFile', 'EffectCode', 'Help', 'Tutor', 'Changelog',
+ 'Rename', 'Hover', 'CodeAction', 'SelectRefs', 'Symbols', 'Diagnostics', 'WsDiagnostics', 'Callers',
+ 'Callees', 'Supertypes', 'Subtypes', 'WsSymbols', 'Lspinfo', 'Lspwhy', 'Restore'}
+BUILTINS = ['Look', 'Exec', 'Kill', 'Dump', 'Msg', 'NextColor', 'Themes', 'DumpThemes', 'TreeContext', 'PdfFit',
+ 'PdfTint', 'PdfSections', 'Petscii', 'Palette', 'Ascii', 'Save', 'New', 'Newcol', 'Del', 'Filter',
+ 'Mode', 'Togglettymode', 'Edit', 'Undo', 'Redo', 'Collapse', 'Delcol', 'DelAbove', 'DelBelow', 'Tty',
+ 'Repl', 'Joincol', 'Config', 'LocationsConfig', 'Messages', 'Mini', 'Find', 'Grep', 'Left', 'Down',
+ 'Up', 'Right', 'Back', 'Forward', 'Last', 'Recent', 'Jumplist', 'Colors', 'Wrap', 'Tagbottom', 'Debug',
+ 'FocusTint', 'Verbose', 'Motion', 'InactiveDim', 'DumpDir', 'Theme', 'Placement', 'BootShell',
+ 'LookWord', 'Get', 'Put', 'Undo', 'Redo', 'Zerox', 'Snarf', 'Paste', 'Cut', 'Sort', 'Font', 'Local']
+PANE_FILES = ['name', 'body', 'tag', 'ctl', 'addr', 'dot', 'limit', 'data', 'xdata', 'sel', 'dirty', 'mark',
+ 'scroll', 'errors', 'event', 'look', 'exec', 'tagexec', 'pty', 'pty/run', 'pty/data', 'pty/ctl',
+ 'pty/status']
+ROOT_FILES = ['README', 'index', 'status', 'focus', 'ctl', 'commands', 'recent', 'look', 'exec', 'log', 'screen',
+ 'listeners', 'layout', 'tag', 'tagexec', 'pane', 'pane/new', 'col', '']
+COL_FILES = ['tag', 'ctl', 'exec']
+WRITABLE = ['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'body', 'data', 'xdata', 'addr', 'dot', 'limit',
+ 'name', 'sel', 'dirty', 'mark', 'scroll', 'errors', 'event', 'pty/run', 'pty/data', 'pty/ctl', 'log']
+
+
+class Ctx:
+ """What the generators and invariants see."""
+
+ def __init__(self, seed):
+ self.seed = seed
+ self.rng = random.Random(seed)
+ self.sess = None
+ self.step = 0
+ self.serials = []
+ self.cols = []
+ self.gone = [] # serials seen and since closed (stale)
+ self.next_h = 0
+ self.last_index = b''
+ self.verbose = False
+
+
+def pick_serial(ctx, rng):
+ """A live pane as a token, {pN}: the Nth serial of /index (mod its
+ length) when the op runs, so a shrunk replay still names a pane that
+ exists; or a stale or impossible serial, literally."""
+ r = rng.random()
+ if ctx.serials and r < 0.85:
+ return '{p%d}' % rng.randrange(64)
+ if ctx.gone and r < 0.95:
+ return rng.choice(ctx.gone)
+ return rng.choice([0, 1, 999, 4294967295, 4294967296, -1])
+
+
+def pick_col(ctx, rng):
+ if ctx.cols and rng.random() < 0.85:
+ return '{c%d}' % rng.randrange(16)
+ return rng.choice([0, 99, 4294967295])
+
+
+def odd_bytes(rng, n):
+ out = bytearray()
+ while len(out) < n:
+ r = rng.random()
+ if r < 0.5:
+ out += rng.choice(WORDS)
+ elif r < 0.7:
+ out += rng.choice(RUNES).encode()
+ elif r < 0.9:
+ out += rng.choice(ODD)
+ else:
+ out.append(rng.randrange(256))
+ if rng.random() < 0.5:
+ out += b' '
+ return bytes(out[:n])
+
+
+def payload(rng, maxlen=64):
+ """Plain or odd bytes, sometimes huge, with or without a newline."""
+ r = rng.random()
+ if r < 0.35:
+ data = rng.choice(WORDS)
+ elif r < 0.55:
+ data = odd_bytes(rng, rng.randrange(0, maxlen))
+ elif r < 0.62:
+ data = rng.choice(ODD)
+ elif r < 0.66:
+ data = (rng.choice(WORDS) + b' ') * rng.randrange(100, 20000) # huge
+ elif r < 0.7:
+ data = b'x' * rng.choice([255, 256, 1023, 1024, 1025, 65535, 65536, 70000])
+ else:
+ data = rng.choice(WORDS) + b' ' + odd_bytes(rng, rng.randrange(0, 12))
+ if rng.random() < 0.6 and not data.endswith(b'\n'):
+ data += rng.choice([b'\n', b'\n', b'\r\n', b'\n\n'])
+ return shell_safe(data)
+
+
+def weird_name(rng):
+ r = rng.random()
+ if r < 0.3:
+ return 'x' * rng.choice([255, 256, 300, 1000])
+ if r < 0.5:
+ return rng.choice(['..', '.', '', ' ', 'new', 'NEW', '01', '1 ', '\n', 'a\0b'])
+ if r < 0.7:
+ return enc(rng.choice(RUNES).encode())
+ return enc(bytes(rng.randrange(1, 256) for _ in range(rng.randrange(1, 20))).replace(b'/', b'_'))
+
+
+def random_path(ctx, rng, writable=False):
+ r = rng.random()
+ if r < 0.6:
+ f = rng.choice(WRITABLE if writable else PANE_FILES)
+ return f'/pane/{pick_serial(ctx, rng)}/{f}'
+ if r < 0.75:
+ f = rng.choice(['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'log'] if writable else ROOT_FILES)
+ return '/' + f
+ if r < 0.85:
+ return f'/col/{pick_col(ctx, rng)}/{rng.choice(COL_FILES)}'
+ if r < 0.93:
+ return f'/pane/{pick_serial(ctx, rng)}/{weird_name(rng)}'
+ return '/' + '/'.join(weird_name(rng) for _ in range(rng.randrange(1, 4)))
+
+
+def data_for(ctx, rng, path):
+ """Payload a file is likely to take, often mutated."""
+ base = path.rsplit('/', 1)[-1]
+ r = rng.random()
+ if r < 0.25:
+ return payload(rng)
+ if base in ('addr', 'dot', 'limit', 'sel'):
+ return address(rng)
+ if base == 'ctl' and path.endswith('pty/ctl'):
+ return rng.choice([b'sig INT\n', b'sig TERM\n', b'sig HUP\n', b'sig QUIT\n', b'sig KILL\n',
+ b'winsize 80 24\n', b'winsize 1 1\n', b'winsize 99999 3\n', b'exec\n', b'sig\n'])
+ if base in ('ctl', 'exec', 'tagexec'):
+ return builtin_line(ctx, rng, path)
+ if base == 'run':
+ return shell_safe(rng.choice(SHELL_LINES)) + b'\n'
+ if base == 'data' and '/pty/' in path:
+ return shell_safe(rng.choice(SHELL_LINES)) + rng.choice([b'\r', b'\n', b'', b'\x03', b'\x04'])
+ if base == 'event':
+ return event_record(rng)
+ if base == 'focus':
+ return str(pick_serial(ctx, rng)).encode() + rng.choice([b'\n', b''])
+ if base == 'look':
+ return shell_safe(rng.choice([b'alpha', b'alpha.txt', b'alpha.txt:2', b'utf8.txt:#3', b'nothere',
+ b'bad.txt', b'crlf.txt:1', b'gamma', b'beta'])) + b'\n'
+ if base in ('dirty', 'mark', 'scroll'):
+ return rng.choice([b'0', b'1', b'0\n', b'1\n', b'2\n', b'', b'yes\n'])
+ if base == 'log':
+ return rng.choice([b'follow\n', b'follow new\n', b'follow', b'nope\n'])
+ return payload(rng, 200)
+
+
+def mutate(rng, data, rounds=None):
+ data = bytearray(data)
+ for _ in range(rounds or rng.randrange(1, 4)):
+ if not data:
+ data += rng.choice(WORDS)
+ continue
+ i = rng.randrange(len(data))
+ r = rng.random()
+ if r < 0.3:
+ del data[i]
+ elif r < 0.55:
+ data[i:i] = shell_safe(rng.choice(ODD) or b'\0')
+ elif r < 0.75:
+ j = rng.randrange(i, len(data) + 1)
+ data[i:i] = data[i:j]
+ else:
+ data[i:i] = rng.choice(RUNES).encode()
+ return bytes(data)
+
+
+def builtin_line(ctx, rng, path):
+ word = rng.choice(BUILTINS).encode()
+ r = rng.random()
+ if r < 0.4:
+ line = word
+ elif r < 0.7:
+ line = word + b' ' + rng.choice(WORDS)
+ elif r < 0.8:
+ line = b'Edit ' + edit_command(rng)
+ return line + b'\n' # Edit text keeps its / delimiters
+ else:
+ line = mutate(rng, word + b' ' + rng.choice(WORDS))
+ if rng.random() < 0.1:
+ line = shell_safe(rng.choice(SHELL_LINES))
+ if rng.random() < 0.8:
+ line += b'\n'
+ return shell_safe(line)
+
+
+# sam's commands without the ones that reach files or shells.
+EDIT_TEXT = b'abcdghijklmopqstuvxyz0123456789 ACEGHIJKLMNOPQRSTUVWXYZ_-'
+
+
+def edit_text(rng):
+ s = bytes(rng.choice(EDIT_TEXT) for _ in range(rng.randrange(0, 8)))
+ if rng.random() < 0.3:
+ s += rng.choice(RUNES).encode()
+ return s
+
+
+def regex(rng):
+ parts = [edit_text(rng) or b'a']
+ for _ in range(rng.randrange(0, 4)):
+ parts.append(rng.choice([b'(a|b)', b'^', b'$', b'[a-z]', b'[^ ]', b'.*', b'\\n',
+ '[é日]'.encode(), b'x+', b'(', b'[', b'\\', b'a**',
+ b'(((a)))', b'[z-a]', (b'a|' * rng.randrange(1, 300)) + b'b']))
+ parts.append(edit_text(rng))
+ return b''.join(parts)
+
+
+def address(rng):
+ """sam addresses: valid, near-valid and garbage."""
+ simple = [b'0', b'$', b'.', b',', b';', b'#0', b'#3', b'#99999', b'1', b'2', b'3:2', b'1:1', b'0:0',
+ b'#1,#2', b'1,$', b'2,1', b'-', b'+', b'-1', b'+2', b'#-1', b'99999', b'1:99', b'?a?']
+ r = rng.random()
+ if r < 0.4:
+ a = rng.choice(simple)
+ elif r < 0.7:
+ a = b'/' + regex(rng) + b'/'
+ elif r < 0.85:
+ a = rng.choice(simple) + rng.choice([b',', b';', b'+', b'-']) + rng.choice(simple + [b'/a/'])
+ else:
+ a = mutate(rng, rng.choice(simple) + b'/' + regex(rng) + b'/')
+ return a + rng.choice([b'', b'\n'])
+
+
+def edit_command(rng):
+ t = lambda: edit_text(rng) # noqa: E731
+ cmds = [lambda: b',x/' + regex(rng) + b'/c/' + t() + b'/',
+ lambda: b's/' + regex(rng) + b'/' + t() + b'/g',
+ lambda: b'a/' + t() + b'/',
+ lambda: b'i/' + t() + b'/',
+ lambda: b'c/' + t() + b'/',
+ lambda: b'd',
+ lambda: b',d',
+ lambda: b',y/' + regex(rng) + b'/d',
+ lambda: b',x/' + regex(rng) + b'/g/' + regex(rng) + b'/d',
+ lambda: b',x/' + regex(rng) + b'/v/' + regex(rng) + b'/c/' + t() + b'/',
+ lambda: b'1,2m$',
+ lambda: b'1t0',
+ lambda: b'k',
+ lambda: b'p',
+ lambda: b'=',
+ lambda: b'u',
+ lambda: b'{\na/' + t() + b'/\ni/' + t() + b'/\n}',
+ lambda: b',x/\\n/a/' + t() + b'/',
+ lambda: b'0,$s/' + regex(rng) + b'/&&/g']
+ cmd = rng.choice(cmds)()
+ if rng.random() < 0.4:
+ cmd = address(rng).rstrip(b'\n') + cmd
+ if rng.random() < 0.2:
+ cmd = mutate(rng, cmd)
+ # Mutation never introduces a banned command letter or shell char.
+ cmd = bytes(b for b in cmd if b not in b'werfbBDn!<>|')
+ return cmd
+
+
+def event_record(rng):
+ origin = rng.choice(b'EFKMZ')
+ action = rng.choice(b'xXlLiIdDZ')
+ q0, q1 = rng.choice([0, 1, 5, 10]), rng.choice([0, 2, 7, 1 << 40, -1])
+ rec = bytes([origin, action]) + f'{q0} {q1} {rng.choice([0, 1, 2, 8])} '.encode()
+ text = shell_safe(edit_text(rng))
+ rec += str(len(text)).encode() + b' ' + text + b'\n'
+ if rng.random() < 0.3:
+ rec = mutate(rng, rec)
+ return shell_safe(rec)
+
+
+def new_handle(ctx):
+ ctx.next_h += 1
+ return f'h{ctx.next_h}'
+
+
+@generator(30)
+def gen_write(ctx, rng):
+ path = random_path(ctx, rng, writable=True)
+ data = data_for(ctx, rng, path)
+ mode = rng.choice([OWRITE, OWRITE, ORDWR, OWRITE | OTRUNC, ORDWR | OTRUNC, OREAD])
+ r = rng.random()
+ chunks = 'bytes' if r < 0.08 and len(data) <= 64 else (
+ [rng.randrange(1, 16) for _ in range(4)] if r < 0.15 else 'whole')
+ op = {'op': 'write', 'path': path, 'data': enc(data), 'mode': mode, 'chunks': chunks}
+ if rng.random() < 0.05:
+ op['offset'] = rng.choice([1, 100, 1 << 40])
+ return op
+
+
+@generator(14)
+def gen_read(ctx, rng):
+ path = random_path(ctx, rng)
+ return {'op': 'read', 'path': path, 'count': rng.choice([1, 2, 7, 64, 200, 8192, 1 << 20]),
+ 'offset': rng.choice([0, 0, 0, 1, 3, 100, 1 << 33]), 'reads': rng.choice([1, 2, 8])}
+
+
+@generator(4)
+def gen_stat(ctx, rng):
+ path = random_path(ctx, rng)
+ if rng.random() < 0.2:
+ path = '/os/' + rng.choice(['etc', 'etc/hosts', 'nonexistent', 'x' * 300])
+ return {'op': 'stat', 'path': path}
+
+
+@generator(5)
+def gen_newpane(ctx, rng):
+ if rng.random() < 0.7:
+ return {'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1}
+ return {'op': 'hold', 'h': new_handle(ctx), 'path': '/pane/new', 'mode': rng.choice([OREAD, ORDWR])}
+
+
+@generator(2)
+def gen_remove(ctx, rng):
+ r = rng.random()
+ if r < 0.6:
+ path = f'/pane/{pick_serial(ctx, rng)}'
+ elif r < 0.85:
+ path = f'/col/{pick_col(ctx, rng)}'
+ else:
+ path = random_path(ctx, rng)
+ return {'op': 'remove', 'path': path}
+
+
+@generator(2)
+def gen_create(ctx, rng):
+ where = rng.choice(['/col', '/pane', '/', f'/pane/{pick_serial(ctx, rng)}'])
+ return {'op': 'create', 'path': where, 'name': weird_name(rng) if rng.random() < 0.5 else 'new',
+ 'perm': rng.choice([0o666, DMDIR | 0o777]), 'mode': rng.choice([OREAD, OWRITE])}
+
+
+@generator(2)
+def gen_clear_window(ctx, rng):
+ """Down to the empty window (every pane closed), sometimes rebuilt."""
+ keep = rng.choice(ctx.serials) if ctx.serials and rng.random() < 0.8 else None
+ ops = [{'op': 'remove', 'path': f'/pane/{s}'} for s in ctx.serials if s != keep]
+ if rng.random() < 0.5:
+ ops += [{'op': 'remove', 'path': f'/col/{c}'} for c in ctx.cols]
+ if rng.random() < 0.6:
+ ops.append({'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1})
+ return {'op': 'seq', 'ops': ops}
+
+
+@generator(6)
+def gen_hold(ctx, rng):
+ held = [h for h in (ctx.sess.handles if ctx.sess else {})]
+ r = rng.random()
+ if not held or r < 0.35:
+ path = random_path(ctx, rng)
+ if rng.random() < 0.3:
+ path = rng.choice(['/log', f'/pane/{pick_serial(ctx, rng)}/event', '/screen', '/index',
+ f'/pane/{pick_serial(ctx, rng)}/ctl'])
+ return {'op': 'hold', 'h': new_handle(ctx), 'path': path, 'mode': rng.choice([OREAD, ORDWR, OWRITE])}
+ h = rng.choice(held)
+ path = ctx.sess.handles[h][1]
+ if r < 0.6:
+ return {'op': 'hwrite', 'h': h, 'data': enc(data_for(ctx, rng, path))}
+ if r < 0.85:
+ return {'op': 'hread', 'h': h, 'count': rng.choice([1, 16, 8192]),
+ 'offset': rng.choice([None, None, 0, 5])}
+ return {'op': 'clunk', 'h': h}
+
+
+@generator(6)
+def gen_shell(ctx, rng):
+ s = pick_serial(ctx, rng)
+ line = shell_safe(rng.choice(SHELL_LINES))
+ r = rng.random()
+ if r < 0.25:
+ return {'op': 'write', 'path': f'/pane/{s}/ctl', 'data': enc(b'Tty\n'), 'mode': OWRITE}
+ if r < 0.45:
+ return {'op': 'write', 'path': f'/pane/{s}/pty/run', 'data': enc(line + b'\n'), 'mode': ORDWR}
+ if r < 0.6:
+ return {'op': 'write', 'path': f'/pane/{s}/pty/data', 'data': enc(line + b'\r'), 'mode': OWRITE}
+ if r < 0.75:
+ return {'op': 'write', 'path': f'/pane/{s}/pty/ctl',
+ 'data': enc(rng.choice([b'sig INT\n', b'sig TERM\n', b'sig KILL\n', b'sig HUP\n'])), 'mode': OWRITE}
+ return {'op': 'write', 'path': rng.choice(['/exec', f'/pane/{s}/exec']), 'data': enc(line + b'\n'),
+ 'mode': OWRITE}
+
+
+@generator(6)
+def gen_edit(ctx, rng):
+ s = pick_serial(ctx, rng)
+ path = rng.choice([f'/pane/{s}/ctl', f'/pane/{s}/exec', '/exec'])
+ return {'op': 'write', 'path': path, 'data': enc(b'Edit ' + edit_command(rng) + b'\n'), 'mode': OWRITE}
+
+
+@generator(8)
+def gen_addr(ctx, rng):
+ s = pick_serial(ctx, rng)
+ ops = [{'op': 'write', 'path': f'/pane/{s}/addr', 'data': enc(address(rng)), 'mode': OWRITE}]
+ r = rng.random()
+ if r < 0.4:
+ ops.append({'op': 'read', 'path': f'/pane/{s}/{rng.choice(["xdata", "data", "addr", "dot"])}',
+ 'count': 8192, 'reads': 2})
+ elif r < 0.7:
+ ops.append({'op': 'write', 'path': f'/pane/{s}/{rng.choice(["data", "xdata"])}',
+ 'data': enc(payload(rng, 20)), 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])})
+ return {'op': 'seq', 'ops': ops}
+
+
+@generator(3)
+def gen_tag(ctx, rng):
+ path = rng.choice([f'/pane/{pick_serial(ctx, rng)}/tag', '/tag', f'/col/{pick_col(ctx, rng)}/tag'])
+ return {'op': 'write', 'path': path, 'data': enc(payload(rng, 40)),
+ 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])}
+
+
+@generator(3)
+def gen_event(ctx, rng):
+ s = pick_serial(ctx, rng)
+ return {'op': 'hold', 'h': new_handle(ctx), 'path': f'/pane/{s}/event', 'mode': ORDWR}
+
+
+@generator(2)
+def gen_dump_restore(ctx, rng):
+ r = rng.random()
+ if r < 0.5:
+ return {'op': 'write', 'path': '/ctl', 'data': enc(rng.choice([b'Dump\n', b'Dump', b'Dump m9pdump\n'])),
+ 'mode': OWRITE}
+ return {'op': 'restore', 'data': enc(rng.choice([b'Restore\n', b'Restore\n', b'Restore m9pdump\n',
+ b'Restore nothere\n']))}
+
+
+@generator(3)
+def gen_host(ctx, rng):
+ name = rng.choice(list(FIXTURES) + ['fresh.txt'])
+ if rng.random() < 0.5:
+ return {'op': 'host', 'action': 'rm', 'name': name}
+ return {'op': 'host', 'action': 'write', 'name': name, 'data': enc(payload(rng, 100))}
+
+
+@generator(3)
+def gen_look_fixture(ctx, rng):
+ name = rng.choice(list(FIXTURES) + ['fresh.txt'])
+ suffix = rng.choice([b'', b':2', b':#4', b':1:3', b':/beta/'])
+ return {'op': 'write', 'path': rng.choice(['/look', f'/pane/{pick_serial(ctx, rng)}/look']),
+ 'data': enc(name.encode() + suffix + b'\n'), 'mode': OWRITE}
+
+
+# --------------------------------------------------------------------------
+# Log window: the records one step caused, between two unique markers.
+
+MARK = re.compile(r'unknown control message "(m9p-mark-\d+)"')
+
+
+def write_marker(sess, step):
+ """An unknown ctl word logs `err - ctl: unknown control message "<word>"`."""
+ word = f'm9p-mark-{step}'
+ ok, why = sess.wire.write_path('/ctl', word.encode() + b'\n')
+ if ok:
+ return None, 'marker write was taken'
+ prev, sess.marker = sess.marker, word
+ if sess.follower is not None:
+ end = sess.follower.wait_for(f'"{word}"')
+ if end is None:
+ return None, 'marker record never reached the follower'
+ lines = sess.follower.lines[:end]
+ start = 0
+ if prev is not None:
+ for i in range(len(lines) - 1, -1, -1):
+ if f'"{prev}"' in lines[i]:
+ start = i + 1
+ break
+ else:
+ return None, None
+ window = lines[start:]
+ del sess.follower.lines[:end]
+ return window, None
+ lines = sess.wire.read_path('/log').decode(errors='replace').splitlines()
+ end = next((i for i in range(len(lines) - 1, -1, -1) if f'"{word}"' in lines[i]), None)
+ if end is None:
+ return None, 'marker record missing from /log'
+ if prev is None:
+ return None, None
+ start = next((i for i in range(end - 1, -1, -1) if f'"{prev}"' in lines[i]), None)
+ if start is None:
+ return None, None # the ring moved past it
+ return lines[start + 1:end], None
+
+
+COUNT = re.compile(r'^(.*) \(x(\d+)\)$')
+
+
+def occurrences(lines, kind):
+ """Records of `kind` (err|msg), counting `(xN)` repeats (fs.md: a record said
+ again is counted; a follower sees each count as a line of its own)."""
+ out, last_text, last_n = [], None, 0
+ for line in lines:
+ if not line.startswith(kind + ' '):
+ last_text = None
+ continue
+ m = COUNT.match(line)
+ text, n = (m.group(1), int(m.group(2))) if m else (line, 1)
+ if text == last_text:
+ new = n - last_n
+ else:
+ new = n
+ out.extend([text] * max(new, 0))
+ last_text, last_n = text, n
+ return out
+
+
+# --------------------------------------------------------------------------
+# Invariants: small, cheap, each with its doc citation.
+
+@invariant
+def alive(ctx, res):
+ """Never crashes or panics (src/crash.zig: a panic leaves its crash file)."""
+ if not ctx.sess.alive():
+ return f'pardes died (exit {ctx.sess.proc.returncode})'
+ if ctx.sess.has_crash_file():
+ return 'crash file written: ' + ctx.sess.crash_text()[-2000:]
+ return None
+
+
+@invariant
+def one_failure_rule(ctx, res):
+ """fs.md 'One rule for what fails': a write fails whenever what it asked
+ for fails ... and logs its reason exactly once, as `err <serial|-> <file>:
+ <why>`, with no `msg` for it. What is not a failure: a look that finds
+ nothing answers nothing and logs one `err`, the write succeeding."""
+ if res.window is None or res.hung_up:
+ return None
+ writes = res.writes()
+ # A refused open with OTRUNC may be refused for the open, not the
+ # truncation (event's `file in use`): allowed an err or none, below.
+ failed = [w for w in writes if w[2] is False and w[0] != 'trunc']
+ errs = occurrences(res.window, 'err')
+ msgs = occurrences(res.window, 'msg')
+ # Only for a write that succeeded: a failed one logs its one err.
+ failed_paths = {w[1] for w in failed}
+ allowed = sum(r[3] for r in res.requests if r[0] == 'allow' and r[1] not in failed_paths)
+ # fs.md 'A write of command lines ... what is left when it closes runs at
+ # the close ... and its failure is in the log alone'.
+ closes = [r for r in res.requests if r[0] == 'close-runs']
+ # Refused opens (other than pane/new), removes and creates are not named
+ # by the rule: allow an err or none.
+ other = [r for r in res.requests if r[2] is False and (
+ r[0] in ('remove', 'create', 'trunc') or r[0] == 'open' and r[1].rstrip('/') != '/pane/new')]
+ lo, hi = len(failed), len(failed) + allowed + len(closes) + len(other)
+ if not lo <= len(errs) <= hi:
+ return (f'{len(failed)} failed write(s) logged {len(errs)} err record(s) '
+ f'(allowed {lo}..{hi}); failed={[(w[0], w[1], w[3]) for w in failed]} window={res.window}')
+ if failed and msgs:
+ return f'a failed write also logged msg: {msgs}; failed={[(w[1], w[3]) for w in failed]}'
+ return None
+
+
+LINE_FILES = ('look', 'exec', 'tagexec', 'ctl')
+
+
+@invariant
+def close_runs_only_line_files(ctx, res):
+ """fs.md: 'A write of command lines -- to look, exec, tagexec, a ctl of the
+ root, a pane or a column, or a column's exec -- runs each line once it is
+ whole ... what is left when it closes runs at the close'. Another file
+ that fails only at its close breaks 'a write fails whenever what it asked
+ for fails' (or the list is short)."""
+ if res.window is None:
+ return None
+ closes = [r[1] for r in res.requests if r[0] == 'close-runs']
+ failed_files = {w[1].rsplit('/', 1)[-1] for w in res.writes() if w[2] is False}
+ for line in occurrences(res.window, 'err'):
+ m = re.match(r'err \S+ ([^:]+):', line)
+ if not m:
+ continue
+ name = m.group(1).rsplit('/', 1)[-1]
+ if name in LINE_FILES or name in failed_files:
+ continue
+ for path in closes:
+ if path.rsplit('/', 1)[-1] == name:
+ return f'a write to {name} with no newline was answered, then failed at the close: {line}'
+ return None
+
+
+@invariant
+def index_matches_pane_dirs(ctx, res):
+ """fs.md /index: one line per pane; /pane/<n>/ each pane's directory."""
+ w = ctx.sess.wire
+ first = w.read_path('/index')
+ fid, err = w.walk_names([b'pane'])
+ if fid is None:
+ return f'walking /pane failed: {err}'
+ ok, _ = w.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ names = dir_names(w.read_all(fid))
+ w.clunk(fid)
+ second = w.read_path('/index')
+ ctx.last_index = second
+ serials = []
+ for line in second.decode(errors='replace').splitlines():
+ try:
+ serials.append(int(line.split()[0]))
+ except (IndexError, ValueError):
+ return f'/index line unparsable: {line!r}'
+ old = set(ctx.serials)
+ ctx.serials = serials
+ ctx.gone = sorted(set(ctx.gone) | (old - set(serials)))[-20:]
+ if first != second:
+ return None # it changed between the reads; no claim
+ dirs = sorted(int(n) for n in names if n.isdigit())
+ extra = [n for n in names if not n.isdigit() and n != 'new']
+ if extra:
+ return f'/pane lists non-serial entries {extra}'
+ if sorted(serials) != dirs:
+ return f'/index serials {sorted(serials)} != /pane dirs {dirs}'
+ return None
+
+
+LISTINGS = ['/index', '/layout', '/', '/pane', '/col', '/pane/{s}', '/status', '/commands', '/pane/{s}/tag',
+ '/recent', '/README']
+
+
+@invariant
+def chunked_equals_whole(ctx, res):
+ """9P read(5): reads at successive offsets see the one file; a listing read
+ in chunks equals one read whole within the same open."""
+ w = ctx.sess.wire
+ path = LISTINGS[ctx.step % len(LISTINGS)]
+ if '{s}' in path:
+ if not ctx.serials:
+ return None
+ path = path.format(s=ctx.serials[ctx.step % len(ctx.serials)])
+ fid, err = w.walk_names(split(path))
+ if fid is None:
+ return None # gone since
+ try:
+ ok, body = w.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ if not ok:
+ return None
+ whole = w.read_all(fid)
+ isdir = path in ('/', '/pane', '/col') or path.count('/') == 2 and path.startswith('/pane/')
+ size = 128 + (ctx.step * 37) % 900 if isdir else 1 + (ctx.step * 7) % 50
+ chunked = w.read_all(fid, count=size)
+ if isdir:
+ whole_n, chunk_n = dir_names(whole), dir_names(chunked)
+ if whole_n != chunk_n:
+ return f'{path}: whole {whole_n} != chunked({size}) {chunk_n}'
+ elif whole != chunked:
+ return f'{path}: whole {whole[:200]!r} != chunked({size}) {chunked[:200]!r}'
+ finally:
+ w.clunk(fid)
+ return None
+
+
+# --------------------------------------------------------------------------
+# Runner.
+
+class Failure(Exception):
+ def __init__(self, kind, why, step):
+ super().__init__(f'{kind}: {why}')
+ self.kind, self.why, self.step = kind, why, step
+
+ def signature(self):
+ """What makes two failures the same bug: the kind, and the message with
+ numbers, quoted words and pane paths blurred."""
+ why = re.sub(r'"[^"]*"', '""', self.why)
+ why = re.sub(r"'[^']*'", "''", why) if 'window=' not in why else why
+ why = re.sub(r'/home/\S*', 'PATH', why)
+ return self.kind + ':' + re.sub(r'\d+', 'N', why)[:300]
+
+
+def check_death(sess, step, what):
+ """After an error: did pardes quit cleanly (Ended), or die (Failure)?"""
+ deadline = time.monotonic() + 1.0
+ while sess.alive() and time.monotonic() < deadline:
+ time.sleep(0.02)
+ if sess.alive():
+ return
+ if sess.proc.returncode == 0 and not sess.has_crash_file():
+ raise Ended(what)
+ raise Failure('crash', f'{what}: pardes exit {sess.proc.returncode} ' + sess.crash_text()[:3000], step)
+
+
+TOKEN = re.compile(r'\{([pc])(\d+)\}')
+
+
+def resolve(ctx, op):
+ """Replace {pN}/{cN} in an op's paths with live serials."""
+ def sub(m):
+ live = ctx.serials if m.group(1) == 'p' else ctx.cols
+ return str(live[int(m.group(2)) % len(live)]) if live else '0'
+ out = dict(op)
+ if 'path' in out:
+ out['path'] = TOKEN.sub(sub, out['path'])
+ if 'ops' in out:
+ out['ops'] = [resolve(ctx, o) for o in out['ops']]
+ if 'data' in out and '/focus' in out.get('path', ''):
+ out['data'] = TOKEN.sub(sub, out['data'])
+ return out
+
+
+def run_step(ctx, op):
+ """Run one op and every invariant. Raises Failure or Ended."""
+ sess = ctx.sess
+ op = resolve(ctx, op)
+ ctx.last_op = op
+ what = f'{op["op"]} {op.get("path", "")}'.strip()
+ try:
+ res = OPS[op['op']](sess, op)
+ except Hang as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('hang', f'{what}: {why}', ctx.step)
+ except Dropped as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('dropped', f'{what}: {why}', ctx.step)
+ res.window = None
+ try:
+ if not sess.alive():
+ check_death(sess, ctx.step, what)
+ # Liveness: a trivial request answers within the timeout.
+ ok, _ = sess.wire.call(TSTAT, struct.pack('<I', 1))
+ window, why = write_marker(sess, ctx.step)
+ if why:
+ raise Failure('log', why, ctx.step)
+ res.window = None if res.hung_up else window
+ if ctx.verbose:
+ print(f' op {ctx.step}: {json.dumps(op)[:300]}')
+ for r in res.requests:
+ print(f' {r[0]} {r[1]!r} -> {"ok" if r[2] else ("held" if r[2] is None else "Rerror")}'
+ f'{": " + str(r[3]) if r[3] else ""}')
+ for line in window or []:
+ print(f' log: {line}')
+ for check in INVARIANTS:
+ problem = check(ctx, res)
+ if problem:
+ raise Failure(check.__name__, problem, ctx.step)
+ # The column list, for the generators.
+ fid, err = sess.wire.walk_names([b'col'])
+ if fid is not None:
+ sess.wire.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ ctx.cols = [int(n) for n in dir_names(sess.wire.read_all(fid)) if n.isdigit()]
+ sess.wire.clunk(fid)
+ except Hang as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('hang', f'after {what}: {why}', ctx.step)
+ except Dropped as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('dropped', f'after {what}: {why}', ctx.step)
+ except Failure as f:
+ # An invariant that failed because pardes was quitting is not one.
+ check_death(sess, ctx.step, what)
+ raise f
+ except OSError as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('checker', f'after {what}: {why}', ctx.step)
+ return res
+
+
+class Runner:
+ def __init__(self, args):
+ self.args = args
+ self.out = args.out
+ os.makedirs(os.path.join(self.out, 'bugs'), exist_ok=True)
+ os.makedirs(os.path.join(self.out, 'runs'), exist_ok=True)
+
+ def new_session(self, ctx, follower, run_dir):
+ if ctx.sess is not None:
+ ctx.sess.close()
+ ctx.sess = Session(self.args.binary, run_dir, follower)
+ ctx.serials, ctx.cols, ctx.gone = [], [], []
+ idx = ctx.sess.wire.read_path('/index')
+ ctx.serials = [int(line.split()[0]) for line in idx.decode(errors='replace').splitlines() if line]
+
+ def execute(self, ops, run_dir, ctx=None, log=None, stop_on_failure=True, verbose=False):
+ """Run concrete ops (a replay). Returns the first Failure or None."""
+ ctx = ctx or Ctx(0)
+ ctx.verbose = verbose
+ failure = None
+ try:
+ for i, op in enumerate(ops):
+ ctx.step = i
+ if op['op'] == 'fresh':
+ self.new_session(ctx, op.get('follower', False), run_dir)
+ continue
+ if ctx.sess is None:
+ self.new_session(ctx, False, run_dir)
+ try:
+ run_step(ctx, op)
+ except Ended:
+ ctx.sess.close()
+ ctx.sess = None
+ except Failure as f:
+ failure = f
+ if verbose:
+ print(f'step {i}: {f}')
+ if stop_on_failure:
+ break
+ finally:
+ if ctx.sess is not None:
+ ctx.sess.close(keep=False)
+ ctx.sess = None
+ return failure
+
+ def fuzz(self, seed, steps):
+ name = f'seed-{seed}'
+ run_dir = os.path.join(self.out, 'runs', name)
+ shutil.rmtree(run_dir, ignore_errors=True)
+ os.makedirs(run_dir)
+ ctx = Ctx(seed)
+ rng = ctx.rng
+ log_path = os.path.join(run_dir, 'ops.jsonl')
+ segment = []
+ found = collections.OrderedDict()
+ t0 = time.monotonic()
+ weights = [w for w, _ in GENERATORS]
+ ended = 0
+ with open(log_path, 'w') as log:
+ log.write(json.dumps({'seed': seed, 'steps': steps, 'binary': self.args.binary}) + '\n')
+
+ def fresh():
+ op = {'op': 'fresh', 'follower': rng.random() < 0.5}
+ log.write(json.dumps(op) + '\n')
+ segment.clear()
+ segment.append(op)
+ self.new_session(ctx, op['follower'], run_dir)
+
+ fresh()
+ for step in range(steps):
+ ctx.step = step
+ gen = rng.choices(GENERATORS, weights)[0][1]
+ op = gen(ctx, rng)
+ log.write(json.dumps(op) + '\n')
+ log.flush()
+ segment.append(op)
+ f = None
+ try:
+ run_step(ctx, op)
+ except Ended:
+ ended += 1
+ fresh()
+ continue
+ except Failure as failure:
+ f = failure
+ except Exception as why: # a harness bug: say so, carry on
+ import traceback
+ f = Failure('harness', f'{type(why).__name__}: {why} ' +
+ traceback.format_exc()[-1500:], step)
+ if f is not None:
+ sig = f.signature()
+ first = sig not in found
+ found.setdefault(sig, []).append(step)
+ print(f'[seed {seed} step {step}] FAIL {f.kind}: {f.why[:400]}', flush=True)
+ if first:
+ crash = ctx.sess.crash_text()
+ ctx.sess.close(keep=True)
+ ctx.sess = None
+ self.record(seed, step, f, list(segment), crash)
+ fresh()
+ if self.args.progress and step and step % self.args.progress == 0:
+ rate = step / (time.monotonic() - t0)
+ print(f'[seed {seed}] step {step}/{steps} {rate:.0f} steps/s, {len(found)} distinct failure(s)',
+ flush=True)
+ if ctx.sess is not None:
+ ctx.sess.close()
+ dt = time.monotonic() - t0
+ print(f'[seed {seed}] {steps} steps in {dt:.0f}s ({steps / dt:.0f}/s), {ended} clean quit(s); '
+ f'ops log {log_path}')
+ for sig, at in found.items():
+ print(f' {len(at)}x {sig[:200]} (first at step {at[0]})')
+ return found
+
+ def record(self, seed, step, f, ops, crash):
+ digest = hashlib.sha1(f.signature().encode()).hexdigest()[:8]
+ path = os.path.join(self.out, 'bugs', f'{f.kind}-{digest}-seed{seed}-step{step}.jsonl')
+ header = {'seed': seed, 'step': step, 'kind': f.kind, 'why': f.why, 'signature': f.signature(),
+ 'crash': crash[-4000:], 'binary': self.args.binary}
+ write_ops(path, header, ops)
+ print(f' bug record {path}', flush=True)
+ if not self.args.no_shrink:
+ self.shrink(path)
+
+ def shrink(self, path):
+ header, ops = read_ops(path)
+ # The first replay pins the signature (this code's, which may have
+ # changed since the record was written); later ones must match it.
+ sig = None
+ kind = header.get('kind')
+ run_dir = os.path.join(self.out, 'runs', f'shrink-{os.getpid()}')
+ os.makedirs(run_dir, exist_ok=True)
+ follower = ops[0].get('follower', False) if ops and ops[0]['op'] == 'fresh' else False
+ body = [op for op in ops if op['op'] != 'fresh']
+ budget = [self.args.shrink_budget]
+ deadline = time.monotonic() + self.args.shrink_seconds
+
+ def fails(candidate):
+ if budget[0] <= 0 or time.monotonic() > deadline:
+ return False
+ budget[0] -= 1
+ nonlocal sig
+ f = self.execute([{'op': 'fresh', 'follower': follower}] + candidate, run_dir)
+ if f is None:
+ return False
+ if sig is None:
+ if kind is not None and f.kind != kind:
+ return False
+ sig = f.signature()
+ return True
+ return f.signature() == sig
+
+ if not fails(body):
+ # One more try with the other log mode, then give up.
+ follower = not follower
+ if not fails(body):
+ header['shrink'] = 'did not reproduce on replay'
+ write_ops(path, header, ops)
+ print(f' not reproduced on replay: {path}', flush=True)
+ return
+ small = ddmin(body, fails)
+ # Then shrink each op's payload.
+ small = shrink_payloads(small, fails)
+ header['shrink'] = f'{len(body)} -> {len(small)} ops'
+ out = path.replace('.jsonl', '.min.jsonl')
+ write_ops(out, header, [{'op': 'fresh', 'follower': follower}] + small)
+ print(f' shrunk {len(body)} -> {len(small)} ops: {out}', flush=True)
+
+
+def ddmin(items, fails):
+ """Zeller's delta debugging: a 1-minimal failing subsequence."""
+ n = 2
+ # Trailing ops after the failure never matter; the failing op is last.
+ while len(items) >= 2:
+ size = max(1, len(items) // n)
+ chunks = [items[i:i + size] for i in range(0, len(items), size)]
+ reduced = False
+ for i in range(len(chunks)):
+ complement = [op for j, c in enumerate(chunks) if j != i for op in c]
+ if complement and fails(complement):
+ items, n, reduced = complement, max(n - 1, 2), True
+ break
+ if not reduced:
+ if n >= len(items):
+ break
+ n = min(len(items), n * 2)
+ return items
+
+
+def shrink_payloads(ops, fails):
+ """Halve long data payloads while the failure stays."""
+ ops = [dict(op) for op in ops]
+ for i, op in enumerate(ops):
+ while 'data' in op and len(op['data']) > 8:
+ half = dict(op, data=op['data'][:len(op['data']) // 2])
+ trial = ops[:i] + [half] + ops[i + 1:]
+ if fails(trial):
+ ops[i] = op = half
+ else:
+ break
+ if op.get('chunks') not in (None, 'whole'):
+ whole = dict(op, chunks='whole')
+ if fails(ops[:i] + [whole] + ops[i + 1:]):
+ ops[i] = whole
+ return ops
+
+
+def write_ops(path, header, ops):
+ with open(path, 'w') as f:
+ f.write(json.dumps(header) + '\n')
+ for op in ops:
+ f.write(json.dumps(op) + '\n')
+
+
+def read_ops(path):
+ with open(path) as f:
+ lines = [json.loads(line) for line in f if line.strip()]
+ return lines[0], lines[1:]
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
+ ap.add_argument('binary')
+ ap.add_argument('--seed', type=int, action='append', help='seed (repeatable); default 1')
+ ap.add_argument('--steps', type=int, default=1000)
+ ap.add_argument('--replay', help='run an ops log (or bug record) exactly')
+ ap.add_argument('--shrink', help='shrink a bug record by delta debugging over replays')
+ ap.add_argument('--no-shrink', action='store_true', help='record failures without shrinking them')
+ ap.add_argument('--shrink-budget', type=int, default=300, help='replays a shrink may spend')
+ ap.add_argument('--shrink-seconds', type=float, default=600)
+ ap.add_argument('--smoke', action='store_true', help='fixed seed, few steps, fail on any failure')
+ ap.add_argument('--progress', type=int, default=500)
+ ap.add_argument('--verbose', '-v', action='store_true', help='replay: print each request and log record')
+ ap.add_argument('--out', default=DEFAULT_OUT)
+ args = ap.parse_args()
+ args.binary = os.path.abspath(args.binary)
+ for key in [k for k in os.environ if k.startswith('PARDES_') or k in ('NINE_MOUNT', 'NAMESPACE')]:
+ del os.environ[key]
+ become_subreaper()
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
+ runner = Runner(args)
+ try:
+ return run_main(runner, args)
+ finally:
+ sweep(f'{os.getpid()}-')
+ for d in RUNTIME_DIRS:
+ shutil.rmtree(d, ignore_errors=True)
+
+
+def run_main(runner, args):
+ if args.replay:
+ header, ops = read_ops(args.replay)
+ run_dir = os.path.join(args.out, 'runs', f'replay-{os.getpid()}')
+ os.makedirs(run_dir, exist_ok=True)
+ f = runner.execute(ops, run_dir, verbose=args.verbose)
+ print(f'replay {args.replay}: ' + (f'FAIL at op {f.step}: {f.kind}: {f.why}' if f else 'passed'))
+ return 1 if f else 0
+ if args.shrink:
+ runner.shrink(args.shrink)
+ return 0
+ if args.smoke:
+ args.no_shrink = True
+ found = runner.fuzz(20260929, args.steps if args.steps != 1000 else 800)
+ return 1 if found else 0
+ total = 0
+ for seed in args.seed or [1]:
+ total += len(runner.fuzz(seed, args.steps))
+ return 1 if total else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())