fix(device-test): contain tool processes and add reviewed preview

This commit is contained in:
chenmingxuan committed 2026-10-30 12:43:36 +08:00
1 parent c98d7527a0
commit 7d31e09cb7
59 files changed
+23722

No files matched your search

@@ -0,0 +1,465 @@
"""Offline safety and protocol regression tests; never contact a real device."""
from __future__ import annotations
import contextlib
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import struct
import sys
import tempfile
import unittest
from unittest.mock import patch
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "device_adapter.py"
SN = "SYNTHETIC-TEST-SN-0001"
MODEL = "SYNTHETIC-MODEL"
TARGET = "9.8.7.6"
# RFC 5737 documentation address; every socket is replaced with an offline fixture.
TEST_IP = "192.0.2.10"
def response(sn=SN, model=MODEL, version=TARGET):
return {
"cmd": 30101, "session": 123, "code": 0,
"data": {
"sn": sn,
"inner": {"type": model, "version": version},
"outer": {"type": model, "version": "1.0.0.0"},
"hardware": {"type": model, "version": TARGET},
},
}
class FragmentedInfoSocket:
"""Transport-only double: creates a protocol response for the actual request."""
def __init__(self, reply):
self.reply = reply
self.pending = bytearray()
self.request = None
self.receives = 0
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def settimeout(self, _timeout):
pass
def sendall(self, packet):
assert packet[:4] == b"EPTP"
self.request = json.loads(packet[8:])
reply = dict(self.reply, session=self.request["session"])
payload = json.dumps(reply).encode("utf-8")
self.pending.extend(b"EPTP" + struct.pack("!I", len(payload)) + payload)
def recv(self, size):
self.receives += 1
count = min(size, 3, len(self.pending))
chunk = bytes(self.pending[:count])
del self.pending[:count]
return chunk
class UpgradeStatusSocket:
"""Run the real transfer state machine with controlled writes and status timing."""
def __init__(self, rom_size, success_after_wire_bytes):
self.success_after_wire_bytes = success_after_wire_bytes
payload = struct.pack("!BIIIII", 2, 123, rom_size, rom_size, rom_size, rom_size)
self.pending = bytearray(b"EPFP" + struct.pack("!II", 4001, len(payload)) + payload)
self.sent = bytearray()
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def setblocking(self, _blocking):
pass
def send(self, packet):
count = min(len(packet), 7)
self.sent.extend(packet[:count])
return count
def recv(self, size):
count = min(size, 3, len(self.pending))
chunk = bytes(self.pending[:count])
del self.pending[:count]
return chunk
def ready(self, readable, writable, exceptional, timeout):
if len(self.sent) >= self.success_after_wire_bytes:
return ([self], [], [])
return ([], [self], [])
class DeviceAdapterTests(unittest.TestCase):
def setUp(self):
self.assertTrue(SCRIPT.is_file(), "device_adapter.py has not been implemented")
spec = importlib.util.spec_from_file_location("device_adapter_under_test", SCRIPT)
self.adapter = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.adapter)
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.rom = Path(self.temp.name) / "synthetic.rom"
self.rom.write_bytes(b"synthetic firmware fixture\x00" * 30)
self.sha = hashlib.sha256(self.rom.read_bytes()).hexdigest()
def run_cli(self, argv, reply=None):
info_socket = FragmentedInfoSocket(reply or response())
connections = []
def connect(address, timeout):
connections.append(address)
if address[1] != 8942:
raise AssertionError("Unexpected write-port connection")
return info_socket
stdout = io.StringIO()
with patch.object(self.adapter.protocol.socket, "create_connection", side_effect=connect), contextlib.redirect_stdout(stdout):
code = self.adapter.main(argv)
return code, json.loads(stdout.getvalue()), connections, info_socket
def identity_args(self):
return ["--expected-sn", SN, "--expected-model", MODEL]
def upgrade_args(self):
return ["upgrade", "--host", TEST_IP, *self.identity_args(),
"--rom", str(self.rom), "--target-version", TARGET,
"--version-field", "inner.version"]
def run_transfer_cli(self, success_after_wire_bytes, host=TEST_IP):
info_socket = FragmentedInfoSocket(response())
transfer_socket = UpgradeStatusSocket(self.rom.stat().st_size, success_after_wire_bytes)
connections = []
def connect(address, timeout):
connections.append(address)
return info_socket if address[1] == 8942 else transfer_socket
stdout = io.StringIO()
args = self.upgrade_args() + ["--execute", "--rom-sha256", self.sha]
args[2] = host
with patch.object(self.adapter.protocol.socket, "create_connection", side_effect=connect), \
patch.object(self.adapter.protocol.select, "select", side_effect=transfer_socket.ready), \
contextlib.redirect_stdout(stdout):
code = self.adapter.main(args)
return code, json.loads(stdout.getvalue()), connections, transfer_socket
def test_v2_original_replaced_at_upgrade_connection_sends_approved_snapshot(self):
approved = self.rom.read_bytes()
for replacement in (b"B" * len(approved), b"C", b"D" * (len(approved) + 5)):
self.rom.write_bytes(approved)
info_socket = FragmentedInfoSocket(response())
transfer_socket = UpgradeStatusSocket(len(approved), 12 + len(approved))
def connect(address, timeout):
if address[1] == 8942:
return info_socket
self.rom.write_bytes(replacement)
return transfer_socket
out = io.StringIO()
with patch.object(self.adapter.protocol.socket, "create_connection", side_effect=connect), \
patch.object(self.adapter.protocol.select, "select", side_effect=transfer_socket.ready), \
contextlib.redirect_stdout(out):
code = self.adapter.main(self.upgrade_args() + ["--execute", "--rom-sha256", self.sha])
self.assertEqual(code, 0, out.getvalue())
self.assertEqual(bytes(transfer_socket.sent)[12:], approved)
def verify_args(self, reply):
path = Path(self.temp.name) / "response.json"
path.write_text(json.dumps(reply), encoding="utf-8")
return ["verify-version", "--response", str(path), *self.identity_args(),
"--target-version", TARGET, "--version-field", "inner.version"]
def test_capabilities_is_offline_and_lists_explicit_v1_transfer(self):
stdout = io.StringIO()
with patch.object(self.adapter.protocol.socket, "socket", side_effect=AssertionError("Network forbidden")), \
patch.object(self.adapter.protocol.socket, "create_connection", side_effect=AssertionError("Network forbidden")), \
contextlib.redirect_stdout(stdout):
code = self.adapter.main(["capabilities"])
result = json.loads(stdout.getvalue())
self.assertEqual(code, 0)
self.assertEqual(result["status"], "CAPABILITIES")
self.assertFalse(result["network_used"])
self.assertFalse(result["firmware_verified"])
self.assertTrue(result["protocols"]["v1"]["implemented"])
self.assertEqual(result["protocols"]["v1"]["operations"], ["discover", "identify", "info", "upgrade", "verify-version"])
self.assertTrue(result["protocols"]["v1"]["upgrade_preflight"])
self.assertTrue(result["protocols"]["v1"]["upgrade_transfer"])
def test_v1_execute_requires_reviewed_digest_before_io_without_v2_fallback(self):
commands = [
self.upgrade_args() + ["--execute"],
]
for command in commands:
with self.subTest(command=command[0], execute="--execute" in command):
stdout = io.StringIO()
with patch.object(self.adapter, "load_vendor", side_effect=AssertionError("No fallback")), \
patch.object(self.adapter, "inspect_rom", side_effect=AssertionError("No ROM access")), \
patch.object(self.adapter, "read_response", side_effect=AssertionError("No response access")), \
patch.object(self.adapter.protocol, "query_device_info", side_effect=AssertionError("No V2 query")), \
patch.object(self.adapter.protocol, "transfer_rom", side_effect=AssertionError("No transfer")), \
patch.object(self.adapter.protocol.socket, "socket", side_effect=AssertionError("No UDP")), \
patch.object(self.adapter.protocol.socket, "create_connection", side_effect=AssertionError("No TCP")), \
contextlib.redirect_stdout(stdout):
code = self.adapter.main(command + ["--protocol", "v1"])
result = json.loads(stdout.getvalue())
self.assertEqual(code, 1)
self.assertEqual(result["status"], "ERROR")
self.assertIn("requires an explicitly reviewed", result["message"])
self.assertEqual(result["protocol"], "v1")
self.assertFalse(result["write_may_have_started"])
self.assertFalse(result["automatic_retry"])
self.assertEqual([e["event"] for e in result["events"]], ["upgrade_target_bound"])
def test_discover_preserves_native_arguments_with_default_or_explicit_v2(self):
native = ["--expected-sn", SN, "--expected-model", MODEL, "--timeout", "3"]
for selection in ([], ["--protocol", "v2"]):
with self.subTest(selection=selection):
discovery = SimpleNamespace(main=lambda arguments: (arguments, 17))
with patch.object(self.adapter, "load_vendor", return_value=discovery) as load:
result = self.adapter.main(["discover", *native, *selection])
self.assertEqual(result, (native, 17))
load.assert_called_once_with("v2_discover.py", "artemis_device_vendor_discover")
def test_explicit_v2_preflight_uses_only_identity_connection(self):
code, result, connections, _ = self.run_cli(self.upgrade_args() + ["--protocol", "v2"])
self.assertEqual(code, 0)
self.assertEqual(result["protocol"], "v2")
self.assertEqual(result["status"], "PREFLIGHT_ONLY")
self.assertFalse(result["write_executed"])
self.assertEqual(connections, [(TEST_IP, 8942)])
def test_info_needs_no_rom_and_reads_fragmented_response(self):
code, result, connections, sock = self.run_cli(["info", "--host", "synthetic.invalid", *self.identity_args()])
self.assertEqual(code, 0)
self.assertEqual(result["status"], "IDENTITY_CONFIRMED")
self.assertEqual(result["response"]["data"]["sn"], SN)
self.assertEqual(connections, [("synthetic.invalid", 8942)])
self.assertGreater(sock.receives, 10)
self.assertEqual(sock.request["cmd"], 30101)
def test_v2_identify_observes_model_with_only_full_sn(self):
code, result, connections, _ = self.run_cli(["identify", "--host", TEST_IP, "--expected-sn", SN])
self.assertEqual(code, 0)
self.assertEqual(result["status"], "IDENTITY_OBSERVED")
self.assertIn(MODEL, result["models"])
self.assertTrue(result["sn_verified"])
self.assertFalse(result["model_verified"])
self.assertFalse(result["write_executed"])
self.assertEqual(connections, [(TEST_IP, 8942)])
def test_v2_rejects_v1_native_version_fields_before_network(self):
for field in ("firmware", "dev_ver"):
with self.subTest(field=field):
args = self.upgrade_args()
args[-1] = field
code, result, connections, _ = self.run_cli(args)
self.assertEqual(code, 1)
self.assertEqual(result["status"], "ERROR")
self.assertEqual(connections, [])
def test_full_serial_mismatch_blocks_upgrade_before_write_port(self):
code, result, connections, _ = self.run_cli(self.upgrade_args() + ["--execute", "--rom-sha256", self.sha], response(sn=SN + "-OTHER"))
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "ERROR")
self.assertEqual([port for _, port in connections], [8942])
def test_model_mismatch_blocks_upgrade_before_write_port(self):
code, result, connections, _ = self.run_cli(self.upgrade_args() + ["--execute", "--rom-sha256", self.sha], response(model="SYNTHETIC-OTHER-MODEL"))
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "ERROR")
self.assertEqual([port for _, port in connections], [8942])
def test_upgrade_without_execute_never_connects_8943(self):
code, result, connections, _ = self.run_cli(self.upgrade_args())
self.assertEqual(code, 0)
self.assertEqual(result["status"], "PREFLIGHT_ONLY")
self.assertFalse(result["firmware_verified"])
self.assertEqual(result["rom"]["sha256"], self.sha)
self.assertEqual(result["target_version"], TARGET)
self.assertEqual(result["version_field"], "inner.version")
self.assertEqual([port for _, port in connections], [8942])
def test_upgrade_rejects_hostname_even_without_execute(self):
args = self.upgrade_args()
args[2] = "synthetic.invalid"
code, result, connections, _ = self.run_cli(args)
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "ERROR")
self.assertEqual(connections, [])
def test_upgrade_rejects_determinable_non_unicast_targets(self):
for host in ("224.0.0.1", "0.0.0.0", "255.255.255.255", "::", "ff02::1",
"::ffff:0.0.0.0", "::ffff:224.0.0.1", "::ffff:255.255.255.255"):
with self.subTest(host=host):
args = self.upgrade_args()
args[2] = host
code, result, connections, _ = self.run_cli(args)
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "ERROR")
self.assertEqual(connections, [])
def test_numeric_ip_is_bound_for_info_and_transfer_and_saved_as_evidence(self):
expected_wire_bytes = self.rom.stat().st_size + 12
for host, normalized in ((TEST_IP, TEST_IP), ("2001:0db8:0:0::10", "2001:db8::10")):
with self.subTest(host=host):
code, result, connections, sock = self.run_transfer_cli(expected_wire_bytes, host)
self.assertEqual(code, 0)
self.assertEqual(result.get("bound_ip"), normalized)
self.assertEqual(connections, [(normalized, 8942), (normalized, 8943)])
self.assertEqual(bytes(sock.sent[:12]), b"EPFP" + struct.pack("!II", 4001, self.rom.stat().st_size))
self.assertEqual(bytes(sock.sent[12:]), self.rom.read_bytes())
self.assertEqual(result["transfer"]["bytes_sent"], self.rom.stat().st_size)
self.assertTrue(result["protocol_success"])
self.assertFalse(result["firmware_verified"])
def test_zero_or_partial_upload_status_2_cannot_claim_protocol_success(self):
for wire_bytes in (0, 12 + self.rom.stat().st_size // 2):
with self.subTest(wire_bytes=wire_bytes):
code, result, connections, sock = self.run_transfer_cli(wire_bytes)
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "ERROR")
self.assertFalse(result.get("protocol_success", False))
self.assertFalse(result["firmware_verified"])
self.assertTrue(result["write_may_have_started"])
self.assertTrue(result["postcheck_required"])
self.assertFalse(result["automatic_retry"])
self.assertEqual(connections, [(TEST_IP, 8942), (TEST_IP, 8943)])
self.assertLess(max(0, len(sock.sent) - 12), self.rom.stat().st_size)
self.assertEqual(result.get("bound_ip"), TEST_IP)
self.assertNotIn("protocol_success", [event["event"] for event in result["events"]])
def test_execute_requires_expected_rom_digest_before_network(self):
code, result, connections, _ = self.run_cli(self.upgrade_args() + ["--execute"])
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "ERROR")
self.assertEqual(connections, [])
def test_wrong_rom_digest_blocks_before_network(self):
code, _result, connections, _ = self.run_cli(self.upgrade_args() + ["--execute", "--rom-sha256", "0" * 64])
self.assertNotEqual(code, 0)
self.assertEqual(connections, [])
def test_offline_only_hashes_local_rom(self):
code, result, connections, _ = self.run_cli(["offline", "--rom", str(self.rom)])
self.assertEqual(code, 0)
self.assertEqual(result["status"], "OFFLINE_READY")
self.assertEqual(result["rom"]["sha256"], self.sha)
self.assertFalse(result["firmware_verified"])
self.assertEqual(connections, [])
def test_protocol_success_does_not_pass_firmware_acceptance(self):
with patch.object(self.adapter.protocol, "transfer_rom", return_value={"status": 2, "bytes_sent": self.rom.stat().st_size}) as transfer:
code, result, _connections, _ = self.run_cli(self.upgrade_args() + ["--execute", "--rom-sha256", self.sha])
self.assertEqual(code, 0)
self.assertEqual(transfer.call_count, 1)
self.assertEqual(result["status"], "PROTOCOL_SUCCESS_POSTCHECK_REQUIRED")
self.assertFalse(result["firmware_verified"])
self.assertTrue(result["postcheck_required"])
def test_write_error_is_not_retried(self):
with patch.object(self.adapter.protocol, "transfer_rom", side_effect=OSError("synthetic disconnect")) as transfer:
code, result, _connections, _ = self.run_cli(self.upgrade_args() + ["--execute", "--rom-sha256", self.sha])
self.assertNotEqual(code, 0)
self.assertEqual(transfer.call_count, 1)
self.assertEqual(result["status"], "ERROR")
self.assertTrue(result.get("write_may_have_started"))
self.assertTrue(result.get("postcheck_required"))
def test_cli_configures_utf8_without_import_side_effects(self):
class ConfigStream(io.StringIO):
def __init__(self):
super().__init__()
self.configurations = []
def reconfigure(self, **kwargs):
self.configurations.append(kwargs)
stdout, stderr = ConfigStream(), ConfigStream()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
code = self.adapter.main(["offline", "--rom", str(self.rom)])
self.assertEqual(code, 0)
self.assertIn({"encoding": "utf-8", "errors": "backslashreplace"}, stdout.configurations)
self.assertIn({"encoding": "utf-8", "errors": "backslashreplace"}, stderr.configurations)
def test_recorded_version_mismatch_cannot_pass(self):
code, result, connections, _ = self.run_cli(self.verify_args(response(version="0.0.0.0")))
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "FAIL")
self.assertFalse(result["firmware_verified"])
self.assertEqual(connections, [])
def test_missing_firmware_version_cannot_pass_even_if_hardware_matches(self):
reply = response()
del reply["data"]["inner"]["version"]
code, result, _connections, _ = self.run_cli(self.verify_args(reply))
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "INCOMPLETE")
self.assertFalse(result["firmware_verified"])
def test_hardware_version_is_rejected_as_acceptance_field(self):
args = self.verify_args(response())
args[-1] = "hardware.version"
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error:
self.adapter.main(args)
self.assertEqual(error.exception.code, 2)
def test_missing_data_cannot_pass(self):
code, result, _connections, _ = self.run_cli(self.verify_args({"cmd": 30101, "code": 0}))
self.assertNotEqual(code, 0)
self.assertEqual(result["status"], "ERROR")
self.assertFalse(result["firmware_verified"])
def test_wrong_identity_in_recorded_response_cannot_pass(self):
code, result, _connections, _ = self.run_cli(self.verify_args(response(sn="SYNTHETIC-WRONG-SN")))
self.assertNotEqual(code, 0)
self.assertFalse(result["firmware_verified"])
def test_matching_recorded_response_verifies_only_recorded_version(self):
code, result, connections, _ = self.run_cli(self.verify_args(response()))
self.assertEqual(code, 0)
self.assertEqual(result["status"], "PASS")
self.assertTrue(result["firmware_verified"])
self.assertEqual(result["evidence_source"], "recorded_response")
self.assertFalse(result["live_readback"])
self.assertEqual(connections, [])
def test_online_version_verification_reads_once(self):
args = ["verify-version", "--host", "synthetic.invalid", *self.identity_args(),
"--target-version", TARGET, "--version-field", "inner.version"]
code, result, connections, _ = self.run_cli(args)
self.assertEqual(code, 0)
self.assertEqual(result["status"], "PASS")
self.assertTrue(result["live_readback"])
self.assertEqual([port for _, port in connections], [8942])
def test_import_preserves_stdout_and_stderr_configuration(self):
class GuardStream(io.StringIO):
def reconfigure(self, **_kwargs):
raise AssertionError("Import must not reconfigure caller output")
# Reload the dependencies too: a cached import would hide the regression.
names = [name for name in sys.modules if name.startswith("artemis_device_vendor")]
removed = {name: sys.modules.pop(name) for name in names}
try:
with contextlib.redirect_stdout(GuardStream()), contextlib.redirect_stderr(GuardStream()):
spec = importlib.util.spec_from_file_location("safe_import_probe", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.load_vendor("v2_discover.py", "artemis_device_vendor_discover")
finally:
sys.modules.update(removed)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,99 @@
import json
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
import evidence
class EvidenceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
(self.root / 'observation.txt').write_text('measured=1', encoding='utf-8')
def tearDown(self):
self.temp.cleanup()
def run_bundle(self, assertions):
manifest = self.root / 'input.json'
manifest.write_text(json.dumps({'schema_version': 1, 'run_id': 'LAB-001',
'evidence_root': '.', 'assertions': assertions}), encoding='utf-8')
return evidence.bundle(manifest, self.root / 'output')
def assertion(self, verdict='passed', refs=None):
return {'id': 'live', 'expected': '1', 'actual': '1', 'verdict': verdict,
'evidence': refs if refs is not None else ['observation.txt']}
def test_success_has_hash_and_portable_archive(self):
result = self.run_bundle([self.assertion()])
self.assertEqual(result['verdict'], 'passed')
self.assertEqual(len(result['artifacts'][0]['sha256']), 64)
with zipfile.ZipFile(self.root / 'output' / 'bundle.zip') as z:
self.assertIn('report.md', z.namelist())
self.assertIn('evidence/observation.txt', z.namelist())
def test_missing_file_does_not_pass(self):
result = self.run_bundle([self.assertion(refs=['missing.txt'])])
self.assertEqual(result['verdict'], 'inconclusive')
def test_empty_evidence_does_not_pass(self):
self.assertEqual(self.run_bundle([self.assertion(refs=[])])['verdict'], 'inconclusive')
def test_expected_hash_mismatch_does_not_pass(self):
result = self.run_bundle([self.assertion(refs=[{'path': 'observation.txt', 'sha256': '0' * 64}])])
self.assertEqual(result['verdict'], 'inconclusive')
self.assertEqual(result['assertions'][0]['requested_evidence'][0]['sha256'], '0' * 64)
self.assertEqual(result['artifacts'][0]['integrity'], 'mismatch')
self.assertTrue((self.root / 'output' / 'evidence' / 'observation.txt').is_file())
def test_real_failure_preserved_alongside_blocked(self):
blocked = self.assertion('blocked', [])
blocked['id'] = 'later'
self.assertEqual(self.run_bundle([self.assertion('failed'), blocked])['verdict'], 'failed')
def test_no_assertions_not_pass(self):
self.assertEqual(self.run_bundle([])['verdict'], 'inconclusive')
def test_path_escape_rejected(self):
with self.assertRaises(ValueError):
self.run_bundle([self.assertion(refs=['../outside.txt'])])
def test_cannot_overwrite_run(self):
self.run_bundle([self.assertion()])
with self.assertRaises(FileExistsError):
evidence.bundle(self.root / 'input.json', self.root / 'output')
def test_invalid_verdict_rejected(self):
with self.assertRaises(ValueError):
self.run_bundle([self.assertion('completed')])
def test_duplicate_ids_rejected(self):
with self.assertRaises(ValueError):
self.run_bundle([self.assertion(), self.assertion()])
def test_optional_checks_alone_cannot_pass(self):
a = self.assertion()
a['required'] = False
self.assertEqual(self.run_bundle([a])['verdict'], 'inconclusive')
def test_source_named_bundle_zip_is_preserved(self):
(self.root / 'bundle.zip').write_bytes(b'external evidence archive')
self.run_bundle([self.assertion(refs=['bundle.zip'])])
with zipfile.ZipFile(self.root / 'output' / 'bundle.zip') as z:
self.assertIn('evidence/bundle.zip', z.namelist())
def test_simulation_is_visible_in_human_report(self):
manifest = self.root / 'input.json'
manifest.write_text(json.dumps({'schema_version': 1, 'run_id': 'EXAMPLE',
'context': {'execution_mode': 'simulation', 'real_device_test_executed': False},
'assertions': [self.assertion()]}), encoding='utf-8')
evidence.bundle(manifest, self.root / 'output')
self.assertIn('simulation', (self.root / 'output' / 'report.md').read_text(encoding='utf-8'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,263 @@
"""Actual owned subprocess regressions; no devices, network or external tools."""
import contextlib
import ctypes
import json
import os
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "skill/scripts"))
import dependency_checks as checks
import dependency_runner as dependencies
import device_runtime as runtime
import tool_runner as runner
from test_review_regressions import process_exited
def parent_code(exit_code=None, output_bytes=0):
return (
"import os,subprocess,sys,time,json;from pathlib import Path;"
"p=subprocess.Popen([sys.executable,'-I','-B','-c','import time;time.sleep(60)'],"
"stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,"
"creationflags=subprocess.CREATE_NO_WINDOW);"
"Path(sys.argv[1]).write_text(json.dumps({'parent':os.getpid(),'child':p.pid}));"
f"sys.stdout.write('x'*{output_bytes});sys.stdout.flush();"
+ ("time.sleep(60)" if exit_code is None else f"sys.exit({exit_code})")
)
@contextlib.contextmanager
def denied_cleanup_accounting():
original = ctypes.WinDLL
def load(name, *args, **kwargs):
library = original(name, *args, **kwargs)
if name != "kernel32":
return library
def deny(*args):
ctypes.set_last_error(5)
return 0
class Proxy:
def __getattr__(self, attr):
return (
deny
if attr == "QueryInformationJobObject"
else getattr(library, attr)
)
return Proxy()
with patch.object(ctypes, "WinDLL", side_effect=load):
yield
@unittest.skipUnless(os.name == "nt", "Windows Job ownership")
class LifecycleReview(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.marker = self.root / "owned-pids.json"
def assert_tree_exited(self):
pids = json.loads(self.marker.read_text())
self.assertTrue(process_exited(pids["parent"]))
self.assertTrue(process_exited(pids["child"]))
def test_doctor_public_entry_reaps_successful_startup_descendants(self):
native = dependencies
prefix = [sys.executable, "-I", "-B", "-c", parent_code(0), str(self.marker)]
def resolve(tool_id, profile, roots):
if tool_id == "ffprobe":
return profile, prefix, {"source": "synthetic", "status": "found"}
raise native.UPSTREAM.ToolMissing(tool_id)
# Substitute only discovery; the real doctor/check/Job processes run unchanged.
with (
patch.object(native, "resolve_dependency", side_effect=resolve),
patch.object(native, "path_directories", return_value=iter(())),
):
result = native.doctor({}, [], check_versions=True)
self.assert_tree_exited()
self.assertEqual(result["execution_status"], "completed")
def test_startup_check_reaps_descendant_on_normal_exit(self):
result = checks.check_startup(
"ffprobe",
[sys.executable, "-I", "-B", "-c", parent_code(0), str(self.marker)],
self.root,
time.monotonic() + 8,
)
self.assertEqual(result["status"], "passed")
self.assert_tree_exited()
def test_check_nonzero_reaps_descendants(self):
result, output = checks.run_check(
[sys.executable, "-I", "-B", "-c", parent_code(7), str(self.marker)],
self.root,
time.monotonic() + 8,
)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["returncode"], 7)
self.assertEqual(output, "")
self.assert_tree_exited()
def test_check_timeout_reaps_descendants(self):
result, output = checks.run_check(
[sys.executable, "-I", "-B", "-c", parent_code(), str(self.marker)],
self.root,
time.monotonic() + 2,
)
self.assertEqual(result["status"], "timed_out")
self.assertEqual(output, "")
self.assert_tree_exited()
def test_check_output_limit_stops_tree_and_discards_partial_output(self):
result, output = checks.run_check(
[
sys.executable,
"-I",
"-B",
"-c",
parent_code(output_bytes=2 * 1024 * 1024),
str(self.marker),
],
self.root,
time.monotonic() + 8,
)
self.assertEqual(result["error_code"], "process_output_limit")
self.assertNotEqual(result["status"], "passed")
self.assertEqual(output, "")
self.assert_tree_exited()
def test_check_owner_death_closes_job_and_reaps_descendants(self):
code = (
"import sys,time;from pathlib import Path;sys.path.insert(0,sys.argv[1]);"
"import dependency_checks as c;"
"c.run_check([sys.executable,'-I','-B','-c',sys.argv[2],sys.argv[3]],Path(sys.argv[3]).parent,time.monotonic()+40)"
)
worker = subprocess.Popen(
[
sys.executable,
"-I",
"-B",
"-c",
code,
str(ROOT),
parent_code(),
str(self.marker),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW,
)
try:
deadline = time.monotonic() + 2
while not self.marker.exists() and time.monotonic() < deadline:
time.sleep(0.01)
self.assertTrue(self.marker.exists())
worker.kill()
worker.wait(timeout=5)
self.assert_tree_exited()
finally:
if worker.poll() is None:
worker.kill()
worker.wait(timeout=5)
def test_real_execution_then_cleanup_error_preserves_started(self):
output = self.root / "actual-write.txt"
plan = runner.Plan(
"capture",
"dumpcap",
[
sys.executable,
"-I",
"-B",
"-c",
"import sys;from pathlib import Path;Path(sys.argv[1]).write_bytes(b'written')",
str(output),
],
5,
)
with denied_cleanup_accounting():
result = runner.execute(plan)
self.assertEqual(output.read_bytes(), b"written")
self.assertEqual(result["execution_status"], "cleanup_unconfirmed")
self.assertEqual(result["error_code"], "process_cleanup_unconfirmed")
self.assertTrue(result["may_have_started"])
def test_prelaunch_failure_remains_known_not_started(self):
plan = runner.Plan("capture", "dumpcap", [str(self.root / "missing.exe")], 5)
result = runner.execute(plan)
self.assertEqual(result["execution_status"], "launch_failed")
self.assertFalse(result["may_have_started"])
def test_unclassified_io_failure_is_unknown_and_redacted(self):
plan = runner.Plan("capture", "dumpcap", ["synthetic"], 5)
with patch.object(runner, "run_process", side_effect=OSError("private-value")):
result = runner.execute(plan)
self.assertEqual(result["execution_status"], "unknown")
self.assertTrue(result["may_have_started"])
self.assertNotIn("private-value", json.dumps(result))
@unittest.skipUnless(os.name == "nt", "Windows lifecycle receipts")
class RuntimeReceiptReview(unittest.IsolatedAsyncioTestCase):
async def test_host_receipt_never_calls_started_cleanup_failure_not_started(self):
with (
tempfile.TemporaryDirectory() as folder,
patch.dict(os.environ, {"ARTEMIS_ARTIFACTS_DIR": folder}),
):
for status, code in (
("cleanup_unconfirmed", "process_cleanup_unconfirmed"),
("unknown", "process_io_unconfirmed"),
("output_limit", "process_output_limit"),
):
payload = {
"execution_status": status,
"error_code": code,
"may_have_started": True,
}
process = Mock(returncode=1)
with (
patch.object(
runtime.asyncio,
"create_subprocess_exec",
new=AsyncMock(return_value=process),
),
patch.object(
runtime,
"read_output",
new=AsyncMock(return_value=json.dumps(payload).encode()),
),
):
result = await runtime.run_cli(
"dependency_runner.py",
["capture"],
operation="capture",
write=True,
)
receipt = result.structuredContent["artemis"]
self.assertEqual(
receipt["execution"],
"partial" if status == "output_limit" else "unknown",
)
self.assertEqual(receipt["side_effect"], "possible")
self.assertFalse(receipt["retryable"])
self.assertEqual(receipt["error_code"], code)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,97 @@
"""Windows-only regressions for reviewed firmware and process-tree risks."""
import ctypes
import hashlib
import importlib.util
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import device_adapter as adapter
import tool_runner as runner
def process_exited(pid):
k = ctypes.WinDLL("kernel32", use_last_error=True)
k.OpenProcess.argtypes = [ctypes.c_uint, ctypes.c_int, ctypes.c_uint]
k.OpenProcess.restype = ctypes.c_void_p
k.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint]
k.CloseHandle.argtypes = [ctypes.c_void_p]
handle = k.OpenProcess(0x100000, False, pid)
if not handle:
return ctypes.get_last_error() == 87
try:
return k.WaitForSingleObject(handle, 5000) == 0
finally:
k.CloseHandle(handle)
@unittest.skipUnless(os.name == "nt", "Windows lock and job regression")
class ReviewRegression(unittest.TestCase):
def test_snapshot_is_bound_and_blocks_modification_and_replacement(self):
with tempfile.TemporaryDirectory() as d:
original = Path(d) / "source.rom"
for replacement in (b"B" * 64, b"B" * 10, b"B" * 128):
original.write_bytes(b"A" * 64)
digest = hashlib.sha256(original.read_bytes()).hexdigest()
with adapter.protected_rom_snapshot(original, digest, 64) as snapshot:
original.write_bytes(replacement)
with self.assertRaises(OSError):
snapshot.write_bytes(replacement)
with self.assertRaises(OSError):
snapshot.unlink()
self.assertEqual(snapshot.read_bytes(), b"A" * 64)
self.assertFalse(snapshot.exists())
def test_snapshot_rejects_changed_source_before_transfer(self):
with tempfile.TemporaryDirectory() as d:
original = Path(d) / "source.rom"
original.write_bytes(b"B" * 64)
digest = hashlib.sha256(b"A" * 64).hexdigest()
with self.assertRaises(adapter.protocol.UpgradeError):
with adapter.protected_rom_snapshot(original, digest, 64):
self.fail("modified image must never be passed to transfer")
def test_timeout_reaps_descendant(self):
with tempfile.TemporaryDirectory() as d:
pidfile = Path(d) / "pid"
code = ("import subprocess,sys,time,pathlib; "
"p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(90)']); "
"pathlib.Path(sys.argv[1]).write_text(str(p.pid)); time.sleep(90)")
with self.assertRaises(subprocess.TimeoutExpired):
runner._run_in_windows_job([sys.executable, "-c", code, str(pidfile)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2)
self.assertTrue(pidfile.exists())
self.assertTrue(process_exited(int(pidfile.read_text())))
def test_worker_termination_closes_job_and_reaps_descendant(self):
with tempfile.TemporaryDirectory() as d:
pidfile = Path(d) / "pid"
child_code = ("import subprocess,sys,time,pathlib; "
"p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(90)']); "
"pathlib.Path(sys.argv[1]).write_text(str(p.pid)); time.sleep(90)")
worker_code = ("import sys; sys.path.insert(0,sys.argv[1]); import tool_runner; "
"tool_runner._run_in_windows_job([sys.executable,'-c',sys.argv[2],sys.argv[3]],timeout=80)")
worker = subprocess.Popen([sys.executable, "-c", worker_code, str(ROOT / "scripts"), child_code, str(pidfile)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
end = time.monotonic() + 10
while not pidfile.exists() and time.monotonic() < end:
time.sleep(.05)
self.assertTrue(pidfile.exists())
pid = int(pidfile.read_text())
worker.kill()
worker.wait(timeout=5)
self.assertTrue(process_exited(pid))
finally:
if worker.poll() is None:
worker.kill()
worker.wait()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,417 @@
"""Offline safety/command tests. No test contacts a device or starts a tool."""
import contextlib
import importlib.util
import io
import json
import pathlib
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "tool_runner.py"
runner = None
if SCRIPT.exists():
spec = importlib.util.spec_from_file_location("artemis_tool_runner", SCRIPT)
runner = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = runner
spec.loader.exec_module(runner)
class ToolRunnerTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(runner, "tool_runner implementation is required")
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = pathlib.Path(self.tmp.name)
self.tools = {}
for name in ("ffprobe", "ffmpeg", "tshark", "dumpcap", "nmap", "binwalk", "powershell"):
path = self.root / (name + ".exe")
path.touch()
self.tools[name] = str(path)
self.profile = {"tools": self.tools}
self.media = self.root / "input media.bin"
self.media.write_bytes(b"offline fixture")
def plan(self, *args, env=None, profile=None):
return runner.build_plan(runner.parse_args(list(args)), self.profile if profile is None else profile, env or {})
def test_scan_constructs_one_host_explicit_ports_no_scripts(self):
p = self.plan("scan", "--host", "192.0.2.10", "--ports", "554,80,22")
self.assertEqual(p.tool_id, "nmap")
self.assertIn("-sT", p.argv)
self.assertIn("-n", p.argv)
self.assertEqual(p.argv[-1], "192.0.2.10")
self.assertEqual(p.argv[p.argv.index("-p") + 1], "22,80,554")
self.assertNotIn("-sV", p.argv)
self.assertNotIn("-A", p.argv)
self.assertFalse(any("script" in x for x in p.argv))
def test_nmap_preserves_aggregate_states_without_assigning_ports(self):
p = self.plan("scan", "--host", "192.0.2.10", "--ports", "22,80,554")
xml = '''<nmaprun><host><status state="up"/><ports>
<extraports state="closed" count="1"><extrareasons reason="conn-refused" count="1"/></extraports>
<extraports state="filtered" count="1"><extrareasons reason="no-response" count="1"/></extraports>
<port protocol="tcp" portid="22"><state state="open"/></port>
</ports></host></nmaprun>'''
data = runner.safe_data(p, xml)
self.assertEqual(data.get("requested_ports"), [22, 80, 554])
host = data["hosts"][0]
self.assertEqual(host["aggregate_states"], [
{"state": "closed", "count": 1, "reasons": [{"reason": "conn-refused", "count": 1}]},
{"state": "filtered", "count": 1, "reasons": [{"reason": "no-response", "count": 1}]},
])
self.assertEqual(host["ports_not_individually_reported"], [80, 554])
self.assertEqual(host["individual_port_coverage"], "partial")
self.assertEqual(host["ports"], [{"port": "22", "protocol": "tcp", "state": "open"}])
def test_nmap_aggregate_only_is_not_individual_port_coverage(self):
p = self.plan("scan", "--host", "192.0.2.10", "--ports", "80,554")
data = runner.safe_data(p, '<nmaprun><host><status state="up"/><ports><extraports state="closed" count="2"/></ports></host></nmaprun>')
host = data["hosts"][0]
self.assertEqual(host.get("aggregate_states"), [{"state": "closed", "count": 2, "reasons": []}])
self.assertEqual(host["ports"], [])
self.assertEqual(host["ports_not_individually_reported"], [80, 554])
self.assertEqual(host["individual_port_coverage"], "none")
def test_nmap_missing_results_remain_explicit(self):
p = self.plan("scan", "--host", "192.0.2.10", "--ports", "22,80")
data = runner.safe_data(p, '<nmaprun><host><status state="up"/><ports><port protocol="tcp" portid="22"><state state="open"/></port></ports></host></nmaprun>')
self.assertEqual(data["hosts"][0].get("aggregate_states"), [])
self.assertEqual(data["hosts"][0]["ports_not_individually_reported"], [80])
absent = runner.safe_data(p, '<nmaprun/>')
self.assertEqual(absent["requested_ports"], [22, 80])
self.assertEqual(absent["hosts"], [])
self.assertFalse(absent["host_results_present"])
def test_scan_rejects_hostnames_networks_and_option_injection(self):
for value in ("192.0.2.0/24", "camera.local", "192.0.2.1 192.0.2.2", "-iLtargets", "224.0.0.1", "0.0.0.0"):
with self.subTest(value=value), self.assertRaises(runner.ValidationError):
self.plan("scan", "--host", value, "--ports", "80")
def test_scan_rejects_ranges_unbounded_and_injected_ports(self):
for value in ("1-65535", "0", "65536", "80;echo", "80 --script vuln", "", "T:80"):
with self.subTest(value=value), self.assertRaises(runner.ValidationError):
self.plan("scan", "--host", "192.0.2.1", "--ports", value)
def test_no_arbitrary_args_or_direct_rtsp_url_cli(self):
for args in (("scan", "--host", "192.0.2.1", "--ports", "80", "--args", "-A"),
("probe", "--url", "rtsp://user:secret@192.0.2.1/live")):
with self.subTest(args=args), self.assertRaises(runner.ValidationError):
runner.parse_args(list(args))
def test_capture_limits_interface_host_time_and_size(self):
p = self.plan("capture", "--interface", "1", "--host", "192.0.2.1", "--duration", "5", "--output", str(self.root / "capture.pcapng"))
self.assertEqual(p.argv[p.argv.index("-f") + 1], "host 192.0.2.1")
self.assertIn("duration:5", p.argv)
self.assertIn("filesize:102400", p.argv)
self.assertIn("-s", p.argv)
self.assertGreater(p.timeout, 5)
def test_capture_rejects_invalid_duration_interface_and_overwrite(self):
common = ["capture", "--interface", "1", "--host", "192.0.2.1", "--duration", "5", "--output", str(self.root / "out.pcapng")]
for index, value in ((2, "-D"), (2, "rpcap://host"), (6, "0"), (6, "601"), (8, str(self.media))):
args = common.copy()
args[index] = value
with self.subTest(value=value), self.assertRaises(runner.ValidationError):
self.plan(*args)
def test_tshark_reads_offline_only_with_safe_fields(self):
p = self.plan("analyze-pcap", "--input", str(self.media), "--max-packets", "10")
self.assertIn("-r", p.argv)
self.assertNotIn("-i", p.argv)
self.assertIn("fields", p.argv)
self.assertIn("ip.src", p.argv)
self.assertNotIn("http.authorization", p.argv)
self.assertNotIn("tcp.payload", p.argv)
def test_probe_local_input_is_absolute_and_network_protocols_disabled(self):
p = self.plan("probe", "--input", str(self.media))
self.assertEqual(p.argv[-1], str(self.media.resolve()))
self.assertIn("-protocol_whitelist", p.argv)
self.assertEqual(p.argv[p.argv.index("-protocol_whitelist") + 1], "file")
self.assertNotIn("-show_format", p.argv)
def test_rtsp_requires_env_and_redacts_all_url_in_plan(self):
url = "rtsp://admin:TopSecret123@192.0.2.1:554/live?token=HiddenToken"
p = self.plan("probe", "--rtsp-env", "CAMERA_RTSP", env={"CAMERA_RTSP": url})
self.assertEqual(p.argv[-1], url)
public = json.dumps(p.public()) + repr(p)
self.assertNotIn("TopSecret123", public)
self.assertNotIn("HiddenToken", public)
self.assertNotIn(url, public)
self.assertIn("CAMERA_RTSP", public)
def test_rtsp_rejects_absent_env_non_rtsp_and_bad_url(self):
for env in ({}, {"URL": "http://192.0.2.1"}, {"URL": "rtsp://"}, {"URL": "rtsp://192.0.2.1:99999/live"}, {"URL": "rtsp://192.0.2.1/live\n-x"}):
with self.subTest(env=env), self.assertRaises(runner.ValidationError):
self.plan("probe", "--rtsp-env", "URL", env=env)
def test_profile_rejects_plaintext_secrets_and_auth_urls(self):
for data in ({"password": "secret"}, {"device": {"password": "secret"}}, {"rtsp_url": "rtsp://a:b@192.0.2.1/live"}, {"tools": {"nmap": {"args": "-A"}}}):
with self.subTest(data=data), self.assertRaises(runner.ValidationError):
runner.validate_profile(data)
runner.validate_profile({"auth": {"password_env": "DEVICE_PASSWORD"}, "tools": {"nmap": self.tools["nmap"]}})
def test_profile_rejects_compound_secret_keys_and_token_urls(self):
for data in ({"device_password": "secret"}, {"authToken": "secret"}, {"url": "https://example.invalid/?access_token=secret"}, {"密码": "secret"}):
with self.subTest(data=data), self.assertRaises(runner.ValidationError):
runner.validate_profile(data)
def test_probe_sanitization_handles_quotes_in_secret_without_echo(self):
url = "rtsp://admin:Se%22cret@192.0.2.1/live"
p = self.plan("probe", "--rtsp-env", "URL", env={"URL": url})
data = runner.safe_data(p, json.dumps({"streams": [{"codec_name": 'Se"cret'}]}))
self.assertNotIn('Se"cret', json.dumps(data).replace('\\"', '"'))
def test_missing_tool_never_falls_back_to_installers(self):
with mock.patch.object(runner.shutil, "which", return_value=None):
with self.assertRaises(runner.ToolMissing):
self.plan("scan", "--host", "192.0.2.1", "--ports", "80", profile={})
installer = self.root / "nmap-setup.exe"
installer.touch()
with self.assertRaises(runner.ValidationError):
self.plan("scan", "--host", "192.0.2.1", "--ports", "80", profile={"tools": {"nmap": str(installer)}})
def test_tool_root_finds_extracted_binary_but_not_zip_members(self):
args = runner.parse_args(["--tool-root", str(self.root), "scan", "--host", "192.0.2.1", "--ports", "80"])
with mock.patch.object(runner.shutil, "which", return_value=None):
self.assertEqual(runner.build_plan(args, {}, {}).argv[0], self.tools["nmap"])
def test_firmware_scan_never_extracts_or_executes(self):
p = self.plan("firmware-scan", "--input", str(self.media))
self.assertEqual(list(p.argv), [self.tools["binwalk"], str(self.media.resolve())])
def test_binwalk_runtime_uses_only_fixed_isolated_module_invocation(self):
python = self.root / "python.exe"
python.touch()
p = self.plan("firmware-scan", "--input", str(self.media),
profile={"runtimes": {"binwalk_python": str(python)}})
self.assertEqual(p.argv, [str(python), "-I", "-m", "binwalk", str(self.media)])
def test_runtime_and_library_configuration_rejects_arbitrary_launchers(self):
for profile in ({"runtimes": {"binwalk_python": {"args": "-c evil"}}},
{"runtimes": {"other_python": "python.exe"}},
{"libraries": {"mediainfo": {"args": "evil"}}},
{"libraries": {"other": "unknown.dll"}}):
with self.subTest(profile=profile), self.assertRaises(runner.ValidationError):
runner.validate_profile(profile)
cmd = self.root / "cmd.exe"
cmd.touch()
with self.assertRaises(runner.ValidationError):
self.plan("firmware-scan", "--input", str(self.media),
profile={"runtimes": {"binwalk_python": str(cmd)}})
def test_profile_resolves_library_and_runtime_relative_to_profile(self):
profile = self.root / "profile.json"
profile.write_text(json.dumps({"runtimes": {"binwalk_python": "env/Scripts/python.exe"},
"libraries": {"mediainfo": "media/MediaInfo.dll"}}))
loaded = runner.load_profile(str(profile))
self.assertEqual(loaded["runtimes"]["binwalk_python"], str(self.root / "env/Scripts/python.exe"))
self.assertEqual(loaded["libraries"]["mediainfo"], str(self.root / "media/MediaInfo.dll"))
def test_binwalk_scan_does_not_load_user_config_or_pythonpath(self):
p = self.plan("firmware-scan", "--input", str(self.media))
seen = {}
def run(argv, **kwargs):
seen.update(kwargs)
config = pathlib.Path(kwargs["env"]["XDG_CONFIG_HOME"])
self.assertTrue(config.is_dir())
self.assertEqual(list(config.iterdir()), [])
return subprocess.CompletedProcess(argv, 0, "signature", "")
with mock.patch.dict(runner.os.environ, {"XDG_CONFIG_HOME": "existing-config", "PYTHONPATH": "unsafe"}), \
mock.patch.object(runner, "_run_in_windows_job", side_effect=run):
result = runner.execute(p)
self.assertEqual(result["execution_status"], "completed")
self.assertNotIn("PYTHONPATH", seen["env"])
self.assertFalse(pathlib.Path(seen["env"]["XDG_CONFIG_HOME"]).exists())
def test_media_info_is_bounded_child_with_local_dll_and_file_only(self):
library = self.root / "MediaInfo.dll"
library.touch()
profile = {"libraries": {"mediainfo": str(library)}}
p = self.plan("media-info", "--input", str(self.media), profile=profile)
self.assertEqual(p.tool_id, "mediainfo")
self.assertEqual(p.argv[:4], [sys.executable, "-I", str(SCRIPT), "_media-info-worker"])
self.assertEqual(p.argv[-4:], ["--library", str(library), "--input", str(self.media)])
self.assertGreater(p.timeout, 0)
for value in ("rtsp://192.0.2.1/live", "https://example.invalid/video.mp4", "//server/media.mp4"):
with self.subTest(value=value), self.assertRaises(runner.ValidationError):
self.plan("media-info", "--input", value, profile=profile)
def test_media_info_filters_fields_including_embedded_title_and_filename(self):
p = runner.Plan("media-info", "mediainfo", [], 10)
raw = {"version": "MediaInfoLib - v21.09", "general": {"Format": "MPEG-4", "CompleteName": "private-file"},
"video": [{"Width": "160", "Height": "120", "FrameCount": "5", "Title": "private-title"}],
"audio": [{"SamplingRate": "8000", "Channels": "1"}], "other": "secret"}
data = runner.safe_data(p, json.dumps(raw))
self.assertEqual(data["video"][0]["Width"], "160")
self.assertEqual(data["audio"][0]["SamplingRate"], "8000")
self.assertNotIn("private", json.dumps(data))
self.assertNotIn("other", data)
def test_media_info_missing_dll_does_not_fall_back_to_gui_installer(self):
with self.assertRaises(runner.ToolMissing):
self.plan("media-info", "--input", str(self.media), profile={})
installer = self.root / "MediaInfo_GUI.exe"
installer.touch()
with self.assertRaises(runner.ValidationError):
self.plan("media-info", "--input", str(self.media),
profile={"libraries": {"mediainfo": str(installer)}})
def test_media_info_worker_disables_internet_using_empty_value_and_checks_it(self):
library = self.root / "MediaInfo.dll"
library.touch()
lib = mock.MagicMock()
states = {None: "1", 123: "1"}
def option(handle, name, value):
if name == "Internet":
# MediaInfo 21.09 treats every nonempty value, including "No",
# as enabled. An empty value is the documented implementation.
states[handle] = "1" if value else ""
return ""
if name == "Internet_Get":
return states[handle]
return "MediaInfoLib - v21.09"
lib.MediaInfo_Option.side_effect = option
lib.MediaInfo_New.return_value = 123
lib.MediaInfo_Open.return_value = 1
lib.MediaInfo_Count_Get.return_value = 0
output = io.StringIO()
with mock.patch("ctypes.WinDLL", return_value=lib, create=True), contextlib.redirect_stdout(output):
code = runner.media_info_worker(["--library", str(library), "--input", str(self.media)])
self.assertEqual(code, 0)
self.assertEqual(states, {None: "", 123: ""})
for handle in (None, 123):
self.assertIn(mock.call(handle, "Internet_Get", ""), lib.MediaInfo_Option.call_args_list)
lib.MediaInfo_Open.assert_called_once_with(123, str(self.media))
def test_media_info_worker_refuses_input_when_internet_disabled_state_is_unconfirmed(self):
library = self.root / "MediaInfo.dll"
library.touch()
for blocked_handle in (None, 123):
for unexpected_state in ("1", "Option not known", None):
with self.subTest(handle=blocked_handle, state=unexpected_state):
lib = mock.MagicMock()
def option(handle, name, value):
if name == "Internet_Get":
return unexpected_state if handle == blocked_handle else ""
return ""
lib.MediaInfo_Option.side_effect = option
lib.MediaInfo_New.return_value = 123
lib.MediaInfo_Open.return_value = 1
lib.MediaInfo_Count_Get.return_value = 0
with mock.patch("ctypes.WinDLL", return_value=lib, create=True), contextlib.redirect_stdout(io.StringIO()):
code = runner.media_info_worker(["--library", str(library), "--input", str(self.media)])
self.assertEqual(code, 1)
lib.MediaInfo_Open.assert_not_called()
def test_short_rtsp_query_secret_does_not_corrupt_media_measurements(self):
url = "rtsp://admin:LongPassword@192.0.2.1/live?token=1&channel=0"
p = self.plan("probe", "--rtsp-env", "URL", env={"URL": url})
raw = {"streams": [{"codec_name": "hevc", "avg_frame_rate": "20/1", "sample_rate": "8000"}],
"format": {"duration": "1.0", "bit_rate": "123456"}}
self.assertEqual(runner.safe_data(p, json.dumps(raw)), raw)
self.assertNotIn(url, json.dumps(p.public()))
def test_serial_is_bounded_read_only_and_no_handshake_flags(self):
p = self.plan("serial", "--port", "COM9", "--baud", "115200", "--duration", "3", "--output", str(self.root / "serial.log"))
self.assertIn("-NoProfile", p.argv)
self.assertIn("-DurationSeconds", p.argv)
self.assertIn("3", p.argv)
self.assertNotIn("-DtrEnable", p.argv)
self.assertNotIn("-RtsEnable", p.argv)
self.assertNotIn("-Command", p.argv)
self.assertTrue(any(x.endswith("serial_monitor.ps1") for x in p.argv))
def test_serial_control_lines_require_explicit_independent_flags(self):
for flags, expected in ((["--dtr-enable"], {"-DtrEnable"}),
(["--rts-enable"], {"-RtsEnable"}),
(["--dtr-enable", "--rts-enable"], {"-DtrEnable", "-RtsEnable"})):
with self.subTest(flags=flags):
p = self.plan("serial", "--port", "COM9", "--baud", "115200", "--duration", "3",
"--output", str(self.root / "serial.log"), *flags)
self.assertEqual(set(p.argv) & {"-DtrEnable", "-RtsEnable"}, expected)
self.assertEqual(p.metadata["dtr_enabled"], "-DtrEnable" in expected)
self.assertEqual(p.metadata["rts_enabled"], "-RtsEnable" in expected)
self.assertNotIn("-Handshake", p.argv)
self.assertNotIn("-Command", p.argv)
def test_timeout_must_be_finite_positive_and_cover_capture(self):
for value in ("0", "-1", "3601", "nan", "inf"):
with self.subTest(value=value), self.assertRaises(runner.ValidationError):
self.plan("--timeout", value, "firmware-scan", "--input", str(self.media))
with self.assertRaises(runner.ValidationError):
self.plan("--timeout", "2", "capture", "--interface", "1", "--host", "192.0.2.1", "--duration", "20", "--output", str(self.root / "out.pcapng"))
def test_execution_uses_array_no_shell_finite_timeout_and_no_raw_error(self):
url = "rtsp://admin:TopSecret123@192.0.2.1/live?token=HiddenToken"
p = self.plan("probe", "--rtsp-env", "URL", env={"URL": url})
completed = subprocess.CompletedProcess(p.argv, 0, json.dumps({"streams": [{"codec_name": "h264", "width": 1920, "tags": {"url": url}}], "format": {"filename": url, "duration": "1.0"}}), url)
with mock.patch.object(runner, "_run_in_windows_job", return_value=completed) as launch:
result = runner.execute(p)
kwargs = launch.call_args.kwargs
self.assertFalse(kwargs["shell"])
self.assertGreater(kwargs["timeout"], 0)
self.assertIsInstance(launch.call_args.args[0], list)
self.assertEqual(result["execution_status"], "completed")
self.assertEqual(result["test_verdict"], "not_evaluated")
self.assertNotIn("TopSecret123", json.dumps(result))
self.assertNotIn("HiddenToken", json.dumps(result))
self.assertEqual(result["data"]["streams"][0]["width"], 1920)
def test_timeout_exception_never_echoes_raw_command_or_partial_output(self):
url = "rtsp://a:TopSecret123@192.0.2.1/live"
p = self.plan("probe", "--rtsp-env", "URL", env={"URL": url})
with mock.patch.object(runner, "_run_in_windows_job", side_effect=subprocess.TimeoutExpired(p.argv, 30, output=url, stderr=url)):
result = runner.execute(p)
self.assertEqual(result["execution_status"], "timed_out")
self.assertNotIn("TopSecret123", json.dumps(result))
def test_dry_run_does_not_launch_process(self):
profile = self.root / "profile.json"
profile.write_text(json.dumps(self.profile), encoding="utf-8")
output = io.StringIO()
with mock.patch.object(runner, "_run_in_windows_job") as launch, contextlib.redirect_stdout(output):
code = runner.main(["--profile", str(profile), "--dry-run", "scan", "--host", "192.0.2.1", "--ports", "80"])
self.assertEqual(code, 0)
self.assertFalse(launch.called)
self.assertEqual(json.loads(output.getvalue())["execution_status"], "planned")
def test_cli_errors_are_json_and_do_not_echo_secrets(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
code = runner.main(["probe", "--url", "rtsp://a:TopSecret123@192.0.2.1/live"])
self.assertEqual(code, 2)
self.assertEqual(json.loads(output.getvalue())["execution_status"], "invalid_arguments")
self.assertNotIn("TopSecret123", output.getvalue())
def test_doctor_without_version_flag_never_launches_tools(self):
with mock.patch.object(runner, "_run_in_windows_job") as launch, mock.patch.object(runner.shutil, "which", return_value=None):
result = runner.inventory({}, [], check_versions=False)
self.assertFalse(launch.called)
rows = {item["tool_id"]: item for item in result["tools"]}
self.assertIn(rows["tshark"]["availability"], ("missing", "package-only"))
self.assertEqual(rows["router"]["availability"], "manual-interface")
def test_inventory_preserves_mcp_and_orchestrator_routes(self):
with mock.patch.object(runner.shutil, "which", return_value=None):
rows = {item["tool_id"]: item for item in runner.inventory({}, [], False)["tools"]}
self.assertEqual(rows["artemis"]["interface"], "mcp")
self.assertEqual(rows["artemis"]["availability"], "mcp-check-required")
self.assertIn("mobile_workspace", rows["artemis"]["mcp_tools"])
self.assertEqual(rows["codex"]["interface"], "orchestrator")
self.assertEqual(rows["codex"]["availability"], "orchestrator-context")
def test_version_summary_uses_first_nonempty_line(self):
completed = subprocess.CompletedProcess([], 0, "\n \nNmap version 7.70\nMore detail\n", "")
with mock.patch.object(runner, "_run_in_windows_job", return_value=completed):
rows = {item["tool_id"]: item for item in runner.inventory(self.profile, [], True)["tools"]}
self.assertEqual(rows["nmap"]["version_check"]["first_line"], "Nmap version 7.70")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,384 @@
"""Offline V1 wire/identity tests: synthetic devices, no real sockets or upgrades."""
from __future__ import annotations
import contextlib
import hashlib
import importlib.util
import io
import ipaddress
import json
from pathlib import Path
import struct
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
SN = "SYNTHETIC-V1-FULL-SN-0001"
MODEL = "SYNTHETIC-V1-MODEL"
IP = "192.0.2.10"
TARGET = "9.8.7"
INFO = 0x5A0E2104
REQUEST = 0x5A0EFF00
RESPONSE = 0x5A0EFF01
SESSION = 1234
def wire(command, body):
data = json.dumps(body, separators=(",", ":")).encode("utf-8")
return struct.pack("!II", command, len(data)) + data
def info_body(**changes):
body = dict(code=0, desc="successed", sn=SN, dev_type=MODEL,
firmware=TARGET, dev_ver=TARGET, hardware="HW-TEST")
body.update(changes)
return body
def discovery_body(**changes):
body = dict(session=SESSION, sn=SN, dev_type=MODEL, ip=IP,
mac="00:00:5E:00:53:01", firmware=TARGET, hardware="HW-TEST")
body.update(changes)
return body
def interface(address="192.0.2.20/24"):
parsed = ipaddress.ip_interface(address)
return SimpleNamespace(address=parsed.ip, prefix_length=parsed.network.prefixlen,
network=parsed.network)
class TcpSocket:
def __init__(self, packet):
self.pending = bytearray(packet)
self.sent = []
self.timeouts = []
self.closed = False
def __enter__(self):
return self
def __exit__(self, *_args):
self.closed = True
def settimeout(self, value):
self.timeouts.append(value)
def sendall(self, packet):
self.sent.append(packet)
def recv(self, count):
count = min(count, 3, len(self.pending))
result = bytes(self.pending[:count])
del self.pending[:count]
return result
class UdpSocket:
def __init__(self, replies=(), bind_failure=False, send_failure=False):
self.replies = list(replies)
self.bind_failure = bind_failure
self.send_failure = send_failure
self.sent, self.bindings, self.options = [], [], []
self.closed = False
def setsockopt(self, *args):
self.options.append(args)
def bind(self, address):
self.bindings.append(address)
if self.bind_failure:
raise OSError("synthetic occupied port")
def setblocking(self, value):
assert value is False
def sendto(self, packet, destination):
self.sent.append((packet, destination))
if self.send_failure:
raise OSError("synthetic route failure")
return len(packet)
def recvfrom(self, _size):
return self.replies.pop(0)
def select(self, readable, *_args):
assert readable == [self]
return ([self] if self.replies else [], [], [])
def close(self):
self.closed = True
class V1ProtocolTests(unittest.TestCase):
def setUp(self):
spec = importlib.util.spec_from_file_location("adapter_v1_test", ROOT / "scripts" / "device_adapter.py")
self.adapter = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.adapter)
self.v1 = self.adapter.v1
self.logger = self.adapter.EventCollector()
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.rom = Path(self.temp.name) / "synthetic-v1.rom"
self.rom.write_bytes(b"synthetic v1 ROM fixture" * 20)
self.sha = hashlib.sha256(self.rom.read_bytes()).hexdigest()
def tcp_query(self, body=None, packet=None):
sock = TcpSocket(packet if packet is not None else wire(INFO, body or info_body()))
with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect:
result = self.v1.query_device_info(IP, 5, self.logger)
self.assertEqual(connect.call_count, 1)
self.assertEqual(connect.call_args.args[0], (IP, 4941))
return result, sock
def cli(self, args, body=None):
sock = TcpSocket(wire(INFO, body or info_body()))
stdout = io.StringIO()
with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect, \
patch.object(self.adapter.protocol, "query_device_info", side_effect=AssertionError("V2 forbidden")), \
patch.object(self.adapter.protocol, "transfer_rom", side_effect=AssertionError("transfer forbidden")), \
contextlib.redirect_stdout(stdout):
code = self.adapter.main(args + ["--protocol", "v1"])
return code, json.loads(stdout.getvalue()), sock, connect
def identity(self):
return ["--expected-sn", SN, "--expected-model", MODEL]
def discover(self, replies=(), **kwargs):
sock = UdpSocket(replies, kwargs.pop("bind_failure", False), kwargs.pop("send_failure", False))
with patch.object(self.v1.socket, "socket", return_value=sock), \
patch.object(self.v1.select, "select", side_effect=sock.select):
result = self.v1.discover(kwargs.pop("interfaces", [interface()]), 1, 4942,
self.logger, session=SESSION, **kwargs)
return result, sock
def test_exact_big_endian_empty_info_header(self):
self.assertEqual(self.v1.encode_frame(INFO), b"\x5a\x0e\x21\x04\x00\x00\x00\x00")
packet = self.v1.encode_frame(REQUEST, {"session": SESSION})
self.assertEqual(packet, wire(REQUEST, {"session": SESSION}))
def test_fragmented_tcp_and_documented_field_filter(self):
body = info_body()
body["undocumented_field"] = {"synthetic": "not retained"}
result, sock = self.tcp_query(body)
self.assertEqual(sock.sent, [bytes.fromhex("5a0e210400000000")])
self.assertTrue(sock.closed)
self.assertEqual(result["data"]["sn"], SN)
self.assertNotIn("undocumented_field", result["data"])
self.assertNotIn("undocumented_field", json.dumps(self.logger.events))
def test_decode_rejects_bad_command_length_json_and_utf8(self):
cases = [b"1234567", wire(INFO + 1, {}), struct.pack("!II", INFO, 0),
struct.pack("!II", INFO, self.v1.MAX_JSON_SIZE + 1),
wire(INFO, {}) + b"extra", wire(INFO, []),
struct.pack("!II", INFO, 1) + b"{",
struct.pack("!II", INFO, 1) + b"\xff"]
for packet in cases:
with self.subTest(packet=packet[:12]), self.assertRaises(self.v1.ProtocolError):
self.v1.decode_frame(packet, INFO)
def test_early_tcp_eof_closes_without_retry(self):
for packet in [b"", wire(INFO, info_body())[:-1]]:
sock = TcpSocket(packet)
with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect, \
self.assertRaises(self.v1.ProtocolError):
self.v1.query_device_info(IP, 5, self.logger)
self.assertEqual(connect.call_count, 1)
self.assertTrue(sock.closed)
def test_tcp_invalid_headers_are_rejected_before_reading_body(self):
for command, size in [(REQUEST, 10), (INFO, 0), (INFO, self.v1.MAX_JSON_SIZE + 1)]:
with self.subTest(command=command, size=size), self.assertRaises(self.v1.ProtocolError):
self.tcp_query(packet=struct.pack("!II", command, size))
def test_tcp_uses_one_total_deadline_across_fragments(self):
sock = TcpSocket(wire(INFO, info_body()))
with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect, \
patch.object(self.v1.time, "monotonic", side_effect=[0, 0.1, 0.2, 0.7, 1.1]), \
self.assertRaises(TimeoutError):
self.v1.query_device_info(IP, 1, self.logger)
self.assertEqual(connect.call_count, 1)
self.assertTrue(sock.closed)
self.assertEqual(len(sock.sent), 1)
def test_native_code_must_be_integer_zero_and_fields_strings(self):
for key, value in [("code", False), ("code", "0"), ("code", 1), ("dev_type", {}), ("sn", 123)]:
body = info_body()
body[key] = value
with self.subTest(key=key, value=value), self.assertRaises(self.v1.ProtocolError):
self.tcp_query(body)
def test_identity_requires_explicit_successful_v1_envelope_and_exact_sn(self):
result, _ = self.tcp_query()
invalid = [dict(result, code=False), dict(result, code=1), dict(result, protocol="v2"),
dict(result, cmd=30101), {"cmd": INFO, "code": 0, "data": result["data"]}]
for value in invalid:
with self.subTest(value=value), self.assertRaises(self.v1.ProtocolError):
self.v1.validate_identity(value, SN, MODEL)
with self.assertRaises(self.v1.ProtocolError):
self.v1.validate_identity(result, "0001", MODEL)
with self.assertRaises(self.v1.ProtocolError):
self.v1.validate_identity(result, SN, "wrong-model")
def test_dev_id_cannot_substitute_for_dev_type(self):
body = info_body()
body["dev_type"], body["dev_id"] = "OTHER-MODEL", MODEL
result, _ = self.tcp_query(body)
with self.assertRaises(self.v1.ProtocolError):
self.v1.validate_identity(result, SN, MODEL)
def test_identify_observes_model_with_only_full_sn(self):
code, result, sock, connect = self.cli(["identify", "--host", IP, "--expected-sn", SN])
self.assertEqual(code, 0)
self.assertEqual(result["status"], "IDENTITY_OBSERVED")
self.assertEqual(result["models"], [MODEL])
self.assertTrue(result["sn_verified"])
self.assertFalse(result["model_verified"])
self.assertFalse(result["write_executed"])
self.assertEqual(connect.call_args.args[0], (IP, 4941))
self.assertEqual(sock.sent, [bytes.fromhex("5a0e210400000000")])
def test_info_model_mismatch_fails_after_one_read(self):
body = info_body()
body["dev_type"] = "WRONG-MODEL"
code, result, sock, connect = self.cli(["info", "--host", IP, *self.identity()], body)
self.assertEqual(code, 1)
self.assertEqual(result["status"], "ERROR")
self.assertEqual(connect.call_count, 1)
self.assertTrue(sock.closed)
def test_verify_native_fields_and_missing_outer_without_alias(self):
for field, expected in [("firmware", "PASS"), ("dev_ver", "PASS"), ("outer.version", "INCOMPLETE")]:
with self.subTest(field=field):
code, result, _, _ = self.cli(["verify-version", "--host", IP, *self.identity(),
"--target-version", TARGET, "--version-field", field])
self.assertEqual(result["status"], expected)
self.assertEqual(result["firmware_verified"], expected == "PASS")
self.assertEqual(result["evidence_source"], "live_v1_2104")
self.assertEqual(code, 0 if expected == "PASS" else 1)
def test_version_mismatch_and_missing_firmware_do_not_use_hardware(self):
for actual, status in [("OLD-VERSION", "FAIL"), ("", "INCOMPLETE")]:
body = info_body()
body.update(firmware=actual, hardware=TARGET)
code, result, _, _ = self.cli(["verify-version", "--host", IP, *self.identity(),
"--target-version", TARGET, "--version-field", "firmware"], body)
self.assertEqual(code, 1)
self.assertEqual(result["status"], status)
self.assertFalse(result["firmware_verified"])
def test_literal_outer_version_can_be_observed_without_alias_or_other_nested_data(self):
for literal, expected in [(TARGET, "PASS"), ("OTHER", "FAIL"), ({}, "INCOMPLETE")]:
body = info_body(outer={"version": literal, "unrelated": "discard"})
code, result, _, _ = self.cli(["verify-version", "--host", IP, *self.identity(),
"--target-version", TARGET, "--version-field", "outer.version"], body)
self.assertEqual(result["status"], expected)
self.assertEqual(code, 0 if expected == "PASS" else 1)
self.assertNotIn("unrelated", result["response"]["data"].get("outer", {}))
def test_recorded_response_requires_v1_envelope_and_no_network(self):
path = Path(self.temp.name) / "recorded.json"
path.write_text(json.dumps({"cmd": 30101, "code": 0, "data": info_body()}), encoding="utf-8")
code, result, _, connect = self.cli(["verify-version", "--response", str(path), *self.identity(),
"--target-version", TARGET, "--version-field", "firmware"])
self.assertEqual(code, 1)
self.assertEqual(result["status"], "ERROR")
self.assertFalse(connect.called)
def test_v1_upgrade_preflight_hashes_and_reads_but_never_sends_upgrade(self):
code, result, sock, connect = self.cli(["upgrade", "--host", IP, *self.identity(),
"--rom", str(self.rom), "--rom-sha256", self.sha,
"--target-version", TARGET, "--version-field", "outer.version"])
self.assertEqual(code, 0)
self.assertEqual(result["status"], "PREFLIGHT_ONLY")
self.assertTrue(result["preflight_complete"])
self.assertFalse(result["write_executed"])
self.assertTrue(result["write_supported"])
self.assertFalse(result["selected_version_field_available"])
self.assertEqual(result["rom"]["sha256"], self.sha)
self.assertEqual(connect.call_count, 1)
self.assertEqual(sock.sent, [bytes.fromhex("5a0e210400000000")])
def test_udp_request_reply_session_prefix_and_dedup(self):
reply = (wire(RESPONSE, discovery_body()), (IP, 4942))
own_request = (wire(REQUEST, {"session": SESSION}), ("192.0.2.20", 4942))
result, sock = self.discover([own_request, reply, reply], expected_sn=SN,
interfaces=[interface(), interface("192.0.2.21/24")])
self.assertEqual(sock.bindings, [("0.0.0.0", 4942)])
self.assertEqual(sock.sent, [(wire(REQUEST, {"session": SESSION}), ("192.0.2.255", 4942))])
self.assertTrue(sock.closed)
self.assertEqual(result["candidate_count"], 1)
self.assertTrue(result["identity_verified"])
self.assertEqual(result["matches"][0]["source_ip"], IP)
self.assertIn("v1_own_request_ignored", [event["event"] for event in self.logger.events])
def test_udp_rejects_unrelated_sessions_malformed_mac_and_frames(self):
bodies = [discovery_body(session=SESSION + 1), discovery_body(session=True),
discovery_body(sn=""), discovery_body(mac=[]), discovery_body(code=False)]
replies = [(wire(RESPONSE, body), (IP, 4942)) for body in bodies]
replies += [(b"bad", (IP, 4942)), (wire(RESPONSE, discovery_body()), (IP, 4942))]
result, _ = self.discover(replies, expected_sn=SN)
self.assertTrue(result["identity_verified"])
self.assertEqual(sum(e["event"] == "v1_invalid_response" for e in self.logger.events), 6)
def test_udp_same_sn_conflicting_ip_mac_or_model_blocks_selection(self):
for body, peer in [(discovery_body(), ("192.0.2.11", 4942)),
(discovery_body(mac="00:00:5E:00:53:02"), (IP, 4942)),
(discovery_body(dev_type="OTHER-MODEL"), (IP, 4942))]:
replies = [(wire(RESPONSE, discovery_body()), (IP, 4942)), (wire(RESPONSE, body), peer)]
with self.subTest(body=body, peer=peer):
result, _ = self.discover(replies, expected_sn=SN)
self.assertEqual(result["status"], "BLOCKED")
self.assertEqual(result["matches"], [])
self.assertEqual(result["identity_conflicts"], [SN])
def test_udp_exact_sn_and_model_filters_no_suffix_match(self):
reply = [(wire(RESPONSE, discovery_body()), (IP, 4942))]
for filters in [dict(expected_sn="0001"), dict(expected_sn=SN, expected_model="wrong")]:
with self.subTest(filters=filters):
result, _ = self.discover(reply, **filters)
self.assertEqual(result["status"], "BLOCKED")
self.assertFalse(result["identity_verified"])
def test_udp_no_response_is_blocked_for_target_not_an_offline_claim(self):
result, sock = self.discover(expected_sn=SN)
self.assertEqual(result["status"], "BLOCKED")
self.assertEqual(result["reason"], "target_not_uniquely_found")
self.assertTrue(sock.closed)
def test_udp_port_conflict_and_send_failure_close_without_retry(self):
for options in [dict(bind_failure=True), dict(send_failure=True)]:
sock = UdpSocket(**options)
with patch.object(self.v1.socket, "socket", return_value=sock) as create, \
self.assertRaises(self.v1.ProtocolError):
self.v1.discover([interface()], 1, 4942, self.logger, session=SESSION)
self.assertEqual(create.call_count, 1)
self.assertEqual(len(sock.bindings), 1)
self.assertTrue(sock.closed)
def test_discovery_cli_routes_actual_v1_frames(self):
sock = UdpSocket([(wire(RESPONSE, discovery_body()), (IP, 4942))])
output = io.StringIO()
with patch.object(self.v1.socket, "socket", return_value=sock), \
patch.object(self.v1.secrets, "randbelow", return_value=SESSION - 1), \
patch.object(self.v1.select, "select", side_effect=sock.select), contextlib.redirect_stdout(output):
code = self.adapter.main(["discover", "--protocol", "v1", "--interface", "192.0.2.20/24",
"--expected-sn", SN])
result = json.loads(output.getvalue())
self.assertEqual(code, 0)
self.assertTrue(result["identity_verified"])
self.assertEqual(sock.sent[0][0][:4], bytes.fromhex("5a0eff00"))
def test_invalid_v1_interface_reports_structured_error_before_network(self):
output = io.StringIO()
with patch.object(self.v1.socket, "socket", side_effect=AssertionError("No network")), \
contextlib.redirect_stdout(output):
code = self.adapter.main(["discover", "--protocol", "v1", "--interface", "invalid"])
self.assertEqual(code, 1)
self.assertEqual(json.loads(output.getvalue())["status"], "ERROR")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,311 @@
"""Run the actual legacy V1 sender against offline, fragmented socket fixtures."""
from __future__ import annotations
import contextlib
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import struct
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
SN = "SYNTHETIC-V1-UPGRADE-SN-0001"
MODEL = "SYNTHETIC-V1-MODEL"
HOST = "192.0.2.10"
CMD = 0x5A0E2170
INFO = 0x5A0E2104
def packet(command=CMD, body=None):
data = json.dumps({"code": 0} if body is None else body).encode("utf-8")
return struct.pack("!II", command, len(data)) + data
class Clock:
value = 0.0
def __call__(self):
return self.value
class UploadSocket:
def __init__(self, reply=None, stop_after=None, clock=None, step=0, on_send=None,
send_error=None, recv_error=None):
self.pending = bytearray(packet() if reply is None else reply)
self.sent = bytearray()
self.timeouts = []
self.closed = False
self.stop_after, self.clock, self.step, self.on_send = stop_after, clock, step, on_send
self.send_error, self.recv_error = send_error, recv_error
def __enter__(self):
return self
def __exit__(self, *_args):
self.closed = True
def settimeout(self, value):
self.timeouts.append(value)
def send(self, data):
if self.send_error:
raise self.send_error
if self.on_send:
action, self.on_send = self.on_send, None
action()
if self.clock:
self.clock.value += self.step
count = min(len(data), 7)
if self.stop_after is not None:
count = min(count, max(0, self.stop_after - len(self.sent)))
self.sent.extend(data[:count])
return count
def recv(self, count):
if self.recv_error:
raise self.recv_error
if self.clock:
self.clock.value += self.step
count = min(count, 3, len(self.pending))
chunk = bytes(self.pending[:count])
del self.pending[:count]
return chunk
class InfoSocket(UploadSocket):
def __init__(self, **changes):
body = dict(code=0, sn=SN, dev_type=MODEL, firmware="OLD", dev_ver="OLD-BUILD")
body.update(changes)
super().__init__(reply=packet(INFO, body))
def sendall(self, data):
self.sent.extend(data)
class V1UpgradeTests(unittest.TestCase):
def setUp(self):
spec = importlib.util.spec_from_file_location("v1_upgrade_test_adapter", ROOT / "scripts" / "device_adapter.py")
self.adapter = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.adapter)
self.v1 = self.adapter.v1
self.logger = self.adapter.EventCollector()
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.rom = Path(self.temp.name) / "fixture.bin"
self.content = b"abc"
self.rom.write_bytes(self.content)
self.sha = hashlib.sha256(self.content).hexdigest()
def transfer(self, sock=None, **changes):
sock = sock or UploadSocket()
options = dict(host=HOST, rom_path=self.rom, rom_size=len(self.content),
connect_timeout=5, stall_timeout=15, total_timeout=60,
chunk_size=2, logger=self.logger, expected_sha256=self.sha)
options.update(changes)
with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect:
result = self.v1.transfer_rom(**options)
self.assertEqual(connect.call_count, 1)
self.assertEqual(connect.call_args.args[0], (HOST, 4941))
self.assertTrue(sock.closed)
return result, sock
def args(self):
return ["upgrade", "--protocol", "v1", "--host", HOST, "--expected-sn", SN,
"--expected-model", MODEL, "--rom", str(self.rom), "--target-version", "NEW",
"--version-field", "outer.version", "--execute", "--rom-sha256", self.sha]
def cli(self, args=None, info=None, upload=None):
info, upload = info or InfoSocket(), upload or UploadSocket()
connections = []
def connect(address, timeout):
connections.append(address)
self.assertEqual(address, (HOST, 4941))
return info if len(connections) == 1 else upload
output = io.StringIO()
with patch.object(self.v1.socket, "create_connection", side_effect=connect), \
patch.object(self.adapter.protocol, "query_device_info", side_effect=AssertionError("No V2 query")), \
patch.object(self.adapter.protocol, "transfer_rom", side_effect=AssertionError("No V2 transfer")), \
contextlib.redirect_stdout(output):
code = self.adapter.main(self.args() if args is None else args)
return code, json.loads(output.getvalue()), connections, upload
def test_v1_original_changed_on_connect_sends_approved_snapshot(self):
for replacement in (b"xyz", b"x", b"longer"):
self.rom.write_bytes(self.content)
info, upload = InfoSocket(), UploadSocket()
count = 0
def connect(address, timeout):
nonlocal count
count += 1
if count == 1:
return info
self.rom.write_bytes(replacement)
return upload
output = io.StringIO()
with patch.object(self.v1.socket, "create_connection", side_effect=connect), \
contextlib.redirect_stdout(output):
code = self.adapter.main(self.args())
self.assertEqual(code, 0, output.getvalue())
self.assertEqual(bytes(upload.sent)[40:], self.content)
def test_known_md5_and_exact_big_endian_frame_with_fragmented_io(self):
result, sock = self.transfer(UploadSocket(packet(body={"code": 0, "desc": "successed", "other": "discard"})))
expected = bytes.fromhex("5a0e217000000023") + b"900150983cd24fb0d6963f7d28e17f72" + b"abc"
self.assertEqual(bytes(sock.sent), expected)
self.assertEqual(result["bytes_sent"], 3)
self.assertEqual(result["wire_bytes_sent"], 43)
self.assertTrue(result["acknowledged"])
self.assertEqual(result["response_code"], 0)
self.assertNotIn("other", result["response"])
self.assertNotIn("discard", json.dumps(self.logger.events))
def test_invalid_ack_frames_fail_and_close_without_reconnect(self):
replies = [b"", packet()[:-1], packet(CMD + 1), struct.pack("!II", CMD, 0),
struct.pack("!II", CMD, self.v1.MAX_JSON_SIZE + 1),
struct.pack("!II", CMD, 1) + b"{", packet(body=[]),
packet(body={}), packet(body={"code": False}), packet(body={"code": "0"}),
packet(body={"code": 1}), packet(body={"code": 0, "desc": []})]
for reply in replies:
with self.subTest(reply=reply[:20]):
sock = UploadSocket(reply)
with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect, \
self.assertRaises(self.v1.ProtocolError):
self.v1.transfer_rom(HOST, self.rom, 3, 5, 15, 60, 2, self.logger, self.sha)
self.assertEqual(connect.call_count, 1)
self.assertTrue(sock.closed)
def test_size_and_digest_mismatch_stop_before_network(self):
for changes in [{"rom_size": 4}, {"rom_size": 2}, {"rom_size": 0},
{"rom_size": 0xFFFFFFFF}, {"expected_sha256": "0" * 64},
{"expected_sha256": "bad"}]:
with self.subTest(changes=changes), \
patch.object(self.v1.socket, "create_connection", side_effect=AssertionError("No network")), \
self.assertRaises(self.v1.ProtocolError):
options = dict(host=HOST, rom_path=self.rom, rom_size=3, connect_timeout=5,
stall_timeout=15, total_timeout=60, chunk_size=2,
logger=self.logger, expected_sha256=self.sha)
options.update(changes)
self.v1.transfer_rom(**options)
def test_stalled_partial_write_is_not_acknowledged_or_retried(self):
self.content = b"x" * 100
self.rom.write_bytes(self.content)
self.sha = hashlib.sha256(self.content).hexdigest()
sock = UploadSocket(stop_after=49)
with self.assertRaises(self.v1.ProtocolError):
self.transfer(sock)
self.assertTrue(sock.closed)
error = self.logger.events[-1]
self.assertEqual(error["bytes_sent"], 9)
self.assertEqual(error["wire_bytes_sent"], 49)
self.assertFalse(error["automatic_retry"])
def test_total_deadline_is_not_reset_by_partial_write_progress(self):
clock = Clock()
sock = UploadSocket(clock=clock, step=0.3)
with patch.object(self.v1.time, "monotonic", side_effect=clock), self.assertRaises(TimeoutError):
self.transfer(sock, total_timeout=1)
self.assertTrue(sock.closed)
self.assertLess(len(sock.sent), 43)
self.assertLessEqual(max(sock.timeouts), 1)
def test_fragmented_ack_also_obeys_total_deadline(self):
clock = Clock()
sock = UploadSocket(clock=clock, step=0.1)
with patch.object(self.v1.time, "monotonic", side_effect=clock), self.assertRaises(TimeoutError):
self.transfer(sock, total_timeout=1.05)
self.assertEqual(len(sock.sent), 43)
self.assertTrue(sock.closed)
def test_socket_stall_and_connect_timeouts_are_bounded_and_not_retried(self):
for sock in [UploadSocket(send_error=TimeoutError("write stalled")),
UploadSocket(recv_error=TimeoutError("reply stalled"))]:
with self.subTest(stage=bool(sock.send_error)), self.assertRaises(TimeoutError):
self.transfer(sock, connect_timeout=2, stall_timeout=0.25)
self.assertTrue(sock.closed)
self.assertTrue(all(0 < value <= 0.25 for value in sock.timeouts))
def test_modified_open_rom_during_upload_cannot_report_success(self):
sock = UploadSocket(on_send=lambda: self.rom.write_bytes(b"xyz"))
with self.assertRaisesRegex(self.v1.ProtocolError, "changed during upload"):
self.transfer(sock)
self.assertTrue(sock.closed)
self.assertFalse(any(e["event"] == "v1_upgrade_acknowledged" for e in self.logger.events))
def test_execute_routes_real_v1_sender_and_requires_independent_acceptance(self):
code, result, connections, sock = self.cli()
self.assertEqual(code, 0)
self.assertEqual(connections, [(HOST, 4941), (HOST, 4941)])
self.assertEqual(result["status"], "PROTOCOL_SUCCESS_POSTCHECK_REQUIRED")
self.assertTrue(result["write_executed"])
self.assertTrue(result["protocol_success"])
self.assertFalse(result["firmware_verified"])
self.assertTrue(result["postcheck_required"])
self.assertFalse(result["selected_version_field_available"])
self.assertEqual(bytes(sock.sent[40:]), b"abc")
def test_bad_digest_and_non_numeric_target_block_before_query(self):
for flag, value in [("--rom-sha256", "0" * 64), ("--host", "synthetic.invalid")]:
args = self.args()
args[args.index(flag) + 1] = value
code, result, connections, _ = self.cli(args)
self.assertEqual(code, 1)
self.assertEqual(connections, [])
self.assertFalse(result["write_may_have_started"])
def test_exact_sn_and_model_mismatch_stop_after_only_info(self):
for changes in [{"sn": "0001"}, {"dev_type": "WRONG"}]:
code, result, connections, sock = self.cli(info=InfoSocket(**changes))
self.assertEqual(code, 1)
self.assertEqual(connections, [(HOST, 4941)])
self.assertFalse(result["write_may_have_started"])
self.assertFalse(sock.sent)
def test_rom_is_rechecked_after_network_preflight_before_write(self):
info = InfoSocket()
original = info.recv
def receive(count):
data = original(count)
if not info.pending:
self.rom.write_bytes(b"xyz")
return data
info.recv = receive
code, result, connections, sock = self.cli(info=info)
self.assertEqual(code, 1)
self.assertEqual(connections, [(HOST, 4941)])
self.assertFalse(result["write_may_have_started"])
self.assertFalse(sock.sent)
def test_transfer_failure_records_uncertain_result_without_retry(self):
code, result, connections, sock = self.cli(upload=UploadSocket(stop_after=41))
self.assertEqual(code, 1)
self.assertEqual(len(connections), 2)
self.assertTrue(result["write_may_have_started"])
self.assertTrue(result["postcheck_required"])
self.assertFalse(result["automatic_retry"])
self.assertTrue(sock.closed)
def test_success_code_cannot_override_incomplete_upload_or_bad_ack(self):
base = {"acknowledged": True, "response_command": CMD, "response_code": 0, "bytes_sent": 3}
for changes in [{"bytes_sent": 0}, {"bytes_sent": 2}, {"bytes_sent": True},
{"response_code": False}, {"response_command": INFO}, {"acknowledged": False}]:
with self.subTest(changes=changes), \
patch.object(self.v1, "transfer_rom", return_value=dict(base, **changes)) as transfer:
code, result, connections, _ = self.cli()
self.assertEqual(code, 1)
self.assertEqual(result["status"], "ERROR")
self.assertFalse(result["firmware_verified"])
self.assertEqual(transfer.call_count, 1)
self.assertEqual(len(connections), 1)
if __name__ == "__main__":
unittest.main()