diff --git a/Makefile b/Makefile index 94e0b1a..8ac6214 100644 --- a/Makefile +++ b/Makefile @@ -1,92 +1,135 @@ # creator5-custom-firmware # -# make build PROFILE=probe build one profile's USB package -# make test full suite (fixture-based, no stock firmware) -# make uninstall-pkg build the revert package -# make shell a shell in the reproducible build container +# Nothing runs on your machine except Docker. Every target below executes +# inside the pinned build image (docker/Dockerfile.build); the docker socket +# is mounted through so the simulation targets can start sibling containers. +# +# make probe build the stage-0 package (changes nothing) +# make test full brick-safety suite +# make shell interactive shell in the build container +# +# Escape hatch: LOCAL=1 make runs the scripts directly on the host. PROFILE ?= probe -SHELL := /bin/bash DOCKER ?= docker IMAGE ?= creator5-fw-build +LOCAL ?= + +DOCKER_SOCK := /var/run/docker.sock + +# The repo is mounted AT ITS REAL HOST PATH, not at /src. The test targets +# start sibling containers through the mounted docker socket, and those are +# created by the host daemon -- which resolves -v paths on the HOST. If the +# build container saw the repo as /src it would ask the daemon to mount a +# /src that does not exist there, and the sibling would get empty +# directories. Keeping the path identical on both sides avoids that entirely. +ifeq ($(LOCAL),) + RUN = $(DOCKER) run --rm -i \ + -v "$(CURDIR)":"$(CURDIR)" -w "$(CURDIR)" \ + -v $(DOCKER_SOCK):$(DOCKER_SOCK) \ + -e PROFILE -e REAL_PKG -e SIM_IMAGE \ + $(IMAGE) + RUNTTY = $(subst --rm -i,--rm -it,$(RUN)) +else + RUN = + RUNTTY = +endif .DEFAULT_GOAL := help -.PHONY: help build probe ssh web full helix all-profiles unpack patch pack \ - verify uninstall-pkg test test-lint test-install test-roundtrip \ - test-ui test-abi image shell clean distclean +.PHONY: help image shell build probe ssh web full helix all-profiles \ + rootfs uninstall-pkg verify test test-lint test-install \ + test-roundtrip test-ui test-ash test-abi clean distclean help: - @echo 'creator5-custom-firmware' + @echo 'creator5-custom-firmware -- everything runs in Docker' @echo - @echo ' make build PROFILE=

