From 11a370891eddbd2bb58e8953c842f8bf8a0d77c7 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 8 Sep 2026 22:55:16 +0200 Subject: [PATCH 01/11] Add experimental native on-printer HID hook installer --- .github/workflows/on-printer.yml | 75 ++++++++++ .gitignore | 3 + on-printer/Cargo.lock | 7 + on-printer/Cargo.toml | 12 ++ on-printer/generate_payload.py | 51 +++++++ on-printer/installer.sh.in | 136 +++++++++++++++++ on-printer/payload/installer.sh | 248 +++++++++++++++++++++++++++++++ on-printer/src/main.rs | 144 ++++++++++++++++++ on-printer/src/protocol.rs | 121 +++++++++++++++ on-printer/src/usb.rs | 189 +++++++++++++++++++++++ tests/test_on_printer.py | 135 +++++++++++++++++ 11 files changed, 1121 insertions(+) create mode 100644 .github/workflows/on-printer.yml create mode 100644 on-printer/Cargo.lock create mode 100644 on-printer/Cargo.toml create mode 100644 on-printer/generate_payload.py create mode 100644 on-printer/installer.sh.in create mode 100644 on-printer/payload/installer.sh create mode 100644 on-printer/src/main.rs create mode 100644 on-printer/src/protocol.rs create mode 100644 on-printer/src/usb.rs create mode 100644 tests/test_on_printer.py diff --git a/.github/workflows/on-printer.yml b/.github/workflows/on-printer.yml new file mode 100644 index 0000000..9e856ca --- /dev/null +++ b/.github/workflows/on-printer.yml @@ -0,0 +1,75 @@ +name: On-printer HID tool +on: + push: + pull_request: + release: + types: [published] +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - name: Check embedded canonical payload and shell contracts + run: | + python on-printer/generate_payload.py --check + python -m unittest discover -s tests -p test_on_printer.py -v + sh -n on-printer/payload/installer.sh + - name: Install native build tools + run: | + rustup toolchain install 1.85.1 --profile minimal --component clippy --target armv7-unknown-linux-musleabihf + sudo apt-get update + sudo apt-get install -y gcc-arm-linux-gnueabihf qemu-user + - name: Test native protocol and compile static ARMv7 binary + env: + CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-gcc + CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_RUNNER: qemu-arm + run: | + cargo +1.85.1 test --locked --manifest-path on-printer/Cargo.toml + cargo +1.85.1 clippy --locked --manifest-path on-printer/Cargo.toml --all-targets -- -D warnings + cargo +1.85.1 test --locked --manifest-path on-printer/Cargo.toml --target armv7-unknown-linux-musleabihf + cargo +1.85.1 build --locked --manifest-path on-printer/Cargo.toml --release --target armv7-unknown-linux-musleabihf + - name: Check static linking and smoke-test executable + run: | + mkdir -p dist + cp on-printer/target/armv7-unknown-linux-musleabihf/release/cc2camera-hid dist/cc2camera-hid-armv7-linux + file dist/cc2camera-hid-armv7-linux + if readelf -l dist/cc2camera-hid-armv7-linux | grep -q INTERP; then exit 1; fi + if readelf -d dist/cc2camera-hid-armv7-linux | grep -q NEEDED; then exit 1; fi + qemu-arm dist/cc2camera-hid-armv7-linux --help + if qemu-arm dist/cc2camera-hid-armv7-linux install; then exit 1; fi + cd dist + sha256sum cc2camera-hid-armv7-linux > cc2camera-hid-armv7-linux.sha256 + wc -c cc2camera-hid-armv7-linux + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cc2camera-hid-armv7-linux + path: | + dist/cc2camera-hid-armv7-linux + dist/cc2camera-hid-armv7-linux.sha256 + if-no-files-found: error + attach: + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cc2camera-hid-armv7-linux + path: dist + - name: Attach to the manually published release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" dist/cc2camera-hid-armv7-linux dist/cc2camera-hid-armv7-linux.sha256 diff --git a/.gitignore b/.gitignore index b8a4258..a9c0c41 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db *.swp *~ *.spec + +# Native on-printer builds +on-printer/target/ diff --git a/on-printer/Cargo.lock b/on-printer/Cargo.lock new file mode 100644 index 0000000..13c34af --- /dev/null +++ b/on-printer/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cc2camera-hid" +version = "0.1.0" diff --git a/on-printer/Cargo.toml b/on-printer/Cargo.toml new file mode 100644 index 0000000..4ad7124 --- /dev/null +++ b/on-printer/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cc2camera-hid" +version = "0.1.0" +edition = "2021" +publish = false + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/on-printer/generate_payload.py b/on-printer/generate_payload.py new file mode 100644 index 0000000..254ef98 --- /dev/null +++ b/on-printer/generate_payload.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Build the native tool's embedded installer from canonical hook bytes. + +Python is required only on the development/build machine, never on the printer. +""" +import hashlib +from pathlib import Path +import sys +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from cc2camera.startup_payloads import RUNNER, erase_hook, KERNEL_MD5 +from cc2camera.adb_backup import EXPECTED_PARTITIONS +from cc2camera.restore_prepare import INSTRUCTIONS, SYMBOLS + +ROOT = Path(__file__).resolve().parent + +def generate(): + fix = erase_hook() + files = {'fix': fix, 'runner': RUNNER} + payloads = '\n'.join( + ' cat > "$STAGE/' + name + '" <<\'CC2_PAYLOAD_END\' || fail payload\n' + + data.decode('ascii') + 'CC2_PAYLOAD_END' + for name, data in files.items() + ) + checks = '\n'.join( + f'[ "$(busybox devmem {a:#x} 32)" = "0x{v:08X}" ] || fail instructions' + for a, v in INSTRUCTIONS.items() + ) + '\n' + '\n'.join( + f'[ "$(busybox awk \'$3=="{n}" {{print $1}}\' /proc/kallsyms)" = "{a:08x}" ] || fail symbols' + for n, a in SYMBOLS.items() + ) + replacements = { + '@KERNEL_MD5@': KERNEL_MD5, + '@HID_MD5@': '8091751fdd4d0d50ea31901663797a86', + '@MTD@': '\n'.join(f'mtd{i}: {size:08x} 00004000 "{name}"' for i, (name,size) in enumerate(EXPECTED_PARTITIONS)), + '@PAYLOADS@': payloads, + '@FIX_MD5@': hashlib.md5(fix).hexdigest(), + '@RUNNER_MD5@': hashlib.md5(RUNNER).hexdigest(), + '@LIVE_CHECKS@': checks, + } + script = (ROOT/'installer.sh.in').read_text() + for key,value in replacements.items(): script=script.replace(key,value) + return script + +if __name__ == '__main__': + path = ROOT/'payload/installer.sh' + data = generate() + if sys.argv[1:] == ['--check']: + if path.read_text() != data: raise SystemExit('Embedded installer is stale; run on-printer/generate_payload.py') + elif not sys.argv[1:]: + path.write_text(data) + else: raise SystemExit('Usage: generate_payload.py [--check]') diff --git a/on-printer/installer.sh.in b/on-printer/installer.sh.in new file mode 100644 index 0000000..39db353 --- /dev/null +++ b/on-printer/installer.sh.in @@ -0,0 +1,136 @@ +#!/bin/sh +# Independently written camera-side installer. Generated payloads are canonical. +PATH=/bin:/sbin:/usr/bin:/usr/sbin +export PATH +LC_ALL=C +export LC_ALL +TOKEN=@TOKEN@ +MODE=@MODE@ +SELF=/tmp/.cc2-hid-$TOKEN.sh +STAGE=/tmp/.cc2-hid-$TOKEN +CONFIG=/etc/conf.d +STATUS=FAIL + +fail() { echo "cc2camera-hid: $*"; exit 1; } +mounted() { + busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts +} +regular() { [ -f "$1" ] && [ ! -L "$1" ]; } +finish() { + # The version query exposes at most 23 bytes. Keep a nonce in every result. + printf '%s:%s\n' "$TOKEN" "$STATUS" > /tmp/version.txt + # Status is RAM-only and available for two minutes, then restore the exact + # original version file. Never clean up incomplete persistent files. + sleep 120 + cp "$STAGE/version" /tmp/version.txt + rm -rf "$STAGE" + rm -f "$SELF" +} +preflight() { + [ "$(id -u)" = 0 ] || fail root + [ ! -L /etc ] && [ ! -L "$CONFIG" ] || fail config-path + mounted || fail config-mount + [ "$(busybox awk '$1=="/dev/mtdblock5" || $2 ~ /^\/etc\/conf.d\// {n++} END {print n+0}' /proc/mounts)" = 1 ] || fail mount-topology + [ "$(busybox sed '1d;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/' /proc/mtd)" = '@MTD@' ] || fail partition-map + [ "$(busybox md5sum /dev/mtd1)" = '@KERNEL_MD5@ /dev/mtd1' ] || fail kernel-fingerprint + [ "$(busybox md5sum /bin/hid_update)" = '@HID_MD5@ /bin/hid_update' ] || fail hid-fingerprint + regular "$CONFIG/serial.cfg" && [ -s "$CONFIG/serial.cfg" ] || fail identity-file + [ ! -L "$CONFIG/enabled" ] && { [ ! -e "$CONFIG/enabled" ] || [ -d "$CONFIG/enabled" ]; } || fail enabled-path + for hook in "$CONFIG"/enabled/*; do + if [ ! -e "$hook" ] && [ ! -L "$hook" ]; then continue; fi + regular "$hook" || fail unrelated-hook-type + done +} +known_or_absent() { + dest=$1 + source=$2 + [ ! -L "$dest" ] || fail managed-link + if [ -e "$dest" ]; then + regular "$dest" && [ "$(busybox stat -c %a "$dest")" = 755 ] && busybox cmp -s "$source" "$dest" || fail unknown-managed-file + fi +} +install_files() { + # Validate both destinations before the first persistent write. + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + if [ -e "$CONFIG/enabled/10-erase-fix.sh" ] && [ -e "$CONFIG/system.sh" ]; then + STATUS=SAME + return + fi + for n in fix runner; do + [ ! -e "$CONFIG/.cc2-hid-$n" ] && [ ! -L "$CONFIG/.cc2-hid-$n" ] || fail incomplete-install + done + # This is a stability check, NOT a backup or clean-space admission check. + baseline=$(busybox md5sum /dev/mtd5) || fail config-read + for n in 1 2; do + [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + done + preflight + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + STATUS=PART + mkdir -p "$CONFIG/enabled" || fail mkdir + # Feature first, boot entry point last. Never overwrite an existing hook. + for n in fix runner; do + case "$n" in + fix) dest=$CONFIG/enabled/10-erase-fix.sh;; + runner) dest=$CONFIG/system.sh;; + esac + known_or_absent "$dest" "$STAGE/$n" + if [ -e "$dest" ]; then continue; fi + temp=$CONFIG/.cc2-hid-$n + [ ! -e "$temp" ] && [ ! -L "$temp" ] || fail incomplete-install + cp "$STAGE/$n" "$temp" || fail copy + chmod 755 "$temp" || fail chmod + busybox cmp -s "$STAGE/$n" "$temp" || fail temporary-readback + sync || fail sync + [ ! -e "$dest" ] && [ ! -L "$dest" ] || fail destination-appeared + mv "$temp" "$dest" || fail rename + sync || fail sync + done + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail final-missing + STATUS=DONE +} +verify_live() { + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail missing-hooks + @LIVE_CHECKS@ + pointer=$(busybox devmem 0x0043b190 32) || fail pointer + case "$pointer" in 0x????????) ;; *) fail pointer-format;; esac + p=$((pointer)) + [ "$p" -ge $((0x80450000)) ] && [ "$p" -le $((0x83fffd9c)) ] && [ "$((p % 4))" -eq 0 ] || fail pointer-range + field=$((p - 0x80000000 + 16)) + [ "$(busybox devmem "$field" 32)" = 0x00001000 ] || fail fix-inactive + [ "$(busybox devmem 0x0043b190 32)" = "$pointer" ] || fail pointer-changed + STATUS=LIVE +} +main() { + # The upload path is a fresh random name under the stock /tmp. Confirm + # tmpfs before any further staging or status writes. + [ ! -L /tmp ] || exit 1 + busybox awk '$2=="/tmp" && $3=="tmpfs" {n++} END {exit(n!=1)}' /proc/mounts || exit 1 + regular "$SELF" || exit 1 + regular /tmp/version.txt || exit 1 + [ "$(busybox stat -c %s /tmp/version.txt)" -le 4096 ] || exit 1 + umask 077 + mkdir "$STAGE" || exit 1 + cp /tmp/version.txt "$STAGE/version" || exit 1 + trap finish 0 + printf '%s:BUSY\n' "$TOKEN" > /tmp/version.txt || fail status + preflight +@PAYLOADS@ + [ "$(busybox md5sum "$STAGE/fix")" = '@FIX_MD5@ '"$STAGE/fix" ] || fail staged-fix + [ "$(busybox md5sum "$STAGE/runner")" = '@RUNNER_MD5@ '"$STAGE/runner" ] || fail staged-runner + case "$MODE" in + install) install_files;; + verify) verify_live;; + *) fail mode;; + esac +} +# All mutation is in functions. A truncated upload lacking this tail cannot +# execute the installer. HID CRC protects each transmitted chunk. +main diff --git a/on-printer/payload/installer.sh b/on-printer/payload/installer.sh new file mode 100644 index 0000000..a05d050 --- /dev/null +++ b/on-printer/payload/installer.sh @@ -0,0 +1,248 @@ +#!/bin/sh +# Independently written camera-side installer. Generated payloads are canonical. +PATH=/bin:/sbin:/usr/bin:/usr/sbin +export PATH +LC_ALL=C +export LC_ALL +TOKEN=@TOKEN@ +MODE=@MODE@ +SELF=/tmp/.cc2-hid-$TOKEN.sh +STAGE=/tmp/.cc2-hid-$TOKEN +CONFIG=/etc/conf.d +STATUS=FAIL + +fail() { echo "cc2camera-hid: $*"; exit 1; } +mounted() { + busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts +} +regular() { [ -f "$1" ] && [ ! -L "$1" ]; } +finish() { + # The version query exposes at most 23 bytes. Keep a nonce in every result. + printf '%s:%s\n' "$TOKEN" "$STATUS" > /tmp/version.txt + # Status is RAM-only and available for two minutes, then restore the exact + # original version file. Never clean up incomplete persistent files. + sleep 120 + cp "$STAGE/version" /tmp/version.txt + rm -rf "$STAGE" + rm -f "$SELF" +} +preflight() { + [ "$(id -u)" = 0 ] || fail root + [ ! -L /etc ] && [ ! -L "$CONFIG" ] || fail config-path + mounted || fail config-mount + [ "$(busybox awk '$1=="/dev/mtdblock5" || $2 ~ /^\/etc\/conf.d\// {n++} END {print n+0}' /proc/mounts)" = 1 ] || fail mount-topology + [ "$(busybox sed '1d;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/' /proc/mtd)" = 'mtd0: 00040000 00004000 "boot" +mtd1: 00150000 00004000 "kernel" +mtd2: 00158000 00004000 "root" +mtd3: 004e8000 00004000 "system" +mtd4: 00010000 00004000 "hwconfig" +mtd5: 00020000 00004000 "config"' ] || fail partition-map + [ "$(busybox md5sum /dev/mtd1)" = '388e256470b2ad70f4a29cc37e0fee32 /dev/mtd1' ] || fail kernel-fingerprint + [ "$(busybox md5sum /bin/hid_update)" = '8091751fdd4d0d50ea31901663797a86 /bin/hid_update' ] || fail hid-fingerprint + regular "$CONFIG/serial.cfg" && [ -s "$CONFIG/serial.cfg" ] || fail identity-file + [ ! -L "$CONFIG/enabled" ] && { [ ! -e "$CONFIG/enabled" ] || [ -d "$CONFIG/enabled" ]; } || fail enabled-path + for hook in "$CONFIG"/enabled/*; do + if [ ! -e "$hook" ] && [ ! -L "$hook" ]; then continue; fi + regular "$hook" || fail unrelated-hook-type + done +} +known_or_absent() { + dest=$1 + source=$2 + [ ! -L "$dest" ] || fail managed-link + if [ -e "$dest" ]; then + regular "$dest" && [ "$(busybox stat -c %a "$dest")" = 755 ] && busybox cmp -s "$source" "$dest" || fail unknown-managed-file + fi +} +install_files() { + # Validate both destinations before the first persistent write. + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + if [ -e "$CONFIG/enabled/10-erase-fix.sh" ] && [ -e "$CONFIG/system.sh" ]; then + STATUS=SAME + return + fi + for n in fix runner; do + [ ! -e "$CONFIG/.cc2-hid-$n" ] && [ ! -L "$CONFIG/.cc2-hid-$n" ] || fail incomplete-install + done + # This is a stability check, NOT a backup or clean-space admission check. + baseline=$(busybox md5sum /dev/mtd5) || fail config-read + for n in 1 2; do + [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + done + preflight + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + STATUS=PART + mkdir -p "$CONFIG/enabled" || fail mkdir + # Feature first, boot entry point last. Never overwrite an existing hook. + for n in fix runner; do + case "$n" in + fix) dest=$CONFIG/enabled/10-erase-fix.sh;; + runner) dest=$CONFIG/system.sh;; + esac + known_or_absent "$dest" "$STAGE/$n" + if [ -e "$dest" ]; then continue; fi + temp=$CONFIG/.cc2-hid-$n + [ ! -e "$temp" ] && [ ! -L "$temp" ] || fail incomplete-install + cp "$STAGE/$n" "$temp" || fail copy + chmod 755 "$temp" || fail chmod + busybox cmp -s "$STAGE/$n" "$temp" || fail temporary-readback + sync || fail sync + [ ! -e "$dest" ] && [ ! -L "$dest" ] || fail destination-appeared + mv "$temp" "$dest" || fail rename + sync || fail sync + done + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail final-missing + STATUS=DONE +} +verify_live() { + known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" + known_or_absent "$CONFIG/system.sh" "$STAGE/runner" + regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail missing-hooks + [ "$(busybox devmem 0x1f06d4 32)" = "0x3C138044" ] || fail instructions +[ "$(busybox devmem 0x1f06e0 32)" = "0x8E64B190" ] || fail instructions +[ "$(busybox devmem 0x1f0764 32)" = "0x8C420010" ] || fail instructions +[ "$(busybox devmem 0x1efca4 32)" = "0x8E220010" ] || fail instructions +[ "$(busybox awk '$3=="recovery_norflash_erase" {print $1}' /proc/kallsyms)" = "801f06cc" ] || fail symbols +[ "$(busybox awk '$3=="jz_spi_norflash_erase_sector" {print $1}' /proc/kallsyms)" = "801efc74" ] || fail symbols +[ "$(busybox awk '$3=="direct_erase_norflash" {print $1}' /proc/kallsyms)" = "801f080c" ] || fail symbols + pointer=$(busybox devmem 0x0043b190 32) || fail pointer + case "$pointer" in 0x????????) ;; *) fail pointer-format;; esac + p=$((pointer)) + [ "$p" -ge $((0x80450000)) ] && [ "$p" -le $((0x83fffd9c)) ] && [ "$((p % 4))" -eq 0 ] || fail pointer-range + field=$((p - 0x80000000 + 16)) + [ "$(busybox devmem "$field" 32)" = 0x00001000 ] || fail fix-inactive + [ "$(busybox devmem 0x0043b190 32)" = "$pointer" ] || fail pointer-changed + STATUS=LIVE +} +main() { + # The upload path is a fresh random name under the stock /tmp. Confirm + # tmpfs before any further staging or status writes. + [ ! -L /tmp ] || exit 1 + busybox awk '$2=="/tmp" && $3=="tmpfs" {n++} END {exit(n!=1)}' /proc/mounts || exit 1 + regular "$SELF" || exit 1 + regular /tmp/version.txt || exit 1 + [ "$(busybox stat -c %s /tmp/version.txt)" -le 4096 ] || exit 1 + umask 077 + mkdir "$STAGE" || exit 1 + cp /tmp/version.txt "$STAGE/version" || exit 1 + trap finish 0 + printf '%s:BUSY\n' "$TOKEN" > /tmp/version.txt || fail status + preflight + cat > "$STAGE/fix" <<'CC2_PAYLOAD_END' || fail payload +#!/bin/sh +# cc2flash erase hook v1; all diagnostics stay in RAM. +fail() { echo "cc2flash: erase fix refused: $*"; exit 1; } +word() { busybox devmem "$1" 32; } +mounted() { busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts; } +[ -n "$CC2_STAGE" ] && [ "$PWD" = / ] || fail runner +busybox pidof ucamera >/dev/null && fail ucamera-running +mounted || fail config-mount +# No other mounts of this filesystem or mounts beneath config. +[ "$(busybox awk '$1=="/dev/mtdblock5" || $2 ~ /^\/etc\/conf.d\// {n++} END {print n+0}' /proc/mounts)" = 1 ] || fail mount-topology +[ "$(busybox sed '1d;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/' /proc/mtd)" = 'mtd0: 00040000 00004000 "boot" +mtd1: 00150000 00004000 "kernel" +mtd2: 00158000 00004000 "root" +mtd3: 004e8000 00004000 "system" +mtd4: 00010000 00004000 "hwconfig" +mtd5: 00020000 00004000 "config"' ] || fail partition-map +[ "$(busybox awk '$3=="recovery_norflash_erase" {print $1}' /proc/kallsyms)" = "801f06cc" ] || fail symbols +[ "$(busybox awk '$3=="jz_spi_norflash_erase_sector" {print $1}' /proc/kallsyms)" = "801efc74" ] || fail symbols +[ "$(busybox awk '$3=="direct_erase_norflash" {print $1}' /proc/kallsyms)" = "801f080c" ] || fail symbols +[ "$(word 0x1f06d4)" = "0x3C138044" ] || fail instructions +[ "$(word 0x1f06e0)" = "0x8E64B190" ] || fail instructions +[ "$(word 0x1f0764)" = "0x8C420010" ] || fail instructions +[ "$(word 0x1efca4)" = "0x8E220010" ] || fail instructions +# Stock BusyBox MD5 is a build fingerprint, not cryptographic authentication. +hash=$(busybox md5sum /dev/mtd1) || fail kernel-read +[ "$hash" = '388e256470b2ad70f4a29cc37e0fee32 /dev/mtd1' ] || fail kernel-hash +pointer=$(word 0x0043b190) || fail pointer +case "$pointer" in 0x????????) ;; *) fail pointer-format;; esac +p=$((pointer)) +[ "$p" -ge $((0x80450000)) ] && [ "$p" -le $((0x83fffd9c)) ] && [ "$((p % 4))" -eq 0 ] || fail pointer-range +field=$((p - 0x80000000 + 16)) +old=$(word "$field") || fail field +case "$old" in 0x00004000|0x00001000) ;; *) fail erase-size;; esac +# Repeat after potentially lengthy hashing. Never remount under ucamera. +busybox pidof ucamera >/dev/null && fail ucamera-running +sync || fail sync +busybox umount /etc/conf.d || fail unmount +# Arrange ordinary recovery mount on every failure after unmount. +trap 'busybox mount -t jffs2 /dev/mtdblock5 /etc/conf.d || echo "cc2flash: recovery mount FAILED"' 0 +busybox awk '$2=="/etc/conf.d" {bad=1} END {exit bad}' /proc/mounts || fail still-mounted +[ "$(word 0x0043b190)" = "$pointer" ] && [ "$(word "$field")" = "$old" ] || fail pointer-changed +[ "$(word 0x1f06d4)" = "0x3C138044" ] || fail instructions +[ "$(word 0x1f06e0)" = "0x8E64B190" ] || fail instructions +[ "$(word 0x1f0764)" = "0x8C420010" ] || fail instructions +[ "$(word 0x1efca4)" = "0x8E220010" ] || fail instructions +if [ "$old" != 0x00001000 ]; then + busybox devmem "$field" 32 0x00001000 || fail ram-write +fi +[ "$(word 0x0043b190)" = "$pointer" ] && [ "$(word "$field")" = 0x00001000 ] || fail readback +busybox mount -t jffs2 /dev/mtdblock5 /etc/conf.d || fail remount +trap - 0 +mounted || fail mount-readback +[ "$(busybox sed '1d;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/' /proc/mtd)" = 'mtd0: 00040000 00004000 "boot" +mtd1: 00150000 00004000 "kernel" +mtd2: 00158000 00004000 "root" +mtd3: 004e8000 00004000 "system" +mtd4: 00010000 00004000 "hwconfig" +mtd5: 00020000 00004000 "config"' ] || fail geometry-changed +[ -s /etc/conf.d/serial.cfg ] || fail serial-missing +echo 'cc2flash: erase fix active; master=0x1000; partition geometry=0x4000' +CC2_PAYLOAD_END + cat > "$STAGE/runner" <<'CC2_PAYLOAD_END' || fail payload +#!/bin/sh +# cc2flash hook runner v1 +PATH=/bin:/sbin:/usr/bin:/usr/sbin +export PATH +LC_ALL=C +export LC_ALL +if [ "$1" != --ram-worker ]; then + # The config script descriptor must close across exec before any unmount. + busybox awk '$2=="/tmp" && $3=="tmpfs" {ok++} END {exit(ok!=1)}' /proc/mounts || exit 1 + umask 077 + stage=$(busybox mktemp -d /tmp/cc2-hooks.XXXXXX) || exit 1 + mkdir "$stage/enabled" || exit 1 + cp /etc/conf.d/system.sh "$stage/runner" || exit 1 + for hook in /etc/conf.d/enabled/*; do + if [ ! -e "$hook" ] && [ ! -L "$hook" ]; then continue; fi + [ -f "$hook" ] && [ ! -L "$hook" ] || exit 1 + cp "$hook" "$stage/enabled/" || exit 1 + done + cd / || exit 1 + exec /bin/sh "$stage/runner" --ram-worker "$stage" >/tmp/cc2-hooks.log 2>&1 + exit 1 +fi +CC2_STAGE=$2 +export CC2_STAGE +for hook in "$CC2_STAGE"/enabled/*; do + [ -f "$hook" ] || continue + echo "cc2flash: running ${hook##*/}" + /bin/sh "$hook" + result=$? + echo "cc2flash: ${hook##*/} exit $result" + # Never release rcS into camera startup with config missing or unusable. + if ! busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts; then + echo 'cc2flash: CONFIG UNUSABLE; boot held. Recover via ADB; do not write config.' + /bin/adbd & + while :; do sleep 60; done + fi +done +rm -rf "$CC2_STAGE" +CC2_PAYLOAD_END + [ "$(busybox md5sum "$STAGE/fix")" = 'c6d5dd18094e9e65fc42e9afa2f40c24 '"$STAGE/fix" ] || fail staged-fix + [ "$(busybox md5sum "$STAGE/runner")" = '057670efd1488e00ec5f3ecfd6343748 '"$STAGE/runner" ] || fail staged-runner + case "$MODE" in + install) install_files;; + verify) verify_live;; + *) fail mode;; + esac +} +# All mutation is in functions. A truncated upload lacking this tail cannot +# execute the installer. HID CRC protects each transmitted chunk. +main diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs new file mode 100644 index 0000000..612b843 --- /dev/null +++ b/on-printer/src/main.rs @@ -0,0 +1,144 @@ +mod protocol; +mod usb; +use std::{io::Read, time::{Duration, Instant}}; +use protocol::{send, upload}; +type Result = std::result::Result>; +const ACCEPT: &str = "--accept-no-backup-space-risk"; +const HELP: &str = "cc2camera-hid: experimental prevention for a working stock CC2 camera + +Run as root on an idle printer. ARMv7 Linux; no Python, ADB, hidraw or shared +libraries required by the static build. Only known camera firmware is supported. + + cc2camera-hid inspect + Read USB descriptors and query the camera version. No camera file writes. + cc2camera-hid install --accept-no-backup-space-risk + Install the persistent erase-fix hook through HID. NO BACKUP is exported, + and safe clean space is NOT checked. A failed write or power loss can leave + the camera unbootable and require an external SPI programmer to recover. + Firmware checks and installed-file comparisons run on the camera. + cc2camera-hid verify + After a successful installation and printer restart, upload a temporary + probe and verify the installed hooks and live RAM correction. No persistent + file or RAM correction writes; temporary files/status are written in /tmp. + +Install/verify launch a camera shell script, temporarily replace its version +response for two minutes, and leave its HID uploader unavailable until restart. +Never retry after an error. A timeout does not cancel a camera-side installer. +This route requires physical validation; a USB acknowledgement is not success. +"; + +#[derive(Debug, PartialEq)] +enum Action { Help, Inspect, Install, Verify } +fn arguments(args: &[String]) -> Result { + match args.iter().map(String::as_str).collect::>().as_slice() { + [] | ["--help"] | ["-h"] => Ok(Action::Help), + ["inspect"] => Ok(Action::Inspect), + ["verify"] => Ok(Action::Verify), + ["install", flag] if *flag==ACCEPT => Ok(Action::Install), + ["install"] => Err(format!("Installation risks an unbootable camera with no exported backup or clean-space check. Read --help; explicit {ACCEPT} is required before USB access.").into()), + _ => Err("unknown arguments; run --help".into()), + } +} +fn script(token: &str, action: &Action) -> Result<(String, String, Vec)> { + if token.len()!=16 || !token.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { + return Err("invalid local session token".into()); + } + let mode=match action { Action::Install=>"install", Action::Verify=>"verify", _=>return Err("invalid script action".into()) }; + let path=format!("/tmp/.cc2-hid-{token}.sh"); + // Literal fopen must fail (the embedded /bin/sh makes a nonexistent directory + // component). Only the preceding shell's rm command launches our worker. + let launch=format!("/tmp/.cc2-launch-{token};/bin/sh${{IFS}}{path}&"); + let data=include_str!("../payload/installer.sh").replace("@TOKEN@",token).replace("@MODE@",mode).into_bytes(); + if launch.len()>127 || data.len()>32768 { return Err("embedded installer exceeds protocol limits".into()); } + Ok((path,launch,data)) +} +fn terminal(payload: &[u8], token: &str, action: &Action) -> Result { + let prefix=format!("{token}:"); + if !payload.starts_with(prefix.as_bytes()) { return Ok(false); } + match &payload[prefix.len()..] { + b"BUSY" => Ok(false), + b"DONE" | b"SAME" if *action==Action::Install => Ok(true), + b"LIVE" if *action==Action::Verify => Ok(true), + b"FAIL" => Err("camera-side checks refused the operation; no persistent installation writes were started. Do not retry this boot.".into()), + b"PART" => Err("installation failed after persistent writes began; camera may be unbootable. Do not retry or restart blindly; preserve power and seek recovery using the documented backup/programmer route.".into()), + _ => Err("unexpected camera operation status".into()), + } +} +fn run() -> Result<()> { + // Parse consent and prepare every outgoing target before touching USB. + let action=arguments(&std::env::args().skip(1).collect::>())?; + if action==Action::Help { print!("{HELP}"); return Ok(()); } + let mut random=[0u8;8]; + let prepared=if matches!(action,Action::Install|Action::Verify) { + std::fs::File::open("/dev/urandom")?.read_exact(&mut random)?; + let token=random.iter().map(|b|format!("{b:02x}")).collect::(); + let payload=script(&token,&action)?; + Some((token,payload)) + } else { None }; + let camera=usb::discover()?; + println!("Camera {}: HID interface {}, input 0x{:02x}, output {:?}",camera.path,camera.interface.number,camera.interface.input,camera.interface.output); + let mut transport=usb::Usb::open(camera)?; + let (status,version)=send(&mut transport,1,&[],1,0)?; + if status!=0 || version.is_empty() || version.len()>23 || !version.iter().all(|b| (0x20..=0x7e).contains(b)) { + return Err("invalid camera version response".into()); + } + println!("Camera version: {}",String::from_utf8_lossy(&version)); + if let Some((token,(path,launch,data)))=prepared { + if action==Action::Install { println!("Accepted: no exported backup or clean-space check; programmer recovery may be required."); } + println!("Uploading temporary camera worker; session {token}. Do not interrupt power."); + upload(&mut transport,path.as_bytes(),&data,false)?; + upload(&mut transport,launch.as_bytes(),b"\n",true)?; + let deadline=Instant::now()+Duration::from_secs(90); + while Instant::now()Vec {a.iter().map(|s|s.to_string()).collect()} + #[test] + fn consent_and_unknown_arguments() { + assert!(arguments(&args(&["install"])).is_err()); + assert!(arguments(&args(&["install","--force"])).is_err()); + assert!(arguments(&args(&["verify",ACCEPT])).is_err()); + assert_eq!(arguments(&args(&["install",ACCEPT])).unwrap(),Action::Install); + assert_eq!(arguments(&args(&["inspect"])).unwrap(),Action::Inspect); + } + #[test] + fn payload_is_fixed_bounded_and_distinct_per_action() { + for action in [Action::Install,Action::Verify] { + let (path,launch,data)=script("0123456789abcdef",&action).unwrap(); + assert!(path.starts_with("/tmp/.cc2-hid-")); + assert!(!launch.contains(' ')); assert!(launch.len()<=127); + assert!(data.len()<32768); assert!(!data.windows(7).any(|w|w==b"@TOKEN@")); + } + assert!(script("../../x;evil",&Action::Install).is_err()); + } + #[test] + fn never_accept_stale_or_wrong_operation_success() { + assert!(!terminal(b"other:DONE","0123456789abcdef",&Action::Install).unwrap()); + assert!(terminal(b"0123456789abcdef:DONE","0123456789abcdef",&Action::Install).unwrap()); + assert!(terminal(b"0123456789abcdef:DONE","0123456789abcdef",&Action::Verify).is_err()); + for status in ["FAIL","PART","unknown"] { + assert!(terminal(format!("0123456789abcdef:{status}").as_bytes(),"0123456789abcdef",&Action::Install).is_err()); + } + } +} diff --git a/on-printer/src/protocol.rs b/on-printer/src/protocol.rs new file mode 100644 index 0000000..43c2e40 --- /dev/null +++ b/on-printer/src/protocol.rs @@ -0,0 +1,121 @@ +//! Independent implementation of the normal-mode HID wire format. +use crate::Result; +pub const SIZE: usize = 1024; +pub const CHUNK: usize = SIZE - 14; + +pub fn crc(data: impl IntoIterator) -> u16 { + let mut value = 0xffffu16; + for byte in data { + value ^= byte as u16; + for _ in 0..8 { + value = if value & 1 != 0 { (value >> 1) ^ 0x1021 } else { value >> 1 }; + } + } + value +} + +pub fn report(command: u16, payload: &[u8], kind: u8, sequence: u32) -> Result<[u8; SIZE]> { + if payload.len() > CHUNK || !matches!(kind, 1 | 2) { return Err("invalid outgoing frame".into()); } + let mut r = [0; SIZE]; + r[..3].copy_from_slice(&[1, 0x5a, 0x5a]); + r[3..5].copy_from_slice(&command.to_le_bytes()); + r[5] = kind; + r[6..8].copy_from_slice(&(payload.len() as u16).to_le_bytes()); + r[8..12].copy_from_slice(&sequence.to_le_bytes()); + r[14..14 + payload.len()].copy_from_slice(payload); + let checksum = crc(r[..12].iter().chain(payload).copied()); + r[12..14].copy_from_slice(&checksum.to_le_bytes()); + Ok(r) +} + +pub fn response(r: &[u8], command: u16) -> Result<(u32, Vec)> { + if r.len() < 14 || r.len() > SIZE || r[..3] != [1, 0x5a, 0x5a] { + return Err("invalid HID response header or length".into()); + } + if u16::from_le_bytes([r[3], r[4]]) != command || r[5] > 2 { + return Err("unexpected HID response command/type".into()); + } + let n = u16::from_le_bytes([r[6], r[7]]) as usize; + if n > CHUNK || n + 14 > r.len() || (r[5] == 0 && n != 0) { + return Err("invalid HID response payload length".into()); + } + let payload = &r[14..14+n]; + if crc(r[..12].iter().chain(payload).copied()) != u16::from_le_bytes([r[12], r[13]]) { + return Err("HID response CRC mismatch".into()); + } + Ok((u32::from_le_bytes(r[8..12].try_into().unwrap()), payload.to_vec())) +} + +pub trait Transport { + fn exchange(&mut self, request: &[u8; SIZE]) -> Result>; +} + +pub fn send(t: &mut impl Transport, command: u16, data: &[u8], kind: u8, seq: u32) -> Result<(u32, Vec)> { + response(&t.exchange(&report(command, data, kind, seq)?)?, command) +} +fn checked(t: &mut impl Transport, command: u16, data: &[u8], kind: u8, seq: u32) -> Result<()> { + let (status, _) = send(t, command, data, kind, seq)?; + if status != 0 { return Err(format!("camera rejected 0x{command:04x}: status {status}").into()); } + Ok(()) +} + +/// The sole command-injection target is generated internally, never caller supplied. +pub fn upload(t: &mut impl Transport, path: &[u8], data: &[u8], launch: bool) -> Result<()> { + if path.is_empty() || path.len() > 127 || path.contains(&0) || path.contains(&b' ') + || data.is_empty() || data.len() > 32768 { + return Err("invalid bounded upload".into()); + } + // No retries: wrong-state commands can receive misleading status-zero replies. + checked(t, 0x3000, &[], 1, 0)?; + checked(t, 0x3110, path, 1, 0)?; + let count = data.chunks(CHUNK).len(); + for (i, chunk) in data.chunks(CHUNK).enumerate() { + checked(t, 0x3200, chunk, if i+1 == count {2} else {1}, i as u32)?; + } + let (status, _) = send(t, 0x3300, &[], 1, 0)?; + if status != if launch {1} else {0} { + return Err("unexpected upload commit status; installation outcome unknown".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn frames_and_corruption() { + assert_eq!(crc(b"123456789".iter().copied()), 0x1dba); + let r = report(0x3200, b"abc", 2, 7).unwrap(); + assert_eq!(response(&r, 0x3200).unwrap(), (7, b"abc".to_vec())); + for i in 0..17 { + let mut bad = r; bad[i] ^= 1; + assert!(response(&bad, 0x3200).is_err()); + } + assert!(response(&r[..16], 0x3200).is_err()); + assert!(report(1, &[0; 1011], 1, 0).is_err()); + } + struct Mock { commands: Vec, fail: Option, launch: bool } + impl Transport for Mock { + fn exchange(&mut self, r: &[u8; SIZE]) -> Result> { + let cmd = u16::from_le_bytes([r[3], r[4]]); + self.commands.push(cmd); + if self.fail == Some(self.commands.len()) { return Err("injected transport failure".into()); } + Ok(report(cmd, &[], 1, u32::from(self.launch && cmd==0x3300))?.to_vec()) + } + } + #[test] + fn failures_never_advance_or_retry() { + for fail in 1..=5 { + let mut t = Mock { commands: vec![], fail: Some(fail), launch: false }; + assert!(upload(&mut t, b"/tmp/file", &[1; 1011], false).is_err()); + assert_eq!(t.commands.len(), fail); + } + let mut t = Mock { commands: vec![], fail: None, launch: false }; + assert!(upload(&mut t, b"/tmp/file", &[], false).is_err()); + assert!(t.commands.is_empty()); + upload(&mut t, b"/tmp/file", &[1; 1011], false).unwrap(); + assert_eq!(t.commands, [0x3000, 0x3110, 0x3200, 0x3200, 0x3300]); + t.launch = true; + upload(&mut t, b"/tmp/launch", b"\n", true).unwrap(); + } +} diff --git a/on-printer/src/usb.rs b/on-printer/src/usb.rs new file mode 100644 index 0000000..dcd4855 --- /dev/null +++ b/on-printer/src/usb.rs @@ -0,0 +1,189 @@ +//! Linux usbfs ABI, restricted to the selected HID interface. No USB reset, +//! configuration changes, generic ioctl entry point, or video-driver detachment. +use std::{fs::{self, File, OpenOptions}, io::Read, os::{fd::AsRawFd, raw::{c_int, c_ulong, c_void}}, path::Path}; +use crate::{Result, protocol::{Transport, SIZE}}; + +#[cfg(not(all(target_os = "linux", any(target_arch = "arm", target_arch = "x86_64", target_arch = "aarch64"))))] +compile_error!("usbfs ioctl layout is supported only on Linux ARM, AArch64 and x86_64"); + +#[derive(Debug, Clone, PartialEq)] +pub struct Interface { pub number: u8, pub input: u8, pub output: Option } +#[derive(Debug)] +pub struct Camera { pub path: String, pub interface: Interface, pub descriptors: Vec } + +fn read_bounded(path: &Path, limit: usize) -> Result> { + let mut data = Vec::new(); + File::open(path)?.take((limit+1) as u64).read_to_end(&mut data)?; + if data.len()>limit { return Err("oversized USB sysfs attribute".into()); } + Ok(data) +} +fn number(path: &Path, radix: u32) -> Result { + Ok(u16::from_str_radix(std::str::from_utf8(&read_bounded(path, 32)?)?.trim(), radix)?) +} + +pub fn interface(data: &[u8], active: u8) -> Result { + if data.len()<18 || data[0..2] != [18,1] || data[8..12] != [0x08,0xa1,0x40,0x22] { + return Err("not the supported normal-mode camera USB descriptor".into()); + } + let mut candidates = Vec::new(); + let mut offset = 18; + while offset < data.len() { + if offset+9 > data.len() || data[offset..offset+2] != [9,2] { return Err("invalid USB configuration".into()); } + let total = u16::from_le_bytes([data[offset+2], data[offset+3]]) as usize; + if total<9 || offset+total>data.len() { return Err("truncated USB configuration".into()); } + let end = offset+total; + let selected = data[offset+5] == active; + offset += 9; + let mut current: Option<(Interface, u8, u8)> = None; + while offset < end { + if offset+2>end { return Err("truncated USB descriptor".into()); } + let n = data[offset] as usize; + if n<2 || offset+n>end { return Err("invalid USB descriptor length".into()); } + let d = &data[offset..offset+n]; + if d[1]==4 { + if let Some(c) = current.take() { candidates.push(c); } + if n!=9 { return Err("invalid interface descriptor".into()); } + if selected && d[5]==3 { + if d[3]!=0 { return Err("HID alternate settings are unsupported".into()); } + current = Some((Interface {number:d[2], input:0, output:None}, d[4], 0)); + } + } else if d[1]==5 { + if let Some((ref mut i, _, ref mut seen)) = current { + if n<7 || d[3] & 3 != 3 || d[2] & 0x0f == 0 || d[2] & 0x70 != 0 { + return Err("expected interrupt HID endpoint".into()); + } + *seen += 1; + if d[2]&0x80 != 0 { + if i.input!=0 { return Err("multiple HID input endpoints".into()); } + i.input=d[2]; + } else if i.output.replace(d[2]).is_some() { return Err("multiple HID output endpoints".into()); } + } + } + offset += n; + } + if let Some(c)=current { candidates.push(c); } + } + if candidates.len()!=1 { return Err("expected exactly one HID interface".into()); } + let (i, expected, seen)=candidates.remove(0); + if i.input==0 || expected!=seen { return Err("missing HID endpoints".into()); } + Ok(i) +} + +pub fn discover() -> Result { + let mut cameras = Vec::new(); + for entry in fs::read_dir("/sys/bus/usb/devices")? { + let p = entry?.path(); + if !p.join("idVendor").exists() { continue; } + let vid = number(&p.join("idVendor"),16)?; + let pid = number(&p.join("idProduct"),16)?; + if vid != 0xa108 || !matches!(pid, 0x2240 | 0xff08) { continue; } + if pid==0xff08 { return Err("camera is in bootloader mode; this installer requires a working camera".into()); } + let bus = number(&p.join("busnum"),10)?; + let dev = number(&p.join("devnum"),10)?; + if bus==0 || bus>999 || dev==0 || dev>127 { return Err("invalid USB bus/address".into()); } + let active=u8::try_from(number(&p.join("bConfigurationValue"),10)?)?; + let descriptors=read_bounded(&p.join("descriptors"), 65536)?; + cameras.push(Camera { path:format!("/dev/bus/usb/{bus:03}/{dev:03}"), interface:interface(&descriptors, active)?, descriptors }); + } + if cameras.len()!=1 { return Err(format!("expected one supported camera, found {}", cameras.len()).into()); } + Ok(cameras.remove(0)) +} + +#[repr(C)] +struct Bulk { ep:u32, len:u32, timeout:u32, data:*mut c_void } +#[repr(C)] +struct Control { request_type:u8, request:u8, value:u16, index:u16, length:u16, timeout:u32, data:*mut c_void } +#[repr(C)] +struct Driver { interface:u32, name:[u8;256] } +#[repr(C)] +struct Disconnect { interface:u32, flags:u32, driver:[u8;256] } +#[repr(C)] +struct InterfaceIoctl { interface:c_int, code:c_int, data:*mut c_void } +extern "C" { fn ioctl(fd:c_int, request:c_ulong, ...) -> c_int; } +const fn ioc(direction:u32, n:u32) -> c_ulong { + ((direction<<30) | ((std::mem::size_of::() as u32)<<16) | (0x55<<8) | n) as c_ulong +} +fn call(file:&File, request:c_ulong, arg:&mut T) -> std::io::Result { + // All callers supply a matching repr(C) ABI object; buffers outlive the + // synchronous ioctl. No asynchronous URB points into Rust-managed memory. + let n=unsafe { ioctl(file.as_raw_fd(), request, arg as *mut T) }; + if n<0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) } +} + +pub struct Usb { file:File, interface:Interface, detached:bool } +impl Usb { + pub fn open(camera:Camera) -> Result { + let mut file=OpenOptions::new().read(true).write(true).open(&camera.path)?; + // Confirm the opened device still has the descriptors used for selection. + let mut actual=vec![0; camera.descriptors.len()]; + file.read_exact(&mut actual)?; + if actual!=camera.descriptors { return Err("USB device changed during selection".into()); } + let mut driver=Driver {interface:camera.interface.number as u32, name:[0;256]}; + let bound=match call(&file,ioc::(1,8),&mut driver) { + Ok(_)=>true, + Err(e) if e.raw_os_error()==Some(61)=>false, // ENODATA: no kernel driver + Err(e)=>return Err(e.into()), + }; + if bound { + if driver.name[..7] != *b"usbhid\0" { return Err("refusing to detach an unexpected interface driver".into()); } + let mut claim=Disconnect {interface:driver.interface,flags:1,driver:driver.name}; + call(&file,ioc::(2,27),&mut claim)?; + } else { + call(&file,ioc::(2,15),&mut driver.interface)?; + } + Ok(Self {file, interface:camera.interface, detached:bound}) + } + fn transfer(&self, ep:u8, data:&mut [u8]) -> Result { + let mut request=Bulk {ep:ep as u32,len:data.len() as u32,timeout:5000,data:data.as_mut_ptr().cast()}; + // usb_bulk_msg also accepts interrupt endpoints (Linux message.c). + Ok(call(&self.file,ioc::(3,2),&mut request)?) + } +} +impl Transport for Usb { + fn exchange(&mut self, report:&[u8;SIZE]) -> Result> { + let mut data=*report; + let count=if let Some(ep)=self.interface.output { self.transfer(ep,&mut data)? } else { + let mut request=Control {request_type:0x21,request:9,value:0x0201,index:self.interface.number as u16,length:SIZE as u16,timeout:5000,data:data.as_mut_ptr().cast()}; + call(&self.file,ioc::(3,0),&mut request)? + }; + if count!=SIZE { return Err("short HID write; no retry performed".into()); } + let mut response=[0;SIZE]; + let n=self.transfer(self.interface.input,&mut response)?; + Ok(response[..n].to_vec()) + } +} +impl Drop for Usb { + fn drop(&mut self) { + let mut i=self.interface.number as u32; + if let Err(e)=call(&self.file,ioc::(2,16),&mut i) { eprintln!("HID interface release failed: {e}"); } + if self.detached { + let mut request=InterfaceIoctl {interface:i as c_int,code:0x5517,data:std::ptr::null_mut()}; + if let Err(e)=call(&self.file,ioc::(3,18),&mut request) { eprintln!("HID driver reattachment failed: {e}"); } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn descriptors() -> Vec { + let mut d=vec![18,1,0,2,0,0,0,64,8,0xa1,0x40,0x22,0,1,0,0,0,1]; + d.extend_from_slice(&[9,2,32,0,1,1,0,0x80,50, 9,4,2,0,2,3,0,0,0, 7,5,0x83,3,0,4,1, 7,5,4,3,0,4,1]); d + } + #[test] + fn descriptor_selection_and_refusals() { + let d=descriptors(); + assert_eq!(interface(&d,1).unwrap(),Interface {number:2,input:0x83,output:Some(4)}); + for n in 0..d.len() { assert!(interface(&d[..n],1).is_err()); } + assert!(interface(&d,2).is_err()); + for (i,v) in [(8,0),(30,1),(32,14),(36,0),(39,2),(38,3),(45,0x84)] { + let mut bad=d.clone(); bad[i]=v; assert!(interface(&bad,1).is_err(),"offset {i}"); + } + } + #[test] + fn ioctl_abi() { + assert_eq!(ioc::(2,15),0x8004550f); + assert_eq!(ioc::(2,27),0x8108551b); + assert_eq!(ioc::(3,2),if cfg!(target_pointer_width="64") {0xc0185502} else {0xc0105502}); + } +} diff --git a/tests/test_on_printer.py b/tests/test_on_printer.py new file mode 100644 index 0000000..1f25c4d --- /dev/null +++ b/tests/test_on_printer.py @@ -0,0 +1,135 @@ +"""Offline native-tool payload contracts; no USB devices or firmware fixtures.""" +import hashlib +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('hid_payload_generator', ROOT/'on-printer/generate_payload.py') +generator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(generator) + + +class PayloadGenerationTests(unittest.TestCase): + def test_generated_payload_matches_canonical_hooks(self): + data = generator.generate() + self.assertEqual((ROOT/'on-printer/payload/installer.sh').read_text(), data) + self.assertIn(generator.RUNNER.decode(), data) + self.assertIn(generator.erase_hook().decode(), data) + self.assertLess(len(data), 32768) + self.assertNotIn('@PAYLOADS@', data) + + +@unittest.skipUnless(os.name == 'posix' and shutil.which('sh') and shutil.which('md5sum'), 'POSIX shell tools required') +class CameraShellTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='cc2-hid-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.config = self.root/'config' + self.stage = self.root/'stage' + self.config.mkdir() + self.stage.mkdir() + (self.config/'serial.cfg').write_text('synthetic identity\n') + (self.stage/'fix').write_bytes(generator.erase_hook()) + (self.stage/'runner').write_bytes(generator.RUNNER) + self.bin = self.root/'bin' + self.bin.mkdir() + (self.bin/'busybox').write_text('#!/bin/sh\nexec "$@"\n') + (self.bin/'id').write_text('#!/bin/sh\necho 0\n') + (self.bin/'sync').write_text('#!/bin/sh\nexit 0\n') + for p in self.bin.iterdir(): p.chmod(0o755) + script = generator.generate().rsplit('\nmain\n', 1)[0] + '\n' + script = script.replace('PATH=/bin:/sbin:/usr/bin:/usr/sbin', f'PATH={self.bin}:/bin:/usr/bin') + script = script.replace('@TOKEN@', '0123456789abcdef').replace('@MODE@', 'install') + mounts = self.root/'mounts' + mounts.write_text(f'/dev/mtdblock5 {self.config} jffs2 rw 0 0\n') + mtd = self.root/'mtd' + mtd.write_text('dev: size erasesize name\n'+'\n'.join( + f'mtd{i}: {size:08x} 00004000 "{name}"' for i,(name,size) in enumerate(generator.EXPECTED_PARTITIONS))+'\n') + for path, content in (('/dev/mtd1', b'synthetic kernel'), ('/bin/hid_update', b'synthetic hid'), ('/dev/mtd5', b'stable synthetic config')): + local = self.root/path.rsplit('/',1)[-1] + local.write_bytes(content) + script = script.replace(path, str(local)) + if path == '/dev/mtd1': script=script.replace(generator.KERNEL_MD5,hashlib.md5(content).hexdigest()) + if path == '/bin/hid_update': script=script.replace('8091751fdd4d0d50ea31901663797a86',hashlib.md5(content).hexdigest()) + script = script.replace('/etc/conf.d',str(self.config)).replace('/proc/mounts',str(mounts)).replace('/proc/mtd',str(mtd)) + self.script = script + f'\nSTAGE={self.stage}\n' + + def run_shell(self, command='preflight; install_files', modifications=''): + result = subprocess.run(['sh'], input=self.script+modifications+'\n'+command+'\nprintf "STATUS=%s\\n" "$STATUS"\n', text=True, capture_output=True, timeout=10) + return result + + def test_success_then_idempotency_without_persistent_changes(self): + result = self.run_shell() + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + self.assertIn('STATUS=DONE', result.stdout) + paths = [self.config/'system.sh',self.config/'enabled/10-erase-fix.sh'] + self.assertEqual(paths[0].read_bytes(),generator.RUNNER) + self.assertEqual(paths[1].read_bytes(),generator.erase_hook()) + before = [(p.stat().st_mtime_ns,p.read_bytes()) for p in paths] + result = self.run_shell() + self.assertIn('STATUS=SAME',result.stdout) + self.assertEqual(before,[(p.stat().st_mtime_ns,p.read_bytes()) for p in paths]) + + def test_unknown_hook_or_symlink_never_writes(self): + dest = self.config/'system.sh' + for link in (False, True): + if link: dest.symlink_to(self.stage/'runner') + else: dest.write_text('unknown script'); dest.chmod(0o755) + result = self.run_shell() + self.assertNotEqual(result.returncode,0,result.stdout) + self.assertFalse((self.config/'enabled').exists()) + dest.unlink() + + def test_kernel_fingerprint_and_partition_map_refuse_before_writes(self): + for name in ('mtd1','mtd'): + p=self.root/name + original=p.read_bytes() + p.write_bytes(b'unsupported') + result=self.run_shell() + self.assertNotEqual(result.returncode,0,result.stdout) + self.assertFalse((self.config/'enabled').exists()) + p.write_bytes(original) + + def test_partial_copy_never_installs_boot_entry_point(self): + # Simulated ENOSPC after a partial persistent file creation. + (self.bin/'cp').write_text('#!/bin/sh\nprintf truncated > "$2"\nexit 1\n') + (self.bin/'cp').chmod(0o755) + result=self.run_shell(modifications="trap 'echo STATUS=$STATUS' 0") + self.assertNotEqual(result.returncode,0) + self.assertIn('STATUS=PART',result.stdout) + self.assertFalse((self.config/'system.sh').exists()) + self.assertFalse((self.config/'enabled/10-erase-fix.sh').exists()) + self.assertTrue((self.config/'.cc2-hid-fix').exists()) + + def test_readback_mismatch_never_renames(self): + (self.bin/'cp').write_text('#!/bin/sh\nprintf corrupted > "$2"\n') + (self.bin/'cp').chmod(0o755) + result=self.run_shell() + self.assertNotEqual(result.returncode,0) + self.assertIn('temporary-readback',result.stdout) + self.assertFalse((self.config/'system.sh').exists()) + self.assertFalse((self.config/'enabled/10-erase-fix.sh').exists()) + + def test_stale_partial_install_refused_without_cleanup(self): + leftover=self.config/'.cc2-hid-fix' + leftover.write_text('preserve diagnostics') + result=self.run_shell() + self.assertNotEqual(result.returncode,0) + self.assertEqual(leftover.read_text(),'preserve diagnostics') + self.assertFalse((self.config/'enabled').exists()) + + def test_changed_config_stops_before_first_write(self): + script=''' +count=0 +preflight() { count=$((count+1)); if [ "$count" = 2 ]; then echo changed > "@CONFIG@"; fi; } +'''.replace('@CONFIG@',str(self.root/'mtd5')) + result=self.run_shell(modifications=script) + self.assertNotEqual(result.returncode,0) + self.assertIn('config-changing',result.stdout) + self.assertFalse((self.config/'enabled').exists()) From bf438ca1692be1d35591e891b4675531c1d3c5d8 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 8 Sep 2026 22:59:43 +0200 Subject: [PATCH 02/11] Document direct HID workflow and verify activation without writes --- README.md | 10 +++ docs/ON-PRINTER-HID.md | 145 +++++++++++++++++++++++++++++++ on-printer/README.md | 178 +++++++++++++++++++++++++++++++++++++++ on-printer/src/main.rs | 2 +- tests/test_on_printer.py | 25 +++++- 5 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 docs/ON-PRINTER-HID.md create mode 100644 on-printer/README.md diff --git a/README.md b/README.md index dff43af..b6fb106 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,16 @@ dump to your device. > It has produced the expected result in two tested cases, but if you have any > doubts, independently audit the tools before using them on your hardware. +## Already have a root shell on the printer? + +The [experimental on-printer HID installer](on-printer/README.md) can install +and verify the startup fix while a working camera stays connected inside the +CC2. It needs neither ADB nor Python on the printer. It does **not** export a +backup or check safely erased configuration space; a failed write may require +an external programmer. Read that guide's risks and prerequisites before use. +This route is hardware-unverified. The computer-based route below retains its +backup and space checks. + ## First identify your camera The known failure affects the `EF-S7-V1.0.30B` camera family. A newer diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md new file mode 100644 index 0000000..fff232e --- /dev/null +++ b/docs/ON-PRINTER-HID.md @@ -0,0 +1,145 @@ +# On-printer HID installation: evidence and limits + +## Accepted scope and risk exception + +The developer explicitly requested a separate small tool that runs from a CC2 +shell without ADB or Python, installs the camera fix through HID without +unplugging the camera, and trades the conservative target-space check for an +explicit risk of programmer recovery. This experimental workflow has no exported +backup or backup-based identity comparison. It installs independently written +canonical configuration hooks, not a firmware image or another camera's data. +The CLI exposes this tradeoff as `install --accept-no-backup-space-risk`. + +This exception applies only to this workflow. The ADB installer, backup, image +patching, and restore safety contracts remain in effect. The shell installer +retains mount/partition checks, known firmware fingerprints, regular-file checks, +unknown-hook refusal, configuration stability checks, and camera-side byte +comparisons. The erase hook itself retains every early-boot compatibility gate. +There is no generic force option or arbitrary-file/command interface. + +A filesystem space report is not the existing clean-marker admission test. +Neither the printer's free space nor an acknowledgement from the uploader can +prove that the camera has enough safely writable JFFS2 space. + +## Observed printer environment + +The developer supplied this shell evidence: + +- BusyBox 1.27.2 ash; neither ADB nor Python available; +- TinaLinux 5.4.61, ARMv7 little endian, two Cortex-A7-class processors, VFPv3/4; +- approximately 108.6 MiB total printer RAM, with approximately 60.4 MiB free + after excluding buffers/cache in the supplied snapshot; +- no `/dev/hidraw*` nodes, but USB device nodes under `/dev/bus/usb`. + +This establishes an architecture and a candidate USB access mechanism. It does +not establish successful interface claiming, report transfer, camera-side worker +execution, or a working installation. USB descriptors must still be inspected on +hardware; the implementation discovers endpoint addresses instead of assuming +an interface number or address from the changing device-node numbers. + +## Protocol and transport + +The implementation follows the normal-mode format in +[PROTOCOL.md](../usb-maintenance/PROTOCOL.md): report ID 1, 1024-byte reports, +`5a 5a` magic, the recovered right-shifting `0x1021` CRC, little-endian fields, +and at most 1010 payload bytes per packet. The shell payload is bounded to +32 KiB, though the camera's uploader allocates its stock 16 MiB + 512-byte receive +buffer. A small host executable does not reduce that camera-side allocation. + +The Linux usbfs backend parses the active configuration, requires exactly one +HID interface and interrupt IN endpoint, and uses the discovered interrupt OUT +endpoint or HID `SET_REPORT` on endpoint zero when there is no OUT endpoint. +It compares the opened device's descriptors with those used for selection, +claims only the HID interface, and detaches only a driver named `usbhid` if +necessary. It issues no USB reset, configuration/alternate-setting changes, or +video-interface detach. Synchronous USB transfers use five-second timeouts. +The ABI definitions are narrowly scoped to Linux ARM, AArch64, and x86-64. + +`inspect` sends only `0x0001` (version query). `install` and `verify` use: + +1. `0x3000`, `0x3110`, numbered `0x3200` packets and `0x3300` to stage an + installer at `/tmp/.cc2-hid-<16-hex-session>.sh`. +2. The same upload sequence with one newline and an internally generated target + ending in `;/bin/sh${IFS}/tmp/.cc2-hid-.sh&`. +3. `0x0001` queries for the exact current session's terminal status, for at most + 90 seconds, with no upload retries. + +The [reconstructed commit handler](../usb-maintenance/source-reconstruction/hid_update_reconstructed.c) +interpolates the target into `rm` before attempting its literal `fopen`. The +background shell starts, and the embedded directory separators make the literal +open fail. Only status 1 is expected for this launch commit. A timeout or malformed +commit reply is an error, not accepted evidence of success. On that failure path, +the uploader is left in state 5; initialization does not reset it. Hence only one +attempt per fresh boot is supported. The version-query handler remains separate +from the upload state machine. + +The worker uses the first line of camera `/tmp/version.txt` as a temporary result +channel. That handler returns at most 23 bytes; a 16-hex-digit random session, +colon, and status fit. `BUSY` is nonterminal; `DONE`/`SAME` confirm installation +comparisons; `LIVE` confirms verification; `FAIL` means preflight refusal and +`PART` means persistent writes started but did not finish successfully. Results +from another session or another operation cannot authorize success. The original +version file is copied back two minutes after the worker finishes. This behavior +and its effect on printer software are hardware-unverified. + +The first staging upload precedes camera-side checks. It relies on the analyzed +stock `/tmp` layout and a fresh random destination; the worker then requires +`/tmp` to be tmpfs before additional staging/status changes. USB identity alone +does not authenticate firmware. Firmware fingerprints guard persistent writes, +not the preceding temporary upload or shell-launch mechanism. This is not an +adversarial-device security boundary. + +## Persistent writes and verification + +The worker checks the exact known kernel MD5 and recovered `/bin/hid_update` +MD5 documented in [EVIDENCE.md](../usb-maintenance/EVIDENCE.md). MD5 is an +interoperability fingerprint, not cryptographic authentication. It checks the +complete expected MTD map, the configuration mount, regular nonempty identity +file, and managed destinations. Three equal raw configuration digests, plus a +repeat immediately before writing, detect observed concurrent changes. They are +not an exported backup or a guarantee against a later concurrent writer. + +Canonical hook bytes come from `cc2camera.startup_payloads`; a generation check +prevents the native copy from drifting. Both payload files are compared against +embedded MD5 fingerprints in RAM before installation. Unknown managed contents, +permissions, symlinks and incomplete-installation paths are refused. Existing +unrelated regular enabled hooks remain and execute under the canonical runner. + +The worker copies each missing file to a fixed refused-if-present temporary +configuration path, sets mode 755, compares bytes, syncs, renames, and syncs. +The erase hook is installed before the runner. Final comparisons check both +files. No direct flash writes, partition erasure/remounting, or live kernel +modifications occur during installation. Failure preserves partial persistent +files; there is no rollback that would consume more JFFS2 records. + +After restart, `verify` stages the same bounded worker in verification mode. +It compares canonical files and reads the known kernel instructions, symbols, +validated SFC pointer and master erase-size field. It reports `LIVE` only when +that field is `0x1000` and the pointer remains stable. It never applies the +correction itself. This is a camera-side verification report over HID, not a +host-side independent full-flash readback or physical erase-pressure test. + +## Validation status + +Software tests cover generation parity, framing/CRC, descriptor selection, +consent and argument refusal, upload failure ordering/no retries, session-bound +status acceptance, shell fingerprint refusals, unknown files and symlinks, +configuration changes, partial writes, readback mismatches and idempotency. +Synthetic fixtures contain no proprietary firmware or real device identity. + +The new workflow compiles a static ARMv7 binary and runs tests under QEMU in +addition to the host tests. These checks cannot substitute for the following +physical validation: + +- inspect the actual camera descriptors and complete a version query from CC2; +- establish that the printer's camera service tolerates HID access; +- observe the expected launch-commit failure and current-session status response; +- compare installation results and verify activation after restart; +- observe version restoration and normal camera feed operation; +- exercise any on-device failure investigations deliberately, with programmer + access and preserved same-camera data where available. + +The existing hook's physical evidence in +[STARTUP-HOOKS-VALIDATION.md](STARTUP-HOOKS-VALIDATION.md) does not validate this +new transport, installer, or otherwise-stock hook-only boot behavior. User +instructions therefore label this route experimental and hardware-unverified. diff --git a/on-printer/README.md b/on-printer/README.md new file mode 100644 index 0000000..32926d9 --- /dev/null +++ b/on-printer/README.md @@ -0,0 +1,178 @@ +# Install the camera fix from a CC2 shell + +`cc2camera-hid` is an **experimental, hardware-unverified** way to protect a +working affected stock camera while it stays connected inside the printer. +It runs on the printer, using USB HID directly. The static ARMv7 executable +needs neither ADB, Python, `hidraw`, nor additional shared libraries. + +The [computer-based USB route](../README.md#working-camera-usb-prevention) +provides an exported backup and a conservative clean-space check. Prefer that +route when you can connect the camera to a computer. + +## Risk and prerequisites + +This installer **does not export a backup or check that enough safely erased +camera configuration space remains**. Even a small write can exhaust the +camera's damaged filesystem. A failed or interrupted installation can leave the +camera unable to boot and require an external SPI programmer. Without a backup, +recovery depends on being able to read the camera's own identity data with that +programmer; recovery is not guaranteed. Never substitute another camera's dump. +The available space reported by `df` on the **printer** says nothing about this +risk on the **camera**. + +Use this route only if you accept that tradeoff to avoid unplugging the camera +and making a cable. The tool requires that acceptance explicitly on installation. +The backup-based tools keep their existing requirements. + +You need: + +- a working affected Ingenic T23 camera, with the known stock kernel and HID + updater; this does not support the newer TX5110 camera or recover a camera + that no longer boots; +- root shell access to the CC2 printer (obtaining this access is outside this + guide), and a way to copy the executable onto its USB drive or filesystem; +- a 32-bit ARMv7 Linux printer with `/sys/bus/usb/devices` and + `/dev/bus/usb`; the initial target is the reported CC2 TinaLinux 5.4.61 system; +- an idle printer, with no print running and no other camera-maintenance tool + using the HID interface. + +Restart the **idle** printer before starting an installation or verification +attempt, so the camera uploader starts in its ordinary state. Do not use +`cc2camera start-adb` or another HID uploader first. During an attempt, do not +interrupt power. The program never resets USB, changes its configuration, +detaches a video interface, reboots the printer, or writes a firmware image. +If a HID kernel driver is present, it temporarily detaches only `usbhid` on the +selected HID interface and requests reattachment when it exits normally. +Physical testing must establish whether the stock printer software tolerates +this access while it owns the camera stream. + +## Obtain and inspect the executable + +For a version containing this tool, download `cc2camera-hid-armv7-linux` and its +`.sha256` file from the repository's [GitHub Releases](https://github.com/OpenCentauri/cc2_camera_fix/releases). +Before a release is available, the **On-printer HID tool** workflow on the PR +provides the same files in its `cc2camera-hid-armv7-linux` artifact. Extract that +artifact on your computer and place the two files at the root of a USB drive. +Do not execute a binary from a failed build. + +On a CC2 where that drive is mounted at `/mnt/exUDISK`: + +```sh +cd /mnt/exUDISK +sha256sum -c cc2camera-hid-armv7-linux.sha256 +cp cc2camera-hid-armv7-linux /tmp/cc2camera-hid +chmod 755 /tmp/cc2camera-hid +/tmp/cc2camera-hid --help +/tmp/cc2camera-hid inspect +``` + +If `sha256sum` is unavailable on the printer, verify the checksum on your +computer before copying the binary. Adjust the drive mount path if necessary. +Copying to `/tmp` avoids execution permissions on the USB drive; the executable +must be copied there again after a printer restart. + +`inspect` reads descriptors and sends only the version-query command. It does +not upload scripts or change camera files. Expect exactly one camera, its +selected HID interface and endpoints, and a printable version response. USB +identification and a version reply alone do **not** establish compatible +firmware: the camera-side installer checks fingerprints before persistent writes. +Stop if inspection reports an error, multiple cameras, or bootloader mode. +Do not manually unbind arbitrary USB drivers to bypass a refusal. + +## Install, then verify after restart + +The following command **can write the camera's persistent configuration**. +It carries the recovery risk described above: + +```sh +/tmp/cc2camera-hid install --accept-no-backup-space-risk +``` + +The tool uploads a temporary installer to camera RAM. The installer checks the +kernel and HID updater fingerprints, partition map, mounts, identity-file +presence, and stability of the raw configuration contents. It refuses unknown +contents or permissions at either managed destination. It installs the canonical +`10-erase-fix.sh` hook first and `system.sh` runner last, comparing temporary and +final file contents. It preserves unrelated regular enabled hooks and refuses +non-regular entries. Exact existing managed files are left untouched. + +The only successful installation message begins: + +```text +Camera reports exact installed hook contents and permissions verified. +``` + +This confirms the camera-side file comparisons, **not activation on boot**. +After that message, wait at least two minutes, restart the idle printer, copy +the executable back to `/tmp`, and run: + +```sh +/tmp/cc2camera-hid verify +``` + +`verify` explicitly uploads and runs a temporary probe in camera RAM. It makes +no persistent changes and does not apply the RAM correction. It checks the +canonical installed files, known kernel instructions and symbols, and the live +erase-size field. Its success message is: + +```text +Camera reports canonical hooks and the live erase correction verified for this boot. +``` + +Also check that the camera feed works. Report the tool output and whether the +feed survives installation/restart when physically validating this route. +Do not treat a passing offline test or a successful HID commit as physical +validation. + +## If anything fails + +Do not retry automatically or repeatedly. An incomplete installation is preserved +for diagnosis, including `.cc2-hid-fix` or `.cc2-hid-runner` temporary files in +camera configuration. The tool deliberately does not delete or overwrite these +files on a later attempt. + +- A camera-side preflight refusal reports that persistent installation writes + did not start. Resolve the refusal before attempting anything else. +- A partial-installation error means persistent writes started. Keep power on + while deciding how to preserve the current camera data; restarting could make + a nearly full camera filesystem unbootable. +- A USB error, malformed response, or timeout leaves the outcome unknown. + A timeout does not stop a worker already running on the camera. Do not assume + that nothing was written or that restarting is safe. +- If the camera no longer boots, use the + [hardware recovery guide](../README.md#failed-camera-hardware-recovery), + preserving its own flash contents first. + +Both `install` and `verify` temporarily replace the camera's version-query +response with a session-specific status. Two minutes after the worker finishes, +it restores the original version file. The command-launch mechanism leaves the +camera's HID **uploader** in an error state until the camera daemon restarts; +version queries remain usable. The program does not automatically restart that +daemon. Do not attempt another upload in the same boot. + +## Build and software verification + +Build on a development computer, not the printer. There are no Cargo dependencies. +Python is used only to generate/check embedded copies of the existing hook bytes. +The workflow uses Rust 1.85.1 and a static +[`armv7-unknown-linux-musleabihf` target](https://doc.rust-lang.org/rustc/platform-support/arm-linux.html). + +On Linux with Rust and an ARM GNU linker installed: + +```sh +python on-printer/generate_payload.py --check +cargo test --locked --manifest-path on-printer/Cargo.toml +rustup target add armv7-unknown-linux-musleabihf +CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc \ + cargo build --locked --manifest-path on-printer/Cargo.toml \ + --release --target armv7-unknown-linux-musleabihf +``` + +CI runs native and QEMU ARM tests, checks that the executable has no ELF +interpreter or shared-library requirements, and exercises help and missing-consent +refusal. It reports the actual binary size and publishes a checksummed artifact. +Manually published releases receive the same executable; the workflow does not +create releases. + +See [implementation evidence and validation limits](../docs/ON-PRINTER-HID.md) +for the protocol, accepted exception, and outstanding hardware checks. diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index 612b843..168f231 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -71,7 +71,7 @@ fn run() -> Result<()> { let mut random=[0u8;8]; let prepared=if matches!(action,Action::Install|Action::Verify) { std::fs::File::open("/dev/urandom")?.read_exact(&mut random)?; - let token=random.iter().map(|b|format!("{b:02x}")).collect::(); + let token=format!("{:016x}", u64::from_be_bytes(random)); let payload=script(&token,&action)?; Some((token,payload)) } else { None }; diff --git a/tests/test_on_printer.py b/tests/test_on_printer.py index 1f25c4d..3400791 100644 --- a/tests/test_on_printer.py +++ b/tests/test_on_printer.py @@ -5,6 +5,7 @@ from pathlib import Path import shutil import subprocess +import sys import tempfile import unittest @@ -24,7 +25,7 @@ def test_generated_payload_matches_canonical_hooks(self): self.assertNotIn('@PAYLOADS@', data) -@unittest.skipUnless(os.name == 'posix' and shutil.which('sh') and shutil.which('md5sum'), 'POSIX shell tools required') +@unittest.skipUnless(sys.platform.startswith('linux') and shutil.which('sh') and shutil.which('md5sum'), 'Linux shell tools required') class CameraShellTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory(prefix='cc2-hid-test-') @@ -133,3 +134,25 @@ def test_changed_config_stops_before_first_write(self): self.assertNotEqual(result.returncode,0) self.assertIn('config-changing',result.stdout) self.assertFalse((self.config/'enabled').exists()) + + def test_verify_reads_active_correction_without_writing_config(self): + self.assertEqual(self.run_shell().returncode, 0) + symbols = self.root/'kallsyms' + symbols.write_text(''.join(f'{a:08x} T {n}\n' for n,a in generator.SYMBOLS.items())) + self.script = self.script.replace('/proc/kallsyms', str(symbols)) + field = self.root/'field' + field.write_text('0x00001000\n') + cases = '\n'.join(f'{a:#x}) echo 0x{v:08X};;' for a,v in generator.INSTRUCTIONS.items()) + wrapper = '#!/bin/sh\nif [ "$1" = devmem ]; then\n[ "$#" = 3 ] || exit 89\ncase "$2" in\n' + cases + wrapper += f'\n0x0043b190) echo 0x80450000;;\n{0x450010}) cat {field};;\n*) exit 1;;\nesac\nelse exec "$@"; fi\n' + (self.bin/'busybox').write_text(wrapper) + before = {str(p):p.read_bytes() for p in self.config.rglob('*') if p.is_file()} + result = self.run_shell('preflight; verify_live') + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('STATUS=LIVE',result.stdout) + self.assertEqual(before,{str(p):p.read_bytes() for p in self.config.rglob('*') if p.is_file()}) + field.write_text('0x00004000\n') + result = self.run_shell('preflight; verify_live') + self.assertNotEqual(result.returncode,0) + self.assertIn('fix-inactive',result.stdout) + self.assertEqual(before,{str(p):p.read_bytes() for p in self.config.rglob('*') if p.is_file()}) From 1e996c067141e76926a0ffb2e3c2477ec5da6b7f Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Sep 2026 00:08:52 +0200 Subject: [PATCH 03/11] Recognize observed 30D USB signature before camera access --- docs/ON-PRINTER-HID.md | 63 +++++++++++++++++++ on-printer/README.md | 10 ++- on-printer/src/main.rs | 30 ++++++++- on-printer/src/usb.rs | 138 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 232 insertions(+), 9 deletions(-) diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index fff232e..0301ac2 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -31,12 +31,75 @@ The developer supplied this shell evidence: after excluding buffers/cache in the supplied snapshot; - no `/dev/hidraw*` nodes, but USB device nodes under `/dev/bus/usb`. +The developer subsequently identified the camera used for that snapshot as +EF-S7-V1.0.30D, not 30B. The absence of `hidraw` in this observation therefore +does not establish whether the printer exposes `hidraw` with a 30B connected. +The direct usbfs backend does not depend on that distinction. + This establishes an architecture and a candidate USB access mechanism. It does not establish successful interface claiming, report transfer, camera-side worker execution, or a working installation. USB descriptors must still be inspected on hardware; the implementation discovers endpoint addresses instead of assuming an interface number or address from the changing device-node numbers. +## Observed 30D USB signature + +A developer-confirmed 30D supplied a complete 1,005-byte (`0x3ed`) USB descriptor +snapshot through the printer's sysfs. It declares one configuration of 987 bytes +(`0x3db`), four interface numbers, and two UVC function associations. The developer +also observes two cameras under Windows for the 30D, versus one for the 30B. +These are USB functions, not evidence of two physical image sensors. + +| Attribute | Observed 30D value | +|---|---| +| VID:PID | `a108:2240` — shared with the 30B | +| `bcdUSB`, `bcdDevice` | `0200`, `0414` | +| Device class/subclass/protocol | `ef/02/01` | +| Manufacturer | `Linux Foundation` | +| Product | `Multi Composite Double Uvc Gadget` | +| First UVC function | Control interface 0; streaming interface 1, alternate settings 0/1; IN endpoint `0x81` at alternate 1 | +| Second UVC function | Control interface 2; streaming interface 3, alternate settings 0/1; IN endpoint `0x82` at alternate 1 | +| Endpoint attributes / maximum packet / interval | `05` / `03fc` (1,020 bytes) / `01`, for both streaming endpoints | +| HID / ADB interfaces | Neither is declared in the sole configuration | + +The absence of an ADB USB interface is distinct from an exposed ADB interface +whose daemon is not running. The developer confirms the 30B exposes both HID +and ADB interfaces even when ADB is not enabled. The existing stock 30B bootstrap +workflow also relies on that distinction. + +The native classifier matches the complete device/configuration header, total +length, manufacturer/product strings, and ordered standard function/interface/ +endpoint descriptors above. Every intervening descriptor must be a well-bounded +class-specific UVC interface descriptor (`0x24`). Its format/frame payload bytes +are not matched. This is an observed USB signature, not authentication or a +full-firmware fingerprint, and does not promise recognition of every 30D firmware +revision. USB addresses, speed, serial strings and the currently selected video +alternate settings are not used for recognition. + +Recognition happens using sysfs, before opening `/dev/bus/usb`, claiming an +interface, or sending a version query. `inspect` reports that the signature +matches the observed 30D and the patch does not apply to that revision. +`install`/`verify` refuse it. Missing HID, a generic double-UVC device, a partial +signature, malformed descriptors, or multiple matching-ID devices cannot produce +that reassurance. Unknown devices still receive the ordinary unsupported result. + +Software coverage uses generated topology vectors with synthetic class-specific +payloads. It checks signature mutations, truncation, malformed lengths, missing +strings, wrong active configuration, mixed 30B/30D discovery and absence of USB +access for every command on a recognized 30D. Live execution of this classifier +on the printer remains unverified. No firmware disassembly is needed to establish +this USB-level distinction. + +The read-only collection command for the observed topology was: + +```sh +busybox hexdump -C /sys/bus/usb/devices/1-1.1/descriptors +``` + +That USB path is specific to the supplied connection and must be discovered +again if topology changes. The sysfs manufacturer/product attributes supplied +the string values; the binary descriptors contain string indices only. + ## Protocol and transport The implementation follows the normal-mode format in diff --git a/on-printer/README.md b/on-printer/README.md index 32926d9..cf29f5d 100644 --- a/on-printer/README.md +++ b/on-printer/README.md @@ -71,7 +71,15 @@ computer before copying the binary. Adjust the drive mount path if necessary. Copying to `/tmp` avoids execution permissions on the USB drive; the executable must be copied there again after a printer restart. -`inspect` reads descriptors and sends only the version-query command. It does +`inspect` recognizes the observed EF-S7-V1.0.30D USB signature from descriptors +and manufacturer/product strings. For that signature it reports that the patch +does not apply, exits successfully, and never opens the USB device node or sends +a camera command. `install` and `verify` refuse that signature without USB access. +The match requires revision `0414` and the specific two-function UVC layout; +shared USB IDs or missing HID alone do not establish a 30D. Unknown layouts +remain unsupported. Multiple matching-ID cameras are refused before classification. + +For a supported HID camera, `inspect` sends only the version-query command. It does not upload scripts or change camera files. Expect exactly one camera, its selected HID interface and endpoints, and a printable version response. USB identification and a version reply alone do **not** establish compatible diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index 168f231..e7d0378 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -10,7 +10,8 @@ Run as root on an idle printer. ARMv7 Linux; no Python, ADB, hidraw or shared libraries required by the static build. Only known camera firmware is supported. cc2camera-hid inspect - Read USB descriptors and query the camera version. No camera file writes. + Read USB descriptors; query the version only for a supported HID camera. + Recognized 30D signatures need no patch and receive no camera commands. cc2camera-hid install --accept-no-backup-space-risk Install the persistent erase-fix hook through HID. NO BACKUP is exported, and safe clean space is NOT checked. A failed write or power loss can leave @@ -64,6 +65,21 @@ fn terminal(payload: &[u8], token: &str, action: &Action) -> Result { _ => Err("unexpected camera operation status".into()), } } +const NEWER_MESSAGE: &str = "USB signature matches the observed EF-S7-V1.0.30D camera. This patch does not apply to that revision. No camera commands sent."; +fn with_hid_camera( + found: usb::Discovery, + action: &Action, + use_camera: impl FnOnce(usb::Camera) -> Result<()>, +) -> Result<()> { + match found { + usb::Discovery::Newer30d if *action == Action::Inspect => { + println!("{NEWER_MESSAGE}"); + Ok(()) + } + usb::Discovery::Newer30d => Err(NEWER_MESSAGE.into()), + usb::Discovery::Hid(camera) => use_camera(camera), + } +} fn run() -> Result<()> { // Parse consent and prepare every outgoing target before touching USB. let action=arguments(&std::env::args().skip(1).collect::>())?; @@ -75,7 +91,7 @@ fn run() -> Result<()> { let payload=script(&token,&action)?; Some((token,payload)) } else { None }; - let camera=usb::discover()?; + with_hid_camera(usb::discover()?, &action, |camera| { println!("Camera {}: HID interface {}, input 0x{:02x}, output {:?}",camera.path,camera.interface.number,camera.interface.input,camera.interface.output); let mut transport=usb::Usb::open(camera)?; let (status,version)=send(&mut transport,1,&[],1,0)?; @@ -103,6 +119,7 @@ fn run() -> Result<()> { return Err("timed out: outcome unknown; camera worker may still be running".into()); } Ok(()) + }) } fn main() { if let Err(e)=run() { @@ -115,6 +132,15 @@ mod tests { use super::*; fn args(a:&[&str])->Vec {a.iter().map(|s|s.to_string()).collect()} #[test] + fn newer_camera_never_reaches_usb_open_for_any_command() { + for action in [Action::Inspect, Action::Install, Action::Verify] { + let result = with_hid_camera(usb::Discovery::Newer30d, &action, |_| { + panic!("30D must never reach USB open, interface claim or command sending") + }); + assert_eq!(result.is_ok(), action == Action::Inspect); + } + } + #[test] fn consent_and_unknown_arguments() { assert!(arguments(&args(&["install"])).is_err()); assert!(arguments(&args(&["install","--force"])).is_err()); diff --git a/on-printer/src/usb.rs b/on-printer/src/usb.rs index dcd4855..73cc436 100644 --- a/on-printer/src/usb.rs +++ b/on-printer/src/usb.rs @@ -69,7 +69,83 @@ pub fn interface(data: &[u8], active: u8) -> Result { Ok(i) } -pub fn discover() -> Result { +/// Observed USB identity only; never authorizes writes to a new firmware family. +#[derive(Debug)] +pub enum Discovery { + Hid(Camera), + Newer30d, +} + +// Device/configuration and standard descriptors from the observed 30D topology. +// Class-specific UVC payloads are deliberately not a firmware fingerprint. +const D30_HEADER: &[u8] = &[ + 18, 1, 0, 2, 0xef, 2, 1, 64, 8, 0xa1, 0x40, 0x22, 0x14, 4, 1, 2, 3, 1, + 9, 2, 0xdb, 3, 4, 1, 4, 0xc0, 1, +]; +const D30_TOPOLOGY: &[&[u8]] = &[ + &[8, 11, 0, 2, 14, 3, 0, 5], + &[9, 4, 0, 0, 0, 14, 1, 0, 5], + &[9, 4, 1, 0, 0, 14, 2, 0, 6], + &[9, 4, 1, 1, 1, 14, 2, 0, 6], + &[7, 5, 0x81, 5, 0xfc, 3, 1], + &[8, 11, 2, 2, 14, 3, 0, 8], + &[9, 4, 2, 0, 0, 14, 1, 0, 8], + &[9, 4, 3, 0, 0, 14, 2, 0, 9], + &[9, 4, 3, 1, 1, 14, 2, 0, 9], + &[7, 5, 0x82, 5, 0xfc, 3, 1], +]; +fn observed_30d(data: &[u8], active: u8, manufacturer: &str, product: &str) -> bool { + if active != 1 || manufacturer != "Linux Foundation" + || product != "Multi Composite Double Uvc Gadget" + || data.len() != 0x3ed || !data.starts_with(D30_HEADER) { + return false; + } + let mut offset = D30_HEADER.len(); + let mut matched = 0; + while offset < data.len() { + if offset + 2 > data.len() { return false; } + let n = data[offset] as usize; + if n < 3 || offset + n > data.len() { return false; } + let descriptor = &data[offset..offset+n]; + if descriptor[1] != 0x24 { + if D30_TOPOLOGY.get(matched).copied() != Some(descriptor) { return false; } + matched += 1; + } + offset += n; + } + matched == D30_TOPOLOGY.len() +} + +struct Candidate { + path: String, + active: u8, + descriptors: Vec, + manufacturer: String, + product: String, +} +fn classify(mut candidates: Vec) -> Result { + // Count before classification: a 30D alongside a 30B must not hide ambiguity. + if candidates.len() != 1 { + return Err(format!("expected one camera with the stock USB ID, found {}", candidates.len()).into()); + } + let c = candidates.remove(0); + if observed_30d(&c.descriptors, c.active, &c.manufacturer, &c.product) { + return Ok(Discovery::Newer30d); + } + Ok(Discovery::Hid(Camera { + path: c.path, + interface: interface(&c.descriptors, c.active)?, + descriptors: c.descriptors, + })) +} +fn optional_string(path: &Path) -> Result { + match read_bounded(path, 1024) { + Ok(data) => Ok(std::str::from_utf8(&data)?.trim_end_matches('\n').to_owned()), + Err(e) if e.downcast_ref::().is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => Ok(String::new()), + Err(e) => Err(e), + } +} +pub fn discover() -> Result { let mut cameras = Vec::new(); for entry in fs::read_dir("/sys/bus/usb/devices")? { let p = entry?.path(); @@ -81,12 +157,15 @@ pub fn discover() -> Result { let bus = number(&p.join("busnum"),10)?; let dev = number(&p.join("devnum"),10)?; if bus==0 || bus>999 || dev==0 || dev>127 { return Err("invalid USB bus/address".into()); } - let active=u8::try_from(number(&p.join("bConfigurationValue"),10)?)?; - let descriptors=read_bounded(&p.join("descriptors"), 65536)?; - cameras.push(Camera { path:format!("/dev/bus/usb/{bus:03}/{dev:03}"), interface:interface(&descriptors, active)?, descriptors }); + cameras.push(Candidate { + path: format!("/dev/bus/usb/{bus:03}/{dev:03}"), + active: u8::try_from(number(&p.join("bConfigurationValue"),10)?)?, + descriptors: read_bounded(&p.join("descriptors"), 65536)?, + manufacturer: optional_string(&p.join("manufacturer"))?, + product: optional_string(&p.join("product"))?, + }); } - if cameras.len()!=1 { return Err(format!("expected one supported camera, found {}", cameras.len()).into()); } - Ok(cameras.remove(0)) + classify(cameras) } #[repr(C)] @@ -180,6 +259,53 @@ mod tests { let mut bad=d.clone(); bad[i]=v; assert!(interface(&bad,1).is_err(),"offset {i}"); } } + fn synthetic_30d() -> Candidate { + let mut data = D30_HEADER.to_vec(); + for d in D30_TOPOLOGY { data.extend_from_slice(d); } + // Synthetic class-specific payloads: the classifier checks the standard + // USB topology, not the contents of video format/frame declarations. + while data.len() < 0x3ed { + let n = (0x3ed - data.len()).min(250); + assert!(n >= 3); + data.extend_from_slice(&[n as u8, 0x24, 0]); + data.resize(data.len() + n - 3, 0); + } + Candidate { + path: "/must/not/be/opened".into(), active: 1, descriptors: data, + manufacturer: "Linux Foundation".into(), + product: "Multi Composite Double Uvc Gadget".into(), + } + } + #[test] + fn newer_camera_is_classified_from_sysfs_only() { + assert!(matches!(classify(vec![synthetic_30d()]).unwrap(), Discovery::Newer30d)); + assert!(classify(vec![]).is_err()); + assert!(classify(vec![synthetic_30d(), synthetic_30d()]).is_err()); + let old = Candidate { descriptors: descriptors(), ..synthetic_30d() }; + assert!(matches!(classify(vec![old]).unwrap(), Discovery::Hid(_))); + let old = Candidate { descriptors: descriptors(), ..synthetic_30d() }; + assert!(classify(vec![old, synthetic_30d()]).is_err()); + } + #[test] + fn shared_ids_missing_hid_and_partial_matches_do_not_identify_30d() { + for offset in 0..111 { + let mut c = synthetic_30d(); + c.descriptors[offset] ^= 1; + assert!(classify(vec![c]).is_err(), "changed standard descriptor byte {offset}"); + } + for n in [0, 18, 27, 111, 1004] { + let mut c = synthetic_30d(); c.descriptors.truncate(n); + assert!(classify(vec![c]).is_err()); + } + let mut c = synthetic_30d(); c.descriptors[111] = 0; + assert!(classify(vec![c]).is_err()); + let mut c = synthetic_30d(); c.product = "USB Camera".into(); + assert!(classify(vec![c]).is_err()); + let mut c = synthetic_30d(); c.manufacturer.clear(); + assert!(classify(vec![c]).is_err()); + let mut c = synthetic_30d(); c.active = 2; + assert!(classify(vec![c]).is_err()); + } #[test] fn ioctl_abi() { assert_eq!(ioc::(2,15),0x8004550f); From 7fb66c3adac01d9df1717b4415005305f4564eda Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Sep 2026 21:25:33 +0200 Subject: [PATCH 04/11] Expose bounded read-only version reply diagnostics and document 30B evidence --- docs/ON-PRINTER-HID.md | 37 +++++++++++++++++++++ on-printer/README.md | 6 ++++ on-printer/src/main.rs | 75 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index 0301ac2..57c512e 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -206,3 +206,40 @@ The existing hook's physical evidence in [STARTUP-HOOKS-VALIDATION.md](STARTUP-HOOKS-VALIDATION.md) does not validate this new transport, installer, or otherwise-stock hook-only boot behavior. User instructions therefore label this route experimental and hardware-unverified. + +## Observed 30B USB snapshot and version-query limitation + +The developer supplied a 1,794-byte (`0x702`) descriptor snapshot from a 30B +connected to the printer. Its single configuration is `0x6f0` bytes, with six +interface numbers. The descriptor-byte SHA-256 is +`732b7795d9f6945fb32df6f21e4ef85a38c640f396c888b51474b96a7db5e62a`. +This digest identifies the supplied metadata snapshot, not a firmware image. + +| Attribute | Observed 30B value | +|---|---| +| VID:PID / device revision | `a108:2240` / `0090` | +| Manufacturer | `Ingenic Semiconductor Co.,Ltd` | +| Product | `Ingenic HD Web Camera` | +| UVC functions | Two, covering interfaces 0/1 and 2/3 | +| HID | Interface 4, class/subclass/protocol `03/00/00`; interrupt IN `0x84`, OUT `0x01`, each maximum packet 1,024 bytes | +| ADB | Interface 5, class/subclass/protocol `ff/42/01`; bulk IN `0x85`, OUT `0x02`, each maximum packet 512 bytes | +| Printer driver observation | No bound driver on HID/ADB; no `/dev/hidraw*` nodes | + +Both observed revisions declare two UVC functions. The developer's Windows +presentation of one camera for the 30B and two for the 30D is not a reliable +USB-level discriminator. The 30D classifier additionally requires its revision, +strings and complete standard descriptor topology, including no HID/ADB. + +On physical hardware, `inspect` selected HID interface 4 with the expected +endpoints and reached the version-content validation error. That control flow +establishes a completed exchange with a matching command and valid framing/CRC; +it does not establish a successful version status or valid version text. +The initial generic error did not reveal which content check failed. The cause +remains unknown until the detailed reply is collected. + +A failed version-content check reports command `0x0001`, decimal/hex status, +payload length, escaped ASCII and a hex preview, capped at 64 payload bytes. +Nonzero status, empty data, more than 23 bytes and non-printable data still +refuse before any upload. No extra camera commands or automatic retries are +introduced. Framing/CRC failures remain protocol errors. Synthetic transport +tests check diagnostic contents, escaping, bounds and a single version query. diff --git a/on-printer/README.md b/on-printer/README.md index cf29f5d..cb465e3 100644 --- a/on-printer/README.md +++ b/on-printer/README.md @@ -87,6 +87,12 @@ firmware: the camera-side installer checks fingerprints before persistent writes Stop if inspection reports an error, multiple cameras, or bootloader mode. Do not manually unbind arbitrary USB drivers to bypass a refusal. +If the version reply fails validation, the error includes its status, payload +length, and escaped text/hex bytes (at most 64 bytes of preview). Paste that +diagnostic when reporting an inspection failure. Control bytes are escaped so +they cannot act as terminal commands. The version check still refuses the +operation before any upload; these diagnostics do not bypass validation. + ## Install, then verify after restart The following command **can write the camera's persistent configuration**. diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index e7d0378..d1a3521 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -80,6 +80,30 @@ fn with_hid_camera( usb::Discovery::Hid(camera) => use_camera(camera), } } +/// Send only the read-only version query; reject before any upload on failure. +fn query_version(transport: &mut impl protocol::Transport) -> Result> { + let (status, version) = send(transport, 1, &[], 1, 0)?; + let reason = if status != 0 { + Some("camera returned a nonzero status") + } else if version.is_empty() { + Some("empty version payload") + } else if version.len() > 23 { + Some("version payload exceeds 23 bytes") + } else if !version.iter().all(|b| (0x20..=0x7e).contains(b)) { + Some("version payload contains non-printable bytes") + } else { + None + }; + if let Some(reason) = reason { + let preview = &version[..version.len().min(64)]; + return Err(format!( + "invalid camera version response: {reason}; command=0x0001; status={status} (0x{status:08x}); payload_len={}; escaped=\"{}\"; hex={:02x?}{}. No upload was started.", + version.len(), preview.escape_ascii(), preview, + if preview.len() < version.len() { " (preview truncated to 64 bytes)" } else { "" } + ).into()); + } + Ok(version) +} fn run() -> Result<()> { // Parse consent and prepare every outgoing target before touching USB. let action=arguments(&std::env::args().skip(1).collect::>())?; @@ -94,10 +118,7 @@ fn run() -> Result<()> { with_hid_camera(usb::discover()?, &action, |camera| { println!("Camera {}: HID interface {}, input 0x{:02x}, output {:?}",camera.path,camera.interface.number,camera.interface.input,camera.interface.output); let mut transport=usb::Usb::open(camera)?; - let (status,version)=send(&mut transport,1,&[],1,0)?; - if status!=0 || version.is_empty() || version.len()>23 || !version.iter().all(|b| (0x20..=0x7e).contains(b)) { - return Err("invalid camera version response".into()); - } + let version=query_version(&mut transport)?; println!("Camera version: {}",String::from_utf8_lossy(&version)); if let Some((token,(path,launch,data)))=prepared { if action==Action::Install { println!("Accepted: no exported backup or clean-space check; programmer recovery may be required."); } @@ -167,4 +188,50 @@ mod tests { assert!(terminal(format!("0123456789abcdef:{status}").as_bytes(),"0123456789abcdef",&Action::Install).is_err()); } } + struct VersionReply { status: u32, payload: Vec, calls: usize } + impl protocol::Transport for VersionReply { + fn exchange(&mut self, request: &[u8; protocol::SIZE]) -> Result> { + assert_eq!(*request, protocol::report(1, &[], 1, 0)?, + "diagnostics must never send an upload or other camera command"); + self.calls += 1; + Ok(protocol::report(1, &self.payload, 1, self.status)?.to_vec()) + } + } + #[test] + fn version_diagnostics_preserve_refusals_and_send_only_one_read_query() { + for (status, payload, reason) in [ + (1, vec![], "nonzero status"), + (0, vec![], "empty version payload"), + (0, vec![b'x'; 24], "exceeds 23 bytes"), + (0, vec![b'A', 0, 10, 13, 27, 255], "non-printable bytes"), + ] { + let mut t = VersionReply { status, payload: payload.clone(), calls: 0 }; + let error = query_version(&mut t).unwrap_err().to_string(); + assert!(error.contains(reason), "{error}"); + assert!(error.contains(&format!("status={status} (0x{status:08x})"))); + assert!(error.contains(&format!("payload_len={}", payload.len()))); + assert!(error.contains("escaped=\"")); + assert!(error.contains("hex=[")); + assert!(error.contains("No upload was started.")); + assert!(!error.bytes().any(|b| b < 0x20 || b > 0x7e)); + assert_eq!(t.calls, 1); + } + let mut t = VersionReply { status: 0, payload: b"1.0.30B".to_vec(), calls: 0 }; + assert_eq!(query_version(&mut t).unwrap(), b"1.0.30B"); + assert_eq!(t.calls, 1); + } + #[test] + fn diagnostic_preview_is_bounded_and_control_bytes_are_escaped() { + let mut t = VersionReply { status: 0, payload: vec![0, 10, 13, 27, 255], calls: 0 }; + let error = query_version(&mut t).unwrap_err().to_string(); + assert!(error.contains(r#"escaped="\x00\n\r\x1b\xff""#), "{error}"); + assert!(error.contains("hex=[00, 0a, 0d, 1b, ff]"), "{error}"); + let mut t = VersionReply { status: 0, payload: vec![255; protocol::CHUNK], calls: 0 }; + let error = query_version(&mut t).unwrap_err().to_string(); + assert!(error.contains("payload_len=1010")); + assert!(error.contains("preview truncated to 64 bytes")); + assert!(error.len() < 1024); + assert_eq!(t.calls, 1); + } + } From 0515de0442bff802056efd45c205dca36ea12814 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Sep 2026 21:26:02 +0200 Subject: [PATCH 05/11] Use range containment in diagnostic escaping regression --- on-printer/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index d1a3521..68c36b7 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -213,7 +213,7 @@ mod tests { assert!(error.contains("escaped=\"")); assert!(error.contains("hex=[")); assert!(error.contains("No upload was started.")); - assert!(!error.bytes().any(|b| b < 0x20 || b > 0x7e)); + assert!(error.bytes().all(|b| (0x20..=0x7e).contains(&b))); assert_eq!(t.calls, 1); } let mut t = VersionReply { status: 0, payload: b"1.0.30B".to_vec(), calls: 0 }; From 247e24f44c3a03abaed6aa811648085a0b5c7298 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Sep 2026 22:12:05 +0200 Subject: [PATCH 06/11] Support missing camera version metadata and temporary HID status files --- docs/ON-PRINTER-HID.md | 22 ++++++++++---- on-printer/README.md | 12 ++++++-- on-printer/installer.sh.in | 38 +++++++++++++++++------ on-printer/payload/installer.sh | 38 +++++++++++++++++------ on-printer/src/main.rs | 35 ++++++++++++++++++--- tests/test_on_printer.py | 54 +++++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 32 deletions(-) diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index 57c512e..f6fea15 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -142,7 +142,10 @@ colon, and status fit. `BUSY` is nonterminal; `DONE`/`SAME` confirm installation comparisons; `LIVE` confirms verification; `FAIL` means preflight refusal and `PART` means persistent writes started but did not finish successfully. Results from another session or another operation cannot authorize success. The original -version file is copied back two minutes after the worker finishes. This behavior +version file is restored two minutes after the worker finishes; if it was absent, +the newly created file is removed. Status records are published by rename so a +query cannot observe a partially written record. Unexpected replacements are +preserved along with staging diagnostics. This behavior and its effect on printer software are hardware-unverified. The first staging upload precedes camera-side checks. It relies on the analyzed @@ -234,12 +237,19 @@ On physical hardware, `inspect` selected HID interface 4 with the expected endpoints and reached the version-content validation error. That control flow establishes a completed exchange with a matching command and valid framing/CRC; it does not establish a successful version status or valid version text. -The initial generic error did not reveal which content check failed. The cause -remains unknown until the detailed reply is collected. +The detailed physical reply was status 1 with an empty payload. The reconstructed +handler returns this when opening or reading camera `/tmp/version.txt` fails. +The supplied repaired, bricked and ADB-repaired flash images have an empty root +`/tmp` directory and mount tmpfs there; they do not capture the live RAM file. A failed version-content check reports command `0x0001`, decimal/hex status, payload length, escaped ASCII and a hex preview, capped at 64 payload bytes. -Nonzero status, empty data, more than 23 bytes and non-printable data still -refuse before any upload. No extra camera commands or automatic retries are -introduced. Framing/CRC failures remain protocol errors. Synthetic transport +The developer-approved exception accepts only status 1 with an empty payload +as unavailable metadata. Inspection still sends one query and performs no upload. +All other nonzero statuses, status-0 empty data, more than 23 bytes and +non-printable data refuse before upload. The worker can create the missing RAM +file; existing symlinks, nonregular files and files above 4096 bytes are refused. +Firmware and persistent-write checks are unchanged. Polling tolerates that exact +unavailable reply while the worker starts, within the existing 90-second deadline; +it never treats it as success or retries an upload. Framing/CRC failures remain protocol errors. Synthetic transport tests check diagnostic contents, escaping, bounds and a single version query. diff --git a/on-printer/README.md b/on-printer/README.md index cb465e3..8ce926a 100644 --- a/on-printer/README.md +++ b/on-printer/README.md @@ -81,13 +81,17 @@ remain unsupported. Multiple matching-ID cameras are refused before classificati For a supported HID camera, `inspect` sends only the version-query command. It does not upload scripts or change camera files. Expect exactly one camera, its -selected HID interface and endpoints, and a printable version response. USB +selected HID interface and endpoints, and either a printable version response or an unavailable-version message. USB identification and a version reply alone do **not** establish compatible firmware: the camera-side installer checks fingerprints before persistent writes. Stop if inspection reports an error, multiple cameras, or bootloader mode. Do not manually unbind arbitrary USB drivers to bypass a refusal. -If the version reply fails validation, the error includes its status, payload +The exact status-1 reply with no payload means the camera could not open/read +its temporary version file. This is accepted as unavailable metadata, not proof +of compatible firmware. Inspection does not create that file. + +If another version reply fails validation, the error includes its status, payload length, and escaped text/hex bytes (at most 64 bytes of preview). Paste that diagnostic when reporting an inspection failure. Control bytes are escaped so they cannot act as terminal commands. The version check still refuses the @@ -159,7 +163,9 @@ files on a later attempt. Both `install` and `verify` temporarily replace the camera's version-query response with a session-specific status. Two minutes after the worker finishes, -it restores the original version file. The command-launch mechanism leaves the +it restores the original version file if one existed, or removes the file it +created. Symlinks, nonregular files and existing files larger than 4096 bytes +are refused before status writes. The command-launch mechanism leaves the camera's HID **uploader** in an error state until the camera daemon restarts; version queries remain usable. The program does not automatically restart that daemon. Do not attempt another upload in the same boot. diff --git a/on-printer/installer.sh.in b/on-printer/installer.sh.in index 39db353..15608b5 100644 --- a/on-printer/installer.sh.in +++ b/on-printer/installer.sh.in @@ -16,13 +16,33 @@ mounted() { busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts } regular() { [ -f "$1" ] && [ ! -L "$1" ]; } +prepare_status() { + # Missing metadata is supported; links and other file types are not. + [ ! -L /tmp/version.txt ] || return 1 + if [ -e /tmp/version.txt ]; then + regular /tmp/version.txt || return 1 + [ "$(busybox stat -c %s /tmp/version.txt)" -le 4096 ] || return 1 + cp -p /tmp/version.txt "$STAGE/version" || return 1 + fi +} +publish_status() { + [ ! -L /tmp/version.txt ] || return 1 + [ ! -e /tmp/version.txt ] || regular /tmp/version.txt || return 1 + # Rename a complete record so a query cannot see a truncated status. + printf '%s:%s\n' "$TOKEN" "$STATUS" > "$STAGE/status" || return 1 + cp "$STAGE/status" "$STAGE/status-next" || return 1 + mv "$STAGE/status-next" /tmp/version.txt +} finish() { - # The version query exposes at most 23 bytes. Keep a nonce in every result. - printf '%s:%s\n' "$TOKEN" "$STATUS" > /tmp/version.txt - # Status is RAM-only and available for two minutes, then restore the exact - # original version file. Never clean up incomplete persistent files. + publish_status || return 1 sleep 120 - cp "$STAGE/version" /tmp/version.txt + # Leave unexpected replacements and staging diagnostics untouched. + regular /tmp/version.txt && busybox cmp -s "$STAGE/status" /tmp/version.txt || return 1 + if [ -e "$STAGE/version" ]; then + mv "$STAGE/version" /tmp/version.txt || return 1 + else + rm /tmp/version.txt || return 1 + fi rm -rf "$STAGE" rm -f "$SELF" } @@ -114,13 +134,13 @@ main() { [ ! -L /tmp ] || exit 1 busybox awk '$2=="/tmp" && $3=="tmpfs" {n++} END {exit(n!=1)}' /proc/mounts || exit 1 regular "$SELF" || exit 1 - regular /tmp/version.txt || exit 1 - [ "$(busybox stat -c %s /tmp/version.txt)" -le 4096 ] || exit 1 umask 077 mkdir "$STAGE" || exit 1 - cp /tmp/version.txt "$STAGE/version" || exit 1 + prepare_status || exit 1 trap finish 0 - printf '%s:BUSY\n' "$TOKEN" > /tmp/version.txt || fail status + STATUS=BUSY + publish_status || exit 1 + STATUS=FAIL preflight @PAYLOADS@ [ "$(busybox md5sum "$STAGE/fix")" = '@FIX_MD5@ '"$STAGE/fix" ] || fail staged-fix diff --git a/on-printer/payload/installer.sh b/on-printer/payload/installer.sh index a05d050..31d25bd 100644 --- a/on-printer/payload/installer.sh +++ b/on-printer/payload/installer.sh @@ -16,13 +16,33 @@ mounted() { busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts } regular() { [ -f "$1" ] && [ ! -L "$1" ]; } +prepare_status() { + # Missing metadata is supported; links and other file types are not. + [ ! -L /tmp/version.txt ] || return 1 + if [ -e /tmp/version.txt ]; then + regular /tmp/version.txt || return 1 + [ "$(busybox stat -c %s /tmp/version.txt)" -le 4096 ] || return 1 + cp -p /tmp/version.txt "$STAGE/version" || return 1 + fi +} +publish_status() { + [ ! -L /tmp/version.txt ] || return 1 + [ ! -e /tmp/version.txt ] || regular /tmp/version.txt || return 1 + # Rename a complete record so a query cannot see a truncated status. + printf '%s:%s\n' "$TOKEN" "$STATUS" > "$STAGE/status" || return 1 + cp "$STAGE/status" "$STAGE/status-next" || return 1 + mv "$STAGE/status-next" /tmp/version.txt +} finish() { - # The version query exposes at most 23 bytes. Keep a nonce in every result. - printf '%s:%s\n' "$TOKEN" "$STATUS" > /tmp/version.txt - # Status is RAM-only and available for two minutes, then restore the exact - # original version file. Never clean up incomplete persistent files. + publish_status || return 1 sleep 120 - cp "$STAGE/version" /tmp/version.txt + # Leave unexpected replacements and staging diagnostics untouched. + regular /tmp/version.txt && busybox cmp -s "$STAGE/status" /tmp/version.txt || return 1 + if [ -e "$STAGE/version" ]; then + mv "$STAGE/version" /tmp/version.txt || return 1 + else + rm /tmp/version.txt || return 1 + fi rm -rf "$STAGE" rm -f "$SELF" } @@ -125,13 +145,13 @@ main() { [ ! -L /tmp ] || exit 1 busybox awk '$2=="/tmp" && $3=="tmpfs" {n++} END {exit(n!=1)}' /proc/mounts || exit 1 regular "$SELF" || exit 1 - regular /tmp/version.txt || exit 1 - [ "$(busybox stat -c %s /tmp/version.txt)" -le 4096 ] || exit 1 umask 077 mkdir "$STAGE" || exit 1 - cp /tmp/version.txt "$STAGE/version" || exit 1 + prepare_status || exit 1 trap finish 0 - printf '%s:BUSY\n' "$TOKEN" > /tmp/version.txt || fail status + STATUS=BUSY + publish_status || exit 1 + STATUS=FAIL preflight cat > "$STAGE/fix" <<'CC2_PAYLOAD_END' || fail payload #!/bin/sh diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index 68c36b7..e8b3790 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -81,8 +81,9 @@ fn with_hid_camera( } } /// Send only the read-only version query; reject before any upload on failure. -fn query_version(transport: &mut impl protocol::Transport) -> Result> { +fn query_version(transport: &mut impl protocol::Transport) -> Result>> { let (status, version) = send(transport, 1, &[], 1, 0)?; + if version_unavailable(status, &version) { return Ok(None); } let reason = if status != 0 { Some("camera returned a nonzero status") } else if version.is_empty() { @@ -102,7 +103,11 @@ fn query_version(transport: &mut impl protocol::Transport) -> Result> { if preview.len() < version.len() { " (preview truncated to 64 bytes)" } else { "" } ).into()); } - Ok(version) + Ok(Some(version)) +} +// The stock handler returns this exact reply when opening/reading its RAM file fails. +fn version_unavailable(status: u32, payload: &[u8]) -> bool { + status == 1 && payload.is_empty() } fn run() -> Result<()> { // Parse consent and prepare every outgoing target before touching USB. @@ -119,7 +124,10 @@ fn run() -> Result<()> { println!("Camera {}: HID interface {}, input 0x{:02x}, output {:?}",camera.path,camera.interface.number,camera.interface.input,camera.interface.output); let mut transport=usb::Usb::open(camera)?; let version=query_version(&mut transport)?; - println!("Camera version: {}",String::from_utf8_lossy(&version)); + match version { + Some(version) => println!("Camera version: {}", String::from_utf8_lossy(&version)), + None => println!("Camera version unavailable (status 1, empty reply). HID communication works; firmware compatibility is not yet verified."), + } if let Some((token,(path,launch,data)))=prepared { if action==Action::Install { println!("Accepted: no exported backup or clean-space check; programmer recovery may be required."); } println!("Uploading temporary camera worker; session {token}. Do not interrupt power."); @@ -128,6 +136,12 @@ fn run() -> Result<()> { let deadline=Instant::now()+Duration::from_secs(90); while Instant::now() '+str(version)+'\n') + result = self.run_shell('prepare_status || exit 1; STATUS=DONE; finish || exit 1') + self.assertNotEqual(result.returncode, 0) + self.assertEqual(version.read_text(), 'other') + self.assertEqual((self.stage/'version').read_text(), 'original') + self.assertFalse((self.config/'enabled').exists()) + def test_success_then_idempotency_without_persistent_changes(self): result = self.run_shell() self.assertEqual(result.returncode, 0, result.stdout+result.stderr) From e7cf66cf51559a4a5ea42f53df1962d2c613c335 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Sep 2026 22:22:14 +0200 Subject: [PATCH 07/11] Log full HID exchanges in verbose mode and report named worker failures --- docs/ON-PRINTER-HID.md | 20 ++++++++++++ on-printer/README.md | 22 +++++++++++++ on-printer/failure-reasons.txt | 34 +++++++++++++++++++ on-printer/generate_payload.py | 10 ++++++ on-printer/installer.sh.in | 11 ++++++- on-printer/payload/installer.sh | 44 ++++++++++++++++++++++++- on-printer/src/main.rs | 53 ++++++++++++++++++++++++++++-- on-printer/src/protocol.rs | 58 +++++++++++++++++++++++++++++++++ tests/test_on_printer.py | 14 +++++++- 9 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 on-printer/failure-reasons.txt diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index f6fea15..3be9e8f 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -253,3 +253,23 @@ Firmware and persistent-write checks are unchanged. Polling tolerates that exact unavailable reply while the worker starts, within the existing 90-second deadline; it never treats it as success or retries an upload. Framing/CRC failures remain protocol errors. Synthetic transport tests check diagnostic contents, escaping, bounds and a single version query. + +## Worker refusal diagnostics + +Physical testing completed both staging uploads, launched the worker, and read +a current-session `FAIL`. This establishes operation of the temporary status +channel and a refusal before installation writes, but does not identify the +failed check or establish successful installation. That attempt predates named +failure diagnostics; its exact failed check cannot be recovered from `FAIL`. + +The worker encodes named failures as `Fnn` (before installation writes) or `Pnn` +(after writes began). The shared `on-printer/failure-reasons.txt` catalog drives +both shell generation and host decoding. A 16-digit token, colon and three-byte +code fit in the 23-byte response. Unknown codes remain failures. Canonical hook +contents and all camera checks are unchanged. + +`--verbose` records complete HID reports before reply validation, including +padding and malformed bytes, plus transport errors. It adds no exchanges and +never retries. It is a HID report log, not a capture of camera shell output or +USB video traffic. Offline tests cover malformed replies, disabled logging, +transport errors, consent, failure-stage preservation and stale session refusal. diff --git a/on-printer/README.md b/on-printer/README.md index 8ce926a..9f630a8 100644 --- a/on-printer/README.md +++ b/on-printer/README.md @@ -196,3 +196,25 @@ create releases. See [implementation evidence and validation limits](../docs/ON-PRINTER-HID.md) for the protocol, accepted exception, and outstanding hardware checks. + +## Diagnostic logging + +Add `--verbose` to any command to log every complete outgoing and incoming HID +report as hex on stderr, including padding, upload payloads and malformed replies. +Transport errors are logged too. TX records describe an attempted exchange, not +proof that the camera received it. Logging adds no queries or retries and does +not capture video traffic or the camera process's stdout/stderr. + +```sh +./cc2camera-hid inspect --verbose > /tmp/cc2camera-inspect.log 2>&1 +cat /tmp/cc2camera-inspect.log +``` + +Installation can also be logged with `install --accept-no-backup-space-risk +--verbose`. Only run that operation under the installation prerequisites above; +logging does not make it read-only or permit retrying in the same boot. + +Worker failures include the check name and a compact status code. `Fnn` means +refusal before persistent installation writes; `Pnn` means writes had begun. +Both are failures, and only a result carrying the current session token is +accepted. Legacy `FAIL`/`PART` replies retain their failure meaning. diff --git a/on-printer/failure-reasons.txt b/on-printer/failure-reasons.txt new file mode 100644 index 0000000..c2ea6e3 --- /dev/null +++ b/on-printer/failure-reasons.txt @@ -0,0 +1,34 @@ +01 chmod +02 config-changing +03 config-mount +04 config-path +05 config-read +06 copy +07 destination-appeared +08 enabled-path +09 final-missing +10 fix-inactive +11 hid-fingerprint +12 identity-file +13 incomplete-install +14 instructions +15 kernel-fingerprint +16 managed-link +17 missing-hooks +18 mkdir +19 mode +20 mount-topology +21 partition-map +22 pointer +23 pointer-changed +24 pointer-format +25 pointer-range +26 rename +27 root +28 staged-fix +29 staged-runner +30 symbols +31 sync +32 temporary-readback +33 unknown-managed-file +34 unrelated-hook-type diff --git a/on-printer/generate_payload.py b/on-printer/generate_payload.py index 254ef98..e1921db 100644 --- a/on-printer/generate_payload.py +++ b/on-printer/generate_payload.py @@ -4,6 +4,7 @@ Python is required only on the development/build machine, never on the printer. """ import hashlib +import re from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -28,7 +29,9 @@ def generate(): f'[ "$(busybox awk \'$3=="{n}" {{print $1}}\' /proc/kallsyms)" = "{a:08x}" ] || fail symbols' for n, a in SYMBOLS.items() ) + reasons = [line.split() for line in (ROOT/'failure-reasons.txt').read_text().splitlines()] replacements = { + '@FAIL_CASES@': '\n'.join(f' {name}) code={code};;' for code,name in reasons), '@KERNEL_MD5@': KERNEL_MD5, '@HID_MD5@': '8091751fdd4d0d50ea31901663797a86', '@MTD@': '\n'.join(f'mtd{i}: {size:08x} 00004000 "{name}"' for i, (name,size) in enumerate(EXPECTED_PARTITIONS)), @@ -39,6 +42,13 @@ def generate(): } script = (ROOT/'installer.sh.in').read_text() for key,value in replacements.items(): script=script.replace(key,value) + # Check only this worker and generated live checks, not the embedded hooks. + worker = (ROOT/'installer.sh.in').read_text() + checks + used = set(re.findall(r'\bfail ([a-z][a-z-]*)', worker)) + if used != {name for _,name in reasons}: + raise ValueError('Worker failure-reasons.txt does not match fail sites') + if len({code for code,_ in reasons}) != len(reasons) or any(not re.fullmatch(r'[0-9]{2}', code) or code == '00' for code,_ in reasons): + raise ValueError('Failure codes must be unique two-digit values other than 00') return script if __name__ == '__main__': diff --git a/on-printer/installer.sh.in b/on-printer/installer.sh.in index 15608b5..2ebc68a 100644 --- a/on-printer/installer.sh.in +++ b/on-printer/installer.sh.in @@ -11,7 +11,16 @@ STAGE=/tmp/.cc2-hid-$TOKEN CONFIG=/etc/conf.d STATUS=FAIL -fail() { echo "cc2camera-hid: $*"; exit 1; } +fail() { + code=00 + case "$1" in +@FAIL_CASES@ + esac + # Preserve the distinction between refusal and a partially written install. + case "$STATUS" in PART) STATUS=P$code;; *) STATUS=F$code;; esac + echo "cc2camera-hid: $*" + exit 1 +} mounted() { busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts } diff --git a/on-printer/payload/installer.sh b/on-printer/payload/installer.sh index 31d25bd..52aac2d 100644 --- a/on-printer/payload/installer.sh +++ b/on-printer/payload/installer.sh @@ -11,7 +11,49 @@ STAGE=/tmp/.cc2-hid-$TOKEN CONFIG=/etc/conf.d STATUS=FAIL -fail() { echo "cc2camera-hid: $*"; exit 1; } +fail() { + code=00 + case "$1" in + chmod) code=01;; + config-changing) code=02;; + config-mount) code=03;; + config-path) code=04;; + config-read) code=05;; + copy) code=06;; + destination-appeared) code=07;; + enabled-path) code=08;; + final-missing) code=09;; + fix-inactive) code=10;; + hid-fingerprint) code=11;; + identity-file) code=12;; + incomplete-install) code=13;; + instructions) code=14;; + kernel-fingerprint) code=15;; + managed-link) code=16;; + missing-hooks) code=17;; + mkdir) code=18;; + mode) code=19;; + mount-topology) code=20;; + partition-map) code=21;; + pointer) code=22;; + pointer-changed) code=23;; + pointer-format) code=24;; + pointer-range) code=25;; + rename) code=26;; + root) code=27;; + staged-fix) code=28;; + staged-runner) code=29;; + symbols) code=30;; + sync) code=31;; + temporary-readback) code=32;; + unknown-managed-file) code=33;; + unrelated-hook-type) code=34;; + esac + # Preserve the distinction between refusal and a partially written install. + case "$STATUS" in PART) STATUS=P$code;; *) STATUS=F$code;; esac + echo "cc2camera-hid: $*" + exit 1 +} mounted() { busybox awk '$2=="/etc/conf.d" {n++; if($1!="/dev/mtdblock5" || $3!="jffs2" || $4 !~ /(^|,)rw(,|$)/)bad=1} END {exit(n!=1 || bad)}' /proc/mounts } diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index e8b3790..633165b 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -22,6 +22,9 @@ libraries required by the static build. Only known camera firmware is supported. probe and verify the installed hooks and live RAM correction. No persistent file or RAM correction writes; temporary files/status are written in /tmp. +Add --verbose to any command to log every full HID report in hex to stderr, +including upload payloads, malformed replies and transport errors. No retries. + Install/verify launch a camera shell script, temporarily replace its version response for two minutes, and leave its HID uploader unavailable until restart. Never retry after an error. A timeout does not cancel a camera-side installer. @@ -40,6 +43,12 @@ fn arguments(args: &[String]) -> Result { _ => Err("unknown arguments; run --help".into()), } } +fn options(args: &[String]) -> Result<(Action, bool)> { + let count = args.iter().filter(|a| a.as_str() == "--verbose").count(); + if count > 1 { return Err("--verbose may only be specified once".into()); } + let filtered: Vec = args.iter().filter(|a| a.as_str() != "--verbose").cloned().collect(); + Ok((arguments(&filtered)?, count == 1)) +} fn script(token: &str, action: &Action) -> Result<(String, String, Vec)> { if token.len()!=16 || !token.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { return Err("invalid local session token".into()); @@ -56,7 +65,20 @@ fn script(token: &str, action: &Action) -> Result<(String, String, Vec)> { fn terminal(payload: &[u8], token: &str, action: &Action) -> Result { let prefix=format!("{token}:"); if !payload.starts_with(prefix.as_bytes()) { return Ok(false); } - match &payload[prefix.len()..] { + let state = &payload[prefix.len()..]; + if state.len() == 3 && matches!(state[0], b'F' | b'P') && state[1..].iter().all(u8::is_ascii_digit) { + let code = std::str::from_utf8(&state[1..])?; + let reason = include_str!("../failure-reasons.txt").lines() + .filter_map(|line| line.split_once(' ')) + .find(|(id, _)| *id == code).map(|(_, name)| name).unwrap_or("unknown-check"); + let detail = if state[0] == b'F' { + "no persistent installation writes were started. Do not retry this boot." + } else { + "persistent writes began; outcome may be partial. Preserve power; do not retry or restart blindly." + }; + return Err(format!("camera-side check failed: {reason} ({}); {detail}", String::from_utf8_lossy(state)).into()); + } + match state { b"BUSY" => Ok(false), b"DONE" | b"SAME" if *action==Action::Install => Ok(true), b"LIVE" if *action==Action::Verify => Ok(true), @@ -111,7 +133,7 @@ fn version_unavailable(status: u32, payload: &[u8]) -> bool { } fn run() -> Result<()> { // Parse consent and prepare every outgoing target before touching USB. - let action=arguments(&std::env::args().skip(1).collect::>())?; + let (action, verbose)=options(&std::env::args().skip(1).collect::>())?; if action==Action::Help { print!("{HELP}"); return Ok(()); } let mut random=[0u8;8]; let prepared=if matches!(action,Action::Install|Action::Verify) { @@ -122,7 +144,7 @@ fn run() -> Result<()> { } else { None }; with_hid_camera(usb::discover()?, &action, |camera| { println!("Camera {}: HID interface {}, input 0x{:02x}, output {:?}",camera.path,camera.interface.number,camera.interface.input,camera.interface.output); - let mut transport=usb::Usb::open(camera)?; + let mut transport=protocol::Trace { inner: usb::Usb::open(camera)?, writer: std::io::stderr(), enabled: verbose }; let version=query_version(&mut transport)?; match version { Some(version) => println!("Camera version: {}", String::from_utf8_lossy(&version)), @@ -176,6 +198,31 @@ mod tests { } } #[test] + fn failure_codes_preserve_stage_and_require_current_session() { + for line in include_str!("../failure-reasons.txt").lines() { + let (code, reason) = line.split_once(' ').unwrap(); + for (stage, message) in [("F", "no persistent installation writes"), ("P", "persistent writes began")] { + let payload = format!("0123456789abcdef:{stage}{code}"); + assert!(payload.len() <= 23); + let error = terminal(payload.as_bytes(), "0123456789abcdef", &Action::Install).unwrap_err().to_string(); + assert!(error.contains(reason) && error.contains(message), "{error}"); + assert!(!terminal(payload.as_bytes(), "fedcba9876543210", &Action::Install).unwrap()); + } + } + for state in ["F00", "P99", "F1", "F010", "Fxx"] { + assert!(terminal(format!("0123456789abcdef:{state}").as_bytes(), "0123456789abcdef", &Action::Install).is_err()); + } + } + #[test] + fn verbose_preserves_consent_and_argument_refusals() { + assert_eq!(options(&args(&["--verbose", "inspect"])).unwrap(), (Action::Inspect, true)); + assert_eq!(options(&args(&["inspect", "--verbose"])).unwrap(), (Action::Inspect, true)); + assert_eq!(options(&args(&["install", ACCEPT, "--verbose"])).unwrap(), (Action::Install, true)); + assert!(options(&args(&["install", "--verbose"])).is_err()); + assert!(options(&args(&["inspect", "--verbose", "--verbose"])).is_err()); + assert!(options(&args(&["inspect", "--unknown", "--verbose"])).is_err()); + } + #[test] fn consent_and_unknown_arguments() { assert!(arguments(&args(&["install"])).is_err()); assert!(arguments(&args(&["install","--force"])).is_err()); diff --git a/on-printer/src/protocol.rs b/on-printer/src/protocol.rs index 43c2e40..e4c9c41 100644 --- a/on-printer/src/protocol.rs +++ b/on-printer/src/protocol.rs @@ -50,6 +50,38 @@ pub trait Transport { fn exchange(&mut self, request: &[u8; SIZE]) -> Result>; } +/// Logs complete HID reports before decoding, without adding exchanges or retries. +pub struct Trace { + pub inner: T, + pub writer: W, + pub enabled: bool, +} +fn dump(writer: &mut impl std::io::Write, direction: &str, bytes: &[u8]) -> std::io::Result<()> { + writeln!(writer, "HID {direction} len={}", bytes.len())?; + for (i, chunk) in bytes.chunks(16).enumerate() { + write!(writer, "{:04x}:", i * 16)?; + for byte in chunk { write!(writer, " {byte:02x}")?; } + writeln!(writer)?; + } + writer.flush() +} +impl Transport for Trace { + fn exchange(&mut self, request: &[u8; SIZE]) -> Result> { + if self.enabled { dump(&mut self.writer, "TX attempt", request)?; } + let result = self.inner.exchange(request); + if self.enabled { + match &result { + Ok(reply) => dump(&mut self.writer, "RX", reply)?, + Err(error) => { + writeln!(self.writer, "HID transport error: {:?}", error.to_string())?; + self.writer.flush()?; + } + } + } + result + } +} + pub fn send(t: &mut impl Transport, command: u16, data: &[u8], kind: u8, seq: u32) -> Result<(u32, Vec)> { response(&t.exchange(&report(command, data, kind, seq)?)?, command) } @@ -83,6 +115,32 @@ pub fn upload(t: &mut impl Transport, path: &[u8], data: &[u8], launch: bool) -> mod tests { use super::*; #[test] + fn trace_preserves_full_reports_and_errors_without_extra_exchanges() { + struct Reply { bytes: Vec, calls: usize, fail: bool } + impl Transport for Reply { + fn exchange(&mut self, _: &[u8; SIZE]) -> Result> { + self.calls += 1; + if self.fail { Err("injected failure".into()) } else { Ok(self.bytes.clone()) } + } + } + for enabled in [false, true] { + for fail in [false, true] { + // Deliberately malformed: trace must include bytes before decoding. + let bytes = vec![0xff, 0, 0x1b]; + let mut t = Trace { inner: Reply { bytes, calls: 0, fail }, writer: Vec::new(), enabled }; + assert!(send(&mut t, 1, &[], 1, 0).is_err()); + assert_eq!(t.inner.calls, 1); + let log = String::from_utf8(t.writer).unwrap(); + if enabled { + assert!(log.contains("HID TX attempt len=1024")); + assert!(log.contains("03f0:"), "padding must not be omitted"); + assert!(log.contains(if fail { "injected failure" } else { "HID RX len=3\n0000: ff 00 1b" })); + assert!(!log.contains('\x1b')); + } else { assert!(log.is_empty()); } + } + } + } + #[test] fn frames_and_corruption() { assert_eq!(crc(b"123456789".iter().copied()), 0x1dba); let r = report(0x3200, b"abc", 2, 7).unwrap(); diff --git a/tests/test_on_printer.py b/tests/test_on_printer.py index 68a42d7..19f6891 100644 --- a/tests/test_on_printer.py +++ b/tests/test_on_printer.py @@ -109,6 +109,17 @@ def test_status_refuses_unsafe_paths_without_changing_targets(self): if kind == 'directory': version.rmdir() else: version.unlink() + def test_refusal_reports_specific_check_through_status_file(self): + version = self.status_setup() + # Observe the published terminal record before cleanup, without waiting. + (self.bin/'sleep').write_text('#!/bin/sh\ncat '+str(version)+'\n') + result = self.run_shell('prepare_status || exit 1; trap finish 0; fail kernel-fingerprint') + self.assertNotEqual(result.returncode, 0) + code = next(line.split()[0] for line in (ROOT/'on-printer/failure-reasons.txt').read_text().splitlines() if line.endswith(' kernel-fingerprint')) + self.assertIn('0123456789abcdef:F'+code, result.stdout) + self.assertFalse(version.exists()) + self.assertFalse((self.config/'enabled').exists()) + def test_status_cleanup_preserves_unexpected_replacement(self): version = self.status_setup() version.write_text('original') @@ -157,7 +168,8 @@ def test_partial_copy_never_installs_boot_entry_point(self): (self.bin/'cp').chmod(0o755) result=self.run_shell(modifications="trap 'echo STATUS=$STATUS' 0") self.assertNotEqual(result.returncode,0) - self.assertIn('STATUS=PART',result.stdout) + code = next(line.split()[0] for line in (ROOT/'on-printer/failure-reasons.txt').read_text().splitlines() if line.endswith(' copy')) + self.assertIn('STATUS=P'+code,result.stdout) self.assertFalse((self.config/'system.sh').exists()) self.assertFalse((self.config/'enabled/10-erase-fix.sh').exists()) self.assertTrue((self.config/'.cc2-hid-fix').exists()) From f3de57d1d1e90d7036428062bff1eb0c35d42999 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Sep 2026 22:51:00 +0200 Subject: [PATCH 08/11] Distinguish starter and erase-hook file validation failures --- docs/ON-PRINTER-HID.md | 12 +++++++++++ on-printer/README.md | 8 ++++++++ on-printer/failure-reasons.txt | 10 +++++++++ on-printer/installer.sh.in | 26 +++++++++++++++++++++++- on-printer/payload/installer.sh | 36 ++++++++++++++++++++++++++++++++- tests/test_on_printer.py | 35 ++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 2 deletions(-) diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index 3be9e8f..c751f29 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -273,3 +273,15 @@ padding and malformed bytes, plus transport errors. It adds no exchanges and never retries. It is a HID report log, not a capture of camera shell output or USB video traffic. Offline tests cover malformed replies, disabled logging, transport errors, consent, failure-stage preservation and stale session refusal. + +After a physical power cycle, the developer observed a complete upload and a +current-session `F33` (`unknown-managed-file`) refusal. This passed the initial +firmware/partition/mount checks, but the combined file check does not reveal +which managed path, file type, permissions or comparison condition failed. +The attached ADB-repaired image contains an older ADB-only `system.sh`; the +developer clarified that it is not a current snapshot and expects the live +camera to contain the canonical enabled-script starter. The image therefore +cannot diagnose the current refusal. Path-specific checks now distinguish file +type, stat failure, mode mismatch, content mismatch and comparison-tool failure. +Offline tests cover absent-file installation and preservation of a canonical +starter when adding a missing erase hook; these are not new compatibility cases. diff --git a/on-printer/README.md b/on-printer/README.md index 9f630a8..1c56bed 100644 --- a/on-printer/README.md +++ b/on-printer/README.md @@ -218,3 +218,11 @@ Worker failures include the check name and a compact status code. `Fnn` means refusal before persistent installation writes; `Pnn` means writes had begun. Both are failures, and only a result carrying the current session token is accepted. Legacy `FAIL`/`PART` replies retain their failure meaning. + +Missing managed files are allowed: a stock camera need not already have +`/etc/conf.d/system.sh` or `enabled/10-erase-fix.sh`. An existing canonical +starter with mode 755 is preserved, including when the erase hook is absent. +Managed-file failures identify `starter` (`system.sh`) or `erase-hook`, followed +by `type`, `stat`, `mode`, `content`, or `compare`. `content` means `cmp` reported +different bytes; `compare` means the comparison command failed. These diagnostics +do not authorize overwriting existing files. diff --git a/on-printer/failure-reasons.txt b/on-printer/failure-reasons.txt index c2ea6e3..ca8ce89 100644 --- a/on-printer/failure-reasons.txt +++ b/on-printer/failure-reasons.txt @@ -32,3 +32,13 @@ 32 temporary-readback 33 unknown-managed-file 34 unrelated-hook-type +35 starter-type +36 starter-stat +37 starter-mode +38 starter-content +39 starter-compare +40 erase-hook-type +41 erase-hook-stat +42 erase-hook-mode +43 erase-hook-content +44 erase-hook-compare diff --git a/on-printer/installer.sh.in b/on-printer/installer.sh.in index 2ebc68a..f4a050c 100644 --- a/on-printer/installer.sh.in +++ b/on-printer/installer.sh.in @@ -70,12 +70,36 @@ preflight() { regular "$hook" || fail unrelated-hook-type done } +managed_failure() { + case "$dest:$1" in + "$CONFIG/system.sh:type") fail starter-type;; + "$CONFIG/system.sh:stat") fail starter-stat;; + "$CONFIG/system.sh:mode") fail starter-mode;; + "$CONFIG/system.sh:content") fail starter-content;; + "$CONFIG/system.sh:compare") fail starter-compare;; + "$CONFIG/enabled/10-erase-fix.sh:type") fail erase-hook-type;; + "$CONFIG/enabled/10-erase-fix.sh:stat") fail erase-hook-stat;; + "$CONFIG/enabled/10-erase-fix.sh:mode") fail erase-hook-mode;; + "$CONFIG/enabled/10-erase-fix.sh:content") fail erase-hook-content;; + "$CONFIG/enabled/10-erase-fix.sh:compare") fail erase-hook-compare;; + *) fail unknown-managed-file;; + esac +} known_or_absent() { dest=$1 source=$2 [ ! -L "$dest" ] || fail managed-link if [ -e "$dest" ]; then - regular "$dest" && [ "$(busybox stat -c %a "$dest")" = 755 ] && busybox cmp -s "$source" "$dest" || fail unknown-managed-file + regular "$dest" || managed_failure type + permissions=$(busybox stat -c %a "$dest") || managed_failure stat + [ "$permissions" = 755 ] || managed_failure mode + busybox cmp -s "$source" "$dest" + comparison=$? + case "$comparison" in + 0) ;; + 1) managed_failure content;; + *) managed_failure compare;; + esac fi } install_files() { diff --git a/on-printer/payload/installer.sh b/on-printer/payload/installer.sh index 52aac2d..411b47f 100644 --- a/on-printer/payload/installer.sh +++ b/on-printer/payload/installer.sh @@ -48,6 +48,16 @@ fail() { temporary-readback) code=32;; unknown-managed-file) code=33;; unrelated-hook-type) code=34;; + starter-type) code=35;; + starter-stat) code=36;; + starter-mode) code=37;; + starter-content) code=38;; + starter-compare) code=39;; + erase-hook-type) code=40;; + erase-hook-stat) code=41;; + erase-hook-mode) code=42;; + erase-hook-content) code=43;; + erase-hook-compare) code=44;; esac # Preserve the distinction between refusal and a partially written install. case "$STATUS" in PART) STATUS=P$code;; *) STATUS=F$code;; esac @@ -108,12 +118,36 @@ mtd5: 00020000 00004000 "config"' ] || fail partition-map regular "$hook" || fail unrelated-hook-type done } +managed_failure() { + case "$dest:$1" in + "$CONFIG/system.sh:type") fail starter-type;; + "$CONFIG/system.sh:stat") fail starter-stat;; + "$CONFIG/system.sh:mode") fail starter-mode;; + "$CONFIG/system.sh:content") fail starter-content;; + "$CONFIG/system.sh:compare") fail starter-compare;; + "$CONFIG/enabled/10-erase-fix.sh:type") fail erase-hook-type;; + "$CONFIG/enabled/10-erase-fix.sh:stat") fail erase-hook-stat;; + "$CONFIG/enabled/10-erase-fix.sh:mode") fail erase-hook-mode;; + "$CONFIG/enabled/10-erase-fix.sh:content") fail erase-hook-content;; + "$CONFIG/enabled/10-erase-fix.sh:compare") fail erase-hook-compare;; + *) fail unknown-managed-file;; + esac +} known_or_absent() { dest=$1 source=$2 [ ! -L "$dest" ] || fail managed-link if [ -e "$dest" ]; then - regular "$dest" && [ "$(busybox stat -c %a "$dest")" = 755 ] && busybox cmp -s "$source" "$dest" || fail unknown-managed-file + regular "$dest" || managed_failure type + permissions=$(busybox stat -c %a "$dest") || managed_failure stat + [ "$permissions" = 755 ] || managed_failure mode + busybox cmp -s "$source" "$dest" + comparison=$? + case "$comparison" in + 0) ;; + 1) managed_failure content;; + *) managed_failure compare;; + esac fi } install_files() { diff --git a/tests/test_on_printer.py b/tests/test_on_printer.py index 19f6891..eae9667 100644 --- a/tests/test_on_printer.py +++ b/tests/test_on_printer.py @@ -142,6 +142,41 @@ def test_success_then_idempotency_without_persistent_changes(self): self.assertIn('STATUS=SAME',result.stdout) self.assertEqual(before,[(p.stat().st_mtime_ns,p.read_bytes()) for p in paths]) + def test_canonical_starter_is_preserved_while_missing_hook_is_installed(self): + starter = self.config/'system.sh' + starter.write_bytes(generator.RUNNER) + starter.chmod(0o755) + before = (starter.read_bytes(), starter.stat().st_mtime_ns) + result = self.run_shell() + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + self.assertIn('STATUS=DONE', result.stdout) + self.assertEqual((starter.read_bytes(), starter.stat().st_mtime_ns), before) + self.assertEqual((self.config/'enabled/10-erase-fix.sh').read_bytes(), generator.erase_hook()) + + def test_managed_file_diagnostics_distinguish_paths_modes_and_comparison_errors(self): + for relative, label, expected in [('system.sh', 'starter', generator.RUNNER), ('enabled/10-erase-fix.sh', 'erase-hook', generator.erase_hook())]: + dest = self.config/relative + dest.parent.mkdir(exist_ok=True) + for kind in ['type', 'stat', 'mode', 'content', 'compare']: + with self.subTest(path=relative, failure=kind): + if kind == 'type': dest.mkdir() + else: + dest.write_bytes(b'unknown' if kind == 'content' else expected) + dest.chmod(0o644 if kind == 'mode' else 0o755) + wrapper = '#!/bin/sh\n' + if kind in ('stat', 'compare'): + applet = 'stat' if kind == 'stat' else 'cmp' + wrapper += f'[ "$1" = {applet} ] && exit 2\n' + (self.bin/'busybox').write_text(wrapper+'exec "$@"\n') + before = {str(p):p.read_bytes() for p in self.config.rglob('*') if p.is_file()} + result = self.run_shell('install_files') + self.assertNotEqual(result.returncode, 0, result.stdout+result.stderr) + self.assertIn(label+'-'+kind, result.stdout) + self.assertEqual(before, {str(p):p.read_bytes() for p in self.config.rglob('*') if p.is_file()}) + if kind == 'type': dest.rmdir() + else: dest.unlink() + (self.bin/'busybox').write_text('#!/bin/sh\nexec "$@"\n') + def test_unknown_hook_or_symlink_never_writes(self): dest = self.config/'system.sh' for link in (False, True): From ce33161c3a02a7e242cfe313d69c549bd97a96f0 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Sep 2026 23:21:51 +0200 Subject: [PATCH 09/11] Allow explicit replacement of both managed startup scripts --- docs/ON-PRINTER-HID.md | 17 +++++++--- on-printer/README.md | 25 +++++++++++++-- on-printer/installer.sh.in | 33 +++++++++++++++++--- on-printer/payload/installer.sh | 33 +++++++++++++++++--- on-printer/src/main.rs | 48 +++++++++++++++++++++------- tests/test_on_printer.py | 55 +++++++++++++++++++++++++++++++-- 6 files changed, 181 insertions(+), 30 deletions(-) diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index c751f29..7a5ca47 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -13,7 +13,7 @@ The CLI exposes this tradeoff as `install --accept-no-backup-space-risk`. This exception applies only to this workflow. The ADB installer, backup, image patching, and restore safety contracts remain in effect. The shell installer retains mount/partition checks, known firmware fingerprints, regular-file checks, -unknown-hook refusal, configuration stability checks, and camera-side byte +default differing-content refusal, configuration stability checks, and camera-side byte comparisons. The erase hook itself retains every early-boot compatibility gate. There is no generic force option or arbitrary-file/command interface. @@ -167,11 +167,12 @@ not an exported backup or a guarantee against a later concurrent writer. Canonical hook bytes come from `cc2camera.startup_payloads`; a generation check prevents the native copy from drifting. Both payload files are compared against -embedded MD5 fingerprints in RAM before installation. Unknown managed contents, -permissions, symlinks and incomplete-installation paths are refused. Existing +embedded MD5 fingerprints in RAM before installation. Differing managed contents +are refused unless install explicitly uses `--overwrite-managed-scripts`. +Unexpected permissions, symlinks and incomplete-installation paths remain refused. Existing unrelated regular enabled hooks remain and execute under the canonical runner. -The worker copies each missing file to a fixed refused-if-present temporary +The worker copies each missing or explicitly replaced file to a fixed refused-if-present temporary configuration path, sets mode 755, compares bytes, syncs, renames, and syncs. The erase hook is installed before the runner. Final comparisons check both files. No direct flash writes, partition erasure/remounting, or live kernel @@ -285,3 +286,11 @@ cannot diagnose the current refusal. Path-specific checks now distinguish file type, stat failure, mode mismatch, content mismatch and comparison-tool failure. Offline tests cover absent-file installation and preservation of a canonical starter when adding a missing erase hook; these are not new compatibility cases. + +The developer authorized optional replacement of both managed scripts after +observing differing erase-hook and starter contents. The install-only +`--overwrite-managed-scripts` flag enables that narrow exception. RAM copies +of existing files are retained outside the worker staging directory, and each +replacement compares its destination against the original copy before rename. +Final checks and verification disable replacement admission. The flag does not +relax file types, permissions, firmware checks, or other enabled-hook handling. diff --git a/on-printer/README.md b/on-printer/README.md index 1c56bed..38fbf0d 100644 --- a/on-printer/README.md +++ b/on-printer/README.md @@ -97,6 +97,26 @@ diagnostic when reporting an inspection failure. Control bytes are escaped so they cannot act as terminal commands. The version check still refuses the operation before any upload; these diagnostics do not bypass validation. +## Replacing existing managed scripts + +`install --accept-no-backup-space-risk --overwrite-managed-scripts` explicitly +allows replacing differing contents of `/etc/conf.d/system.sh` and +`/etc/conf.d/enabled/10-erase-fix.sh`, including scripts from older tool versions. +This can remove custom behavior in those two files. Other enabled scripts are +preserved. Without this flag, differing contents remain a refusal. + +Only regular, nonsymlink files with mode 755 may be replaced. All firmware, +identity, mount, partition, stability and readback checks remain. The existing +no-backup/no-space-check risk consent is still required. `inspect` and `verify` +reject this flag; verification always requires the tool's exact expected bytes. + +Before writes, existing originals are copied to camera RAM under +`/tmp/.cc2-old-scripts-/fix` and `runner`. They survive worker cleanup +but are lost when the camera reboots; they are not an exported or durable backup. +Each replacement rechecks the original against its RAM copy and uses a verified +temporary file followed by rename. Successful installation checks the final +contents strictly. Partial-write failures do not automatically roll back. + ## Install, then verify after restart The following command **can write the camera's persistent configuration**. @@ -108,8 +128,9 @@ It carries the recovery risk described above: The tool uploads a temporary installer to camera RAM. The installer checks the kernel and HID updater fingerprints, partition map, mounts, identity-file -presence, and stability of the raw configuration contents. It refuses unknown -contents or permissions at either managed destination. It installs the canonical +presence, and stability of the raw configuration contents. By default it refuses +differing contents at either managed destination. Unexpected permissions are +always refused. It installs the canonical `10-erase-fix.sh` hook first and `system.sh` runner last, comparing temporary and final file contents. It preserves unrelated regular enabled hooks and refuses non-regular entries. Exact existing managed files are left untouched. diff --git a/on-printer/installer.sh.in b/on-printer/installer.sh.in index f4a050c..6ac1303 100644 --- a/on-printer/installer.sh.in +++ b/on-printer/installer.sh.in @@ -10,6 +10,8 @@ SELF=/tmp/.cc2-hid-$TOKEN.sh STAGE=/tmp/.cc2-hid-$TOKEN CONFIG=/etc/conf.d STATUS=FAIL +OVERWRITE=@OVERWRITE@ +OLD_DIR=/tmp/.cc2-old-scripts-$TOKEN fail() { code=00 @@ -97,7 +99,10 @@ known_or_absent() { comparison=$? case "$comparison" in 0) ;; - 1) managed_failure content;; + 1) + [ "$OVERWRITE" = 1 ] && [ "$MODE" = install ] || managed_failure content + case "$dest" in "$CONFIG/system.sh"|"$CONFIG/enabled/10-erase-fix.sh") ;; *) managed_failure content;; esac + ;; *) managed_failure compare;; esac fi @@ -106,7 +111,7 @@ install_files() { # Validate both destinations before the first persistent write. known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" - if [ -e "$CONFIG/enabled/10-erase-fix.sh" ] && [ -e "$CONFIG/system.sh" ]; then + if [ -e "$CONFIG/enabled/10-erase-fix.sh" ] && [ -e "$CONFIG/system.sh" ] && busybox cmp -s "$STAGE/fix" "$CONFIG/enabled/10-erase-fix.sh" && busybox cmp -s "$STAGE/runner" "$CONFIG/system.sh"; then STATUS=SAME return fi @@ -122,32 +127,50 @@ install_files() { known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + if [ "$OVERWRITE" = 1 ]; then + mkdir "$OLD_DIR" || fail incomplete-install + for n in fix runner; do + case "$n" in fix) dest=$CONFIG/enabled/10-erase-fix.sh;; runner) dest=$CONFIG/system.sh;; esac + if [ -e "$dest" ]; then + cp -p "$dest" "$OLD_DIR/$n" || fail copy + busybox cmp -s "$dest" "$OLD_DIR/$n" || fail config-changing + fi + done + [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + fi STATUS=PART mkdir -p "$CONFIG/enabled" || fail mkdir - # Feature first, boot entry point last. Never overwrite an existing hook. + # Feature first, boot entry point last. Replacement requires explicit consent. for n in fix runner; do case "$n" in fix) dest=$CONFIG/enabled/10-erase-fix.sh;; runner) dest=$CONFIG/system.sh;; esac known_or_absent "$dest" "$STAGE/$n" - if [ -e "$dest" ]; then continue; fi + if [ -e "$dest" ] && busybox cmp -s "$STAGE/$n" "$dest"; then continue; fi temp=$CONFIG/.cc2-hid-$n [ ! -e "$temp" ] && [ ! -L "$temp" ] || fail incomplete-install cp "$STAGE/$n" "$temp" || fail copy chmod 755 "$temp" || fail chmod busybox cmp -s "$STAGE/$n" "$temp" || fail temporary-readback sync || fail sync - [ ! -e "$dest" ] && [ ! -L "$dest" ] || fail destination-appeared + if [ -e "$dest" ]; then + [ "$OVERWRITE" = 1 ] && regular "$dest" && [ "$(busybox stat -c %a "$dest")" = 755 ] || fail destination-appeared + busybox cmp -s "$OLD_DIR/$n" "$dest" || fail config-changing + else + [ ! -L "$dest" ] || fail destination-appeared + fi mv "$temp" "$dest" || fail rename sync || fail sync done + OVERWRITE=0 known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail final-missing STATUS=DONE } verify_live() { + OVERWRITE=0 known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail missing-hooks diff --git a/on-printer/payload/installer.sh b/on-printer/payload/installer.sh index 411b47f..b094a1b 100644 --- a/on-printer/payload/installer.sh +++ b/on-printer/payload/installer.sh @@ -10,6 +10,8 @@ SELF=/tmp/.cc2-hid-$TOKEN.sh STAGE=/tmp/.cc2-hid-$TOKEN CONFIG=/etc/conf.d STATUS=FAIL +OVERWRITE=@OVERWRITE@ +OLD_DIR=/tmp/.cc2-old-scripts-$TOKEN fail() { code=00 @@ -145,7 +147,10 @@ known_or_absent() { comparison=$? case "$comparison" in 0) ;; - 1) managed_failure content;; + 1) + [ "$OVERWRITE" = 1 ] && [ "$MODE" = install ] || managed_failure content + case "$dest" in "$CONFIG/system.sh"|"$CONFIG/enabled/10-erase-fix.sh") ;; *) managed_failure content;; esac + ;; *) managed_failure compare;; esac fi @@ -154,7 +159,7 @@ install_files() { # Validate both destinations before the first persistent write. known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" - if [ -e "$CONFIG/enabled/10-erase-fix.sh" ] && [ -e "$CONFIG/system.sh" ]; then + if [ -e "$CONFIG/enabled/10-erase-fix.sh" ] && [ -e "$CONFIG/system.sh" ] && busybox cmp -s "$STAGE/fix" "$CONFIG/enabled/10-erase-fix.sh" && busybox cmp -s "$STAGE/runner" "$CONFIG/system.sh"; then STATUS=SAME return fi @@ -170,32 +175,50 @@ install_files() { known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + if [ "$OVERWRITE" = 1 ]; then + mkdir "$OLD_DIR" || fail incomplete-install + for n in fix runner; do + case "$n" in fix) dest=$CONFIG/enabled/10-erase-fix.sh;; runner) dest=$CONFIG/system.sh;; esac + if [ -e "$dest" ]; then + cp -p "$dest" "$OLD_DIR/$n" || fail copy + busybox cmp -s "$dest" "$OLD_DIR/$n" || fail config-changing + fi + done + [ "$(busybox md5sum /dev/mtd5)" = "$baseline" ] || fail config-changing + fi STATUS=PART mkdir -p "$CONFIG/enabled" || fail mkdir - # Feature first, boot entry point last. Never overwrite an existing hook. + # Feature first, boot entry point last. Replacement requires explicit consent. for n in fix runner; do case "$n" in fix) dest=$CONFIG/enabled/10-erase-fix.sh;; runner) dest=$CONFIG/system.sh;; esac known_or_absent "$dest" "$STAGE/$n" - if [ -e "$dest" ]; then continue; fi + if [ -e "$dest" ] && busybox cmp -s "$STAGE/$n" "$dest"; then continue; fi temp=$CONFIG/.cc2-hid-$n [ ! -e "$temp" ] && [ ! -L "$temp" ] || fail incomplete-install cp "$STAGE/$n" "$temp" || fail copy chmod 755 "$temp" || fail chmod busybox cmp -s "$STAGE/$n" "$temp" || fail temporary-readback sync || fail sync - [ ! -e "$dest" ] && [ ! -L "$dest" ] || fail destination-appeared + if [ -e "$dest" ]; then + [ "$OVERWRITE" = 1 ] && regular "$dest" && [ "$(busybox stat -c %a "$dest")" = 755 ] || fail destination-appeared + busybox cmp -s "$OLD_DIR/$n" "$dest" || fail config-changing + else + [ ! -L "$dest" ] || fail destination-appeared + fi mv "$temp" "$dest" || fail rename sync || fail sync done + OVERWRITE=0 known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail final-missing STATUS=DONE } verify_live() { + OVERWRITE=0 known_or_absent "$CONFIG/enabled/10-erase-fix.sh" "$STAGE/fix" known_or_absent "$CONFIG/system.sh" "$STAGE/runner" regular "$CONFIG/enabled/10-erase-fix.sh" && regular "$CONFIG/system.sh" || fail missing-hooks diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index 633165b..853164a 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -22,6 +22,11 @@ libraries required by the static build. Only known camera firmware is supported. probe and verify the installed hooks and live RAM correction. No persistent file or RAM correction writes; temporary files/status are written in /tmp. +Install optionally accepts --overwrite-managed-scripts to replace differing +contents of system.sh and enabled/10-erase-fix.sh. Custom behavior may be lost. +Originals are retained in camera RAM for this boot, not exported or durable. +Other enabled scripts remain untouched. Default: refuse differing contents. + Add --verbose to any command to log every full HID report in hex to stderr, including upload payloads, malformed replies and transport errors. No retries. @@ -43,22 +48,27 @@ fn arguments(args: &[String]) -> Result { _ => Err("unknown arguments; run --help".into()), } } -fn options(args: &[String]) -> Result<(Action, bool)> { +fn options(args: &[String]) -> Result<(Action, bool, bool)> { let count = args.iter().filter(|a| a.as_str() == "--verbose").count(); if count > 1 { return Err("--verbose may only be specified once".into()); } - let filtered: Vec = args.iter().filter(|a| a.as_str() != "--verbose").cloned().collect(); - Ok((arguments(&filtered)?, count == 1)) + let overwrite = args.iter().filter(|a| a.as_str() == "--overwrite-managed-scripts").count(); + if overwrite > 1 { return Err("--overwrite-managed-scripts may only be specified once".into()); } + let filtered: Vec = args.iter().filter(|a| !matches!(a.as_str(), "--verbose" | "--overwrite-managed-scripts")).cloned().collect(); + let action = arguments(&filtered)?; + if overwrite != 0 && action != Action::Install { return Err("--overwrite-managed-scripts requires install".into()); } + Ok((action, count == 1, overwrite == 1)) } -fn script(token: &str, action: &Action) -> Result<(String, String, Vec)> { +fn script(token: &str, action: &Action, overwrite: bool) -> Result<(String, String, Vec)> { if token.len()!=16 || !token.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { return Err("invalid local session token".into()); } + if overwrite && *action != Action::Install { return Err("overwrite requires install".into()); } let mode=match action { Action::Install=>"install", Action::Verify=>"verify", _=>return Err("invalid script action".into()) }; let path=format!("/tmp/.cc2-hid-{token}.sh"); // Literal fopen must fail (the embedded /bin/sh makes a nonexistent directory // component). Only the preceding shell's rm command launches our worker. let launch=format!("/tmp/.cc2-launch-{token};/bin/sh${{IFS}}{path}&"); - let data=include_str!("../payload/installer.sh").replace("@TOKEN@",token).replace("@MODE@",mode).into_bytes(); + let data=include_str!("../payload/installer.sh").replace("@TOKEN@",token).replace("@MODE@",mode).replace("@OVERWRITE@", if overwrite {"1"} else {"0"}).into_bytes(); if launch.len()>127 || data.len()>32768 { return Err("embedded installer exceeds protocol limits".into()); } Ok((path,launch,data)) } @@ -133,13 +143,13 @@ fn version_unavailable(status: u32, payload: &[u8]) -> bool { } fn run() -> Result<()> { // Parse consent and prepare every outgoing target before touching USB. - let (action, verbose)=options(&std::env::args().skip(1).collect::>())?; + let (action, verbose, overwrite)=options(&std::env::args().skip(1).collect::>())?; if action==Action::Help { print!("{HELP}"); return Ok(()); } let mut random=[0u8;8]; let prepared=if matches!(action,Action::Install|Action::Verify) { std::fs::File::open("/dev/urandom")?.read_exact(&mut random)?; let token=format!("{:016x}", u64::from_be_bytes(random)); - let payload=script(&token,&action)?; + let payload=script(&token,&action,overwrite)?; Some((token,payload)) } else { None }; with_hid_camera(usb::discover()?, &action, |camera| { @@ -152,6 +162,7 @@ fn run() -> Result<()> { } if let Some((token,(path,launch,data)))=prepared { if action==Action::Install { println!("Accepted: no exported backup or clean-space check; programmer recovery may be required."); } + if overwrite { println!("Accepted: replace differing system.sh and enabled/10-erase-fix.sh. Previous custom behavior may be lost. Existing originals are retained in camera RAM at /tmp/.cc2-old-scripts-{token}, lost on reboot."); } println!("Uploading temporary camera worker; session {token}. Do not interrupt power."); upload(&mut transport,path.as_bytes(),&data,false)?; upload(&mut transport,launch.as_bytes(),b"\n",true)?; @@ -215,14 +226,27 @@ mod tests { } #[test] fn verbose_preserves_consent_and_argument_refusals() { - assert_eq!(options(&args(&["--verbose", "inspect"])).unwrap(), (Action::Inspect, true)); - assert_eq!(options(&args(&["inspect", "--verbose"])).unwrap(), (Action::Inspect, true)); - assert_eq!(options(&args(&["install", ACCEPT, "--verbose"])).unwrap(), (Action::Install, true)); + assert_eq!(options(&args(&["--verbose", "inspect"])).unwrap(), (Action::Inspect, true, false)); + assert_eq!(options(&args(&["inspect", "--verbose"])).unwrap(), (Action::Inspect, true, false)); + assert_eq!(options(&args(&["install", ACCEPT, "--verbose"])).unwrap(), (Action::Install, true, false)); assert!(options(&args(&["install", "--verbose"])).is_err()); assert!(options(&args(&["inspect", "--verbose", "--verbose"])).is_err()); assert!(options(&args(&["inspect", "--unknown", "--verbose"])).is_err()); } #[test] + fn overwrite_is_install_only_and_requires_existing_risk_consent() { + let flag = "--overwrite-managed-scripts"; + assert_eq!(options(&args(&["install", ACCEPT, flag])).unwrap(), (Action::Install, false, true)); + for a in [vec!["install", flag], vec!["inspect", flag], vec!["verify", flag], vec!["install", ACCEPT, flag, flag]] { + assert!(options(&args(&a)).is_err()); + } + assert!(script("0123456789abcdef", &Action::Verify, true).is_err()); + for overwrite in [false, true] { + let (_, _, payload) = script("0123456789abcdef", &Action::Install, overwrite).unwrap(); + assert!(String::from_utf8(payload).unwrap().contains(if overwrite {"OVERWRITE=1"} else {"OVERWRITE=0"})); + } + } + #[test] fn consent_and_unknown_arguments() { assert!(arguments(&args(&["install"])).is_err()); assert!(arguments(&args(&["install","--force"])).is_err()); @@ -233,12 +257,12 @@ mod tests { #[test] fn payload_is_fixed_bounded_and_distinct_per_action() { for action in [Action::Install,Action::Verify] { - let (path,launch,data)=script("0123456789abcdef",&action).unwrap(); + let (path,launch,data)=script("0123456789abcdef",&action,false).unwrap(); assert!(path.starts_with("/tmp/.cc2-hid-")); assert!(!launch.contains(' ')); assert!(launch.len()<=127); assert!(data.len()<32768); assert!(!data.windows(7).any(|w|w==b"@TOKEN@")); } - assert!(script("../../x;evil",&Action::Install).is_err()); + assert!(script("../../x;evil",&Action::Install,false).is_err()); } #[test] fn never_accept_stale_or_wrong_operation_success() { diff --git a/tests/test_on_printer.py b/tests/test_on_printer.py index eae9667..4c42103 100644 --- a/tests/test_on_printer.py +++ b/tests/test_on_printer.py @@ -46,7 +46,7 @@ def setUp(self): for p in self.bin.iterdir(): p.chmod(0o755) script = generator.generate().rsplit('\nmain\n', 1)[0] + '\n' script = script.replace('PATH=/bin:/sbin:/usr/bin:/usr/sbin', f'PATH={self.bin}:/bin:/usr/bin') - script = script.replace('@TOKEN@', '0123456789abcdef').replace('@MODE@', 'install') + script = script.replace('@TOKEN@', '0123456789abcdef').replace('@MODE@', 'install').replace('@OVERWRITE@', '0') mounts = self.root/'mounts' mounts.write_text(f'/dev/mtdblock5 {self.config} jffs2 rw 0 0\n') mtd = self.root/'mtd' @@ -59,7 +59,7 @@ def setUp(self): if path == '/dev/mtd1': script=script.replace(generator.KERNEL_MD5,hashlib.md5(content).hexdigest()) if path == '/bin/hid_update': script=script.replace('8091751fdd4d0d50ea31901663797a86',hashlib.md5(content).hexdigest()) script = script.replace('/etc/conf.d',str(self.config)).replace('/proc/mounts',str(mounts)).replace('/proc/mtd',str(mtd)) - self.script = script + f'\nSTAGE={self.stage}\n' + self.script = script + f'\nSTAGE={self.stage}\nOLD_DIR={self.root}/originals\n' def run_shell(self, command='preflight; install_files', modifications=''): result = subprocess.run(['sh'], input=self.script+modifications+'\n'+command+'\nprintf "STATUS=%s\\n" "$STATUS"\n', text=True, capture_output=True, timeout=10) @@ -177,6 +177,57 @@ def test_managed_file_diagnostics_distinguish_paths_modes_and_comparison_errors( else: dest.unlink() (self.bin/'busybox').write_text('#!/bin/sh\nexec "$@"\n') + def setup_old_scripts(self): + (self.config/'enabled').mkdir() + paths = [self.config/'enabled/10-erase-fix.sh', self.config/'system.sh'] + for p in paths: + p.write_bytes(b'old '+p.name.encode()) + p.chmod(0o755) + extra = self.config/'enabled/90-adb.sh' + extra.write_bytes(b'unrelated enabled script') + extra.chmod(0o755) + return paths, extra + + def test_overwrite_replaces_both_and_preserves_originals_and_other_hooks(self): + paths, extra = self.setup_old_scripts() + old = [p.read_bytes() for p in paths] + result = self.run_shell(modifications='OVERWRITE=1') + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + self.assertIn('STATUS=DONE', result.stdout) + self.assertEqual([p.read_bytes() for p in paths], [generator.erase_hook(), generator.RUNNER]) + self.assertEqual([(self.root/'originals'/n).read_bytes() for n in ['fix', 'runner']], old) + self.assertEqual(extra.read_bytes(), b'unrelated enabled script') + result = self.run_shell(modifications='OVERWRITE=1') + self.assertIn('STATUS=SAME', result.stdout) + paths[0].write_bytes(b'changed') + result = self.run_shell('verify_live', modifications='OVERWRITE=1; MODE=verify') + self.assertNotEqual(result.returncode, 0) + self.assertIn('erase-hook-content', result.stdout) + + def test_overwrite_does_not_bypass_file_type_mode_or_firmware_checks(self): + paths, _ = self.setup_old_scripts() + for kind in ['symlink', 'mode', 'firmware']: + old = paths[0].read_bytes() + if kind == 'symlink': paths[0].unlink(); paths[0].symlink_to(paths[1]) + elif kind == 'mode': paths[0].chmod(0o644) + else: (self.root/'mtd1').write_bytes(b'unsupported') + result = self.run_shell(modifications='OVERWRITE=1') + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root/'originals').exists()) + self.assertEqual(paths[1].read_bytes(), b'old system.sh') + if kind == 'symlink': paths[0].unlink(); paths[0].write_bytes(old) + paths[0].chmod(0o755) + + def test_overwrite_partial_copy_keeps_old_scripts_and_originals(self): + paths, _ = self.setup_old_scripts() + old = [p.read_bytes() for p in paths] + (self.bin/'cp').write_text('#!/bin/sh\ncase "$2" in */.cc2-hid-fix) printf truncated > "$2"; exit 1;; esac\nexec /bin/cp "$@"\n') + (self.bin/'cp').chmod(0o755) + result = self.run_shell(modifications='OVERWRITE=1') + self.assertNotEqual(result.returncode, 0) + self.assertEqual([p.read_bytes() for p in paths], old) + self.assertEqual([(self.root/'originals'/n).read_bytes() for n in ['fix', 'runner']], old) + def test_unknown_hook_or_symlink_never_writes(self): dest = self.config/'system.sh' for link in (False, True): From aa5f097ddd9466bbac22ae10c7379ecb294c1147 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 10 Sep 2026 00:05:19 +0200 Subject: [PATCH 10/11] Record physical HID installation and live verification on 30B --- docs/ON-PRINTER-HID.md | 48 +++++++++++++++++++++++++++--------------- on-printer/README.md | 11 +++++++--- on-printer/src/main.rs | 8 ++++--- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index 7a5ca47..fbd1e66 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -146,7 +146,8 @@ version file is restored two minutes after the worker finishes; if it was absent the newly created file is removed. Status records are published by rename so a query cannot observe a partially written record. Unexpected replacements are preserved along with staging diagnostics. This behavior -and its effect on printer software are hardware-unverified. +has been observed for session-bound results; cleanup timing and its effect on +printer software have not been independently confirmed. The first staging upload precedes camera-side checks. It relies on the analyzed stock `/tmp` layout and a fresh random destination; the worker then requires @@ -194,22 +195,35 @@ status acceptance, shell fingerprint refusals, unknown files and symlinks, configuration changes, partial writes, readback mismatches and idempotency. Synthetic fixtures contain no proprietary firmware or real device identity. -The new workflow compiles a static ARMv7 binary and runs tests under QEMU in -addition to the host tests. These checks cannot substitute for the following -physical validation: - -- inspect the actual camera descriptors and complete a version query from CC2; -- establish that the printer's camera service tolerates HID access; -- observe the expected launch-commit failure and current-session status response; -- compare installation results and verify activation after restart; -- observe version restoration and normal camera feed operation; -- exercise any on-device failure investigations deliberately, with programmer - access and preserved same-camera data where available. - -The existing hook's physical evidence in -[STARTUP-HOOKS-VALIDATION.md](STARTUP-HOOKS-VALIDATION.md) does not validate this -new transport, installer, or otherwise-stock hook-only boot behavior. User -instructions therefore label this route experimental and hardware-unverified. +The workflow compiles a static ARMv7 binary and runs tests under QEMU in +addition to host tests. + +On 2026-09-09, the developer supplied physical 30B logs from the on-printer +binary at commit `ce33161c3a02a7e242cfe313d69c549bd97a96f0`: + +- Installation with `--overwrite-managed-scripts` progressed from `BUSY` to a + matching-session `DONE`. The worker completed its checks and verified both + installed script contents and mode 755 after persistent writes. +- The subsequent verification log progressed from `BUSY` to a fresh matching- + session `LIVE`. This confirms the worker's installed-file comparisons, kernel + fingerprints/instruction/symbol checks, validated pointer, and live erase-size + field of `0x00001000`. The verification worker reads that field and does not + apply the correction. + +This is physical evidence for installation and subsequent live verification +through the printer's USB HID transport on the tested, previously modified 30B. +It is not an independent full-flash readback, a clean stock-camera installation +test, or an erase-pressure/endurance test. Normal camera-feed behavior and the +status-file cleanup timing have not been separately reported. The route remains +experimental and retains its no-backup/no-space-admission risks. + +Earlier testing after a printer shell `reboot` timed out on the first upload +packet. A full power cycle allowed uploads again. A printer reboot must not be +assumed to reset camera power or its updater state; instructions require a full +power cycle between completed attempts, after worker cleanup. + +The existing hook's separate physical evidence is documented in +[STARTUP-HOOKS-VALIDATION.md](STARTUP-HOOKS-VALIDATION.md). ## Observed 30B USB snapshot and version-query limitation diff --git a/on-printer/README.md b/on-printer/README.md index 38fbf0d..64aecbd 100644 --- a/on-printer/README.md +++ b/on-printer/README.md @@ -1,9 +1,11 @@ # Install the camera fix from a CC2 shell -`cc2camera-hid` is an **experimental, hardware-unverified** way to protect a +`cc2camera-hid` is an **experimental** way to protect a working affected stock camera while it stays connected inside the printer. It runs on the printer, using USB HID directly. The static ARMv7 executable needs neither ADB, Python, `hidraw`, nor additional shared libraries. +Installation with managed-script replacement and subsequent live verification +have succeeded on the tested 30B camera. See the [validation scope](../docs/ON-PRINTER-HID.md#validation-status). The [computer-based USB route](../README.md#working-camera-usb-prevention) provides an exported backup and a conservative clean-space check. Prefer that @@ -142,8 +144,11 @@ Camera reports exact installed hook contents and permissions verified. ``` This confirms the camera-side file comparisons, **not activation on boot**. -After that message, wait at least two minutes, restart the idle printer, copy -the executable back to `/tmp`, and run: +After that message, wait at least two minutes, fully power-cycle the idle printer, copy +the executable back to `/tmp`, and run. A shell `reboot` may leave the camera +powered and its HID uploader in the previous state; it is not sufficient to +establish a fresh camera boot: + ```sh /tmp/cc2camera-hid verify diff --git a/on-printer/src/main.rs b/on-printer/src/main.rs index 853164a..0339d41 100644 --- a/on-printer/src/main.rs +++ b/on-printer/src/main.rs @@ -18,7 +18,7 @@ libraries required by the static build. Only known camera firmware is supported. the camera unbootable and require an external SPI programmer to recover. Firmware checks and installed-file comparisons run on the camera. cc2camera-hid verify - After a successful installation and printer restart, upload a temporary + After a successful installation and full printer power cycle, upload a temporary probe and verify the installed hooks and live RAM correction. No persistent file or RAM correction writes; temporary files/status are written in /tmp. @@ -33,7 +33,9 @@ including upload payloads, malformed replies and transport errors. No retries. Install/verify launch a camera shell script, temporarily replace its version response for two minutes, and leave its HID uploader unavailable until restart. Never retry after an error. A timeout does not cancel a camera-side installer. -This route requires physical validation; a USB acknowledgement is not success. +Installation and live verification have passed on a tested 30B camera. +A USB acknowledgement alone is not installation or activation success. +A printer shell reboot may leave the camera powered; use a full power cycle. "; #[derive(Debug, PartialEq)] @@ -178,7 +180,7 @@ fn run() -> Result<()> { if status!=0 { return Err("camera status query failed".into()); } if terminal(&payload,&token,&action)? { if action==Action::Install { - println!("Camera reports exact installed hook contents and permissions verified. Activation is not yet verified. Wait at least two minutes, restart the idle printer, then run cc2camera-hid verify."); + println!("Camera reports exact installed hook contents and permissions verified. Activation is not yet verified. Wait at least two minutes, fully power-cycle the idle printer, then run cc2camera-hid verify."); } else { println!("Camera reports canonical hooks and the live erase correction verified for this boot."); } return Ok(()); } From f546a510d1ca5b8198fafa681e649821b6cf4c37 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 10 Sep 2026 00:21:02 +0200 Subject: [PATCH 11/11] Record normal camera feed after HID installation and verification --- docs/ON-PRINTER-HID.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md index fbd1e66..7dfe70a 100644 --- a/docs/ON-PRINTER-HID.md +++ b/docs/ON-PRINTER-HID.md @@ -146,8 +146,9 @@ version file is restored two minutes after the worker finishes; if it was absent the newly created file is removed. Status records are published by rename so a query cannot observe a partially written record. Unexpected replacements are preserved along with staging diagnostics. This behavior -has been observed for session-bound results; cleanup timing and its effect on -printer software have not been independently confirmed. +has been observed for session-bound results, and the developer confirmed normal +camera-feed operation after installation and verification. Cleanup timing has +not been independently confirmed. The first staging upload precedes camera-side checks. It relies on the analyzed stock `/tmp` layout and a fresh random destination; the worker then requires @@ -213,8 +214,9 @@ binary at commit `ce33161c3a02a7e242cfe313d69c549bd97a96f0`: This is physical evidence for installation and subsequent live verification through the printer's USB HID transport on the tested, previously modified 30B. It is not an independent full-flash readback, a clean stock-camera installation -test, or an erase-pressure/endurance test. Normal camera-feed behavior and the -status-file cleanup timing have not been separately reported. The route remains +test, or an erase-pressure/endurance test. The developer also confirmed that the +camera feed works normally after installation and verification. Status-file +cleanup timing has not been separately confirmed. The route remains experimental and retains its no-backup/no-space-admission risks. Earlier testing after a printer shell `reboot` timed out on the first upload