Add identity-bound app flash adapters, exact-receipt radio2 sweeps through preamble 384, measured packet-window goodput, stock-peer delivery probes, Zopfli1000 USB OTA serving and bridge outage/recovery checks. Keep dwell experiments opt-in, production timing unchanged, generated artifacts outside Git, and wire offline safety tests into CI.
307 lines
16 KiB
Python
307 lines
16 KiB
Python
"""Exercise the S3 watchdog-finish adapter against inert flasher functions.
|
|
|
|
No serial device is opened. fuser/USB/DFU tools are replaced with explicit
|
|
non-hardware stubs; the fake flasher records only phase ordering and failures.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ADAPTER = ROOT / "tools/hil/mercer_mt_flash.sh"
|
|
PORT = "/dev/ttyACM99"
|
|
MAC = "aabbccddeeff"
|
|
|
|
FAKE_FUNCTIONS = r'''
|
|
esptool_set_variables() {
|
|
NORESET=no-reset
|
|
WATCHDOGRESET=${SIM_RESET_MODE-watchdog-reset}
|
|
READMAC=read-mac
|
|
printf 'variables\n' >> "$SIM_LOG"
|
|
}
|
|
prepare_esp32_flash_session() {
|
|
printf 'prepare\n' >> "$SIM_LOG"
|
|
ESP32_SESSION_IS_S3=${SIM_S3:-1}
|
|
ESP32_FLASH_EXPECTED_MAC=${SIM_ACTUAL_MAC:-aabbccddeeff}
|
|
}
|
|
selected_flash_serial_port() {
|
|
printf '%s\n' "$1"
|
|
}
|
|
esp32_verified_destructive_port() {
|
|
printf 'verify:%s\n' "$2" >> "$SIM_LOG"
|
|
if [[ "${SIM_FAIL_PHASE:-}" == "$2" ]]; then return 47; fi
|
|
printf '%s\n' "$1"
|
|
}
|
|
run_esp32_session_esptool() {
|
|
printf 'run:%s\n' "$*" >> "$SIM_LOG"
|
|
case " $* " in
|
|
*' flash-id '*) printf 'Detected flash size: %sMB\n' "${SIM_FLASH_MB:-16}" ;;
|
|
*' write-flash '*)
|
|
if [[ "${SIM_FAIL_PHASE:-}" == write ]]; then return 42; fi
|
|
# Model the production wrapper's positive verification contract:
|
|
# a missing verification marker is failure, even after exit zero.
|
|
if [[ "${SIM_FAIL_PHASE:-}" == unverified-write ]]; then return 43; fi
|
|
printf 'Hash of data verified.\n'
|
|
printf 'verified-write\n' >> "$SIM_LOG" ;;
|
|
*' write-mem '*)
|
|
if [[ "${SIM_FAIL_PHASE:-}" == volatile ]]; then return 44; fi ;;
|
|
*' --after watchdog-reset '*)
|
|
if [[ "${SIM_FAIL_PHASE:-}" == reset ]]; then return 45; fi ;;
|
|
esac
|
|
}
|
|
finish_esp32_flash_session() {
|
|
printf 'ordinary-finish\n' >> "$SIM_LOG"
|
|
}
|
|
'''
|
|
|
|
|
|
@unittest.skipUnless(os.name == "posix" and shutil.which("bash"),
|
|
"POSIX Bash required; run on Linux/WSL")
|
|
class MercerMtWatchdogFinishTest(unittest.TestCase):
|
|
def setUp(self):
|
|
temporary = tempfile.TemporaryDirectory(prefix="meshcore-mt-finish-mock-")
|
|
self.addCleanup(temporary.cleanup)
|
|
self.work = Path(temporary.name)
|
|
self.log = self.work / "actions.log"
|
|
self.startup = self.work / "FORBIDDEN-STARTUP"
|
|
self.flasher = self.work / "mock-mcfirmware.sh"
|
|
self.image = self.work / "application.bin"
|
|
self.image.write_bytes(b"INERT MOCK IMAGE; NEVER FLASH")
|
|
# Startup commands and traps must never be sourced by the adapter.
|
|
source = ('#!/usr/bin/env bash\n'
|
|
'printf startup > "$SIM_STARTUP"\n'
|
|
'trap \'printf unwanted-trap >> "$SIM_LOG"\' EXIT\n'
|
|
'exit 98\n') + FAKE_FUNCTIONS
|
|
# The adapter deliberately requires a complete definitions inventory.
|
|
source += "".join(f"inert_function_{i}() {{\n return 0\n}}\n"
|
|
for i in range(100))
|
|
self.flasher.write_text(source, encoding="utf-8")
|
|
bindir = self.work / "bin"
|
|
bindir.mkdir()
|
|
for name in ("python3", "pipx", "timeout", "udevadm"):
|
|
path = bindir / name
|
|
path.write_text("#!/bin/sh\nprintf 'FORBIDDEN-TOOL\\n' >> \"$SIM_LOG\"\nexit 99\n",
|
|
encoding="utf-8")
|
|
path.chmod(0o700)
|
|
fuser = bindir / "fuser"
|
|
fuser.write_text("#!/bin/sh\nprintf 'owner-check:%s\\n' \"$*\" >> \"$SIM_LOG\"\n"
|
|
'if [ "${SIM_BUSY:-0}" = 1 ]; then printf "1234\\n"; exit 0; fi\nexit 1\n',
|
|
encoding="utf-8")
|
|
fuser.chmod(0o700)
|
|
self.env = dict(os.environ, PATH=str(bindir) + os.pathsep + os.environ["PATH"],
|
|
SIM_LOG=str(self.log), SIM_STARTUP=str(self.startup))
|
|
for name in ("SIM_RESET_MODE", "SIM_S3", "SIM_ACTUAL_MAC", "SIM_FAIL_PHASE",
|
|
"SIM_BUSY", "SIM_FLASH_MB"):
|
|
self.env.pop(name, None)
|
|
|
|
def invoke(self, *, mode="write-flash", watchdog=True, allow=True,
|
|
allow_reset=False, operation_args=None, expected_mac=MAC,
|
|
flash_bytes="16777216", environment=None):
|
|
command = [shutil.which("bash"), str(ADAPTER), "--flasher", str(self.flasher),
|
|
"--port", PORT, "--workdir", str(self.work),
|
|
"--expect-mac", expected_mac, "--flash-bytes", flash_bytes]
|
|
if watchdog:
|
|
command.append("--watchdog-finish")
|
|
if allow:
|
|
command.extend(("--allow-write", "--allow-erase"))
|
|
if allow_reset:
|
|
command.append("--allow-reset")
|
|
command.extend(("--", mode))
|
|
if mode == "write-flash":
|
|
command.extend(("0x10000", str(self.image)))
|
|
elif mode == "read-flash":
|
|
command.extend(("0x10000", "32", str(self.work / "unused-read.bin")))
|
|
elif mode == "erase-region":
|
|
command.extend(("0x10000", "4096"))
|
|
if operation_args:
|
|
command.extend(operation_args)
|
|
result = subprocess.run(command, text=True, capture_output=True,
|
|
env=dict(self.env, **(environment or {})),
|
|
timeout=10, check=False)
|
|
self.assertFalse(self.startup.exists(), "the flasher's global startup ran")
|
|
actions = self.log.read_text(encoding="utf-8") if self.log.exists() else ""
|
|
self.assertNotIn("FORBIDDEN-TOOL", actions)
|
|
self.assertNotIn("unwanted-trap", actions)
|
|
return result, actions
|
|
|
|
def test_watchdog_refuses_every_nonwrite_mode_before_flasher_or_serial_tools(self):
|
|
for mode in ("probe", "read-flash", "erase-flash", "erase-region"):
|
|
with self.subTest(mode=mode):
|
|
result, actions = self.invoke(mode=mode)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("--watchdog-finish requires write-flash", result.stderr)
|
|
self.assertEqual(actions, "")
|
|
|
|
def test_watchdog_does_not_grant_write_permission(self):
|
|
result, actions = self.invoke(allow=False)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("write-flash requires --allow-write", result.stderr)
|
|
self.assertEqual(actions, "")
|
|
|
|
def test_s3_and_supported_reset_are_required_before_any_flash_write(self):
|
|
for environment, message in (({"SIM_S3": "0"}, "did not identify ESP32-S3"),
|
|
({"SIM_RESET_MODE": ""}, "supported S3 watchdog reset"),
|
|
({"SIM_RESET_MODE": "hard-reset"}, "supported S3 watchdog reset")):
|
|
with self.subTest(environment=environment):
|
|
self.log.unlink(missing_ok=True)
|
|
result, actions = self.invoke(environment=environment)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(message, result.stderr)
|
|
self.assertIn("prepare", actions)
|
|
self.assertNotIn("write-flash", actions)
|
|
self.assertNotIn("write-mem", actions)
|
|
self.assertNotIn("--after watchdog-reset", actions)
|
|
|
|
def test_expected_mac_is_checked_before_write_and_finish(self):
|
|
result, actions = self.invoke(environment={"SIM_ACTUAL_MAC": "112233445566"})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("MAC mismatch", result.stderr)
|
|
self.assertNotIn("write-flash", actions)
|
|
self.assertNotIn("write-mem", actions)
|
|
|
|
def test_verified_write_precedes_masked_clear_identity_recheck_and_one_watchdog(self):
|
|
result, actions = self.invoke()
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
phases = ["verify:write-flash", " write-flash ", "verified-write",
|
|
"verify:S3 volatile forced-ROM bit clear", "write-mem 0x6000812c 0 0x1",
|
|
"verify:S3 watchdog finish", "--after watchdog-reset --baud 115200 read-mac"]
|
|
positions = [actions.index(phase) for phase in phases]
|
|
self.assertEqual(positions, sorted(positions), actions)
|
|
self.assertEqual(actions.count("--after watchdog-reset"), 1)
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
self.assertNotIn("erase-flash", actions)
|
|
self.assertIn("Application boot still requires an application-level check", result.stdout)
|
|
|
|
def test_failed_or_unverified_write_cannot_clear_rom_or_reset(self):
|
|
for phase in ("write", "unverified-write"):
|
|
with self.subTest(phase=phase):
|
|
self.log.unlink(missing_ok=True)
|
|
result, actions = self.invoke(environment={"SIM_FAIL_PHASE": phase})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(" write-flash ", actions)
|
|
self.assertNotIn("write-mem", actions)
|
|
self.assertNotIn("--after watchdog-reset", actions)
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
|
|
def test_either_finish_identity_failure_prevents_the_next_phase(self):
|
|
for phase, clear_expected in (("S3 volatile forced-ROM bit clear", False),
|
|
("S3 watchdog finish", True)):
|
|
with self.subTest(phase=phase):
|
|
self.log.unlink(missing_ok=True)
|
|
result, actions = self.invoke(environment={"SIM_FAIL_PHASE": phase})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual("write-mem" in actions, clear_expected)
|
|
self.assertNotIn("--after watchdog-reset", actions)
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
|
|
def test_volatile_clear_failure_cannot_watchdog_or_fall_back_to_hard_reset(self):
|
|
result, actions = self.invoke(environment={"SIM_FAIL_PHASE": "volatile"})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("write-mem", actions)
|
|
self.assertNotIn("--after watchdog-reset", actions)
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
|
|
def test_failed_watchdog_is_not_retried_or_followed_by_another_reset(self):
|
|
result, actions = self.invoke(environment={"SIM_FAIL_PHASE": "reset"})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(actions.count("--after watchdog-reset"), 1)
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
self.assertNotIn("Application boot still requires", result.stdout)
|
|
|
|
def test_without_flag_the_original_finish_is_retained(self):
|
|
result, actions = self.invoke(watchdog=False)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("ordinary-finish", actions)
|
|
self.assertNotIn("write-mem", actions)
|
|
self.assertNotIn("--after watchdog-reset", actions)
|
|
|
|
def test_runtime_reset_requires_its_own_consent_and_zero_arguments_before_tools(self):
|
|
for options, message in (({}, "reset-runtime requires --allow-reset"),
|
|
({"allow_reset": True, "operation_args": ["0x10000", str(self.image)]},
|
|
"reset-runtime takes no arguments"),
|
|
({"allow_reset": True, "watchdog": True},
|
|
"--watchdog-finish requires write-flash")):
|
|
with self.subTest(options=options):
|
|
self.log.unlink(missing_ok=True)
|
|
config = {"mode": "reset-runtime", "watchdog": False, "allow": False, **options}
|
|
result, actions = self.invoke(**config)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(message, result.stderr)
|
|
self.assertEqual(actions, "")
|
|
|
|
def test_runtime_reset_requires_exact_mac_and_size_and_never_grants_flash_authority(self):
|
|
for field in ("expected_mac", "flash_bytes"):
|
|
with self.subTest(field=field):
|
|
self.log.unlink(missing_ok=True)
|
|
result, actions = self.invoke(mode="reset-runtime", watchdog=False,
|
|
allow=False, allow_reset=True, **{field: ""})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("supply the verified expected MAC and flash bytes", result.stderr)
|
|
self.assertEqual(actions, "")
|
|
result, actions = self.invoke(mode="reset-runtime", watchdog=False,
|
|
allow=False, allow_reset=True)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
phases = [" flash-id", "verify:S3 volatile forced-ROM bit clear",
|
|
"write-mem 0x6000812c 0 0x1", "verify:S3 watchdog finish",
|
|
"--after watchdog-reset --baud 115200 read-mac"]
|
|
positions = [actions.index(phase) for phase in phases]
|
|
self.assertEqual(positions, sorted(positions), actions)
|
|
self.assertEqual(actions.count("--after watchdog-reset"), 1)
|
|
for forbidden in ("write-flash", "erase-flash", "erase-region", "efuse",
|
|
"reset-runtime", "ordinary-finish"):
|
|
self.assertNotIn(forbidden, actions)
|
|
|
|
def test_runtime_reset_wrong_identity_chip_or_size_cannot_clear_or_watchdog(self):
|
|
for environment, size in (({"SIM_ACTUAL_MAC": "112233445566"}, "16777216"),
|
|
({"SIM_S3": "0"}, "16777216"),
|
|
({"SIM_RESET_MODE": "hard-reset"}, "16777216"),
|
|
({}, "8388608")):
|
|
with self.subTest(environment=environment, size=size):
|
|
self.log.unlink(missing_ok=True)
|
|
result, actions = self.invoke(mode="reset-runtime", watchdog=False,
|
|
allow=False, allow_reset=True, flash_bytes=size, environment=environment)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertNotIn("write-mem", actions)
|
|
self.assertNotIn("--after watchdog-reset", actions)
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
|
|
def test_runtime_reset_rechecks_identity_and_fails_closed_without_fallback(self):
|
|
for phase in ("S3 volatile forced-ROM bit clear", "volatile",
|
|
"S3 watchdog finish", "reset"):
|
|
with self.subTest(phase=phase):
|
|
self.log.unlink(missing_ok=True)
|
|
result, actions = self.invoke(mode="reset-runtime", watchdog=False,
|
|
allow=False, allow_reset=True, environment={"SIM_FAIL_PHASE": phase})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(actions.count("--after watchdog-reset"), int(phase == "reset"))
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
self.assertNotIn("write-flash", actions)
|
|
self.assertNotIn("erase-flash", actions)
|
|
|
|
def test_runtime_reset_busy_owner_is_never_killed_or_reset(self):
|
|
result, actions = self.invoke(mode="reset-runtime", watchdog=False,
|
|
allow=False, allow_reset=True, environment={"SIM_BUSY": "1"})
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("no owner will be killed", result.stderr)
|
|
self.assertNotIn("write-mem", actions)
|
|
self.assertNotIn("--after watchdog-reset", actions)
|
|
self.assertNotIn("ordinary-finish", actions)
|
|
|
|
def test_runtime_reset_accepts_verified_eight_mib_without_write_or_erase_consent(self):
|
|
result, actions = self.invoke(mode="reset-runtime", watchdog=False,
|
|
allow=False, allow_reset=True, flash_bytes="8388608",
|
|
environment={"SIM_FLASH_MB": "8"})
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertEqual(actions.count("--after watchdog-reset"), 1)
|
|
self.assertNotIn("write-flash", actions)
|
|
self.assertNotIn("erase-flash", actions)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|