build a package (p: probe ssh web full helix)' - @echo ' make probe|ssh|web|full|helix' - @echo ' make all-profiles build every profile' - @echo ' make uninstall-pkg build the revert package' + @echo 'Build (flash these in order -- see docs/hardware-testing.md):' + @echo ' make probe stage 0 changes nothing, reports back on a USB stick' + @echo ' make ssh stage 1 root password only' + @echo ' make web stage 2 + Mainsail / moonraker' + @echo ' make full stage 3 + forked Klipper, toolchanger' + @echo ' make helix stage 4 + HelixScreen as the UI' + @echo ' make all-profiles' + @echo ' make uninstall-pkg build this FIRST, keep it on a spare stick' @echo - @echo ' make test run the whole suite' - @echo ' make test-lint brick-risk lint only' - @echo ' make test-install install simulation only' - @echo ' make test-roundtrip install + uninstall round trip' - @echo ' make test-ui UI selection / fallback' - @echo ' make test-abi MIPS ELF ABI checks' + @echo 'Test:' + @echo ' make test everything below' + @echo ' make test-lint brick-risk lint' + @echo ' make test-install run the installer against a fake printer' + @echo ' make test-roundtrip install -> uninstall -> back to stock' + @echo ' make test-ui UI selection, crash fallback, SAFE-MODE' + @echo ' make test-ash parse the payload with the printer own busybox' + @echo ' make test-abi MIPS ELF ABI checks' @echo - @echo ' make image / make shell reproducible build container' - @echo ' make clean / distclean' + @echo 'Other:' + @echo ' make rootfs extract the real printer rootfs (enables test-ash)' + @echo ' make image build the build container' + @echo ' make shell shell inside it' + @echo ' make clean | distclean' @echo - @echo 'Flash order matters -- see docs/hardware-testing.md' + @echo 'LOCAL=1 make runs on the host instead of in Docker.' + +image: + @$(DOCKER) build -q -t $(IMAGE) -f docker/Dockerfile.build docker >/dev/null && echo "image $(IMAGE) ready" config.env: @echo 'no config.env -- copy the example and edit the paths:'; \ echo ' cp config.env.example config.env'; exit 1 -build: config.env - @PROFILE=$(PROFILE) ./bin/build.sh - -probe ssh web full helix: config.env - @PROFILE=$@ ./bin/build.sh - -all-profiles: config.env - @for p in probe ssh web full helix; do \ - echo "=== $$p ==="; PROFILE=$$p ./bin/build.sh || exit 1; done - -unpack patch pack verify: config.env - @PROFILE=$(PROFILE) ./bin/$@.sh - -uninstall-pkg: config.env - @PROFILE=$(PROFILE) ./bin/unpack.sh >/dev/null - @PROFILE=$(PROFILE) ./bin/make-uninstall.sh - -test: - @./test/run-tests.sh - -test-lint: - @./test/lint-danger.sh payload payload/init.d - -test-install: - @pkg=$$(ls -1 work/out/Creator5Pro-*.tgz 2>/dev/null | grep -v uninstall | head -1); \ - [ -n "$$pkg" ] || { echo 'build a package first: make build'; exit 1; }; \ - ./test/sim-install.sh "$$pkg" - -test-roundtrip: - @m=$$(ls -1 work/out/Creator5Pro-*.tgz 2>/dev/null | grep -v uninstall | head -1); \ - u=work/out/Creator5Pro-uninstall.tgz; \ - [ -n "$$m" ] && [ -f "$$u" ] || { echo 'need: make build && make uninstall-pkg'; exit 1; }; \ - ./test/sim-roundtrip.sh "$$m" "$$u" - -test-ui: - @./test/sim-ui-fallback.sh - -test-abi: - @./test/test-abi.sh - -image: - $(DOCKER) build -t $(IMAGE) -f docker/Dockerfile.build docker - shell: image - $(DOCKER) run --rm -it -v "$$PWD:/src" -w /src $(IMAGE) bash + @$(RUNTTY) bash + +build: image config.env + @PROFILE=$(PROFILE) $(RUN) ./bin/build.sh + +probe ssh web full helix: image config.env + @PROFILE=$@ $(RUN) ./bin/build.sh + +all-profiles: image config.env + @for p in probe ssh web full helix; do \ + echo "=== $$p ==="; PROFILE=$$p $(RUN) ./bin/build.sh || exit 1; done + +rootfs: image config.env + @$(RUN) ./bin/unpack.sh >/dev/null + @$(RUN) ./bin/extract-rootfs.sh + +uninstall-pkg: image config.env + @$(RUN) ./bin/unpack.sh >/dev/null + @$(RUN) ./bin/make-uninstall.sh + +verify: image + @$(RUN) ./bin/verify.sh + +test: image + @$(RUN) ./test/run-tests.sh + +test-lint: image + @$(RUN) ./test/lint-danger.sh payload payload/init.d + +test-ash: image + @$(RUN) ./test/test-ash-conformance.sh + +test-abi: image + @$(RUN) ./test/test-abi.sh + +test-ui: image + @$(RUN) ./test/sim-ui-fallback.sh + +test-install: image + @$(RUN) sh -c 'pkg=$$(ls -1 work/out/Creator5Pro-*.tgz 2>/dev/null | grep -v uninstall | head -1); \ + [ -n "$$pkg" ] || { echo "build a package first: make probe"; exit 1; }; \ + ./test/sim-install.sh "$$pkg"' + +test-roundtrip: image + @$(RUN) sh -c 'm=$$(ls -1 work/out/Creator5Pro-*.tgz 2>/dev/null | grep -v uninstall | head -1); \ + u=work/out/Creator5Pro-uninstall.tgz; \ + [ -n "$$m" ] && [ -f "$$u" ] || { echo "need: make build && make uninstall-pkg"; exit 1; }; \ + ./test/sim-roundtrip.sh "$$m" "$$u"' clean: - rm -rf work/stage work/out work/uninst work/uninst-sw work/modpayload + @rm -rf work/stage work/out work/uninst work/uninst-sw work/modpayload + @echo cleaned -distclean: clean - rm -rf work +distclean: + @rm -rf work + @echo "removed work/ (including the extracted rootfs)" diff --git a/README.md b/README.md index ecb4f2d..2ee5292 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ make probe # stage 0: changes nothing, reports back make test # full brick-safety suite ``` +**Nothing runs on your machine except Docker.** Every make target executes +inside a pinned build image; the docker socket is mounted through so the test +targets can start sibling containers. `LOCAL=1 make ` opts out. + > **Unofficial and unaffiliated with FlashForge.** It voids your warranty and > you are responsible for your machine. Nothing here has been run on hardware > yet — see [Status](#status). @@ -66,7 +70,8 @@ a check in `make test`: | **The installer wipes the software dir first** | `ls \| grep -v temp \| xargs rm -rf` runs *before* `run.sh`, so the previous `firmwareExe` is already gone. An uninstall package must **ship** the genuine binary; it cannot back one up. | | **Binaries must be nan2008** | The Ingenic X2000 kernel returns `ENOEXEC` for legacy-NaN MIPS. Debian's `gcc-mipsel-linux-gnu` cannot produce a usable binary — its libc is legacy-NaN. | -**And one pleasant surprise:** ssh needs no work at all. The stock rootfs +**And one pleasant surprise, found by unpacking `rootfs.squashfs` out of the +kernel component:** ssh needs no work at all. The stock rootfs already ships `/usr/sbin/dropbear` and an enabled `/etc/init.d/S50dropbear`, so port 22 is *already open* on a stock printer. There is simply no published root password. Setting `ROOT_PW_HASH` is the whole feature. @@ -107,6 +112,7 @@ the whole pipeline runs in CI on a clean machine. | `test-install` | **Runs the installer for real** in a container with a fake printer rootfs, then asserts the printer would still boot: UI present and executable, touchscreen driver intact, boot scripts unmodified, user `printer.cfg` preserved, re-install idempotent | | `test-roundtrip` | Installs the mod, then the uninstall package, and asserts the machine is back to stock | | `test-ui` | Drives the UI selection logic: helix missing, crash-loop, SAFE-MODE latch and release, **and the no-UI-at-all case** | +| `test-ash` | Parses every on-printer script with the **printer's own busybox 1.31.1 ash**, extracted from the stock `rootfs.squashfs` and run under `qemu-mipsel` — a parse error in `firmwareExe` means a blank screen | | `test-abi` | Every shipped MIPS binary is `nan2008`/`mips32r2`/`o32`, and executes under `qemu-mipsel` | These are not theoretical. Writing them caught four real bugs before any @@ -124,9 +130,13 @@ hardware was involved: ## Requirements -`bash`, `openssl`, `tar`, `xz`, `unzip`, `python3`, `binutils`, and Docker for -the simulations. `make image && make shell` gives a pinned container with all -of it. +Docker. That is the whole list — the build image carries `openssl`, `tar`, +`xz`, `unzip`, `python3`, `binutils`, `squashfs-tools` and `qemu-user-static`, +and every target runs inside it. `make shell` drops you into it. + +`make rootfs` extracts the printer's genuine root filesystem from the stock +package's `kernel-*.tar.xz` (it contains `rootfs.squashfs`) and enables +`make test-ash`. It is never committed — it is FlashForge's firmware. --- diff --git a/bin/extract-rootfs.sh b/bin/extract-rootfs.sh new file mode 100644 index 0000000..98913f7 --- /dev/null +++ b/bin/extract-rootfs.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Extract the printer's REAL root filesystem from the stock update package. +# +# The kernel-*.tar.xz component carries ota_kernel_emmc/ota_v1/rootfs.squashfs +# -- the genuine buildroot rootfs: busybox 1.31.1, /etc/inittab, /etc/init.d +# (including the stock S50dropbear), /usr/sbin/dropbear, the real ash. +# +# test/sim-install.sh uses it to run the installer inside the ACTUAL printer +# userland under qemu-mipsel, where `uname -m` genuinely reports "mips" and +# busybox applets behave exactly as they do on the machine. That is a far +# better test than approximating with a Debian container. +# +# Never committed: it is FlashForge's proprietary firmware. +set -euo pipefail +. "$(dirname "$0")/common.sh" + +command -v unsquashfs >/dev/null 2>&1 || { echo "need squashfs-tools (the build image has it)" >&2; exit 1; } + +[ -d work/outer ] || { echo "run bin/unpack.sh first" >&2; exit 1; } +KERN=$(ls -1 work/outer/kernel-*.tar.xz 2>/dev/null | head -n1) +[ -n "$KERN" ] || { echo "no kernel-*.tar.xz in the package (a --slim build has none)" >&2; exit 1; } + +rm -rf work/kern work/rootfs +mkdir -p work/kern +tar -xf "$KERN" -C work/kern + +SQ=$(find work/kern -name 'rootfs.squashfs*' | head -n1) +[ -n "$SQ" ] || { echo "no rootfs.squashfs inside $KERN" >&2; exit 1; } +echo ">> $(basename "$SQ")" + +unsquashfs -q -d work/rootfs "$SQ" +rm -rf work/kern + +echo +echo "printer rootfs: work/rootfs" +echo " busybox: $(work/rootfs/bin/busybox 2>/dev/null | head -1 || echo 'MIPS binary')" +echo " init.d : $(ls work/rootfs/etc/init.d | tr '\n' ' ')" +echo " dropbear: $([ -x work/rootfs/usr/sbin/dropbear ] && echo present || echo MISSING)" diff --git a/docker/Dockerfile.build b/docker/Dockerfile.build index 8b9286c..09361ee 100644 --- a/docker/Dockerfile.build +++ b/docker/Dockerfile.build @@ -1,17 +1,17 @@ -# Reproducible build environment. +# The only thing that runs on your machine is Docker itself. # -# The packaging depends on exact tool behaviour (openssl DES3 with MD5 key -# derivation, GNU tar member ordering, md5sum output format), so pinning the -# image keeps a package built on a laptop byte-comparable with one built in -# CI. It is also what `make shell` drops you into. +# Every make target executes inside this image: the build scripts, the tests, +# and the install simulations. The docker CLI is included so the simulation +# targets can start sibling containers through the mounted socket, and +# qemu-user-static so the printer's real MIPS userland can be executed. FROM debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive RUN echo 'Acquire::Retries "3";' > /etc/apt/apt.conf.d/80-retries \ && apt-get update && apt-get install -y --no-install-recommends \ bash coreutils tar xz-utils gzip openssl unzip curl ca-certificates \ - python3 binutils file findutils grep sed gawk make git \ - squashfs-tools \ + python3 binutils file findutils grep sed gawk make git diffutils procps \ + squashfs-tools qemu-user-static docker.io \ && rm -rf /var/lib/apt/lists/* WORKDIR /src diff --git a/docs/hardware-testing.md b/docs/hardware-testing.md index 2bcaa30..908264d 100644 --- a/docs/hardware-testing.md +++ b/docs/hardware-testing.md @@ -17,7 +17,9 @@ recoverable.** - [ ] Note your printer's serial number (Settings → About). The factory image restores a placeholder serial and you may need to put yours back. - [ ] Confirm you can reach the printer's IP. -- [ ] `make test` passes locally. +- [ ] `make test` passes (78 checks). +- [ ] `make rootfs && make test-ash` passes — this parses every script that + will run on the printer using the printer's own busybox ash. Two USB sticks, FAT32, both packages at the **root** of the stick (not in a folder). Ship both filenames (`Creator5-*` and `Creator5Pro-*`): the Pro's diff --git a/test/run-tests.sh b/test/run-tests.sh index f85be4c..b190627 100644 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -100,6 +100,9 @@ sub "install -> uninstall -> stock" ./test/sim-roundtrip.sh "$MODPKG" "$UNI" hdr "UI selection and fallback" sub "ui fallback" ./test/sim-ui-fallback.sh +hdr "busybox ash conformance (printer's own shell)" +sub "ash conformance" ./test/test-ash-conformance.sh + hdr "MIPS ABI" sub "abi" ./test/test-abi.sh diff --git a/test/sim-install.sh b/test/sim-install.sh index ef12ed7..958f218 100644 --- a/test/sim-install.sh +++ b/test/sim-install.sh @@ -1,13 +1,11 @@ #!/usr/bin/env bash -# The core anti-brick test: build a fake printer rootfs inside a container, -# ACTUALLY RUN the installer against it, then assert the printer would still -# boot afterwards. +# The core anti-brick test: run the installer against a fake printer and +# assert the machine would still boot. # -# A container is disposable, so the install scripts can use their real -# absolute paths (/usr/prog, /usr/data) with no sandbox gymnastics -- and it -# behaves identically on a laptop and on a GitHub Actions runner. -# -# ./test/sim-install.sh +# Runs in a container with a modelled printer filesystem, so it works in CI +# with no proprietary firmware. The stock installer gates on `uname -m` = +# mips, which is stubbed here; running the payload under the printer's real +# MIPS busybox is test/test-ash-conformance.sh's job. set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" PKG="${1:?usage: sim-install.sh }" @@ -20,6 +18,10 @@ command -v $DOCKER >/dev/null 2>&1 || { echo " SKIP: docker not available"; exi $DOCKER info >/dev/null 2>&1 || { echo " SKIP: docker daemon not running"; exit 0; } PKG_ABS="$(cd "$(dirname "$PKG")" && pwd)/$(basename "$PKG")" +# The installer itself is arch-neutral shell, so it is exercised here in a +# Debian container with a modelled printer filesystem. The printer's REAL +# busybox ash is used separately by test/test-ash-conformance.sh to parse +# every payload script -- that is where MIPS/busybox fidelity comes from. $DOCKER run --rm -i \ -v "$PKG_ABS:/pkg.tgz:ro" \ @@ -34,7 +36,6 @@ export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 apt-get install -y -qq openssl xz-utils >/dev/null 2>&1 -# ------------------------------------------------ fake printer filesystem mkdir -p /usr/prog/bin /usr/prog/etc /usr/prog/modules \ /usr/prog/klipper/klippy/extras /usr/prog/klipper/klippy/kinematics \ /usr/prog/nginx/sbin /usr/prog/nginx/conf \ @@ -58,6 +59,7 @@ A printf 'root:x:0:0:root:/root:/bin/sh\n' > /usr/prog/etc/passwd printf 'root:$1$stockstock$aaaaaaaaaaaaaaaaaaaaa0:20603::::::\n'> /usr/prog/etc/shadow printf 'STOCK-FIRMWAREEXE\n' > /usr/prog/PROGRAM/software/firmwareExe +chmod +x /usr/prog/PROGRAM/software/firmwareExe printf 'STOCK-FIRMWAREEXE\n' > /usr/prog/PROGRAM/software/1.9.7/firmwareExe printf '# stock klippy\n' > /usr/prog/klipper/klippy/klippy.py printf 'USER-CONFIG-MUST-SURVIVE\n' > /usr/data/config/printer.cfg @@ -66,7 +68,6 @@ printf 'events{}\n' > /usr/prog/nginx/conf/nginx.conf touch /usr/prog/nginx/sbin/nginx; chmod +x /usr/prog/nginx/sbin/nginx chmod +x /usr/prog/app_startup.sh /usr/prog/klipper/start.sh -# hardware-only commands the scripts call -- stub so they reach real logic mkdir -p /usr/local/bin for c in insmod reboot chrt start-stop-daemon killall pgrep cmd_mcu \ checkEboard klipperDaemon moonrakerDaemon dropbear dropbearkey; do @@ -76,12 +77,14 @@ printf '#!/bin/sh\nexit 0\n' > /usr/prog/klipper/klipperDaemon printf '#!/bin/sh\nexit 0\n' > /usr/prog/klipper/moonrakerDaemon printf '#!/bin/sh\nexit 0\n' > /usr/prog/klipper/checkEboard chmod +x /usr/prog/klipper/*Daemon /usr/prog/klipper/checkEboard +# MODEL fidelity only: the stock installer gates on `uname -m` = mips. +printf '#!/bin/sh\n[ "$1" = "-m" ] && { echo mips; exit 0; }\nexec /bin/uname "$@"\n' > /usr/local/bin/uname +chmod +x /usr/local/bin/uname +export PATH=/usr/local/bin:$PATH -# the USB stick, still mounted while the installer runs cp /pkg.tgz "/mnt/Creator5Pro-test.tgz" cp /usr/prog/app_startup.sh /tmp/stock_app_startup -# ------------------------------------ decrypt exactly the way unTar does if openssl des3 -d -k "$FF_KEY" -salt -md md5 -in /pkg.tgz 2>/dev/null \ | tar -xf - -C /usr/data/update; then ok "decrypted into /usr/data/update (unTar path)" @@ -89,7 +92,6 @@ else bad "decrypt failed -- the printer's unTar would reject this"; exit 1 fi -# ------------------------------------------------------- run the installer chmod +x /usr/data/update/runFirmwareExe.sh 2>/dev/null /usr/data/update/runFirmwareExe.sh Creator5Pro 0029 >/tmp/install.log 2>&1 RC=$? @@ -99,88 +101,61 @@ else bad "installer exited $RC"; tail -25 /tmp/install.log | sed 's/^/ /' echo echo " -- would the printer still boot? --" -# 1. The mod must add NOTHING to the boot scripts. (A firmware package always -# ships its own app_startup.sh -- that is stock behaviour -- so we assert -# it matches the one in the package and carries none of our markers.) mkdir -p /tmp/ref && tar -xf /usr/data/update/software-*.tar.xz -C /tmp/ref ./app_startup.sh 2>/dev/null if [ -f /tmp/ref/app_startup.sh ] && diff -q /tmp/ref/app_startup.sh /usr/prog/app_startup.sh >/dev/null 2>&1; then ok "app_startup.sh matches the package's own copy (not modified by the mod)" else bad "app_startup.sh differs from the package copy" fi -if grep -q 'anvil' /usr/prog/app_startup.sh; then - bad "app_startup.sh carries mod markers -- boot scripts should be untouched" -else - ok "app_startup.sh carries NO mod markers (mod replaces firmwareExe instead)" -fi +grep -q 'anvil' /usr/prog/app_startup.sh \ + && bad "app_startup.sh carries mod markers -- boot scripts should be untouched" \ + || ok "app_startup.sh carries NO mod markers (mod replaces firmwareExe instead)" grep -q 'gt9xx_touch' /usr/prog/app_startup.sh \ && ok "touchscreen driver still loaded" || bad "BRICK: gt9xx_touch insmod lost" -# 2. something must exist at the path app_startup.sh executes... FE=/usr/prog/PROGRAM/software/firmwareExe -[ -s "$FE" ] && ok "firmwareExe present where app_startup.sh runs it" \ - || bad "BRICK: $FE missing" +[ -s "$FE" ] && ok "firmwareExe present where app_startup.sh runs it" || bad "BRICK: $FE missing" [ -x "$FE" ] && ok "firmwareExe is executable" || bad "BRICK: $FE not executable" if head -c 2 "$FE" 2>/dev/null | grep -q '#!'; then - sh -n "$FE" && ok "firmwareExe wrapper is valid shell" \ - || bad "BRICK: firmwareExe wrapper has a syntax error" - # ...and the real binary must be preserved as the fallback UI + sh -n "$FE" && ok "firmwareExe wrapper is valid shell" || bad "BRICK: wrapper syntax error" [ -s /usr/prog/PROGRAM/software/firmwareExe.stock ] \ && ok "original firmwareExe preserved as firmwareExe.stock" \ || bad "BRICK: wrapper installed but the stock UI binary is gone" fi -# 2b. If a mod payload was installed, something must own Klipper startup: -# stock firmwareExe was the ONLY thing that ran start.sh, so replacing it -# must not silently drop Klipper. (The probe profile installs no payload -# and leaves the stock binary in place, so this does not apply.) if [ -d /usr/data/mod/init.d ]; then grep -rq 'start\.sh' /usr/data/mod/init.d/ 2>/dev/null \ && ok "a service owns Klipper startup (start.sh is invoked)" \ || bad "nothing starts Klipper -- UI would boot with no motion/heaters" elif head -c 2 "$FE" 2>/dev/null | grep -q '#!'; then - bad "firmwareExe was replaced but no service dir exists to start Klipper" + bad "firmwareExe replaced but no service dir exists to start Klipper" else - ok "no mod payload and stock firmwareExe intact (probe profile) -- Klipper unaffected" + ok "no mod payload and stock firmwareExe intact (probe profile)" fi -# 3. klipper must still be loadable -[ -f /usr/prog/klipper/klippy/klippy.py ] \ - && ok "klippy.py present" \ - || bad "klippy.py missing -- printer boots but cannot print" - -# 4. user data untouched +[ -f /usr/prog/klipper/klippy/klippy.py ] && ok "klippy.py present" || bad "klippy.py missing" grep -q 'USER-CONFIG-MUST-SURVIVE' /usr/data/config/printer.cfg 2>/dev/null \ - && ok "user printer.cfg preserved" \ - || bad "user printer.cfg was clobbered" + && ok "user printer.cfg preserved" || bad "user printer.cfg was clobbered" -# 5. every replaced stock file must be recoverable if [ -d /usr/data/mod/backup ]; then n=$(find /usr/data/mod/backup -type f | wc -l) [ "$n" -gt 0 ] && ok "installer wrote $n backup file(s)" || bad "backup dir empty" - [ -d /usr/data/mod/backup/stock ] \ - && ok "pristine backup snapshot kept at backup/stock" \ - || bad "no pristine backup snapshot" + [ -d /usr/data/mod/backup/stock ] && ok "pristine snapshot at backup/stock" || bad "no pristine snapshot" fi -# 6. anything we dropped on the printer must be valid sh for f in /usr/data/mod/init.d/S60web /usr/data/mod/init.d/S70klipper \ /usr/data/mod/init.d/S80ui /usr/prog/klipper/start.sh; do [ -f "$f" ] || continue sh -n "$f" 2>/dev/null && ok "valid shell: $f" || bad "syntax error in $f" done -# 7. second install must be safe (users re-flash; installs must be idempotent) cp /usr/prog/app_startup.sh /tmp/after1 rm -rf /usr/data/update; mkdir -p /usr/data/update openssl des3 -d -k "$FF_KEY" -salt -md md5 -in /pkg.tgz 2>/dev/null | tar -xf - -C /usr/data/update /usr/data/update/runFirmwareExe.sh Creator5Pro 0029 >/tmp/install2.log 2>&1 -if diff -q /tmp/after1 /usr/prog/app_startup.sh >/dev/null 2>&1; then - ok "re-install is idempotent (no duplicated hook)" -else - bad "re-install changed app_startup.sh again -- hook is accumulating" - diff /tmp/after1 /usr/prog/app_startup.sh | head -10 | sed 's/^/ /' -fi +diff -q /tmp/after1 /usr/prog/app_startup.sh >/dev/null 2>&1 \ + && ok "re-install is idempotent" \ + || { bad "re-install changed app_startup.sh again"; diff /tmp/after1 /usr/prog/app_startup.sh | head -8 | sed 's/^/ /'; } echo [ "$FAIL" = 0 ] && echo " install simulation clean" || echo " INSTALL SIMULATION FAILED" diff --git a/test/test-ash-conformance.sh b/test/test-ash-conformance.sh new file mode 100644 index 0000000..3b3fae3 --- /dev/null +++ b/test/test-ash-conformance.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Parse every on-printer script with the PRINTER'S OWN busybox ash. +# +# The payload runs under busybox 1.31.1 ash on MIPS, not under bash or dash. +# `sh -n` on a build machine catches only some of the difference. Here we run +# the genuine /bin/busybox out of the printer's real rootfs under qemu-mipsel, +# so a construct that busybox rejects is caught before it ever reaches the +# machine -- where a parse error in firmwareExe means a blank screen. +# +# No binfmt registration is needed: `busybox sh -n` is a single process and +# forks nothing. +# +# Needs work/rootfs (bin/extract-rootfs.sh). Skips cleanly without it, so CI +# -- which has no proprietary firmware -- still passes. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IMAGE="${SIM_IMAGE:-debian:bookworm-slim}" +RFS="$ROOT/work/rootfs" + +DOCKER=docker +command -v docker >/dev/null 2>&1 || DOCKER=docker.exe +command -v $DOCKER >/dev/null 2>&1 || { echo " SKIP: docker not available"; exit 0; } +$DOCKER info >/dev/null 2>&1 || { echo " SKIP: docker daemon not running"; exit 0; } +[ -f "$RFS/bin/busybox" ] || { + echo " SKIP: no work/rootfs -- run 'make rootfs' to extract the real one" + exit 0 +} + +$DOCKER run --rm -i \ + -v "$RFS:/pr:ro" \ + -v "$ROOT/payload:/payload:ro" \ + "$IMAGE" sh -s <<'INNER' +set -u +FAIL=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=1; } + +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq >/dev/null 2>&1 +apt-get install -y -qq qemu-user-static >/dev/null 2>&1 + +ASH="qemu-mipsel-static -L /pr /pr/bin/busybox" + +V=$($ASH 2>&1 | head -1) +echo " using: $(echo "$V" | cut -c1-46)" +[ "$($ASH uname -m 2>/dev/null)" = mips ] \ + && ok "genuine MIPS userland (uname -m = mips)" \ + || bad "not running the printer userland" + +echo +for f in /payload/firmwareExe /payload/start.sh /payload/run-pre.sh \ + /payload/run-append.sh /payload/report.sh /payload/init.d/S*; do + [ -f "$f" ] || continue + n=$(basename "$f") + if $ASH sh -n "$f" 2>/tmp/err; then + ok "parses under busybox ash: $n" + else + bad "busybox ash REJECTS $n" + head -3 /tmp/err | sed 's/^/ /' + fi +done + +echo +echo " -- applet flags the payload depends on --" +# The stock installer uses `md5sum -s -c`, which GNU coreutils spells +# --status. Confirm the printer's busybox really accepts -s. +if $ASH md5sum -s -c /dev/null 2>/dev/null; then + ok "busybox md5sum accepts -s (used by the stock installer's gate)" +else + echo " (md5sum -s on empty input is inconclusive; not a failure)" +fi +$ASH ps >/dev/null 2>&1 && ok "busybox ps available (UI liveness check)" \ + || bad "no busybox ps -- the UI liveness check degrades" +$ASH head -c 2 /pr/bin/busybox >/dev/null 2>&1 \ + && ok "busybox head -c available (used to detect our wrapper script)" \ + || bad "busybox head -c missing" + +echo +[ "$FAIL" = 0 ] && echo " payload is busybox-ash clean" || echo " ASH CONFORMANCE FAILED" +exit $FAIL +INNER