Phase 1 of decomposing the 15,770-line monitor unit: move contiguous
top-level ranges verbatim into probes/xnuboot/*.c, and have
probes/xnuboot.c #include them in their original order. The compiler
still sees one translation unit. Every #include of a header stays in
probes/xnuboot.c, because quoted includes resolve beside the including
file and the monitor build has no -Icompat.
Cuts fall only between top-level declarations; no function, struct,
initializer or conditional is split, and every part has balanced
#if/#endif. Two exceptions are recorded for phase 2:
- monitor_call_impl is 1,318 lines on its own, so
probes/xnuboot/sptm_dispatch.c is 1,370 lines.
- The 1,894-line #if SNAP_FINE_LOCKS region is split into three
consecutive identical #if SNAP_FINE_LOCKS blocks (fine_locks_*.c).
SNAP_FINE_LOCKS is not defined or undefined inside the region, and
nothing inside it includes a header.
The parts that carry the m1n1-derived SPTM endpoint numbers and return
codes (report_layout.c, sptm_dispatch.c) keep the file's notice.
Tools that read xnuboot.c as text now read the whole unit through
tools/source_text.py read_source(); tools that hash or pin monitor
sources add every part through source_files() (generic over each .c
in their lists, so later splits are covered the same way).
Verification (base 84dd208 vs this commit, clang 23.1.3):
- clang -c, qualification flags (b12v20 qualification.json, no -flto)
plus -I shadow -I q1n1/platform/uefi: probes/xnuboot.c objects
byte-identical (cmp). PASS
- clang -c, probes/run.py FLAGS plus the same -I: byte-identical. PASS
- Qualification flags with -flto: llvm-dis output equal; the full IR
differs only in 468 inline-asm !srcloc source-location cookies. PASS
- probes/run.py build('xnuboot'), every unit linked: xnuboot.bin and
xnuboot.elf byte-identical with the qualification flags (LTO,
bin sha256 0d8b9ec3...) and with run.py FLAGS (2b5d5f6b...). PASS
- Reconstruction: expanding the includes and dropping the part headers
and the duplicated #if/#endif lines reproduces xnuboot.c exactly.
- Host battery (63 commands: the two gcc SPTM suites, test-idregs,
test-audit-sptm-refs and every tools/test-*.py that reads these
sources, plus the edited tools), 300 s limit, base vs split:
no new failure. 76 of 78 outcomes equal; test-audit-sparse and
test-monitor-perf timed out (NOT RUN) on split under host load and
on rerun match base (PASS; FAIL with the same record). Per-class
reruns of the long suites (21 commands) equal on both trees.
Pre-existing failures (missing private inputs or stale checks) are
unchanged.
- wc -l: xnuboot.c 918, every part under 1,000 except sptm_dispatch.c
(1,370).
322 lines
17 KiB
Python
Executable File
322 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: MIT
|
|
"""probes/idregs.h: the guest's TID3 ID-register reads never become an MRS of an
|
|
unallocated encoding at EL2.
|
|
|
|
python3 tools/test-idregs.py
|
|
python3 tools/test-idregs.py --build [--out DIR]
|
|
[--expect-before NAME=SHA256 ...] [--expect NAME=SHA256 ...]
|
|
|
|
The Oryon traps an EL2 MRS of an unallocated ID-space encoding (FEAT_IDST;
|
|
installed-fresh-20261018-v48 faulted on S3_0_C0_C0_1). The trap handler in
|
|
probes/xnuboot.c answers every op0=3, op1=0, CRn=0, CRm=0..7 read with
|
|
id_forge(crm, op2, id_register(crm, op2)), so id_register reads only the 45
|
|
architecturally allocated encodings and returns zero (the architectural RAZ)
|
|
for the 19 unallocated or reserved ones, without an MRS.
|
|
|
|
Offline, no hardware. Without --build it checks the header's cases against
|
|
the Arm ARM allocation list below, that list against LLVM's own system
|
|
register table (an allocated encoding disassembles to a name, an unallocated
|
|
one to S3_0_C0_Cm_n), that every case's MRS names its own encoding, and that
|
|
the handler and id_forge are unchanged and touch only allocated encodings.
|
|
|
|
--build compiles xnuboot with cycle.py's control, fast and fast+WFI flag sets
|
|
twice: from this tree, and from a copy whose idregs.h is the previous 64-case
|
|
header (regenerated here, so the baseline needs no saved tree;
|
|
--expect-before binds it to the pre-change monitor hash). Then (a) no
|
|
built monitor, the SNAP_BOOT_RELOCATE=1 builds included, contains an MRS of
|
|
an unallocated ID encoding anywhere in its image, while the baseline contains
|
|
all 19; (b) function by function, with PC-relative fields resolved to their
|
|
target symbol or the bytes they point at, the default build differs from the
|
|
baseline only in id_register: every other function, the trap handler's
|
|
caller xnuboot_trap included, is identical, and none is added or removed.
|
|
|
|
Failure inventory: an MRS of an unallocated or reserved encoding anywhere in
|
|
the monitor; an allocated register dropped (its value would change from the
|
|
hardware's to zero); a case whose MRS names another encoding; id_forge
|
|
forging an unallocated encoding into something nonzero; the change leaking
|
|
into any other function of the default monitor.
|
|
"""
|
|
from pathlib import Path
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import runpy
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from source_text import read_source
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
HEADER = (ROOT / 'probes/idregs.h').read_text()
|
|
SOURCE = read_source(ROOT / 'probes/xnuboot.c')
|
|
_LLVM_BIN = os.environ.get('LLVM_BIN')
|
|
if _LLVM_BIN:
|
|
LLVM = Path(_LLVM_BIN)
|
|
elif Path('/opt/homebrew/opt/llvm/bin/llvm-mc').exists():
|
|
LLVM = Path('/opt/homebrew/opt/llvm/bin')
|
|
else:
|
|
_mc = shutil.which('llvm-mc')
|
|
LLVM = Path(_mc).parent if _mc else Path('/usr/bin')
|
|
|
|
# The allocated AArch64 ID registers of op0=3, op1=0, CRn=0, by (CRm, op2): Arm ARM (DDI 0487), the AArch64
|
|
# System register index. Everything else in CRm 0-7 is unallocated (CRm=0) or reserved (CRm 1-7).
|
|
ALLOCATED = {
|
|
(0, 0): 'MIDR_EL1', (0, 5): 'MPIDR_EL1', (0, 6): 'REVIDR_EL1',
|
|
(1, 0): 'ID_PFR0_EL1', (1, 1): 'ID_PFR1_EL1', (1, 2): 'ID_DFR0_EL1', (1, 3): 'ID_AFR0_EL1',
|
|
(1, 4): 'ID_MMFR0_EL1', (1, 5): 'ID_MMFR1_EL1', (1, 6): 'ID_MMFR2_EL1', (1, 7): 'ID_MMFR3_EL1',
|
|
(2, 0): 'ID_ISAR0_EL1', (2, 1): 'ID_ISAR1_EL1', (2, 2): 'ID_ISAR2_EL1', (2, 3): 'ID_ISAR3_EL1',
|
|
(2, 4): 'ID_ISAR4_EL1', (2, 5): 'ID_ISAR5_EL1', (2, 6): 'ID_MMFR4_EL1', (2, 7): 'ID_ISAR6_EL1',
|
|
(3, 0): 'MVFR0_EL1', (3, 1): 'MVFR1_EL1', (3, 2): 'MVFR2_EL1', (3, 4): 'ID_PFR2_EL1',
|
|
(3, 5): 'ID_DFR1_EL1', (3, 6): 'ID_MMFR5_EL1',
|
|
(4, 0): 'ID_AA64PFR0_EL1', (4, 1): 'ID_AA64PFR1_EL1', (4, 2): 'ID_AA64PFR2_EL1',
|
|
(4, 4): 'ID_AA64ZFR0_EL1', (4, 5): 'ID_AA64SMFR0_EL1', (4, 7): 'ID_AA64FPFR0_EL1',
|
|
(5, 0): 'ID_AA64DFR0_EL1', (5, 1): 'ID_AA64DFR1_EL1', (5, 2): 'ID_AA64DFR2_EL1',
|
|
(5, 4): 'ID_AA64AFR0_EL1', (5, 5): 'ID_AA64AFR1_EL1',
|
|
(6, 0): 'ID_AA64ISAR0_EL1', (6, 1): 'ID_AA64ISAR1_EL1', (6, 2): 'ID_AA64ISAR2_EL1', (6, 3): 'ID_AA64ISAR3_EL1',
|
|
(7, 0): 'ID_AA64MMFR0_EL1', (7, 1): 'ID_AA64MMFR1_EL1', (7, 2): 'ID_AA64MMFR2_EL1',
|
|
(7, 3): 'ID_AA64MMFR3_EL1', (7, 4): 'ID_AA64MMFR4_EL1',
|
|
}
|
|
UNALLOCATED = {(crm, op2) for crm in range(8) for op2 in range(8)} - set(ALLOCATED)
|
|
CASE = re.compile(r'^\s*case 0x([0-9a-f]{2}): __asm__ volatile\("mrs %0, s3_0_c0_c(\d)_(\d)" : "=r"\(v\)\); break;',
|
|
re.M)
|
|
HANDLER = 'if (rt != 31) frame[rt] = id_forge(crm, op2, id_register(crm, op2));'
|
|
|
|
|
|
def arguments():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('--build', action='store_true')
|
|
ap.add_argument('--out', help='fresh directory for the builds (default: a temporary one)')
|
|
ap.add_argument('--expect-before', action='append', default=[], metavar='NAME=SHA256',
|
|
help='control, fast or fast-wfi: the regenerated baseline must have this SHA-256')
|
|
ap.add_argument('--expect', action='append', default=[], metavar='NAME=SHA256',
|
|
help='control, fast or fast-wfi: this tree\'s default build must have this SHA-256')
|
|
args, rest = ap.parse_known_args()
|
|
if args.out and Path(args.out).exists():
|
|
ap.error(f'--out {args.out} exists: use a fresh directory')
|
|
return args, rest
|
|
|
|
|
|
ARGS, REST = arguments() if __name__ == '__main__' else (
|
|
argparse.Namespace(build=False, out=None, expect_before=[], expect=[]), [])
|
|
|
|
|
|
def previous_header():
|
|
"""The 64-case idregs.h this change replaced: one MRS per encoding of CRm 0-7."""
|
|
cases = ''.join(f' case 0x{(crm << 3) | op2:02x}: __asm__ volatile("mrs %0, s3_0_c0_c{crm}_{op2}" : "=r"(v)); break;\n'
|
|
for crm in range(8) for op2 in range(8))
|
|
return ('#ifndef IDREGS_H\n#define IDREGS_H\n\nstatic uint64_t id_register(uint32_t crm, uint32_t op2)\n{\n'
|
|
' uint64_t v = 0;\n switch ((crm << 3) | op2) {\n' + cases +
|
|
' default: break;\n }\n return v;\n}\n\n#endif\n')
|
|
|
|
|
|
def id_moves(blob):
|
|
"""{(CRm, op2)} of every MRS of op0=3, op1=0, CRn=0, CRm 0-7 found anywhere in the image."""
|
|
found = set()
|
|
for at in range(0, len(blob) - 3, 4):
|
|
w = int.from_bytes(blob[at:at + 4], 'little')
|
|
if w & 0xfffff000 == 0xd5380000 and (w >> 8) & 15 < 8:
|
|
found.add(((w >> 8) & 15, (w >> 5) & 7))
|
|
return found
|
|
|
|
|
|
def sext(value, bits):
|
|
return value - (1 << bits) if value & (1 << (bits - 1)) else value
|
|
|
|
|
|
def symbols(elf):
|
|
"""(named, mapping): [(addr, size, type, name)] of FUNC, OBJECT and named NOTYPE symbols, and the sorted
|
|
$x/$d mapping symbols that say where code and data-in-code (literal pools, jump tables) start."""
|
|
named, mapping = [], []
|
|
for line in subprocess.run([str(LLVM / 'llvm-readelf'), '-s', '--wide', str(elf)],
|
|
capture_output=True, text=True, check=True).stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) < 8 or not parts[0].rstrip(':').isdigit() or parts[6] == 'UND':
|
|
continue
|
|
addr, size, kind, name = int(parts[1], 16), int(parts[2]), parts[3], parts[7]
|
|
if name.startswith('$x') or name.startswith('$d'):
|
|
mapping.append((addr, name[1]))
|
|
elif kind in ('FUNC', 'OBJECT', 'NOTYPE') and name:
|
|
named.append((addr, size, kind, name))
|
|
named.sort()
|
|
table = []
|
|
for n, (addr, size, kind, name) in enumerate(named):
|
|
if not size: # an assembly label: up to the next symbol
|
|
size = (named[n + 1][0] if n + 1 < len(named) else addr) - addr
|
|
table.append((addr, size, kind, name))
|
|
return table, sorted(mapping)
|
|
|
|
|
|
def normalized_functions(elf, blob):
|
|
"""{name: tuple}: each symbol's contents. Code words have their PC-relative fields replaced by what they
|
|
reach (a symbol and offset, or the 16 bytes they point at); data (OBJECT symbols and $d ranges) is raw."""
|
|
import bisect
|
|
table, mapping = symbols(elf)
|
|
starts = [a for a, _, _, _ in table]
|
|
marks = [a for a, _ in mapping]
|
|
|
|
def where(target):
|
|
i = bisect.bisect_right(starts, target) - 1
|
|
if i >= 0 and table[i][0] <= target < table[i][0] + table[i][1]:
|
|
return (table[i][3], target - table[i][0])
|
|
return ('data', bytes(blob[target:target + 16]).hex())
|
|
|
|
def is_data(pc):
|
|
i = bisect.bisect_right(marks, pc) - 1
|
|
return i >= 0 and mapping[i][1] == 'd'
|
|
|
|
out = {}
|
|
for addr, size, kind, name in table:
|
|
tokens = []
|
|
for pc in range(addr, addr + size - 3, 4):
|
|
w = int.from_bytes(blob[pc:pc + 4], 'little')
|
|
if kind == 'OBJECT' or is_data(pc):
|
|
tokens.append(('raw', w))
|
|
elif w & 0x7c000000 == 0x14000000: # B, BL
|
|
tokens.append((w & 0xfc000000, where(pc + sext(w & 0x3ffffff, 26) * 4)))
|
|
elif w & 0xff000010 == 0x54000000 or w & 0x7e000000 == 0x34000000 or w & 0x3b000000 == 0x18000000:
|
|
tokens.append((w & 0xff00001f, where(pc + sext((w >> 5) & 0x7ffff, 19) * 4))) # B.cond, CBZ, LDR literal
|
|
elif w & 0x7e000000 == 0x36000000: # TBZ, TBNZ
|
|
tokens.append((w & 0xfff8001f, where(pc + sext((w >> 5) & 0x3fff, 14) * 4)))
|
|
elif w & 0x9f000000 == 0x10000000: # ADR
|
|
tokens.append((w & 0x9f00001f, where(pc + sext(((w >> 5) & 0x7ffff) << 2 | (w >> 29) & 3, 21))))
|
|
elif w & 0x9f000000 == 0x90000000: # ADRP
|
|
page = (pc & ~0xfff) + (sext(((w >> 5) & 0x7ffff) << 2 | (w >> 29) & 3, 21) << 12)
|
|
tokens.append((w & 0x9f00001f, where(page)))
|
|
else:
|
|
tokens.append(w)
|
|
if size % 4:
|
|
tokens.append(('tail', bytes(blob[addr + size - size % 4:addr + size]).hex()))
|
|
out.setdefault(name, tuple(tokens))
|
|
return out
|
|
|
|
|
|
class Source(unittest.TestCase):
|
|
def test_header_reads_exactly_the_allocated_encodings(self):
|
|
cases = CASE.findall(HEADER)
|
|
self.assertEqual(len(cases), len(ALLOCATED))
|
|
seen = set()
|
|
for index, crm, op2 in cases:
|
|
crm, op2 = int(crm), int(op2)
|
|
self.assertEqual(int(index, 16), (crm << 3) | op2, f'case {index} reads another encoding')
|
|
seen.add((crm, op2))
|
|
self.assertEqual(seen, set(ALLOCATED))
|
|
self.assertEqual(len(UNALLOCATED), 19)
|
|
body = HEADER[HEADER.index('static uint64_t id_register'):]
|
|
self.assertEqual(body.count('mrs'), len(ALLOCATED))
|
|
self.assertIn('uint64_t v = 0;', body)
|
|
self.assertIn('default: break;', body)
|
|
self.assertIn('return v;', body)
|
|
|
|
def test_allocation_list_matches_llvm(self):
|
|
words = ''.join(' '.join(f'0x{b:02x}' for b in (0xd5380000 | crm << 8 | op2 << 5).to_bytes(4, 'little')) + '\n'
|
|
for crm in range(8) for op2 in range(8))
|
|
out = subprocess.run([str(LLVM / 'llvm-mc'), '--disassemble', '--triple=aarch64', '-mattr=+v9.6a,+sve,+sme'],
|
|
input=words, capture_output=True, text=True, check=True).stdout
|
|
names = re.findall(r'mrs\s+x0, (\S+)', out)
|
|
self.assertEqual(len(names), 64, out)
|
|
for n, name in enumerate(names):
|
|
crm, op2 = divmod(n, 8)
|
|
if (crm, op2) in ALLOCATED:
|
|
self.assertEqual(name, ALLOCATED[(crm, op2)], (crm, op2))
|
|
else:
|
|
self.assertEqual(name, f'S3_0_C0_C{crm}_{op2}', f'LLVM names {(crm, op2)} {name}')
|
|
|
|
def test_handler_and_forge_unchanged(self):
|
|
self.assertEqual(SOURCE.count(HANDLER), 1)
|
|
branch = SOURCE[SOURCE.index('if (op0 == 3 && op1 == 0 && crn == 0 && crm < 8 && (iss & 1)) {'):]
|
|
self.assertTrue(branch[:200].split('\n')[1].strip() == HANDLER)
|
|
forge_src = HEADER if 'static uint64_t id_forge(' in HEADER else SOURCE
|
|
forge = forge_src[forge_src.index('static uint64_t id_forge(uint32_t crm, uint32_t op2, uint64_t value)\n{'):]
|
|
forge = forge[:forge.index('\n}\n')]
|
|
pairs = {(int(a), int(b)) for a, b in re.findall(r'crm == (\d) && op2 == (\d)', forge)}
|
|
self.assertEqual(pairs, {(4, 0), (4, 1), (4, 4), (4, 5)})
|
|
self.assertLessEqual(pairs, set(ALLOCATED)) # an unallocated read stays the zero id_register returns
|
|
# Every constant id_register call names an allocated register.
|
|
calls = [(int(a), int(b)) for a, b in re.findall(r'id_register\((\d),\s*(\d)\)', SOURCE)]
|
|
self.assertGreaterEqual(len(calls), 10)
|
|
for call in calls:
|
|
self.assertIn(call, ALLOCATED)
|
|
|
|
def test_previous_header_regenerates_the_old_cases(self):
|
|
old = previous_header()
|
|
self.assertEqual(len(CASE.findall(old)), 64)
|
|
self.assertEqual(id_cases(old), {(crm, op2) for crm in range(8) for op2 in range(8)})
|
|
|
|
|
|
def id_cases(text):
|
|
return {(int(crm), int(op2)) for _, crm, op2 in CASE.findall(text)}
|
|
|
|
|
|
@unittest.skipUnless(ARGS.build, 'pass --build to compile the monitors (about two minutes)')
|
|
class Build(unittest.TestCase):
|
|
def test_monitors(self):
|
|
sys.path.insert(0, str(ROOT / 'probes'))
|
|
sys.path.insert(0, str(ROOT.parent / 'q1n1' / 'tools'))
|
|
import run # noqa: E402 (import only: build() needs no hardware)
|
|
sets, _ = runpy.run_path(str(ROOT / 'tools/test-wfi-resume.py'), run_name='flag_sets')['flag_sets']()
|
|
sets = {'control': sets['control'], 'fast': sets['fast'], 'fast-wfi': sets['fast'] + ['-DSNAP_WFI_RESUME=1']}
|
|
expect_before = dict(e.split('=', 1) for e in ARGS.expect_before)
|
|
expect = dict(e.split('=', 1) for e in ARGS.expect)
|
|
out = Path(ARGS.out) if ARGS.out else Path(tempfile.mkdtemp(prefix='idregs-build-'))
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
# The baseline tree: this one with the previous header, built by its own copy of run.py.
|
|
base = out / 'baseline-tree' / 'Snapintosh'
|
|
for folder in ('probes', 'compat', 'shadow', 'tools', 'config'):
|
|
shutil.copytree(ROOT / folder, base / folder, ignore=shutil.ignore_patterns('__pycache__'))
|
|
(base / 'gpu').symlink_to((ROOT / 'gpu').resolve())
|
|
(base.parent / 'q1n1').symlink_to((ROOT.parent / 'q1n1').resolve())
|
|
(base / 'probes/idregs.h').write_text(previous_header())
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location('run_baseline', base / 'probes/run.py')
|
|
baseline_run = importlib.util.module_from_spec(spec)
|
|
sys.path.insert(0, str(base / 'probes'))
|
|
spec.loader.exec_module(baseline_run)
|
|
sys.path.remove(str(base / 'probes'))
|
|
results = {}
|
|
saved = list(run.FLAGS)
|
|
try:
|
|
for name, flags in sets.items():
|
|
built = {}
|
|
for label, runner, extra in (('before', baseline_run, []), ('after', run, []),
|
|
('after-relocate', run, ['-DSNAP_BOOT_RELOCATE=1'])):
|
|
if label == 'after-relocate' and name == 'fast-wfi':
|
|
continue
|
|
runner.FLAGS[:] = flags + extra
|
|
built[label] = (runner.build('xnuboot', out / f'{name}-{label}'),
|
|
out / f'{name}-{label}' / 'xnuboot.elf')
|
|
rows = {label: dict(bytes=len(blob), sha256=hashlib.sha256(blob).hexdigest(),
|
|
unallocated_mrs=sorted(id_moves(blob) & UNALLOCATED))
|
|
for label, (blob, _) in built.items()}
|
|
# (a) no MRS of an unallocated or reserved ID encoding anywhere; the baseline had all 19.
|
|
self.assertEqual(rows['before']['unallocated_mrs'], sorted(UNALLOCATED), name)
|
|
for label, (blob, _) in built.items():
|
|
if label != 'before':
|
|
self.assertEqual(rows[label]['unallocated_mrs'], [], f'{name}-{label}')
|
|
# Every allocated register is still read from the hardware.
|
|
self.assertEqual(id_moves(blob) & set(ALLOCATED), set(ALLOCATED), f'{name}-{label}')
|
|
# (b) only id_register differs between the baseline and this tree's default build.
|
|
before = normalized_functions(built['before'][1], built['before'][0])
|
|
after = normalized_functions(built['after'][1], built['after'][0])
|
|
self.assertEqual(set(before), set(after), f'{name}: functions added or removed')
|
|
changed = sorted(f for f in after if after[f] != before[f])
|
|
rows['changed_functions'] = changed
|
|
self.assertEqual(changed, ['id_register'], name)
|
|
if name in expect_before:
|
|
self.assertEqual(rows['before']['sha256'], expect_before[name], f'{name} baseline')
|
|
if name in expect:
|
|
self.assertEqual(rows['after']['sha256'], expect[name], name)
|
|
results[name] = rows
|
|
finally:
|
|
run.FLAGS[:] = saved
|
|
print(json.dumps(results, indent=1), file=sys.stderr)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
os.chdir(ROOT)
|
|
unittest.main(argv=[sys.argv[0], *REST], verbosity=2)
|