From 343a56605fca40a09b355f8e4bf1e93a920a21b8 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Sun, 6 Sep 2026 21:05:43 +0200 Subject: [PATCH 1/3] Unify cc2flash CLI and build Windows executable for manually published releases --- .github/workflows/release.yml | 60 ++++ .github/workflows/tests.yml | 31 ++ .gitignore | 1 + README.md | 34 +- .../cc2flash => cc2flash}/__init__.py | 2 +- .../cc2flash => cc2flash}/__main__.py | 1 + .../cc2flash => cc2flash}/adb_backup.py | 16 +- {usb-maintenance/cc2flash => cc2flash}/cli.py | 174 ++++----- .../cc2flash => cc2flash}/commands.py | 1 + cc2flash/display.py | 11 + .../cc2flash => cc2flash}/hid_transport.py | 3 +- .../cc2_sig_tool.py => cc2flash/image.py | 340 ++---------------- .../cc2flash => cc2flash}/protocol.py | 1 + .../cc2flash => cc2flash}/restore_prepare.py | 1 + docs/CLI.md | 118 ++++++ docs/INSTALLATION.md | 111 ++++++ .../CC2_RECOVERY_VERIFICATION.md | 11 +- hardware-recovery/CLI_REFERENCE.md | 153 ++------ hardware-recovery/PROGRAMMER_REFERENCE.md | 4 +- hardware-recovery/TECHNICAL_DETAILS.md | 17 +- hardware-recovery/TEST_RESULTS.md | 9 +- packaging/entrypoint.py | 5 + packaging/smoke_test.py | 39 ++ pyproject.toml | 20 ++ {usb-maintenance/tests => tests}/__init__.py | 1 + tests/test_cli.py | 103 ++++++ .../tests => tests}/test_commands.py | 1 + .../tests => tests}/test_hwconfig_variants.py | 8 +- tests/test_implementation.py | 64 ++++ .../tests => tests}/test_protocol.py | 42 +-- .../tests => tests}/test_restore_prepare.py | 9 +- .../tests => tests}/test_usb_backup.py | 27 +- usb-maintenance/PROJECT-STATUS.md | 10 +- usb-maintenance/PROTOCOL.md | 2 +- usb-maintenance/REFERENCE.md | 52 +-- usb-maintenance/SHA256SUMS | 56 +-- usb-maintenance/pyproject.toml | 19 - .../protocol_reference.md | 2 +- 38 files changed, 834 insertions(+), 725 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/tests.yml rename {usb-maintenance/cc2flash => cc2flash}/__init__.py (85%) rename {usb-maintenance/cc2flash => cc2flash}/__main__.py (98%) rename {usb-maintenance/cc2flash => cc2flash}/adb_backup.py (97%) rename {usb-maintenance/cc2flash => cc2flash}/cli.py (75%) rename {usb-maintenance/cc2flash => cc2flash}/commands.py (99%) create mode 100644 cc2flash/display.py rename {usb-maintenance/cc2flash => cc2flash}/hid_transport.py (98%) rename hardware-recovery/cc2_sig_tool.py => cc2flash/image.py (85%) rename {usb-maintenance/cc2flash => cc2flash}/protocol.py (99%) rename {usb-maintenance/cc2flash => cc2flash}/restore_prepare.py (99%) create mode 100644 docs/CLI.md create mode 100644 docs/INSTALLATION.md create mode 100644 packaging/entrypoint.py create mode 100644 packaging/smoke_test.py create mode 100644 pyproject.toml rename {usb-maintenance/tests => tests}/__init__.py (50%) create mode 100644 tests/test_cli.py rename {usb-maintenance/tests => tests}/test_commands.py (99%) rename {hardware-recovery/tests => tests}/test_hwconfig_variants.py (86%) create mode 100644 tests/test_implementation.py rename {usb-maintenance/tests => tests}/test_protocol.py (98%) rename {usb-maintenance/tests => tests}/test_restore_prepare.py (95%) rename {hardware-recovery/tests => tests}/test_usb_backup.py (87%) delete mode 100644 usb-maintenance/pyproject.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bc26802 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Build release executable + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + runs-on: windows-2022 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + architecture: x64 + - name: Install package and executable builder + run: python -m pip install . pyinstaller==6.22.2 + - name: Run offline tests + run: python -m unittest discover -s tests -v + - name: Build console executable + run: python -m PyInstaller --clean --noconfirm --onefile --console --name cc2flash --hidden-import hid packaging/entrypoint.py + - name: Smoke-test frozen application outside source tree + run: python packaging/smoke_test.py dist/cc2flash.exe + - name: Hash executable + shell: pwsh + run: | + $hash = (Get-FileHash dist/cc2flash.exe -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash cc2flash.exe" | Set-Content dist/cc2flash.exe.sha256 -Encoding ascii + - uses: actions/upload-artifact@v4 + with: + name: release-executable + path: | + dist/cc2flash.exe + dist/cc2flash.exe.sha256 + if-no-files-found: error + + attach: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: release-executable + path: dist + - name: Attach to the existing manually published release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" dist/cc2flash.exe dist/cc2flash.exe.sha256 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0c7c0db --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Offline tests +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-2022] + python: ['3.10', '3.12'] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install . + - run: python -m unittest discover -s tests -v + - run: python -m cc2flash --help + - name: Install executable builder + if: matrix.os == 'windows-2022' && matrix.python == '3.12' + run: python -m pip install pyinstaller==6.22.2 + - name: Build and test the Windows executable without publishing + if: matrix.os == 'windows-2022' && matrix.python == '3.12' + run: | + python -m PyInstaller --clean --noconfirm --onefile --console --name cc2flash --hidden-import hid packaging/entrypoint.py + if ($LASTEXITCODE -ne 0) { throw 'Executable build failed' } + python packaging/smoke_test.py dist/cc2flash.exe diff --git a/.gitignore b/.gitignore index 3338f4b..b8a4258 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist/ Thumbs.db *.swp *~ +*.spec diff --git a/README.md b/README.md index 9b33cbd..0154a23 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,8 @@ and writes that image through USB without requiring an SPI programmer. - access to a printer with a 0.2 mm nozzle; - four P50 pogo pins, such as P50-J1; - the USB-A plug and cable from an unused USB data cable; -- Python 3.10 or later; -- the Android platform `adb` executable; -- the optional Python `hidapi` package installed with `cc2flash`. +- [cc2flash installed](docs/INSTALLATION.md), either the Windows executable or Python package; +- the Android platform `adb` executable as described in the installation guide. ### Build the camera-to-USB cable @@ -123,18 +122,20 @@ confirm that +5 V is not shorted to ground or either data line. ### USB prevention command sequence Installation and detailed stop conditions are documented in the -[USB-maintenance reference](usb-maintenance/REFERENCE.md). After `cc2flash` -and `adb` are installed, the supported sequence is: +[USB-maintenance reference](usb-maintenance/REFERENCE.md). After following the [installation guide](docs/INSTALLATION.md) for `cc2flash` +and `adb`, the supported sequence is: ```sh cc2flash start-adb cc2flash backup backup.zip -python hardware-recovery/cc2_sig_tool.py build backup.zip -cc2flash plan-restore backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip +cc2flash build-image backup.zip +cc2flash restore --dry-run backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip cc2flash restore backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip ``` -On Windows, use `py` instead of `python` if that is how Python was installed. +Use the command prefix from your [platform's installation instructions](docs/INSTALLATION.md). +If ADB is not on PATH, add `--adb "path/to/adb"` to connected-camera commands, +but not to offline `build-image` or `restore --dry-run`. `start-adb` temporarily starts the camera's existing root ADB service. `backup` then reads the complete flash repeatedly and publishes `backup.zip` @@ -142,7 +143,7 @@ only after obtaining three consecutive identical images. Keep that ZIP unchanged and in a separate safe location. The builder validates the backup and creates the camera-specific preventive -image. `plan-restore` performs the same-camera and allowed-change checks +image. `restore --dry-run` performs the same-camera and allowed-change checks without opening USB. `restore` repeats those checks, validates the live camera against the preserved backup, temporarily prepares the known stock SFC driver, and requires the literal confirmation `RESTORE-CC2` before its first write. @@ -166,8 +167,7 @@ a command line. - an SOIC-8 test clip and cable; - a multimeter; - NeoProgrammer; -- Python 3.10 or later; -- the files from this repository. +- [cc2flash installed](docs/INSTALLATION.md); the standalone Windows executable needs no Python or Git. The CH341 worked with the flash still soldered on the tested camera. A Bus Pirate is possible but was not reliable in-circuit on that setup; see the @@ -214,12 +214,11 @@ the only verified backup of this camera's identity-bearing data. ### 4. Build the recovery image -In Windows Explorer, open the folder containing `cc2_sig_tool.py` and the three -dump files. Click the address bar, type `cmd`, and press Enter. Then run this -single command: +Open a terminal in the folder containing your three dump files, as described +in the [installation guide](docs/INSTALLATION.md). Run: -```bat -py cc2_sig_tool.py build cc2-camera-1.bin --confirm cc2-camera-2.bin cc2-camera-3.bin +```sh +cc2flash build-image cc2-camera-1.bin --confirm-read cc2-camera-2.bin --confirm-read cc2-camera-3.bin ``` The command performs the stability, firmware, flash-layout, identity, patch, @@ -275,6 +274,8 @@ No full camera dump or vendor firmware image is included in this repository. ## Advanced and audit documentation +- [Installation and release downloads](docs/INSTALLATION.md) +- [Complete cc2flash command reference](docs/CLI.md) - [Hardware-recovery CLI reference](hardware-recovery/CLI_REFERENCE.md) - [Programmer and alternative-hardware reference](hardware-recovery/PROGRAMMER_REFERENCE.md) - [Independent recovery-tool verification](hardware-recovery/CC2_RECOVERY_VERIFICATION.md) @@ -284,4 +285,3 @@ No full camera dump or vendor firmware image is included in this repository. - [Physical USB restore validation](usb-maintenance/PHYSICAL-VALIDATION.md) - [Recovered USB protocol](usb-maintenance/PROTOCOL.md) - [USB reverse-engineering evidence](usb-maintenance/EVIDENCE.md) - diff --git a/usb-maintenance/cc2flash/__init__.py b/cc2flash/__init__.py similarity index 85% rename from usb-maintenance/cc2flash/__init__.py rename to cc2flash/__init__.py index 27ccd50..ec7be8d 100644 --- a/usb-maintenance/cc2flash/__init__.py +++ b/cc2flash/__init__.py @@ -5,4 +5,4 @@ __all__ = ["__version__", "commands"] -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/usb-maintenance/cc2flash/__main__.py b/cc2flash/__main__.py similarity index 98% rename from usb-maintenance/cc2flash/__main__.py rename to cc2flash/__main__.py index 8f0c1d5..b82544c 100644 --- a/usb-maintenance/cc2flash/__main__.py +++ b/cc2flash/__main__.py @@ -2,3 +2,4 @@ raise SystemExit(main()) + diff --git a/usb-maintenance/cc2flash/adb_backup.py b/cc2flash/adb_backup.py similarity index 97% rename from usb-maintenance/cc2flash/adb_backup.py rename to cc2flash/adb_backup.py index df8113e..b1b7286 100644 --- a/usb-maintenance/cc2flash/adb_backup.py +++ b/cc2flash/adb_backup.py @@ -40,7 +40,7 @@ class AdbUnavailable(ProtocolError): ("config", 0x020000), ) -# Keep these aligned with hardware-recovery/cc2_sig_tool.py. Three matching +# Keep these aligned with cc2flash/image.py. Three matching # physical reads are the repository-wide minimum for a non-reference image. # USB acquisition gets two extra chances because live testing showed JFFS2 may # still change immediately after the one-shot ADB startup command. @@ -66,7 +66,7 @@ class AdbUnavailable(ProtocolError): ) MAX_MANIFEST_SIZE = 1024 * 1024 -# hardware-recovery/cc2_sig_tool.py is intentionally allowed to change only +# cc2flash/image.py is intentionally allowed to change only # its audited SquashFS window and the writable config partition. Restore uses # these boundaries to prove that a candidate still belongs to the camera whose # preserved USB backup authorizes the write. @@ -146,7 +146,11 @@ def run(self, *args: str, timeout: float = 30) -> bytes: try: result = subprocess.run(command, capture_output=True, timeout=timeout, check=False) except FileNotFoundError as exc: - raise ProtocolError(f"ADB executable not found: {self.executable}") from exc + raise ProtocolError( + f"ADB executable not found: {self.executable}. " + "Install Google SDK Platform Tools, then pass --adb with the path to adb. " + "Setup: https://github.com/phryneas/cc2_camera_fix/blob/main/docs/INSTALLATION.md" + ) from exc except subprocess.TimeoutExpired as exc: raise ProtocolError(f"ADB command timed out: {' '.join(command)}") from exc if result.returncode: @@ -263,7 +267,11 @@ def ensure_available(self, *, timeout: float = 10.0) -> None: command, capture_output=True, timeout=timeout, check=False ) except FileNotFoundError as exc: - raise ProtocolError(f"ADB executable not found: {self.executable}") from exc + raise ProtocolError( + f"ADB executable not found: {self.executable}. " + "Install Google SDK Platform Tools, then pass --adb with the path to adb. " + "Setup: https://github.com/phryneas/cc2_camera_fix/blob/main/docs/INSTALLATION.md" + ) from exc except subprocess.TimeoutExpired as exc: raise ProtocolError(f"ADB availability check timed out: {' '.join(command)}") from exc state = result.stdout.decode("utf-8", "replace").strip() diff --git a/usb-maintenance/cc2flash/cli.py b/cc2flash/cli.py similarity index 75% rename from usb-maintenance/cc2flash/cli.py rename to cc2flash/cli.py index 0499d23..5ce4266 100644 --- a/usb-maintenance/cc2flash/cli.py +++ b/cc2flash/cli.py @@ -11,6 +11,8 @@ import time from . import __version__ +from . import image as image_tools +from .display import invocation from .restore_prepare import prepare_restore, validate_preparation_image from .adb_backup import ( AdbClient, @@ -158,10 +160,10 @@ def command_backup(args) -> int: bootloader = manifest["bootloader"] observed = str(bootloader["sha256"]) - accepted = args.accept_bootloader_hash + accepted = args.accept_bootloader_sha256 if accepted is not None and accepted != observed: raise ProtocolError( - "--accept-bootloader-hash does not match the observed boot partition: " + "--accept-bootloader-sha256 does not match the observed boot partition: " f"observed {observed}" ) if bootloader["known_reference"]: @@ -170,7 +172,7 @@ def command_backup(args) -> int: bootloader["acceptance"] = "explicit-hash" else: rerun = _common_command(args, "backup") - rerun += ["--accept-bootloader-hash", observed, str(output)] + rerun += ["--accept-bootloader-sha256", observed, str(output)] raise ProtocolError( "unknown bootloader SHA-256; no backup was published:\n" f" {observed}\n" @@ -193,6 +195,8 @@ def command_backup(args) -> int: ) print(f"Saved archive: {archive_path}") print("Archive members: flash.bin, manifest.json") + print("Next, build this camera's recovery image:") + print(invocation("build-image", str(archive_path))) return 0 @@ -223,7 +227,8 @@ def command_start_adb(args) -> int: identity, _parts = adb.identity_and_partitions() print(f"ADB started for this boot. Root identity: {identity}") print("No flash backup was read and no persistent startup file was installed.") - print("Now run cc2flash backup .") + print("Next:") + print(invocation(*_common_command(args, "backup")[1:], "backup.zip")) return 0 @@ -264,7 +269,7 @@ def command_install_adb_startup(args) -> int: print("No flash was read and no backup file was created.") print("Restart or power-cycle the camera, wait for normal USB mode, then run:") print(" " + " ".join(_common_command(args, "backup")) + " ") - return 3 + return 0 def command_plan(args) -> int: @@ -276,6 +281,7 @@ def command_plan(args) -> int: if backup_path is not None: backup_image, backup_hashes = load_preserved_backup(backup_path) validate_replacement_against_backup(image, backup_image) + validate_preparation_image(backup_image) blob, plan = build_update_blob(image) result = { "image": str(image_path), @@ -299,6 +305,8 @@ def command_plan(args) -> int: def _confirm(image_path: Path, image_hashes: dict, backup_path: Path) -> None: + if not sys.stdin.isatty(): + raise ProtocolError("restore requires an interactive RESTORE-CC2 confirmation; nothing was written") print("WRITE OPERATION") print(f"Input: {image_path}") print(f"Range: 0x000000-0x{FLASH_SIZE - 1:06x}") @@ -331,6 +339,8 @@ def _confirm_temporary_adb_for_readback() -> None: def command_restore(args) -> int: + if args.dry_run: + return command_plan(args) image_path = Path(args.image) backup_path = Path(args.backup) backup_image, backup_hashes = load_preserved_backup(backup_path) @@ -342,14 +352,13 @@ def command_restore(args) -> int: print("Note: replacement image is byte-identical to the preserved backup.") blob, plan = build_update_blob(image) validate_preparation_image(backup_image) - if not args.yes: - _confirm(image_path, image_hashes, backup_path) + _confirm(image_path, image_hashes, backup_path) print("Validating the live camera and preparing its temporary SFC erase size.") prepare_restore(_adb(args), backup_image, progress=_read_progress) print("Entering bootloader HID mode; the 8-byte flag write begins now.") enter_bootloader() - wait_for_hid(BOOT_VID, BOOT_HID_PID, timeout=args.enumeration_timeout) + wait_for_hid(BOOT_VID, BOOT_HID_PID, timeout=args.bootloader_timeout) last_percent = -1 @@ -365,10 +374,6 @@ def progress(done: int, total: int) -> None: print("Do not disconnect power; waiting for normal-mode USB to return.") wait_for_hid(NORMAL_VID, NORMAL_PID, timeout=args.reboot_timeout) - if args.no_post_verify: - print("Normal mode returned. Post-write readback was explicitly skipped.") - return 0 - print( "Normal mode returned; requiring three consecutive identical flash " "reads for post-write verification." @@ -447,117 +452,68 @@ def progress(done: int, total: int) -> None: def parser() -> argparse.ArgumentParser: common = argparse.ArgumentParser(add_help=False) common.add_argument("--adb", default="adb", help="ADB executable (default: adb)") - common.add_argument("--serial", help="ADB device serial") + common.add_argument("--serial", help="ADB device serial; not the camera's embedded identity") - result = argparse.ArgumentParser(prog="cc2flash") + result = argparse.ArgumentParser(prog="cc2flash", description="Back up, inspect, repair and restore the supported Elegoo CC2 stock camera.") result.add_argument("--version", action="version", version=__version__) commands = result.add_subparsers(dest="command", required=True) - list_parser = commands.add_parser("list", parents=[common], help="list USB/ADB devices; read-only") - list_parser.set_defaults(func=command_list) - - info = commands.add_parser("info", parents=[common], help="validate and show MTD layout; read-only") + devices = commands.add_parser("devices", help="list USB/ADB devices; read-only") + devices.add_argument("--adb", default="adb", help="ADB executable (default: adb)") + devices.set_defaults(func=command_list, serial=None) + info = commands.add_parser("device-info", parents=[common], help="validate root ADB and show flash layout; read-only") info.set_defaults(func=command_info) - backup = commands.add_parser( - "backup", - parents=[common], - help=( - "require three consecutive identical flash reads within five attempts; " - "strictly read-only" - ), - ) - backup.add_argument( - "output", - help="single ZIP archive to publish; must end in .zip", - ) - backup.add_argument( - "--accept-bootloader-hash", - type=_sha256_argument, - help=( - "accept one reviewed unknown boot-partition SHA-256; the supplied " - "value must exactly match the observed hash" - ), - ) + backup = commands.add_parser("backup", parents=[common], help="save three consecutive identical flash reads; read-only") + backup.add_argument("output", help="new backup archive ending in .zip") + backup.add_argument("--accept-bootloader-sha256", type=_sha256_argument, + help="accept exactly one independently reviewed unknown boot-partition SHA-256") backup.set_defaults(func=command_backup) - start_adb = commands.add_parser( - "start-adb", - parents=[common], - help=( - "start root ADB temporarily through normal HID; no persistent file" - ), - ) - start_adb.add_argument( - "--timeout", - type=_positive_finite_duration, - default=30, - help="seconds to wait for ADB after temporary HID startup (default: 30)", - ) - start_adb.set_defaults(func=command_start_adb) - - install_adb = commands.add_parser( - "install-adb-startup", - parents=[common], - help="persistently install /etc/conf.d/system.sh; restart required", - ) - install_adb.add_argument( - "--yes", - action="store_true", - help="skip typed ENABLE-ADB confirmation", - ) - install_adb.set_defaults(func=command_install_adb_startup) - - plan = commands.add_parser("plan-restore", help="validate and describe an image; no USB writes") - plan.add_argument("image") - plan.add_argument( - "--backup", - help=( - "preserved cc2flash ZIP; also prove the image differs only in " - "hardware-recovery's audited regions" - ), - ) - plan.set_defaults(func=command_plan) - - restore = commands.add_parser( - "restore", parents=[common], - help="restore one full 8 MiB image; requires online root ADB", - description=("Requires online root ADB and a preserved backup. Temporarily " - "sets the known stock SFC driver erase size to 4 KiB before " - "the stock HID flag write; does not manually erase config."), - ) - restore.add_argument("image") - restore.add_argument( - "--backup", - required=True, - help="preserved cc2flash .zip archive", - ) - restore.add_argument("--yes", action="store_true", help="skip typed confirmation") - restore.add_argument("--no-post-verify", action="store_true", help="skip normal-mode ADB readback") - restore.add_argument( - "--enumeration-timeout", type=_positive_finite_duration, default=30 - ) - restore.add_argument( - "--reboot-timeout", type=_positive_finite_duration, default=180 - ) - restore.add_argument( - "--adb-timeout", - type=_positive_finite_duration, - default=60, - help=( - "seconds to wait for ADB to become online before post-write " - "verification starts (default: 60; does not cap the reads)" - ), - ) + start = commands.add_parser("start-adb", parents=[common], help="start root ADB for this boot; no persistent file") + start.add_argument("--timeout", type=_positive_finite_duration, default=30, help="ADB startup wait in seconds (default: 30)") + start.set_defaults(func=command_start_adb) + install = commands.add_parser("install-adb-startup", parents=[common], help="overwrite the persistent ADB startup hook; restart required") + install.add_argument("--yes", action="store_true", help="consent to overwrite the startup hook without typing ENABLE-ADB") + install.set_defaults(func=command_install_adb_startup) + + inspect = commands.add_parser("inspect-image", help="validate a raw dump or backup ZIP; offline, no output files") + build = commands.add_parser("build-image", help="build a camera-specific recovery bundle; offline") + for command in (inspect, build): + command.add_argument("image", metavar="INPUT", help="raw 8 MiB dump or unmodified cc2flash backup ZIP") + command.add_argument("--confirm-read", action="append", default=[], metavar="DUMP", + help="additional independent read that must match; repeat for each file") + command.add_argument("--show-identifiers", action="store_true", help="show full unit identifiers in the analysis report") + inspect.set_defaults(func=image_tools.cmd_analyze) + build.add_argument("-o", "--output", metavar="DIR", help="new output directory (default: -cc2-recovery)") + build.add_argument("--config-mode", choices=("serial-only", "preserve-files"), default="serial-only", + help="rebuild only serial.cfg or all supported live files (default: serial-only)") + build.add_argument("--wipe-unknown-config", action="store_true", help="with serial-only, explicitly discard unfamiliar config names") + build.add_argument("--allow-fewer-reads", action="store_true", help="explicitly accept fewer than three matching reads; other validation remains mandatory") + build.set_defaults(func=image_tools.cmd_build) + + restore = commands.add_parser("restore", parents=[common], help="write and verify a full camera image, or check it offline with --dry-run") + restore.add_argument("image", metavar="IMAGE") + restore.add_argument("--backup", required=True, metavar="BACKUP.zip", help="preserved three-read cc2flash backup archive") + restore.add_argument("--dry-run", action="store_true", help="local validation only; no USB or ADB access") + restore.add_argument("--bootloader-timeout", type=_positive_finite_duration, default=30, help="bootloader USB wait in seconds (default: 30)") + restore.add_argument("--reboot-timeout", type=_positive_finite_duration, default=180, help="normal USB return wait in seconds (default: 180)") + restore.add_argument("--adb-timeout", type=_positive_finite_duration, default=60, help="post-write ADB availability wait, not the read duration (default: 60)") restore.set_defaults(func=command_restore) return result def main(argv: list[str] | None = None) -> int: - args = parser().parse_args(argv) + argv = list(sys.argv[1:] if argv is None else argv) + command_parser = parser() + args = command_parser.parse_args(argv) + if getattr(args, "dry_run", False): + hardware_options = {"--adb", "--serial", "--bootloader-timeout", "--reboot-timeout", "--adb-timeout"} + if any(token.split("=", 1)[0] in hardware_options for token in argv): + command_parser.error("--dry-run is offline and cannot be combined with hardware options") try: - return int(args.func(args)) - except (ProtocolError, OSError) as exc: + return int(args.func(args) or 0) + except (ProtocolError, image_tools.ValidationError, OSError, EOFError) as exc: print(f"cc2flash: error: {exc}", file=sys.stderr) return 2 diff --git a/usb-maintenance/cc2flash/commands.py b/cc2flash/commands.py similarity index 99% rename from usb-maintenance/cc2flash/commands.py rename to cc2flash/commands.py index ba50d36..ceab269 100644 --- a/usb-maintenance/cc2flash/commands.py +++ b/cc2flash/commands.py @@ -1010,3 +1010,4 @@ def decode_boot_terminal_status(frame: BootFrame | bytes) -> bool: if parsed.payload not in (b"\x00", b"\x01"): raise ProtocolError("bootloader type 5 payload must be one byte 00 or 01") return parsed.payload == b"\x01" + diff --git a/cc2flash/display.py b/cc2flash/display.py new file mode 100644 index 0000000..c826e46 --- /dev/null +++ b/cc2flash/display.py @@ -0,0 +1,11 @@ +"""Copyable commands for Command Prompt on Windows and POSIX shells elsewhere.""" +import os +import shlex +import subprocess +import sys + + +def invocation(*arguments: str) -> str: + prefix = [sys.executable] if getattr(sys, "frozen", False) else [sys.executable, "-m", "cc2flash"] + argv = [*prefix, *map(str, arguments)] + return subprocess.list2cmdline(argv) if os.name == "nt" else shlex.join(argv) diff --git a/usb-maintenance/cc2flash/hid_transport.py b/cc2flash/hid_transport.py similarity index 98% rename from usb-maintenance/cc2flash/hid_transport.py rename to cc2flash/hid_transport.py index 00aa490..979bd04 100644 --- a/usb-maintenance/cc2flash/hid_transport.py +++ b/cc2flash/hid_transport.py @@ -51,7 +51,8 @@ def _hid_module(): import hid # type: ignore except ImportError as exc: raise ProtocolError( - "USB support requires the optional 'hidapi' Python package" + "HID support could not load. Reinstall the complete cc2flash package " + "or download the Windows executable from the project's GitHub Releases." ) from exc return hid diff --git a/hardware-recovery/cc2_sig_tool.py b/cc2flash/image.py similarity index 85% rename from hardware-recovery/cc2_sig_tool.py rename to cc2flash/image.py index 3a37948..3ae8174 100644 --- a/hardware-recovery/cc2_sig_tool.py +++ b/cc2flash/image.py @@ -31,8 +31,9 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable +from .display import invocation -TOOL_VERSION = "1.2.1" +from . import __version__ as TOOL_VERSION MIN_IDENTICAL_READS = 3 MAX_USB_READ_ATTEMPTS = 5 @@ -1173,11 +1174,11 @@ def build_preserved_config(files: Iterable[ConfigFile]) -> bytes: } if actual != expected: raise ValidationError( - "Internal preserve-data JFFS2 round-trip validation failed" + "Internal preserve-files JFFS2 round-trip validation failed" ) if parsed["obsolete_node_count"] != 0: raise ValidationError( - "Internal preserve-data rebuild unexpectedly contains obsolete nodes" + "Internal preserve-files rebuild unexpectedly contains obsolete nodes" ) return built @@ -1527,26 +1528,17 @@ def build_recovery( output_dir: Path, *, confirmation_paths: Iterable[Path], - keep_config: bool, config_mode: str, wipe_unknown_config: bool, - overwrite: bool, show_serial: bool, allow_fewer_reads: bool, ) -> dict[str, Any]: confirmation_paths = tuple(confirmation_paths) - if config_mode not in {"clean-data", "preserve-data"}: + if config_mode not in {"serial-only", "preserve-files"}: raise ValidationError(f"Unknown config mode: {config_mode}") - if wipe_unknown_config and config_mode != "clean-data": + if wipe_unknown_config and config_mode != "serial-only": raise ValidationError( - "--wipe-unknown-config is valid only with --config-mode clean-data" - ) - if keep_config and ( - config_mode != "clean-data" or wipe_unknown_config - ): - raise ValidationError( - "--keep-config cannot be combined with preserve-data or " - "--wipe-unknown-config" + "--wipe-unknown-config is valid only with --config-mode serial-only" ) image, input_source = read_image_source(input_path) confirmed_reads = verify_confirmation_reads(image, confirmation_paths) @@ -1557,9 +1549,9 @@ def build_recovery( image, source_name, allow_unknown_config=( - wipe_unknown_config or config_mode == "preserve-data" + wipe_unknown_config or config_mode == "preserve-files" ), - preserve_config_data=config_mode == "preserve-data", + preserve_config_data=config_mode == "preserve-files", ) total_reads = max( @@ -1574,17 +1566,10 @@ def build_recovery( raise ValidationError( f"This dump is not an exact known reference and only {total_reads} " f"identical read(s) were supplied. Provide {MIN_IDENTICAL_READS} " - "total physical reads with --confirm, or explicitly accept the " + "total physical reads with --confirm-read, or explicitly accept the " "higher risk with --allow-fewer-reads." ) - if keep_config and not analysis["config_is_canonical"]: - raise ValidationError( - "--keep-config is allowed only for the exact canonical rebuilt " - "config. Preserving a noncanonical or exhausted JFFS2 partition " - "could leave the camera bricked." - ) - output_resolved = output_dir.resolve() protected_inputs = (input_path, *confirmation_paths) for protected in protected_inputs: @@ -1596,26 +1581,8 @@ def build_recovery( f"Refusing an output directory that contains input dump {protected}" ) - if output_dir.exists(): - if output_dir.is_symlink(): - raise ValidationError(f"Refusing symlink output directory: {output_dir}") - if not overwrite: - raise ValidationError( - f"Output directory already exists: {output_dir}. " - "Use --overwrite only after checking its contents." - ) - if output_dir.is_file(): - raise ValidationError(f"Output path is a file: {output_dir}") - for child in output_dir.iterdir(): - if child.name not in OUTPUT_FILE_NAMES or child.is_dir(): - raise ValidationError( - "Refusing to overwrite a directory containing an unknown " - f"entry: {child}" - ) - for child in output_dir.iterdir(): - child.unlink() - else: - output_dir.mkdir(parents=True) + # mkdir is create-if-absent: never replace an existing bundle. + output_dir.mkdir(parents=True) recovery = bytearray(image) @@ -1625,10 +1592,7 @@ def build_recovery( recovery[PATCH_START:PATCH_END] = patched_region canonical_config = build_minimal_config(analysis["serial_payload"]) - if keep_config: - rebuilt_config = image[CONFIG_START:CONFIG_END] - config_changed = False - elif config_mode == "preserve-data": + if config_mode == "preserve-files": live_config_files = analysis["live_config_files"] assert live_config_files is not None rebuilt_config = build_preserved_config(live_config_files) @@ -1654,14 +1618,13 @@ def build_recovery( output_analysis = analyze_image( recovery_bytes, source_name="generated recovery image", - allow_unknown_config=config_mode == "preserve-data", - preserve_config_data=config_mode == "preserve-data", + allow_unknown_config=config_mode == "preserve-files", + preserve_config_data=config_mode == "preserve-files", ) if output_analysis["system_state"] != "known-bashrc-patched": raise ValidationError("Generated image is not in the known patched system state") if ( - not keep_config - and config_mode == "clean-data" + config_mode == "serial-only" and not output_analysis["config_is_canonical"] ): raise ValidationError("Generated config is not canonical") @@ -1705,9 +1668,9 @@ def build_recovery( """ write_text(output_dir / layout_name, layout) - tool_filename = Path(__file__).name + tool_filename = "cc2flash" preserved_files_manifest: list[dict[str, Any]] = [] - if config_mode == "preserve-data": + if config_mode == "preserve-files": live_config_files = analysis["live_config_files"] assert live_config_files is not None preserved_files_manifest = [ @@ -1792,8 +1755,7 @@ def build_recovery( "preserved_files": preserved_files_manifest, }, "changed_regions": changed_regions, - "keep_config": keep_config, - "config_mode": "keep-config" if keep_config else config_mode, + "config_mode": config_mode, "wipe_unknown_config": wipe_unknown_config, "reference_full_sha256": REFERENCE_FULL_SHA256, } @@ -1822,12 +1784,11 @@ def build_recovery( + f"System patch changed: {'yes' if patch_changed else 'no'}\n" + f"Config changed: {'yes' if config_changed else 'no'}\n" + f"Config mode: " - + ("keep-config" if keep_config else config_mode) + + config_mode + "\n" + f"Unknown-name wipe: " + ("yes" if wipe_unknown_config else "no") + "\n" - + f"keep_config requested: {'yes' if keep_config else 'no'}\n" + f"Changed regions: " + ( ", ".join(region["name"] for region in changed_regions) @@ -1897,9 +1858,9 @@ def build_recovery( flashrom -p buspirate_spi:dev=COM11,spispeed=1M -r cc2-camera-readback.bin -Then verify it with this tool: +Then compare every byte: -py {tool_filename} verify {output_name} cc2-camera-readback.bin +fc.exe /b {output_name} cc2-camera-readback.bin\n\nOn Linux/macOS use: cmp {output_name} cc2-camera-readback.bin Never connect normal USB/device power and programmer-supplied target power at the same time. Confirm the SPI voltage from the exact flash part marking or @@ -1936,118 +1897,16 @@ def build_recovery( } -def verify_readback(expected_path: Path, readback_path: Path) -> None: - expected = expected_path.read_bytes() - actual = readback_path.read_bytes() - - expected_analysis = analyze_image( - expected, - str(expected_path), - allow_unknown_config=True, - preserve_config_data=True, - ) - if expected_analysis["system_state"] != "known-bashrc-patched": - raise ValidationError("Expected image is not in the audited patched state") - - if len(expected) != len(actual): - raise ValidationError( - f"Size mismatch: expected {len(expected)} bytes, " - f"readback has {len(actual)} bytes" - ) - - if expected == actual: - actual_analysis = analyze_image( - actual, - str(readback_path), - allow_unknown_config=True, - preserve_config_data=True, - ) - if actual_analysis["system_state"] != "known-bashrc-patched": - raise ValidationError("Readback is identical but not a valid recovery image") - print("READBACK VERIFIED: byte-for-byte identical") - print(f"SHA-256: {sha256(actual)}") - return - - summary = difference_summary(expected, actual) - affected_flags = { - "system_bashrc_patch": False, - "config": False, - "other": False, - } - for index, (left, right) in enumerate(zip(expected, actual)): - if left == right: - continue - if PATCH_START <= index < PATCH_END: - affected_flags["system_bashrc_patch"] = True - elif CONFIG_START <= index < CONFIG_END: - affected_flags["config"] = True - else: - affected_flags["other"] = True - affected = [name for name, value in affected_flags.items() if value] - - raise ValidationError( - "Readback differs from the expected image: " - f"{summary['different_bytes']} bytes; first=0x" - f"{summary['first_difference']:06X}, last=0x" - f"{summary['last_difference']:06X}; affected={', '.join(affected)}" - ) - - -def self_test(image_path: Path | None = None) -> None: - padding = len(ORIGINAL_COPY_BLOCK) - len(PATCHED_COPY_BLOCK_VISIBLE) - if padding < 0: - raise ValidationError("Readable bashrc patch block is too large") - replacement = PATCHED_COPY_BLOCK_VISIBLE[:-1] + b" " * padding + b"\n" - if len(replacement) != len(ORIGINAL_COPY_BLOCK): - raise ValidationError("Readable bashrc patch does not preserve length") - - xz_vector = (b"CC2 auditable SquashFS patch self-test\n" * 257) + b"end" - xz_stream = build_squashfs_xz_fragment(xz_vector) - if lzma.decompress(xz_stream) != xz_vector: - raise ValidationError("Deterministic XZ builder self-test failed") - - dummy = b"serial=12PSSSS4TEST000000000000000000000000\n" - # Dummy must retain the exact 36-character serial value format. - if not serial_payload_is_valid(dummy): - raise ValidationError("Internal serial regex self-test vector is invalid") - config = build_minimal_config(dummy) - parsed = extract_serial_and_config_info(config) - if parsed["serial_payload"] != dummy: - raise ValidationError("JFFS2 self-test failed") - - image_result = "not requested" - if image_path is not None: - image, source = read_image_source(image_path) - source_name = str(image_path) - if source["image_member"] is not None: - source_name += f"!{source['image_member']}" - analysis = analyze_image(image, source_name) - if analysis["system_state"] == "stock-unpatched": - patched = build_system_patch_window(image) - if sha256(patched) != PATCHED_PATCH_SHA256: - raise ValidationError("Full system patch self-test failed") - image_result = "stock image generated the exact audited patch window" - else: - image_result = "image already contains the exact audited patch window" - - print("SELF-TEST PASSED") - print(f"Tool version: {TOOL_VERSION}") - print(f"Readable bashrc replacement padding: {padding} bytes") - print(f"Deterministic XZ test SHA-256: {sha256(xz_stream)}") - print(f"Generated JFFS2 SHA-256: {sha256(config)}") - print(f"Image patch test: {image_result}") - - def cmd_analyze(args: argparse.Namespace) -> None: path = Path(args.image) image, source = read_image_source(path) confirmations = verify_confirmation_reads( - image, [Path(item) for item in args.confirm] + image, [Path(item) for item in args.confirm_read] ) source_name = str(path) if source["image_member"] is not None: source_name += f"!{source['image_member']}" - print(format_analysis(analyze_image(image, source_name), show_serial=args.show_serial)) + print(format_analysis(analyze_image(image, source_name), show_serial=args.show_identifiers)) evidenced_reads = max( source["evidenced_identical_reads"], 1 + len(confirmations), @@ -2074,12 +1933,10 @@ def cmd_build(args: argparse.Namespace) -> None: result = build_recovery( input_path, output_dir, - confirmation_paths=[Path(item) for item in args.confirm], - keep_config=args.keep_config, + confirmation_paths=[Path(item) for item in args.confirm_read], config_mode=args.config_mode, wipe_unknown_config=args.wipe_unknown_config, - overwrite=args.overwrite, - show_serial=args.show_serial, + show_serial=args.show_identifiers, allow_fewer_reads=args.allow_fewer_reads, ) manifest = result["manifest"] @@ -2098,143 +1955,8 @@ def cmd_build(args: argparse.Namespace) -> None: ) else: print("Regions to write: none") - - -def cmd_verify(args: argparse.Namespace) -> None: - verify_readback(Path(args.expected), Path(args.readback)) - - -def cmd_self_test(args: argparse.Namespace) -> None: - self_test(Path(args.image) if args.image else None) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description=( - "Strict validator and recovery-image builder for the exact CC2 " - "camera firmware family observed in two independent dumps." - ) - ) - parser.add_argument( - "--version", action="version", version=f"%(prog)s {TOOL_VERSION}" - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - analyze = subparsers.add_parser( - "analyze", help="validate a dump without creating a recovery image" - ) - analyze.add_argument( - "image", help="primary raw 8 MiB dump or cc2flash backup ZIP" - ) - analyze.add_argument( - "--confirm", - nargs="*", - default=[], - metavar="DUMP", - help="additional physical reads that must be byte-identical", - ) - analyze.add_argument( - "--show-serial", - action="store_true", - help="print the full unit identifiers instead of masking them", - ) - analyze.set_defaults(func=cmd_analyze) - - build = subparsers.add_parser( - "build", help="validate a dump and generate a recovery bundle" - ) - build.add_argument( - "image", help="primary raw 8 MiB dump or cc2flash backup ZIP" - ) - build.add_argument( - "--confirm", - nargs="*", - default=[], - metavar="DUMP", - help="additional physical reads that must be byte-identical", - ) - build.add_argument( - "-o", "--output", help="output directory (default: beside the input)" - ) - build.add_argument( - "--keep-config", - action="store_true", - help=( - "leave config untouched only if it is already the exact canonical " - "rebuild; exhausted/noncanonical config is refused" - ), - ) - build.add_argument( - "--config-mode", - choices=("clean-data", "preserve-data"), - default="clean-data", - help=( - "clean-data keeps only serial.cfg and lets the next boot recreate " - "defaults; preserve-data recreates each live regular file once " - "(default: clean-data)" - ), - ) - build.add_argument( - "--wipe-unknown-config", - action="store_true", - help=( - "with clean-data, explicitly discard CRC-valid config names " - "outside the audited set while still preserving serial.cfg" - ), - ) - build.add_argument( - "--allow-fewer-reads", - action="store_true", - help=( - "explicitly accept fewer than three identical physical reads for " - "a dump that is not an exact known reference" - ), - ) - build.add_argument( - "--overwrite", - action="store_true", - help="replace an existing output directory", - ) - build.add_argument( - "--show-serial", - action="store_true", - help="include full identifiers in VALIDATION.txt", - ) - build.set_defaults(func=cmd_build) - - verify = subparsers.add_parser( - "verify", help="compare a full programmer readback with a generated image" - ) - verify.add_argument("expected", help="generated recovery image") - verify.add_argument("readback", help="full 8 MiB readback") - verify.set_defaults(func=cmd_verify) - - test = subparsers.add_parser( - "self-test", help="check the readable patch, XZ, and JFFS2 implementation" - ) - test.add_argument( - "image", - nargs="?", - help=( - "optional supported raw image or cc2flash backup ZIP for a " - "complete system-patch self-test" - ), - ) - test.set_defaults(func=cmd_self_test) - - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - try: - args.func(args) - except (OSError, ValidationError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) + if manifest["input"]["source_format"] == USB_BACKUP_FORMAT: + print("Next, check the USB restore offline:") + print(invocation("restore", str(result["output_path"]), "--backup", str(input_path), "--dry-run")) + else: + print("Next, program the generated full image with your external programmer and require full-chip verification.") diff --git a/usb-maintenance/cc2flash/protocol.py b/cc2flash/protocol.py similarity index 99% rename from usb-maintenance/cc2flash/protocol.py rename to cc2flash/protocol.py index b07323e..449fbd7 100644 --- a/usb-maintenance/cc2flash/protocol.py +++ b/cc2flash/protocol.py @@ -244,3 +244,4 @@ def validate_full_restore_image(image: bytes) -> None: "image contains the persistent upgrade magic at 0x7f8000 and would boot-loop" ) + diff --git a/usb-maintenance/cc2flash/restore_prepare.py b/cc2flash/restore_prepare.py similarity index 99% rename from usb-maintenance/cc2flash/restore_prepare.py rename to cc2flash/restore_prepare.py index 5cb84b5..fa0f9e6 100644 --- a/usb-maintenance/cc2flash/restore_prepare.py +++ b/cc2flash/restore_prepare.py @@ -132,3 +132,4 @@ def prepare_restore(adb: AdbClient, preserved: bytes, *, progress=None) -> None: f"{exc}; no HID trigger was sent. RAM may already be patched; " "no automatic retry or rollback was attempted" ) from exc + diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..cf3d080 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,118 @@ +# cc2flash command reference + +Install using the [setup guide](INSTALLATION.md). Start with the workflows in +the [main guide](../README.md); these options are for reference. +Every command accepts `--help`. `cc2flash --version` reports the installed version. +On Windows PowerShell, use `.\cc2flash.exe` for a downloaded executable in the +current folder. Python installations also support `python -m cc2flash`. + +## Connected camera + +```text +cc2flash devices [--adb EXECUTABLE] +cc2flash device-info [--adb EXECUTABLE] [--serial SERIAL] +cc2flash start-adb [--adb EXECUTABLE] [--serial SERIAL] [--timeout SECONDS] +cc2flash install-adb-startup [--adb EXECUTABLE] [--serial SERIAL] [--yes] +cc2flash backup OUTPUT.zip [--adb EXECUTABLE] [--serial SERIAL] + [--accept-bootloader-sha256 SHA256] +``` + +- `devices`: best-effort, read-only USB/HID and ADB listing. +- `device-info`: validate root ADB and the supported MTD partition layout. + This is not a complete firmware-image inspection. +- `start-adb`: temporarily start the existing daemon; no persistent startup + file. Default timeout: 30 seconds. +- `install-adb-startup`: overwrite `/etc/conf.d/system.sh` with the known + ADB startup content. Requires `ENABLE-ADB` confirmation or `--yes`. + Restart manually afterward; successful installation exits with status 0. + If ADB is already online, this command does not install anything. +- `backup`: require three consecutive identical full reads within five + attempts, then publish a new ZIP atomically. Never overwrites a backup. + If ADB is offline, explicitly start it and rerun backup; backup itself is + read-only. A reviewed unknown bootloader can be accepted by its exact + SHA-256 without bypassing any other check. + +`--adb` defaults to `adb`; a path to the executable is also accepted. +`--serial` is the USB ADB serial, not the identifier embedded in the firmware. +It does not relax the restore requirement for exactly one connected camera. + +## Offline image work + +```text +cc2flash inspect-image INPUT [--confirm-read DUMP]... [--show-identifiers] +cc2flash build-image INPUT [--confirm-read DUMP]... [-o DIR | --output DIR] + [--config-mode {serial-only,preserve-files}] + [--wipe-unknown-config] [--allow-fewer-reads] [--show-identifiers] +``` + +`INPUT` is a raw 8 MiB dump or an unmodified cc2flash backup ZIP. +Inspection validates without creating files; building creates a recovery bundle +in a new `-cc2-recovery/` directory unless `--output` is given. +Existing output directories are refused. Neither command accesses hardware. + +For raw dumps, repeat `--confirm-read` for each independent matching read: + +```sh +cc2flash build-image camera-1.bin --confirm-read camera-2.bin --confirm-read camera-3.bin +``` + +A valid ZIP supplies its own three-consecutive-read evidence. Non-reference raw +inputs require three matching reads unless `--allow-fewer-reads` explicitly +accepts the higher risk. This exception is recorded in the bundle and does not +bypass firmware, identity, or mismatch checks. Exact full reference hashes can +be rebuilt from one copy. + +| Config mode | Behavior | +|---|---| +| `serial-only` (default) | Rebuild config with only the camera's original serial.cfg; missing defaults are created at next boot. | +| `preserve-files` | Rebuild every supported live regular file once, preserving contents and metadata while dropping obsolete records. | + +`--wipe-unknown-config` permits discarding unfamiliar names only with +`serial-only`. It never permits discarding the camera's identity. +`--show-identifiers` reveals identifiers in analysis reports; default reports +mask them. Raw dumps, ZIPs, serial.cfg, and recovery images always contain +private unit data regardless of report masking. + +## USB restore + +```text +cc2flash restore IMAGE --backup BACKUP.zip --dry-run +cc2flash restore IMAGE --backup BACKUP.zip + [--adb EXECUTABLE] [--serial SERIAL] + [--bootloader-timeout SECONDS] [--reboot-timeout SECONDS] + [--adb-timeout SECONDS] +``` + +`--dry-run` validates the candidate, preserved backup, allowed changes, known +preparation kernel, and transfer representation entirely offline. It opens no +transport and writes nothing. Hardware-specific options are rejected with it. +It cannot certify the live camera or predict successful physical writing. + +A real restore repeats local validation, requires typed `RESTORE-CC2` consent, +validates the live camera, applies the temporary SFC correction, and writes the +full image. Keep power connected until post-write verification completes. +If needed after reboot, temporary ADB startup requires separate consent. +There is no noninteractive write bypass or post-verification opt-out. + +Timeouts default to 30 seconds for bootloader USB, 180 for normal USB return, +and 60 for post-write ADB availability. The last timeout does not cap the flash +reads themselves. + +Verification requires three consecutive identical reads and exact agreement +through the end of HWCONFIG. Config may change during the required boot; the +output states explicitly whether config also matched byte-for-byte. + +## External programmer verification + +Require full-chip verification against the generated 8 MiB image in the +programmer software. If saving an independent readback, compare it with +`fc.exe /b expected.bin readback.bin` on Windows or +`cmp expected.bin readback.bin` on Linux/macOS. Any mismatch is a failure. +The tool does not operate an external programmer. + +## Exit status + +0 means the requested operation succeeded. 2 means a CLI, validation, or +operating-system error. A successful dry run is not a successful physical +restore. A failed post-write check means a write may already have occurred; +read the error and keep the original backup. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..0657e79 --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,111 @@ +# Install cc2flash + +First [identify your camera](../README.md#first-identify-your-camera). +Installing this tool does not change the camera. + +## Windows: standalone executable + +1. Open the [GitHub Releases page](https://github.com/phryneas/cc2_camera_fix/releases). +2. Choose a tested release and expand **Assets**. +3. Download **cc2flash.exe** to a new folder, for example `Documents\CC2-camera`. + The executable is for 64-bit Intel/AMD Windows and includes Python and HID + support. You do not need Python, Git, pip, or a repository checkout. +4. In File Explorer, open that folder, click its address bar, type `cmd`, + and press Enter. This opens Command Prompt in the correct folder. +5. Paste this command and press Enter: + +```bat +cc2flash.exe --version +``` + +It should print a version number. Keep this window open for the recovery +instructions. Do not double-click the executable: it is a command-line tool. +In PowerShell, prefix a local executable with `.\`, for example +`.\cc2flash.exe --version`. + +The executable is unsigned; Windows may show a reputation warning. Check that +you obtained it from this repository's intended release. Do not disable your +antivirus. If you are not comfortable running it, use the Python route below. +Each release build also attaches `cc2flash.exe.sha256`; you can compare its +digest with `certutil -hashfile cc2flash.exe SHA256`. This checks download +integrity, not publisher identity. + +Assets appear only after the release build succeeds. If a release has no +executable, inspect its Actions result or use Python; the source-code ZIP is +not a standalone executable. + +## USB workflows also need ADB + +Offline image inspection/building and external-programmer recovery do not need +ADB. USB startup, backup, and restore need Google's +[SDK Platform Tools](https://developer.android.com/tools/releases/platform-tools). + +On Windows, download and extract the Windows ZIP. Copy the **whole extracted +platform-tools folder** into your CC2-camera folder; keep its DLLs beside +`adb.exe`. There is no need to edit PATH. Test: + +```bat +platform-tools\adb.exe version +cc2flash.exe devices --adb "platform-tools\adb.exe" +``` + +Add `--adb "platform-tools\adb.exe"` to connected-camera commands in the guide. +For example: + +```bat +cc2flash.exe start-adb --adb "platform-tools\adb.exe" +cc2flash.exe backup backup.zip --adb "platform-tools\adb.exe" +``` + +Do not add ADB options to offline commands, including `restore --dry-run`. +On Linux/macOS, download the matching Platform Tools archive and extract it; +use `--adb "/path/to/platform-tools/adb"` if it is not on PATH. +An offline ADB camera is expected before `start-adb`; an authorization, +driver, or device-ambiguity error is not equivalent to that state. + +## Python alternative: install directly from GitHub + +Use Python 3.10 or newer. No Git or manual repository download is required. +Choose a tested release tag from Releases. In the command below, replace +`TAG` with that tag (including its leading `v`, if present). + +Windows Command Prompt, in your working folder: + +```bat +py -m venv .venv +.venv\Scripts\python.exe -m pip install "cc2flash @ https://github.com/phryneas/cc2_camera_fix/archive/refs/tags/TAG.zip" +.venv\Scripts\python.exe -m cc2flash --version +``` + +Linux/macOS: + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install "cc2flash @ https://github.com/phryneas/cc2_camera_fix/archive/refs/tags/TAG.zip" +.venv/bin/python -m cc2flash --version +``` + +Use that same Python path followed by `-m cc2flash` in place of `cc2flash` +throughout the guide. No environment activation or PATH edit is needed. +HID support is installed with the package. ADB remains separate. +On Linux, your distribution may require its Python venv package and USB device +permissions. Do not use `sudo pip` or bypass an externally-managed-Python error. +If installation tries to compile HID support, use a Python/platform combination +with a supported hidapi wheel rather than requiring beginners to install a compiler. + +These commands require access to the release source. Private repositories need +authentication; do not paste credentials into shared commands. + +## Contributor and release maintenance + +Contributors can install from the repository root with +`python -m pip install -e .` and run +`python -m unittest discover -s tests -v`. + +The maintainer creates and publishes releases manually in GitHub. Publishing a +release (including a prerelease) triggers the executable workflow on its commit: +install, offline tests, PyInstaller build, frozen CLI smoke checks, hash, upload. +Saving a draft does not trigger it. The workflow creates neither tags nor releases. +Existing assets are not overwritten. The Windows runner builds a console +application with Python/HID support, not ADB or firmware images. +The workflow must be present in the released source. diff --git a/hardware-recovery/CC2_RECOVERY_VERIFICATION.md b/hardware-recovery/CC2_RECOVERY_VERIFICATION.md index fc2dbf0..85181cc 100644 --- a/hardware-recovery/CC2_RECOVERY_VERIFICATION.md +++ b/hardware-recovery/CC2_RECOVERY_VERIFICATION.md @@ -1,7 +1,7 @@ # CC2 camera recovery tool — independent verification Review date: 2026-08-22 -Reviewed tool: `cc2_sig_tool.py` v1.1.0 +Scope: independent audit of the recovery-image algorithm; current entry point: `cc2flash build-image`. ## Conclusion @@ -81,10 +81,9 @@ An independent read-only SquashFS v4/XZ parser extracted `bashrc.sh` from the st Additional safety changes: -- `--keep-config` refuses any exhausted or noncanonical partition; - non-reference devices require three matching reads by default; -- readback verification first validates that the expected image is patched and canonical; -- output overwrite cannot recursively delete inputs, subdirectories, symlink targets, or unrelated files; +- generated recovery images are strictly validated before programmer verification; +- existing output directories are refused without deleting their contents; - generated Bus Pirate examples use documented `dev` and `spispeed` parameters and do not silently enable target power. See the [official flashrom Bus Pirate documentation](https://flashrom.org/supported_hw/supported_prog/buspirate.html). ## Regression results @@ -103,9 +102,9 @@ All executed tests passed: ## Recommended hardware validation sequence 1. Make three full reads without moving the clip; require identical SHA-256 hashes. -2. Run `analyze`, then `build` with the other two reads supplied via `--confirm`. +2. Run `inspect-image`, then `build-image` with the other two reads supplied via `--confirm-read`. 3. Verify the exact flash part voltage and all SPI connections from the marking/datasheet. 4. Write only the generated layout regions. Do not power the target simultaneously from USB/device power and programmer target power. 5. Without disturbing the connection, make a full 8 MiB readback. -6. Run the tool's `verify` command and require byte-for-byte identity. +6. Compare every readback byte with the generated image using `cmp` or Windows `fc.exe /b`. 7. Disconnect the programmer, restore normal power, and observe boot and USB enumeration. diff --git a/hardware-recovery/CLI_REFERENCE.md b/hardware-recovery/CLI_REFERENCE.md index 0cc0c57..5de77bf 100644 --- a/hardware-recovery/CLI_REFERENCE.md +++ b/hardware-recovery/CLI_REFERENCE.md @@ -1,108 +1,41 @@ # Hardware-recovery CLI reference -The normal recovery path is documented in the [main user guide](../README.md#failed-camera-hardware-recovery). -This reference contains optional, diagnostic, and advanced commands. +Install cc2flash using the [setup guide](../docs/INSTALLATION.md). +The [main guide](../README.md#failed-camera-hardware-recovery) describes +connection, three independent programmer reads, full-chip programming and +verification. All commands and options are listed in the +[unified CLI reference](../docs/CLI.md). -## Usage - -Download `cc2_sig_tool.py` from this repository and place it in a working directory. You need Python 3.10 or later and either a raw full 8 MiB dump from your camera or an unmodified `cc2flash-backup-v2` ZIP created by the USB-maintenance tool. Do not continue to a write until you have evidence for three identical physical reads. - -For raw programmer dumps, supply three independently read files with identical -SHA-256 hashes as shown below. For a USB backup ZIP, the builder validates -`flash.bin`, `manifest.json`, the exact member set, hashes, boot fingerprint, -partition map, and three-consecutive-read acquisition evidence. A valid ZIP -therefore satisfies the three-read gate directly; do not extract or rewrite it. - -The command-line examples in this section use a normal Unix shell and -`python3`. The later NeoProgrammer walkthrough uses Windows syntax where the -programmer software requires it. - -Run the internal self-test. Supplying a supported ROM also exercises the complete readable SquashFS patch path: - -```sh -python3 cc2_sig_tool.py self-test cc2-camera-1.bin -``` - -Analyze without creating anything: - -```sh -python3 cc2_sig_tool.py analyze cc2-camera-1.bin -``` - -The equivalent USB-backup workflow is: - -```sh -python3 cc2_sig_tool.py analyze backup.zip -python3 cc2_sig_tool.py build backup.zip -``` - -The second command creates -`backup-cc2-recovery/cc2-camera-recovery.bin`. Keep the original -`backup.zip` unchanged: USB maintenance uses it both as the preserved -three-read backup and to prove that the recovery image belongs to the same -camera before restoring it. - -Choose the config treatment explicitly when the default is not appropriate: - -```sh -# Default: wipe ordinary config and preserve only serial.cfg -python3 cc2_sig_tool.py build backup.zip --config-mode clean-data - -# Same clean rebuild, after explicitly approving unfamiliar config names -python3 cc2_sig_tool.py build backup.zip --wipe-unknown-config - -# Recreate every live regular config file once, without dead JFFS2 copies -python3 cc2_sig_tool.py build backup.zip --config-mode preserve-data -``` - -Raw 8 MiB backups are first-class inputs. For a bricked camera, raw dumps made -with an external programmer are the only acquisition path; supply three stable -reads with `--confirm` as described below. - -Build a recovery bundle and require three physical reads to be byte-identical: +## Build from programmer reads ```sh -python3 cc2_sig_tool.py build cc2-camera-1.bin --confirm cc2-camera-2.bin cc2-camera-3.bin +cc2flash inspect-image camera-1.bin --confirm-read camera-2.bin --confirm-read camera-3.bin +cc2flash build-image camera-1.bin --confirm-read camera-2.bin --confirm-read camera-3.bin ``` -For a non-reference unit, three byte-identical physical reads are required by default. With fewer reads, the tool refuses unless the higher risk is explicitly accepted: +## Build from a USB backup ```sh -python3 cc2_sig_tool.py build cc2-camera-1.bin --allow-fewer-reads +cc2flash inspect-image backup.zip +cc2flash build-image backup.zip +cc2flash restore backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip --dry-run ``` -Exact full reference dumps listed in `REFERENCE_FINGERPRINTS.json` may be rebuilt from one copy because their complete 8 MiB hashes already match known inputs. +Keep the backup ZIP unchanged. It contains both flash.bin and acquisition +evidence and is required for a real USB restore. A programmer can write the +generated full image using the procedure in the main guide. -To validate the complete USB round trip without opening USB: +## Config choices -```sh -cc2flash plan-restore backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip -``` - -The corresponding write command exists, but the current USB entry path is not -safe on an ordinary camera: - -```sh -cc2flash restore backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip -``` - -Do not run it until USB maintenance implements and validates a safe -normal-Linux-to-U-Boot transition. The bootloader transfer itself passed a -physical full-write/readback experiment, but the stock Linux trigger can damage -an occupied JFFS2 node without entering upgrade mode. See -[the physical USB validation record](../usb-maintenance/PHYSICAL-VALIDATION.md). - -USB maintenance refuses the candidate if any byte outside this builder's -audited `0x463000–0x46AFFF` system window and -`0x7E0000–0x7FFFFF` config partition differs from the preserved backup. - -For the raw `cc2-camera-1.bin` example, the default output directory is: - -```text -cc2-camera-1-cc2-recovery -``` +The default `--config-mode serial-only` rebuilds config containing only +serial.cfg. Missing defaults are created on the next boot. +`--config-mode preserve-files` rebuilds every supported live regular file +once, retaining contents and metadata but dropping obsolete records. +`--wipe-unknown-config` explicitly discards unfamiliar config names in +serial-only mode without weakening identity validation. -It contains: +The original image is never modified. By default the new bundle is written +beside it in `-cc2-recovery/`: ```text cc2-camera-recovery.bin @@ -115,34 +48,12 @@ FLASHING.txt SHA256SUMS.txt ``` -`FLASHING.txt` contains a command assembled from the regions that actually changed. For example: - -- exhausted stock image: `system_bashrc_patch` and `config`; -- already config-recovered image: normally `system_bashrc_patch` only; -- already permanently patched image: no write required. - -To patch only `bashrc.sh` while preserving the current config partition byte-for-byte: - -```sh -python3 cc2_sig_tool.py build cc2-camera-1.bin --keep-config -``` - -This option is accepted only when the partition already equals the exact canonical rebuild. It refuses exhausted or otherwise noncanonical config, because preserving one could leave the device bricked. -It cannot be combined with `--config-mode preserve-data` or -`--wipe-unknown-config`. - -## Readback verification - -After writing, make a full 8 MiB readback without disturbing the programmer connection. Then run: - -```bat -py cc2_sig_tool.py verify cc2-camera-recovery.bin cc2-camera-readback.bin -``` - -Success is reported only when the files are byte-for-byte identical. - -`verify` first requires the expected file to be a strict, patched recovery image -with a safely reconstructable config, then requires the full readback to be -byte-for-byte identical. Both clean-data and preserve-data outputs are valid. +Use the complete cc2-camera-recovery.bin for full-chip programming, not +config-restored.bin or serial.cfg. FLASHING.txt also contains an advanced +flashrom region-write template based on regions that actually changed. +Regardless of write method, require full-chip verification against the +generated image before disconnecting the programmer and booting the camera. -Only disconnect the programmer and attempt a normal boot after this comparison succeeds. Never connect normal camera/USB power and programmer target power simultaneously. +For a separate saved readback, use `fc.exe /b expected.bin readback.bin` +on Windows or `cmp expected.bin readback.bin` on Linux/macOS. +Never connect programmer target power and normal camera power simultaneously. diff --git a/hardware-recovery/PROGRAMMER_REFERENCE.md b/hardware-recovery/PROGRAMMER_REFERENCE.md index 611b712..cd132e9 100644 --- a/hardware-recovery/PROGRAMMER_REFERENCE.md +++ b/hardware-recovery/PROGRAMMER_REFERENCE.md @@ -50,7 +50,7 @@ All-`00`, all-`FF`, unstable, differently hashed, wrong-size, or intermittently Build the recovery image from those reads: ```bat -py cc2_sig_tool.py build cc2-camera-1.bin --confirm cc2-camera-2.bin cc2-camera-3.bin +cc2flash build-image cc2-camera-1.bin --confirm-read cc2-camera-2.bin --confirm-read cc2-camera-3.bin ``` #### Full-chip erase, program, and verify @@ -65,7 +65,7 @@ py cc2_sig_tool.py build cc2-camera-1.bin --confirm cc2-camera-2.bin cc2-camera- 8. Verify that readback with the recovery tool: ```bat -py cc2_sig_tool.py verify cc2-camera-1-cc2-recovery\cc2-camera-recovery.bin cc2-camera-readback.bin +fc.exe /b cc2-camera-1-cc2-recovery\cc2-camera-recovery.bin cc2-camera-readback.bin ``` Only after the tool reports a byte-for-byte match should you unplug the CH341 programmer, remove the clip, reconnect normal camera power, and test boot/USB enumeration. diff --git a/hardware-recovery/TECHNICAL_DETAILS.md b/hardware-recovery/TECHNICAL_DETAILS.md index d34a372..3d77cf6 100644 --- a/hardware-recovery/TECHNICAL_DETAILS.md +++ b/hardware-recovery/TECHNICAL_DETAILS.md @@ -31,7 +31,7 @@ This is a useful isolation test, not proof of this exact flash failure. The buil ## What this repair requires -This is a hardware recovery. You must read and rewrite the camera's eight-pin SPI flash memory with an external programmer. +For a failed camera, read and rewrite the eight-pin SPI flash with an external programmer. A working camera can use the USB prevention workflow in the main guide. Both use the same image builder. The recommended beginner setup is: @@ -75,7 +75,7 @@ The input window, compressed fragment, decompressed fragment, original `bashrc.s ### Config recovery modes -`clean-data` is the default and retains the original recovery behavior. The +`serial-only` is the default. The builder extracts the camera's own CRC-valid `serial.cfg` and creates a minimal 128 KiB JFFS2 partition containing: @@ -93,7 +93,7 @@ config contents too, add `--wipe-unknown-config`. That override still preserves and cross-checks `serial.cfg`; it is not permission to ignore identity or firmware validation failures. -`preserve-data` instead resolves the current root-directory view and recreates +`preserve-files` instead resolves the current root-directory view and recreates every live regular file once in a fresh compact JFFS2 image. It preserves file contents, mode, owner, timestamps, flags, inode identity, and directory-entry metadata, while dropping obsolete/dead historical nodes. It supports @@ -164,9 +164,9 @@ The validator therefore performs the strongest safe equivalent of “100% match 4. The excluded fields are still validated: - the UOID must have the expected 94-byte structure; - `config` must contain CRC-valid JFFS2 nodes; - - clean-data accepts only the six known filenames unless + - serial-only accepts only the six known filenames unless `--wipe-unknown-config` is explicit; - - preserve-data accepts additional live names only when every entry can be + - preserve-files accepts additional live names only when every entry can be safely reconstructed as a regular root file; - exactly one unambiguous `serial.cfg` value must be recoverable; - the serial and UOID must share the expected 12-byte unit prefix. @@ -177,7 +177,7 @@ No other differences are accepted. See `REFERENCE_FINGERPRINTS.json`. The builder: -- can require any additional physical reads supplied with `--confirm` to be byte-for-byte identical; +- can require any additional physical reads supplied with `--confirm-read` to be byte-for-byte identical; - requires three identical reads for a non-reference unit unless reduced confidence is explicitly accepted; - refuses files that are not exactly 8 MiB; - validates exact hashes for every invariant segment; @@ -186,9 +186,8 @@ The builder: - preserves the entire input HWCONFIG partition; - preserves the original serial payload; - validates the generated JFFS2 image by parsing it again, including a complete - content/metadata round trip in preserve-data mode; -- refuses `--keep-config` for a noncanonical/exhausted partition; -- refuses ambiguous, unsupported, or oversized preserve-data layouts; + content/metadata round trip in preserve-files mode; +- refuses ambiguous, unsupported, or oversized preserve-files layouts; - refuses unsafe output-directory reuse that could delete inputs or unrelated files; - validates the complete generated recovery image again; - proves no bytes changed outside the selected patch/config regions; diff --git a/hardware-recovery/TEST_RESULTS.md b/hardware-recovery/TEST_RESULTS.md index 5a5fa4a..f63ff62 100644 --- a/hardware-recovery/TEST_RESULTS.md +++ b/hardware-recovery/TEST_RESULTS.md @@ -15,7 +15,7 @@ Review date: 2026-09-04 - The validated manifest contributes exactly three evidenced physical reads, satisfying the ordinary non-reference build gate without `--allow-fewer-reads`. -- The hardware-recovery self-test and all nine focused +- The deterministic patch/XZ/JFFS2 checks and the focused interoperability/config-mode tests pass on Python 3.12. - Oversized zero-filled fragments are rejected before allocation. Zlib fragments are rejected before decompression when their declared size exceeds @@ -30,8 +30,8 @@ reads, the known bootloader fingerprint, and the camera's six-partition map. - The unmodified ZIP passed strict archive, evidence, partition-map, firmware, identity, and JFFS2 validation without reduced-read overrides. -- Default clean-data correctly refused the live unfamiliar `system.sh` name. -- Clean-data with `--wipe-unknown-config` and preserve-data both built directly +- Default serial-only correctly refused the live unfamiliar `system.sh` name. +- Serial-only with `--wipe-unknown-config` and preserve-files both built directly from the ZIP without extraction. - Preserve-data retained the contents and relevant metadata of all seven live regular files: `dev_config.cfg`, `serial.cfg`, `system.sh`, `uvc.attr`, @@ -55,7 +55,7 @@ was not present in this interoperability run. Both inputs have the same exact invariant firmware fingerprint and stock SquashFS window, but different serials, UOIDs, two-byte HWCONFIG check values, and raw JFFS2 histories. Each output preserves its own complete HWCONFIG partition and exact recovered `serial.cfg` payload. The attached unit-B hardware readback was independently hashed and compared -byte-for-byte with the newly generated clean-data image in this review. +byte-for-byte with the newly generated serial-only image in this review. ## Independent filesystem and patch checks @@ -82,7 +82,6 @@ byte-for-byte with the newly generated clean-data image in this review. - One-bit kernel mutation: rejected. - One-bit system-patch-window mutation: rejected. - Serial from one unit combined with another unit's UOID: rejected. -- `--keep-config` on either exhausted bricked partition: rejected. - One-bit full-programmer readback mismatch: rejected with the affected region. - Rebuilding an already patched canonical image: idempotent; no write region reported. - Existing output directory containing an unknown entry: rejected instead of deleting it. diff --git a/packaging/entrypoint.py b/packaging/entrypoint.py new file mode 100644 index 0000000..26b77fa --- /dev/null +++ b/packaging/entrypoint.py @@ -0,0 +1,5 @@ +"""Console entry point for the standalone executable.""" +from cc2flash.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/smoke_test.py b/packaging/smoke_test.py new file mode 100644 index 0000000..b037f20 --- /dev/null +++ b/packaging/smoke_test.py @@ -0,0 +1,39 @@ +"""Exercise an installed/frozen console application without a physical camera.""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def main(): + executable = str(Path(sys.argv[1]).resolve()) + with tempfile.TemporaryDirectory() as directory: + def run(*args, expected=0): + result = subprocess.run( + [executable, *args], cwd=directory, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=60, + ) + if result.returncode != expected: + raise AssertionError(f"{args}: exit {result.returncode}\n{result.stdout}\n{result.stderr}") + return result + + run("--version") + help_text = run("--help").stdout + for command in ("devices", "device-info", "start-adb", "install-adb-startup", + "backup", "inspect-image", "build-image", "restore"): + assert command in help_text, command + run(command, "--help") + # Enumeration is read-only. No device selection, startup or write occurs. + devices = json.loads(run("devices", "--adb", str(Path(directory) / "missing-adb")).stdout) + assert "hid_error" not in devices, devices + assert "ADB executable not found" in devices["adb_error"], devices + run("restore", "missing.bin", "--backup", "missing.zip", "--dry-run", expected=2) + Path(directory, "invalid.bin").write_bytes(b"not a supported image") + failure = run("build-image", "invalid.bin", expected=2) + assert "cc2flash: error:" in failure.stderr, failure.stderr + assert not Path(directory, "invalid-cc2-recovery").exists() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c34e620 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "cc2flash" +dynamic = ["version"] +description = "Backup, repair and restore the Elegoo CC2 stock camera" +requires-python = ">=3.10" +dependencies = ["hidapi>=0.14"] + +[project.scripts] +cc2flash = "cc2flash.cli:main" + +[tool.setuptools] +packages = ["cc2flash"] +license-files = ["hardware-recovery/LICENSE"] + +[tool.setuptools.dynamic] +version = {attr = "cc2flash.__version__"} diff --git a/usb-maintenance/tests/__init__.py b/tests/__init__.py similarity index 50% rename from usb-maintenance/tests/__init__.py rename to tests/__init__.py index 8b13789..139597f 100644 --- a/usb-maintenance/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ + diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..fce97fe --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,103 @@ +"""Unified CLI contracts; all hardware calls are mocked.""" +from contextlib import ExitStack, redirect_stderr, redirect_stdout +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock +from cc2flash import cli, image +from cc2flash.protocol import ProtocolError + + +class CliTests(unittest.TestCase): + def test_exact_command_surface(self): + import argparse + action = next(a for a in cli.parser()._actions if isinstance(a, argparse._SubParsersAction)) + self.assertEqual(set(action.choices), { + "devices", "device-info", "start-adb", "install-adb-startup", + "backup", "inspect-image", "build-image", "restore", + }) + + def test_image_defaults_and_repeatable_reads(self): + args = cli.parser().parse_args(["build-image", "one.bin", "--confirm-read", "two.bin", "--confirm-read", "three.bin"]) + self.assertEqual(args.config_mode, "serial-only") + self.assertEqual(args.confirm_read, ["two.bin", "three.bin"]) + self.assertFalse(args.allow_fewer_reads) + + def test_removed_options_are_rejected(self): + commands = [ + ["build-image", "input.bin", "--keep-config"], + ["build-image", "input.bin", "--overwrite"], + ["build-image", "input.bin", "--config-mode", "unchanged"], + ["restore", "input.bin", "--backup", "backup.zip", "--yes"], + ["restore", "input.bin", "--backup", "backup.zip", "--no-post-verify"], + ["restore", "input.bin", "--dry-run"], + ["self-test"], ["verify-readback", "a", "b"], ["plan-restore", "a"], + ] + for argv in commands: + with self.subTest(argv=argv), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + cli.main(argv) + + def test_dry_run_rejects_hardware_options_even_explicit_defaults(self): + for option, value in [ + ("--adb", "adb"), ("--serial", "TEST"), + ("--bootloader-timeout", "30"), ("--reboot-timeout", "180"), + ("--adb-timeout", "60"), + ]: + for suffix in ([option, value], [option + "=" + value]): + with self.subTest(suffix=suffix), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + cli.main(["restore", "image", "--backup", "backup.zip", "--dry-run", *suffix]) + + def test_dry_run_validates_every_local_gate_without_transport(self): + with tempfile.TemporaryDirectory() as directory, ExitStack() as stack: + path = Path(directory) / "image.bin" + path.write_bytes(b"image") + events = [] + stack.enter_context(mock.patch.object(cli, "load_preserved_backup", return_value=(b"backup", {"sha256": "hash"}))) + for name in ("validate_full_restore_image", "validate_replacement_against_backup", "validate_preparation_image"): + stack.enter_context(mock.patch.object(cli, name, side_effect=lambda *a, n=name: events.append(n))) + plan = mock.Mock(flash_offset=0, transfer_size=10, packet_count=1, packet_payload_size=3060) + stack.enter_context(mock.patch.object(cli, "build_update_blob", return_value=(b"blob", plan))) + guards = [stack.enter_context(mock.patch.object(cli, name, side_effect=AssertionError("hardware accessed"))) + for name in ("_adb", "expected_devices", "prepare_restore", "enter_bootloader", "restore_blob", "_confirm", "start_adb_through_upload_command")] + output = stack.enter_context(redirect_stdout(io.StringIO())) + self.assertEqual(cli.main(["restore", str(path), "--backup", "backup.zip", "--dry-run"]), 0) + self.assertEqual(events, ["validate_full_restore_image", "validate_replacement_against_backup", "validate_preparation_image"]) + self.assertFalse(json.loads(output.getvalue())["writes_performed"]) + for guard in guards: + guard.assert_not_called() + + def test_bad_dry_run_before_hardware(self): + with mock.patch.object(cli, "_adb") as adb, redirect_stderr(io.StringIO()): + self.assertEqual(cli.main(["restore", "missing.bin", "--backup", "missing.zip", "--dry-run"]), 2) + adb.assert_not_called() + + def test_write_confirmation_is_required(self): + for answer in ("", "yes", "RESTORE-OTHER"): + with mock.patch.object(cli.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value=answer), redirect_stdout(io.StringIO()), self.assertRaises(ProtocolError): + cli._confirm(Path("image"), {"sha256": "hash"}, Path("backup")) + with mock.patch.object(cli.sys.stdin, "isatty", return_value=False), self.assertRaises(ProtocolError): + cli._confirm(Path("image"), {"sha256": "hash"}, Path("backup")) + + def test_fewer_reads_does_not_bypass_image_validation(self): + with mock.patch.object(image, "read_image_source", return_value=(b"bad", {"image_member": None})), mock.patch.object(image, "analyze_image", side_effect=image.ValidationError("unsupported firmware")), redirect_stderr(io.StringIO()): + self.assertEqual(cli.main(["build-image", "input", "--allow-fewer-reads"]), 2) + + def test_read_opt_out_and_existing_output_preservation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "existing" + output.mkdir() + sentinel = output / "unrelated.txt" + sentinel.write_text("keep me") + source = {"image_member": None, "evidenced_identical_reads": 1} + with mock.patch.object(image, "read_image_source", return_value=(b"image", source)), mock.patch.object(image, "analyze_image", return_value={"exact_reference": None}): + kwargs = dict(confirmation_paths=[], config_mode="serial-only", + wipe_unknown_config=False, show_serial=False) + with self.assertRaisesRegex(image.ValidationError, "higher risk"): + image.build_recovery(root / "input", output, allow_fewer_reads=False, **kwargs) + # The explicit opt-out passes the read gate, but cannot overwrite. + with self.assertRaises(FileExistsError): + image.build_recovery(root / "input", output, allow_fewer_reads=True, **kwargs) + self.assertEqual(sentinel.read_text(), "keep me") diff --git a/usb-maintenance/tests/test_commands.py b/tests/test_commands.py similarity index 99% rename from usb-maintenance/tests/test_commands.py rename to tests/test_commands.py index 972ede3..1fa9973 100644 --- a/usb-maintenance/tests/test_commands.py +++ b/tests/test_commands.py @@ -257,3 +257,4 @@ def test_terminal_decoder_distinguishes_ram_md5_result(self): if __name__ == "__main__": unittest.main() + diff --git a/hardware-recovery/tests/test_hwconfig_variants.py b/tests/test_hwconfig_variants.py similarity index 86% rename from hardware-recovery/tests/test_hwconfig_variants.py rename to tests/test_hwconfig_variants.py index 65e1035..855d7bf 100644 --- a/hardware-recovery/tests/test_hwconfig_variants.py +++ b/tests/test_hwconfig_variants.py @@ -6,12 +6,7 @@ import unittest -TOOL_PATH = Path(__file__).parents[1] / "cc2_sig_tool.py" -SPEC = importlib.util.spec_from_file_location("cc2_sig_tool_hwconfig", TOOL_PATH) -assert SPEC is not None and SPEC.loader is not None -tool = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = tool -SPEC.loader.exec_module(tool) +from cc2flash import image as tool def hwconfig_record(length: int, extension: bytes = b"") -> bytes: @@ -58,3 +53,4 @@ def test_non_type12_record_is_rejected(self): if __name__ == "__main__": unittest.main() + diff --git a/tests/test_implementation.py b/tests/test_implementation.py new file mode 100644 index 0000000..e312317 --- /dev/null +++ b/tests/test_implementation.py @@ -0,0 +1,64 @@ +"""Offline deterministic image-generation checks (no public CLI command).""" +import io +import lzma +from pathlib import Path +import unittest +from contextlib import redirect_stdout +from cc2flash.image import ( + ORIGINAL_COPY_BLOCK, PATCHED_COPY_BLOCK_VISIBLE, ValidationError, + build_squashfs_xz_fragment, serial_payload_is_valid, build_minimal_config, + extract_serial_and_config_info, read_image_source, analyze_image, + build_system_patch_window, sha256, PATCHED_PATCH_SHA256, TOOL_VERSION, +) + +def implementation_checks(image_path: Path | None = None) -> None: + padding = len(ORIGINAL_COPY_BLOCK) - len(PATCHED_COPY_BLOCK_VISIBLE) + if padding < 0: + raise ValidationError("Readable bashrc patch block is too large") + replacement = PATCHED_COPY_BLOCK_VISIBLE[:-1] + b" " * padding + b"\n" + if len(replacement) != len(ORIGINAL_COPY_BLOCK): + raise ValidationError("Readable bashrc patch does not preserve length") + + xz_vector = (b"CC2 auditable SquashFS patch self-test\n" * 257) + b"end" + xz_stream = build_squashfs_xz_fragment(xz_vector) + if lzma.decompress(xz_stream) != xz_vector: + raise ValidationError("Deterministic XZ builder self-test failed") + + dummy = b"serial=12PSSSS4TEST000000000000000000000000\n" + # Dummy must retain the exact 36-character serial value format. + if not serial_payload_is_valid(dummy): + raise ValidationError("Internal serial regex self-test vector is invalid") + config = build_minimal_config(dummy) + parsed = extract_serial_and_config_info(config) + if parsed["serial_payload"] != dummy: + raise ValidationError("JFFS2 self-test failed") + + image_result = "not requested" + if image_path is not None: + image, source = read_image_source(image_path) + source_name = str(image_path) + if source["image_member"] is not None: + source_name += f"!{source['image_member']}" + analysis = analyze_image(image, source_name) + if analysis["system_state"] == "stock-unpatched": + patched = build_system_patch_window(image) + if sha256(patched) != PATCHED_PATCH_SHA256: + raise ValidationError("Full system patch self-test failed") + image_result = "stock image generated the exact audited patch window" + else: + image_result = "image already contains the exact audited patch window" + + print("SELF-TEST PASSED") + print(f"Tool version: {TOOL_VERSION}") + print(f"Readable bashrc replacement padding: {padding} bytes") + print(f"Deterministic XZ test SHA-256: {sha256(xz_stream)}") + print(f"Generated JFFS2 SHA-256: {sha256(config)}") + print(f"Image patch test: {image_result}") + + + +class ImplementationTests(unittest.TestCase): + def test_deterministic_patch_xz_and_jffs2(self): + with redirect_stdout(io.StringIO()): + implementation_checks() + diff --git a/usb-maintenance/tests/test_protocol.py b/tests/test_protocol.py similarity index 98% rename from usb-maintenance/tests/test_protocol.py rename to tests/test_protocol.py index 1b69ab5..49e74b7 100644 --- a/usb-maintenance/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -951,7 +951,8 @@ def test_plan_restore_can_prove_preserved_backup_compatibility(self): ), redirect_stdout(output), ): - status = cli.command_plan(args) + with mock.patch.object(cli, "validate_preparation_image"): + status = cli.command_plan(args) self.assertEqual(status, 0) compatible.assert_called_once_with(image, image) result = json.loads(output.getvalue()) @@ -966,10 +967,9 @@ def test_restore_refuses_wrong_unit_before_opening_usb(self): args = SimpleNamespace( image=str(image_path), backup="backup.zip", - yes=True, - enumeration_timeout=30, + dry_run=False, + bootloader_timeout=30, reboot_timeout=180, - no_post_verify=True, adb_timeout=60, adb="adb", serial=None, @@ -1071,7 +1071,7 @@ def test_interactive_install_adb_startup_requires_exact_phrase(self): redirect_stderr(io.StringIO()), ): status = cli.main(["install-adb-startup"]) - self.assertEqual(status, 3) + self.assertEqual(status, 0) install.assert_called_once_with() def test_install_confirmation_mismatch_does_not_write(self): @@ -1115,7 +1115,7 @@ def test_explicit_install_requires_restart(self): redirect_stderr(io.StringIO()), ): status = cli.main(["install-adb-startup", "--yes"]) - self.assertEqual(status, 3) + self.assertEqual(status, 0) install.assert_called_once_with() self.assertIn("restart", stdout.getvalue().casefold()) @@ -1163,7 +1163,7 @@ def test_unknown_bootloader_failure_prints_exact_hash_after_reads(self): save.assert_not_called() error = stderr.getvalue() self.assertIn(observed, error) - self.assertIn(f"--accept-bootloader-hash {observed}", error) + self.assertIn(f"--accept-bootloader-sha256 {observed}", error) def test_exact_unknown_bootloader_hash_can_be_accepted(self): image = b"image" @@ -1177,7 +1177,7 @@ def test_exact_unknown_bootloader_hash_can_be_accepted(self): redirect_stdout(io.StringIO()), ): status = cli.main( - ["backup", "backup.zip", "--accept-bootloader-hash", observed] + ["backup", "backup.zip", "--accept-bootloader-sha256", observed] ) self.assertEqual(status, 0) self.assertEqual(manifest["bootloader"]["acceptance"], "explicit-hash") @@ -1196,7 +1196,7 @@ def test_wrong_bootloader_override_prints_observed_and_publishes_nothing(self): redirect_stderr(stderr), ): status = cli.main( - ["backup", "backup.zip", "--accept-bootloader-hash", supplied] + ["backup", "backup.zip", "--accept-bootloader-sha256", supplied] ) self.assertEqual(status, 2) save.assert_not_called() @@ -1212,10 +1212,9 @@ def test_restore_online_probe_uses_availability_timeout(self): args = SimpleNamespace( image=str(image_path), backup="backup.zip", - yes=True, - enumeration_timeout=30, + dry_run=False, + bootloader_timeout=30, reboot_timeout=180, - no_post_verify=False, adb_timeout=7, adb="adb", serial=None, @@ -1233,6 +1232,7 @@ def test_restore_online_probe_uses_availability_timeout(self): ), mock.patch.object(cli, "validate_preparation_image"), mock.patch.object(cli, "prepare_restore"), + mock.patch.object(cli, "_confirm"), mock.patch.object(cli, "enter_bootloader"), mock.patch.object(cli, "wait_for_hid"), mock.patch.object(cli, "restore_blob"), @@ -1265,10 +1265,9 @@ def test_restore_prompts_before_temporary_adb_start(self): args = SimpleNamespace( image=str(image_path), backup="backup.zip", - yes=True, - enumeration_timeout=30, + dry_run=False, + bootloader_timeout=30, reboot_timeout=180, - no_post_verify=False, adb_timeout=7, adb="adb", serial=None, @@ -1286,6 +1285,7 @@ def test_restore_prompts_before_temporary_adb_start(self): ), mock.patch.object(cli, "validate_preparation_image"), mock.patch.object(cli, "prepare_restore"), + mock.patch.object(cli, "_confirm"), mock.patch.object(cli, "enter_bootloader"), mock.patch.object(cli, "wait_for_hid"), mock.patch.object(cli, "restore_blob"), @@ -1327,10 +1327,9 @@ def test_restore_declined_temporary_adb_start_sends_no_hid(self): args = SimpleNamespace( image=str(image_path), backup="backup.zip", - yes=True, - enumeration_timeout=30, + dry_run=False, + bootloader_timeout=30, reboot_timeout=180, - no_post_verify=False, adb_timeout=7, adb="adb", serial=None, @@ -1350,6 +1349,7 @@ def test_restore_declined_temporary_adb_start_sends_no_hid(self): ), mock.patch.object(cli, "validate_preparation_image"), mock.patch.object(cli, "prepare_restore"), + mock.patch.object(cli, "_confirm"), mock.patch.object(cli, "enter_bootloader"), mock.patch.object(cli, "wait_for_hid"), mock.patch.object(cli, "restore_blob"), @@ -1380,10 +1380,9 @@ def test_noninteractive_restore_never_starts_temporary_adb(self): args = SimpleNamespace( image=str(image_path), backup="backup.zip", - yes=True, - enumeration_timeout=30, + dry_run=False, + bootloader_timeout=30, reboot_timeout=180, - no_post_verify=False, adb_timeout=7, adb="adb", serial=None, @@ -1403,6 +1402,7 @@ def test_noninteractive_restore_never_starts_temporary_adb(self): ), mock.patch.object(cli, "validate_preparation_image"), mock.patch.object(cli, "prepare_restore"), + mock.patch.object(cli, "_confirm"), mock.patch.object(cli, "enter_bootloader"), mock.patch.object(cli, "wait_for_hid"), mock.patch.object(cli, "restore_blob"), diff --git a/usb-maintenance/tests/test_restore_prepare.py b/tests/test_restore_prepare.py similarity index 95% rename from usb-maintenance/tests/test_restore_prepare.py rename to tests/test_restore_prepare.py index 13a74a7..f6e888b 100644 --- a/usb-maintenance/tests/test_restore_prepare.py +++ b/tests/test_restore_prepare.py @@ -192,7 +192,7 @@ def test_confirmation_and_preparation_precede_trigger(self): path = Path(directory) / "candidate.bin" path.write_bytes(b"candidate") args = cli.parser().parse_args([ - "restore", str(path), "--backup", "backup.zip", "--no-post-verify" + "restore", str(path), "--backup", "backup.zip" ]) events = [] stack.enter_context(mock.patch.object(cli, "load_preserved_backup", return_value=(b"backup", {"sha256": "x"}))) @@ -201,6 +201,9 @@ def test_confirmation_and_preparation_precede_trigger(self): stack.enter_context(mock.patch.object(cli, "build_update_blob", return_value=(b"blob", None))) for name in ("validate_preparation_image", "_confirm", "prepare_restore", "enter_bootloader", "wait_for_hid", "restore_blob"): stack.enter_context(mock.patch.object(cli, name, side_effect=lambda *a, _name=name, **k: events.append(_name))) + stack.enter_context(mock.patch.object(cli, "_adb", return_value=mock.Mock())) + stack.enter_context(mock.patch.object(cli, "acquire_stable", return_value=(b"candidate", {}))) + stack.enter_context(mock.patch.object(cli, "validate_post_restore_readback", return_value=True)) stack.enter_context(redirect_stdout(io.StringIO())) self.assertEqual(cli.command_restore(args), 0) self.assertEqual(events, ["validate_preparation_image", "_confirm", "prepare_restore", "enter_bootloader", "wait_for_hid", "restore_blob", "wait_for_hid"]) @@ -226,9 +229,9 @@ def test_preparation_failure_never_triggers_hid(self): with tempfile.TemporaryDirectory() as directory, ExitStack() as stack: path = Path(directory) / "candidate.bin" path.write_bytes(b"candidate") - args = cli.parser().parse_args(["restore", str(path), "--backup", "backup.zip", "--yes"]) + args = cli.parser().parse_args(["restore", str(path), "--backup", "backup.zip"]) stack.enter_context(mock.patch.object(cli, "load_preserved_backup", return_value=(b"backup", {"sha256": "x"}))) - for name in ("validate_full_restore_image", "validate_replacement_against_backup", "validate_preparation_image"): + for name in ("validate_full_restore_image", "validate_replacement_against_backup", "validate_preparation_image", "_confirm"): stack.enter_context(mock.patch.object(cli, name)) stack.enter_context(mock.patch.object(cli, "build_update_blob", return_value=(b"blob", None))) prepare = stack.enter_context(mock.patch.object(cli, "prepare_restore", side_effect=ProtocolError("refused"))) diff --git a/hardware-recovery/tests/test_usb_backup.py b/tests/test_usb_backup.py similarity index 87% rename from hardware-recovery/tests/test_usb_backup.py rename to tests/test_usb_backup.py index bff72d4..ca3ab59 100644 --- a/hardware-recovery/tests/test_usb_backup.py +++ b/tests/test_usb_backup.py @@ -11,12 +11,7 @@ import zlib -TOOL_PATH = Path(__file__).parents[1] / "cc2_sig_tool.py" -SPEC = importlib.util.spec_from_file_location("cc2_sig_tool", TOOL_PATH) -assert SPEC is not None and SPEC.loader is not None -tool = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = tool -SPEC.loader.exec_module(tool) +from cc2flash import image as tool def usb_manifest(image: bytes) -> dict: @@ -187,29 +182,13 @@ def test_zlib_fragment_output_is_bounded(self): tool.decode_jffs2_fragment(node, b"expanding-zlib") def test_wipe_override_is_clean_data_only(self): - with self.assertRaisesRegex(tool.ValidationError, "clean-data"): + with self.assertRaisesRegex(tool.ValidationError, "serial-only"): tool.build_recovery( Path("unused.bin"), Path("unused-output"), confirmation_paths=[], - keep_config=False, - config_mode="preserve-data", + config_mode="preserve-files", wipe_unknown_config=True, - overwrite=False, - show_serial=False, - allow_fewer_reads=False, - ) - - def test_keep_config_cannot_be_combined_with_preserve_data(self): - with self.assertRaisesRegex(tool.ValidationError, "cannot be combined"): - tool.build_recovery( - Path("unused.bin"), - Path("unused-output"), - confirmation_paths=[], - keep_config=True, - config_mode="preserve-data", - wipe_unknown_config=False, - overwrite=False, show_serial=False, allow_fewer_reads=False, ) diff --git a/usb-maintenance/PROJECT-STATUS.md b/usb-maintenance/PROJECT-STATUS.md index 3abd75d..5e8d85e 100644 --- a/usb-maintenance/PROJECT-STATUS.md +++ b/usb-maintenance/PROJECT-STATUS.md @@ -47,11 +47,11 @@ - Refactored the CLI-used HID paths to consume the public builders rather than maintaining a second set of command literals. - Added direct interoperability with hardware recovery: its builder consumes - the preserved USB ZIP, while `plan-restore --backup` and `restore` prove the + the preserved USB ZIP, while `restore --dry-run --backup` and `restore` prove the generated image matches that same camera outside the two audited edit regions. - Made post-write verification compatible with both hardware-recovery config - modes: after a clean-data boot it separately prompts for permission to start + modes: after a serial-only boot it separately prompts for permission to start ADB temporarily, requires an exact match through HWCONFIG, and separately reports config changes made by that boot. It never treats restore `--yes` as consent to start ADB. @@ -74,12 +74,12 @@ Before opening USB, restore now compares the replacement with `flash.bin` from the validated preserved ZIP. Only hardware recovery's exact SquashFS patch window and config partition may differ. This rejects a wrong-camera recovery image or unsupported additional edits. The same check is available read-only -through `plan-restore --backup`. +through `restore --dry-run --backup`. After restore, the client acquires another three consecutive reads. All bytes through `0x7dffff` must match the candidate exactly. The writable config partition is compared and reported separately because the first successful -clean-data boot recreates missing defaults before ADB can read it back. +serial-only boot recreates missing defaults before ADB can read it back. The expanded tests verify exact catalog completeness, all 21 configuration pairs, all 13 uploader commands, all four U-Boot frame types, group-wide @@ -128,7 +128,7 @@ malformed-device states remain refusals. worked, and a post-write backup obtained three consecutive identical 8 MiB reads in three attempts. Boot through HWCONFIG matched the candidate exactly; strict validation passed, while config differed only as expected from the - clean-data first boot. `system.sh` was absent. + serial-only first boot. `system.sh` was absent. - A later test exercised the integrated client as one uninterrupted operation, without a manual RAM edit, config erase, process suspension, or separate continuation. It obtained three stable pre-write reads, completed the dynamic diff --git a/usb-maintenance/PROTOCOL.md b/usb-maintenance/PROTOCOL.md index a19033a..0f81d27 100644 --- a/usb-maintenance/PROTOCOL.md +++ b/usb-maintenance/PROTOCOL.md @@ -740,7 +740,7 @@ full 256 KiB `boot` partition. The built-in known reference is: If the stable image has a different boot SHA-256, the command publishes no backup and prints that exact observed value. The user may independently review -it and rerun with `--accept-bootloader-hash `. A supplied hash +it and rerun with `--accept-bootloader-sha256 `. A supplied hash that differs from the newly observed partition is rejected. The accepted v2 manifest records whether the basis was `known-reference` or `explicit-hash`. The raw image and manifest are published as exactly two members of one ordinary diff --git a/usb-maintenance/REFERENCE.md b/usb-maintenance/REFERENCE.md index 613b029..1361102 100644 --- a/usb-maintenance/REFERENCE.md +++ b/usb-maintenance/REFERENCE.md @@ -50,22 +50,12 @@ updater. Read `source-reconstruction/README.md` before relying on it. ## Install -Python 3.10 or newer is required. Backup also requires the Android platform -`adb` executable. Normal-HID ADB recovery and restore support use the optional -`hidapi` package. +Follow the [beginner installation guide](../docs/INSTALLATION.md). +The Windows executable includes Python and HID support; ADB is installed +separately. The complete command syntax is in the [CLI reference](../docs/CLI.md). -```sh -python3 -m venv .venv -. .venv/bin/activate -python -m pip install -e '.[usb]' -``` - -For inspection and backup when ADB is already running, the package itself has -no Python dependencies: - -```sh -python -m pip install -e . -``` +For contributor installation, run `python -m pip install -e .` at the repository +root. Run the offline suite there with `python -m unittest discover -s tests -v`. ## Public Python command API @@ -123,8 +113,8 @@ reports mutate the device; building them does not. Connect exactly one camera in normal USB mode, then: ```sh -cc2flash list -cc2flash info +cc2flash devices +cc2flash device-info cc2flash backup backup.zip ``` @@ -178,7 +168,7 @@ the literal confirmation `ENABLE-ADB`. It then sends: 3. `0x3200`, final frame — upload the exact 11 bytes `/bin/adbd &`; and 4. `0x3300` — remove any old target, write the file, and chmod it `0777`. -The command then exits with status `3`. It has not read flash or created a +The command then exits successfully with status `0`. It has not read flash or created a backup. Restart or power-cycle the camera, wait for normal USB mode, and run `cc2flash backup backup.zip`; `rcS` will start `adbd` during boot. @@ -221,7 +211,7 @@ prints the exact observed hash. After independently reviewing it, that one value can be accepted explicitly: ```sh -cc2flash backup --accept-bootloader-hash backup.zip +cc2flash backup --accept-bootloader-sha256 backup.zip ``` The supplied value must exactly match the newly observed hash. Only then does @@ -245,8 +235,8 @@ Pass the unmodified ZIP directly to the hardware-recovery builder; its embedded three-consecutive-read evidence satisfies that tool's physical-read gate: ```sh -python ../hardware-recovery/cc2_sig_tool.py analyze backup.zip -python ../hardware-recovery/cc2_sig_tool.py build backup.zip +cc2flash inspect-image backup.zip +cc2flash build-image backup.zip ``` The builder writes @@ -257,7 +247,7 @@ evidence required by restore. Validate the resulting pair without opening USB: ```sh -cc2flash plan-restore \ +cc2flash restore --dry-run \ backup-cc2-recovery/cc2-camera-recovery.bin \ --backup backup.zip ``` @@ -275,7 +265,7 @@ If ADB lists more than one device, pass the camera serial explicitly: cc2flash backup --serial Ucamera001 backup.zip ``` -The exact serial exposed by ADB may differ; use `cc2flash list` to inspect it. +The exact serial exposed by ADB may differ; use `cc2flash devices` to inspect it. ## Restore workflow (physically validated on the supported camera) @@ -287,7 +277,7 @@ kernel gate, runtime pointer derivation, failure behavior, and hardware evidence Validate a candidate without opening USB: ```sh -cc2flash plan-restore fixed.bin --backup backup.zip +cc2flash restore --dry-run fixed.bin --backup backup.zip ``` A full restore requires exactly `0x800000` bytes. It also rejects an image @@ -314,7 +304,7 @@ validation are recorded in [PHYSICAL-VALIDATION.md](PHYSICAL-VALIDATION.md) and [SFC-RESTORE-PREPARATION.md](SFC-RESTORE-PREPARATION.md). The command prints the complete target range and hashes, then requires the -literal confirmation `RESTORE-CC2` unless `--yes` was supplied. Its phases are: +literal confirmation `RESTORE-CC2` for every real restore. Its phases are: 1. Validate `fixed.bin` and both members of `backup.zip` locally. 2. Require the known kernel, three consecutive identical live reads, and a @@ -333,16 +323,12 @@ literal confirmation `RESTORE-CC2` unless `--yes` was supplied. Its phases are: images over ADB. 8. Require every boot-stable byte from `0x000000` through the end of HWCONFIG at `0x7dffff` to match `fixed.bin`. Report whether config also stayed exact; - clean-data images legitimately create default config files during this boot. + serial-only images legitimately create default config files during this boot. The bootloader reports MD5 acceptance **before** erase/write and offers no -post-write status or readback. An independently available ADB or programmer -read is therefore required for end-to-end verification. `--no-post-verify` -gives up that assurance. +post-write status or readback. ADB readback is therefore mandatory for a successful USB restore. -Consent to the flash write and consent to start ADB are separate. `restore ---yes` skips only the typed `RESTORE-CC2` write confirmation. It does not answer -the later ADB question. Empty or negative input, and non-interactive stdin, send +Consent to the flash write and consent to start ADB are separate. Both require interactive confirmation. Empty or negative input, and non-interactive stdin, send no ADB-start HID command and leave the completed restore explicitly unverified. @@ -408,7 +394,7 @@ boot-hash gating, create-if-absent ZIP publication, strict v2 manifest parsing including JSON decoder-limit failures, unsupported-compression rejection, both explicit ADB startup commands, hardware-recovery region compatibility, boot-stable post-write comparison, -separately confirmed temporary ADB startup after a clean-data restore, and +separately confirmed temporary ADB startup after a serial-only restore, and pre-USB wrong-unit rejection, bounded/validated availability timeouts, expected final-commit failure/disconnection, the Windows `error: closed` diff --git a/usb-maintenance/SHA256SUMS b/usb-maintenance/SHA256SUMS index e0dbabc..3a8f2ca 100644 --- a/usb-maintenance/SHA256SUMS +++ b/usb-maintenance/SHA256SUMS @@ -1,28 +1,28 @@ -f8de534a98d7e49f5f94622da1bb3cc245b4a4d4a6466a1c3253fde04a65362c ./CHANGELOG.md -0befef79d715414085b8642f072ae4b2e5657a25c192c3e5df4dd4b4288174b9 ./EVIDENCE.md -bb55ba209567256cd0dc04c0686a28f8c47ef632b0dd95b1e092538a3c95b252 ./PROJECT-STATUS.md -fef49c4a3c77193c571d584c873beb6c9dc8d922d8a3f8c38275b650dd0de71e ./PROTOCOL.md -16b43546cf8664e480a29ca152e9d9289547a84ccae24adb7447720c430aabe5 ./README.md -8f35ab1aa72b0750d871d4dd681a1bb2ca8e0ce36d68779f372688ee43c623ed ./cc2flash/__init__.py -76fa916e93e0ae1a6c1bb51dcc0bcf289c014e9f760a47165f8a84401ccf41d7 ./cc2flash/__main__.py -1203bbfa6cb2aa7225868efca9175b30213093e9dcdda2528ea75c8b8fc9d1ee ./cc2flash/adb_backup.py -9caae0c03b9ce7c8bcd9fb1c5fbed7d0620922c8e8b85c4a499e78de62b75715 ./cc2flash/cli.py -77ea854e9d96b4019891ab55b298e7eaabf53f1b5ab3bd8789f9cab927a77a85 ./cc2flash/commands.py -8bbc07278b2c78381f3f72d1a315d4d802cc7632afc62d049588b97566e54a84 ./cc2flash/hid_transport.py -6bec2e91065bd48a242f2dc19794816c8b8cdd87d12e2f2a6f8d011a6fc42272 ./cc2flash/protocol.py -12294b5e9e1ebfce626d3cb07b072264f875c333ce1abbe8b6214df4e4d8f1fc ./pyproject.toml -664fb78d83bd483aebbfd6b1e2fc7aaea31d08e1154ec8d42b1b42059e14b4bf ./source-reconstruction/README.md -7df9714e5fa66f4039bfca7142cc7cfecdebe0654517dff68fe99b31d7dba863 ./source-reconstruction/REPRODUCING.md -261c163a0f4a3c5256a3895a3b0b6ab38911cbef699588f1ba22b05a24aa2fac ./source-reconstruction/TRACEABILITY.md -6f898f3521d2bcc98800c01ac2e9a05695d05eae77bf16550bfbc9f0b20d0516 ./source-reconstruction/hid_update_reconstructed.c -fef49c4a3c77193c571d584c873beb6c9dc8d922d8a3f8c38275b650dd0de71e ./source-reconstruction/protocol_reference.md -13c359b62e7dec5c8f770da493eaaf29750f196083b2df5fc5350038f9c0e506 ./source-reconstruction/spl_upgrade_gate_reconstructed.c -5c7ed61c78be1e57be27ae5075b38d41510da3cef7d01471771378a7eeae3068 ./source-reconstruction/uboot_updater_reconstructed.c -e0ea49659511c879a72e7e7e6ad1d0db9ec34fcb70ea011f2f3d7f0a7ccaba15 ./source-reconstruction/verify_protocol_vectors.py -01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b ./tests/__init__.py -63cefbfa71257b4cb0224d0ec2561ecbb966c97aca7d17643f3bf8fea1db281d ./tests/test_commands.py -af35de9037906ee83c1ca377c4f6be159c210bf522f2b481e3c75b9e86123e79 ./tests/test_protocol.py -8f41651b7e94deba5d3e22873e4f9e8a6cfbbd739396e98cead53cd1b8738007 ./tools/README.md -4741175ec415490e154f5927a1b463d8fed5f74e24d9dc09c1f82c8cd8921090 ./tools/mips32_disasm.py -23c8a1a5a33efffdf659577878d47b1989a2f62414262913a3ea9afbdd99d0f4 ./tools/mips_pic_xrefs.py -270554b8d0d561b3dca5e8f76be2bd04e76de8c74c12f2fa13a90e12e30d3c09 ./tools/squashfs_extract.py +f616e344edd9e5a64ef9a2b61073a319c33c820e7c32e9ed8f5ca3b90d375afd ./CHANGELOG.md +867bc8a899dd109e483fde30bdf7b183180935dc1ff90be4908a94e940e1739c ./EVIDENCE.md +f53eff272eec43e178ab61fbd074a4be6578228202d2553dcd1d0d012cf9feaa ./PROJECT-STATUS.md +27953fb883dcbb2748c0d35dc152d8b6e12b032cdbdd358b3d6698ab944226aa ./PROTOCOL.md +022f1561b205c883c0ae2eac4caba74aa05d082d93750d01968bc1579ad6c9e4 ./README.md +fbaad85a19d7d4b767ab0507c20f8083f34e21b0ac2629b89f02a308be749e97 ../cc2flash/__init__.py +4d7e67f4bb67e4eaf444a2e6b860a254d7d6fee2fff9393db126ead45f2f9888 ../cc2flash/__main__.py +9bd90eb2a7c78b55f42b5ff317a172cf4851adbacc5c8ba44335642b691a66f8 ../cc2flash/adb_backup.py +a462816cd69cce9b22677f27ee7ab3bfe452c55e7973d745aaa133ab2101119b ../cc2flash/cli.py +f74bb50ae67442294bb0483d38539648b572f924a3aa70e78ea58de2562f95ee ../cc2flash/commands.py +89e81b2ee9273a3ecba06337450e9c06e48fb657f7f2b5a65f4dc2f5c50d1f1f ../cc2flash/hid_transport.py +6a86bcc05deef339bca65f3cf21adb4613a4eae4afa26c8408ac0eb66639a000 ../cc2flash/protocol.py +26a9ce1db17c81ca875c02552f50cb915df0ed50d329b8e775d24038ac3572e5 ../pyproject.toml +02c07ed4a581505003f6f5e8e52fdc5d0289b1a7ec2dc66fc01c93b4ed8c07b1 ./source-reconstruction/README.md +9acc77b2ce92c1ce4ca7aa1955e3eb8aa408d507a97f280ed2f9ea3bcf2e847b ./source-reconstruction/REPRODUCING.md +cfa527bce319f29b56ece0cc28b2d5127f2334189eb3ebd2ab5df18ae6181080 ./source-reconstruction/TRACEABILITY.md +bb478a521a3f6e59bcd1d4651071bb1b53c97bb9a9523ffcdc4461156b01f337 ./source-reconstruction/hid_update_reconstructed.c +47e9f8abf466e731248a42c792ef2c250edf5816ef77c58ec4b9a188e930b267 ./source-reconstruction/protocol_reference.md +7df1112a6c5dd9121b10918885e1270b91c3dd933a630125e81f081a57cef7d0 ./source-reconstruction/spl_upgrade_gate_reconstructed.c +e1a3cddf7476083d1bf28da4061369b3ce0c90330171a1f06170b2bb3bf5fdc5 ./source-reconstruction/uboot_updater_reconstructed.c +8fc2ad8f6c496be68ee169e9fa5e2a81c6e66b4f728385eef1bf9adc7c30af6a ./source-reconstruction/verify_protocol_vectors.py +75a11da44c802486bc6f65640aa48a730f0f684c5c07a42ba3cd1735eb3fb070 ../tests/__init__.py +be6db7b7cc646ec833e6dd8b9d67a93ed12bd9208a1d16b4a0c076a40ba1e13c ../tests/test_commands.py +6d7039a3f3f2c42719bc9c5cfa55eae379f2aaca18396b3004ef1ed0652d3757 ../tests/test_protocol.py +7e59d0ca33a6a30ac33a9612ee41b1322f85289a656a6590f4bd8fc7dc341a35 ./tools/README.md +a3681c75d41aa6867b26ffb02a942d624496b4650330c11a9495b4b1800e0ad8 ./tools/mips32_disasm.py +7e91a358ad24b7bae08e052a181f59d10de731e23b42614014090ae7a3fc2972 ./tools/mips_pic_xrefs.py +e7a0e7cc13d35cbb0e71b91f34c03c6e3e3341fd579274515d6b7b71d2d57310 ./tools/squashfs_extract.py diff --git a/usb-maintenance/pyproject.toml b/usb-maintenance/pyproject.toml deleted file mode 100644 index 291c6a7..0000000 --- a/usb-maintenance/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "cc2flash" -version = "0.6.0" -description = "Safety-focused backup/restore client for the Elegoo CC2 stock camera" -requires-python = ">=3.10" -dependencies = [] - -[project.optional-dependencies] -usb = ["hidapi>=0.14"] - -[project.scripts] -cc2flash = "cc2flash.cli:main" - -[tool.setuptools] -packages = ["cc2flash"] diff --git a/usb-maintenance/source-reconstruction/protocol_reference.md b/usb-maintenance/source-reconstruction/protocol_reference.md index a5e2ce8..f5c61d2 100644 --- a/usb-maintenance/source-reconstruction/protocol_reference.md +++ b/usb-maintenance/source-reconstruction/protocol_reference.md @@ -740,7 +740,7 @@ full 256 KiB `boot` partition. The built-in known reference is: If the stable image has a different boot SHA-256, the command publishes no backup and prints that exact observed value. The user may independently review -it and rerun with `--accept-bootloader-hash `. A supplied hash +it and rerun with `--accept-bootloader-sha256 `. A supplied hash that differs from the newly observed partition is rejected. The accepted v2 manifest records whether the basis was `known-reference` or `explicit-hash`. The raw image and manifest are published as exactly two members of one ordinary From 3308e1251ffafa8b62416328897f2c4e53e8b2f6 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Sun, 6 Sep 2026 23:40:49 +0200 Subject: [PATCH 2/3] Address CLI and documentation reviews; publish bundles atomically and audit pinned workflows --- .github/workflows/release.yml | 8 +- .github/workflows/tests.yml | 22 +- README.md | 9 +- cc2flash/bundle.py | 46 ++ cc2flash/cli.py | 11 +- cc2flash/image.py | 598 +++++++++--------- docs/CLI.md | 31 +- docs/INSTALLATION.md | 16 +- .../CC2_RECOVERY_VERIFICATION.md | 11 +- hardware-recovery/CLI_REFERENCE.md | 59 -- hardware-recovery/README.md | 3 +- hardware-recovery/TECHNICAL_DETAILS.md | 2 +- hardware-recovery/TEST_RESULTS.md | 9 +- tests/test_cli.py | 10 +- tests/test_protocol.py | 6 +- tests/test_review_regressions.py | 90 +++ usb-maintenance/PROJECT-STATUS.md | 4 +- usb-maintenance/REFERENCE.md | 7 +- usb-maintenance/SHA256SUMS | 28 - 19 files changed, 523 insertions(+), 447 deletions(-) create mode 100644 cc2flash/bundle.py delete mode 100644 hardware-recovery/CLI_REFERENCE.md create mode 100644 tests/test_review_regressions.py delete mode 100644 usb-maintenance/SHA256SUMS diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc26802..c2dd34d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,11 +12,11 @@ jobs: runs-on: windows-2022 timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' architecture: x64 @@ -33,7 +33,7 @@ jobs: run: | $hash = (Get-FileHash dist/cc2flash.exe -Algorithm SHA256).Hash.ToLowerInvariant() "$hash cc2flash.exe" | Set-Content dist/cc2flash.exe.sha256 -Encoding ascii - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-executable path: | @@ -48,7 +48,7 @@ jobs: permissions: contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-executable path: dist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c7c0db..5432b48 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,15 +6,15 @@ jobs: test: strategy: matrix: - os: [ubuntu-latest, windows-2022] + os: [ubuntu-latest, windows-2022, macos-latest] python: ['3.10', '3.12'] runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} - run: python -m pip install . @@ -29,3 +29,19 @@ jobs: python -m PyInstaller --clean --noconfirm --onefile --console --name cc2flash --hidden-import hid packaging/entrypoint.py if ($LASTEXITCODE -ne 0) { throw 'Executable build failed' } python packaging/smoke_test.py dist/cc2flash.exe + + workflow-audit: + runs-on: ubuntu-latest + timeout-minutes: 5 + 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' + - run: python -m pip install zizmor==1.30.0 + - name: Audit all workflows + env: + GH_TOKEN: ${{ github.token }} + run: zizmor --strict-collection .github/workflows diff --git a/README.md b/README.md index 0154a23..c1c7d8d 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,13 @@ and `adb`, the supported sequence is: cc2flash start-adb cc2flash backup backup.zip cc2flash build-image backup.zip -cc2flash restore --dry-run backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip cc2flash restore backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip ``` Use the command prefix from your [platform's installation instructions](docs/INSTALLATION.md). If ADB is not on PATH, add `--adb "path/to/adb"` to connected-camera commands, -but not to offline `build-image` or `restore --dry-run`. +but not to offline `build-image`. `restore --dry-run` accepts these options +without using them. `start-adb` temporarily starts the camera's existing root ADB service. `backup` then reads the complete flash repeatedly and publishes `backup.zip` @@ -143,7 +143,7 @@ only after obtaining three consecutive identical images. Keep that ZIP unchanged and in a separate safe location. The builder validates the backup and creates the camera-specific preventive -image. `restore --dry-run` performs the same-camera and allowed-change checks +image. The optional `restore --dry-run` performs the same-camera and allowed-change checks without opening USB. `restore` repeats those checks, validates the live camera against the preserved backup, temporarily prepares the known stock SFC driver, and requires the literal confirmation `RESTORE-CC2` before its first write. @@ -156,7 +156,7 @@ generated image. Do not work around a validation failure. ## Failed camera: hardware recovery This route reads and rewrites the camera's eight-pin SPI flash with an external -programmer. The recommended beginner setup uses the NeoProgrammer graphical +programmer. The recommended setup uses the NeoProgrammer graphical application for every flash operation; only the recovery-image build requires a command line. @@ -276,7 +276,6 @@ No full camera dump or vendor firmware image is included in this repository. - [Installation and release downloads](docs/INSTALLATION.md) - [Complete cc2flash command reference](docs/CLI.md) -- [Hardware-recovery CLI reference](hardware-recovery/CLI_REFERENCE.md) - [Programmer and alternative-hardware reference](hardware-recovery/PROGRAMMER_REFERENCE.md) - [Independent recovery-tool verification](hardware-recovery/CC2_RECOVERY_VERIFICATION.md) - [Hardware-recovery regression results](hardware-recovery/TEST_RESULTS.md) diff --git a/cc2flash/bundle.py b/cc2flash/bundle.py new file mode 100644 index 0000000..a44e431 --- /dev/null +++ b/cc2flash/bundle.py @@ -0,0 +1,46 @@ +"""Publish a complete directory without replacing an existing destination.""" + +from contextlib import contextmanager +import ctypes +import errno +import os +from pathlib import Path +import sys +import tempfile + + +def rename_new(source: Path, destination: Path) -> None: + # POSIX rename alone can replace an existing empty directory. Use the + # platform's exclusive rename so a concurrent creator is also protected. + if sys.platform == "win32": + os.rename(source, destination) + return + libc = ctypes.CDLL(None, use_errno=True) + if sys.platform.startswith("linux") and hasattr(libc, "renameat2"): + rename = libc.renameat2 + rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, + ctypes.c_char_p, ctypes.c_uint] + args = (-100, os.fsencode(source), -100, os.fsencode(destination), 1) + elif sys.platform == "darwin" and hasattr(libc, "renamex_np"): + rename = libc.renamex_np + rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + args = (os.fsencode(source), os.fsencode(destination), 4) + else: + raise OSError(errno.ENOTSUP, "Atomic exclusive directory publication is unavailable") + rename.restype = ctypes.c_int + if rename(*args) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), str(destination)) + + +@contextmanager +def staged_directory(destination: Path): + """Yield a private staging directory, then publish it only on success.""" + if os.path.lexists(destination): + raise FileExistsError(errno.EEXIST, "Output already exists", str(destination)) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".cc2flash-build-", dir=destination.parent) as temporary: + staging = Path(temporary) / "bundle" + staging.mkdir() + yield staging + rename_new(staging, destination) diff --git a/cc2flash/cli.py b/cc2flash/cli.py index 5ce4266..0599687 100644 --- a/cc2flash/cli.py +++ b/cc2flash/cli.py @@ -241,9 +241,8 @@ def command_install_adb_startup(args) -> int: except AdbUnavailable as unavailable: print(f"ADB is unavailable: {unavailable}", file=sys.stderr) else: - print("ADB is already online; no persistent startup file was installed.") - print("You can now run cc2flash backup .") - return 0 + identity, _parts = adb.identity_and_partitions() + print(f"ADB is online. Installing startup for future boots. Identity: {identity}") print("Persistent ADB installation will modify the camera:", file=sys.stderr) print(f" overwrite {ADB_STARTUP_PATH}", file=sys.stderr) @@ -252,7 +251,7 @@ def command_install_adb_startup(args) -> int: f"{ADB_STARTUP_CONTENT.decode('ascii')}", file=sys.stderr, ) - print("No flash backup can be read until after a manual restart.", file=sys.stderr) + print("The startup hook takes effect on the next boot.", file=sys.stderr) if not args.yes: if not sys.stdin.isatty(): @@ -507,10 +506,6 @@ def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) command_parser = parser() args = command_parser.parse_args(argv) - if getattr(args, "dry_run", False): - hardware_options = {"--adb", "--serial", "--bootloader-timeout", "--reboot-timeout", "--adb-timeout"} - if any(token.split("=", 1)[0] in hardware_options for token in argv): - command_parser.error("--dry-run is offline and cannot be combined with hardware options") try: return int(args.func(args) or 0) except (ProtocolError, image_tools.ValidationError, OSError, EOFError) as exc: diff --git a/cc2flash/image.py b/cc2flash/image.py index 3ae8174..8745a2d 100644 --- a/cc2flash/image.py +++ b/cc2flash/image.py @@ -32,6 +32,7 @@ from pathlib import Path from typing import Any, Iterable from .display import invocation +from .bundle import staged_directory from . import __version__ as TOOL_VERSION MIN_IDENTICAL_READS = 3 @@ -1581,320 +1582,319 @@ def build_recovery( f"Refusing an output directory that contains input dump {protected}" ) - # mkdir is create-if-absent: never replace an existing bundle. - output_dir.mkdir(parents=True) + destination = output_dir + with staged_directory(destination) as output_dir: + recovery = bytearray(image) - recovery = bytearray(image) - - patched_region, patch_changed = apply_system_patch( - image, analysis["system_state"] - ) - recovery[PATCH_START:PATCH_END] = patched_region - - canonical_config = build_minimal_config(analysis["serial_payload"]) - if config_mode == "preserve-files": - live_config_files = analysis["live_config_files"] - assert live_config_files is not None - rebuilt_config = build_preserved_config(live_config_files) - config_changed = image[CONFIG_START:CONFIG_END] != rebuilt_config - recovery[CONFIG_START:CONFIG_END] = rebuilt_config - else: - rebuilt_config = canonical_config - config_changed = image[CONFIG_START:CONFIG_END] != rebuilt_config - recovery[CONFIG_START:CONFIG_END] = rebuilt_config - - recovery_bytes = bytes(recovery) + patched_region, patch_changed = apply_system_patch( + image, analysis["system_state"] + ) + recovery[PATCH_START:PATCH_END] = patched_region + + canonical_config = build_minimal_config(analysis["serial_payload"]) + if config_mode == "preserve-files": + live_config_files = analysis["live_config_files"] + assert live_config_files is not None + rebuilt_config = build_preserved_config(live_config_files) + config_changed = image[CONFIG_START:CONFIG_END] != rebuilt_config + recovery[CONFIG_START:CONFIG_END] = rebuilt_config + else: + rebuilt_config = canonical_config + config_changed = image[CONFIG_START:CONFIG_END] != rebuilt_config + recovery[CONFIG_START:CONFIG_END] = rebuilt_config - # Prove that no bytes outside the two allowed regions changed. - if ( - recovery_bytes[:PATCH_START] != image[:PATCH_START] - or recovery_bytes[PATCH_END:CONFIG_START] - != image[PATCH_END:CONFIG_START] - or recovery_bytes[CONFIG_END:] != image[CONFIG_END:] - ): - raise ValidationError("Internal safety check: bytes changed outside allowed regions") + recovery_bytes = bytes(recovery) - # Re-run the complete strict validator on the generated image. - output_analysis = analyze_image( - recovery_bytes, - source_name="generated recovery image", - allow_unknown_config=config_mode == "preserve-files", - preserve_config_data=config_mode == "preserve-files", - ) - if output_analysis["system_state"] != "known-bashrc-patched": - raise ValidationError("Generated image is not in the known patched system state") - if ( - config_mode == "serial-only" - and not output_analysis["config_is_canonical"] - ): - raise ValidationError("Generated config is not canonical") - if recovery_bytes[CONFIG_START:CONFIG_END] != rebuilt_config: - raise ValidationError( - "Generated config differs from the selected rebuilt partition" + # Prove that no bytes outside the two allowed regions changed. + if ( + recovery_bytes[:PATCH_START] != image[:PATCH_START] + or recovery_bytes[PATCH_END:CONFIG_START] + != image[PATCH_END:CONFIG_START] + or recovery_bytes[CONFIG_END:] != image[CONFIG_END:] + ): + raise ValidationError("Internal safety check: bytes changed outside allowed regions") + + # Re-run the complete strict validator on the generated image. + output_analysis = analyze_image( + recovery_bytes, + source_name="generated recovery image", + allow_unknown_config=config_mode == "preserve-files", + preserve_config_data=config_mode == "preserve-files", ) + if output_analysis["system_state"] != "known-bashrc-patched": + raise ValidationError("Generated image is not in the known patched system state") + if ( + config_mode == "serial-only" + and not output_analysis["config_is_canonical"] + ): + raise ValidationError("Generated config is not canonical") + if recovery_bytes[CONFIG_START:CONFIG_END] != rebuilt_config: + raise ValidationError( + "Generated config differs from the selected rebuilt partition" + ) - changed_regions: list[dict[str, Any]] = [] - if patch_changed: - changed_regions.append( - { - "name": "system_bashrc_patch", - "start": PATCH_START, - "end_inclusive": PATCH_END - 1, - "size": PATCH_SIZE, - } - ) - if config_changed: - changed_regions.append( - { - "name": "config", - "start": CONFIG_START, - "end_inclusive": CONFIG_END - 1, - "size": CONFIG_SIZE, - } - ) + changed_regions: list[dict[str, Any]] = [] + if patch_changed: + changed_regions.append( + { + "name": "system_bashrc_patch", + "start": PATCH_START, + "end_inclusive": PATCH_END - 1, + "size": PATCH_SIZE, + } + ) + if config_changed: + changed_regions.append( + { + "name": "config", + "start": CONFIG_START, + "end_inclusive": CONFIG_END - 1, + "size": CONFIG_SIZE, + } + ) - output_name = "cc2-camera-recovery.bin" - layout_name = "cc2-camera-layout.txt" - output_path = output_dir / output_name - output_path.write_bytes(recovery_bytes) - (output_dir / "config-restored.bin").write_bytes(rebuilt_config) - (output_dir / "serial.cfg").write_bytes(analysis["serial_payload"]) - - layout = """\ -00000000:00462fff immutable_before_patch -00463000:0046afff system_bashrc_patch -0046b000:007dffff immutable_after_patch -007e0000:007fffff config -""" - write_text(output_dir / layout_name, layout) - - tool_filename = "cc2flash" - preserved_files_manifest: list[dict[str, Any]] = [] - if config_mode == "preserve-files": - live_config_files = analysis["live_config_files"] - assert live_config_files is not None - preserved_files_manifest = [ - { - "name_hex": item.name.hex(), - "name_display": item.name.decode( - "ascii", errors="backslashreplace" - ), - "size": len(item.data), - "sha256": sha256(item.data), - "inode": item.inode, - "mode": item.mode, - "uid": item.uid, - "gid": item.gid, - "atime": item.atime, - "mtime": item.mtime, - "ctime": item.ctime, - "flags": item.flags, - "dirent_mctime": item.dirent_mctime, - "dirent_type": item.dtype, - } - for item in live_config_files - ] - manifest = { - "tool": { - "name": tool_filename, - "version": TOOL_VERSION, - }, - "input": { - "filename": input_path.name, - "source_format": input_source["format"], - "image_member": input_source["image_member"], - "size": len(image), - "sha256": sha256(image), - "acquisition_evidence": { - key: value - for key, value in input_source.items() - if key - not in { - "format", - "image_member", - "size", - "sha256", - "md5", - "evidenced_identical_reads", + output_name = "cc2-camera-recovery.bin" + layout_name = "cc2-camera-layout.txt" + output_path = output_dir / output_name + output_path.write_bytes(recovery_bytes) + (output_dir / "config-restored.bin").write_bytes(rebuilt_config) + (output_dir / "serial.cfg").write_bytes(analysis["serial_payload"]) + + layout = """\ + 00000000:00462fff immutable_before_patch + 00463000:0046afff system_bashrc_patch + 0046b000:007dffff immutable_after_patch + 007e0000:007fffff config + """ + write_text(output_dir / layout_name, layout) + + tool_filename = "cc2flash" + preserved_files_manifest: list[dict[str, Any]] = [] + if config_mode == "preserve-files": + live_config_files = analysis["live_config_files"] + assert live_config_files is not None + preserved_files_manifest = [ + { + "name_hex": item.name.hex(), + "name_display": item.name.decode( + "ascii", errors="backslashreplace" + ), + "size": len(item.data), + "sha256": sha256(item.data), + "inode": item.inode, + "mode": item.mode, + "uid": item.uid, + "gid": item.gid, + "atime": item.atime, + "mtime": item.mtime, + "ctime": item.ctime, + "flags": item.flags, + "dirent_mctime": item.dirent_mctime, + "dirent_type": item.dtype, } + for item in live_config_files + ] + manifest = { + "tool": { + "name": tool_filename, + "version": TOOL_VERSION, }, - "confirmed_reads": confirmed_reads, - "total_identical_reads": total_reads, - "fewer_reads_explicitly_allowed": ( - insufficient_reads and allow_fewer_reads - ), - }, - "validation": { - "invariant_sha256": analysis["invariant_sha256"], - "invariant_match": True, - "hwconfig_variant": analysis["hwconfig_variant"], - "exact_full_reference": analysis["exact_reference"], - "system_state_before": analysis["system_state"], - "system_state_after": output_analysis["system_state"], - "serial_source": analysis["serial_source"], - "serial_masked": mask_value(analysis["serial_value"]), - "serial_sha256": analysis["serial_sha256"], - "uoid_masked": mask_value(analysis["uoid"]), - "serial_uoid_prefix_match": True, - "config_usage_percent_before": round( - analysis["config_usage_percent"], 6 - ), - "config_obsolete_nodes_before": analysis[ - "config_obsolete_node_count" - ], - "config_unexpected_names_before": analysis[ - "config_unexpected_names" - ], - }, - "output": { - "filename": output_name, - "size": len(recovery_bytes), - "sha256": sha256(recovery_bytes), - "canonical_config_sha256": sha256(canonical_config), - "rebuilt_config_sha256": sha256(rebuilt_config), - "preserved_files": preserved_files_manifest, - }, - "changed_regions": changed_regions, - "config_mode": config_mode, - "wipe_unknown_config": wipe_unknown_config, - "reference_full_sha256": REFERENCE_FULL_SHA256, - } - write_text( - output_dir / "MANIFEST.json", - json.dumps(manifest, indent=2, sort_keys=True) + "\n", - ) + "input": { + "filename": input_path.name, + "source_format": input_source["format"], + "image_member": input_source["image_member"], + "size": len(image), + "sha256": sha256(image), + "acquisition_evidence": { + key: value + for key, value in input_source.items() + if key + not in { + "format", + "image_member", + "size", + "sha256", + "md5", + "evidenced_identical_reads", + } + }, + "confirmed_reads": confirmed_reads, + "total_identical_reads": total_reads, + "fewer_reads_explicitly_allowed": ( + insufficient_reads and allow_fewer_reads + ), + }, + "validation": { + "invariant_sha256": analysis["invariant_sha256"], + "invariant_match": True, + "hwconfig_variant": analysis["hwconfig_variant"], + "exact_full_reference": analysis["exact_reference"], + "system_state_before": analysis["system_state"], + "system_state_after": output_analysis["system_state"], + "serial_source": analysis["serial_source"], + "serial_masked": mask_value(analysis["serial_value"]), + "serial_sha256": analysis["serial_sha256"], + "uoid_masked": mask_value(analysis["uoid"]), + "serial_uoid_prefix_match": True, + "config_usage_percent_before": round( + analysis["config_usage_percent"], 6 + ), + "config_obsolete_nodes_before": analysis[ + "config_obsolete_node_count" + ], + "config_unexpected_names_before": analysis[ + "config_unexpected_names" + ], + }, + "output": { + "filename": output_name, + "size": len(recovery_bytes), + "sha256": sha256(recovery_bytes), + "canonical_config_sha256": sha256(canonical_config), + "rebuilt_config_sha256": sha256(rebuilt_config), + "preserved_files": preserved_files_manifest, + }, + "changed_regions": changed_regions, + "config_mode": config_mode, + "wipe_unknown_config": wipe_unknown_config, + "reference_full_sha256": REFERENCE_FULL_SHA256, + } + write_text( + output_dir / "MANIFEST.json", + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + ) - source_report = format_analysis(analysis, show_serial=show_serial) - source_report += ( - f"\nPhysical-read confirmation\n--------------------------\n" - f"Byte-identical reads supplied: {total_reads}\n" - ) - for item in confirmed_reads: - source_report += f"- {item['filename']} {item['sha256']}\n" - if insufficient_reads: + source_report = format_analysis(analysis, show_serial=show_serial) source_report += ( - "WARNING: fewer than three reads were supplied for a non-reference " - "image; the higher risk was explicitly accepted.\n" + f"\nPhysical-read confirmation\n--------------------------\n" + f"Byte-identical reads supplied: {total_reads}\n" ) - output_report = format_analysis(output_analysis, show_serial=show_serial) - validation_text = ( - source_report - + "\nGenerated image\n===============\n\n" - + f"Output SHA-256: {sha256(recovery_bytes)}\n" - + f"System patch changed: {'yes' if patch_changed else 'no'}\n" - + f"Config changed: {'yes' if config_changed else 'no'}\n" - + f"Config mode: " - + config_mode - + "\n" - + f"Unknown-name wipe: " - + ("yes" if wipe_unknown_config else "no") - + "\n" - + f"Changed regions: " - + ( - ", ".join(region["name"] for region in changed_regions) - if changed_regions - else "(none; input already equals generated image)" + for item in confirmed_reads: + source_report += f"- {item['filename']} {item['sha256']}\n" + if insufficient_reads: + source_report += ( + "WARNING: fewer than three reads were supplied for a non-reference " + "image; the higher risk was explicitly accepted.\n" + ) + output_report = format_analysis(output_analysis, show_serial=show_serial) + validation_text = ( + source_report + + "\nGenerated image\n===============\n\n" + + f"Output SHA-256: {sha256(recovery_bytes)}\n" + + f"System patch changed: {'yes' if patch_changed else 'no'}\n" + + f"Config changed: {'yes' if config_changed else 'no'}\n" + + f"Config mode: " + + config_mode + + "\n" + + f"Unknown-name wipe: " + + ("yes" if wipe_unknown_config else "no") + + "\n" + + f"Changed regions: " + + ( + ", ".join(region["name"] for region in changed_regions) + if changed_regions + else "(none; input already equals generated image)" + ) + + "\n\nPost-build validation\n---------------------\n" + + output_report ) - + "\n\nPost-build validation\n---------------------\n" - + output_report - ) - write_text(output_dir / "VALIDATION.txt", validation_text) + write_text(output_dir / "VALIDATION.txt", validation_text) - selected = " ".join( - f"-i {region['name']}" for region in changed_regions - ) - if changed_regions: - write_command = ( - "flashrom " - "-p buspirate_spi:dev=COM11,spispeed=1M " - f"-l {layout_name} {selected} -w {output_name}" + selected = " ".join( + f"-i {region['name']}" for region in changed_regions ) - write_section = f"""\ -WRITE COMMAND TEMPLATE ----------------------- -Edit COM11 and speed settings for your programmer, then run from this folder: - -{write_command} - -The command intentionally omits --progress because some flashrom versions -spam progress-accounting warnings. Flashrom still performs verification. - -The command intentionally does not enable programmer-supplied power. Verify -the flash chip's required voltage and your wiring first, then power it using -the hardware method you have independently tested while reading. -""" - else: - write_section = """\ -WRITE COMMAND -------------- -No write is necessary: the input already equals the generated recovery image. -""" - - read_warning = "" - if insufficient_reads: - read_warning = """\ -READ-CONFIDENCE WARNING ------------------------ -This non-reference image was built from fewer than three byte-identical -physical reads. Re-read the flash and rebuild before writing if at all possible. - -""" - - flashing = f"""\ -CC2 CAMERA RECOVERY — GENERATED INSTRUCTIONS -============================================ - -Input SHA-256: -{sha256(image)} - -Recovery SHA-256: -{sha256(recovery_bytes)} - -{read_warning} -{write_section} -FULL READBACK -------------- -Keep the same stable programmer connection and make a complete 8 MiB read: - -flashrom -p buspirate_spi:dev=COM11,spispeed=1M -r cc2-camera-readback.bin - -Then compare every byte: + if changed_regions: + write_command = ( + "flashrom " + "-p buspirate_spi:dev=COM11,spispeed=1M " + f"-l {layout_name} {selected} -w {output_name}" + ) + write_section = f"""\ + WRITE COMMAND TEMPLATE + ---------------------- + Edit COM11 and speed settings for your programmer, then run from this folder: -fc.exe /b {output_name} cc2-camera-readback.bin\n\nOn Linux/macOS use: cmp {output_name} cc2-camera-readback.bin + {write_command} -Never connect normal USB/device power and programmer-supplied target power at -the same time. Confirm the SPI voltage from the exact flash part marking or -datasheet before making any powered connection. + The command intentionally omits --progress because some flashrom versions + spam progress-accounting warnings. Flashrom still performs verification. -PRIVACY -------- -serial.cfg and the generated recovery image contain this camera's unique -identifier. Do not publish either file unredacted. -""" - write_text(output_dir / "FLASHING.txt", flashing) - - hashes = [] - for name in [ - output_name, - "config-restored.bin", - "serial.cfg", - layout_name, - "MANIFEST.json", - "VALIDATION.txt", - "FLASHING.txt", - ]: - content = (output_dir / name).read_bytes() - hashes.append(f"{sha256(content)} {name}") - write_text(output_dir / "SHA256SUMS.txt", "\n".join(hashes) + "\n") - - return { - "analysis": analysis, - "output_analysis": output_analysis, - "manifest": manifest, - "confirmed_reads": confirmed_reads, - "output_dir": output_dir, - "output_path": output_path, - } + The command intentionally does not enable programmer-supplied power. Verify + the flash chip's required voltage and your wiring first, then power it using + the hardware method you have independently tested while reading. + """ + else: + write_section = """\ + WRITE COMMAND + ------------- + No write is necessary: the input already equals the generated recovery image. + """ + + read_warning = "" + if insufficient_reads: + read_warning = """\ + READ-CONFIDENCE WARNING + ----------------------- + This non-reference image was built from fewer than three byte-identical + physical reads. Re-read the flash and rebuild before writing if at all possible. + + """ + + flashing = f"""\ + CC2 CAMERA RECOVERY — GENERATED INSTRUCTIONS + ============================================ + + Input SHA-256: + {sha256(image)} + + Recovery SHA-256: + {sha256(recovery_bytes)} + + {read_warning} + {write_section} + FULL READBACK + ------------- + Keep the same stable programmer connection and make a complete 8 MiB read: + + flashrom -p buspirate_spi:dev=COM11,spispeed=1M -r cc2-camera-readback.bin + + Then compare every byte: + + fc.exe /b {output_name} cc2-camera-readback.bin\n\nOn Linux/macOS use: cmp {output_name} cc2-camera-readback.bin + + Never connect normal USB/device power and programmer-supplied target power at + the same time. Confirm the SPI voltage from the exact flash part marking or + datasheet before making any powered connection. + + PRIVACY + ------- + serial.cfg and the generated recovery image contain this camera's unique + identifier. Do not publish either file unredacted. + """ + write_text(output_dir / "FLASHING.txt", flashing) + + hashes = [] + for name in [ + output_name, + "config-restored.bin", + "serial.cfg", + layout_name, + "MANIFEST.json", + "VALIDATION.txt", + "FLASHING.txt", + ]: + content = (output_dir / name).read_bytes() + hashes.append(f"{sha256(content)} {name}") + write_text(output_dir / "SHA256SUMS.txt", "\n".join(hashes) + "\n") + + return { + "analysis": analysis, + "output_analysis": output_analysis, + "manifest": manifest, + "confirmed_reads": confirmed_reads, + "output_dir": destination, + "output_path": destination / output_name, + } def cmd_analyze(args: argparse.Namespace) -> None: @@ -1956,7 +1956,7 @@ def cmd_build(args: argparse.Namespace) -> None: else: print("Regions to write: none") if manifest["input"]["source_format"] == USB_BACKUP_FORMAT: - print("Next, check the USB restore offline:") - print(invocation("restore", str(result["output_path"]), "--backup", str(input_path), "--dry-run")) + print("Next, restore the recovery image:") + print(invocation("restore", str(result["output_path"]), "--backup", str(input_path))) else: print("Next, program the generated full image with your external programmer and require full-chip verification.") diff --git a/docs/CLI.md b/docs/CLI.md index cf3d080..305c846 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -25,7 +25,7 @@ cc2flash backup OUTPUT.zip [--adb EXECUTABLE] [--serial SERIAL] - `install-adb-startup`: overwrite `/etc/conf.d/system.sh` with the known ADB startup content. Requires `ENABLE-ADB` confirmation or `--yes`. Restart manually afterward; successful installation exits with status 0. - If ADB is already online, this command does not install anything. + It also installs the hook when temporary ADB is already online. - `backup`: require three consecutive identical full reads within five attempts, then publish a new ZIP atomically. Never overwrites a backup. If ADB is offline, explicitly start it and rerun backup; backup itself is @@ -48,7 +48,9 @@ cc2flash build-image INPUT [--confirm-read DUMP]... [-o DIR | --output DIR] `INPUT` is a raw 8 MiB dump or an unmodified cc2flash backup ZIP. Inspection validates without creating files; building creates a recovery bundle in a new `-cc2-recovery/` directory unless `--output` is given. -Existing output directories are refused. Neither command accesses hardware. +The complete bundle is published atomically; failed builds leave no partial +bundle at the output path. Existing output directories are refused. Neither +command accesses hardware. For raw dumps, repeat `--confirm-read` for each independent matching read: @@ -73,11 +75,27 @@ be rebuilt from one copy. mask them. Raw dumps, ZIPs, serial.cfg, and recovery images always contain private unit data regardless of report masking. +The bundle contains: + +```text +cc2-camera-recovery.bin +cc2-camera-layout.txt +config-restored.bin +serial.cfg +MANIFEST.json +VALIDATION.txt +FLASHING.txt +SHA256SUMS.txt +``` + +Use the complete `cc2-camera-recovery.bin` for full-chip programming. +`FLASHING.txt` also contains an advanced flashrom region-write template based +on regions that actually changed. Always require full-chip verification. + ## USB restore ```text -cc2flash restore IMAGE --backup BACKUP.zip --dry-run -cc2flash restore IMAGE --backup BACKUP.zip +cc2flash restore IMAGE --backup BACKUP.zip [--dry-run] [--adb EXECUTABLE] [--serial SERIAL] [--bootloader-timeout SECONDS] [--reboot-timeout SECONDS] [--adb-timeout SECONDS] @@ -85,10 +103,11 @@ cc2flash restore IMAGE --backup BACKUP.zip `--dry-run` validates the candidate, preserved backup, allowed changes, known preparation kernel, and transfer representation entirely offline. It opens no -transport and writes nothing. Hardware-specific options are rejected with it. +transport and writes nothing. It accepts the same hardware options as a real +restore, but does not use or require them. It cannot certify the live camera or predict successful physical writing. -A real restore repeats local validation, requires typed `RESTORE-CC2` consent, +A real restore repeats local validation, requires the user to type `RESTORE-CC2`, validates the live camera, applies the temporary SFC correction, and writes the full image. Keep power connected until post-write verification completes. If needed after reboot, temporary ADB startup requires separate consent. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 0657e79..64b9686 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -1,15 +1,10 @@ # Install cc2flash -First [identify your camera](../README.md#first-identify-your-camera). -Installing this tool does not change the camera. - ## Windows: standalone executable 1. Open the [GitHub Releases page](https://github.com/phryneas/cc2_camera_fix/releases). -2. Choose a tested release and expand **Assets**. +2. Choose a release and expand **Assets**. 3. Download **cc2flash.exe** to a new folder, for example `Documents\CC2-camera`. - The executable is for 64-bit Intel/AMD Windows and includes Python and HID - support. You do not need Python, Git, pip, or a repository checkout. 4. In File Explorer, open that folder, click its address bar, type `cmd`, and press Enter. This opens Command Prompt in the correct folder. 5. Paste this command and press Enter: @@ -57,7 +52,7 @@ cc2flash.exe start-adb --adb "platform-tools\adb.exe" cc2flash.exe backup backup.zip --adb "platform-tools\adb.exe" ``` -Do not add ADB options to offline commands, including `restore --dry-run`. +`restore --dry-run` accepts these options but does not access ADB or USB. On Linux/macOS, download the matching Platform Tools archive and extract it; use `--adb "/path/to/platform-tools/adb"` if it is not on PATH. An offline ADB camera is expected before `start-adb`; an authorization, @@ -66,7 +61,7 @@ driver, or device-ambiguity error is not equivalent to that state. ## Python alternative: install directly from GitHub Use Python 3.10 or newer. No Git or manual repository download is required. -Choose a tested release tag from Releases. In the command below, replace +Choose a release tag from Releases. In the command below, replace `TAG` with that tag (including its leading `v`, if present). Windows Command Prompt, in your working folder: @@ -90,11 +85,6 @@ throughout the guide. No environment activation or PATH edit is needed. HID support is installed with the package. ADB remains separate. On Linux, your distribution may require its Python venv package and USB device permissions. Do not use `sudo pip` or bypass an externally-managed-Python error. -If installation tries to compile HID support, use a Python/platform combination -with a supported hidapi wheel rather than requiring beginners to install a compiler. - -These commands require access to the release source. Private repositories need -authentication; do not paste credentials into shared commands. ## Contributor and release maintenance diff --git a/hardware-recovery/CC2_RECOVERY_VERIFICATION.md b/hardware-recovery/CC2_RECOVERY_VERIFICATION.md index 85181cc..fc2dbf0 100644 --- a/hardware-recovery/CC2_RECOVERY_VERIFICATION.md +++ b/hardware-recovery/CC2_RECOVERY_VERIFICATION.md @@ -1,7 +1,7 @@ # CC2 camera recovery tool — independent verification Review date: 2026-08-22 -Scope: independent audit of the recovery-image algorithm; current entry point: `cc2flash build-image`. +Reviewed tool: `cc2_sig_tool.py` v1.1.0 ## Conclusion @@ -81,9 +81,10 @@ An independent read-only SquashFS v4/XZ parser extracted `bashrc.sh` from the st Additional safety changes: +- `--keep-config` refuses any exhausted or noncanonical partition; - non-reference devices require three matching reads by default; -- generated recovery images are strictly validated before programmer verification; -- existing output directories are refused without deleting their contents; +- readback verification first validates that the expected image is patched and canonical; +- output overwrite cannot recursively delete inputs, subdirectories, symlink targets, or unrelated files; - generated Bus Pirate examples use documented `dev` and `spispeed` parameters and do not silently enable target power. See the [official flashrom Bus Pirate documentation](https://flashrom.org/supported_hw/supported_prog/buspirate.html). ## Regression results @@ -102,9 +103,9 @@ All executed tests passed: ## Recommended hardware validation sequence 1. Make three full reads without moving the clip; require identical SHA-256 hashes. -2. Run `inspect-image`, then `build-image` with the other two reads supplied via `--confirm-read`. +2. Run `analyze`, then `build` with the other two reads supplied via `--confirm`. 3. Verify the exact flash part voltage and all SPI connections from the marking/datasheet. 4. Write only the generated layout regions. Do not power the target simultaneously from USB/device power and programmer target power. 5. Without disturbing the connection, make a full 8 MiB readback. -6. Compare every readback byte with the generated image using `cmp` or Windows `fc.exe /b`. +6. Run the tool's `verify` command and require byte-for-byte identity. 7. Disconnect the programmer, restore normal power, and observe boot and USB enumeration. diff --git a/hardware-recovery/CLI_REFERENCE.md b/hardware-recovery/CLI_REFERENCE.md deleted file mode 100644 index 5de77bf..0000000 --- a/hardware-recovery/CLI_REFERENCE.md +++ /dev/null @@ -1,59 +0,0 @@ -# Hardware-recovery CLI reference - -Install cc2flash using the [setup guide](../docs/INSTALLATION.md). -The [main guide](../README.md#failed-camera-hardware-recovery) describes -connection, three independent programmer reads, full-chip programming and -verification. All commands and options are listed in the -[unified CLI reference](../docs/CLI.md). - -## Build from programmer reads - -```sh -cc2flash inspect-image camera-1.bin --confirm-read camera-2.bin --confirm-read camera-3.bin -cc2flash build-image camera-1.bin --confirm-read camera-2.bin --confirm-read camera-3.bin -``` - -## Build from a USB backup - -```sh -cc2flash inspect-image backup.zip -cc2flash build-image backup.zip -cc2flash restore backup-cc2-recovery/cc2-camera-recovery.bin --backup backup.zip --dry-run -``` - -Keep the backup ZIP unchanged. It contains both flash.bin and acquisition -evidence and is required for a real USB restore. A programmer can write the -generated full image using the procedure in the main guide. - -## Config choices - -The default `--config-mode serial-only` rebuilds config containing only -serial.cfg. Missing defaults are created on the next boot. -`--config-mode preserve-files` rebuilds every supported live regular file -once, retaining contents and metadata but dropping obsolete records. -`--wipe-unknown-config` explicitly discards unfamiliar config names in -serial-only mode without weakening identity validation. - -The original image is never modified. By default the new bundle is written -beside it in `-cc2-recovery/`: - -```text -cc2-camera-recovery.bin -cc2-camera-layout.txt -config-restored.bin -serial.cfg -MANIFEST.json -VALIDATION.txt -FLASHING.txt -SHA256SUMS.txt -``` - -Use the complete cc2-camera-recovery.bin for full-chip programming, not -config-restored.bin or serial.cfg. FLASHING.txt also contains an advanced -flashrom region-write template based on regions that actually changed. -Regardless of write method, require full-chip verification against the -generated image before disconnecting the programmer and booting the camera. - -For a separate saved readback, use `fc.exe /b expected.bin readback.bin` -on Windows or `cmp expected.bin readback.bin` on Linux/macOS. -Never connect programmer target power and normal camera power simultaneously. diff --git a/hardware-recovery/README.md b/hardware-recovery/README.md index 1abae36..90fb6dc 100644 --- a/hardware-recovery/README.md +++ b/hardware-recovery/README.md @@ -7,7 +7,7 @@ NeoProgrammer workflow with one required command-line invocation. For details beyond that normal path: -- [CLI reference](CLI_REFERENCE.md) documents inspection, alternate build +- [CLI reference](../docs/CLI.md) documents inspection, alternate build modes, output files, and independent readback verification. - [Technical details](TECHNICAL_DETAILS.md) document the root cause, exact firmware transformation, supported image family, identity handling, safety @@ -17,3 +17,4 @@ For details beyond that normal path: - [Independent verification](CC2_RECOVERY_VERIFICATION.md) and [regression results](TEST_RESULTS.md) record the current evidence. + diff --git a/hardware-recovery/TECHNICAL_DETAILS.md b/hardware-recovery/TECHNICAL_DETAILS.md index 3d77cf6..d90c4f8 100644 --- a/hardware-recovery/TECHNICAL_DETAILS.md +++ b/hardware-recovery/TECHNICAL_DETAILS.md @@ -33,7 +33,7 @@ This is a useful isolation test, not proof of this exact flash failure. The buil For a failed camera, read and rewrite the eight-pin SPI flash with an external programmer. A working camera can use the USB prevention workflow in the main guide. Both use the same image builder. -The recommended beginner setup is: +The recommended setup is: - a CH341A/CH341B USB programmer that has been verified for 3.3 V operation; - an SOIC-8 test clip and cable, allowing the chip to be accessed on the camera board; diff --git a/hardware-recovery/TEST_RESULTS.md b/hardware-recovery/TEST_RESULTS.md index f63ff62..5a5fa4a 100644 --- a/hardware-recovery/TEST_RESULTS.md +++ b/hardware-recovery/TEST_RESULTS.md @@ -15,7 +15,7 @@ Review date: 2026-09-04 - The validated manifest contributes exactly three evidenced physical reads, satisfying the ordinary non-reference build gate without `--allow-fewer-reads`. -- The deterministic patch/XZ/JFFS2 checks and the focused +- The hardware-recovery self-test and all nine focused interoperability/config-mode tests pass on Python 3.12. - Oversized zero-filled fragments are rejected before allocation. Zlib fragments are rejected before decompression when their declared size exceeds @@ -30,8 +30,8 @@ reads, the known bootloader fingerprint, and the camera's six-partition map. - The unmodified ZIP passed strict archive, evidence, partition-map, firmware, identity, and JFFS2 validation without reduced-read overrides. -- Default serial-only correctly refused the live unfamiliar `system.sh` name. -- Serial-only with `--wipe-unknown-config` and preserve-files both built directly +- Default clean-data correctly refused the live unfamiliar `system.sh` name. +- Clean-data with `--wipe-unknown-config` and preserve-data both built directly from the ZIP without extraction. - Preserve-data retained the contents and relevant metadata of all seven live regular files: `dev_config.cfg`, `serial.cfg`, `system.sh`, `uvc.attr`, @@ -55,7 +55,7 @@ was not present in this interoperability run. Both inputs have the same exact invariant firmware fingerprint and stock SquashFS window, but different serials, UOIDs, two-byte HWCONFIG check values, and raw JFFS2 histories. Each output preserves its own complete HWCONFIG partition and exact recovered `serial.cfg` payload. The attached unit-B hardware readback was independently hashed and compared -byte-for-byte with the newly generated serial-only image in this review. +byte-for-byte with the newly generated clean-data image in this review. ## Independent filesystem and patch checks @@ -82,6 +82,7 @@ byte-for-byte with the newly generated serial-only image in this review. - One-bit kernel mutation: rejected. - One-bit system-patch-window mutation: rejected. - Serial from one unit combined with another unit's UOID: rejected. +- `--keep-config` on either exhausted bricked partition: rejected. - One-bit full-programmer readback mismatch: rejected with the affected region. - Rebuilding an already patched canonical image: idempotent; no write region reported. - Existing output directory containing an unknown entry: rejected instead of deleting it. diff --git a/tests/test_cli.py b/tests/test_cli.py index fce97fe..cabc68e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,15 +39,17 @@ def test_removed_options_are_rejected(self): with self.subTest(argv=argv), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): cli.main(argv) - def test_dry_run_rejects_hardware_options_even_explicit_defaults(self): + def test_dry_run_accepts_hardware_options_without_transport(self): for option, value in [ ("--adb", "adb"), ("--serial", "TEST"), ("--bootloader-timeout", "30"), ("--reboot-timeout", "180"), ("--adb-timeout", "60"), + ("--bootloader-t", "31"), ]: for suffix in ([option, value], [option + "=" + value]): - with self.subTest(suffix=suffix), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): - cli.main(["restore", "image", "--backup", "backup.zip", "--dry-run", *suffix]) + with self.subTest(suffix=suffix), mock.patch.object(cli, "command_plan", return_value=0) as plan, mock.patch.object(cli, "_adb", side_effect=AssertionError("hardware accessed")): + self.assertEqual(cli.main(["restore", "image", "--backup", "backup.zip", "--dry-run", *suffix]), 0) + plan.assert_called_once() def test_dry_run_validates_every_local_gate_without_transport(self): with tempfile.TemporaryDirectory() as directory, ExitStack() as stack: @@ -62,7 +64,7 @@ def test_dry_run_validates_every_local_gate_without_transport(self): guards = [stack.enter_context(mock.patch.object(cli, name, side_effect=AssertionError("hardware accessed"))) for name in ("_adb", "expected_devices", "prepare_restore", "enter_bootloader", "restore_blob", "_confirm", "start_adb_through_upload_command")] output = stack.enter_context(redirect_stdout(io.StringIO())) - self.assertEqual(cli.main(["restore", str(path), "--backup", "backup.zip", "--dry-run"]), 0) + self.assertEqual(cli.main(["restore", str(path), "--backup", "backup.zip", "--dry-run", "--adb", "unused-adb", "--serial", "TEST", "--bootloader-timeout", "31", "--reboot-timeout", "181", "--adb-timeout", "61"]), 0) self.assertEqual(events, ["validate_full_restore_image", "validate_replacement_against_backup", "validate_preparation_image"]) self.assertFalse(json.loads(output.getvalue())["writes_performed"]) for guard in guards: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 49e74b7..0c79234 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1119,16 +1119,18 @@ def test_explicit_install_requires_restart(self): install.assert_called_once_with() self.assertIn("restart", stdout.getvalue().casefold()) - def test_install_when_adb_online_does_not_write(self): + def test_install_when_adb_online_installs_for_future_boots(self): fake_adb = mock.Mock() + fake_adb.identity_and_partitions.return_value = ("root", []) with ( mock.patch.object(cli, "_adb", return_value=fake_adb), mock.patch.object(cli, "install_adb_startup") as install, redirect_stdout(io.StringIO()), + redirect_stderr(io.StringIO()), ): status = cli.main(["install-adb-startup", "--yes"]) self.assertEqual(status, 0) - install.assert_not_called() + install.assert_called_once_with() def test_known_bootloader_is_accepted_after_stable_acquisition(self): image = b"image" diff --git a/tests/test_review_regressions.py b/tests/test_review_regressions.py new file mode 100644 index 0000000..f6f70a3 --- /dev/null +++ b/tests/test_review_regressions.py @@ -0,0 +1,90 @@ +"""Review regressions; no camera access.""" +from contextlib import redirect_stdout, redirect_stderr +import io +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from cc2flash import cli, image +from cc2flash.adb_backup import AdbUnavailable +from cc2flash.bundle import staged_directory +from cc2flash.protocol import ProtocolError + + +class StartupTests(unittest.TestCase): + def test_install_with_online_or_offline_adb(self): + for online in (True, False): + with self.subTest(online=online), mock.patch.object(cli, "_adb") as adb, mock.patch.object(cli, "install_adb_startup") as install, redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + adb.return_value.identity_and_partitions.return_value = ("root", []) + if not online: + adb.return_value.ensure_available.side_effect = AdbUnavailable("offline") + self.assertEqual(cli.main(["install-adb-startup", "--yes"]), 0) + install.assert_called_once_with() + + def test_online_install_still_requires_consent_and_valid_device(self): + for invalid in (False, True): + with self.subTest(invalid=invalid), mock.patch.object(cli, "_adb") as adb, mock.patch.object(cli, "install_adb_startup") as install, mock.patch.object(cli.sys.stdin, "isatty", return_value=False), redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + adb.return_value.identity_and_partitions.return_value = ("root", []) + if invalid: + adb.return_value.identity_and_partitions.side_effect = ProtocolError("unsupported layout") + self.assertEqual(cli.main(["install-adb-startup"] + (["--yes"] if invalid else [])), 2) + install.assert_not_called() + + +class BundleTests(unittest.TestCase): + def test_builder_failure_does_not_publish_partial_bundle(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = {"image_member": None, "evidenced_identical_reads": 3} + analysis = {"exact_reference": None, "system_state": "stock-unpatched"} + with mock.patch.object(image, "read_image_source", return_value=(b"image", source)), mock.patch.object(image, "analyze_image", return_value=analysis), mock.patch.object(image, "apply_system_patch", side_effect=OSError("build failed")), self.assertRaises(OSError): + image.build_recovery(root / "input.bin", root / "recovery", confirmation_paths=[], config_mode="serial-only", wipe_unknown_config=False, show_serial=False, allow_fewer_reads=False) + self.assertEqual(list(root.iterdir()), []) + + def test_failure_cleans_staging_and_retry_publishes_complete_bundle(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "recovery" + with self.assertRaises(OSError): + with staged_directory(target) as staging: + (staging / "image.bin").write_bytes(b"partial") + self.assertFalse(target.exists()) + raise OSError("disk full") + self.assertEqual(list(root.iterdir()), []) + with staged_directory(target) as staging: + (staging / "image.bin").write_bytes(b"complete") + (staging / "manifest.txt").write_text("validated") + self.assertFalse(target.exists()) + self.assertEqual(sorted(p.name for p in target.iterdir()), ["image.bin", "manifest.txt"]) + self.assertEqual(list(root.iterdir()), [target]) + + def test_existing_and_concurrently_created_destinations_are_preserved(self): + for concurrent in (False, True): + for populated in (False, True): + with self.subTest(concurrent=concurrent, populated=populated), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "recovery" + def create_destination(): + target.mkdir() + if populated: + (target / "keep.txt").write_text("original") + if not concurrent: + create_destination() + with self.assertRaises(OSError): + with staged_directory(target) as staging: + (staging / "new.txt").write_text("new") + if concurrent: + create_destination() + self.assertFalse((target / "new.txt").exists()) + if populated: + self.assertEqual((target / "keep.txt").read_text(), "original") + self.assertEqual(list(root.iterdir()), [target]) + + def test_publication_error_cleans_staging(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "recovery" + with mock.patch("cc2flash.bundle.rename_new", side_effect=OSError("rename failed")), self.assertRaises(OSError): + with staged_directory(target) as staging: + (staging / "image.bin").write_bytes(b"complete") + self.assertEqual(list(Path(directory).iterdir()), []) diff --git a/usb-maintenance/PROJECT-STATUS.md b/usb-maintenance/PROJECT-STATUS.md index 5e8d85e..cc2511e 100644 --- a/usb-maintenance/PROJECT-STATUS.md +++ b/usb-maintenance/PROJECT-STATUS.md @@ -53,8 +53,8 @@ - Made post-write verification compatible with both hardware-recovery config modes: after a serial-only boot it separately prompts for permission to start ADB temporarily, requires an exact match through HWCONFIG, and separately - reports config changes made by that boot. It never treats restore `--yes` as - consent to start ADB. + reports config changes made by that boot. Starting temporary ADB requires + separate consent. ## Client status diff --git a/usb-maintenance/REFERENCE.md b/usb-maintenance/REFERENCE.md index 1361102..e8ef62d 100644 --- a/usb-maintenance/REFERENCE.md +++ b/usb-maintenance/REFERENCE.md @@ -50,7 +50,7 @@ updater. Read `source-reconstruction/README.md` before relying on it. ## Install -Follow the [beginner installation guide](../docs/INSTALLATION.md). +Follow the [installation guide](../docs/INSTALLATION.md). The Windows executable includes Python and HID support; ADB is installed separately. The complete command syntax is in the [CLI reference](../docs/CLI.md). @@ -183,8 +183,9 @@ download or preserve the previous file. A missing local `adb` executable, multiple/unauthorized ADB devices, a non-root shell, or a wrong MTD map does not trigger either ADB-start command. -Both ADB-start commands are no-ops when the selected ADB device is already -online. `backup` never invokes either command automatically. +`start-adb` is a no-op when the selected ADB device is already online. +`install-adb-startup` still installs the persistent hook for future boots. +`backup` never invokes either command automatically. `backup` requires the exact recovered partition sequence and sizes: diff --git a/usb-maintenance/SHA256SUMS b/usb-maintenance/SHA256SUMS deleted file mode 100644 index 3a8f2ca..0000000 --- a/usb-maintenance/SHA256SUMS +++ /dev/null @@ -1,28 +0,0 @@ -f616e344edd9e5a64ef9a2b61073a319c33c820e7c32e9ed8f5ca3b90d375afd ./CHANGELOG.md -867bc8a899dd109e483fde30bdf7b183180935dc1ff90be4908a94e940e1739c ./EVIDENCE.md -f53eff272eec43e178ab61fbd074a4be6578228202d2553dcd1d0d012cf9feaa ./PROJECT-STATUS.md -27953fb883dcbb2748c0d35dc152d8b6e12b032cdbdd358b3d6698ab944226aa ./PROTOCOL.md -022f1561b205c883c0ae2eac4caba74aa05d082d93750d01968bc1579ad6c9e4 ./README.md -fbaad85a19d7d4b767ab0507c20f8083f34e21b0ac2629b89f02a308be749e97 ../cc2flash/__init__.py -4d7e67f4bb67e4eaf444a2e6b860a254d7d6fee2fff9393db126ead45f2f9888 ../cc2flash/__main__.py -9bd90eb2a7c78b55f42b5ff317a172cf4851adbacc5c8ba44335642b691a66f8 ../cc2flash/adb_backup.py -a462816cd69cce9b22677f27ee7ab3bfe452c55e7973d745aaa133ab2101119b ../cc2flash/cli.py -f74bb50ae67442294bb0483d38539648b572f924a3aa70e78ea58de2562f95ee ../cc2flash/commands.py -89e81b2ee9273a3ecba06337450e9c06e48fb657f7f2b5a65f4dc2f5c50d1f1f ../cc2flash/hid_transport.py -6a86bcc05deef339bca65f3cf21adb4613a4eae4afa26c8408ac0eb66639a000 ../cc2flash/protocol.py -26a9ce1db17c81ca875c02552f50cb915df0ed50d329b8e775d24038ac3572e5 ../pyproject.toml -02c07ed4a581505003f6f5e8e52fdc5d0289b1a7ec2dc66fc01c93b4ed8c07b1 ./source-reconstruction/README.md -9acc77b2ce92c1ce4ca7aa1955e3eb8aa408d507a97f280ed2f9ea3bcf2e847b ./source-reconstruction/REPRODUCING.md -cfa527bce319f29b56ece0cc28b2d5127f2334189eb3ebd2ab5df18ae6181080 ./source-reconstruction/TRACEABILITY.md -bb478a521a3f6e59bcd1d4651071bb1b53c97bb9a9523ffcdc4461156b01f337 ./source-reconstruction/hid_update_reconstructed.c -47e9f8abf466e731248a42c792ef2c250edf5816ef77c58ec4b9a188e930b267 ./source-reconstruction/protocol_reference.md -7df1112a6c5dd9121b10918885e1270b91c3dd933a630125e81f081a57cef7d0 ./source-reconstruction/spl_upgrade_gate_reconstructed.c -e1a3cddf7476083d1bf28da4061369b3ce0c90330171a1f06170b2bb3bf5fdc5 ./source-reconstruction/uboot_updater_reconstructed.c -8fc2ad8f6c496be68ee169e9fa5e2a81c6e66b4f728385eef1bf9adc7c30af6a ./source-reconstruction/verify_protocol_vectors.py -75a11da44c802486bc6f65640aa48a730f0f684c5c07a42ba3cd1735eb3fb070 ../tests/__init__.py -be6db7b7cc646ec833e6dd8b9d67a93ed12bd9208a1d16b4a0c076a40ba1e13c ../tests/test_commands.py -6d7039a3f3f2c42719bc9c5cfa55eae379f2aaca18396b3004ef1ed0652d3757 ../tests/test_protocol.py -7e59d0ca33a6a30ac33a9612ee41b1322f85289a656a6590f4bd8fc7dc341a35 ./tools/README.md -a3681c75d41aa6867b26ffb02a942d624496b4650330c11a9495b4b1800e0ad8 ./tools/mips32_disasm.py -7e91a358ad24b7bae08e052a181f59d10de731e23b42614014090ae7a3fc2972 ./tools/mips_pic_xrefs.py -e7a0e7cc13d35cbb0e71b91f34c03c6e3e3341fd579274515d6b7b71d2d57310 ./tools/squashfs_extract.py From 682cf4a48d5b26ba309df055e43fe618bc81a2b8 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Sun, 6 Sep 2026 23:59:53 +0200 Subject: [PATCH 3/3] Trim redundant CLI reference explanations --- docs/CLI.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 305c846..41ad10a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -25,7 +25,6 @@ cc2flash backup OUTPUT.zip [--adb EXECUTABLE] [--serial SERIAL] - `install-adb-startup`: overwrite `/etc/conf.d/system.sh` with the known ADB startup content. Requires `ENABLE-ADB` confirmation or `--yes`. Restart manually afterward; successful installation exits with status 0. - It also installs the hook when temporary ADB is already online. - `backup`: require three consecutive identical full reads within five attempts, then publish a new ZIP atomically. Never overwrites a backup. If ADB is offline, explicitly start it and rerun backup; backup itself is @@ -103,8 +102,7 @@ cc2flash restore IMAGE --backup BACKUP.zip [--dry-run] `--dry-run` validates the candidate, preserved backup, allowed changes, known preparation kernel, and transfer representation entirely offline. It opens no -transport and writes nothing. It accepts the same hardware options as a real -restore, but does not use or require them. +transport and writes nothing. It cannot certify the live camera or predict successful physical writing. A real restore repeats local validation, requires the user to type `RESTORE-CC2`,