diff --git a/test/tsbtest.py b/test/tsbtest.py index 0811e52..a592055 100644 --- a/test/tsbtest.py +++ b/test/tsbtest.py @@ -5,6 +5,7 @@ wire protocol over its pty (as the real host tools do), and actually flash it. Usage: tsbtest.py Exits 0 if every scenario passes. """ +import os import subprocess import sys import time @@ -13,16 +14,22 @@ import serial CONFIRM = 0x21 # '!' REQUEST = 0x3F # '?' +KNOCK = 0x40 # '@' PAGE = 128 # ATmega328P: 64 words 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( [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.pty = None deadline = time.time() + 5 @@ -137,6 +144,28 @@ class Host: self._expect(CONFIRM, "C end") 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): if not cond: @@ -144,45 +173,73 @@ def check(cond, msg): print(f" ok: {msg}") +# A config page carrying a password "PW": appjump 0, timeout 0x40, password +# 0x50 0x57 terminated by 0xff. +PW_CONFIG = "0000405057ff" +PW_BYTES = bytes([0x50, 0x57]) + + +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() + 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[14] == info[15], f"device-type bytes 14==15 (got {info[14]:#x},{info[15]:#x})") + check(host.pagesize == PAGE, f"page size {PAGE} (got {host.pagesize})") + 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}") + + app = bytes(range(256)) # two pages of known data + host.write_flash(app) + check(host.read_flash(2) == app, "flash round-trip 2 pages") + + edata = bytes((i * 7) & 0xFF for i in range(PAGE)) + host.write_eeprom(edata) + check(host.read_eeprom(1) == edata, "eeprom round-trip 1 page") + + cfg = bytes([0x00, 0x00, 0x40]) + b"\xff" * (PAGE - 3) # timeout 0x40, no password + check(host.write_config(cfg) == cfg, "config write echoes the programmed page") + 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] - dev = Device(binary, elf, boot_base) failures = [] - try: - host = Host(dev.pty) - # --- activation --- - info = host.activate() - 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[14] == info[15], f"device-type bytes 14==15 (got {info[14]:#x},{info[15]:#x})") - check(host.pagesize == PAGE, f"page size {PAGE} (got {host.pagesize})") - 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}") - - # --- flash write / read round-trip (actual self-programming) --- - app = bytes(range(256)) # two pages of known data - host.write_flash(app) - back = host.read_flash(2) - 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)) - host.write_eeprom(edata) - eback = host.read_eeprom(1) - check(eback == edata, "eeprom round-trip 1 page") - - # --- config page write / read round-trip --- - cfg = bytes([0x00, 0x00, 0x40]) + b"\xff" * (PAGE - 3) # appjump 0, timeout 0x40, no password - echo = host.write_config(cfg) - check(echo == cfg, "config write echoes the programmed page") - check(host.read_config() == cfg, "config read-back matches") - - except AssertionError as e: - failures.append(str(e)) - print(f" FAIL: {e}") - finally: - dev.stop() + # 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: + failures.append(f"{name}: {e}") + print(f" FAIL: {e}") + finally: + dev.stop() if failures: print(f"FAILED ({len(failures)})")