Files
Artemis-Plugins/plugins/artemis-device-test/skill/scripts/device_adapter.py
T

425 lines
22 KiB
Python

#!/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())