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
+48 -58
View File
@@ -35,11 +35,10 @@ import json
from PIL import Image, ImageFilter
# occupancy thresholds for structural(): a downsampled cell counts as
# "occupied" if it is meaningfully brighter than black, or sits on an edge.
# Both are 0..255 greyscale/edge-magnitude averages over the cell.
# structural(): a cell is occupied when its grey is this far from the crop's
# median (its background) or its FIND_EDGES energy exceeds the edge threshold.
# occupancy thresholds for structural(): a cell is occupied when its grey is
# this far from the crop's median (its background), or its FIND_EDGES energy
# exceeds the edge threshold. Both are 0..255 greyscale/edge-magnitude
# averages over the cell.
# Validated on real captures: a switch knob left/right differs in 240/256
# cells, a dark card's icon and text stand out from its (7,13,29) ground.
DEVIATION_THRESHOLD = 28
@@ -53,64 +52,31 @@ EDGE_THRESHOLD = 24
def load_ppm(path):
"""Read a binary PPM (P6), return (w, h, rgb_bytes).
QEMU's screendump writes a comment-free header ("P6\\nW H\\n255\\n"
then raw bytes) but the token reader here handles the general P6
grammar -- whitespace runs and '#' comments -- since that costs
nothing extra and means any other producer of P6 files just works.
Delegates to Pillow's own P6 decoder (Image.open, format auto-detected
from the magic) instead of re-parsing the PPM grammar by hand: skipping
whitespace runs and '#' comments, and normalising any maxval to 8-bit,
are Pillow's problem here, not ours. convert("RGB") guarantees the
tightly-packed 3-bytes-per-pixel layout every caller in this file
assumes regardless of the source channel depth.
"""
with open(path, "rb") as f:
data = f.read()
def skip_ws_comments(p):
while p < len(data):
c = data[p]
if c in b" \t\r\n":
p += 1
elif c == ord("#"):
while p < len(data) and data[p] != ord("\n"):
p += 1
else:
break
return p
def read_token(p):
p = skip_ws_comments(p)
start = p
while p < len(data) and data[p] not in b" \t\r\n":
p += 1
return data[start:p], p
pos = 0
magic, pos = read_token(pos)
if magic != b"P6":
raise ValueError(f"{path}: not a P6 PPM (magic={magic!r})")
w_tok, pos = read_token(pos)
h_tok, pos = read_token(pos)
maxval_tok, pos = read_token(pos)
w, h, maxval = int(w_tok), int(h_tok), int(maxval_tok)
if maxval != 255:
raise ValueError(f"{path}: unsupported PPM maxval {maxval} (only 255 handled)")
# exactly one whitespace byte separates the header from the binary data
pos += 1
need = w * h * 3
pixels = data[pos:pos + need]
if len(pixels) != need:
raise ValueError(f"{path}: truncated PPM, want {need} bytes got {len(pixels)}")
return w, h, pixels
with Image.open(path) as im:
im = im.convert("RGB")
return im.width, im.height, im.tobytes()
def crop(img, x, y, w, h):
"""Crop (W,H,rgb) to the x,y,w,h box, return a new (w,h,rgb) image."""
"""Crop (W,H,rgb) to the x,y,w,h box, return a new (w,h,rgb) image.
The bounds check stays explicit and fails loudly (see main()'s own
comment on that) rather than delegating to PIL's crop, which silently
zero-pads a box that runs outside the source image instead of raising --
exactly the kind of stale/mistyped region box this check exists to catch.
"""
width, height, data = img
if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > width or y + h > height:
raise ValueError(f"crop box {x},{y},{w}x{h} outside image {width}x{height}")
row_bytes = w * 3
out = bytearray(h * row_bytes)
for row in range(h):
src_off = ((y + row) * width + x) * 3
dst_off = row * row_bytes
out[dst_off:dst_off + row_bytes] = data[src_off:src_off + row_bytes]
return w, h, bytes(out)
cropped = _to_pil(img).crop((x, y, x + w, y + h))
return w, h, cropped.tobytes()
def _to_pil(img):
@@ -188,7 +154,7 @@ def phash(img):
def hamming(a, b):
"""Bit-differences between two phash ints."""
return bin(a ^ b).count("1")
return (a ^ b).bit_count()
# ---------------------------------------------------------------------------
@@ -241,7 +207,7 @@ def _median_grey(grey):
def _structural_diff(a, b):
"""Count of differing bits between two 32-byte occupancy masks."""
return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
return (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).bit_count()
# ---------------------------------------------------------------------------
@@ -405,6 +371,10 @@ def cmd_bench(argv):
print(f"bench: {cw}x{ch} region, n={n} iterations")
for label, fn in (("phash", lambda: phash(region)),
("structural", lambda: structural(region))):
for _ in range(5):
fn() # warm up: first call can carry one-off costs (e.g. module-
# level caches settling) that don't belong in the steady-state
# population below
samples = timeit.repeat(fn, repeat=n, number=1)
lo, p50, p95, hi = percentiles(samples)
print(f" {label:<10} min={lo * 1000:7.3f}ms p50={p50 * 1000:7.3f}ms "
@@ -493,6 +463,26 @@ def selftest():
check("crop keeps the drawn pixel in place",
cropped[2][px_off:px_off + 3] == bytes((220, 20, 20)))
# a header QEMU never writes but the P6 grammar allows: a '#'
# comment line and a non-255 maxval. Pillow's decoder (load_ppm no
# longer hand-parses the header) must still read it correctly.
odd_path = os.path.join(tmp, "odd-header.ppm")
with open(odd_path, "wb") as f:
f.write(b"P6\n# generated for a selftest, not by QEMU\n2 2\n100\n")
f.write(bytes((100, 0, 0, 0, 100, 0, 0, 0, 100, 100, 100, 100)))
ow, oh, odata = load_ppm(odd_path)
check("load_ppm reads width/height past a comment line", (ow, oh) == (2, 2))
check("load_ppm scales a non-255 maxval channel to 8-bit",
odata[0:3] == bytes((255, 0, 0)))
# crop box outside the image must still fail loudly, not silently
# zero-pad the way PIL's own Image.crop does
try:
crop((bw, bh, bdata), bw - 4, bh - 4, 32, 32)
check("crop rejects a box past the image edge", False)
except ValueError:
check("crop rejects a box past the image edge", True)
# _dct2d_32's _DCT_KEEP truncation must land on the exact same
# coefficients an untruncated 32x32 DCT would produce
small = _to_pil((bw, bh, bdata)).convert("L").resize((32, 32), Image.LANCZOS)
+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
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Offline regression test for run.sh's own argv construction: no real QEMU,
# no kernel image. A fake `qemu-system-arm` placed first on PATH dumps the
# argv it was handed (one token per line) and exits, so this pins the exact
# contract run.sh:121-138 and rootfs/sbin/init:54,161 share without either
# side moving: init decides ttyS0 vs ttyS1 for the Modbus alias purely by
# grepping warden.ctl off /proc/cmdline, so run.sh has to keep two promises
# every single invocation -- the ctl pci-serial device, when present, comes
# BEFORE the rs485 one in argv (virt's PCI bus enumerates in that order),
# and an rs485 pci-serial device (real or null-backed) is always there so
# the port count init relies on never shifts.
#
# What is worth pinning: nothing else exercises this. test-ui-drive-rs485.sh
# stubs run.sh out entirely (a fake VM), and the only real boot in CI
# (boot-smoke.sh) passes neither --ctl nor --rs485, so a swapped
# `[ -n "$CTL" ]`/`[ -n "$RS485" ]` block, or a dropped null-chardev
# fallback, would reach a panel as Modbus polls landing on the debug channel
# (run.sh:131-133's own incident) before anything here caught it.
#
# bash run-sh-args-test.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUN_SH="$HERE/../run.sh"
SCRATCH="$(mktemp -d /tmp/runshargs.XXXXXX)"
trap 'rm -rf "$SCRATCH"' EXIT
FAIL=0
pass() { printf '[PASS] %s\n' "$1"; }
fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
KERNEL="$SCRATCH/fake-zImage"
INITRD="$SCRATCH/fake-initramfs.cpio.gz"
: > "$KERNEL"
: > "$INITRD"
BIN="$SCRATCH/bin"
mkdir -p "$BIN"
cat > "$BIN/qemu-system-arm" <<'STUB'
#!/usr/bin/env bash
# Stand-in for the real binary: record argv, one token per line, and exit
# straight away. $ARGV_CAPTURE names where -- run.sh always `exec`s this as
# its very last step, so nothing downstream of it ever runs.
printf '%s\n' "$@" > "$ARGV_CAPTURE"
STUB
chmod +x "$BIN/qemu-system-arm"
# run_case ARGV_FILE EXTRA_ARGS...: invoke the real run.sh --no-disk (so
# nothing under the real qemu/out/ is ever touched) with the fake binary
# first on PATH, capturing its argv into ARGV_FILE. Fails the case loudly if
# run.sh itself exits nonzero -- a silent empty capture would otherwise look
# just like "the assertions below simply found nothing".
run_case() {
local argv_file="$1"; shift
local out rc
out="$(cd "$SCRATCH" && PATH="$BIN:$PATH" ARGV_CAPTURE="$argv_file" \
bash "$RUN_SH" --kernel "$KERNEL" --initrd "$INITRD" --no-disk "$@" 2>&1)"
rc=$?
[ "$rc" -eq 0 ] || { fail "run.sh exited $rc for: $* -- output: $out"; return 1; }
[ -s "$argv_file" ] || { fail "run.sh produced no captured argv for: $*"; return 1; }
return 0
}
# chardev_line ARGV_FILE PREFIX: 1-indexed line number of the first argv
# token starting with PREFIX (the socket/null chardev spec, which always
# immediately follows the "-chardev" token it belongs to), or empty.
chardev_line() { grep -n -m1 "^$2" "$1" | cut -d: -f1; }
# --- case: neither --ctl nor --rs485 -> null-backed rs485, no ctl device ---
argv="$SCRATCH/argv-neither.txt"
if run_case "$argv"; then
if ! grep -qF 'id=ctl' "$argv"; then
pass "neither flag: no ctl chardev/device at all"
else
fail "neither flag: a ctl chardev/device appeared unrequested"
fi
if grep -qF -- '-append' "$argv" && ! grep -qw 'warden.ctl' "$argv"; then
pass "neither flag: -append omits warden.ctl"
else
fail "neither flag: -append should omit warden.ctl"
fi
if [ -n "$(chardev_line "$argv" 'null,id=rs485')" ] \
&& grep -qF 'pci-serial,chardev=rs485' "$argv"; then
pass "neither flag: null-backed rs485 pci-serial device is still present"
else
fail "neither flag: expected a null-backed rs485 device (port count must not shift)"
fi
fi
# --- case: --rs485 alone -> real rs485 device, still no ctl device ---------
argv="$SCRATCH/argv-rs485-only.txt"
if run_case "$argv" --rs485 "$SCRATCH/rs.sock"; then
if ! grep -qF 'id=ctl' "$argv"; then
pass "rs485 only: no ctl chardev/device"
else
fail "rs485 only: a ctl chardev/device appeared unrequested"
fi
if ! grep -qw 'warden.ctl' "$argv"; then
pass "rs485 only: -append omits warden.ctl"
else
fail "rs485 only: -append should omit warden.ctl"
fi
if [ -n "$(chardev_line "$argv" "socket,id=rs485,path=$SCRATCH/rs.sock,")" ]; then
pass "rs485 only: rs485 chardev carries the requested socket path"
else
fail "rs485 only: rs485 chardev did not carry the requested socket path"
fi
fi
# --- case: --ctl alone -> ctl device first, null-backed rs485 still present,
# and warden.ctl on the cmdline ------------------------------------------
argv="$SCRATCH/argv-ctl-only.txt"
if run_case "$argv" --ctl "$SCRATCH/ctl.sock"; then
ctl_ln="$(chardev_line "$argv" "socket,id=ctl,path=$SCRATCH/ctl.sock,")"
rs_ln="$(chardev_line "$argv" 'null,id=rs485')"
if [ -n "$ctl_ln" ] && [ -n "$rs_ln" ] && [ "$ctl_ln" -lt "$rs_ln" ]; then
pass "ctl only: ctl chardev (line $ctl_ln) precedes the null rs485 chardev (line $rs_ln)"
else
fail "ctl only: expected ctl chardev before a null-backed rs485 chardev, got ctl=$ctl_ln rs485=$rs_ln"
fi
if grep -qw 'warden.ctl' "$argv"; then
pass "ctl only: -append carries warden.ctl"
else
fail "ctl only: -append should carry warden.ctl"
fi
fi
# --- case: --ctl and --rs485 together -> ctl device still enumerates first -
argv="$SCRATCH/argv-both.txt"
if run_case "$argv" --ctl "$SCRATCH/ctl.sock" --rs485 "$SCRATCH/rs.sock"; then
ctl_ln="$(chardev_line "$argv" "socket,id=ctl,path=$SCRATCH/ctl.sock,")"
rs_ln="$(chardev_line "$argv" "socket,id=rs485,path=$SCRATCH/rs.sock,")"
if [ -n "$ctl_ln" ] && [ -n "$rs_ln" ] && [ "$ctl_ln" -lt "$rs_ln" ]; then
pass "both flags: ctl chardev (line $ctl_ln) precedes the rs485 chardev (line $rs_ln)"
else
fail "both flags: expected ctl chardev before rs485 chardev, got ctl=$ctl_ln rs485=$rs_ln"
fi
if grep -qw 'warden.ctl' "$argv"; then
pass "both flags: -append carries warden.ctl"
else
fail "both flags: -append should carry warden.ctl"
fi
fi
if [ "$FAIL" -eq 0 ]; then
echo "ALL RUN.SH ARGV TESTS PASSED"
exit 0
else
echo "RUN.SH ARGV TESTS FAILED"
exit 1
fi
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# Regression test for mkimage.sh's SEED_DIR hook (see its Env note and the
# block right after --state is applied): a caller-supplied directory of
# pre-built userdata/warden files, copied in whole and applied AFTER --state
# so a seeded file can override a same-named --state value, after each entry
# is validated the same way --state's own KEY=VALUE is validated -- a
# symlink, a non-plain-file entry (a subdirectory included), or a name
# outside [A-Za-z0-9_.-]+ fails closed before cp -a runs. Nothing else in
# the qemu test suite ever sets SEED_DIR -- the CI qemu-tools job runs
# mkimage.sh unseeded, and only ui-drive.sh --seed exercises this path, and
# only when booting a real VM with a flare-edge checkout on hand -- so this
# is the only offline coverage of it.
#
# Builds a real disk image the same way mkimage.sh always does (unprivileged
# mkfs.ext4 -d), then reads the userdata partition back with debugfs -R
# (read-only, no mount or loop device needed) to check what actually landed
# on disk rather than trusting the script's own log output.
#
# bash seed-dir.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
QEMU_DIR="$(cd "$HERE/.." && pwd)" # qemu/
FAIL=0
pass() { printf '[PASS] %s\n' "$1"; }
fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
# mkfs.ext4 and debugfs both live in sbin, which user shells on Debian don't
# put on PATH -- same fix mkimage.sh itself applies.
PATH="$PATH:/usr/sbin:/sbin"
command -v debugfs >/dev/null || {
echo "FATAL: debugfs (e2fsprogs) not found: needed to read the userdata partition back" >&2
exit 1
}
# shellcheck source=../lib.sh disable=SC1091
. "$QEMU_DIR/lib.sh"
# shellcheck source=../blkdevparts.conf disable=SC1091
. "$QEMU_DIR/blkdevparts.conf"
SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-seed-dir.XXXXXX")"
trap 'rm -rf "$SCRATCH"' EXIT
# userdata's byte offset/size come from the same blkdevparts string
# mkimage.sh itself parses, not a hardcoded number: a future layout change
# doesn't strand this test.
USERDATA_OFF=""
USERDATA_SIZE=""
capture_userdata() { [ "$1" = userdata ] && { USERDATA_OFF="$2"; USERDATA_SIZE="$3"; }; return 0; }
qemu_each_partition capture_userdata
[ -n "$USERDATA_OFF" ] || { echo "FATAL: no 'userdata' entry in blkdevparts.conf" >&2; exit 1; }
# extract_userdata DISK OUTFILE: pull the userdata partition window out of a
# built disk image. Sparse output so an otherwise near-empty 1G partition
# costs kilobytes of scratch space, not a real gigabyte, per scenario.
extract_userdata() {
dd if="$1" of="$2" bs=4096 skip=$((USERDATA_OFF / 4096)) \
count=$((USERDATA_SIZE / 4096)) conv=sparse status=none
}
# Reuse an already-verified busybox (read-only) so this test stays offline
# wherever a prior build has already produced one; only a checkout that has
# never run mkimage.sh falls back to the same fetch+verify mkimage.sh always
# does, once, shared by every scenario below.
BUSYBOX_BIN="$QEMU_DIR/out/busybox-armv7l"
if [ ! -f "$BUSYBOX_BIN" ]; then
OUT="$SCRATCH" qemu_get_busybox
BUSYBOX_BIN="$BB"
fi
# --- scenario 1: seed applied verbatim, secret mode preserved, seed beats a same-named --state ---
SEED="$SCRATCH/seed"
mkdir -p "$SEED"
printf 'plain-value\n' > "$SEED/plain.key"
printf 'secret-value\n' > "$SEED/secret.key"
chmod 0600 "$SEED/secret.key"
printf 'seeded-value\n' > "$SEED/override.key"
OUT1="$SCRATCH/out1"
if OUT="$OUT1" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED" \
bash "$QEMU_DIR/mkimage.sh" --state "override.key=state-value" \
> "$SCRATCH/mkimage1.log" 2>&1; then
UIMG="$SCRATCH/userdata1.img"
extract_userdata "$OUT1/disk.img" "$UIMG"
plain_stat="$(debugfs -R "stat /warden/plain.key" "$UIMG" 2>/dev/null)"
if [ -n "$plain_stat" ]; then
pass "SEED_DIR: plain.key landed under userdata/warden"
else
fail "SEED_DIR: plain.key missing from userdata/warden"
fi
secret_stat="$(debugfs -R "stat /warden/secret.key" "$UIMG" 2>/dev/null)"
secret_mode="$(printf '%s' "$secret_stat" | grep -oE 'Mode: *[0-7]+' | grep -oE '[0-7]+$')"
if [ "$secret_mode" = "0600" ]; then
pass "SEED_DIR: secret.key kept mode 0600 through cp -a"
else
fail "SEED_DIR: secret.key mode '$secret_mode', want 0600"
fi
override_content="$(debugfs -R "cat /warden/override.key" "$UIMG" 2>/dev/null)"
if [ "$override_content" = "seeded-value" ]; then
pass "SEED_DIR: seeded override.key beats the same-named --state value"
else
fail "SEED_DIR: override.key = '$override_content', want 'seeded-value' (seed must apply after --state)"
fi
else
fail "SEED_DIR: mkimage.sh exited nonzero with a valid seed dir (see $SCRATCH/mkimage1.log)"
fi
# --- scenario 2: SEED_DIR that is not a directory fails closed ---
NOTADIR="$SCRATCH/notadir"
: > "$NOTADIR"
OUT2="$SCRATCH/out2"
err2="$(OUT="$OUT2" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$NOTADIR" \
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
rc2=$?
if [ "$rc2" -ne 0 ] && printf '%s' "$err2" | grep -qF "FATAL: SEED_DIR '$NOTADIR' is not a directory"; then
pass "SEED_DIR: a non-directory path fails closed with the FATAL message"
else
fail "SEED_DIR: non-directory path gave rc=$rc2, stderr='$err2' (want nonzero + the FATAL message)"
fi
# --- scenario 3: a symlink entry fails closed instead of being copied verbatim ---
SEED3="$SCRATCH/seed3"
mkdir -p "$SEED3"
printf 'plain-value\n' > "$SEED3/plain.key"
ln -s /etc/passwd "$SEED3/evil.key"
OUT3="$SCRATCH/out3"
err3="$(OUT="$OUT3" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED3" \
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
rc3=$?
if [ "$rc3" -ne 0 ] && printf '%s' "$err3" | grep -qF "FATAL: SEED_DIR entry 'evil.key' is a symlink"; then
pass "SEED_DIR: a symlink entry fails closed instead of being copied verbatim"
else
fail "SEED_DIR: symlink entry gave rc=$rc3, stderr='$err3' (want nonzero + the symlink FATAL message)"
fi
[ -e "$OUT3/disk.img" ] && fail "SEED_DIR: a disk image was written despite the symlink entry"
# --- scenario 4: an entry with a character outside [A-Za-z0-9_.-]+ fails closed ---
SEED4="$SCRATCH/seed4"
mkdir -p "$SEED4"
printf 'x\n' > "$SEED4/bad key"
OUT4="$SCRATCH/out4"
err4="$(OUT="$OUT4" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED4" \
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
rc4=$?
if [ "$rc4" -ne 0 ] && printf '%s' "$err4" | grep -qF "FATAL: SEED_DIR entry 'bad key' must match [A-Za-z0-9_.-]+"; then
pass "SEED_DIR: an entry name outside [A-Za-z0-9_.-]+ fails closed"
else
fail "SEED_DIR: bad-name entry gave rc=$rc4, stderr='$err4' (want nonzero + the charset FATAL message)"
fi
# --- scenario 5: a subdirectory entry fails closed (not a plain file) ---
SEED5="$SCRATCH/seed5"
mkdir -p "$SEED5/subdir"
printf 'x\n' > "$SEED5/subdir/leaf.key"
OUT5="$SCRATCH/out5"
err5="$(OUT="$OUT5" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED5" \
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
rc5=$?
if [ "$rc5" -ne 0 ] && printf '%s' "$err5" | grep -qF "FATAL: SEED_DIR entry 'subdir' is not a plain file"; then
pass "SEED_DIR: a subdirectory entry fails closed instead of being recursed into"
else
fail "SEED_DIR: subdirectory entry gave rc=$rc5, stderr='$err5' (want nonzero + the plain-file FATAL message)"
fi
# --- scenario 6: a hyphenated key (seed-fixtures.py's KEY_RE, e.g.
# "gas-plant.devices") still seeds cleanly -- guards against tightening the
# charset to --state's stricter [A-Za-z0-9_.]+ by mistake, which would
# reject keys committed flow specs already seed through this path ---
SEED6="$SCRATCH/seed6"
mkdir -p "$SEED6"
printf 'r5\n' > "$SEED6/gas-plant.devices"
OUT6="$SCRATCH/out6"
if OUT="$OUT6" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED6" \
bash "$QEMU_DIR/mkimage.sh" > "$SCRATCH/mkimage6.log" 2>&1; then
UIMG6="$SCRATCH/userdata6.img"
extract_userdata "$OUT6/disk.img" "$UIMG6"
hyphen_content="$(debugfs -R "cat /warden/gas-plant.devices" "$UIMG6" 2>/dev/null)"
if [ "$hyphen_content" = "r5" ]; then
pass "SEED_DIR: a hyphenated key (gas-plant.devices) still seeds cleanly"
else
fail "SEED_DIR: gas-plant.devices = '$hyphen_content', want 'r5'"
fi
else
fail "SEED_DIR: mkimage.sh rejected a valid hyphenated key (see $SCRATCH/mkimage6.log)"
fi
[ "$FAIL" -eq 0 ] && echo "ALL SEED_DIR TESTS PASSED" || echo "SEED_DIR TESTS FAILED"
[ "$FAIL" -eq 0 ]
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Offline regression test for qemu_stage_rootfs() (qemu/lib.sh): the staged
# etc/shadow must come out mode 0600 regardless of the mode the SOURCE
# qemu/rootfs/etc/shadow happens to carry in the working tree. Git tracks
# only the executable bit, so a fresh checkout can land that source file at
# anything a non-executable blob gets under the checking-out user's umask
# (644 under the common 022) -- world readable, exposing root's crypt hash
# to any unprivileged process in the guest. The test stages from an isolated
# copy of qemu/rootfs with etc/shadow deliberately set to 0644 first, so it
# still catches the regression even when the real working tree's copy
# already happens to be 0600 locally (that local mode is never what ships;
# only what git tracks does). No real busybox or QEMU needed: a stub binary
# is enough to exercise the staging function itself.
#
# bash stage-rootfs-perms.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REAL_QEMU_DIR="$(cd "$HERE/.." && pwd)"
SCRATCH="$(mktemp -d /tmp/wqperm.XXXXXX)"
trap 'rm -rf "$SCRATCH"' EXIT
FAIL=0
pass() { printf '[PASS] %s\n' "$1"; }
fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
# shellcheck source=../lib.sh disable=SC1091
. "$REAL_QEMU_DIR/lib.sh"
# Isolated QEMU_DIR: a copy of the real rootfs skeleton, source etc/shadow
# forced to 0644 to simulate the permissive-umask checkout this test must
# catch regardless of what the working tree's own copy happens to be.
QEMU_DIR="$SCRATCH/qemu"
mkdir -p "$QEMU_DIR"
cp -a "$REAL_QEMU_DIR/rootfs" "$QEMU_DIR/rootfs"
chmod 0644 "$QEMU_DIR/rootfs/etc/shadow"
# Stand in for a verified busybox download: qemu_stage_rootfs only installs
# it, never reads its content.
BB="$SCRATCH/fake-busybox"
printf '#!/bin/sh\nexit 0\n' > "$BB"
chmod 0755 "$BB"
ROOT="$SCRATCH/root"
qemu_stage_rootfs "$ROOT"
shadow_mode="$(stat -c '%a' "$ROOT/etc/shadow")"
if [ "$shadow_mode" = "600" ]; then
pass "etc/shadow staged at 0600"
else
fail "etc/shadow staged at $shadow_mode, want 600"
fi
# Control: passwd/group carry no secrets and stay world-readable, same as
# every other Linux system -- confirms the fix targets shadow specifically
# rather than locking the whole /etc tree down.
passwd_mode="$(stat -c '%a' "$ROOT/etc/passwd")"
if [ "$passwd_mode" = "644" ] || [ "$passwd_mode" = "664" ]; then
pass "etc/passwd untouched by the shadow chmod (mode $passwd_mode)"
else
fail "etc/passwd unexpectedly mode $passwd_mode"
fi
[ "$FAIL" -eq 0 ] && echo "ALL STAGE-ROOTFS PERM TESTS PASSED" || echo "STAGE-ROOTFS PERM TESTS FAILED"
[ "$FAIL" -eq 0 ]
+9 -8
View File
@@ -92,8 +92,12 @@ work="$SCRATCH/a"; build_rig "$work"
FLARE_EDGE="$SCRATCH/flare-edge-a"
mkdir -p "$FLARE_EDGE/tools/modbus-sim"
printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/modbus-sim/mbsim.py"
RIG_OUT="$(unset TEST_MAKE_RS_SOCK; export FLARE_EDGE; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
# A VAR=val prefix on a function call exports VAR into that one call only
# (and any children it spawns) and restores whatever VAR held before once the
# call returns -- the same per-case scoping a wrapping subshell gave us, but
# without a subshell: run_rig's RIG_OUT/RIG_RC writes land here directly, so
# nothing needs to be re-serialized as text and re-parsed back out below.
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK='' run_rig "$work"
[ "$RIG_RC" -ne 0 ] \
&& pass "case A: never-appeared rs.sock fails the run (rc=$RIG_RC)" \
|| fail "case A: never-appeared rs.sock fails the run: rc=$RIG_RC, out: $RIG_OUT"
@@ -112,8 +116,7 @@ printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/
FAKEBIN="$SCRATCH/fakebin-b"; mkdir -p "$FAKEBIN"
printf '#!/usr/bin/env bash\necho "FAKE SOCAT: simulated failure" >&2\nexit 1\n' > "$FAKEBIN/socat"
chmod +x "$FAKEBIN/socat"
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE; export PATH="$FAKEBIN:$PATH"; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 PATH="$FAKEBIN:$PATH" run_rig "$work"
[ "$RIG_RC" -ne 0 ] \
&& pass "case B: socat never linking rs.pty fails the run (rc=$RIG_RC)" \
|| fail "case B: socat never linking rs.pty fails the run: rc=$RIG_RC, out: $RIG_OUT"
@@ -131,8 +134,7 @@ import sys
sys.stderr.write("FAKE MBSIM: simulated crash before opening the control socket\n")
sys.exit(1)
EOS
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE; unset TEST_MBSIM_PID_FILE; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE='' run_rig "$work"
[ "$RIG_RC" -ne 0 ] \
&& pass "case C: mbsim.py crashing before rs.ctl fails the run (rc=$RIG_RC)" \
|| fail "case C: mbsim.py crashing before rs.ctl fails the run: rc=$RIG_RC, out: $RIG_OUT"
@@ -174,8 +176,7 @@ while True:
time.sleep(1)
EOS
PIDFILE="$SCRATCH/mbsim-d.pid"
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE TEST_MBSIM_PID_FILE="$PIDFILE"; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE="$PIDFILE" run_rig "$work"
if [ -s "$PIDFILE" ]; then
mbsim_pid="$(cat "$PIDFILE")"
if ! kill -0 "$mbsim_pid" 2>/dev/null; then
+396 -1
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""Offline tests for qmp.py's drive(): the QMP socket and the control channel
are faked, so this runs in well under two seconds with no VM.
are faked, so this needs no VM and runs in a few seconds -- most of that is
test_mismatches_are_fails_not_stops deliberately waiting out two real
one-second timeouts to prove wait_json/wait_hit honour them.
What is worth pinning is the contract the docstring makes: every step gets a
results.jsonl row of ok / fail / fatal and the run CONTINUES, so one run
@@ -14,8 +16,10 @@ was never compared with the one the reference was captured under.
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
@@ -232,6 +236,23 @@ class PureHelpers(unittest.TestCase):
self.assertTrue(qmp.apply_op("lt", 1, "2"))
self.assertTrue(qmp.apply_op("eq", "connected", "connected"))
def test_apply_op_rejects_bad_combinations(self):
# eval_json and verb_assert_stat both catch (TypeError, ValueError)
# specifically so a malformed OP in a hand-written or generated
# script reads as a `fail` row with a reason, not a driver crash --
# that contract depends on apply_op actually raising these, which
# nothing exercised directly before.
with self.assertRaises(ValueError):
qmp.apply_op("bogus", 1, "1")
with self.assertRaises(TypeError):
qmp.apply_op("contains", 5, "1")
def test_flag_reads_an_optional_argv_pair_or_the_default(self):
argv = ["qmp.py", "sock", "drive", "s.txt", "out", "--size", "480"]
self.assertEqual(qmp.flag(argv, "--size"), "480")
self.assertEqual(qmp.flag(argv, "--ctl"), None)
self.assertEqual(qmp.flag(argv, "--ctl", "default"), "default")
def test_parse_stats(self):
got = qmp.parse_stats(FakeCtl("x").send("stats"))
self.assertEqual(got, {"cpu": 12.0, "fps": 10.0, "render": 3.2, "idle": 0.0})
@@ -380,6 +401,102 @@ class DriveVerbs(unittest.TestCase):
for row in rows[:-1]:
self.assertIn("QMP socket closed", row["detail"])
def test_malformed_numeric_argument_is_fatal_for_the_step_not_a_crash(self):
# 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). A typo'd coordinate or a missing argument -- exactly
# what a hand-edited *.txt script or a flowc.py bug can produce --
# used to raise ValueError/IndexError straight out of drive(),
# losing every row from that line onward instead of reading as its
# own fatal row (flare-edge #244).
rc, by, rows = run_script(
"tap 10 abc\n"
"sleep 0\n",
)
self.assertEqual(rc, 1)
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
self.assertIn("invalid literal", by["tap 10 abc"]["detail"])
rc, by, rows = run_script(
"tap 10\n"
"sleep 0\n",
)
self.assertEqual(rc, 1)
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
self.assertIn("list index out of range", by["tap 10"]["detail"])
rc, by, rows = run_script(
"capture_region r1 0 0 8 notanumber exact\n"
"sleep 0\n",
)
self.assertEqual(rc, 1)
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
self.assertIn("invalid literal",
by["capture_region r1 0 0 8 notanumber exact"]["detail"])
def test_bad_op_reads_as_a_fail_row_not_a_crash(self):
# apply_op's error paths (unknown OP -> ValueError, 'contains'
# against the wrong type -> TypeError) are caught by both callers
# (eval_json, verb_assert_stat) and must read as an ordinary `fail`
# row through the real verb handlers, not an uncaught exception or a
# SystemExit out of drive() itself.
rc, by, rows = run_script(
"assert_json a.b bogus 1\n"
"assert_stat fps bogus 1\n"
)
self.assertEqual(rc, 1)
self.assertEqual([r["status"] for r in rows], ["fail", "fail"])
self.assertIn("bogus", by["assert_json a.b bogus 1"]["detail"])
self.assertIn("bogus", by["assert_stat fps bogus 1"]["detail"])
def test_assert_ocr_reports_no_tesseract_ocr_failure_and_match_or_not(self):
# assert_ocr's own surface -- the tesseract-not-installed fatal, the
# subprocess call, its exception net, and the final regex decision
# -- had no coverage at all: a regression here would only be caught
# by a live rig run against real tesseract. shutil.which and
# subprocess.run are swapped the same way rs485_send is above, since
# both are stdlib calls qmp.py makes directly, not seams of its own.
refs = {"r1": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact",
"phash": "0" * 16, "structural": "00" * 32}}
saved_which, saved_run = qmp.shutil.which, qmp.subprocess.run
def restore():
qmp.shutil.which, qmp.subprocess.run = saved_which, saved_run
try:
qmp.shutil.which = lambda name: None
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
self.assertEqual(rows[0]["status"], "fatal")
self.assertIn("tesseract not installed", rows[0]["detail"])
qmp.shutil.which = lambda name: "/usr/bin/tesseract"
def crashing_run(*a, **k):
raise OSError("tesseract crashed")
qmp.subprocess.run = crashing_run
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
self.assertEqual(rows[0]["status"], "fatal")
self.assertIn("ocr failed", rows[0]["detail"])
def matching_run(cmd, **k):
return type("R", (), {"stdout": "hello world\n"})()
qmp.subprocess.run = matching_run
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
self.assertEqual(rows[0]["status"], "ok")
def nonmatching_run(cmd, **k):
return type("R", (), {"stdout": "goodbye\n"})()
qmp.subprocess.run = nonmatching_run
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
self.assertEqual(rows[0]["status"], "fail")
self.assertIn("goodbye", rows[0]["detail"])
finally:
restore()
def test_region_faults_are_per_step_fatal(self):
# A pre-seeded reference whose box does not fit a 64x64 screendump,
# a name with no reference, a tolerance other than the captured one,
@@ -469,6 +586,38 @@ class DriveVerbs(unittest.TestCase):
self.assertEqual(rows[0]["status"], "fatal")
self.assertIn("must not contain '/'", rows[0]["detail"])
def test_wait_region_hits_the_same_fatal_paths_as_assert_region(self):
# wait_region drives the same fresh_region() call as assert_region
# (comment on verb_wait_region), so a missing reference, a foreign
# tolerance, and a refs.json name that could escape outdir must all
# be fatal here too -- and, since none of them can ever start
# passing, each must stop on its first check instead of waiting out
# TIMEOUT_S (poll_until's check_region() signals this by returning
# ok=True, caught via the `fault` list).
refs = {
"r1": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact",
"phash": "0" * 16, "structural": "00" * 32},
"../evil": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact",
"phash": "0" * 16, "structural": "00" * 32},
}
t0 = time.monotonic()
rc, by, rows = run_script(
"wait_region nope exact 2\n"
"wait_region r1 loose 2\n"
"wait_region ../evil exact 2\n",
refs=refs,
)
waited = time.monotonic() - t0
self.assertEqual(rc, 1)
self.assertEqual([r["status"] for r in rows], ["fatal", "fatal", "fatal"])
self.assertIn("no reference", by["wait_region nope exact 2"]["detail"])
self.assertIn("captured as exact, script expects loose",
by["wait_region r1 loose 2"]["detail"])
self.assertIn("must not contain '/'", by["wait_region ../evil exact 2"]["detail"])
self.assertLess(waited, 2.0,
"none of these three can ever pass, so none may wait "
"out its TIMEOUT_S of 2s each")
def test_unknown_verb_is_fatal_for_the_run(self):
# A silently-ignored line is a test that proves nothing, so this one
# is the documented exception to "the run continues": drive() exits
@@ -576,5 +725,251 @@ class FullscreenToggleTracksRealState(unittest.TestCase):
"the already-off real state instead of turning it on")
class LazyImports(unittest.TestCase):
def test_module_import_does_not_pull_in_imgtools_or_pillow(self):
# imgtools.py imports Pillow at its own module scope; qmp.py used to
# `import imgtools` at ITS module scope too, so every subprocess
# invocation of qmp.py paid that cost even for screendump/tap/quit,
# which never touch a pixel -- defeating the Pillow-free boot-wait
# loop ui-drive.sh's own comment documents. A subprocess (not just
# checking qmp.imgtools in-process) is what actually pins this: the
# other tests in this file exercise region verbs and so leave the
# lazy slot filled in for the rest of THIS process.
script = (
"import sys\n"
f"sys.path.insert(0, {HERE!r})\n"
"import qmp\n"
"assert 'imgtools' not in sys.modules, 'imgtools imported eagerly'\n"
"assert 'PIL' not in sys.modules, 'Pillow imported eagerly'\n"
)
result = subprocess.run([sys.executable, "-c", script],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def _bare_ctl(sock):
"""A Ctl instance around an already-connected socket, bypassing
__init__'s own socket()+connect() (there is no path on disk to connect
to -- these tests drive a socketpair() end directly)."""
ctl = qmp.Ctl.__new__(qmp.Ctl)
ctl.sock = sock
ctl.buf = b""
return ctl
class CtlSocketProtocol(unittest.TestCase):
"""Ctl.send() itself -- the line-buffering loop that reassembles a reply
across possibly many recv() calls, skips the cooked-mode echo of the
command it just sent, and stops on the SENTINEL line -- has zero
coverage anywhere else in this file: every FakeCtl/DyingCtl/etc. above
replaces the whole class, never exercising the real one. This drives the
real qmp.Ctl over a live AF_UNIX socketpair standing in for the FIFO
bridge in rootfs/sbin/init, so the actual wire protocol gets checked
without a VM or rootfs changes."""
def setUp(self):
self.client_sock, self.server_sock = socket.socketpair(
socket.AF_UNIX, socket.SOCK_STREAM)
self.client_sock.settimeout(5.0)
self.ctl = _bare_ctl(self.client_sock)
def tearDown(self):
self.client_sock.close()
self.server_sock.close()
def test_reassembles_a_reply_split_across_two_recv_calls(self):
# The reply plus SENTINEL arrive in two separate writes, forcing
# Ctl.send() through at least two recv() calls for one line: the
# exact shape a reply straddling a 4096-byte read boundary takes on
# real hardware.
def server():
self.server_sock.recv(4096) # the command line
self.server_sock.sendall(b"first line\nsecond ")
time.sleep(0.05)
self.server_sock.sendall(b"line\n<<END>>\n")
th = threading.Thread(target=server, daemon=True)
th.start()
try:
got = self.ctl.send("stats")
finally:
th.join(timeout=2)
self.assertEqual(got, "first line\nsecond line")
def test_strips_the_cooked_mode_echo_of_the_command(self):
# The tty is in cooked mode, so the command comes back echoed before
# the real reply; Ctl.send() must drop that line, not treat it as
# part of the answer.
def server():
cmd_line = self.server_sock.recv(4096)
self.server_sock.sendall(cmd_line) # cooked-mode echo
self.server_sock.sendall(b"the actual reply\n<<END>>\n")
th = threading.Thread(target=server, daemon=True)
th.start()
try:
got = self.ctl.send("page")
finally:
th.join(timeout=2)
self.assertEqual(got, "the actual reply")
def test_leftover_bytes_after_sentinel_carry_over_to_the_next_send(self):
# One write carries this reply's SENTINEL immediately followed by
# bytes belonging to the NEXT command's reply -- proving self.buf
# correctly holds the leftover across two separate send() calls
# instead of dropping or re-reading it.
def server():
self.server_sock.recv(4096)
self.server_sock.sendall(b"reply one\n<<END>>\nreply two\n<<END>>\n")
th = threading.Thread(target=server, daemon=True)
th.start()
try:
got1 = self.ctl.send("cmd1")
finally:
th.join(timeout=2)
self.assertEqual(got1, "reply one")
# cmd2's own reply is already sitting in self.ctl.buf from the single
# write above; send() must serve it without another recv().
got2 = self.ctl.send("cmd2")
self.assertEqual(got2, "reply two")
class CtlBufferCap(unittest.TestCase):
"""Regression for Ctl.send() growing self.buf without bound: a peer that
keeps streaming bytes fast enough to beat the per-recv() socket timeout,
but never emits a newline or SENTINEL, used to grow self.buf forever
instead of failing closed."""
def test_raises_instead_of_growing_self_buf_without_bound(self):
client_sock, server_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
client_sock.settimeout(3.0)
ctl = _bare_ctl(client_sock)
def server():
server_sock.recv(4096)
target = qmp.Ctl.MAX_BUF + 8192
sent = 0
try:
while sent < target:
server_sock.sendall(b"x" * 4096) # no newline, ever
sent += 4096
except OSError:
pass # the client closed once the cap tripped; nothing left to send to
th = threading.Thread(target=server, daemon=True)
th.start()
try:
with self.assertRaises(RuntimeError) as cm:
ctl.send("stats")
self.assertIn("too large", str(cm.exception))
self.assertLessEqual(
len(ctl.buf), qmp.Ctl.MAX_BUF + 4096,
"must fail as soon as the cap is crossed, not keep draining "
"an unbounded peer first")
finally:
client_sock.close()
server_sock.close()
th.join(timeout=2)
class QmpSocketTimeout(unittest.TestCase):
"""Regression for the QMP unix socket having no timeout: a peer that
accepts the connection but never answers (a wedged VM -- a TCG stall or
a kernel panic loop) used to block main()'s greeting readline() forever.
ui-drive.sh's own cleanup() calls `quit` on this exact socket before it
reaches reap("$QEMU_PID"), so an unbounded hang here defeats the one
thing meant to guarantee a wedged qemu-system-arm cannot outlive the
script."""
def test_main_bounds_a_wedged_qmp_peer_instead_of_hanging_forever(self):
d = tempfile.mkdtemp(prefix="qmpsock.")
sock_path = os.path.join(d, "qmp.sock")
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
srv.listen(1)
def accept_and_hang():
conn, _ = srv.accept()
time.sleep(5) # never answer the greeting/qmp_capabilities handshake
conn.close()
th = threading.Thread(target=accept_and_hang, daemon=True)
th.start()
saved_timeout, saved_argv = qmp.QMP_TIMEOUT_S, sys.argv
qmp.QMP_TIMEOUT_S = 0.3
sys.argv = ["qmp.py", sock_path, "quit"]
try:
t0 = time.monotonic()
with self.assertRaises(OSError):
qmp.main()
elapsed = time.monotonic() - t0
self.assertLess(
elapsed, 2.0,
"a wedged QMP peer must be bounded by QMP_TIMEOUT_S, not hang "
"indefinitely (main()'s socket needs its own settimeout(), the "
"same way Ctl's already has one)")
finally:
sys.argv = saved_argv
qmp.QMP_TIMEOUT_S = saved_timeout
srv.close()
th.join(timeout=6)
class JsonStatusFetch(unittest.TestCase):
"""fetch_status_json()'s two failure branches -- the guest's snapshot not
existing yet (the bridge answers 'bridge: no such file: PATH' for the
first couple of seconds after boot, before webstatus.c's first 2s timer
tick) and a torn/invalid JSON snapshot -- have no coverage anywhere else
in this file: every FakeCtl-style '@cat' handler above always returns
valid JSON."""
class StubCtl:
def __init__(self, reply):
self.reply = reply
def send(self, cmd):
assert cmd.startswith("@cat "), cmd
return self.reply
def test_missing_snapshot_file_is_a_detail_not_a_crash(self):
ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json")
doc, err = qmp.fetch_status_json(ctl)
self.assertIsNone(doc)
self.assertEqual(err, "bridge: no such file: /tmp/warden-web-status.json")
def test_torn_json_is_a_detail_not_a_crash(self):
ctl = self.StubCtl('{"a": 1, "b":')
doc, err = qmp.fetch_status_json(ctl)
self.assertIsNone(doc)
self.assertIn("status json unparsable", err)
def test_eval_json_turns_a_missing_snapshot_into_an_ordinary_fail(self):
ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json")
ok, detail = qmp.eval_json(ctl, "a.b", "eq", "1")
self.assertFalse(ok)
self.assertEqual(detail, "bridge: no such file: /tmp/warden-web-status.json")
def test_eval_json_turns_torn_json_into_an_ordinary_fail(self):
ctl = self.StubCtl('{"a": 1, "b":')
ok, detail = qmp.eval_json(ctl, "a.b", "eq", "1")
self.assertFalse(ok)
self.assertIn("status json unparsable", detail)
def test_wait_json_retries_a_missing_snapshot_instead_of_treating_it_fatal(self):
# A missing snapshot is an ordinary not-yet-true check, so wait_json
# must poll it out to TIMEOUT_S like any other fail -- not read the
# bridge's plain-text error as a channel fault the way a dead
# RuntimeError/OSError from ctl.send() itself already is.
ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json")
t0 = time.monotonic()
ok, detail, waited, fatal = qmp.poll_until(
lambda: qmp.eval_json(ctl, "a.b", "eq", "1"), 0.6, period=0.2)
self.assertFalse(ok)
self.assertFalse(fatal, "a missing snapshot is a fail to retry, not a channel fault")
self.assertGreaterEqual(time.monotonic() - t0, 0.6)
if __name__ == "__main__":
unittest.main(verbosity=1)
+28 -12
View File
@@ -114,10 +114,34 @@ reap() {
done
kill -KILL "$p" 2>/dev/null || true
}
# Poll for PATH to appear, checking every 0.1s for up to 5 seconds -- the
# rs.sock/rs.pty/rs.ctl handshake budget below, now set in one place instead
# of three copies that could drift out of step with each other. When PID is
# given, also stop the moment PID has died: a process that's already gone
# will never create the path, so there is no reason to spend the rest of the
# budget waiting on it. The exit status carries no verdict -- each call site
# still makes its own existence (and, for rs.ctl, liveness) check right after
# this returns, exactly as it did before the loop was pulled out.
wait_for_path() {
local path="$1" pid="${2:-}" _i
for _i in $(seq 1 50); do
[ -e "$path" ] && return 0
if [ -n "$pid" ]; then
kill -0 "$pid" 2>/dev/null || return 0
fi
sleep 0.1
done
return 0
}
cleanup() {
for p in $SIM_PIDS; do reap "$p"; done
if [ -n "$QEMU_PID" ]; then
python3 "$HERE/qmp.py" "$WORK/qmp.sock" quit 2>/dev/null || true
# `timeout` here is a second, independent bound on top of qmp.py's own
# QMP_TIMEOUT_S socket timeout: whichever one it is that stalls, this
# call must not itself keep cleanup() from reaching reap "$QEMU_PID"
# below -- the one thing meant to guarantee a wedged qemu-system-arm
# cannot outlive this script.
timeout -k 5 25 python3 "$HERE/qmp.py" "$WORK/qmp.sock" quit 2>/dev/null || true
sleep 1
reap "$QEMU_PID"
fi
@@ -173,7 +197,7 @@ if [ -n "$RS485_DEVICES" ]; then
command -v socat >/dev/null || { echo "FATAL: --rs485-devices needs socat" >&2; exit 1; }
[ -n "${FLARE_EDGE:-}" ] && [ -f "$FLARE_EDGE/tools/modbus-sim/mbsim.py" ] || {
echo "FATAL: --rs485-devices needs FLARE_EDGE to point at a flare-edge checkout (mbsim.py)" >&2; exit 1; }
for _i in $(seq 1 50); do [ -S "$WORK/rs.sock" ] && break; sleep 0.1; done
wait_for_path "$WORK/rs.sock"
[ -S "$WORK/rs.sock" ] || {
echo "FATAL: rs485 bus socket never appeared at $WORK/rs.sock (qemu's --rs485 chardev never came up)" >&2
tail -25 "$WORK/console.log" >&2
@@ -186,11 +210,7 @@ if [ -n "$RS485_DEVICES" ]; then
# poll budget: a socat that never links the pty is usually already gone
# (bad UNIX-CONNECT target, no pty node available), and kill -0 catches
# that in one tick instead of five seconds.
for _i in $(seq 1 50); do
[ -e "$WORK/rs.pty" ] && break
kill -0 "$socat_pid" 2>/dev/null || break
sleep 0.1
done
wait_for_path "$WORK/rs.pty" "$socat_pid"
[ -e "$WORK/rs.pty" ] || {
echo "FATAL: rs485 socat never created rs.pty (see $WORK/socat.log)" >&2
cat "$WORK/socat.log" >&2
@@ -204,11 +224,7 @@ if [ -n "$RS485_DEVICES" ]; then
python3 "$FLARE_EDGE/tools/modbus-sim/mbsim.py" --port "$WORK/rs.pty" --control "$WORK/rs.ctl" "${dev_args[@]}" > "$WORK/mbsim.log" 2>&1 &
mbsim_pid=$!
SIM_PIDS="$SIM_PIDS $mbsim_pid"
for _i in $(seq 1 50); do
[ -S "$WORK/rs.ctl" ] && break
kill -0 "$mbsim_pid" 2>/dev/null || break
sleep 0.1
done
wait_for_path "$WORK/rs.ctl" "$mbsim_pid"
{ [ -S "$WORK/rs.ctl" ] && kill -0 "$mbsim_pid" 2>/dev/null; } || {
echo "FATAL: rs485 simulator (mbsim.py) never came up (see $WORK/mbsim.log)" >&2
cat "$WORK/mbsim.log" >&2