fix(device-test): contain tool processes and add reviewed preview
This commit is contained in:
1 parent
c98d7527a0
commit
7d31e09cb7
59 files changed
+23722
No files matched your search
@@ -0,0 +1,424 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user