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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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. |
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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:"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+91
-30
@@ -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.
|
||||||
imgtools = None
|
# Deferred to need_imgtools() at first actual use instead; a missing or
|
||||||
|
# broken imgtools still must not take down the verbs that don't need it.
|
||||||
|
imgtools = None
|
||||||
|
_imgtools_import_error = None
|
||||||
|
|
||||||
# Pillow, likewise, is only needed to hand tesseract an image file (it has no
|
|
||||||
# raw-RGB stdin mode); a host without it still runs every other verb.
|
def _load_imgtools():
|
||||||
try:
|
global imgtools, _imgtools_import_error
|
||||||
from PIL import Image as _PILImage
|
if imgtools is None and _imgtools_import_error is None:
|
||||||
except ImportError:
|
try:
|
||||||
_PILImage = None
|
import imgtools # noqa: F811 -- binds the module-level name above
|
||||||
|
except ImportError as e:
|
||||||
|
_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
|
||||||
|
|||||||
Executable
+149
@@ -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
|
||||||
Executable
+190
@@ -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 ]
|
||||||
Executable
+63
@@ -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 ]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
Executable
+304
@@ -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
@@ -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"
|
||||||
Executable
+56
@@ -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"
|
||||||
Executable
+45
@@ -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"
|
||||||
Reference in New Issue
Block a user