bin/verify.sh is retired; four of its forty checks move to qa/replica

verify.sh described itself as "simulate every check the printer performs,
against a built package". The word carrying that sentence is SIMULATE, and it
is why thirty-six of the forty are deleted rather than moved.

THE REPLICA LANE DOES NOT SIMULATE THE INSTALL. install-package.sh puts the
package on a genuine FAT filesystem at /dev/sda1 and runs the machine's own
/usr/prog/app_startup.sh over it, verbatim, under qemu. So these are
PRECONDITIONS of the lane now rather than assertions in it -- break any and
installed_image() raises with the install log and the whole lane goes red:

  * the decrypt with the firmware key (the printer's unTar does it)
  * runFirmwareExe.sh present, components plain tar not xz (bare `tar -xvf`)
  * md5sum.list verifying (the installer aborts and deletes the payload)
  * the MACHINE= gate (runFirmwareExe.sh refuses a foreign package)
  * the /mnt/<Model>-*.tgz glob (app_startup.sh finds the file by it)

A host-side re-reading of those rules is a second implementation, and a second
implementation can agree with itself while disagreeing with the printer. That
is the defect qa/replica/conftest.py records this repo learning twice about
hand-placed payloads.

A second block was asking what the suite already answers, of an installed
filesystem rather than of a tar listing: the klippy tree and c_helper.so
(test_install, test_abi), the s6-rc database and the oneshot runner's execline
(test_s6rc), s6 and s6-ftrigrd (test_supervisor), the .install-manifest
(test_upgrade), libsodium's bare .so symlink and the chamber configs
(static/test_ipk), and every shipped script's syntax
(static/test_shell_syntax).

THE FOUR THAT MOVED are the ones nothing else asks, and each is stronger for
the move -- a `tar -t` listing cannot tell you that a shared object loads, or
what a name resolves to on a PATH:

  * the interpreter IMPORTS sqlite3, lmdb and _cffi_backend, rather than a
    grep finding three .so files. This is what catches a cross build that
    resolved to an x86_64 manylinux wheel: present on disk, raises on import.
  * nothing in $MODDIR/bin shadows FlashForge's python3 -- asked with
    `command -v` after sourcing anvil-env.sh, which is the file that PREPENDS
    $MODDIR/bin to PATH and so creates the hazard in the first place.
  * CPython's dev half (headers, pkgconfig, config-3.13-*) did not ship.
  * the ship boundary: no file on the installed printer is byte-identical to
    one under bin/ or docker/. Asked of the machine rather than of the .tgz,
    so it also sees whatever a maintainer script wrote as root at install
    time. The sweep filters on exact size before hashing, which is what keeps
    a walk of /usr/prog through qemu cheap.

WHAT GENUINELY LEFT, recorded in docs/qa-migration.md rather than only here:
there is no longer a check you can run on the build host, in seconds and
without docker, against a package you are about to flash. Nothing warns you
before you copy the file to a stick that it was built from the wrong model's
stock package. The printer still refuses it and `make qa-replica` still
catches it, but the fast host-side warning the hardware checklist used is
gone. `make verify` is removed and `make release` no longer calls it.

VERIFIED. All four ran against a real replica (the cached installed image,
PRINTER_IMAGE=monstrofil/creator5-printer:latest): 4 passed in 17s. Then each
was DRIVEN RED on purpose with a throwaway module, which is this repo's own
standard from 4ce928f -- bin/common.sh copied byte-for-byte into $MODDIR, a
python3 symlink in $MODDIR/bin, an include/python3.13 directory, and a module
name that does not exist. Each was caught, each named the planted file in its
message, and each went green again once it was removed. The leak sweep walks
21132 files under $MODDIR and /usr/prog, so its floor of 100 is not load
bearing. The static lane goes 123 -> 122, which is exactly the `bash -n` test
that test_shell_syntax.py generated per bin/*.sh file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAQKdBfZxbnPagQyidFk6Y
This commit is contained in:
Oleksandr ShyshatskyiandClaude Opus 5 committed 2026-08-30 14:19:15 +03:00
1 parent 6dc89469ac
commit fbb3182e68
8 files changed
+471 -357

No files matched your search

+5 -5
View File
@@ -83,7 +83,7 @@ RUNBLDTTY = $(subst --rm -i,--rm -it,$(RUN))
.DEFAULT_GOAL := help
.PHONY: help image shell passwd build vendor packages \
rootfs verify \
rootfs \
printer-image printer-image-push \
boot-screen boot-screen-sim \
qa qa-static qa-replica \
@@ -207,20 +207,20 @@ packages: image config.env
# One package per model, collected in dist/. They cannot share content: the
# two stock packages ship different firmwareExe binaries.
#
# It checks nothing itself. bin/verify.sh used to run here and was a host-side
# re-reading of what the printer does at install time; `make qa-replica` makes
# the printer actually do it, so THAT is the gate before a release goes out.
release: image config.env
@rm -rf dist && mkdir -p dist
@for m in Creator5Pro Creator5; do \
echo "=== $$m ==="; \
MODEL=$$m $(RUN) ./bin/build.sh $(PACKARGS) || exit 1; \
MODEL=$$m $(RUN) ./bin/verify.sh || exit 1; \
cp work/out/$$m-*.tgz dist/ || exit 1; \
done
@echo; echo "dist/:"; ls -lh dist | awk 'NR>1{print " "$$9" "$$5}'
@echo; echo "Each file installs ONLY on the model in its name."
verify: image
@$(RUN) ./bin/verify.sh
# ===========================================================================
# TEST LANE -- never ships. Reads test.env for the replica settings; the only
# targets allowed to reach the docker daemon ($(RUNSIM)).
-332
View File
@@ -1,332 +0,0 @@
#!/usr/bin/env bash
# Simulate every check the printer performs, against a built package.
# ./bin/verify.sh [path/to/pkg.tgz] default: work/out/<model>-*.tgz
set -uo pipefail
. "$(dirname "$0")/common.sh"
# The selected model's package, or a two-model release verifies one file twice.
PKG="${1:-$(ls -1 "work/out/${TARGET_MACHINE:-Creator5Pro}"-*.tgz 2>/dev/null | head -n1)}"
[ -f "${PKG:-}" ] || { echo "no package; run ./bin/pack.sh" >&2; exit 1; }
echo "verifying $PKG"; echo
FAIL=0
ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=1; }
warn() { printf ' \033[33mWARN\033[0m %s\n' "$*"; }
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
# --- 1. decrypt
if openssl des3 -d -k "$FF_KEY" -salt -md md5 -in "$PKG" 2>/dev/null \
| tar -xf - -C "$WORK" 2>/dev/null; then
ok "decrypts with the firmware key and is a valid tar"
else
bad "decrypt/untar failed -- the printer's unTar will reject this"; exit 1
fi
# --- 2. installer present
if [ -f "$WORK/runFirmwareExe.sh" ]; then
ok "runFirmwareExe.sh present"
else
bad "no runFirmwareExe.sh -- app_startup.sh falls back to plain tar and gives up"
fi
# --- 3. components are PLAIN tar, not xz
for f in "$WORK"/software-*.tar.xz "$WORK"/kernel-*.tar.xz "$WORK"/control-*.tar.xz "$WORK"/library-*.tar.xz; do
[ -f "$f" ] || continue
b=$(basename "$f")
if tar -tf "$f" >/dev/null 2>&1; then
ok "$b is a plain tar (stock installer runs bare 'tar -xvf')"
else
bad "$b is not a plain tar -- stock 'tar -xvf' cannot read it"
fi
done
# --- 4. md5sum gate
SOFTWARE_TAR=$(ls -1 "$WORK"/software-*.tar.xz 2>/dev/null | head -n1)
if [ -n "$SOFTWARE_TAR" ]; then
mkdir -p "$WORK/sw" && tar -xf "$SOFTWARE_TAR" -C "$WORK/sw"
if [ -f "$WORK/sw/md5sum.list" ]; then
# busybox md5sum spells quiet -s; GNU coreutils spells it --status.
if ( cd "$WORK/sw" && md5sum --status -c md5sum.list 2>/dev/null ) \
|| ( cd "$WORK/sw" && md5sum -s -c md5sum.list 2>/dev/null ); then
ok "md5sum.list verifies ($(wc -l < "$WORK/sw/md5sum.list") entries)"
else
bad "md5sum.list does NOT verify -- installer aborts and deletes the payload"
( cd "$WORK/sw" && md5sum -c md5sum.list 2>&1 | grep -v ': OK$' | head -5 | sed 's/^/ /' )
fi
grep -q 'md5sum.list' "$WORK/sw/md5sum.list" && bad "md5sum.list lists itself (can never match)"
else
bad "no md5sum.list in the software component"
fi
fi
# --- 5. shell syntax of scripts
# start.sh and firmwareExe are the payload's now, not the component's -- section
# 7 extracts and checks them there.
for s in "$WORK/runFirmwareExe.sh" "$WORK/sw/run.sh" "$WORK/sw/app_startup.sh"; do
[ -f "$s" ] || continue
if sh -n "$s" 2>/dev/null; then ok "syntax OK: $(basename "$s")"
else bad "SYNTAX ERROR in $(basename "$s")"; sh -n "$s" 2>&1 | head -3 | sed 's/^/ /'; fi
done
# --- 6. boot chain actually hooks up
if [ -f "$WORK/sw/app_startup.sh" ]; then
grep -q 'gt9xx_touch.ko' "$WORK/sw/app_startup.sh" \
&& ok "touchscreen driver still loaded at boot" \
|| bad "gt9xx_touch.ko insmod missing -- no touchscreen"
grep -q 'firmwareExe' "$WORK/sw/app_startup.sh" \
&& ok "a UI is launched at boot" \
|| bad "nothing launches a UI -- printer boots to a blank screen"
fi
# --- 7. the payload's klippy tree
# The klippy tree is in the PAYLOAD now, not the software component: the
# klipper service execs $MODDIR/klipper/klippy, so the component copy and the
# chelper.tar that carried its .so are both gone. Unconditional, because
# v20260824 passed here by having every check below conditional on files the
# broken build did not contain. ./klipper alone, not the whole payload --
# section 8 answers the rest from a listing. ./prog rides along because the two
# stock-path scripts moved there and still need a syntax gate. Paths carry a
# leading ./ because bin/pack.sh tars `.` from inside $PAYLOAD_DIR.
PAYLOAD_TAR=$(ls -1 "$WORK"/anvil.tar.xz 2>/dev/null | head -n1)
if [ -n "$PAYLOAD_TAR" ]; then
mkdir -p "$WORK/pl"
xz -dc "$PAYLOAD_TAR" 2>/dev/null | tar -xf - -C "$WORK/pl" ./klipper ./prog ./etc 2>/dev/null \
|| tar -xf "$PAYLOAD_TAR" -C "$WORK/pl" ./klipper ./prog ./etc 2>/dev/null
fi
for s in "$WORK/pl/prog/start.sh" "$WORK/pl/prog/firmwareExe"; do
[ -f "$s" ] || { bad "the payload carries no prog/$(basename "$s") -- link-prog has nothing to point the stock path at"; continue; }
if sh -n "$s" 2>/dev/null; then ok "syntax OK: prog/$(basename "$s")"
else bad "SYNTAX ERROR in prog/$(basename "$s")"; sh -n "$s" 2>&1 | head -3 | sed 's/^/ /'; fi
done
if [ -f "$WORK/pl/klipper/klippy/chelper/__init__.py" ]; then
ok "payload carries the fork klippy tree"
else
bad "no klippy tree in the payload -- anvil-klipper did not install; this is the stock-overlay build that shipped as v20260824"
fi
CHELPER="$WORK/pl/klipper/klippy/chelper/c_helper.so"
[ -f "$CHELPER" ] || bad "no c_helper.so in the payload klippy tree -- klippy cannot talk to an MCU"
# PRESENCE IS ALL THIS FILE ASKS OF IT. The ABI question -- o32/nan2008/
# mips32r2, which the kernel answers with ENOEXEC -- is asked once, of every ELF
# on the installed filesystem, in qa/replica/test_abi.py; a fourth partial copy
# of that rule lived here and read only this one object out of a payload full of
# them. The symbol check that sat beside it went with test/: every function
# klippy cdefs against the .so's dynamic symbols, because cffi resolves lazily
# and a stale .so imports cleanly. What keeps that out now is pkgs/klipper
# compiling the .so from the chelper sources of the very tree it ships, so a .so
# older than its klippy is not something a build can produce.
# The component must not carry one: two klippy trees under one version number
# is what this release removed, and a stray one is the copy a stock flash
# leaves running.
[ -e "$WORK/sw/klipper/klippy" ] \
&& bad "the software component still carries a klippy tree -- bin/patch.sh section 1 is staging one again" \
|| ok "no klippy tree in the software component (the payload is the only one)"
# Same rule for the two stock-path scripts: anvil-core owns them at
# $MODDIR/prog/ and link-prog symlinks them into place. A component copy of the
# wrapper is worse than none -- with no payload it never starts a UI, where an
# absent one lets app_startup.sh restore the stock binary.
for f in firmwareExe start.sh; do
[ -e "$WORK/sw/$f" ] \
&& bad "the software component still carries $f -- bin/patch.sh section 8 is staging one again" \
|| ok "no $f in the software component (anvil-core's \$MODDIR/prog is the only one)"
done
# The oneshot runner's #!, which is the one thing about the compiled database a
# host can check: s6-rc-compile bakes in the execline of the stack it was linked
# against, so this line is what every oneshot on the printer will exec. It used
# to be asserted in bin/patch.sh, against a native compiler that could be built
# with the wrong --prefix. The database is compiled in the replica by the
# s6-rc-compile we ship now, so this should be true by construction -- which is
# exactly why it is cheap to keep, and it is the assertion that would catch the
# construction being broken.
S6RC_RUNNER=$(ls -d "$WORK/pl/etc/s6-rc/compiled"/db-*/servicedirs/s6rc-oneshot-runner/run 2>/dev/null | head -n1)
if [ -z "$S6RC_RUNNER" ]; then
bad "the payload's s6-rc database has no oneshot runner -- has the source tree no oneshots?"
else
S6RC_SHEBANG=$(head -1 "$S6RC_RUNNER")
case "$S6RC_SHEBANG" in
"#!$MODDIR/bin/execlineb"*)
ok "the s6-rc database execs $MODDIR/bin/execlineb (the execline we ship)" ;;
*)
bad "the s6-rc database asks for '$S6RC_SHEBANG', not $MODDIR/bin/execlineb -- every oneshot would ENOENT" ;;
esac
fi
# `current` must resolve, or s6-rc-init has nothing to read at boot.
if [ -d "$WORK/pl/etc/s6-rc/compiled/current" ]; then
ok "compiled/current resolves ($(readlink "$WORK/pl/etc/s6-rc/compiled/current" 2>/dev/null))"
else
bad "compiled/current does not resolve -- s6-rc-init finds no database at boot"
fi
# --- 8. mod payload
if [ -f "$WORK/anvil.tar.xz" ]; then
if xz -t "$WORK/anvil.tar.xz" 2>/dev/null; then ok "anvil.tar.xz is valid xz"
elif tar -tf "$WORK/anvil.tar.xz" >/dev/null 2>&1; then ok "anvil.tar.xz is a plain tar"
else bad "anvil.tar.xz is neither valid xz nor tar"; fi
LIST=$(xz -dc "$WORK/anvil.tar.xz" 2>/dev/null | tar -tf - 2>/dev/null || tar -tf "$WORK/anvil.tar.xz" 2>/dev/null)
# Herestrings, NOT `echo | grep -q`: under pipefail, grep -q exiting at an
# early match SIGPIPEs the echo and the pipeline returns 141.
# The compiled s6-rc database IS the service set.
grep -q 'etc/s6-rc/compiled/' <<<"$LIST" && ok "mod payload has the compiled s6-rc database" \
|| bad "mod payload has no s6-rc database -- no service would ever start"
grep -q 'servicedirs/klipper/run' <<<"$LIST" && ok "mod payload owns Klipper startup" \
|| bad "mod payload has no klipper service -- Klipper would never start"
# printer.base.cfg has a bare [include printer.chamber.cfg] and Klipper
# treats a missing include as fatal. Without the ff-*.cfg, klippy starts
# fine and the machine is silently single-tool.
if [ "${BUILD_TOOLCHANGE:-1}" = "1" ]; then
for _m in Creator5 Creator5Pro; do
grep -q "config/chamber/$_m.cfg" <<<"$LIST" \
&& ok "mod payload carries the $_m chamber config" \
|| bad "mod payload has no config/chamber/$_m.cfg -- that model will not start klippy"
done
grep -q 'config/printer.base.cfg' <<<"$LIST" \
&& ok "mod payload carries printer.base.cfg (every other config hangs off it)" \
|| bad "mod payload has no config/printer.base.cfg -- klippy will not start"
grep -q 'bin/anvil-link-prog.sh' <<<"$LIST" \
&& ok "the linker is in the payload (it resolves chamber/<MACHINE>.cfg on the printer)" \
|| bad "no bin/anvil-link-prog.sh -- nothing links a chamber config into /usr/data/config"
grep -q 'config/ff-toolchange.cfg' <<<"$LIST" \
&& ok "mod payload carries the toolchanger config" \
|| bad "mod payload has no config/ff-*.cfg -- klippy starts as a single-tool printer, silently"
fi
# Without it run-append.sh falls back to the old seven-directory rm -rf,
# forever, and that fallback eats whatever else lives in $MODDIR/bin.
grep -q '\.install-manifest' <<<"$LIST" && ok "install manifest present (next update deletes by list, not by rm -rf)" \
|| bad "mod payload has no .install-manifest -- updates fall back to wiping whole directories"
# s6-ftrigrd is on no PATH -- s6-svlisten spawns it from the compiled-in
# libexecdir -- so bin/ without libexec/ supervises fine until every
# waiting verb (s6-svc -w, s6-svwait) fails.
grep -q 'bin/s6-svscan' <<<"$LIST" && ok "s6 supervision binaries present" \
|| bad "no s6 in the payload -- bin/patch.sh did not stage the cross-build"
grep -q 'libexec/s6-ftrigrd' <<<"$LIST" && ok "s6-ftrigrd present in libexec (the waiting verbs can spawn it)" \
|| bad "no libexec/s6-ftrigrd -- s6-svwait and s6-svc -w will fail on the printer"
# CPython 3.13, which FF_PYTHON points at -- klippy included now, since the
# klipper service execs $FF_PYTHON against $MODDIR/klipper/klippy.
# The second check is the one worth having: the interpreter runs perfectly
# while _sqlite3 is absent, because a dropped -lm makes configure's link
# probe fail and CPython records the module missing rather than stopping.
grep -q 'bin/python3\.13$' <<<"$LIST" \
&& ok "CPython 3.13 present (FF_PYTHON -- Moonraker and the ff-startup scripts run on it)" \
|| bad "no bin/python3.13 in the payload -- bin/patch.sh did not stage the cross-build"
grep -q 'lib-dynload/_sqlite3' <<<"$LIST" \
&& ok "python3.13 carries _sqlite3 (the module FlashForge's 3.8.2 has not got)" \
|| bad "python3.13 ships WITHOUT _sqlite3 -- the one module it exists for; check LIBS/LIBSQLITE3_LIBS in pkgs/3rdparty/python/build.sh"
# No `python3` symlink: anvil-env.sh prepends $MODDIR/bin to PATH, so one
# would silently put our interpreter ahead of FlashForge's for every process
# that says `python3`. Its presence is the bug.
grep -qE '(^|/)bin/python3$' <<<"$LIST" \
&& bad "payload ships bin/python3 -- it would shadow FlashForge's interpreter on PATH" \
|| ok "no bin/python3 symlink (nothing shadows FlashForge's python3 on PATH)"
# lmdb is Moonraker's database at the pinned commit, _cffi_backend is what
# klippy dlopens c_helper.so through -- the reason this interpreter had to
# be glibc, not musl. Both are what a cross build resolving to a manylinux
# wheel gets WRONG rather than missing, and the .so suffix is the
# architecture: cpython-313-mipsel-linux-gnu.so, not x86_64.
grep -q 'site-packages/lmdb/cpython.cpython-313-mipsel-linux-gnu\.so' <<<"$LIST" \
&& ok "lmdb's mipsel CPython extension present (Moonraker's database at this pin)" \
|| bad "no mipsel lmdb extension in site-packages -- Moonraker cannot open its database; check work/.pkg-python-lmdb/wheel-lmdb.log"
grep -q 'site-packages/_cffi_backend.cpython-313-mipsel-linux-gnu\.so' <<<"$LIST" \
&& ok "cffi's mipsel extension present (klippy's route to c_helper.so)" \
|| bad "no mipsel _cffi_backend in site-packages -- klippy could not load chelper on 3.13"
# The dev half must not ship: headers, pkgconfig and config-3.13-* exist so
# the python-* recipes can compile on a BUILD machine. patch.sh prunes them
# via PKG_DEV_FILES, and extra files in a payload ship silently.
if grep -qE '(^|/)(include/python3\.13/|lib/pkgconfig/|config-3\.13-)' <<<"$LIST"; then
bad "payload carries CPython's dev files (headers/pkgconfig/config-3.13-*) -- anvil-python-dev's half was not pruned"
else
ok "no CPython dev files in the payload (headers and build config stay in anvil-python-dev)"
fi
# The bare `libsodium.so` name is what libnacl's path fallback constructs; a
# payload with only the versioned file fails at Moonraker's auth component.
grep -qE 'lib/libsodium\.so\.[0-9]' <<<"$LIST" \
&& ok "libsodium present in lib/ (libnacl's ed25519, no /usr/prog needed)" \
|| bad "no lib/libsodium.so.* in the payload -- bin/patch.sh section 5d did not stage the cross-build"
grep -qE 'lib/libsodium\.so$' <<<"$LIST" \
&& ok "lib/libsodium.so link present (the name libnacl's dlopen fallback builds)" \
|| bad "lib/libsodium.so link missing -- libnacl looks for that exact name and would fall through to /usr/prog"
grep -q 'mainsail/index.html' <<<"$LIST" && ok "Mainsail present" || warn "no Mainsail in payload"
grep -q 'helixscreen/bin/helix-screen' <<<"$LIST" && ok "HelixScreen present" || warn "no HelixScreen in payload"
# We ship no dropbear -- the stock rootfs has one. Its PRESENCE is the bug,
# hence the check reads the way round it does.
grep -q 'bin/dropbear' <<<"$LIST" \
&& warn "dropbear in the payload -- we ship none; stock S50dropbear provides ssh" \
|| ok "no dropbear in the payload (stock S50dropbear provides ssh)"
# Reads the PACKAGE: the init script starts $MODDIR/moonraker/moonraker.py
# from where the payload landed, so the directory without that file ships
# a Moonraker that cannot start.
grep -q 'moonraker/moonraker.py' <<<"$LIST" && ok "Moonraker present" \
|| warn "no Moonraker in payload -- the stock 2022 build stays, and Mainsail will hide the webcam"
else
warn "no anvil.tar.xz -- scripts only"
fi
# --- 8b. MODEL GATE
# A package built from the wrong model's firmware refuses to install.
PKG_MACHINE=$(sed -n 's/^MACHINE=//p' "$WORK/runFirmwareExe.sh" 2>/dev/null | head -n1)
PKG_PID=$(sed -n 's/^PID=//p' "$WORK/runFirmwareExe.sh" 2>/dev/null | head -n1)
if [ -n "$PKG_MACHINE" ]; then
echo " package installs on: $PKG_MACHINE (PID $PKG_PID)"
if [ -n "${TARGET_MACHINE:-}" ]; then
if [ "$PKG_MACHINE" = "$TARGET_MACHINE" ]; then
ok "model gate matches TARGET_MACHINE=$TARGET_MACHINE"
else
bad "MODEL MISMATCH: package is for '$PKG_MACHINE' but TARGET_MACHINE='$TARGET_MACHINE'"
bad " this package will be REFUSED by your printer (\"Firmware does not match machine type\")"
bad " you need a stock package built for $TARGET_MACHINE to start from"
fi
else
warn "TARGET_MACHINE not set in config.env -- cannot check this against your printer"
fi
else
warn "no MACHINE= gate found in runFirmwareExe.sh"
fi
# --- 9. USB names
# The prefix must match the model (app_startup.sh globs /mnt/Creator5Pro-*.tgz)
# AND the gate inside, or the printer picks the file up and then refuses it.
PKG_NAME=$(basename "$PKG")
case "$PKG_NAME" in
Creator5Pro-*.tgz) FN_MACHINE=Creator5Pro ;;
Creator5-*.tgz) FN_MACHINE=Creator5 ;;
*) FN_MACHINE=""; bad "filename '$PKG_NAME' matches no glob -- app_startup.sh will ignore it" ;;
esac
if [ -n "$FN_MACHINE" ]; then
if [ -z "$PKG_MACHINE" ] || [ "$FN_MACHINE" = "$PKG_MACHINE" ]; then
ok "filename prefix matches the model gate ($FN_MACHINE)"
else
bad "filename says $FN_MACHINE but the gate inside says $PKG_MACHINE"
bad " the printer would pick this file up and then refuse it"
fi
fi
# --- 10. the ship boundary
# Only payload/ and assets/ may reach a printer. Compared by CONTENT, not name:
# a blacklist would trip over any file Mainsail happens to call common.sh.
mkdir -p "$WORK/ship"
tar -xf "$WORK"/software-*.tar.xz -C "$WORK/ship" 2>/dev/null || true
if [ -f "$WORK/anvil.tar.xz" ]; then
mkdir -p "$WORK/ship/.anvil"
xz -dc "$WORK/anvil.tar.xz" 2>/dev/null | tar -xf - -C "$WORK/ship/.anvil" 2>/dev/null \
|| tar -xf "$WORK/anvil.tar.xz" -C "$WORK/ship/.anvil" 2>/dev/null || true
fi
find bin docker -type f 2>/dev/null | xargs -r md5sum 2>/dev/null \
| awk '{print $1}' | sort -u > "$WORK/host.md5"
find "$WORK/ship" -type f 2>/dev/null | xargs -r md5sum 2>/dev/null > "$WORK/ship.md5"
LEAK=$(awk 'NR==FNR{h[$1];next} $1 in h {print}' "$WORK/host.md5" "$WORK/ship.md5" \
| sed "s|$WORK/ship/||")
# Those finds are RELATIVE: from any other cwd host.md5 comes out empty and
# this reports ok having compared nothing. common.sh cds to the root.
if [ ! -s "$WORK/host.md5" ]; then
bad "ship-boundary check compared nothing (no files found under bin/, docker/ -- wrong cwd?)"
elif [ -z "$LEAK" ]; then
ok "package carries nothing from bin/ or docker/"
else
bad "host-side files leaked into the package:"
echo "$LEAK" | head -10 | sed 's/^/ /'
fi
echo
if [ "$FAIL" = 0 ]; then echo "ALL CHECKS PASSED -- safe to copy to USB"; else echo "FAILURES ABOVE -- do not install"; exit 1; fi
+10 -9
View File
@@ -31,15 +31,16 @@ unpack.sh decrypt the stock .tgz, open the software component
patch.sh apply the mods to work/software/, and install the .ipk feed
into work/modpayload-root/ to make the payload
pack.sh regenerate md5sum.list, tar, encrypt → work/out/<Model>-anvil-<date>.tgz
verify.sh simulate every check the printer performs, against the built file
```
`make build` runs the first four. It needs the .ipk feed to exist first --
`make build` runs all four. It needs the .ipk feed to exist first --
`patch.sh` assembles the payload by installing it -- so `make packages`
comes before a cold `make build`; patch.sh says so if it is missing.
`verify.sh` is NOT one of them -- run
`make verify` after it, as the hardware checklist does. Each is idempotent and
safe to re-run.
comes before a cold `make build`; patch.sh says so if it is missing. Each is
idempotent and safe to re-run.
There is no host-side check of the built file. `bin/verify.sh` used to
re-implement the printer's install checks in bash; `make qa-replica` makes the
printer perform them, so that is the gate now.
Packages carry only the **software** component by default. The stock installer
skips any component that is absent, so the kernel, the rootfs image and the
@@ -282,7 +283,7 @@ compiler on the printer to assemble it with.
**Builds it** — host-side, never installed:
```
bin/ fetch-assets -> unpack -> patch -> pack, plus verify.
bin/ fetch-assets -> unpack -> patch -> pack.
build-packages.sh is the packaging lane below, and the four
steps need it to have run: patch.sh checks for the feed and
refuses without one
@@ -354,8 +355,8 @@ for why the host half moved.
Two things keep the boundary from eroding: the only files of ours that
`patch.sh` copies into a package are the ones under a recipe's `payload/`,
`prog/` or `seed/`, and `make verify` fails if a built package contains any
file byte-identical to one in `bin/`, `test/` or `docker/`.
`prog/` or `seed/`, and `qa/replica/test_what_ships.py` fails if any file on
the installed printer is byte-identical to one in `bin/` or `docker/`.
## Rebuilding chelper
+7 -4
View File
@@ -42,13 +42,16 @@ Find yours in Settings → About, or in
`TARGET_MACHINE` in `config.env`. Then:
```sh
make build && make verify # one model
make build # one model
make release # both, into dist/
```
`verify.sh` fails loudly on a mismatch. **You must start from a stock package
built for your own model** — the mod inherits the gate from whatever package
you unpack.
**You must start from a stock package built for your own model** — the mod
inherits the gate from whatever package you unpack, so a package built from
the wrong stock file carries the wrong gate and your printer will refuse it
with "Firmware does not match machine type". Nothing on the build host warns
you first; `make qa-replica` catches it by installing the package the way the
printer does.
## Before the first flash
+4 -2
View File
@@ -203,8 +203,10 @@ firmware, so each must be built from its own stock package.
The filename prefix and the gate inside must agree: `app_startup.sh` globs for
the prefix, and `runFirmwareExe.sh` checks the gate. A mismatch means the
printer picks the file up and then refuses it. `make verify` and
`bin/verify.sh` both check this.
printer picks the file up and then refuses it. Nothing on the build host
checks the pair any more: `make qa-replica` installs the package the way the
printer does, off a real FAT filesystem through `app_startup.sh`, so a
mismatch stops the install there rather than being predicted beforehand.
## Recovery
+58 -3
View File
@@ -342,8 +342,10 @@ build host's execline. The database is compiled in the replica now, by the
`s6-rc-compile` the payload ships, so that hazard cannot exist. The price is
that a boot-order mistake is caught by the replica lane rather than in a
second, and that the timeout and edge assertions are not currently made
anywhere. `bin/verify.sh` keeps the one part a host can still check: the
oneshot runner's shebang, read straight out of the shipped database.
anywhere. The oneshot runner's shebang was the one part a host could still
check and `bin/verify.sh` kept it; that file is gone too (see *`bin/verify.sh`
is retired* below), and `qa/replica/test_s6rc.py` covers the same ground by
booting the database rather than reading it.
### The gates phase 8 killed have been retired
@@ -461,7 +463,7 @@ Three gates were dropped rather than ported, as a coverage decision:
|---|---|---|
| the pytest gate | `run-tests.py` ran pytest and re-parsed its XML | none at the time -- `make test-py` and CI ran pytest directly; both went when `test/` was deleted |
| the rootfs extraction | ran `unpack.sh` + `extract_rootfs` inline | none -- `make rootfs` does exactly this, and the replica lane already fails with that command in the message |
| **the packaging build on a synthetic stock package** | `make-stock-fixture.sh`, then `unpack`/`patch`/`pack`/`verify.sh` | **real: `bin/unpack.sh`, `patch.sh`, `pack.sh` and `verify.sh` now have no test at all** |
| **the packaging build on a synthetic stock package** | `make-stock-fixture.sh`, then `unpack`/`patch`/`pack`/`verify.sh` | **real: `bin/unpack.sh`, `patch.sh` and `pack.sh` now have no test at all.** `verify.sh` has since been retired outright -- see below |
That last row is the one to be uneasy about, and it is recorded here rather
than buried in a commit message. The build path is what produces the `.tgz` a
@@ -486,6 +488,59 @@ klippy tree still went out on the firmware partition; the tree lives at
`$MODDIR/klipper` now and neither that section nor the `chelper.tar` exists. Recover both from git history rather than from a fresh reading of
a stock package.
### `bin/verify.sh` is retired
Forty checks, run on the build host against a decrypted copy of the `.tgz`.
Four survive, in `qa/replica/test_what_ships.py`. The rest went because of
what the word *simulate* was doing in the file's own description -- "simulate
every check the printer performs".
The replica lane does not simulate the install. `qa/replica/actions/install-package.sh`
puts the package on a genuine FAT filesystem at `/dev/sda1` and runs the
machine's own `/usr/prog/app_startup.sh` over it, verbatim, under qemu. So the
decrypt, the plain-tar components, `md5sum.list`, the `MACHINE=` gate and the
`/mnt/<Model>-*.tgz` glob are all *preconditions of the lane* now rather than
assertions in it: break any of them and the install fails, `installed_image()`
raises with the install log attached, and every replica test goes red at once.
A host-side re-reading of the same rules is a second implementation, and a
second implementation can agree with itself while disagreeing with the printer
-- the lesson `qa/replica/conftest.py` records this repo learning twice about
hand-placed payloads.
A second block was asking what the suite already answers, and answers of an
installed filesystem rather than of a `tar -t` listing: the klippy tree and
`c_helper.so` (`test_install.py`, `test_abi.py`), the compiled s6-rc database
and the oneshot runner's execline (`test_s6rc.py`), s6 and `s6-ftrigrd`
(`test_supervisor.py`), the `.install-manifest` (`test_upgrade.py`),
libsodium's bare `.so` symlink and the chamber configs
(`static/test_ipk.py`), and every shipped script's syntax
(`static/test_shell_syntax.py`).
The four that moved are the ones nothing else asks, and each got stronger for
the move -- a listing cannot tell you that a shared object loads, or what a
name resolves to on a `PATH`:
- the shipped interpreter **imports** `sqlite3`, `lmdb` and `_cffi_backend`,
rather than a grep finding three `.so` files. This is the check that catches
a cross build resolving to an x86_64 manylinux wheel, which is present on
disk and raises on import.
- nothing in `$MODDIR/bin` shadows FlashForge's `python3`, asked with
`command -v` after sourcing `anvil-env.sh` -- which is the file that
*prepends* `$MODDIR/bin` to `PATH` and therefore creates the hazard.
- CPython's dev half (headers, pkgconfig, `config-3.13-*`) did not ship.
- the ship boundary: no file on the installed printer is byte-identical to one
under `bin/` or `docker/`. Asked of the machine rather than of the `.tgz`,
so it also sees anything a maintainer script wrote as root at install time.
**What genuinely left with it**, and is recorded here rather than in a commit
message: there is no longer any check you can run on the build host, in
seconds and without docker, against a package you are about to flash. In
particular nothing warns you *before* you copy the file to a stick that it was
built from the wrong model's stock package. The printer still refuses it
("Firmware does not match machine type") and `make qa-replica` still catches
it, but the fast host-side warning that `make verify` gave the hardware
checklist is gone.
### What survived in test/, and then did not
Two things did, for a while. Both are gone now and `test/` with them:
+5 -2
View File
@@ -216,9 +216,12 @@ Tests that cannot fail are worse than no tests, because they read as coverage:
- `test-ash-conformance` parsed the payload with the printer's busybox;
`qa/replica/test_install.py` runs `sh -n` over every *installed* script
with the same qemu'd busybox.
- `test-model-gate` checked what `verify.sh` §8b/§9 checks and what
- `test-model-gate` checked what `bin/verify.sh` §8b/§9 checked and what
`pack.sh` already refuses to build. Its header claimed it proved the two
models ship different files; no such check existed in it.
models ship different files; no such check existed in it. `verify.sh` has
since been retired; the model gate is now enforced where it always
mattered, by `runFirmwareExe.sh` refusing a foreign package -- which the
replica lane exercises on every install.
- `test-base-cfg` compared our `printer.base.cfg` against
`work/software/.../printer.base.cfg` — which `bin/patch.sh` overwrites
*with our own file* before the test reads it. It had been diffing our file
+382
View File
@@ -0,0 +1,382 @@
"""What the package puts on the printer, and what it must not.
THE FILE THIS REPLACES
bin/verify.sh, which described itself as "simulate every check the printer
performs, against a built package". Forty checks, run on the host, against a
.tgz decrypted into a temp directory. The word doing the work in that sentence
is SIMULATE, and it is why nearly all forty are gone rather than moved.
qa/replica does not simulate the install. qa/replica/actions/install-package.sh
puts the real package on a genuine FAT filesystem exposed as /dev/sda1 and runs
the machine's OWN /usr/prog/app_startup.sh over it, verbatim, under qemu. So
every one of these, which verify.sh spent lines re-implementing on the host, is
now a PRECONDITION of this lane rather than an assertion in it:
* the package decrypts with the firmware key (the printer's unTar does it)
* runFirmwareExe.sh is present and the components are plain tar, not xz
(the stock installer runs a bare `tar -xvf`)
* md5sum.list verifies (the installer aborts and deletes the payload if not)
* the MACHINE= gate matches (runFirmwareExe.sh refuses a foreign package)
* the filename matches the glob (app_startup.sh globs /mnt/<Model>-*.tgz to
find the file at all)
None of those can be quietly true here. If any of them broke, the install
would fail, `installed_image()` would raise with the install log attached, and
every test in the replica lane would go red at once. A host-side re-reading of
the same rules is a second implementation that can agree with itself while
disagreeing with the printer -- the same defect qa/replica/conftest.py records
this repo learning twice already about hand-placed payloads.
A further block of verify.sh was asking questions this suite already answers,
and answers of an installed filesystem rather than of a tar listing:
* the klippy tree and c_helper.so -> test_install.py, test_abi.py
* the compiled s6-rc database, the
oneshot runner's execline, s6 and
s6-ftrigrd -> test_s6rc.py, test_supervisor.py
* the klipper service, printer.base
and the chamber configs -> test_install.py, static/test_ipk.py
* the .install-manifest -> test_upgrade.py
* libsodium's bare .so symlink -> static/test_ipk.py
* every shipped script's syntax -> static/test_shell_syntax.py
WHAT IS LEFT, AND WHY IT IS THESE FOUR
Each one below is a question nothing else in the repo asks, and each is asked
here in a stronger form than verify.sh could manage. verify.sh read a `tar -t`
listing and grepped it for path fragments; a listing cannot tell you that a
shared object actually loads, or what a name resolves to on a PATH. This lane
has a booted machine, so it can simply ask.
The two negatives are both of the shape where PRESENCE IS THE BUG, which is
the shape that rots quietest: nothing fails, the payload is merely wrong.
"""
import hashlib
import os
import pytest
from lib.paths import ROOT
pytestmark = pytest.mark.replica
MODDIR = "/usr/data/anvil"
# Ours, not FlashForge's 3.8.2. anvil-env.sh exports this as FF_PYTHON and the
# klipper service, Moonraker and the ff-startup scripts all exec it.
PY = MODDIR + "/bin/python3.13"
ENV = MODDIR + "/anvil-env.sh"
# The host-side trees that must not reach a printer. verify.sh compared these
# same two directories, and by CONTENT rather than by name -- a blacklist would
# trip over any file Mainsail happens to call common.sh.
HOST_TREES = ("bin", "docker")
# Where the leak sweep looks. Everything the package writes lands under one of
# these two: $MODDIR is the payload, /usr/prog is the software component.
# Not the whole filesystem -- that is ~24000 files through qemu, and a build
# script of ours appearing in FlashForge's stock tree is not a thing a package
# can cause.
SHIP_ROOTS = (MODDIR, "/usr/prog")
LEAK_SCAN = "/tmp/qa-leak-scan.py"
LEAK_SIZES = "/tmp/qa-leak-sizes.txt"
# Reads the candidate sizes, walks the two roots, and md5s only the files whose
# size is one the host set contains. The size filter is what keeps this cheap:
# hashing every file under /usr/prog through qemu is minutes, and a leaked file
# is byte-identical to its host original by definition, so it cannot escape a
# filter on exact size.
LEAK_PROGRAM = r'''
import hashlib, os, sys
sizes = set()
for line in open("/tmp/qa-leak-sizes.txt"):
line = line.strip()
if line:
sizes.add(int(line))
roots = sys.argv[1:]
out = sys.stdout
seen = 0
for root in roots:
for dirpath, dirnames, filenames in os.walk(root):
for name in filenames:
path = os.path.join(dirpath, name)
if os.path.islink(path):
continue
try:
size = os.path.getsize(path)
except OSError:
continue
seen += 1
if size not in sizes:
continue
try:
with open(path, "rb") as fh:
digest = hashlib.md5(fh.read()).hexdigest()
except (OSError, IOError):
continue
out.write(digest + "\t" + path + "\n")
out.write("WALKED\t" + str(seen) + "\n")
'''
@pytest.fixture(scope="module")
def box(printer):
"""The installed machine, checked once.
Every test below is meaningless on a machine where the payload did not
land, and one clear failure here beats four obscure ones underneath.
"""
if not printer.file(MODDIR).is_dir:
pytest.fail(
"there is no %s, so the install did not land and there is no "
"package to ask about -- `make build` first." % MODDIR)
return printer
def _host_files():
"""Every file under bin/ and docker/ in the checkout, by md5.
Returns {md5: [relative paths]}. Several of these files are identical to
each other often enough that the value has to be a list -- an empty
__init__.py in two places would otherwise lose one of its names in the
report.
"""
by_digest = {}
for tree in HOST_TREES:
base = ROOT / tree
if not base.is_dir():
continue
for dirpath, dirnames, filenames in os.walk(str(base)):
for name in filenames:
path = os.path.join(dirpath, name)
if os.path.islink(path):
continue
try:
with open(path, "rb") as fh:
digest = hashlib.md5(fh.read()).hexdigest()
except (OSError, IOError):
continue
rel = os.path.relpath(path, str(ROOT))
by_digest.setdefault(digest, []).append(rel)
return by_digest
# ------------------------------------------------------ the interpreter works
# The three extension modules this interpreter exists for. verify.sh grepped a
# tar listing for each one's .so, which proves a file was staged and nothing
# about whether it loads.
#
# sqlite3 the module FlashForge's 3.8.2 has not got, and the reason
# there is a second CPython on this printer at all. It is
# also the one that fails INVISIBLY: a dropped -lm makes
# configure's link probe fail, and CPython then records the
# module as missing and builds a perfectly working
# interpreter without it.
# lmdb Moonraker's database at the pinned commit.
# _cffi_backend klippy's route to c_helper.so, and the reason this
# interpreter had to be glibc rather than musl.
#
# lmdb and _cffi_backend are also the two a cross build gets WRONG rather than
# missing, by resolving to an x86_64 manylinux wheel. Importing them is what
# tells the two apart: a wheel for the wrong architecture is present on disk
# and raises on import.
WANTED_MODULES = ("sqlite3", "lmdb", "_cffi_backend")
def test_the_interpreter_imports_what_it_was_rebuilt_for(box):
"""Asked by importing, on the printer, through anvil-env.sh.
Sourcing the environment first is not a detail: the interpreter is linked
against libraries in FlashForge's /usr/prog/<package>/ directories that are
on no default loader path, so a bare exec dies at
"libpython3.8.so.1.0: cannot open shared object file" long before it has
an opinion about sqlite3. That is anvil-env.sh's whole reason for
existing, and running without it would test the wrong thing.
One module per line rather than one import statement, so the report names
which of the three is missing instead of stopping at the first.
"""
assert box.file(PY).executable, (
"this package ships no interpreter at %s. It is a build output "
"(pkgs/3rdparty/python), not a file in the checkout -- `make build`."
% PY)
broken = []
for module in WANTED_MODULES:
got = box.sh(". %s; %s -c 'import %s'" % (ENV, PY, module))
if not got.ok:
# The last line of a traceback is the exception, which is the part
# that distinguishes "no such module" from "wrong architecture".
tail = got.text.strip().splitlines()
broken.append("%s: %s" % (module, tail[-1] if tail else "no output"))
assert not broken, (
"%s cannot import the modules it was rebuilt to provide:\n %s\n"
"A missing sqlite3 means checking LIBS/LIBSQLITE3_LIBS in "
"pkgs/3rdparty/python/build.sh; a missing lmdb or _cffi_backend is "
"usually a cross build that resolved to an x86_64 manylinux wheel -- "
"see work/.pkg-python-*/wheel-*.log."
% (PY, "\n ".join(broken)))
def test_nothing_we_ship_shadows_flashforges_python3(box):
"""PRESENCE IS THE BUG, and the bug would be ours.
anvil-env.sh PREPENDS $MODDIR/bin to PATH, because s6-svscan execs
s6-supervise by name and s6-svc -w does the same for s6-svlisten. That
prepend is what makes a `python3` symlink in $MODDIR/bin dangerous: it
would silently put our 3.13 ahead of FlashForge's 3.8.2 for every process
that says `python3` after the environment is sourced -- which is every
process the mod starts, and several of theirs. Nothing would fail at
install time; things would fail later, in FlashForge's code, against an
interpreter it was never written for.
anvil-env.sh's own comment states the rule ("adding our bin/ to PATH
cannot quietly change what `python3` means"), and this is that rule asked
of the machine. Callers that want ours use $FF_PYTHON, which the same file
exports as an absolute path.
Asked with `command -v` AFTER sourcing, rather than by looking for the
symlink: the property is what the name resolves to, and that is what a
future payload could break by some other route than the one file
verify.sh grepped for.
"""
got = box.sh(". %s; command -v python3" % ENV)
resolved = got.out.strip()
# Not an assertion about FlashForge having one -- if the base rootfs ever
# ships without python3 that is their change, not a regression in ours.
if not resolved:
pytest.skip(
"nothing on PATH is called python3 even with the mod environment "
"sourced, so there is nothing for us to be shadowing")
assert not resolved.startswith(MODDIR), (
"`python3` resolves to %s with anvil-env.sh sourced, so our 3.13 is "
"ahead of FlashForge's 3.8.2 on PATH for every process that says "
"python3. Callers that want ours use $FF_PYTHON; nothing in "
"%s/bin may be called python3." % (resolved, MODDIR))
# --------------------------------------------------------- what must not ship
# The dev half of CPython: headers so the python-* recipes can compile against
# it on a BUILD machine, pkgconfig files, and the config-3.13-* directory
# holding the Makefile and the static library. anvil-python-dev owns all of it
# and bin/patch.sh prunes it via PKG_DEV_FILES.
DEV_MARKERS = (
MODDIR + "/include/python3.13",
MODDIR + "/lib/pkgconfig",
)
DEV_GLOB = MODDIR + "/lib/python3.13/config-3.13-*"
def test_no_cpython_dev_files_reached_the_printer(box):
"""Extra files in a payload ship silently, which is the whole problem.
Nothing breaks when the headers are there. The package is simply bigger
than it should be, on a printer whose data partition is the reason the
payload was split off the firmware partition in the first place, and no
other check in the repo would ever mention it.
"""
found = [p for p in DEV_MARKERS if box.file(p).exists]
globbed = box.sh("ls -d %s 2>/dev/null" % DEV_GLOB).out.split()
found.extend(globbed)
assert not found, (
"the payload carries CPython's dev half:\n %s\n"
"Those belong to anvil-python-dev, which a printer does not install. "
"Check PKG_DEV_FILES in pkgs/3rdparty/python." % "\n ".join(found))
def test_no_host_side_build_file_reached_the_printer(box):
"""The ship boundary: only payload/ and assets/ may reach a printer.
Compared by CONTENT and not by name, which is verify.sh's own reasoning
and worth keeping: a blacklist of names would trip over any file Mainsail
happens to call common.sh, and would miss a build script copied in under a
new name -- which is the case that actually matters, because it is the one
nobody did on purpose.
Asked of the INSTALLED filesystem rather than of the .tgz, which is the
one thing this move makes stronger. verify.sh untarred the package and
compared that; the machine also shows anything a maintainer script wrote
at install time, and postinst scripts are code that runs as root on a
printer.
"""
by_digest = _host_files()
# verify.sh carried this guard because its own `find` was relative and
# came back empty from the wrong cwd, at which point it reported "package
# carries nothing" having compared nothing at all. A gate that cannot go
# red is worse than no gate.
#
# 5 and not the 10 distinct digests measured here on 2026-08-30: the floor
# is there to catch an empty or near-empty walk, and a floor set AT the
# current count turns deleting one script in bin/ into a failure of this
# test, which would teach exactly the wrong lesson about what it checks.
assert len(by_digest) >= 5, (
"the leak sweep hashed only %d files under %s, which is not this "
"checkout -- so it compared nothing and proves nothing"
% (len(by_digest), " and ".join(t + "/" for t in HOST_TREES)))
sizes = set()
for tree in HOST_TREES:
base = ROOT / tree
if not base.is_dir():
continue
for dirpath, dirnames, filenames in os.walk(str(base)):
for name in filenames:
path = os.path.join(dirpath, name)
if not os.path.islink(path):
try:
sizes.add(os.path.getsize(path))
except OSError:
pass
box.write(LEAK_SIZES, "\n".join(str(s) for s in sorted(sizes)) + "\n")
box.write(LEAK_SCAN, LEAK_PROGRAM)
got = box.sh("%s %s %s" % (PY, LEAK_SCAN, " ".join(SHIP_ROOTS)), timeout=900)
assert got.ok, (
"the leak sweep did not run on %s (exit %s):\n%s"
% (PY, got.code, got.text))
lines = got.out.splitlines()
# The sweep's completion marker, for the same reason test_abi.py has one:
# a walk killed halfway reports a short list that this assertion passes
# happily, which is a green gate over an unswept filesystem.
assert lines and lines[-1].startswith("WALKED\t"), (
"the leak sweep did not run to completion, so what it reported is a "
"partial filesystem:\n%s" % got.text[-2000:])
# 21132 files under the two roots, measured on the replica 2026-08-30. The
# floor is two orders below that: it is here to catch a walk that found
# nothing, not to pin a number that legitimately moves with the payload.
walked = int(lines[-1].split("\t")[1])
assert walked >= 100, (
"the sweep looked at only %d files under %s -- the roots are wrong or "
"the install did not land, and nothing was really compared"
% (walked, " and ".join(SHIP_ROOTS)))
leaked = []
for line in lines[:-1]:
fields = line.split("\t", 1)
if len(fields) != 2:
continue
digest, path = fields
if digest in by_digest:
leaked.append("%s (identical to %s)"
% (path, ", ".join(by_digest[digest])))
assert not leaked, (
"host-side build files reached the printer:\n %s\n"
"Only payload/ and assets/ may ship. Something under %s is being "
"copied into the package."
% ("\n ".join(leaked),
" or ".join(t + "/" for t in HOST_TREES)))