From 89dcd8208930bd546e8ea02a2d9fc06650d8dcfe Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 18 Oct 2026 00:24:03 -0700 Subject: [PATCH] Add guarded hardware acceptance and restoration tools --- .github/workflows/run-unit-tests.yml | 8 + test/test_companion_release_acceptance.py | 176 +++++ test/test_ethernet_serial_guard.py | 4 +- test/test_mcfirmware_bench_app_flash.py | 235 +++++++ test/test_mcfirmware_bench_backup.py | 44 ++ test/test_mcfirmware_bench_data_restore.py | 274 ++++++++ test/test_official_console_acceptance.py | 302 +++++++++ tools/hil/companion_release_acceptance.py | 425 +++++++++++++ tools/hil/mcfirmware_bench_app_flash.sh | 389 ++++++++++++ tools/hil/mcfirmware_bench_backup.sh | 218 +++++++ tools/hil/mcfirmware_bench_data_restore.sh | 358 +++++++++++ tools/hil/official_console_acceptance.py | 515 +++++++++++++++ tools/hil/room_browser_acceptance.py | 588 +++++++++++++++++ tools/hil/room_webconfig_acceptance.py | 636 +++++++++++++++++++ tools/hil/serial_session.py | 7 +- tools/hil/test_room_webconfig_acceptance.py | 295 +++++++++ tools/hil/test_serial_session.py | 41 +- variants/sensecap_indicator-espnow/README.md | 32 +- 18 files changed, 4529 insertions(+), 18 deletions(-) create mode 100644 test/test_companion_release_acceptance.py create mode 100644 test/test_mcfirmware_bench_app_flash.py create mode 100644 test/test_mcfirmware_bench_backup.py create mode 100644 test/test_mcfirmware_bench_data_restore.py create mode 100644 test/test_official_console_acceptance.py create mode 100644 tools/hil/companion_release_acceptance.py create mode 100644 tools/hil/mcfirmware_bench_app_flash.sh create mode 100755 tools/hil/mcfirmware_bench_backup.sh create mode 100755 tools/hil/mcfirmware_bench_data_restore.sh create mode 100755 tools/hil/official_console_acceptance.py create mode 100644 tools/hil/room_browser_acceptance.py create mode 100644 tools/hil/room_webconfig_acceptance.py create mode 100644 tools/hil/test_room_webconfig_acceptance.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 046989e84..8ae675d0e 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -358,6 +358,7 @@ jobs: python3 -B test/test_mqtt_diagnostics.py -v python3 -B test/test_mqtt_advert_verify.py -v python3 -B test/test_mqtt_publish_ack.py -v + python3 -B test/test_companion_mqtt_diagnostics.py -v python3 -B test/test_wifi_network_power_policy.py -v python3 -B test/test_mqtt_wifi_network_power.py -v python3 -B test/test_companion_wifi_network_power.py -v @@ -625,7 +626,14 @@ jobs: run: | python3 -B test/test_cpp_source.py -v python3 -B test/test_official_web_console.py -v + python3 -B test/test_official_console_acceptance.py -v python3 -B test/test_hil_linux_web_serial.py -v + python3 -B tools/hil/test_serial_session.py -v + python3 -B test/test_companion_release_acceptance.py -v + python3 -B test/test_mcfirmware_bench_backup.py -v + python3 -B test/test_mcfirmware_bench_app_flash.py -v + python3 -B test/test_mcfirmware_bench_data_restore.py -v + python3 -B tools/hil/test_room_webconfig_acceptance.py -v python3 -B test/test_hil_slowfi_guard.py -v - name: Verify official setup GUI initial time command deadline diff --git a/test/test_companion_release_acceptance.py b/test/test_companion_release_acceptance.py new file mode 100644 index 000000000..a7c6c57bf --- /dev/null +++ b/test/test_companion_release_acceptance.py @@ -0,0 +1,176 @@ +"""Offline release identity gates; no serial port, SDK, browser or radio.""" +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import struct +import sys +import tempfile +from types import ModuleType, SimpleNamespace +import unittest +from unittest import mock + +HIL = Path(__file__).resolve().parents[1] / "tools/hil" +sys.path.insert(0, str(HIL)) +# The acceptance code's serial entry points are never called by this fixture. +# Keep the offline contract runnable without installing a hardware dependency. +serial = ModuleType("serial") +serial.tools = ModuleType("serial.tools") +serial.tools.list_ports = SimpleNamespace(comports=lambda: []) +with mock.patch.dict(sys.modules, {"serial": serial, "serial.tools": serial.tools}): + spec = importlib.util.spec_from_file_location("companion_acceptance", HIL / "companion_release_acceptance.py") + acceptance = importlib.util.module_from_spec(spec) + spec.loader.exec_module(acceptance) + +VERSION = "v1.18.0.1-halo-keymind-cascade-dev-652289ee" +BUILD = "18-Oct-2026" +FULL_VERSION = VERSION + " (Build: " + BUILD + ")" + + +def fixed(value, size): + return value.encode("ascii").ljust(size, b"\0") + + +def self_info(): + frame = bytearray(58) + frame[:4] = bytes((5, 1, 20, 22)) + frame[4:36] = bytes(range(1, 33)) + struct.pack_into(">4)) +def endf_identity(image): + trailer=image[-56:] + if len(trailer)!=56 or trailer[:4]!=b'EndF' or struct.unpack_from('&2; exit 9; } +fuser() { printf 'unguarded_fuser_called\n' >&2; return 1; } +sudo() { + [ "$#" = 3 ] && [ "$1" = -n ] && [ "$2" = fuser ] && [ "$3" = /dev/ttyACM999 ] || exit 8 + printf '%s' "$FAKE_STDOUT" + printf '%s' "$FAKE_STDERR" >&2 + return "$FAKE_STATUS" +} +''' + return subprocess.run(["bash", "-c", script + guard + "\nbackup_assert_unowned /dev/ttyACM999\n"], + env={"FAKE_STATUS": str(status), "FAKE_STDOUT": stdout, + "FAKE_STDERR": stderr}, capture_output=True, text=True) + + def test_root_capable_quiet_no_owner_probe_is_accepted(self): + result = self.guard(1) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_owner_permission_failure_or_ambiguous_probe_refuses(self): + for status, stdout, stderr in ((0, "12345", ""), (1, "", "sudo: a password is required"), + (1, "", "Usage: fuser"), (1, "12345", ""), (2, "", "")): + with self.subTest(status=status, has_stdout=bool(stdout), has_stderr=bool(stderr)): + result = self.guard(status, stdout, stderr) + self.assertEqual(result.returncode, 9) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mcfirmware_bench_data_restore.py b/test/test_mcfirmware_bench_data_restore.py new file mode 100644 index 000000000..7261b9eee --- /dev/null +++ b/test/test_mcfirmware_bench_data_restore.py @@ -0,0 +1,274 @@ +"""Offline guarded-flasher contract tests: no device or PlatformIO process. + +The reviewed real flasher and parser are pinned runtime inputs, outside this +repository. This fixture supplies a separately pinned, pure parser and inert +flasher functions; serial commands operate only a temporary bytearray file. +""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "tools/hil/mcfirmware_bench_data_restore.sh" +MAC = "441bf669c9c0" +CAPACITY = 0x800000 +NVS = (0x9000, 0x5000) +SPIFFS = (0x30000, 0x1000) +APP = (0x10000, 256) + +PARSER = r''' +import hashlib,json,re,struct,zlib +class ExpansionError(Exception): pass +def safe_json(data): return json.loads(data) +def parse_table(data,capacity): + if len(data)!=4096: raise ExpansionError('bad table length') + result=[] + for offset in range(0,len(data),32): + row=data[offset:offset+32] + if row[:2]==b'\xff\xff': break + if row[:2]!=b'\xaa\x50': raise ExpansionError('bad table entry') + kind,subtype,address,size,name,flags=struct.unpack_from('capacity: + raise ExpansionError('bad table geometry') + result.append(dict(type=kind,subtype=subtype,offset=address,size=size,name=name.rstrip(b'\0').decode(),flags=flags)) + ordered=sorted(result,key=lambda p:p['offset']) + if any(a['offset']+a['size']>b['offset'] for a,b in zip(ordered,ordered[1:])): + raise ExpansionError('overlapping table') + return result +def active_ota_offset(parts,data): + if len(data)!=8192: raise ExpansionError('bad OTA size') + apps=sorted((p for p in parts if p['type']==0),key=lambda p:p['subtype']) + if data==b'\xff'*8192: return apps[0]['offset'] + sequence=struct.unpack_from('> "$MOCK_ACTIONS" + ESP32_FLASH_EXPECTED_MAC=441bf669c9c0;ESP32_SESSION_IS_S3=1 +} +selected_flash_serial_port() { + printf '%s\n' "$1" +} +esp32_record_and_verify_probe_output() { + [[ "$ESP32_FLASH_EXPECTED_MAC" == 441bf669c9c0 ]] +} +esp32_verified_destructive_port() { + printf '%s\n' "$1" +} +esp32_write_after_mode() { + printf '%s\n' "${MOCK_AFTER:-no-reset}" +} +finish_esp32_flash_session() { + printf 'finish\n' >> "$MOCK_ACTIONS" +} +run_esp32_session_esptool() { + printf '%s\n' "$*" >> "$MOCK_ACTIONS" + shift + [[ "$1" == --after && ( "$2" == no-reset || "$2" == hard-reset ) && "$3" == --baud && "$4" == 115200 ]] || return 97 + shift 4 + case "$1" in + flash-id) printf 'Chip is %s\nMAC:44:1b:f6:69:c9:c0\nDetected flash size:8MB\n' "${MOCK_CHIP:-ESP32-S3}" ;; + read-mac) printf 'MAC:44:1b:f6:69:c9:c0\n' ;; + read-flash) python3 - "$MOCK_FLASH" "$2" "$3" "$4" <<'READ' +import os,sys +with open(sys.argv[1],'rb') as src: + src.seek(int(sys.argv[2],0)); data=src.read(int(sys.argv[3],0)) +if os.environ.get('MOCK_BAD_READBACK') and 'nvs-readback' in sys.argv[4]: + data=bytes([data[0]^1])+data[1:] +open(sys.argv[4],'wb').write(data) +READ + ;; + write-flash) python3 - "$MOCK_FLASH" "$2" "$3" <<'WRITE' +import os,sys +with open(sys.argv[1],'r+b') as dst: + dst.seek(int(sys.argv[2],0));dst.write(open(sys.argv[3],'rb').read()) + if int(sys.argv[2],0)==0x9000 and os.environ.get('MOCK_MUTATE_AFTER_WRITE'): + at=int(os.environ['MOCK_MUTATE_AFTER_WRITE'],0) + dst.seek(at);old=dst.read(1);dst.seek(at);dst.write(bytes([old[0]^1])) +WRITE + ;; + *) return 98 ;; + esac +} +''' + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +@unittest.skipUnless(os.name == "posix" and all(shutil.which(c) for c in + ("bash", "python3", "awk", "stat", "sha256sum", "realpath", "timeout")), + "POSIX shell tools required; no physical device required") +class DataRestoreTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="mcfirmware-data-offline-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.root.chmod(0o700) + self.bin = self.root / "bin" + self.bin.mkdir() + scripts = {"readlink": "#!/bin/sh\nprintf '%s\\n' /dev/null\n", + "fuser": "#!/bin/sh\nexit 1\n", "pipx": "#!/bin/sh\nexit 99\n", + "udevadm": "#!/bin/sh\nexit 99\n", + "sudo": '#!/bin/sh\n[ "$1" = -n ] && [ "$2" = fuser ] || exit 98\nshift\nexec "$@"\n'} + for name, text in scripts.items(): + p = self.bin / name + p.write_text(text) + p.chmod(0o700) + self.flasher = self.root / "flasher/mcfirmware.sh" + helper = self.flasher.parent / "tools/meshcore_partition_expansion.py" + helper.parent.mkdir(parents=True) + helper.write_text(PARSER) + self.flasher.write_text('MESHCORE_PARTITION_EXPANSION_TOOL_SHA256="' + digest(helper.read_bytes()) + '"\n' + + 'MESHCORE_USB_RESET_TOOL_SHA256="' + '0' * 64 + '"\n' + + 'MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256="' + '0' * 64 + '"\n' + FLASHER + + ''.join(f'inert_{i}() {{\n :\n}}\n' for i in range(100))) + table = b"".join(struct.pack('<2sBBII16sI', b'\xaa\x50', kind, subtype, offset, length, name.encode(), 0) + for name, kind, subtype, offset, length in [ + ('nvs', 1, 2, *NVS), ('otadata', 1, 0, 0xe000, 0x2000), + ('app0', 0, 0x10, 0x10000, 0x10000), ('app1', 0, 0x11, 0x20000, 0x10000), + ('spiffs', 1, 0x82, *SPIFFS)]) + self.table = table.ljust(4096, b'\xff') + backup = bytearray(b'\xff' * CAPACITY) + backup[0x8000:0x9000] = self.table + backup[NVS[0]:sum(NVS)] = b'\x11' * NVS[1] + backup[SPIFFS[0]:sum(SPIFFS)] = b'\x22' * SPIFFS[1] + self.backup = self.root / "backup.private.bin" + self.backup.write_bytes(backup) + self.backup.chmod(0o600) + self.manifest = self.root / "backup.private.bin.json" + self.manifest.write_text(json.dumps(dict(complete=True, operation='read-only-flash-backup', chip='ESP32-S3', + device_mac=MAC, offset=0, length=CAPACITY, flash_bytes=CAPACITY, sha256=digest(backup), artifact=self.backup.name))) + self.manifest.chmod(0o600) + self.app = bytes(range(256)) + live = bytearray(backup) + live[NVS[0]:sum(NVS)] = b'\x33' * NVS[1] + live[SPIFFS[0]:sum(SPIFFS)] = b'\x44' * SPIFFS[1] + live[APP[0]:sum(APP)] = self.app + self.before = bytes(live) + self.flash = self.root / "live-flash.bin" + self.flash.write_bytes(live) + self.trace = self.root / "actions" + self.bench = self.root / "bench" + self.bench.mkdir(mode=0o700) + self.args = ['bash', str(TOOL), '--port', '/dev/ttyACM99', '--usb-path', 'exact-path', '--usb-id', 'exact-id', + '--device', 'MOCK', '--bench-dir', str(self.bench), '--expect-mac', MAC, + '--backup', str(self.backup), '--backup-manifest', str(self.manifest), '--backup-sha256', digest(backup), + '--expect-partition-sha256', digest(self.table), '--app-address', hex(APP[0]), + '--app-length', str(APP[1]), '--expect-app-sha256', digest(self.app), '--allow-write', + '--flasher', str(self.flasher), '--flasher-sha256', digest(self.flasher.read_bytes())] + + def run_tool(self, **env): + result = subprocess.run(self.args, env=dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ['PATH'], + MOCK_FLASH=str(self.flash), MOCK_ACTIONS=str(self.trace), **env), capture_output=True, text=True, timeout=15) + self.actions = self.trace.read_text().splitlines() if self.trace.exists() else [] + return result + + def assert_no_writes(self, result): + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(any('write-flash' in line for line in self.actions), self.actions) + self.assertNotIn('finish', self.actions) + + def test_only_original_nvs_and_spiffs_written_and_every_other_byte_preserved(self): + result = self.run_tool() + self.assertEqual(result.returncode, 0, result.stderr) + writes = [line.split() for line in self.actions if 'write-flash' in line] + self.assertEqual([int(line[6]) for line in writes], [NVS[0], SPIFFS[0]]) + after = self.flash.read_bytes() + expected = bytearray(self.before) + original = self.backup.read_bytes() + for offset, length in (NVS, SPIFFS): + expected[offset:offset + length] = original[offset:offset + length] + self.assertEqual(after, expected) + self.assertEqual(self.actions[-1], 'finish') + state = next(self.bench.glob('.mcfirmware-data-restore.*')) + report = json.loads((state / 'result.json').read_text()) + self.assertTrue(report['active_application_unchanged'] and report['data_readbacks_verified']) + self.assertEqual({p.stat().st_mode & 0o777 for p in state.iterdir() if p.is_file()}, {0o600}) + + def test_uart_final_probe_reset_then_real_finish(self): + result = self.run_tool(MOCK_AFTER='hard-reset') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--after hard-reset --baud 115200 read-mac', self.actions[-2]) + self.assertEqual(self.actions[-1], 'finish') + + def test_changed_live_table_refused_before_data_writes(self): + data = bytearray(self.flash.read_bytes()) + data[0x800c] ^= 1 + self.flash.write_bytes(data) + self.assert_no_writes(self.run_tool()) + + def test_changed_live_ota_refused_before_data_writes(self): + data = bytearray(self.flash.read_bytes()) + data[0xe000] = 1 + self.flash.write_bytes(data) + self.assert_no_writes(self.run_tool()) + + def test_changed_live_current_app_refused_before_data_writes(self): + data = bytearray(self.flash.read_bytes()) + data[APP[0]] ^= 1 + self.flash.write_bytes(data) + self.assert_no_writes(self.run_tool()) + + def test_wrong_chip_refused_before_data_writes(self): + self.assert_no_writes(self.run_tool(MOCK_CHIP='ESP32-C3')) + + def test_wrong_backup_hash_refused_before_prepare(self): + self.args[self.args.index('--backup-sha256') + 1] = '0' * 64 + self.assert_no_writes(self.run_tool()) + self.assertNotIn('prepare', self.actions) + + def test_wrong_mac_refused_before_data_writes(self): + self.args[self.args.index('--expect-mac') + 1] = '112233445566' + self.assert_no_writes(self.run_tool()) + + def test_first_readback_fault_prevents_second_write_and_runtime_finish(self): + result = self.run_tool(MOCK_BAD_READBACK='1') + self.assertNotEqual(result.returncode, 0) + writes = [line.split() for line in self.actions if 'write-flash' in line] + self.assertEqual([int(line[6]) for line in writes], [NVS[0]]) + self.assertNotIn('finish', self.actions) + + def test_postwrite_table_ota_app_mutations_prevent_runtime_finish(self): + for address in (0x8000, 0xe000, APP[0]): + with self.subTest(changed_address=address): + self.flash.write_bytes(self.before) + self.trace.unlink(missing_ok=True) + result = self.run_tool(MOCK_MUTATE_AFTER_WRITE=hex(address)) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('finish', self.actions) + + def test_wrong_active_app_address_refused_before_prepare(self): + self.args[self.args.index('--app-address') + 1] = '0x20000' + self.assert_no_writes(self.run_tool()) + self.assertNotIn('prepare', self.actions) + + def test_root_owner_or_sudo_error_refused_before_prepare(self): + (self.bin / 'sudo').write_text('#!/bin/sh\nprintf "owner inspection unavailable" >&2\nexit 1\n') + self.assert_no_writes(self.run_tool()) + self.assertNotIn('prepare', self.actions) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_official_console_acceptance.py b/test/test_official_console_acceptance.py new file mode 100644 index 000000000..292fc011b --- /dev/null +++ b/test/test_official_console_acceptance.py @@ -0,0 +1,302 @@ +"""Offline owned-CDP startup regressions; never open a browser or serial port.""" +import importlib.util +import io +import json +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +HIL = Path(__file__).resolve().parents[1] / "tools/hil" +sys.path.insert(0, str(HIL)) +spec = importlib.util.spec_from_file_location("official_acceptance", HIL / "official_console_acceptance.py") +acceptance = importlib.util.module_from_spec(spec) +spec.loader.exec_module(acceptance) + + +class OwnedStartupTests(unittest.TestCase): + def fixture(self, *, fail_first_page=False, wrong_profile=False, pages=1): + requests, sockets, connections = [], [], [] + fail = [fail_first_page] + profile = Path("/private/owned-profile") + arguments = ["--user-data-dir=" + str(profile), "--remote-debugging-port=12345"] + if wrong_profile: + arguments[0] = "--user-data-dir=/private/another-browser" + + class Socket: + def __init__(self, browser): + self.browser, self.closed, self.queue = browser, False, [] + sockets.append(self) + + def send(self, text): + request = json.loads(text) + if self.browser: + # Browser events must not be mistaken for the command reply. + self.queue.extend([{"method": "Target.targetCreated"}, + {"id": request["id"], "result": {"arguments": arguments}}]) + else: + if fail[0]: + fail[0] = False + raise OSError("renderer connection interrupted") + self.queue.append({"id": request["id"], "result": {}}) + + def recv(self): + return json.dumps(self.queue.pop(0)) + + def settimeout(self, timeout): + self.timeout = timeout + + def close(self): + self.closed = True + + def connect(url, **kwargs): + connections.append((url, kwargs)) + return Socket("/browser/" in url) + + class Opener: + def open(self, request, timeout): + requests.append(request) + if request.endswith("/json/version"): + value = {"webSocketDebuggerUrl": "ws://127.0.0.1:12345/browser/owned", "Browser": "test Chromium"} + elif request.endswith("/json/list"): + value = [{"type": "page", "url": "about:blank", + "webSocketDebuggerUrl": f"ws://127.0.0.1:12345/page/{i}"} for i in range(pages)] + else: + raise AssertionError("Startup must reuse its initial page, never PUT /json/new") + return io.StringIO(json.dumps(value)) + + websocket = SimpleNamespace(create_connection=connect, + WebSocketTimeoutException=type("SocketTimeout", (Exception,), {}), + WebSocketConnectionClosedException=type("SocketClosed", (Exception,), {})) + return profile, websocket, Opener(), requests, sockets, connections + + def test_reuses_initial_page_and_matches_owned_reply_after_event(self): + profile, websocket, opener, requests, sockets, connections = self.fixture() + with patch.object(acceptance.urllib.request, "build_opener", return_value=opener): + page = acceptance.CDP(websocket, 12345, profile) + self.assertEqual(requests, ["http://127.0.0.1:12345/json/version", "http://127.0.0.1:12345/json/list"]) + self.assertEqual(page.browser_version, "test Chromium") + self.assertTrue(sockets[0].closed) + self.assertFalse(sockets[1].closed) + for _, kwargs in connections: + self.assertEqual(kwargs["http_no_proxy"], ["127.0.0.1", "localhost"]) + page.socket.close() + + def test_partial_page_socket_is_closed_and_retry_creates_no_new_target(self): + profile, websocket, opener, requests, sockets, _ = self.fixture(fail_first_page=True) + browser = SimpleNamespace(poll=lambda: None) + report = {} + with patch.object(acceptance.urllib.request, "build_opener", return_value=opener), \ + patch.object(acceptance.time, "sleep"): + page = acceptance.wait_for_owned_browser(websocket, 12345, profile, browser, report) + self.assertEqual(report["browser_startup"]["attempts"], 2) + self.assertEqual(report["browser_startup"]["last_phase"], "initial_page_enable") + self.assertTrue(sockets[0].closed and sockets[1].closed and sockets[2].closed) + self.assertFalse(sockets[3].closed) + self.assertEqual(sum(request.endswith("/json/list") for request in requests), 2) + self.assertTrue(all(isinstance(request, str) for request in requests)) + page.socket.close() + + def test_another_owned_profile_is_rejected_before_page_attach(self): + profile, websocket, opener, requests, sockets, _ = self.fixture(wrong_profile=True) + with patch.object(acceptance.urllib.request, "build_opener", return_value=opener), \ + self.assertRaisesRegex(RuntimeError, "does_not_match_owned_profile"): + acceptance.CDP(websocket, 12345, profile) + self.assertEqual(len(requests), 1) + self.assertTrue(sockets[0].closed) + + def test_extra_pages_are_rejected_without_adding_another_page(self): + profile, websocket, opener, _, sockets, _ = self.fixture(pages=2) + with patch.object(acceptance.urllib.request, "build_opener", return_value=opener), \ + self.assertRaisesRegex(RuntimeError, "one_initial_blank_page"): + acceptance.CDP(websocket, 12345, profile) + self.assertEqual(len(sockets), 1) + self.assertTrue(sockets[0].closed) + + def test_startup_deadline_preserves_phase_without_arbitrary_exception_text(self): + _, websocket, _, _, _, _ = self.fixture() + error = OSError("private host details") + error.startup_phase = "version_endpoint" + report = {} + with patch.object(acceptance, "CDP", side_effect=error), \ + patch.object(acceptance.time, "monotonic", side_effect=[0, 0, .001, .02]), \ + patch.object(acceptance.time, "sleep"), \ + self.assertRaisesRegex(RuntimeError, "owned_Chromium_startup_deadline"): + acceptance.wait_for_owned_browser(websocket, 12345, Path("/owned"), + SimpleNamespace(poll=lambda: None), report, timeout=.01) + self.assertEqual(report["browser_startup"]["attempts"], 2) + self.assertEqual(report["browser_startup"]["last_phase"], "version_endpoint") + self.assertNotIn("private host details", json.dumps(report)) + + +class OwnedCleanupTests(unittest.TestCase): + def test_termination_unwinds_and_restores_original_handler(self): + previous = signal.getsignal(signal.SIGTERM) + unwound = [] + with self.assertRaises(acceptance.NativeConsoleTerminated): + with acceptance.cleanup_on_sigterm(): + try: + signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None) + finally: + unwound.append(True) + self.assertEqual(signal.getsignal(signal.SIGTERM), signal.SIG_IGN) + self.assertEqual(unwound, [True]) + self.assertIs(signal.getsignal(signal.SIGTERM), previous) + + def test_normal_exit_restores_original_handler(self): + previous = signal.getsignal(signal.SIGTERM) + with acceptance.cleanup_on_sigterm(): + self.assertTrue(callable(signal.getsignal(signal.SIGTERM))) + self.assertIs(signal.getsignal(signal.SIGTERM), previous) + + def test_socket_and_stream_failure_still_closes_only_owned_browser(self): + from unittest.mock import Mock + page = SimpleNamespace(evaluate=Mock(side_effect=RuntimeError("private stream details")), + socket=SimpleNamespace(close=Mock(side_effect=OSError("private socket details")))) + browser = SimpleNamespace(pid=123456, poll=Mock(side_effect=[None, 0]), wait=Mock()) + report = {} + with patch.object(acceptance.os, "killpg") as kill: + acceptance.close_owned_browser(page, browser, report) + kill.assert_called_once_with(123456, signal.SIGTERM) + browser.wait.assert_called_once_with(timeout=10) + self.assertEqual(report, {"browser_stream_cleanup_error": True, + "browser_socket_cleanup_error": True, "owned_browser_closed": True}) + self.assertNotIn("private", json.dumps(report)) + + def test_interrupted_stream_cleanup_still_closes_owned_browser(self): + from unittest.mock import Mock + page = SimpleNamespace(evaluate=Mock(side_effect=acceptance.NativeConsoleTerminated()), + socket=SimpleNamespace(close=Mock())) + browser = SimpleNamespace(pid=123456, poll=Mock(side_effect=[None, 0]), wait=Mock()) + with patch.object(acceptance.os, "killpg") as kill, \ + self.assertRaises(acceptance.NativeConsoleTerminated): + acceptance.close_owned_browser(page, browser, {}) + page.socket.close.assert_called_once_with() + kill.assert_called_once_with(123456, signal.SIGTERM) + + def test_unresponsive_owned_browser_uses_bounded_kill_fallback(self): + from unittest.mock import Mock, call + browser = SimpleNamespace(pid=123456, poll=Mock(side_effect=[None, -9]), + wait=Mock(side_effect=[subprocess.TimeoutExpired("owned", 10), None])) + report = {} + with patch.object(acceptance.os, "killpg") as kill: + acceptance.close_owned_browser(None, browser, report) + self.assertEqual(kill.call_args_list, [call(123456, signal.SIGTERM), call(123456, signal.SIGKILL)]) + self.assertEqual(browser.wait.call_args_list, [call(timeout=10), call(timeout=5)]) + self.assertTrue(report["owned_browser_closed"]) + + def test_termination_produces_private_failure_report_and_143(self): + def interrupted(_args, report): + try: + signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None) + finally: + report["mock_owned_resources_unwound"] = True + + previous = signal.getsignal(signal.SIGTERM) + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + work.chmod(0o700) + output = work / "result.json" + with patch.object(acceptance, "execute", side_effect=interrupted), \ + patch("sys.stdout", new=io.StringIO()): + status = acceptance.main(["--serial", "exact", "--expected-board", "board", + "--expected-version", "version", "--logging", "off", "--console-source", "/unused", + "--artifact-manifest", "/unused", "--output", str(output), + "--work-dir", str(work), "--execute"]) + report = json.loads(output.read_text()) + self.assertEqual(status, 143) + self.assertFalse(report["ok"]) + self.assertEqual(report["termination_signal"], "SIGTERM") + self.assertTrue(report["mock_owned_resources_unwound"]) + self.assertEqual(output.stat().st_mode & 0o777, 0o600) + self.assertIs(signal.getsignal(signal.SIGTERM), previous) + + +class SafeJavaScriptFailureTests(unittest.TestCase): + def evaluate_error(self, details): + page = object.__new__(acceptance.CDP) + page.call = lambda *args, **kwargs: {"exceptionDetails": details} + with self.assertRaises(acceptance.NativeConsoleJavaScriptError) as caught: + page.evaluate("hil.query('board')") + return caught.exception + + def test_page_literal_error_is_reported_without_stack_or_description(self): + error = self.evaluate_error({"text": "Uncaught (in promise)", "exception": { + "className": "Error", "description": "Error: framed_reply_deadline\n at private-site/secret"}}) + self.assertEqual(error.safe_details, {"class": "Error", "code": "framed_reply_deadline"}) + self.assertEqual(str(error), "native_console_javascript_failed:framed_reply_deadline") + self.assertNotIn("secret", json.dumps(error.safe_details)) + + def test_dom_exception_name_is_safe_but_native_message_is_hashed(self): + message = "NetworkError: private serial endpoint secret" + error = self.evaluate_error({"exception": {"className": "DOMException", "description": message}}) + self.assertEqual(error.safe_details["class"], "DOMException") + self.assertEqual(error.safe_details["dom_exception_name"], "NetworkError") + self.assertEqual(error.safe_details["message_sha256"], acceptance.hashlib.sha256(message.encode()).hexdigest()) + self.assertNotIn("secret", str(error) + json.dumps(error.safe_details)) + + def test_unknown_class_and_known_code_prefix_do_not_expose_arbitrary_text(self): + message = "Error: framed_reply_deadline secret-password" + error = self.evaluate_error({"exception": {"className": "private-class-secret", "description": message}}) + self.assertEqual(error.safe_details["class"], "unrecognized") + self.assertEqual(error.safe_details["code"], "unclassified_exception") + self.assertEqual(error.safe_details["message_sha256"], acceptance.hashlib.sha256(message.encode()).hexdigest()) + self.assertNotIn("secret", str(error) + json.dumps(error.safe_details)) + + @unittest.skipUnless(shutil.which("node"), "Node required for offline PAGE protocol test") + def test_page_accepts_exact_prompt_echo_and_requires_complete_response(self): + # Execute the actual PAGE module body with an inert SDK-shaped class. + # navigator.serial returns a plain object; no browser/device is opened. + script = acceptance.PAGE.split('', 1)[0] + self.assertIn("import {SerialConsole} from './console.js';", script) + script = script.replace("import {SerialConsole} from './console.js';", "") + setup = r''' +import assert from 'node:assert/strict'; +globalThis.window=globalThis; +Object.defineProperty(globalThis,'navigator',{value:{serial:{getPorts:async()=>[ + {getInfo:()=>({usbVendorId:0x303a,usbProductId:0x1001})} +]}}}); +const realTimeout=globalThis.setTimeout; +// Only test deadlines are shortened; idle remains a no-transmit timer. +globalThis.setTimeout=(fn,ms)=>realTimeout(fn,ms===10000?5:ms); +let echoPrefix='> ', complete=true; +class SerialConsole { + constructor(port){this.port=port;this.connected=false;} + async connect(){this.connected=true;} + async disconnect(){this.connected=false;} + async reset(){throw Error('reset_must_never_be_called');} + async sendCommand(command){ + this.onOutput(echoPrefix+command+'\n'); + this.onOutput(' -> bench reply'+(complete?'\n':'')); + } +} +''' + checks = r''' +await hil.open(0x303a,0x1001); +for(const prefix of ['> ','>','']){ + echoPrefix=prefix; + const result=await hil.query('board'); + assert.equal(result.body,'bench reply'); + assert.equal(result.complete_CRLF_reply,true); +} +const writes=hil.writes; +await hil.idle(0); +assert.equal(hil.writes,writes); +echoPrefix='> stale '; +await assert.rejects(hil.query('board'),/framed_reply_deadline/); +echoPrefix='> ';complete=false; +await assert.rejects(hil.query('board'),/framed_reply_deadline/); +await hil.close(); +''' + ran = subprocess.run([shutil.which("node"), "--input-type=module", "-"], + input=setup + script + checks, capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/hil/companion_release_acceptance.py b/tools/hil/companion_release_acceptance.py new file mode 100644 index 000000000..98e71cfb5 --- /dev/null +++ b/tools/hil/companion_release_acceptance.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Identity-bound USB Companion release acceptance, without radio traffic. + +snapshot saves private raw contact/channel frames and settings. smoke additionally +runs persistent queries and fresh USB sessions, checking monotonic uptime. +owned-crud changes only one randomly generated, confirmed-absent contact and +removes it before requiring the original contact/channel/settings baseline. + +Neither APP_START nor reopening proves cold-boot durability. This tool never +reboots, exports a private key, sends a mesh message, or changes node settings. +The snapshot contains channel keys and the BLE PIN; keep it private. +The local binary CLI verifies the complete expected firmware version and its +Build suffix before any contact mutation; DEVICE_INFO alone truncates versions. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import struct +import subprocess +import sys +import time + +import serial +from serial.tools import list_ports + +from esp32_companion_serial_stress import ( + DeviceFrameReader, TransportCounters, encode_host_frame, + validate_app_start_response, validate_core_stats_response, + validate_device_info_response, +) +from profile_switch import configure_session +from serial_session import open_configured_session + +VERSION_REQUEST = b"\x427f|ver" +VERSION_REPLY_PREFIX = b"\x1d7f|" + + +class AcceptanceError(RuntimeError): + pass + + +def require(condition, message): + if not condition: + raise AcceptanceError(message) + + +def validate_full_version_response(payload, expected_version, build_date): + require(payload.startswith(VERSION_REPLY_PREFIX), "Local CLI version reply prefix mismatch") + try: + text = payload[len(VERSION_REPLY_PREFIX):].decode("ascii", "strict") + except UnicodeError: + raise AcceptanceError("Local CLI version reply is not ASCII") from None + require(re.fullmatch(r"[ -~]{1,11}", build_date) is not None, + "DEVICE_INFO build date is missing or invalid") + require(text == f"{expected_version} (Build: {build_date})", + "Full local CLI version or Build suffix does not match the expected candidate") + return text + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def private_json(path, value): + """Never replace a backup or follow a final-component symlink.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + os.fchmod(output.fileno(), 0o600) + json.dump(value, output, indent=2, sort_keys=True) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + + +def resolve_port(chip_serial): + matches = [item for item in list_ports.comports() + if (item.serial_number or "").upper() == chip_serial.upper() + and "logging" not in (item.interface or "").lower()] + require(len(matches) == 1, "USB serial must resolve to exactly one primary application interface") + item = matches[0] + require(re.fullmatch(r"/dev/tty(?:ACM|USB)\d+", os.path.realpath(item.device)) is not None, + "Only a local Linux USB serial application endpoint is accepted") + return item + + +def require_unowned(device): + checked = subprocess.run(["sudo", "-n", "fuser", os.path.realpath(device)], + capture_output=True, text=True, timeout=10) + require(checked.returncode == 1 and not checked.stdout.strip() and not checked.stderr.strip(), + "Cannot prove the selected serial endpoint is unowned; no owner was stopped") + + +class Session: + def __init__(self, args, counters): + self.args, self.counters = args, counters + self.port = None + self.reader = DeviceFrameReader(counters) + + def __enter__(self): + item = resolve_port(self.args.serial) + require_unowned(item.device) + port = serial.Serial(port=None, baudrate=115200, timeout=0.05, + write_timeout=5, exclusive=True) + port.port = item.device + configure_session(port) + try: + open_configured_session(port) + self.port = port + self.counters.ports_opened += 1 + require(os.path.realpath(resolve_port(self.args.serial).device) == os.path.realpath(item.device), + "USB serial changed endpoints while the session opened") + # Inventory may have left the ASCII terminal enabled. STOP is safe + # in an already-binary session; the following binary handshake is + # the authority, not an optional ASCII acknowledgement. + port.write(b"+++MESHCORE-TERM-STOP\r\n") + port.flush() + end, quiet = time.monotonic() + 2, time.monotonic() + 0.25 + text = bytearray() + while time.monotonic() < min(end, quiet): + part = port.read(min(port.in_waiting or 1, 4096)) + if part: + text.extend(part) + require(len(text) <= 65536, "Terminal exit exceeded its bounded reply capture") + quiet = time.monotonic() + 0.15 + require(b"ERROR:" not in text, "Terminal refused Binary mode; inspect USB logging policy") + self.handshake() + return self + except BaseException: + port.close() + if self.port is not None: + self.counters.ports_closed += 1 + self.port = None + raise + + def __exit__(self, *_): + if self.port is not None: + self.port.close() + self.counters.ports_closed += 1 + self.port = None + + def send(self, payload): + frame = encode_host_frame(payload) + require(self.port.write(frame) == len(frame), "Incomplete Companion request write") + self.port.flush() + self.counters.request_frames_sent += 1 + self.counters.request_bytes_sent += len(frame) + + def receive(self, timeout=None): + end = time.monotonic() + (timeout or self.args.timeout) + while time.monotonic() < end: + value = self.reader.read_frame(self.port, max(0.01, end - time.monotonic())) + if value[0] >= 0x80: + self.counters.ignored_push_frames += 1 + continue + return value + raise AcceptanceError("Companion response deadline expired") + + def request(self, payload, response, *, allow_error=False): + self.send(payload) + value = self.receive() + if value[0] == 1: + require(len(value) == 2, "Malformed Companion error response") + if allow_error: + return value + raise AcceptanceError(f"Companion command {payload[0]} failed with code {value[1]}") + require(value[0] == response, f"Unexpected response opcode for command {payload[0]}") + return value + + def handshake(self): + self.self_frame = self.request(b"\x01" + bytes(7) + b"Release acceptance", 5) + validate_app_start_response(self.self_frame) + self.device_frame = self.request(b"\x16\x0e", 13) + self.device = validate_device_info_response(self.device_frame) + require(self.args.expected_board.casefold() in self.device["manufacturer"].casefold(), + "Application board does not match --expected-board") + expected = self.args.expected_version.encode("utf-8")[:19].decode("utf-8") + require(self.device["firmware"] == expected, + "Application binary version does not match --expected-version (19-byte field)") + self.full_cli_version_frame = self.request(VERSION_REQUEST, 29) + self.full_cli_version = validate_full_version_response( + self.full_cli_version_frame, self.args.expected_version, self.device["build_date"]) + + def stats(self): + return validate_core_stats_response(self.request(b"\x38\x00", 24)) + + def contacts(self): + self.send(b"\x04") + start = self.receive() + require(len(start) == 5 and start[0] == 2, "Contact stream did not start with a complete header") + count = struct.unpack_from("= previous["uptime_seconds"], "Device uptime decreased") + require(current["error_flags"] & ~previous["error_flags"] == 0, "New core error flags appeared") + + +def smoke(args, counters, baseline, report): + previous = baseline["core_stats"] + with Session(args, counters) as session: + for index in range(args.queries): + if index % 2: + session.handshake() + current = session.stats() + check_uptime(previous, current) + previous = current + report["persistent_query_cycles"] = args.queries + for _ in range(args.reopens): + with Session(args, counters) as session: + current = session.stats() + check_uptime(previous, current) + previous = current + report["reopen_cycles"] = args.reopens + report["uptime_start"] = baseline["core_stats"]["uptime_seconds"] + report["uptime_end"] = previous["uptime_seconds"] + report["uptime_monotonic"] = True + return previous + + +def owned_plan(baseline): + require(len(baseline["contacts"]) < validate_device_info_response( + bytes.fromhex(baseline["device_info_frame_hex"]))["contact_capacity"], + "Owned CRUD needs a free contact slot; no existing contact will be removed") + while True: + key = secrets.token_bytes(32) + if key.hex() not in baseline["contacts"] and key != bytes.fromhex(baseline["self_info_frame_hex"])[4:36]: + break + label = ("Release-HIL-" + secrets.token_hex(4)).encode("ascii") + # Complete update form: opcode/key/type/flags/path/name/advert/GPS/lastmod. + body = key + b"\x01\x00\xff" + bytes(64) + label.ljust(32, b"\0") + body += struct.pack("&2; exit 1; } +app_usage() { + printf '%s\n' \ + 'Usage: mcfirmware_bench_app_flash.sh --port /dev/ttyACM... --usb-path EXACT_BASENAME' \ + ' --usb-id EXACT_BASENAME --device LABEL --bench-dir PRIVATE_DIRECTORY' \ + ' --expect-mac XX:XX:XX:XX:XX:XX --image /absolute/APPLICATION.bin --image-sha256 HEX' \ + ' --backup /absolute/FULL_FLASH.bin --backup-manifest /absolute/FULL_FLASH.bin.json' \ + ' --backup-sha256 HEX --expect-partition-sha256 HEX' \ + ' --address ACTIVE_SLOT_ADDRESS --slot-size EXACT_ACTIVE_SLOT_SIZE --allow-write' \ + ' [--flasher PATH] [--flasher-sha256 HEX]' \ + ' [--expect-target-id INTEGER --expect-hardware-id EXACT_ENDF_ID]' \ + 'The exact pinned existing 8/16MiB table and its active OTA app0/app1 slot are required.' \ + 'The existing complete private backup must match this silicon MAC and full SHA256.' \ + 'Writes only the selected active slot, verifies its full readback and unchanged table/otadata,' \ + 'then uses the actual flasher runtime finish. Application health is a separate check.' \ + 'No original NVS/SPIFFS/otadata restoration is performed by this command.' +} +app_flasher=/home/mikec/meshfirmware/mcfirmware.sh +app_flasher_hash=4da05ba37c2cb8b4a7e455a4aa3ee24f6d8020912d71874c38afd099e98e75b2 +app_port='' app_usb_path='' app_usb_id='' app_device='' app_bench='' app_mac='' +app_image='' app_image_hash='' app_backup='' app_backup_manifest='' app_backup_hash='' +app_table_hash='' app_address='' app_slot_size='' app_allow=0 app_active=0 +app_target_id='' app_hardware_id='' +while (($#)); do + case "$1" in + --flasher|--flasher-sha256|--port|--usb-path|--usb-id|--device|--bench-dir|--expect-mac|--image|--image-sha256|--backup|--backup-manifest|--backup-sha256|--expect-partition-sha256|--address|--slot-size|--expect-target-id|--expect-hardware-id) + (($# >= 2)) || app_die "Missing value for $1" + case "$1" in + --flasher) app_flasher=$2 ;; --flasher-sha256) app_flasher_hash=$2 ;; + --port) app_port=$2 ;; --usb-path) app_usb_path=$2 ;; --usb-id) app_usb_id=$2 ;; + --device) app_device=$2 ;; --bench-dir) app_bench=$2 ;; --expect-mac) app_mac=$2 ;; + --image) app_image=$2 ;; --image-sha256) app_image_hash=$2 ;; + --backup) app_backup=$2 ;; --backup-manifest) app_backup_manifest=$2 ;; + --backup-sha256) app_backup_hash=$2 ;; --expect-partition-sha256) app_table_hash=$2 ;; + --address) app_address=$2 ;; --slot-size) app_slot_size=$2 ;; + --expect-target-id) app_target_id=$2 ;; --expect-hardware-id) app_hardware_id=$2 ;; + esac + shift 2 ;; + --allow-write) app_allow=1; shift ;; + -h|--help) app_usage; exit 0 ;; + *) app_die "Unknown option: $1" ;; + esac +done +((app_allow)) || app_die 'Explicit --allow-write is required.' +[[ "$app_port" =~ ^/dev/tty(ACM|USB)[0-9]+$ ]] || app_die 'Select one local USB serial tty.' +for app_identity in "$app_usb_path" "$app_usb_id"; do + [[ -n "$app_identity" && "$app_identity" != */* && "$app_identity" != .* ]] \ + || app_die 'Exact USB by-path and by-id basenames are required.' +done +[[ "$app_mac" =~ ^([[:xdigit:]]{12}|([[:xdigit:]]{2}:){5}[[:xdigit:]]{2})$ ]] || app_die 'An explicit silicon MAC is required.' +app_mac=${app_mac//:/}; app_mac=${app_mac,,} +for app_hash in "$app_image_hash" "$app_backup_hash" "$app_table_hash" "$app_flasher_hash"; do + [[ "$app_hash" =~ ^[[:xdigit:]]{64}$ ]] || app_die 'Every expected SHA256 must be explicit and complete.' +done +app_image_hash=${app_image_hash,,}; app_backup_hash=${app_backup_hash,,} +app_table_hash=${app_table_hash,,}; app_flasher_hash=${app_flasher_hash,,} +app_number() { + [[ "$1" =~ ^(0[xX][[:xdigit:]]{1,8}|[0-9]{1,10})$ ]] || app_die 'Slot address/size must be explicit decimal or hexadecimal integers.' + if [[ "$1" == 0[xX]* ]]; then printf '%d\n' "$((16#${1:2}))"; else printf '%d\n' "$((10#$1))"; fi +} +app_address=$(app_number "$app_address") +app_slot_size=$(app_number "$app_slot_size") +if [[ -n "$app_target_id" || -n "$app_hardware_id" ]]; then + [[ -n "$app_target_id" && "$app_hardware_id" =~ ^[A-Za-z0-9_-]{1,32}$ ]] \ + || app_die 'Explicit EndF target and exact hardware identity must be supplied together.' + app_target_id=$(app_number "$app_target_id") + ((app_target_id <= 0xffffffff)) || app_die 'EndF target must fit an unsigned 32-bit integer.' +fi +(( app_address >= 0x10000 && app_slot_size > 0 && app_address + app_slot_size <= 0x1000000 )) \ + || app_die 'Requested application slot is outside the supported flash bounds.' +[[ -n "$app_device" && "$app_device" != *$'\n'* && "$app_bench" == /* && -d "$app_bench" ]] \ + || app_die 'Supply a board label and an existing dedicated absolute private bench directory.' +for app_command in awk bash python3 pipx timeout udevadm fuser sudo realpath sha256sum stat readlink cp chmod; do + command -v "$app_command" >/dev/null || app_die "Required command unavailable: $app_command" +done +app_bench=$(realpath -e -- "$app_bench") +[[ "$(stat -Lc '%u' -- "$app_bench")" == "$(id -u)" ]] || app_die 'Bench directory must belong to this user.' +app_permissions=$(stat -Lc '%a' -- "$app_bench") +(( (8#$app_permissions & 077) == 0 )) || app_die 'Bench directory must have no group/other permissions.' +for app_input in "$app_flasher" "$app_image" "$app_backup" "$app_backup_manifest"; do + [[ "$app_input" == /* && -f "$app_input" && -s "$app_input" && ! -L "$app_input" ]] \ + || app_die 'All inputs must be absolute non-symlink regular files.' +done +app_flasher=$(realpath -e -- "$app_flasher") +app_observed=$(sha256sum -- "$app_flasher") +[[ "${app_observed%% *}" == "$app_flasher_hash" ]] || app_die 'The complete flasher differs from the pinned source.' +app_helper="$(dirname -- "$app_flasher")/tools/meshcore_partition_expansion.py" +app_helper_hash=$(sed -n 's/^MESHCORE_PARTITION_EXPANSION_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$app_flasher") +[[ "$app_helper_hash" =~ ^[0-9a-f]{64}$ && -f "$app_helper" && ! -L "$app_helper" ]] \ + || app_die 'The current flasher must contain its pinned partition/OTA parser.' +app_observed=$(sha256sum -- "$app_helper") +[[ "${app_observed%% *}" == "$app_helper_hash" ]] || app_die 'The partition/OTA parser differs from the flasher pin.' + +app_assert_unowned() { + local output='' status=0 + if output=$(sudo -n fuser "$1" 2>&1); then app_die 'Selected serial port has an owner; no process will be killed.'; else status=$?; fi + [[ "$status" == 1 && -z "$output" ]] || app_die 'Cannot prove that the selected serial port is unowned.' +} +app_guard_usb() { + local physical selected initial + physical=$(readlink -e -- "/dev/serial/by-path/$app_usb_path") || app_die 'Exact physical USB path is absent.' + selected=$(readlink -e -- "${DEVICE_PORT:-$app_port}") || app_die 'Selected serial endpoint is absent.' + [[ "$selected" == "$physical" && -c "$physical" ]] || app_die 'Selected port and exact physical path disagree.' + if [[ -z "${ESP32_FLASH_EXPECTED_MAC:-}" ]]; then + initial=$(readlink -e -- "/dev/serial/by-id/$app_usb_id") || app_die 'Exact initial USB by-id is absent.' + [[ "$initial" == "$physical" ]] || app_die 'Exact USB by-id and physical path disagree.' + fi + app_assert_unowned "$selected" +} +app_guard_usb +app_state=$(mktemp -d "$app_bench/.mcfirmware-app.XXXXXX") +app_copy="$app_state/application.bin" +cp --reflink=auto -- "$app_image" "$app_copy" +chmod 600 -- "$app_copy" +# This validator uses only pinned, pure image/table/OTA parsing functions from +# the actual flasher helper. It cannot operate a serial port or write firmware. +cat > "$app_state/validate.py" <<'PY' +import hashlib, importlib.util, json, os, pathlib, struct, sys +mode, helper_path, state_dir, *args = sys.argv[1:] +state = pathlib.Path(state_dir) +spec = importlib.util.spec_from_file_location('reviewed_partition_parser', helper_path) +helper = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = helper +spec.loader.exec_module(helper) +def check(value, reason): + if not value: + raise ValueError(reason) +def digest(data): + return hashlib.sha256(data).hexdigest() +def layout(table, capacity): + parts = helper.parse_table(table, capacity) + apps = sorted((part for part in parts if part['type'] == 0), key=lambda part: part['subtype']) + check([part['subtype'] for part in apps] == [0x10, 0x11] + and all(part['flags'] == 0 for part in apps), + 'only two unencrypted contiguous OTA application identities are supported') + ota = [part for part in parts if part['type'] == 1 and part['subtype'] == 0] + check(len(ota) == 1 and ota[0]['size'] == 0x2000 and ota[0]['flags'] == 0, + 'a unique unencrypted 8192-byte OTA metadata partition is required') + return parts, apps, ota[0] +def selected_slot(parts, apps, otadata, address, size): + active = helper.active_ota_offset(parts, otadata) + slot = next((part for part in apps if part['offset'] == active), None) + check(slot is not None and slot['offset'] == address and slot['size'] == size, + 'requested address/size does not match the CRC-verified active OTA slot') + return slot +try: + if mode == 'inputs': + backup_path, manifest_path, expected_backup, expected_image, expected_table, mac, address, slot_size, expected_target, expected_hardware = args + address, slot_size = int(address), int(slot_size) + for path in (backup_path, manifest_path): + metadata = os.stat(path, follow_symlinks=False) + check(metadata.st_uid == os.geteuid() and metadata.st_mode & 0o077 == 0, + 'backup inputs must be private and owned by this user') + manifest = helper.safe_json(pathlib.Path(manifest_path).read_bytes()) + check(isinstance(manifest, dict), 'backup manifest must be an object') + capacity = manifest.get('flash_bytes') + check(type(capacity) is int and capacity in (0x800000, 0x1000000), + 'backup must pin a supported 8/16MiB flash capacity') + backup = pathlib.Path(backup_path).read_bytes() + check(len(backup) == capacity and digest(backup) == expected_backup, + 'full backup size/hash mismatch') + check(manifest.get('complete') is True and manifest.get('operation') == 'read-only-flash-backup' + and manifest.get('chip') == 'ESP32-S3' and manifest.get('device_mac') == mac + and manifest.get('offset') == 0 and manifest.get('length') == capacity + and manifest.get('sha256') == expected_backup + and manifest.get('artifact') == pathlib.Path(backup_path).name, + 'full backup manifest identity/range/hash mismatch') + table = backup[0x8000:0x9000] + check(digest(table) == expected_table, 'backup partition table does not match its explicit pin') + parts, apps, ota = layout(table, capacity) + backup_ota = backup[ota['offset']:ota['offset'] + ota['size']] + slot = selected_slot(parts, apps, backup_ota, address, slot_size) + image = (state/'application.bin').read_bytes() + check(0 < len(image) <= slot_size and (len(image) + 0xfff) // 0x1000 * 0x1000 <= slot_size + and digest(image) == expected_image, 'application length/erase extent/hash mismatch') + chip, image_capacity = helper.image_header(image) + check(chip == 9 and image_capacity == capacity and image[2] == 2, + 'application must declare ESP32-S3, the exact physical capacity, and DIO') + check(struct.unpack_from(' "$app_state/validation-inputs.log" \ + || { cat "$app_state/validation-inputs.log" >&2; app_die "Offline input validation failed; private evidence: $app_state"; } +read -r app_flash_bytes app_ota_address app_ota_size < <(python3 - "$app_state/inputs.json" <<'PY' +import json, sys +value = json.load(open(sys.argv[1])) +print(value["flash_bytes"], value["otadata_address"], value["otadata_size"]) +PY +) +app_bytes=$(stat -Lc '%s' -- "$app_copy") +app_definitions="$app_state/functions.sh" +awk ' + { sub(/\r$/, "") } + /^[A-Za-z_][A-Za-z0-9_]*\(\)[[:space:]]*\{[[:space:]]*$/ { if (capture) exit 2; capture=1; count++ } + capture { print } + capture && /^}[[:space:]]*$/ { capture=0 } + END { if (capture || count < 100) exit 2 } +' "$app_flasher" > "$app_definitions" +bash -n "$app_definitions" +# shellcheck source=/dev/null +source "$app_definitions" +for app_function in esptool_set_variables save_selected_serial_port prepare_esp32_flash_session \ + selected_flash_serial_port esp32_record_and_verify_probe_output esp32_verified_destructive_port \ + run_esp32_session_esptool esp32_write_after_mode finish_esp32_flash_session; do + declare -F "$app_function" >/dev/null || app_die "Flasher lacks safety function: $app_function" +done +PWD_SCRIPT=$(dirname -- "$app_flasher") +FIRMWARE_ROOT=$app_state DOWNLOAD_DIR="$app_state/downloads" +mkdir -- "$DOWNLOAD_DIR" +DEVICE_PORT=$app_port DEVICE_PORT_FILE="$app_state/selected-port" DEVICE_PORT_NAME_FILE="$app_state/selected-by-id" +DEVICE=$app_device ARCHITECTURE=esp32 +ESPTOOL_PIPX_APP='' ESPTOOL_VERSION_OUTPUT='' ESPTOOL_CMD='pipx run --spec esptool esptool' +ESP32_SAFE_BAUD=115200 ESP32_FLASH_EXPECTED_MAC='' ESP32_SESSION_IS_S3=0 ESP32_NATIVE_ROM_READY=0 +ESP32_FLASH_SELECTED_BY_ID='' ESP32_FLASH_EXPECTED_SERIAL='' ESP32_FLASH_EXPECTED_PATH_STEM='' +BOOTLOADER_PROBE_PORT='' BOOTLOADER_PROBE_ACTIVE=0 USB_RESET_FAILED=0 +USB_RESET_RECOVERED_PORT='' USB_RESET_EXPECTED_IDENTITY='' +LOCKEDSERVICE=None MCFIRMWARE_NO_SUDO=1 MESH_DISABLE_1200_RECOVERY=1 +ESP32_PROBE_TIMEOUT_SECONDS=12 NRF52_DFU_REENUMERATE_TIMEOUT_SECONDS=30 NRF52_DFU_REENUMERATE_POLL_SECONDS=0.25 +MESHCORE_USB_RESET_TOOL_SHA256=$(sed -n 's/^MESHCORE_USB_RESET_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$app_flasher") +MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256=$(sed -n 's/^MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$app_flasher") +[[ "$MESHCORE_USB_RESET_TOOL_SHA256" =~ ^[0-9a-f]{64}$ && "$MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256" =~ ^[0-9a-f]{64}$ ]] \ + || app_die 'Current flasher must pin both bundled USB handoff helpers.' +prepare_serial_port_for_flash() { app_assert_unowned "$1"; } +recover_busy_serial_port() { return 1; } +offer_serial_port_recovery() { return 1; } +offer_identity_safe_1200_touch() { return 1; } +manual_reboot_choice() { return 1; } +esp32_recover_interrupted_transport() { return 1; } +esp32_run_chunked_write_recovery() { return 1; } +resolve_meshcore_usb_reset_tool() { + local tool="$PWD_SCRIPT/tools/meshcore_usb_reset.py" + meshcore_usb_reset_tool_hash_matches "$tool" || return 1 + printf '%s\n' "$tool" +} +resolve_meshcore_esp32_bootloader_tool() { + local tool="$PWD_SCRIPT/tools/meshcore_esp32_bootloader.py" + resolve_meshcore_usb_reset_tool >/dev/null || return 1 + meshcore_esp32_bootloader_tool_hash_matches "$tool" || return 1 + printf '%s\n' "$tool" +} +app_notice() { + if ((app_active)); then + printf 'App-only session incomplete. No blind reset/recovery attempted; inspect private state %s.\n' "$app_state" >&2 + fi +} +trap app_notice EXIT +esptool_set_variables > "$app_state/esptool-version.log" 2>&1 +[[ "$NORESET" == no-reset && "$READFLASH" == read-flash && "$WRITEFLASH" == write-flash ]] \ + || app_die 'The reviewed native-S3 finish requires esptool5 or newer.' +ESP32_OPERATION_BEFORE=$NORESET +app_guard_usb +app_active=1 +prepare_esp32_flash_session "$DEVICE_PORT" "$DEVICE" > "$app_state/prepare.log" 2>&1 +[[ "$ESP32_SESSION_IS_S3" == 1 && "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] \ + || app_die 'Prepared silicon is not the explicitly selected ESP32-S3 MAC.' +DEVICE_PORT=$(selected_flash_serial_port "$DEVICE_PORT") +app_guard_usb +run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 "$FLASHID" > "$app_state/flash-id.log" 2>&1 +app_probe=$(cat "$app_state/flash-id.log") +esp32_record_and_verify_probe_output "$app_probe" > "$app_state/mac-check.log" 2>&1 +[[ "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] || app_die 'Live chip MAC mismatch.' +python3 "$app_state/validate.py" probe "$app_helper" "$app_state" +app_read() { + local offset=$1 length=$2 output=$3 + app_guard_usb + DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'app-only pinned readback') + app_guard_usb + run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 \ + "$READFLASH" "$offset" "$length" "$output" > "$output.log" 2>&1 +} +app_read 0x8000 0x1000 "$app_state/table-before.bin" +app_read "$app_ota_address" "$app_ota_size" "$app_state/otadata-before.bin" +python3 "$app_state/validate.py" live "$app_helper" "$app_state" "$app_table_hash" +app_guard_usb +DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'application-only active OTA slot write') +app_guard_usb +[[ "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] || app_die 'Immediate pre-write silicon identity mismatch.' +app_observed=$(sha256sum -- "$app_copy") +[[ "${app_observed%% *}" == "$app_image_hash" ]] || app_die 'Private application copy changed before write.' +# The sole flash write in this adapter: never erase-all, another slot, table, +# OTA metadata, identity, NVS or filesystem. Keep native S3 in ROM until readback succeeds. +run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 \ + "$WRITEFLASH" "$app_address" "$app_copy" > "$app_state/write.log" 2>&1 +app_read "$app_address" "$app_bytes" "$app_state/application-readback.bin" +app_read 0x8000 0x1000 "$app_state/table-after.bin" +app_read "$app_ota_address" "$app_ota_size" "$app_state/otadata-after.bin" +python3 "$app_state/validate.py" readback "$app_helper" "$app_state" "$app_image_hash" "$app_table_hash" +app_guard_usb +# UART adapters can require the final harmless probe to carry their normal +# after-mode reset; native USB keeps no-reset until the real guarded finish. +DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'application-only verified runtime handoff') +app_guard_usb +[[ "$ESP32_FLASH_EXPECTED_MAC" == "$app_mac" ]] || app_die 'Final runtime handoff silicon identity mismatch.' +app_after=$(esp32_write_after_mode "$DEVICE_PORT") +if [[ "$app_after" != "$NORESET" ]]; then + run_esp32_session_esptool "$DEVICE_PORT" --after "$app_after" --baud 115200 "$READMAC" \ + > "$app_state/runtime-reset.log" 2>&1 +fi +finish_esp32_flash_session "$DEVICE_PORT" > "$app_state/finish.log" 2>&1 +BOOTLOADER_PROBE_ACTIVE=0 BOOTLOADER_PROBE_PORT='' +app_active=0 +python3 - "$app_state" "$app_mac" "$app_flasher_hash" <<'PY' +import hashlib, json, pathlib, sys +state, mac, flasher_hash = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3] +result = json.loads((state/'inputs.json').read_text()) +result.update(schema_version=1, operation='application-only-flash', complete=True, + device_mac=mac, chip='ESP32-S3', + application_readback_sha256_verified=True, partition_table_unchanged=True, + otadata_unchanged=True, other_flash_ranges_written=False, + live_otadata_sha256=hashlib.sha256((state/'otadata-before.bin').read_bytes()).hexdigest(), + flasher_sha256=flasher_hash, + definitions_sha256=hashlib.sha256((state/'functions.sh').read_bytes()).hexdigest(), + session_finish='real flasher completed; application health requires separate verification') +(state/'result.json').write_text(json.dumps(result, indent=2)+'\n') +print(json.dumps(result, sort_keys=True)) +PY +printf 'Private app-only evidence: %s\n' "$app_state" diff --git a/tools/hil/mcfirmware_bench_backup.sh b/tools/hil/mcfirmware_bench_backup.sh new file mode 100755 index 000000000..532ddde2d --- /dev/null +++ b/tools/hil/mcfirmware_bench_backup.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# Read-only flash backup through the real mcfirmware ESP32 session helpers. +# ROM entry and runtime finish reset the explicitly selected board. No flash +# write/erase command, service stop, owner kill, or automatic recovery is used. +set -euo pipefail +umask 077 + +backup_die() { printf 'Backup refused: %s\n' "$*" >&2; exit 1; } +backup_usage() { + printf '%s\n' \ + 'Usage: mcfirmware_bench_backup.sh --port /dev/ttyACM... --usb-path EXACT_BASENAME' \ + ' --usb-id EXACT_BASENAME --device LABEL --bench-dir PRIVATE_DIRECTORY' \ + ' --expect-mac XX:XX:XX:XX:XX:XX --flash-bytes 0x1000000' \ + ' --offset 0 --length 0x1000000 --output /absolute/NEW_BACKUP.bin' \ + ' [--flasher /home/mikec/meshfirmware/mcfirmware.sh] [--read-baud 115200]' \ + 'A new OUTPUT.json report is created alongside the backup. Both are private.' \ + 'Only explicit ranges on a positively identified ESP32-S3 are supported.' +} + +backup_flasher=/home/mikec/meshfirmware/mcfirmware.sh +backup_requested_port='' backup_usb_path='' backup_usb_id='' backup_device='' +backup_bench='' backup_expected_mac='' backup_flash_bytes='' backup_offset='' +backup_length='' backup_output='' backup_baud=115200 backup_active=0 +while (($#)); do + case "$1" in + --flasher|--port|--usb-path|--usb-id|--device|--bench-dir|--expect-mac|--flash-bytes|--offset|--length|--output|--read-baud) + (($# >= 2)) || backup_die "Missing value for $1" + case "$1" in + --flasher) backup_flasher=$2 ;; --port) backup_requested_port=$2 ;; + --usb-path) backup_usb_path=$2 ;; --usb-id) backup_usb_id=$2 ;; + --device) backup_device=$2 ;; --bench-dir) backup_bench=$2 ;; + --expect-mac) backup_expected_mac=$2 ;; --flash-bytes) backup_flash_bytes=$2 ;; + --offset) backup_offset=$2 ;; --length) backup_length=$2 ;; + --output) backup_output=$2 ;; --read-baud) backup_baud=$2 ;; + esac + shift 2 ;; + -h|--help) backup_usage; exit 0 ;; + *) backup_die "Unknown option: $1" ;; + esac +done +[[ -f "$backup_flasher" && -s "$backup_flasher" ]] || backup_die 'The complete local flasher is required.' +[[ "$backup_requested_port" =~ ^/dev/tty(ACM|USB)[0-9]+$ ]] || backup_die 'Select one local USB serial tty.' +for backup_identity in "$backup_usb_path" "$backup_usb_id"; do + [[ -n "$backup_identity" && "$backup_identity" != */* && "$backup_identity" != .* ]] || backup_die 'Exact USB by-path and by-id basenames are required.' +done +[[ -n "$backup_device" && "$backup_bench" == /* && -d "$backup_bench" ]] || backup_die 'Create a dedicated absolute bench directory and supply the board label.' +backup_bench=$(realpath -e -- "$backup_bench") +[[ "$(stat -Lc '%u' -- "$backup_bench")" == "$(id -u)" ]] || backup_die 'The bench directory must belong to this user.' +backup_permissions=$(stat -Lc '%a' -- "$backup_bench") +(( (8#$backup_permissions & 077) == 0 )) || backup_die 'The bench directory must have no group/other permissions.' +[[ "$backup_baud" == 115200 ]] || backup_die 'This backup wrapper permits only conservative 115200 baud.' +[[ "$backup_expected_mac" =~ ^([[:xdigit:]]{12}|([[:xdigit:]]{2}:){5}[[:xdigit:]]{2})$ ]] || backup_die 'Supply an explicit twelve-digit expected MAC.' +backup_expected_mac=${backup_expected_mac//:/} +backup_expected_mac=${backup_expected_mac,,} +backup_number() { + [[ "$1" =~ ^(0[xX][[:xdigit:]]{1,8}|[0-9]{1,10})$ ]] || backup_die 'Ranges must be explicit decimal or hexadecimal integers.' + if [[ "$1" == 0[xX]* ]]; then printf '%d\n' "$((16#${1:2}))"; else printf '%d\n' "$((10#$1))"; fi +} +backup_flash_bytes=$(backup_number "$backup_flash_bytes") +backup_offset=$(backup_number "$backup_offset") +backup_length=$(backup_number "$backup_length") +(( backup_flash_bytes == 8388608 || backup_flash_bytes == 16777216 )) || backup_die 'Only confirmed 8 MiB or 16 MiB boards are supported.' +(( backup_length > 0 && backup_offset + backup_length <= backup_flash_bytes )) || backup_die 'The requested range exceeds the confirmed flash capacity.' +[[ "$backup_output" == /* ]] || backup_die 'Supply an absolute new backup filename.' +backup_output=$(realpath -m -- "$backup_output") +[[ "$backup_output" == "$backup_bench"/* && ! -e "$backup_output" && ! -L "$backup_output" ]] || backup_die 'Backup output must be a new file inside the private bench directory.' +[[ "$(dirname -- "$backup_output")" == "$backup_bench" ]] || backup_die 'Place the backup directly in the private bench directory.' +backup_report="$backup_output.json" +[[ ! -e "$backup_report" && ! -L "$backup_report" ]] || backup_die 'The backup report already exists.' +for backup_command in awk bash python3 pipx timeout udevadm fuser sudo realpath sha256sum stat readlink; do + command -v "$backup_command" >/dev/null || backup_die "Required command unavailable: $backup_command" +done + +backup_assert_unowned() { + local output='' status=0 + if output=$(sudo -n fuser "$1" 2>&1); then backup_die 'The selected serial port has an owner; no process will be killed.'; else status=$?; fi + [[ "$status" == 1 && -z "$output" ]] || backup_die 'Unable to prove that the selected serial port is unowned.' +} +backup_guard_usb() { + local selected physical by_id + physical=$(readlink -e -- "/dev/serial/by-path/$backup_usb_path") || backup_die 'The exact physical USB path is absent.' + selected=$(readlink -e -- "${DEVICE_PORT:-$backup_requested_port}") || backup_die 'The selected serial endpoint is absent.' + [[ "$selected" == "$physical" && -c "$physical" ]] || backup_die 'The selected endpoint no longer matches the physical USB path.' + # ROM handoff may rename the product/by-id name. Its exact initial link, + # physical path, and silicon MAC are independently pinned before reading. + if [[ "${ESP32_FLASH_EXPECTED_MAC:-}" == '' ]]; then + by_id=$(readlink -e -- "/dev/serial/by-id/$backup_usb_id") || backup_die 'The exact initial USB by-id link is absent.' + [[ "$by_id" == "$physical" ]] || backup_die 'USB by-id and physical path disagree.' + fi + backup_assert_unowned "$selected" +} +backup_guard_usb +backup_state=$(mktemp -d "$backup_bench/.mcfirmware-backup.XXXXXX") +backup_definitions="$backup_state/functions.sh" +# Extract definitions only; never source startup, global host changes, or traps. +awk ' + { sub(/\r$/, "") } + /^[A-Za-z_][A-Za-z0-9_]*\(\)[[:space:]]*\{[[:space:]]*$/ { + if (capture) exit 2 + capture=1; count++ + } + capture { print } + capture && /^}[[:space:]]*$/ { capture=0 } + END { if (capture || count < 100) exit 2 } +' "$backup_flasher" > "$backup_definitions" +bash -n "$backup_definitions" +# shellcheck source=/dev/null +source "$backup_definitions" +for backup_function in esptool_set_variables save_selected_serial_port prepare_esp32_flash_session selected_flash_serial_port esp32_record_and_verify_probe_output esp32_verified_destructive_port run_esp32_session_esptool esp32_write_after_mode finish_esp32_flash_session; do + declare -F "$backup_function" >/dev/null || backup_die "Flasher lacks required function: $backup_function" +done + +# Private session state, equivalent to the existing bench-only flasher adapter. +PWD_SCRIPT=$(dirname -- "$(realpath -e -- "$backup_flasher")") +FIRMWARE_ROOT=$backup_state +DOWNLOAD_DIR="$backup_state/downloads" +mkdir -- "$DOWNLOAD_DIR" +DEVICE_PORT=$backup_requested_port +DEVICE_PORT_FILE="$backup_state/selected-port" +DEVICE_PORT_NAME_FILE="$backup_state/selected-by-id" +DEVICE=$backup_device ARCHITECTURE=esp32 +ESPTOOL_PIPX_APP='' ESPTOOL_VERSION_OUTPUT='' ESPTOOL_CMD='pipx run --spec esptool esptool' +ESP32_SAFE_BAUD=115200 ESP32_FLASH_EXPECTED_MAC='' ESP32_SESSION_IS_S3=0 ESP32_NATIVE_ROM_READY=0 +ESP32_FLASH_SELECTED_BY_ID='' ESP32_FLASH_EXPECTED_SERIAL='' ESP32_FLASH_EXPECTED_PATH_STEM='' +BOOTLOADER_PROBE_PORT='' BOOTLOADER_PROBE_ACTIVE=0 USB_RESET_FAILED=0 +USB_RESET_RECOVERED_PORT='' USB_RESET_EXPECTED_IDENTITY='' +LOCKEDSERVICE=None MCFIRMWARE_NO_SUDO=1 MESH_DISABLE_1200_RECOVERY=1 +ESP32_PROBE_TIMEOUT_SECONDS=12 NRF52_DFU_REENUMERATE_TIMEOUT_SECONDS=30 NRF52_DFU_REENUMERATE_POLL_SECONDS=0.25 +MESHCORE_USB_RESET_TOOL_SHA256=$(sed -n 's/^MESHCORE_USB_RESET_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$backup_flasher") +[[ "$MESHCORE_USB_RESET_TOOL_SHA256" =~ ^[0-9a-f]{64}$ ]] || backup_die 'The installed flasher must pin its bundled USB helper.' +MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256=$(sed -n 's/^MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$backup_flasher") +# Restrict host-affecting boundaries; preserve actual USB/MAC/bootstrap/finish. +prepare_serial_port_for_flash() { backup_assert_unowned "$1"; } +recover_busy_serial_port() { return 1; } +offer_serial_port_recovery() { return 1; } +offer_identity_safe_1200_touch() { return 1; } +manual_reboot_choice() { return 1; } +esp32_recover_interrupted_transport() { return 1; } +esp32_run_chunked_write_recovery() { return 1; } +resolve_meshcore_usb_reset_tool() { + local tool="$PWD_SCRIPT/tools/meshcore_usb_reset.py" + meshcore_usb_reset_tool_hash_matches "$tool" || return 1 + printf '%s\n' "$tool" +} +if declare -F meshcore_esp32_bootloader_tool_hash_matches >/dev/null; then + [[ "$MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256" =~ ^[0-9a-f]{64}$ ]] || backup_die 'The flasher must pin its bundled ESP32 bootloader helper.' + resolve_meshcore_esp32_bootloader_tool() { + local tool="$PWD_SCRIPT/tools/meshcore_esp32_bootloader.py" + resolve_meshcore_usb_reset_tool >/dev/null || return 1 + meshcore_esp32_bootloader_tool_hash_matches "$tool" || return 1 + printf '%s\n' "$tool" + } +fi +backup_notice() { + if ((backup_active)); then + printf 'Backup/session incomplete. No automatic recovery attempted; inspect private state %s before continuing.\n' "$backup_state" >&2 + fi +} +trap backup_notice EXIT +esptool_set_variables > "$backup_state/esptool-version.log" 2>&1 +[[ "$NORESET" == no-reset && "$READFLASH" == read-flash ]] || backup_die 'The backup requires esptool 5 or newer.' +ESP32_OPERATION_BEFORE=$NORESET +backup_guard_usb +backup_active=1 +prepare_esp32_flash_session "$DEVICE_PORT" "$DEVICE" > "$backup_state/prepare.log" 2>&1 +[[ "$ESP32_SESSION_IS_S3" == 1 && "$ESP32_FLASH_EXPECTED_MAC" == "$backup_expected_mac" ]] || backup_die 'The session chip/MAC differs from the explicit ESP32-S3 target.' +backup_guard_usb +backup_live_port=$(selected_flash_serial_port "$DEVICE_PORT") +DEVICE_PORT=$backup_live_port +backup_guard_usb +run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 "$FLASHID" > "$backup_state/flash-id.log" 2>&1 +backup_probe=$(cat "$backup_state/flash-id.log") +esp32_record_and_verify_probe_output "$backup_probe" > "$backup_state/mac-check.log" 2>&1 +[[ "$ESP32_FLASH_EXPECTED_MAC" == "$backup_expected_mac" ]] || backup_die 'The flash probe MAC differs from the explicit target.' +[[ "$backup_probe" =~ Detected[[:space:]]flash[[:space:]]size:[[:space:]]*([0-9]+)MB ]] || backup_die 'The flash probe did not report an unambiguous capacity.' +(( BASH_REMATCH[1] * 1024 * 1024 == backup_flash_bytes )) || backup_die 'The detected flash capacity differs from the explicit target.' +backup_live_port=$(esp32_verified_destructive_port "$DEVICE_PORT" 'read-only full/range flash backup') +DEVICE_PORT=$backup_live_port +backup_guard_usb +[[ "$ESP32_FLASH_EXPECTED_MAC" == "$backup_expected_mac" ]] || backup_die 'The pre-read MAC differs from the explicit target.' +# Reserve both outputs exclusively before the read. The directory is private, +# and esptool writes only the newly reserved 0600 backup pathname. +(set -o noclobber; : > "$backup_output"; : > "$backup_report") || backup_die 'An output already exists; refusing to overwrite it.' +chmod 600 -- "$backup_output" "$backup_report" +run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud "$backup_baud" "$READFLASH" "$backup_offset" "$backup_length" "$backup_output" > "$backup_state/read.log" 2>&1 +[[ -f "$backup_output" && ! -L "$backup_output" && "$(stat -Lc '%s' -- "$backup_output")" == "$backup_length" ]] || backup_die 'The backup length differs from the requested range.' +chmod 600 -- "$backup_output" +backup_guard_usb +# UART ESP32 finish is normally a no-op: its final probe must supply the reset. +backup_after=$(esp32_write_after_mode "$DEVICE_PORT") +if [[ "$backup_after" != "$NORESET" ]]; then + run_esp32_session_esptool "$DEVICE_PORT" --after "$backup_after" --baud 115200 "$READMAC" > "$backup_state/runtime-reset.log" 2>&1 +fi +finish_esp32_flash_session "$DEVICE_PORT" > "$backup_state/finish.log" 2>&1 +BOOTLOADER_PROBE_ACTIVE=0 BOOTLOADER_PROBE_PORT='' +backup_active=0 +python3 - "$backup_output" "$backup_report" "$backup_state" "$backup_flasher" "$backup_definitions" "$backup_expected_mac" "$backup_flash_bytes" "$backup_offset" "$backup_length" <<'PY' +import hashlib, json, os, pathlib, sys +output, report, state, flasher, definitions, mac, capacity, offset, length = sys.argv[1:] +def digest(path): + value = hashlib.sha256() + with open(path, 'rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + value.update(block) + return value.hexdigest() +result = dict(schema_version=1, operation='read-only-flash-backup', complete=True, + artifact=pathlib.Path(output).name, sha256=digest(output), + device_mac=mac, chip='ESP32-S3', flash_bytes=int(capacity), + offset=int(offset), length=int(length), baud=115200, + flasher_sha256=digest(flasher), definitions_sha256=digest(definitions), + flash_probe_sha256=digest(pathlib.Path(state)/'flash-id.log'), + session_finish='flasher completed; application health requires a separate check') +with open(report, 'w', encoding='utf-8') as stream: + json.dump(result, stream, indent=2) + stream.write('\n') +os.chmod(report, 0o600) +print(json.dumps(result, sort_keys=True)) +PY diff --git a/tools/hil/mcfirmware_bench_data_restore.sh b/tools/hil/mcfirmware_bench_data_restore.sh new file mode 100755 index 000000000..1b2c87802 --- /dev/null +++ b/tools/hil/mcfirmware_bench_data_restore.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# Restore only original NVS/SPIFFS from an exact private full-flash backup. +# No app/table/OTA/full erase, identity synthesis, services or guessed reset. +set -euo pipefail +umask 077 +restore_die() { printf 'Data restoration refused: %s\n' "$*" >&2; exit 1; } +restore_usage() { + printf '%s\n' \ + 'Usage: mcfirmware_bench_data_restore.sh --port /dev/ttyACM... --usb-path EXACT_BASENAME' \ + ' --usb-id EXACT_BASENAME --device LABEL --bench-dir PRIVATE_DIRECTORY' \ + ' --expect-mac XX:XX:XX:XX:XX:XX --backup /absolute/FULL_FLASH.bin' \ + ' --backup-manifest /absolute/FULL_FLASH.bin.json --backup-sha256 HEX' \ + ' --expect-partition-sha256 HEX --app-address ACTIVE_SLOT_ADDRESS' \ + ' --app-length EXACT_CURRENT_APP_BYTES --expect-app-sha256 HEX --allow-write' \ + ' [--flasher PATH] [--flasher-sha256 HEX]' \ + 'Requires an exact 8/16MiB ESP32-S3 backup tied to this MAC, unchanged live table/OTA,' \ + 'and a separately pinned current active application. Writes only original nvs/spiffs bytes.' \ + 'Verifies both readbacks and unchanged table/OTA/current app before the real runtime finish.' +} +restore_flasher=/home/mikec/meshfirmware/mcfirmware.sh +restore_flasher_hash=4da05ba37c2cb8b4a7e455a4aa3ee24f6d8020912d71874c38afd099e98e75b2 +restore_port='' restore_usb_path='' restore_usb_id='' restore_device='' restore_bench='' restore_mac='' +restore_backup='' restore_manifest='' restore_backup_hash='' restore_table_hash='' +restore_app_address='' restore_app_length='' restore_app_hash='' restore_allow=0 restore_active=0 +while (($#)); do + case "$1" in + --flasher|--flasher-sha256|--port|--usb-path|--usb-id|--device|--bench-dir|--expect-mac|--backup|--backup-manifest|--backup-sha256|--expect-partition-sha256|--app-address|--app-length|--expect-app-sha256) + (($# >= 2)) || restore_die "Missing value for $1" + case "$1" in + --flasher) restore_flasher=$2 ;; --flasher-sha256) restore_flasher_hash=$2 ;; + --port) restore_port=$2 ;; --usb-path) restore_usb_path=$2 ;; --usb-id) restore_usb_id=$2 ;; + --device) restore_device=$2 ;; --bench-dir) restore_bench=$2 ;; --expect-mac) restore_mac=$2 ;; + --backup) restore_backup=$2 ;; --backup-manifest) restore_manifest=$2 ;; + --backup-sha256) restore_backup_hash=$2 ;; --expect-partition-sha256) restore_table_hash=$2 ;; + --app-address) restore_app_address=$2 ;; --app-length) restore_app_length=$2 ;; + --expect-app-sha256) restore_app_hash=$2 ;; + esac + shift 2 ;; + --allow-write) restore_allow=1; shift ;; + -h|--help) restore_usage; exit 0 ;; + *) restore_die "Unknown option: $1" ;; + esac +done +((restore_allow)) || restore_die 'Explicit --allow-write is required.' +[[ "$restore_port" =~ ^/dev/tty(ACM|USB)[0-9]+$ ]] || restore_die 'Select one local USB serial tty.' +for restore_id in "$restore_usb_path" "$restore_usb_id"; do + [[ -n "$restore_id" && "$restore_id" != */* && "$restore_id" != .* ]] \ + || restore_die 'Exact USB by-path and by-id basenames are required.' +done +[[ "$restore_mac" =~ ^([[:xdigit:]]{12}|([[:xdigit:]]{2}:){5}[[:xdigit:]]{2})$ ]] \ + || restore_die 'An explicit silicon MAC is required.' +restore_mac=${restore_mac//:/}; restore_mac=${restore_mac,,} +for restore_hash in "$restore_flasher_hash" "$restore_backup_hash" "$restore_table_hash" "$restore_app_hash"; do + [[ "$restore_hash" =~ ^[[:xdigit:]]{64}$ ]] || restore_die 'All SHA256 pins must be explicit and complete.' +done +restore_flasher_hash=${restore_flasher_hash,,}; restore_backup_hash=${restore_backup_hash,,} +restore_table_hash=${restore_table_hash,,}; restore_app_hash=${restore_app_hash,,} +restore_number() { + [[ "$1" =~ ^(0[xX][[:xdigit:]]{1,8}|[0-9]{1,10})$ ]] || restore_die 'Application geometry must be explicit integers.' + if [[ "$1" == 0[xX]* ]]; then printf '%d\n' "$((16#${1:2}))"; else printf '%d\n' "$((10#$1))"; fi +} +restore_app_address=$(restore_number "$restore_app_address") +restore_app_length=$(restore_number "$restore_app_length") +((restore_app_address >= 0x10000 && restore_app_length > 0 \ + && restore_app_address + restore_app_length <= 0x1000000)) || restore_die 'Application geometry is out of bounds.' +[[ -n "$restore_device" && "$restore_device" != *$'\n'* && "$restore_bench" == /* && -d "$restore_bench" ]] \ + || restore_die 'Supply a device label and existing dedicated absolute private bench directory.' +for restore_command in awk bash python3 pipx timeout udevadm fuser sudo realpath sha256sum stat readlink chmod; do + command -v "$restore_command" >/dev/null || restore_die "Required command unavailable: $restore_command" +done +restore_bench=$(realpath -e -- "$restore_bench") +[[ "$(stat -Lc '%u' -- "$restore_bench")" == "$(id -u)" ]] || restore_die 'Bench directory must belong to this user.' +restore_permissions=$(stat -Lc '%a' -- "$restore_bench") +(( (8#$restore_permissions & 077) == 0 )) || restore_die 'Bench directory must be private.' +for restore_input in "$restore_flasher" "$restore_backup" "$restore_manifest"; do + [[ "$restore_input" == /* && -f "$restore_input" && -s "$restore_input" && ! -L "$restore_input" ]] \ + || restore_die 'Inputs must be absolute non-symlink regular files.' +done +restore_flasher=$(realpath -e -- "$restore_flasher") +restore_observed=$(sha256sum -- "$restore_flasher") +[[ "${restore_observed%% *}" == "$restore_flasher_hash" ]] || restore_die 'Flasher source differs from the explicit pin.' +restore_helper="$(dirname -- "$restore_flasher")/tools/meshcore_partition_expansion.py" +restore_helper_hash=$(sed -n 's/^MESHCORE_PARTITION_EXPANSION_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$restore_flasher") +[[ "$restore_helper_hash" =~ ^[0-9a-f]{64}$ && -f "$restore_helper" && ! -L "$restore_helper" ]] \ + || restore_die 'Current flasher must contain its pinned partition/OTA parser.' +restore_observed=$(sha256sum -- "$restore_helper") +[[ "${restore_observed%% *}" == "$restore_helper_hash" ]] || restore_die 'Partition/OTA parser differs from the flasher pin.' +restore_assert_unowned() { + local output='' status=0 + if output=$(sudo -n fuser "$1" 2>&1); then restore_die 'Selected serial port has an owner; no process will be killed.'; else status=$?; fi + [[ "$status" == 1 && -z "$output" ]] || restore_die 'Cannot prove that the selected serial port is unowned.' +} +restore_guard_usb() { + local physical selected initial + physical=$(readlink -e -- "/dev/serial/by-path/$restore_usb_path") || restore_die 'Exact physical USB path is absent.' + selected=$(readlink -e -- "${DEVICE_PORT:-$restore_port}") || restore_die 'Selected USB endpoint is absent.' + [[ "$selected" == "$physical" && -c "$physical" ]] || restore_die 'Selected port and exact physical path disagree.' + if [[ -z "${ESP32_FLASH_EXPECTED_MAC:-}" ]]; then + initial=$(readlink -e -- "/dev/serial/by-id/$restore_usb_id") || restore_die 'Exact initial USB by-id is absent.' + [[ "$initial" == "$physical" ]] || restore_die 'Initial USB by-id and physical path disagree.' + fi + restore_assert_unowned "$selected" +} +# Do not inherit a previous caller's session identity and skip the initial +# by-id comparison. The real flasher establishes its own fresh MAC below. +ESP32_FLASH_EXPECTED_MAC='' DEVICE_PORT='' +restore_guard_usb +restore_state=$(mktemp -d "$restore_bench/.mcfirmware-data-restore.XXXXXX") +# Pure parsing only; the pinned helper receives no command-line main invocation. +cat > "$restore_state/validate.py" <<'PY' +import hashlib, importlib.util, json, os, pathlib, sys +mode, helper_path, state_path, *args = sys.argv[1:] +state = pathlib.Path(state_path) +spec = importlib.util.spec_from_file_location('reviewed_partition_parser', helper_path) +helper = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = helper +spec.loader.exec_module(helper) +def check(value, reason): + if not value: + raise ValueError(reason) +def digest(data): + return hashlib.sha256(data).hexdigest() +def layout(table, capacity): + parts = helper.parse_table(table, capacity) + apps = sorted((p for p in parts if p['type'] == 0), key=lambda p: p['subtype']) + check([p['subtype'] for p in apps] == [0x10, 0x11] and all(p['flags'] == 0 for p in apps), + 'exactly two unencrypted contiguous OTA application identities are required') + def one(kind, subtype, name, size=None): + found = [p for p in parts if p['type'] == kind and p['subtype'] == subtype] + check(len(found) == 1 and found[0]['name'] == name and found[0]['flags'] == 0 + and (size is None or found[0]['size'] == size), + 'required unique unencrypted data partition geometry is missing or ambiguous') + return found[0] + return parts, one(1, 0, 'otadata', 0x2000), [one(1, 2, 'nvs'), one(1, 0x82, 'spiffs')] +def preserved(inputs, suffix): + table = (state/('table-' + suffix + '.bin')).read_bytes() + ota = (state/('otadata-' + suffix + '.bin')).read_bytes() + app = (state/('application-' + suffix + '.bin')).read_bytes() + check(len(table) == 0x1000 and digest(table) == inputs['partition_sha256'] + and table == (state/'backup-table.bin').read_bytes(), 'live table differs from the pinned original backup') + parts, ota_part, data_parts = layout(table, inputs['flash_bytes']) + check(ota_part['offset'] == inputs['otadata_address'] and ota_part['size'] == inputs['otadata_size'] + and ota == (state/'backup-otadata.bin').read_bytes(), + 'live OTA metadata differs from the pinned original backup') + check(helper.active_ota_offset(parts, ota) == inputs['application_address'], + 'live active application slot changed') + check(len(app) == inputs['application_bytes'] and digest(app) == inputs['application_sha256'], + 'live active application differs from the explicit current app pin') +try: + if mode == 'inputs': + backup_path, manifest_path, expected_backup, expected_table, mac, app_address, app_length, app_hash = args + for path in (backup_path, manifest_path): + metadata = os.stat(path, follow_symlinks=False) + check(metadata.st_uid == os.geteuid() and metadata.st_mode & 0o077 == 0, + 'backup inputs must be private and owned by this user') + manifest = helper.safe_json(pathlib.Path(manifest_path).read_bytes()) + check(isinstance(manifest, dict), 'backup manifest must be an object') + capacity = manifest.get('flash_bytes') + check(type(capacity) is int and capacity in (0x800000, 0x1000000), 'backup must pin 8/16MiB capacity') + backup = pathlib.Path(backup_path).read_bytes() + check(len(backup) == capacity and digest(backup) == expected_backup, 'full backup size/hash mismatch') + check(manifest.get('complete') is True and manifest.get('operation') == 'read-only-flash-backup' + and manifest.get('chip') == 'ESP32-S3' and manifest.get('device_mac') == mac + and manifest.get('offset') == 0 and manifest.get('length') == capacity + and manifest.get('sha256') == expected_backup + and manifest.get('artifact') == pathlib.Path(backup_path).name, + 'full backup manifest identity/range/hash mismatch') + table = backup[0x8000:0x9000] + check(digest(table) == expected_table, 'backup table differs from its explicit pin') + parts, ota_part, data_parts = layout(table, capacity) + ota = backup[ota_part['offset']:ota_part['offset'] + ota_part['size']] + app_address, app_length = int(app_address), int(app_length) + active = helper.active_ota_offset(parts, ota) + app = next((p for p in parts if p['type'] == 0 and p['offset'] == active), None) + check(app is not None and app_address == active and 0 < app_length <= app['size'], + 'current application geometry does not match the original active slot') + ranges = [] + for part in data_parts: + data = backup[part['offset']:part['offset'] + part['size']] + (state/(part['name'] + '-original.private.bin')).write_bytes(data) + ranges.append(dict(name=part['name'], offset=part['offset'], length=part['size'], sha256=digest(data))) + inputs = dict(flash_bytes=capacity, backup_sha256=expected_backup, partition_sha256=expected_table, + otadata_address=ota_part['offset'], otadata_size=ota_part['size'], + otadata_sha256=digest(ota), application_address=active, + application_bytes=app_length, application_sha256=app_hash, restored_ranges=ranges) + (state/'backup-table.bin').write_bytes(table) + (state/'backup-otadata.bin').write_bytes(ota) + (state/'inputs.json').write_text(json.dumps(inputs)) + (state/'ranges.tsv').write_text(''.join(f"{p['name']} {p['offset']} {p['length']} {p['sha256']}\n" for p in ranges)) + elif mode == 'probe': + inputs = json.loads((state/'inputs.json').read_text()) + capacity, chip = helper.flash_identity((state/'flash-id.log').read_text()) + check(capacity == inputs['flash_bytes'] and chip == 9, 'live chip/capacity differs from the pinned S3 backup') + elif mode in ('before', 'after'): + inputs = json.loads((state/'inputs.json').read_text()) + preserved(inputs, mode) + if mode == 'after': + for part in inputs['restored_ranges']: + expected = (state/(part['name'] + '-original.private.bin')).read_bytes() + observed = (state/(part['name'] + '-readback.private.bin')).read_bytes() + check(len(observed) == part['length'] and digest(observed) == part['sha256'] and observed == expected, + 'restored data readback differs from the exact original backup range') + else: + raise ValueError('unknown validation mode') +except Exception as error: + safe = type(error) is ValueError or type(error) is helper.ExpansionError + print('Data validation failed: ' + (str(error) if safe else type(error).__name__), file=sys.stderr) + sys.exit(1) +PY +python3 "$restore_state/validate.py" inputs "$restore_helper" "$restore_state" \ + "$restore_backup" "$restore_manifest" "$restore_backup_hash" "$restore_table_hash" \ + "$restore_mac" "$restore_app_address" "$restore_app_length" "$restore_app_hash" \ + 2> "$restore_state/validation-inputs.log" \ + || { cat "$restore_state/validation-inputs.log" >&2; restore_die "Offline input validation failed; private evidence: $restore_state"; } +read -r restore_ota_address restore_ota_size < <(python3 - "$restore_state/inputs.json" <<'PY' +import json, sys +v = json.load(open(sys.argv[1])) +print(v['otadata_address'], v['otadata_size']) +PY +) +restore_definitions="$restore_state/functions.sh" +awk ' + { sub(/\r$/, "") } + /^[A-Za-z_][A-Za-z0-9_]*\(\)[[:space:]]*\{[[:space:]]*$/ { if (capture) exit 2; capture=1; count++ } + capture { print } + capture && /^}[[:space:]]*$/ { capture=0 } + END { if (capture || count < 100) exit 2 } +' "$restore_flasher" > "$restore_definitions" +bash -n "$restore_definitions" +# shellcheck source=/dev/null +source "$restore_definitions" +for restore_function in esptool_set_variables save_selected_serial_port prepare_esp32_flash_session \ + selected_flash_serial_port esp32_record_and_verify_probe_output esp32_verified_destructive_port \ + run_esp32_session_esptool esp32_write_after_mode finish_esp32_flash_session; do + declare -F "$restore_function" >/dev/null || restore_die "Flasher lacks safety function: $restore_function" +done +PWD_SCRIPT=$(dirname -- "$restore_flasher") +FIRMWARE_ROOT=$restore_state DOWNLOAD_DIR="$restore_state/downloads" +mkdir -- "$DOWNLOAD_DIR" +DEVICE_PORT=$restore_port DEVICE_PORT_FILE="$restore_state/selected-port" DEVICE_PORT_NAME_FILE="$restore_state/selected-by-id" +DEVICE=$restore_device ARCHITECTURE=esp32 +ESPTOOL_PIPX_APP='' ESPTOOL_VERSION_OUTPUT='' ESPTOOL_CMD='pipx run --spec esptool esptool' +ESP32_SAFE_BAUD=115200 ESP32_FLASH_EXPECTED_MAC='' ESP32_SESSION_IS_S3=0 ESP32_NATIVE_ROM_READY=0 +ESP32_FLASH_SELECTED_BY_ID='' ESP32_FLASH_EXPECTED_SERIAL='' ESP32_FLASH_EXPECTED_PATH_STEM='' +BOOTLOADER_PROBE_PORT='' BOOTLOADER_PROBE_ACTIVE=0 USB_RESET_FAILED=0 +USB_RESET_RECOVERED_PORT='' USB_RESET_EXPECTED_IDENTITY='' +LOCKEDSERVICE=None MCFIRMWARE_NO_SUDO=1 MESH_DISABLE_1200_RECOVERY=1 +ESP32_PROBE_TIMEOUT_SECONDS=12 NRF52_DFU_REENUMERATE_TIMEOUT_SECONDS=30 NRF52_DFU_REENUMERATE_POLL_SECONDS=0.25 +MESHCORE_USB_RESET_TOOL_SHA256=$(sed -n 's/^MESHCORE_USB_RESET_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$restore_flasher") +MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256=$(sed -n 's/^MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256="\([0-9a-f]\{64\}\)"$/\1/p' "$restore_flasher") +[[ "$MESHCORE_USB_RESET_TOOL_SHA256" =~ ^[0-9a-f]{64}$ && "$MESHCORE_ESP32_BOOTLOADER_TOOL_SHA256" =~ ^[0-9a-f]{64}$ ]] \ + || restore_die 'Current flasher must pin both bundled USB handoff helpers.' +prepare_serial_port_for_flash() { restore_assert_unowned "$1"; } +recover_busy_serial_port() { return 1; } +offer_serial_port_recovery() { return 1; } +offer_identity_safe_1200_touch() { return 1; } +manual_reboot_choice() { return 1; } +esp32_recover_interrupted_transport() { return 1; } +esp32_run_chunked_write_recovery() { return 1; } +resolve_meshcore_usb_reset_tool() { + local tool="$PWD_SCRIPT/tools/meshcore_usb_reset.py" + meshcore_usb_reset_tool_hash_matches "$tool" || return 1 + printf '%s\n' "$tool" +} +resolve_meshcore_esp32_bootloader_tool() { + local tool="$PWD_SCRIPT/tools/meshcore_esp32_bootloader.py" + resolve_meshcore_usb_reset_tool >/dev/null || return 1 + meshcore_esp32_bootloader_tool_hash_matches "$tool" || return 1 + printf '%s\n' "$tool" +} +restore_notice() { + if ((restore_active)); then + printf 'Data restore session incomplete. No blind reset/recovery attempted; inspect private state %s.\n' "$restore_state" >&2 + fi +} +trap restore_notice EXIT +esptool_set_variables > "$restore_state/esptool-version.log" 2>&1 +[[ "$NORESET" == no-reset && "$READFLASH" == read-flash && "$WRITEFLASH" == write-flash ]] \ + || restore_die 'The reviewed native-S3 finish requires esptool5 or newer.' +ESP32_OPERATION_BEFORE=$NORESET +restore_guard_usb +restore_active=1 +prepare_esp32_flash_session "$DEVICE_PORT" "$DEVICE" > "$restore_state/prepare.log" 2>&1 +[[ "$ESP32_SESSION_IS_S3" == 1 && "$ESP32_FLASH_EXPECTED_MAC" == "$restore_mac" ]] \ + || restore_die 'Prepared silicon is not the explicitly selected ESP32-S3 MAC.' +DEVICE_PORT=$(selected_flash_serial_port "$DEVICE_PORT") +restore_guard_usb +run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 "$FLASHID" > "$restore_state/flash-id.log" 2>&1 +restore_probe=$(cat "$restore_state/flash-id.log") +esp32_record_and_verify_probe_output "$restore_probe" > "$restore_state/mac-check.log" 2>&1 +[[ "$ESP32_FLASH_EXPECTED_MAC" == "$restore_mac" ]] || restore_die 'Live silicon MAC mismatch.' +python3 "$restore_state/validate.py" probe "$restore_helper" "$restore_state" +restore_read() { + local address=$1 length=$2 output=$3 + restore_guard_usb + DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'pinned data restoration readback') + restore_guard_usb + [[ "$ESP32_FLASH_EXPECTED_MAC" == "$restore_mac" ]] || restore_die 'Immediate pre-read MAC mismatch.' + run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 \ + "$READFLASH" "$address" "$length" "$output" > "$output.log" 2>&1 +} +restore_preserved_snapshot() { + local suffix=$1 + restore_read 0x8000 0x1000 "$restore_state/table-$suffix.bin" + restore_read "$restore_ota_address" "$restore_ota_size" "$restore_state/otadata-$suffix.bin" + restore_read "$restore_app_address" "$restore_app_length" "$restore_state/application-$suffix.bin" +} +restore_preserved_snapshot before +python3 "$restore_state/validate.py" before "$restore_helper" "$restore_state" +# Exactly these two parsed, unencrypted DATA partitions. The app/table/OTA +# addresses never enter WRITEFLASH, and no erase-all command is available here. +while read -r restore_name restore_address restore_length restore_range_hash; do + [[ "$restore_name" == nvs || "$restore_name" == spiffs ]] || restore_die 'Unexpected restoration range.' + restore_original="$restore_state/$restore_name-original.private.bin" + restore_observed=$(sha256sum -- "$restore_original") + [[ "${restore_observed%% *}" == "$restore_range_hash" \ + && "$(stat -Lc '%s' -- "$restore_original")" == "$restore_length" ]] \ + || restore_die 'Staged original data changed before its write.' + restore_guard_usb + DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'exact original data-partition restoration') + restore_guard_usb + [[ "$ESP32_FLASH_EXPECTED_MAC" == "$restore_mac" ]] || restore_die 'Immediate pre-write MAC mismatch.' + run_esp32_session_esptool "$DEVICE_PORT" --after "$NORESET" --baud 115200 \ + "$WRITEFLASH" "$restore_address" "$restore_original" > "$restore_state/$restore_name-write.log" 2>&1 + restore_read "$restore_address" "$restore_length" "$restore_state/$restore_name-readback.private.bin" + restore_observed=$(sha256sum -- "$restore_state/$restore_name-readback.private.bin") + [[ "${restore_observed%% *}" == "$restore_range_hash" \ + && "$(stat -Lc '%s' -- "$restore_state/$restore_name-readback.private.bin")" == "$restore_length" ]] \ + || restore_die 'Data range readback mismatch; no next range or runtime finish attempted.' +done < "$restore_state/ranges.tsv" +restore_preserved_snapshot after +python3 "$restore_state/validate.py" after "$restore_helper" "$restore_state" +restore_guard_usb +DEVICE_PORT=$(esp32_verified_destructive_port "$DEVICE_PORT" 'verified original-data runtime handoff') +restore_guard_usb +[[ "$ESP32_FLASH_EXPECTED_MAC" == "$restore_mac" ]] || restore_die 'Final runtime handoff MAC mismatch.' +restore_after=$(esp32_write_after_mode "$DEVICE_PORT") +if [[ "$restore_after" != "$NORESET" ]]; then + run_esp32_session_esptool "$DEVICE_PORT" --after "$restore_after" --baud 115200 "$READMAC" \ + > "$restore_state/runtime-reset.log" 2>&1 +fi +finish_esp32_flash_session "$DEVICE_PORT" > "$restore_state/finish.log" 2>&1 +BOOTLOADER_PROBE_ACTIVE=0 BOOTLOADER_PROBE_PORT='' +restore_active=0 +python3 - "$restore_state" "$restore_mac" "$restore_flasher_hash" <<'PY' +import hashlib, json, pathlib, sys +state, mac, flasher_hash = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3] +v = json.loads((state/'inputs.json').read_text()) +v.update(schema_version=1, operation='original-data-partition-restoration', complete=True, + device_mac=mac, chip='ESP32-S3', data_readbacks_verified=True, + partition_table_unchanged=True, otadata_unchanged=True, active_application_unchanged=True, + other_flash_ranges_written=False, flasher_sha256=flasher_hash, + definitions_sha256=hashlib.sha256((state/'functions.sh').read_bytes()).hexdigest(), + session_finish='real flasher completed; application health requires separate verification') +(state/'result.json').write_text(json.dumps(v, indent=2)+'\n') +print(json.dumps(v, sort_keys=True)) +PY +printf 'Private data-restoration evidence: %s\n' "$restore_state" diff --git a/tools/hil/official_console_acceptance.py b/tools/hil/official_console_acceptance.py new file mode 100755 index 000000000..b1a32a1a4 --- /dev/null +++ b/tools/hil/official_console_acceptance.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""Run the pinned official console through Chromium's native Linux Web Serial. + +This explicitly executed hardware test only reads infrastructure CLI commands. +Configure the requested logging mode separately. It never resets the device, +changes firmware settings, adds keepalives, or substitutes a serial transport. +""" +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import http.server +import json +import os +from pathlib import Path +import re +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +import urllib.request + +from linux_web_serial import prepared_linux_web_serial + +CONSOLE_COMMIT = "45a5257bf792b11a83f4c51c18d4f312addfb677" +CONSOLE_SHA256 = "107d3ad867915aea8b2454c324327397a0f9e2f0469656046054011bc64475f9" +CONSOLE_BYTES = 2455 + +PAGE = r'''Native official console acceptance +''' + +PAGE_ERROR_CODES = frozenset(re.findall(r"\bError\('([A-Za-z0-9_]+)'\)", PAGE)) +JS_ERROR_CLASSES = frozenset({"Error", "TypeError", "RangeError", "ReferenceError", + "SyntaxError", "EvalError", "URIError", "AggregateError", "DOMException"}) +DOM_EXCEPTION_NAMES = frozenset({ + "AbortError", "DataError", "EncodingError", "HierarchyRequestError", "IndexSizeError", + "InvalidAccessError", "InvalidCharacterError", "InvalidModificationError", "InvalidStateError", + "NetworkError", "NotAllowedError", "NotFoundError", "NotReadableError", "NotSupportedError", + "OperationError", "QuotaExceededError", "ReadOnlyError", "SecurityError", "SyntaxError", + "TimeoutError", "TransactionInactiveError", "UnknownError", "VersionError", "WrongDocumentError", +}) + + +def safe_javascript_exception(details): + """Reduce a CDP exception to fixed names/codes and hashes, never its text. + + Native messages and JS stacks can contain host paths, node output or + credentials. Even a description beginning with a known code is not safe + unless the entire first line is that exact PAGE literal error. + """ + if not isinstance(details, dict): + details = {} + exception = details.get("exception", {}) + if not isinstance(exception, dict): + exception = {} + description = exception.get("description") + if not isinstance(description, str): + description = exception.get("value") + if not isinstance(description, str): + description = details.get("text", "") + if not isinstance(description, str): + description = "" + first_line = description.splitlines()[0] if description else "" + class_name = exception.get("className") + safe = {"class": class_name if isinstance(class_name, str) + and class_name in JS_ERROR_CLASSES | DOM_EXCEPTION_NAMES else "unrecognized", + "code": "unclassified_exception"} + message = first_line.removeprefix("Error: ") + if message in PAGE_ERROR_CODES: + safe["code"] = message + else: + safe["message_sha256"] = hashlib.sha256(first_line.encode("utf-8", errors="replace")).hexdigest() + name = first_line.split(":", 1)[0] + if name in DOM_EXCEPTION_NAMES: + safe["dom_exception_name"] = name + return safe + + +class NativeConsoleJavaScriptError(RuntimeError): + def __init__(self, details): + self.safe_details = safe_javascript_exception(details) + super().__init__("native_console_javascript_failed:" + self.safe_details["code"]) + + +class NativeConsoleTerminated(KeyboardInterrupt): + """Unwind the owned resources and report a normal SIGTERM interruption.""" + + +@contextmanager +def cleanup_on_sigterm(): + previous = signal.getsignal(signal.SIGTERM) + + def terminate(signum, _frame): + # A repeated TERM must not interrupt the bounded cleanup already in + # progress. The caller's original handler is restored on every exit. + signal.signal(signum, signal.SIG_IGN) + raise NativeConsoleTerminated() + + signal.signal(signal.SIGTERM, terminate) + try: + yield + finally: + signal.signal(signal.SIGTERM, previous) + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, _format, *_args): + pass + + +class CDP: + def __init__(self, websocket, debug_port, profile): + self.websocket = websocket + self.socket = None + self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + phase = "version_endpoint" + try: + with self.opener.open(f"http://127.0.0.1:{debug_port}/json/version", timeout=5) as response: + version = json.load(response) + phase = "browser_ownership" + browser = websocket.create_connection(version["webSocketDebuggerUrl"], timeout=5, + suppress_origin=True, http_no_proxy=["127.0.0.1", "localhost"]) + try: + browser.send(json.dumps({"id": 1, "method": "Browser.getBrowserCommandLine"})) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + result = json.loads(browser.recv()) + if result.get("id") == 1: + break + else: + raise TimeoutError("owned_browser_command_line_deadline") + arguments = result.get("result", {}).get("arguments", []) + require("--user-data-dir=" + str(profile) in arguments + and "--remote-debugging-port=" + str(debug_port) in arguments, + "CDP_browser_does_not_match_owned_profile") + finally: + browser.close() + phase = "initial_page_endpoint" + # The fresh owned browser already has one about:blank page. Reuse + # it: PUT /json/new on every retry creates extra pages/renderer work + # and can compound a startup failure on the small Pi. + with self.opener.open(f"http://127.0.0.1:{debug_port}/json/list", timeout=5) as response: + targets = json.load(response) + pages = [target for target in targets if target.get("type") == "page"] + if not pages: + raise OSError("owned_initial_page_not_ready") + require(len(pages) == 1 and pages[0].get("url") == "about:blank", + "owned_browser_requires_one_initial_blank_page") + phase = "initial_page_connection" + self.socket = websocket.create_connection(pages[0]["webSocketDebuggerUrl"], timeout=5, + suppress_origin=True, http_no_proxy=["127.0.0.1", "localhost"]) + self.socket.settimeout(0.5) + self.sequence = 0 + self.browser_version = version.get("Browser") + phase = "initial_page_enable" + self.call("Page.enable") + self.call("Runtime.enable") + except BaseException as error: + # A failed constructor is never returned to execute()'s finally. + # Close this partial connection here before any startup retry. + if self.socket is not None: + self.socket.close() + error.startup_phase = phase + raise + + def call(self, method, parameters=None, timeout=15): + self.sequence += 1 + sequence = self.sequence + self.socket.send(json.dumps({"id": sequence, "method": method, "params": parameters or {}})) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + response = json.loads(self.socket.recv()) + except self.websocket.WebSocketTimeoutException: + continue + if response.get("id") == sequence: + require("error" not in response, "CDP_request_failed") + return response.get("result", {}) + raise RuntimeError("CDP_request_deadline") + + def evaluate(self, expression, timeout=15): + response = self.call("Runtime.evaluate", {"expression": expression, + "returnByValue": True, "awaitPromise": True}, timeout) + if "exceptionDetails" in response: + raise NativeConsoleJavaScriptError(response["exceptionDetails"]) + return response.get("result", {}).get("value") + + +def wait_for_owned_browser(websocket, debug_port, profile, browser, report, timeout=60): + startup = report["browser_startup"] = {"attempts": 0, "initial_page_reused": True} + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + require(browser.poll() is None, "owned_Chromium_exited_before_ready") + startup["attempts"] += 1 + try: + page = CDP(websocket, debug_port, profile) + startup["ready"] = True + return page + except (OSError, urllib.error.URLError, websocket.WebSocketTimeoutException, + websocket.WebSocketConnectionClosedException) as error: + startup.update(last_error_type=type(error).__name__, + last_phase=getattr(error, "startup_phase", "unknown")) + time.sleep(0.1) + raise RuntimeError("owned_Chromium_startup_deadline") + + +def select_device(serial_number): + from serial.tools import list_ports + matches = [] + for info in list_ports.comports(): + if info.serial_number != serial_number: + continue + properties = subprocess.run(["udevadm", "info", "--query=property", "--name=" + info.device], + check=True, capture_output=True, text=True).stdout + values = dict(line.split("=", 1) for line in properties.splitlines() if "=" in line) + if values.get("ID_USB_INTERFACE_NUM") == "00": + matches.append(info) + require(len(matches) == 1, "exact_USB_serial_requires_one_primary_interface") + device = matches[0] + require(device.vid is not None and device.pid is not None, "USB_vendor_product_ID_required") + ownership = subprocess.run(["sudo", "-n", "fuser", device.device], capture_output=True) + require(ownership.returncode == 1 and not ownership.stdout and not ownership.stderr, + "selected_serial_port_must_be_unowned") + return device + + +def close_owned_browser(page, browser, report): + try: + if page is not None: + try: + page.evaluate("hil.client ? hil.close() : null") + except Exception as error: + report["browser_stream_cleanup_error"] = True + if isinstance(error, NativeConsoleJavaScriptError): + report["browser_stream_cleanup_exception"] = error.safe_details + finally: + try: + page.socket.close() + except Exception: + report["browser_socket_cleanup_error"] = True + finally: + # Stream/CDP failures must never skip termination of our process group. + if browser is not None and browser.poll() is None: + os.killpg(browser.pid, signal.SIGTERM) + try: + browser.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(browser.pid, signal.SIGKILL) + browser.wait(timeout=5) + report["owned_browser_closed"] = browser is None or browser.poll() is not None + + +def execute(args, report): + if args.vendor: + sys.path.insert(0, str(args.vendor.resolve())) + import websocket + + source = args.console_source.read_bytes() + require(len(source) == CONSOLE_BYTES and hashlib.sha256(source).hexdigest() == CONSOLE_SHA256, + "official_console_source_pin_mismatch") + report.update(console_commit=CONSOLE_COMMIT, console_sha256=CONSOLE_SHA256, + console_bytes=CONSOLE_BYTES, artifact_manifest_sha256=sha256(args.artifact_manifest), + expected_board_sha256=hashlib.sha256(args.expected_board.encode()).hexdigest(), + expected_version=args.expected_version, logging_expected=args.logging, + serial_identity_sha256=hashlib.sha256(args.serial.encode()).hexdigest()) + device = select_device(args.serial) + work = Path(tempfile.mkdtemp(prefix="official-native-console-", dir=args.work_dir)) + work.chmod(0o700) + report["private_state_directory"] = str(work) + site = work / "site" + site.mkdir() + (site / "index.html").write_text(PAGE, encoding="utf-8") + (site / "console.js").write_bytes(source) + handler = lambda *values, **kw: QuietHandler(*values, directory=str(site), **kw) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + origin = f"http://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + profile = work / "profile" + (profile / "Default").mkdir(parents=True) + grant = {"name": device.product or args.expected_board, "vendor_id": device.vid, + "product_id": device.pid, "serial_number": args.serial} + preferences = {"profile": {"content_settings": {"exceptions": {"serial_chooser_data": { + origin + ",*": {"setting": {"chosen-objects": [grant]}}}}}}} + (profile / "Default/Preferences").write_text(json.dumps(preferences)) + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + debug_port = reservation.getsockname()[1] + browser = page = None + browser_log = (work / "chromium-private.log").open("xb") + try: + with prepared_linux_web_serial(device.device) as tty: + report["TTY_preflight"] = tty + try: + # Re-enumerate the exact UID and ownership after TTY preparation. + require(select_device(args.serial).device == device.device, "USB_endpoint_changed_before_browser") + browser = subprocess.Popen([str(args.chromium), "--headless=new", "--no-sandbox", + "--disable-dev-shm-usage", "--disable-gpu", "--disable-background-networking", + "--disable-extensions", "--enable-automation", "--renderer-process-limit=1", + "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=" + str(debug_port), + "--user-data-dir=" + str(profile), "about:blank"], + stdout=browser_log, stderr=browser_log, start_new_session=True) + report["phase"] = "browser_startup" + page = wait_for_owned_browser(websocket, debug_port, profile, browser, report) + report["browser_version"] = page.browser_version + report["phase"] = "official_module_startup" + page.call("Page.navigate", {"url": origin + "/index.html"}, timeout=60) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if page.evaluate("!!window.hil?.ready"): + break + time.sleep(0.1) + require(page.evaluate("!!window.hil?.ready"), "official_module_did_not_load") + report["phase"] = "native_serial_open" + page.evaluate(f"hil.open({device.vid},{device.pid})") + + def query(command): + report["phase"] = "native_query:" + command + result = page.evaluate("hil.query(" + json.dumps(command) + ")") + require(result["complete_CRLF_reply"], "complete_CRLF_reply_required") + body = result["body"] + require(not body.startswith(("Error", "Unknown", "Unsupported")), "firmware_command_rejected") + return body, result["elapsed_ms"] + + board, _ = query("board") + version, _ = query("ver") + logging, _ = query("get usb.logging") + require(board == args.expected_board, "runtime_board_mismatch") + require(version == args.expected_version, "runtime_version_mismatch") + require(logging == "> " + args.logging, "logging_mode_must_be_preconfigured") + before, _ = query("stats-core") + before = json.loads(before) + report["before"] = before + report["phase"] = "uninterrupted_idle" + print(json.dumps({"phase": "native-console-idle-start", "seconds": args.idle_seconds}), flush=True) + idle = page.evaluate(f"hil.idle({args.idle_seconds})", timeout=args.idle_seconds + 30) + report["idle"] = idle + require(idle["seconds"] >= args.idle_seconds and idle["command_writes"] == 0, + "uninterrupted_no_transmit_idle_required") + after, elapsed = query("stats-core") + after = json.loads(after) + report.update(after=after, first_post_idle_reply_ms=elapsed) + elapsed_uptime = after["uptime_secs"] - before["uptime_secs"] + require(args.idle_seconds - 2 <= elapsed_uptime <= idle["seconds"] + 15, + "uptime_delta_indicates_reset_or_stall") + report["idle_uptime_delta_seconds"] = elapsed_uptime + report["phase"] = "native_serial_first_close" + report["first_close"] = page.evaluate("hil.close()") + report["phase"] = "native_serial_reopen" + page.evaluate(f"hil.open({device.vid},{device.pid})") + reopened, elapsed = query("stats-core") + reopened = json.loads(reopened) + require(reopened["uptime_secs"] >= after["uptime_secs"], "reopen_reset_or_uptime_regression") + require(query("board")[0] == args.expected_board and query("ver")[0] == args.expected_version, + "runtime_identity_changed_on_reopen") + require(query("get usb.logging")[0] == "> " + args.logging, "logging_mode_changed") + report.update(reopened=reopened, reopened_reply_ms=elapsed, + command_writes=page.evaluate("hil.writes"), complete_CRLF_replies=page.evaluate("hil.replies")) + report["phase"] = "native_serial_final_close" + report["final_close"] = page.evaluate("hil.close()") + report["native_streams_closed"] = True + report["phase"] = "complete" + finally: + close_owned_browser(page, browser, report) + report["original_TTY_restored"] = True + require(select_device(args.serial).device == device.device, "USB_identity_or_ownership_changed_after_cleanup") + require(not (report.get("browser_stream_cleanup_error") or report.get("browser_socket_cleanup_error")), + "native_stream_cleanup_failed") + finally: + browser_log.close() + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--serial", required=True) + parser.add_argument("--expected-board", required=True) + parser.add_argument("--expected-version", required=True, help="Exact full ver reply, including Build suffix") + parser.add_argument("--logging", choices=("on", "off"), required=True) + parser.add_argument("--idle-seconds", type=int, default=600) + parser.add_argument("--console-source", type=Path, required=True) + parser.add_argument("--artifact-manifest", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--work-dir", type=Path, required=True) + parser.add_argument("--chromium", type=Path, default=Path("/usr/bin/chromium")) + parser.add_argument("--vendor", type=Path, help="Optional existing pySerial/websocket-client package directory") + parser.add_argument("--execute", action="store_true", help="Explicitly open the selected native serial port") + args = parser.parse_args(argv) + if not 1 <= args.idle_seconds <= 3600: + parser.error("idle seconds must be between 1 and 3600") + if not args.serial or any(value in args.serial for value in "\r\n\0"): + parser.error("serial must be an exact single-line USB identity") + if not args.execute: + print(json.dumps({"execute": False, "idle_seconds": args.idle_seconds, + "console_sha256": CONSOLE_SHA256, "native_WebSerial": True, + "commands": ["board", "ver", "get usb.logging", "stats-core"], + "settings_mutation": False, "reset_hooks": False})) + return 0 + os.umask(0o077) + require(args.work_dir.is_dir(), "existing_private_work_directory_required") + require(args.work_dir.stat().st_uid == os.getuid() + and args.work_dir.stat().st_mode & 0o077 == 0, "work_directory_must_be_private_and_owned") + descriptor = os.open(args.output, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + report = {"schema_version": 1, "ok": False, + "started_utc": datetime.now(timezone.utc).isoformat(), + "scope": "pinned-official-console-native-WebSerial-open-idle-reopen", + "settings_mutation": False, "reset_hooks": False, + "firmware_artifact_binding": "external manifest plus exact runtime board/version; no flash read"} + status = 1 + try: + with cleanup_on_sigterm(): + execute(args, report) + report["ok"] = True + status = 0 + except BaseException as error: + # Preserve only controlled errors, never arbitrary native console output. + report["error_type"] = type(error).__name__ + report["error"] = str(error) if isinstance(error, RuntimeError) else "hardware_acceptance_interrupted_or_failed" + if isinstance(error, NativeConsoleJavaScriptError): + report["javascript_exception"] = error.safe_details + if isinstance(error, NativeConsoleTerminated): + report["termination_signal"] = "SIGTERM" + status = 143 + elif isinstance(error, KeyboardInterrupt): + status = 130 + finally: + report["finished_utc"] = datetime.now(timezone.utc).isoformat() + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(report, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + print(json.dumps({"ok": report["ok"], "output": str(args.output), "error": report.get("error")})) + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/room_browser_acceptance.py b/tools/hil/room_browser_acceptance.py new file mode 100644 index 000000000..0f0f4b47a --- /dev/null +++ b/tools/hil/room_browser_acceptance.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python3 +"""Exercise the real Room page against an explicitly selected live board. + +Run with other room authentication tests paused. This causes a temporary global +30-second password lock, uses three ephemeral browser identities, and reads +chat/index data. It never submits a chat draft, saves settings, or changes room +data. Chromium uses an isolated private profile; window.fetch is not replaced. +CDP passively observes requests and responses without pausing or altering them. +Any unexpected write fails the run immediately. Request evidence excludes bodies, +passwords, tokens and headers. + +Example, with an existing SSH TCP forward to the actual board:: + + python3 tools/hil/room_browser_acceptance.py \ + --endpoint http://127.0.0.1:19080 --manifest identity.json \ + --secrets-file private.json --run-id v4-room-ui-01 \ + --allow-auth-attempts --work-dir /private/browser-runs \ + --chromium /usr/bin/chromium --report new-result.json + +The manifest uses room_webconfig_acceptance.py's identity/artifact schema. +Its optional room_html_sha256 pins the exact served HTML; otherwise --room-source +extracts that hash from the repository's RoomWebHtml.h. The firmware SHA is +external evidence, not a live flash measurement. The owned 0600/0400 private JSON +needs admin_password and room_public_read_only; optional request_timeout, +room_timeout and timing_guard have the same meaning as the HTTP runner. Its +source_ips are unused: the Room budget is global across tokens. Passwords, +tokens, chat contents, names, origins and raw browser errors are never printed. + +Requires Chromium and websocket-client (optionally supplied by --vendor), plus +the adjacent official_console_acceptance.py, linux_web_serial.py and +room_webconfig_acceptance.py. No serial port is opened. Allow about 90 seconds. +Failure may leave a temporary room lock; private browser state is retained in +the protected work directory for operator cleanup, and the owned browser exits. +""" + +from __future__ import annotations + +import argparse +from collections import Counter +import hashlib +import http.client +import ipaddress +import json +import os +from pathlib import Path +import re +import secrets +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +from urllib.parse import urlsplit + +from official_console_acceptance import CDP +from room_webconfig_acceptance import ( + CheckFailure, HttpClient, MAX_RESPONSE, ROOM_ERROR, ROOM_WRONG, RoomClient, + load_json, parse_endpoint, require, resolve_endpoint, subset_matches, + validate_manifest, +) + + +def private_settings(value): + password = value.get("admin_password") + require(isinstance(password, str) and 0 < len(password.encode("utf-8")) <= 64 + and "\0" not in password, "private_admin_password_invalid") + require(type(value.get("room_public_read_only")) is bool, + "room_read_only_expectation_required") + value = dict(value) + for key, default, minimum, maximum in ( + ("request_timeout", 2.0, 0.1, 5.0), + ("room_timeout", 12.0, 2.0, 20.0), + ("timing_guard", 0.35, 0.2, 1.0)): + number = value.get(key, default) + require(type(number) in (int, float) and minimum <= number <= maximum, + "private_timing_value_invalid") + value[key] = float(number) + return value + + +def html_hash(args, manifest): + supplied = manifest.get("room_html_sha256") + if supplied is not None: + require(isinstance(supplied, str) and re.fullmatch(r"[0-9a-fA-F]{64}", supplied), + "manifest_room_html_sha256_invalid") + return supplied.lower() + try: + source = args.room_source.read_bytes() + require(source.count(b'R"roomhtml(') == 1 + and source.count(b')roomhtml";') == 1, "room_source_delimiters_invalid") + html = source.split(b'R"roomhtml(', 1)[1].split(b')roomhtml";', 1)[0] + except OSError: + raise CheckFailure("room_source_unreadable_or_manifest_HTML_hash_required") from None + require(html.startswith(b""), "room_source_HTML_invalid") + return hashlib.sha256(html).hexdigest() + + +def served_html_hash(endpoint, timeout): + connection = http.client.HTTPConnection(endpoint["address"], endpoint["port"], + timeout=timeout, source_address=("127.0.0.1", 0)) + try: + connection.request("GET", "/room", headers={"Host": endpoint["host_header"], + "Accept": "text/html", "Connection": "close"}) + response = connection.getresponse() + body = response.read(MAX_RESPONSE + 1) + require(response.status == 200 and len(body) <= MAX_RESPONSE + and response.getheader("Content-Type", "").startswith("text/html"), + "live_room_page_contract_invalid") + return hashlib.sha256(body).hexdigest() + except (OSError, http.client.HTTPException): + raise CheckFailure("live_room_page_request_failed") from None + finally: + connection.close() + + +class PassiveCDP(CDP): + """Observe the existing transport without Fetch interception or patches.""" + + def __init__(self, websocket, debug_port, profile, origin): + self.origin = origin + self.operations = Counter() + self.unexpected_requests = 0 + self.write_requests = 0 + self.network_started = time.monotonic() + self.requests = [] + self.request_ids = {} + self.room_operations = {} + self.runtime_errors = Counter() + self.browser_errors = Counter() + super().__init__(websocket, debug_port, profile) + try: + self.call("Log.enable") + self.call("Network.enable", {"maxPostDataSize": 4096}) + except BaseException: + # A constructor failure never reaches the caller's page cleanup. + # Release this partial connection before retrying the owned page. + self.socket.close() + raise + + def network(self, method, event): + identifier = event.get("requestId") + elapsed = round(time.monotonic() - self.network_started, 3) + if method == "Network.requestWillBeSent": + request = event["request"] + url = urlsplit(request["url"]) + verb = request["method"] + same_origin = url.scheme + "://" + url.netloc == self.origin + known_paths = ("/room", "/api/status", "/favicon.ico", "/api/room", "/api/room/result") + path = url.path if same_origin and url.path in known_paths else "other" + headers = {key.lower(): value for key, value in request.get("headers", {}).items()} + sequence = headers.get("x-room-seq", "") + sequence = int(sequence) if re.fullmatch(r"[0-9]{1,10}", str(sequence)) else None + operation = None + read_only = same_origin and not url.query and not url.fragment and ( + verb == "GET" and path in ("/room", "/api/status", "/favicon.ico") + or verb == "POST" and path == "/api/room/result") + if verb == "POST" and path == "/api/room": + try: + payload = json.loads(request.get("postData", "")) + operation = payload.get("op") if isinstance(payload, dict) else None + except (ValueError, TypeError): + operation = None + if operation in ("status", "posts", "board.index"): + self.operations[operation] += 1 + if sequence is not None: + self.room_operations[sequence] = operation + read_only = not url.query and not url.fragment + else: + operation = "unexpected" + elif path == "/api/room/result": + operation = self.room_operations.get(sequence, "unmatched_result") + entry = {"ordinal": len(self.requests) + 1, "path": path, + "method": verb if verb in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") else "other", + "operation": operation, "room_sequence": sequence, + "started_seconds": elapsed, "status": None, + "finished": False, "failed": False} + self.requests.append(entry) + self.request_ids[identifier] = entry + if not read_only: + self.unexpected_requests += 1 + if verb not in ("GET", "HEAD", "OPTIONS"): + self.write_requests += 1 + raise CheckFailure("unexpected_browser_request_observed_passively") + return + entry = self.request_ids.get(identifier) + if entry is None: + return + if method == "Network.responseReceived": + status = event.get("response", {}).get("status") + entry["status"] = int(status) if type(status) in (int, float) else None + entry["response_seconds"] = elapsed + elif method == "Network.loadingFinished": + entry.update(finished=True, finished_seconds=elapsed, + encoded_bytes=event.get("encodedDataLength")) + elif method == "Network.loadingFailed": + error = event.get("errorText", "") + entry.update(failed=True, failed_seconds=elapsed, + canceled=bool(event.get("canceled")), + failure_class=(error if re.fullmatch(r"net::ERR_[A-Z0-9_]+", error) + else "other_network_failure")) + + def call(self, method, parameters=None, timeout=15): + self.sequence += 1 + sequence = self.sequence + self.socket.send(json.dumps({"id": sequence, "method": method, "params": parameters or {}})) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + response = json.loads(self.socket.recv()) + except self.websocket.WebSocketTimeoutException: + continue + if response.get("method", "").startswith("Network."): + self.network(response["method"], response.get("params", {})) + continue + if response.get("method") == "Runtime.exceptionThrown": + details = response.get("params", {}).get("exceptionDetails", {}) + name = details.get("exception", {}).get("className", "OtherError") + if name not in ("SyntaxError", "ReferenceError", "TypeError", "RangeError", + "SecurityError", "DOMException", "Error"): + name = "OtherError" + self.runtime_errors[name] += 1 + continue + if response.get("method") == "Log.entryAdded": + entry = response.get("params", {}).get("entry", {}) + if entry.get("level") == "error": + source = entry.get("source") + if source not in ("javascript", "network", "security", "storage", "rendering"): + source = "other" + self.browser_errors[source] += 1 + continue + identifier = response.get("id") + if identifier == sequence: + require("error" not in response, "CDP_request_failed") + return response.get("result", {}) + raise CheckFailure("CDP_request_deadline") + + +def wait_for_room_browser(websocket, debug_port, profile, origin, browser, timeout=60): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + require(browser.poll() is None, "owned_Chromium_exited_before_ready") + try: + return PassiveCDP(websocket, debug_port, profile, origin) + except (OSError, urllib.error.URLError, websocket.WebSocketTimeoutException, + websocket.WebSocketConnectionClosedException): + time.sleep(0.1) + raise CheckFailure("owned_Chromium_startup_deadline") + + +def snapshot(page, draft): + return page.evaluate("""(() => { + const el=id=>document.getElementById(id); + // DOM nodes precede the large inline script in the streamed response. + // Also tolerate lexical bindings that are still uninitialized mid-parse. + const safely=(read,fallback)=>{try{return read()}catch(_){return fallback}}; + const complete=document.readyState==='complete'; + const scriptReady=complete&&safely(()=>typeof joined==='boolean' + &&typeof polling==='boolean'&&typeof role==='number' + &&typeof makeIdentity==='function'&&typeof request==='function' + &&typeof refreshChat==='function'&&typeof chain==='object',false); + return {ready:scriptReady&&!!el('join')&&!!el('message')&&!!el('refresh'), + document_ready_state:document.readyState, script_ready:scriptReady, + form_present:!!el('join'), draft_input_present:!!el('message'), + joined:safely(()=>typeof joined==='boolean'&&joined,false), + role:safely(()=>typeof role==='number'?role:null,null), + boot:safely(()=>typeof boot==='number'?boot:null,null), + polling:safely(()=>typeof polling==='boolean'&&polling,false), + room_visible:!!el('room')&&!el('room').hidden, + state:el('state')?.textContent, error:el('error')?.textContent, + draft_matches:el('message')?.value===__DRAFT__, + password_cleared:el('password')?.value==='', + notices_ready:!!el('notices')?.childNodes.length, + native_fetch:Function.prototype.toString.call(window.fetch).includes('[native code]'), + web_key:el('web-key')?.textContent, room_name:el('room-name')?.textContent}; + })()""".replace("__DRAFT__", json.dumps(draft))) + + +def safe_snapshot(value): + """Only fixed classifications/booleans leave the private CDP state.""" + keep = ("ready", "document_ready_state", "script_ready", "form_present", + "draft_input_present", "joined", "role", "polling", "room_visible", + "draft_matches", "password_cleared", "notices_ready", "native_fetch") + result = {key: value.get(key) for key in keep} + states = {"Admin access": "admin", "Read and write access": "read_write", + "Read-only access": "read_only", "Disconnected": "disconnected", "": "empty"} + result["state_kind"] = states.get(value.get("state"), "other") + error = value.get("error") or "" + result["error_kind"] = ({"": "none", ROOM_ERROR: "password_rate_window", + ROOM_WRONG: "incorrect_password", + "No confirmation received. Refresh and check the room before trying the operation again.": + "request_confirmation_timeout"}.get(error, "other")) + result["error_length"] = len(error) + result["boot_present"] = type(value.get("boot")) is int and value["boot"] > 0 + return result + + +def require_join_handler(page): + node = page.call("Runtime.evaluate", { + "expression": "document.getElementById('join')", "returnByValue": False}) + object_id = node.get("result", {}).get("objectId") + require(object_id is not None, "production_join_form_object_missing") + try: + listeners = page.call("DOMDebugger.getEventListeners", {"objectId": object_id}) + require(any(listener.get("type") == "submit" for listener in listeners.get("listeners", [])), + "production_join_submit_handler_not_attached") + finally: + page.call("Runtime.releaseObject", {"objectId": object_id}) + + +def wait_for(page, draft, predicate, seconds, failure): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + require(page.unexpected_requests == 0, "unexpected_browser_request_observed_passively") + value = snapshot(page, draft) + if predicate(value): + return value + time.sleep(0.1) + raise CheckFailure(failure) + + +def join(page, password): + # Interact with the production form; never invoke an API or compose handler. + require_join_handler(page) + page.evaluate("""(() => { + if(document.readyState!=='complete'||typeof joined!=='boolean') + throw Error('Room production handlers are not ready'); + document.getElementById('password').value=__PASSWORD__; + document.getElementById('join').requestSubmit(); return true; + })()""".replace("__PASSWORD__", json.dumps(password))) + + +def execute(args, manifest, private, report, record): + if args.vendor: + sys.path.insert(0, str(args.vendor.resolve())) + import websocket + + endpoint = resolve_endpoint(args.endpoint) + require(ipaddress.ip_address(endpoint["address"]).is_loopback, + "endpoint_must_be_loopback_forward_to_board") + origin = "http://" + endpoint["host_header"] + expected_html = html_hash(args, manifest) + client = HttpClient(endpoint, "127.0.0.1", private["request_timeout"]) + + def identity(): + reply = client.request("GET", "/api/status", use_cookie=False) + require(reply.status == 200 and subset_matches(reply.body, manifest["status"]), + "live_status_identity_mismatch") + require(reply.body.get("password_supported") is True + and reply.body.get("needs_setup") is False + and reply.body.get("needs_password") is False, + "configured_admin_auth_precondition_failed") + require(type(reply.body.get("uptime_s")) is int, "live_uptime_missing") + return reply.body["uptime_s"], time.monotonic() + + uptime, identity_at = identity() + require(served_html_hash(endpoint, private["request_timeout"]) == expected_html, + "live_room_HTML_does_not_match_expected_source") + report["room_html_sha256"] = expected_html + record("live_node_version_mode_and_Room_HTML_match_manifest_and_source") + report["phase"] = "initial_quiet_window" + record("initial_quiet_window", "WAIT", seconds=30 + private["timing_guard"]) + time.sleep(30 + private["timing_guard"]) + + work = Path(tempfile.mkdtemp(prefix="room-browser-", dir=args.work_dir)) + work.chmod(0o700) + report["private_state_directory"] = str(work) + profile = work / "profile" + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + debug_port = reservation.getsockname()[1] + browser = page = None + browser_log = (work / "chromium-private.log").open("xb") + draft = "HIL unsent draft " + secrets.token_hex(12) + report["draft_sha256"] = hashlib.sha256(draft.encode()).hexdigest() + try: + browser = subprocess.Popen([str(args.chromium), "--headless=new", "--no-sandbox", + "--disable-dev-shm-usage", "--disable-gpu", "--disable-background-networking", + "--disable-extensions", "--disable-sync", "--no-first-run", "--no-default-browser-check", + "--enable-automation", "--renderer-process-limit=1", + "--proxy-server=direct://", "--proxy-bypass-list=*", + "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=" + str(debug_port), + "--user-data-dir=" + str(profile), "about:blank"], + stdout=browser_log, stderr=browser_log, start_new_session=True) + page = wait_for_room_browser(websocket, debug_port, profile, origin, browser) + report["browser_version"] = page.browser_version + report["phase"] = "initial_admin_UI_login" + page.call("Page.navigate", {"url": origin + "/room"}) + wait_for(page, draft, lambda state: state["ready"], 15, "real_room_UI_did_not_load") + report["authentication_attempts_started"] = True + join(page, private["admin_password"]) + ready = lambda state: (state["joined"] and state["role"] == 3 + and state["room_visible"] and not state["polling"] and state["notices_ready"] + and state["state"] == "Admin access" and state["error"] == "") + initial = wait_for(page, draft, ready, 30, "real_admin_UI_login_failed") + require(initial["password_cleared"] and initial["native_fetch"], + "UI_password_not_cleared_or_fetch_not_native") + expected_name = manifest["config_expected"].get("radio", {}).get("name") + if expected_name is not None: + require(initial["room_name"] == expected_name, "room_UI_node_name_mismatch") + require(type(initial["boot"]) is int and initial["boot"] > 0 + and re.fullmatch(r"[0-9A-Fa-f]{64}", initial["web_key"] or "") is not None, + "room_UI_boot_or_identity_missing") + page.evaluate("""(() => {const el=document.getElementById('message'); + el.value=__DRAFT__;el.dispatchEvent(new Event('input',{bubbles:true}));return true;})() + """.replace("__DRAFT__", json.dumps(draft))) + require(snapshot(page, draft)["draft_matches"], "draft_input_failed") + record("real_admin_UI_login_and_unsent_draft") + + report["phase"] = "force_global_failure_window" + actors = [RoomClient(HttpClient(endpoint, "127.0.0.1", private["request_timeout"]), + private["room_timeout"]) for _ in range(2)] + forbidden = {private["admin_password"], private.get("room_guest_password", "")} + first = None + before_status_requests = page.operations["status"] + for index in range(5): + wrong = "hil-wrong-" + secrets.token_hex(16) + while wrong in forbidden: + wrong = "hil-wrong-" + secrets.token_hex(16) + forbidden.add(wrong) + reply = actors[index % 2].status(wrong) + if first is None: + first = reply + if private["room_public_read_only"]: + require("error" not in reply.body and reply.body.get("role") == 0, + "wrong_password_not_admitted_as_public_guest") + else: + require(reply.body.get("error") == ROOM_WRONG, + "wrong_password_did_not_match_private_policy") + require(time.monotonic() - first.started < 10, + "five_failure_setup_too_slow_for_UI_window") + locked = actors[1].status(private["admin_password"]) + require(locked.status == 200 and locked.body.get("error") == ROOM_ERROR, + "real_global_lock_not_confirmed_as_JSON_error") + empty = actors[1].status("") + if private["room_public_read_only"]: + require("error" not in empty.body and empty.body.get("role") == 0, + "empty_password_public_guest_access_lost_during_lock") + else: + require(empty.body.get("error") == ROOM_WRONG, + "empty_password_bypassed_private_room_policy") + record("five_unique_wrong_passwords_across_two_tokens_lock_valid_admin_JSON_error") + + report["phase"] = "real_automatic_UI_poll_under_lock" + # Pump CDP events while the unmodified page's own 15-second timer polls. + remaining = min(20.0, first.started + 27.0 - time.monotonic()) + require(remaining > 0, "insufficient_time_for_automatic_UI_poll") + failed = wait_for(page, draft, lambda state: state["error"] == ROOM_ERROR, + remaining, "automatic_UI_poll_did_not_show_real_lock_message") + require(failed["draft_matches"] and failed["joined"] + and failed["room_visible"] and failed["native_fetch"] + and failed["boot"] == initial["boot"] + and page.operations["status"] > before_status_requests, + "UI_draft_session_or_real_poll_not_preserved_during_lock") + record("automatic_real_UI_poll_shows_rate_window_and_preserves_draft") + + report["phase"] = "window_expiry_and_UI_reconnect" + deadline = first.finished + 30 + private["timing_guard"] + while time.monotonic() < deadline: + require(snapshot(page, draft)["draft_matches"], "draft_changed_while_waiting_for_retry") + require(page.unexpected_requests == 0, "unexpected_browser_request_observed_passively") + time.sleep(min(0.25, max(0, deadline - time.monotonic()))) + # Connect is the production retry that also clears an old error banner. + # Refresh alone leaves that banner's text unchanged in the current UI. + join(page, private["admin_password"]) + recovered = wait_for(page, draft, ready, 30, "UI_admin_retry_did_not_recover_after_window") + require(recovered["draft_matches"] and recovered["password_cleared"] + and recovered["native_fetch"] and recovered["boot"] == initial["boot"] + and recovered["web_key"] == initial["web_key"], + "UI_retry_changed_draft_identity_boot_or_fetch") + record("Connect_retry_recovers_admin_and_clears_error_without_clearing_draft") + final_uptime, final_at = identity() + require(abs(final_uptime - uptime - (final_at - identity_at)) < 5, + "board_restarted_or_uptime_changed_during_UI_scenario") + require(page.unexpected_requests == 0, "unexpected_browser_request_observed_passively") + report.update(browser_read_operations=dict(page.operations), + unexpected_browser_requests_observed=page.unexpected_requests, + native_fetch_unchanged=True, room_boot_unchanged=True, + browser_identity_preserved=True, unsent_draft_preserved=True, + retry_method="production Connect form", temporary_room_lock_may_remain=False) + finally: + if page is not None: + try: + report["final_UI_state"] = safe_snapshot(snapshot(page, draft)) + except Exception: + report["final_UI_state_unavailable"] = True + report["browser_read_operations"] = dict(page.operations) + report["unexpected_browser_requests_observed"] = page.unexpected_requests + report["browser_write_requests_observed"] = page.write_requests + report["browser_request_evidence"] = page.requests + report["page_runtime_exception_classes"] = dict(page.runtime_errors) + report["browser_error_sources"] = dict(page.browser_errors) + page.socket.close() + if browser is not None and browser.poll() is None: + os.killpg(browser.pid, signal.SIGTERM) + try: + browser.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(browser.pid, signal.SIGKILL) + browser.wait(timeout=5) + report["owned_browser_closed"] = browser is None or browser.poll() is not None + browser_log.close() + + +def argument_parser(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--endpoint", required=True, help="Loopback HTTP origin forwarded to the actual board") + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--secrets-file", type=Path, + default=os.environ.get("MESHCORE_AUTH_SECRETS_FILE")) + parser.add_argument("--run-id", required=True) + parser.add_argument("--allow-auth-attempts", action="store_true") + parser.add_argument("--report", type=Path, required=True, help="New sanitized JSON report; never overwritten") + parser.add_argument("--work-dir", type=Path, required=True, help="Existing private parent for browser state") + parser.add_argument("--chromium", type=Path, default=Path("/usr/bin/chromium")) + parser.add_argument("--vendor", type=Path, help="Directory containing websocket-client") + parser.add_argument("--room-source", type=Path, + default=Path(__file__).resolve().parents[2] / "src/helpers/esp32/RoomWebHtml.h") + return parser + + +def main(argv=None): + args = argument_parser().parse_args(argv) + report = {"schema": 1, "result": "FAIL", "phase": "input_validation", "steps": [], + "run_id": None, "live_flash_hash_verified": False, + "firmware_sha256_evidence": "externally supplied manifest", + "board_settings_changed_by_runner": False, "compose_submit_actions": 0, + "browser_network_interception": False, "authentication_attempts_started": False, + "temporary_room_lock_may_remain": False} + output = None + started = time.monotonic() + + def record(step, result="PASS", **values): + event = {"step": step, "result": result, **values} + report["steps"].append(event) + print(json.dumps(event, sort_keys=True), flush=True) + + try: + require(args.allow_auth_attempts, "explicit_allow_auth_attempts_required") + require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}", args.run_id) is not None, + "run_id_invalid") + report["run_id"] = args.run_id + require(args.secrets_file is not None, "private_secrets_file_required") + manifest = validate_manifest(load_json(args.manifest)) + private = private_settings(load_json(args.secrets_file, private=True)) + endpoint = parse_endpoint(args.endpoint) + require(endpoint["hostname"] in ("localhost", "127.0.0.1"), + "endpoint_must_be_localhost_or_127_0_0_1_forward") + require(args.work_dir.is_dir() and args.work_dir.stat().st_uid == os.geteuid() + and args.work_dir.stat().st_mode & 0o077 == 0, + "work_directory_must_be_owned_and_private") + try: + output = os.fdopen(os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600), "w", encoding="utf-8") + except OSError: + raise CheckFailure("report_path_unavailable_or_already_exists") from None + report.update(source_commit=manifest["source_commit"].lower(), + firmware_sha256=manifest["firmware_sha256"].lower(), + mode=manifest["status"]["mode"], + node_id_sha256=hashlib.sha256(manifest["status"]["node_id"].encode()).hexdigest()) + execute(args, manifest, private, report, record) + report["result"] = "PASS" + report["phase"] = "complete" + return_code = 0 + except (CheckFailure, KeyboardInterrupt) as error: + report["failure"] = "operator_interrupted" if isinstance(error, KeyboardInterrupt) else str(error) + return_code = 130 if isinstance(error, KeyboardInterrupt) else 1 + except Exception as error: + # Do not expose URLs, request bodies, raw browser errors or credentials. + report["failure"] = "unexpected_" + type(error).__name__ + return_code = 1 + finally: + if report["result"] != "PASS": + report["temporary_room_lock_may_remain"] = report["authentication_attempts_started"] + report["elapsed_seconds"] = round(time.monotonic() - started, 3) + if output: + json.dump(report, output, indent=2, sort_keys=True) + output.write("\n") + output.close() + print(json.dumps({key: value for key, value in report.items() if key != "steps"}, + sort_keys=True), flush=True) + return return_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/hil/room_webconfig_acceptance.py b/tools/hil/room_webconfig_acceptance.py new file mode 100644 index 000000000..956206b8a --- /dev/null +++ b/tools/hil/room_webconfig_acceptance.py @@ -0,0 +1,636 @@ +#!/usr/bin/env python3 +"""Live WebConfig/room password acceptance checks; never changes board settings. + +Run on a gateway with two ALREADY CONFIGURED IPv4 source addresses, while other +authentication attempts and room polling are paused. This deliberately causes +temporary login lockouts and creates WebConfig sessions (evicting the old single +session). It sends only login/logout, config/status reads and room status reads; +it does not post messages, change preferences, reboot, or configure networking. +Room access must already be enabled, with spare browser slots and a poll quota +that permits roughly 25 status reads during one minute (zero disables quotas). + +Example (credentials are never command-line arguments):: + + python3 tools/hil/room_webconfig_acceptance.py \ + --endpoint http://192.0.2.10 --manifest identity.json \ + --secrets-file private.json --run-id candidate-lan-01 \ + --scenario all --allow-auth-attempts --report sanitized-result.json + +Manifest example (replace every placeholder with independently checked values):: + + {"source_commit":"<8-to-40-hex>", "firmware_sha256":"<64-hex>", + "status":{"node_id":"<16-hex>", "board":"", + "role":"", "fw":"", + "mode":"lan"}, + "config_expected":{"radio":{"name":""}}} + +Private JSON must be owned by this user and mode 0600 (or 0400):: + + {"admin_password":"", + "source_ips":["192.0.2.20","192.0.2.21"], + "room_guest_password":"", + "room_public_read_only":false, + "request_timeout":2.0, "room_timeout":12.0, "timing_guard":0.35} + +MESHCORE_AUTH_SECRETS_FILE may supply the private file path instead. The runner +does not print endpoints, source addresses, node names, passwords, cookies, +room tokens or raw responses. The manifest's firmware hash is EXTERNAL evidence, +not a live flash-hash measurement: /api/status has no such field. Network checks +use timing guard bands; exact millisecond boundaries belong to the host tests. +Allow about three minutes for all scenarios. On failure, a partial budget or +30-second lock can remain; the report records that possibility explicitly. +""" + +from __future__ import annotations + +import argparse +import hashlib +import http.client +from http.cookies import SimpleCookie +import ipaddress +import json +import os +from pathlib import Path +import re +import secrets +import socket +import stat +import sys +import time +from dataclasses import dataclass +from urllib.parse import urlsplit + + +CLIENT_ERROR = "locked, retry in 30s" +SHARED_ERROR = "too many failed logins, retry shortly" +ROOM_ERROR = "too many wrong room passwords; wait 30 seconds" +ROOM_WRONG = "incorrect room password" +MAX_RESPONSE = 131072 + + +class CheckFailure(RuntimeError): + """Only fixed, credential-free reason codes may leave the runner.""" + + +def require(condition, code): + if not condition: + raise CheckFailure(code) + + +def load_json(path, *, private=False): + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "r", encoding="utf-8") as stream: + metadata = os.fstat(stream.fileno()) + require(stat.S_ISREG(metadata.st_mode), "input_not_regular_file") + require(metadata.st_size <= 65536, "input_file_too_large") + if private: + require(stat.S_IMODE(metadata.st_mode) in (0o600, 0o400), + "private_file_requires_mode_0600_or_0400") + require(metadata.st_uid == os.geteuid(), "private_file_wrong_owner") + value = json.load(stream) + except CheckFailure: + raise + except (OSError, ValueError, UnicodeError): + raise CheckFailure("input_file_unreadable_or_invalid_json") from None + require(isinstance(value, dict), "input_json_must_be_object") + return value + + +def subset_matches(actual, expected): + if isinstance(expected, dict): + return isinstance(actual, dict) and all( + key in actual and subset_matches(actual[key], value) + for key, value in expected.items()) + return type(actual) is type(expected) and actual == expected + + +def has_expected_leaf(value): + return (any(has_expected_leaf(item) for item in value.values()) + if isinstance(value, dict) else True) + + +def validate_manifest(value): + require(re.fullmatch(r"[0-9a-fA-F]{8,40}", str(value.get("source_commit", ""))) + is not None, "manifest_source_commit_required") + require(re.fullmatch(r"[0-9a-fA-F]{64}", str(value.get("firmware_sha256", ""))) + is not None, "manifest_firmware_sha256_required") + expected = value.get("status") + require(isinstance(expected, dict), "manifest_status_required") + for key in ("node_id", "board", "role", "fw", "mode"): + require(isinstance(expected.get(key), str) and bool(expected[key]), + "manifest_identity_fields_required") + require(re.fullmatch(r"[0-9a-f]{16}", expected["node_id"]) is not None, + "manifest_node_id_invalid") + require(expected["mode"] in ("lan", "setup"), "manifest_mode_invalid") + require(isinstance(value.get("config_expected"), dict) + and has_expected_leaf(value["config_expected"]), + "manifest_config_expectation_required") + return value + + +def validate_private(value, scenario): + password = value.get("admin_password") + require(isinstance(password, str) and 0 < len(password.encode("utf-8")) <= 64 + and "\0" not in password, "private_admin_password_invalid") + addresses = value.get("source_ips") + require(isinstance(addresses, list) and len(addresses) == 2 + and all(isinstance(address, str) for address in addresses), + "two_source_addresses_required") + try: + parsed = [str(ipaddress.IPv4Address(item)) for item in addresses] + except (ValueError, TypeError, ipaddress.AddressValueError): + raise CheckFailure("source_addresses_must_be_ipv4") from None + require(parsed[0] != parsed[1], "source_addresses_must_differ") + for address in parsed: + ip = ipaddress.IPv4Address(address) + require(not ip.is_unspecified and not ip.is_multicast, + "source_address_not_unicast") + value = dict(value, source_ips=parsed) + if scenario in ("room", "all"): + require(type(value.get("room_public_read_only")) is bool, + "room_read_only_expectation_required") + guest = value.get("room_guest_password") + if guest is not None: + require(isinstance(guest, str) and 0 < len(guest.encode("utf-8")) <= 64 + and "\0" not in guest and guest != password, + "private_guest_password_invalid_or_same_as_admin") + for key, default, minimum, maximum in ( + ("request_timeout", 2.0, 0.1, 5.0), + ("room_timeout", 12.0, 2.0, 20.0), + ("timing_guard", 0.35, 0.2, 1.0)): + number = value.get(key, default) + require(type(number) in (int, float) and minimum <= number <= maximum, + "private_timing_value_invalid") + value[key] = float(number) + return value + + +@dataclass +class Reply: + status: int + body: dict + headers: list + started: float + finished: float + + +class HttpClient: + def __init__(self, endpoint, source_ip, timeout, *, connection_factory=None, + clock=time.monotonic): + self.endpoint, self.source_ip, self.timeout = endpoint, source_ip, timeout + self.connection_factory = connection_factory or http.client.HTTPConnection + self.clock = clock + self.cookie = None + + def request(self, method, path, payload=None, *, headers=None, use_cookie=True): + require((method, path) in { + ("GET", "/api/status"), ("GET", "/api/config"), + ("POST", "/api/login"), ("POST", "/api/logout"), + ("POST", "/api/room"), ("POST", "/api/room/result")}, + "request_outside_read_and_auth_allowlist") + if path == "/api/room": + require(isinstance(payload, dict) and set(payload) == {"op", "password"} + and payload["op"] == "status", "room_operation_must_be_status_read") + elif path == "/api/login": + require(isinstance(payload, dict) and set(payload) == {"password"}, + "login_payload_invalid") + else: + require(payload is None or (path == "/api/logout" and payload == {}), + "unexpected_body_on_read_or_logout") + body = None if payload is None else json.dumps( + payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + request_headers = {"Host": self.endpoint["host_header"], + "Accept": "application/json", "Connection": "close"} + if body is not None: + request_headers["Content-Type"] = "application/json" + if use_cookie and self.cookie: + request_headers["Cookie"] = self.cookie + request_headers.update(headers or {}) + connection = self.connection_factory( + self.endpoint["address"], self.endpoint["port"], timeout=self.timeout, + source_address=(self.source_ip, 0)) + started = self.clock() + try: + connection.connect() + require(connection.sock.getsockname()[0] == self.source_ip, + "http_source_binding_not_observed") + connection.request(method, path, body=body, headers=request_headers) + response = connection.getresponse() + require(response.getheader("Content-Type", "").split(";", 1)[0].strip() + == "application/json", "response_not_json_content_type") + raw = response.read(MAX_RESPONSE + 1) + require(len(raw) <= MAX_RESPONSE, "response_too_large") + parsed = json.loads(raw) + require(isinstance(parsed, dict), "response_json_not_object") + return Reply(response.status, parsed, response.getheaders(), + started, self.clock()) + except CheckFailure: + raise + except (OSError, ValueError, UnicodeError, http.client.HTTPException): + raise CheckFailure("http_transport_or_json_failure") from None + finally: + connection.close() + + def login(self, password): + reply = self.request("POST", "/api/login", {"password": password}) + if reply.status == 200: + require(reply.body.get("ok") is True and "error" not in reply.body, + "login_success_contract_invalid") + cookies = SimpleCookie() + try: + for key, value in reply.headers: + if key.lower() == "set-cookie": + cookies.load(value) + except Exception: + raise CheckFailure("session_cookie_invalid") from None + require("wcs" in cookies, "admin_login_did_not_create_session") + cookie = cookies["wcs"] + require(re.fullmatch(r"[0-9a-f]{32}", cookie.value) is not None + and bool(cookie["httponly"]) + and cookie["samesite"].lower() == "lax", + "session_cookie_security_contract_invalid") + self.cookie = "wcs=" + cookie.value + return reply + + +class RoomClient: + def __init__(self, client, timeout, *, clock=time.monotonic, sleep=time.sleep): + self.client, self.timeout, self.clock, self.sleep = client, timeout, clock, sleep + self.token, self.sequence, self.boot = secrets.token_hex(32), 0, None + + def status(self, password): + self.sequence += 1 + headers = {"X-Room-Token": self.token, "X-Room-Seq": str(self.sequence)} + deadline = self.clock() + self.timeout + started = self.clock() + while True: + require(self.clock() < deadline, "room_admission_timeout") + reply = self.client.request("POST", "/api/room", + {"op": "status", "password": password}, + headers=headers, use_cookie=False) + if reply.status == 202: + require(reply.body.get("accepted") is True, "room_admission_contract_invalid") + break + require(reply.status == 429 and reply.body.get("error") == "room service busy", + "room_submission_rejected") + self.sleep(0.1) + while True: + require(self.clock() < deadline, "room_result_timeout") + reply = self.client.request("POST", "/api/room/result", headers=headers, + use_cookie=False) + if reply.status == 202: + require(reply.body.get("pending") is True, "room_pending_contract_invalid") + self.sleep(0.05) + continue + require(reply.status == 200, "room_result_transport_status_invalid") + if reply.body.get("error") in ( + "room browser disabled; use set room.web on", + "room browser busy; try later", + "room rate limit reached; try after the next minute"): + raise CheckFailure("room_enabled_capacity_or_quota_precondition_failed") + if "error" not in reply.body: + require(type(reply.body.get("boot")) is int + and type(reply.body.get("role")) is int, + "room_status_identity_fields_missing") + if self.boot is None: + self.boot = reply.body["boot"] + require(reply.body["boot"] == self.boot, "room_reboot_during_test") + return Reply(reply.status, reply.body, reply.headers, started, reply.finished) + + +def parse_endpoint(value): + try: + url = urlsplit(value) + require(url.scheme == "http" and bool(url.hostname) + and not url.username and not url.password and url.path in ("", "/") + and not url.query and not url.fragment, "endpoint_must_be_plain_http_origin") + port = url.port or 80 + require(1 <= port <= 65535, "endpoint_port_invalid") + return {"hostname": url.hostname, "port": port, + "host_header": url.netloc} + except (ValueError, TypeError): + raise CheckFailure("endpoint_invalid") from None + + +def resolve_endpoint(value): + result = parse_endpoint(value) + try: + addresses = socket.getaddrinfo(result["hostname"], result["port"], + socket.AF_INET, socket.SOCK_STREAM) + except OSError: + raise CheckFailure("endpoint_ipv4_resolution_failed") from None + require(bool(addresses), "endpoint_ipv4_resolution_failed") + # Resolve once, then pin every request to that address. No proxies/redirects. + result["address"] = addresses[0][4][0] + return result + + +class Acceptance: + def __init__(self, endpoint, manifest, private, record, *, clock=time.monotonic, + sleep=time.sleep): + self.manifest, self.private, self.record = manifest, private, record + self.clock, self.sleep = clock, sleep + self.clients = [HttpClient(endpoint, address, private["request_timeout"], clock=clock) + for address in private["source_ips"]] + self.guard = private["timing_guard"] + self.uptime = None + self.uptime_at = None + self.auth_attempts_started = False + self.phase = "preflight" + self.generated_wrong_passwords = set() + + def passed(self, step): + self.record({"step": step, "result": "PASS"}) + + def until(self, deadline): + while self.clock() < deadline: + self.sleep(min(0.5, deadline - self.clock())) + + def quiet_window(self, label): + self.record({"step": label, "result": "WAIT", "seconds": 30 + self.guard}) + self.until(self.clock() + 30 + self.guard) + + def identity(self): + for client in self.clients: + reply = client.request("GET", "/api/status", use_cookie=False) + require(reply.status == 200 and subset_matches(reply.body, self.manifest["status"]), + "live_status_identity_mismatch") + require(reply.body.get("password_supported") is True + and reply.body.get("needs_setup") is False + and reply.body.get("needs_password") is False + and reply.body.get("auth") is False, + "admin_auth_not_required_or_initial_setup") + uptime, now = reply.body.get("uptime_s"), self.clock() + require(type(uptime) is int and uptime >= 0, "live_uptime_missing") + if self.uptime is not None: + elapsed = now - self.uptime_at + difference = uptime - self.uptime + if difference < 0: + difference += 4294967.296 + require(abs(difference - elapsed) < 5.0, "board_uptime_changed_unexpectedly") + self.uptime, self.uptime_at = uptime, now + + def authorized(self, client): + self.auth_attempts_started = True + reply = client.login(self.private["admin_password"]) + require(reply.status == 200, "valid_admin_login_rejected") + return reply + + def config(self, client): + reply = client.request("GET", "/api/config") + require(reply.status == 200 and "error" not in reply.body + and subset_matches(reply.body, self.manifest["config_expected"]), + "protected_config_read_not_authorized_or_changed") + + def wrong_password(self): + while True: + candidate = "hil-" + secrets.token_hex(5) + if candidate not in (self.private["admin_password"], + self.private.get("room_guest_password")) \ + and candidate not in self.generated_wrong_passwords: + self.generated_wrong_passwords.add(candidate) + return candidate + + @staticmethod + def error(reply, status, error, code): + require(reply.status == status and reply.body.get("error") == error, code) + + def wrong_login(self, client): + self.auth_attempts_started = True + reply = client.login(self.wrong_password()) + self.error(reply, 401, "wrong password", "wrong_login_not_counted_as_failure") + return reply + + def webconfig(self): + self.phase = "webconfig" + a, b = self.clients + self.quiet_window("webconfig_initial_quiet_window") + self.authorized(a) + self.authorized(b) + self.config(b) + burst_started = self.clock() + for _ in range(5): + fifth = self.wrong_login(a) + require(self.clock() - burst_started < 3.0, "client_failure_burst_too_slow") + self.error(a.login(self.private["admin_password"]), 429, CLIENT_ERROR, + "fifth_failure_did_not_lock_its_address") + self.config(b) + self.authorized(b) + self.error(a.login(self.private["admin_password"]), 429, CLIENT_ERROR, + "other_address_success_cleared_client_lock") + self.passed("webconfig_five_failures_isolate_address_and_preserve_owner_session") + self.until(fifth.started + 29.0) + self.error(a.login(self.private["admin_password"]), 429, CLIENT_ERROR, + "client_lock_expired_before_guarded_boundary") + self.until(fifth.finished + 30 + self.guard) + self.authorized(a) + self.passed("webconfig_client_lock_recovers_after_guarded_30s_boundary") + self.authorized(b) + self.authorized(a) # A owns the current single session. + burst_started = self.clock() + first = None + for index in range(6): + reply = self.wrong_login(self.clients[index % 2]) + if first is None: + first = reply + require(self.clock() - burst_started < 3.0, "shared_failure_burst_too_slow") + self.error(b.login(self.private["admin_password"]), 429, SHARED_ERROR, + "six_failures_did_not_exhaust_shared_budget") + self.config(a) + self.passed("webconfig_shared_budget_blocks_new_logins_preserves_existing_cookie") + self.until(first.started + 5.0) + self.error(b.login(self.private["admin_password"]), 429, SHARED_ERROR, + "shared_budget_refilled_before_guarded_boundary") + self.until(first.finished + 6 + self.guard) + self.authorized(b) + self.wrong_login(a) + self.error(b.login(self.private["admin_password"]), 429, SHARED_ERROR, + "successful_login_refunded_shared_failure_budget") + self.config(b) + self.passed("webconfig_shared_budget_refills_after_guarded_6s_without_success_refund") + self.quiet_window("webconfig_release_lockouts") + self.authorized(a) + self.authorized(b) + self.config(b) + self.identity() + + def room_success(self, actor, password, role): + reply = actor.status(password) + require("error" not in reply.body and reply.body.get("role") == role, + "room_valid_credential_or_read_only_policy_mismatch") + return reply + + def room_wrong(self, actor, password): + self.auth_attempts_started = True + reply = actor.status(password) + if self.private["room_public_read_only"]: + require("error" not in reply.body and reply.body.get("role") == 0, + "wrong_room_password_not_admitted_as_read_only_guest") + else: + self.error(reply, 200, ROOM_WRONG, "wrong_room_password_not_rejected") + return reply + + def room_empty(self, actor): + reply = actor.status("") + if self.private["room_public_read_only"]: + require("error" not in reply.body and reply.body.get("role") == 0, + "empty_room_password_lost_public_read_only_access") + else: + self.error(reply, 200, ROOM_WRONG, "empty_room_password_bypassed_read_only_policy") + + def room(self): + self.phase = "room" + self.quiet_window("room_initial_quiet_window") + owner, other = [RoomClient(client, self.private["room_timeout"], + clock=self.clock, sleep=self.sleep) + for client in self.clients] + admin = self.private["admin_password"] + guest = self.private.get("room_guest_password") + self.room_success(owner, admin, 3) + if guest: + self.room_success(other, guest, 2) + self.room_empty(other) + repeated = self.wrong_password() + first = self.room_wrong(other, repeated) + for index in range(5): + self.room_wrong((owner, other)[index % 2], repeated) + self.room_success(owner, admin, 3) + self.passed("room_repeated_wrong_password_across_tokens_counts_once") + for actor in (owner, other, owner): + self.room_wrong(actor, self.wrong_password()) + self.room_success(owner, admin, 3) + if guest: + self.room_success(other, guest, 2) + # Separate first and fifth failures enough to detect a window that + # incorrectly starts at its fifth failure, without millisecond claims. + self.until(first.finished + 2.0) + self.room_wrong(other, self.wrong_password()) + require(self.clock() - first.started < 10.0, "room_failure_window_setup_too_slow") + for actor, password in ((owner, admin), (other, repeated)): + self.error(actor.status(password), 200, ROOM_ERROR, + "room_shared_window_did_not_block_nonempty_credential") + if guest: + self.error(other.status(guest), 200, ROOM_ERROR, + "room_shared_window_did_not_block_correct_guest_password") + self.room_empty(other) + self.passed("room_five_unique_failures_block_all_nonempty_credentials_without_success_refund") + self.passed("room_empty_password_preserves_configured_read_only_policy_during_lock") + self.until(first.started + 29.0) + self.error(owner.status(admin), 200, ROOM_ERROR, + "room_window_expired_before_guarded_boundary") + self.until(first.finished + 30 + self.guard) + self.room_success(owner, admin, 3) + self.passed("room_window_recovers_30s_after_first_failure_without_lock_extension") + self.identity() + + def logout(self): + # Last successful WebConfig login owns the one slot. Identify it with + # a config read; logout is the only additional state change permitted. + found = False + for client in reversed(self.clients): + if not client.cookie: + continue + reply = client.request("GET", "/api/config") + if reply.status == 200: + reply = client.request("POST", "/api/logout", {}) + require(reply.status == 200 and reply.body.get("ok") is True, + "owned_session_logout_failed") + found = True + self.passed("owned_webconfig_session_logged_out") + break + require(reply.status == 401, "owned_session_cleanup_unexpected_status") + require(found, "owned_session_not_usable_for_cleanup") + + +def argument_parser(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--endpoint", required=True) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--secrets-file", type=Path, + default=os.environ.get("MESHCORE_AUTH_SECRETS_FILE")) + parser.add_argument("--run-id", required=True) + parser.add_argument("--scenario", choices=("webconfig", "room", "all"), default="all") + parser.add_argument("--allow-auth-attempts", action="store_true") + parser.add_argument("--report", type=Path, help="New sanitized JSON report; never overwritten") + return parser + + +def main(argv=None): + args = argument_parser().parse_args(argv) + report = {"schema": 1, "run_id": None, "result": "FAIL", "steps": [], + "live_flash_hash_verified": False, + "firmware_sha256_evidence": "externally supplied manifest", + "timing_evidence": "network guard bands; not exact millisecond boundaries", + "board_settings_changed_by_runner": False} + output = None + acceptance = None + started = time.monotonic() + + def record(step): + report["steps"].append(step) + print(json.dumps(step, sort_keys=True), flush=True) + + try: + require(args.allow_auth_attempts, "explicit_allow_auth_attempts_required") + require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}", args.run_id) is not None, + "run_id_invalid") + report["run_id"] = args.run_id + require(args.secrets_file is not None, "private_secrets_file_required") + manifest = validate_manifest(load_json(args.manifest)) + private = validate_private(load_json(args.secrets_file, private=True), args.scenario) + parse_endpoint(args.endpoint) # Validate before creating output/resolving. + if args.report: + try: + output = os.fdopen(os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600), "w", encoding="utf-8") + except OSError: + raise CheckFailure("report_path_unavailable_or_already_exists") from None + report.update(source_commit=manifest["source_commit"].lower(), + firmware_sha256=manifest["firmware_sha256"].lower(), + scenario=args.scenario, mode=manifest["status"]["mode"], + node_id_sha256=hashlib.sha256( + manifest["status"]["node_id"].encode("ascii")).hexdigest()) + endpoint = resolve_endpoint(args.endpoint) + acceptance = Acceptance(endpoint, manifest, private, record) + acceptance.identity() + acceptance.passed("both_source_addresses_match_manifest_identity_and_require_admin_auth") + # Prove the protected config expectation before ANY wrong password. + acceptance.authorized(acceptance.clients[0]) + acceptance.config(acceptance.clients[0]) + acceptance.passed("protected_config_matches_manifest") + if args.scenario in ("webconfig", "all"): + acceptance.webconfig() + if args.scenario in ("room", "all"): + acceptance.room() + acceptance.logout() + report["result"] = "PASS" + report["temporary_auth_lockout_may_remain"] = False + return_code = 0 + except (CheckFailure, KeyboardInterrupt) as error: + report["failure"] = ("operator_interrupted" if isinstance(error, KeyboardInterrupt) + else str(error)) + return_code = 130 if isinstance(error, KeyboardInterrupt) else 1 + except Exception as error: + # Never expose a third-party exception's URL, response, headers or body. + report["failure"] = "unexpected_" + type(error).__name__ + return_code = 1 + finally: + report["elapsed_seconds"] = round(time.monotonic() - started, 3) + if report["result"] != "PASS": + report["phase"] = acceptance.phase if acceptance else "input_validation" + report["temporary_auth_lockout_may_remain"] = bool( + acceptance and acceptance.auth_attempts_started) + if output: + json.dump(report, output, indent=2, sort_keys=True) + output.write("\n") + output.close() + print(json.dumps({key: value for key, value in report.items() if key != "steps"}, + sort_keys=True), flush=True) + return return_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/hil/serial_session.py b/tools/hil/serial_session.py index 16af4ed68..a1fc3196e 100644 --- a/tools/hil/serial_session.py +++ b/tools/hil/serial_session.py @@ -68,13 +68,16 @@ def assert_port_idle(path): if not inspector: return try: - result = subprocess.run([inspector, "--", os.path.realpath(path)], + # Some Pi fuser implementations reject "--" with status 1, which is + # also their normal no-owner status. The resolved absolute path cannot + # be interpreted as an option, so no separator is needed. + result = subprocess.run([inspector, os.path.realpath(path)], capture_output=True, text=True, timeout=3) except (OSError, subprocess.TimeoutExpired) as error: raise RuntimeError("Cannot safely inspect serial-port ownership") from error if result.returncode == 0: raise RuntimeError("Selected serial port is already in use; finish the flasher/client first") - if result.returncode != 1: + if result.returncode != 1 or result.stdout or result.stderr: raise RuntimeError("Cannot safely inspect serial-port ownership (fuser error)") diff --git a/tools/hil/test_room_webconfig_acceptance.py b/tools/hil/test_room_webconfig_acceptance.py new file mode 100644 index 000000000..2e73383f4 --- /dev/null +++ b/tools/hil/test_room_webconfig_acceptance.py @@ -0,0 +1,295 @@ +"""Offline guards and live-wire contract tests; no sockets or hardware.""" + +import contextlib +import io +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import room_webconfig_acceptance as hil +import room_browser_acceptance as browser_hil + + +def manifest(): + return {"source_commit": "12345678", "firmware_sha256": "a" * 64, + "status": {"node_id": "0123456789abcdef", "board": "test-board", + "role": "Room Server", "fw": "test-fw", "mode": "lan"}, + "config_expected": {"radio": {"name": "private-node-name"}}} + + +class FakeResponse: + def __init__(self, status=200, body=None, headers=None): + self.status = status + self.body = json.dumps(body or {}).encode() + self.headers = headers or [("Content-Type", "application/json")] + + def getheader(self, name, default=None): + return next((value for key, value in self.headers if key.lower() == name.lower()), default) + + def getheaders(self): + return self.headers + + def read(self, maximum): + return self.body[:maximum] + + +class FakeConnection: + def __init__(self, response, source="192.0.2.20", error=None): + self.response, self.source, self.error = response, source, error + self.sock, self.closed, self.sent = self, False, None + + def getsockname(self): + return self.source, 12345 + + def connect(self): + if self.error: + raise self.error + + def request(self, method, path, **values): + self.sent = method, path, values + + def getresponse(self): + return self.response + + def close(self): + self.closed = True + + +class ContractTests(unittest.TestCase): + def client(self, connection): + factory = mock.Mock(return_value=connection) + client = hil.HttpClient({"address": "192.0.2.10", "port": 80, + "host_header": "board.example"}, + "192.0.2.20", 2, connection_factory=factory) + return client, factory + + def test_explicit_authorization_guard_precedes_files_and_network(self): + output = io.StringIO() + with contextlib.redirect_stdout(output), mock.patch.object(hil, "load_json") as load, \ + mock.patch.object(hil.socket, "getaddrinfo") as resolve: + result = hil.main(["--endpoint", "http://192.0.2.10", "--manifest", "/missing", + "--run-id", "guard-test"]) + self.assertEqual(result, 1) + load.assert_not_called() + resolve.assert_not_called() + self.assertEqual(json.loads(output.getvalue())["failure"], + "explicit_allow_auth_attempts_required") + + def test_private_permissions_and_identity_pins_are_required(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "private.json" + path.write_text('{"admin_password":"never-output-this"}') + path.chmod(0o600) + self.assertEqual(hil.load_json(path, private=True)["admin_password"], + "never-output-this") + path.chmod(0o644) + with self.assertRaisesRegex(hil.CheckFailure, "private_file_requires_mode"): + hil.load_json(path, private=True) + self.assertEqual(hil.validate_manifest(manifest()), manifest()) + incomplete = manifest() + del incomplete["status"]["node_id"] + with self.assertRaises(hil.CheckFailure): + hil.validate_manifest(incomplete) + incomplete = manifest() + incomplete["config_expected"] = {} + with self.assertRaises(hil.CheckFailure): + hil.validate_manifest(incomplete) + incomplete["config_expected"] = {"radio": {}} + with self.assertRaises(hil.CheckFailure): + hil.validate_manifest(incomplete) + + def test_source_addresses_must_be_distinct_and_room_policy_explicit(self): + value = {"admin_password": "secret", "source_ips": ["192.0.2.20", "192.0.2.21"]} + hil.validate_private(value, "webconfig") + with self.assertRaisesRegex(hil.CheckFailure, "read_only_expectation"): + hil.validate_private(value, "room") + value["source_ips"] = ["192.0.2.20", "192.0.2.20"] + with self.assertRaisesRegex(hil.CheckFailure, "must_differ"): + hil.validate_private(value, "webconfig") + value["source_ips"] = [True, "192.0.2.20"] + with self.assertRaises(hil.CheckFailure): + hil.validate_private(value, "webconfig") + + def test_http_binds_and_observes_source_and_preserves_origin_host(self): + connection = FakeConnection(FakeResponse(body={"auth": False})) + client, factory = self.client(connection) + reply = client.request("GET", "/api/status", use_cookie=False) + factory.assert_called_once_with("192.0.2.10", 80, timeout=2, + source_address=("192.0.2.20", 0)) + self.assertEqual(connection.sent[2]["headers"]["Host"], "board.example") + self.assertNotIn("Cookie", connection.sent[2]["headers"]) + self.assertEqual(reply.body, {"auth": False}) + self.assertTrue(connection.closed) + connection.source = "192.0.2.99" + with self.assertRaisesRegex(hil.CheckFailure, "source_binding"): + client.request("GET", "/api/status") + + def test_mutating_room_or_config_operations_never_connect(self): + connection = FakeConnection(FakeResponse()) + client, factory = self.client(connection) + with self.assertRaises(hil.CheckFailure): + client.request("POST", "/api/config", {"set": {"name": "change"}}) + with self.assertRaises(hil.CheckFailure): + client.request("POST", "/api/room", {"op": "board.save", "password": "secret"}) + factory.assert_not_called() + + def test_json_failures_and_transport_exceptions_do_not_leak_private_text(self): + connection = FakeConnection(FakeResponse(), error=OSError("password=private-value")) + client, _ = self.client(connection) + with self.assertRaisesRegex(hil.CheckFailure, "http_transport_or_json_failure") as error: + client.request("GET", "/api/status") + self.assertNotIn("private-value", str(error.exception)) + self.assertTrue(connection.closed) + connection.error = None + connection.response.headers = [("Content-Type", "text/html")] + with self.assertRaisesRegex(hil.CheckFailure, "not_json_content_type"): + client.request("GET", "/api/status") + + def test_success_cookie_is_private_and_error_response_keeps_existing_session(self): + connection = FakeConnection(FakeResponse(body={"ok": True}, headers=[ + ("Content-Type", "application/json"), + ("Set-Cookie", "wcs=" + "a" * 32 + "; HttpOnly; SameSite=Lax; Path=/")])) + client, _ = self.client(connection) + client.login("never-print-password") + self.assertEqual(client.cookie, "wcs=" + "a" * 32) + connection.response = FakeResponse(429, {"error": hil.SHARED_ERROR}) + self.assertEqual(client.login("never-print-password").status, 429) + self.assertEqual(client.cookie, "wcs=" + "a" * 32) + + def test_room_retries_busy_and_polls_owned_200_json_error_without_cookie(self): + client = mock.Mock() + client.request.side_effect = [ + hil.Reply(429, {"error": "room service busy"}, [], 0, 0), + hil.Reply(202, {"accepted": True}, [], 0, 0), + hil.Reply(202, {"pending": True}, [], 0, 0), + hil.Reply(200, {"error": hil.ROOM_ERROR}, [], 0, 0)] + room = hil.RoomClient(client, 12, clock=lambda: 0, sleep=lambda _: None) + reply = room.status("private-room-password") + self.assertEqual(reply.status, 200) + self.assertEqual(reply.body["error"], hil.ROOM_ERROR) + calls = client.request.call_args_list + self.assertEqual([call.args[1] for call in calls], + ["/api/room", "/api/room", "/api/room/result", "/api/room/result"]) + self.assertEqual(calls[0].args[2], calls[1].args[2]) + self.assertEqual({call.kwargs["headers"]["X-Room-Token"] for call in calls}, {room.token}) + self.assertEqual({call.kwargs["headers"]["X-Room-Seq"] for call in calls}, {"1"}) + self.assertTrue(all(call.kwargs["use_cookie"] is False for call in calls)) + + def test_room_reboot_is_failure_and_200_transport_is_not_enough(self): + client = mock.Mock() + room = hil.RoomClient(client, 12, clock=lambda: 0, sleep=lambda _: None) + client.request.side_effect = [ + hil.Reply(202, {"accepted": True}, [], 0, 0), + hil.Reply(200, {"role": 3, "boot": 1}, [], 0, 0), + hil.Reply(202, {"accepted": True}, [], 0, 0), + hil.Reply(200, {"role": 3, "boot": 2}, [], 0, 0)] + room.status("secret") + with self.assertRaisesRegex(hil.CheckFailure, "reboot"): + room.status("secret") + client.request.side_effect = [hil.Reply(200, {"ok": True}, [], 0, 0)] + with self.assertRaisesRegex(hil.CheckFailure, "submission_rejected"): + room.status("secret") + + def test_public_read_only_browser_access_uses_guest_wire_role_zero(self): + # Public readers are GUEST (0), distinct from an explicitly assigned + # READ_ONLY (1) ACL role. The live board exposed this distinction. + acceptance = hil.Acceptance.__new__(hil.Acceptance) + acceptance.private = {"room_public_read_only": True} + actor = mock.Mock() + actor.status.return_value = hil.Reply(200, {"role": 0}, [], 0, 0) + acceptance.room_empty(actor) + acceptance.room_wrong(actor, "wrong-password") + actor.status.return_value = hil.Reply(200, {"role": 1}, [], 0, 0) + with self.assertRaisesRegex(hil.CheckFailure, "public_read_only_access"): + acceptance.room_empty(actor) + + def test_summary_and_report_exclude_private_inputs_and_mark_hash_as_external(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + identity, private, report = [directory / name for name in + ("identity.json", "private.json", "report.json")] + identity.write_text(json.dumps(manifest())) + private.write_text(json.dumps({"admin_password": "never-output-password", + "source_ips": ["192.0.2.20", "192.0.2.21"]})) + private.chmod(0o600) + output = io.StringIO() + with contextlib.redirect_stdout(output), \ + mock.patch.object(hil, "resolve_endpoint", return_value={}), \ + mock.patch.object(hil, "Acceptance"): + result = hil.main(["--endpoint", "http://192.0.2.10", "--manifest", str(identity), + "--secrets-file", str(private), "--run-id", "private-output-test", + "--scenario", "webconfig", "--allow-auth-attempts", + "--report", str(report)]) + self.assertEqual(result, 0) + for text in (output.getvalue(), report.read_text()): + for sensitive in ("never-output-password", "192.0.2.10", "192.0.2.20", + "192.0.2.21", "private-node-name", "0123456789abcdef"): + self.assertNotIn(sensitive, text) + self.assertFalse(json.loads(report.read_text())["live_flash_hash_verified"]) + # A report path is an exclusive run artifact, never silently replaced. + with contextlib.redirect_stdout(io.StringIO()), \ + mock.patch.object(hil, "resolve_endpoint") as resolve: + result = hil.main(["--endpoint", "http://192.0.2.10", "--manifest", str(identity), + "--secrets-file", str(private), "--run-id", "private-output-test", + "--scenario", "webconfig", "--allow-auth-attempts", + "--report", str(report)]) + self.assertEqual(result, 1) + resolve.assert_not_called() + + +class BrowserStartupTests(unittest.TestCase): + def websocket(self): + return SimpleNamespace( + WebSocketTimeoutException=type("SocketTimeout", (Exception,), {}), + WebSocketConnectionClosedException=type("SocketClosed", (Exception,), {})) + + def test_partial_observer_socket_is_closed_after_enable_failure(self): + websocket = self.websocket() + for failed_method in ("Log.enable", "Network.enable"): + with self.subTest(failed_method=failed_method): + socket = mock.Mock() + + def initialize(page, *args): + page.socket = socket + + def enable(_page, method, *args): + if method == failed_method: + raise websocket.WebSocketConnectionClosedException() + + with mock.patch.object(browser_hil.CDP, "__init__", initialize), \ + mock.patch.object(browser_hil.PassiveCDP, "call", enable), \ + self.assertRaises(websocket.WebSocketConnectionClosedException): + browser_hil.PassiveCDP(websocket, 12345, Path("/owned"), "http://127.0.0.1:1234") + socket.close.assert_called_once_with() + + def test_owned_page_startup_retries_both_transient_websocket_errors(self): + websocket = self.websocket() + page = object() + process = SimpleNamespace(poll=lambda: None) + with mock.patch.object(browser_hil, "PassiveCDP", side_effect=[ + websocket.WebSocketTimeoutException(), + websocket.WebSocketConnectionClosedException(), page]) as attach, \ + mock.patch.object(browser_hil.time, "sleep") as pause: + actual = browser_hil.wait_for_room_browser( + websocket, 12345, Path("/owned"), "http://127.0.0.1:1234", process) + self.assertIs(actual, page) + self.assertEqual(attach.call_count, 3) + self.assertEqual(pause.call_count, 2) + + def test_ownership_refusal_is_not_retried(self): + with mock.patch.object(browser_hil, "PassiveCDP", side_effect=hil.CheckFailure( + "CDP_browser_does_not_match_owned_profile")) as attach, \ + mock.patch.object(browser_hil.time, "sleep") as pause, \ + self.assertRaisesRegex(hil.CheckFailure, "does_not_match_owned_profile"): + browser_hil.wait_for_room_browser(self.websocket(), 12345, Path("/owned"), + "http://127.0.0.1:1234", + SimpleNamespace(poll=lambda: None)) + attach.assert_called_once() + pause.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/hil/test_serial_session.py b/tools/hil/test_serial_session.py index 1ad034a31..8f3084266 100644 --- a/tools/hil/test_serial_session.py +++ b/tools/hil/test_serial_session.py @@ -1,11 +1,50 @@ -"""Verify the shared serial opener keeps optional SDK dependencies optional.""" +"""Verify serial ownership checks and optional SDK dependency isolation.""" from pathlib import Path import ast import hashlib +import importlib.util import subprocess import sys from tempfile import TemporaryDirectory import unittest +from unittest.mock import patch + + +class SerialPortOwnershipTests(unittest.TestCase): + def setUp(self): + spec = importlib.util.spec_from_file_location( + "serial_ownership_test", Path(__file__).with_name("serial_session.py")) + self.session = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.session) + + def inspect(self, result): + with patch.object(self.session.sys, "platform", "linux"), \ + patch.object(self.session.shutil, "which", return_value="/usr/bin/fuser"), \ + patch.object(self.session.os.path, "realpath", return_value="/dev/ttyACM7"), \ + patch.object(self.session.subprocess, "run", return_value=result) as run: + self.session.assert_port_idle("/dev/serial/by-id/exact-device") + return run + + def test_quiet_no_owner_uses_absolute_path_without_unsupported_separator(self): + run = self.inspect(subprocess.CompletedProcess([], 1, stdout="", stderr="")) + run.assert_called_once_with(["/usr/bin/fuser", "/dev/ttyACM7"], + capture_output=True, text=True, timeout=3) + + def test_pi_usage_error_is_not_mistaken_for_an_idle_port(self): + result = subprocess.CompletedProcess([], 1, stdout="", + stderr="Usage: fuser [-a|-s] [-4|-6] [-SIGNAL] NAME...\n") + with self.assertRaisesRegex(RuntimeError, "fuser error"): + self.inspect(result) + + def test_other_output_with_no_owner_status_refuses(self): + for stdout, stderr in (("1234\n", ""), ("", "permission denied\n"), ("\n", "")): + with self.subTest(stdout=stdout, stderr=stderr), \ + self.assertRaisesRegex(RuntimeError, "fuser error"): + self.inspect(subprocess.CompletedProcess([], 1, stdout=stdout, stderr=stderr)) + + def test_existing_owner_still_refuses(self): + with self.assertRaisesRegex(RuntimeError, "already in use"): + self.inspect(subprocess.CompletedProcess([], 0, stdout="1234\n", stderr="/dev/ttyACM7:")) class SerialSessionImportTests(unittest.TestCase): diff --git a/variants/sensecap_indicator-espnow/README.md b/variants/sensecap_indicator-espnow/README.md index 38abd430a..43f2af1ad 100644 --- a/variants/sensecap_indicator-espnow/README.md +++ b/variants/sensecap_indicator-espnow/README.md @@ -18,12 +18,15 @@ preserves the previously saved numeric radio tuple; erase/install the LoRa merged image when the compiled fresh defaults must replace it. The Indicator preserves the existing 160x160 UI coordinate system. Ordinary -images use a 320x320, 4-bit internal canvas scaled 1.5x onto the 480x480 panel. -Full Companion images first request a native 480x480 canvas, then normally -shrink to 320x320 only for the tight ESP-NOW + BLE combination before Bluetooth -starts. The canvas stays out of PSRAM to avoid contention with RGB scanout. The -screen blanks after five minutes on USB power; the hardware button or a touch -wakes it without selecting anything. +images and LoRa Full Companion images use a 320x320, 4-bit internal canvas +scaled 1.5x onto the 480x480 panel. LoRa Full retains this profile in both +WiFi and BLE modes to leave internal RAM for alerts and held messages while +preserving 350 contacts, 512 queued frames and all Full transports. ESP-NOW +Full first requests a native 480x480 canvas, then normally shrinks to 320x320 +for its BLE combination before Bluetooth starts. The canvas stays out of +PSRAM to avoid contention with RGB scanout. The screen blanks after five +minutes on USB power; the hardware button or a touch wakes it without +selecting anything. ## Touch navigation @@ -216,7 +219,7 @@ action or BLE-status block. Long IP addresses retain a smaller bounded row instead of clipping. The Indicator WiFi setup/status page uses a fixed physical scale on both the -native and 320x320 emergency canvases. Its content begins below the shared +native 480x480 and scaled 320x320 canvases. Its content begins below the shared status bar and eight page dots. The page only reads connection state; it never starts or stops WebConfig, WiFi, ESP-NOW, or LoRa. Other display layouts retain their existing setup overlay. @@ -226,16 +229,17 @@ selecting BLE leaves the primary ESP-NOW WiFi radio and its fixed channel running; it disables only infrastructure WiFi. Rendering follows the memory cost of each combination: -- LoRa + WiFi: targets a native 480x480 canvas. -- LoRa + BLE: targets a native 480x480 canvas. +- LoRa + WiFi: fixed 320x320 canvas scaled 1.5x to the panel. +- LoRa + BLE: fixed 320x320 canvas scaled 1.5x to the panel. - ESP-NOW + infrastructure WiFi: targets a native 480x480 canvas. - ESP-NOW + BLE: 320x320 canvas scaled 1.5x to the panel. -If the early native allocation or a later profile retry cannot obtain a -contiguous DMA-capable block, any mode requesting 480x480 keeps or restores the -320x320 emergency canvas instead of disabling the display. The startup log -reports the canvas actually retained; an emergency fallback does not change the -saved Companion transport or stop the primary LoRa/ESP-NOW radio. +If the early native allocation or a later ESP-NOW profile retry cannot obtain +a contiguous DMA-capable block, it keeps or restores the 320x320 emergency +canvas instead of disabling the display. The ESP-NOW startup log reports the +canvas actually retained; an emergency fallback does not change the saved +Companion transport or stop the primary ESP-NOW radio. LoRa Full uses its +fixed 320x320 profile directly rather than attempting the native allocation. If a Companion client does not supply device time over USB or WiFi, this build starts evaluating LoRa time after two minutes. It requires three independent,