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:
+91
-30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user