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

+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):