tsb: protocol test covers the password gate and emergency erase

Each scenario group now runs on its own freshly-reset device: the round-trip
on a blank config page, plus a password-config device that must be sent the
password after the knock to activate, and an emergency-erase device where a
0-byte + two confirms wipes flash, EEPROM and the config page (verified by
reading all three back as 0xff). All three tiers pass every group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:49:20 +02:00
parent 3a0d3790ee
commit beff7bb3ee

View File

@@ -5,6 +5,7 @@ wire protocol over its pty (as the real host tools do), and actually flash it.
Usage: tsbtest.py <device_binary> <tsb.elf> <boot_base_hex> Usage: tsbtest.py <device_binary> <tsb.elf> <boot_base_hex>
Exits 0 if every scenario passes. Exits 0 if every scenario passes.
""" """
import os
import subprocess import subprocess
import sys import sys
import time import time
@@ -13,16 +14,22 @@ import serial
CONFIRM = 0x21 # '!' CONFIRM = 0x21 # '!'
REQUEST = 0x3F # '?' REQUEST = 0x3F # '?'
KNOCK = 0x40 # '@'
PAGE = 128 # ATmega328P: 64 words PAGE = 128 # ATmega328P: 64 words
class Device: class Device:
"""The simavr runner, exposing UART0 as a pty.""" """The simavr runner, exposing UART0 as a pty. `config` seeds the config
page (via the device's TSB_CONFIG hook) so the password gate and emergency
erase are exercisable."""
def __init__(self, binary, elf, boot_base, dump="/tmp/tsb_dump.bin"): def __init__(self, binary, elf, boot_base, dump="/tmp/tsb_dump.bin", config=None):
env = dict(os.environ)
if config is not None:
env["TSB_CONFIG"] = config
self.proc = subprocess.Popen( self.proc = subprocess.Popen(
[binary, elf, boot_base, dump], [binary, elf, boot_base, dump],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env)
self.dump = dump self.dump = dump
self.pty = None self.pty = None
deadline = time.time() + 5 deadline = time.time() + 5
@@ -137,6 +144,28 @@ class Host:
self._expect(CONFIRM, "C end") self._expect(CONFIRM, "C end")
return echo return echo
# Activation when the config page carries a password: 3×'@' then the
# password bytes, then the info block + mainloop '!'.
def activate_password(self, password):
self.s.reset_input_buffer()
self.s.write(bytes([KNOCK, KNOCK, KNOCK]) + password)
reply = self._read(17)
if reply[16] != CONFIRM:
raise AssertionError(f"password activation not '!'-terminated: {reply.hex()}")
self.info = reply[:16]
return self.info
# A 0 byte where a password byte is expected requests emergency erase; the
# device asks for two confirmations, then wipes and returns to the mainloop.
def emergency_erase(self):
self.s.reset_input_buffer()
self.s.write(bytes([KNOCK, KNOCK, KNOCK, 0x00]))
self._expect(REQUEST, "emergency confirm 1")
self.s.write(bytes([CONFIRM]))
self._expect(REQUEST, "emergency confirm 2")
self.s.write(bytes([CONFIRM]))
self._expect(CONFIRM, "emergency mainloop ready")
def check(cond, msg): def check(cond, msg):
if not cond: if not cond:
@@ -144,14 +173,15 @@ def check(cond, msg):
print(f" ok: {msg}") print(f" ok: {msg}")
def main(): # A config page carrying a password "PW": appjump 0, timeout 0x40, password
binary, elf, boot_base = sys.argv[1], sys.argv[2], sys.argv[3] # 0x50 0x57 terminated by 0xff.
dev = Device(binary, elf, boot_base) PW_CONFIG = "0000405057ff"
failures = [] PW_BYTES = bytes([0x50, 0x57])
try:
host = Host(dev.pty)
# --- activation ---
def scenario_roundtrip(host):
"""Activation + info block + flash/EEPROM/config read-write round-trips, on
a device with a blank (erased) config page — the usual no-password case."""
info = host.activate() info = host.activate()
check(info[0:3] == b"TSB", f"magic 'TSB' (got {info[0:3]!r})") check(info[0:3] == b"TSB", f"magic 'TSB' (got {info[0:3]!r})")
check(info[6:9] == bytes([0x1E, 0x95, 0x0F]), f"signature 1E 95 0F (got {info[6:9].hex()})") check(info[6:9] == bytes([0x1E, 0x95, 0x0F]), f"signature 1E 95 0F (got {info[6:9].hex()})")
@@ -160,26 +190,53 @@ def main():
check(host.eeprom_size == 1024, f"eeprom size 1024 (got {host.eeprom_size})") check(host.eeprom_size == 1024, f"eeprom size 1024 (got {host.eeprom_size})")
print(f" info: {info.hex()} appflash={host.appflash} eeprom={host.eeprom_size}") print(f" info: {info.hex()} appflash={host.appflash} eeprom={host.eeprom_size}")
# --- flash write / read round-trip (actual self-programming) ---
app = bytes(range(256)) # two pages of known data app = bytes(range(256)) # two pages of known data
host.write_flash(app) host.write_flash(app)
back = host.read_flash(2) check(host.read_flash(2) == app, "flash round-trip 2 pages")
check(back == app, f"flash round-trip 2 pages ({'match' if back == app else 'MISMATCH'})")
# --- EEPROM write / read round-trip ---
edata = bytes((i * 7) & 0xFF for i in range(PAGE)) edata = bytes((i * 7) & 0xFF for i in range(PAGE))
host.write_eeprom(edata) host.write_eeprom(edata)
eback = host.read_eeprom(1) check(host.read_eeprom(1) == edata, "eeprom round-trip 1 page")
check(eback == edata, "eeprom round-trip 1 page")
# --- config page write / read round-trip --- cfg = bytes([0x00, 0x00, 0x40]) + b"\xff" * (PAGE - 3) # timeout 0x40, no password
cfg = bytes([0x00, 0x00, 0x40]) + b"\xff" * (PAGE - 3) # appjump 0, timeout 0x40, no password check(host.write_config(cfg) == cfg, "config write echoes the programmed page")
echo = host.write_config(cfg)
check(echo == cfg, "config write echoes the programmed page")
check(host.read_config() == cfg, "config read-back matches") check(host.read_config() == cfg, "config read-back matches")
def scenario_password(host):
"""A device whose config page carries a password activates only when the
host sends it after the knock."""
info = host.activate_password(PW_BYTES)
check(info[0:3] == b"TSB", f"password activation returns the info block (got {info[0:3]!r})")
def scenario_emergency(host):
"""Emergency erase (password 0-byte + two confirms) wipes flash, EEPROM and
the config page; the device stays alive in its boot section."""
host.emergency_erase()
check(host.read_config() == b"\xff" * PAGE, "config page wiped")
check(host.read_flash(1) == b"\xff" * PAGE, "application flash wiped")
check(host.read_eeprom(1) == b"\xff" * PAGE, "EEPROM wiped")
def main():
binary, elf, boot_base = sys.argv[1], sys.argv[2], sys.argv[3]
failures = []
# Each group runs on its own freshly-reset device (simavr reloads the ELF,
# so nothing persists between them); the password groups seed a config page.
groups = [
("round-trip", None, scenario_roundtrip),
("password activation", PW_CONFIG, scenario_password),
("emergency erase", PW_CONFIG, scenario_emergency),
]
for name, config, fn in groups:
print(f"--- {name} ---")
dev = Device(binary, elf, boot_base, config=config)
try:
fn(Host(dev.pty))
except AssertionError as e: except AssertionError as e:
failures.append(str(e)) failures.append(f"{name}: {e}")
print(f" FAIL: {e}") print(f" FAIL: {e}")
finally: finally:
dev.stop() dev.stop()