diff --git a/.github/workflows/on-printer.yml b/.github/workflows/on-printer.yml new file mode 100644 index 0000000..9e856ca --- /dev/null +++ b/.github/workflows/on-printer.yml @@ -0,0 +1,75 @@ +name: On-printer HID tool +on: + push: + pull_request: + release: + types: [published] +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - name: Check embedded canonical payload and shell contracts + run: | + python on-printer/generate_payload.py --check + python -m unittest discover -s tests -p test_on_printer.py -v + sh -n on-printer/payload/installer.sh + - name: Install native build tools + run: | + rustup toolchain install 1.85.1 --profile minimal --component clippy --target armv7-unknown-linux-musleabihf + sudo apt-get update + sudo apt-get install -y gcc-arm-linux-gnueabihf qemu-user + - name: Test native protocol and compile static ARMv7 binary + env: + CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-gcc + CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_RUNNER: qemu-arm + run: | + cargo +1.85.1 test --locked --manifest-path on-printer/Cargo.toml + cargo +1.85.1 clippy --locked --manifest-path on-printer/Cargo.toml --all-targets -- -D warnings + cargo +1.85.1 test --locked --manifest-path on-printer/Cargo.toml --target armv7-unknown-linux-musleabihf + cargo +1.85.1 build --locked --manifest-path on-printer/Cargo.toml --release --target armv7-unknown-linux-musleabihf + - name: Check static linking and smoke-test executable + run: | + mkdir -p dist + cp on-printer/target/armv7-unknown-linux-musleabihf/release/cc2camera-hid dist/cc2camera-hid-armv7-linux + file dist/cc2camera-hid-armv7-linux + if readelf -l dist/cc2camera-hid-armv7-linux | grep -q INTERP; then exit 1; fi + if readelf -d dist/cc2camera-hid-armv7-linux | grep -q NEEDED; then exit 1; fi + qemu-arm dist/cc2camera-hid-armv7-linux --help + if qemu-arm dist/cc2camera-hid-armv7-linux install; then exit 1; fi + cd dist + sha256sum cc2camera-hid-armv7-linux > cc2camera-hid-armv7-linux.sha256 + wc -c cc2camera-hid-armv7-linux + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cc2camera-hid-armv7-linux + path: | + dist/cc2camera-hid-armv7-linux + dist/cc2camera-hid-armv7-linux.sha256 + if-no-files-found: error + attach: + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cc2camera-hid-armv7-linux + path: dist + - name: Attach to the manually published release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" dist/cc2camera-hid-armv7-linux dist/cc2camera-hid-armv7-linux.sha256 diff --git a/.gitignore b/.gitignore index b8a4258..a9c0c41 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db *.swp *~ *.spec + +# Native on-printer builds +on-printer/target/ diff --git a/README.md b/README.md index dff43af..b6fb106 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,16 @@ dump to your device. > It has produced the expected result in two tested cases, but if you have any > doubts, independently audit the tools before using them on your hardware. +## Already have a root shell on the printer? + +The [experimental on-printer HID installer](on-printer/README.md) can install +and verify the startup fix while a working camera stays connected inside the +CC2. It needs neither ADB nor Python on the printer. It does **not** export a +backup or check safely erased configuration space; a failed write may require +an external programmer. Read that guide's risks and prerequisites before use. +This route is hardware-unverified. The computer-based route below retains its +backup and space checks. + ## First identify your camera The known failure affects the `EF-S7-V1.0.30B` camera family. A newer diff --git a/docs/ON-PRINTER-HID.md b/docs/ON-PRINTER-HID.md new file mode 100644 index 0000000..7dfe70a --- /dev/null +++ b/docs/ON-PRINTER-HID.md @@ -0,0 +1,312 @@ +# 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 new file mode 100644 index 0000000..13c34af --- /dev/null +++ b/on-printer/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cc2camera-hid" +version = "0.1.0" diff --git a/on-printer/Cargo.toml b/on-printer/Cargo.toml new file mode 100644 index 0000000..4ad7124 --- /dev/null +++ b/on-printer/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cc2camera-hid" +version = "0.1.0" +edition = "2021" +publish = false + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/on-printer/README.md b/on-printer/README.md new file mode 100644 index 0000000..64aecbd --- /dev/null +++ b/on-printer/README.md @@ -0,0 +1,254 @@ +# 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 the tested 30B camera. See the [validation scope](../docs/ON-PRINTER-HID.md#validation-status). + +The [computer-based USB route](../README.md#working-camera-usb-prevention) +provides an exported backup and a conservative clean-space check. Prefer that +route when you can connect the camera to a computer. + +## Risk and prerequisites + +This installer **does not export a backup or check that enough safely erased +camera configuration space remains**. Even a small write can exhaust the +camera's damaged filesystem. A failed or interrupted installation can leave the +camera unable to boot and require an external SPI programmer. Without a backup, +recovery depends on being able to read the camera's own identity data with that +programmer; recovery is not guaranteed. Never substitute another camera's dump. +The available space reported by `df` on the **printer** says nothing about this +risk on the **camera**. + +Use this route only if you accept that tradeoff to avoid unplugging the camera +and making a cable. The tool requires that acceptance explicitly on installation. +The backup-based tools keep their existing requirements. + +You need: + +- a working affected Ingenic T23 camera, with the known stock kernel and HID + updater; this does not support the newer TX5110 camera or recover a camera + that no longer boots; +- root shell access to the CC2 printer (obtaining this access is outside this + guide), and a way to copy the executable onto its USB drive or filesystem; +- a 32-bit ARMv7 Linux printer with `/sys/bus/usb/devices` and + `/dev/bus/usb`; the initial target is the reported CC2 TinaLinux 5.4.61 system; +- an idle printer, with no print running and no other camera-maintenance tool + using the HID interface. + +Restart the **idle** printer before starting an installation or verification +attempt, so the camera uploader starts in its ordinary state. Do not use +`cc2camera start-adb` or another HID uploader first. During an attempt, do not +interrupt power. The program never resets USB, changes its configuration, +detaches a video interface, reboots the printer, or writes a firmware image. +If a HID kernel driver is present, it temporarily detaches only `usbhid` on the +selected HID interface and requests reattachment when it exits normally. +Physical testing must establish whether the stock printer software tolerates +this access while it owns the camera stream. + +## Obtain and inspect the executable + +For a version containing this tool, download `cc2camera-hid-armv7-linux` and its +`.sha256` file from the repository's [GitHub Releases](https://github.com/OpenCentauri/cc2_camera_fix/releases). +Before a release is available, the **On-printer HID tool** workflow on the PR +provides the same files in its `cc2camera-hid-armv7-linux` artifact. Extract that +artifact on your computer and place the two files at the root of a USB drive. +Do not execute a binary from a failed build. + +On a CC2 where that drive is mounted at `/mnt/exUDISK`: + +```sh +cd /mnt/exUDISK +sha256sum -c cc2camera-hid-armv7-linux.sha256 +cp cc2camera-hid-armv7-linux /tmp/cc2camera-hid +chmod 755 /tmp/cc2camera-hid +/tmp/cc2camera-hid --help +/tmp/cc2camera-hid inspect +``` + +If `sha256sum` is unavailable on the printer, verify the checksum on your +computer before copying the binary. Adjust the drive mount path if necessary. +Copying to `/tmp` avoids execution permissions on the USB drive; the executable +must be copied there again after a printer restart. + +`inspect` 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 new file mode 100644 index 0000000..ca8ce89 --- /dev/null +++ b/on-printer/failure-reasons.txt @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..e1921db --- /dev/null +++ b/on-printer/generate_payload.py @@ -0,0 +1,61 @@ +#!/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 new file mode 100644 index 0000000..6ac1303 --- /dev/null +++ b/on-printer/installer.sh.in @@ -0,0 +1,212 @@ +#!/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 new file mode 100644 index 0000000..b094a1b --- /dev/null +++ b/on-printer/payload/installer.sh @@ -0,0 +1,367 @@ +#!/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 new file mode 100644 index 0000000..0339d41 --- /dev/null +++ b/on-printer/src/main.rs @@ -0,0 +1,335 @@ +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 new file mode 100644 index 0000000..e4c9c41 --- /dev/null +++ b/on-printer/src/protocol.rs @@ -0,0 +1,179 @@ +//! 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 new file mode 100644 index 0000000..73cc436 --- /dev/null +++ b/on-printer/src/usb.rs @@ -0,0 +1,315 @@ +//! 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 new file mode 100644 index 0000000..4c42103 --- /dev/null +++ b/tests/test_on_printer.py @@ -0,0 +1,310 @@ +"""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()})