Add guarded hardware acceptance and restoration tools

This commit is contained in:
mikecarper committed 2026-10-18 00:24:03 -07:00
1 parent 48bad8ddfb
commit 89dcd82089
18 files changed
+4529 -18

No files matched your search

+8
View File
@@ -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
+176
View File
@@ -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()
+3 -1
View File
@@ -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):
+235
View File
@@ -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()
+44
View File
@@ -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()
+274
View File
@@ -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()
+302
View File
@@ -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()
+425
View File
@@ -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)
+389
View File
@@ -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"
+218
View File
@@ -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
+358
View File
@@ -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"
+515
View File
@@ -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())
+588
View File
@@ -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())
+636
View File
@@ -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())
+5 -2
View File
@@ -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)")
+295
View File
@@ -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()
+40 -1
View File
@@ -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 -14
View File
@@ -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,