136 lines
7.3 KiB
Python
136 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Check canonical Reader replies and stack using the linked RAK Full ARM ELF.
|
|
|
|
Requires unicorn==2.1.4. Every first page and all pages of the three longest
|
|
verses are compared with the independently ASCII-normalized pinned source.
|
|
--every-page reconstructs every verse. Only replyVerse and its callee frames
|
|
are measured; the fixed caller reserve is not a physical high-water reading
|
|
and does not establish ISR/SoftDevice/whole-firmware stack or hardware safety.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from firmware_elf import FirmwareElf
|
|
from unicorn import Uc, UC_ARCH_ARM, UC_MODE_THUMB, UC_MODE_MCLASS, UC_HOOK_CODE
|
|
from unicorn import arm_const as arm
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
TYPOGRAPHY = str.maketrans({'\u00a0':' ', '\u2013':'-', '\u2014':'-', '\u2018':"'", '\u2019':"'", '\u201c':'"', '\u201d':'"'})
|
|
|
|
|
|
def check(path, every_page=False):
|
|
elf = FirmwareElf(path)
|
|
if elf.unpack('H', 18)[0] != 40:
|
|
raise ValueError('Expected ARM ELF')
|
|
names = [n for n in elf.symbols if 'replyVerse' in n]
|
|
# AAPCS passes the three-byte Selection aggregate in r0, then page,
|
|
# reply and capacity in r1-r3. Reject ABI-changing LTO clones rather than
|
|
# accidentally invoking one with the original source function's ABI.
|
|
expected_name = '_ZN4mesh12_GLOBAL__N_110replyVerseENS0_9SelectionEhPcj'
|
|
if names != [expected_name]:
|
|
raise ValueError('Reply function needs manual ABI review: ' + repr(names))
|
|
name = names[0]
|
|
uc = Uc(UC_ARCH_ARM, UC_MODE_THUMB | UC_MODE_MCLASS)
|
|
uc.mem_map(0, 0x100000)
|
|
uc.mem_map(0x20000000, 0x40000)
|
|
for section in elf.sections:
|
|
if section[1] != 8 and section[2] & 2 and section[5]:
|
|
uc.mem_write(section[3], elf.section_bytes(section))
|
|
stop, top, reply = 0x1000, 0x2003e000, 0x20030000
|
|
uc.mem_write(stop, b'\0\xbe')
|
|
state = {'minimum_sp': top, 'instructions': 0}
|
|
def instruction(machine, address, size, data):
|
|
state['minimum_sp'] = min(state['minimum_sp'], machine.reg_read(arm.UC_ARM_REG_SP))
|
|
state['instructions'] += 1
|
|
uc.hook_add(UC_HOOK_CODE, instruction)
|
|
document = json.loads((ROOT / 'tools/bible/reader-web.json').read_text(encoding='utf-8'))
|
|
counts = (51, 25, 36, 54, 47, 71, 53, 59, 41, 42, 57,
|
|
50, 38, 31, 27, 33, 26, 40, 42, 31, 25)
|
|
references = [f'{chapter}:{verse}' for chapter, count in enumerate(counts, 1)
|
|
for verse in range(1, count + 1)]
|
|
if set(document['verses']) != set(references):
|
|
raise ValueError('Canonical source must include exactly the 879 references')
|
|
# Every first page exercises every compressed block. Also fully reconstruct
|
|
# the three longest verses in the normal CI run, including 21:17.
|
|
complete = set(sorted(references, key=lambda ref: len(document['verses'][ref]),
|
|
reverse=True)[:3])
|
|
maximum, calls, total_instructions, checked_bytes = 0, 0, 0, 0
|
|
max_ref = None
|
|
guard = b'\x91\xa2\xb3\xc4'
|
|
for index, ref in enumerate(references):
|
|
raw = document['verses'][ref]
|
|
chapter, verse = map(int, ref.split(':'))
|
|
selection = chapter | (verse << 8) | (verse << 16)
|
|
expected = raw.translate(TYPOGRAPHY).encode('ascii')
|
|
capacity, page, reconstructed = 157, 1, b''
|
|
while True:
|
|
uc.reg_write(arm.UC_ARM_REG_SP, top)
|
|
uc.reg_write(arm.UC_ARM_REG_LR, stop | 1)
|
|
for register, value in zip((arm.UC_ARM_REG_R0, arm.UC_ARM_REG_R1, arm.UC_ARM_REG_R2, arm.UC_ARM_REG_R3), (selection, page, reply, capacity)):
|
|
uc.reg_write(register, value)
|
|
uc.mem_write(reply-4, guard + b'?'*capacity + guard)
|
|
state['minimum_sp'], state['instructions'] = top, 0
|
|
uc.emu_start(elf.address(name) | 1, stop, timeout=10000000, count=10000000)
|
|
if uc.reg_read(arm.UC_ARM_REG_PC) != stop or uc.reg_read(arm.UC_ARM_REG_SP) != top:
|
|
raise ValueError('Function return/stack restoration failed for ' + ref)
|
|
if bytes(uc.mem_read(reply-4, 4)) != guard or bytes(uc.mem_read(reply+capacity, 4)) != guard:
|
|
raise ValueError('Reply buffer guard overwritten for ' + ref)
|
|
output = bytes(uc.mem_read(reply, capacity))
|
|
if b'\0' not in output:
|
|
raise ValueError('Reply unterminated for ' + ref)
|
|
output = output.split(b'\0', 1)[0]
|
|
header, body = output.split(b'\n', 1)
|
|
if not re.fullmatch(rb'Reader ' + ref.encode() + rb' (?:\(WEB\)|\[' + str(page).encode() + rb'/[0-9]+\])', header):
|
|
raise ValueError('Unexpected header for ' + ref + ': ' + repr(header))
|
|
marker = body.find(b'\nNext: ')
|
|
next_command = body[marker+7:] if marker >= 0 else None
|
|
fragment = body[:marker] if marker >= 0 else body
|
|
if not fragment or expected[len(reconstructed):len(reconstructed)+len(fragment)] != fragment:
|
|
raise ValueError('Canonical linked text mismatch for ' + ref + ' page ' + str(page))
|
|
reconstructed += fragment
|
|
checked_bytes += len(fragment)
|
|
used = top - state['minimum_sp']
|
|
if used + 1536 > 8192:
|
|
raise ValueError('Reader frames exceed 8 KiB with caller reserve: ' + str(used))
|
|
if used > maximum:
|
|
maximum, max_ref = used, ref
|
|
calls += 1
|
|
total_instructions += state['instructions']
|
|
if next_command is None:
|
|
if reconstructed != expected:
|
|
raise ValueError('Canonical text incomplete for ' + ref)
|
|
break
|
|
if next_command != f'get reader {ref} {page+1}'.encode():
|
|
raise ValueError('Continuation mismatch for ' + ref)
|
|
if not every_page and ref not in complete:
|
|
break
|
|
page += 1
|
|
if page > 32:
|
|
raise ValueError('Unexpected page loop for ' + ref)
|
|
if index % 100 == 0:
|
|
print(f'checked {index+1}/879 references; max stack {maximum}', file=sys.stderr, flush=True)
|
|
source_digest = hashlib.sha256(json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
return {'passed': True, 'elf_sha256': hashlib.sha256(elf.data).hexdigest(), 'reply_symbol': name,
|
|
'references': len(document['verses']), 'calls': calls,
|
|
'canonical_bytes_checked': checked_bytes, 'all_pages': every_page,
|
|
'fully_reconstructed_references': references if every_page else sorted(complete),
|
|
'canonical_source_sha256': source_digest,
|
|
'maximum_reply_verse_stack_bytes': maximum, 'maximum_stack_reference': max_ref,
|
|
'caller_reserve_bytes': 1536, 'loop_stack_bytes': 8192,
|
|
'total_arm_instructions': total_instructions,
|
|
'scope': 'Actual linked replyVerse instructions and callees; excludes firmware caller/ISR/task and physical high-water.'}
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('elf')
|
|
parser.add_argument('--every-page', action='store_true')
|
|
args = parser.parse_args()
|
|
try:
|
|
print(json.dumps(check(args.elf, args.every_page), indent=2))
|
|
except (ValueError, OSError) as error:
|
|
parser.exit(1, f'FAILED: {error}\n')
|