diff --git a/Makefile b/Makefile index 85deea0..ae1723a 100644 --- a/Makefile +++ b/Makefile @@ -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)). diff --git a/bin/verify.sh b/bin/verify.sh deleted file mode 100755 index 39dc961..0000000 --- a/bin/verify.sh +++ /dev/null @@ -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/-*.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/.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 diff --git a/docs/building.md b/docs/building.md index 5482334..d985d68 100644 --- a/docs/building.md +++ b/docs/building.md @@ -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/-anvil-.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 diff --git a/docs/hardware-testing.md b/docs/hardware-testing.md index fb51400..9e6f501 100644 --- a/docs/hardware-testing.md +++ b/docs/hardware-testing.md @@ -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 diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f77ccc2..1b9a44c 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -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 diff --git a/docs/qa-migration.md b/docs/qa-migration.md index 16878f7..b1b81b5 100644 --- a/docs/qa-migration.md +++ b/docs/qa-migration.md @@ -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/-*.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: diff --git a/docs/testing.md b/docs/testing.md index 6933fea..cdee5a9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/qa/replica/test_what_ships.py b/qa/replica/test_what_ships.py new file mode 100644 index 0000000..c8f312f --- /dev/null +++ b/qa/replica/test_what_ships.py @@ -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/-*.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// 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)))