From 3b26093cf3e3e39818b04cbc51a4e7e6d69940f3 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Sun, 27 Sep 2026 13:34:18 +0200 Subject: [PATCH] Revert "Add experimental on-printer HID erase-fix installer" --- .github/workflows/on-printer.yml | 57 ----- .github/workflows/release.yml | 18 +- .gitignore | 3 - README.md | 12 - docs/ON-PRINTER-HID.md | 312 -------------------------- on-printer/Cargo.lock | 7 - on-printer/Cargo.toml | 12 - on-printer/README.md | 263 ---------------------- on-printer/failure-reasons.txt | 44 ---- on-printer/generate_payload.py | 61 ----- on-printer/installer.sh.in | 212 ------------------ on-printer/payload/installer.sh | 367 ------------------------------- on-printer/src/main.rs | 335 ---------------------------- on-printer/src/protocol.rs | 179 --------------- on-printer/src/usb.rs | 315 -------------------------- tests/test_on_printer.py | 310 -------------------------- 16 files changed, 3 insertions(+), 2504 deletions(-) delete mode 100644 .github/workflows/on-printer.yml delete mode 100644 docs/ON-PRINTER-HID.md delete mode 100644 on-printer/Cargo.lock delete mode 100644 on-printer/Cargo.toml delete mode 100644 on-printer/README.md delete mode 100644 on-printer/failure-reasons.txt delete mode 100644 on-printer/generate_payload.py delete mode 100644 on-printer/installer.sh.in delete mode 100644 on-printer/payload/installer.sh delete mode 100644 on-printer/src/main.rs delete mode 100644 on-printer/src/protocol.rs delete mode 100644 on-printer/src/usb.rs delete mode 100644 tests/test_on_printer.py diff --git a/.github/workflows/on-printer.yml b/.github/workflows/on-printer.yml deleted file mode 100644 index 41bb44e..0000000 --- a/.github/workflows/on-printer.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: On-printer HID tool -on: - push: - pull_request: - workflow_call: -permissions: - contents: read -jobs: - build: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8731920..7dbc006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Build release executables +name: Build release executable on: release: @@ -41,13 +41,8 @@ jobs: dist/cc2camera.exe.sha256 if-no-files-found: error - on-printer: - uses: $/.github/workflows/on-printer.yml - permissions: - contents: read - attach: - needs: [build, on-printer] + needs: build runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -57,16 +52,9 @@ jobs: with: name: release-executable path: dist - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: cc2camera-hid-armv7-linux - path: dist - name: Attach to the existing 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.exe dist/cc2camera.exe.sha256 \ - dist/cc2camera-hid-armv7-linux dist/cc2camera-hid-armv7-linux.sha256 + run: gh release upload "$RELEASE_TAG" dist/cc2camera.exe dist/cc2camera.exe.sha256 diff --git a/.gitignore b/.gitignore index a9c0c41..b8a4258 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,3 @@ Thumbs.db *.swp *~ *.spec - -# Native on-printer builds -on-printer/target/ diff --git a/README.md b/README.md index 3b670c9..892c6e6 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,6 @@ 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. -It has been tested on a previously modified camera; volunteers are still needed -for a first install on an unmodified, supported camera. If you try it, please -[open an issue](https://github.com/OpenCentauri/cc2_camera_fix/issues/new) to share -results, get help, and guide any needed tool adjustments. - ## First identify your camera The known failure affects one 8 MiB flash-layout variant of the diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md deleted file mode 100644 index 7dfe70a..0000000 --- a/docs/ON-PRINTER-HID.md +++ /dev/null @@ -1,312 +0,0 @@ -# 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, -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. - -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`. - -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 -[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 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, 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 -`/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. 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 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 -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 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. 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 -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 - -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 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. -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. - -## 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. - -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. - -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/Cargo.lock b/on-printer/Cargo.lock deleted file mode 100644 index 13c34af..0000000 --- a/on-printer/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index 4ad7124..0000000 --- a/on-printer/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[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/README.md b/on-printer/README.md deleted file mode 100644 index 11fdd2a..0000000 --- a/on-printer/README.md +++ /dev/null @@ -1,263 +0,0 @@ -# Install the camera fix from a CC2 shell - -`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 a previously modified 30B camera. Volunteers are still needed -for a first install on an unmodified, supported camera. If you try it, please -[open an issue](https://github.com/OpenCentauri/cc2_camera_fix/issues/new) to share -results and work through any problems or needed tool adjustments. -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 -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 camera from the affected 8 MiB Ingenic T23 / ZB25VQ64 family, with - the supported kernel and HID updater. The earlier 16 MiB / P25Q128H 30B and - newer TX5110 30D are unsupported. PCB labels and stream identification do not - override the firmware and partition-map checks; this cannot 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. - -Fully power-cycle the **idle** printer before starting an installation or -verification attempt, so the camera uploader starts in its ordinary state. -A shell `reboot` may leave the camera powered and is not sufficient. 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. -The camera feed remained functional on the tested setup; report any disruption -on yours in an issue. - -## 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). -The release workflow builds and attaches the ARM binary and checksum alongside -the Windows executable when a release is manually published. -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` 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 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. - -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 -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**. -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. 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. - -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, 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 -``` - -`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 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. - -## 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. - -## 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. - -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 deleted file mode 100644 index ca8ce89..0000000 --- a/on-printer/failure-reasons.txt +++ /dev/null @@ -1,44 +0,0 @@ -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 -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/generate_payload.py b/on-printer/generate_payload.py deleted file mode 100644 index e1921db..0000000 --- a/on-printer/generate_payload.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/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 -import re -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() - ) - 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)), - '@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) - # 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__': - 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 deleted file mode 100644 index 6ac1303..0000000 --- a/on-printer/installer.sh.in +++ /dev/null @@ -1,212 +0,0 @@ -#!/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 -OVERWRITE=@OVERWRITE@ -OLD_DIR=/tmp/.cc2-old-scripts-$TOKEN - -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 -} -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() { - publish_status || return 1 - sleep 120 - # 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" -} -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 -} -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" || 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) - [ "$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 -} -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" ] && busybox cmp -s "$STAGE/fix" "$CONFIG/enabled/10-erase-fix.sh" && busybox cmp -s "$STAGE/runner" "$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 - 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. 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" ] && 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 - 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 - @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 - umask 077 - mkdir "$STAGE" || exit 1 - prepare_status || exit 1 - trap finish 0 - STATUS=BUSY - publish_status || exit 1 - STATUS=FAIL - 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 deleted file mode 100644 index b094a1b..0000000 --- a/on-printer/payload/installer.sh +++ /dev/null @@ -1,367 +0,0 @@ -#!/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 -OVERWRITE=@OVERWRITE@ -OLD_DIR=/tmp/.cc2-old-scripts-$TOKEN - -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;; - 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 - 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" ]; } -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() { - publish_status || return 1 - sleep 120 - # 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" -} -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 -} -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" || 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) - [ "$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 -} -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" ] && busybox cmp -s "$STAGE/fix" "$CONFIG/enabled/10-erase-fix.sh" && busybox cmp -s "$STAGE/runner" "$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 - 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. 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" ] && 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 - 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 - [ "$(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 - umask 077 - mkdir "$STAGE" || exit 1 - prepare_status || exit 1 - trap finish 0 - STATUS=BUSY - publish_status || exit 1 - STATUS=FAIL - 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 deleted file mode 100644 index 0339d41..0000000 --- a/on-printer/src/main.rs +++ /dev/null @@ -1,335 +0,0 @@ -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; 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 - 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 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. - -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. - -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. -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)] -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 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 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, 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).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)) -} -fn terminal(payload: &[u8], token: &str, action: &Action) -> Result { - let prefix=format!("{token}:"); - if !payload.starts_with(prefix.as_bytes()) { return Ok(false); } - 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), - 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()), - } -} -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), - } -} -/// 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)?; - if version_unavailable(status, &version) { return Ok(None); } - 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(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. - 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,overwrite)?; - Some((token,payload)) - } 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=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)), - 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."); } - 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)?; - let deadline=Instant::now()+Duration::from_secs(90); - while Instant::now()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 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, 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()); - 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,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,false).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()); - } - } - 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 [ - (2, vec![], "nonzero status"), - (1, b"unexpected".to_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().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 }; - assert_eq!(query_version(&mut t).unwrap(), Some(b"1.0.30B".to_vec())); - assert_eq!(t.calls, 1); - } - #[test] - fn unavailable_version_is_metadata_only_and_sends_one_read_query() { - let mut t = VersionReply { status: 1, payload: vec![], calls: 0 }; - assert_eq!(query_version(&mut t).unwrap(), None); - assert_eq!(t.calls, 1); - assert!(version_unavailable(1, &[])); - assert!(!version_unavailable(0, &[])); - assert!(!version_unavailable(2, &[])); - assert!(!version_unavailable(1, b"unexpected")); - } - #[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); - } - -} diff --git a/on-printer/src/protocol.rs b/on-printer/src/protocol.rs deleted file mode 100644 index e4c9c41..0000000 --- a/on-printer/src/protocol.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! 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>; -} - -/// 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) -} -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 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(); - 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 deleted file mode 100644 index 73cc436..0000000 --- a/on-printer/src/usb.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! 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) -} - -/// 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(); - 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()); } - 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"))?, - }); - } - classify(cameras) -} - -#[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}"); - } - } - 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); - 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 deleted file mode 100644 index 4c42103..0000000 --- a/tests/test_on_printer.py +++ /dev/null @@ -1,310 +0,0 @@ -"""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 sys -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(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-') - 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').replace('@OVERWRITE@', '0') - 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}\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) - return result - - - def status_setup(self): - version = self.root/'version.txt' - self.script = self.script.replace('/tmp/version.txt', str(version)) - (self.bin/'sleep').write_text('#!/bin/sh\nexit 0\n') - (self.bin/'sleep').chmod(0o755) - return version - - def test_status_restores_existing_file_or_removes_created_file(self): - version = self.status_setup() - for existing in (False, True): - self.stage.mkdir(exist_ok=True) - if existing: - version.write_bytes(b'original version\nsecond line\x00') - version.chmod(0o640) - result = self.run_shell('prepare_status || exit 1; STATUS=BUSY; publish_status || exit 1; cat '+str(version)+'; STATUS=DONE; finish || exit 1') - self.assertEqual(result.returncode, 0, result.stdout+result.stderr) - self.assertIn('0123456789abcdef:BUSY', result.stdout) - if existing: - self.assertEqual(version.read_bytes(), b'original version\nsecond line\x00') - self.assertEqual(version.stat().st_mode & 0o777, 0o640) - else: - self.assertFalse(version.exists()) - self.assertFalse(self.stage.exists()) - self.assertFalse((self.config/'enabled').exists()) - - def test_status_refuses_unsafe_paths_without_changing_targets(self): - version = self.status_setup() - target = self.root/'target' - target.write_text('untouched') - for kind in ('link', 'dangling', 'directory', 'fifo', 'oversized'): - if kind == 'link': version.symlink_to(target) - elif kind == 'dangling': version.symlink_to(self.root/'absent') - elif kind == 'directory': version.mkdir() - elif kind == 'fifo': os.mkfifo(version) - else: version.write_bytes(b'x'*4097) - result = self.run_shell('prepare_status || exit 1') - self.assertNotEqual(result.returncode, 0, kind) - self.assertEqual(target.read_text(), 'untouched') - self.assertFalse((self.stage/'version').exists()) - self.assertFalse((self.config/'enabled').exists()) - 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') - (self.bin/'sleep').write_text('#!/bin/sh\nprintf other > '+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) - 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_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 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): - 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) - 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()) - - 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()) - - 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()})