Files
MeshCore/test/test_mercer_mt_flash.py
T
mikecarper c6e43c5530 Add guarded Meshtext, radio2 and multi-hop OTA hardware tests
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.
2026-10-15 21:05:40 -07:00

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()