qemu and build: review fixes across the rig driver, boot script, and fetch helpers

Bounded waits and validated arguments in run.sh and ui-drive.sh, a seeded
settings directory and root-only staged rootfs permissions with their own
tests, qmp.py and imgtools.py hardening, the fetch scripts checking what they
download, and ASCII typography throughout. Each fix carries its test under
qemu/tests or tests/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
This commit is contained in:
Noah
2026-09-09 19:17:54 -06:00
co-authored by Claude Fable 5.1
parent bda6c6c633
commit 2b6e8a2098
24 changed files with 1823 additions and 137 deletions
+91 -30
View File
@@ -125,24 +125,40 @@ import sys
import time
# imgtools.py lives beside this file and does the actual pixel math (phash,
# structural hash, crop, compare) for the region/ocr verbs. Imported at
# module load but never let a missing/broken imgtools take down the verbs
# that don't need it: nav/tap/page/... must keep working while it is being
# written, so the failure is deferred to need_imgtools() at first use.
try:
import imgtools
except ImportError:
imgtools = None
# structural hash, crop, compare) for the region/ocr verbs -- and imports
# Pillow at its OWN module scope to do it. Importing it here at qmp.py's
# module scope would drag that cost onto every subprocess invocation of this
# file, including screendump/tap/quit, which never touch a pixel:
# ui-drive.sh's own boot-wait loop calls screendump specifically to avoid a
# Pillow dependency (see its comment beside the plain byte-loop colour
# count), so the other half of that same loop must not quietly re-add one.
# Deferred to need_imgtools() at first actual use instead; a missing or
# broken imgtools still must not take down the verbs that don't need it.
imgtools = None
_imgtools_import_error = None
# Pillow, likewise, is only needed to hand tesseract an image file (it has no
# raw-RGB stdin mode); a host without it still runs every other verb.
try:
from PIL import Image as _PILImage
except ImportError:
_PILImage = None
def _load_imgtools():
global imgtools, _imgtools_import_error
if imgtools is None and _imgtools_import_error is None:
try:
import imgtools # noqa: F811 -- binds the module-level name above
except ImportError as e:
_imgtools_import_error = e
return imgtools
AXIS_MAX = 32767
# Bounds every blocking read on the QMP socket -- the greeting banner, the
# qmp_capabilities handshake, and every screendump/tap/quit round trip --
# the same way Ctl.__init__ already bounds the control channel (see its
# `timeout` default below). A socket with no timeout blocks forever on a
# wedged VM (a TCG stall or a kernel panic loop), and ui-drive.sh's own
# cleanup() calls `quit` on this socket before it ever reaches
# `reap "$QEMU_PID"` -- the bounded kill that is supposed to guarantee a
# wedged qemu-system-arm cannot outlive the script.
QMP_TIMEOUT_S = 20.0
# Where webstatus.c (ui-src/src/warden/webstatus.c) publishes its atomic
# snapshot inside the guest. assert_json/wait_json `@cat` this path over the
# control channel; see the bridge in rootfs/sbin/init.
@@ -240,8 +256,23 @@ class Ctl:
not evidence about the UI either way.
"""
# Must match, byte for byte, the `echo "<<END>>" >&3` in
# rootfs/sbin/init's ctl bridge (the FIFO-to-socket relay this class
# talks to). Nothing enforces the two staying in sync -- a sentinel
# changed on one side and not the other means send() below blocks until
# its own 15s timeout on every ctl-dependent step, with nothing at that
# point pointing back at this mismatch as the cause.
SENTINEL = "<<END>>"
# A real reply (a hit's box, the status JSON) is at most a few KB. The
# 15s socket timeout below bounds each individual recv(), not the total
# bytes accepted, so a peer that keeps streaming data fast enough to beat
# that per-call timeout, but never emits a newline or SENTINEL, would
# otherwise grow self.buf without limit and exhaust host memory before
# anything fails. Fail closed instead, the same way a channel that goes
# silent or closes outright already does.
MAX_BUF = 256 * 1024
def __init__(self, path, timeout=15.0):
self.sock = socket.socket(socket.AF_UNIX)
self.sock.settimeout(timeout)
@@ -258,6 +289,10 @@ class Ctl:
if not chunk:
raise RuntimeError("control channel closed")
self.buf += chunk
if len(self.buf) > self.MAX_BUF:
raise RuntimeError(
f"control channel reply too large (over {self.MAX_BUF} "
f"bytes with no newline or {self.SENTINEL!r} seen)")
continue
line = self.buf[:nl].decode("utf-8", "replace").rstrip("\r")
self.buf = self.buf[nl + 1:]
@@ -538,11 +573,18 @@ def save_refs(path, refs):
def write_png(img, path):
"""imgtools' (w, h, rgb_bytes) tuple -> a PNG file, because tesseract has
no raw-RGB input mode. Only assert_ocr needs this; assert_region and
capture_region work on imgtools' own tuples end to end."""
if _PILImage is None:
raise RuntimeError("Pillow (PIL) is not installed")
capture_region work on imgtools' own tuples end to end. Pillow is
imported here, not at module scope, for the same reason imgtools.py's
own import is deferred above: a bare screendump/tap/quit subprocess must
never pay for it. By the time assert_ocr reaches this call,
need_imgtools() has already required imgtools -- which itself imports
Pillow -- so in practice this import is a cache hit, not a fresh cost."""
try:
from PIL import Image
except ImportError as e:
raise RuntimeError(f"Pillow (PIL) is not installed: {e}") from e
w, h, data = img
_PILImage.frombytes("RGB", (w, h), data).save(path)
Image.frombytes("RGB", (w, h), data).save(path)
def safe_out_path(outdir, name, prefix="", suffix=""):
@@ -599,7 +641,10 @@ class Ctx:
# A whole-script infrastructure gap (imgtools.py absent), same class
# as a missing --ctl: no region/ocr verb can do anything without it,
# so this exits the process rather than recording a per-step fail.
if imgtools is None:
# This is also the first point that actually needs imgtools loaded
# (see _load_imgtools above), so it is where the deferred import
# happens, not module load.
if _load_imgtools() is None:
sys.exit(f"FATAL: {self.script_path}:{lineno}: '{cmd}' needs imgtools.py "
f"next to qmp.py (vendor/warden-sdk/qemu/tests/imgtools.py)")
@@ -984,7 +1029,7 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
try:
result = handler(ctx, lineno, cmd, args, line)
except (RuntimeError, OSError) as e:
except (RuntimeError, OSError, ValueError, IndexError, TypeError) as e:
# poll_until already turns this into a fatal row for the wait_*
# verbs; every other verb reaches ctl.send() (nav, wake,
# scroll/home, page/hit/stats/ctl, assert_page, assert_hit,
@@ -992,8 +1037,18 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
# (tap/swipe/fling/shot, the region verbs) with no guard of its
# own, so without this a dying channel or a gone QMP socket ends
# the whole run as an unhandled traceback instead of one fatal
# step. Recorded the same as any other fatal row: the run keeps
# going past it.
# step. ValueError/IndexError/TypeError are caught for the same
# reason: several verbs parse their own arguments with bare
# int()/float()/positional indexing before any handler-local
# guard (tap, swipe, fling, sleep, wait_hit, wait_json,
# capture_region, wait_region), and a malformed or missing
# argument -- a typo'd coordinate, a hand-edited *.txt script, a
# future flowc.py bug -- used to raise straight out of drive()
# and silently drop every row from that line onward, the exact
# truncated-run failure mode this file exists to rule out
# (flare-edge #244).
# Recorded the same as any other fatal row: the run keeps going
# past it.
result = ("fatal", str(e))
if result is not None:
record(lineno, line, *result)
@@ -1010,6 +1065,14 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
sys.exit(1)
def flag(argv, name, default=None):
"""One optional `NAME VALUE` pair out of argv, or DEFAULT when NAME is
absent. Factored so a sixth optional flag is a one-line call instead of
another hand-rolled argv.index() lookup -- and so a copy-pasted lookup
can no longer search for one flag while reporting a different one."""
return argv[argv.index(name) + 1] if name in argv else default
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
@@ -1020,16 +1083,14 @@ def main():
if len(sys.argv) < need[cmd]:
sys.exit(f"{cmd}: missing argument(s)\n{__doc__}")
size = 720
if "--size" in sys.argv:
size = int(sys.argv[sys.argv.index("--size") + 1])
ctl_path = sys.argv[sys.argv.index("--ctl") + 1] if "--ctl" in sys.argv else None
console_path = sys.argv[sys.argv.index("--console") + 1] if "--console" in sys.argv else None
refs_path = sys.argv[sys.argv.index("--refs") + 1] if "--refs" in sys.argv else None
rs485_control = (sys.argv[sys.argv.index("--rs485-control") + 1]
if "--rs485-control" in sys.argv else None)
size = int(flag(sys.argv, "--size", 720))
ctl_path = flag(sys.argv, "--ctl")
console_path = flag(sys.argv, "--console")
refs_path = flag(sys.argv, "--refs")
rs485_control = flag(sys.argv, "--rs485-control")
s = socket.socket(socket.AF_UNIX)
s.settimeout(QMP_TIMEOUT_S)
s.connect(path)
f = s.makefile("r")
f.readline() # greeting banner