Add guarded hardware acceptance and restoration tools
This commit is contained in:
1 parent
48bad8ddfb
commit
89dcd82089
18 files changed
+4529
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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("<II", frame, 48, 910525, 62500)
|
||||
frame[56:58] = bytes((7, 5))
|
||||
return bytes(frame)
|
||||
|
||||
|
||||
def device_info(board="Heltec V4"):
|
||||
return (bytes((13, 14, 50, 1)) + struct.pack("<I", 987654)
|
||||
+ fixed(BUILD, 12) + fixed(board, 40) + fixed(VERSION[:19], 20)
|
||||
+ bytes((0, 2)))
|
||||
|
||||
|
||||
class WireSession(acceptance.Session):
|
||||
"""Exercise production handshake/request validation with inert frames."""
|
||||
version_reply = b"\x1d7f|" + FULL_VERSION.encode("ascii")
|
||||
board_reply = device_info()
|
||||
sent = []
|
||||
|
||||
def __enter__(self):
|
||||
self.handshake()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
pass
|
||||
|
||||
def send(self, payload):
|
||||
self.sent.append(payload)
|
||||
self.pending = payload
|
||||
|
||||
def receive(self, timeout=None):
|
||||
if self.pending[:1] == b"\x01":
|
||||
return self_info()
|
||||
if self.pending[:1] == b"\x16":
|
||||
return self.board_reply
|
||||
if self.pending[:1] == b"\x42":
|
||||
return self.version_reply
|
||||
raise AssertionError("Unexpected request; no mutation is allowed by this wire fixture")
|
||||
|
||||
def contacts(self):
|
||||
return {}
|
||||
|
||||
def channels(self):
|
||||
return [{"index": 0, "name_hex": "70726976617465", "key_hex": "ab" * 32}]
|
||||
|
||||
def stats(self):
|
||||
return {"uptime_seconds": 100, "error_flags": 0}
|
||||
|
||||
|
||||
class ReleaseIdentityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
WireSession.sent = []
|
||||
WireSession.version_reply = b"\x1d7f|" + FULL_VERSION.encode("ascii")
|
||||
WireSession.board_reply = device_info()
|
||||
|
||||
def session(self):
|
||||
return WireSession(SimpleNamespace(expected_version=VERSION, expected_board="Heltec V4", serial="ExactUSB123"),
|
||||
acceptance.TransportCounters())
|
||||
|
||||
def test_exact_complete_candidate_and_device_build_are_verified(self):
|
||||
session = self.session()
|
||||
snapshot = session.snapshot()
|
||||
self.assertEqual(WireSession.sent[-1], b"\x427f|ver")
|
||||
self.assertEqual(session.full_cli_version, FULL_VERSION)
|
||||
self.assertEqual(snapshot["full_cli_version"], FULL_VERSION)
|
||||
self.assertEqual(snapshot["full_cli_version_frame_hex"], WireSession.version_reply.hex())
|
||||
self.assertEqual(acceptance.stable_state(snapshot)["full_cli_version"], FULL_VERSION)
|
||||
|
||||
def test_same_19_byte_prefix_wrong_source_is_rejected_before_crud(self):
|
||||
other = VERSION.replace("652289ee", "d3e05abc")
|
||||
self.assertEqual(other[:19], VERSION[:19])
|
||||
WireSession.version_reply = b"\x1d7f|" + (other + " (Build: " + BUILD + ")").encode()
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
args = SimpleNamespace(mode="owned-crud", expected_version=VERSION, expected_board="Heltec V4",
|
||||
manifest=None, serial="ExactUSB123", snapshot=directory / "private.json",
|
||||
report=directory / "report.json")
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(acceptance, "parse_args", return_value=args), \
|
||||
mock.patch.object(acceptance, "Session", WireSession), \
|
||||
mock.patch.object(acceptance, "owned_plan") as plan, \
|
||||
mock.patch.object(acceptance, "owned_crud") as crud, \
|
||||
contextlib.redirect_stdout(output):
|
||||
self.assertEqual(acceptance.main(), 1)
|
||||
plan.assert_not_called()
|
||||
crud.assert_not_called()
|
||||
self.assertFalse(args.snapshot.exists())
|
||||
report = json.loads(args.report.read_text())
|
||||
self.assertFalse(report["passed"])
|
||||
self.assertFalse(report["source_version_verified"])
|
||||
self.assertNotIn("full_runtime_version", report)
|
||||
self.assertNotIn(WireSession.board_reply.hex(), output.getvalue())
|
||||
|
||||
def test_malformed_or_uncorrelated_cli_reply_is_rejected(self):
|
||||
for reply in (b"\x1d" + FULL_VERSION.encode(), b"\x1d7g|" + FULL_VERSION.encode(),
|
||||
b"\x1d7f|" + FULL_VERSION.encode() + b"\0", b"\x1d7f|\xff"):
|
||||
with self.subTest(reply=reply[:4]):
|
||||
WireSession.version_reply = reply
|
||||
with self.assertRaises(acceptance.AcceptanceError):
|
||||
self.session().handshake()
|
||||
|
||||
def test_wrong_board_is_rejected_before_local_cli(self):
|
||||
WireSession.board_reply = device_info("Unrelated board")
|
||||
with self.assertRaisesRegex(acceptance.AcceptanceError, "board"):
|
||||
self.session().handshake()
|
||||
self.assertFalse(any(frame[0] == 0x42 for frame in WireSession.sent))
|
||||
|
||||
def test_cli_error_and_incomplete_build_formatter_are_rejected(self):
|
||||
for reply in (b"\x01\x03", b"\x1d7f|Error: unavailable",
|
||||
b"\x1d7f|" + VERSION.encode(),
|
||||
b"\x1d7f|" + (VERSION + " (Build: different)").encode()):
|
||||
with self.subTest(reply=reply[:4]):
|
||||
WireSession.version_reply = reply
|
||||
with self.assertRaises(acceptance.AcceptanceError):
|
||||
self.session().handshake()
|
||||
|
||||
def test_sanitized_pass_records_full_version_without_pin_or_channel_key(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
args = SimpleNamespace(mode="snapshot", expected_version=VERSION, expected_board="Heltec V4",
|
||||
manifest=None, serial="ExactUSB123", snapshot=directory / "private.json",
|
||||
report=directory / "report.json")
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(acceptance, "parse_args", return_value=args), \
|
||||
mock.patch.object(acceptance, "Session", WireSession), contextlib.redirect_stdout(output):
|
||||
self.assertEqual(acceptance.main(), 0)
|
||||
report = json.loads(args.report.read_text())
|
||||
self.assertTrue(report["passed"] and report["source_version_verified"])
|
||||
self.assertEqual(report["full_runtime_version"], FULL_VERSION)
|
||||
self.assertFalse(report["live_flash_hash_verified"])
|
||||
self.assertFalse(report["cold_boot_tested"])
|
||||
self.assertEqual(args.snapshot.stat().st_mode & 0o777, 0o600)
|
||||
for sanitized in (output.getvalue(), args.report.read_text()):
|
||||
self.assertNotIn("ab" * 32, sanitized)
|
||||
self.assertNotIn(WireSession.board_reply.hex(), sanitized)
|
||||
self.assertNotIn("987654", sanitized)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,6 +20,8 @@ import ethernet_primary_probe as primary
|
||||
|
||||
class PortOwnershipTests(unittest.TestCase):
|
||||
def run_inspector(self, result):
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return patch.object(session.subprocess, "run", return_value=result)
|
||||
|
||||
def test_existing_owner_refuses_without_killing_anything(self):
|
||||
@@ -28,7 +30,7 @@ class PortOwnershipTests(unittest.TestCase):
|
||||
self.run_inspector(SimpleNamespace(returncode=0)) as run:
|
||||
with self.assertRaisesRegex(RuntimeError, "already in use"):
|
||||
session.assert_port_idle("/dev/ttyUSB0")
|
||||
self.assertEqual(run.call_args.args[0][:2], ["/usr/bin/fuser", "--"])
|
||||
self.assertEqual(run.call_args.args[0], ["/usr/bin/fuser", "/dev/ttyUSB0"])
|
||||
self.assertNotIn("-k", run.call_args.args[0])
|
||||
|
||||
def test_idle_port_is_allowed(self):
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Offline app-only flash contracts; every flash operation is a temporary file.
|
||||
|
||||
Reuse the data-restoration test's pinned fake parser/flasher boundary. The real
|
||||
adapter runs unchanged, including input validation, guards, sole app write,
|
||||
readbacks and runtime finish. No installed flasher, serial port or PIO is used.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
import test_mcfirmware_bench_data_restore as fixture
|
||||
|
||||
TOOL = fixture.ROOT / 'tools/hil/mcfirmware_bench_app_flash.sh'
|
||||
IMAGE_PARSER = r'''
|
||||
def image_header(image):
|
||||
if len(image)<36 or image[0]!=0xe9: raise ExpansionError('bad image header')
|
||||
return struct.unpack_from('<H',image,12)[0],1<<(20+(image[3]>>4))
|
||||
def endf_identity(image):
|
||||
trailer=image[-56:]
|
||||
if len(trailer)!=56 or trailer[:4]!=b'EndF' or struct.unpack_from('<I',trailer,4)[0]!=len(image)-56:
|
||||
raise ExpansionError('bad EndF length')
|
||||
if trailer[8:16]!=hashlib.sha256(image[:-56]).digest()[:8]: raise ExpansionError('bad EndF hash')
|
||||
version,target=struct.unpack_from('<II',trailer,16)
|
||||
hardware=trailer[24:].rstrip(b'\0').decode('ascii')
|
||||
if not version or not target or not hardware: raise ExpansionError('bad EndF identity')
|
||||
return version,target,hardware
|
||||
'''
|
||||
|
||||
|
||||
def image(capacity, *, target=0, hardware='MOCK_BOARD', chip=9, mode=2, length=256):
|
||||
body = bytearray(length - 56)
|
||||
body[0:4] = bytes([0xe9, 1, mode, (capacity.bit_length() - 21) << 4])
|
||||
struct.pack_into('<H', body, 12, chip)
|
||||
struct.pack_into('<I', body, 32, 0xabcd5432)
|
||||
return (bytes(body) + b'EndF' + struct.pack('<I', len(body)) + hashlib.sha256(body).digest()[:8]
|
||||
+ struct.pack('<II', 0x01120001, target) + hardware.encode().ljust(32, b'\0'))
|
||||
|
||||
|
||||
def ota(slot):
|
||||
data = bytearray(b'\xff' * 8192)
|
||||
sequence = slot + 1
|
||||
struct.pack_into('<I', data, 0, sequence)
|
||||
struct.pack_into('<I', data, 28, zlib.crc32(struct.pack('<I', sequence), 0xffffffff) & 0xffffffff)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
@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 AppFlashTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# This creates only private temporary files and PATH command stubs.
|
||||
fixture.DataRestoreTests.setUp(self)
|
||||
helper = self.flasher.parent / 'tools/meshcore_partition_expansion.py'
|
||||
helper.write_text(fixture.PARSER + IMAGE_PARSER)
|
||||
source = self.flasher.read_text().replace(fixture.digest(fixture.PARSER.encode()),
|
||||
fixture.digest(helper.read_bytes()))
|
||||
source = source.replace('Detected flash size:8MB\\n\' "${MOCK_CHIP:-ESP32-S3}"',
|
||||
'Detected flash size:%sMB\\n\' "${MOCK_CHIP:-ESP32-S3}" "${MOCK_MB:-8}"')
|
||||
source = source.replace("'nvs-readback'", "'application-readback'")
|
||||
source = source.replace("int(sys.argv[2],0)==0x9000 and os.environ.get('MOCK_MUTATE_AFTER_WRITE')",
|
||||
"os.environ.get('MOCK_MUTATE_AFTER_WRITE')")
|
||||
self.flasher.write_text(source)
|
||||
self.image_path = self.root / 'application.bin'
|
||||
self.configure()
|
||||
|
||||
def configure(self, *, layout='xiao', active=0, relocated=False):
|
||||
self.capacity = 0x1000000 if layout == 'v4' else 0x800000
|
||||
if layout == 'v4':
|
||||
self.apps = [(0x10000, 0x640000), (0x650000, 0x640000)]
|
||||
data = [('spiffs', 1, 0x82, 0xc90000, 0x360000), ('coredump', 1, 3, 0xff0000, 0x10000)]
|
||||
elif layout == 'indicator':
|
||||
self.apps = [(0x10000, 0x280000), (0x400000, 0x280000)]
|
||||
data = [('spiffs', 1, 0x82, 0x290000, 0x160000), ('coredump', 1, 3, 0x3f0000, 0x10000)]
|
||||
else:
|
||||
self.apps = [(0x10000, 0x330000), (0x340000, 0x330000)]
|
||||
data = [('spiffs', 1, 0x82, 0x670000, 0x190000)]
|
||||
self.active = active
|
||||
self.ota_address = 0xc000 if relocated else 0xe000
|
||||
entries = [('nvs', 1, 2, 0x9000, 0x3000 if relocated else 0x5000),
|
||||
('otadata', 1, 0, self.ota_address, 0x2000)]
|
||||
entries += [('app' + str(i), 0, 0x10 + i, *part) for i, part in enumerate(self.apps)] + data
|
||||
self.table = b''.join(struct.pack('<2sBBII16sI', b'\xaa\x50', kind, sub, offset, length,
|
||||
name.encode(), 0)
|
||||
for name, kind, sub, offset, length in entries).ljust(4096, b'\xff')
|
||||
before = bytearray(b'\xff' * self.capacity)
|
||||
before[0x8000:0x9000] = self.table
|
||||
before[self.ota_address:self.ota_address + 8192] = ota(active)
|
||||
self.before = bytes(before)
|
||||
self.backup.write_bytes(before)
|
||||
self.flash.write_bytes(before)
|
||||
self.trace.unlink(missing_ok=True)
|
||||
self.manifest.write_text(json.dumps(dict(complete=True, operation='read-only-flash-backup',
|
||||
chip='ESP32-S3', device_mac=fixture.MAC, offset=0, length=self.capacity,
|
||||
flash_bytes=self.capacity, sha256=fixture.digest(before), artifact=self.backup.name)))
|
||||
self.image = image(self.capacity)
|
||||
self.image_path.write_bytes(self.image)
|
||||
address, size = self.apps[active]
|
||||
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', fixture.MAC, '--backup', str(self.backup), '--backup-manifest', str(self.manifest),
|
||||
'--backup-sha256', fixture.digest(before), '--expect-partition-sha256', fixture.digest(self.table),
|
||||
'--address', hex(address), '--slot-size', hex(size), '--image', str(self.image_path),
|
||||
'--image-sha256', fixture.digest(self.image), '--expect-target-id', '0',
|
||||
'--expect-hardware-id', 'MOCK_BOARD', '--allow-write', '--flasher', str(self.flasher),
|
||||
'--flasher-sha256', fixture.digest(self.flasher.read_bytes())]
|
||||
|
||||
def argument(self, option, value):
|
||||
self.args[self.args.index(option) + 1] = str(value)
|
||||
|
||||
def run_tool(self, **env):
|
||||
self.trace.unlink(missing_ok=True)
|
||||
return fixture.DataRestoreTests.run_tool(self, MOCK_MB=str(self.capacity // 0x100000), **env)
|
||||
|
||||
def refuse(self, *, before_prepare=False, **env):
|
||||
result = self.run_tool(**env)
|
||||
fixture.DataRestoreTests.assert_no_writes(self, result)
|
||||
self.assertEqual(self.flash.read_bytes(), self.before)
|
||||
if before_prepare:
|
||||
self.assertNotIn('prepare', self.actions)
|
||||
|
||||
def assert_success(self, **env):
|
||||
result = self.run_tool(**env)
|
||||
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], [self.apps[self.active][0]])
|
||||
expected = bytearray(self.before)
|
||||
offset = self.apps[self.active][0]
|
||||
expected[offset:offset + len(self.image)] = self.image
|
||||
self.assertEqual(self.flash.read_bytes(), expected)
|
||||
self.assertEqual(self.actions[-1], 'finish')
|
||||
self.assertFalse(any('erase' in line for line in self.actions))
|
||||
return result
|
||||
|
||||
def test_only_active_app_written_in_both_slots_of_three_real_layout_shapes(self):
|
||||
for layout in ('v4', 'xiao', 'indicator'):
|
||||
for active in (0, 1):
|
||||
with self.subTest(layout=layout, active=active):
|
||||
self.configure(layout=layout, active=active)
|
||||
self.assert_success()
|
||||
self.assertFalse(any('hard-reset' in action for action in self.actions))
|
||||
|
||||
def test_relocated_ota_read_and_uart_final_probe_reset_then_finish(self):
|
||||
self.configure(layout='indicator', active=1, relocated=True)
|
||||
self.assert_success(MOCK_AFTER='hard-reset')
|
||||
self.assertIn('--after hard-reset --baud 115200 read-mac', self.actions[-2])
|
||||
reads = [int(line.split()[6], 0) for line in self.actions if 'read-flash' in line]
|
||||
self.assertEqual(reads.count(0xc000), 2)
|
||||
self.assertNotIn(0xe000, reads)
|
||||
|
||||
def test_live_chip_or_capacity_mismatch_refused_before_write(self):
|
||||
self.refuse(MOCK_CHIP='ESP32-C3')
|
||||
# Keep the image/backup capacity at8MiB, change only the live probe.
|
||||
self.capacity = 0x1000000
|
||||
self.refuse()
|
||||
|
||||
def test_changed_live_table_or_active_ota_refused_before_write(self):
|
||||
for address, replacement in ((0x8000, b'\x00'), (self.ota_address, ota(1))):
|
||||
with self.subTest(address=address):
|
||||
changed = bytearray(self.before)
|
||||
changed[address:address + len(replacement)] = replacement
|
||||
self.flash.write_bytes(changed)
|
||||
# The altered live state itself must also remain untouched.
|
||||
self.before = bytes(changed)
|
||||
self.refuse()
|
||||
self.configure()
|
||||
|
||||
def test_wrong_requested_active_slot_or_size_refused_before_prepare(self):
|
||||
self.argument('--address', hex(self.apps[1][0]))
|
||||
self.refuse(before_prepare=True)
|
||||
self.configure()
|
||||
self.argument('--slot-size', self.apps[0][1] - 4096)
|
||||
self.refuse(before_prepare=True)
|
||||
|
||||
def test_image_hash_backup_hash_and_mac_refused_before_prepare(self):
|
||||
for option, wrong in (('--image-sha256', '0' * 64), ('--backup-sha256', '0' * 64),
|
||||
('--expect-mac', '112233445566'), ('--expect-partition-sha256', '0' * 64)):
|
||||
with self.subTest(option=option):
|
||||
self.configure()
|
||||
self.argument(option, wrong)
|
||||
self.refuse(before_prepare=True)
|
||||
|
||||
def test_full_zero_target_requires_exact_optin_and_hardware(self):
|
||||
for option, wrong in (('--expect-target-id', '1234'), ('--expect-hardware-id', 'OTHER_BOARD')):
|
||||
with self.subTest(option=option):
|
||||
self.configure()
|
||||
self.argument(option, wrong)
|
||||
self.refuse(before_prepare=True)
|
||||
|
||||
def test_endf_body_corruption_and_oversize_image_refused_before_prepare(self):
|
||||
for changed in (self.image[:40] + bytes([self.image[40] ^ 1]) + self.image[41:],
|
||||
image(self.capacity, length=self.apps[0][1] + 1)):
|
||||
with self.subTest(length=len(changed)):
|
||||
self.image_path.write_bytes(changed)
|
||||
self.argument('--image-sha256', fixture.digest(changed))
|
||||
self.refuse(before_prepare=True)
|
||||
|
||||
def test_image_chip_capacity_dio_and_descriptor_refused_before_prepare(self):
|
||||
images = [image(self.capacity, chip=5), image(0x1000000), image(self.capacity, mode=0)]
|
||||
bad_descriptor = bytearray(self.image)
|
||||
bad_descriptor[32] ^= 1
|
||||
images.append(bytes(bad_descriptor))
|
||||
for changed in images:
|
||||
with self.subTest(header=changed[:4].hex()):
|
||||
self.image_path.write_bytes(changed)
|
||||
self.argument('--image-sha256', fixture.digest(changed))
|
||||
self.refuse(before_prepare=True)
|
||||
|
||||
def test_app_readback_fault_or_changed_table_ota_prevents_runtime_finish(self):
|
||||
for env in ({'MOCK_BAD_READBACK': '1'}, {'MOCK_MUTATE_AFTER_WRITE': '0x8000'},
|
||||
{'MOCK_MUTATE_AFTER_WRITE': hex(self.ota_address)}):
|
||||
with self.subTest(fault=env):
|
||||
self.configure()
|
||||
result = self.run_tool(**env)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(sum('write-flash' in line for line in self.actions), 1)
|
||||
self.assertNotIn('finish', self.actions)
|
||||
|
||||
def test_nonzero_infrastructure_target_keeps_existing_interface(self):
|
||||
self.image = image(self.capacity, target=1234)
|
||||
self.image_path.write_bytes(self.image)
|
||||
self.argument('--image-sha256', fixture.digest(self.image))
|
||||
for option in ('--expect-target-id', '--expect-hardware-id'):
|
||||
index = self.args.index(option)
|
||||
del self.args[index:index + 2]
|
||||
self.assert_success()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Offline backup ownership guard; never inspect or open a real serial port."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
TOOL = Path(__file__).resolve().parents[1] / "tools/hil/mcfirmware_bench_backup.sh"
|
||||
|
||||
|
||||
class BackupOwnershipTests(unittest.TestCase):
|
||||
def guard(self, status, stdout="", stderr=""):
|
||||
source = TOOL.read_text()
|
||||
guard = re.search(r"^backup_assert_unowned\(\) \{\n.*?^\}", source, re.M | re.S).group()
|
||||
# sudo/fuser are inert functions: an unprivileged fallback fails this
|
||||
# contract, including when its quiet return code resembles no owner.
|
||||
script = r'''
|
||||
set -eu
|
||||
backup_die() { printf '%s\n' "$*" >&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()
|
||||
@@ -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('<BBII16sI',row,2)
|
||||
if address<0x9000 or address%4096 or size%4096 or not size or address+size>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('<I',data)[0]
|
||||
crc=struct.unpack_from('<I',data,28)[0]
|
||||
if crc!=(zlib.crc32(struct.pack('<I',sequence),0xffffffff)&0xffffffff):
|
||||
raise ExpansionError('bad OTA CRC')
|
||||
return apps[(sequence-1)%len(apps)]['offset']
|
||||
def flash_identity(text):
|
||||
chip=9 if 'ESP32-S3' in text else 5
|
||||
return int(re.search(r'flash size:(\d+)MB',text)[1])*1048576,chip
|
||||
'''
|
||||
|
||||
FLASHER = r'''
|
||||
esptool_set_variables() {
|
||||
NORESET=no-reset;HARDRESET=hard-reset;READMAC=read-mac;READFLASH=read-flash;WRITEFLASH=write-flash;FLASHID=flash-id
|
||||
}
|
||||
save_selected_serial_port() {
|
||||
DEVICE_PORT=$1
|
||||
}
|
||||
prepare_esp32_flash_session() {
|
||||
printf 'prepare\n' >> "$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()
|
||||
@@ -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('<script type="module">', 1)[1].split('</script>', 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()
|
||||
@@ -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("<I", start, 1)[0]
|
||||
require(count <= self.device["contact_capacity"], "Contact count exceeds device capacity")
|
||||
contacts = {}
|
||||
while True:
|
||||
frame = self.receive(timeout=max(10, self.args.timeout))
|
||||
if frame[0] == 4:
|
||||
require(len(frame) == 5 and len(contacts) == count,
|
||||
"Contact stream ended with an incomplete or inconsistent count")
|
||||
return dict(sorted(contacts.items()))
|
||||
require(len(frame) == 148 and frame[0] == 3, "Contact frame must contain all 148 bytes")
|
||||
key = frame[1:33].hex()
|
||||
require(key not in contacts, "Contact stream contains a duplicate identity")
|
||||
require(len(contacts) < count, "Contact stream exceeds its advertised count")
|
||||
contacts[key] = frame.hex()
|
||||
|
||||
def channels(self):
|
||||
result = []
|
||||
for index in range(self.device["channel_capacity"]):
|
||||
# Extended requests retain a 256-bit Meshtext key when supported;
|
||||
# ordinary channels return their stock 128-bit key.
|
||||
frame = self.request(bytes((31, index, 32)), 18)
|
||||
require(len(frame) in (50, 66) and frame[1] == index,
|
||||
"Channel response length or index mismatch")
|
||||
name = frame[2:34].split(b"\0", 1)[0]
|
||||
result.append({"index": index, "name_hex": name.hex(),
|
||||
"key_hex": frame[34:].hex(), "raw_frame_hex": frame.hex()})
|
||||
return result
|
||||
|
||||
def snapshot(self):
|
||||
self.handshake()
|
||||
return {"schema": 1, "usb_serial": self.args.serial,
|
||||
"self_info_frame_hex": self.self_frame.hex(),
|
||||
"device_info_frame_hex": self.device_frame.hex(),
|
||||
"full_cli_version": self.full_cli_version,
|
||||
"full_cli_version_frame_hex": self.full_cli_version_frame.hex(),
|
||||
"contacts": self.contacts(), "channels": self.channels(),
|
||||
"core_stats": self.stats()}
|
||||
|
||||
|
||||
def stable_state(snapshot):
|
||||
return {"self_info": snapshot["self_info_frame_hex"],
|
||||
"device_info": snapshot["device_info_frame_hex"],
|
||||
"full_cli_version": snapshot["full_cli_version"],
|
||||
"contacts": snapshot["contacts"],
|
||||
"channels": [{key: channel[key] for key in ("index", "name_hex", "key_hex")}
|
||||
for channel in snapshot["channels"]]}
|
||||
|
||||
|
||||
def check_uptime(previous, current):
|
||||
require(current["uptime_seconds"] >= 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("<IiiI", 1700000000, 0, 0, 1700000000)
|
||||
return {"add_update_frame_hex": (b"\x09" + body).hex()}
|
||||
|
||||
|
||||
def owned_crud(args, counters, baseline, report):
|
||||
body = bytes.fromhex(baseline["owned_crud_plan"]["add_update_frame_hex"])[1:]
|
||||
key = body[:32]
|
||||
label = body[99:131].split(b"\0", 1)[0]
|
||||
expected_frames = []
|
||||
cleanup_needed = False
|
||||
failure = None
|
||||
try:
|
||||
with Session(args, counters) as session:
|
||||
absent = session.request(b"\x1e" + key, 3, allow_error=True)
|
||||
require(absent == b"\x01\x02", "Synthetic contact is not confirmed absent")
|
||||
for update in range(2):
|
||||
changed = bytearray(body)
|
||||
if update:
|
||||
changed[99:131] = (label + b"-updated").ljust(32, b"\0")
|
||||
expected = b"\x03" + bytes(changed)
|
||||
expected_frames.append(expected)
|
||||
cleanup_needed = True # A lost acknowledgement may still mean acceptance.
|
||||
reply = session.request(b"\x09" + bytes(changed), 0)
|
||||
require(reply == b"\x00", "Contact mutation returned malformed success")
|
||||
readback = session.request(b"\x1e" + key, 3)
|
||||
require(readback == expected, "Owned contact readback differs from its complete update")
|
||||
time.sleep(args.settle_seconds)
|
||||
with Session(args, counters) as session:
|
||||
require(session.request(b"\x1e" + key, 3) == expected_frames[-1],
|
||||
"Owned contact did not survive a USB reopen")
|
||||
report["owned_add_update_read_reopen"] = True
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
finally:
|
||||
if cleanup_needed:
|
||||
# Fresh handshake after any timeout prevents a delayed mutation ACK
|
||||
# from being mistaken for this cleanup operation's acknowledgement.
|
||||
try:
|
||||
with Session(args, counters) as session:
|
||||
present = session.request(b"\x1e" + key, 3, allow_error=True)
|
||||
if present != b"\x01\x02":
|
||||
require(present in expected_frames, "Owned contact changed; refusing to delete an unrecognized record")
|
||||
require(session.request(b"\x0f" + key, 0) == b"\x00", "Owned contact deletion failed")
|
||||
require(session.request(b"\x1e" + key, 3, allow_error=True) == b"\x01\x02",
|
||||
"Owned contact remains after cleanup")
|
||||
time.sleep(args.settle_seconds)
|
||||
report["owned_contact_removed"] = True
|
||||
except BaseException as cleanup_error:
|
||||
report["owned_cleanup_failed"] = True
|
||||
if failure is None:
|
||||
failure = cleanup_error
|
||||
if failure is not None:
|
||||
raise failure
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--serial", required=True, help="Exact application USB chip serial; no wildcard")
|
||||
parser.add_argument("--expected-board", required=True, help="Required DEVICE_INFO board substring")
|
||||
parser.add_argument("--expected-version", required=True,
|
||||
help="Exact complete FIRMWARE_VERSION, including the candidate source suffix; exclude the Build suffix")
|
||||
parser.add_argument("--mode", choices=("snapshot", "smoke", "owned-crud"), default="snapshot")
|
||||
parser.add_argument("--snapshot", required=True, type=Path, help="New private 0600 baseline JSON path")
|
||||
parser.add_argument("--report", required=True, type=Path, help="New sanitized JSON report path")
|
||||
parser.add_argument("--queries", type=int, default=100)
|
||||
parser.add_argument("--reopens", type=int, default=10)
|
||||
parser.add_argument("--timeout", type=float, default=10)
|
||||
parser.add_argument("--settle-seconds", type=float, default=7)
|
||||
parser.add_argument("--manifest", type=Path, help="Optional externally reviewed artifact manifest")
|
||||
parser.add_argument("--manifest-sha256", help="Exact expected SHA-256 of --manifest")
|
||||
args = parser.parse_args()
|
||||
require(re.fullmatch(r"(?:[A-Za-z0-9]{6,64}|(?:[A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2})", args.serial) is not None,
|
||||
"USB serial must be exact alphanumeric text or a six-byte colon-separated MAC")
|
||||
require(args.expected_board.strip() and args.expected_version.isascii() and args.expected_version.strip(),
|
||||
"Expected board and ASCII version must be nonempty")
|
||||
require(1 <= args.queries <= 10000 and 1 <= args.reopens <= 1000
|
||||
and 1 <= args.timeout <= 120 and 5 <= args.settle_seconds <= 60,
|
||||
"Query, reopen, timeout or save-settle setting is outside its bounded range")
|
||||
require(args.snapshot.resolve() != args.report.resolve(), "Snapshot and report paths must differ")
|
||||
require(not args.snapshot.exists() and not args.snapshot.is_symlink()
|
||||
and not args.report.exists() and not args.report.is_symlink(), "Output paths must be new")
|
||||
require(bool(args.manifest) == bool(args.manifest_sha256), "Supply both manifest and its expected SHA-256")
|
||||
if args.manifest:
|
||||
require(re.fullmatch(r"[A-Fa-f0-9]{64}", args.manifest_sha256) is not None, "Manifest hash must be SHA-256")
|
||||
require(hashlib.sha256(args.manifest.read_bytes()).hexdigest() == args.manifest_sha256.lower(),
|
||||
"External artifact manifest hash mismatch")
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
counters = TransportCounters()
|
||||
report = {"schema": 1, "mode": args.mode, "passed": False,
|
||||
"cold_boot_tested": False, "private_key_exported": False,
|
||||
"source_version_verified": False,
|
||||
"live_flash_hash_verified": False}
|
||||
if args.manifest:
|
||||
report["external_manifest_sha256"] = args.manifest_sha256.lower()
|
||||
try:
|
||||
with Session(args, counters) as session:
|
||||
baseline = session.snapshot()
|
||||
report["full_runtime_version"] = baseline["full_cli_version"]
|
||||
report["source_version_verified"] = True
|
||||
if args.mode == "owned-crud":
|
||||
# Save the exact generated key/record privately before sending any
|
||||
# mutation, so interrupted cleanup remains manually recoverable.
|
||||
baseline["owned_crud_plan"] = owned_plan(baseline)
|
||||
private_json(args.snapshot, baseline)
|
||||
report["snapshot_sha256"] = hashlib.sha256(args.snapshot.read_bytes()).hexdigest()
|
||||
report["baseline_state_sha256"] = digest(stable_state(baseline))
|
||||
report["contacts"] = len(baseline["contacts"])
|
||||
report["channels"] = len(baseline["channels"])
|
||||
report["initial_core_stats"] = baseline["core_stats"]
|
||||
if args.mode == "smoke":
|
||||
smoke(args, counters, baseline, report)
|
||||
elif args.mode == "owned-crud":
|
||||
owned_crud(args, counters, baseline, report)
|
||||
with Session(args, counters) as session:
|
||||
final = session.snapshot()
|
||||
check_uptime(baseline["core_stats"], final["core_stats"])
|
||||
require(stable_state(final) == stable_state(baseline),
|
||||
"Final contacts, channels, identity/name or settings differ from the original baseline")
|
||||
report["final_state_sha256"] = digest(stable_state(final))
|
||||
report["final_core_stats"] = final["core_stats"]
|
||||
report["baseline_restored"] = True
|
||||
report["passed"] = True
|
||||
except BaseException as error:
|
||||
# Validators never include full wire frames or channel/private keys.
|
||||
report["failure"] = f"{type(error).__name__}: {error}"
|
||||
finally:
|
||||
report["transport"] = vars(counters)
|
||||
private_json(args.report, report)
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 0 if report["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (AcceptanceError, OSError) as error:
|
||||
print(f"Acceptance preflight failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env bash
|
||||
# Application-only 8/16 MiB ESP32-S3 bench role switch on a pinned existing OTA layout. No erase-all,
|
||||
# partition/OTA/NVS/filesystem write, service stop or direct reset bypass.
|
||||
# Uses the real mcfirmware session/MAC verification and guarded finish.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
app_die() { printf 'Application flash refused: %s\n' "$*" >&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('<I', image, 32)[0] == 0xabcd5432,
|
||||
'image has no application descriptor at the first segment')
|
||||
if expected_target == '0':
|
||||
# Production Full builds intentionally disable LoRa OTA and stamp
|
||||
# target zero. The migration parser requires a nonzero target;
|
||||
# this explicit opt-in still checks every EndF integrity field.
|
||||
trailer = image[-56:]
|
||||
check(len(trailer) == 56 and trailer[:4] == b'EndF'
|
||||
and struct.unpack_from('<I', trailer, 4)[0] == len(image) - 56
|
||||
and trailer[8:16] == hashlib.sha256(image[:-56]).digest()[:8],
|
||||
'application EndF length/body hash invalid')
|
||||
version, target = struct.unpack_from('<II', trailer, 16)
|
||||
hardware_bytes = expected_hardware.encode('ascii')
|
||||
check(version != 0 and target == 0
|
||||
and trailer[24:] == hardware_bytes.ljust(32, b'\0'),
|
||||
'application EndF version/explicit zero target/exact hardware mismatch')
|
||||
hardware = expected_hardware
|
||||
else:
|
||||
version, target, hardware = helper.endf_identity(image)
|
||||
check(version and target and hardware, 'application EndF identity invalid')
|
||||
if expected_target:
|
||||
check(target == int(expected_target) and hardware == expected_hardware,
|
||||
'application EndF differs from the explicit target/hardware pins')
|
||||
(state/'inputs.json').write_text(json.dumps(dict(
|
||||
image_bytes=len(image), image_sha256=expected_image, backup_sha256=expected_backup,
|
||||
partition_sha256=expected_table, firmware_body_hash16=image[-48:-40].hex(),
|
||||
target_id=target, hardware_id=hardware, firmware_version=version, flash_bytes=capacity,
|
||||
application_address=address, slot_size=slot_size, slot_name=slot['name'],
|
||||
otadata_address=ota['offset'], otadata_size=ota['size'],
|
||||
backup_otadata_sha256=digest(backup_ota))))
|
||||
(state/'backup-table.bin').write_bytes(table)
|
||||
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 full backup')
|
||||
elif mode == 'live':
|
||||
expected_table, = args
|
||||
inputs = json.loads((state/'inputs.json').read_text())
|
||||
table = (state/'table-before.bin').read_bytes()
|
||||
check(len(table) == 0x1000 and digest(table) == expected_table
|
||||
and table == (state/'backup-table.bin').read_bytes(), 'live table differs from the exact pinned backup')
|
||||
parts, apps, ota = layout(table, inputs['flash_bytes'])
|
||||
check(ota['offset'] == inputs['otadata_address'] and ota['size'] == inputs['otadata_size'],
|
||||
'live OTA metadata geometry differs from the pinned backup')
|
||||
selected_slot(parts, apps, (state/'otadata-before.bin').read_bytes(),
|
||||
inputs['application_address'], inputs['slot_size'])
|
||||
elif mode == 'readback':
|
||||
expected_image, expected_table = args
|
||||
original = (state/'application.bin').read_bytes()
|
||||
observed = (state/'application-readback.bin').read_bytes()
|
||||
check(observed == original and digest(observed) == expected_image, 'hardware app readback hash mismatch')
|
||||
check((state/'table-after.bin').read_bytes() == (state/'table-before.bin').read_bytes()
|
||||
and digest((state/'table-after.bin').read_bytes()) == expected_table,
|
||||
'partition table changed during app-only write')
|
||||
check((state/'otadata-after.bin').read_bytes() == (state/'otadata-before.bin').read_bytes(),
|
||||
'OTA metadata changed during app-only write')
|
||||
else:
|
||||
raise ValueError('unknown validation mode')
|
||||
except Exception as error:
|
||||
# Only fixed validator/parser reasons or exception classes; never raw flash,
|
||||
# JSON/file contents, paths or credential-bearing operating-system errors.
|
||||
safe_reason = type(error) is ValueError or type(error) is helper.ExpansionError
|
||||
print('App validation failed: ' + (str(error) if safe_reason else type(error).__name__), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
python3 "$app_state/validate.py" inputs "$app_helper" "$app_state" "$app_backup" "$app_backup_manifest" \
|
||||
"$app_backup_hash" "$app_image_hash" "$app_table_hash" "$app_mac" "$app_address" "$app_slot_size" \
|
||||
"$app_target_id" "$app_hardware_id" 2> "$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"
|
||||
Executable
+218
@@ -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
|
||||
Executable
+358
@@ -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"
|
||||
Executable
+515
@@ -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'''<!doctype html><meta charset="utf-8"><title>Native official console acceptance</title>
|
||||
<script type="module">
|
||||
import {SerialConsole} from './console.js';
|
||||
window.hil = {ready:false, client:null, task:null, writes:0, replies:0,
|
||||
pending:null, disconnected:false};
|
||||
hil.open = async function(vid,pid) {
|
||||
const ports=await navigator.serial.getPorts();
|
||||
if(ports.length!==1) throw Error('exactly_one_pregranted_native_port_required');
|
||||
const info=ports[0].getInfo();
|
||||
if(info.usbVendorId!==vid||info.usbProductId!==pid) throw Error('native_USB_ID_mismatch');
|
||||
hil.client=new SerialConsole(ports[0]);
|
||||
hil.client.onOutput=text=>{
|
||||
if(text.includes('*** Terminal disconnected')) hil.disconnected=true;
|
||||
// A terminal prompt and the subsequent input echo share a CRLF line.
|
||||
// Accept that exact prefix as well as the bare echo after a fresh open.
|
||||
const line=text.replace(/\r?\n$/, '');
|
||||
if(hil.pending && (line===hil.pending.command || line==='> '+hil.pending.command ||
|
||||
line==='>'+hil.pending.command)) hil.pending.echo_seen=true;
|
||||
// The unmodified official CRLF transformer delivers these complete lines.
|
||||
// Raw console output stays in RAM and is never logged or saved.
|
||||
if(hil.pending?.echo_seen && text.startsWith(' -> ') && text.endsWith('\n')) {
|
||||
const waiter=hil.pending; hil.pending=null; ++hil.replies;
|
||||
clearTimeout(waiter.timer); waiter.resolve(text.slice(5).trim());
|
||||
}
|
||||
};
|
||||
hil.task=hil.client.connect();
|
||||
const end=performance.now()+10000;
|
||||
while(!hil.client.connected) {
|
||||
if(hil.disconnected||performance.now()>end) throw Error('native_console_open_failed');
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}
|
||||
return {native_ports:ports.length, connected:hil.client.connected};
|
||||
};
|
||||
hil.query = async function(command) {
|
||||
if(hil.pending||!hil.client.connected||hil.disconnected) throw Error('console_not_ready');
|
||||
const start=performance.now();
|
||||
const reply=new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>{hil.pending=null;reject(Error('framed_reply_deadline'));},10000);
|
||||
hil.pending={resolve,reject,timer,command,echo_seen:false};
|
||||
});
|
||||
++hil.writes;
|
||||
try { await hil.client.sendCommand(command); }
|
||||
catch(error) { if(hil.pending) {clearTimeout(hil.pending.timer);hil.pending=null;} throw error; }
|
||||
const body=await reply;
|
||||
return {body,elapsed_ms:performance.now()-start,complete_CRLF_reply:true};
|
||||
};
|
||||
hil.idle = async function(seconds) {
|
||||
const before=hil.writes,start=performance.now();
|
||||
await new Promise(resolve=>setTimeout(resolve,seconds*1000));
|
||||
if(!hil.client.connected||hil.disconnected) throw Error('console_disconnected_while_idle');
|
||||
if(hil.writes!==before) throw Error('unexpected_command_during_idle');
|
||||
return {seconds:(performance.now()-start)/1000,command_writes:hil.writes-before,
|
||||
connected:hil.client.connected};
|
||||
};
|
||||
hil.close = async function() {
|
||||
if(hil.client) {await hil.client.disconnect();await hil.task;}
|
||||
hil.client=null;hil.task=null;hil.disconnected=false;
|
||||
return {native_streams_closed:true};
|
||||
};
|
||||
hil.ready=true;
|
||||
</script>'''
|
||||
|
||||
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())
|
||||
@@ -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"<!doctype html>"), "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())
|
||||
@@ -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":"<exact board>",
|
||||
"role":"<exact role>", "fw":"<exact /api/status fw>",
|
||||
"mode":"lan"},
|
||||
"config_expected":{"radio":{"name":"<expected node name>"}}}
|
||||
|
||||
Private JSON must be owned by this user and mode 0600 (or 0400)::
|
||||
|
||||
{"admin_password":"<existing password>",
|
||||
"source_ips":["192.0.2.20","192.0.2.21"],
|
||||
"room_guest_password":"<optional existing 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())
|
||||
@@ -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)")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user