diff --git a/Justfile b/Justfile index b0156a3..2d55782 100644 --- a/Justfile +++ b/Justfile @@ -78,3 +78,13 @@ verify-stateful-drivers: verify-cups-patch-chain: tests/cups-patch-chain.sh + +verify: + just validate + just verify-cups-patch-chain + just verify-core + just verify-payload + just verify-raster-drivers + just verify-packaged-drivers + just verify-stateful-drivers + tests/appliance-parity.sh diff --git a/docs/oci-physical-validation.md b/docs/oci-physical-validation.md new file mode 100644 index 0000000..6c956ab --- /dev/null +++ b/docs/oci-physical-validation.md @@ -0,0 +1,63 @@ +# OCI physical printer validation + +`just verify` proves the automated appliance contract with synthetic jobs. It does not prove USB enumeration, network discovery, printer firmware behavior, ink or toner output, media handling, or device-specific color. Record those results only after testing real hardware. + +## Automated baseline + +Run: + +```bash +just verify +``` + +The x86_64 image has a 500 MiB (524,288,000-byte) uncompressed ceiling. The implementation baseline measured 449,525,807 bytes on 2026-09-16. `tests/appliance-parity.sh` measures the local image through Podman and fails above that ceiling. The gate also checks OCI metadata, payload inventory, absence of development content, interpreter policy, and every ELF dependency. + +The OCI driver contract is the complete list under [Contained Printer Drivers](../README.md#contained-printer-drivers-in-the-snap), not a sample. Despite the historical heading, the FSDK image must retain every listed Ghostscript/Foomatic driver and every named external driver family. The parity gate requires each legacy Ghostscript name as either a compiled Ghostscript device or an exact Foomatic PPD entry, then separately checks all external driver, filter, backend, and PPD-provider families. + +## USB printer + +1. Build the exact revision under test with `just build`. +2. Connect and power on the printer. Confirm the host sees it before starting the container. +3. Start the appliance with host networking, persistent state, and USB access: + + ```bash + mkdir -p .state/physical-usb + podman unshare chown -R 65532:65532 .state/physical-usb + podman run --rm --name ghostscript-printer-app-usb \ + --network host \ + --device /dev/bus/usb \ + --group-add keep-groups \ + -e PORT=18080 \ + -v "$PWD/.state/physical-usb:/var/lib/ghostscript-printer-app:Z" \ + ghcr.io/projectbluefin/ghostscript-printer-app:build + ``` + +4. Open `http://127.0.0.1:18080`, add the discovered USB device, and select its intended driver rather than a generic substitute. +5. Print the built-in test page. Confirm that the job completes, paper and resolution match the selected options, graphics and text are complete, and the device reports no protocol or filter error. +6. Restart the same command and confirm that the printer and any edited profile or configuration remain present. + +If rootless device access is denied, fix host udev/group permissions. Do not validate with a privileged container because that hides the shipping access model. + +## Network printer + +1. Build the exact revision under test with `just build`. +2. Start the appliance without USB access: + + ```bash + mkdir -p .state/physical-network + podman unshare chown -R 65532:65532 .state/physical-network + podman run --rm --name ghostscript-printer-app-network \ + --network host \ + -e PORT=18081 \ + -v "$PWD/.state/physical-network:/var/lib/ghostscript-printer-app:Z" \ + ghcr.io/projectbluefin/ghostscript-printer-app:build + ``` + +3. Open `http://127.0.0.1:18081`. Confirm DNS-SD discovery when the printer advertises itself; otherwise add its `socket://`, `ipp://`, or `ipps://` address manually. +4. Select the intended driver and print the built-in test page. +5. Confirm completed job state, physical output, selected media/resolution, and absence of backend or filter errors. +6. Restart with the same state directory and repeat one print to prove persisted configuration. + +## Result record + +Record the image digest, commit, date, printer make/model, connection type, device URI, selected driver, tested options, restart result, and observed output. Mark USB and network separately. A synthetic CI pass must never be recorded as physical validation. diff --git a/docs/superpowers/plans/2026-09-16-fsdk-appliance-parity.md b/docs/superpowers/plans/2026-09-16-fsdk-appliance-parity.md new file mode 100644 index 0000000..154a116 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-fsdk-appliance-parity.md @@ -0,0 +1,31 @@ +# FSDK Appliance Parity Implementation Plan + +**Goal:** Provide one local command that proves the complete FSDK OCI appliance contract against the real built image, while keeping physical printer validation explicit and honest. + +**Architecture:** Keep the existing slice-specific smoke tests as the owners of conversion and lifecycle behavior. Add one final image-audit script for cross-cutting invariants: OCI metadata, uncompressed size, runtime bloat, interpreter/toolchain policy, complete ELF closure, and the advertised backend/filter/driver/PPD-family inventory. Expose an aggregate `just verify` recipe that runs graph validation, every slice gate, and the final audit. Strip the residual FSDK debug payload during OCI layer assembly rather than adding another compose layer. + +## Task 1: Enforce final image invariants + +- [x] Remove `/usr/lib/debug` from the assembled OCI layer; keep licenses and runtime data. +- [x] Add `tests/appliance-parity.sh` with a 500 MiB uncompressed x86_64 image ceiling and exact OCI user, entrypoint, architecture, source, license, title, application-version, and FSDK-version checks. +- [x] Reject package managers, compilers, build systems, headers, debug files, non-license test trees, and static archives in the final image. +- [x] Require Bash and Python as the only application interpreters, and reject Perl, Ruby, Node, Lua, and Tcl runtimes. +- [x] Walk every ELF file in the running image and fail if any shared-library dependency is unresolved. + +## Task 2: Enforce advertised payload parity + +- [x] Require the complete CUPS backend set used by the appliance: DNS-SD, IPP/IPPS, LPD, SNMP, socket, and USB. +- [x] Require every README-advertised Ghostscript/Foomatic driver plus executables and CUPS filters for HPIJS, pnm2ppa, pxljr, foo2zjs, SpliX, brlaser, fxlinuxprint, c2esp, rastertosag-gdi, Dymo, P-Touch, c2050, cjet, min12xxw, m2300w, CUPS, and cups-filters. +- [x] Require every PPD provider family: core cups-filters/Foomatic/manufacturer data plus pxljr, foo2zjs, SpliX, brlaser, fxlinuxprint, c2esp, rastertosag-gdi, Dymo, P-Touch, OKI, and m2300w. +- [x] Add `just verify` as the single aggregate command running graph validation, the CUPS patch-chain proof, all real-image behavior gates, and the final parity audit. + +## Task 3: Document physical validation + +- [x] Add `docs/oci-physical-validation.md` with separate USB and network-printer procedures, the automated size ceiling, expected discovery/printing observations, and a result-record template. +- [x] State explicitly that CI and local synthetic gates do not prove physical printer behavior. + +## Task 4: Verify and publish + +- [x] Run `just verify`, workflow lint, shell syntax checks, and `git diff --check`. +- [x] Review the full diff from `feat/fsdk-stateful-drivers` and resolve all blocking findings. +- [x] Resolve issue 07, commit and push `feat/fsdk-appliance-parity`, and open a stacked PR based on `feat/fsdk-stateful-drivers`. diff --git a/elements/oci/ghostscript-printer-app.bst b/elements/oci/ghostscript-printer-app.bst index 9cf7a30..aa660d5 100644 --- a/elements/oci/ghostscript-printer-app.bst +++ b/elements/oci/ghostscript-printer-app.bst @@ -22,6 +22,8 @@ config: 'nonroot:x:65532:' \ >> /layer/etc/group rm -rf /layer/run/dbus /layer/run/avahi-daemon /layer/run/ghostscript-printer-app + rm -rf /layer/usr/lib/debug + rm -f /layer/usr/bin/cupsd /layer/usr/bin/ippeveprinter chmod 0777 /layer/run install -d -m 1777 /layer/tmp install -d -m 0777 \ diff --git a/tests/appliance-parity.sh b/tests/appliance-parity.sh new file mode 100755 index 0000000..5384a61 --- /dev/null +++ b/tests/appliance-parity.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -euo pipefail + +podman_binary="$(command -v podman)" +if ! "$podman_binary" info >/dev/null 2>&1; then + podman() { sudo "$podman_binary" "$@"; } +fi + +image="ghcr.io/projectbluefin/ghostscript-printer-app:build" +size_limit_bytes="${IMAGE_SIZE_LIMIT_BYTES:-524288000}" + +just build + +advertised_ghostscript_drivers="$(python3 - <<'PY' +import pathlib +import re + +readme = pathlib.Path("README.md").read_text() +match = re.search( + r"### Contained Printer Drivers.*?- \*\*Ghostscript built-in\*\*:\s*```(.*?)```", + readme, + re.DOTALL, +) +if match is None: + raise SystemExit("FAIL: README Ghostscript driver inventory is missing") +print(" ".join(match.group(1).replace(",", " ").split())) +PY +)" + +fsdk_version="$(python3 - <<'PY' +import pathlib +import re + +junction = pathlib.Path("elements/freedesktop-sdk.bst").read_text() +match = re.search(r"ref: freedesktop-sdk-(.+?)-0-g[0-9a-f]{40}$", junction, re.MULTILINE) +if match is None: + raise SystemExit("FAIL: pinned freedesktop-sdk release is missing") +print(match.group(1)) +PY +)" + +size_bytes="$(podman image inspect "$image" --format '{{.Size}}')" +if ((size_bytes > size_limit_bytes)); then + printf 'FAIL: uncompressed image is %s bytes; limit is %s bytes\n' "$size_bytes" "$size_limit_bytes" >&2 + exit 1 +fi + +case "$(uname -m)" in + x86_64) expected_arch=amd64 ;; + aarch64) expected_arch=arm64 ;; + *) printf 'FAIL: unsupported verification architecture %s\n' "$(uname -m)" >&2; exit 1 ;; +esac + +test "$(podman image inspect "$image" --format '{{.Architecture}}')" = "$expected_arch" +test "$(podman image inspect "$image" --format '{{.Config.User}}')" = 65532:65532 +test "$(podman image inspect "$image" --format '{{json .Config.Entrypoint}}')" = '["/usr/bin/catatonit","--","/usr/bin/bash","/usr/libexec/ghostscript-printer-app/container-entrypoint"]' +test "$(podman image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.title"}}')" = ghostscript-printer-app +test "$(podman image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.source"}}')" = https://github.com/projectbluefin/ghostscript-printer-app +test "$(podman image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.licenses"}}')" = Apache-2.0 +application_version="$(podman run --rm --entrypoint /usr/bin/ghostscript-printer-app "$image" --version)" +test "$(podman image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = "$application_version" +test "$(podman image inspect "$image" --format '{{index .Config.Labels "io.projectbluefin.fsdk.version"}}')" = "$fsdk_version" + +podman run --rm --user 0:0 --entrypoint /usr/bin/bash \ + -e ADVERTISED_GHOSTSCRIPT_DRIVERS="$advertised_ghostscript_drivers" \ + "$image" -c ' + set -euo pipefail + + backends=(dnssd ipp ipps lpd snmp socket usb) + for backend in "${backends[@]}"; do + test -x "/usr/lib/cups/backend/$backend" + done + + filters=( + c2esp c2espC command2esp command2foo2lava-pjl + foomatic-rip gstoraster pdftopdffx pdftops + pstoqpdl raster2dymolm raster2dymolw rastertobrlaser + rastertoepson rastertoescpx rastertohp rastertolabel + rastertookidotmatrix rastertookimonochrome rastertopclx + rastertoptch rastertoqpdl rastertosag-gdi + ) + for filter in "${filters[@]}"; do + test -x "/usr/lib/cups/filter/$filter" + done + + commands=( + c2050 cjet foo2zjs-wrapper gs hpijs m2300w-wrapper + min12xxw pnm2ppa psnup ijs_pxljr + ) + for command in "${commands[@]}"; do + command -v "$command" >/dev/null + done + + ppd_providers=( + KodakESP_16.drv KodakESP_C_07.drv brlaser.drv + cups-filters-ppds dymo-ppds foo2zjs-ppds foomatic-ppds + fxlinuxprint-ppds m2300w-ppds manufacturer-ppds oki-ppds + ptouch-ppds pxljr-ppds rastertosag-gdi-ppds splix-ppds + ) + for provider in "${ppd_providers[@]}"; do + test -e "/usr/share/ppd/$provider" + done + + provider_contains() { + local path="$1" marker="${2,,}" contents + if [[ -x "$path" ]]; then + contents="$("$path" list)" + else + contents="$(cat "$path")" + fi + if [[ "${contents,,}" != *"$marker"* ]]; then + printf "FAIL: %s does not contain advertised family %s\n" "$path" "$2" >&2 + exit 1 + fi + } + provider_contains /usr/share/ppd/KodakESP_16.drv Kodak + provider_contains /usr/share/ppd/KodakESP_C_07.drv Kodak + provider_contains /usr/share/ppd/brlaser.drv Brother + provider_contains /usr/share/ppd/cups-filters-ppds "PCL 6 CUPS" + provider_contains /usr/share/ppd/dymo-ppds Dymo + provider_contains /usr/share/ppd/foo2zjs-ppds Minolta + provider_contains /usr/share/ppd/foomatic-ppds Foomatic + provider_contains /usr/share/ppd/fxlinuxprint-ppds "Fuji Xerox" + provider_contains /usr/share/ppd/m2300w-ppds "KONICA MINOLTA" + for manufacturer in Gestetner InfoPrint Infotec Lanier NRG Ricoh Savin Samsung; do + provider_contains /usr/share/ppd/manufacturer-ppds "$manufacturer" + done + provider_contains /usr/share/ppd/oki-ppds Oki + provider_contains /usr/share/ppd/ptouch-ppds Brother + provider_contains /usr/share/ppd/pxljr-ppds "HP Color LaserJet" + provider_contains /usr/share/ppd/rastertosag-gdi-ppds Ricoh + provider_contains /usr/share/ppd/splix-ppds Samsung + provider_contains /usr/share/cups/drv/sample.drv Intellitech + provider_contains /usr/share/cups/drv/sample.drv Zebra + + devices=" $(gs -h 2>&1 | tr "\n" " ") " + foomatic_entries="$(/usr/share/ppd/foomatic-ppds list)" + read -r -a ghostscript_drivers <<< "$ADVERTISED_GHOSTSCRIPT_DRIVERS" + ((${#ghostscript_drivers[@]} > 0)) + for driver in "${ghostscript_drivers[@]}"; do + if [[ "$devices" != *" $driver "* && "$foomatic_entries" != *"-$driver.ppd\""* ]]; then + printf "FAIL: advertised Ghostscript driver %s has no device or PPD entry\n" "$driver" >&2 + exit 1 + fi + done + + command -v bash >/dev/null + command -v python3 >/dev/null + for interpreter in perl ruby node lua tclsh wish; do + ! command -v "$interpreter" >/dev/null 2>&1 + done + for tool in apt apt-get apk dnf dpkg pacman rpm pip pip3 cc c++ gcc g++ clang make cmake meson ninja pkg-config autoconf automake libtool ld ar as nm objcopy ranlib strip; do + ! command -v "$tool" >/dev/null 2>&1 + done + + python3 - <<"PY" +import os +import subprocess +import sys + +forbidden = [] +unresolved = [] +for root, dirs, files in os.walk("/"): + if root == "/": + dirs[:] = [name for name in dirs if name not in {"dev", "proc", "run", "sys", "tmp"}] + if root.startswith("/usr/share/licenses/"): + dirs[:] = [] + continue + if root == "/usr/include" or root.startswith("/usr/include/"): + forbidden.extend(os.path.join(root, name) for name in files) + if root == "/usr/lib/debug" or root.startswith("/usr/lib/debug/"): + forbidden.extend(os.path.join(root, name) for name in files) + for directory in dirs: + if directory.lower() in {"test", "tests", "testing"}: + forbidden.append(os.path.join(root, directory)) + for name in files: + path = os.path.join(root, name) + if name.endswith((".a", ".la")): + forbidden.append(path) + if os.path.islink(path): + continue + try: + with open(path, "rb") as stream: + is_elf = stream.read(4) == b"\x7fELF" + except OSError as error: + unresolved.append(f"{path}: audit failed: {error}") + continue + if not is_elf: + continue + result = subprocess.run( + ["/usr/bin/ldd", path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + output = result.stdout.strip() + if "not found" in output: + unresolved.append(f"{path}: {output}") + elif result.returncode != 0 and "not a dynamic executable" not in output and "statically linked" not in output: + unresolved.append(f"{path}: ldd exited {result.returncode}: {output}") + +if forbidden: + print("FAIL: forbidden runtime payload:\n" + "\n".join(forbidden), file=sys.stderr) +if unresolved: + print("FAIL: unresolved ELF dependencies:\n" + "\n".join(unresolved), file=sys.stderr) +if forbidden or unresolved: + raise SystemExit(1) +PY +' + +printf 'OK: complete appliance inventory, metadata, size, and runtime closure (%s bytes)\n' "$size_bytes" diff --git a/tests/core-appliance.sh b/tests/core-appliance.sh index a28c47e..4b63fda 100755 --- a/tests/core-appliance.sh +++ b/tests/core-appliance.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +podman_binary="$(command -v podman)" +if ! "$podman_binary" info >/dev/null 2>&1; then + podman() { sudo "$podman_binary" "$@"; } +fi + image="ghcr.io/projectbluefin/ghostscript-printer-app:build" name="ghostscript-printer-app-smoke" failure_name="ghostscript-printer-app-child-failure" diff --git a/tests/core-payload.sh b/tests/core-payload.sh index 30cf11a..961a5a5 100755 --- a/tests/core-payload.sh +++ b/tests/core-payload.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +podman_binary="$(command -v podman)" +if ! "$podman_binary" info >/dev/null 2>&1; then + podman() { sudo "$podman_binary" "$@"; } +fi + image="ghcr.io/projectbluefin/ghostscript-printer-app:build" name="ghostscript-printer-app-payload" port="${PORT:-18010}" diff --git a/tests/packaged-drivers.sh b/tests/packaged-drivers.sh index 8832f69..17cd87f 100755 --- a/tests/packaged-drivers.sh +++ b/tests/packaged-drivers.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +podman_binary="$(command -v podman)" +if ! "$podman_binary" info >/dev/null 2>&1; then + podman() { sudo "$podman_binary" "$@"; } +fi + image="ghcr.io/projectbluefin/ghostscript-printer-app:build" name="ghostscript-printer-app-packaged-drivers" port="${PORT:-18030}" diff --git a/tests/standalone-raster-drivers.sh b/tests/standalone-raster-drivers.sh index 31045ef..11afa01 100755 --- a/tests/standalone-raster-drivers.sh +++ b/tests/standalone-raster-drivers.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +podman_binary="$(command -v podman)" +if ! "$podman_binary" info >/dev/null 2>&1; then + podman() { sudo "$podman_binary" "$@"; } +fi + image="ghcr.io/projectbluefin/ghostscript-printer-app:build" name="ghostscript-printer-app-raster-drivers" port="${PORT:-18020}" diff --git a/tests/stateful-drivers.sh b/tests/stateful-drivers.sh index 7c4aaaa..6e355ac 100755 --- a/tests/stateful-drivers.sh +++ b/tests/stateful-drivers.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +podman_binary="$(command -v podman)" +if ! "$podman_binary" info >/dev/null 2>&1; then + podman() { sudo "$podman_binary" "$@"; } +fi + image="ghcr.io/projectbluefin/ghostscript-printer-app:build" name="ghostscript-printer-app-stateful-drivers" port="${PORT:-18040}"