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
+63 -1
View File
@@ -110,7 +110,16 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
shellcheck -x qemu/*.sh qemu/tests/*.sh build/*.sh \ shellcheck -x qemu/*.sh qemu/tests/*.sh build/*.sh \
qemu/rootfs/etc/warden-lib.sh qemu/rootfs/etc/rc \ qemu/rootfs/etc/warden-lib.sh qemu/rootfs/etc/rc \
qemu/rootfs/sbin/init qemu/rootfs/init qemu/rootfs/sbin/init qemu/rootfs/init tests/mk-bootimg/*.sh \
tests/fetch-vendor/*.sh tests/fetch-buildroot-tarball/*.sh
- name: cache apt archives (python3-pil)
# Same cost class as the busybox binary cached below: a system package
# plus its libjpeg/libpng transitive deps, downloaded fresh on every
# push otherwise.
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-archives-python3-pil-${{ runner.os }}
- name: ui-drive driver and image tools (offline) - name: ui-drive driver and image tools (offline)
# qmp.py's drive() with QMP and the control channel faked, plus # qmp.py's drive() with QMP and the control channel faked, plus
# imgtools' self-test: the per-step ok/fail/fatal contract and the # imgtools' self-test: the per-step ok/fail/fatal contract and the
@@ -119,6 +128,59 @@ jobs:
sudo apt-get install -y -qq python3-pil sudo apt-get install -y -qq python3-pil
python3 qemu/tests/imgtools.py selftest python3 qemu/tests/imgtools.py selftest
python3 qemu/tests/test_qmp_drive.py python3 qemu/tests/test_qmp_drive.py
- name: imgtools bench (smoke, printed for trend-watching)
# Same pattern as the sim/rs485-bridge bench job above: no stored
# baseline yet, just a number in the log so a phash/structural
# regression (DCT size, downscale filter, occupancy thresholds)
# is visible instead of only showing up as an unexplained slower
# flow run later.
run: python3 qemu/tests/imgtools.py bench
- name: mk-bootimg probe regression tests
# Guards issue #17 (mkimage's non-zero exit sinking the probe's grep
# pipeline under set -o pipefail) on every push/PR, not only on the
# next workflow_dispatch that happens to exercise mk-bootimg.sh for
# real via kernel-build.
run: bash tests/mk-bootimg/run-probe-tests.sh
- name: run.sh argv ordering regression test
# Pins the CTL-before-RS485 pci-serial argv order that
# rootfs/sbin/init's ttyS0-vs-ttyS1 alias depends on: a swap here
# reproduces run.sh:131-133's own incident, Modbus frames landing
# on the debug channel. All offline (a stub qemu-system-arm on
# PATH), so it runs on every push/PR, not only a real boot.
run: bash qemu/tests/run-sh-args-test.sh
- name: mkimage.sh SEED_DIR regression test
# Only ui-drive.sh --seed (a real VM boot) exercises this hook
# otherwise; this builds the same unprivileged mkfs.ext4 image and
# reads it back with debugfs, no VM needed.
run: bash qemu/tests/seed-dir.sh
- name: qemu_stage_rootfs permission regression test
# Git tracks only the executable bit, so a fresh checkout can land
# the source etc/shadow world-readable under a permissive umask;
# this pins the staged copy at 0600 regardless of the source mode.
run: bash qemu/tests/stage-rootfs-perms.sh
- name: fetch-vendor regression tests
# --check state machine (MISSING/OK/DRIFTED), --help, and the
# clone stall guard, against local throwaway repos: no network.
run: bash tests/fetch-vendor/run-fetch-vendor-tests.sh
- name: fetch-buildroot-tarball regression tests
# Retry-on-mismatch, cleanup, and the already-verified
# short-circuit, against a fake curl on PATH: no network.
run: bash tests/fetch-buildroot-tarball/run-fetch-buildroot-tarball-tests.sh
- name: mk-bootimg boot.img validation regression tests
# Guards issue #22 (a missing/erroring fdtget silently skipping the
# data-position check) plus the FIT metadata and per-image
# data-position %512 checks and the embedded-data-FIT check.
run: bash tests/mk-bootimg/run-boot-img-validate-tests.sh
- name: mk-bootimg --help regression test
# Pins --help against its own header comment so a hardcoded line
# range can't silently start printing code again the next time the
# header grows or shrinks (the bug fetch-vendor.sh's --help had).
run: bash tests/mk-bootimg/run-help-tests.sh
- name: qemu-tools CI wiring regression test
# Catches a regression test shipping in this job without this job
# ever calling it -- the exact gap run-probe-tests.sh sat in before
# the step above wired it in.
run: bash tests/mk-bootimg/run-ci-wiring-tests.sh
- name: cache pinned busybox - name: cache pinned busybox
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
+1 -2
View File
@@ -52,13 +52,12 @@ scenario tests (portal, OTA apply, display + touch, watchdog).
| Directory | Contents | | Directory | Contents |
|---|---| |---|---|
| `patches/` | the RV1106 forward-port onto pristine linux-6.18.46, subsystem-split | | `patches/` | the RV1106 forward-port onto pristine linux-6.18.46, subsystem-split |
| `build/` | hermetic kernel build: pinned fetch -> apply patches -> `zImage` + dtb | | `build/` | hermetic kernel build: pinned fetch -> apply patches -> `zImage` + dtb; `vendor.manifest` pins the third-party trees this platform builds against (LVGL, the vendor RV1106 SDK) to exact commits, and `fetch-vendor.sh` obtains and verifies them |
| `qemu/` | device simulator: QEMU `-M virt` boots the real kernel and real userspace | | `qemu/` | device simulator: QEMU `-M virt` boots the real kernel and real userspace |
| `sim/` | register-level hardware models (Rust): membus, HPMCU, CRU, Modbus, RGA, NPU | | `sim/` | register-level hardware models (Rust): membus, HPMCU, CRU, Modbus, RGA, NPU |
| `drivers/` | hardened hardware-facing drivers: HAL seams, test harnesses | | `drivers/` | hardened hardware-facing drivers: HAL seams, test harnesses |
| `kernel/` | forward-port provenance and bring-up records (`patches/` is canonical) | | `kernel/` | forward-port provenance and bring-up records (`patches/` is canonical) |
| `tools/` | `config-lint` (static memory-map gates) and dev tooling | | `tools/` | `config-lint` (static memory-map gates) and dev tooling |
| `build/vendor.manifest` | the third-party trees this platform builds against (LVGL, the vendor RV1106 SDK), pinned to exact commits; `build/fetch-vendor.sh` obtains and verifies them |
| `docs/` | architecture, ADRs (`decisions/`), CI/CD | | `docs/` | architecture, ADRs (`decisions/`), CI/CD |
## Architecture ## Architecture
+13 -1
View File
@@ -12,6 +12,14 @@
# Same shape as fetch-kernel-tarball.sh, deliberately: a version bump edits this # Same shape as fetch-kernel-tarball.sh, deliberately: a version bump edits this
# file and the pin beside it, nothing else. FAILS CLOSED on a missing pin. # file and the pin beside it, nothing else. FAILS CLOSED on a missing pin.
# #
# Not wired into build-firmware.sh or CI yet -- tools/build-firmware.sh stages
# sdk-patches/buildroot/ onto whatever buildroot tree the vendor SDK already
# has, it does not yet extract this pinned tarball over it. Exercised today by
# tests/fetch-buildroot-tarball/run-fetch-buildroot-tarball-tests.sh and by
# running the script by hand; wiring it into the real build path is a separate
# change (it has to reconcile the pristine tree with the existing SDK buildroot
# checkout first).
#
# Usage: fetch-buildroot-tarball.sh <destination-path> # Usage: fetch-buildroot-tarball.sh <destination-path>
set -euo pipefail set -euo pipefail
@@ -43,7 +51,11 @@ fi
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "== fetching buildroot-$BRVER (attempt $attempt)" echo "== fetching buildroot-$BRVER (attempt $attempt)"
if curl -fsSL --retry 2 -o "$TB" "$URL" && verify; then # --retry only re-fires once curl decides a transfer has failed; a connection
# that opens and then stalls (blackholed route, hung proxy) never reaches
# that decision and would otherwise block forever. --connect-timeout bounds
# the handshake, --max-time bounds the whole request.
if curl -fsSL --retry 2 --connect-timeout 20 --max-time 120 -o "$TB" "$URL" && verify; then
echo "buildroot-$BRVER: sha256 verified" echo "buildroot-$BRVER: sha256 verified"
exit 0 exit 0
fi fi
+18 -1
View File
@@ -26,7 +26,11 @@ while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--check) MODE="check"; shift ;; --check) MODE="check"; shift ;;
--fetch) MODE="fetch"; shift ;; --fetch) MODE="fetch"; shift ;;
-h|--help) sed -n '2,18p' "$0"; exit 0 ;; # Print the header comment (line 1 is the shebang, so start at 2) and
# stop at the first line of code rather than a hardcoded line count --
# a fixed range silently starts printing code again the next time the
# header comment grows or shrinks.
-h|--help) awk '/^set /{exit} NR>1{print}' "$0"; exit 0 ;;
*) DIR="$1"; shift ;; *) DIR="$1"; shift ;;
esac esac
done done
@@ -34,6 +38,19 @@ done
[ -r "$MANIFEST" ] || { echo "FATAL: no manifest at $MANIFEST" >&2; exit 1; } [ -r "$MANIFEST" ] || { echo "FATAL: no manifest at $MANIFEST" >&2; exit 1; }
command -v git >/dev/null || { echo "FATAL: git not on PATH" >&2; exit 1; } command -v git >/dev/null || { echo "FATAL: git not on PATH" >&2; exit 1; }
# A stalled clone (dead peer, wedged proxy) must not hang this script forever
# with no way for a caller to tell "still working" from "wedged" -- the
# luckfox-pico tree alone is ~21 GB, so a plain wall-clock timeout would also
# abort a clone that is merely slow. Abort only on a stall instead: git's http
# transport already aborts a transfer whose average speed drops below
# LOW_SPEED_LIMIT bytes/sec for LOW_SPEED_TIME seconds straight, so a slow but
# progressing clone is never penalized. Both are overridable for a link that
# is legitimately slow.
: "${WARDEN_VENDOR_LOW_SPEED_LIMIT:=1000}"
: "${WARDEN_VENDOR_LOW_SPEED_TIME:=60}"
export GIT_HTTP_LOW_SPEED_LIMIT="$WARDEN_VENDOR_LOW_SPEED_LIMIT"
export GIT_HTTP_LOW_SPEED_TIME="$WARDEN_VENDOR_LOW_SPEED_TIME"
if [ -z "$DIR" ]; then if [ -z "$DIR" ]; then
DIR="${WARDEN_VENDOR_DIR:-$HOME/projects/scada/flare-edge}" DIR="${WARDEN_VENDOR_DIR:-$HOME/projects/scada/flare-edge}"
fi fi
+25 -13
View File
@@ -42,7 +42,12 @@ while [ $# -gt 0 ]; do
# displays it on an ordinary boot. # displays it on an ordinary boot.
--logo-verbose) LOGO_VERBOSE="${2:?}"; shift 2 ;; --logo-verbose) LOGO_VERBOSE="${2:?}"; shift 2 ;;
--resource-tool) RTOOL="${2:?}"; shift 2 ;; --resource-tool) RTOOL="${2:?}"; shift 2 ;;
-h|--help) sed -n '2,25p' "$0"; exit 0 ;; # Print the header comment (line 1 is the shebang, so start at 2) and
# stop at the first line of code rather than a hardcoded line count --
# a fixed range silently starts printing code again the next time the
# header comment grows or shrinks (see build/fetch-vendor.sh's own
# --help, which had this exact bug).
-h|--help) awk '/^set /{exit} NR>1{print}' "$0"; exit 0 ;;
*) echo "FATAL: unknown argument '$1'" >&2; exit 1 ;; *) echo "FATAL: unknown argument '$1'" >&2; exit 1 ;;
esac esac
done done
@@ -52,6 +57,11 @@ done
[ -n "$OUT" ] || { echo "FATAL: --out is required" >&2; exit 1; } [ -n "$OUT" ] || { echo "FATAL: --out is required" >&2; exit 1; }
command -v mkimage >/dev/null || { command -v mkimage >/dev/null || {
echo "FATAL: mkimage not on PATH (Debian/Ubuntu: u-boot-tools)" >&2; exit 1; } echo "FATAL: mkimage not on PATH (Debian/Ubuntu: u-boot-tools)" >&2; exit 1; }
# fdtget backs the post-build alignment assertions below (the whole point of
# which is that a misaligned image boots fine in CI and fails on a panel), so
# its absence must fail the build rather than silently skip those checks.
command -v fdtget >/dev/null || {
echo "FATAL: fdtget not on PATH (Debian/Ubuntu: device-tree-compiler)" >&2; exit 1; }
# resource_tool is a Rockchip host tool. It has no free-standing source here, so # resource_tool is a Rockchip host tool. It has no free-standing source here, so
# it is taken from the vendor SDK when one is present rather than vendored as a # it is taken from the vendor SDK when one is present rather than vendored as a
@@ -160,11 +170,6 @@ ITS
# vendor's -p value -- the absolute position of the first payload -- not an # vendor's -p value -- the absolute position of the first payload -- not an
# alignment. # alignment.
# #
# The flag is feature-detected because the SDK vendors mkimage 2017.09,
# which has no -B at all and dies with "invalid option -- 'B'". Its packer
# already 512-aligns, so omitting the flag there is correct rather than a
# fallback. project/build.sh prepends the SDK tool dir to PATH, so that
# binary IS what a build inside the SDK environment resolves.
# A -B-capable mkimage is REQUIRED, not preferred. The SDK vendors 2017.09, # A -B-capable mkimage is REQUIRED, not preferred. The SDK vendors 2017.09,
# which has no -B, and project/build.sh:64 puts it first on PATH -- so the # which has no -B, and project/build.sh:64 puts it first on PATH -- so the
# wrong one is what a build inside the SDK environment picks up. Measured: # wrong one is what a build inside the SDK environment picks up. Measured:
@@ -198,16 +203,24 @@ echo "== FIT (external data, -E -p 0x800 -B 0x200) using $MKIMAGE"
# Assert what U-Boot actually requires, on every build: the failure is silent -- # Assert what U-Boot actually requires, on every build: the failure is silent --
# a misread offset does not fail the build, it fails on a panel, and sometimes # a misread offset does not fail the build, it fails on a panel, and sometimes
# only as a missing logo. # only as a missing logo. Computed once and reused below (the embedded-data-FIT
_meta="$(od -An -tu4 -j4 -N4 --endian=big "$WORKDIR/boot.img" | tr -d ' ')" # check further down needs the same value) so a future fix to how this is read
if [ $(( _meta % 512 )) -ne 0 ]; then # cannot land in one check and not the other.
echo "FATAL: FIT metadata is $_meta bytes, not a multiple of 512;" >&2 meta="$(od -An -tu4 -j4 -N4 --endian=big "$WORKDIR/boot.img" | tr -d ' ')"
if [ $(( meta % 512 )) -ne 0 ]; then
echo "FATAL: FIT metadata is $meta bytes, not a multiple of 512;" >&2
echo " FIT_ALIGN would round it up and every payload reads late" >&2 echo " FIT_ALIGN would round it up and every payload reads late" >&2
exit 1 exit 1
fi fi
for _n in fdt kernel resource; do for _n in fdt kernel resource; do
_pos="$(fdtget -t u "$WORKDIR/boot.img" "/images/$_n" data-position 2>/dev/null || true)" # fdtget's presence is checked up front; a failure here means the FIT this
[ -n "$_pos" ] || continue # script just built is malformed, not that the field is legitimately
# absent (mkimage -E gives every one of these images a data-position). Fail
# loud rather than treat an empty read as nothing to check.
if ! _pos="$(fdtget -t u "$WORKDIR/boot.img" "/images/$_n" data-position 2>&1)"; then
echo "FATAL: fdtget could not read /images/$_n data-position: $_pos" >&2
exit 1
fi
if [ $(( _pos % 512 )) -ne 0 ]; then if [ $(( _pos % 512 )) -ne 0 ]; then
echo "FATAL: /images/$_n data-position $_pos is not 512-aligned;" >&2 echo "FATAL: /images/$_n data-position $_pos is not 512-aligned;" >&2
echo " U-Boot's truncating block divide would read the wrong offset" >&2 echo " U-Boot's truncating block divide would read the wrong offset" >&2
@@ -218,7 +231,6 @@ done
# A FIT whose metadata swelled to the size of the whole image is an # A FIT whose metadata swelled to the size of the whole image is an
# embedded-data build, which this U-Boot rejects. Catch it here rather than on # embedded-data build, which this U-Boot rejects. Catch it here rather than on
# a panel that will not come back. # a panel that will not come back.
meta="$(od -An -tu4 -j4 -N4 --endian=big "$WORKDIR/boot.img" | tr -d ' ')"
total="$(stat -c %s "$WORKDIR/boot.img")" total="$(stat -c %s "$WORKDIR/boot.img")"
if [ "${meta:-0}" -ge 4096 ] || [ "${meta:-0}" -ge "$total" ]; then if [ "${meta:-0}" -ge 4096 ] || [ "${meta:-0}" -ge "$total" ]; then
echo "FATAL: FIT metadata is ${meta} bytes of a ${total}-byte image: that is an" >&2 echo "FATAL: FIT metadata is ${meta} bytes of a ${total}-byte image: that is an" >&2
+8 -1
View File
@@ -1351,7 +1351,14 @@ CONFIG_NETFILTER_XT_MATCH_CONNTRACK=y
CONFIG_NETFILTER_XT_NAT=y CONFIG_NETFILTER_XT_NAT=y
CONFIG_NETFILTER_XT_TARGET_MASQUERADE=y CONFIG_NETFILTER_XT_TARGET_MASQUERADE=y
CONFIG_NF_DEFRAG_IPV4=y CONFIG_NF_DEFRAG_IPV4=y
CONFIG_NF_CONNTRACK_IPV4=y # No NF_CONNTRACK_IPV4 symbol here -- IPv4 conntrack has been unconditional in
# NF_CONNTRACK's core since well before 6.18 (net/ipv4/netfilter/Kconfig has no
# such config). Only stale arch defconfigs (e.g. keystone_defconfig) still set
# it; Kconfig drops an unknown symbol with no warning, so it looked live but
# did nothing. Left out on purpose so this file does not claim a gate that
# does not exist. If a future kernel bump reintroduces a real symbol by this
# name, catch it by diffing the expanded .config, not by functional test alone
# -- see PORT-STATUS.md's own history of a silent-drop hiding a real gap.
CONFIG_IP_NF_IPTABLES=y CONFIG_IP_NF_IPTABLES=y
# 6.18 SPLIT THE LEGACY TABLES OUT. IP_NF_FILTER and IP_NF_NAT depend on # 6.18 SPLIT THE LEGACY TABLES OUT. IP_NF_FILTER and IP_NF_NAT depend on
# IP_NF_IPTABLES_LEGACY, which did not exist in 5.10 -- so copying the vendor # IP_NF_IPTABLES_LEGACY, which did not exist in 5.10 -- so copying the vendor
+1 -1
View File
@@ -13,7 +13,7 @@ execute code on private infrastructure (ADR-0007).
| `mcdc` | ubuntu-latest | 100% MC/DC enforced on every `drivers/*/test` (gcc-14 `-fcondition-coverage`). | | `mcdc` | ubuntu-latest | 100% MC/DC enforced on every `drivers/*/test` (gcc-14 `-fcondition-coverage`). |
| `bench` | ubuntu-latest | Smoke-runs the sim + rs485-bridge micro-benchmarks; emits ns/op trend JSON. | | `bench` | ubuntu-latest | Smoke-runs the sim + rs485-bridge micro-benchmarks; emits ns/op trend JSON. |
| `patches-apply` | ubuntu-latest | Fetches pristine linux-6.18.46 (cached, sha256-verified) and applies `patches/*` in order. | | `patches-apply` | ubuntu-latest | Fetches pristine linux-6.18.46 (cached, sha256-verified) and applies `patches/*` in order. |
| `qemu-tools` | ubuntu-latest | shellcheck on `qemu/**.sh`; builds the initramfs (pinned busybox) and the A/B disk image. | | `qemu-tools` | ubuntu-latest | shellcheck on `qemu/**.sh`, `tests/mk-bootimg/*.sh`, `tests/fetch-vendor/*.sh`, `tests/fetch-buildroot-tarball/*.sh`; runs the offline regression tests for the mk-bootimg probe, boot.img validation, run.sh argv ordering, mkimage's SEED_DIR hook, staged rootfs permissions, fetch-vendor, fetch-buildroot-tarball, and its own CI wiring; builds the initramfs (pinned busybox) and the A/B disk image. |
| `quality` | ubuntu-latest | Codacy-style grade computed in-pipeline: clippy, cppcheck, shellcheck, ruff, lizard, jscpd, cargo-audit feed `tools/quality/score.py` (SQALE debt ratio + a separate worst-of security axis; SonarQube's published thresholds). Uploads `quality.json`; fails if the security grade is worse than C. | | `quality` | ubuntu-latest | Codacy-style grade computed in-pipeline: clippy, cppcheck, shellcheck, ruff, lizard, jscpd, cargo-audit feed `tools/quality/score.py` (SQALE debt ratio + a separate worst-of security axis; SonarQube's published thresholds). Uploads `quality.json`; fails if the security grade is worse than C. |
| `kernel-build` | ubuntu-latest, **dispatch-only** | apt-installs the cross toolchain + qemu, `build/build-kernel.sh` -> `zImage` + `rv1106-warden.dtb`, QEMU `-M virt` boot smoke (fail-closed), artifact upload (best-effort). Trigger: `gh workflow run ci.yml`. | | `kernel-build` | ubuntu-latest, **dispatch-only** | apt-installs the cross toolchain + qemu, `build/build-kernel.sh` -> `zImage` + `rv1106-warden.dtb`, QEMU `-M virt` boot smoke (fail-closed), artifact upload (best-effort). Trigger: `gh workflow run ci.yml`. |
| `prune-artifacts` | ubuntu-latest, dispatch-only | Deletes `kernel-rv1106` artifacts beyond the newest 3. | | `prune-artifacts` | ubuntu-latest, dispatch-only | Deletes `kernel-rv1106` artifacts beyond the newest 3. |
+5 -3
View File
@@ -160,16 +160,18 @@ build-m2.sh reproducible M2 build (zImage + dtb)
The full ported tree lives in `flare-edge/research/linux-6.18.46/` (scratch); this dir is The full ported tree lives in `flare-edge/research/linux-6.18.46/` (scratch); this dir is
the durable, reviewable capture, to become a proper patch series as milestones land. the durable, reviewable capture, to become a proper patch series as milestones land.
## Hardware verification of the CURRENT series (2026-09-03, bench panel) ## Hardware verification (2026-09-03)
The series in `../../patches/` was built with `build/build-kernel.sh` + The current series in `../../patches/` was built with `build/build-kernel.sh` +
`build/warden_defconfig` (rockchip gcc 8.3), packaged with the new `build/warden_defconfig` (rockchip gcc 8.3), packaged with the new
`build/mk-bootimg.sh`, flashed to the bench panel's inactive slot armed for a `build/mk-bootimg.sh`, flashed to the bench panel's inactive slot armed for a
single try, and booted. Working on real silicon: display (`/dev/fb0`, single try, and booted. Working on real silicon: display (`/dev/fb0`,
`/dev/dri/card0`), backlight, Goodix touch, RGA (`/dev/rga`), eth0, the usb0 `/dev/dri/card0`), backlight, Goodix touch, RGA (`/dev/rga`), eth0, the usb0
gadget, all userspace daemons, and zero kernel faults in dmesg. gadget, all userspace daemons, and zero kernel faults in dmesg.
M4/M5/M6 above are STALE: display and RGA are in the series and verified here. M4 (display) and part of M6 (RGA) above are STALE: both are in the series and
verified here. M5 (wifi) and the rest of M6 (watchdog, HPMCU, full USB-OTG
dual role) are still open -- this run gives no evidence for them.
**What the same test found missing, and why it matters.** Diffing this **What the same test found missing, and why it matters.** Diffing this
defconfig's expansion against the kernel actually shipping on a panel showed defconfig's expansion against the kernel actually shipping on a panel showed
+13 -5
View File
@@ -80,7 +80,10 @@ show up in screendumps at random (issue #18).
The guest carries the panel's own `/etc/passwd`, `/etc/shadow` and The guest carries the panel's own `/etc/passwd`, `/etc/shadow` and
`/etc/group` (root's md5-crypt of the documented default password), so a `/etc/group` (root's md5-crypt of the documented default password), so a
screen that verifies the root password against `/etc/shadow` behaves as it screen that verifies the root password against `/etc/shadow` behaves as it
does on a panel instead of rejecting every attempt. does on a panel instead of rejecting every attempt. `qemu_stage_rootfs()`
(`lib.sh`) forces `etc/shadow` to mode 0600 on every stage: git tracks only
the executable bit, so the checked-out source file's own mode depends on the
checking-out umask and cannot be trusted to arrive non-world-readable.
## Scenarios ## Scenarios
@@ -88,6 +91,8 @@ All take the virt-fragment `<zImage>`; `FLARE_EDGE=<checkout>` where noted.
| Scenario | Needs | Proves | | Scenario | Needs | Proves |
|---|---|---| |---|---|---|
| `stage-rootfs-perms.sh` | - | offline, no VM: `qemu_stage_rootfs()` always lands `etc/shadow` at 0600, even staged from a source copy deliberately left 0644 |
| `seed-dir.sh` | - | offline, no VM: `mkimage.sh`'s `SEED_DIR` hook lands every seeded file under userdata/warden with its original mode (a 0600 secret included), a seeded file beats a same-named `--state` value, a `SEED_DIR` that isn't a directory fails closed, and so does an individual entry that is a symlink, a subdirectory, or named outside `[A-Za-z0-9_.-]+` -- a hyphenated key such as `gas-plant.devices` still seeds cleanly |
| `boot-smoke.sh` | - | sentinel-asserting boot; runs in CI inside kernel-build | | `boot-smoke.sh` | - | sentinel-asserting boot; runs in CI inside kernel-build |
| `portal-scenario.sh` | `FLARE_EDGE` | real flared against the desk mock portal: authenticated check-in, desired-state pull, signed tier-1 `.wfw` download; verify/stage/APPLYING as a dry run (no `WARDEN_FW_ALLOW_APPLY`) | | `portal-scenario.sh` | `FLARE_EDGE` | real flared against the desk mock portal: authenticated check-in, desired-state pull, signed tier-1 `.wfw` download; verify/stage/APPLYING as a dry run (no `WARDEN_FW_ALLOW_APPLY`) |
| `ota-apply.sh` | `FLARE_EDGE` | the FULL apply: the `.wfw`'s bootable rootfs payload is written to rootfs_b (`run.sh --allow-apply` gates it per boot), the AvbABData in `misc` flips, and slot `_b` boots the applied version | | `ota-apply.sh` | `FLARE_EDGE` | the FULL apply: the `.wfw`'s bootable rootfs payload is written to rootfs_b (`run.sh --allow-apply` gates it per boot), the AvbABData in `misc` flips, and slot `_b` boots the applied version |
@@ -95,6 +100,7 @@ All take the virt-fragment `<zImage>`; `FLARE_EDGE=<checkout>` where noted.
| `ui-drive.sh <script>` | - | the same rig for a SEQUENCE: boots once, runs a `qmp.py drive` script of taps/swipes/screenshots in panel pixels, and FAILS if warden-ui died on the way (stage-2 init announces the exit on the console). `tests/scripts/nav-stress.txt` is the navigation regression: it reproduces the s_row_left overflow that segfaulted the UI on returning to Settings > Apps; `tests/scripts/home-leaves-fullscreen.txt` proves the `home` verb clears dashboard fullscreen, the state a hardware wake tap leaves behind (flare-edge #176); `tests/scripts/fullscreen-toggle-tracks-real-state.txt` proves a `fullscreen toggle` right after `home` (or a real tap) reads the dashboard's actual state rather than a belief `home` bypassed -- `test_qmp_drive.py`'s `FullscreenToggleTracksRealState` pins the same contract offline | | `ui-drive.sh <script>` | - | the same rig for a SEQUENCE: boots once, runs a `qmp.py drive` script of taps/swipes/screenshots in panel pixels, and FAILS if warden-ui died on the way (stage-2 init announces the exit on the console). `tests/scripts/nav-stress.txt` is the navigation regression: it reproduces the s_row_left overflow that segfaulted the UI on returning to Settings > Apps; `tests/scripts/home-leaves-fullscreen.txt` proves the `home` verb clears dashboard fullscreen, the state a hardware wake tap leaves behind (flare-edge #176); `tests/scripts/fullscreen-toggle-tracks-real-state.txt` proves a `fullscreen toggle` right after `home` (or a real tap) reads the dashboard's actual state rather than a belief `home` bypassed -- `test_qmp_drive.py`'s `FullscreenToggleTracksRealState` pins the same contract offline |
| `real-image-boot.sh` | matched `rootfs.img` + `oem.img` | an ACTUAL flare-edge build (placed by `mkimage.sh --rootfs-image/--oem-image`) boots its own init chain to getty; binaries predating known fixes reproduce their bugs faithfully, a time machine for field issues | | `real-image-boot.sh` | matched `rootfs.img` + `oem.img` | an ACTUAL flare-edge build (placed by `mkimage.sh --rootfs-image/--oem-image`) boots its own init chain to getty; binaries predating known fixes reproduce their bugs faithfully, a time machine for field issues |
| `test-ui-drive-rs485.sh` | - | offline, no VM: a fake `run.sh` and (for one case) a fake `socat` stand in so `ui-drive.sh --rs485-devices`'s startup fails closed instead of printing a false "== rs485 simulator: ..." over a bus nothing is serving, for each of a qemu rs.sock that never appears, a socat that never links rs.pty, and an mbsim.py that dies before opening rs.ctl; also proves `cleanup()` escalates to SIGKILL for a sim process that ignores SIGTERM | | `test-ui-drive-rs485.sh` | - | offline, no VM: a fake `run.sh` and (for one case) a fake `socat` stand in so `ui-drive.sh --rs485-devices`'s startup fails closed instead of printing a false "== rs485 simulator: ..." over a bus nothing is serving, for each of a qemu rs.sock that never appears, a socat that never links rs.pty, and an mbsim.py that dies before opening rs.ctl; also proves `cleanup()` escalates to SIGKILL for a sim process that ignores SIGTERM |
| `run-sh-args-test.sh` | - | offline, no VM: a fake `qemu-system-arm` captures run.sh's own argv and pins the ctl/rs485 pci-serial ordering contract rootfs/sbin/init's ttyS0-vs-ttyS1 alias depends on -- the ctl device always enumerates before rs485, warden.ctl only lands on the cmdline when --ctl is given, and an rs485 pci-serial device (real or null-backed) is always present so the port count never shifts |
| watchdog (`run.sh --watchdog`) | - | arm `/dev/watchdog`, don't pet: the VM resets ~30 s later (verified) | | watchdog (`run.sh --watchdog`) | - | arm `/dev/watchdog`, don't pet: the VM resets ~30 s later (verified) |
Scenario fine print: Scenario fine print:
@@ -121,7 +127,9 @@ Scenario fine print:
## Requirements ## Requirements
`qemu-system-arm` (Debian 13 ships QEMU 10), `curl`, `cpio`, `mkfs.ext4`, `qemu-system-arm` (Debian 13 ships QEMU 10), `curl`, `cpio`, `mkfs.ext4`,
`gcc-arm-linux-gnueabihf` (kernel build), `python3` (+`cryptography` for the `debugfs` (both ship in `e2fsprogs`; `seed-dir.sh` reads a built partition
portal scenario's `.wfw` signing). CI: the hosted `qemu-tools` job builds the back with it), `gcc-arm-linux-gnueabihf` (kernel build), `python3`
tooling; the boot smoke runs inside the (also hosted, dispatch-only) (+`cryptography` for the portal scenario's `.wfw` signing). CI: the hosted
`kernel-build` job, which apt-installs its own toolchain and qemu (ADR-0007). `qemu-tools` job builds the tooling; the boot smoke runs inside the (also
hosted, dispatch-only) `kernel-build` job, which apt-installs its own
toolchain and qemu (ADR-0007).
+6
View File
@@ -44,6 +44,12 @@ qemu_stage_rootfs() {
cp -a "$QEMU_DIR/rootfs/." "$root/" cp -a "$QEMU_DIR/rootfs/." "$root/"
chmod 0755 "$root/init" "$root/sbin/init" "$root/etc/rc" \ chmod 0755 "$root/init" "$root/sbin/init" "$root/etc/rc" \
"$root/usr/share/udhcpc/default.script" "$root/usr/share/udhcpc/default.script"
# git only tracks the executable bit, so a checkout lands etc/shadow at
# whatever the umask gives a non-executable file (644 under the common
# 022) -- root's crypt hash world-readable. Force the normal shadow mode
# here, once, for every caller (mkimage.sh and mkinitramfs.sh both stage
# through this function).
chmod 0600 "$root/etc/shadow"
} }
# Parse a "SIZE[@OFFSET](NAME)" blkdevparts entry list (without the "vda:" # Parse a "SIZE[@OFFSET](NAME)" blkdevparts entry list (without the "vda:"
+32
View File
@@ -99,6 +99,38 @@ done
# same-named --state value; `cp -a` preserves the 0600 on a secret entry. # same-named --state value; `cp -a` preserves the 0600 on a secret entry.
if [ -n "${SEED_DIR:-}" ]; then if [ -n "${SEED_DIR:-}" ]; then
[ -d "$SEED_DIR" ] || { echo "FATAL: SEED_DIR '$SEED_DIR' is not a directory" >&2; exit 1; } [ -d "$SEED_DIR" ] || { echo "FATAL: SEED_DIR '$SEED_DIR' is not a directory" >&2; exit 1; }
# Validate every entry before cp -a touches any of them: `cp -a` preserves
# a symlink rather than following it, so one placed in SEED_DIR would land
# as a live symlink under /userdata/warden that a later read (flared's
# settings loader, running inside the guest) resolves through. Fail closed
# on the first bad entry the same way --state fails closed above, rather
# than copy it into the image and let it surface as a confusing read
# later. Only a top-level plain file matches "one file per settings key"
# (the Env note above) -- seed-fixtures.py never nests a subdirectory, and
# neither should anything else pointed at this hook.
#
# Charset is seed-fixtures.py's KEY_RE, not --state's stricter
# [A-Za-z0-9_.]+ above: seed-fixtures.py deliberately also allows '-' for
# keys like "gas-plant.devices" (flare-edge#151), and committed flow
# specs (gas_compression, gas_plant, liquid_pumping, power_generation)
# already seed hyphenated keys through this exact path -- --state's
# charset would reject every one of them.
while IFS= read -r -d '' entry; do
base="$(basename "$entry")"
case "$base" in
*[!A-Za-z0-9_.-]*)
echo "FATAL: SEED_DIR entry '$base' must match [A-Za-z0-9_.-]+ (it becomes a filename)" >&2
exit 1 ;;
esac
if [ -L "$entry" ]; then
echo "FATAL: SEED_DIR entry '$base' is a symlink (refusing to copy it into userdata verbatim)" >&2
exit 1
fi
[ -f "$entry" ] || {
echo "FATAL: SEED_DIR entry '$base' is not a plain file (refusing to copy it into userdata verbatim)" >&2
exit 1
}
done < <(find "$SEED_DIR" -mindepth 1 -maxdepth 1 -print0)
cp -a "$SEED_DIR"/. "$UDATA/warden/" cp -a "$SEED_DIR"/. "$UDATA/warden/"
fi fi
+48 -58
View File
@@ -35,11 +35,10 @@ import json
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
# occupancy thresholds for structural(): a downsampled cell counts as # occupancy thresholds for structural(): a cell is occupied when its grey is
# "occupied" if it is meaningfully brighter than black, or sits on an edge. # this far from the crop's median (its background), or its FIND_EDGES energy
# Both are 0..255 greyscale/edge-magnitude averages over the cell. # exceeds the edge threshold. Both are 0..255 greyscale/edge-magnitude
# structural(): a cell is occupied when its grey is this far from the crop's # averages over the cell.
# median (its background) or its FIND_EDGES energy exceeds the edge threshold.
# Validated on real captures: a switch knob left/right differs in 240/256 # 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. # cells, a dark card's icon and text stand out from its (7,13,29) ground.
DEVIATION_THRESHOLD = 28 DEVIATION_THRESHOLD = 28
@@ -53,64 +52,31 @@ EDGE_THRESHOLD = 24
def load_ppm(path): def load_ppm(path):
"""Read a binary PPM (P6), return (w, h, rgb_bytes). """Read a binary PPM (P6), return (w, h, rgb_bytes).
QEMU's screendump writes a comment-free header ("P6\\nW H\\n255\\n" Delegates to Pillow's own P6 decoder (Image.open, format auto-detected
then raw bytes) but the token reader here handles the general P6 from the magic) instead of re-parsing the PPM grammar by hand: skipping
grammar -- whitespace runs and '#' comments -- since that costs whitespace runs and '#' comments, and normalising any maxval to 8-bit,
nothing extra and means any other producer of P6 files just works. 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: with Image.open(path) as im:
data = f.read() im = im.convert("RGB")
return im.width, im.height, im.tobytes()
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
def crop(img, x, y, w, h): 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 width, height, data = img
if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > width or y + h > height: 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}") raise ValueError(f"crop box {x},{y},{w}x{h} outside image {width}x{height}")
row_bytes = w * 3 cropped = _to_pil(img).crop((x, y, x + w, y + h))
out = bytearray(h * row_bytes) return w, h, cropped.tobytes()
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)
def _to_pil(img): def _to_pil(img):
@@ -188,7 +154,7 @@ def phash(img):
def hamming(a, b): def hamming(a, b):
"""Bit-differences between two phash ints.""" """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): def _structural_diff(a, b):
"""Count of differing bits between two 32-byte occupancy masks.""" """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") print(f"bench: {cw}x{ch} region, n={n} iterations")
for label, fn in (("phash", lambda: phash(region)), for label, fn in (("phash", lambda: phash(region)),
("structural", lambda: structural(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) samples = timeit.repeat(fn, repeat=n, number=1)
lo, p50, p95, hi = percentiles(samples) lo, p50, p95, hi = percentiles(samples)
print(f" {label:<10} min={lo * 1000:7.3f}ms p50={p50 * 1000:7.3f}ms " 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", check("crop keeps the drawn pixel in place",
cropped[2][px_off:px_off + 3] == bytes((220, 20, 20))) 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 # _dct2d_32's _DCT_KEEP truncation must land on the exact same
# coefficients an untruncated 32x32 DCT would produce # coefficients an untruncated 32x32 DCT would produce
small = _to_pil((bw, bh, bdata)).convert("L").resize((32, 32), Image.LANCZOS) small = _to_pil((bw, bh, bdata)).convert("L").resize((32, 32), Image.LANCZOS)
+89 -28
View File
@@ -125,24 +125,40 @@ import sys
import time import time
# imgtools.py lives beside this file and does the actual pixel math (phash, # imgtools.py lives beside this file and does the actual pixel math (phash,
# structural hash, crop, compare) for the region/ocr verbs. Imported at # structural hash, crop, compare) for the region/ocr verbs -- and imports
# module load but never let a missing/broken imgtools take down the verbs # Pillow at its OWN module scope to do it. Importing it here at qmp.py's
# that don't need it: nav/tap/page/... must keep working while it is being # module scope would drag that cost onto every subprocess invocation of this
# written, so the failure is deferred to need_imgtools() at first use. # file, including screendump/tap/quit, which never touch a pixel:
try: # ui-drive.sh's own boot-wait loop calls screendump specifically to avoid a
import imgtools # Pillow dependency (see its comment beside the plain byte-loop colour
except ImportError: # 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 = 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. def _load_imgtools():
global imgtools, _imgtools_import_error
if imgtools is None and _imgtools_import_error is None:
try: try:
from PIL import Image as _PILImage import imgtools # noqa: F811 -- binds the module-level name above
except ImportError: except ImportError as e:
_PILImage = None _imgtools_import_error = e
return imgtools
AXIS_MAX = 32767 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 # 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 # snapshot inside the guest. assert_json/wait_json `@cat` this path over the
# control channel; see the bridge in rootfs/sbin/init. # control channel; see the bridge in rootfs/sbin/init.
@@ -240,8 +256,23 @@ class Ctl:
not evidence about the UI either way. 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>>" 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): def __init__(self, path, timeout=15.0):
self.sock = socket.socket(socket.AF_UNIX) self.sock = socket.socket(socket.AF_UNIX)
self.sock.settimeout(timeout) self.sock.settimeout(timeout)
@@ -258,6 +289,10 @@ class Ctl:
if not chunk: if not chunk:
raise RuntimeError("control channel closed") raise RuntimeError("control channel closed")
self.buf += chunk 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 continue
line = self.buf[:nl].decode("utf-8", "replace").rstrip("\r") line = self.buf[:nl].decode("utf-8", "replace").rstrip("\r")
self.buf = self.buf[nl + 1:] self.buf = self.buf[nl + 1:]
@@ -538,11 +573,18 @@ def save_refs(path, refs):
def write_png(img, path): def write_png(img, path):
"""imgtools' (w, h, rgb_bytes) tuple -> a PNG file, because tesseract has """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 no raw-RGB input mode. Only assert_ocr needs this; assert_region and
capture_region work on imgtools' own tuples end to end.""" capture_region work on imgtools' own tuples end to end. Pillow is
if _PILImage is None: imported here, not at module scope, for the same reason imgtools.py's
raise RuntimeError("Pillow (PIL) is not installed") 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 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=""): def safe_out_path(outdir, name, prefix="", suffix=""):
@@ -599,7 +641,10 @@ class Ctx:
# A whole-script infrastructure gap (imgtools.py absent), same class # A whole-script infrastructure gap (imgtools.py absent), same class
# as a missing --ctl: no region/ocr verb can do anything without it, # 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. # 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 " sys.exit(f"FATAL: {self.script_path}:{lineno}: '{cmd}' needs imgtools.py "
f"next to qmp.py (vendor/warden-sdk/qemu/tests/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: try:
result = handler(ctx, lineno, cmd, args, line) 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_* # poll_until already turns this into a fatal row for the wait_*
# verbs; every other verb reaches ctl.send() (nav, wake, # verbs; every other verb reaches ctl.send() (nav, wake,
# scroll/home, page/hit/stats/ctl, assert_page, assert_hit, # 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 # (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 # own, so without this a dying channel or a gone QMP socket ends
# the whole run as an unhandled traceback instead of one fatal # the whole run as an unhandled traceback instead of one fatal
# step. Recorded the same as any other fatal row: the run keeps # step. ValueError/IndexError/TypeError are caught for the same
# going past it. # 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)) result = ("fatal", str(e))
if result is not None: if result is not None:
record(lineno, line, *result) 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) 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(): def main():
if len(sys.argv) < 3: if len(sys.argv) < 3:
sys.exit(__doc__) sys.exit(__doc__)
@@ -1020,16 +1083,14 @@ def main():
if len(sys.argv) < need[cmd]: if len(sys.argv) < need[cmd]:
sys.exit(f"{cmd}: missing argument(s)\n{__doc__}") sys.exit(f"{cmd}: missing argument(s)\n{__doc__}")
size = 720 size = int(flag(sys.argv, "--size", 720))
if "--size" in sys.argv: ctl_path = flag(sys.argv, "--ctl")
size = int(sys.argv[sys.argv.index("--size") + 1]) console_path = flag(sys.argv, "--console")
ctl_path = sys.argv[sys.argv.index("--ctl") + 1] if "--ctl" in sys.argv else None refs_path = flag(sys.argv, "--refs")
console_path = sys.argv[sys.argv.index("--console") + 1] if "--console" in sys.argv else None rs485_control = flag(sys.argv, "--rs485-control")
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)
s = socket.socket(socket.AF_UNIX) s = socket.socket(socket.AF_UNIX)
s.settimeout(QMP_TIMEOUT_S)
s.connect(path) s.connect(path)
f = s.makefile("r") f = s.makefile("r")
f.readline() # greeting banner 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" FLARE_EDGE="$SCRATCH/flare-edge-a"
mkdir -p "$FLARE_EDGE/tools/modbus-sim" 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" 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")" # A VAR=val prefix on a function call exports VAR into that one call only
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}" # (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 ] \ [ "$RIG_RC" -ne 0 ] \
&& pass "case A: never-appeared rs.sock fails the run (rc=$RIG_RC)" \ && 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" || 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" FAKEBIN="$SCRATCH/fakebin-b"; mkdir -p "$FAKEBIN"
printf '#!/usr/bin/env bash\necho "FAKE SOCAT: simulated failure" >&2\nexit 1\n' > "$FAKEBIN/socat" printf '#!/usr/bin/env bash\necho "FAKE SOCAT: simulated failure" >&2\nexit 1\n' > "$FAKEBIN/socat"
chmod +x "$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")" FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 PATH="$FAKEBIN:$PATH" run_rig "$work"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
[ "$RIG_RC" -ne 0 ] \ [ "$RIG_RC" -ne 0 ] \
&& pass "case B: socat never linking rs.pty fails the run (rc=$RIG_RC)" \ && 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" || 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.stderr.write("FAKE MBSIM: simulated crash before opening the control socket\n")
sys.exit(1) sys.exit(1)
EOS 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")" FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE='' run_rig "$work"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
[ "$RIG_RC" -ne 0 ] \ [ "$RIG_RC" -ne 0 ] \
&& pass "case C: mbsim.py crashing before rs.ctl fails the run (rc=$RIG_RC)" \ && 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" || 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) time.sleep(1)
EOS EOS
PIDFILE="$SCRATCH/mbsim-d.pid" 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")" FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE="$PIDFILE" run_rig "$work"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
if [ -s "$PIDFILE" ]; then if [ -s "$PIDFILE" ]; then
mbsim_pid="$(cat "$PIDFILE")" mbsim_pid="$(cat "$PIDFILE")"
if ! kill -0 "$mbsim_pid" 2>/dev/null; then if ! kill -0 "$mbsim_pid" 2>/dev/null; then
+396 -1
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Offline tests for qmp.py's drive(): the QMP socket and the control channel """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 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 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 json
import os import os
import socket import socket
import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
@@ -232,6 +236,23 @@ class PureHelpers(unittest.TestCase):
self.assertTrue(qmp.apply_op("lt", 1, "2")) self.assertTrue(qmp.apply_op("lt", 1, "2"))
self.assertTrue(qmp.apply_op("eq", "connected", "connected")) 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): def test_parse_stats(self):
got = qmp.parse_stats(FakeCtl("x").send("stats")) got = qmp.parse_stats(FakeCtl("x").send("stats"))
self.assertEqual(got, {"cpu": 12.0, "fps": 10.0, "render": 3.2, "idle": 0.0}) 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]: for row in rows[:-1]:
self.assertIn("QMP socket closed", row["detail"]) 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): def test_region_faults_are_per_step_fatal(self):
# A pre-seeded reference whose box does not fit a 64x64 screendump, # A pre-seeded reference whose box does not fit a 64x64 screendump,
# a name with no reference, a tolerance other than the captured one, # 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.assertEqual(rows[0]["status"], "fatal")
self.assertIn("must not contain '/'", rows[0]["detail"]) 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): def test_unknown_verb_is_fatal_for_the_run(self):
# A silently-ignored line is a test that proves nothing, so this one # A silently-ignored line is a test that proves nothing, so this one
# is the documented exception to "the run continues": drive() exits # 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") "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__": if __name__ == "__main__":
unittest.main(verbosity=1) unittest.main(verbosity=1)
+28 -12
View File
@@ -114,10 +114,34 @@ reap() {
done done
kill -KILL "$p" 2>/dev/null || true 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() { cleanup() {
for p in $SIM_PIDS; do reap "$p"; done for p in $SIM_PIDS; do reap "$p"; done
if [ -n "$QEMU_PID" ]; then 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 sleep 1
reap "$QEMU_PID" reap "$QEMU_PID"
fi fi
@@ -173,7 +197,7 @@ if [ -n "$RS485_DEVICES" ]; then
command -v socat >/dev/null || { echo "FATAL: --rs485-devices needs socat" >&2; exit 1; } 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" ] || { [ -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; } 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" ] || { [ -S "$WORK/rs.sock" ] || {
echo "FATAL: rs485 bus socket never appeared at $WORK/rs.sock (qemu's --rs485 chardev never came up)" >&2 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 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 # 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 # (bad UNIX-CONNECT target, no pty node available), and kill -0 catches
# that in one tick instead of five seconds. # that in one tick instead of five seconds.
for _i in $(seq 1 50); do wait_for_path "$WORK/rs.pty" "$socat_pid"
[ -e "$WORK/rs.pty" ] && break
kill -0 "$socat_pid" 2>/dev/null || break
sleep 0.1
done
[ -e "$WORK/rs.pty" ] || { [ -e "$WORK/rs.pty" ] || {
echo "FATAL: rs485 socat never created rs.pty (see $WORK/socat.log)" >&2 echo "FATAL: rs485 socat never created rs.pty (see $WORK/socat.log)" >&2
cat "$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 & 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=$! mbsim_pid=$!
SIM_PIDS="$SIM_PIDS $mbsim_pid" SIM_PIDS="$SIM_PIDS $mbsim_pid"
for _i in $(seq 1 50); do wait_for_path "$WORK/rs.ctl" "$mbsim_pid"
[ -S "$WORK/rs.ctl" ] && break
kill -0 "$mbsim_pid" 2>/dev/null || break
sleep 0.1
done
{ [ -S "$WORK/rs.ctl" ] && kill -0 "$mbsim_pid" 2>/dev/null; } || { { [ -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 echo "FATAL: rs485 simulator (mbsim.py) never came up (see $WORK/mbsim.log)" >&2
cat "$WORK/mbsim.log" >&2 cat "$WORK/mbsim.log" >&2
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Regression tests for build/fetch-buildroot-tarball.sh's retry/verify path.
#
# The script had never been exercised by anything (nothing calls it yet --
# see its own header) or by a test, so its retry-on-mismatch, cleanup, and
# already-verified short-circuit had never actually run. It also fetched with
# no --connect-timeout/--max-time, so a connection that opens and then stalls
# (a blackholed route, a hung proxy) would block forever instead of retrying;
# issue tracked separately.
#
# Runs the real script (copied into a fixture dir so its own HERE-relative pin
# lookup finds a pin we control) with a fake curl first on PATH, so no network
# is used and the outcome of each attempt is exact.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$HERE/../../build/fetch-buildroot-tarball.sh"
[ -f "$SCRIPT" ] || { echo "FAIL: script not found at $SCRIPT"; exit 1; }
FAIL=0
ok() { printf '[PASS] %s\n' "$1"; }
bad() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
WANT_SHA="$(printf 'good-bytes' | sha256sum | awk '{print $1}')"
# A private copy of the real script plus a pin file we control, in its own
# directory: the script resolves its pin relative to itself, so this is the
# shipping file under test, not a reimplementation of it.
setup() { # setup <case-name> -> prints the fixture dir
local dir="$TMP/$1"
mkdir -p "$dir/bin"
cp "$SCRIPT" "$dir/fetch-buildroot-tarball.sh"
printf '%s buildroot-2025.02.8.tar.xz\n' "$WANT_SHA" \
> "$dir/buildroot-2025.02.8.tar.xz.sha256"
printf '%s' "$dir"
}
run() { # run <dir> <dest> -> stdout+stderr on stdout, $? in $RC
local dir="$1" dest="$2"
OUT="$(PATH="$dir/bin:$PATH" bash "$dir/fetch-buildroot-tarball.sh" "$dest" 2>&1)"
RC=$?
}
# --- case A: destination already matches the pin -> curl never runs -------
dir="$(setup case-a)"
printf 'good-bytes' > "$dir/dest.tar.xz"
cat > "$dir/bin/curl" <<'FAKE'
#!/bin/sh
echo "curl should not have run" >&2
exit 1
FAKE
chmod +x "$dir/bin/curl"
run "$dir" "$dir/dest.tar.xz"
if [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q 'already present and verified'; then
ok "already-verified destination: curl never invoked"
else
bad "already-verified destination: curl never invoked (rc=$RC, out=$OUT)"
fi
# --- case B: fails once, succeeds on retry -> verified, no extra attempts -
dir="$(setup case-b)"
cat > "$dir/bin/curl" <<FAKE
#!/bin/sh
cnt_file="$dir/curl-calls"
n=0
[ -f "\$cnt_file" ] && n=\$(cat "\$cnt_file")
n=\$((n + 1))
echo "\$n" > "\$cnt_file"
out="" prev=""
for a in "\$@"; do
[ "\$prev" = "-o" ] && out="\$a"
prev="\$a"
done
if [ "\$n" -lt 2 ]; then printf 'bad-bytes' > "\$out"
else printf 'good-bytes' > "\$out"
fi
FAKE
chmod +x "$dir/bin/curl"
run "$dir" "$dir/dest.tar.xz"
calls="$(cat "$dir/curl-calls" 2>/dev/null || echo 0)"
if [ "$RC" -eq 0 ] && [ "$calls" -eq 2 ] && printf '%s' "$OUT" | grep -q 'sha256 verified'; then
ok "mismatch then match: verified on attempt 2, stops retrying"
else
bad "mismatch then match: verified on attempt 2, stops retrying (rc=$RC calls=$calls out=$OUT)"
fi
# --- case C: every attempt mismatches -> fails closed, no partial file left
dir="$(setup case-c)"
cat > "$dir/bin/curl" <<'FAKE'
#!/bin/sh
out="" prev=""
for a in "$@"; do
[ "$prev" = "-o" ] && out="$a"
prev="$a"
done
printf 'always-bad' > "$out"
FAKE
chmod +x "$dir/bin/curl"
run "$dir" "$dir/dest.tar.xz"
if [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q 'FATAL: could not fetch'; then
ok "persistent mismatch: exits nonzero with FATAL"
else
bad "persistent mismatch: exits nonzero with FATAL (rc=$RC, out=$OUT)"
fi
if [ ! -e "$dir/dest.tar.xz" ]; then
ok "persistent mismatch: no partial/corrupt tarball left at the destination"
else
bad "persistent mismatch: no partial/corrupt tarball left at the destination"
fi
attempts="$(printf '%s' "$OUT" | grep -c '== fetching')"
if [ "$attempts" -eq 3 ]; then
ok "persistent mismatch: exactly 3 attempts"
else
bad "persistent mismatch: exactly 3 attempts (got $attempts)"
fi
# --- guard: the fetch carries a connect and overall timeout ---------------
# Without these, --retry never fires (it only re-attempts a transfer curl has
# already decided failed) and a connection that opens then stalls blocks
# forever -- exactly the routing failure this workspace sees from some hosts.
if grep -q -- '--connect-timeout' "$SCRIPT" && grep -q -- '--max-time' "$SCRIPT"; then
ok "fetch carries --connect-timeout and --max-time"
else
bad "fetch carries --connect-timeout and --max-time"
fi
if [ "$FAIL" -eq 0 ]; then
echo "ALL FETCH-BUILDROOT-TARBALL TESTS PASSED"
else
echo "FETCH-BUILDROOT-TARBALL TESTS FAILED"
fi
exit "$FAIL"
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env bash
# Regression and coverage tests for build/fetch-vendor.sh.
#
# The script had no test of any kind before this: not the --check/--fetch
# state machine (MISSING/OK/DRIFTED, the lvgl/luckfox-pico path mapping, the
# "locally modified" annotation), not --help, not the clone stall guard. Two
# concrete regressions motivate the first two cases:
#
# - --help sliced its own source with a hardcoded line range that stopped
# one line too late, so it printed "set -uo pipefail" -- the first line
# of code -- as the last line of help text.
# - git clone ran with no bound on a stalled transfer: a dead peer or a
# wedged proxy mid-clone (the luckfox-pico tree alone is ~21 GB) hung the
# script forever with no way for a caller to tell "still working" from
# "wedged". The fix sets GIT_HTTP_LOW_SPEED_LIMIT/TIME so a stalled
# transfer aborts while a merely slow one is left alone.
#
# --help runs the shipping script directly. The --fetch/--check cases run a
# copy of it in a private fixture dir (its manifest lookup is relative to
# itself, so a copy is how its own HERE-relative resolution can be pointed at
# a manifest we control) against local, throwaway origin repos -- no network,
# and the real git binary does the work throughout (a thin logging wrapper
# only intercepts "clone" to record the env it saw, then execs straight
# through), so these exercise the shipping script's actual git calls.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$HERE/../../build/fetch-vendor.sh"
[ -f "$SCRIPT" ] || { echo "FAIL: script not found at $SCRIPT"; exit 1; }
FAIL=0
ok() { printf '[PASS] %s\n' "$1"; }
bad() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
GITC() { git -c user.email=t@t.invalid -c user.name=t "$@"; }
# mkrepo1 <dir> -> one commit on main, prints its hash
mkrepo1() {
local dir="$1"
git init -q -b main "$dir"
printf 'v1\n' > "$dir/file.txt"
git -C "$dir" add file.txt
GITC -C "$dir" commit -q -m v1
git -C "$dir" rev-parse HEAD
}
# mkrepo2 <dir> -> two commits on main, prints "C1 C2" (C1 older)
mkrepo2() {
local dir="$1" c1 c2
git init -q -b main "$dir"
printf 'v1\n' > "$dir/file.txt"
git -C "$dir" add file.txt
GITC -C "$dir" commit -q -m v1
c1="$(git -C "$dir" rev-parse HEAD)"
printf 'v2\n' > "$dir/file.txt"
git -C "$dir" add file.txt
GITC -C "$dir" commit -q -m v2
c2="$(git -C "$dir" rev-parse HEAD)"
printf '%s %s\n' "$c1" "$c2"
}
# ---------------------------------------------------------------------------
# Regression: --help must not spill into the script's own code.
# ---------------------------------------------------------------------------
HELP_OUT="$(bash "$SCRIPT" --help)"
if printf '%s\n' "$HELP_OUT" | grep -q 'set -uo pipefail'; then
bad "--help does not print the script's own code"
else
ok "--help does not print the script's own code"
fi
LAST_LINE="$(printf '%s\n' "$HELP_OUT" | tail -1)"
if [ "$LAST_LINE" = "# someone's debugging session, not something to silently throw away." ]; then
ok "--help ends on the last comment line, not past it"
else
bad "--help ends on the last comment line, not past it (got: $LAST_LINE)"
fi
# ---------------------------------------------------------------------------
# Regression: the clone stall guard reaches git clone's environment.
# ---------------------------------------------------------------------------
REALGIT="$(command -v git)"
FAKEBIN="$WORK/fakebin"
mkdir -p "$FAKEBIN"
CAPTURE="$WORK/clone-env.txt"
cat > "$FAKEBIN/git" <<FAKEGIT
#!/bin/sh
if [ "\$1" = "clone" ]; then
printf '%s %s\n' "\$GIT_HTTP_LOW_SPEED_LIMIT" "\$GIT_HTTP_LOW_SPEED_TIME" >> "$CAPTURE"
fi
exec "$REALGIT" "\$@"
FAKEGIT
chmod +x "$FAKEBIN/git"
ORIGIN_GUARD="$WORK/origin-guard"
GUARD_C="$(mkrepo1 "$ORIGIN_GUARD")"
MANIFEST_GUARD="$WORK/manifest-guard"
printf 'widget\t%s\t%s\tguard test tree\n' "$ORIGIN_GUARD" "$GUARD_C" > "$MANIFEST_GUARD"
# fetch-vendor.sh finds its manifest next to itself, so give it a private
# fixture dir carrying a copy of the real script alongside our manifest.
GUARD_FIXTURE="$WORK/guard-fixture"
mkdir -p "$GUARD_FIXTURE"
cp "$SCRIPT" "$GUARD_FIXTURE/fetch-vendor.sh"
cp "$MANIFEST_GUARD" "$GUARD_FIXTURE/vendor.manifest"
: > "$CAPTURE"
PATH="$FAKEBIN:$PATH" bash "$GUARD_FIXTURE/fetch-vendor.sh" --fetch "$WORK/vendor-default" \
> "$WORK/guard-default.log" 2>&1
if [ "$(cat "$CAPTURE")" = "1000 60" ]; then
ok "clone runs with the default low-speed guard (1000 bytes/sec, 60s)"
else
bad "clone runs with the default low-speed guard (got: $(cat "$CAPTURE" 2>/dev/null))"
fi
: > "$CAPTURE"
PATH="$FAKEBIN:$PATH" WARDEN_VENDOR_LOW_SPEED_LIMIT=5 WARDEN_VENDOR_LOW_SPEED_TIME=9 \
bash "$GUARD_FIXTURE/fetch-vendor.sh" --fetch "$WORK/vendor-override" \
> "$WORK/guard-override.log" 2>&1
if [ "$(cat "$CAPTURE")" = "5 9" ]; then
ok "the low-speed guard is overridable"
else
bad "the low-speed guard is overridable (got: $(cat "$CAPTURE" 2>/dev/null))"
fi
# ---------------------------------------------------------------------------
# Coverage: the --check/--fetch state machine and the name-to-path mapping.
# ---------------------------------------------------------------------------
ORIGIN_WIDGET="$WORK/origin-widget"
read -r WIDGET_C1 WIDGET_C2 <<< "$(mkrepo2 "$ORIGIN_WIDGET")"
ORIGIN_LVGL="$WORK/origin-lvgl"
LVGL_C="$(mkrepo1 "$ORIGIN_LVGL")"
ORIGIN_SDK="$WORK/origin-sdk"
SDK_C="$(mkrepo1 "$ORIGIN_SDK")"
MANIFEST="$WORK/vendor.manifest"
write_manifest() { # write_manifest <widget-commit>
{
printf 'widget\t%s\t%s\tgeneric tree, default path mapping\n' "$ORIGIN_WIDGET" "$1"
printf 'lvgl\t%s\t%s\tlvgl name maps under ui/lvgl\n' "$ORIGIN_LVGL" "$LVGL_C"
printf 'luckfox-pico\t%s\t%s\tluckfox-pico name maps under sdk\n' "$ORIGIN_SDK" "$SDK_C"
} > "$MANIFEST"
}
STATE_FIXTURE="$WORK/state-fixture"
mkdir -p "$STATE_FIXTURE"
cp "$SCRIPT" "$STATE_FIXTURE/fetch-vendor.sh"
VENDOR_DIR="$WORK/vendor"
write_manifest "$WIDGET_C1"
cp "$MANIFEST" "$STATE_FIXTURE/vendor.manifest"
# Case 1: nothing cloned yet -> --check reports MISSING for all three, rc=1.
OUT="$(bash "$STATE_FIXTURE/fetch-vendor.sh" --check "$VENDOR_DIR")"; RC=$?
if [ "$RC" -ne 0 ] \
&& printf '%s\n' "$OUT" | grep -q '^MISSING widget' \
&& printf '%s\n' "$OUT" | grep -q '^MISSING lvgl' \
&& printf '%s\n' "$OUT" | grep -q '^MISSING luckfox-pico'; then
ok "--check reports MISSING and rc=1 when nothing is cloned"
else
bad "--check reports MISSING and rc=1 when nothing is cloned (rc=$RC)"
fi
# Case 2: --fetch clones each tree under its mapped path and checks out the pin.
OUT="$(bash "$STATE_FIXTURE/fetch-vendor.sh" --fetch "$VENDOR_DIR")"; RC=$?
if [ "$RC" -eq 0 ] \
&& [ -e "$VENDOR_DIR/widget/.git" ] \
&& [ -e "$VENDOR_DIR/ui/lvgl/.git" ] \
&& [ -e "$VENDOR_DIR/sdk/.git" ]; then
ok "--fetch clones lvgl under ui/lvgl and luckfox-pico under sdk"
else
bad "--fetch clones lvgl under ui/lvgl and luckfox-pico under sdk (rc=$RC)"
fi
if [ "$(git -C "$VENDOR_DIR/widget" rev-parse HEAD 2>/dev/null)" = "$WIDGET_C1" ]; then
ok "--fetch checks out the manifest-pinned commit"
else
bad "--fetch checks out the manifest-pinned commit"
fi
# Case 3: a checkout sitting at its pin --check's clean, rc=0.
OUT="$(bash "$STATE_FIXTURE/fetch-vendor.sh" --check "$VENDOR_DIR")"; RC=$?
if [ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q "^OK widget ${WIDGET_C1:0:12}$"; then
ok "--check reports OK with no suffix for a clean checkout at the pin"
else
bad "--check reports OK with no suffix for a clean checkout at the pin"
fi
# Case 4: manifest moves to a commit the checkout is not on -> DRIFTED,
# rc=1, and the checkout itself is left untouched (never reset).
write_manifest "$WIDGET_C2"
cp "$MANIFEST" "$STATE_FIXTURE/vendor.manifest"
OUT="$(bash "$STATE_FIXTURE/fetch-vendor.sh" --check "$VENDOR_DIR")"; RC=$?
if [ "$RC" -ne 0 ] \
&& printf '%s\n' "$OUT" | grep -q "^DRIFTED widget want ${WIDGET_C2:0:12} have ${WIDGET_C1:0:12}"; then
ok "--check reports DRIFTED when HEAD does not match the pin"
else
bad "--check reports DRIFTED when HEAD does not match the pin"
fi
if [ "$(git -C "$VENDOR_DIR/widget" rev-parse HEAD 2>/dev/null)" = "$WIDGET_C1" ]; then
ok "a drifted checkout is reported, never reset"
else
bad "a drifted checkout is reported, never reset"
fi
# Case 5: back at the pin but with an uncommitted local change -> OK, but
# annotated, and still rc=0 (a dirty vendor tree is expected, not a failure).
write_manifest "$WIDGET_C1"
cp "$MANIFEST" "$STATE_FIXTURE/vendor.manifest"
echo "local debugging change" >> "$VENDOR_DIR/widget/file.txt"
OUT="$(bash "$STATE_FIXTURE/fetch-vendor.sh" --check "$VENDOR_DIR")"; RC=$?
if [ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q "^OK widget ${WIDGET_C1:0:12} (locally modified)$"; then
ok "--check reports OK (locally modified) for a dirty checkout at the pin, rc=0"
else
bad "--check reports OK (locally modified) for a dirty checkout at the pin, rc=0"
fi
# ---------------------------------------------------------------------------
# Case 6: git clone fails (bad origin) -> reported, rc=1, no directory left
# behind for that tree, and -- the actual regression this guards -- the loop
# still reaches the remaining manifest entries and reports exactly one
# failure line for widget, not a second "FAILED to check out" once the
# clone's own continue has fired.
# ---------------------------------------------------------------------------
ORIGIN_BAD="$WORK/no-such-origin"
CLONEFAIL_FIXTURE="$WORK/clonefail-fixture"
mkdir -p "$CLONEFAIL_FIXTURE"
cp "$SCRIPT" "$CLONEFAIL_FIXTURE/fetch-vendor.sh"
{
printf 'widget\t%s\t%s\tbad origin, clone must fail\n' "$ORIGIN_BAD" "$WIDGET_C1"
printf 'lvgl\t%s\t%s\tlvgl name maps under ui/lvgl\n' "$ORIGIN_LVGL" "$LVGL_C"
printf 'luckfox-pico\t%s\t%s\tluckfox-pico name maps under sdk\n' "$ORIGIN_SDK" "$SDK_C"
} > "$CLONEFAIL_FIXTURE/vendor.manifest"
CLONEFAIL_DIR="$WORK/vendor-clonefail"
OUT="$(bash "$CLONEFAIL_FIXTURE/fetch-vendor.sh" --fetch "$CLONEFAIL_DIR" 2>&1)"; RC=$?
FAILED_COUNT="$(printf '%s\n' "$OUT" | grep -c 'FAILED to clone widget')"
if [ "$RC" -ne 0 ] && [ "$FAILED_COUNT" -eq 1 ] \
&& ! printf '%s\n' "$OUT" | grep -q 'FAILED to check out'; then
ok "--fetch reports FAILED to clone once and rc=1 for a bad origin"
else
bad "--fetch reports FAILED to clone once and rc=1 for a bad origin (rc=$RC, count=$FAILED_COUNT)"
fi
if [ ! -e "$CLONEFAIL_DIR/widget/.git" ]; then
ok "a failed clone leaves no checkout behind for that tree"
else
bad "a failed clone leaves no checkout behind for that tree"
fi
if [ -e "$CLONEFAIL_DIR/ui/lvgl/.git" ] && [ -e "$CLONEFAIL_DIR/sdk/.git" ]; then
ok "a clone failure on one tree does not stop the remaining trees from being fetched"
else
bad "a clone failure on one tree does not stop the remaining trees from being fetched"
fi
# ---------------------------------------------------------------------------
# Case 7: git checkout fails (pinned commit missing from the origin) ->
# reported, rc=1, the remaining trees still get fetched, and the checkout is
# left wherever the failed checkout left it -- a later --check must report
# that as DRIFTED, never mistake it for success.
# ---------------------------------------------------------------------------
BOGUS_COMMIT="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
CHECKOUTFAIL_FIXTURE="$WORK/checkoutfail-fixture"
mkdir -p "$CHECKOUTFAIL_FIXTURE"
cp "$SCRIPT" "$CHECKOUTFAIL_FIXTURE/fetch-vendor.sh"
{
printf 'widget\t%s\t%s\tcommit missing from the origin, checkout must fail\n' "$ORIGIN_WIDGET" "$BOGUS_COMMIT"
printf 'lvgl\t%s\t%s\tlvgl name maps under ui/lvgl\n' "$ORIGIN_LVGL" "$LVGL_C"
printf 'luckfox-pico\t%s\t%s\tluckfox-pico name maps under sdk\n' "$ORIGIN_SDK" "$SDK_C"
} > "$CHECKOUTFAIL_FIXTURE/vendor.manifest"
CHECKOUTFAIL_DIR="$WORK/vendor-checkoutfail"
OUT="$(bash "$CHECKOUTFAIL_FIXTURE/fetch-vendor.sh" --fetch "$CHECKOUTFAIL_DIR" 2>&1)"; RC=$?
if [ "$RC" -ne 0 ] && printf '%s\n' "$OUT" | grep -q "FAILED to check out $BOGUS_COMMIT"; then
ok "--fetch reports FAILED to check out for a commit missing from the origin"
else
bad "--fetch reports FAILED to check out for a commit missing from the origin (rc=$RC)"
fi
# The regression this guards: if the checkout failure's own "continue" were
# ever dropped, the same loop iteration falls through into the have-vs-pin
# comparison below and prints a bogus DRIFTED/OK line for widget in this same
# --fetch run, on top of the FAILED line above.
if ! printf '%s\n' "$OUT" | grep -qE '^(DRIFTED {2}|OK {7})widget'; then
ok "a checkout failure does not fall through to a DRIFTED/OK line in the same run"
else
bad "a checkout failure does not fall through to a DRIFTED/OK line in the same run"
fi
if [ -e "$CHECKOUTFAIL_DIR/widget/.git" ] \
&& [ -e "$CHECKOUTFAIL_DIR/ui/lvgl/.git" ] && [ -e "$CHECKOUTFAIL_DIR/sdk/.git" ]; then
ok "a checkout failure on one tree does not stop the remaining trees from being fetched"
else
bad "a checkout failure on one tree does not stop the remaining trees from being fetched"
fi
OUT="$(bash "$CHECKOUTFAIL_FIXTURE/fetch-vendor.sh" --check "$CHECKOUTFAIL_DIR" 2>&1)"; RC=$?
if [ "$RC" -ne 0 ] && printf '%s\n' "$OUT" | grep -q "^DRIFTED widget want ${BOGUS_COMMIT:0:12}"; then
ok "a later --check reports the failed checkout as DRIFTED, never as success"
else
bad "a later --check reports the failed checkout as DRIFTED, never as success"
fi
[ "$FAIL" -eq 0 ] && echo "All fetch-vendor tests passed." || echo "Some fetch-vendor tests failed."
exit "$FAIL"
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
# Regression tests for the post-build boot.img validation in
# build/mk-bootimg.sh: the FIT metadata %512 check, the per-image
# data-position %512 check (and fdtget's own failure path, issue #22 --
# a missing or erroring fdtget used to be swallowed and treated as nothing
# to check), and the >=4096 embedded-data-FIT check.
#
# Runs the real script end to end with stub mkimage/resource_tool/fdtget so
# the arithmetic is exercised as it actually ships, not copied out and
# re-tested in isolation. The stub mkimage answers the -B capability probe
# and, for the real build invocation, writes a synthetic boot.img whose
# 4-byte big-endian metadata-size word and total length are test-controlled --
# the same field mk-bootimg.sh reads with `od -An -tu4 -j4 -N4 --endian=big`.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$HERE/../../build/mk-bootimg.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
STUBS="$TMP/stubs"
mkdir -p "$STUBS"
cat > "$STUBS/mkimage" <<'EOF'
#!/bin/bash
if [ "$#" -eq 0 ]; then
echo "Usage: mkimage [-T type] -l image" >&2
echo " -B => align size in hex for FIT structure and header" >&2
exit 1
fi
out=""
for a in "$@"; do out="$a"; done
python3 -c '
import sys
out, meta, total = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
total = max(total, 8)
data = bytearray(total)
data[4:8] = (meta & 0xffffffff).to_bytes(4, "big")
open(out, "wb").write(bytes(data))
' "$out" "${MKBI_META:-512}" "${MKBI_TOTAL:-8192}"
EOF
chmod +x "$STUBS/mkimage"
cat > "$STUBS/resource_tool" <<'EOF'
#!/bin/bash
img=""
for a in "$@"; do
case "$a" in --image=*) img="${a#--image=}" ;; esac
done
echo stub > "$img"
EOF
chmod +x "$STUBS/resource_tool"
cat > "$STUBS/fdtget" <<'EOF'
#!/bin/bash
# Invoked as: fdtget -t u FILE /images/NAME data-position
node="$4"
name="${node#/images/}"
var="FDTGET_$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]')_POS"
val="${!var-}"
if [ -z "$val" ] || [ "$val" = "FAIL" ]; then
echo "fdtget: FDT_ERR_NOTFOUND, $node data-position" >&2
exit 1
fi
echo "$val"
EOF
chmod +x "$STUBS/fdtget"
KERNEL="$TMP/zImage"; DTB="$TMP/rv1106-warden.dtb"
head -c 4096 /dev/urandom > "$KERNEL"
head -c 512 /dev/urandom > "$DTB"
fails=0
run_case() { # run_case <name> <want_rc:ok|fail> <want_grep>
local name="$1" want_rc="$2" want_grep="$3" out rc
out="$(PATH="$STUBS:$PATH" "$SCRIPT" --kernel "$KERNEL" --dtb "$DTB" \
--resource-tool "$STUBS/resource_tool" --out "$TMP/boot.img" 2>&1)"
rc=$?
if [ "$want_rc" = ok ] && [ "$rc" -ne 0 ]; then
echo "FAIL: $name (expected success, got rc=$rc: $out)"; fails=$((fails + 1)); return
fi
if [ "$want_rc" = fail ] && [ "$rc" -eq 0 ]; then
echo "FAIL: $name (expected failure, script exited 0: $out)"; fails=$((fails + 1)); return
fi
if ! printf '%s' "$out" | grep -qF -- "$want_grep"; then
echo "FAIL: $name (output missing '$want_grep'): $out"; fails=$((fails + 1)); return
fi
echo "PASS: $name"
}
# A well-formed image: metadata 512-aligned and small, every data-position
# 512-aligned. The baseline every failure case below is a single change from.
MKBI_META=512 MKBI_TOTAL=8192 \
FDTGET_FDT_POS=512 FDTGET_KERNEL_POS=1024 FDTGET_RESOURCE_POS=1536 \
run_case "well-formed image accepted" ok "FIT metadata 512 bytes"
# Metadata size itself not a multiple of 512 (the exact value measured from
# the SDK's vendored mkimage 2017.09, see the comment above the probe).
MKBI_META=1064 MKBI_TOTAL=8192 \
FDTGET_FDT_POS=512 FDTGET_KERNEL_POS=1024 FDTGET_RESOURCE_POS=1536 \
run_case "unaligned metadata size rejected" fail "not a multiple of 512"
# One sub-image's data-position not a multiple of 512; the FATAL must name it.
MKBI_META=512 MKBI_TOTAL=8192 \
FDTGET_FDT_POS=512 FDTGET_KERNEL_POS=148 FDTGET_RESOURCE_POS=1536 \
run_case "unaligned data-position rejected" fail "/images/kernel data-position 148 is not 512-aligned"
# fdtget itself fails (not on PATH, or the FIT it just built is malformed).
# Regression for issue #22: this used to be swallowed by `|| true` and
# treated as "nothing to check" instead of a build failure.
MKBI_META=512 MKBI_TOTAL=8192 \
FDTGET_FDT_POS=512 FDTGET_KERNEL_POS=FAIL FDTGET_RESOURCE_POS=1536 \
run_case "fdtget failure fails the build, not skips the check" fail \
"fdtget could not read /images/kernel data-position"
# Metadata swelled to >=4096 bytes: an embedded-data FIT, which this U-Boot
# rejects outright. 4096 is itself a multiple of 512 so this must be caught
# by the second check, not mistaken for the first.
MKBI_META=4096 MKBI_TOTAL=8192 \
FDTGET_FDT_POS=512 FDTGET_KERNEL_POS=1024 FDTGET_RESOURCE_POS=1536 \
run_case "embedded-data FIT rejected" fail "embedded-data FIT"
[ "$fails" -eq 0 ] && echo "All mk-bootimg boot.img validation tests passed." || echo "$fails test(s) failed."
exit "$fails"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Regression test: every offline regression-test script this job ships must
# actually be invoked by the qemu-tools CI job, not just committed.
#
# run-probe-tests.sh (guards issue #17) sat in the tree unwired into
# .github/workflows/ci.yml: it passed by hand but ran nowhere in CI, so a
# regression in the mkimage probe would only have surfaced on the next
# workflow_dispatch kernel build, not on every push/PR. The same gap later
# reopened for six more scripts written the same way, so this now checks
# every one of them (including itself) instead of only the first: it
# isolates the qemu-tools job and checks each script's basename appears in
# its steps.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
CI_YML="$HERE/../../.github/workflows/ci.yml"
[ -f "$CI_YML" ] || { echo "FAIL: no workflow file at $CI_YML"; exit 1; }
# Isolate the qemu-tools job: from its own header line up to (but not
# including) the next job at the same two-space indent.
job="$(awk '
/^ qemu-tools:/ { inside = 1; print; next }
inside && /^ [A-Za-z0-9_-]+:/ { exit }
inside { print }
' "$CI_YML")"
[ -n "$job" ] || { echo "FAIL: qemu-tools job not found in $CI_YML"; exit 1; }
# Every offline regression-test script the qemu-tools job owns. Add new
# scripts here when they are written, not only when someone remembers to
# wire them in -- that is the failure this test exists to catch.
scripts=(
"tests/mk-bootimg/run-probe-tests.sh"
"qemu/tests/run-sh-args-test.sh"
"qemu/tests/seed-dir.sh"
"qemu/tests/stage-rootfs-perms.sh"
"tests/fetch-vendor/run-fetch-vendor-tests.sh"
"tests/fetch-buildroot-tarball/run-fetch-buildroot-tarball-tests.sh"
"tests/mk-bootimg/run-boot-img-validate-tests.sh"
"tests/mk-bootimg/run-help-tests.sh"
"tests/mk-bootimg/run-ci-wiring-tests.sh"
)
fail=0
for s in "${scripts[@]}"; do
base="$(basename "$s")"
if echo "$job" | grep -q -- "$base"; then
echo "PASS: qemu-tools job invokes $s"
else
echo "FAIL: qemu-tools job never runs $s"
fail=1
fi
done
exit "$fail"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Regression test: --help must not spill into build/mk-bootimg.sh's own code.
#
# build/fetch-vendor.sh's --help sliced its own source with a hardcoded line
# range that stopped one line too late, printing "set -uo pipefail" -- the
# first line of code -- as the last line of help text. mk-bootimg.sh's
# --help used the same hardcoded-range shape (currently pointed at the right
# span), so the same slip was one header-comment edit away here too. It now
# uses the same self-terminating awk pattern fetch-vendor.sh was fixed to
# use, so this pins both that it stays in sync as the header grows or
# shrinks and that it matches the header verbatim today.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$HERE/../../build/mk-bootimg.sh"
[ -f "$SCRIPT" ] || { echo "FAIL: script not found at $SCRIPT"; exit 1; }
fail=0
HELP_OUT="$(bash "$SCRIPT" --help)"
if printf '%s\n' "$HELP_OUT" | grep -q 'set -euo pipefail'; then
echo "FAIL: --help prints the script's own code"
fail=1
else
echo "PASS: --help does not print the script's own code"
fi
LAST_LINE="$(printf '%s\n' "$HELP_OUT" | tail -1)"
if [ "$LAST_LINE" = "# [--resource-tool PATH]" ]; then
echo "PASS: --help ends on the last header comment line, not past it"
else
echo "FAIL: --help ends on the last header comment line, not past it (got: $LAST_LINE)"
fail=1
fi
FIRST_LINE="$(printf '%s\n' "$HELP_OUT" | head -1)"
if [ "$FIRST_LINE" = "# Package a bootable boot.img from a kernel this SDK built." ]; then
echo "PASS: --help starts after the shebang, not on it"
else
echo "FAIL: --help starts after the shebang, not on it (got: $FIRST_LINE)"
fail=1
fi
exit "$fail"