#!/usr/bin/env python3 """Local ROM checks and explicit V1/V2 discovery, identity and firmware operations.""" from __future__ import annotations import argparse from contextlib import contextmanager import os import shutil import tempfile import hashlib import importlib.util import ipaddress import json import math from pathlib import Path import re import sys from typing import Any VENDOR = Path(__file__).resolve().parent / "vendor" FIRMWARE_FIELDS = ("inner.version", "outer.version") V1_NATIVE_FIELDS = ("firmware", "dev_ver") def load_vendor(filename: str, module_name: str): """Load only the bundled, hash-pinned helper without modifying sys.path.""" path = VENDOR / filename manifest = json.loads((VENDOR / "source-manifest.json").read_text(encoding="utf-8")) entry = next(item for item in manifest["files"] if item["file"] == filename) if hashlib.sha256(path.read_bytes()).hexdigest() != entry["vendored_sha256"]: raise RuntimeError("Bundled protocol helper checksum mismatch: " + filename) if module_name in sys.modules: return sys.modules[module_name] spec = importlib.util.spec_from_file_location(module_name, path) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module try: spec.loader.exec_module(module) except BaseException: sys.modules.pop(module_name, None) raise return module protocol = load_vendor("device_upgrade.py", "artemis_device_vendor_upgrade") _v1_spec = importlib.util.spec_from_file_location("artemis_device_v1", Path(__file__).with_name("v1_protocol.py")) v1 = importlib.util.module_from_spec(_v1_spec) _v1_spec.loader.exec_module(v1) class UnsupportedProtocolError(protocol.UpgradeError): """A selected protocol has no implementation; no device I/O is allowed.""" def require_supported_protocol(name: str): if name not in ("v1", "v2"): raise UnsupportedProtocolError("Unknown device protocol") def capabilities() -> dict: return { "status": "CAPABILITIES", "network_used": False, "write_executed": False, "firmware_verified": False, "default_protocol": "v2", "protocols": { "v1": {"implemented": True, "operations": ["discover", "identify", "info", "upgrade", "verify-version"], "upgrade_preflight": True, "upgrade_transfer": True, "upgrade_default": "online_preflight_only", "upgrade_transport": "tcp_4941_2170_md5_ascii32_plus_rom", "version_fields": list(V1_NATIVE_FIELDS), "documentation": "references/protocol-support.md"}, "v2": {"implemented": True, "operations": ["discover", "identify", "info", "upgrade", "verify-version"], "upgrade_default": "online_preflight_only", "documentation": "references/device-adapter.md"}, }, } class EventCollector: """Preserve protocol evidence inside one JSON result without changing streams.""" def __init__(self): self.events: list[dict[str, Any]] = [] def emit(self, event: str, **fields: Any): if event == "protocol_success": # Upstream reports only a remote status; the adapter still checks our upload. event = "device_reported_status_2" self.events.append({"event": event, **fields}) def nonempty(value: str) -> str: if not value.strip(): raise argparse.ArgumentTypeError("value must not be empty or whitespace") return value def positive_seconds(value: str) -> float: number = float(value) if not math.isfinite(number) or not 0 < number <= 3600: raise argparse.ArgumentTypeError("timeout must be finite and in (0, 3600]") return number def add_identity(parser): parser.add_argument("--expected-sn", required=True, type=nonempty, help="Full serial number; exact equality, no suffix matching") parser.add_argument("--expected-model", required=True, type=nonempty, help="Exact dev_type for V1, or inner/outer/hardware.type for V2") def add_target(parser): parser.add_argument("--target-version", required=True, type=nonempty) parser.add_argument("--version-field", required=True, choices=FIRMWARE_FIELDS + V1_NATIVE_FIELDS, help="Explicit firmware field; hardware.version is not firmware") def add_protocol(parser): parser.add_argument("--protocol", choices=("v1", "v2"), default="v2", help="Device wire protocol; default v2. No automatic fallback.") def build_parser(): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("capabilities", help="List implemented protocols without network access") discover = commands.add_parser("discover", help="Read-only broadcast discovery; forwards native arguments", add_help=False) add_protocol(discover) discover.add_argument("arguments", nargs=argparse.REMAINDER) offline = commands.add_parser("offline", help="Only inspect/hash a local ROM; no network") offline.add_argument("--rom", required=True, type=Path) offline.add_argument("--rom-sha256", help="Optional expected SHA-256") identify = commands.add_parser("identify", help="Read-only query with full SN to observe the reported model") add_protocol(identify) identify.add_argument("--host", required=True, type=nonempty) identify.add_argument("--timeout", type=positive_seconds, default=10.0) identify.add_argument("--expected-sn", required=True, type=nonempty) info = commands.add_parser("info", help="Read-only identity query; no ROM needed") add_protocol(info) info.add_argument("--host", required=True, type=nonempty) info.add_argument("--timeout", type=positive_seconds, default=10.0) add_identity(info) upgrade = commands.add_parser("upgrade", help="Online preflight by default; write only with --execute") add_protocol(upgrade) upgrade.add_argument("--host", required=True, type=nonempty, help="Explicit numeric unicast IPv4/IPv6; hostnames are rejected even for preflight") add_identity(upgrade) add_target(upgrade) upgrade.add_argument("--rom", required=True, type=Path) upgrade.add_argument("--rom-sha256", help="Expected digest; mandatory with --execute") upgrade.add_argument("--execute", action="store_true") upgrade.add_argument("--timeout", type=positive_seconds, default=10.0) upgrade.add_argument("--stall-timeout", type=positive_seconds, default=60.0) upgrade.add_argument("--total-timeout", type=positive_seconds, default=600.0) verify = commands.add_parser("verify-version", help="Compare an explicit firmware version against recorded or live info") add_protocol(verify) source = verify.add_mutually_exclusive_group(required=True) source.add_argument("--response", type=Path, help="Protocol response JSON or info result JSON; V1 requires its explicit envelope") source.add_argument("--host", type=nonempty, help="Query selected protocol once; read-only") verify.add_argument("--timeout", type=positive_seconds, default=10.0) add_identity(verify) add_target(verify) return parser def inspect_rom(path: Path, expected_digest: str | None) -> dict: if expected_digest is not None and re.fullmatch(r"[0-9a-fA-F]{64}", expected_digest) is None: raise protocol.UpgradeError("--rom-sha256 must contain exactly 64 hexadecimal characters") rom = protocol.validate_rom(path) if expected_digest is not None and rom["sha256"] != expected_digest.lower(): raise protocol.UpgradeError("ROM SHA-256 mismatch") rom["expected_sha256"] = expected_digest.lower() if expected_digest else None rom["expected_digest_verified"] = expected_digest is not None return rom def numeric_upgrade_target(host: str) -> str: """Bind both protocol connections to one numeric address without DNS selection.""" try: address = ipaddress.ip_address(host) except ValueError as exc: raise protocol.UpgradeError("upgrade --host requires an explicit numeric IPv4/IPv6 address") from exc effective = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None effective = effective if effective is not None else address if effective.is_multicast or effective.is_unspecified or str(effective) == "255.255.255.255": raise protocol.UpgradeError("upgrade --host must be unicast; multicast, unspecified and limited-broadcast addresses are rejected") return str(address) def validate_response(response: Any, expected_sn: str, expected_model: str | None, protocol_name="v2"): if protocol_name == "v1": return v1.validate_identity(response, expected_sn, expected_model) if not isinstance(response, dict): raise protocol.UpgradeError("30101 response must be a JSON object") if response.get("cmd") != 30101 or response.get("code") != 0: raise protocol.UpgradeError("Expected a successful cmd=30101, code=0 response") return protocol.validate_identity(response, expected_sn, expected_model) def read_response(path: Path) -> dict: if path.stat().st_size > 2 * protocol.MAX_JSON_SIZE: raise protocol.UpgradeError("Recorded response exceeds 2 MiB") response = json.loads(path.read_text(encoding="utf-8-sig")) if isinstance(response, dict) and "response" in response: response = response["response"] return response def verify_version(response: dict, expected_sn: str, expected_model: str, target_version: str, version_field: str, protocol_name="v2") -> dict: allowed = FIRMWARE_FIELDS + V1_NATIVE_FIELDS if protocol_name == "v1" else FIRMWARE_FIELDS if version_field not in allowed: raise protocol.UpgradeError("Selected firmware field is not supported by this protocol") data, models = validate_response(response, expected_sn, expected_model, protocol_name) if "." in version_field: section, field = version_field.split(".") values = data.get(section) actual = values.get(field) if isinstance(values, dict) else None else: actual = data.get(version_field) available = isinstance(actual, str) and bool(actual.strip()) matched = available and actual == target_version return { "status": "PASS" if matched else ("FAIL" if available else "INCOMPLETE"), "firmware_verified": bool(matched), "acceptance_scope": "identity_and_selected_firmware_version_only", "sn": data["sn"], "models": models, "version_field": version_field, "target_version": target_version, "actual_version": actual, "reason": "exact_match" if matched else ("version_mismatch" if available else "firmware_version_missing"), "response": response, } @contextmanager def protected_rom_snapshot(source: Path, expected_sha256: str, expected_size: int): """Hold a Windows read-only, no-write/no-delete share lock through transfer.""" if os.name != "nt": raise protocol.UpgradeError("Protected firmware snapshots require Windows") import ctypes from ctypes import wintypes kernel = ctypes.WinDLL("kernel32", use_last_error=True) kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] kernel.CreateFileW.restype = wintypes.HANDLE kernel.CloseHandle.argtypes = [wintypes.HANDLE] kernel.CloseHandle.restype = wintypes.BOOL with tempfile.TemporaryDirectory(prefix="artemis-rom-") as folder: snapshot = Path(folder) / "firmware.rom" with source.open("rb") as src, snapshot.open("xb") as dst: shutil.copyfileobj(src, dst, length=1024 * 1024) # FILE_SHARE_READ only: conflicting existing writers prevent this open; # future writers, renames and deletion remain blocked until CloseHandle. handle = kernel.CreateFileW(str(snapshot), 0x80000000, 1, None, 3, 0x80, None) if handle == ctypes.c_void_p(-1).value: raise protocol.UpgradeError("Unable to protect firmware snapshot") try: info = inspect_rom(snapshot, expected_sha256) if info["size"] != expected_size: raise protocol.UpgradeError("Firmware snapshot size differs from preflight") yield snapshot finally: kernel.CloseHandle(handle) def run(args, logger: EventCollector) -> dict: if args.command == "capabilities": return capabilities() if args.command == "offline": return {"status": "OFFLINE_READY", "mode": "offline", "firmware_verified": False, "rom": inspect_rom(args.rom, args.rom_sha256), "network_used": False} require_supported_protocol(args.protocol) if args.command in ("upgrade", "verify-version") and args.protocol == "v2" and args.version_field not in FIRMWARE_FIELDS: raise protocol.UpgradeError("V2 requires inner.version or outer.version") driver = v1 if args.protocol == "v1" else protocol if args.command == "verify-version": live = args.host is not None response = (driver.query_device_info(args.host, args.timeout, logger) if live else read_response(args.response)) result = verify_version(response, args.expected_sn, args.expected_model, args.target_version, args.version_field, args.protocol) result.update(mode="read_only" if live else "offline", live_readback=live, evidence_source=("live_v1_2104" if args.protocol == "v1" else "live_30101") if live else "recorded_response", network_used=live) return result rom = None host = args.host if args.command == "upgrade": host = numeric_upgrade_target(args.host) logger.emit("upgrade_target_bound", bound_ip=host) if args.execute and not args.rom_sha256: raise protocol.UpgradeError("--execute requires an explicitly reviewed --rom-sha256") rom = inspect_rom(args.rom, args.rom_sha256) response = driver.query_device_info(host, args.timeout, logger) expected_model = getattr(args, "expected_model", None) data, models = validate_response(response, args.expected_sn, expected_model, args.protocol) result = {"status": "IDENTITY_CONFIRMED", "mode": "read_only", "firmware_verified": False, "sn": data["sn"], "models": models, "response": response, "network_used": True, "write_executed": False} if args.protocol == "v1": result["native_versions"] = {key: data[key] for key in ("firmware", "dev_ver", "hardware") if key in data} if args.command == "identify": result.update(status="IDENTITY_OBSERVED", sn_verified=True, model_verified=False) return result if args.command == "info": return result result.update(status="PREFLIGHT_ONLY", rom=rom, target_version=args.target_version, version_field=args.version_field, write_executed=False, postcheck_required=True, bound_ip=host, rom_model_verification="Caller must verify ROM compatibility; protocol cannot prove it") if args.protocol == "v1": observation = verify_version(response, args.expected_sn, args.expected_model, args.target_version, args.version_field, "v1") result.update(preflight_complete=True, write_supported=True, selected_version_field_available=observation["reason"] != "firmware_version_missing", current_selected_version=observation["actual_version"]) if not args.execute: return result # Hash and transfer the same snapshot while Windows denies writes and replacement. with protected_rom_snapshot(Path(rom["path"]), args.rom_sha256, rom["size"]) as snapshot: logger.emit("write_attempt", rom_sha256=rom["sha256"], bound_ip=host, target_version=args.target_version, version_field=args.version_field) transfer_options = {"expected_sha256": args.rom_sha256} if args.protocol == "v1" else {} transfer = driver.transfer_rom( host=host, rom_path=snapshot, rom_size=rom["size"], connect_timeout=args.timeout, stall_timeout=args.stall_timeout, total_timeout=args.total_timeout, chunk_size=256 * 1024, logger=logger, **transfer_options, ) logger.emit("transfer_result", **transfer) if args.protocol == "v1": if (transfer.get("acknowledged") is not True or transfer.get("response_command") != v1.UPGRADE_COMMAND or type(transfer.get("response_code")) is not int or transfer["response_code"] != 0): raise protocol.UpgradeError("Device did not acknowledge the V1 upgrade command with integer code=0") elif transfer.get("status") != 2: raise protocol.UpgradeError("Device did not report protocol status=2") bytes_sent = transfer.get("bytes_sent") if type(bytes_sent) is not int or bytes_sent != rom["size"]: raise protocol.UpgradeError( "Device reported success before a complete ROM upload: expected %d bytes, sent %r" % (rom["size"], bytes_sent) ) result.update(status="PROTOCOL_SUCCESS_POSTCHECK_REQUIRED", mode="write", write_executed=True, protocol_success=True, transfer=transfer) return result def discover_v1_cli(argv, logger): parser = argparse.ArgumentParser(description="V1 UDP ff00/ff01 read-only discovery; never upgrades a device") parser.add_argument("--interface", action="append", default=[], metavar="IPv4/PREFIX") parser.add_argument("--include-virtual", action="store_true") parser.add_argument("--expected-sn", type=nonempty) parser.add_argument("--expected-model", type=nonempty, help="Exact dev_type") parser.add_argument("--timeout", type=positive_seconds, default=3.0) parser.add_argument("--client-port", type=int, default=v1.DISCOVERY_PORT, help="Local receive port; default same broadcast channel 4942; V1 source-port rule is unspecified") args = parser.parse_args(argv) if args.timeout > 300 or not 1 <= args.client_port <= 65535: raise protocol.UpgradeError("V1 discovery requires timeout <= 300s and client-port in 1..65535") # Reuse only the reviewed Windows interface enumeration, never V2 packets or ports. helper = load_vendor("v2_discover.py", "artemis_device_vendor_discover") try: interfaces = helper.explicit_interfaces(args.interface) if args.interface else helper.powershell_interfaces() except helper.DiscoveryError as exc: raise protocol.UpgradeError(str(exc)) from exc selected = [] for interface in interfaces: reason = helper.exclusion_reason(interface, args.include_virtual) logger.emit("v1_interface", alias=interface.alias, address=str(interface.address), prefix_length=interface.prefix_length, selected=reason is None, exclusion_reason=reason) if reason is None: selected.append(interface) if not selected: raise protocol.UpgradeError("No eligible V1 broadcast interface") return v1.discover(selected, args.timeout, args.client_port, logger, args.expected_sn, args.expected_model) def main(argv: list[str] | None = None) -> int: # Only CLI execution configures streams; importing the adapter is side-effect free. for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8", errors="backslashreplace") argv = list(sys.argv[1:] if argv is None else argv) logger = EventCollector() selected_protocol = None try: if argv and argv[0] == "discover": router = argparse.ArgumentParser(add_help=False, allow_abbrev=False) add_protocol(router) routing, native_arguments = router.parse_known_args(argv[1:]) selected_protocol = routing.protocol require_supported_protocol(selected_protocol) if selected_protocol == "v2": discovery = load_vendor("v2_discover.py", "artemis_device_vendor_discover") return discovery.main(native_arguments) result = discover_v1_cli(native_arguments, logger) else: args = build_parser().parse_args(argv) selected_protocol = getattr(args, "protocol", None) result = run(args, logger) code = 0 if result["status"] not in ("FAIL", "INCOMPLETE", "BLOCKED") else 1 except (OSError, ValueError, protocol.UpgradeError) as exc: write_attempted = any(event["event"] == "write_attempt" for event in logger.events) bound_ip = next((event["bound_ip"] for event in logger.events if event["event"] == "upgrade_target_bound"), None) result = {"status": "ERROR", "firmware_verified": False, "error_type": type(exc).__name__, "message": str(exc), "automatic_retry": False, "write_may_have_started": write_attempted, "postcheck_required": write_attempted, "bound_ip": bound_ip} if isinstance(exc, UnsupportedProtocolError): result.update(status="BLOCKED", error_code=getattr(exc, "error_code", "protocol_not_implemented"), network_used=False, write_executed=False) code = 1 if selected_protocol is not None: result["protocol"] = selected_protocol result["events"] = logger.events print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) return code if __name__ == "__main__": raise SystemExit(main())