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,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()
|
||||
Reference in new issue
Block a user